@webmcp-auto-ui/agent 2.5.25 → 2.5.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +1 -1
  2. package/src/autoui-server.ts +44 -0
  3. package/src/diagnostics.ts +6 -6
  4. package/src/discovery-cache.ts +17 -3
  5. package/src/index.ts +18 -4
  6. package/src/loop.ts +31 -34
  7. package/src/notebook-widgets/compact.ts +312 -0
  8. package/src/notebook-widgets/document.ts +372 -0
  9. package/src/notebook-widgets/editorial.ts +348 -0
  10. package/src/notebook-widgets/recipes/compact.md +104 -0
  11. package/src/notebook-widgets/recipes/document.md +100 -0
  12. package/src/notebook-widgets/recipes/editorial.md +104 -0
  13. package/src/notebook-widgets/recipes/workspace.md +94 -0
  14. package/src/notebook-widgets/shared.ts +1064 -0
  15. package/src/notebook-widgets/workspace.ts +328 -0
  16. package/src/prompts/claude-prompt-builder.ts +81 -0
  17. package/src/prompts/gemma4-prompt-builder.ts +205 -0
  18. package/src/prompts/index.ts +55 -0
  19. package/src/prompts/mistral-prompt-builder.ts +90 -0
  20. package/src/prompts/qwen-prompt-builder.ts +90 -0
  21. package/src/prompts/tool-call-parsers.ts +322 -0
  22. package/src/prompts/tool-refs.ts +196 -0
  23. package/src/providers/factory.ts +20 -3
  24. package/src/providers/transformers-models.ts +143 -0
  25. package/src/providers/transformers-serialize.ts +81 -0
  26. package/src/providers/transformers.ts +329 -0
  27. package/src/providers/transformers.worker.ts +667 -0
  28. package/src/providers/wasm.ts +150 -510
  29. package/src/recipes/_generated.ts +515 -0
  30. package/src/recipes/canary-data.md +50 -0
  31. package/src/recipes/canary-display.md +99 -0
  32. package/src/recipes/canary-middle.md +32 -0
  33. package/src/recipes/hackathon-assemblee-nationale.md +111 -0
  34. package/src/recipes/hummingbird-data.md +32 -0
  35. package/src/recipes/hummingbird-display.md +36 -0
  36. package/src/recipes/hummingbird-middle.md +18 -0
  37. package/src/recipes/notebook-playbook.md +129 -0
  38. package/src/tool-layers.ts +33 -157
  39. package/src/trace-observer.ts +669 -0
  40. package/src/types.ts +20 -5
  41. package/src/util/opfs-cache.ts +265 -0
  42. package/tests/gemma-prompt.test.ts +472 -0
  43. package/tests/loop.test.ts +5 -5
  44. package/tests/transformers-serialize.test.ts +103 -0
  45. package/src/providers/gemma.worker.legacy.ts +0 -123
  46. package/src/providers/litert.worker.ts +0 -294
  47. package/src/recipes/widgets/actions.md +0 -28
  48. package/src/recipes/widgets/alert.md +0 -27
  49. package/src/recipes/widgets/cards.md +0 -41
  50. package/src/recipes/widgets/carousel.md +0 -39
  51. package/src/recipes/widgets/chart-rich.md +0 -51
  52. package/src/recipes/widgets/chart.md +0 -32
  53. package/src/recipes/widgets/code.md +0 -21
  54. package/src/recipes/widgets/d3.md +0 -36
  55. package/src/recipes/widgets/data-table.md +0 -46
  56. package/src/recipes/widgets/gallery.md +0 -39
  57. package/src/recipes/widgets/grid-data.md +0 -57
  58. package/src/recipes/widgets/hemicycle.md +0 -43
  59. package/src/recipes/widgets/js-sandbox.md +0 -32
  60. package/src/recipes/widgets/json-viewer.md +0 -27
  61. package/src/recipes/widgets/kv.md +0 -31
  62. package/src/recipes/widgets/list.md +0 -24
  63. package/src/recipes/widgets/log.md +0 -39
  64. package/src/recipes/widgets/map.md +0 -49
  65. package/src/recipes/widgets/profile.md +0 -49
  66. package/src/recipes/widgets/recipe-browser.md +0 -102
  67. package/src/recipes/widgets/sankey.md +0 -54
  68. package/src/recipes/widgets/stat-card.md +0 -43
  69. package/src/recipes/widgets/stat.md +0 -35
  70. package/src/recipes/widgets/tags.md +0 -30
  71. package/src/recipes/widgets/text.md +0 -19
  72. package/src/recipes/widgets/timeline.md +0 -38
  73. package/src/recipes/widgets/trombinoscope.md +0 -39
@@ -0,0 +1,90 @@
1
+ // Mistral/Ministral prompt builder — FLEX 5-STEP template adapted to Mistral tool-call format.
2
+ // The [INST]/[/INST] tags are added by tokenizer.apply_chat_template in the
3
+ // worker, using the chat_template baked into Mistral's tokenizer_config.json.
4
+ // This builder returns only the system-message TEXT content.
5
+
6
+ import type { PromptRefs } from './tool-refs.js';
7
+
8
+ export function buildMistralPrompt(refs: PromptRefs): string {
9
+ const { listRecipes, searchRecipes, listTools, searchTools, getRecipes, actionTools } = refs;
10
+
11
+ return `You are FLEX, an AI assistant that helps users by answering their questions and completing tasks using recipes (also called skills) which are procedures containing instructions for AI agents to use tools (functions, scripts, schemas, and other relevant information) and tools. If no recipe or tool fits user demand, FLEX falls back to a traditional chat (STEP 5).
12
+
13
+ There are two kinds of servers: MCP servers exposing DATA (database, images, text, json) with tool calls and WebMCP servers exposing UI (widget_display, canvas, recall) with other tool calls to render DATA on the canvas. Both servers have recipes describing how to best use their tools.
14
+
15
+ CRITICAL RULE: FLEX does not narrate its process in the response. FLEX's Internal reasoning is permitted but must not appear in the final output.
16
+
17
+ FLEX follows a multi-step lazy-loading protocol:
18
+
19
+ STEP 1 — FLEX lists all recipes
20
+
21
+ FLEX tries to fetch a relevant DATA or UI recipe using these functions:
22
+
23
+ ${listRecipes.join('\n')}
24
+
25
+ If at least one relevant recipe is found → FLEX goes to STEP 2.
26
+ If no results → FLEX goes to STEP 1b.
27
+
28
+ STEP 1b — FLEX search recipes
29
+
30
+ If FLEX does not find appropriate recipe by listing, FLEX searches an appropriate DATA or UI recipe with keyword(s) extracted from the request with these functions:
31
+
32
+ ${searchRecipes.join('\n')}
33
+
34
+ FLEX picks the most relevant recipe for the request.
35
+ If a recipe matches → FLEX goes to STEP 2.
36
+ If no recipe is available or relevant → FLEX goes to STEP 1c.
37
+
38
+ STEP 1c — FLEX lists tools
39
+
40
+ If FLEX does not find any applicable recipe, FLEX lists relevant tools using these functions:
41
+
42
+ ${listTools.join('\n')}
43
+
44
+ If FLEX finds a relevant tool → FLEX uses it directly in STEP 3.
45
+ If FLEX does not find any relevant tools by listing them → FLEX goes to STEP 1d.
46
+
47
+ STEP 1d — FLEX searches tools using these functions:
48
+
49
+ ${searchTools.join('\n')}
50
+
51
+ FLEX picks the most relevant tool(s) and use it directly in STEP 3.
52
+
53
+ STEP 2 — FLEX ingests the recipe in its context
54
+
55
+ ${getRecipes.join('\n')}
56
+
57
+ FLEX knows tools functions arguments or schemas because they come from the result of list_recipes (STEP 1) or search_recipes (STEP 1b), whichever was called by FLEX. If FLEX does not know tools functions arguments or schemas, FLEX goes to STEP 1 again.
58
+
59
+ If FLEX knows tool functions arguments or schemas, FLEX also read the full instructions of the selected recipe and execute them directly in STEP 3.
60
+
61
+ STEP 3 — FLEX executes tool functions
62
+
63
+ FLEX prefers recipes over direct tool calls when a recipe matches the task. FLEX uses low-level instructions (DB queries, schema introspection, raw scripts) only when invoked from within a recipe's instructions.
64
+
65
+ FLEX follows recipe instructions exactly if they are present. Otherwise FLEX directly uses the tools with their schemas if it knows them. If FLEX does not know tools functions arguments or schemas, FLEX goes to STEP 1 again.
66
+
67
+ Output format: (1) FLEX returns a one-sentence summary of the action performed, then (2) FLEX display the result usually as a UI element such as a widget in STEP 4.
68
+
69
+ STEP 4 — UI display
70
+
71
+ Unless a recipe specifies otherwise, FLEX uses these functions to display its responses on the canvas:
72
+
73
+ ${actionTools.join('\n')}
74
+
75
+ FLEX knows that widget_display may ONLY be called with data returned by a DATA tool actually invoked in the current session. If no DATA tool has been called yet, FLEX goes back to STEP 1 or if in chat mode, to STEP 5.
76
+
77
+ STEP 5 — Fallback
78
+
79
+ If previous steps failed, FLEX falls back to a classic chat without tool calling.
80
+
81
+ TOOL CALL FORMAT — IMPORTANT
82
+
83
+ When calling tools, emit the calls EXACTLY as a single line:
84
+ [TOOL_CALLS][{"name": "tool_name", "arguments": {"key": "value"}}]
85
+
86
+ You can batch multiple calls in the same array:
87
+ [TOOL_CALLS][{"name": "tool_a", "arguments": {...}}, {"name": "tool_b", "arguments": {...}}]
88
+
89
+ Do NOT use Python syntax. Do NOT use XML. Use ONLY the [TOOL_CALLS]-JSON format above.`;
90
+ }
@@ -0,0 +1,90 @@
1
+ // Qwen 3/3.5 prompt builder — FLEX 5-STEP template adapted to ChatML syntax.
2
+ // The ChatML role tags (<|im_start|>system\n...\n<|im_end|>) are added by
3
+ // tokenizer.apply_chat_template in the worker, using the chat_template baked
4
+ // into Qwen's tokenizer_config.json. This builder returns only the
5
+ // system-message TEXT content.
6
+
7
+ import type { PromptRefs } from './tool-refs.js';
8
+
9
+ export function buildQwenPrompt(refs: PromptRefs): string {
10
+ const { listRecipes, searchRecipes, listTools, searchTools, getRecipes, actionTools } = refs;
11
+
12
+ return `You are FLEX, an AI assistant that helps users by answering their questions and completing tasks using recipes (also called skills) which are procedures containing instructions for AI agents to use tools (functions, scripts, schemas, and other relevant information) and tools. If no recipe or tool fits user demand, FLEX falls back to a traditional chat (STEP 5).
13
+
14
+ There are two kinds of servers: MCP servers exposing DATA (database, images, text, json) with tool calls and WebMCP servers exposing UI (widget_display, canvas, recall) with other tool calls to render DATA on the canvas. Both servers have recipes describing how to best use their tools.
15
+
16
+ CRITICAL RULE: FLEX does not narrate its process in the response. FLEX's Internal reasoning is permitted but must not appear in the final output.
17
+
18
+ FLEX follows a multi-step lazy-loading protocol:
19
+
20
+ STEP 1 — FLEX lists all recipes
21
+
22
+ FLEX tries to fetch a relevant DATA or UI recipe using these functions:
23
+
24
+ ${listRecipes.join('\n')}
25
+
26
+ If at least one relevant recipe is found → FLEX goes to STEP 2.
27
+ If no results → FLEX goes to STEP 1b.
28
+
29
+ STEP 1b — FLEX search recipes
30
+
31
+ If FLEX does not find appropriate recipe by listing, FLEX searches an appropriate DATA or UI recipe with keyword(s) extracted from the request with these functions:
32
+
33
+ ${searchRecipes.join('\n')}
34
+
35
+ FLEX picks the most relevant recipe for the request.
36
+ If a recipe matches → FLEX goes to STEP 2.
37
+ If no recipe is available or relevant → FLEX goes to STEP 1c.
38
+
39
+ STEP 1c — FLEX lists tools
40
+
41
+ If FLEX does not find any applicable recipe, FLEX lists relevant tools using these functions:
42
+
43
+ ${listTools.join('\n')}
44
+
45
+ If FLEX finds a relevant tool → FLEX uses it directly in STEP 3.
46
+ If FLEX does not find any relevant tools by listing them → FLEX goes to STEP 1d.
47
+
48
+ STEP 1d — FLEX searches tools using these functions:
49
+
50
+ ${searchTools.join('\n')}
51
+
52
+ FLEX picks the most relevant tool(s) and use it directly in STEP 3.
53
+
54
+ STEP 2 — FLEX ingests the recipe in its context
55
+
56
+ ${getRecipes.join('\n')}
57
+
58
+ FLEX knows tools functions arguments or schemas because they come from the result of list_recipes (STEP 1) or search_recipes (STEP 1b), whichever was called by FLEX. If FLEX does not know tools functions arguments or schemas, FLEX goes to STEP 1 again.
59
+
60
+ If FLEX knows tool functions arguments or schemas, FLEX also read the full instructions of the selected recipe and execute them directly in STEP 3.
61
+
62
+ STEP 3 — FLEX executes tool functions
63
+
64
+ FLEX prefers recipes over direct tool calls when a recipe matches the task. FLEX uses low-level instructions (DB queries, schema introspection, raw scripts) only when invoked from within a recipe's instructions.
65
+
66
+ FLEX follows recipe instructions exactly if they are present. Otherwise FLEX directly uses the tools with their schemas if it knows them. If FLEX does not know tools functions arguments or schemas, FLEX goes to STEP 1 again.
67
+
68
+ Output format: (1) FLEX returns a one-sentence summary of the action performed, then (2) FLEX display the result usually as a UI element such as a widget in STEP 4.
69
+
70
+ STEP 4 — UI display
71
+
72
+ Unless a recipe specifies otherwise, FLEX uses these functions to display its responses on the canvas:
73
+
74
+ ${actionTools.join('\n')}
75
+
76
+ FLEX knows that widget_display may ONLY be called with data returned by a DATA tool actually invoked in the current session. If no DATA tool has been called yet, FLEX goes back to STEP 1 or if in chat mode, to STEP 5.
77
+
78
+ STEP 5 — Fallback
79
+
80
+ If previous steps failed, FLEX falls back to a classic chat without tool calling.
81
+
82
+ TOOL CALL FORMAT — IMPORTANT
83
+
84
+ When calling any tool, emit the call EXACTLY as:
85
+ <tool_call>
86
+ {"name": "tool_name", "arguments": {"key": "value"}}
87
+ </tool_call>
88
+
89
+ Do NOT emit Python-style calls like tool_name(args=...). Do NOT emit XML-style tags with attributes. Use ONLY the JSON-inside-<tool_call> format above.`;
90
+ }
@@ -0,0 +1,322 @@
1
+ // Unified tool-call parser for in-browser providers.
2
+ // Each family of models emits tool calls in a different syntax:
3
+ // - Gemma 4 native → <|tool_call>call:name{args}<tool_call|>
4
+ // - Qwen 3/3.5 (ChatML) → <tool_call>{"name": ..., "arguments": {...}}</tool_call>
5
+ // - Mistral/Ministral → [TOOL_CALLS][{"name": ..., "arguments": {...}}]
6
+ //
7
+ // Each parser returns a list of ContentBlocks — tool_use blocks for detected
8
+ // calls, plus a single text block with the remaining prose (tool-call tags
9
+ // stripped, thinking tags stripped for Qwen).
10
+
11
+ import type { ContentBlock } from '../types.js';
12
+
13
+ export type ToolCallFormat = 'gemma-native' | 'qwen-json' | 'mistral-toolcalls';
14
+
15
+ export interface ParseResult {
16
+ content: ContentBlock[];
17
+ foundToolCall: boolean;
18
+ }
19
+
20
+ export function parseToolCalls(text: string, format: ToolCallFormat): ParseResult {
21
+ switch (format) {
22
+ case 'gemma-native': return parseGemmaNative(text);
23
+ case 'qwen-json': return parseQwenJson(text);
24
+ case 'mistral-toolcalls': return parseMistralToolCalls(text);
25
+ }
26
+ }
27
+
28
+ // ──────────────────────────────────────────────────────────────────────────
29
+ // Gemma 4 native — single source of truth (previously also in wasm.ts).
30
+ // Exported so that WasmProvider can import and reuse instead of duplicating.
31
+ // ──────────────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Extract a brace-balanced `{...}` block starting at `text[startIdx]`.
35
+ * Ignores `{` and `}` that appear inside `<|"|>...<|"|>` string delimiters
36
+ * or inside standard JSON `"..."` strings.
37
+ * Returns the full block including outer braces, or null if unbalanced.
38
+ */
39
+ export function extractArgsBlock(text: string, startIdx: number): string | null {
40
+ if (text[startIdx] !== '{') return null;
41
+ const DELIM = '<|"|>';
42
+ let depth = 0;
43
+ let i = startIdx;
44
+ while (i < text.length) {
45
+ if (text.startsWith(DELIM, i)) {
46
+ i += DELIM.length;
47
+ const end = text.indexOf(DELIM, i);
48
+ if (end === -1) return null;
49
+ i = end + DELIM.length;
50
+ continue;
51
+ }
52
+ if (text[i] === '"') {
53
+ i++;
54
+ while (i < text.length && text[i] !== '"') {
55
+ if (text[i] === '\\' && i + 1 < text.length) { i += 2; continue; }
56
+ i++;
57
+ }
58
+ i++;
59
+ continue;
60
+ }
61
+ if (text[i] === '{') depth++;
62
+ else if (text[i] === '}') {
63
+ depth--;
64
+ if (depth === 0) return text.slice(startIdx, i + 1);
65
+ }
66
+ i++;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Skip "noise" chars that Gemma sometimes hallucinates between the end of a
73
+ * balanced args block and the `<tool_call|>` closing tag. Observed in prod:
74
+ * excess `}` braces, trailing commas, whitespace. Without this tolerance the
75
+ * strict scanner rejects the whole tool call silently (no tool_use produced),
76
+ * which breaks widget rendering.
77
+ */
78
+ function skipNoise(text: string, pos: number): number {
79
+ while (pos < text.length) {
80
+ const c = text[pos];
81
+ if (c === '}' || c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') {
82
+ pos++;
83
+ continue;
84
+ }
85
+ break;
86
+ }
87
+ return pos;
88
+ }
89
+
90
+ /**
91
+ * Scan `fullText` for Gemma native tool calls and return the list of
92
+ * `{ name, argsBlock }` pairs found. Tolerates hallucinated noise
93
+ * (excess `}`, whitespace, trailing commas) between the balanced args
94
+ * block and the `<tool_call|>` closing tag.
95
+ */
96
+ export function extractGemmaToolCalls(fullText: string): Array<{ name: string; argsBlock: string }> {
97
+ const START_TAG = '<|tool_call>call:';
98
+ const END_TAG = '<tool_call|>';
99
+ const out: Array<{ name: string; argsBlock: string }> = [];
100
+ let scanIdx = 0;
101
+ while (true) {
102
+ const startIdx = fullText.indexOf(START_TAG, scanIdx);
103
+ if (startIdx === -1) break;
104
+ const nameStart = startIdx + START_TAG.length;
105
+ const braceIdx = fullText.indexOf('{', nameStart);
106
+ if (braceIdx === -1) break;
107
+ const name = fullText.slice(nameStart, braceIdx);
108
+ if (!/^\w+$/.test(name)) { scanIdx = nameStart; continue; }
109
+ const argsBlock = extractArgsBlock(fullText, braceIdx);
110
+ if (!argsBlock) break;
111
+ const afterArgsRaw = braceIdx + argsBlock.length;
112
+ const afterArgs = skipNoise(fullText, afterArgsRaw);
113
+ if (!fullText.startsWith(END_TAG, afterArgs)) { scanIdx = afterArgsRaw; continue; }
114
+ out.push({ name, argsBlock });
115
+ scanIdx = afterArgs + END_TAG.length;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Parse Gemma native tool call args by normalizing to strict JSON.
122
+ * Handles both `<|"|>...<|"|>` (Gemma native) and `"..."` (JSON-style, emitted
123
+ * when the model copies JS-syntax examples from recipe bodies). Raw newlines
124
+ * inside JSON strings are escaped. Unquoted keys are quoted.
125
+ */
126
+ export function parseGemmaArgs(raw: string): Record<string, unknown> {
127
+ const DELIM = '<|"|>';
128
+ let out = '';
129
+ let i = 0;
130
+ while (i < raw.length) {
131
+ if (raw.startsWith(DELIM, i)) {
132
+ i += DELIM.length;
133
+ const end = raw.indexOf(DELIM, i);
134
+ if (end === -1) return {};
135
+ // Decode standard backslash escapes inside <|"|>…<|"|> strings so that
136
+ // a recipe-copied `\n` becomes a real newline, not the two-char
137
+ // sequence `\n` that would then be re-escaped to `\\n` by
138
+ // JSON.stringify and reach the sandbox as literal text. Other
139
+ // backslash-x sequences are preserved verbatim (single backslash kept).
140
+ const body = raw.slice(i, end);
141
+ let decoded = '';
142
+ for (let k = 0; k < body.length; k++) {
143
+ const ch = body[k];
144
+ if (ch === '\\' && k + 1 < body.length) {
145
+ const nxt = body[k + 1];
146
+ if (nxt === 'n') { decoded += '\n'; k++; continue; }
147
+ if (nxt === 't') { decoded += '\t'; k++; continue; }
148
+ if (nxt === 'r') { decoded += '\r'; k++; continue; }
149
+ if (nxt === '"') { decoded += '"'; k++; continue; }
150
+ if (nxt === '\\') { decoded += '\\'; k++; continue; }
151
+ // Unknown escape — keep the backslash verbatim
152
+ decoded += ch;
153
+ continue;
154
+ }
155
+ decoded += ch;
156
+ }
157
+ out += JSON.stringify(decoded);
158
+ i = end + DELIM.length;
159
+ continue;
160
+ }
161
+ const c = raw[i];
162
+ if (c === '"') {
163
+ let content = '';
164
+ i++;
165
+ while (i < raw.length && raw[i] !== '"') {
166
+ const ch = raw[i];
167
+ if (ch === '\\' && i + 1 < raw.length) { content += ch + raw[i + 1]; i += 2; continue; }
168
+ if (ch === '\n') content += '\\n';
169
+ else if (ch === '\r') content += '\\r';
170
+ else if (ch === '\t') content += '\\t';
171
+ else content += ch;
172
+ i++;
173
+ }
174
+ if (i >= raw.length) return {};
175
+ out += '"' + content + '"';
176
+ i++;
177
+ continue;
178
+ }
179
+ if (c === '{' || c === ',') {
180
+ out += c;
181
+ i++;
182
+ while (i < raw.length && /\s/.test(raw[i])) { out += raw[i++]; }
183
+ const keyStart = i;
184
+ while (i < raw.length && /[a-zA-Z0-9_$]/.test(raw[i])) i++;
185
+ if (i > keyStart) {
186
+ let j = i;
187
+ while (j < raw.length && /\s/.test(raw[j])) j++;
188
+ if (raw[j] === ':') out += '"' + raw.slice(keyStart, i) + '"';
189
+ else out += raw.slice(keyStart, i);
190
+ }
191
+ continue;
192
+ }
193
+ out += c;
194
+ i++;
195
+ }
196
+ try {
197
+ const parsed = JSON.parse(out);
198
+ return (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : {};
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ function parseGemmaNative(text: string): ParseResult {
205
+ const calls = extractGemmaToolCalls(text); // uses the exported helper above
206
+ const content: ContentBlock[] = [];
207
+ for (let idx = 0; idx < calls.length; idx++) {
208
+ const c = calls[idx];
209
+ content.push({
210
+ type: 'tool_use',
211
+ id: `tc-${Date.now()}-${idx}`,
212
+ name: c.name,
213
+ input: parseGemmaArgs(c.argsBlock),
214
+ });
215
+ }
216
+ if (calls.length === 0) {
217
+ const cleanText = text.replace(/<\|tool_call>[\s\S]*?<tool_call\|>/g, '').trim();
218
+ content.push({ type: 'text', text: cleanText || text });
219
+ return { content, foundToolCall: false };
220
+ }
221
+ // Preserve any prose around the tool calls (strip the call blocks).
222
+ const prose = text.replace(/<\|tool_call>[\s\S]*?<tool_call\|>/g, '').trim();
223
+ if (prose) {
224
+ content.unshift({ type: 'text', text: prose });
225
+ }
226
+ return { content, foundToolCall: true };
227
+ }
228
+
229
+ // ──────────────────────────────────────────────────────────────────────────
230
+ // Qwen 3/3.5 ChatML tool-call format
231
+ // ──────────────────────────────────────────────────────────────────────────
232
+
233
+ function parseQwenJson(text: string): ParseResult {
234
+ // Strip <think>...</think> thinking tokens from prose (they shouldn't leak).
235
+ const noThink = text.replace(/<think>[\s\S]*?<\/think>/g, '');
236
+
237
+ const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
238
+ const content: ContentBlock[] = [];
239
+ const matches: Array<{ name: string; input: Record<string, unknown> }> = [];
240
+ let m: RegExpExecArray | null;
241
+ while ((m = re.exec(noThink)) !== null) {
242
+ try {
243
+ const obj = JSON.parse(m[1]);
244
+ if (obj && typeof obj === 'object' && typeof obj.name === 'string') {
245
+ matches.push({
246
+ name: obj.name,
247
+ input: (obj.arguments ?? {}) as Record<string, unknown>,
248
+ });
249
+ }
250
+ } catch {
251
+ // Skip unparseable tool_call blocks.
252
+ }
253
+ }
254
+
255
+ const prose = noThink.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '').trim();
256
+
257
+ if (matches.length === 0) {
258
+ content.push({ type: 'text', text: prose || noThink.trim() });
259
+ return { content, foundToolCall: false };
260
+ }
261
+
262
+ if (prose) {
263
+ content.push({ type: 'text', text: prose });
264
+ }
265
+ for (let i = 0; i < matches.length; i++) {
266
+ content.push({
267
+ type: 'tool_use',
268
+ id: `tc-${Date.now()}-${i}`,
269
+ name: matches[i].name,
270
+ input: matches[i].input,
271
+ });
272
+ }
273
+ return { content, foundToolCall: true };
274
+ }
275
+
276
+ // ──────────────────────────────────────────────────────────────────────────
277
+ // Mistral [TOOL_CALLS][...] format
278
+ // ──────────────────────────────────────────────────────────────────────────
279
+
280
+ function parseMistralToolCalls(text: string): ParseResult {
281
+ const re = /\[TOOL_CALLS\]\s*(\[[\s\S]*?\])/g;
282
+ const content: ContentBlock[] = [];
283
+ const matches: Array<{ name: string; input: Record<string, unknown> }> = [];
284
+ let m: RegExpExecArray | null;
285
+ while ((m = re.exec(text)) !== null) {
286
+ try {
287
+ const arr = JSON.parse(m[1]);
288
+ if (Array.isArray(arr)) {
289
+ for (const entry of arr) {
290
+ if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
291
+ matches.push({
292
+ name: entry.name,
293
+ input: (entry.arguments ?? {}) as Record<string, unknown>,
294
+ });
295
+ }
296
+ }
297
+ }
298
+ } catch {
299
+ // Skip unparseable [TOOL_CALLS] blocks.
300
+ }
301
+ }
302
+
303
+ const prose = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*?\]/g, '').trim();
304
+
305
+ if (matches.length === 0) {
306
+ content.push({ type: 'text', text: prose || text.trim() });
307
+ return { content, foundToolCall: false };
308
+ }
309
+
310
+ if (prose) {
311
+ content.push({ type: 'text', text: prose });
312
+ }
313
+ for (let i = 0; i < matches.length; i++) {
314
+ content.push({
315
+ type: 'tool_use',
316
+ id: `tc-${Date.now()}-${i}`,
317
+ name: matches[i].name,
318
+ input: matches[i].input,
319
+ });
320
+ }
321
+ return { content, foundToolCall: true };
322
+ }