diff --git a/agent_core/providers/anthropic.py b/agent_core/providers/anthropic.py index 47e68de..2b8ac6e 100644 --- a/agent_core/providers/anthropic.py +++ b/agent_core/providers/anthropic.py @@ -102,6 +102,7 @@ def _build_kwargs( for m in msgs if (converted := _to_anthropic_msg(m)) is not None ] + pairs = _merge_tool_results(pairs) transient_tail = 0 for _, is_transient in reversed(pairs): if not is_transient: @@ -378,6 +379,39 @@ async def _prepare_request(self, request: httpx.Request) -> None: # ── Conversion helpers ─────────────────────────────────────────────────── +def _merge_tool_results( + pairs: list[tuple[dict[str, Any], bool]], +) -> list[tuple[dict[str, Any], bool]]: + """Fold consecutive tool-result-only user messages into one. + + Each OpenAI ``tool`` message converts to its own user message, so a turn + with parallel calls produces several in a row. Anthropic merges them, but + the documented shape is ONE user message carrying every ``tool_result``, + and translating gateways (llm-hub in front of a non-Claude model) reject + the split form: "An assistant message with 'tool_calls' must be followed + by tool messages responding to each 'tool_call_id'". + """ + def only_results(msg: dict[str, Any]) -> bool: + content = msg.get("content") + return ( + msg.get("role") == "user" + and isinstance(content, list) + and bool(content) + and all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content) + ) + + out: list[tuple[dict[str, Any], bool]] = [] + for msg, transient in pairs: + if out and not transient and not out[-1][1] and only_results(msg) and only_results( + out[-1][0] + ): + prev = out[-1][0] + out[-1] = ({**prev, "content": [*prev["content"], *msg["content"]]}, False) + else: + out.append((msg, transient)) + return out + + def _split_system(messages: list[Message]) -> tuple[str, list[Message]]: """Pull out the (single) leading system message; Anthropic takes it as a top-level kwarg, not as a message.""" diff --git a/changes/anthropic-merge-tool-results.fix.md b/changes/anthropic-merge-tool-results.fix.md new file mode 100644 index 0000000..b34710f --- /dev/null +++ b/changes/anthropic-merge-tool-results.fix.md @@ -0,0 +1 @@ +The Anthropic client now sends the tool results of one turn's parallel tool calls in a single user message, as Anthropic documents, instead of one user message per result. Translating gateways (llm-hub in front of a non-Claude model such as deepseek-flash) rejected the split form with a 400. diff --git a/tests/test_provider_native_clients.py b/tests/test_provider_native_clients.py index 515aa9d..6d1726d 100644 --- a/tests/test_provider_native_clients.py +++ b/tests/test_provider_native_clients.py @@ -1190,3 +1190,34 @@ def test_transient_flag_is_not_sent_on_openai_wire(): from agent_core.messages import for_wire assert for_wire([_transient("x")]) == [user_msg("x")] + + +def test_anthropic_parallel_tool_results_share_one_user_message(monkeypatch): + """Translating gateways reject one user message per tool_result.""" + monkeypatch.setenv("ANTHROPIC_PROMPT_CACHE", "0") + c = ac.AnthropicClient("claude-x", api_key="x") + calls = [{"id": i, "type": "function", "function": {"name": "bash", "arguments": "{}"}} + for i in ("a", "b")] + kwargs = c._build_kwargs( + [system_msg("s"), user_msg("q"), assistant_msg("", tool_calls=calls), + tool_msg("ra", "a"), tool_msg("rb", "b"), _transient("[env]")], + tools=None, temperature=None, max_tokens=None, extra_headers=None, timeout=None, + ) + msgs = kwargs["messages"] + assert [m["role"] for m in msgs] == ["user", "assistant", "user", "user"] + assert [b["tool_use_id"] for b in msgs[2]["content"]] == ["a", "b"] + assert msgs[3]["content"] == "[env]" + + +def test_anthropic_cache_breakpoint_after_merged_tool_results(monkeypatch): + monkeypatch.delenv("ANTHROPIC_PROMPT_CACHE", raising=False) + c = ac.AnthropicClient("claude-x", api_key="x") + calls = [{"id": i, "type": "function", "function": {"name": "bash", "arguments": "{}"}} + for i in ("a", "b")] + kwargs = c._build_kwargs( + [system_msg("s"), user_msg("q"), assistant_msg("", tool_calls=calls), + tool_msg("ra", "a"), tool_msg("rb", "b"), _transient("[env]")], + tools=None, temperature=None, max_tokens=None, extra_headers=None, timeout=None, + ) + assert _tail_breakpoints(kwargs) == [2] + assert "cache_control" in kwargs["messages"][2]["content"][-1]