mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
226 lines
8.5 KiB
Python
226 lines
8.5 KiB
Python
"""Tests for the shared MCP agent-tool refresh helper and discovery-wait bound.
|
|
|
|
``refresh_agent_mcp_tools`` is the single rebuild path used by the TUI
|
|
``reload.mcp`` RPC, the gateway reload, and the late-binding refresh thread —
|
|
so a slow MCP server that connects after the agent's one-time tool snapshot is
|
|
picked up everywhere identically. These assert the *contracts* those callers
|
|
rely on (name-based diff, in-place mutation, agent-scoped filtering) rather than
|
|
freezing any particular tool list.
|
|
"""
|
|
|
|
import threading
|
|
import types
|
|
|
|
from tools import mcp_tool
|
|
|
|
|
|
def _tool(name):
|
|
return {"type": "function", "function": {"name": name, "description": "", "parameters": {}}}
|
|
|
|
|
|
def _agent(tool_names, *, enabled=None, disabled=None):
|
|
a = types.SimpleNamespace()
|
|
a.tools = [_tool(n) for n in tool_names]
|
|
a.valid_tool_names = set(tool_names)
|
|
a.enabled_toolsets = enabled
|
|
a.disabled_toolsets = disabled
|
|
return a
|
|
|
|
|
|
def test_refresh_adds_late_landing_tools(monkeypatch):
|
|
"""A server that registers after build → its tools land in the snapshot."""
|
|
agent = _agent(["read_file", "terminal"])
|
|
|
|
new_defs = [_tool(n) for n in ("read_file", "terminal", "mcp_granola_get_account_info")]
|
|
monkeypatch.setattr(mcp_tool, "get_tool_definitions", lambda **kw: new_defs, raising=False)
|
|
# get_tool_definitions is imported inside the helper from model_tools, so patch there too.
|
|
import model_tools
|
|
monkeypatch.setattr(model_tools, "get_tool_definitions", lambda **kw: new_defs)
|
|
|
|
added = mcp_tool.refresh_agent_mcp_tools(agent)
|
|
|
|
assert added == {"mcp_granola_get_account_info"}
|
|
assert "mcp_granola_get_account_info" in agent.valid_tool_names
|
|
assert len(agent.tools) == 3
|
|
|
|
|
|
def test_refresh_preserves_memory_provider_and_context_engine_tools(monkeypatch):
|
|
"""B1 regression: a rebuild must NOT drop post-build-injected tools.
|
|
|
|
get_tool_definitions() returns only the registry-derived tools. agent_init
|
|
appends memory-provider tools (mem0/honcho/…) and context-engine tools
|
|
(lcm_*) directly onto agent.tools AFTER that. A naive
|
|
`agent.tools = get_tool_definitions()` would silently delete them on every
|
|
refresh. The helper must re-inject them.
|
|
"""
|
|
# Agent already carries: a built-in, a memory-provider tool, a context tool.
|
|
agent = _agent(["read_file", "memory_search", "lcm_grep"])
|
|
|
|
# Provider exposes its schemas; context compressor exposes lcm_*.
|
|
agent._memory_manager = types.SimpleNamespace(
|
|
get_all_tool_schemas=lambda: [
|
|
{"name": "memory_search", "description": "", "parameters": {}}
|
|
]
|
|
)
|
|
agent.context_compressor = types.SimpleNamespace(
|
|
get_tool_schemas=lambda: [
|
|
{"name": "lcm_grep", "description": "", "parameters": {}}
|
|
]
|
|
)
|
|
agent._context_engine_tool_names = {"lcm_grep"}
|
|
|
|
import model_tools
|
|
# The registry now ALSO has a newly-connected MCP tool, but does NOT contain
|
|
# the memory/context tools (they're never in get_tool_definitions output).
|
|
monkeypatch.setattr(
|
|
model_tools, "get_tool_definitions",
|
|
lambda **kw: [_tool("read_file"), _tool("mcp_new_server_tool")],
|
|
)
|
|
|
|
added = mcp_tool.refresh_agent_mcp_tools(agent)
|
|
|
|
# The new MCP tool landed AND the injected families survived.
|
|
assert "mcp_new_server_tool" in agent.valid_tool_names
|
|
assert "memory_search" in agent.valid_tool_names # not clobbered
|
|
assert "lcm_grep" in agent.valid_tool_names # not clobbered
|
|
assert added == {"mcp_new_server_tool"}
|
|
|
|
|
|
def test_refresh_does_not_reinject_disabled_memory_provider_tools(monkeypatch):
|
|
"""A refresh removes stale provider tools when memory becomes disabled."""
|
|
agent = _agent(
|
|
["read_file", "memory_search"],
|
|
enabled=["all"],
|
|
disabled=["memory"],
|
|
)
|
|
agent._memory_manager = types.SimpleNamespace(
|
|
get_all_tool_schemas=lambda: [
|
|
{"name": "memory_search", "description": "", "parameters": {}}
|
|
]
|
|
)
|
|
|
|
import model_tools
|
|
monkeypatch.setattr(
|
|
model_tools,
|
|
"get_tool_definitions",
|
|
lambda **kw: [_tool("read_file")],
|
|
)
|
|
|
|
mcp_tool.refresh_agent_mcp_tools(agent)
|
|
|
|
assert "memory_search" not in agent.valid_tool_names
|
|
assert all(t["function"]["name"] != "memory_search" for t in agent.tools)
|
|
|
|
|
|
def test_refresh_respects_context_engine_toolset_gate(monkeypatch):
|
|
"""#5544: context-engine tools must NOT be re-injected on a restricted
|
|
toolset. A platform with enabled_toolsets that excludes context_engine
|
|
must not get lcm_* leaked back in by a refresh."""
|
|
agent = _agent(["read_file"], enabled=["coding"]) # context_engine NOT enabled
|
|
agent.context_compressor = types.SimpleNamespace(
|
|
get_tool_schemas=lambda: [{"name": "lcm_grep", "description": "", "parameters": {}}]
|
|
)
|
|
agent._context_engine_tool_names = set()
|
|
|
|
import model_tools
|
|
monkeypatch.setattr(
|
|
model_tools, "get_tool_definitions",
|
|
lambda **kw: [_tool("read_file"), _tool("mcp_new_tool")],
|
|
)
|
|
|
|
mcp_tool.refresh_agent_mcp_tools(agent)
|
|
|
|
assert "mcp_new_tool" in agent.valid_tool_names # MCP tool still lands
|
|
assert "lcm_grep" not in agent.valid_tool_names # gated out (#5544)
|
|
|
|
|
|
def test_refreshed_tool_is_callable_through_valid_tool_names_guard(monkeypatch):
|
|
"""The whole point: a late tool, once refreshed, passes the name guard the
|
|
run loop uses to accept/reject tool calls (agent.valid_tool_names)."""
|
|
agent = _agent(["read_file"])
|
|
|
|
import model_tools
|
|
monkeypatch.setattr(
|
|
model_tools, "get_tool_definitions",
|
|
lambda **kw: [_tool("read_file"), _tool("mcp_granola_list_meetings")],
|
|
)
|
|
|
|
# Before refresh the run loop would reject the call ("Tool does not exist").
|
|
assert "mcp_granola_list_meetings" not in agent.valid_tool_names
|
|
|
|
mcp_tool.refresh_agent_mcp_tools(agent)
|
|
|
|
# After refresh the same guard accepts it AND it's in the tools= payload.
|
|
assert "mcp_granola_list_meetings" in agent.valid_tool_names
|
|
assert any(t["function"]["name"] == "mcp_granola_list_meetings" for t in agent.tools)
|
|
|
|
|
|
def test_refresh_is_thread_safe_under_concurrent_calls(monkeypatch):
|
|
"""Concurrent refreshes keep tools / valid_tool_names coherent.
|
|
|
|
The registry alternates between two DIFFERENT tool sets every call, so the
|
|
write path (publish) runs repeatedly rather than short-circuiting on the
|
|
no-change early return — this actually exercises the lock. The invariant:
|
|
a reader of ``valid_tool_names`` must always match ``agent.tools``, and the
|
|
final published pair must be one of the two valid sets (never a mix).
|
|
"""
|
|
agent = _agent(["a"])
|
|
|
|
import itertools
|
|
set_a = [_tool("a"), _tool("b")]
|
|
set_b = [_tool("a"), _tool("c")]
|
|
flip = itertools.cycle([set_a, set_b])
|
|
flip_lock = threading.Lock()
|
|
|
|
def _gtd(**kw):
|
|
with flip_lock:
|
|
return list(next(flip))
|
|
|
|
import model_tools
|
|
monkeypatch.setattr(model_tools, "get_tool_definitions", _gtd)
|
|
|
|
errors = []
|
|
|
|
def _worker():
|
|
try:
|
|
for _ in range(50):
|
|
mcp_tool.refresh_agent_mcp_tools(agent)
|
|
# Coherence invariant: the name set must match the tool list
|
|
# at every observation, never a torn cross-attribute state.
|
|
names = {t["function"]["name"] for t in agent.tools}
|
|
assert agent.valid_tool_names == names
|
|
assert names in ({"a", "b"}, {"a", "c"})
|
|
except Exception as exc: # pragma: no cover - failure path
|
|
errors.append(exc)
|
|
|
|
threads = [threading.Thread(target=_worker) for _ in range(4)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert not errors
|
|
assert agent.valid_tool_names in ({"a", "b"}, {"a", "c"})
|
|
|
|
|
|
# ── discovery-wait bound (mcp_discovery_timeout config) ──────────────────────
|
|
|
|
|
|
def test_resolve_discovery_timeout_explicit_wins(monkeypatch):
|
|
from hermes_cli import mcp_startup
|
|
|
|
assert mcp_startup._resolve_discovery_timeout(2.5) == 2.5
|
|
|
|
|
|
def test_wait_returns_instantly_when_no_discovery_thread(monkeypatch):
|
|
"""The common case (no MCP / discovery done) pays ~0s regardless of bound."""
|
|
import time
|
|
from hermes_cli import mcp_startup
|
|
|
|
monkeypatch.setattr(mcp_startup, "_mcp_discovery_thread", None)
|
|
import hermes_cli.config as cfg
|
|
monkeypatch.setattr(cfg, "load_config", lambda: {"mcp_discovery_timeout": 999.0})
|
|
|
|
t0 = time.time()
|
|
mcp_startup.wait_for_mcp_discovery()
|
|
assert time.time() - t0 < 0.2 # never blocks on the bound when nothing's pending
|