Skip to content

Parallel tool calls: a dict state key removed by a later call comes back in the persisted state #7661

Description

@AkshayShah03

🔴 Required Information

Describe the Bug:
When parallel tool calls write the same dict-valued state key, and one call removes keys from the dict another call wrote, the removed keys come back in the persisted session state.

In the snippet, add_items sets state["cart"] = {"apple": 1, "pear": 2}. remove_pear runs after it in the same parallel batch, reads that cart, removes pear and writes {"apple": 1} back. While the batch runs, the tools see {"apple": 1}. The merged function-response event, and therefore the session, still stores {"apple": 1, "pear": 2}.

Root cause: _apply_latest_state_writes in src/google/adk/flows/llm_flows/tools/_batch_executor.py finds that remove_pear's write is the latest value of cart. It then calls deep_merge_dicts(merged_delta, latest_writes), which merges that value into the union of both writes instead of replacing it, so pear survives. Lists are fine, because deep_merge_dicts overwrites lists. This is the dict side of #5190.

Steps to Reproduce:

  1. pip install google-adk==2.11.0
  2. Run the snippet below. It uses a fake model, so no API key is needed.

Expected Behavior:

cart seen by the tools after remove_pear: {'apple': 1}
cart persisted in the session: {'apple': 1}

Observed Behavior:

cart seen by the tools after remove_pear: {'apple': 1}
cart persisted in the session: {'apple': 1, 'pear': 2}

Environment Details:

  • ADK Library Version: 2.11.0 (also reproduced on main at d324744)
  • Desktop OS: macOS
  • Python Version: 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (fake BaseLlm in the snippet)

🟡 Optional Information

Minimal Reproduction Code:

import asyncio
from typing import AsyncGenerator

from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools.tool_context import ToolContext
from google.genai import types


class TwoParallelCalls(BaseLlm):
  """Asks for add_items and remove_pear in one turn, then says done."""

  model: str = "fake"

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    if llm_request.contents[-1].parts[0].function_response:
      yield LlmResponse(content=types.ModelContent("done"))
      return
    yield LlmResponse(
        content=types.Content(
            role="model",
            parts=[
                types.Part.from_function_call(name="add_items", args={}),
                types.Part.from_function_call(name="remove_pear", args={}),
            ],
        )
    )


async def add_items(tool_context: ToolContext) -> str:
  tool_context.state["cart"] = {"apple": 1, "pear": 2}
  return "ok"


async def remove_pear(tool_context: ToolContext) -> str:
  await asyncio.sleep(0.01)  # runs after add_items has written the cart
  cart = dict(tool_context.state["cart"])
  cart.pop("pear")
  tool_context.state["cart"] = cart
  print("cart seen by the tools after remove_pear:", tool_context.state["cart"])
  return "ok"


async def main():
  agent = LlmAgent(
      name="root", model=TwoParallelCalls(), tools=[add_items, remove_pear]
  )
  runner = InMemoryRunner(agent=agent, app_name="app")
  session = await runner.session_service.create_session(
      app_name="app", user_id="u"
  )
  async for _ in runner.run_async(
      user_id="u", session_id=session.id, new_message=types.UserContent("go")
  ):
    pass
  session = await runner.session_service.get_session(
      app_name="app", user_id="u", session_id=session.id
  )
  print("cart persisted in the session:", session.state["cart"])


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Proposed fix (looking for guidance before I open a PR)

The simple fix is to replace the key with the latest value instead of deep-merging it. That breaks an existing, intended behaviour. test_parallel_writes_of_separate_dict_keys_are_all_kept (tests/unittests/flows/llm_flows/tools/test_functions_parallel.py) has two parallel calls each write state["drafts"] = {draft_id: "body"} without reading it, and expects the persisted value to be the union {"a": "body", "b": "body"}. From the merge's point of view the two cases look the same: in both, the latest value is a dict that lacks a key another call wrote. The difference is whether the latest writer saw the other write before writing.

Options:

  1. Track whether each call read the key before writing it (my preference). When a call reads a dict or list key through tool_context.state, record the object it got. In _apply_latest_state_writes, replace the key with the latest value when the latest writer had read another call's write of that key (it built on it, so missing keys were removed on purpose). Otherwise keep today's union. This fixes the cart case and keeps the drafts test passing, because those calls never read drafts. The cost is a little read bookkeeping in State or the tool context.
  2. Heuristic: replace when the latest dict shares keys with another call's write. This needs no new tracking and covers both tests above. It guesses, though: two calls that independently write {"a": 1, "updated": True} and {"b": 1, "updated": True} share a key, so "a" would be dropped.
  3. Always last-write-wins for dicts, the same as lists. This is the simplest and matches what the tools see during the run. It changes the tested union behaviour for separate writes, so callers that rely on it would need to read-modify-write instead.

Questions:

  • Is the union for independent dict writes behaviour you want to keep? If so, does option 1 fit, or is there a place you'd prefer to track this?
  • If last-write-wins (option 3) is acceptable, I can do that with a small change and update the existing test.

I can open a PR with a regression test as soon as a direction is chosen.

Activity

  1. danieldutu commented on Oct 23, 2026

    @danieldutu

    I'd like to take this one. Root cause is the deep_merge_dicts call in _batch_executor.py that merges the
    latest write over the first write instead of replacing it, so a key a later call removed comes back. Fix with a unit test is ready; PR coming shortly.

  2. AkshayShah03 commented on Oct 24, 2026

    @AkshayShah03
    ContributorAuthor

    Thanks @danieldutu. One thing to check before the PR: a plain replace breaks test_parallel_writes_of_separate_dict_keys_are_all_kept, which expects two calls that each write their own drafts dict to be merged into {"a": ..., "b": ...}. That's the trade-off in the "Proposed fix" section above. The options differ in whether that union is kept, so it's probably worth waiting for @sanketpatil06 / the maintainers to pick one.

  3. danieldutu commented on Oct 24, 2026

    @danieldutu

    Agreed, a plain replace is the wrong trade-off, and #7703 does not do that. It keeps the union by default and only lets the latest write replace the merged dict when that call read another call's write of the same key through State.__getitem__. The drafts case never reads drafts, so the union is kept and test_parallel_writes_of_separate_dict_keys_are_all_kept is untouched and passing. The cart case reads the other call's write before removing the key, so the removal wins. The new test test_parallel_dict_write_that_removes_a_key_replaces_the_union covers that path.

    The gap is reads that bypass __getitem__ (state.to_dict(), session.state); those keep the union as before. If the maintainers prefer a different rule, I can adjust the PR.

  4. sanketpatil06 commented on Oct 26, 2026

    @sanketpatil06

    Thanks @AkshayShah03 for the detailed report.

    We're going with Option 1 (tracking reads). The union for independent dict writes is intended, so last-write-wins isn't an option here.

    @danieldutu, #7703 takes this approach. I checked it locally: the repro now persists {'apple': 1}, the drafts union still holds, and the 64 tests in the touched test files pass. Before merge, please:

    1. Deep-copy the unread writes (copy.deepcopy(write)). The merge that follows currently writes into nested dicts owned by the tools. Please add a regression test.
    2. Pass the reads next to the events in _batch_executor instead of setting an undeclared _state_reads attribute on Event. Rebuilding the event from data (e.g. model_validate(model_dump())) drops it silently and brings back the union.
    3. Document and test that any read through tool_context.state, including in callbacks, counts as building on another call's write.

    Until this ships, do the read-modify-write in a single tool, or store the collection as a list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions