@webmcp-auto-ui/agent 2.5.25 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/autoui-server.ts +17 -0
  3. package/src/diagnostics.ts +6 -6
  4. package/src/discovery-cache.ts +17 -3
  5. package/src/index.ts +3 -3
  6. package/src/loop.ts +27 -22
  7. package/src/providers/wasm.ts +185 -345
  8. package/src/recipes/_generated.ts +273 -0
  9. package/src/recipes/canary-data.md +50 -0
  10. package/src/recipes/canary-display.md +99 -0
  11. package/src/recipes/canary-middle.md +32 -0
  12. package/src/recipes/hummingbird-data.md +32 -0
  13. package/src/recipes/hummingbird-display.md +36 -0
  14. package/src/recipes/hummingbird-middle.md +18 -0
  15. package/src/tool-layers.ts +303 -31
  16. package/src/types.ts +6 -1
  17. package/tests/loop.test.ts +2 -2
  18. package/src/providers/gemma.worker.legacy.ts +0 -123
  19. package/src/providers/litert.worker.ts +0 -294
  20. package/src/recipes/widgets/actions.md +0 -28
  21. package/src/recipes/widgets/alert.md +0 -27
  22. package/src/recipes/widgets/cards.md +0 -41
  23. package/src/recipes/widgets/carousel.md +0 -39
  24. package/src/recipes/widgets/chart-rich.md +0 -51
  25. package/src/recipes/widgets/chart.md +0 -32
  26. package/src/recipes/widgets/code.md +0 -21
  27. package/src/recipes/widgets/d3.md +0 -36
  28. package/src/recipes/widgets/data-table.md +0 -46
  29. package/src/recipes/widgets/gallery.md +0 -39
  30. package/src/recipes/widgets/grid-data.md +0 -57
  31. package/src/recipes/widgets/hemicycle.md +0 -43
  32. package/src/recipes/widgets/js-sandbox.md +0 -32
  33. package/src/recipes/widgets/json-viewer.md +0 -27
  34. package/src/recipes/widgets/kv.md +0 -31
  35. package/src/recipes/widgets/list.md +0 -24
  36. package/src/recipes/widgets/log.md +0 -39
  37. package/src/recipes/widgets/map.md +0 -49
  38. package/src/recipes/widgets/profile.md +0 -49
  39. package/src/recipes/widgets/recipe-browser.md +0 -102
  40. package/src/recipes/widgets/sankey.md +0 -54
  41. package/src/recipes/widgets/stat-card.md +0 -43
  42. package/src/recipes/widgets/stat.md +0 -35
  43. package/src/recipes/widgets/tags.md +0 -30
  44. package/src/recipes/widgets/text.md +0 -19
  45. package/src/recipes/widgets/timeline.md +0 -38
  46. package/src/recipes/widgets/trombinoscope.md +0 -39
@@ -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}_{protocol}_{tool} convention. */
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(layers: ToolLayer[]): SystemPromptResult {
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)}_webmcp_`;
512
+ const prefix = `${sanitizeServerName(l.serverName)}_ui_`;
367
513
  for (const t of l.tools) {
368
- if (t.name === 'search_recipes') searchRecipes.push(`${prefix}search_recipes()`);
369
- if (t.name === 'list_recipes') listRecipes.push(`${prefix}list_recipes()`);
370
- if (t.name === 'get_recipe') getRecipes.push(`${prefix}get_recipe()`);
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
- searchTools.push(`${prefix}search_tools(query)`);
374
- listTools.push(`${prefix}list_tools()`);
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)}_mcp_`;
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
- if (m.role === 'search_recipes') searchRecipes.push(`${canonicalPrefixed}()`);
392
- if (m.role === 'list_recipes') listRecipes.push(`${canonicalPrefixed}()`);
393
- if (m.role === 'get_recipe') getRecipes.push(`${canonicalPrefixed}()`);
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
- searchTools.push(`${prefix}search_tools(query)`);
398
- listTools.push(`${prefix}list_tools()`);
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)}_webmcp_`;
406
- for (const t of l.tools) {
407
- if (ACTION_NAMES.includes(t.name)) actionTools.push(`${prefix}${t.name}`);
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
- // ── Build prompt (cascade: list recipes search recipes list tools → search tools) ──
412
- let prompt = `You are an AI assistant that helps users by answering their questions and completing tasks using recipes (also called skills). These are not cooking recipes but instructions for an AI agent with scripts, schemas, and information to help it. If you cannot find a relevant recipe or tool, you may fall back to a traditional chat without tool calling (STEP 5).
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: You MUST execute all steps silently. Do NOT generate any internal reasoning, thinking, or intermediate text.
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
- Follow the recipe instructions exactly if you have one. Otherwise use the tools directly. Produce ONLY the final result, a one-sentence summary of the action performed, and the result.
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: `Search tools by keyword on the ${layer.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.`,
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: `List ALL available tools on the ${layer.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.`,
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: `Search tools by keyword on the ${layer.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.`,
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: `List ALL available tools on the ${layer.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.`,
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; maxTools?: number; maxMessages?: number }
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
 
@@ -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: 'test_mcp_search', input: { q: 'x' } }],
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: 'test_mcp_search', input: { q: 'x' } }], stopReason: 'tool_use' } satisfies LLMResponse;
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
- };