From 90a6761b071db22b263d9b85f2b409fabe247d4c Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 26 May 2026 23:30:50 +0000 Subject: [PATCH] v2.4.0-unsloth-studio-lift: port 3 Unsloth Studio AGPL-3.0 modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../src/services/__tests__/html-to-md.test.ts | 223 +++++++++ .../__tests__/llama-args-validator.test.ts | 160 +++++++ ...arser.test.ts => tool-call-parser.test.ts} | 304 +++++++++---- .../__tests__/tool-suggestions.test.ts | 82 ++++ apps/server/src/services/agents.ts | 31 ++ .../src/services/inference/error-handler.ts | 5 +- .../inference/llama-args-validator.ts | 142 ++++++ .../server/src/services/inference/provider.ts | 3 + .../src/services/inference/stream-phase.ts | 4 +- .../services/inference/tool-call-parser.ts | 426 ++++++++++++++++++ .../src/services/inference/tool-phase.ts | 4 +- .../src/services/inference/xml-parser.ts | 204 --------- apps/server/src/services/web/html-to-md.ts | 347 ++++++++++++++ apps/server/src/services/web/index.ts | 1 + apps/server/src/services/web_fetch.ts | 30 +- apps/server/src/types/api.ts | 1 + pnpm-lock.yaml | 16 + 17 files changed, 1672 insertions(+), 311 deletions(-) create mode 100644 apps/server/src/services/__tests__/html-to-md.test.ts create mode 100644 apps/server/src/services/__tests__/llama-args-validator.test.ts rename apps/server/src/services/__tests__/{xml-parser.test.ts => tool-call-parser.test.ts} (54%) create mode 100644 apps/server/src/services/__tests__/tool-suggestions.test.ts create mode 100644 apps/server/src/services/inference/llama-args-validator.ts create mode 100644 apps/server/src/services/inference/tool-call-parser.ts delete mode 100644 apps/server/src/services/inference/xml-parser.ts create mode 100644 apps/server/src/services/web/html-to-md.ts create mode 100644 apps/server/src/services/web/index.ts 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 = '
  • one
  • two
  • three
'; + const md = htmlToMarkdown(html); + expect(md).toContain('* one'); + expect(md).toContain('* two'); + expect(md).toContain('* three'); + }); + + it('converts ordered list', () => { + const html = '
  1. first
  2. second
'; + const md = htmlToMarkdown(html); + expect(md).toContain('1. first'); + expect(md).toContain('2. second'); + }); + + it('handles nested lists', () => { + const html = '
  • outer
    • inner
'; + const md = htmlToMarkdown(html); + expect(md).toContain('* outer'); + expect(md).toContain(' * inner'); + }); + + it('converts 3-column GFM table with header', () => { + const html = ` + + + + + + +
NameAgeCity
Alice30NYC
Bob25LA
`; + 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 = '
A
x | y
'; + 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 = '
  1. five
  2. 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

+ + + + + + +
MetricValue
Uptime99.9%
Latency42ms
+

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 <tool_call> parser, the new Anthropic -// <invoke> parser, the partial-opener detector for both flavors, the unified -// extraction helper, and the unknown-tool error formatter that downstream -// dispatch uses to give the model a recovery hint when it drifts to a -// Claude Code tool name like read_file instead of BooCode's view_file. - import { describe, expect, it } from 'vitest'; import { parseXmlToolCall, parseInvokeToolCall, partialXmlOpenerStart, extractToolCallBlocks, + parseToolCallsFromText, + stripToolMarkup, + hasToolSignal, XML_TOOL_OPEN, XML_TOOL_CLOSE, INVOKE_TOOL_OPEN, INVOKE_TOOL_CLOSE, -} from '../inference/xml-parser.js'; -import { - levenshtein, - suggestToolName, - formatUnknownToolError, -} from '../inference/tool-suggestions.js'; + TOOL_XML_SIGNALS, + BUDGET_EXHAUSTED_NUDGE, + DUPLICATE_CALL_NUDGE, + TOOL_ERROR_NUDGE, + TOOL_ERROR_PREFIXES, +} from '../inference/tool-call-parser.js'; + +// ── Ported from xml-parser.test.ts ─────────────────────────────────────── describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => { it('parses a well-formed single-parameter call', () => { @@ -66,7 +65,6 @@ describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => { }); describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => { - // Spec case 1 it('parses a well-formed single-parameter call (spec case 1)', () => { const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>'; expect(parseInvokeToolCall(block)).toEqual({ @@ -75,7 +73,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => { }); }); - // Spec case 2 it('parses a multi-parameter call (spec case 2)', () => { const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>'; expect(parseInvokeToolCall(block)).toEqual({ @@ -84,7 +81,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => { }); }); - // Spec case 3 it('tolerates newlines and spaces in attributes (spec case 3)', () => { const block = `<invoke name="view_file" @@ -99,7 +95,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => { }); }); - // Spec case 4 (parser portion — the not-found enrichment is tested below) it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => { const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>'; expect(parseInvokeToolCall(block)).toEqual({ @@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => { }); describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { - // Spec case 1 (extraction-level) it('extracts a single <invoke> block (spec case 1)', () => { const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>'; const result = extractToolCallBlocks(input); @@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { expect(result.remaining).toBe(''); }); - // Spec case 5: opener arrives in one chunk, closer in the next. it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => { const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>'; const result = extractToolCallBlocks(firstChunk); @@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { expect(r2.remaining).toBe(''); }); - // Spec case 6: prose interleaving it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => { const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.'; const result = extractToolCallBlocks(input); @@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { expect(result.remaining).toBe(''); }); - // Spec case 7 regression it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => { const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>'; const result = extractToolCallBlocks(input); @@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { }); }); -describe('levenshtein', () => { - it('returns 0 for identical strings', () => { - expect(levenshtein('view_file', 'view_file')).toBe(0); +// ── New tests: Unsloth-ported functions ────────────────────────────────── + +describe('hasToolSignal', () => { + it('returns true for <tool_call>', () => { + expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true); }); - it('returns the length when one string is empty', () => { - expect(levenshtein('', 'view_file')).toBe(9); - expect(levenshtein('view_file', '')).toBe(9); + it('returns true for <function=', () => { + expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true); }); - it('computes a small distance for a single-character substitution', () => { - expect(levenshtein('cat', 'bat')).toBe(1); + it('returns true for <invoke', () => { + expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true); }); - it('computes a known case: read_file → view_file is 4', () => { - // r→v, e→i, a→e, d→w → 4 substitutions, same length - expect(levenshtein('read_file', 'view_file')).toBe(4); + it('returns false for near-miss <tool>', () => { + expect(hasToolSignal('prefix <tool> suffix')).toBe(false); + }); + + it('returns false for near-miss <function>', () => { + expect(hasToolSignal('prefix <function> suffix')).toBe(false); + }); + + it('returns false for near-miss <tool_call_thing>', () => { + expect(hasToolSignal('<tool_call_thing>')).toBe(false); + }); + + it('returns false for plain text', () => { + expect(hasToolSignal('just some text')).toBe(false); }); }); -describe('suggestToolName (v1.13.16)', () => { - const tools = [ - 'view_file', - 'list_dir', - 'grep', - 'find_files', - 'view_truncated_output', - 'ask_user_input', - 'web_search', - ]; - - it('suggests the closest match when distance is small', () => { - expect(suggestToolName('view_files', tools)).toBe('view_file'); +describe('stripToolMarkup', () => { + it('strips closed <tool_call> blocks', () => { + const input = 'before <tool_call>{"name":"x"}</tool_call> after'; + expect(stripToolMarkup(input)).toBe('before after'); }); - it('suggests via substring match when distance alone would miss', () => { - // 'file' is a substring of multiple tools; closest by distance wins. - expect(suggestToolName('file', tools)).toBe('view_file'); + it('strips closed <function=...> blocks', () => { + const input = 'before <function=x><parameter=y>z</parameter></function> after'; + expect(stripToolMarkup(input)).toBe('before after'); }); - it('returns null when nothing is close', () => { - expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull(); + it('strips closed <invoke> blocks', () => { + const input = 'before <invoke name="x"><parameter name="y">z</parameter></invoke> after'; + expect(stripToolMarkup(input)).toBe('before after'); }); - it('is case-insensitive in the distance check', () => { - expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file'); + it('leaves trailing unclosed block when final=false', () => { + const input = 'text <tool_call>{"name":"x"'; + expect(stripToolMarkup(input)).toBe('text <tool_call>{"name":"x"'); + }); + + it('strips trailing unclosed <tool_call> when final=true', () => { + const input = 'text <tool_call>{"name":"x"'; + expect(stripToolMarkup(input, { final: true })).toBe('text'); + }); + + it('strips trailing unclosed <function= when final=true', () => { + const input = 'text <function=run_bash><parameter=command>ls'; + expect(stripToolMarkup(input, { final: true })).toBe('text'); + }); + + it('strips trailing unclosed <invoke when final=true', () => { + const input = 'text <invoke name="x"><parameter name="y">val'; + expect(stripToolMarkup(input, { final: true })).toBe('text'); + }); + + it('trims whitespace when final=true', () => { + const input = ' text <tool_call>partial'; + expect(stripToolMarkup(input, { final: true })).toBe('text'); + }); + + it('strips multiple closed blocks', () => { + const input = '<tool_call>a</tool_call> mid <tool_call>b</tool_call>'; + expect(stripToolMarkup(input)).toBe(' mid '); }); }); -describe('formatUnknownToolError (v1.13.16)', () => { - const tools = ['view_file', 'list_dir', 'grep', 'find_files']; +describe('parseToolCallsFromText', () => { + describe('pattern 1: <tool_call>{json}</tool_call>', () => { + it('parses a well-formed JSON tool call', () => { + const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.id).toBe('call_0'); + expect(calls[0]!.type).toBe('function'); + expect(calls[0]!.function.name).toBe('web_search'); + expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' }); + }); - it('includes the wrong name and the available tools list', () => { - const msg = formatUnknownToolError('read_file', tools); - expect(msg).toContain("Tool 'read_file' not found"); - expect(msg).toContain('Available tools:'); - expect(msg).toContain('view_file'); - expect(msg).toContain('find_files'); + it('handles string arguments field', () => { + const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>'; + const calls = parseToolCallsFromText(input); + expect(calls[0]!.function.arguments).toBe('already a string'); + }); + + it('handles balanced braces inside JSON strings', () => { + const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + const parsed = JSON.parse(calls[0]!.function.arguments); + expect(parsed.q).toBe('} { extra '); + }); + + it('respects idOffset', () => { + const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>'; + const calls = parseToolCallsFromText(input, { idOffset: 5 }); + expect(calls[0]!.id).toBe('call_5'); + }); + + it('parses multiple JSON tool calls', () => { + const input = + '<tool_call>{"name":"a","arguments":{}}</tool_call>' + + '<tool_call>{"name":"b","arguments":{}}</tool_call>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(2); + expect(calls[0]!.id).toBe('call_0'); + expect(calls[1]!.id).toBe('call_1'); + }); + + it('skips malformed JSON', () => { + const input = '<tool_call>{not json}</tool_call>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(0); + }); + + it('handles missing closing tag', () => { + const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('x'); + }); }); - it('includes a suggestion when the drifted name is within threshold', () => { - // distance(view_files, view_file) = 1 (one extra char) - const msg = formatUnknownToolError('view_files', tools); - expect(msg).toContain('Did you mean: view_file?'); + describe('pattern 2: <function=name><parameter=key>value', () => { + it('parses a single-parameter function call', () => { + const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('view_file'); + expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); + }); + + it('single-param fast path preserves embedded </parameter>', () => { + const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"'); + }); + + it('multi-param: value of first stops at start of second', () => { + const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + const args = JSON.parse(calls[0]!.function.arguments); + expect(args.pattern).toBe('foo'); + expect(args.path).toBe('src/'); + }); + + it('tolerates missing closing tags', () => { + const input = '<function=view_file><parameter=path>/tmp/foo'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('view_file'); + expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); + }); + + it('does not fire when pattern 1 found results', () => { + const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('a'); + }); }); - it('omits the suggestion clause when no tool is close enough', () => { - const msg = formatUnknownToolError('zzzzzzz', tools); - expect(msg).toContain("Tool 'zzzzzzz' not found"); - expect(msg).toContain('Available tools:'); - expect(msg).not.toContain('Did you mean'); - }); + describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => { + it('parses a single-parameter invoke call', () => { + const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('view_file'); + expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); + }); - // The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the - // model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so - // the spec's threshold (<=3) doesn't suggest view_file — the model still - // gets the available-tools list to pick from. This pins that behavior so a - // future loosening of the threshold is a deliberate choice. - it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => { - const msg = formatUnknownToolError('read_file', tools); - expect(msg).not.toContain('Did you mean'); + it('parses multi-parameter invoke call', () => { + const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + const args = JSON.parse(calls[0]!.function.arguments); + expect(args.pattern).toBe('foo'); + expect(args.path).toBe('src/'); + }); + + it('does not fire when pattern 1 found results', () => { + const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('a'); + }); + + it('does not fire when pattern 2 found results', () => { + const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('a'); + }); + + it('tolerates missing closing tags', () => { + const input = '<invoke name="view_file"><parameter name="path">/tmp/foo'; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); + }); + + it('supports single-quoted attributes', () => { + const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>"; + const calls = parseToolCallsFromText(input); + expect(calls).toHaveLength(1); + expect(calls[0]!.function.name).toBe('view_file'); + }); + }); +}); + +describe('constants', () => { + it('TOOL_XML_SIGNALS includes all three signal prefixes', () => { + expect(TOOL_XML_SIGNALS).toContain('<tool_call>'); + expect(TOOL_XML_SIGNALS).toContain('<function='); + expect(TOOL_XML_SIGNALS).toContain('<invoke'); + }); + + it('nudge constants are non-empty strings', () => { + expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0); + expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0); + expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0); + }); + + it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => { + expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0); + expect(TOOL_ERROR_PREFIXES).toContain('Error'); }); }); 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<Agent, 'source'> { model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null, max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null, steps: typeof fm.steps === 'number' ? fm.steps : null, + llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null, }; } 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<void> { const { sessionId, chatId, assistantMessageId } = args; - const { content, finishReason, promptTokens, completionTokens } = result; + const content = stripToolMarkup(result.content, { final: true }); + const { finishReason, promptTokens, completionTokens } = result; // v1.11.3: see executeToolPhase for the rationale. const mctx = await modelContext.getModelContext(session.model); 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<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; +} 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 <tool_call> and Anthropic <invoke> markup in one pass. -import { extractToolCallBlocks } from './xml-parser.js'; +import { extractToolCallBlocks } from './tool-call-parser.js'; import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js'; import type { InferenceContext, 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 = '<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) }; +} 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<ToolPhaseResult> { const { sessionId, chatId, assistantMessageId } = args; - const { content, toolCalls, promptTokens, completionTokens } = result; + const content = stripToolMarkup(result.content, { final: true }); + const { toolCalls, promptTokens, completionTokens } = result; // v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the // streaming completion (which doesn't emit n_ctx). getModelContext caches 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 -// <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) }; -} 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<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()); +} 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(/<title[^>]*>([\s\S]*?)<\/title>/i); - const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined; - // Drop script + style + comments entirely (their CONTENT must not leak — - // a regex tag stripper alone would expose inline JS as plain text). - const text = html - .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') - .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') - .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ') - .replace(/<!--[\s\S]*?-->/g, ' ') - .replace(/<[^>]+>/g, ' ') - // Minimal entity decode — full coverage would need a table; covering - // the five common ones plus   is enough for snippet readability. - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\s+/g, ' ') - .trim(); - return { text, title }; + return titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined; } // v1.11.10: streaming body reader. Aborts the response stream the instant @@ -211,9 +192,8 @@ export async function executeWebFetch( let textRaw: string; let title: string | undefined; if (contentType.includes('text/html') || contentType.includes('application/xhtml')) { - const stripped = stripHtml(body); - textRaw = stripped.text; - title = stripped.title; + title = extractTitle(body); + textRaw = htmlToMarkdown(body); } else if ( contentType.includes('text/plain') || contentType.includes('text/markdown') || diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 3353293..b41596c 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -113,6 +113,7 @@ export interface Agent { // v1.14.0: per-agent step cap for the outer inference loop. null means // bounded only by MAX_STEPS (200). 0 means "no tool calls allowed." steps: number | null; + llama_extra_args: string[] | null; } // One entry per malformed `## Name` block. Per-block errors don't fail the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad614f3..967e61e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: fastify: specifier: ^4.28.1 version: 4.29.1 + parse5: + specifier: ^8.0.1 + version: 8.0.1 postgres: specifier: ^3.4.4 version: 3.4.9 @@ -2382,6 +2385,10 @@ packages: resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3274,6 +3281,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6110,6 +6120,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -7267,6 +7279,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {}