Hermes-agent
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""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"]))
|
||||
Reference in New Issue
Block a user