Skip to content

fix(agent): avoid fuzzy repair for MCP tool names - #62701

Open
TurgutKural wants to merge 1 commit into
NousResearch:mainfrom
TurgutKural:fix/mcp-tool-name-repair-scope
Open

fix(agent): avoid fuzzy repair for MCP tool names#62701
TurgutKural wants to merge 1 commit into
NousResearch:mainfrom
TurgutKural:fix/mcp-tool-name-repair-scope

Conversation

@TurgutKural

Copy link
Copy Markdown
Contributor

Summary

  • keep exact/normalization/suffix tool-name repairs intact
  • disable the final fuzzy-name fallback for unmatched MCP tool names
  • add regression coverage for hallucinated MCP tools such as mcp__ghidra_mcp__disassemble_function

Why

MCP tool names encode both the server and the operation. The generic fuzzy fallback can silently turn a nonexistent MCP tool into a different valid operation inside the same namespace. For example, a model may hallucinate mcp__ghidra_mcp__disassemble_function; because Ghidra MCP exposes decompile_function but not disassemble_function, fuzzy repair can rewrite the call to mcp__ghidra_mcp__decompile_function instead of letting the normal unknown-tool correction path run.

That is a semantic substitution, not a safe spelling repair. It is especially risky for analysis tools where disassembly and decompilation are different evidence surfaces.

Validation

  • python3 -m py_compile agent/agent_runtime_helpers.py tests/run_agent/test_repair_tool_call_name.py
  • python3 -m pytest tests/run_agent/test_repair_tool_call_name.py -q → 32 passed
  • git diff --check

Notes

Exact MCP matches and deterministic repairs still work:

  • case normalization remains allowed
  • trailing _tool suffix stripping remains allowed when it resolves to an exact registered MCP tool
  • only the final fuzzy fallback is skipped for unmatched mcp__... names

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/mcp MCP client and OAuth P3 Low — cosmetic, nice to have labels Jul 11, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused regression fix. Current main still performs the unconditional fuzzy fallback in agent/agent_runtime_helpers.py:2411-2414, and the live loop applies any returned repair before invalid-tool correction in agent/conversation_loop.py:4430-4439. The proposed guard is placed after the existing exact, normalized, and suffix-based candidate checks, so it preserves deterministic repairs while preventing an unmatched native MCP operation from being silently substituted.

The regression coverage targets the affected helper in tests/run_agent/test_repair_tool_call_name.py, and the PR's required CI, lint, unit, and e2e checks are passing.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
@TurgutKural TurgutKural reopened this Jul 15, 2026
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from e846a8b to b9e2eb0 Compare July 17, 2026 09:48
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main (head b9e2eb03e). All required CI checks pass.

@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from b9e2eb0 to 07c6bbd Compare July 19, 2026 09:06
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Deep upstream-already-fixed analysis — verdict: STILL_OPEN ✅ (does NOT qualify for close)

I inspected the current upstream/main (c0c76a47153398953c718ca729bc5192da1e63ac, fetched fresh) end-to-end. The mcp__ guard from this PR is not present anywhere upstream and no alternative mechanism protects MCP tool names from fuzzy swap. This PR is still needed.

What I checked (real commands, real output)

1. The repair function still uses difflib.get_close_matches with NO MCP branch.
agent/agent_runtime_helpers.py (current main):

  • repair_tool_call defined at line 2541 (renamed from repair_tool_call_name; behavior identical).
  • Fallback fuzzy match at line 2628:
    # Fuzzy match as last resort.
    matches = get_close_matches(lowered, agent.valid_tool_names, n=1, cutoff=0.7)
    if matches:
        return matches[0]
  • Exhaustive grep for mcp__ / startswith / namespace / registration-check inside agent_runtime_helpers.py and the call site conversation_loop.py: zero hits. There is no prefix-based skip, no MCP-aware validator, no schema-driven lookup, and no config flag gating repair.

2. The function has NOT been removed/replaced/renamed to something safe.
git log upstream/main --since=2026-04-01 -- agent/agent_runtime_helpers.py shows ~30 commits touching the file — none remove or MCP-guard the repair path. repair_tool_call is the live path; forwarded via run_agent.py:3982 _repair_tool_call and called at conversation_loop.py:4715:

if tc.function.name not in agent.valid_tool_names:
    repaired = agent._repair_tool_call(tc.function.name)
    if repaired:
        tc.function.name = repaired   # ← silent swap happens here

3. MCP tool names DO reach this function — they are in valid_tool_names.
valid_tool_names is built from agent.tools (agent/agent_init.py:1223). MCP tools are registered with the mcp__ wire prefix (agent/anthropic_adapter.py:376 _MCP_TOOL_PREFIX = "mcp__", :2579 return _MCP_TOOL_PREFIX + name). So a hallucinated mcp__ghidra__disassemble_function that isn't an exact member of valid_tool_names flows into the fuzzy fallback and can be swapped to a different mcp__ghidra__* op — exactly the dangerous case this PR describes.

4. No newer tool-resolution path bypasses fuzzy repair for namespaced names.
tool_executor.py uses valid_tool_names only to build the enabled-tools list (tool_executor.py:1492,1534); dispatch does not consult the live MCP server's tool list before/instead of repair. The only MCP-aware code (anthropic_adapter.py, transports/anthropic.py) handles prefix round-tripping on the wire, not name-validation against the live server — so it cannot make fuzzy repair moot.

5. Clean-apply check. git merge-tree between upstream/main and this branch reports NO CONFLICTS — the fix still applies cleanly to current main, confirming no upstream drift has obviated it.

Conclusion

The gap is real and unaddressed on main: repair_tool_call (agent/agent_runtime_helpers.py:2628) applies get_close_matches to mcp__-prefixed names with no guard. This PR's if normalized.startswith("mcp__"): return None (PR branch 07c6bbd96, +10/-1) is the correct, minimal fix and remains the needed change. Leaving open.

(Note: main's HEAD advanced from the 94f8166dc referenced in the original triage to c0c76a471 — re-verified against the new HEAD, verdict unchanged.)

@GottZ

GottZ commented Jul 20, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Thanks — this is a focused, well-scoped fix. Confirmed against current main (agent/agent_runtime_helpers.py:2627): the get_close_matches fallback runs on mcp__-prefixed names, and the shared mcp__<server>__ prefix can push two different operations above the 0.7 cutoff, so a hallucinated op is silently rewritten into a real neighbouring op. The guard is correctly placed after the exact/normalized/suffix candidate checks and before the fuzzy fallback, so deterministic repairs (case, _tool-suffix, exact) are preserved — verified in isolation, and the call site (agent/conversation_loop.py:4714) correctly falls through to the unknown-tool correction path on None. Regression coverage targets the real failure case. CI is green and the branch merges cleanly onto current main.

One design point worth surfacing for maintainers: this disables fuzzy repair for all unmatched mcp__ names, including genuine intra-operation typos (e.g. ..._decompile_functon), which now fall through to unknown-tool correction rather than being auto-fixed. That's a defensible safety-over-convenience trade-off. Note that #37100 targets the same bug (cross-operation MCP remap) with a more surgical approach — scoring only the operation suffix so same-op typos stay repairable. The two overlap on the same region of repair_tool_call and the same test file, so they're mutually exclusive; picking between "blunt and safe" here vs. "surgical but more complex" there is the real decision. No fixes # is referenced — this reads as proactive hardening rather than a fix for a filed issue.

@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 2 times, most recently from 92d4cc7 to 8aa6fed Compare July 22, 2026 06:01
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and the design-point framing.

Agreed on the safety-over-convenience trade-off: genuine intra-operation typos (e.g. ..._decompile_functon) now fall through to unknown-tool correction rather than being silently remapped. That is intentional — the cross-operation remap failure class (hallucinated op rewritten into a real neighbouring op) is strictly worse than a missed auto-fix, because it executes an unintended operation without any user-visible signal.

Noted the overlap with #37100 (surgical suffix-scoring approach). The two are mutually exclusive on the same region of repair_tool_call; happy to defer to whichever direction maintainers prefer. If #37100 lands first, this PR can be closed as superseded.

No fixes # reference is intentional — this is proactive hardening, not a filed-issue fix.

@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Jul 22, 2026
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 4 times, most recently from b9ebe14 to f4ca46a Compare July 24, 2026 04:09
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 24, 2026
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 4 times, most recently from 9824368 to 357fa20 Compare July 28, 2026 03:36
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main (7965462). The CI failures (slice 1/8 + 8/8) were pre-existing vercel sandbox test issues now fixed on main — not related to this PR's MCP tool name guard.

@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 9dfe645 to 4d76060 Compare July 31, 2026 03:35
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 4d76060 to baf4d7f Compare August 1, 2026 03:36
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from baf4d7f to 79fb33a Compare August 2, 2026 03:35
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 79fb33a to 1ea531e Compare August 3, 2026 05:32
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 1ea531e to b75f427 Compare August 4, 2026 05:57
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 2 times, most recently from 1cb5ffc to 4c233d6 Compare August 5, 2026 10:26
TurgutKural added a commit to TurgutKural/hermes-agent that referenced this pull request Aug 5, 2026
…vior

Add pre_db_checkpoint, pre_fuzzy_repair, pre_delegation_credentials,
and pre_compression hooks so plugins can override or veto specific
core behaviors without patching core files.

Motivation: several open PRs (NousResearch#72549, NousResearch#62701, NousResearch#61499, NousResearch#58512) fix
real bugs by patching core internals that the plugin system cannot
reach. Rather than carrying fork-specific patches across every
upstream rebase, expose narrow, fail-open hook points that let a
user-installed plugin (~/.hermes/plugins/) implement the same fixes
without touching core files.

Hook contracts (all fail-open — no plugin loaded = original behavior):

- pre_db_checkpoint: fired before WAL checkpoint in SessionDB.close()
  and pre-VACUUM paths. Return {"mode": "PASSIVE"} to override the
  default TRUNCATE mode. Enables plugins to prevent page-tear under
  SIGTERM races (NousResearch#45383) without patching hermes_state.py.

- pre_fuzzy_repair: fired before the fuzzy-match fallback in
  repair_tool_call(). Return {"skip": True} to suppress fuzzy
  matching for specific tool names (e.g. MCP names where fuzzy
  substitution changes semantics — NousResearch#62701).

- pre_delegation_credentials: fired at the top of
  _resolve_delegation_credentials(). Return a full credential dict
  (with "provider" key) to short-circuit built-in resolution.
  Enables plugins to implement Nous JWT rotation (NousResearch#61499) before
  the direct-endpoint path runs.

- pre_compression: fired just before context compression begins.
  Return {"skip": True, "reason": str} to veto compression for
  this tick. Side-effect hooks (e.g. rebinding a shared context
  engine) may run without returning (NousResearch#58512).

Changes:
- hermes_cli/plugins.py: add four hooks to VALID_HOOKS with docs
- hermes_state.py: fire pre_db_checkpoint in close() and pre-VACUUM
- hermes_state_search.py: fire pre_db_checkpoint in optimize VACUUM
- agent/agent_runtime_helpers.py: fire pre_fuzzy_repair before fuzzy
- tools/delegate_tool.py: fire pre_delegation_credentials at entry
- agent/conversation_compression.py: fire pre_compression before start
- tests/hermes_cli/test_extension_hooks.py: 26 tests covering all
  four hooks (default behavior, override, error fallback, kwargs)

Validation:
- python -m pytest tests/hermes_cli/test_extension_hooks.py → 26 passed
- python -m pytest tests/test_wal_checkpoint_strategy.py
  tests/run_agent/test_repair_tool_call_name.py
  tests/hermes_cli/test_plugins.py
  tests/tools/test_delegate.py::TestDelegationCredentialResolution
  tests/run_agent/test_message_sequence_repair.py → 95 passed
- python -m py_compile on all six modified files → OK
- Pre-existing failures (No module named 'openai') confirmed identical
  on unmodified upstream/main — not caused by this change.
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 4 times, most recently from 91dadbd to 1f43cab Compare August 13, 2026 03:48
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch 7 times, most recently from b03fed4 to 223e64a Compare August 19, 2026 04:27
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main (395c70d). All required checks pass (47/47 pass+skip, no pending, no failures). The MCP fuzzy-repair guard (return None for mcp__-prefixed names before the get_close_matches fallback) is unchanged and still applies cleanly — no upstream drift has touched repair_tool_call.

@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 223e64a to 02f4ee8 Compare August 20, 2026 04:19
@TurgutKural
TurgutKural force-pushed the fix/mcp-tool-name-repair-scope branch from 02f4ee8 to b58aae2 Compare August 21, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants