Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions agent_core/providers/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions changes/anthropic-merge-tool-results.fix.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions tests/test_provider_native_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading