@webmcp-auto-ui/agent 2.5.36 → 2.5.37

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.37",
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
  // ---------------------------------------------------------------------------
@@ -930,6 +964,7 @@ Call widget_display({name: "chat-input", params: {placeholder: "Your reply..."}}
930
964
  const _NOTEBOOK_RECIPE_SOURCES: string[] = [
931
965
  notebookRecipe as string,
932
966
  recipeBrowserRecipe,
967
+ toolBrowserRecipe,
933
968
  ];
934
969
  export const NATIVE_WIDGET_NAMES = [...RECIPES, ..._NOTEBOOK_RECIPE_SOURCES].map(r => {
935
970
  const match = r.match(/widget:\s*(\S+)/);
@@ -955,6 +990,9 @@ autoui.registerWidget(notebookRecipe as string, renderNotebook as any);
955
990
  // Recipe browser — resolved by WidgetRenderer as <auto-recipe-browser> custom element
956
991
  autoui.registerWidget(recipeBrowserRecipe, undefined);
957
992
 
993
+ // Tool browser — resolved by WidgetRenderer as <auto-tool-browser> custom element
994
+ autoui.registerWidget(toolBrowserRecipe, undefined);
995
+
958
996
  // Register flow recipes (multi-step procedures) from the global recipe registry
959
997
  // that declare this server (autoui) in their frontmatter.
960
998
  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
package/src/loop.ts CHANGED
@@ -275,6 +275,10 @@ export async function runAgentLoop(
275
275
  }
276
276
  }
277
277
 
278
+ // Defensive: strip any orphan tool_result blocks before sending — strict
279
+ // providers (Anthropic) reject these with a 400.
280
+ pruneOrphanToolResults(messages);
281
+
278
282
  callbacks.onLLMRequest?.(messages, iterationTools);
279
283
  const t0 = performance.now();
280
284
  let streamingText = '';
@@ -456,10 +460,18 @@ export async function runAgentLoop(
456
460
  if (!client) {
457
461
  result = `Error: no MCP client available for tool ${name}`;
458
462
  } 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;
463
+ const mcpLayer = (options.layers ?? []).find(
464
+ l => sanitizeServerName(l.serverName) === serverName && l.protocol === 'mcp'
465
+ ) as { serverUrl?: string } | undefined;
466
+ const serverUrl = mcpLayer?.serverUrl;
467
+ if (!serverUrl) {
468
+ result = `Error: no serverUrl resolved for tool "${name}" (server "${serverName}"). Pass serverUrl on the matching ToolLayer.`;
469
+ } else {
470
+ const mcpResult = await (client as unknown as { callToolOn: (u: string, n: string, a: unknown) => Promise<{ content?: { type: string; text?: string }[] }> }).callToolOn(serverUrl, realToolName, toolInput);
471
+ const textContent = mcpResult.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
472
+ const rawResult = textContent?.text ?? JSON.stringify(mcpResult);
473
+ result = truncateResults ? truncateResult(rawResult, maxResultLength) : rawResult;
474
+ }
463
475
  }
464
476
  } else if (protocol === 'webmcp') {
465
477
  // Intercept recall BEFORE hitting executeTool — use the local resultBuffer directly
@@ -539,6 +551,15 @@ export async function runAgentLoop(
539
551
  const c = messages[i].content;
540
552
  if (Array.isArray(c) && c.length === 0) messages.splice(i, 1);
541
553
  }
554
+ // Also drop pending tool_results in the current iteration whose
555
+ // tool_use was just stripped from the assistant turn — otherwise
556
+ // they become orphans when pushed at the end of the loop.
557
+ for (let j = toolResults.length - 1; j >= 0; j--) {
558
+ const b = toolResults[j];
559
+ if (b.type === 'tool_result' && strippedIds.has((b as { tool_use_id: string }).tool_use_id)) {
560
+ toolResults.splice(j, 1);
561
+ }
562
+ }
542
563
  }
543
564
  hasRendered = false;
544
565
  }
@@ -663,18 +684,39 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
663
684
 
664
685
  // Remove orphaned tool_result blocks anywhere in history — strict providers
665
686
  // (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.
687
+ // correspond to an earlier assistant tool_use.
688
+ pruneOrphanToolResults(trimmed);
689
+
690
+ // Ensure the first non-system message is role=user (API requirement)
691
+ while (trimmed.length > 0) {
692
+ const firstNonSystem = trimmed.findIndex(m => m.role !== 'system');
693
+ if (firstNonSystem >= 0 && trimmed[firstNonSystem].role === 'assistant') {
694
+ trimmed.splice(firstNonSystem, 1);
695
+ } else {
696
+ break;
697
+ }
698
+ }
699
+
700
+ return trimmed;
701
+ }
702
+
703
+ /**
704
+ * Strip orphan tool_result blocks (whose tool_use_id has no matching tool_use
705
+ * in any preceding assistant message) and drop user messages that become
706
+ * empty as a result. Mutates `messages` in place.
707
+ *
708
+ * Strict providers (Anthropic API) reject orphans with a 400. This guard
709
+ * runs immediately before each provider call.
710
+ */
711
+ export function pruneOrphanToolResults(messages: ChatMessage[]): void {
668
712
  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
713
+ for (let i = 0; i < messages.length; i++) {
714
+ const msg = messages[i];
672
715
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
673
716
  for (const b of msg.content as any[]) {
674
717
  if (b?.type === 'tool_use' && typeof b.id === 'string') validToolUseIds.add(b.id);
675
718
  }
676
719
  }
677
- // Filter out orphan tool_result blocks in user messages
678
720
  if (msg.role === 'user' && Array.isArray(msg.content)) {
679
721
  msg.content = (msg.content as any[]).filter(b => {
680
722
  if (b?.type !== 'tool_result') return true;
@@ -682,21 +724,8 @@ export function trimConversationHistory(history: ChatMessage[], maxTokens: numbe
682
724
  }) as any;
683
725
  }
684
726
  }
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);
727
+ for (let i = messages.length - 1; i >= 0; i--) {
728
+ const c = messages[i].content;
729
+ if (Array.isArray(c) && c.length === 0) messages.splice(i, 1);
689
730
  }
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
731
  }
@@ -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,