mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(anthropic): extend leading-user guard to the extracted-system-absent path
The salvaged guard from #52276 only fired when a system prompt was present in messages[] (system is not None after extraction). The live repro of #52160 is the auto path: the system prompt is passed outside messages[], so after the second compaction messages[0] is the assistant-role summary with system=None — and the guard never ran. Make _ensure_leading_user_turn unconditional, exactly mirroring the Bedrock Converse adapter ('Converse requires the first message to be from the user' — convert_messages_to_converse). Add a regression test building the exact post-double-compaction shape (no system in messages, messages[0]=assistant summary) asserting the converted payload leads with a user turn, and update existing fixtures that started with a bare assistant message to locate roles instead of indexing result[0].
This commit is contained in:
parent
e71c1137e8
commit
b6a2b701c1
2 changed files with 67 additions and 14 deletions
|
|
@ -2412,9 +2412,21 @@ 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":
|
||||
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
"""Anthropic requires messages[0] to have role=user.
|
||||
|
||||
After a second context compaction on the auto path the summary can be
|
||||
emitted as role=assistant with nothing in front of it (the system prompt
|
||||
lives outside messages[] or is extracted into the separate ``system``
|
||||
param), so messages[0] ends up assistant and the Messages API rejects
|
||||
the request with HTTP 400 — often masked by a misleading
|
||||
"tool_use ids were found without tool_result blocks" error (#52160).
|
||||
|
||||
Mirror the Bedrock Converse adapter, which unconditionally prepends a
|
||||
minimal user turn when the first message is not user
|
||||
(convert_messages_to_converse).
|
||||
"""
|
||||
if result and result[0].get("role") != "user":
|
||||
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
|
||||
|
||||
|
||||
|
|
@ -2476,7 +2488,7 @@ def convert_messages_to_anthropic(
|
|||
|
||||
_strip_orphaned_tool_blocks(result)
|
||||
result = _merge_consecutive_roles(result)
|
||||
_ensure_leading_user_turn(result, system)
|
||||
_ensure_leading_user_turn(result)
|
||||
_manage_thinking_signatures(result, base_url, model)
|
||||
_evict_old_screenshots(result)
|
||||
|
||||
|
|
|
|||
|
|
@ -945,7 +945,7 @@ class TestConvertMessages:
|
|||
{"role": "tool", "tool_call_id": "tc_1", "content": "search results"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
blocks = result[0]["content"]
|
||||
blocks = next(m for m in result if m["role"] == "assistant")["content"]
|
||||
assert blocks[0] == {"type": "text", "text": "Let me search."}
|
||||
assert blocks[1]["type"] == "tool_use"
|
||||
assert blocks[1]["id"] == "tc_1"
|
||||
|
|
@ -963,8 +963,13 @@ class TestConvertMessages:
|
|||
{"role": "tool", "tool_call_id": "tc_1", "content": "result data"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
# tool result is in the second message (user role)
|
||||
user_msg = [m for m in result if m["role"] == "user"][0]
|
||||
# tool result is in the user message following the assistant turn
|
||||
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"])
|
||||
)
|
||||
assert user_msg["content"][0]["type"] == "tool_result"
|
||||
assert user_msg["content"][0]["tool_use_id"] == "tc_1"
|
||||
|
||||
|
|
@ -983,7 +988,12 @@ class TestConvertMessages:
|
|||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
# assistant + merged user (with 2 tool_results)
|
||||
user_msgs = [m for m in result if m["role"] == "user"]
|
||||
user_msgs = [
|
||||
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"])
|
||||
]
|
||||
assert len(user_msgs) == 1
|
||||
assert len(user_msgs[0]["content"]) == 2
|
||||
|
||||
|
|
@ -1041,7 +1051,12 @@ class TestConvertMessages:
|
|||
{"role": "tool", "tool_call_id": "tc_orphan", "content": "stale result"},
|
||||
]
|
||||
_, 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_results = [
|
||||
b for b in user_msg["content"] if b.get("type") == "tool_result"
|
||||
]
|
||||
|
|
@ -1096,7 +1111,12 @@ class TestConvertMessages:
|
|||
_, result = convert_messages_to_anthropic(messages)
|
||||
asst = [m for m in result if m["role"] == "assistant"][0]
|
||||
assert any(b.get("type") == "tool_use" for b in asst["content"])
|
||||
user = [m for m in result if m["role"] == "user"][0]
|
||||
user = 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"])
|
||||
)
|
||||
assert any(b.get("type") == "tool_result" for b in user["content"])
|
||||
|
||||
def test_system_with_cache_control(self):
|
||||
|
|
@ -1381,6 +1401,27 @@ class TestConvertMessages:
|
|||
for m in result
|
||||
)
|
||||
|
||||
def test_double_compaction_no_system_in_messages_leads_with_user(self):
|
||||
"""Exact post-double-compaction shape on the auto path (#52160).
|
||||
|
||||
On the auto path the system prompt is NOT inside messages[] and after
|
||||
the second compaction protect_head has decayed to 0, so the
|
||||
assistant-role summary is messages[0]. The converted payload must
|
||||
still lead with a user turn or Anthropic 400s.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "assistant", "content": "[Context compaction summary] earlier work…"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
|
||||
assert system is None
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == [{"type": "text", "text": " "}]
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert "Context compaction summary" in str(result[1]["content"])
|
||||
|
||||
def test_leading_user_message_is_not_modified(self):
|
||||
"""A normal transcript that already starts with user must be untouched."""
|
||||
messages = [
|
||||
|
|
@ -2145,7 +2186,7 @@ class TestThinkingBlockSignatureManagement:
|
|||
},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
blocks = result[0]["content"]
|
||||
blocks = next(m for m in result if m["role"] == "assistant")["content"]
|
||||
thinking = [b for b in blocks if b.get("type") == "thinking"]
|
||||
assert len(thinking) == 1
|
||||
assert thinking[0]["signature"] == "sig_valid"
|
||||
|
|
@ -2163,7 +2204,7 @@ class TestThinkingBlockSignatureManagement:
|
|||
},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
blocks = result[0]["content"]
|
||||
blocks = next(m for m in result if m["role"] == "assistant")["content"]
|
||||
|
||||
# No thinking blocks should remain
|
||||
assert not any(b.get("type") == "thinking" for b in blocks)
|
||||
|
|
@ -2183,7 +2224,7 @@ class TestThinkingBlockSignatureManagement:
|
|||
},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
blocks = result[0]["content"]
|
||||
blocks = next(m for m in result if m["role"] == "assistant")["content"]
|
||||
redacted = [b for b in blocks if b.get("type") == "redacted_thinking"]
|
||||
assert len(redacted) == 1
|
||||
assert redacted[0]["data"] == "opaque_signature_data"
|
||||
|
|
@ -2277,7 +2318,7 @@ class TestThinkingBlockSignatureManagement:
|
|||
_, result = convert_messages_to_anthropic(messages)
|
||||
# First assistant is non-last, so thinking is stripped completely.
|
||||
# The original content was empty and thinking was unsigned → placeholder
|
||||
first_assistant = result[0]
|
||||
first_assistant = next(m for m in result if m["role"] == "assistant")
|
||||
assert first_assistant["role"] == "assistant"
|
||||
assert len(first_assistant["content"]) >= 1
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue