@webmcp-auto-ui/agent 2.5.24 → 2.5.26
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.
- package/package.json +1 -1
- package/src/autoui-server.ts +17 -0
- package/src/diagnostics.ts +6 -6
- package/src/discovery-cache.ts +17 -3
- package/src/index.ts +3 -3
- package/src/loop.ts +27 -19
- package/src/providers/wasm.ts +184 -330
- package/src/recipes/_generated.ts +273 -0
- package/src/recipes/canary-data.md +50 -0
- package/src/recipes/canary-display.md +99 -0
- package/src/recipes/canary-middle.md +32 -0
- package/src/recipes/hummingbird-data.md +32 -0
- package/src/recipes/hummingbird-display.md +36 -0
- package/src/recipes/hummingbird-middle.md +18 -0
- package/src/tool-layers.ts +303 -31
- package/src/types.ts +6 -1
- package/tests/loop.test.ts +2 -2
- package/src/providers/gemma.worker.legacy.ts +0 -123
- package/src/providers/litert.worker.ts +0 -294
- package/src/recipes/widgets/actions.md +0 -28
- package/src/recipes/widgets/alert.md +0 -27
- package/src/recipes/widgets/cards.md +0 -41
- package/src/recipes/widgets/carousel.md +0 -39
- package/src/recipes/widgets/chart-rich.md +0 -51
- package/src/recipes/widgets/chart.md +0 -32
- package/src/recipes/widgets/code.md +0 -21
- package/src/recipes/widgets/d3.md +0 -36
- package/src/recipes/widgets/data-table.md +0 -46
- package/src/recipes/widgets/gallery.md +0 -39
- package/src/recipes/widgets/grid-data.md +0 -57
- package/src/recipes/widgets/hemicycle.md +0 -43
- package/src/recipes/widgets/js-sandbox.md +0 -32
- package/src/recipes/widgets/json-viewer.md +0 -27
- package/src/recipes/widgets/kv.md +0 -31
- package/src/recipes/widgets/list.md +0 -24
- package/src/recipes/widgets/log.md +0 -39
- package/src/recipes/widgets/map.md +0 -49
- package/src/recipes/widgets/profile.md +0 -49
- package/src/recipes/widgets/recipe-browser.md +0 -102
- package/src/recipes/widgets/sankey.md +0 -54
- package/src/recipes/widgets/stat-card.md +0 -43
- package/src/recipes/widgets/stat.md +0 -35
- package/src/recipes/widgets/tags.md +0 -30
- package/src/recipes/widgets/text.md +0 -19
- package/src/recipes/widgets/timeline.md +0 -38
- package/src/recipes/widgets/trombinoscope.md +0 -39
package/src/tool-layers.ts
CHANGED
|
@@ -8,9 +8,19 @@ import type { SchemaPatch } from '@webmcp-auto-ui/core';
|
|
|
8
8
|
import { DiscoveryCache, type ServerCache } from './discovery-cache.js';
|
|
9
9
|
import type { PipelineTrace } from './pipeline-trace.js';
|
|
10
10
|
|
|
11
|
+
/** Map an internal protocol type to the token used in tool-name prefixes.
|
|
12
|
+
* - `mcp` (remote data sources) → `data`
|
|
13
|
+
* - `webmcp` (local UI widgets) → `ui`
|
|
14
|
+
* Final tool names follow {server}_{token}_{tool} (e.g. `tricoteuses_data_search_recipes`,
|
|
15
|
+
* `autoui_ui_widget_display`). Using neutral tokens avoids lexical confusion between
|
|
16
|
+
* MCP and WebMCP for small LLMs. */
|
|
17
|
+
export function protocolToken(protocol: 'mcp' | 'webmcp'): 'data' | 'ui' {
|
|
18
|
+
return protocol === 'mcp' ? 'data' : 'ui';
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/** Sanitize a server name for use in tool name prefixes.
|
|
12
22
|
* Returns a clean underscore-separated identifier with no "mcp"/"server" noise.
|
|
13
|
-
* Final tool names follow {server}_{
|
|
23
|
+
* Final tool names follow {server}_{token}_{tool} convention. */
|
|
14
24
|
export function sanitizeServerName(name: string): string {
|
|
15
25
|
let result = name.toLowerCase()
|
|
16
26
|
.replace(/[^a-z0-9]+/g, '_') // all non-alphanumeric → underscore
|
|
@@ -23,6 +33,16 @@ export function sanitizeServerName(name: string): string {
|
|
|
23
33
|
return result || 'mcp';
|
|
24
34
|
}
|
|
25
35
|
|
|
36
|
+
// ── Discovery pseudo-tool descriptions (shared between prompt and tool schemas) ──
|
|
37
|
+
const shortSearchToolsDesc = (serverName: string) =>
|
|
38
|
+
`Search tools by keyword on the ${serverName} server.`;
|
|
39
|
+
const shortListToolsDesc = (serverName: string) =>
|
|
40
|
+
`List ALL tools on the ${serverName} server.`;
|
|
41
|
+
const longSearchToolsDesc = (serverName: string) =>
|
|
42
|
+
`Search tools by keyword on the ${serverName} server. Use this when you need to find a specific data-fetching or action tool but don't know its exact name. Pass a keyword related to the task (e.g. "weather", "search", "create") and get back matching tool names with descriptions and input schemas. This is more targeted than list_tools — prefer it when you have a clear idea of what you're looking for. Returns an array of {name, description, inputSchema} objects.`;
|
|
43
|
+
const longListToolsDesc = (serverName: string) =>
|
|
44
|
+
`List ALL available tools on the ${serverName} server with their names, descriptions, and input schemas. Use this when search_tools returned no results, or when you want to browse the full capabilities of the server. Returns the complete tool catalog — useful when the user's request doesn't map to an obvious keyword. Does not accept any parameters.`;
|
|
45
|
+
|
|
26
46
|
/** MCP data layer — tools and recipes from a connected MCP server */
|
|
27
47
|
export interface McpLayer {
|
|
28
48
|
protocol: 'mcp';
|
|
@@ -44,6 +64,113 @@ export interface WebMcpLayer {
|
|
|
44
64
|
|
|
45
65
|
export type ToolLayer = McpLayer | WebMcpLayer;
|
|
46
66
|
|
|
67
|
+
/** Which provider syntax to emit in the system prompt.
|
|
68
|
+
* - `generic`: `tool_name()` / `tool_name(arg1, arg2)` — Claude, Ollama, most providers.
|
|
69
|
+
* - `gemma`: `<|tool_call>call:tool_name{}<tool_call|>` — Gemma 4 native format. */
|
|
70
|
+
export type ProviderKind = 'generic' | 'gemma';
|
|
71
|
+
|
|
72
|
+
/** Extract the first parameter name from a ProviderTool's input_schema.
|
|
73
|
+
* Priority: first required field > first property > fallback.
|
|
74
|
+
* Used to emit real parameter names in tool references for better prompting. */
|
|
75
|
+
function firstParamName(tool?: ProviderTool, fallback = 'query'): string {
|
|
76
|
+
if (!tool?.input_schema) return fallback;
|
|
77
|
+
const schema = tool.input_schema as Record<string, unknown>;
|
|
78
|
+
const required = (schema.required as string[] | undefined) ?? [];
|
|
79
|
+
if (required.length > 0) return required[0];
|
|
80
|
+
const props = (schema.properties as Record<string, unknown> | undefined) ?? {};
|
|
81
|
+
return Object.keys(props)[0] ?? fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Format a tool reference for inclusion in the system prompt.
|
|
85
|
+
* Generic (Claude/Ollama/etc): `name()` or `name(arg1, arg2)`.
|
|
86
|
+
* Gemma: emits the full `<|tool>declaration:...<tool|>` block inline when a tool is provided,
|
|
87
|
+
* so declarations appear in context at each step of the workflow (no appendix).
|
|
88
|
+
* Falls back to a plain backtick reference if no tool is provided.
|
|
89
|
+
*/
|
|
90
|
+
function fmtToolRef(
|
|
91
|
+
prefixedName: string,
|
|
92
|
+
args: string[] = [],
|
|
93
|
+
kind: ProviderKind = 'generic',
|
|
94
|
+
tool?: ProviderTool,
|
|
95
|
+
): string {
|
|
96
|
+
if (kind === 'gemma' && tool) {
|
|
97
|
+
// Inline: full Gemma declaration with canonical prefixed name but real schema
|
|
98
|
+
return formatGemmaToolDeclaration({ ...tool, name: prefixedName });
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'gemma') {
|
|
101
|
+
return args.length ? `\`${prefixedName}(${args.join(', ')})\`` : `\`${prefixedName}\``;
|
|
102
|
+
}
|
|
103
|
+
return args.length ? `${prefixedName}(${args.join(', ')})` : `${prefixedName}()`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Format a value for Gemma 4 native tool syntax.
|
|
108
|
+
* Strings use <|"|> delimiters, numbers/booleans/null are bare.
|
|
109
|
+
*/
|
|
110
|
+
export function gemmaValue(v: unknown): string {
|
|
111
|
+
const q = '<|"|>';
|
|
112
|
+
if (v === null || v === undefined) return 'null';
|
|
113
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
114
|
+
if (Array.isArray(v)) return `[${v.map(i => gemmaValue(i)).join(',')}]`;
|
|
115
|
+
if (typeof v === 'object') {
|
|
116
|
+
const entries = Object.entries(v as Record<string, unknown>)
|
|
117
|
+
.map(([k, val]) => `${k}:${gemmaValue(val)}`);
|
|
118
|
+
return `{${entries.join(',')}}`;
|
|
119
|
+
}
|
|
120
|
+
return `${q}${String(v)}${q}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format a tool declaration in Gemma 4 native syntax.
|
|
125
|
+
* Emitted in the system prompt tail so Gemma sees tool schemas alongside the
|
|
126
|
+
* STEP-by-STEP instructions.
|
|
127
|
+
*/
|
|
128
|
+
export function formatGemmaToolDeclaration(tool: ProviderTool): string {
|
|
129
|
+
const q = '<|"|>';
|
|
130
|
+
let decl = `<|tool>declaration:${tool.name}{\n`;
|
|
131
|
+
decl += ` description:${q}${tool.description}${q}`;
|
|
132
|
+
|
|
133
|
+
const schema = tool.input_schema;
|
|
134
|
+
if (schema?.properties) {
|
|
135
|
+
const props = schema.properties as Record<string, { description?: string; type?: string; enum?: string[]; format?: string; default?: unknown }>;
|
|
136
|
+
decl += `,\n parameters:{\n properties:{\n`;
|
|
137
|
+
|
|
138
|
+
const propEntries = Object.entries(props);
|
|
139
|
+
for (let i = 0; i < propEntries.length; i++) {
|
|
140
|
+
const [key, val] = propEntries[i];
|
|
141
|
+
decl += ` ${key}:{`;
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (val.description) parts.push(`description:${q}${val.description}${q}`);
|
|
144
|
+
// If no type specified, infer OBJECT for params-like fields to avoid
|
|
145
|
+
// Gemma wrapping the value in <|"|>...<|"|> (treating it as a string)
|
|
146
|
+
let inferredType = val.type;
|
|
147
|
+
if (!inferredType) {
|
|
148
|
+
const descLower = (val.description ?? '').toLowerCase();
|
|
149
|
+
if (descLower.includes('objet') || descLower.includes('object') || descLower.includes('parameter') || descLower.includes('paramètre') || key === 'params') {
|
|
150
|
+
inferredType = 'object';
|
|
151
|
+
} else {
|
|
152
|
+
inferredType = 'string';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
parts.push(`type:${q}${inferredType.toUpperCase()}${q}`);
|
|
156
|
+
if (val.enum) parts.push(`enum:[${val.enum.map(e => `${q}${e}${q}`).join(',')}]`);
|
|
157
|
+
if (val.format) parts.push(`format:${q}${val.format}${q}`);
|
|
158
|
+
if (val.default !== undefined) parts.push(`default:${gemmaValue(val.default)}`);
|
|
159
|
+
decl += parts.join(',');
|
|
160
|
+
decl += `}${i < propEntries.length - 1 ? ',' : ''}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
decl += ` }`;
|
|
164
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
165
|
+
decl += `,\n required:[${(schema.required as string[]).map(r => `${q}${r}${q}`).join(',')}]`;
|
|
166
|
+
}
|
|
167
|
+
decl += `,\n type:${q}OBJECT${q}\n }`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
decl += `\n}<tool|>`;
|
|
171
|
+
return decl;
|
|
172
|
+
}
|
|
173
|
+
|
|
47
174
|
/** Options controlling how tool schemas are transformed before sending to the LLM */
|
|
48
175
|
export interface SchemaTransformOptions {
|
|
49
176
|
/** Strip oneOf/anyOf/allOf/not/if-then-else/$ref (default: true) */
|
|
@@ -289,7 +416,7 @@ export function buildToolsFromLayers(layers: ToolLayer[], schemaOptions?: Schema
|
|
|
289
416
|
const tools: ProviderTool[] = [];
|
|
290
417
|
|
|
291
418
|
for (const layer of layers) {
|
|
292
|
-
const prefix = `${sanitizeServerName(layer.serverName)}_${layer.protocol}_`;
|
|
419
|
+
const prefix = `${sanitizeServerName(layer.serverName)}_${protocolToken(layer.protocol)}_`;
|
|
293
420
|
|
|
294
421
|
if (layer.protocol === 'mcp') {
|
|
295
422
|
for (const tool of toProviderTools(layer.tools, schemaOptions, trace)) {
|
|
@@ -347,11 +474,30 @@ export interface SystemPromptResult {
|
|
|
347
474
|
/**
|
|
348
475
|
* Build system prompt with a local alias map (parallel-safe).
|
|
349
476
|
* Prefer this over buildSystemPrompt() when running multiple agent loops.
|
|
477
|
+
*
|
|
478
|
+
* The `providerKind` option controls the syntax of tool references in the prompt:
|
|
479
|
+
* - `'generic'` (default): `tool_name()` / `tool_name(arg)` — for Claude, Ollama, etc.
|
|
480
|
+
* - `'gemma'`: `<|tool_call>call:tool_name{}<tool_call|>` — Gemma 4 native format.
|
|
350
481
|
*/
|
|
351
|
-
export function buildSystemPromptWithAliases(
|
|
482
|
+
export function buildSystemPromptWithAliases(
|
|
483
|
+
layers: ToolLayer[],
|
|
484
|
+
options: { providerKind?: ProviderKind } = {},
|
|
485
|
+
): SystemPromptResult {
|
|
486
|
+
const kind = options.providerKind ?? 'generic';
|
|
352
487
|
const mcpLayers = layers.filter((l): l is McpLayer => l.protocol === 'mcp');
|
|
353
488
|
const webmcpLayers = layers.filter((l): l is WebMcpLayer => l.protocol === 'webmcp');
|
|
354
489
|
|
|
490
|
+
// DISPLAY servers = WebMCP layers that expose widget_display (can render on canvas).
|
|
491
|
+
// DATA servers = everything else (MCP servers + WebMCP without widget_display).
|
|
492
|
+
const displayLayers = webmcpLayers.filter(l => l.tools.some(t => t.name === 'widget_display'));
|
|
493
|
+
const dataLayers = layers.filter(l => !displayLayers.includes(l as WebMcpLayer));
|
|
494
|
+
|
|
495
|
+
// Pre-build an index of prefixed tool name → ProviderTool so we can emit
|
|
496
|
+
// real param names (and, for Gemma, inline declarations) at each call site.
|
|
497
|
+
const providerToolsByName = new Map<string, ProviderTool>(
|
|
498
|
+
buildToolsFromLayers(layers, { sanitize: true }).tools.map(t => [t.name, t]),
|
|
499
|
+
);
|
|
500
|
+
|
|
355
501
|
const aliasMap = new Map<string, string>();
|
|
356
502
|
|
|
357
503
|
// ── Collect search_recipes / list_recipes / get_recipe from all layers ──
|
|
@@ -363,20 +509,43 @@ export function buildSystemPromptWithAliases(layers: ToolLayer[]): SystemPromptR
|
|
|
363
509
|
|
|
364
510
|
// WebMCP layers: always exact match (we control the naming)
|
|
365
511
|
for (const l of webmcpLayers) {
|
|
366
|
-
const prefix = `${sanitizeServerName(l.serverName)}
|
|
512
|
+
const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
|
|
367
513
|
for (const t of l.tools) {
|
|
368
|
-
if (t.name === 'search_recipes')
|
|
369
|
-
|
|
370
|
-
|
|
514
|
+
if (t.name === 'search_recipes') {
|
|
515
|
+
const name = `${prefix}search_recipes`;
|
|
516
|
+
const toolDef = providerToolsByName.get(name);
|
|
517
|
+
searchRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'query')], kind, toolDef));
|
|
518
|
+
}
|
|
519
|
+
if (t.name === 'list_recipes') {
|
|
520
|
+
const name = `${prefix}list_recipes`;
|
|
521
|
+
const toolDef = providerToolsByName.get(name);
|
|
522
|
+
listRecipes.push(fmtToolRef(name, [], kind, toolDef));
|
|
523
|
+
}
|
|
524
|
+
if (t.name === 'get_recipe') {
|
|
525
|
+
const name = `${prefix}get_recipe`;
|
|
526
|
+
const toolDef = providerToolsByName.get(name);
|
|
527
|
+
getRecipes.push(fmtToolRef(name, [firstParamName(toolDef, 'id')], kind, toolDef));
|
|
528
|
+
}
|
|
371
529
|
}
|
|
372
|
-
// Pseudo-tools for tool discovery on WebMCP servers
|
|
373
|
-
|
|
374
|
-
|
|
530
|
+
// Pseudo-tools for tool discovery on WebMCP servers — not in providerToolsByName,
|
|
531
|
+
// so we build synthetic ProviderTools for inline declarations.
|
|
532
|
+
const searchToolsPseudo: ProviderTool = {
|
|
533
|
+
name: `${prefix}search_tools`,
|
|
534
|
+
description: shortSearchToolsDesc(l.serverName),
|
|
535
|
+
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
|
|
536
|
+
};
|
|
537
|
+
searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
|
|
538
|
+
const listToolsPseudo: ProviderTool = {
|
|
539
|
+
name: `${prefix}list_tools`,
|
|
540
|
+
description: shortListToolsDesc(l.serverName),
|
|
541
|
+
input_schema: { type: 'object', properties: {} },
|
|
542
|
+
};
|
|
543
|
+
listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
|
|
375
544
|
}
|
|
376
545
|
|
|
377
546
|
// MCP layers: 4-layer matching + alias registration
|
|
378
547
|
for (const l of mcpLayers) {
|
|
379
|
-
const prefix = `${sanitizeServerName(l.serverName)}
|
|
548
|
+
const prefix = `${sanitizeServerName(l.serverName)}_data_`;
|
|
380
549
|
const matches = resolveCanonicalTools(l.tools);
|
|
381
550
|
|
|
382
551
|
for (const m of matches) {
|
|
@@ -388,32 +557,126 @@ export function buildSystemPromptWithAliases(layers: ToolLayer[]): SystemPromptR
|
|
|
388
557
|
aliasMap.set(canonicalPrefixed, realPrefixed);
|
|
389
558
|
}
|
|
390
559
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
560
|
+
// Look up the REAL tool (by real prefixed name) to get the actual schema
|
|
561
|
+
const realToolDef = providerToolsByName.get(realPrefixed);
|
|
562
|
+
|
|
563
|
+
if (m.role === 'search_recipes') {
|
|
564
|
+
searchRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'query')], kind, realToolDef));
|
|
565
|
+
}
|
|
566
|
+
if (m.role === 'list_recipes') {
|
|
567
|
+
listRecipes.push(fmtToolRef(canonicalPrefixed, [], kind, realToolDef));
|
|
568
|
+
}
|
|
569
|
+
if (m.role === 'get_recipe') {
|
|
570
|
+
getRecipes.push(fmtToolRef(canonicalPrefixed, [firstParamName(realToolDef, 'id')], kind, realToolDef));
|
|
571
|
+
}
|
|
394
572
|
}
|
|
395
573
|
|
|
396
574
|
// Pseudo-tools for tool discovery on all MCP servers
|
|
397
|
-
|
|
398
|
-
|
|
575
|
+
const searchToolsPseudo: ProviderTool = {
|
|
576
|
+
name: `${prefix}search_tools`,
|
|
577
|
+
description: shortSearchToolsDesc(l.serverName),
|
|
578
|
+
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for.' } }, required: ['query'] },
|
|
579
|
+
};
|
|
580
|
+
searchTools.push(fmtToolRef(`${prefix}search_tools`, ['query'], kind, searchToolsPseudo));
|
|
581
|
+
const listToolsPseudo: ProviderTool = {
|
|
582
|
+
name: `${prefix}list_tools`,
|
|
583
|
+
description: shortListToolsDesc(l.serverName),
|
|
584
|
+
input_schema: { type: 'object', properties: {} },
|
|
585
|
+
};
|
|
586
|
+
listTools.push(fmtToolRef(`${prefix}list_tools`, [], kind, listToolsPseudo));
|
|
399
587
|
}
|
|
400
588
|
|
|
401
589
|
// ── WebMCP action tools (widget_display, canvas, recall) ──
|
|
590
|
+
// Iterate in canonical order (widget_display, canvas, recall) so the prompt
|
|
591
|
+
// always lists them in the same sequence regardless of tool definition order.
|
|
402
592
|
const actionTools: string[] = [];
|
|
403
593
|
const ACTION_NAMES = ['widget_display', 'canvas', 'recall'];
|
|
404
594
|
for (const l of webmcpLayers) {
|
|
405
|
-
const prefix = `${sanitizeServerName(l.serverName)}
|
|
406
|
-
for (const
|
|
407
|
-
if (
|
|
595
|
+
const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
|
|
596
|
+
for (const actionName of ACTION_NAMES) {
|
|
597
|
+
if (l.tools.some(t => t.name === actionName)) {
|
|
598
|
+
const prefixedName = `${prefix}${actionName}`;
|
|
599
|
+
const toolDef = providerToolsByName.get(prefixedName);
|
|
600
|
+
const args = actionName === 'widget_display' ? ['name', 'params'] : [];
|
|
601
|
+
actionTools.push(fmtToolRef(prefixedName, args, kind, toolDef));
|
|
602
|
+
}
|
|
408
603
|
}
|
|
409
604
|
}
|
|
410
605
|
|
|
411
|
-
//
|
|
412
|
-
|
|
606
|
+
// Same refs, grouped by DATA vs DISPLAY category, for the gemma-minimalist template.
|
|
607
|
+
const dataPrefixes = new Set(dataLayers.map(l => `${sanitizeServerName(l.serverName)}_${protocolToken(l.protocol)}_`));
|
|
608
|
+
const displayPrefixes = new Set(displayLayers.map(l => `${sanitizeServerName(l.serverName)}_ui_`));
|
|
609
|
+
|
|
610
|
+
function splitByCategory(refs: string[]): { data: string[]; display: string[] } {
|
|
611
|
+
// Refs may be backticked names, full declarations, or tool_name(arg) — in all cases,
|
|
612
|
+
// the prefixed name (e.g. `tricoteuses_data_search_recipes`) is detectable by substring match.
|
|
613
|
+
const data: string[] = [];
|
|
614
|
+
const display: string[] = [];
|
|
615
|
+
for (const ref of refs) {
|
|
616
|
+
const isDisplay = [...displayPrefixes].some(p => ref.includes(p));
|
|
617
|
+
if (isDisplay) display.push(ref);
|
|
618
|
+
else data.push(ref);
|
|
619
|
+
}
|
|
620
|
+
return { data, display };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const listRecipesByCat = splitByCategory(listRecipes);
|
|
624
|
+
const searchRecipesByCat = splitByCategory(searchRecipes);
|
|
625
|
+
const getRecipesByCat = splitByCategory(getRecipes);
|
|
626
|
+
// Suppress unused-variable warnings — dataPrefixes is referenced indirectly via dataLayers.
|
|
627
|
+
void dataPrefixes;
|
|
628
|
+
|
|
629
|
+
// ── Build prompt ──
|
|
630
|
+
let prompt: string;
|
|
631
|
+
|
|
632
|
+
if (kind === 'gemma') {
|
|
633
|
+
// ── Minimalist template for Gemma (4B/E4B), inline declarations included ──
|
|
634
|
+
const dataListSearch = [
|
|
635
|
+
...listRecipesByCat.data,
|
|
636
|
+
...searchRecipesByCat.data,
|
|
637
|
+
].join('\n');
|
|
638
|
+
const displayListSearch = [
|
|
639
|
+
...listRecipesByCat.display,
|
|
640
|
+
...searchRecipesByCat.display,
|
|
641
|
+
].join('\n');
|
|
642
|
+
const allGetRecipes = [
|
|
643
|
+
...getRecipesByCat.data,
|
|
644
|
+
...getRecipesByCat.display,
|
|
645
|
+
].join('\n');
|
|
646
|
+
|
|
647
|
+
prompt = `Route: DATA (fetch) or DISPLAY (render). Greetings → chat.
|
|
648
|
+
|
|
649
|
+
STEP 1 — List or search a recipe.
|
|
650
|
+
DATA:
|
|
651
|
+
${dataListSearch}
|
|
652
|
+
DISPLAY:
|
|
653
|
+
${displayListSearch}
|
|
654
|
+
The tool results are for you, not for the user. Pick the best match and go to STEP 2. Never ask the user to choose.
|
|
655
|
+
|
|
656
|
+
STEP 2 — Fetch the recipe.
|
|
657
|
+
${allGetRecipes}
|
|
658
|
+
|
|
659
|
+
STEP 3 — Execute using the schema from STEP 2.
|
|
660
|
+
- Data: follow the recipe (SQL / FTS / script).
|
|
661
|
+
- Display: call widget_display(name, params).
|
|
662
|
+
${actionTools.join('\n')}
|
|
663
|
+
If no recipe fits, use a tool directly:
|
|
664
|
+
${listTools.join('\n')}
|
|
665
|
+
${searchTools.join('\n')}
|
|
666
|
+
Only use data returned by tools or given by the user. Never fabricate.
|
|
667
|
+
|
|
668
|
+
Reply: one-line summary + result.`;
|
|
669
|
+
} else {
|
|
670
|
+
// ── Existing generic template for Claude/remote — DO NOT MODIFY ──
|
|
671
|
+
const reasoningRule = 'Do not narrate your process in the response. Internal reasoning is permitted but must not appear in the final output. For trivial conversational messages such as greetings or small talk, skip directly to STEP 5.';
|
|
672
|
+
|
|
673
|
+
prompt = `You are an AI assistant that helps users by answering their questions and completing tasks using recipes (also called skills) — instructions for an AI agent with scripts, schemas, and information. If no recipe or tool fits, fall back to a traditional chat (STEP 5).
|
|
674
|
+
|
|
675
|
+
There are two kinds of servers: MCP servers expose DATA (recipes, instructions, tools) AND WebMCP servers expose UI tools (widget_display, canvas, recall) to render DATA on the canvas.
|
|
413
676
|
|
|
414
677
|
You MUST NOT skip steps.
|
|
415
678
|
|
|
416
|
-
CRITICAL RULE:
|
|
679
|
+
CRITICAL RULE: ${reasoningRule}
|
|
417
680
|
|
|
418
681
|
STEP 1 — List all recipes
|
|
419
682
|
|
|
@@ -452,12 +715,17 @@ Pick the most relevant tool(s) and use them to respond (go to STEP 3).
|
|
|
452
715
|
STEP 2 — Read the recipe
|
|
453
716
|
|
|
454
717
|
${getRecipes.join('\n')}
|
|
718
|
+
The id comes from the result of list_recipes (STEP 1) or search_recipes (STEP 1b), whichever was called.
|
|
455
719
|
|
|
456
720
|
Read the full instructions of the selected recipe.
|
|
457
721
|
|
|
458
722
|
STEP 3 — Execute
|
|
459
723
|
|
|
460
|
-
|
|
724
|
+
Prefer recipes over direct tool calls when a recipe matches the task. Use low-level instructions (DB queries, schema introspection, raw scripts) only when invoked from within a recipe's instructions.
|
|
725
|
+
|
|
726
|
+
Follow the recipe instructions exactly if you have one. Otherwise use the tools with their schemas.
|
|
727
|
+
|
|
728
|
+
Output format: (1) a one-sentence summary of the action performed, then (2) the result. Nothing else.
|
|
461
729
|
|
|
462
730
|
STEP 4 — UI display
|
|
463
731
|
|
|
@@ -470,6 +738,10 @@ widget_display may ONLY be called with data returned by a non-autoui DATA tool a
|
|
|
470
738
|
STEP 5 — Fallback
|
|
471
739
|
|
|
472
740
|
If previous steps failed, fall back to a classic chat without tool calling.`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Note: for Gemma (kind === 'gemma'), tool declarations are emitted INLINE at each
|
|
744
|
+
// STEP via `fmtToolRef(..., kind, tool)` — no appendix is appended here.
|
|
473
745
|
|
|
474
746
|
return { prompt, aliasMap };
|
|
475
747
|
}
|
|
@@ -478,8 +750,8 @@ If previous steps failed, fall back to a classic chat without tool calling.`;
|
|
|
478
750
|
* Also populates the deprecated global toolAliasMap for legacy consumers.
|
|
479
751
|
* For parallel-safe usage, use buildSystemPromptWithAliases() instead.
|
|
480
752
|
*/
|
|
481
|
-
export function buildSystemPrompt(layers: ToolLayer[]): string {
|
|
482
|
-
const { prompt, aliasMap } = buildSystemPromptWithAliases(layers);
|
|
753
|
+
export function buildSystemPrompt(layers: ToolLayer[], options?: { providerKind?: ProviderKind }): string {
|
|
754
|
+
const { prompt, aliasMap } = buildSystemPromptWithAliases(layers, options);
|
|
483
755
|
|
|
484
756
|
// Populate deprecated global singleton for backward compat
|
|
485
757
|
toolAliasMap.clear();
|
|
@@ -503,7 +775,7 @@ export function buildDiscoveryToolsWithAliases(layers: ToolLayer[], schemaOption
|
|
|
503
775
|
const aliasMap = new Map<string, string>();
|
|
504
776
|
|
|
505
777
|
for (const layer of layers) {
|
|
506
|
-
const prefix = `${sanitizeServerName(layer.serverName)}_${layer.protocol}_`;
|
|
778
|
+
const prefix = `${sanitizeServerName(layer.serverName)}_${protocolToken(layer.protocol)}_`;
|
|
507
779
|
|
|
508
780
|
if (layer.protocol === 'mcp') {
|
|
509
781
|
const allProviderTools = toProviderTools(layer.tools, schemaOptions, trace);
|
|
@@ -527,12 +799,12 @@ export function buildDiscoveryToolsWithAliases(layers: ToolLayer[], schemaOption
|
|
|
527
799
|
// Pseudo-tools for tool discovery on MCP servers
|
|
528
800
|
tools.push({
|
|
529
801
|
name: `${prefix}search_tools`,
|
|
530
|
-
description:
|
|
802
|
+
description: longSearchToolsDesc(layer.serverName),
|
|
531
803
|
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for in tool names and descriptions, e.g. "weather", "user", "search". Case-insensitive.' } }, required: ['query'] },
|
|
532
804
|
});
|
|
533
805
|
tools.push({
|
|
534
806
|
name: `${prefix}list_tools`,
|
|
535
|
-
description:
|
|
807
|
+
description: longListToolsDesc(layer.serverName),
|
|
536
808
|
input_schema: { type: 'object', properties: {} },
|
|
537
809
|
});
|
|
538
810
|
} else {
|
|
@@ -547,12 +819,12 @@ export function buildDiscoveryToolsWithAliases(layers: ToolLayer[], schemaOption
|
|
|
547
819
|
// Pseudo-tools for tool discovery on WebMCP servers
|
|
548
820
|
tools.push({
|
|
549
821
|
name: `${prefix}search_tools`,
|
|
550
|
-
description:
|
|
822
|
+
description: longSearchToolsDesc(layer.serverName),
|
|
551
823
|
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Keyword to search for in tool names and descriptions, e.g. "weather", "user", "search". Case-insensitive.' } }, required: ['query'] },
|
|
552
824
|
});
|
|
553
825
|
tools.push({
|
|
554
826
|
name: `${prefix}list_tools`,
|
|
555
|
-
description:
|
|
827
|
+
description: longListToolsDesc(layer.serverName),
|
|
556
828
|
input_schema: { type: 'object', properties: {} },
|
|
557
829
|
});
|
|
558
830
|
}
|
|
@@ -590,7 +862,7 @@ export function activateServerTools(
|
|
|
590
862
|
schemaOptions?: SchemaTransformOptions,
|
|
591
863
|
trace?: PipelineTrace,
|
|
592
864
|
): ProviderTool[] {
|
|
593
|
-
const prefix = `${sanitizeServerName(layer.serverName)}_${layer.protocol}_`;
|
|
865
|
+
const prefix = `${sanitizeServerName(layer.serverName)}_${protocolToken(layer.protocol)}_`;
|
|
594
866
|
const existing = new Set(currentTools.map(t => t.name));
|
|
595
867
|
const newTools = [...currentTools];
|
|
596
868
|
|
package/src/types.ts
CHANGED
|
@@ -48,10 +48,15 @@ export interface LLMResponse {
|
|
|
48
48
|
export interface LLMProvider {
|
|
49
49
|
readonly name: string;
|
|
50
50
|
readonly model: string;
|
|
51
|
+
/** Hint for system prompt builders: which syntax this provider expects for tool
|
|
52
|
+
* references. `undefined` → treated as `'generic'`. Providers using a non-standard
|
|
53
|
+
* native call syntax (e.g. Gemma) should set this so the agent loop can build
|
|
54
|
+
* the prompt with the correct formatting. */
|
|
55
|
+
readonly promptKind?: 'generic' | 'gemma';
|
|
51
56
|
chat(
|
|
52
57
|
messages: ChatMessage[],
|
|
53
58
|
tools: ProviderTool[],
|
|
54
|
-
options?: { signal?: AbortSignal; cacheEnabled?: boolean; system?: string; maxTokens?: number; temperature?: number; topK?: number; onToken?: (token: string) => void
|
|
59
|
+
options?: { signal?: AbortSignal; cacheEnabled?: boolean; system?: string; maxTokens?: number; temperature?: number; topK?: number; onToken?: (token: string) => void }
|
|
55
60
|
): Promise<LLMResponse>;
|
|
56
61
|
}
|
|
57
62
|
|
package/tests/loop.test.ts
CHANGED
|
@@ -77,7 +77,7 @@ describe('runAgentLoop', () => {
|
|
|
77
77
|
name: 'mock', model: 'claude-haiku',
|
|
78
78
|
// Always returns a tool call — never end_turn (using prefixed tool name)
|
|
79
79
|
chat: vi.fn().mockResolvedValue({
|
|
80
|
-
content: [{ type: 'tool_use', id: 'tc1', name: '
|
|
80
|
+
content: [{ type: 'tool_use', id: 'tc1', name: 'test_data_search', input: { q: 'x' } }],
|
|
81
81
|
stopReason: 'tool_use',
|
|
82
82
|
} satisfies LLMResponse),
|
|
83
83
|
};
|
|
@@ -100,7 +100,7 @@ describe('runAgentLoop', () => {
|
|
|
100
100
|
name: 'mock', model: 'claude-haiku',
|
|
101
101
|
chat: vi.fn()
|
|
102
102
|
.mockImplementationOnce(async () => {
|
|
103
|
-
return { content: [{ type: 'tool_use', id: 'tc1', name: '
|
|
103
|
+
return { content: [{ type: 'tool_use', id: 'tc1', name: 'test_data_search', input: { q: 'x' } }], stopReason: 'tool_use' } satisfies LLMResponse;
|
|
104
104
|
})
|
|
105
105
|
.mockImplementationOnce(async () => {
|
|
106
106
|
ac.abort();
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gemma 4 Web Worker
|
|
3
|
-
* Uses @huggingface/transformers v3+ with WebGPU
|
|
4
|
-
* Requires COOP/COEP headers for SharedArrayBuffer
|
|
5
|
-
*
|
|
6
|
-
* Messages IN: { type: 'init', model?: string }
|
|
7
|
-
* { type: 'chat', id: string, prompt: string, maxTokens?: number }
|
|
8
|
-
* { type: 'abort', id: string }
|
|
9
|
-
* Messages OUT: { type: 'progress', progress: number, status: string }
|
|
10
|
-
* { type: 'ready' }
|
|
11
|
-
* { type: 'token', id: string, token: string }
|
|
12
|
-
* { type: 'done', id: string, text: string }
|
|
13
|
-
* { type: 'error', id: string | null, message: string }
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { AutoProcessor, Gemma4ForConditionalGeneration, TextStreamer, env } from '@huggingface/transformers';
|
|
17
|
-
|
|
18
|
-
env.allowLocalModels = false;
|
|
19
|
-
|
|
20
|
-
const WASM_MODEL_REGISTRY: Record<string, { repo: string; dtype: string }> = {
|
|
21
|
-
'gemma-e2b': { repo: 'onnx-community/gemma-4-E2B-it-ONNX', dtype: 'q4f16' },
|
|
22
|
-
'gemma-e4b': { repo: 'onnx-community/gemma-4-E4B-it-ONNX', dtype: 'q4f16' },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
-
let processor: any = null;
|
|
27
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
-
let model: any = null;
|
|
29
|
-
const abortControllers = new Map<string, AbortController>();
|
|
30
|
-
|
|
31
|
-
self.onmessage = async (e: MessageEvent) => {
|
|
32
|
-
const { type, id, model: modelId, prompt } = e.data as {
|
|
33
|
-
type: string; id?: string; model?: string; prompt?: string;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
if (type === 'init') {
|
|
37
|
-
try {
|
|
38
|
-
const key = modelId ?? 'gemma-e2b';
|
|
39
|
-
const { repo, dtype } = WASM_MODEL_REGISTRY[key] ?? WASM_MODEL_REGISTRY['gemma-e2b'];
|
|
40
|
-
const device = typeof navigator !== 'undefined' && 'gpu' in navigator ? 'webgpu' : 'wasm';
|
|
41
|
-
|
|
42
|
-
const progress_callback = (p: { status: string; progress?: number; loaded?: number; total?: number; name?: string }) => {
|
|
43
|
-
self.postMessage({
|
|
44
|
-
type: 'progress',
|
|
45
|
-
progress: p.progress ?? 0,
|
|
46
|
-
status: p.status,
|
|
47
|
-
name: p.name ?? '',
|
|
48
|
-
loaded: p.loaded,
|
|
49
|
-
total: p.total,
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
processor = await AutoProcessor.from_pretrained(repo, { progress_callback });
|
|
54
|
-
model = await Gemma4ForConditionalGeneration.from_pretrained(repo, {
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
-
dtype: dtype as any,
|
|
57
|
-
device,
|
|
58
|
-
progress_callback,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
self.postMessage({ type: 'ready' });
|
|
62
|
-
} catch (err) {
|
|
63
|
-
self.postMessage({ type: 'error', id: null, message: String(err) });
|
|
64
|
-
}
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const maxTokens = (e.data as { maxTokens?: number }).maxTokens;
|
|
69
|
-
|
|
70
|
-
if (type === 'chat' && id && prompt) {
|
|
71
|
-
if (!model || !processor) {
|
|
72
|
-
self.postMessage({ type: 'error', id, message: 'Model not initialized' });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const ac = new AbortController();
|
|
77
|
-
abortControllers.set(id, ac);
|
|
78
|
-
let fullText = '';
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const conversation = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
|
|
82
|
-
const inputs = await processor.apply_chat_template(conversation, {
|
|
83
|
-
tokenize: true,
|
|
84
|
-
add_generation_prompt: true,
|
|
85
|
-
return_dict: true,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const streamer = new TextStreamer(processor.tokenizer, {
|
|
89
|
-
skip_prompt: true,
|
|
90
|
-
callback_function: (token: string) => {
|
|
91
|
-
fullText += token;
|
|
92
|
-
self.postMessage({ type: 'token', id, token });
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
await model.generate({
|
|
97
|
-
...inputs,
|
|
98
|
-
max_new_tokens: maxTokens ?? 8192,
|
|
99
|
-
do_sample: true,
|
|
100
|
-
temperature: 0.7,
|
|
101
|
-
streamer,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
abortControllers.delete(id);
|
|
105
|
-
self.postMessage({ type: 'done', id, text: fullText });
|
|
106
|
-
} catch (err) {
|
|
107
|
-
abortControllers.delete(id);
|
|
108
|
-
const msg = String(err);
|
|
109
|
-
if (msg.includes('AbortError') || msg.includes('aborted')) {
|
|
110
|
-
self.postMessage({ type: 'done', id, text: fullText });
|
|
111
|
-
} else {
|
|
112
|
-
self.postMessage({ type: 'error', id, message: msg });
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (type === 'abort' && id) {
|
|
119
|
-
abortControllers.get(id)?.abort();
|
|
120
|
-
abortControllers.delete(id);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
};
|