Skip to content

fix(registry): normalize tool names at dispatch entry to tolerate LLM drift - #14188

Closed
JayGwod wants to merge 1 commit into
NousResearch:mainfrom
JayGwod:fix/registry-tool-name-normalize
Closed

fix(registry): normalize tool names at dispatch entry to tolerate LLM drift#14188
JayGwod wants to merge 1 commit into
NousResearch:mainfrom
JayGwod:fix/registry-tool-name-normalize

Conversation

@JayGwod

@JayGwod JayGwod commented Apr 22, 2026

Copy link
Copy Markdown

Fixes #14186.

What

Adds _normalize_tool_name() and wires it into ToolRegistry.dispatch() as a miss-only fallback to recover from cosmetic drift in the name field emitted by Claude-family models.

Why

See #14186 for full context. TL;DR: CamelCase / trailing-_tool / mixed-case variants of correct tool names are emitted at ~30% by Claude 4.5+ (per mastra-ai/mastra#12581's 44-model study), producing spurious Unknown tool: X errors and retry loops.

Changes

tools/registry.py

  • New _normalize_tool_name(name) — fixpoint loop (bounded MAX_ITER=4) that applies:
    • strip trailing _tool suffix
    • CamelCase → snake_case
    • hyphen → underscore
    • lowercase
  • ToolRegistry.dispatch() — on exact-match miss, try _normalize_tool_name() once and log a warning if it recovers a handler.

Key invariants preserved

  • Exact match always wins — normalization only fires when the registry does not contain name verbatim. Zero impact on well-formed calls.
  • Fail-closed on truly unknown toolsNotARealTool_tool still returns Unknown tool: NotARealTool_tool.
  • Drift stays observablelogger.warning("Tool name normalized: %r -> %r (dispatch recovery)", ...) so ops can monitor model behavior.

Layered drift example

TodoTool_tool requires fixpoint iteration:

pass 1: strip _tool suffix -> "TodoTool"
pass 2: camel->snake       -> "todo_tool"
pass 3: strip _tool suffix -> "todo"        ✓ registry hit

Testing

Unit tests for _normalize_tool_name:

Input Expected Pass
TodoTool_tool todo
Patch_tool patch
BrowserClick_tool browser_click
Terminal_tool terminal
WriteFile_tool write_file
todo (already canonical) todo

End-to-end dispatch (on this installation's live gateway, PID 2473729):

dispatch(name, args) Outcome Judgment
TodoTool_tool, {...} routes to todo handler ✅ normalized
Patch_tool, {} path required (param validation, not unknown-tool) ✅ normalized
NotARealTool_tool, {} Unknown tool: NotARealTool_tool ✅ no false match

Risk

Low. ~100 LOC (normalizer + tests). Fallback-only wiring preserves existing behavior for well-formed calls. No dependency changes.

Related

Companion PR # addresses the parameter-side of the same robustness theme (string-encoded todos).

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/tools Tool registry, model_tools, toolsets labels Apr 22, 2026
@JayGwod
JayGwod force-pushed the fix/registry-tool-name-normalize branch 4 times, most recently from 9211fee to ff049a7 Compare May 2, 2026 00:59
@JayGwod

JayGwod commented May 2, 2026

Copy link
Copy Markdown
Author

Bumping — open for 10 days with no review activity.

Just rebased onto current upstream/main (f903ceece) — the original commit conflicted with #17098 (perf(tools): memoize get_tool_definitions + TTL-cache check_fn results), which touched tools/registry.py. The rebased commit on the branch (ff049a750) is semantically identical to the original, just sitting on top of the memoization changes. mergeStateStatus is back to MERGEABLE.

Local verification on the rebased state:

  • pytest tests/tools/test_registry.py -k "normalize or dispatch or camel" → 3 passed.
  • Running on top of main for ~10 days; tolerates the recurring Tool_tool / CamelCase drift we see from a few providers without breaking exact-match dispatch.

Happy to take feedback on the normalization rules (currently strip _tool suffix + snake_case) or fold this into a different layer if you'd prefer. Thanks!

@JayGwod
JayGwod force-pushed the fix/registry-tool-name-normalize branch 5 times, most recently from 41d0ed6 to 3a3c8d6 Compare May 9, 2026 15:23
@JayGwod
JayGwod force-pushed the fix/registry-tool-name-normalize branch 4 times, most recently from a73720a to 2d10230 Compare May 18, 2026 09:52
@JayGwod
JayGwod force-pushed the fix/registry-tool-name-normalize branch 2 times, most recently from 8b0c621 to a99588c Compare May 22, 2026 18:14
… drift

Some LLMs (notably Claude 4.5/4.6/4.7) emit incorrect tool names at a
non-trivial rate — CamelCase ("TodoTool"), trailing "_tool" suffix
("Todo_tool"), hyphen-separated ("todo-tool"), or a combination
("TodoTool_tool"). Empirically the Claude family fails this at ~30%
while other model families stay near 0%, per mastra-ai/mastra#12581's
44-model study.

This commit installs the normalization at both dispatch entry points
with defense-in-depth:

1. tools/registry.py::_normalize_tool_name
   - Single-pass -> fixpoint iteration (bounded MAX_ITER=4). Layered
     drift like "TodoTool_tool" needs two passes:
       pass 1: strip _tool suffix -> "TodoTool" -> camel->snake
               -> "todo_tool"
       pass 2: strip _tool suffix -> "todo"
   - Hyphen/space unified to underscore up-front so "todo-tool" and
     "Browser-Navigate" collapse to canonical form.
   - ToolRegistry.dispatch calls the normalizer on lookup miss, giving
     every dispatch caller (subagents, batch_runner, RL envs, MCP,
     tests) the fix for free.

2. run_agent.py::_repair_tool_call
   - New step 3 calls the same _normalize_tool_name before the
     difflib(cutoff=0.7) fallback. The main agent loop's early
     invalid-tool gate now canonicalizes before consuming the
     invalid-tool retry counter, so "TodoTool_tool" is repaired in
     place instead of being rejected and retried.

Verified:
  - Drift sweep: 17/18 targeted cases pass (edge: "_tool" with only
    5 chars is kept intact by the len>5 guard — prevents degenerate
    empty string).
  - Registry test suite: 31/31 pass.
  - Wider agent regression: 1769 passed, 1 pre-existing failure
    (test_minimax_provider AttributeError _fallback_chain) — confirmed
    unrelated via stash baseline.
  - Well-formed names ("todo", "patch") still hit the registry on
    first lookup and bypass normalization entirely (zero overhead).

References:
  mastra-ai/mastra#12581  (Wrong tool name calling from claude-sonnet-4-5)
  anthropics/claude-code#50235  (Opus 4.7 bucket-bypass drift)
@JayGwod
JayGwod force-pushed the fix/registry-tool-name-normalize branch from a99588c to 2ea8949 Compare May 27, 2026 21:26
@liuhao1024

Copy link
Copy Markdown
Contributor

I reviewed the _normalize_tool_name() logic in tools/registry.py and have two concerns:

1. No tests for the normalization function

The fixpoint iteration, suffix stripping, CamelCase conversion, and edge cases (empty string, non-string input, all-uppercase, hyphens/spaces) each need unit test coverage. The existing tests/tools/test_registry.py is not extended here. Given that this function sits on the dispatch hot path and silently transforms tool names, regressions would be hard to diagnose without tests.

Suggested: add tests/tools/test_normalize_tool_name.py covering the docstring examples plus edge cases like "TODO""todo", "HTTPServer""http_server", "TodoTool_tool""todo", """", NoneNone.

2. _tool suffix stripping is unconditional

The normalization strips _tool from any name ending in it (e.g. "todo_tool""todo"). This is safe today — confirmed no currently registered tool ends in _tool — but it creates a latent collision risk. If a future tool like memory_tool is registered alongside memory, the normalization would map "Memory_tool""memory" (the wrong tool).

Consider scoping the suffix strip to only fire when the stripped form is NOT in the registry, or when both the original and stripped forms miss (pure drift recovery):

# Only strip _tool if the result exists in registry and original doesn't
if len(n) > 5 and n.lower().endswith("_tool"):
    candidate = n[:-5]
    if candidate in registry and n not in registry:
        n = candidate

This is a minor design concern since no current tools are affected, but worth addressing before the registry grows.

@JayGwod

JayGwod commented May 27, 2026

Copy link
Copy Markdown
Author

Closing as superseded by upstream PR #15124 (commit a1caec1, merged 2026-04-24 by @teknium1) which addresses the same Claude-family tool-name drift (TodoTool_tool, Patch_tool, CamelCase emissions).

Verified equivalence:

  • Teknium's fix extends run_agent.py::_repair_tool_call with two-pass CamelCase→snake_case + trailing _tool/-tool suffix normalization
  • All LLM-emitted tool calls flow through _repair_tool_call before reaching registry.dispatch (verified in agent/conversation_loop.py:3358)
  • This PR's additional registry.dispatch-side normalization layer was defense-in-depth, but no real bypass path exists from the LLM side

Local stack has dropped the redundant patch; tests/run_agent/test_repair_tool_call_name.py passes on top of current upstream/main.

Thanks.

@JayGwod JayGwod closed this May 27, 2026
@JayGwod
JayGwod deleted the fix/registry-tool-name-normalize branch May 27, 2026 21:46
@JayGwod

JayGwod commented May 27, 2026

Copy link
Copy Markdown
Author

@liuhao1024 thanks for the careful review — both concerns are valid.

Unfortunate timing on the close: it landed three minutes after your comment, but the reason is unrelated. Upstream PR #15124 (commit a1caec1, merged 2026-04-24) addresses the same Claude-family drift symptoms via a different code path — _repair_tool_call in run_agent.py rather than registry.dispatch. Since all LLM-emitted tool calls flow through _repair_tool_call before reaching dispatch (agent/conversation_loop.py:3358), the registry-side defense in this PR became redundant once #15124 landed.

If anyone revisits the registry-layer approach later (e.g. for the plugin SDK dispatch path in hermes_cli/plugins.py:495, which bypasses _repair_tool_call), both your suggestions — dedicated unit coverage and registry-aware _tool suffix stripping (only strip when the result resolves and the original does not) — should be folded in. Appreciate you taking the time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

registry.dispatch: 'Unknown tool' error for trivially-normalizable CamelCase/_tool-suffix drift from Claude family

3 participants