v2.4.0-unsloth-studio-lift: port 3 Unsloth Studio AGPL-3.0 modules

Batch 1 — tool-call-parser.ts: replaces xml-parser.ts with a port of
Unsloth's tool_call_parser.py. Adds balanced-brace JSON scanner,
single-param fast path, hasToolSignal/stripToolMarkup/parseToolCallsFromText
exports, and stream-finalization stripping at all three final-write sites
(error-handler, finalizeCompletion, executeToolPhase). Anthropic <invoke>
shape preserved. 75+12 tests.

Batch 2 — web/html-to-md.ts: parse5 tree-walking HTML-to-Markdown converter
ported from Unsloth's _html_to_md.py. Replaces web_fetch's regex stripHtml
with structured markdown output (headings, links, lists, tables, code blocks,
blockquotes, entity decoding). 29 tests.

Batch 3 — llama-args-validator.ts: port of llama_server_args.py deny-list
validator. Wired into AGENTS.md frontmatter parser — llama_extra_args field
validated at load time, rejects managed flags (model identity, networking,
auth/TLS, server UI). No runtime consumer yet (llama-swap boundary). 76 tests.

All three files carry SPDX-License-Identifier: AGPL-3.0-only headers.
LICENSE flipped to AGPL-3.0-only in prior commit (a938cf1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:30:50 +00:00
parent a938cf1d42
commit 90a6761b07
17 changed files with 1672 additions and 311 deletions

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest';
import { htmlToMarkdown } from '../web/html-to-md.js';
describe('htmlToMarkdown', () => {
it('converts h1 heading', () => {
expect(htmlToMarkdown('<h1>Title</h1>')).toBe('# Title');
});
it('converts h1 through h6', () => {
const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>';
const md = htmlToMarkdown(html);
expect(md).toContain('# One');
expect(md).toContain('## Two');
expect(md).toContain('### Three');
expect(md).toContain('#### Four');
expect(md).toContain('##### Five');
expect(md).toContain('###### Six');
});
it('converts anchor with href', () => {
expect(htmlToMarkdown('<a href="https://example.com">click here</a>'))
.toBe('[click here](https://example.com)');
});
it('converts anchor without href to plain text', () => {
expect(htmlToMarkdown('<a>just text</a>')).toBe('just text');
});
it('converts bold and italic', () => {
expect(htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
expect(htmlToMarkdown('<b>bold</b>')).toBe('**bold**');
expect(htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
expect(htmlToMarkdown('<i>italic</i>')).toBe('*italic*');
});
it('handles combined bold+italic', () => {
const md = htmlToMarkdown('<strong><em>bold italic</em></strong>');
expect(md).toBe('***bold italic***');
});
it('converts unordered list', () => {
const html = '<ul><li>one</li><li>two</li><li>three</li></ul>';
const md = htmlToMarkdown(html);
expect(md).toContain('* one');
expect(md).toContain('* two');
expect(md).toContain('* three');
});
it('converts ordered list', () => {
const html = '<ol><li>first</li><li>second</li></ol>';
const md = htmlToMarkdown(html);
expect(md).toContain('1. first');
expect(md).toContain('2. second');
});
it('handles nested lists', () => {
const html = '<ul><li>outer<ul><li>inner</li></ul></li></ul>';
const md = htmlToMarkdown(html);
expect(md).toContain('* outer');
expect(md).toContain(' * inner');
});
it('converts 3-column GFM table with header', () => {
const html = `
<table>
<thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>
<tbody>
<tr><td>Alice</td><td>30</td><td>NYC</td></tr>
<tr><td>Bob</td><td>25</td><td>LA</td></tr>
</tbody>
</table>`;
const md = htmlToMarkdown(html);
expect(md).toContain('| Name | Age | City |');
expect(md).toContain('| --- | --- | --- |');
expect(md).toContain('| Alice | 30 | NYC |');
expect(md).toContain('| Bob | 25 | LA |');
});
it('escapes pipe characters in table cells', () => {
const html = '<table><tr><th>A</th></tr><tr><td>x | y</td></tr></table>';
const md = htmlToMarkdown(html);
expect(md).toContain('x \\| y');
});
it('converts blockquote', () => {
const html = '<blockquote><p>quoted text</p></blockquote>';
const md = htmlToMarkdown(html);
expect(md).toContain('> quoted text');
});
it('converts multi-line blockquote', () => {
const html = '<blockquote><p>line one</p><p>line two</p></blockquote>';
const md = htmlToMarkdown(html);
expect(md).toContain('> line one');
expect(md).toContain('> line two');
});
it('converts fenced code block', () => {
const html = '<pre><code>const x = 1;</code></pre>';
const md = htmlToMarkdown(html);
expect(md).toContain('```\nconst x = 1;\n```');
});
it('preserves language hint from code class', () => {
const html = '<pre><code class="language-py">print("hello")</code></pre>';
const md = htmlToMarkdown(html);
expect(md).toContain('```py\nprint("hello")\n```');
});
it('converts inline code', () => {
expect(htmlToMarkdown('use <code>npm install</code> to install'))
.toContain('`npm install`');
});
it('decodes HTML entities', () => {
expect(htmlToMarkdown('&amp; &lt; &gt; &quot;')).toBe('& < > "');
});
it('decodes numeric character references', () => {
expect(htmlToMarkdown('&#39;')).toBe("'");
});
it('decodes &nbsp; as space', () => {
const md = htmlToMarkdown('hello&nbsp;world');
expect(md).toMatch(/hello\s+world/);
});
it('skips script content', () => {
const html = '<p>before</p><script>alert("xss")</script><p>after</p>';
const md = htmlToMarkdown(html);
expect(md).not.toContain('alert');
expect(md).toContain('before');
expect(md).toContain('after');
});
it('skips style content', () => {
const html = '<p>text</p><style>body { color: red }</style>';
const md = htmlToMarkdown(html);
expect(md).not.toContain('color');
expect(md).toContain('text');
});
it('does not throw on malformed HTML', () => {
expect(() => htmlToMarkdown('<p>unclosed <b>bold <i>italic')).not.toThrow();
const md = htmlToMarkdown('<p>unclosed <b>bold <i>italic');
expect(md).toContain('bold');
expect(md).toContain('italic');
});
it('returns empty string for empty input', () => {
expect(htmlToMarkdown('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(htmlToMarkdown(' \n\n ')).toBe('');
});
it('converts hr to horizontal rule', () => {
const md = htmlToMarkdown('<p>above</p><hr><p>below</p>');
expect(md).toContain('---');
});
it('converts br to newline', () => {
const md = htmlToMarkdown('line one<br>line two');
expect(md).toContain('line one\nline two');
});
it('handles ol with start attribute', () => {
const html = '<ol start="5"><li>five</li><li>six</li></ol>';
const md = htmlToMarkdown(html);
expect(md).toContain('5. five');
expect(md).toContain('6. six');
});
it('collapses excessive blank lines', () => {
const html = '<p>one</p><p></p><p></p><p></p><p>two</p>';
const md = htmlToMarkdown(html);
const blankRuns = md.match(/\n{3,}/g);
expect(blankRuns).toBeNull();
});
// Golden test: small Hacker News-style snippet
it('golden: HN-style snippet produces structured markdown', () => {
const html = `
<html>
<head><title>Test Page</title></head>
<body>
<h1>Welcome</h1>
<p>This is a <strong>test</strong> page with <a href="https://example.com">a link</a>.</p>
<h2>Features</h2>
<ul>
<li>Fast</li>
<li>Reliable</li>
<li>Secure</li>
</ul>
<h2>Data</h2>
<table>
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
<tbody>
<tr><td>Uptime</td><td>99.9%</td></tr>
<tr><td>Latency</td><td>42ms</td></tr>
</tbody>
</table>
<blockquote><p>This tool is amazing.</p></blockquote>
<pre><code class="language-js">console.log("hello");</code></pre>
<script>evil();</script>
</body>
</html>`;
const md = htmlToMarkdown(html);
expect(md).toContain('# Welcome');
expect(md).toContain('**test**');
expect(md).toContain('[a link](https://example.com)');
expect(md).toContain('## Features');
expect(md).toContain('* Fast');
expect(md).toContain('| Metric | Value |');
expect(md).toContain('| --- | --- |');
expect(md).toContain('| Uptime | 99.9% |');
expect(md).toContain('> This tool is amazing.');
expect(md).toContain('```js\nconsole.log("hello");\n```');
expect(md).not.toContain('evil');
expect(md).not.toContain('<title>');
});
});

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import {
validateExtraArgs,
isManagedFlag,
stripShadowingFlags,
} from '../inference/llama-args-validator.js';
import { parseAgentsMd } from '../agents.js';
describe('validateExtraArgs', () => {
describe('deny list — each alias rejected', () => {
const denied = [
'-m', '--model',
'-mu', '--model-url',
'-dr', '--docker-repo',
'-hf', '-hfr', '--hf-repo',
'-hff', '--hf-file',
'-hfv', '-hfrv', '--hf-repo-v',
'-hffv', '--hf-file-v',
'-hft', '--hf-token',
'-mm', '--mmproj',
'-mmu', '--mmproj-url',
'--host', '--port', '--path', '--api-prefix', '--reuse-port',
'--api-key', '--api-key-file',
'--ssl-key-file', '--ssl-cert-file',
'--webui', '--no-webui', '--ui', '--no-ui',
'--ui-config', '--ui-config-file',
'--ui-mcp-proxy', '--no-ui-mcp-proxy',
'--models-dir', '--models-preset', '--models-max',
'--models-autoload', '--no-models-autoload',
];
for (const flag of denied) {
it(`rejects ${flag}`, () => {
expect(() => validateExtraArgs([flag])).toThrow(/managed/);
});
}
});
describe('safe flags accepted', () => {
const safe = [
'-c', '--ctx-size', '-ngl', '--gpu-layers',
'--top-k', '--cache-type-k', '--jinja', '--no-jinja',
'--spec-draft-n-max', '-fa', '--flash-attn',
'-t', '--threads', '-np', '--parallel',
];
for (const flag of safe) {
it(`accepts ${flag}`, () => {
expect(() => validateExtraArgs([flag])).not.toThrow();
expect(validateExtraArgs([flag])).toEqual([flag]);
});
}
});
it('handles --flag=value shape (denies the flag part)', () => {
expect(() => validateExtraArgs(['--model=evil.gguf'])).toThrow(/managed/);
});
it('handles --flag=value shape (accepts safe flag)', () => {
expect(validateExtraArgs(['--ctx-size=4096'])).toEqual(['--ctx-size=4096']);
});
it('returns empty array for undefined input', () => {
expect(validateExtraArgs(undefined)).toEqual([]);
});
it('returns empty array for empty input', () => {
expect(validateExtraArgs([])).toEqual([]);
});
it('treats negative numbers as values, not flags', () => {
expect(validateExtraArgs(['--seed', '-1'])).toEqual(['--seed', '-1']);
});
});
describe('isManagedFlag', () => {
it('returns true for denied flags', () => {
expect(isManagedFlag('--model')).toBe(true);
expect(isManagedFlag('-m')).toBe(true);
expect(isManagedFlag('--api-key')).toBe(true);
expect(isManagedFlag('--port')).toBe(true);
});
it('returns false for safe flags', () => {
expect(isManagedFlag('-c')).toBe(false);
expect(isManagedFlag('--ctx-size')).toBe(false);
expect(isManagedFlag('--top-k')).toBe(false);
});
});
describe('stripShadowingFlags', () => {
it('strips auto -c when user supplies -c', () => {
const result = stripShadowingFlags(['-c', '4096', '--top-k', '40']);
expect(result).toEqual(['--top-k', '40']);
});
it('retains both when no overlap', () => {
const result = stripShadowingFlags(['--top-k', '40', '--top-p', '0.95']);
expect(result).toEqual(['--top-k', '40', '--top-p', '0.95']);
});
it('strips --ctx-size=value form', () => {
const result = stripShadowingFlags(['--ctx-size=4096']);
expect(result).toEqual([]);
});
it('strips boolean --jinja flag (no value consumed)', () => {
const result = stripShadowingFlags(['--jinja', '--top-k', '40']);
expect(result).toEqual(['--top-k', '40']);
});
it('respects stripContext=false to keep context flags', () => {
const result = stripShadowingFlags(['-c', '4096'], { stripContext: false });
expect(result).toEqual(['-c', '4096']);
});
it('strips cache flags by default', () => {
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
expect(result).toEqual([]);
});
it('strips spec flags by default', () => {
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
expect(result).toEqual([]);
});
});
describe('AGENTS.md frontmatter validation', () => {
it('rejects agent with managed flag in llama_extra_args', () => {
const md = `## Evil Agent
---
llama_extra_args: ["--model", "evil.gguf"]
---
You are evil.`;
const { agents, errors } = parseAgentsMd(md);
expect(agents).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]!.reason).toContain('managed');
});
it('accepts agent with safe llama_extra_args', () => {
const md = `## Good Agent
---
llama_extra_args: ["--top-k", "20"]
---
You are good.`;
const { agents, errors } = parseAgentsMd(md);
expect(errors).toHaveLength(0);
expect(agents).toHaveLength(1);
expect(agents[0]!.llama_extra_args).toEqual(['--top-k', '20']);
});
it('agent without llama_extra_args has null field', () => {
const md = `## Simple Agent
---
temperature: 0.5
---
You are simple.`;
const { agents } = parseAgentsMd(md);
expect(agents[0]!.llama_extra_args).toBeNull();
});
});

View File

@@ -1,25 +1,24 @@
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
// <invoke> parser, the partial-opener detector for both flavors, the unified
// extraction helper, and the unknown-tool error formatter that downstream
// dispatch uses to give the model a recovery hint when it drifts to a
// Claude Code tool name like read_file instead of BooCode's view_file.
import { describe, expect, it } from 'vitest';
import {
parseXmlToolCall,
parseInvokeToolCall,
partialXmlOpenerStart,
extractToolCallBlocks,
parseToolCallsFromText,
stripToolMarkup,
hasToolSignal,
XML_TOOL_OPEN,
XML_TOOL_CLOSE,
INVOKE_TOOL_OPEN,
INVOKE_TOOL_CLOSE,
} from '../inference/xml-parser.js';
import {
levenshtein,
suggestToolName,
formatUnknownToolError,
} from '../inference/tool-suggestions.js';
TOOL_XML_SIGNALS,
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
TOOL_ERROR_NUDGE,
TOOL_ERROR_PREFIXES,
} from '../inference/tool-call-parser.js';
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
it('parses a well-formed single-parameter call', () => {
@@ -66,7 +65,6 @@ describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
});
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
// Spec case 1
it('parses a well-formed single-parameter call (spec case 1)', () => {
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
@@ -75,7 +73,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
});
});
// Spec case 2
it('parses a multi-parameter call (spec case 2)', () => {
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
@@ -84,7 +81,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
});
});
// Spec case 3
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
const block = `<invoke
name="view_file"
@@ -99,7 +95,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
});
});
// Spec case 4 (parser portion — the not-found enrichment is tested below)
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
@@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
});
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
// Spec case 1 (extraction-level)
it('extracts a single <invoke> block (spec case 1)', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
const result = extractToolCallBlocks(input);
@@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(result.remaining).toBe('');
});
// Spec case 5: opener arrives in one chunk, closer in the next.
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
const result = extractToolCallBlocks(firstChunk);
@@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(r2.remaining).toBe('');
});
// Spec case 6: prose interleaving
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
const result = extractToolCallBlocks(input);
@@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(result.remaining).toBe('');
});
// Spec case 7 regression
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
const result = extractToolCallBlocks(input);
@@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
});
});
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('view_file', 'view_file')).toBe(0);
// ── New tests: Unsloth-ported functions ──────────────────────────────────
describe('hasToolSignal', () => {
it('returns true for <tool_call>', () => {
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
});
it('returns the length when one string is empty', () => {
expect(levenshtein('', 'view_file')).toBe(9);
expect(levenshtein('view_file', '')).toBe(9);
it('returns true for <function=', () => {
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
});
it('computes a small distance for a single-character substitution', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
it('returns true for <invoke', () => {
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
});
it('computes a known case: read_file → view_file is 4', () => {
// r→v, e→i, a→e, d→w → 4 substitutions, same length
expect(levenshtein('read_file', 'view_file')).toBe(4);
it('returns false for near-miss <tool>', () => {
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
});
it('returns false for near-miss <function>', () => {
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
});
it('returns false for near-miss <tool_call_thing>', () => {
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
});
it('returns false for plain text', () => {
expect(hasToolSignal('just some text')).toBe(false);
});
});
describe('suggestToolName (v1.13.16)', () => {
const tools = [
'view_file',
'list_dir',
'grep',
'find_files',
'view_truncated_output',
'ask_user_input',
'web_search',
];
it('suggests the closest match when distance is small', () => {
expect(suggestToolName('view_files', tools)).toBe('view_file');
describe('stripToolMarkup', () => {
it('strips closed <tool_call> blocks', () => {
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
expect(stripToolMarkup(input)).toBe('before after');
});
it('suggests via substring match when distance alone would miss', () => {
// 'file' is a substring of multiple tools; closest by distance wins.
expect(suggestToolName('file', tools)).toBe('view_file');
it('strips closed <function=...> blocks', () => {
const input = 'before <function=x><parameter=y>z</parameter></function> after';
expect(stripToolMarkup(input)).toBe('before after');
});
it('returns null when nothing is close', () => {
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
it('strips closed <invoke> blocks', () => {
const input = 'before <invoke name="x"><parameter name="y">z</parameter></invoke> after';
expect(stripToolMarkup(input)).toBe('before after');
});
it('is case-insensitive in the distance check', () => {
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
it('leaves trailing unclosed block when final=false', () => {
const input = 'text <tool_call>{"name":"x"';
expect(stripToolMarkup(input)).toBe('text <tool_call>{"name":"x"');
});
it('strips trailing unclosed <tool_call> when final=true', () => {
const input = 'text <tool_call>{"name":"x"';
expect(stripToolMarkup(input, { final: true })).toBe('text');
});
it('strips trailing unclosed <function= when final=true', () => {
const input = 'text <function=run_bash><parameter=command>ls';
expect(stripToolMarkup(input, { final: true })).toBe('text');
});
it('strips trailing unclosed <invoke when final=true', () => {
const input = 'text <invoke name="x"><parameter name="y">val';
expect(stripToolMarkup(input, { final: true })).toBe('text');
});
it('trims whitespace when final=true', () => {
const input = ' text <tool_call>partial';
expect(stripToolMarkup(input, { final: true })).toBe('text');
});
it('strips multiple closed blocks', () => {
const input = '<tool_call>a</tool_call> mid <tool_call>b</tool_call>';
expect(stripToolMarkup(input)).toBe(' mid ');
});
});
describe('formatUnknownToolError (v1.13.16)', () => {
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
describe('parseToolCallsFromText', () => {
describe('pattern 1: <tool_call>{json}</tool_call>', () => {
it('parses a well-formed JSON tool call', () => {
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.id).toBe('call_0');
expect(calls[0]!.type).toBe('function');
expect(calls[0]!.function.name).toBe('web_search');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
});
it('includes the wrong name and the available tools list', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).toContain("Tool 'read_file' not found");
expect(msg).toContain('Available tools:');
expect(msg).toContain('view_file');
expect(msg).toContain('find_files');
it('handles string arguments field', () => {
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls[0]!.function.arguments).toBe('already a string');
});
it('handles balanced braces inside JSON strings', () => {
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const parsed = JSON.parse(calls[0]!.function.arguments);
expect(parsed.q).toBe('} { extra ');
});
it('respects idOffset', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
const calls = parseToolCallsFromText(input, { idOffset: 5 });
expect(calls[0]!.id).toBe('call_5');
});
it('parses multiple JSON tool calls', () => {
const input =
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(2);
expect(calls[0]!.id).toBe('call_0');
expect(calls[1]!.id).toBe('call_1');
});
it('skips malformed JSON', () => {
const input = '<tool_call>{not json}</tool_call>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(0);
});
it('handles missing closing tag', () => {
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('x');
});
});
it('includes a suggestion when the drifted name is within threshold', () => {
// distance(view_files, view_file) = 1 (one extra char)
const msg = formatUnknownToolError('view_files', tools);
expect(msg).toContain('Did you mean: view_file?');
describe('pattern 2: <function=name><parameter=key>value', () => {
it('parses a single-parameter function call', () => {
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('single-param fast path preserves embedded </parameter>', () => {
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
});
it('multi-param: value of first stops at start of second', () => {
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const args = JSON.parse(calls[0]!.function.arguments);
expect(args.pattern).toBe('foo');
expect(args.path).toBe('src/');
});
it('tolerates missing closing tags', () => {
const input = '<function=view_file><parameter=path>/tmp/foo';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('does not fire when pattern 1 found results', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
});
it('omits the suggestion clause when no tool is close enough', () => {
const msg = formatUnknownToolError('zzzzzzz', tools);
expect(msg).toContain("Tool 'zzzzzzz' not found");
expect(msg).toContain('Available tools:');
expect(msg).not.toContain('Did you mean');
});
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
it('parses a single-parameter invoke call', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
// the spec's threshold (<=3) doesn't suggest view_file — the model still
// gets the available-tools list to pick from. This pins that behavior so a
// future loosening of the threshold is a deliberate choice.
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).not.toContain('Did you mean');
it('parses multi-parameter invoke call', () => {
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
const args = JSON.parse(calls[0]!.function.arguments);
expect(args.pattern).toBe('foo');
expect(args.path).toBe('src/');
});
it('does not fire when pattern 1 found results', () => {
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
it('does not fire when pattern 2 found results', () => {
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('a');
});
it('tolerates missing closing tags', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
});
it('supports single-quoted attributes', () => {
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
const calls = parseToolCallsFromText(input);
expect(calls).toHaveLength(1);
expect(calls[0]!.function.name).toBe('view_file');
});
});
});
describe('constants', () => {
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
expect(TOOL_XML_SIGNALS).toContain('<function=');
expect(TOOL_XML_SIGNALS).toContain('<invoke');
});
it('nudge constants are non-empty strings', () => {
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
});
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
expect(TOOL_ERROR_PREFIXES).toContain('Error');
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
levenshtein,
suggestToolName,
formatUnknownToolError,
} from '../inference/tool-suggestions.js';
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('view_file', 'view_file')).toBe(0);
});
it('returns the length when one string is empty', () => {
expect(levenshtein('', 'view_file')).toBe(9);
expect(levenshtein('view_file', '')).toBe(9);
});
it('computes a small distance for a single-character substitution', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
});
it('computes a known case: read_file → view_file is 4', () => {
expect(levenshtein('read_file', 'view_file')).toBe(4);
});
});
describe('suggestToolName (v1.13.16)', () => {
const tools = [
'view_file',
'list_dir',
'grep',
'find_files',
'view_truncated_output',
'ask_user_input',
'web_search',
];
it('suggests the closest match when distance is small', () => {
expect(suggestToolName('view_files', tools)).toBe('view_file');
});
it('suggests via substring match when distance alone would miss', () => {
expect(suggestToolName('file', tools)).toBe('view_file');
});
it('returns null when nothing is close', () => {
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
});
it('is case-insensitive in the distance check', () => {
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
});
});
describe('formatUnknownToolError (v1.13.16)', () => {
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
it('includes the wrong name and the available tools list', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).toContain("Tool 'read_file' not found");
expect(msg).toContain('Available tools:');
expect(msg).toContain('view_file');
expect(msg).toContain('find_files');
});
it('includes a suggestion when the drifted name is within threshold', () => {
const msg = formatUnknownToolError('view_files', tools);
expect(msg).toContain('Did you mean: view_file?');
});
it('omits the suggestion clause when no tool is close enough', () => {
const msg = formatUnknownToolError('zzzzzzz', tools);
expect(msg).toContain("Tool 'zzzzzzz' not found");
expect(msg).toContain('Available tools:');
expect(msg).not.toContain('Did you mean');
});
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).not.toContain('Did you mean');
});
});