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_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']}