Skip to content

Commit 1ff84eb

Browse files
enjoykumawatGWeale
authored andcommitted
fix: concatenate list values in deep_merge_dicts during parallel tool call merge
Merge #5191 ## Summary - **Bug:** When multiple tool calls run in parallel and each writes to the same `state_delta` key containing a list value, `deep_merge_dicts` silently drops all but the last value because lists hit the `else` branch and get overwritten. - **Fix:** Add a list-type check in `deep_merge_dicts` so list values are concatenated (`d1[key] + value`) instead of overwritten, preserving all entries from parallel function responses. - **Tests:** Added 5 unit tests covering list concatenation, scalar overwrite, nested dict merge, mixed-type handling, and an integration test for `merge_parallel_function_response_events` with `state_delta` lists. ## Reproduction ```python # Tool A's delta: {"state_delta": {"items": ["a"]}} # Tool B's delta: {"state_delta": {"items": ["b"]}} # Before fix: {"state_delta": {"items": ["b"]}} — item "a" is lost # After fix: {"state_delta": {"items": ["a", "b"]}} — both preserved ``` ## Test plan - [x] All 5 new unit tests pass (`test_deep_merge_dicts_*` and `test_merge_parallel_function_response_events_merges_state_delta_lists`) - [x] All 32 existing tests in `test_functions_simple.py` pass (0 regressions) - [ ] Manual: create parallel tool calls that accumulate list state and verify no data loss Fixes #5190 Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5191 from enjoykumawat:fix/parallel-state-delta-list-merge 7425481 PiperOrigin-RevId: 936119923
1 parent 551feae commit 1ff84eb

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

‎src/google/adk/flows/llm_flows/functions.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,10 +1221,17 @@ def _build_function_response_content(
12211221

12221222

12231223
def deep_merge_dicts(d1: dict, d2: dict) -> dict:
1224-
"""Recursively merges d2 into d1."""
1224+
"""Recursively merges d2 into d1.
1225+
1226+
For dict values, merges recursively. For list values, concatenates instead of
1227+
overwriting so that parallel tool calls don't silently drop list entries
1228+
(e.g. state_delta lists from concurrent function responses).
1229+
"""
12251230
for key, value in d2.items():
12261231
if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict):
12271232
d1[key] = deep_merge_dicts(d1[key], value)
1233+
elif key in d1 and isinstance(d1[key], list) and isinstance(value, list):
1234+
d1[key] = d1[key] + value
12281235
else:
12291236
d1[key] = value
12301237
return d1

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
# limitations under the License.
1414

1515
from google.adk.agents.llm_agent import Agent
16+
from google.adk.events.event import Event
1617
from google.adk.events.event_actions import EventActions
18+
from google.adk.flows.llm_flows.functions import deep_merge_dicts
19+
from google.adk.flows.llm_flows.functions import merge_parallel_function_response_events
1720
from google.adk.tools.tool_context import ToolContext
1821
from google.genai import types
1922
import pytest
@@ -105,3 +108,83 @@ async def transfer_to_agent(
105108
},
106109
transfer_to_agent='test_sub_agent',
107110
)
111+
112+
113+
def test_deep_merge_dicts_concatenates_lists():
114+
"""Test that deep_merge_dicts concatenates list values instead of overwriting."""
115+
d1 = {'state_delta': {'items': ['a']}}
116+
d2 = {'state_delta': {'items': ['b']}}
117+
result = deep_merge_dicts(d1, d2)
118+
assert result['state_delta']['items'] == ['a', 'b']
119+
120+
121+
def test_deep_merge_dicts_overwrites_non_list_non_dict():
122+
"""Test that deep_merge_dicts still overwrites scalar values."""
123+
d1 = {'key': 'old'}
124+
d2 = {'key': 'new'}
125+
result = deep_merge_dicts(d1, d2)
126+
assert result['key'] == 'new'
127+
128+
129+
def test_deep_merge_dicts_merges_nested_dicts():
130+
"""Test that deep_merge_dicts recursively merges nested dicts."""
131+
d1 = {'a': {'b': 1, 'c': 2}}
132+
d2 = {'a': {'b': 3, 'd': 4}}
133+
result = deep_merge_dicts(d1, d2)
134+
assert result == {'a': {'b': 3, 'c': 2, 'd': 4}}
135+
136+
137+
def test_deep_merge_dicts_handles_mixed_list_and_non_list():
138+
"""Test that deep_merge_dicts overwrites when types differ (list vs non-list)."""
139+
d1 = {'key': 'not_a_list'}
140+
d2 = {'key': ['a', 'b']}
141+
result = deep_merge_dicts(d1, d2)
142+
assert result['key'] == ['a', 'b']
143+
144+
d1 = {'key': ['a', 'b']}
145+
d2 = {'key': 'not_a_list'}
146+
result = deep_merge_dicts(d1, d2)
147+
assert result['key'] == 'not_a_list'
148+
149+
150+
def test_merge_parallel_function_response_events_merges_state_delta_lists():
151+
"""Test that parallel events with list state_delta values are concatenated, not overwritten."""
152+
invocation_id = 'base_invocation_123'
153+
154+
event1 = Event(
155+
invocation_id=invocation_id,
156+
author='tool',
157+
content=types.Content(
158+
role='user',
159+
parts=[
160+
types.Part(
161+
function_response=types.FunctionResponse(
162+
name='func_1',
163+
response={'result': 'ok'},
164+
)
165+
)
166+
],
167+
),
168+
actions=EventActions(state_delta={'items': ['a']}),
169+
)
170+
171+
event2 = Event(
172+
invocation_id=invocation_id,
173+
author='tool',
174+
content=types.Content(
175+
role='user',
176+
parts=[
177+
types.Part(
178+
function_response=types.FunctionResponse(
179+
name='func_2',
180+
response={'result': 'ok'},
181+
)
182+
)
183+
],
184+
),
185+
actions=EventActions(state_delta={'items': ['b']}),
186+
)
187+
188+
merged_event = merge_parallel_function_response_events([event1, event2])
189+
190+
assert merged_event.actions.state_delta == {'items': ['a', 'b']}

0 commit comments

Comments
 (0)