From 4306662ad35bc85bd6db2850c0baf4ec018c4564 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Tue, 7 Apr 2026 18:08:10 +0530 Subject: [PATCH 1/2] fix: concatenate list values in deep_merge_dicts during parallel tool call merge 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. Add a list-type check so that list values are concatenated instead of overwritten, preserving all entries from parallel function responses. Fixes #5190 --- src/google/adk/flows/llm_flows/functions.py | 11 ++- .../flows/llm_flows/test_functions_simple.py | 71 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 36a9596bd36..8ec5cdfd495 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -1393,11 +1393,18 @@ def _build_function_response_content( return types.Content(role='user', parts=[part_function_response]) -def deep_merge_dicts(d1: dict[str, Any], d2: dict[str, Any]) -> dict[str, Any]: - """Recursively merges d2 into d1.""" +def deep_merge_dicts(d1: dict, d2: dict) -> dict: + """Recursively merges d2 into d1. + + For dict values, merges recursively. For list values, concatenates instead of + overwriting so that parallel tool calls don't silently drop list entries + (e.g. state_delta lists from concurrent function responses). + """ for key, value in d2.items(): if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict): d1[key] = deep_merge_dicts(d1[key], value) + elif key in d1 and isinstance(d1[key], list) and isinstance(value, list): + d1[key] = d1[key] + value else: d1[key] = value return d1 diff --git a/tests/unittests/flows/llm_flows/test_functions_simple.py b/tests/unittests/flows/llm_flows/test_functions_simple.py index 28ffd03aa82..1475c03c052 100644 --- a/tests/unittests/flows/llm_flows/test_functions_simple.py +++ b/tests/unittests/flows/llm_flows/test_functions_simple.py @@ -24,6 +24,7 @@ from google.adk.events.event import Event from google.adk.events.event_actions import EventActions from google.adk.events.ui_widget import UiWidget +from google.adk.flows.llm_flows.functions import deep_merge_dicts from google.adk.flows.llm_flows.functions import find_matching_function_call from google.adk.flows.llm_flows.functions import handle_function_calls_async from google.adk.flows.llm_flows.functions import handle_function_calls_live @@ -1864,3 +1865,73 @@ async def slow_fn_2() -> dict[str, str]: await asyncio.sleep(0) assert len(invocation_context.active_non_blocking_tool_tasks) == 0 + + +def test_deep_merge_dicts_concatenates_lists(): + """Test that deep_merge_dicts concatenates list values instead of overwriting.""" + d1 = {"state_delta": {"items": ["a"]}} + d2 = {"state_delta": {"items": ["b"]}} + result = deep_merge_dicts(d1, d2) + assert result["state_delta"]["items"] == ["a", "b"] + + +def test_deep_merge_dicts_overwrites_non_list_non_dict(): + """Test that deep_merge_dicts still overwrites scalar values.""" + d1 = {"key": "old"} + d2 = {"key": "new"} + result = deep_merge_dicts(d1, d2) + assert result["key"] == "new" + + +def test_deep_merge_dicts_merges_nested_dicts(): + """Test that deep_merge_dicts recursively merges nested dicts.""" + d1 = {"a": {"b": 1, "c": 2}} + d2 = {"a": {"b": 3, "d": 4}} + result = deep_merge_dicts(d1, d2) + assert result == {"a": {"b": 3, "c": 2, "d": 4}} + + +def test_deep_merge_dicts_handles_mixed_list_and_non_list(): + """Test that deep_merge_dicts overwrites when types differ (list vs non-list).""" + d1 = {"key": "not_a_list"} + d2 = {"key": ["a", "b"]} + result = deep_merge_dicts(d1, d2) + assert result["key"] == ["a", "b"] + + d1 = {"key": ["a", "b"]} + d2 = {"key": "not_a_list"} + result = deep_merge_dicts(d1, d2) + assert result["key"] == "not_a_list" + + +def test_merge_parallel_function_response_events_merges_state_delta_lists(): + """Test that parallel events with list state_delta values are concatenated, not overwritten.""" + invocation_id = "base_invocation_123" + + event1 = Event( + invocation_id=invocation_id, + author="tool", + content=types.Content( + role="user", + parts=[types.Part(function_response=types.FunctionResponse( + name="func_1", response={"result": "ok"}, + ))], + ), + actions=EventActions(state_delta={"items": ["a"]}), + ) + + event2 = Event( + invocation_id=invocation_id, + author="tool", + content=types.Content( + role="user", + parts=[types.Part(function_response=types.FunctionResponse( + name="func_2", response={"result": "ok"}, + ))], + ), + actions=EventActions(state_delta={"items": ["b"]}), + ) + + merged_event = merge_parallel_function_response_events([event1, event2]) + + assert merged_event.actions.state_delta == {"items": ["a", "b"]} From e625f510d3173ee3c3ecc38cbb6fd4abbaa0edce Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Tue, 7 Apr 2026 23:40:00 +0530 Subject: [PATCH 2/2] refactor: move deep_merge_dicts and state_delta list tests to test_functions_parallel Move tests for deep_merge_dicts and merge_parallel_function_response_events with list state_delta merging from test_functions_simple.py to test_functions_parallel.py per reviewer feedback. --- .../llm_flows/test_functions_parallel.py | 83 +++++++++++++++++++ .../flows/llm_flows/test_functions_simple.py | 71 ---------------- 2 files changed, 83 insertions(+), 71 deletions(-) diff --git a/tests/unittests/flows/llm_flows/test_functions_parallel.py b/tests/unittests/flows/llm_flows/test_functions_parallel.py index f192f2e95c9..79f1dcf1dba 100644 --- a/tests/unittests/flows/llm_flows/test_functions_parallel.py +++ b/tests/unittests/flows/llm_flows/test_functions_parallel.py @@ -13,7 +13,10 @@ # limitations under the License. from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event from google.adk.events.event_actions import EventActions +from google.adk.flows.llm_flows.functions import deep_merge_dicts +from google.adk.flows.llm_flows.functions import merge_parallel_function_response_events from google.adk.tools.tool_context import ToolContext from google.genai import types import pytest @@ -105,3 +108,83 @@ async def transfer_to_agent( }, transfer_to_agent='test_sub_agent', ) + + +def test_deep_merge_dicts_concatenates_lists(): + """Test that deep_merge_dicts concatenates list values instead of overwriting.""" + d1 = {'state_delta': {'items': ['a']}} + d2 = {'state_delta': {'items': ['b']}} + result = deep_merge_dicts(d1, d2) + assert result['state_delta']['items'] == ['a', 'b'] + + +def test_deep_merge_dicts_overwrites_non_list_non_dict(): + """Test that deep_merge_dicts still overwrites scalar values.""" + d1 = {'key': 'old'} + d2 = {'key': 'new'} + result = deep_merge_dicts(d1, d2) + assert result['key'] == 'new' + + +def test_deep_merge_dicts_merges_nested_dicts(): + """Test that deep_merge_dicts recursively merges nested dicts.""" + d1 = {'a': {'b': 1, 'c': 2}} + d2 = {'a': {'b': 3, 'd': 4}} + result = deep_merge_dicts(d1, d2) + assert result == {'a': {'b': 3, 'c': 2, 'd': 4}} + + +def test_deep_merge_dicts_handles_mixed_list_and_non_list(): + """Test that deep_merge_dicts overwrites when types differ (list vs non-list).""" + d1 = {'key': 'not_a_list'} + d2 = {'key': ['a', 'b']} + result = deep_merge_dicts(d1, d2) + assert result['key'] == ['a', 'b'] + + d1 = {'key': ['a', 'b']} + d2 = {'key': 'not_a_list'} + result = deep_merge_dicts(d1, d2) + assert result['key'] == 'not_a_list' + + +def test_merge_parallel_function_response_events_merges_state_delta_lists(): + """Test that parallel events with list state_delta values are concatenated, not overwritten.""" + invocation_id = 'base_invocation_123' + + event1 = Event( + invocation_id=invocation_id, + author='tool', + content=types.Content( + role='user', + parts=[ + types.Part( + function_response=types.FunctionResponse( + name='func_1', + response={'result': 'ok'}, + ) + ) + ], + ), + actions=EventActions(state_delta={'items': ['a']}), + ) + + event2 = Event( + invocation_id=invocation_id, + author='tool', + content=types.Content( + role='user', + parts=[ + types.Part( + function_response=types.FunctionResponse( + name='func_2', + response={'result': 'ok'}, + ) + ) + ], + ), + actions=EventActions(state_delta={'items': ['b']}), + ) + + merged_event = merge_parallel_function_response_events([event1, event2]) + + assert merged_event.actions.state_delta == {'items': ['a', 'b']} diff --git a/tests/unittests/flows/llm_flows/test_functions_simple.py b/tests/unittests/flows/llm_flows/test_functions_simple.py index 1475c03c052..28ffd03aa82 100644 --- a/tests/unittests/flows/llm_flows/test_functions_simple.py +++ b/tests/unittests/flows/llm_flows/test_functions_simple.py @@ -24,7 +24,6 @@ from google.adk.events.event import Event from google.adk.events.event_actions import EventActions from google.adk.events.ui_widget import UiWidget -from google.adk.flows.llm_flows.functions import deep_merge_dicts from google.adk.flows.llm_flows.functions import find_matching_function_call from google.adk.flows.llm_flows.functions import handle_function_calls_async from google.adk.flows.llm_flows.functions import handle_function_calls_live @@ -1865,73 +1864,3 @@ async def slow_fn_2() -> dict[str, str]: await asyncio.sleep(0) assert len(invocation_context.active_non_blocking_tool_tasks) == 0 - - -def test_deep_merge_dicts_concatenates_lists(): - """Test that deep_merge_dicts concatenates list values instead of overwriting.""" - d1 = {"state_delta": {"items": ["a"]}} - d2 = {"state_delta": {"items": ["b"]}} - result = deep_merge_dicts(d1, d2) - assert result["state_delta"]["items"] == ["a", "b"] - - -def test_deep_merge_dicts_overwrites_non_list_non_dict(): - """Test that deep_merge_dicts still overwrites scalar values.""" - d1 = {"key": "old"} - d2 = {"key": "new"} - result = deep_merge_dicts(d1, d2) - assert result["key"] == "new" - - -def test_deep_merge_dicts_merges_nested_dicts(): - """Test that deep_merge_dicts recursively merges nested dicts.""" - d1 = {"a": {"b": 1, "c": 2}} - d2 = {"a": {"b": 3, "d": 4}} - result = deep_merge_dicts(d1, d2) - assert result == {"a": {"b": 3, "c": 2, "d": 4}} - - -def test_deep_merge_dicts_handles_mixed_list_and_non_list(): - """Test that deep_merge_dicts overwrites when types differ (list vs non-list).""" - d1 = {"key": "not_a_list"} - d2 = {"key": ["a", "b"]} - result = deep_merge_dicts(d1, d2) - assert result["key"] == ["a", "b"] - - d1 = {"key": ["a", "b"]} - d2 = {"key": "not_a_list"} - result = deep_merge_dicts(d1, d2) - assert result["key"] == "not_a_list" - - -def test_merge_parallel_function_response_events_merges_state_delta_lists(): - """Test that parallel events with list state_delta values are concatenated, not overwritten.""" - invocation_id = "base_invocation_123" - - event1 = Event( - invocation_id=invocation_id, - author="tool", - content=types.Content( - role="user", - parts=[types.Part(function_response=types.FunctionResponse( - name="func_1", response={"result": "ok"}, - ))], - ), - actions=EventActions(state_delta={"items": ["a"]}), - ) - - event2 = Event( - invocation_id=invocation_id, - author="tool", - content=types.Content( - role="user", - parts=[types.Part(function_response=types.FunctionResponse( - name="func_2", response={"result": "ok"}, - ))], - ), - actions=EventActions(state_delta={"items": ["b"]}), - ) - - merged_event = merge_parallel_function_response_events([event1, event2]) - - assert merged_event.actions.state_delta == {"items": ["a", "b"]}