Rework fix for parallel function calling with Gemini 3

This commit is contained in:
Paul Kompfner
2026-01-13 16:33:59 -05:00
parent 6668712f7b
commit 30fbcfbf71

View File

@@ -255,8 +255,8 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
# Apply thought signatures to the corresponding messages # Apply thought signatures to the corresponding messages
self._apply_thought_signatures_to_messages(thought_signature_dicts, messages) self._apply_thought_signatures_to_messages(thought_signature_dicts, messages)
# Merge consecutive tool calls and tool responses into single multi-part messages # When thinking is enabled, merge parallel tool calls into single messages
messages = self._merge_consecutive_tool_messages(messages) messages = self._merge_parallel_tool_calls_for_thinking(messages)
# Check if we only have function-related messages (no regular text) # Check if we only have function-related messages (no regular text)
has_regular_messages = any( has_regular_messages = any(
@@ -436,23 +436,35 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
tool_call_id_to_name_mapping=tool_call_id_to_name_mapping, tool_call_id_to_name_mapping=tool_call_id_to_name_mapping,
) )
def _merge_consecutive_tool_messages(self, messages: List[Content]) -> List[Content]: def _merge_parallel_tool_calls_for_thinking(self, messages: List[Content]) -> List[Content]:
"""Merge consecutive tool call messages within tool exchange blocks. """Merge parallel tool calls into single Content objects when thinking is enabled.
Gemini (and Gemini 3 in particular, where thought signatures are Gemini expects parallel tool calls (multiple function calls made
involved) expects multiple parallel tool calls to be in a single Content simultaneously) to be in a single Content with multiple function_call
with multiple function_call parts. Parts. This method takes a list of Content messages, where parallel
tool calls may be split across multiple messages, and merges them into
single messages.
This method detects "tool exchange blocks" (sequences of tool calls and This only has an effect when thought_signatures are present (i.e., when
responses, including alternating patterns like call1, response1, call2, thinking is enabled). When thinking is disabled, merging doesn't matter.
response2) and merges all tool calls within each block into a single When thinking is enabled, there is a guarantee that the first tool call
Content, followed by the individual tool responses. (and only the first) in any batch of parallel tool calls will have a
thought_signature. This allows us to distinguish:
- Parallel tool calls: share a single thought_signature (on the first call)
- Sequential tool calls: each have their own thought_signature
Algorithm: A tool call message with a thought_signature starts a new
parallel group. Any tool call messages after it without a
thought_signature get merged into that group, regardless of what
messages appear in between.
Args: Args:
messages: List of Content messages to process. messages: List of Content messages to process.
Returns: Returns:
List of Content messages with tool calls merged within each block. List of Content messages with parallel tool calls merged when
thought_signatures are present, otherwise unchanged.
""" """
if not messages: if not messages:
return messages return messages
@@ -465,17 +477,9 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
and all(getattr(part, "function_call", None) for part in msg.parts) and all(getattr(part, "function_call", None) for part in msg.parts)
) )
def is_tool_response_message(msg: Content) -> bool: def message_has_thought_signature(msg: Content) -> bool:
"""Check if message contains only function_response parts.""" """Check if any part in the message has a thought_signature."""
return ( return any(getattr(part, "thought_signature", None) for part in msg.parts)
msg.role == "user"
and msg.parts
and all(getattr(part, "function_response", None) for part in msg.parts)
)
def is_tool_message(msg: Content) -> bool:
"""Check if message is either a tool call or tool response."""
return is_tool_call_message(msg) or is_tool_response_message(msg)
merged_messages = [] merged_messages = []
i = 0 i = 0
@@ -483,26 +487,31 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
while i < len(messages): while i < len(messages):
current = messages[i] current = messages[i]
# Check for a tool exchange block (sequence of tool calls and/or responses) # If this is a tool call message with a thought signature, start merging
if is_tool_message(current): if is_tool_call_message(current) and message_has_thought_signature(current):
tool_call_parts = [] merged_parts = list(current.parts)
tool_response_messages = [] other_messages = []
j = i + 1
# Collect all consecutive tool messages (calls and responses) # Scan forward, merging tool calls without signatures, collecting others
j = i while j < len(messages):
while j < len(messages) and is_tool_message(messages[j]): next_msg = messages[j]
msg = messages[j] if is_tool_call_message(next_msg):
if is_tool_call_message(msg): if message_has_thought_signature(next_msg):
tool_call_parts.extend(msg.parts) # New parallel group starts, stop here
else: # is_tool_response_message break
tool_response_messages.append(msg) else:
j += 1 # Merge this call into the current group
merged_parts.extend(next_msg.parts)
# Output merged tool calls first, then individual tool responses j += 1
if tool_call_parts: else:
merged_messages.append(Content(role="model", parts=tool_call_parts)) # Collect non-tool-call message, keep scanning
merged_messages.extend(tool_response_messages) other_messages.append(next_msg)
j += 1
# Output merged calls, then collected other messages
merged_messages.append(Content(role="model", parts=merged_parts))
merged_messages.extend(other_messages)
i = j i = j
else: else:
merged_messages.append(current) merged_messages.append(current)