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:
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal 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('& < > "')).toBe('& < > "');
|
||||
});
|
||||
|
||||
it('decodes numeric character references', () => {
|
||||
expect(htmlToMarkdown(''')).toBe("'");
|
||||
});
|
||||
|
||||
it('decodes as space', () => {
|
||||
const md = htmlToMarkdown('hello 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>');
|
||||
});
|
||||
});
|
||||
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user