diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index a79effebb..c254bf613 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -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: diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index aa9b2a38a..c26557243 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -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