-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix: merge interleaved function call/response contents to avoid thought_signature errors #3858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
f4b1a25
c93f71d
1a8d84d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| ) | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To improve readability and reduce code duplication in 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)
)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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 | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for identifying pure function call and function response contents is repeated multiple times within this block. To improve readability and maintainability, you can use helper functions for these checks. This will make the main logic of
_merge_interleaved_function_call_contentscleaner and easier to follow. I've added another comment suggesting the new helper functions.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1a8d84d