fix(anthropic): prepend user turn when compaction leaves a leading assistant message

Anthropic extracts the system prompt into a separate `system` field and
requires messages[0] to be role="user"; a leading assistant turn is rejected
with HTTP 400. After a second context compaction the only surviving leading
anchor is the system prompt, so a summary/handoff message emitted as
role="assistant" becomes the first messages entry once the system prompt is
extracted. Anthropic reports this with a misleading error —
`messages.N: tool_use ids were found without tool_result blocks immediately
after: toolu_...` — even when every tool_use/tool_result pair is adjacent and
matched; the real structural defect is the leading assistant role (#52160).

This is engine-agnostic: it fires for any producer of a leading-assistant
transcript (built-in compressor, the DAG/LCM context engine, session
truncation), unlike the compressor-scoped fix in #52167. The native Bedrock
Converse adapter already guards the same invariant
(convert_messages_to_converse); this mirrors it for the native Anthropic path.

Add _ensure_leading_user_turn() to the convert_messages_to_anthropic
post-processing chain, scoped to the system-extracted case (the production
trigger) so bare assistant-only unit fixtures are unaffected. Adds regression
tests (leading-assistant, leading-assistant-with-adjacent-tool_use, and a
no-op negative control).
This commit is contained in:
fesalfayed 2026-06-24 22:54:46 -04:00 committed by Teknium
parent 17fa5910be
commit e71c1137e8
2 changed files with 78 additions and 2 deletions

View file

@ -2412,6 +2412,12 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]], system: Any) -> None:
"""Anthropic requires messages[0] to be user when system is extracted."""
if system is not None and result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
@ -2470,6 +2476,7 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result, system)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)

View file

@ -1121,7 +1121,8 @@ class TestConvertMessages:
])
_, result = convert_messages_to_anthropic(messages)
assistant_blocks = result[0]["content"]
assistant_msg = next(m for m in result if m["role"] == "assistant")
assistant_blocks = assistant_msg["content"]
assert assistant_blocks[0]["type"] == "text"
assert assistant_blocks[0]["text"] == "Hello from assistant"
@ -1207,7 +1208,12 @@ class TestConvertMessages:
], native_anthropic=True)
_, result = convert_messages_to_anthropic(messages)
user_msg = [m for m in result if m["role"] == "user"][0]
user_msg = next(
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
)
tool_block = user_msg["content"][0]
assert tool_block["type"] == "tool_result"
@ -1356,6 +1362,69 @@ class TestConvertMessages:
assert isinstance(result[0]["content"], list)
assert result[0]["content"] == [{"type": "text", "text": "(empty message)"}]
def test_leading_assistant_after_compaction_gets_user_turn_prepended(self):
"""The adapter backstops compactors that emit a leading assistant summary."""
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "[Context compaction summary] earlier work…"},
{"role": "user", "content": "continue"},
]
system, result = convert_messages_to_anthropic(messages)
assert system == "You are helpful."
assert result[0]["role"] == "user"
assert result[0]["content"] == [{"type": "text", "text": " "}]
assert result[1]["role"] == "assistant"
assert any(
m["role"] == "assistant" and "Context compaction summary" in str(m["content"])
for m in result
)
def test_leading_user_message_is_not_modified(self):
"""A normal transcript that already starts with user must be untouched."""
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
_, result = convert_messages_to_anthropic(messages)
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[0]["content"] == "hello"
def test_leading_assistant_with_tool_use_after_compaction_is_repaired(self):
"""Repair the leading role without disturbing adjacent tool pairs."""
messages = [
{"role": "system", "content": "sys"},
{
"role": "assistant",
"content": "running it",
"tool_calls": [
{"id": "toolu_1", "function": {"name": "terminal", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "toolu_1", "content": "ok"},
{"role": "user", "content": "next"},
]
_, result = convert_messages_to_anthropic(messages)
assert result[0]["role"] == "user"
asst_idx = next(
i for i, m in enumerate(result)
if m["role"] == "assistant"
and any(b.get("type") == "tool_use" for b in m["content"] if isinstance(b, dict))
)
nxt = result[asst_idx + 1]
assert nxt["role"] == "user"
assert any(
isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") == "toolu_1"
for b in nxt["content"]
)
# ---------------------------------------------------------------------------
# Build kwargs