diff --git a/apps/server/src/services/__tests__/html-to-md.test.ts b/apps/server/src/services/__tests__/html-to-md.test.ts
new file mode 100644
index 0000000..33c1bdc
--- /dev/null
+++ b/apps/server/src/services/__tests__/html-to-md.test.ts
@@ -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('
Title
')).toBe('# Title');
+ });
+
+ it('converts h1 through h6', () => {
+ const html = 'One
Two
Three
Four
Five
Six
';
+ 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('click here'))
+ .toBe('[click here](https://example.com)');
+ });
+
+ it('converts anchor without href to plain text', () => {
+ expect(htmlToMarkdown('just text')).toBe('just text');
+ });
+
+ it('converts bold and italic', () => {
+ expect(htmlToMarkdown('bold')).toBe('**bold**');
+ expect(htmlToMarkdown('bold')).toBe('**bold**');
+ expect(htmlToMarkdown('italic')).toBe('*italic*');
+ expect(htmlToMarkdown('italic')).toBe('*italic*');
+ });
+
+ it('handles combined bold+italic', () => {
+ const md = htmlToMarkdown('bold italic');
+ expect(md).toBe('***bold italic***');
+ });
+
+ it('converts unordered list', () => {
+ const html = '';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('* one');
+ expect(md).toContain('* two');
+ expect(md).toContain('* three');
+ });
+
+ it('converts ordered list', () => {
+ const html = '- first
- second
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('1. first');
+ expect(md).toContain('2. second');
+ });
+
+ it('handles nested lists', () => {
+ const html = '';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('* outer');
+ expect(md).toContain(' * inner');
+ });
+
+ it('converts 3-column GFM table with header', () => {
+ const html = `
+
+ | Name | Age | City |
+
+ | Alice | 30 | NYC |
+ | Bob | 25 | LA |
+
+
`;
+ 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 = '';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('x \\| y');
+ });
+
+ it('converts blockquote', () => {
+ const html = 'quoted text
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('> quoted text');
+ });
+
+ it('converts multi-line blockquote', () => {
+ const html = 'line one
line two
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('> line one');
+ expect(md).toContain('> line two');
+ });
+
+ it('converts fenced code block', () => {
+ const html = 'const x = 1;
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('```\nconst x = 1;\n```');
+ });
+
+ it('preserves language hint from code class', () => {
+ const html = 'print("hello")
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('```py\nprint("hello")\n```');
+ });
+
+ it('converts inline code', () => {
+ expect(htmlToMarkdown('use npm install 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 = 'before
after
';
+ const md = htmlToMarkdown(html);
+ expect(md).not.toContain('alert');
+ expect(md).toContain('before');
+ expect(md).toContain('after');
+ });
+
+ it('skips style content', () => {
+ const html = 'text
';
+ const md = htmlToMarkdown(html);
+ expect(md).not.toContain('color');
+ expect(md).toContain('text');
+ });
+
+ it('does not throw on malformed HTML', () => {
+ expect(() => htmlToMarkdown('unclosed bold italic')).not.toThrow();
+ const md = htmlToMarkdown('
unclosed bold 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('
above
below
');
+ expect(md).toContain('---');
+ });
+
+ it('converts br to newline', () => {
+ const md = htmlToMarkdown('line one
line two');
+ expect(md).toContain('line one\nline two');
+ });
+
+ it('handles ol with start attribute', () => {
+ const html = '- five
- six
';
+ const md = htmlToMarkdown(html);
+ expect(md).toContain('5. five');
+ expect(md).toContain('6. six');
+ });
+
+ it('collapses excessive blank lines', () => {
+ const html = 'one
two
';
+ 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 = `
+
+ Test Page
+
+ Welcome
+ This is a test page with a link.
+ Features
+
+ - Fast
+ - Reliable
+ - Secure
+
+ Data
+
+ | Metric | Value |
+
+ | Uptime | 99.9% |
+ | Latency | 42ms |
+
+
+ This tool is amazing.
+ console.log("hello");
+
+
+ `;
+ 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('');
+ });
+});
diff --git a/apps/server/src/services/__tests__/llama-args-validator.test.ts b/apps/server/src/services/__tests__/llama-args-validator.test.ts
new file mode 100644
index 0000000..b1c2792
--- /dev/null
+++ b/apps/server/src/services/__tests__/llama-args-validator.test.ts
@@ -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();
+ });
+});
diff --git a/apps/server/src/services/__tests__/xml-parser.test.ts b/apps/server/src/services/__tests__/tool-call-parser.test.ts
similarity index 54%
rename from apps/server/src/services/__tests__/xml-parser.test.ts
rename to apps/server/src/services/__tests__/tool-call-parser.test.ts
index 6e2bd32..d38944f 100644
--- a/apps/server/src/services/__tests__/xml-parser.test.ts
+++ b/apps/server/src/services/__tests__/tool-call-parser.test.ts
@@ -1,25 +1,24 @@
-// v1.13.16: covers the Qwen/Hermes parser, the new Anthropic
-// parser, the partial-opener detector for both flavors, the unified
-// extraction helper, and the unknown-tool error formatter that downstream
-// dispatch uses to give the model a recovery hint when it drifts to a
-// Claude Code tool name like read_file instead of BooCode's view_file.
-
import { describe, expect, it } from 'vitest';
import {
parseXmlToolCall,
parseInvokeToolCall,
partialXmlOpenerStart,
extractToolCallBlocks,
+ parseToolCallsFromText,
+ stripToolMarkup,
+ hasToolSignal,
XML_TOOL_OPEN,
XML_TOOL_CLOSE,
INVOKE_TOOL_OPEN,
INVOKE_TOOL_CLOSE,
-} from '../inference/xml-parser.js';
-import {
- levenshtein,
- suggestToolName,
- formatUnknownToolError,
-} from '../inference/tool-suggestions.js';
+ TOOL_XML_SIGNALS,
+ BUDGET_EXHAUSTED_NUDGE,
+ DUPLICATE_CALL_NUDGE,
+ TOOL_ERROR_NUDGE,
+ TOOL_ERROR_PREFIXES,
+} from '../inference/tool-call-parser.js';
+
+// ── Ported from xml-parser.test.ts ───────────────────────────────────────
describe('parseXmlToolCall (Qwen/Hermes )', () => {
it('parses a well-formed single-parameter call', () => {
@@ -66,7 +65,6 @@ describe('parseXmlToolCall (Qwen/Hermes )', () => {
});
describe('parseInvokeToolCall (Anthropic ) — v1.13.16', () => {
- // Spec case 1
it('parses a well-formed single-parameter call (spec case 1)', () => {
const block = '/tmp/foo';
expect(parseInvokeToolCall(block)).toEqual({
@@ -75,7 +73,6 @@ describe('parseInvokeToolCall (Anthropic ) — v1.13.16', () => {
});
});
- // Spec case 2
it('parses a multi-parameter call (spec case 2)', () => {
const block = 'foosrc/';
expect(parseInvokeToolCall(block)).toEqual({
@@ -84,7 +81,6 @@ describe('parseInvokeToolCall (Anthropic ) — v1.13.16', () => {
});
});
- // Spec case 3
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
const block = `) — v1.13.16', () => {
});
});
- // Spec case 4 (parser portion — the not-found enrichment is tested below)
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
const block = '/tmp/foo';
expect(parseInvokeToolCall(block)).toEqual({
@@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
});
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
- // Spec case 1 (extraction-level)
it('extracts a single block (spec case 1)', () => {
const input = '/tmp/foo';
const result = extractToolCallBlocks(input);
@@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(result.remaining).toBe('');
});
- // Spec case 5: opener arrives in one chunk, closer in the next.
it('holds the partial chunk when the closer has not arrived (spec case 5, first chunk)', () => {
const firstChunk = '/tmp/foo';
const result = extractToolCallBlocks(firstChunk);
@@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(r2.remaining).toBe('');
});
- // Spec case 6: prose interleaving
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
const input = 'I will read the file.\n/tmp/foo\nThanks.';
const result = extractToolCallBlocks(input);
@@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
expect(result.remaining).toBe('');
});
- // Spec case 7 regression
it('extracts a Qwen block alongside the new code path (spec case 7 regression)', () => {
const input = '/tmp/foo';
const result = extractToolCallBlocks(input);
@@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
});
});
-describe('levenshtein', () => {
- it('returns 0 for identical strings', () => {
- expect(levenshtein('view_file', 'view_file')).toBe(0);
+// ── New tests: Unsloth-ported functions ──────────────────────────────────
+
+describe('hasToolSignal', () => {
+ it('returns true for ', () => {
+ expect(hasToolSignal('prefix suffix')).toBe(true);
});
- it('returns the length when one string is empty', () => {
- expect(levenshtein('', 'view_file')).toBe(9);
- expect(levenshtein('view_file', '')).toBe(9);
+ it('returns true for {
+ expect(hasToolSignal('prefix suffix')).toBe(true);
});
- it('computes a small distance for a single-character substitution', () => {
- expect(levenshtein('cat', 'bat')).toBe(1);
+ it('returns true for {
+ expect(hasToolSignal('prefix suffix')).toBe(true);
});
- it('computes a known case: read_file → view_file is 4', () => {
- // r→v, e→i, a→e, d→w → 4 substitutions, same length
- expect(levenshtein('read_file', 'view_file')).toBe(4);
+ it('returns false for near-miss ', () => {
+ expect(hasToolSignal('prefix suffix')).toBe(false);
+ });
+
+ it('returns false for near-miss ', () => {
+ expect(hasToolSignal('prefix suffix')).toBe(false);
+ });
+
+ it('returns false for near-miss ', () => {
+ expect(hasToolSignal('')).toBe(false);
+ });
+
+ it('returns false for plain text', () => {
+ expect(hasToolSignal('just some text')).toBe(false);
});
});
-describe('suggestToolName (v1.13.16)', () => {
- const tools = [
- 'view_file',
- 'list_dir',
- 'grep',
- 'find_files',
- 'view_truncated_output',
- 'ask_user_input',
- 'web_search',
- ];
-
- it('suggests the closest match when distance is small', () => {
- expect(suggestToolName('view_files', tools)).toBe('view_file');
+describe('stripToolMarkup', () => {
+ it('strips closed blocks', () => {
+ const input = 'before {"name":"x"} after';
+ expect(stripToolMarkup(input)).toBe('before after');
});
- it('suggests via substring match when distance alone would miss', () => {
- // 'file' is a substring of multiple tools; closest by distance wins.
- expect(suggestToolName('file', tools)).toBe('view_file');
+ it('strips closed blocks', () => {
+ const input = 'before z after';
+ expect(stripToolMarkup(input)).toBe('before after');
});
- it('returns null when nothing is close', () => {
- expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
+ it('strips closed blocks', () => {
+ const input = 'before z after';
+ expect(stripToolMarkup(input)).toBe('before after');
});
- it('is case-insensitive in the distance check', () => {
- expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
+ it('leaves trailing unclosed block when final=false', () => {
+ const input = 'text {"name":"x"';
+ expect(stripToolMarkup(input)).toBe('text {"name":"x"');
+ });
+
+ it('strips trailing unclosed when final=true', () => {
+ const input = 'text {"name":"x"';
+ expect(stripToolMarkup(input, { final: true })).toBe('text');
+ });
+
+ it('strips trailing unclosed {
+ const input = 'text ls';
+ expect(stripToolMarkup(input, { final: true })).toBe('text');
+ });
+
+ it('strips trailing unclosed {
+ const input = 'text val';
+ expect(stripToolMarkup(input, { final: true })).toBe('text');
+ });
+
+ it('trims whitespace when final=true', () => {
+ const input = ' text partial';
+ expect(stripToolMarkup(input, { final: true })).toBe('text');
+ });
+
+ it('strips multiple closed blocks', () => {
+ const input = 'a mid b';
+ expect(stripToolMarkup(input)).toBe(' mid ');
});
});
-describe('formatUnknownToolError (v1.13.16)', () => {
- const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
+describe('parseToolCallsFromText', () => {
+ describe('pattern 1: {json}', () => {
+ it('parses a well-formed JSON tool call', () => {
+ const input = '{"name":"web_search","arguments":{"query":"hello"}}';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.id).toBe('call_0');
+ expect(calls[0]!.type).toBe('function');
+ expect(calls[0]!.function.name).toBe('web_search');
+ expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
+ });
- it('includes the wrong name and the available tools list', () => {
- const msg = formatUnknownToolError('read_file', tools);
- expect(msg).toContain("Tool 'read_file' not found");
- expect(msg).toContain('Available tools:');
- expect(msg).toContain('view_file');
- expect(msg).toContain('find_files');
+ it('handles string arguments field', () => {
+ const input = '{"name":"x","arguments":"already a string"}';
+ const calls = parseToolCallsFromText(input);
+ expect(calls[0]!.function.arguments).toBe('already a string');
+ });
+
+ it('handles balanced braces inside JSON strings', () => {
+ const input = '{"name":"x","arguments":{"q":"} { extra "}}';
+ 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 = '{"name":"a","arguments":{}}';
+ const calls = parseToolCallsFromText(input, { idOffset: 5 });
+ expect(calls[0]!.id).toBe('call_5');
+ });
+
+ it('parses multiple JSON tool calls', () => {
+ const input =
+ '{"name":"a","arguments":{}}' +
+ '{"name":"b","arguments":{}}';
+ 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 = '{not json}';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(0);
+ });
+
+ it('handles missing closing tag', () => {
+ const input = '{"name":"x","arguments":{"q":"hello"}}';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.function.name).toBe('x');
+ });
});
- it('includes a suggestion when the drifted name is within threshold', () => {
- // distance(view_files, view_file) = 1 (one extra char)
- const msg = formatUnknownToolError('view_files', tools);
- expect(msg).toContain('Did you mean: view_file?');
+ describe('pattern 2: value', () => {
+ it('parses a single-parameter function call', () => {
+ const input = '/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('single-param fast path preserves embedded ', () => {
+ const input = 'echo ""';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo ""');
+ });
+
+ it('multi-param: value of first stops at start of second', () => {
+ const input = 'foosrc/';
+ 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 = '/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 = '{"name":"a","arguments":{}}y';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.function.name).toBe('a');
+ });
});
- it('omits the suggestion clause when no tool is close enough', () => {
- const msg = formatUnknownToolError('zzzzzzz', tools);
- expect(msg).toContain("Tool 'zzzzzzz' not found");
- expect(msg).toContain('Available tools:');
- expect(msg).not.toContain('Did you mean');
- });
+ describe('pattern 3: value (Anthropic)', () => {
+ it('parses a single-parameter invoke call', () => {
+ const input = '/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' });
+ });
- // The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
- // model emit . lev(read_file, view_file) = 4, so
- // the spec's threshold (<=3) doesn't suggest view_file — the model still
- // gets the available-tools list to pick from. This pins that behavior so a
- // future loosening of the threshold is a deliberate choice.
- it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
- const msg = formatUnknownToolError('read_file', tools);
- expect(msg).not.toContain('Did you mean');
+ it('parses multi-parameter invoke call', () => {
+ const input = 'foosrc/';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ const args = JSON.parse(calls[0]!.function.arguments);
+ expect(args.pattern).toBe('foo');
+ expect(args.path).toBe('src/');
+ });
+
+ it('does not fire when pattern 1 found results', () => {
+ const input = '{"name":"a","arguments":{}}y';
+ 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 = 'yy';
+ const calls = parseToolCallsFromText(input);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.function.name).toBe('a');
+ });
+
+ it('tolerates missing closing tags', () => {
+ const input = '/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 = "/tmp/foo";
+ 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('');
+ expect(TOOL_XML_SIGNALS).toContain(' {
+ 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');
});
});
diff --git a/apps/server/src/services/__tests__/tool-suggestions.test.ts b/apps/server/src/services/__tests__/tool-suggestions.test.ts
new file mode 100644
index 0000000..b9e1cc5
--- /dev/null
+++ b/apps/server/src/services/__tests__/tool-suggestions.test.ts
@@ -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');
+ });
+});
diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts
index c986bd8..681ac01 100644
--- a/apps/server/src/services/agents.ts
+++ b/apps/server/src/services/agents.ts
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
import { ALL_TOOLS, resolveToolTier } from './tools.js';
+import { validateExtraArgs } from './inference/llama-args-validator.js';
// v1.8.1: global agents live at /data/AGENTS.md inside the container
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
@@ -97,6 +98,7 @@ interface ParsedFrontmatter {
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
// allowed" — the model responds text-only.
steps?: number;
+ llama_extra_args?: string[];
}
function stripQuotes(s: string): string {
@@ -227,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
} else {
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
}
+ } else if (key === 'llama_extra_args') {
+ if (valueRaw === '') {
+ data.llama_extra_args = [];
+ // No arrayKey support — llama_extra_args uses inline list only.
+ } else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
+ const inner = valueRaw.slice(1, -1);
+ const parsed = inner
+ .split(',')
+ .map((s) => stripQuotes(s.trim()))
+ .filter((s) => s.length > 0);
+ try {
+ validateExtraArgs(parsed);
+ data.llama_extra_args = parsed;
+ } catch (err) {
+ errors.push(err instanceof Error ? err.message : String(err));
+ }
+ } else {
+ const parsed = valueRaw
+ .split(',')
+ .map((s) => stripQuotes(s.trim()))
+ .filter((s) => s.length > 0);
+ try {
+ validateExtraArgs(parsed);
+ data.llama_extra_args = parsed;
+ } catch (err) {
+ errors.push(err instanceof Error ? err.message : String(err));
+ }
+ }
}
// Unknown keys silently ignored — forward-compat.
}
@@ -328,6 +358,7 @@ function parseAgentSection(section: RawSection): Omit {
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
steps: typeof fm.steps === 'number' ? fm.steps : null,
+ llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
};
}
diff --git a/apps/server/src/services/inference/error-handler.ts b/apps/server/src/services/inference/error-handler.ts
index 6ecbdf5..77b4488 100644
--- a/apps/server/src/services/inference/error-handler.ts
+++ b/apps/server/src/services/inference/error-handler.ts
@@ -9,6 +9,7 @@ import * as modelContext from '../model-context.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage } from './parts.js';
import type { PartInsert } from './parts.js';
+import { stripToolMarkup } from './tool-call-parser.js';
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
export async function handleAbortOrError(
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
const errMsg = err instanceof Error ? err.message : String(err);
+ accumulated = stripToolMarkup(accumulated, { final: true });
// v1.8.2: persist a structured error metadata blob on genuine failures so
// the bubble can render the reason on reload without re-deriving from the
// (one-shot) WS error frame. User-initiated abort skips this — there's no
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
session: Session
): Promise {
const { sessionId, chatId, assistantMessageId } = args;
- const { content, finishReason, promptTokens, completionTokens } = result;
+ const content = stripToolMarkup(result.content, { final: true });
+ const { finishReason, promptTokens, completionTokens } = result;
// v1.11.3: see executeToolPhase for the rationale.
const mctx = await modelContext.getModelContext(session.model);
diff --git a/apps/server/src/services/inference/llama-args-validator.ts b/apps/server/src/services/inference/llama-args-validator.ts
new file mode 100644
index 0000000..2b06118
--- /dev/null
+++ b/apps/server/src/services/inference/llama-args-validator.ts
@@ -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> = [
+ // 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 = 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[] {
+ 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,
+ opts?: StripOptions,
+): string[] {
+ const shadowing = new Set();
+ 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;
+}
diff --git a/apps/server/src/services/inference/provider.ts b/apps/server/src/services/inference/provider.ts
index d9faf93..ce0ae79 100644
--- a/apps/server/src/services/inference/provider.ts
+++ b/apps/server/src/services/inference/provider.ts
@@ -1,6 +1,9 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
+// TODO: When per-agent llama-server flag overrides are added, route them
+// through validateExtraArgs (./llama-args-validator.ts) first.
+
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
diff --git a/apps/server/src/services/inference/stream-phase.ts b/apps/server/src/services/inference/stream-phase.ts
index 63a9899..c1e6a38 100644
--- a/apps/server/src/services/inference/stream-phase.ts
+++ b/apps/server/src/services/inference/stream-phase.ts
@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import type { OpenAiMessage } from './payload.js';
-// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
-// recognizes both Qwen and Anthropic markup in one pass.
-import { extractToolCallBlocks } from './xml-parser.js';
+import { extractToolCallBlocks } from './tool-call-parser.js';
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
import type {
InferenceContext,
diff --git a/apps/server/src/services/inference/tool-call-parser.ts b/apps/server/src/services/inference/tool-call-parser.ts
new file mode 100644
index 0000000..c6bd48b
--- /dev/null
+++ b/apps/server/src/services/inference/tool-call-parser.ts
@@ -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 = '';
+export const XML_TOOL_CLOSE = '';
+export const INVOKE_TOOL_OPEN = '.*?<\/tool_call>/gs,
+ /.*?<\/function>/gs,
+ /]*>.*?<\/invoke>/gs,
+];
+
+const TOOL_ALL_PATS = [
+ ...TOOL_CLOSED_PATS,
+ /.*$/gs,
+ /.*$/gs,
+ /]*>.*$/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 = /\s*\{/g;
+const TC_FUNC_START_RE = /\s*/g;
+const TC_END_TAG_RE = /<\/tool_call>/;
+const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
+const TC_PARAM_START_RE = /\s*/g;
+const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
+
+const TC_INVOKE_START_RE = //g;
+const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
+const TC_INVOKE_PARAM_RE = //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: {json} -- 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;
+ 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: value -- closing tags optional.
+ // Body boundary uses or next ,
+ // 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 = {};
+ 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
+ // 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: 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 = {};
+ 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;
+}
+
+const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '', '']);
+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): 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 = /\s]+)\s*>/;
+const QWEN_PARAM_RE = /\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 = {};
+ 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 =
+ //;
+const INVOKE_PARAM_RE =
+ /([\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 = {};
+ 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 = [
+ { 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) };
+}
diff --git a/apps/server/src/services/inference/tool-phase.ts b/apps/server/src/services/inference/tool-phase.ts
index 25eb9d4..fc6d295 100644
--- a/apps/server/src/services/inference/tool-phase.ts
+++ b/apps/server/src/services/inference/tool-phase.ts
@@ -14,6 +14,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
// Resolves the grant root before pausing the loop so the user is never
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
import { resolveGrantRoot } from '../grant_resolver.js';
+import { stripToolMarkup } from './tool-call-parser.js';
import type {
InferenceContext,
StreamResult,
@@ -100,7 +101,8 @@ export async function executeToolPhase(
projectRoot: string
): Promise {
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//props, not the
// streaming completion (which doesn't emit n_ctx). getModelContext caches
diff --git a/apps/server/src/services/inference/xml-parser.ts b/apps/server/src/services/inference/xml-parser.ts
deleted file mode 100644
index 0f8b932..0000000
--- a/apps/server/src/services/inference/xml-parser.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-// v1.10.5: XML-tag tool-call fallback. Some models emit
-// value
-// 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
-// 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 = '';
-export const XML_TOOL_CLOSE = '';
-
-// v1.13.16: Anthropic opener is matched by prefix (not the full
-// `` tag) because attributes follow. Closer is the literal tag.
-export const INVOKE_TOOL_OPEN = ';
-}
-
-const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '', '']);
-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): 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. ``). Name capture is non-whitespace,
-// non-`>` so a stray space doesn't get absorbed into the function name.
-const QWEN_FUNCTION_RE = /\s]+)\s*>/;
-const QWEN_PARAM_RE = /\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 = {};
- 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 =
- //;
-const INVOKE_PARAM_RE =
- /([\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 = {};
- 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 (`` or ` ParsedCall | null;
-}
-
-const OPENER_SPECS: ReadonlyArray = [
- { 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) };
-}
diff --git a/apps/server/src/services/web/html-to-md.ts b/apps/server/src/services/web/html-to-md.ts
new file mode 100644
index 0000000..0216aa3
--- /dev/null
+++ b/apps/server/src/services/web/html-to-md.ts
@@ -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 = {
+ 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());
+}
diff --git a/apps/server/src/services/web/index.ts b/apps/server/src/services/web/index.ts
new file mode 100644
index 0000000..f0ae8d9
--- /dev/null
+++ b/apps/server/src/services/web/index.ts
@@ -0,0 +1 @@
+export { htmlToMarkdown } from './html-to-md.js';
diff --git a/apps/server/src/services/web_fetch.ts b/apps/server/src/services/web_fetch.ts
index 9c5dbe7..fc22dec 100644
--- a/apps/server/src/services/web_fetch.ts
+++ b/apps/server/src/services/web_fetch.ts
@@ -12,6 +12,7 @@ import { z } from 'zod';
import { isPublicUrl } from './url_guard.js';
import type { ToolDef } from './tools.js';
import { truncateIfNeeded } from './truncate.js';
+import { htmlToMarkdown } from './web/index.js';
const WebFetchInput = z.object({
url: z.string().min(1).max(2048),
@@ -38,29 +39,9 @@ export type WebFetchOutput =
}
| { error: string; reason: string; content_type?: string };
-function stripHtml(html: string): { text: string; title: string | undefined } {
- // Title first, before we destroy the markup. Trim collapsed whitespace.
+function extractTitle(html: string): string | undefined {
const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i);
- const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
- // Drop script + style + comments entirely (their CONTENT must not leak —
- // a regex tag stripper alone would expose inline JS as plain text).
- const text = html
- .replace(/