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'];
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');
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 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?');
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('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('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 ');
});
// 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('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');
});
});
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');
});
});
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' });
});
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');
});
});

View File

@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
import { ALL_TOOLS, resolveToolTier } from './tools.js';
import { validateExtraArgs } from './inference/llama-args-validator.js';
// v1.8.1: global agents live at /data/AGENTS.md inside the container
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
@@ -97,6 +98,7 @@ interface ParsedFrontmatter {
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
// allowed" — the model responds text-only.
steps?: number;
llama_extra_args?: string[];
}
function stripQuotes(s: string): string {
@@ -227,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
} else {
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
}
} else if (key === 'llama_extra_args') {
if (valueRaw === '') {
data.llama_extra_args = [];
// No arrayKey support — llama_extra_args uses inline list only.
} else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
const inner = valueRaw.slice(1, -1);
const parsed = inner
.split(',')
.map((s) => stripQuotes(s.trim()))
.filter((s) => s.length > 0);
try {
validateExtraArgs(parsed);
data.llama_extra_args = parsed;
} catch (err) {
errors.push(err instanceof Error ? err.message : String(err));
}
} else {
const parsed = valueRaw
.split(',')
.map((s) => stripQuotes(s.trim()))
.filter((s) => s.length > 0);
try {
validateExtraArgs(parsed);
data.llama_extra_args = parsed;
} catch (err) {
errors.push(err instanceof Error ? err.message : String(err));
}
}
}
// Unknown keys silently ignored — forward-compat.
}
@@ -328,6 +358,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
steps: typeof fm.steps === 'number' ? fm.steps : null,
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
};
}

View File

@@ -9,6 +9,7 @@ import * as modelContext from '../model-context.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage } from './parts.js';
import type { PartInsert } from './parts.js';
import { stripToolMarkup } from './tool-call-parser.js';
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
export async function handleAbortOrError(
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
const errMsg = err instanceof Error ? err.message : String(err);
accumulated = stripToolMarkup(accumulated, { final: true });
// v1.8.2: persist a structured error metadata blob on genuine failures so
// the bubble can render the reason on reload without re-deriving from the
// (one-shot) WS error frame. User-initiated abort skips this — there's no
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
session: Session
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, finishReason, promptTokens, completionTokens } = result;
const content = stripToolMarkup(result.content, { final: true });
const { finishReason, promptTokens, completionTokens } = result;
// v1.11.3: see executeToolPhase for the rationale.
const mctx = await modelContext.getModelContext(session.model);

View File

@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
// Ported from studio/backend/core/inference/llama_server_args.py.
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/llama_server_args.py
// Each group is the full set of aliases (short + long) for one hard-denied
// flag, taken from the llama-server README. Flags NOT in this list pass
// through and override auto-set values via llama.cpp's last-wins CLI parsing.
const DENYLIST_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
// Model identity
new Set(['-m', '--model']),
new Set(['-mu', '--model-url']),
new Set(['-dr', '--docker-repo']),
new Set(['-hf', '-hfr', '--hf-repo']),
new Set(['-hff', '--hf-file']),
new Set(['-hfv', '-hfrv', '--hf-repo-v']),
new Set(['-hffv', '--hf-file-v']),
new Set(['-hft', '--hf-token']),
new Set(['-mm', '--mmproj']),
new Set(['-mmu', '--mmproj-url']),
// Networking
new Set(['--host']),
new Set(['--port']),
new Set(['--path']),
new Set(['--api-prefix']),
new Set(['--reuse-port']),
// Auth / TLS
new Set(['--api-key']),
new Set(['--api-key-file']),
new Set(['--ssl-key-file']),
new Set(['--ssl-cert-file']),
// Single-model server / UI
new Set(['--webui', '--no-webui']),
new Set(['--ui', '--no-ui']),
new Set(['--ui-config']),
new Set(['--ui-config-file']),
new Set(['--ui-mcp-proxy', '--no-ui-mcp-proxy']),
new Set(['--models-dir']),
new Set(['--models-preset']),
new Set(['--models-max']),
new Set(['--models-autoload', '--no-models-autoload']),
];
const DENYLIST: ReadonlySet<string> = new Set(
DENYLIST_GROUPS.flatMap((g) => [...g]),
);
function flagName(token: string): string | null {
if (!token.startsWith('-') || token === '-' || token === '--') return null;
if (token.length >= 2 && (token[1]!.match(/\d/) || token[1] === '.')) return null;
return token.split('=', 1)[0]!;
}
export function validateExtraArgs(args?: Iterable<string>): string[] {
if (!args) return [];
const out: string[] = [];
for (const raw of args) {
const token = String(raw);
const flag = flagName(token);
if (flag !== null && DENYLIST.has(flag)) {
throw new Error(
`llama-server flag '${flag}' is managed and cannot be passed as an extra arg`,
);
}
out.push(token);
}
return out;
}
export function isManagedFlag(flag: string): boolean {
return DENYLIST.has(flag);
}
// Shadowing flag groups: pass-through flags that shadow first-class settings.
const CONTEXT_FLAGS = new Set(['-c', '--ctx-size']);
const CACHE_FLAGS = new Set(['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']);
const SPEC_FLAGS = new Set([
'--spec-default',
'--spec-type',
'--spec-ngram-size-n',
'--spec-ngram-size',
'--draft-min',
'--draft-max',
'--spec-draft-n-max',
'--spec-draft-n-min',
'--spec-draft-p-min',
'--spec-draft-p-split',
'--spec-ngram-mod-n-match',
'--spec-ngram-mod-n-min',
'--spec-ngram-mod-n-max',
]);
const TEMPLATE_FLAGS = new Set([
'--chat-template',
'--chat-template-file',
'--chat-template-kwargs',
'--jinja',
'--no-jinja',
]);
const BOOLEAN_SHADOWING_FLAGS = new Set([
'--spec-default', '--jinja', '--no-jinja',
]);
export interface StripOptions {
stripContext?: boolean;
stripCache?: boolean;
stripSpec?: boolean;
stripTemplate?: boolean;
}
export function stripShadowingFlags(
args: Iterable<string>,
opts?: StripOptions,
): string[] {
const shadowing = new Set<string>();
if (opts?.stripContext !== false) for (const f of CONTEXT_FLAGS) shadowing.add(f);
if (opts?.stripCache !== false) for (const f of CACHE_FLAGS) shadowing.add(f);
if (opts?.stripSpec !== false) for (const f of SPEC_FLAGS) shadowing.add(f);
if (opts?.stripTemplate !== false) for (const f of TEMPLATE_FLAGS) shadowing.add(f);
const tokens = [...args].map(String);
const out: string[] = [];
let i = 0;
const n = tokens.length;
while (i < n) {
const tok = tokens[i]!;
const flag = flagName(tok);
if (flag === null || !shadowing.has(flag)) {
out.push(tok);
i++;
continue;
}
if (BOOLEAN_SHADOWING_FLAGS.has(flag) || tok.includes('=')) {
i++;
} else if (i + 1 < n && flagName(tokens[i + 1]!) === null) {
i += 2;
} else {
i++;
}
}
return out;
}

View File

@@ -1,6 +1,9 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
// TODO: When per-agent llama-server flag overrides are added, route them
// through validateExtraArgs (./llama-args-validator.ts) first.
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
// upstream without touching env vars. No apiKey — llama-swap is unauth in our

View File

@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import type { OpenAiMessage } from './payload.js';
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
import { extractToolCallBlocks } from './xml-parser.js';
import { extractToolCallBlocks } from './tool-call-parser.js';
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
import type {
InferenceContext,

View File

@@ -0,0 +1,426 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
// Ported from studio/backend/core/inference/tool_call_parser.py.
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/tool_call_parser.py
// ── Constants ────────────────────────────────────────────────────────────
export const XML_TOOL_OPEN = '<tool_call>';
export const XML_TOOL_CLOSE = '</tool_call>';
export const INVOKE_TOOL_OPEN = '<invoke';
export const INVOKE_TOOL_CLOSE = '</invoke>';
export const TOOL_XML_SIGNALS = [XML_TOOL_OPEN, '<function=', INVOKE_TOOL_OPEN] as const;
export const TOOL_ERROR_PREFIXES = [
'Error',
'Search failed',
'Execution error',
'Blocked:',
'Exit code',
'Failed to fetch',
'Failed to resolve',
'No query provided',
] as const;
export const DUPLICATE_CALL_NUDGE =
'You already made this exact call. Do not repeat the same tool ' +
'call. Try a different approach: fetch a URL from previous ' +
'results, use Python to process data you already have, or ' +
'provide your final answer now.';
export const TOOL_ERROR_NUDGE =
'\n\nThe tool call encountered an issue. Please try a different ' +
'approach or rephrase your request.';
export const BUDGET_EXHAUSTED_NUDGE =
'You have used all available tool calls. Based on everything you ' +
'have found so far, provide your final answer now. Do not call ' +
'any more tools.';
// ── Strip patterns ───────────────────────────────────────────────────────
const TOOL_CLOSED_PATS = [
/<tool_call>.*?<\/tool_call>/gs,
/<function=\w+>.*?<\/function>/gs,
/<invoke\s[^>]*>.*?<\/invoke>/gs,
];
const TOOL_ALL_PATS = [
...TOOL_CLOSED_PATS,
/<tool_call>.*$/gs,
/<function=\w+>.*$/gs,
/<invoke\s[^>]*>.*$/gs,
];
// ── Strip / signal ───────────────────────────────────────────────────────
export function stripToolMarkup(text: string, opts?: { final?: boolean }): string {
const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS;
for (const pat of pats) {
text = text.replace(pat, '');
}
return opts?.final ? text.trim() : text;
}
export function hasToolSignal(text: string): boolean {
return TOOL_XML_SIGNALS.some((s) => text.includes(s));
}
// ── parseToolCallsFromText (Unsloth port + Anthropic extension) ──────────
export interface OpenAiToolCall {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
const TC_JSON_START_RE = /<tool_call>\s*\{/g;
const TC_FUNC_START_RE = /<function=(\w+)>\s*/g;
const TC_END_TAG_RE = /<\/tool_call>/;
const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
const TC_PARAM_START_RE = /<parameter=(\w+)>\s*/g;
const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
const TC_INVOKE_START_RE = /<invoke\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
const TC_INVOKE_PARAM_RE = /<parameter\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
const TC_INVOKE_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
function scanBalancedBraces(content: string, start: number): number {
let depth = 0;
let i = start;
let inString = false;
while (i < content.length) {
const ch = content[i]!;
if (inString) {
if (ch === '\\' && i + 1 < content.length) {
i += 2;
continue;
}
if (ch === '"') inString = false;
} else if (ch === '"') {
inString = true;
} else if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}
export function parseToolCallsFromText(
content: string,
opts?: { idOffset?: number },
): OpenAiToolCall[] {
const toolCalls: OpenAiToolCall[] = [];
const idOffset = opts?.idOffset ?? 0;
// Pattern 1: <tool_call>{json}</tool_call> -- balanced-brace JSON scanner.
// Skips braces inside JSON strings so nested objects parse correctly.
TC_JSON_START_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TC_JSON_START_RE.exec(content)) !== null) {
const braceStart = m.index + m[0].length - 1;
const braceEnd = scanBalancedBraces(content, braceStart);
if (braceEnd === -1) continue;
const jsonStr = content.slice(braceStart, braceEnd + 1);
try {
const obj = JSON.parse(jsonStr) as Record<string, unknown>;
const name = typeof obj.name === 'string' ? obj.name : '';
let args: string;
const rawArgs = obj.arguments ?? {};
if (typeof rawArgs === 'string') {
args = rawArgs;
} else {
args = JSON.stringify(rawArgs);
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name, arguments: args },
});
} catch {
// malformed JSON -- skip
}
}
// Pattern 2: <function=name><parameter=key>value -- closing tags optional.
// Body boundary uses </tool_call> or next <function= (not </function>,
// because code parameter values can contain that literal).
if (toolCalls.length === 0) {
TC_FUNC_START_RE.lastIndex = 0;
const funcStarts: Array<{ match: RegExpExecArray; name: string }> = [];
while ((m = TC_FUNC_START_RE.exec(content)) !== null) {
funcStarts.push({ match: m, name: m[1]! });
}
for (let idx = 0; idx < funcStarts.length; idx++) {
const { match: fm, name: funcName } = funcStarts[idx]!;
const bodyStart = fm.index + fm[0].length;
const nextFunc = idx + 1 < funcStarts.length
? funcStarts[idx + 1]!.match.index
: content.length;
const endTag = TC_END_TAG_RE.exec(content.slice(bodyStart));
let bodyEnd = endTag ? bodyStart + endTag.index : content.length;
bodyEnd = Math.min(bodyEnd, nextFunc);
let body = content.slice(bodyStart, bodyEnd);
body = body.replace(TC_FUNC_CLOSE_RE, '');
const args: Record<string, string> = {};
TC_PARAM_START_RE.lastIndex = 0;
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
let pm: RegExpExecArray | null;
while ((pm = TC_PARAM_START_RE.exec(body)) !== null) {
paramStarts.push({ match: pm, name: pm[1]! });
}
if (paramStarts.length === 1) {
// Single param: take everything to body end so embedded
// </parameter> in code strings is preserved.
const p = paramStarts[0]!;
let val = body.slice(p.match.index + p.match[0].length);
val = val.replace(TC_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
} else {
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
const p = paramStarts[pidx]!;
const valStart = p.match.index + p.match[0].length;
const nextParam = pidx + 1 < paramStarts.length
? paramStarts[pidx + 1]!.match.index
: body.length;
let val = body.slice(valStart, nextParam);
val = val.replace(TC_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
}
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name: funcName, arguments: JSON.stringify(args) },
});
}
}
// Pattern 3: <invoke name="..."><parameter name="...">value -- Anthropic
// shape that qwen3.6 drifts to from Claude Code documentation residue.
// Closing tags optional; same single-param fast path as pattern 2.
if (toolCalls.length === 0) {
TC_INVOKE_START_RE.lastIndex = 0;
const invokeStarts: Array<{ match: RegExpExecArray; name: string }> = [];
while ((m = TC_INVOKE_START_RE.exec(content)) !== null) {
const name = (m[1] ?? m[2] ?? '').trim();
if (name) invokeStarts.push({ match: m, name });
}
for (let idx = 0; idx < invokeStarts.length; idx++) {
const { match: im, name: invokeName } = invokeStarts[idx]!;
const bodyStart = im.index + im[0].length;
const nextInvoke = idx + 1 < invokeStarts.length
? invokeStarts[idx + 1]!.match.index
: content.length;
const closeTag = content.slice(bodyStart).match(/<\/invoke>/);
let bodyEnd = closeTag ? bodyStart + (closeTag.index ?? 0) : content.length;
bodyEnd = Math.min(bodyEnd, nextInvoke);
let body = content.slice(bodyStart, bodyEnd);
body = body.replace(TC_INVOKE_CLOSE_RE, '');
const args: Record<string, string> = {};
TC_INVOKE_PARAM_RE.lastIndex = 0;
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
let pm: RegExpExecArray | null;
while ((pm = TC_INVOKE_PARAM_RE.exec(body)) !== null) {
const pname = (pm[1] ?? pm[2] ?? '').trim();
if (pname) paramStarts.push({ match: pm, name: pname });
}
if (paramStarts.length === 1) {
const p = paramStarts[0]!;
let val = body.slice(p.match.index + p.match[0].length);
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
} else {
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
const p = paramStarts[pidx]!;
const valStart = p.match.index + p.match[0].length;
const nextParam = pidx + 1 < paramStarts.length
? paramStarts[pidx + 1]!.match.index
: body.length;
let val = body.slice(valStart, nextParam);
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
args[p.name] = val.trim();
}
}
toolCalls.push({
id: `call_${idOffset + toolCalls.length}`,
type: 'function',
function: { name: invokeName, arguments: JSON.stringify(args) },
});
}
}
return toolCalls;
}
// ── BooCode streaming helpers ────────────────────────────────────────────
export interface ParsedCall {
name: string;
args: Record<string, unknown>;
}
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
export function isPlaceholderArgValue(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (trimmed === '') return true;
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
return false;
}
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
for (const value of Object.values(args)) {
if (isPlaceholderArgValue(value)) return true;
}
return false;
}
function logRejectedPlaceholder(parsed: ParsedCall): void {
console.debug(
{ toolName: parsed.name, args: parsed.args },
'rejected placeholder tool call at parse time',
);
}
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 };
}
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 };
}
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;
}
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) {
if (hasPlaceholderArgs(parsed.args)) {
logRejectedPlaceholder(parsed);
flushed += block;
} else {
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) };
}

View File

@@ -14,6 +14,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
// Resolves the grant root before pausing the loop so the user is never
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js';
import type {
InferenceContext,
StreamResult,
@@ -100,7 +101,8 @@ export async function executeToolPhase(
projectRoot: string
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, toolCalls, promptTokens, completionTokens } = result;
const content = stripToolMarkup(result.content, { final: true });
const { toolCalls, promptTokens, completionTokens } = result;
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
// streaming completion (which doesn't emit n_ctx). getModelContext caches

View File

@@ -1,204 +0,0 @@
// 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>;
}
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
/** True when a string arg looks like a model placeholder, not a real path/value. */
export function isPlaceholderArgValue(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (trimmed === '') return true;
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
return false;
}
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
for (const value of Object.values(args)) {
if (isPlaceholderArgValue(value)) return true;
}
return false;
}
function logRejectedPlaceholder(parsed: ParsedCall): void {
// Pure helper — no Fastify logger here (stream-phase.ts stays unchanged).
console.debug(
{ toolName: parsed.name, args: parsed.args },
'rejected placeholder tool call at parse time',
);
}
// 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) {
if (hasPlaceholderArgs(parsed.args)) {
logRejectedPlaceholder(parsed);
flushed += block;
} else {
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) };
}

View File

@@ -0,0 +1,347 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
// Ported from studio/backend/core/inference/_html_to_md.py.
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/_html_to_md.py
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
type Document = DefaultTreeAdapterTypes.Document;
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
type Element = DefaultTreeAdapterTypes.Element;
type TextNode = DefaultTreeAdapterTypes.TextNode;
const SKIP_TAGS = new Set([
'script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer',
]);
const BLOCK_TAGS = new Set([
'p', 'div', 'section', 'article', 'main', 'aside', 'figure',
'figcaption', 'details', 'summary', 'dl', 'dt', 'dd',
]);
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const INLINE_EMPHASIS: Record<string, string> = {
strong: '**', b: '**', em: '*', i: '*',
};
function isElement(node: ChildNode): node is Element {
return 'tagName' in node;
}
function isText(node: ChildNode): node is TextNode {
return node.nodeName === '#text';
}
class MarkdownRenderer {
private out: string[] = [];
private inLink = false;
private linkHref: string | null = null;
private linkTextParts: string[] = [];
private listStack: string[] = [];
private olCounter: number[] = [];
private inTable = false;
private currentRow: string[] = [];
private cellParts: string[] = [];
private inCell = false;
private headerRowDone = false;
private rowHasTh = false;
private isFirstRow = false;
private inPre = false;
private preParts: string[] = [];
private preLanguage: string | null = null;
private inInlineCode = false;
private bqStack: string[][] = [];
private emit(text: string): void {
if (this.inLink) {
this.linkTextParts.push(text);
} else if (this.inCell) {
this.cellParts.push(text);
} else if (this.inPre) {
this.preParts.push(text);
} else if (this.bqStack.length > 0) {
this.bqStack[this.bqStack.length - 1]!.push(text);
} else {
this.out.push(text);
}
}
private prefixBlockquote(content: string): string {
content = content.replace(/[ \t]+$/gm, '');
content = content.replace(/\n{3,}/g, '\n\n').trim();
if (!content) return '';
return content.split('\n').map(line =>
line.trim() ? '> ' + line : '>'
).join('\n');
}
private finishCell(): void {
if (!this.inCell) return;
this.inCell = false;
let cellText = this.cellParts.join('').trim().replace(/\n/g, ' ');
cellText = cellText.replace(/\|/g, '\\|');
this.currentRow.push(cellText);
this.cellParts = [];
}
private finishRow(): void {
if (this.currentRow.length === 0) return;
const line = '| ' + this.currentRow.join(' | ') + ' |';
this.emit(line + '\n');
if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
const sep = '| ' + this.currentRow.map(() => '---').join(' | ') + ' |';
this.emit(sep + '\n');
this.headerRowDone = true;
}
this.isFirstRow = false;
this.currentRow = [];
this.rowHasTh = false;
}
private finishLink(): void {
const text = this.linkTextParts.join('').replace(/\s+/g, ' ').trim();
const href = this.linkHref ?? '';
this.inLink = false;
if (href && text) {
this.emit(`[${text}](${href})`);
} else if (text) {
this.emit(text);
}
}
private getAttr(el: Element, name: string): string | undefined {
return el.attrs.find(a => a.name === name)?.value;
}
private handleOpen(el: Element): void {
const tag = el.tagName.toLowerCase();
if (HEADING_TAGS.has(tag)) {
const level = parseInt(tag[1]!, 10);
this.emit('\n\n' + '#'.repeat(level) + ' ');
} else if (tag === 'a') {
this.linkHref = this.getAttr(el, 'href') ?? null;
this.linkTextParts = [];
this.inLink = true;
} else if (tag in INLINE_EMPHASIS) {
this.emit(INLINE_EMPHASIS[tag]!);
} else if (tag === 'br') {
this.emit('\n');
} else if (BLOCK_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'hr') {
this.emit('\n\n---\n\n');
} else if (tag === 'blockquote') {
this.emit('\n\n');
this.bqStack.push([]);
} else if (tag === 'ul') {
this.listStack.push('ul');
this.emit('\n');
} else if (tag === 'ol') {
this.listStack.push('ol');
const startAttr = this.getAttr(el, 'start');
let start = 1;
if (startAttr != null) {
const parsed = parseInt(startAttr, 10);
if (!isNaN(parsed)) start = parsed;
}
this.olCounter.push(start - 1);
this.emit('\n');
} else if (tag === 'li') {
const indent = ' '.repeat(Math.max(0, this.listStack.length - 1));
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
if (this.olCounter.length > 0) {
this.olCounter[this.olCounter.length - 1]!++;
this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
} else {
this.emit(`\n${indent}1. `);
}
} else {
this.emit(`\n${indent}* `);
}
} else if (tag === 'pre') {
this.preParts = [];
this.inPre = true;
this.preLanguage = null;
const codeChild = el.childNodes.find(
(c): c is Element => isElement(c) && c.tagName === 'code'
);
if (codeChild) {
const cls = this.getAttr(codeChild, 'class') ?? '';
const langMatch = cls.match(/(?:^|\s)language-(\S+)/);
if (langMatch) this.preLanguage = langMatch[1]!;
}
} else if (tag === 'code' && !this.inPre) {
this.inInlineCode = true;
this.emit('`');
} else if (tag === 'table') {
this.inTable = true;
this.headerRowDone = false;
this.isFirstRow = true;
this.emit('\n\n');
} else if (tag === 'tr') {
this.finishCell();
this.finishRow();
} else if (tag === 'th' || tag === 'td') {
this.finishCell();
this.cellParts = [];
this.inCell = true;
if (tag === 'th') this.rowHasTh = true;
}
}
private handleClose(tag: string): void {
tag = tag.toLowerCase();
if (HEADING_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'a') {
this.finishLink();
} else if (tag in INLINE_EMPHASIS) {
this.emit(INLINE_EMPHASIS[tag]!);
} else if (BLOCK_TAGS.has(tag)) {
this.emit('\n\n');
} else if (tag === 'blockquote') {
if (this.bqStack.length > 0) {
const content = this.bqStack.pop()!.join('');
const prefixed = this.prefixBlockquote(content);
if (prefixed) this.emit('\n\n' + prefixed + '\n\n');
}
} else if (tag === 'ul') {
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ul') {
this.listStack.pop();
}
this.emit('\n');
} else if (tag === 'ol') {
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
this.listStack.pop();
if (this.olCounter.length > 0) this.olCounter.pop();
}
this.emit('\n');
} else if (tag === 'pre') {
const raw = this.preParts.join('');
this.inPre = false;
const lang = this.preLanguage ?? '';
const block = '```' + lang + '\n' + raw + '\n```';
this.emit('\n\n' + block + '\n\n');
this.preLanguage = null;
} else if (tag === 'code' && !this.inPre) {
this.inInlineCode = false;
this.emit('`');
} else if (tag === 'th' || tag === 'td') {
this.finishCell();
} else if (tag === 'tr') {
this.finishCell();
this.finishRow();
} else if (tag === 'table') {
this.finishCell();
this.finishRow();
this.inTable = false;
this.emit('\n');
}
}
private handleText(data: string): void {
if (this.inPre) {
this.preParts.push(data);
return;
}
if (this.inInlineCode) {
this.emit(data);
return;
}
const text = data.replace(/\s+/g, ' ');
if (this.inTable && !this.inCell && !text.trim()) return;
this.emit(text);
}
walk(node: ChildNode | Document): void {
if (isText(node as ChildNode)) {
this.handleText((node as TextNode).value);
return;
}
if (node.nodeName === '#comment') return;
if (isElement(node as ChildNode)) {
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (SKIP_TAGS.has(tag)) return;
if (tag === 'img') return;
this.handleOpen(el);
if (tag === 'pre') {
for (const child of el.childNodes) {
if (isElement(child) && child.tagName === 'code') {
for (const grandchild of child.childNodes) {
this.walk(grandchild);
}
} else {
this.walk(child);
}
}
} else {
for (const child of el.childNodes) {
this.walk(child);
}
}
this.handleClose(tag);
return;
}
if ('childNodes' in node) {
for (const child of (node as Document).childNodes) {
this.walk(child);
}
}
}
getOutput(): string {
return this.out.join('');
}
}
function cleanup(text: string): string {
const lines = text.split('\n');
const out: string[] = [];
let inFence = false;
let blankRun = 0;
for (const line of lines) {
const stripped = line.replace(/[ \t]+$/, '');
if (stripped.startsWith('```')) {
inFence = !inFence;
blankRun = 0;
out.push(stripped);
continue;
}
if (inFence) {
out.push(line);
continue;
}
if (!stripped) {
blankRun++;
if (blankRun <= 1) out.push('');
continue;
}
blankRun = 0;
out.push(stripped);
}
return out.join('\n').trim();
}
export function htmlToMarkdown(sourceHtml: string): string {
sourceHtml = sourceHtml.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const doc = parse(sourceHtml);
const renderer = new MarkdownRenderer();
renderer.walk(doc);
return cleanup(renderer.getOutput());
}

View File

@@ -0,0 +1 @@
export { htmlToMarkdown } from './html-to-md.js';

View File

@@ -12,6 +12,7 @@ import { z } from 'zod';
import { isPublicUrl } from './url_guard.js';
import type { ToolDef } from './tools.js';
import { truncateIfNeeded } from './truncate.js';
import { htmlToMarkdown } from './web/index.js';
const WebFetchInput = z.object({
url: z.string().min(1).max(2048),
@@ -38,29 +39,9 @@ export type WebFetchOutput =
}
| { error: string; reason: string; content_type?: string };
function stripHtml(html: string): { text: string; title: string | undefined } {
// Title first, before we destroy the markup. Trim collapsed whitespace.
function extractTitle(html: string): string | undefined {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
// Drop script + style + comments entirely (their CONTENT must not leak —
// a regex tag stripper alone would expose inline JS as plain text).
const text = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
// Minimal entity decode — full coverage would need a table; covering
// the five common ones plus &nbsp; is enough for snippet readability.
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
return { text, title };
return titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
}
// v1.11.10: streaming body reader. Aborts the response stream the instant
@@ -211,9 +192,8 @@ export async function executeWebFetch(
let textRaw: string;
let title: string | undefined;
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
const stripped = stripHtml(body);
textRaw = stripped.text;
title = stripped.title;
title = extractTitle(body);
textRaw = htmlToMarkdown(body);
} else if (
contentType.includes('text/plain') ||
contentType.includes('text/markdown') ||

View File

@@ -113,6 +113,7 @@ export interface Agent {
// v1.14.0: per-agent step cap for the outer inference loop. null means
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
llama_extra_args: string[] | null;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the

16
pnpm-lock.yaml generated
View File

@@ -155,6 +155,9 @@ importers:
fastify:
specifier: ^4.28.1
version: 4.29.1
parse5:
specifier: ^8.0.1
version: 8.0.1
postgres:
specifier: ^3.4.4
version: 3.4.9
@@ -2382,6 +2385,10 @@ packages:
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
engines: {node: '>=10.13.0'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -3274,6 +3281,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -6110,6 +6120,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@8.0.0: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -7267,6 +7279,10 @@ snapshots:
parse-ms@4.0.0: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
parseurl@1.3.3: {}
path-browserify@1.0.1: {}