@webmcp-auto-ui/agent 2.5.36 → 2.5.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/agent",
3
- "version": "2.5.36",
3
+ "version": "2.5.38",
4
4
  "description": "LLM agent loop + remote/WASM/local providers + MCP wrapper",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -54,6 +54,40 @@ When the user wants to browse, search, or pick a recipe — for example "show me
54
54
  Call widget_display({name: "recipe-browser", params: {recipes: [...], layout: "list"}}). The widget emits a bubbling 'widget:interact' CustomEvent with detail={action:"pick", payload: recipe} when the user clicks Pick.
55
55
  `;
56
56
 
57
+ // Inline recipe for tool-browser (real vanilla widget)
58
+ const toolBrowserRecipe = `---
59
+ widget: tool-browser
60
+ description: Interactive tool browser with search/filters/preview. Use when the user wants to browse, search, or pick a tool from connected servers.
61
+ group: rich
62
+ schema:
63
+ type: object
64
+ required:
65
+ - tools
66
+ properties:
67
+ tools:
68
+ type: array
69
+ description: List of BrowsableTool objects (name, description, server, inputSchema, ...).
70
+ items:
71
+ type: object
72
+ filters:
73
+ type: object
74
+ description: Initial filters
75
+ properties:
76
+ q:
77
+ type: string
78
+ layout:
79
+ type: string
80
+ enum: [list, grid]
81
+ description: Default layout (default list)
82
+ ---
83
+
84
+ ## When to use
85
+ When the user wants to browse, search, or pick a tool — for example "show me the available tools".
86
+
87
+ ## How to use
88
+ Call widget_display({name: "tool-browser", params: {tools: [...]}}). The widget emits a bubbling 'widget:interact' CustomEvent on user actions.
89
+ `;
90
+
57
91
  // ---------------------------------------------------------------------------
58
92
  // Inline recipes (frontmatter + body)
59
93
  // ---------------------------------------------------------------------------
@@ -306,24 +340,37 @@ schema:
306
340
  title:
307
341
  type: string
308
342
  columns:
343
+ description: Column definitions. Also accepts plain strings ["A","B"]. Alias "headers" is also accepted.
309
344
  type: array
310
345
  items:
311
- type: object
312
- required:
313
- - key
314
- - label
315
- properties:
316
- key:
317
- type: string
318
- label:
319
- type: string
320
- align:
321
- type: string
322
- enum: [left, center, right]
346
+ oneOf:
347
+ - type: string
348
+ - type: object
349
+ required:
350
+ - key
351
+ - label
352
+ properties:
353
+ key:
354
+ type: string
355
+ label:
356
+ type: string
357
+ align:
358
+ type: string
359
+ enum: [left, center, right]
360
+ headers:
361
+ description: Alias for columns. Accepts plain strings ["A","B"] or full column objects.
362
+ type: array
363
+ items:
364
+ oneOf:
365
+ - type: string
366
+ - type: object
323
367
  rows:
368
+ description: Array of row objects OR array of arrays (positionally mapped to columns/headers keys).
324
369
  type: array
325
370
  items:
326
- type: object
371
+ oneOf:
372
+ - type: object
373
+ - type: array
327
374
  ---
328
375
 
329
376
  ## When to use
@@ -331,6 +378,7 @@ Display structured data in a table with column sorting.
331
378
 
332
379
  ## How to use
333
380
  Call widget_display({name: "data-table", params: {columns: [{key:"name",label:"Nom"}], rows: [{name:"Alice"}]}}).
381
+ Rows can also be arrays: {headers:["name","age"], rows:[["Alice",30],["Bob",25]]}.
334
382
  `,
335
383
 
336
384
  // ── timeline ─────────────────────────────────────────────────────────────
@@ -545,8 +593,6 @@ schema:
545
593
  type: array
546
594
  items:
547
595
  type: object
548
- required:
549
- - values
550
596
  properties:
551
597
  label:
552
598
  type: string
@@ -554,6 +600,9 @@ schema:
554
600
  type: array
555
601
  items:
556
602
  type: number
603
+ value:
604
+ type: number
605
+ description: Scalar shorthand — treated as values:[value]. Use for single-point series.
557
606
  color:
558
607
  type: string
559
608
  ---
@@ -563,6 +612,7 @@ Pour des graphiques multi-series (barres, lignes, aires, camembert, donut).
563
612
 
564
613
  ## How to use
565
614
  Call widget_display({name: "chart-rich", params: {type: "bar", labels: ["Q1","Q2"], data: [{label:"Ventes", values:[10,20]}]}}).
615
+ Single-point shorthand: data: [{label:"A", value:5}, {label:"B", value:8}].
566
616
  `,
567
617
 
568
618
  // ── cards ────────────────────────────────────────────────────────────────
@@ -571,13 +621,30 @@ widget: cards
571
621
  description: Card grid (results, records, entities).
572
622
  schema:
573
623
  type: object
574
- required:
575
- - cards
576
624
  properties:
577
625
  title:
578
626
  type: string
579
627
  cards:
580
628
  type: array
629
+ description: List of cards (alias: items)
630
+ items:
631
+ type: object
632
+ required:
633
+ - title
634
+ properties:
635
+ title:
636
+ type: string
637
+ description:
638
+ type: string
639
+ subtitle:
640
+ type: string
641
+ tags:
642
+ type: array
643
+ items:
644
+ type: string
645
+ items:
646
+ type: array
647
+ description: Alias for cards — use either cards or items
581
648
  items:
582
649
  type: object
583
650
  required:
@@ -773,12 +840,11 @@ Call widget_display({name: "carousel", params: {slides: [{src: "https://...", ti
773
840
  // ── stat-card ────────────────────────────────────────────────────────────
774
841
  `---
775
842
  widget: stat-card
776
- description: Enriched KPI with unit, delta, and colored variant (success/warning/error/info).
843
+ description: >
844
+ Enriched KPI card with unit, delta, icon, and colored variant.
845
+ Supports single card OR a grid of N cards via the "items" array.
777
846
  schema:
778
847
  type: object
779
- required:
780
- - label
781
- - value
782
848
  properties:
783
849
  label:
784
850
  type: string
@@ -787,6 +853,11 @@ schema:
787
853
  unit:
788
854
  type: string
789
855
  description: Unite affichee apres la valeur (ex "%", "km")
856
+ icon:
857
+ type: string
858
+ description: >
859
+ Emoji, unicode symbol, or keyword (e.g. "fire", "trending-up", "star").
860
+ Unknown keywords fall back silently to ℹ.
790
861
  delta:
791
862
  type: string
792
863
  description: Variation affichee (ex "+12%")
@@ -798,13 +869,47 @@ schema:
798
869
  variant:
799
870
  type: string
800
871
  enum: [default, success, warning, error, info]
872
+ items:
873
+ type: array
874
+ description: >
875
+ Grid mode: pass an array of stat items to render N cards side by side.
876
+ When present, top-level label/value/icon are ignored.
877
+ items:
878
+ type: object
879
+ required:
880
+ - label
881
+ - value
882
+ properties:
883
+ label:
884
+ type: string
885
+ value:
886
+ type: string
887
+ unit:
888
+ type: string
889
+ icon:
890
+ type: string
891
+ delta:
892
+ type: string
893
+ trend:
894
+ type: string
895
+ enum: [up, down, flat]
896
+ previousValue:
897
+ type: string
898
+ variant:
899
+ type: string
900
+ enum: [default, success, warning, error, info]
801
901
  ---
802
902
 
803
903
  ## When to use
804
- Pour un KPI enrichi avec delta, unite et variante de couleur.
904
+ Pour un KPI enrichi avec delta, unite, icone et variante de couleur.
905
+ Pour plusieurs KPIs cote a cote, utiliser le champ "items".
805
906
 
806
907
  ## How to use
807
- Call widget_display({name: "stat-card", params: {label: "Uptime", value: "99.9", unit: "%", trend: "up", variant: "success"}}).
908
+ Single card:
909
+ Call widget_display({name: "stat-card", params: {label: "Uptime", value: "99.9", unit: "%", trend: "up", variant: "success", icon: "🟢"}}).
910
+
911
+ Grid of cards:
912
+ Call widget_display({name: "stat-card", params: {items: [{label: "CPU", value: "42", unit: "%", icon: "💻", variant: "info"}, {label: "RAM", value: "8", unit: "GB", icon: "🗄", variant: "warning"}]}}).
808
913
  `,
809
914
 
810
915
  // ── grid-data ────────────────────────────────────────────────────────────
@@ -893,6 +998,78 @@ Pour des visualisations custom, animations, ou prototypes interactifs en JS pur.
893
998
 
894
999
  ## How to use
895
1000
  Call widget_display({name: "js-sandbox", params: {code: "document.getElementById('root').innerHTML = '<h1>Hello</h1>'"}}).
1001
+ `,
1002
+
1003
+ // ── map ─────────────────────────────────────────────────────────────────
1004
+ `---
1005
+ widget: map
1006
+ description: Interactive geospatial map (MapLibre GL, light theme) with markers and/or GeoJSON overlay. Auto-fits view to data via Turf bbox.
1007
+ group: rich
1008
+ schema:
1009
+ type: object
1010
+ properties:
1011
+ markers:
1012
+ type: array
1013
+ description: Points to display as map markers. Aliases tolerated per marker — \`lng\` accepted for \`lon\`, \`popup\` accepted for \`label\`.
1014
+ items:
1015
+ type: object
1016
+ required:
1017
+ - lat
1018
+ properties:
1019
+ lat:
1020
+ type: number
1021
+ lon:
1022
+ type: number
1023
+ description: Longitude. Alias \`lng\` also accepted.
1024
+ label:
1025
+ type: string
1026
+ description: Popup text shown when the marker is clicked. Alias \`popup\` also accepted.
1027
+ color:
1028
+ type: string
1029
+ description: CSS color for the marker pin (defaults to blue).
1030
+ geojson:
1031
+ type: object
1032
+ description: GeoJSON Feature or FeatureCollection rendered as fill/line/circle layers.
1033
+ center:
1034
+ description: "Initial center. Either \`[lon, lat]\` array (MapLibre/Turf convention) OR object \`{lat, lon}\` / \`{lat, lng}\`. If omitted, computed from data via Turf bbox."
1035
+ zoom:
1036
+ type: number
1037
+ description: Initial zoom. If omitted, derived by fitting the bbox of the data.
1038
+ height:
1039
+ type: string
1040
+ description: CSS height of the map container (default "400px").
1041
+ cluster:
1042
+ type: boolean
1043
+ description: When true, group nearby markers into clusters (MapLibre native clustering, clusterRadius 50). Click a cluster to zoom in.
1044
+ tileLayers:
1045
+ type: array
1046
+ description: Raster tile overlays drawn on top of the base style (e.g. NASA GIBS imagery).
1047
+ items:
1048
+ type: object
1049
+ required:
1050
+ - url
1051
+ properties:
1052
+ name:
1053
+ type: string
1054
+ description: Optional human-readable layer name.
1055
+ url:
1056
+ type: string
1057
+ description: Tile URL template with \`{z}/{x}/{y}\` placeholders.
1058
+ opacity:
1059
+ type: number
1060
+ description: Layer opacity 0..1 (default 1).
1061
+ required: []
1062
+ ---
1063
+
1064
+ ## When to use
1065
+ Pour visualiser des donnees geospatiales : positions, itineraires, polygones (zones, regions). Choisir cette carte pour points + popups + overlays GeoJSON sur fond clair (Carto Positron).
1066
+
1067
+ ## How to use
1068
+ Call widget_display({name: "map", params: {markers: [{lat: 48.85, lon: 2.35, label: "Paris"}]}}).
1069
+
1070
+ ## Common mistakes
1071
+ - Coordonnees au format [lon, lat] (PAS [lat, lon]) pour le champ \`center\` et pour les Point GeoJSON — convention Turf/MapLibre.
1072
+ - Ne PAS fournir un \`zoom\` sans \`center\` si tu veux l'auto-fit : laisse les deux vides pour que la vue s'adapte automatiquement aux donnees.
896
1073
  `,
897
1074
 
898
1075
  `---
@@ -930,6 +1107,7 @@ Call widget_display({name: "chat-input", params: {placeholder: "Your reply..."}}
930
1107
  const _NOTEBOOK_RECIPE_SOURCES: string[] = [
931
1108
  notebookRecipe as string,
932
1109
  recipeBrowserRecipe,
1110
+ toolBrowserRecipe,
933
1111
  ];
934
1112
  export const NATIVE_WIDGET_NAMES = [...RECIPES, ..._NOTEBOOK_RECIPE_SOURCES].map(r => {
935
1113
  const match = r.match(/widget:\s*(\S+)/);
@@ -955,6 +1133,9 @@ autoui.registerWidget(notebookRecipe as string, renderNotebook as any);
955
1133
  // Recipe browser — resolved by WidgetRenderer as <auto-recipe-browser> custom element
956
1134
  autoui.registerWidget(recipeBrowserRecipe, undefined);
957
1135
 
1136
+ // Tool browser — resolved by WidgetRenderer as <auto-tool-browser> custom element
1137
+ autoui.registerWidget(toolBrowserRecipe, undefined);
1138
+
958
1139
  // Register flow recipes (multi-step procedures) from the global recipe registry
959
1140
  // that declare this server (autoui) in their frontmatter.
960
1141
  for (const [key, rawMd] of Object.entries(RAW_RECIPES)) {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { ProviderTool } from './types.js';
8
8
  import type { PipelineTrace } from './pipeline-trace.js';
9
+ import { sanitizeServerName } from './tool-layers.js';
9
10
 
10
11
  /** Tool names that are resolved locally from cache — hidden from user-facing browsers. */
11
12
  export const DISCOVERY_TOOL_NAMES = new Set(['list_recipes', 'search_recipes', 'get_recipe', 'list_tools', 'search_tools']);
@@ -30,14 +31,21 @@ export interface ServerCache {
30
31
  export class DiscoveryCache {
31
32
  private servers = new Map<string, ServerCache>();
32
33
 
34
+ /** Normalize a server prefix so register/lookup share the same key shape
35
+ * regardless of casing, spaces, or other artifacts. Mirrors the
36
+ * sanitization applied to tool name prefixes by tool-layers. */
37
+ private norm(serverPrefix: string): string {
38
+ return sanitizeServerName(serverPrefix);
39
+ }
40
+
33
41
  /** Register a server's recipes and tools */
34
42
  register(serverPrefix: string, data: ServerCache): void {
35
- this.servers.set(serverPrefix, data);
43
+ this.servers.set(this.norm(serverPrefix), data);
36
44
  }
37
45
 
38
46
  /** Check if we have cached data for a server prefix */
39
47
  has(serverPrefix: string): boolean {
40
- return this.servers.has(serverPrefix);
48
+ return this.servers.has(this.norm(serverPrefix));
41
49
  }
42
50
 
43
51
  /** Clear all cached data */
@@ -45,7 +53,7 @@ export class DiscoveryCache {
45
53
  this.servers.clear();
46
54
  }
47
55
 
48
- /** All registered server prefixes */
56
+ /** All registered server prefixes (normalized) */
49
57
  serverPrefixes(): string[] {
50
58
  return [...this.servers.keys()];
51
59
  }
@@ -70,22 +78,22 @@ export class DiscoveryCache {
70
78
 
71
79
  /** Recipe count for a specific server */
72
80
  recipeCount(serverPrefix: string): number {
73
- return this.servers.get(serverPrefix)?.recipes.length ?? 0;
81
+ return this.servers.get(this.norm(serverPrefix))?.recipes.length ?? 0;
74
82
  }
75
83
 
76
84
  /** Get the cached recipes for a specific server prefix. */
77
85
  recipesFor(serverPrefix: string): CachedRecipe[] {
78
- return this.servers.get(serverPrefix)?.recipes ?? [];
86
+ return this.servers.get(this.norm(serverPrefix))?.recipes ?? [];
79
87
  }
80
88
 
81
89
  /** Tool count for a specific server */
82
90
  toolCount(serverPrefix: string): number {
83
- return this.servers.get(serverPrefix)?.tools.length ?? 0;
91
+ return this.servers.get(this.norm(serverPrefix))?.tools.length ?? 0;
84
92
  }
85
93
 
86
94
  /** Tool count excluding discovery tools (hidden from user-facing browsers) */
87
95
  browsableToolCount(serverPrefix: string): number {
88
- const tools = this.servers.get(serverPrefix)?.tools ?? [];
96
+ const tools = this.servers.get(this.norm(serverPrefix))?.tools ?? [];
89
97
  return tools.filter(t => !DISCOVERY_TOOL_NAMES.has(t.name)).length;
90
98
  }
91
99
 
@@ -103,7 +111,7 @@ export class DiscoveryCache {
103
111
  params: Record<string, unknown>,
104
112
  trace?: PipelineTrace,
105
113
  ): string | null {
106
- const cache = this.servers.get(serverPrefix);
114
+ const cache = this.servers.get(this.norm(serverPrefix));
107
115
  if (!cache) return null;
108
116
 
109
117
  switch (realToolName) {
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ export { GemmaProvider } from './providers/gemma.js';
32
32
  export type { GemmaProviderOptions, GemmaStatus } from './providers/gemma.js';
33
33
 
34
34
  // Agent loop
35
- export { runAgentLoop, toProviderTools, fromMcpTools, trimConversationHistory } from './loop.js';
35
+ export { runAgentLoop, toProviderTools, fromMcpTools, trimConversationHistory, pruneOrphanToolResults } from './loop.js';
36
36
  export { buildSystemPrompt, buildSystemPromptWithAliases } from './prompts/index.js';
37
37
  export type { SystemPromptResult } from './prompts/index.js';
38
38
  export type { AgentLoopOptions } from './loop.js';
@@ -41,7 +41,7 @@ export type { AgentLoopOptions } from './loop.js';
41
41
  export { autoui, NATIVE_WIDGET_NAMES } from './autoui-server.js';
42
42
 
43
43
  // Tool layers
44
- export { buildToolsFromLayers, buildDiscoveryTools, buildDiscoveryToolsWithAliases, activateServerTools, resolveCanonicalTools, toolAliasMap, flattenPathMaps, buildDiscoveryCache } from './tool-layers.js';
44
+ export { buildToolsFromLayers, buildDiscoveryTools, buildDiscoveryToolsWithAliases, activateServerTools, resolveCanonicalTools, toolAliasMap, flattenPathMaps, buildDiscoveryCache, sanitizeServerName } from './tool-layers.js';
45
45
  export type { ToolLayer, McpLayer, WebMcpLayer, DiscoveryToolsResult, SchemaTransformOptions, BuildToolsResult, ProviderKind } from './tool-layers.js';
46
46
 
47
47
  // Discovery cache
@@ -88,7 +88,7 @@ export {
88
88
  } from './ort-version.js';
89
89
 
90
90
  // Trace observer — live visual trace for runAgentLoop
91
- export { createTraceObserver, type TraceObserver, type TraceObserverContext, type RoundTripDetail } from './trace-observer.js';
91
+ export { createTraceObserver, type TraceObserver, type TraceObserverContext, type RoundTripDetail, type WidgetLineage } from './trace-observer.js';
92
92
 
93
93
  // Nano-RAG — context compaction
94
94
  export { ContextRAG, type ContextRAGOptions } from './nano-rag/mod.js';
package/src/loop.ts CHANGED
@@ -12,6 +12,7 @@ import type { ToolLayer, SchemaTransformOptions } from './tool-layers.js';
12
12
  import { buildToolsFromLayers, buildDiscoveryToolsWithAliases, activateServerTools, toProviderTools, sanitizeServerName } from './tool-layers.js';
13
13
  import { buildSystemPromptWithAliases } from './prompts/index.js';
14
14
  import type { DiscoveryCache } from './discovery-cache.js';
15
+ import { DISCOVERY_TOOL_NAMES } from './discovery-cache.js';
15
16
  import { unflattenParams, validateJsonSchema } from '@webmcp-auto-ui/core';
16
17
  import type { JsonSchema } from '@webmcp-auto-ui/core';
17
18
  import { autoRepairParams } from './auto-repair.js';
@@ -22,12 +23,6 @@ export { buildSystemPrompt } from './prompts/index.js';
22
23
 
23
24
  const MAX_RESULT_LEN = 10_000;
24
25
 
25
- /** Tool names (after prefix strip) that indicate discovery/exploration */
26
- const DISCOVERY_TOOL_NAMES = new Set([
27
- 'search_recipes', 'get_recipe', 'list_recipes', 'list_tables', 'describe_table',
28
- 'get_json_schemas', 'get_typescript_types', 'recall',
29
- ]);
30
-
31
26
  function isDiscoveryTool(prefixedName: string): boolean {
32
27
  const match = prefixedName.match(/^.+?_(data|ui)_(.+)$/);
33
28
  if (!match) return false;
@@ -275,6 +270,10 @@ export async function runAgentLoop(
275
270
  }
276
271
  }
277
272
 
273
+ // Defensive: strip any orphan tool_result blocks before sending — strict
274
+ // providers (Anthropic) reject these with a 400.
275
+ pruneOrphanToolResults(messages);
276
+
278
277
  callbacks.onLLMRequest?.(messages, iterationTools);
279
278
  const t0 = performance.now();
280
279
  let streamingText = '';
@@ -360,14 +359,28 @@ export async function runAgentLoop(
360
359
  }
361
360
 
362
361
  // ── Intercept list_tools / search_tools (local pseudo-tools) ──
363
- // These are read-only discovery operations do NOT activate the server.
362
+ // The agent inspecting a server's tools is a strong signal of intent to use them.
363
+ // Activate the server now so the next iteration can invoke its data tools directly,
364
+ // matching the system-prompt promise that "list_tools → use it directly in STEP 3".
364
365
  if (toolMatch && (toolMatch[3] === 'list_tools' || toolMatch[3] === 'search_tools')) {
365
366
  const [, serverName, token, pseudoTool] = toolMatch;
366
367
  const protocol = tokenToProtocol(token);
367
368
  const layer = (options.layers ?? []).find(l => sanitizeServerName(l.serverName) === serverName && l.protocol === protocol);
368
369
  if (!layer) {
369
370
  result = 'Error: server not found';
370
- } else if (pseudoTool === 'list_tools') {
371
+ } else {
372
+ const serverKey = `${serverName}_${token}`;
373
+ if (!activatedServers.has(serverKey)) {
374
+ try {
375
+ const act = activateServerTools(activeTools, layer, schemaOptions, trace);
376
+ activeTools = act.tools;
377
+ for (const [k, v] of act.pathMaps) localPathMaps.set(k, v);
378
+ activatedServers.add(serverKey);
379
+ } catch (e) {
380
+ trace.push('activate', name, `activation failed for ${serverKey}: ${e instanceof Error ? e.message : String(e)}`, 'error');
381
+ }
382
+ }
383
+ if (pseudoTool === 'list_tools') {
371
384
  const tools = layer.tools.map(t => ({ name: t.name, description: t.description, inputSchema: (t as any).inputSchema }));
372
385
  result = JSON.stringify(tools, null, 2);
373
386
  } else {
@@ -384,6 +397,7 @@ export async function runAgentLoop(
384
397
  result = JSON.stringify(tools, null, 2);
385
398
  }
386
399
  }
400
+ }
387
401
  } else {
388
402
  // ── Normal tool dispatch — activate server on first contact ──
389
403
 
@@ -456,10 +470,18 @@ export async function runAgentLoop(
456
470
  if (!client) {
457
471
  result = `Error: no MCP client available for tool ${name}`;
458
472
  } else {
459
- const mcpResult = await client.callTool(realToolName, toolInput);
460
- const textContent = mcpResult.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
461
- const rawResult = textContent?.text ?? JSON.stringify(mcpResult);
462
- result = truncateResults ? truncateResult(rawResult, maxResultLength) : rawResult;
473
+ const mcpLayer = (options.layers ?? []).find(
474
+ l => sanitizeServerName(l.serverName) === serverName && l.protocol === 'mcp'
475
+ ) as { serverUrl?: string } | undefined;
476
+ const serverUrl = mcpLayer?.serverUrl;
477
+ if (!serverUrl) {
478
+ result = `Error: no serverUrl resolved for tool "${name}" (server "${serverName}"). Pass serverUrl on the matching ToolLayer.`;
479
+ } else {
480
+ const mcpResult = await (client as unknown as { callToolOn: (u: string, n: string, a: unknown) => Promise<{ content?: { type: string; text?: string }[] }> }).callToolOn(serverUrl, realToolName, toolInput);
481
+ const textContent = mcpResult.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
482
+ const rawResult = textContent?.text ?? JSON.stringify(mcpResult);
483
+ result = truncateResults ? truncateResult(rawResult, maxResultLength) : rawResult;
484
+ }
463
485
  }
464
486
  } else if (protocol === 'webmcp') {
465
487
  // Intercept recall BEFORE hitting executeTool — use the local resultBuffer directly
@@ -539,6 +561,15 @@ export async function runAgentLoop(
539
561
  const c = messages[i].content;
540
562
  if (Array.isArray(c) && c.length === 0) messages.splice(i, 1);
541
563
  }
564
+ // Also drop pending tool_results in the current iteration whose
565
+ // tool_use was just stripped from the assistant turn — otherwise
566
+ // they become orphans when pushed at the end of the loop.
567
+ for (let j = toolResults.length - 1; j >= 0; j--) {
568
+ const b = toolResults[j];
569
+ if (b.type === 'tool_result' && strippedIds.has((b as { tool_use_id: string }).tool_use_id)) {
570
+ toolResults.splice(j, 1);
571
+ }
572
+ }
542
573
  }
543
574
  hasRendered = false;
544
575
  }
@@ -663,18 +694,39 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
663
694
 
664
695
  // Remove orphaned tool_result blocks anywhere in history — strict providers
665
696
  // (Anthropic, etc.) reject tool_result blocks whose tool_use_id does not
666
- // correspond to an earlier assistant tool_use. Head-only pruning misses
667
- // internal orphans caused by mid-history trims.
697
+ // correspond to an earlier assistant tool_use.
698
+ pruneOrphanToolResults(trimmed);
699
+
700
+ // Ensure the first non-system message is role=user (API requirement)
701
+ while (trimmed.length > 0) {
702
+ const firstNonSystem = trimmed.findIndex(m => m.role !== 'system');
703
+ if (firstNonSystem >= 0 && trimmed[firstNonSystem].role === 'assistant') {
704
+ trimmed.splice(firstNonSystem, 1);
705
+ } else {
706
+ break;
707
+ }
708
+ }
709
+
710
+ return trimmed;
711
+ }
712
+
713
+ /**
714
+ * Strip orphan tool_result blocks (whose tool_use_id has no matching tool_use
715
+ * in any preceding assistant message) and drop user messages that become
716
+ * empty as a result. Mutates `messages` in place.
717
+ *
718
+ * Strict providers (Anthropic API) reject orphans with a 400. This guard
719
+ * runs immediately before each provider call.
720
+ */
721
+ export function pruneOrphanToolResults(messages: ChatMessage[]): void {
668
722
  const validToolUseIds = new Set<string>();
669
- for (let i = 0; i < trimmed.length; i++) {
670
- const msg = trimmed[i];
671
- // Collect tool_use ids from assistant messages seen so far
723
+ for (let i = 0; i < messages.length; i++) {
724
+ const msg = messages[i];
672
725
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
673
726
  for (const b of msg.content as any[]) {
674
727
  if (b?.type === 'tool_use' && typeof b.id === 'string') validToolUseIds.add(b.id);
675
728
  }
676
729
  }
677
- // Filter out orphan tool_result blocks in user messages
678
730
  if (msg.role === 'user' && Array.isArray(msg.content)) {
679
731
  msg.content = (msg.content as any[]).filter(b => {
680
732
  if (b?.type !== 'tool_result') return true;
@@ -682,21 +734,8 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
682
734
  }) as any;
683
735
  }
684
736
  }
685
- // Drop user messages that became empty after orphan-pruning
686
- for (let i = trimmed.length - 1; i >= 0; i--) {
687
- const c = trimmed[i].content;
688
- if (Array.isArray(c) && c.length === 0) trimmed.splice(i, 1);
737
+ for (let i = messages.length - 1; i >= 0; i--) {
738
+ const c = messages[i].content;
739
+ if (Array.isArray(c) && c.length === 0) messages.splice(i, 1);
689
740
  }
690
-
691
- // Ensure the first non-system message is role=user (API requirement)
692
- while (trimmed.length > 0) {
693
- const firstNonSystem = trimmed.findIndex(m => m.role !== 'system');
694
- if (firstNonSystem >= 0 && trimmed[firstNonSystem].role === 'assistant') {
695
- trimmed.splice(firstNonSystem, 1);
696
- } else {
697
- break;
698
- }
699
- }
700
-
701
- return trimmed;
702
741
  }
@@ -261,10 +261,9 @@ export function toProviderTools(tools: McpToolDef[], schemaOptions?: SchemaTrans
261
261
  }
262
262
  }
263
263
  }
264
- const schemaObj = schema as Record<string, unknown>;
265
- // Ensure root schema has additionalProperties for strict mode
264
+ let schemaObj = schema as Record<string, unknown>;
266
265
  if (schemaObj.type === 'object' && !('additionalProperties' in schemaObj)) {
267
- schemaObj.additionalProperties = false;
266
+ schemaObj = { ...schemaObj, additionalProperties: false };
268
267
  }
269
268
  return {
270
269
  name: t.name,
@@ -42,6 +42,19 @@ export interface RoundTripDetail {
42
42
  originRecipe?: string;
43
43
  }
44
44
 
45
+ /** Lineage of a widget mount: tool_calls in the same iteration that produced it. */
46
+ export interface WidgetLineage {
47
+ widgetType: string;
48
+ widgetParams: Record<string, unknown>;
49
+ toolCalls: Array<{
50
+ serverName: string;
51
+ toolName: string;
52
+ args: Record<string, unknown>;
53
+ resultPreview?: string;
54
+ }>;
55
+ originRecipe?: string;
56
+ }
57
+
45
58
  export interface TraceObserver {
46
59
  /** Partial AgentCallbacks to merge into runAgentLoop callbacks. */
47
60
  callbacks: Partial<AgentCallbacks>;
@@ -57,6 +70,8 @@ export interface TraceObserver {
57
70
  getLoadedRecipes: () => Map<string, string>;
58
71
  /** Current recipe context: name of the most recently loaded recipe via get_recipe, or undefined. */
59
72
  getCurrentRecipeContext: () => string | undefined;
73
+ /** Resolve the chain of tool_calls in the same iteration that produced a widget. */
74
+ getWidgetLineage: (blockIdOrTraceNodeId: string) => WidgetLineage | null;
60
75
  }
61
76
 
62
77
  type NodeKind =
@@ -552,11 +567,12 @@ export function createTraceObserver(ctx: TraceObserverContext): TraceObserver {
552
567
  scheduleFlush();
553
568
  },
554
569
 
555
- onWidget: (type: string, _data: Record<string, unknown>, serverName?: string): void => {
570
+ onWidget: (type: string, data: Record<string, unknown>, serverName?: string): void => {
556
571
  const node = addNode('widget', `${serverName ?? '?'}/${type}`, {
557
572
  iterationId: currentIterationId,
558
573
  type,
559
574
  serverName,
575
+ data,
560
576
  });
561
577
  const parent = lastToolCallId ?? currentLlmRespId;
562
578
  if (parent) {
@@ -654,5 +670,66 @@ export function createTraceObserver(ctx: TraceObserverContext): TraceObserver {
654
670
  getCurrentRecipeContext(): string | undefined {
655
671
  return currentRecipeContext;
656
672
  },
673
+ getWidgetLineage(blockIdOrTraceNodeId: string): WidgetLineage | null {
674
+ let widgetNode = nodes.find(
675
+ (n) => n.kind === 'widget' && n.id === blockIdOrTraceNodeId,
676
+ );
677
+ if (!widgetNode) {
678
+ widgetNode = nodes.find((n) => {
679
+ if (n.kind !== 'widget') return false;
680
+ for (const v of Object.values(n.meta)) {
681
+ if (v === blockIdOrTraceNodeId) return true;
682
+ if (v && typeof v === 'object') {
683
+ for (const vv of Object.values(v as Record<string, unknown>)) {
684
+ if (vv === blockIdOrTraceNodeId) return true;
685
+ }
686
+ }
687
+ }
688
+ return false;
689
+ });
690
+ }
691
+ if (!widgetNode) return null;
692
+ const iter = iterationAncestor(widgetNode);
693
+ if (!iter) {
694
+ return {
695
+ widgetType: (widgetNode.meta.type as string) ?? '',
696
+ widgetParams: ((widgetNode.meta.data as Record<string, unknown>) ?? {}) as Record<string, unknown>,
697
+ toolCalls: [],
698
+ };
699
+ }
700
+ const callNodes = nodes
701
+ .filter(
702
+ (n) =>
703
+ n.kind === 'tool_call' &&
704
+ n.meta.iterationId === iter.id &&
705
+ n.startMs <= widgetNode!.startMs,
706
+ )
707
+ .sort((a, b) => a.startMs - b.startMs);
708
+ const toolCalls: WidgetLineage['toolCalls'] = [];
709
+ let originRecipe: string | undefined;
710
+ for (const c of callNodes) {
711
+ const detail = nodeDetails.get(c.id);
712
+ const toolName = detail?.toolName ?? '';
713
+ const serverName = (c.meta.serverName as string | undefined)
714
+ ?? (toolName.includes('_') ? toolName.split('_')[0]! : '');
715
+ const args = ((c.meta.args as Record<string, unknown>) ?? {}) as Record<string, unknown>;
716
+ let resultPreview: string | undefined;
717
+ if (detail?.toolResult !== undefined) {
718
+ resultPreview = detail.toolResult.slice(0, 4096);
719
+ }
720
+ toolCalls.push({ serverName, toolName, args, resultPreview });
721
+ if (toolName === 'get_recipe' || toolName.endsWith('_get_recipe')) {
722
+ const r = (args.recipe_name ?? args.name ?? args.id) as unknown;
723
+ if (typeof r === 'string') originRecipe = r;
724
+ }
725
+ }
726
+ const lineage: WidgetLineage = {
727
+ widgetType: (widgetNode.meta.type as string) ?? '',
728
+ widgetParams: ((widgetNode.meta.data as Record<string, unknown>) ?? {}) as Record<string, unknown>,
729
+ toolCalls,
730
+ };
731
+ if (originRecipe) lineage.originRecipe = originRecipe;
732
+ return lineage;
733
+ },
657
734
  };
658
735
  }