fix(registry): normalize tool names at dispatch entry to tolerate LLM drift - #14188
fix(registry): normalize tool names at dispatch entry to tolerate LLM drift#14188JayGwod wants to merge 1 commit into
Conversation
9211fee to
ff049a7
Compare
|
Bumping — open for 10 days with no review activity. Just rebased onto current upstream/main ( Local verification on the rebased state:
Happy to take feedback on the normalization rules (currently strip |
41d0ed6 to
3a3c8d6
Compare
a73720a to
2d10230
Compare
8b0c621 to
a99588c
Compare
… 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)
a99588c to
2ea8949
Compare
|
I reviewed the 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 Suggested: add 2. The normalization strips 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 = candidateThis is a minor design concern since no current tools are affected, but worth addressing before the registry grows. |
|
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:
Local stack has dropped the redundant patch; Thanks. |
|
@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 — If anyone revisits the registry-layer approach later (e.g. for the plugin SDK dispatch path in |
Fixes #14186.
What
Adds
_normalize_tool_name()and wires it intoToolRegistry.dispatch()as a miss-only fallback to recover from cosmetic drift in thenamefield 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 spuriousUnknown tool: Xerrors and retry loops.Changes
tools/registry.py_normalize_tool_name(name)— fixpoint loop (boundedMAX_ITER=4) that applies:_toolsuffixToolRegistry.dispatch()— on exact-match miss, try_normalize_tool_name()once and log awarningif it recovers a handler.Key invariants preserved
nameverbatim. Zero impact on well-formed calls.NotARealTool_toolstill returnsUnknown tool: NotARealTool_tool.logger.warning("Tool name normalized: %r -> %r (dispatch recovery)", ...)so ops can monitor model behavior.Layered drift example
TodoTool_toolrequires fixpoint iteration:Testing
Unit tests for
_normalize_tool_name:TodoTool_tooltodoPatch_toolpatchBrowserClick_toolbrowser_clickTerminal_toolterminalWriteFile_toolwrite_filetodo(already canonical)todoEnd-to-end dispatch (on this installation's live gateway, PID 2473729):
dispatch(name, args)TodoTool_tool,{...}todohandlerPatch_tool,{}path required(param validation, not unknown-tool)NotARealTool_tool,{}Unknown tool: NotARealTool_toolRisk
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).