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>
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
// v1.10.5: XML-tag tool-call fallback. Some models emit
|
|
// <tool_call><function=foo><parameter=key>value</parameter></function></tool_call>
|
|
// in plain content instead of using the OpenAI tool_calls JSON channel.
|
|
// The streaming loop in stream-phase.ts extracts these blocks via these helpers.
|
|
//
|
|
// v1.13.16: also recognize Anthropic <invoke name="..."><parameter name="...">
|
|
// markup. qwen3.6-35b-a3b-mxfp4 drifts to this format when prompted as an
|
|
// "Architect"-style agent because Claude Code documentation in its
|
|
// pre-training data uses this shape. Both formats route through the same
|
|
// synthetic ToolCall path with shared xml_call_${idx} IDs; downstream
|
|
// dispatch handles unknown tool names with a richer error (see
|
|
// tool-suggestions.ts + tool-phase.ts).
|
|
|
|
export const XML_TOOL_OPEN = '<tool_call>';
|
|
export const XML_TOOL_CLOSE = '</tool_call>';
|
|
|
|
// v1.13.16: Anthropic <invoke> opener is matched by prefix (not the full
|
|
// `<invoke ...>` tag) because attributes follow. Closer is the literal tag.
|
|
export const INVOKE_TOOL_OPEN = '<invoke';
|
|
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
|
|
|
export interface ParsedCall {
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
}
|
|
|
|
// v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace
|
|
// around `=` (e.g. `<function = view_file>`). Name capture is non-whitespace,
|
|
// non-`>` so a stray space doesn't get absorbed into the function name.
|
|
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
|
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
|
|
|
export function parseXmlToolCall(block: string): ParsedCall | null {
|
|
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
|
if (!nameMatch || !nameMatch[1]) return null;
|
|
const name = nameMatch[1].trim();
|
|
if (!name) return null;
|
|
const args: Record<string, unknown> = {};
|
|
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
|
const key = (m[1] ?? '').trim();
|
|
if (!key) continue;
|
|
const raw = (m[2] ?? '').trim();
|
|
try {
|
|
args[key] = JSON.parse(raw);
|
|
} catch {
|
|
args[key] = raw;
|
|
}
|
|
}
|
|
return { name, args };
|
|
}
|
|
|
|
// v1.13.16: Anthropic-flavor parser. Same JSON-parse-with-string-fallback
|
|
// shape as parseXmlToolCall so the dispatch layer doesn't need to care which
|
|
// flavor produced the call.
|
|
const INVOKE_NAME_RE =
|
|
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
|
const INVOKE_PARAM_RE =
|
|
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
|
|
|
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
|
const nameMatch = block.match(INVOKE_NAME_RE);
|
|
if (!nameMatch) return null;
|
|
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
|
if (!name) return null;
|
|
const args: Record<string, unknown> = {};
|
|
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
|
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
|
if (!key) continue;
|
|
const raw = (m[4] ?? '').trim();
|
|
try {
|
|
args[key] = JSON.parse(raw);
|
|
} catch {
|
|
args[key] = raw;
|
|
}
|
|
}
|
|
return { name, args };
|
|
}
|
|
|
|
// Locate the first character that begins (or completely contains) an
|
|
// unfinished opener (either flavor) in `s`. Returns -1 when `s` can be
|
|
// flushed to the client in full without risking a partial tag leak.
|
|
// Case 1: a full opener (`<tool_call>` or `<invoke`) with no matching
|
|
// closer — caller must keep everything from that index forward
|
|
// until the next chunk arrives with the closer.
|
|
// Case 2: `s` ends with a strict prefix of either opener (e.g. `<tool_c`
|
|
// or `<invo`). Caller must keep just that suffix in the buffer.
|
|
// Note: case 1 assumes the calling loop already extracted every complete
|
|
// block before reaching this check.
|
|
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
|
|
|
export function partialXmlOpenerStart(s: string): number {
|
|
let earliest = -1;
|
|
for (const op of ALL_OPENERS) {
|
|
const idx = s.indexOf(op);
|
|
if (idx === -1) continue;
|
|
if (earliest === -1 || idx < earliest) earliest = idx;
|
|
}
|
|
if (earliest !== -1) return earliest;
|
|
const lastLt = s.lastIndexOf('<');
|
|
if (lastLt === -1) return -1;
|
|
const suffix = s.slice(lastLt);
|
|
for (const op of ALL_OPENERS) {
|
|
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// v1.13.16: unified extraction. Replaces the inline loop that used to live
|
|
// in stream-phase.ts. Pure function — returns the visible text to flush,
|
|
// the parsed tool-call payloads in source order, and the buffer remainder
|
|
// to retain for the next streaming chunk. Parse failures are silently
|
|
// dropped (matches the pre-v1.13.16 behavior — leaking partial XML to the
|
|
// chat looks worse than swallowing a bad block).
|
|
export interface ToolCallExtraction {
|
|
flushed: string;
|
|
calls: ParsedCall[];
|
|
remaining: string;
|
|
}
|
|
|
|
interface OpenerSpec {
|
|
open: string;
|
|
close: string;
|
|
parse: (block: string) => ParsedCall | null;
|
|
}
|
|
|
|
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
|
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
|
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
|
];
|
|
|
|
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
|
let flushed = '';
|
|
const calls: ParsedCall[] = [];
|
|
let pos = 0;
|
|
|
|
while (pos < buffer.length) {
|
|
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
|
for (const spec of OPENER_SPECS) {
|
|
const openIdx = buffer.indexOf(spec.open, pos);
|
|
if (openIdx === -1) continue;
|
|
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
|
if (closeIdx === -1) continue;
|
|
if (next === null || openIdx < next.openIdx) {
|
|
next = { spec, openIdx, closeIdx };
|
|
}
|
|
}
|
|
if (next === null) break;
|
|
|
|
if (next.openIdx > pos) {
|
|
flushed += buffer.slice(pos, next.openIdx);
|
|
}
|
|
const blockEnd = next.closeIdx + next.spec.close.length;
|
|
const block = buffer.slice(next.openIdx, blockEnd);
|
|
const parsed = next.spec.parse(block);
|
|
if (parsed) calls.push(parsed);
|
|
pos = blockEnd;
|
|
}
|
|
|
|
const tail = buffer.slice(pos);
|
|
const partialIdx = partialXmlOpenerStart(tail);
|
|
if (partialIdx === -1) {
|
|
flushed += tail;
|
|
return { flushed, calls, remaining: '' };
|
|
}
|
|
if (partialIdx > 0) {
|
|
flushed += tail.slice(0, partialIdx);
|
|
}
|
|
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
|
}
|