From 51c9f1d32ae27a62f611b0e319750502ffc76c95 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 | 9 ++- .../flows/llm_flows/test_functions_simple.py | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 724bcd3f0e..672b997011 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -1151,10 +1151,17 @@ def __build_response_event( def deep_merge_dicts(d1: dict, d2: dict) -> dict: - """Recursively merges d2 into d1.""" + """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 f63cefeb45..fd0cecf842 100644 --- a/tests/unittests/flows/llm_flows/test_functions_simple.py +++ b/tests/unittests/flows/llm_flows/test_functions_simple.py @@ -23,6 +23,7 @@ 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 +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.computer_use.computer_use_tool import ComputerUseTool from google.adk.tools.function_tool import FunctionTool @@ -1236,3 +1237,73 @@ async def mock_run(*args, **kwargs): # Verify the image was converted to a blob assert len(response_part.parts) == 1 assert response_part.parts[0].inline_data is not None + + +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 4b3db5593490904a80acf96085c4d5d3b4262901 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 f192f2e95c..79f1dcf1db 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 fd0cecf842..f63cefeb45 100644 --- a/tests/unittests/flows/llm_flows/test_functions_simple.py +++ b/tests/unittests/flows/llm_flows/test_functions_simple.py @@ -23,7 +23,6 @@ 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 -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.computer_use.computer_use_tool import ComputerUseTool from google.adk.tools.function_tool import FunctionTool @@ -1237,73 +1236,3 @@ async def mock_run(*args, **kwargs): # Verify the image was converted to a blob assert len(response_part.parts) == 1 assert response_part.parts[0].inline_data is not None - - -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"]}