Merge consecutive same-role contents for native Gemini

_build_gemini_contents emitted one contents entry per source message and
never merged adjacent same-role entries. Gemini's generateContent requires
strict user/model alternation and rejects consecutive same-role turns with
HTTP 400 ("Please ensure that multiturn requests alternate between user and
model"). A parallel tool call turns into two tool results in a row, which
become two consecutive user functionResponse contents, so every multi-tool
turn produced an unsendable history.

Fold adjacent same-role contents into one by concatenating their parts after
the per-message loop, matching the Anthropic and Bedrock converters. For a
parallel call this yields the grouped multi-functionResponse user turn Gemini
expects.
This commit is contained in:
Max Freedom Pollard 2026-06-29 15:46:47 -04:00 committed by Teknium
parent 885e80df74
commit 936af2f4f5
2 changed files with 69 additions and 0 deletions

View file

@ -337,6 +337,22 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
if parts:
contents.append({"role": gemini_role, "parts": parts})
# Gemini's generateContent requires strict user/model alternation;
# consecutive same-role contents are rejected with HTTP 400 "Please ensure
# that multiturn requests alternate between user and model". The loop above
# emits one content per source message, so parallel tool calls (N tool
# results become N user functionResponse contents), back-to-back user turns,
# or merged assistant turns would each violate that. Merge adjacent
# same-role contents by concatenating their parts. For parallel calls this
# also produces the grouped multi-functionResponse turn Gemini expects.
merged_contents: List[Dict[str, Any]] = []
for content in contents:
if merged_contents and merged_contents[-1]["role"] == content["role"]:
merged_contents[-1]["parts"].extend(content["parts"])
else:
merged_contents.append(content)
contents = merged_contents
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:

View file

@ -85,6 +85,59 @@ def test_build_native_request_uses_original_function_name_for_tool_result():
assert tool_response["name"] == "get_weather"
def test_parallel_tool_results_merge_into_one_user_content():
"""Gemini requires strict user/model alternation; two consecutive `user`
contents are rejected with HTTP 400. Parallel tool calls produce two tool
results in a row, so their functionResponses must be grouped into a single
user content instead of two consecutive ones."""
from agent.gemini_native_adapter import _build_gemini_contents
messages = [
{"role": "user", "content": "Read a.txt and b.txt"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "read_file", "arguments": '{"path": "a.txt"}'}},
{"id": "call_2", "type": "function",
"function": {"name": "read_file", "arguments": '{"path": "b.txt"}'}},
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "AAA"},
{"role": "tool", "tool_call_id": "call_2", "content": "BBB"},
]
contents, _ = _build_gemini_contents(messages)
roles = [c["role"] for c in contents]
# No two adjacent contents may share a role.
assert all(roles[i] != roles[i - 1] for i in range(1, len(roles))), roles
assert roles == ["user", "model", "user"]
# Both parallel functionResponses land in the single trailing user content.
response_parts = [
p for p in contents[2]["parts"] if "functionResponse" in p
]
outputs = [p["functionResponse"]["response"]["output"] for p in response_parts]
assert outputs == ["AAA", "BBB"]
def test_consecutive_user_messages_merge_for_gemini_alternation():
"""Back-to-back user messages must also be merged, not sent as two
consecutive user contents."""
from agent.gemini_native_adapter import _build_gemini_contents
messages = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "ok"},
]
contents, _ = _build_gemini_contents(messages)
roles = [c["role"] for c in contents]
assert roles == ["user", "model"], roles
def test_build_native_request_strips_json_schema_only_fields_from_tool_parameters():
from agent.gemini_native_adapter import build_gemini_request