Skip to content

Commit 1392ba6

Browse files
RichmondAlakeclaude
andcommitted
fix(anthropic): convert OpenAI tool messages to Anthropic content blocks
memorizz's core loop records tool calls/results in OpenAI's shape — an assistant message with `tool_calls` and a follow-up `{"role": "tool", ...}` result. The Anthropic provider passed these straight to the Messages API, which has no `tool` role, so the second call (carrying tool output back to Claude) 400'd: `Unexpected role "tool"`. Tool use was effectively broken on Anthropic. New `_convert_messages` (run in both generate and generate_stream after _split_system) rewrites them into Anthropic's format: - assistant `tool_calls` -> `tool_use` content blocks (arguments JSON-parsed to the `input` dict; optional leading text block kept) - `{"role": "tool", tool_call_id, content}` -> a `tool_result` block, with consecutive results (parallel tool calls) merged into one user message - plain text messages pass through unchanged Covered by tests/unit/test_anthropic_tool_messages.py (7 cases incl. round-trip, parallel-call merge, malformed-args fallback). Full unit gate: 301 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 370e129 commit 1392ba6

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

src/memorizz/llms/anthropic.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def generate(
192192
(``response.choices[0].message.tool_calls``).
193193
"""
194194
system_text, api_messages = self._split_system(messages)
195+
api_messages = self._convert_messages(api_messages)
195196

196197
kwargs: Dict[str, Any] = {
197198
"model": self.model,
@@ -234,6 +235,7 @@ def generate_stream(
234235
- ``{"type": "done", "content": "<accumulated text>"}``
235236
"""
236237
system_text, api_messages = self._split_system(messages)
238+
api_messages = self._convert_messages(api_messages)
237239

238240
kwargs: Dict[str, Any] = {
239241
"model": self.model,
@@ -387,6 +389,81 @@ def _split_system(messages: List[Dict[str, str]]):
387389
remaining.append(msg)
388390
return ("\n\n".join(system_parts) if system_parts else None, remaining)
389391

392+
@staticmethod
393+
def _convert_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
394+
"""Convert OpenAI-style tool messages to Anthropic's content-block format.
395+
396+
memorizz's core loop records tool calls/results in OpenAI's shape::
397+
398+
{"role": "assistant", "content": ..., "tool_calls": [
399+
{"id": ..., "function": {"name": ..., "arguments": "<json>"}}]}
400+
{"role": "tool", "tool_call_id": ..., "content": "..."}
401+
402+
Anthropic has no ``tool`` role: a tool call is a ``tool_use`` block in the
403+
assistant message, and its result is a ``tool_result`` block inside the
404+
*following user* message. Without this, the follow-up request that carries
405+
tool results back 400s with ``Unexpected role "tool"``.
406+
407+
Consecutive tool results (parallel tool calls) are merged into one user
408+
message, as Anthropic requires. Plain text messages pass through.
409+
"""
410+
converted: List[Dict[str, Any]] = []
411+
pending_results: List[Dict[str, Any]] = []
412+
413+
def flush_results():
414+
if pending_results:
415+
converted.append({"role": "user", "content": list(pending_results)})
416+
pending_results.clear()
417+
418+
for msg in messages:
419+
role = msg.get("role")
420+
421+
if role == "tool":
422+
content = msg.get("content")
423+
pending_results.append(
424+
{
425+
"type": "tool_result",
426+
"tool_use_id": msg.get("tool_call_id", ""),
427+
"content": "" if content is None else str(content),
428+
}
429+
)
430+
continue
431+
432+
# A non-tool message closes any open run of tool results.
433+
flush_results()
434+
435+
if role == "assistant" and msg.get("tool_calls"):
436+
blocks: List[Dict[str, Any]] = []
437+
text = msg.get("content")
438+
if text:
439+
blocks.append({"type": "text", "text": text})
440+
for tc in msg["tool_calls"]:
441+
fn = tc.get("function", {}) or {}
442+
raw_args = fn.get("arguments", "")
443+
if isinstance(raw_args, dict):
444+
parsed = raw_args
445+
else:
446+
try:
447+
parsed = json.loads(raw_args) if raw_args else {}
448+
except (json.JSONDecodeError, TypeError):
449+
parsed = {}
450+
if not isinstance(parsed, dict):
451+
parsed = {}
452+
blocks.append(
453+
{
454+
"type": "tool_use",
455+
"id": tc.get("id", ""),
456+
"name": fn.get("name", ""),
457+
"input": parsed,
458+
}
459+
)
460+
converted.append({"role": "assistant", "content": blocks})
461+
else:
462+
converted.append(msg)
463+
464+
flush_results()
465+
return converted
466+
390467
@staticmethod
391468
def _convert_tools(
392469
tools: List[Dict[str, Any]],
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Copyright (c) 2024 Richmond Alake. All rights reserved.
2+
# Licensed under the PolyForm Noncommercial License 1.0.0.
3+
# See LICENSE file in the project root for full license information.
4+
5+
"""Anthropic provider: OpenAI-style tool messages -> Anthropic content blocks.
6+
7+
Regression tests for the bug where a follow-up request carrying tool results
8+
back to Claude 400'd with ``Unexpected role "tool"`` — memorizz records tool
9+
calls/results in OpenAI's shape, which Anthropic doesn't accept. The provider
10+
now converts them via the (SDK-free) staticmethod ``_convert_messages``.
11+
"""
12+
13+
import pytest
14+
15+
# The anthropic SDK is imported lazily in __init__, so the module + this
16+
# staticmethod import/run without the SDK installed.
17+
from memorizz.llms.anthropic import Anthropic # noqa: E402
18+
19+
convert = Anthropic._convert_messages
20+
21+
22+
@pytest.mark.unit
23+
def test_tool_round_trip_converts_roles_and_blocks():
24+
msgs = [
25+
{"role": "user", "content": "search for X"},
26+
{
27+
"role": "assistant",
28+
"content": None,
29+
"tool_calls": [
30+
{
31+
"id": "call_1",
32+
"type": "function",
33+
"function": {"name": "search", "arguments": '{"q": "X"}'},
34+
}
35+
],
36+
},
37+
{"role": "tool", "tool_call_id": "call_1", "name": "search", "content": "hits"},
38+
]
39+
out = convert(msgs)
40+
41+
assert out[0] == {"role": "user", "content": "search for X"}
42+
assert out[1]["role"] == "assistant"
43+
assert out[1]["content"] == [
44+
{"type": "tool_use", "id": "call_1", "name": "search", "input": {"q": "X"}}
45+
]
46+
assert out[2]["role"] == "user"
47+
assert out[2]["content"] == [
48+
{"type": "tool_result", "tool_use_id": "call_1", "content": "hits"}
49+
]
50+
# The whole point: no "tool" role survives.
51+
assert all(m["role"] != "tool" for m in out)
52+
53+
54+
@pytest.mark.unit
55+
def test_assistant_text_and_tool_calls_emit_text_then_tool_use():
56+
msgs = [
57+
{
58+
"role": "assistant",
59+
"content": "let me check",
60+
"tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": "{}"}}],
61+
}
62+
]
63+
blocks = convert(msgs)[0]["content"]
64+
assert blocks[0] == {"type": "text", "text": "let me check"}
65+
assert blocks[1] == {"type": "tool_use", "id": "c1", "name": "f", "input": {}}
66+
67+
68+
@pytest.mark.unit
69+
def test_parallel_tool_results_merge_into_one_user_message():
70+
msgs = [
71+
{
72+
"role": "assistant",
73+
"content": None,
74+
"tool_calls": [
75+
{"id": "a", "function": {"name": "f", "arguments": "{}"}},
76+
{"id": "b", "function": {"name": "g", "arguments": "{}"}},
77+
],
78+
},
79+
{"role": "tool", "tool_call_id": "a", "content": "ra"},
80+
{"role": "tool", "tool_call_id": "b", "content": "rb"},
81+
]
82+
out = convert(msgs)
83+
assert len(out) == 2
84+
assert out[0]["role"] == "assistant" and len(out[0]["content"]) == 2
85+
assert out[1]["role"] == "user"
86+
assert [b["tool_use_id"] for b in out[1]["content"]] == ["a", "b"]
87+
assert [b["content"] for b in out[1]["content"]] == ["ra", "rb"]
88+
89+
90+
@pytest.mark.unit
91+
def test_malformed_arguments_fall_back_to_empty_input():
92+
msgs = [
93+
{
94+
"role": "assistant",
95+
"tool_calls": [
96+
{"id": "c1", "function": {"name": "f", "arguments": "not-json"}}
97+
],
98+
}
99+
]
100+
assert convert(msgs)[0]["content"][0]["input"] == {}
101+
102+
103+
@pytest.mark.unit
104+
def test_dict_arguments_passed_through():
105+
msgs = [
106+
{
107+
"role": "assistant",
108+
"tool_calls": [
109+
{"id": "c1", "function": {"name": "f", "arguments": {"q": "y"}}}
110+
],
111+
}
112+
]
113+
assert convert(msgs)[0]["content"][0]["input"] == {"q": "y"}
114+
115+
116+
@pytest.mark.unit
117+
def test_plain_messages_pass_through_unchanged():
118+
msgs = [
119+
{"role": "user", "content": "hi"},
120+
{"role": "assistant", "content": "hello"},
121+
]
122+
assert convert(msgs) == msgs
123+
124+
125+
@pytest.mark.unit
126+
def test_none_tool_content_becomes_empty_string():
127+
out = convert([{"role": "tool", "tool_call_id": "c1", "content": None}])
128+
assert out[0]["content"][0]["content"] == ""

0 commit comments

Comments
 (0)