Files
boocode/apps/server/src/services/inference/tool-suggestions.ts
indifferentketchup 2e1a81de72 v1.13.16-xml-parser: Anthropic <invoke> support + unknown-tool recovery hints
Two-part fix for the model-emitted XML drift the v1.13.15-codecontext-synth
investigation surfaced (1 raw <invoke> leak observed out of 190 qwen3.6
turns — qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted
as an Architect-style agent because Claude Code documentation in its
pre-training corpus uses that shape).

## Parser extension

xml-parser.ts now recognizes BOTH XML tool-call flavors:

  - Qwen/Hermes:   <tool_call><function=NAME>...<parameter=K>V</parameter>...</function></tool_call>
  - Anthropic:     <invoke name="NAME"><parameter name="K">V</parameter></invoke>

Both route through the same synthetic-id xml_call_${idx} ToolCall path.
extractToolCallBlocks() and partialXmlOpenerStart() handle both openers
(<tool_call> and <invoke...) so partial buffers don't get prematurely
flushed during streaming.

The existing Qwen parser was tightened to tolerate whitespace around `=`
(<function = name>, <parameter = key>...) so a stray space doesn't get
absorbed into the function name. Name capture is non-whitespace,
non-`>`.

## Unknown-tool recovery hint

New tool-suggestions.ts exports levenshtein() + suggestToolName() +
formatUnknownToolError(). When tool-phase.ts:executeToolCall receives a
toolCall.name that isn't in TOOLS_BY_NAME, the error returned to the
model now includes a "Did you mean: X?" hint based on Levenshtein
distance ≤3 or substring match against Object.keys(TOOLS_BY_NAME).
Targets the qwen3.6 drift to read_file → suggest view_file. Applies to
all unknown tool names, not just <invoke>-derived ones — at the
dispatch layer we no longer know which format produced the call, and
the extra signal is harmless for Qwen-derived calls.

## Test coverage

xml-parser.test.ts: 46 tests, all green. Covers both parsers
(well-formed, malformed, multi-parameter, nested-content), the
partial-opener detector for both flavors, the unified extraction
helper, and the unknown-tool error formatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:59:25 +00:00

64 lines
2.2 KiB
TypeScript

// v1.13.16: Levenshtein + suggestion + formatter for the unknown-tool error
// returned to the model when an XML-extracted tool call references a name
// that isn't in TOOLS_BY_NAME. The drift incident this targets: qwen3.6
// emitting <invoke name="read_file"> from its Claude Code training residue
// when BooCode's actual file-read tool is view_file. Hand-rolled distance
// function — no new dep.
export function levenshtein(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const dp: number[][] = Array.from(
{ length: a.length + 1 },
() => new Array<number>(b.length + 1).fill(0),
);
for (let i = 0; i <= a.length; i++) dp[i]![0] = i;
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i]![j] = Math.min(
dp[i - 1]![j]! + 1,
dp[i]![j - 1]! + 1,
dp[i - 1]![j - 1]! + cost,
);
}
}
return dp[a.length]![b.length]!;
}
// Threshold per the v1.13.16 dispatch: distance <= 3 OR substring match
// (either direction). Ties broken by smallest distance, then alphabetical.
export function suggestToolName(
name: string,
available: readonly string[],
): string | null {
const lower = name.toLowerCase();
let best: { name: string; dist: number } | null = null;
for (const tool of available) {
const tlower = tool.toLowerCase();
const dist = levenshtein(lower, tlower);
const isSubstr = tlower.includes(lower) || lower.includes(tlower);
if (dist > 3 && !isSubstr) continue;
if (
best === null ||
dist < best.dist ||
(dist === best.dist && tool.localeCompare(best.name) < 0)
) {
best = { name: tool, dist };
}
}
return best?.name ?? null;
}
export function formatUnknownToolError(
name: string,
available: readonly string[],
): string {
const sorted = [...available].sort();
const suggestion = suggestToolName(name, sorted);
const list = sorted.join(', ');
const tail = suggestion ? ` Did you mean: ${suggestion}?` : '';
return `Tool '${name}' not found. Available tools: [${list}].${tail}`;
}