Skip to content

Commit d42a3be

Browse files
GWealecopybara-github
authored andcommitted
fix: keep the latest write when parallel tool calls update a state list
Parallel tool calls share the session state, so a call that reads a list after another call wrote it builds on that write, yet the merged event took each key from the last call in the model's response, letting a call that finished first drop the other calls' entries. The merge now re-applies the session's latest value for list and dict keys that several calls wrote, when that value is one of their writes. Part of #5190 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 987669949
1 parent 49e7264 commit d42a3be

2 files changed

Lines changed: 156 additions & 0 deletions

File tree

‎src/google/adk/flows/llm_flows/tools/_batch_executor.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,33 @@ def merge_parallel_function_response_events(
116116
return merged_event
117117

118118

119+
def _apply_latest_state_writes(
120+
merged_event: Event,
121+
function_response_events: list[Event],
122+
session_state: dict[str, Any],
123+
) -> None:
124+
"""Re-applies the latest value of list and dict keys several calls wrote.
125+
126+
Calls share the session state, so a call that reads a key after another call
127+
wrote it builds on that write, which merging in call order alone can drop.
128+
"""
129+
merged_delta = merged_event.actions.state_delta
130+
latest_writes: dict[str, Any] = {}
131+
for key in merged_delta:
132+
latest = session_state.get(key)
133+
if not isinstance(latest, (dict, list)):
134+
continue
135+
writes = [
136+
event.actions.state_delta[key]
137+
for event in function_response_events
138+
if key in event.actions.state_delta
139+
]
140+
# State stores the same object in the session and in the call's delta.
141+
if len(writes) > 1 and any(write is latest for write in writes):
142+
latest_writes[key] = latest
143+
deep_merge_dicts(merged_delta, latest_writes)
144+
145+
119146
def _merge_and_trace_function_response_events(
120147
invocation_context: InvocationContext,
121148
function_response_events: list[Event],
@@ -124,6 +151,9 @@ def _merge_and_trace_function_response_events(
124151
merged_event = merge_parallel_function_response_events(
125152
function_response_events
126153
)
154+
_apply_latest_state_writes(
155+
merged_event, function_response_events, invocation_context.session.state
156+
)
127157

128158
# this is needed for debug traces of parallel calls
129159
# individual response with tool.name is traced in __build_response_event

‎tests/unittests/flows/llm_flows/tools/test_functions_parallel.py‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
17+
import asyncio
18+
from collections.abc import Awaitable
19+
from collections.abc import Callable
20+
import copy
21+
from typing import Any
22+
1523
from google.adk.agents.llm_agent import Agent
1624
from google.adk.events.event_actions import EventActions
1725
from google.adk.tools.tool_context import ToolContext
@@ -21,6 +29,38 @@
2129
from .... import testing_utils
2230

2331

32+
async def _run_parallel_calls(
33+
tool: Callable[..., Awaitable[None]],
34+
args_list: list[dict[str, str]],
35+
initial_state: dict[str, Any] | None = None,
36+
) -> dict[str, Any]:
37+
"""Runs one model turn of parallel calls to `tool`, returns the stored state."""
38+
function_calls = [
39+
types.Part.from_function_call(name=tool.__name__, args=args)
40+
for args in args_list
41+
]
42+
agent = Agent(
43+
name='root_agent',
44+
model=testing_utils.MockModel.create(responses=[function_calls, 'done']),
45+
tools=[tool],
46+
)
47+
runner = testing_utils.TestInMemoryRunner(agent)
48+
session = await runner.session_service.create_session(
49+
app_name=runner.app_name, user_id='test_user', state=initial_state
50+
)
51+
async for _ in runner.run_async(
52+
user_id='test_user',
53+
session_id=session.id,
54+
new_message=testing_utils.get_user_content('test'),
55+
):
56+
pass
57+
stored = await runner.session_service.get_session(
58+
app_name=runner.app_name, user_id='test_user', session_id=session.id
59+
)
60+
assert stored is not None
61+
return stored.state
62+
63+
2464
@pytest.mark.asyncio
2565
async def test_parallel_function_calls_with_state_change():
2666
function_calls = [
@@ -105,3 +145,89 @@ async def transfer_to_agent(
105145
},
106146
transfer_to_agent='test_sub_agent',
107147
)
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_parallel_appends_finishing_out_of_order_keep_every_item():
152+
b_written = asyncio.Event()
153+
154+
async def append_item(item: str, tool_context: ToolContext) -> None:
155+
if item == 'a':
156+
await asyncio.wait_for(b_written.wait(), timeout=5)
157+
tool_context.state['items'] = tool_context.state.get('items', []) + [item]
158+
b_written.set()
159+
160+
state = await _run_parallel_calls(append_item, [{'item': 'a'}, {'item': 'b'}])
161+
162+
assert state['items'] == ['b', 'a']
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_parallel_appends_finishing_in_order_store_no_duplicates():
167+
async def append_item(item: str, tool_context: ToolContext) -> None:
168+
tool_context.state['items'] = tool_context.state.get('items', []) + [item]
169+
170+
state = await _run_parallel_calls(append_item, [{'item': 'a'}, {'item': 'b'}])
171+
172+
assert state['items'] == ['a', 'b']
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_parallel_snapshot_writes_keep_the_latest_nested_list():
177+
b_written = asyncio.Event()
178+
179+
async def add_label(label: str, tool_context: ToolContext) -> None:
180+
if label == 'a':
181+
await asyncio.wait_for(b_written.wait(), timeout=5)
182+
doc = copy.deepcopy(tool_context.state['doc'])
183+
doc['entities'][0]['labels'].append(label)
184+
tool_context.state['doc'] = doc
185+
b_written.set()
186+
187+
state = await _run_parallel_calls(
188+
add_label,
189+
[{'label': 'a'}, {'label': 'b'}],
190+
initial_state={'doc': {'entities': [{'name': 'e1', 'labels': []}]}},
191+
)
192+
193+
assert state['doc'] == {'entities': [{'name': 'e1', 'labels': ['b', 'a']}]}
194+
195+
196+
@pytest.mark.asyncio
197+
async def test_parallel_writes_of_separate_dict_keys_are_all_kept():
198+
async def save_draft(draft_id: str, tool_context: ToolContext) -> None:
199+
tool_context.state['drafts'] = {draft_id: 'body'}
200+
201+
state = await _run_parallel_calls(
202+
save_draft, [{'draft_id': 'a'}, {'draft_id': 'b'}]
203+
)
204+
205+
assert state['drafts'] == {'a': 'body', 'b': 'body'}
206+
207+
208+
@pytest.mark.asyncio
209+
async def test_parallel_list_writes_bypassing_state_keep_call_order():
210+
async def set_items(item: str, tool_context: ToolContext) -> None:
211+
tool_context.actions.state_delta['items'] = [item]
212+
213+
state = await _run_parallel_calls(
214+
set_items,
215+
[{'item': 'a'}, {'item': 'b'}],
216+
initial_state={'items': ['old']},
217+
)
218+
219+
assert state['items'] == ['b']
220+
221+
222+
@pytest.mark.asyncio
223+
async def test_parallel_scalar_writes_bypassing_state_keep_call_order():
224+
async def set_done(done: str, tool_context: ToolContext) -> None:
225+
tool_context.actions.state_delta['done'] = done == 'yes'
226+
227+
state = await _run_parallel_calls(
228+
set_done,
229+
[{'done': 'no'}, {'done': 'yes'}],
230+
initial_state={'done': False},
231+
)
232+
233+
assert state['done'] is True

0 commit comments

Comments
 (0)