From f4b1a25bd0c326fe0865d31874b2117239a1c709 Mon Sep 17 00:00:00 2001 From: haseryo0403 Date: Sun, 7 Dec 2025 21:19:11 +0900 Subject: [PATCH 1/4] feat: merge interleaved function call/response contents to avoid thought_signature errors --- src/google/adk/flows/llm_flows/contents.py | 132 +++++++++ .../flows/llm_flows/test_contents_function.py | 257 ++++++++++++++++++ .../llm_flows/test_functions_sequential.py | 24 +- 3 files changed, 401 insertions(+), 12 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index fefa014c45..975ea9ef45 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -333,6 +333,132 @@ 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] + + # Check if this is a model content with only function calls + if ( + current.role == 'model' + and current.parts + and all(_is_function_call_part(p) for p in current.parts) + ): + # 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] + + # Check if next is user content with only function responses + is_response = ( + next_content.role == 'user' + and next_content.parts + and all(_is_function_response_part(p) for p in next_content.parts) + ) + # Check if the one after is model content with only function calls + is_next_call = ( + next_next_content.role == 'model' + and next_next_content.parts + and all(_is_function_call_part(p) for p in next_next_content.parts) + ) + + if is_response and is_next_call: + # 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): + trailing = contents[j] + if ( + trailing.role == 'user' + and trailing.parts + and all(_is_function_response_part(p) for p in trailing.parts) + ): + function_response_parts.extend(trailing.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 _get_contents( current_branch: Optional[str], events: list[Event], agent_name: str = '' ) -> list[types.Content]: @@ -445,6 +571,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..b34503b1bf 100644 --- a/tests/unittests/flows/llm_flows/test_contents_function.py +++ b/tests/unittests/flows/llm_flows/test_contents_function.py @@ -590,3 +590,260 @@ 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..8238d2b9a0 100644 --- a/tests/unittests/flows/llm_flows/test_functions_sequential.py +++ b/tests/unittests/flows/llm_flows/test_functions_sequential.py @@ -59,34 +59,34 @@ 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. From c93f71d77e3d2bbf677a33a49a048a5825947c09 Mon Sep 17 00:00:00 2001 From: haseryo0403 Date: Mon, 8 Dec 2025 16:41:37 +0900 Subject: [PATCH 2/4] feat: add functions to check for pure function call and response content --- src/google/adk/flows/llm_flows/contents.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 975ea9ef45..b0c7906be8 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -459,6 +459,24 @@ def _is_function_response_part(part: types.Part) -> bool: ) +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]: From 1a8d84d12fcb410386a9148d6a7413162e7ec507 Mon Sep 17 00:00:00 2001 From: haseryo0403 Date: Mon, 8 Dec 2025 17:08:24 +0900 Subject: [PATCH 3/4] fix: simplify checks for pure function call and response content --- src/google/adk/flows/llm_flows/contents.py | 38 +++++----------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index b0c7906be8..0fadc830b9 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -366,12 +366,7 @@ def _merge_interleaved_function_call_contents( while i < len(contents): current = contents[i] - # Check if this is a model content with only function calls - if ( - current.role == 'model' - and current.parts - and all(_is_function_call_part(p) for p in current.parts) - ): + 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] = [] @@ -383,20 +378,9 @@ def _merge_interleaved_function_call_contents( next_content = contents[j] next_next_content = contents[j + 1] - # Check if next is user content with only function responses - is_response = ( - next_content.role == 'user' - and next_content.parts - and all(_is_function_response_part(p) for p in next_content.parts) - ) - # Check if the one after is model content with only function calls - is_next_call = ( - next_next_content.role == 'model' - and next_next_content.parts - and all(_is_function_call_part(p) for p in next_next_content.parts) - ) - - if is_response and is_next_call: + 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) @@ -409,15 +393,11 @@ def _merge_interleaved_function_call_contents( # 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): - trailing = contents[j] - if ( - trailing.role == 'user' - and trailing.parts - and all(_is_function_response_part(p) for p in trailing.parts) - ): - function_response_parts.extend(trailing.parts) - j += 1 + 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) From aaeb73b3bbe4d82581157fb693f3a5f392289c2b Mon Sep 17 00:00:00 2001 From: haseryo0403 Date: Sun, 14 Dec 2025 11:26:33 +0900 Subject: [PATCH 4/4] fix: format content structure in test cases for clarity --- .../flows/llm_flows/test_contents_function.py | 8 +++++-- .../llm_flows/test_functions_sequential.py | 23 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/unittests/flows/llm_flows/test_contents_function.py b/tests/unittests/flows/llm_flows/test_contents_function.py index b34503b1bf..149d3ed987 100644 --- a/tests/unittests/flows/llm_flows/test_contents_function.py +++ b/tests/unittests/flows/llm_flows/test_contents_function.py @@ -639,7 +639,9 @@ async def test_interleaved_function_calls_are_merged(): Event( invocation_id="inv2", author="test_agent", - content=types.ModelContent([types.Part(function_call=function_call_1)]), + content=types.ModelContent( + [types.Part(function_call=function_call_1)] + ), ), # First function response Event( @@ -653,7 +655,9 @@ async def test_interleaved_function_calls_are_merged(): Event( invocation_id="inv4", author="test_agent", - content=types.ModelContent([types.Part(function_call=function_call_2)]), + content=types.ModelContent( + [types.Part(function_call=function_call_2)] + ), ), # Second function response Event( diff --git a/tests/unittests/flows/llm_flows/test_functions_sequential.py b/tests/unittests/flows/llm_flows/test_functions_sequential.py index 8238d2b9a0..fa2240bd8c 100644 --- a/tests/unittests/flows/llm_flows/test_functions_sequential.py +++ b/tests/unittests/flows/llm_flows/test_functions_sequential.py @@ -79,14 +79,31 @@ def increase_by_one(x: int) -> int: assert testing_utils.simplify_contents(mockModel.requests[2].contents) == [ ('user', 'test'), ('model', [function_call({'x': 1}), function_call({'x': 2})]), - ('user', [function_response({'result': 2}), function_response({'result': 3})]), + ( + 'user', + [function_response({'result': 2}), function_response({'result': 3})], + ), ] # 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}), function_call({'x': 2}), function_call({'x': 3})]), - ('user', [function_response({'result': 2}), function_response({'result': 3}), 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.