Repository navigation
Parallel tool calls: a dict state key removed by a later call comes back in the persisted state #7661
Description
🔴 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:
pip install google-adk==2.11.0- 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
mainat d324744) - Desktop OS: macOS
- Python Version: 3.12.13
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A (fake
BaseLlmin 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:
- 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 readdrafts. The cost is a little read bookkeeping inStateor the tool context. - 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. - 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
danieldutu commentedon Oct 23, 2026
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.
AkshayShah03 commentedon Oct 24, 2026
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 owndraftsdict 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.danieldutu commentedon Oct 24, 2026
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 readsdrafts, so the union is kept andtest_parallel_writes_of_separate_dict_keys_are_all_keptis untouched and passing. The cart case reads the other call's write before removing the key, so the removal wins. The new testtest_parallel_dict_write_that_removes_a_key_replaces_the_unioncovers 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.fix(flows): persist the dict key removal of a later parallel tool call
sanketpatil06 commentedon Oct 26, 2026
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:copy.deepcopy(write)). The merge that follows currently writes into nested dicts owned by the tools. Please add a regression test._batch_executorinstead of setting an undeclared_state_readsattribute onEvent. Rebuilding the event from data (e.g.model_validate(model_dump())) drops it silently and brings back the union.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.