mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""Tests for GH-25255: Anthropic OAuth mcp_ prefix stripping.
|
|
|
|
When strip_tool_prefix=True (Anthropic OAuth path), the transport must only
|
|
strip the ``mcp_`` prefix from OAuth-injected tools, NOT from Hermes-native
|
|
MCP server tools that are registered under their full ``mcp_<server>_<tool>``
|
|
name in the tool registry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_tool_use_block(name: str, block_id: str = "tc_1", input_data: dict | None = None):
|
|
"""Create a fake Anthropic tool_use content block."""
|
|
return SimpleNamespace(
|
|
type="tool_use",
|
|
id=block_id,
|
|
name=name,
|
|
input=input_data or {"query": "test"},
|
|
)
|
|
|
|
|
|
def _make_response(*blocks, stop_reason="end_turn"):
|
|
"""Create a fake Anthropic Messages response."""
|
|
return SimpleNamespace(
|
|
content=list(blocks),
|
|
stop_reason=stop_reason,
|
|
model="claude-sonnet-4",
|
|
usage=SimpleNamespace(input_tokens=100, output_tokens=50),
|
|
)
|
|
|
|
|
|
class _FakeRegistry:
|
|
"""Minimal fake tool registry for testing prefix stripping logic."""
|
|
|
|
def __init__(self, registered_names: set[str]):
|
|
self._names = registered_names
|
|
|
|
def get_entry(self, name: str):
|
|
if name in self._names:
|
|
return SimpleNamespace(name=name) # truthy = tool exists
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAnthropicMcpPrefixStrip:
|
|
"""Verify that strip_tool_prefix only strips OAuth-injected prefixes."""
|
|
|
|
def _get_transport(self):
|
|
from agent.transports.anthropic import AnthropicTransport
|
|
return AnthropicTransport()
|
|
|
|
def test_strips_prefix_for_oauth_injected_tool(self):
|
|
"""OAuth tools: mcp_read_file -> read_file (stripped).
|
|
|
|
The tool was registered as 'read_file' in the registry.
|
|
Anthropic sees 'mcp_read_file' because Hermes adds the prefix.
|
|
On response, we must strip it back to 'read_file'.
|
|
"""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("mcp_read_file")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({"read_file", "terminal", "web_search"})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].name == "read_file"
|
|
|
|
def test_preserves_native_mcp_server_tool_name(self):
|
|
"""Native MCP tools: mcp_composio_SEARCH -> mcp_composio_SEARCH (kept).
|
|
|
|
The tool is registered with the full mcp_ prefix in the registry.
|
|
Stripping would break registry lookup.
|
|
"""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("mcp_composio_COMPOSIO_SEARCH_TOOLS")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({
|
|
"mcp_composio_COMPOSIO_SEARCH_TOOLS",
|
|
"mcp_composio_COMPOSIO_GET_TOOL_SCHEMAS",
|
|
"read_file",
|
|
})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].name == "mcp_composio_COMPOSIO_SEARCH_TOOLS"
|
|
|
|
def test_no_strip_when_flag_false(self):
|
|
"""When strip_tool_prefix=False, names are never modified."""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("mcp_read_file")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({"read_file"})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=False)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].name == "mcp_read_file"
|
|
|
|
def test_no_strip_when_not_mcp_prefixed(self):
|
|
"""Non-mcp_ names are untouched regardless of strip flag."""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("web_search")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({"web_search"})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].name == "web_search"
|
|
|
|
def test_preserves_name_when_neither_in_registry(self):
|
|
"""When neither stripped nor full name is in registry, keep full name.
|
|
|
|
Safety fallback: if we can't determine the type, prefer the full name
|
|
since it's what the LLM was told about.
|
|
"""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("mcp_unknown_tool")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({"read_file"}) # neither name registered
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
assert result.tool_calls[0].name == "mcp_unknown_tool"
|
|
|
|
def test_mixed_tools_same_response(self):
|
|
"""Both OAuth and native MCP tools in the same response."""
|
|
transport = self._get_transport()
|
|
block1 = _make_tool_use_block("mcp_read_file", block_id="tc_1")
|
|
block2 = _make_tool_use_block("mcp_composio_SEARCH", block_id="tc_2")
|
|
block3 = _make_tool_use_block("mcp_composio_SEARCH", block_id="tc_3") # also registered natively
|
|
response = _make_response(block1, block2, block3)
|
|
|
|
registry = _FakeRegistry({
|
|
"read_file", # OAuth-injected
|
|
"mcp_composio_SEARCH", # native MCP
|
|
})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 3
|
|
# OAuth tool: stripped
|
|
assert result.tool_calls[0].name == "read_file"
|
|
# Native MCP: preserved (both stripped and full are registered, full wins)
|
|
assert result.tool_calls[1].name == "mcp_composio_SEARCH"
|
|
assert result.tool_calls[2].name == "mcp_composio_SEARCH"
|
|
|
|
def test_both_stripped_and_full_registered_prefers_full(self):
|
|
"""Edge case: both 'foo' and 'mcp_foo' exist in registry.
|
|
|
|
Keep 'mcp_foo' (the original name) since it's what the LLM requested.
|
|
"""
|
|
transport = self._get_transport()
|
|
block = _make_tool_use_block("mcp_foo")
|
|
response = _make_response(block)
|
|
|
|
registry = _FakeRegistry({"foo", "mcp_foo"})
|
|
with patch("tools.registry.registry", registry):
|
|
result = transport.normalize_response(response, strip_tool_prefix=True)
|
|
|
|
assert len(result.tool_calls) == 1
|
|
# Both exist — the condition `get_entry(stripped) and not get_entry(name)`
|
|
# is False because get_entry(name) IS truthy, so we keep the full name.
|
|
assert result.tool_calls[0].name == "mcp_foo"
|
|
|
|
|
|
class TestAnthropicOAuthOutgoingPrefix:
|
|
"""Verify the outgoing-side companion fix: build_anthropic_kwargs must not
|
|
double-prefix tool names that already start with ``mcp_`` (native MCP server
|
|
tools registered as ``mcp_<server>_<tool>``). GH-25255."""
|
|
|
|
def _build(self, tools, is_oauth=True):
|
|
from agent.anthropic_adapter import build_anthropic_kwargs
|
|
return build_anthropic_kwargs(
|
|
model="claude-sonnet-4-6",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
tools=tools,
|
|
max_tokens=4096,
|
|
reasoning_config=None,
|
|
is_oauth=is_oauth,
|
|
)
|
|
|
|
def test_oauth_adds_prefix_to_bare_tool_name(self):
|
|
"""OAuth + bare name → prefix added (existing Claude Code convention)."""
|
|
kwargs = self._build([{
|
|
"type": "function",
|
|
"function": {"name": "read_file", "description": "x", "parameters": {}},
|
|
}])
|
|
names = [t["name"] for t in kwargs["tools"]]
|
|
assert names == ["mcp_read_file"]
|
|
|
|
def test_oauth_does_not_double_prefix_native_mcp_tool(self):
|
|
"""OAuth + already-prefixed native MCP name → left alone."""
|
|
kwargs = self._build([{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "mcp_composio_COMPOSIO_SEARCH_TOOLS",
|
|
"description": "x",
|
|
"parameters": {},
|
|
},
|
|
}])
|
|
names = [t["name"] for t in kwargs["tools"]]
|
|
# Must NOT become "mcp_mcp_composio_..." — that breaks the round-trip
|
|
# because normalize_response only strips ONE mcp_ prefix.
|
|
assert names == ["mcp_composio_COMPOSIO_SEARCH_TOOLS"]
|
|
|
|
def test_oauth_mixed_native_and_bare_tools(self):
|
|
"""Mixed: native MCP preserved, bare names prefixed."""
|
|
kwargs = self._build([
|
|
{"type": "function", "function": {"name": "read_file",
|
|
"description": "x", "parameters": {}}},
|
|
{"type": "function", "function": {"name": "mcp_composio_SEARCH",
|
|
"description": "y", "parameters": {}}},
|
|
{"type": "function", "function": {"name": "terminal",
|
|
"description": "z", "parameters": {}}},
|
|
])
|
|
names = sorted(t["name"] for t in kwargs["tools"])
|
|
assert names == ["mcp_composio_SEARCH", "mcp_read_file", "mcp_terminal"]
|
|
|
|
def test_non_oauth_path_untouched(self):
|
|
"""Non-OAuth requests never get the prefix — schemas pass through as-is."""
|
|
kwargs = self._build([
|
|
{"type": "function", "function": {"name": "read_file",
|
|
"description": "x", "parameters": {}}},
|
|
{"type": "function", "function": {"name": "mcp_composio_SEARCH",
|
|
"description": "y", "parameters": {}}},
|
|
], is_oauth=False)
|
|
names = sorted(t["name"] for t in kwargs["tools"])
|
|
assert names == ["mcp_composio_SEARCH", "read_file"]
|