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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseXmlToolCall,
|
parseXmlToolCall,
|
||||||
parseInvokeToolCall,
|
parseInvokeToolCall,
|
||||||
partialXmlOpenerStart,
|
partialXmlOpenerStart,
|
||||||
extractToolCallBlocks,
|
extractToolCallBlocks,
|
||||||
|
parseToolCallsFromText,
|
||||||
|
stripToolMarkup,
|
||||||
|
hasToolSignal,
|
||||||
XML_TOOL_OPEN,
|
XML_TOOL_OPEN,
|
||||||
XML_TOOL_CLOSE,
|
XML_TOOL_CLOSE,
|
||||||
INVOKE_TOOL_OPEN,
|
INVOKE_TOOL_OPEN,
|
||||||
INVOKE_TOOL_CLOSE,
|
INVOKE_TOOL_CLOSE,
|
||||||
} from '../inference/xml-parser.js';
|
TOOL_XML_SIGNALS,
|
||||||
import {
|
BUDGET_EXHAUSTED_NUDGE,
|
||||||
levenshtein,
|
DUPLICATE_CALL_NUDGE,
|
||||||
suggestToolName,
|
TOOL_ERROR_NUDGE,
|
||||||
formatUnknownToolError,
|
TOOL_ERROR_PREFIXES,
|
||||||
} from '../inference/tool-suggestions.js';
|
} from '../inference/tool-call-parser.js';
|
||||||
|
|
||||||
|
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||||
|
|
||||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||||
it('parses a well-formed single-parameter 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', () => {
|
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||||
// Spec case 1
|
|
||||||
it('parses a well-formed single-parameter call (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>';
|
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
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)', () => {
|
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>';
|
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
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)', () => {
|
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||||
const block = `<invoke
|
const block = `<invoke
|
||||||
name="view_file"
|
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)', () => {
|
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>';
|
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
@@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||||
// Spec case 1 (extraction-level)
|
|
||||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(result.remaining).toBe('');
|
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)', () => {
|
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 firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||||
const result = extractToolCallBlocks(firstChunk);
|
const result = extractToolCallBlocks(firstChunk);
|
||||||
@@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(r2.remaining).toBe('');
|
expect(r2.remaining).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 6: prose interleaving
|
|
||||||
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
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 input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(result.remaining).toBe('');
|
expect(result.remaining).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 7 regression
|
|
||||||
it('extracts a <tool_call> Qwen block alongside the new code path (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 input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('levenshtein', () => {
|
// ── New tests: Unsloth-ported functions ──────────────────────────────────
|
||||||
it('returns 0 for identical strings', () => {
|
|
||||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
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', () => {
|
it('returns true for <function=', () => {
|
||||||
expect(levenshtein('', 'view_file')).toBe(9);
|
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
|
||||||
expect(levenshtein('view_file', '')).toBe(9);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes a small distance for a single-character substitution', () => {
|
it('returns true for <invoke', () => {
|
||||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes a known case: read_file → view_file is 4', () => {
|
it('returns false for near-miss <tool>', () => {
|
||||||
// r→v, e→i, a→e, d→w → 4 substitutions, same length
|
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
|
||||||
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
});
|
||||||
|
|
||||||
|
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)', () => {
|
describe('stripToolMarkup', () => {
|
||||||
const tools = [
|
it('strips closed <tool_call> blocks', () => {
|
||||||
'view_file',
|
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
|
||||||
'list_dir',
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
'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', () => {
|
it('strips closed <function=...> blocks', () => {
|
||||||
// 'file' is a substring of multiple tools; closest by distance wins.
|
const input = 'before <function=x><parameter=y>z</parameter></function> after';
|
||||||
expect(suggestToolName('file', tools)).toBe('view_file');
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when nothing is close', () => {
|
it('strips closed <invoke> blocks', () => {
|
||||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
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', () => {
|
it('leaves trailing unclosed block when final=false', () => {
|
||||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
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)', () => {
|
describe('parseToolCallsFromText', () => {
|
||||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
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', () => {
|
it('handles string arguments field', () => {
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
|
||||||
expect(msg).toContain("Tool 'read_file' not found");
|
const calls = parseToolCallsFromText(input);
|
||||||
expect(msg).toContain('Available tools:');
|
expect(calls[0]!.function.arguments).toBe('already a string');
|
||||||
expect(msg).toContain('view_file');
|
});
|
||||||
expect(msg).toContain('find_files');
|
|
||||||
|
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', () => {
|
describe('pattern 2: <function=name><parameter=key>value', () => {
|
||||||
// distance(view_files, view_file) = 1 (one extra char)
|
it('parses a single-parameter function call', () => {
|
||||||
const msg = formatUnknownToolError('view_files', tools);
|
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
|
||||||
expect(msg).toContain('Did you mean: view_file?');
|
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', () => {
|
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
|
||||||
const msg = formatUnknownToolError('zzzzzzz', tools);
|
it('parses a single-parameter invoke call', () => {
|
||||||
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(msg).toContain('Available tools:');
|
const calls = parseToolCallsFromText(input);
|
||||||
expect(msg).not.toContain('Did you mean');
|
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
|
it('parses multi-parameter invoke call', () => {
|
||||||
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
|
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
// the spec's threshold (<=3) doesn't suggest view_file — the model still
|
const calls = parseToolCallsFromText(input);
|
||||||
// gets the available-tools list to pick from. This pins that behavior so a
|
expect(calls).toHaveLength(1);
|
||||||
// future loosening of the threshold is a deliberate choice.
|
const args = JSON.parse(calls[0]!.function.arguments);
|
||||||
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
expect(args.pattern).toBe('foo');
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
expect(args.path).toBe('src/');
|
||||||
expect(msg).not.toContain('Did you mean');
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||||
import { ALL_TOOLS, resolveToolTier } from './tools.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
|
// 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
|
// (./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
|
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||||
// allowed" — the model responds text-only.
|
// allowed" — the model responds text-only.
|
||||||
steps?: number;
|
steps?: number;
|
||||||
|
llama_extra_args?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -227,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
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.
|
// 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,
|
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,
|
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||||
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||||
|
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||||
import type { PartInsert } from './parts.js';
|
import type { PartInsert } from './parts.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||||
|
|
||||||
export async function handleAbortOrError(
|
export async function handleAbortOrError(
|
||||||
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
|
|||||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
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
|
// 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
|
// 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
|
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
||||||
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
|
|||||||
session: Session
|
session: Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
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.
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
const mctx = await modelContext.getModelContext(session.model);
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
|||||||
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
142
apps/server/src/services/inference/llama-args-validator.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
import type { LanguageModel } from 'ai';
|
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
|
// 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
|
// 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
|
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import type { OpenAiMessage } from './payload.js';
|
import type { OpenAiMessage } from './payload.js';
|
||||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
import { extractToolCallBlocks } from './tool-call-parser.js';
|
||||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
|
||||||
import { extractToolCallBlocks } from './xml-parser.js';
|
|
||||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
|
|||||||
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
426
apps/server/src/services/inference/tool-call-parser.ts
Normal 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) };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
|
|||||||
// Resolves the grant root before pausing the loop so the user is never
|
// 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).
|
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
StreamResult,
|
StreamResult,
|
||||||
@@ -100,7 +101,8 @@ export async function executeToolPhase(
|
|||||||
projectRoot: string
|
projectRoot: string
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
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
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
||||||
|
|||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
347
apps/server/src/services/web/html-to-md.ts
Normal file
347
apps/server/src/services/web/html-to-md.ts
Normal 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());
|
||||||
|
}
|
||||||
1
apps/server/src/services/web/index.ts
Normal file
1
apps/server/src/services/web/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { htmlToMarkdown } from './html-to-md.js';
|
||||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|||||||
import { isPublicUrl } from './url_guard.js';
|
import { isPublicUrl } from './url_guard.js';
|
||||||
import type { ToolDef } from './tools.js';
|
import type { ToolDef } from './tools.js';
|
||||||
import { truncateIfNeeded } from './truncate.js';
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
import { htmlToMarkdown } from './web/index.js';
|
||||||
|
|
||||||
const WebFetchInput = z.object({
|
const WebFetchInput = z.object({
|
||||||
url: z.string().min(1).max(2048),
|
url: z.string().min(1).max(2048),
|
||||||
@@ -38,29 +39,9 @@ export type WebFetchOutput =
|
|||||||
}
|
}
|
||||||
| { error: string; reason: string; content_type?: string };
|
| { error: string; reason: string; content_type?: string };
|
||||||
|
|
||||||
function stripHtml(html: string): { text: string; title: string | undefined } {
|
function extractTitle(html: string): string | undefined {
|
||||||
// Title first, before we destroy the markup. Trim collapsed whitespace.
|
|
||||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
|
return 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 is enough for snippet readability.
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return { text, title };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
||||||
@@ -211,9 +192,8 @@ export async function executeWebFetch(
|
|||||||
let textRaw: string;
|
let textRaw: string;
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
||||||
const stripped = stripHtml(body);
|
title = extractTitle(body);
|
||||||
textRaw = stripped.text;
|
textRaw = htmlToMarkdown(body);
|
||||||
title = stripped.title;
|
|
||||||
} else if (
|
} else if (
|
||||||
contentType.includes('text/plain') ||
|
contentType.includes('text/plain') ||
|
||||||
contentType.includes('text/markdown') ||
|
contentType.includes('text/markdown') ||
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface Agent {
|
|||||||
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
// 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."
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
steps: number | null;
|
steps: number | null;
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -155,6 +155,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^4.28.1
|
specifier: ^4.28.1
|
||||||
version: 4.29.1
|
version: 4.29.1
|
||||||
|
parse5:
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.4
|
specifier: ^3.4.4
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
@@ -2382,6 +2385,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
||||||
engines: {node: '>=10.13.0'}
|
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:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3274,6 +3281,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -6110,6 +6120,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
entities@8.0.0: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
@@ -7267,6 +7279,10 @@ snapshots:
|
|||||||
|
|
||||||
parse-ms@4.0.0: {}
|
parse-ms@4.0.0: {}
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
entities: 8.0.0
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user