mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
HTTP 400 "messages.N.content.M.text.parsed_output: Extra inputs are not permitted" on the native Anthropic transport. Anthropic SDK 0.87.0 response blocks carry output-only attributes the Messages *input* schema forbids: text blocks get `parsed_output` and `citations=None`, tool_use blocks get `caller`. normalize_response captured blocks verbatim via _to_plain_data and replayed them as request input on the next turn, so the forbidden fields leaked back -> 400. Like the earlier thinking-block bug, one poisoned turn wedges every subsequent request in the session (even the diagnostic turn), recoverable only by switching models or deleting the session. This is a defect in the anthropic_content_blocks channel added for the interleaved-thinking fix: it preserved block ORDER correctly but copied every SDK attribute, including output-only ones. Fix — whitelist input-permitted fields per block type at all three leak points: - agent/transports/anthropic.py normalize_response: sanitize at CAPTURE so the poison never persists to state.db (defence-in-depth). - agent/anthropic_adapter.py _sanitize_replay_block (new): whitelist used on the ordered-blocks replay path; also recovers already-poisoned stored sessions. - agent/anthropic_adapter.py _convert_content_part_to_anthropic: a stored `text` part is rebuilt from whitelisted fields instead of dict(part) verbatim (this was the exact content.N.text.parsed_output failure locus). Whitelist not blacklist, so future SDK output-only fields can't reintroduce it. Block order and thinking-block signatures are preserved (the reason the channel exists). Adds tests/agent/test_anthropic_output_field_leak.py; full adapter suite green (163 tests). Existing poisoned state.db rows scrubbed out-of-band.
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""Regression: output-only SDK fields must not leak into Anthropic request input.
|
|
|
|
Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are
|
|
not permitted`. Anthropic SDK response blocks carry output-only attributes
|
|
(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)
|
|
that the Messages *input* schema forbids. normalize_response captured blocks
|
|
verbatim via _to_plain_data and replayed them as input → 400.
|
|
|
|
Fix: whitelist input-permitted fields per block type at three points —
|
|
normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and
|
|
_convert_content_part_to_anthropic (content-list replay).
|
|
"""
|
|
import sys, os
|
|
sys.path.insert(0, os.path.expanduser("~/.hermes/hermes-agent"))
|
|
|
|
import pytest
|
|
from agent.anthropic_adapter import (
|
|
_sanitize_replay_block,
|
|
_convert_content_part_to_anthropic,
|
|
_convert_assistant_message,
|
|
)
|
|
|
|
FORBIDDEN = {"parsed_output", "caller"}
|
|
|
|
|
|
def _assert_clean(block):
|
|
"""No forbidden output-only key, and no null citations, anywhere."""
|
|
assert isinstance(block, dict)
|
|
for k in FORBIDDEN:
|
|
assert k not in block, f"forbidden field {k!r} survived: {block}"
|
|
if "citations" in block:
|
|
assert isinstance(block["citations"], list) and block["citations"], \
|
|
"citations must be a non-empty list if present (None/[] is input-invalid)"
|
|
|
|
|
|
class TestSanitizeReplayBlock:
|
|
def test_text_block_strips_parsed_output_and_null_citations(self):
|
|
poisoned = {"type": "text", "text": "hi", "parsed_output": None, "citations": None}
|
|
out = _sanitize_replay_block(poisoned)
|
|
_assert_clean(out)
|
|
assert out == {"type": "text", "text": "hi"}
|
|
|
|
def test_tool_use_strips_caller(self):
|
|
poisoned = {"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
|
"input": {"path": "a"}, "caller": {"type": "agent"}}
|
|
out = _sanitize_replay_block(poisoned)
|
|
_assert_clean(out)
|
|
assert out["name"] == "read_file" and out["input"] == {"path": "a"}
|
|
|
|
def test_thinking_preserves_signature(self):
|
|
b = {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
|
|
out = _sanitize_replay_block(b)
|
|
assert out == {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
|
|
|
|
def test_text_keeps_real_citations(self):
|
|
real = [{"type": "char_location", "cited_text": "q"}]
|
|
out = _sanitize_replay_block({"type": "text", "text": "t", "citations": real})
|
|
assert out["citations"] == real
|
|
|
|
def test_unknown_type_dropped(self):
|
|
assert _sanitize_replay_block({"type": "server_tool_use", "foo": 1}) is None
|
|
|
|
|
|
class TestContentPartConversion:
|
|
def test_stored_text_block_with_parsed_output_cleaned(self):
|
|
# The exact content.N.text.parsed_output failure shape.
|
|
part = {"type": "text", "text": "hello", "parsed_output": None, "citations": None}
|
|
out = _convert_content_part_to_anthropic(part)
|
|
_assert_clean(out)
|
|
|
|
|
|
class TestAssistantReplay:
|
|
def test_interleaved_blocks_replayed_clean_and_ordered(self):
|
|
m = {
|
|
"role": "assistant",
|
|
"anthropic_content_blocks": [
|
|
{"type": "thinking", "thinking": "plan", "signature": "s1"},
|
|
{"type": "text", "text": "doing it", "parsed_output": None, "citations": None},
|
|
{"type": "tool_use", "id": "toolu_1", "name": "read_file",
|
|
"input": {"path": "a"}, "caller": {"type": "agent"}},
|
|
],
|
|
}
|
|
out = _convert_assistant_message(m)
|
|
blocks = out["content"]
|
|
# order preserved
|
|
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
|
|
# every block clean
|
|
for b in blocks:
|
|
_assert_clean(b)
|
|
# signature + tool fields intact
|
|
assert blocks[0]["signature"] == "s1"
|
|
assert blocks[2]["name"] == "read_file"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(pytest.main([__file__, "-v"]))
|