Skip to content

Commit fda2347

Browse files
google-genai-botcopybara-github
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 PiperOrigin-RevId: 936249245
1 parent 3afdb08 commit fda2347

2 files changed

Lines changed: 1 addition & 91 deletions

File tree

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

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

12221222

12231223
def deep_merge_dicts(d1: dict, d2: dict) -> dict:
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-
"""
1224+
"""Recursively merges d2 into d1."""
12301225
for key, value in d2.items():
12311226
if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict):
12321227
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
12351228
else:
12361229
d1[key] = value
12371230
return d1

tests/unittests/flows/llm_flows/test_functions_parallel.py

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

1515
from google.adk.agents.llm_agent import Agent
16-
from google.adk.events.event import Event
1716
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
2017
from google.adk.tools.tool_context import ToolContext
2118
from google.genai import types
2219
import pytest
@@ -108,83 +105,3 @@ async def transfer_to_agent(
108105
},
109106
transfer_to_agent='test_sub_agent',
110107
)
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)