diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index fefa014c45..0fadc830b9 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -333,6 +333,130 @@ def _process_compaction_events(events: list[Event]) -> list[Event]: return events_to_process +def _merge_interleaved_function_call_contents( + contents: list[types.Content], +) -> list[types.Content]: + """Merge interleaved function call/response contents into grouped format. + + Gemini 3 models with thinking enabled require function calls and responses + to be grouped rather than interleaved. This function transforms: + [model(fc1), user(fr1), model(fc2), user(fr2)] + into: + [model([fc1, fc2]), user([fr1, fr2])] + + This fixes the "missing thought_signature" error (GitHub issue #3705). + + Note: A single function call/response pair (model(fc1) -> user(fr1)) is NOT + merged since it's already in the correct format and doesn't cause issues. + + Args: + contents: A list of Content objects that may have interleaved + function calls and responses. + + Returns: + A list of Content objects with consecutive function call contents + merged and consecutive function response contents merged. + """ + if not contents: + return contents + + result: list[types.Content] = [] + i = 0 + + while i < len(contents): + current = contents[i] + + if _is_pure_function_call_content(current): + # Start collecting consecutive function call/response pairs + function_call_parts: list[types.Part] = list(current.parts) + function_response_parts: list[types.Part] = [] + j = i + 1 + pairs_count = 0 # Track how many fc/fr pairs we've seen + + # Look ahead for interleaved pattern: fr1 -> fc2 -> fr2 -> fc3 -> ... + while j + 1 < len(contents): + next_content = contents[j] + next_next_content = contents[j + 1] + + if _is_pure_function_response_content( + next_content + ) and _is_pure_function_call_content(next_next_content): + # This is an interleaved pattern, collect the parts + function_response_parts.extend(next_content.parts) + function_call_parts.extend(next_next_content.parts) + pairs_count += 1 + j += 2 + else: + break + + # Only merge if we found at least one interleaved pair (fc1->fr1->fc2) + # A single fc->fr pair should not be merged + if pairs_count > 0: + # Check if there's a trailing function response for the last fc + if j < len(contents) and _is_pure_function_response_content( + contents[j] + ): + function_response_parts.extend(contents[j].parts) + j += 1 + + # Create merged model content with all function calls + merged_model = types.Content(role='model', parts=function_call_parts) + result.append(merged_model) + + # Create merged user content with all function responses + merged_user = types.Content(role='user', parts=function_response_parts) + result.append(merged_user) + + i = j + continue + + # Not an interleaved pattern (or single fc/fr pair), keep as-is + result.append(current) + i += 1 + + return result + + +def _is_function_call_part(part: types.Part) -> bool: + """Check if a part contains only a function call.""" + return ( + part.function_call is not None + and part.text is None + and part.inline_data is None + and part.file_data is None + and part.function_response is None + ) + + +def _is_function_response_part(part: types.Part) -> bool: + """Check if a part contains only a function response.""" + return ( + part.function_response is not None + and part.text is None + and part.inline_data is None + and part.file_data is None + and part.function_call is None + ) + + +def _is_pure_function_call_content(content: types.Content) -> bool: + """Checks if a content object contains only function calls.""" + return ( + content.role == 'model' + and content.parts + and all(_is_function_call_part(p) for p in content.parts) + ) + + +def _is_pure_function_response_content(content: types.Content) -> bool: + """Checks if a content object contains only function responses.""" + return ( + content.role == 'user' + and content.parts + and all(_is_function_response_part(p) for p in content.parts) + ) + + def _get_contents( current_branch: Optional[str], events: list[Event], agent_name: str = '' ) -> list[types.Content]: @@ -445,6 +569,12 @@ def _get_contents( if content: remove_client_function_call_id(content) contents.append(content) + + # Merge interleaved function call/response contents to avoid thought_signature + # errors with Gemini 3 models that have thinking enabled. + # See: https://github.com/google/adk-python/issues/3705 + contents = _merge_interleaved_function_call_contents(contents) + return contents diff --git a/tests/unittests/flows/llm_flows/test_contents_function.py b/tests/unittests/flows/llm_flows/test_contents_function.py index 251d5461dc..149d3ed987 100644 --- a/tests/unittests/flows/llm_flows/test_contents_function.py +++ b/tests/unittests/flows/llm_flows/test_contents_function.py @@ -590,3 +590,264 @@ async def test_error_when_function_response_without_matching_call(): invocation_context, llm_request ): pass + + +@pytest.mark.asyncio +async def test_interleaved_function_calls_are_merged(): + """Test that interleaved function call/response patterns are merged. + + This tests the fix for GitHub issue #3705 where Gemini 3 models with + thinking enabled fail with "missing thought_signature" error when + function calls and responses are interleaved. + + The pattern: + [model(fc1), user(fr1), model(fc2), user(fr2)] + should be merged to: + [model([fc1, fc2]), user([fr1, fr2])] + """ + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + # Create interleaved function calls and responses + function_call_1 = types.FunctionCall( + id="call_1", name="search_tool", args={"query": "topic 1"} + ) + function_response_1 = types.FunctionResponse( + id="call_1", + name="search_tool", + response={"results": ["result 1"]}, + ) + function_call_2 = types.FunctionCall( + id="call_2", name="search_tool", args={"query": "topic 2"} + ) + function_response_2 = types.FunctionResponse( + id="call_2", + name="search_tool", + response={"results": ["result 2"]}, + ) + + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Research two topics"), + ), + # First function call + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent( + [types.Part(function_call=function_call_1)] + ), + ), + # First function response + Event( + invocation_id="inv3", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response_1)] + ), + ), + # Second function call (interleaved) + Event( + invocation_id="inv4", + author="test_agent", + content=types.ModelContent( + [types.Part(function_call=function_call_2)] + ), + ), + # Second function response + Event( + invocation_id="inv5", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response_2)] + ), + ), + ] + invocation_context.session.events = events + + # Process the request + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + # Verify interleaved pattern was merged: + # [model(fc1), user(fr1), model(fc2), user(fr2)] + # becomes: + # [user(query), model([fc1, fc2]), user([fr1, fr2])] + assert len(llm_request.contents) == 3 + assert llm_request.contents[0] == types.UserContent("Research two topics") + + # Check merged model content contains both function calls + merged_model = llm_request.contents[1] + assert merged_model.role == "model" + assert len(merged_model.parts) == 2 + assert merged_model.parts[0].function_call == function_call_1 + assert merged_model.parts[1].function_call == function_call_2 + + # Check merged user content contains both function responses + merged_user = llm_request.contents[2] + assert merged_user.role == "user" + assert len(merged_user.parts) == 2 + assert merged_user.parts[0].function_response == function_response_1 + assert merged_user.parts[1].function_response == function_response_2 + + +@pytest.mark.asyncio +async def test_three_interleaved_function_calls_are_merged(): + """Test that three or more interleaved function calls are properly merged.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + # Create three interleaved function calls + fc1 = types.FunctionCall(id="call_1", name="tool", args={"q": "1"}) + fr1 = types.FunctionResponse(id="call_1", name="tool", response={"r": "1"}) + fc2 = types.FunctionCall(id="call_2", name="tool", args={"q": "2"}) + fr2 = types.FunctionResponse(id="call_2", name="tool", response={"r": "2"}) + fc3 = types.FunctionCall(id="call_3", name="tool", args={"q": "3"}) + fr3 = types.FunctionResponse(id="call_3", name="tool", response={"r": "3"}) + + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Query"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([types.Part(function_call=fc1)]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent([types.Part(function_response=fr1)]), + ), + Event( + invocation_id="inv4", + author="test_agent", + content=types.ModelContent([types.Part(function_call=fc2)]), + ), + Event( + invocation_id="inv5", + author="user", + content=types.UserContent([types.Part(function_response=fr2)]), + ), + Event( + invocation_id="inv6", + author="test_agent", + content=types.ModelContent([types.Part(function_call=fc3)]), + ), + Event( + invocation_id="inv7", + author="user", + content=types.UserContent([types.Part(function_response=fr3)]), + ), + ] + invocation_context.session.events = events + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + # Verify all three calls/responses are merged + assert len(llm_request.contents) == 3 + + merged_model = llm_request.contents[1] + assert merged_model.role == "model" + assert len(merged_model.parts) == 3 + assert merged_model.parts[0].function_call == fc1 + assert merged_model.parts[1].function_call == fc2 + assert merged_model.parts[2].function_call == fc3 + + merged_user = llm_request.contents[2] + assert merged_user.role == "user" + assert len(merged_user.parts) == 3 + assert merged_user.parts[0].function_response == fr1 + assert merged_user.parts[1].function_response == fr2 + assert merged_user.parts[2].function_response == fr3 + + +@pytest.mark.asyncio +async def test_interleaved_merge_with_text_after(): + """Test that interleaved merge works when followed by text content.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + fc1 = types.FunctionCall(id="call_1", name="tool", args={"q": "1"}) + fr1 = types.FunctionResponse(id="call_1", name="tool", response={"r": "1"}) + fc2 = types.FunctionCall(id="call_2", name="tool", args={"q": "2"}) + fr2 = types.FunctionResponse(id="call_2", name="tool", response={"r": "2"}) + + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Query"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([types.Part(function_call=fc1)]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent([types.Part(function_response=fr1)]), + ), + Event( + invocation_id="inv4", + author="test_agent", + content=types.ModelContent([types.Part(function_call=fc2)]), + ), + Event( + invocation_id="inv5", + author="user", + content=types.UserContent([types.Part(function_response=fr2)]), + ), + # Text content after interleaved calls + Event( + invocation_id="inv6", + author="test_agent", + content=types.ModelContent("Here are the results"), + ), + Event( + invocation_id="inv7", + author="user", + content=types.UserContent("Thanks!"), + ), + ] + invocation_context.session.events = events + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + # Verify merge happened and text content is preserved + assert len(llm_request.contents) == 5 + assert llm_request.contents[0] == types.UserContent("Query") + + # Merged function calls + assert llm_request.contents[1].role == "model" + assert len(llm_request.contents[1].parts) == 2 + + # Merged function responses + assert llm_request.contents[2].role == "user" + assert len(llm_request.contents[2].parts) == 2 + + # Text content preserved + assert llm_request.contents[3] == types.ModelContent("Here are the results") + assert llm_request.contents[4] == types.UserContent("Thanks!") diff --git a/tests/unittests/flows/llm_flows/test_functions_sequential.py b/tests/unittests/flows/llm_flows/test_functions_sequential.py index 5ae073c615..fa2240bd8c 100644 --- a/tests/unittests/flows/llm_flows/test_functions_sequential.py +++ b/tests/unittests/flows/llm_flows/test_functions_sequential.py @@ -59,34 +59,51 @@ def increase_by_one(x: int) -> int: ] # Asserts the requests. + # Note: Due to the fix for GitHub issue #3705, interleaved function + # call/response patterns are merged into grouped format to avoid + # "missing thought_signature" errors with Gemini 3 models. assert len(mockModel.requests) == 4 # 1 item: user content assert testing_utils.simplify_contents(mockModel.requests[0].contents) == [ ('user', 'test') ] # 3 items: user content, function call / response for the 1st call + # (single call/response pair is NOT merged) assert testing_utils.simplify_contents(mockModel.requests[1].contents) == [ ('user', 'test'), ('model', function_call({'x': 1})), ('user', function_response({'result': 2})), ] - # 5 items: user content, function call / response for two calls + # 3 items: user content, merged function calls, merged function responses + # (interleaved pattern: fc1->fr1->fc2->fr2 is merged to [fc1,fc2]->[fr1,fr2]) assert testing_utils.simplify_contents(mockModel.requests[2].contents) == [ ('user', 'test'), - ('model', function_call({'x': 1})), - ('user', function_response({'result': 2})), - ('model', function_call({'x': 2})), - ('user', function_response({'result': 3})), + ('model', [function_call({'x': 1}), function_call({'x': 2})]), + ( + 'user', + [function_response({'result': 2}), function_response({'result': 3})], + ), ] - # 7 items: user content, function call / response for three calls + # 3 items: user content, merged function calls, merged function responses + # (interleaved pattern merged for all 3 calls) assert testing_utils.simplify_contents(mockModel.requests[3].contents) == [ ('user', 'test'), - ('model', function_call({'x': 1})), - ('user', function_response({'result': 2})), - ('model', function_call({'x': 2})), - ('user', function_response({'result': 3})), - ('model', function_call({'x': 3})), - ('user', function_response({'result': 4})), + ( + 'model', + [ + function_call({'x': 1}), + function_call({'x': 2}), + function_call({'x': 3}), + ], + ), + ( + 'user', + [ + function_response({'result': 2}), + function_response({'result': 3}), + function_response({'result': 4}), + ], + ), ] # Asserts the function calls.