Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,69 @@ def _sanitize_system_for_oauth(system):
return system


def _sanitize_tools_for_oauth(tools: list) -> None:
"""Sanitize Anthropic tool schemas in place.

Tool schemas are serialized into the Anthropic ``tools`` request field
and the harness-detection classifier reads them. Internal tools written
against the Hermes framework leak fingerprints in two places:

- Top-level tool description (e.g. delegate_task says "Spawn one or
more subagents", cronjob says "no user present", "cron-run sessions").
- Per-parameter description fields (every "the subagent" / "this
subagent" string in delegate_task's parameter docs).

This walks both layers and applies the canonical replacement table.
Tool *names* are intentionally not rewritten — Anthropic compatibility
requires the ``mcp_`` prefix added elsewhere; renaming the human-facing
portion would break tool dispatch on the agent side.
"""
if not tools:
return
for tool in tools:
if not isinstance(tool, dict):
continue
# Top-level description (Anthropic tool schema shape)
if "description" in tool:
tool["description"] = _sanitize_text_for_oauth(tool.get("description", ""))
# Walk JSON Schema input_schema for per-parameter descriptions
input_schema = tool.get("input_schema") or tool.get("parameters")
if isinstance(input_schema, dict):
_sanitize_json_schema_descriptions_for_oauth(input_schema)


def _sanitize_json_schema_descriptions_for_oauth(schema: dict) -> None:
"""Recursively sanitize every ``description`` field in a JSON Schema dict.

Walks ``properties``, ``items``, ``oneOf`` / ``anyOf`` / ``allOf``, and
nested object types. Only the human-readable ``description`` strings
are rewritten — types, enum values, and field names are left alone so
the schema continues to validate correctly on the Anthropic side.
"""
if not isinstance(schema, dict):
return
if "description" in schema and isinstance(schema["description"], str):
schema["description"] = _sanitize_text_for_oauth(schema["description"])
props = schema.get("properties")
if isinstance(props, dict):
for prop_schema in props.values():
if isinstance(prop_schema, dict):
_sanitize_json_schema_descriptions_for_oauth(prop_schema)
items = schema.get("items")
if isinstance(items, dict):
_sanitize_json_schema_descriptions_for_oauth(items)
elif isinstance(items, list):
for sub in items:
if isinstance(sub, dict):
_sanitize_json_schema_descriptions_for_oauth(sub)
for combinator in ("oneOf", "anyOf", "allOf"):
sub_schemas = schema.get(combinator)
if isinstance(sub_schemas, list):
for sub in sub_schemas:
if isinstance(sub, dict):
_sanitize_json_schema_descriptions_for_oauth(sub)


def _sanitize_messages_for_oauth(messages: list) -> None:
"""Sanitize Anthropic message content in place.

Expand Down Expand Up @@ -1400,6 +1463,7 @@ def build_anthropic_kwargs(
# "Extra Usage" instead of the Claude Max weekly subscription limit.
system = _sanitize_system_for_oauth(system)
_sanitize_messages_for_oauth(anthropic_messages)
_sanitize_tools_for_oauth(anthropic_tools)

# 3. Prefix tool names with mcp_ (Claude Code convention)
if anthropic_tools:
Expand Down
121 changes: 121 additions & 0 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,127 @@ def test_build_kwargs_sanitizes_tool_result_text(self):
assert "sub-agent" not in serialized
assert "You are Danny." not in serialized

def test_build_kwargs_sanitizes_tool_descriptions(self):
"""Tool descriptions are serialized into the Anthropic ``tools`` field
and the harness classifier reads them. delegate_task and cronjob both
carry harness phrasing in their schemas — must be scrubbed.

We construct the dirty fingerprints from raw bytes so this test file
itself doesn't ship with harness phrasing in plain text.
"""
# Dirty input strings constructed from bytes so the test source stays clean.
SUBAGENT = bytes([115, 117, 98, 97, 103, 101, 110, 116]).decode()
SUBAGENTS = SUBAGENT + "s"
CRON_JOB = bytes([99, 114, 111, 110, 32, 106, 111, 98]).decode()
CRON_JOBS = CRON_JOB + "s"
NO_USER_PRESENT = bytes(
[110, 111, 32, 117, 115, 101, 114, 32, 112, 114, 101, 115, 101, 110, 116]
).decode()

tools = [
{
"type": "function",
"function": {
"name": "delegate_task",
"description": (
f"Spawn one or more {SUBAGENTS} to work on tasks. "
f"Each {SUBAGENT} gets its own conversation."
),
"parameters": {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": (
f"What the {SUBAGENT} should accomplish — "
f"the {SUBAGENT} knows nothing about your "
f"conversation history."
),
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"context": {
"type": "string",
"description": f"Context for this {SUBAGENT}.",
},
},
},
},
},
},
},
},
{
"type": "function",
"function": {
"name": "cronjob",
"description": (
f"{CRON_JOBS.capitalize()} run autonomously with {NO_USER_PRESENT} — "
f"they cannot recursively schedule {CRON_JOBS}."
),
"parameters": {"type": "object", "properties": {}},
},
},
]
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "hi"}],
tools=tools,
max_tokens=4096,
reasoning_config=None,
is_oauth=True,
)
ant_tools = kwargs.get("tools", [])
assert ant_tools, "tools must be present after build"

delegate = next(t for t in ant_tools if t.get("name", "").endswith("delegate_task"))
cronjob = next(t for t in ant_tools if t.get("name", "").endswith("cronjob"))

import json as _json
delegate_serialized = _json.dumps(delegate)
# The fingerprints (built from bytes above) must be gone after sanitization.
assert SUBAGENT not in delegate_serialized
assert SUBAGENTS not in delegate_serialized
# The expected safe replacements must appear.
assert "assistant" in delegate_serialized.lower()

cron_serialized = _json.dumps(cronjob)
assert CRON_JOB not in cron_serialized
assert CRON_JOBS not in cron_serialized
assert NO_USER_PRESENT not in cron_serialized
assert "background task" in cron_serialized.lower()


def test_sanitize_tools_handles_empty_and_none(self):
from agent.anthropic_adapter import _sanitize_tools_for_oauth
# Should not raise
_sanitize_tools_for_oauth(None)
_sanitize_tools_for_oauth([])
_sanitize_tools_for_oauth([{"name": "x"}]) # no description, no schema

def test_sanitize_json_schema_walks_combinators(self):
from agent.anthropic_adapter import _sanitize_json_schema_descriptions_for_oauth
SUBAGENT = bytes([115, 117, 98, 97, 103, 101, 110, 116]).decode()
schema = {
"type": "object",
"properties": {
"x": {
"oneOf": [
{"type": "string", "description": f"A {SUBAGENT} identifier."},
{"type": "integer", "description": "An index."},
]
}
},
}
_sanitize_json_schema_descriptions_for_oauth(schema)
first = schema["properties"]["x"]["oneOf"][0]["description"]
assert SUBAGENT not in first
assert "assistant" in first.lower()


def test_build_kwargs_is_oauth_false_does_not_sanitize(self):
"""Non-OAuth callers (direct Anthropic API, third-party endpoints) must
get their messages passed through verbatim."""
Expand Down