@frockbot/plugin-tools 0.3.2 → 0.3.4

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": "@frockbot/plugin-tools",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typecheck": "tsc --noEmit -p tsconfig.json"
18
18
  },
19
19
  "dependencies": {
20
- "@frockbot/kernel-contracts": "0.3.2",
20
+ "@frockbot/kernel-contracts": "0.3.4",
21
21
  "cordis": "4.0.0-rc.8"
22
22
  },
23
23
  "devDependencies": {
@@ -0,0 +1,485 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ type PromptAssemblyContext,
4
+ type PromptSection,
5
+ type ToolCall,
6
+ type ToolDefinition,
7
+ type ToolExecutionContext,
8
+ } from "@frockbot/kernel-contracts";
9
+ import { Context, Service } from "cordis";
10
+ import {
11
+ CALL_DYNAMIC_TOOL_NAME,
12
+ FROCKBOT_NAMESPACE_USE_INSTRUCTIONS,
13
+ GET_DYNAMIC_TOOLS_NAME,
14
+ ToolRegistry,
15
+ } from "./tools.js";
16
+
17
+ class PromptFixture extends Service {
18
+ readonly sections = new Map<string, PromptSection>();
19
+
20
+ constructor(ctx: Context) {
21
+ super(ctx, "systemPrompt");
22
+ }
23
+
24
+ register(section: PromptSection): () => void {
25
+ this.sections.set(section.id, section);
26
+ return () => this.sections.delete(section.id);
27
+ }
28
+
29
+ async assemble(context: PromptAssemblyContext) {
30
+ const sections = await Promise.all(
31
+ [...this.sections.values()].map(async (section) => ({
32
+ id: section.id,
33
+ text: await section.render(context),
34
+ })),
35
+ );
36
+ return {
37
+ sections,
38
+ text: sections
39
+ .map(({ text }) => text.trim())
40
+ .filter(Boolean)
41
+ .join("\n\n"),
42
+ };
43
+ }
44
+ }
45
+
46
+ const roots: Context[] = [];
47
+
48
+ async function rootWithTools(prompt = false): Promise<Context> {
49
+ const root = new Context();
50
+ roots.push(root);
51
+ if (prompt) await root.plugin(PromptFixture);
52
+ await root.plugin(ToolRegistry);
53
+ return root;
54
+ }
55
+
56
+ function contextFor(call: ToolCall): ToolExecutionContext {
57
+ return {
58
+ botId: "bot-1",
59
+ agentId: "bot-1",
60
+ sessionId: "user-1:bot-1",
61
+ compositionGenerationId: "generation-1",
62
+ effectId: "tool:1:1:0",
63
+ toolCall: call,
64
+ turnType: "chat",
65
+ signal: new AbortController().signal,
66
+ };
67
+ }
68
+
69
+ async function invoke(root: Context, name: string, input: unknown) {
70
+ const call = { id: "call-1", name, input };
71
+ const context = contextFor(call);
72
+ const preparation = await root.tools.prepare(call, context);
73
+ if (preparation.kind === "denied") return preparation.result;
74
+ return root.tools.executePrepared(preparation, context);
75
+ }
76
+
77
+ function dynamicTool(
78
+ namespace: string,
79
+ name: string,
80
+ description = `${namespace}/${name}`,
81
+ ): ToolDefinition {
82
+ return {
83
+ namespace,
84
+ name,
85
+ description,
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: { value: { type: "string" } },
89
+ },
90
+ execute: (input) =>
91
+ Promise.resolve({ content: JSON.stringify(input), isError: false }),
92
+ };
93
+ }
94
+
95
+ afterEach(async () => {
96
+ await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
97
+ });
98
+
99
+ describe("progressive tool disclosure", () => {
100
+ test("keeps namespaced schemas hidden and always exposes the two meta-tools", async () => {
101
+ const root = await rootWithTools();
102
+ root.tools.register({
103
+ name: "native_read",
104
+ description: "Native.",
105
+ inputSchema: { type: "object" },
106
+ execute: () => Promise.resolve({ content: "read", isError: false }),
107
+ });
108
+ root.tools.register(dynamicTool("mail", "search"));
109
+
110
+ expect(
111
+ root.tools.schemas({ turnType: "chat" }).map(({ name }) => name),
112
+ ).toEqual(["native_read", GET_DYNAMIC_TOOLS_NAME, CALL_DYNAMIC_TOOL_NAME]);
113
+ expect(root.tools.registeredNames?.()).toEqual([
114
+ CALL_DYNAMIC_TOOL_NAME,
115
+ GET_DYNAMIC_TOOLS_NAME,
116
+ "mail/search",
117
+ "native_read",
118
+ ]);
119
+ const schemas = root.tools.schemas({ turnType: "chat" });
120
+ for (const name of [GET_DYNAMIC_TOOLS_NAME, CALL_DYNAMIC_TOOL_NAME]) {
121
+ const description = schemas.find(
122
+ (schema) => schema.name === name,
123
+ )?.description;
124
+ expect(description).toContain(
125
+ "IMPORTANT: Always call get_dynamic_tools for this namespace/tool before calling to ensure correct parameters.",
126
+ );
127
+ expect(description).toContain(
128
+ "get_dynamic_tools({ namespace, toolName })",
129
+ );
130
+ expect(description).toContain("200 characters");
131
+ expect(description).toContain("status is not ready");
132
+ }
133
+ expect(
134
+ schemas.find(({ name }) => name === GET_DYNAMIC_TOOLS_NAME)?.inputSchema,
135
+ ).toEqual({
136
+ type: "object",
137
+ properties: {
138
+ namespace: {
139
+ description: "Dynamic namespace to inspect, e.g. an MCP server.",
140
+ type: "string",
141
+ },
142
+ pattern: {
143
+ description:
144
+ "RE2 regex pattern to search namespace and tool names (max 256 chars).",
145
+ type: "string",
146
+ },
147
+ toolName: {
148
+ description:
149
+ "Tool name within the namespace. Requires namespace to be set.",
150
+ type: "string",
151
+ },
152
+ },
153
+ });
154
+ expect(
155
+ schemas.find(({ name }) => name === CALL_DYNAMIC_TOOL_NAME)?.inputSchema,
156
+ ).toEqual({
157
+ type: "object",
158
+ properties: {
159
+ arguments: {
160
+ description:
161
+ "Arguments to pass to the tool, as described by the tool descriptor.",
162
+ type: "object",
163
+ },
164
+ mcpDetails: {
165
+ description:
166
+ "MCP-specific call metadata. Set only for external MCP namespaces; omit for frockbot.",
167
+ type: "object",
168
+ properties: {
169
+ description: { type: "string" },
170
+ requestSmartModeApproval: { type: "boolean" },
171
+ smartModeBlockReason: { type: "string" },
172
+ },
173
+ required: ["description"],
174
+ },
175
+ namespace: { type: "string" },
176
+ toolName: { type: "string" },
177
+ },
178
+ required: ["namespace", "toolName"],
179
+ });
180
+ });
181
+
182
+ test("returns catalog, pattern, namespace, and single-tool forms", async () => {
183
+ const root = await rootWithTools();
184
+ const longDescription = "x".repeat(201);
185
+ root.tools.registerNamespace({
186
+ name: "mail",
187
+ description: "Mail namespace",
188
+ status: "ready",
189
+ });
190
+ root.tools.register(dynamicTool("mail", "search_threads", longDescription));
191
+ root.tools.register(dynamicTool("mail", "send_message"));
192
+ root.tools.register(dynamicTool("calendar", "search_events"));
193
+
194
+ const catalogResult = await invoke(root, GET_DYNAMIC_TOOLS_NAME, {});
195
+ expect(catalogResult.isError).toBe(false);
196
+ const catalog = JSON.parse(catalogResult.content);
197
+ expect(catalog).toMatchObject({
198
+ mode: "catalog",
199
+ namespaces: [
200
+ {
201
+ namespace: "calendar",
202
+ tools: [{ tool: "search_events" }],
203
+ },
204
+ {
205
+ namespace: "mail",
206
+ namespaceDescription: "Mail namespace",
207
+ namespaceStatus: "ready",
208
+ },
209
+ ],
210
+ });
211
+ const truncated = catalog.namespaces[1].tools.find(
212
+ (tool: { tool: string }) => tool.tool === "search_threads",
213
+ ).description as string;
214
+ expect([...truncated]).toHaveLength(200);
215
+ expect(truncated.endsWith("... [truncated]")).toBe(true);
216
+ expect(catalogResult.content).not.toContain("inputSchema");
217
+ expect(await invoke(root, GET_DYNAMIC_TOOLS_NAME, undefined)).toEqual(
218
+ catalogResult,
219
+ );
220
+
221
+ const pattern = JSON.parse(
222
+ (
223
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
224
+ pattern: "threads|calendar",
225
+ })
226
+ ).content,
227
+ );
228
+ expect(pattern.namespaces).toEqual([
229
+ expect.objectContaining({
230
+ namespace: "calendar",
231
+ tools: [expect.objectContaining({ tool: "search_events" })],
232
+ }),
233
+ expect.objectContaining({
234
+ namespace: "mail",
235
+ tools: [expect.objectContaining({ tool: "search_threads" })],
236
+ }),
237
+ ]);
238
+
239
+ const scopedPattern = JSON.parse(
240
+ (
241
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
242
+ namespace: "mail",
243
+ pattern: "send",
244
+ })
245
+ ).content,
246
+ );
247
+ expect(scopedPattern.namespaces[0].tools).toEqual([
248
+ expect.objectContaining({ tool: "send_message" }),
249
+ ]);
250
+
251
+ const namespace = JSON.parse(
252
+ (await invoke(root, GET_DYNAMIC_TOOLS_NAME, { namespace: "mail" }))
253
+ .content,
254
+ );
255
+ expect(namespace.tools).toHaveLength(2);
256
+ const threadSchema = namespace.tools.find(
257
+ (tool: { tool: string }) => tool.tool === "search_threads",
258
+ );
259
+ expect(threadSchema).toEqual({
260
+ tool: "search_threads",
261
+ description: longDescription,
262
+ inputSchema: {
263
+ type: "object",
264
+ properties: { value: { type: "string" } },
265
+ },
266
+ });
267
+
268
+ const single = JSON.parse(
269
+ (
270
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
271
+ namespace: "mail",
272
+ toolName: "search_threads",
273
+ })
274
+ ).content,
275
+ );
276
+ expect(single).toEqual(threadSchema);
277
+ });
278
+
279
+ test("returns tool errors for invalid patterns and unknown lookups", async () => {
280
+ const root = await rootWithTools();
281
+ root.tools.register(dynamicTool("mail", "search"));
282
+
283
+ for (const pattern of ["[", "(a+)+$", "x".repeat(257)]) {
284
+ expect(
285
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, { pattern }),
286
+ ).toMatchObject({ isError: true });
287
+ }
288
+ expect(
289
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, { namespace: "missing" }),
290
+ ).toEqual({ content: "Namespace not found", isError: true });
291
+ expect(
292
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
293
+ namespace: "mail",
294
+ toolName: "missing",
295
+ }),
296
+ ).toEqual({ content: "Tool not found", isError: true });
297
+ expect(
298
+ await invoke(root, GET_DYNAMIC_TOOLS_NAME, { toolName: "search" }),
299
+ ).toEqual({ content: "toolName requires namespace", isError: true });
300
+ expect(
301
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
302
+ namespace: "missing",
303
+ toolName: "search",
304
+ }),
305
+ ).toEqual({ content: "Namespace not found", isError: true });
306
+ expect(
307
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
308
+ namespace: "mail",
309
+ toolName: "missing",
310
+ }),
311
+ ).toEqual({ content: "Tool not found", isError: true });
312
+ });
313
+
314
+ test("prepares and executes the inner call through every registry hook", async () => {
315
+ const root = await rootWithTools();
316
+ const order: string[] = [];
317
+ root.tools.register({
318
+ ...dynamicTool("frockbot", "write_setup"),
319
+ idempotent: true,
320
+ execute: (input) => {
321
+ order.push(`body:${JSON.stringify(input)}`);
322
+ return Promise.resolve({ content: "written", isError: false });
323
+ },
324
+ });
325
+ root.on("tools/pre-execute", async (call, _context, next) => {
326
+ order.push(`pre:${call.name}`);
327
+ return next();
328
+ });
329
+ root.on("tools/execute", async (call, _context, next) => {
330
+ order.push(`execute:${call.name}`);
331
+ return next();
332
+ });
333
+ root.on("tools/post-execute", async (call, _result, _context, next) => {
334
+ order.push(`post:${call.name}`);
335
+ return next();
336
+ });
337
+ root.on("tools/result", (call) => order.push(`result:${call.name}`));
338
+
339
+ const outer: ToolCall = {
340
+ id: "same-call-id",
341
+ name: CALL_DYNAMIC_TOOL_NAME,
342
+ input: {
343
+ namespace: "frockbot",
344
+ toolName: "write_setup",
345
+ arguments: { value: "one" },
346
+ },
347
+ };
348
+ const context = contextFor(outer);
349
+ const preparation = await root.tools.prepare(outer, context);
350
+ expect(preparation).toMatchObject({
351
+ kind: "ready",
352
+ idempotent: true,
353
+ call: {
354
+ id: "same-call-id",
355
+ name: "write_setup",
356
+ input: { value: "one" },
357
+ },
358
+ });
359
+ if (preparation.kind !== "ready") throw new Error("call was denied");
360
+ expect(await root.tools.executePrepared(preparation, context)).toEqual({
361
+ content: "written",
362
+ isError: false,
363
+ });
364
+ expect(order).toEqual([
365
+ "pre:write_setup",
366
+ "execute:write_setup",
367
+ 'body:{"value":"one"}',
368
+ "post:write_setup",
369
+ "result:write_setup",
370
+ ]);
371
+ });
372
+
373
+ test("requires call metadata for external namespaces and blocks non-ready ones", async () => {
374
+ const root = await rootWithTools();
375
+ root.tools.registerNamespace({
376
+ name: "mail",
377
+ external: true,
378
+ status: "ready",
379
+ });
380
+ root.tools.register(dynamicTool("mail", "search"));
381
+ root.tools.registerNamespace({
382
+ name: "calendar",
383
+ external: true,
384
+ status: "needsAuth",
385
+ });
386
+ root.tools.register(dynamicTool("calendar", "search"));
387
+
388
+ expect(
389
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
390
+ namespace: "mail",
391
+ toolName: "search",
392
+ arguments: {},
393
+ }),
394
+ ).toEqual({
395
+ content: 'External namespace "mail" requires mcpDetails.description',
396
+ isError: true,
397
+ });
398
+ expect(
399
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
400
+ namespace: "mail",
401
+ toolName: "search",
402
+ arguments: { value: "ok" },
403
+ mcpDetails: { description: "Search the connected mailbox" },
404
+ }),
405
+ ).toEqual({ content: '{"value":"ok"}', isError: false });
406
+ expect(
407
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
408
+ namespace: "calendar",
409
+ toolName: "search",
410
+ mcpDetails: { description: "Search calendar" },
411
+ }),
412
+ ).toEqual({
413
+ content: "Namespace is not ready: calendar (needsAuth)",
414
+ isError: true,
415
+ });
416
+ });
417
+
418
+ test("propagates an inner tool error as the meta-tool result", async () => {
419
+ const root = await rootWithTools();
420
+ root.tools.register({
421
+ ...dynamicTool("frockbot", "fail"),
422
+ execute: () =>
423
+ Promise.resolve({ content: "inner failure", isError: true }),
424
+ });
425
+
426
+ expect(
427
+ await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
428
+ namespace: "frockbot",
429
+ toolName: "fail",
430
+ }),
431
+ ).toEqual({ content: "inner failure", isError: true });
432
+ });
433
+
434
+ test("renders an escaped prompt catalog and omits the block when empty", async () => {
435
+ const root = await rootWithTools(true);
436
+ expect(
437
+ (root.systemPrompt as PromptFixture).assemble({
438
+ sessionId: "session",
439
+ provider: "provider",
440
+ model: "model",
441
+ turnType: "chat",
442
+ }),
443
+ ).resolves.toMatchObject({ text: "" });
444
+
445
+ root.tools.registerNamespace({
446
+ name: "mail&\"'work",
447
+ status: "ready",
448
+ useInstructions: 'Use <schema> & "call".\nThen invoke.',
449
+ });
450
+ root.tools.register(dynamicTool("mail&\"'work", 'search<"mail'));
451
+ root.tools.register(dynamicTool("frockbot", "package_author"));
452
+ const assembly = await root.systemPrompt.assemble({
453
+ sessionId: "session",
454
+ provider: "provider",
455
+ model: "model",
456
+ turnType: "chat",
457
+ });
458
+
459
+ expect(assembly.text).toBe(
460
+ [
461
+ "<dynamic_tool_catalog>",
462
+ "These dynamic tool namespaces were available when this conversation started. Availability may have changed, so use get_dynamic_tools to check current state before calling call_dynamic_tool.",
463
+ "",
464
+ "<dynamic_tool_namespaces>",
465
+ `<namespace name="frockbot" tools="package_author" namespaceUseInstructions="${FROCKBOT_NAMESPACE_USE_INSTRUCTIONS}" />`,
466
+ '<namespace name="mail&amp;&quot;&apos;work" tools="search&lt;&quot;mail" namespaceUseInstructions="Use &lt;schema&gt; &amp; &quot;call&quot;.&#10;Then invoke." namespaceStatus="ready" />',
467
+ "</dynamic_tool_namespaces>",
468
+ "</dynamic_tool_catalog>",
469
+ ].join("\n"),
470
+ );
471
+ });
472
+
473
+ test("rejects duplicate namespace/tool identities but permits the same bare name elsewhere", async () => {
474
+ const root = await rootWithTools();
475
+ root.tools.register(dynamicTool("mail-one", "search"));
476
+ root.tools.register(dynamicTool("mail-two", "search"));
477
+ expect(() =>
478
+ root.tools.register(dynamicTool("mail-one", "search")),
479
+ ).toThrow('tool "mail-one/search" is already registered');
480
+ root.tools.registerNamespace({ name: "mail-one" });
481
+ expect(() => root.tools.registerNamespace({ name: "mail-one" })).toThrow(
482
+ 'tool namespace "mail-one" is already registered',
483
+ );
484
+ });
485
+ });
package/src/tools.test.ts CHANGED
@@ -249,6 +249,21 @@ describe("ToolRegistry turn admission", () => {
249
249
  return root;
250
250
  }
251
251
 
252
+ function admittedNames(
253
+ root: Context,
254
+ admission: {
255
+ turnType: ToolExecutionContext["turnType"];
256
+ subagentRole?: string;
257
+ },
258
+ ): string[] {
259
+ return root.tools
260
+ .schemas(admission)
261
+ .map((schema) => schema.name)
262
+ .filter(
263
+ (name) => name !== "get_dynamic_tools" && name !== "call_dynamic_tool",
264
+ );
265
+ }
266
+
252
267
  const work: ToolDefinition = {
253
268
  name: "work",
254
269
  description: "A work tool.",
@@ -290,9 +305,7 @@ describe("ToolRegistry turn admission", () => {
290
305
  const root = await admissionRoot();
291
306
  root.tools.register(work);
292
307
  for (const turnType of ["chat", "automation", "subagent"] as const) {
293
- expect(root.tools.schemas({ turnType }).map((s) => s.name)).toEqual([
294
- "work",
295
- ]);
308
+ expect(admittedNames(root, { turnType })).toEqual(["work"]);
296
309
  }
297
310
  });
298
311
 
@@ -302,12 +315,14 @@ describe("ToolRegistry turn admission", () => {
302
315
  root.tools.register(chatOnly);
303
316
  root.tools.register(automationOnly);
304
317
 
305
- expect(root.tools.schemas({ turnType: "chat" }).map((s) => s.name)).toEqual(
306
- ["work", "send_to_user"],
307
- );
308
- expect(
309
- root.tools.schemas({ turnType: "automation" }).map((s) => s.name),
310
- ).toEqual(["work", "wake_parent"]);
318
+ expect(admittedNames(root, { turnType: "chat" })).toEqual([
319
+ "work",
320
+ "send_to_user",
321
+ ]);
322
+ expect(admittedNames(root, { turnType: "automation" })).toEqual([
323
+ "work",
324
+ "wake_parent",
325
+ ]);
311
326
  });
312
327
 
313
328
  test("bounds a tool declaration by the manifest ceiling", async () => {
@@ -317,10 +332,8 @@ describe("ToolRegistry turn admission", () => {
317
332
  admissionCeiling: ["automation", "subagent"],
318
333
  });
319
334
 
320
- expect(root.tools.schemas({ turnType: "chat" })).toEqual([]);
321
- expect(
322
- root.tools.schemas({ turnType: "automation" }).map((s) => s.name),
323
- ).toEqual(["work"]);
335
+ expect(admittedNames(root, { turnType: "chat" })).toEqual([]);
336
+ expect(admittedNames(root, { turnType: "automation" })).toEqual(["work"]);
324
337
  });
325
338
 
326
339
  test("denies an out-of-admission call without executing it", async () => {
@@ -431,9 +444,10 @@ describe("ToolRegistry turn admission", () => {
431
444
  const root = await admissionRoot();
432
445
  root.tools.register(work);
433
446
  root.tools.register(desktop);
434
- expect(root.tools.schemas({ turnType: "chat" }).map((s) => s.name)).toEqual(
435
- ["work", "computer_exec"],
436
- );
447
+ expect(admittedNames(root, { turnType: "chat" })).toEqual([
448
+ "work",
449
+ "computer_exec",
450
+ ]);
437
451
  });
438
452
 
439
453
  test("trims the catalog to what the subagent role admits", async () => {
@@ -443,19 +457,22 @@ describe("ToolRegistry turn admission", () => {
443
457
  root.tools.register(browser);
444
458
 
445
459
  expect(
446
- root.tools
447
- .schemas({ turnType: "subagent", subagentRole: "browserUse" })
448
- .map((s) => s.name),
460
+ admittedNames(root, {
461
+ turnType: "subagent",
462
+ subagentRole: "browserUse",
463
+ }),
449
464
  ).toEqual(["work", "computer_browser"]);
450
465
  expect(
451
- root.tools
452
- .schemas({ turnType: "subagent", subagentRole: "computerUse" })
453
- .map((s) => s.name),
466
+ admittedNames(root, {
467
+ turnType: "subagent",
468
+ subagentRole: "computerUse",
469
+ }),
454
470
  ).toEqual(["work", "computer_exec", "computer_browser"]);
455
471
  expect(
456
- root.tools
457
- .schemas({ turnType: "subagent", subagentRole: "watchVideo" })
458
- .map((s) => s.name),
472
+ admittedNames(root, {
473
+ turnType: "subagent",
474
+ subagentRole: "watchVideo",
475
+ }),
459
476
  ).toEqual(["work"]);
460
477
  });
461
478
 
@@ -463,14 +480,16 @@ describe("ToolRegistry turn admission", () => {
463
480
  const root = await admissionRoot();
464
481
  root.tools.register(browser, { subagentRoleCeiling: ["executor"] });
465
482
  expect(
466
- root.tools
467
- .schemas({ turnType: "subagent", subagentRole: "browserUse" })
468
- .map((s) => s.name),
483
+ admittedNames(root, {
484
+ turnType: "subagent",
485
+ subagentRole: "browserUse",
486
+ }),
469
487
  ).toEqual([]);
470
488
  expect(
471
- root.tools
472
- .schemas({ turnType: "subagent", subagentRole: "executor" })
473
- .map((s) => s.name),
489
+ admittedNames(root, {
490
+ turnType: "subagent",
491
+ subagentRole: "executor",
492
+ }),
474
493
  ).toEqual(["computer_browser"]);
475
494
  });
476
495
 
package/src/tools.ts CHANGED
@@ -10,12 +10,193 @@ import {
10
10
  type ToolExecutionContext,
11
11
  type ToolExecutionResult,
12
12
  type ToolGuard,
13
+ type ToolNamespaceRegistration,
13
14
  type ToolPreparation,
14
15
  type ToolRegistrationOptions,
15
16
  type ToolSchema,
16
17
  type TurnTypeV1,
17
18
  } from "@frockbot/kernel-contracts";
18
19
 
20
+ export const GET_DYNAMIC_TOOLS_NAME = "get_dynamic_tools";
21
+ export const CALL_DYNAMIC_TOOL_NAME = "call_dynamic_tool";
22
+ export const FROCKBOT_TOOL_NAMESPACE = "frockbot";
23
+
24
+ const CATALOG_DESCRIPTION_MAX_CHARS = 200;
25
+ const TRUNCATION_SUFFIX = "... [truncated]";
26
+
27
+ export const FROCKBOT_NAMESPACE_USE_INSTRUCTIONS =
28
+ "Native FrockBot tools for this session. You MUST read the tool schemas before calling them.";
29
+
30
+ const GET_DYNAMIC_TOOLS_DESCRIPTION = [
31
+ "Discover schemas for dynamic tools.",
32
+ "Call forms: get_dynamic_tools() returns the namespace catalog; get_dynamic_tools({ pattern }) searches namespace and tool names; get_dynamic_tools({ namespace }) returns every complete tool schema in one namespace; get_dynamic_tools({ namespace, pattern }) searches within one namespace; get_dynamic_tools({ namespace, toolName }) returns one complete tool schema.",
33
+ "Catalog and pattern results omit input schemas and truncate descriptions to 200 characters ending in ... [truncated]. Namespace and single-tool lookups return complete descriptions and input schemas.",
34
+ `IMPORTANT: Always call ${GET_DYNAMIC_TOOLS_NAME} for this namespace/tool before calling to ensure correct parameters.`,
35
+ "Namespaces whose status is not ready are unusable until fixed.",
36
+ ].join(" ");
37
+
38
+ const CALL_DYNAMIC_TOOL_DESCRIPTION = [
39
+ "Invoke a dynamic tool using a descriptor returned by get_dynamic_tools.",
40
+ "Discovery call forms: get_dynamic_tools() returns the namespace catalog; get_dynamic_tools({ pattern }) searches namespace and tool names; get_dynamic_tools({ namespace }) returns every complete tool schema in one namespace; get_dynamic_tools({ namespace, pattern }) searches within one namespace; get_dynamic_tools({ namespace, toolName }) returns one complete tool schema.",
41
+ "Catalog and pattern results omit input schemas and truncate descriptions to 200 characters ending in ... [truncated]. Namespace and single-tool lookups return complete descriptions and input schemas.",
42
+ `IMPORTANT: Always call ${GET_DYNAMIC_TOOLS_NAME} for this namespace/tool before calling to ensure correct parameters.`,
43
+ "Pass the descriptor's namespace and tool name, put its input in arguments, and include mcpDetails.description for an external namespace.",
44
+ "Namespaces whose status is not ready are unusable until fixed.",
45
+ ].join(" ");
46
+
47
+ const GET_DYNAMIC_TOOLS_SCHEMA: ToolSchema = {
48
+ name: GET_DYNAMIC_TOOLS_NAME,
49
+ description: GET_DYNAMIC_TOOLS_DESCRIPTION,
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ namespace: {
54
+ description: "Dynamic namespace to inspect, e.g. an MCP server.",
55
+ type: "string",
56
+ },
57
+ pattern: {
58
+ description:
59
+ "RE2 regex pattern to search namespace and tool names (max 256 chars).",
60
+ type: "string",
61
+ },
62
+ toolName: {
63
+ description:
64
+ "Tool name within the namespace. Requires namespace to be set.",
65
+ type: "string",
66
+ },
67
+ },
68
+ },
69
+ };
70
+
71
+ const CALL_DYNAMIC_TOOL_SCHEMA: ToolSchema = {
72
+ name: CALL_DYNAMIC_TOOL_NAME,
73
+ description: CALL_DYNAMIC_TOOL_DESCRIPTION,
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ arguments: {
78
+ description:
79
+ "Arguments to pass to the tool, as described by the tool descriptor.",
80
+ type: "object",
81
+ },
82
+ mcpDetails: {
83
+ description:
84
+ "MCP-specific call metadata. Set only for external MCP namespaces; omit for frockbot.",
85
+ type: "object",
86
+ properties: {
87
+ description: { type: "string" },
88
+ requestSmartModeApproval: { type: "boolean" },
89
+ smartModeBlockReason: { type: "string" },
90
+ },
91
+ required: ["description"],
92
+ },
93
+ namespace: { type: "string" },
94
+ toolName: { type: "string" },
95
+ },
96
+ required: ["namespace", "toolName"],
97
+ },
98
+ };
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
102
+ }
103
+
104
+ function optionalString(record: Record<string, unknown>, key: string): boolean {
105
+ return record[key] === undefined || typeof record[key] === "string";
106
+ }
107
+
108
+ function validGetDynamicToolsInput(input: unknown): boolean {
109
+ return (
110
+ input === undefined ||
111
+ (isRecord(input) &&
112
+ optionalString(input, "namespace") &&
113
+ optionalString(input, "pattern") &&
114
+ optionalString(input, "toolName"))
115
+ );
116
+ }
117
+
118
+ function validMcpDetailsShape(input: unknown): boolean {
119
+ if (!isRecord(input)) return false;
120
+ return (
121
+ (input.description === undefined ||
122
+ typeof input.description === "string") &&
123
+ (input.requestSmartModeApproval === undefined ||
124
+ typeof input.requestSmartModeApproval === "boolean") &&
125
+ (input.smartModeBlockReason === undefined ||
126
+ typeof input.smartModeBlockReason === "string")
127
+ );
128
+ }
129
+
130
+ function validMcpDetails(input: unknown): boolean {
131
+ return (
132
+ isRecord(input) &&
133
+ validMcpDetailsShape(input) &&
134
+ typeof input.description === "string"
135
+ );
136
+ }
137
+
138
+ function validCallDynamicToolInput(input: unknown): boolean {
139
+ return (
140
+ isRecord(input) &&
141
+ typeof input.namespace === "string" &&
142
+ input.namespace.length > 0 &&
143
+ typeof input.toolName === "string" &&
144
+ input.toolName.length > 0 &&
145
+ (input.arguments === undefined || isRecord(input.arguments)) &&
146
+ (input.mcpDetails === undefined || validMcpDetailsShape(input.mcpDetails))
147
+ );
148
+ }
149
+
150
+ function truncateCatalogText(value: string): string {
151
+ const characters = [...value];
152
+ if (characters.length <= CATALOG_DESCRIPTION_MAX_CHARS) return value;
153
+ return `${characters
154
+ .slice(0, CATALOG_DESCRIPTION_MAX_CHARS - TRUNCATION_SUFFIX.length)
155
+ .join("")}${TRUNCATION_SUFFIX}`;
156
+ }
157
+
158
+ function unsafeRegexPattern(pattern: string): boolean {
159
+ // JS RegExp is intentionally used here. Reject the common catastrophic
160
+ // forms before compiling so model-supplied searches cannot monopolise the
161
+ // isolate: a quantified group containing another quantifier, backreferences,
162
+ // and repeated match-any quantifiers.
163
+ return (
164
+ /(^|[^\\])\((?:\\.|[^()])*?(?:[+*]|\{\d+(?:,\d*)?\})(?:\\.|[^()])*\)(?:[+*]|\{\d+(?:,\d*)?\})/.test(
165
+ pattern,
166
+ ) ||
167
+ /(^|[^\\])\\[1-9]/.test(pattern) ||
168
+ /\.\*(?:[^|)]*\.\*)/.test(pattern)
169
+ );
170
+ }
171
+
172
+ function compilePattern(
173
+ pattern: string,
174
+ ): { regex: RegExp } | { error: string } {
175
+ if ([...pattern].length > 256) {
176
+ return { error: "Pattern exceeds the 256 character limit" };
177
+ }
178
+ if (unsafeRegexPattern(pattern)) {
179
+ return { error: "Pattern is unsafe" };
180
+ }
181
+ try {
182
+ return { regex: new RegExp(pattern) };
183
+ } catch {
184
+ return { error: "Pattern is invalid" };
185
+ }
186
+ }
187
+
188
+ function xmlAttribute(value: string): string {
189
+ return value
190
+ .replaceAll("&", "&amp;")
191
+ .replaceAll('"', "&quot;")
192
+ .replaceAll("'", "&apos;")
193
+ .replaceAll("<", "&lt;")
194
+ .replaceAll(">", "&gt;")
195
+ .replaceAll("\t", "&#9;")
196
+ .replaceAll("\n", "&#10;")
197
+ .replaceAll("\r", "&#13;");
198
+ }
199
+
19
200
  function sameToolCall(left: ToolCall, right: ToolCall): boolean {
20
201
  return (
21
202
  left.id === right.id &&
@@ -41,22 +222,69 @@ interface RegisteredTool {
41
222
  admittedRoles: readonly string[] | undefined;
42
223
  }
43
224
 
225
+ interface AvailableNamespace {
226
+ name: string;
227
+ metadata?: ToolNamespaceRegistration;
228
+ tools: RegisteredTool[];
229
+ }
230
+
231
+ interface ResolvedDynamicCall {
232
+ call: ToolCall;
233
+ registered: RegisteredTool;
234
+ }
235
+
44
236
  export class ToolRegistry extends Service implements ToolExecution {
45
- private definitions = new Map<string, RegisteredTool>();
237
+ private nativeDefinitions = new Map<string, RegisteredTool>();
238
+ private dynamicDefinitions = new Map<string, Map<string, RegisteredTool>>();
239
+ private namespaces = new Map<string, ToolNamespaceRegistration>();
46
240
  private guards: ToolGuard[] = [];
241
+ private preparedDefinitions = new WeakMap<object, RegisteredTool>();
47
242
 
48
243
  constructor(ctx: Context) {
49
244
  super(ctx, "tools");
245
+ this.namespaces.set(FROCKBOT_TOOL_NAMESPACE, {
246
+ name: FROCKBOT_TOOL_NAMESPACE,
247
+ external: false,
248
+ useInstructions: FROCKBOT_NAMESPACE_USE_INSTRUCTIONS,
249
+ });
250
+ this.installMetaTool({
251
+ ...GET_DYNAMIC_TOOLS_SCHEMA,
252
+ validate: validGetDynamicToolsInput,
253
+ idempotent: true,
254
+ execute: (input, context) => this.discover(input, context),
255
+ });
256
+ // Successful calls are rewritten to the inner definition during prepare;
257
+ // this body exists only to keep the registered definition total.
258
+ this.installMetaTool({
259
+ ...CALL_DYNAMIC_TOOL_SCHEMA,
260
+ validate: validCallDynamicToolInput,
261
+ execute: () =>
262
+ Promise.resolve({
263
+ content: "Dynamic tool call was not prepared",
264
+ isError: true,
265
+ }),
266
+ });
267
+ // A ToolRegistry is useful in narrow test/runtime roots without prompt
268
+ // assembly. When the prompt Package is present, this lifecycle-owned child
269
+ // registration activates and disappears with the registry.
270
+ void ctx.inject(["systemPrompt"], (promptCtx) =>
271
+ promptCtx.systemPrompt.register({
272
+ id: "dynamic-tool-catalog",
273
+ order: 70,
274
+ render: (context) => this.renderDynamicToolCatalog(context.turnType),
275
+ }),
276
+ );
50
277
  }
51
278
 
52
- register(
279
+ private installMetaTool(definition: ToolDefinition): void {
280
+ this.nativeDefinitions.set(definition.name, this.registered(definition));
281
+ }
282
+
283
+ private registered(
53
284
  definition: ToolDefinition,
54
285
  options?: ToolRegistrationOptions,
55
- ): () => void {
56
- if (this.definitions.has(definition.name)) {
57
- throw new Error(`tool "${definition.name}" is already registered`);
58
- }
59
- const registered: RegisteredTool = {
286
+ ): RegisteredTool {
287
+ return {
60
288
  definition,
61
289
  admitted: admittedTurnTypesV1(
62
290
  definition.admission?.turnTypes,
@@ -67,16 +295,67 @@ export class ToolRegistry extends Service implements ToolExecution {
67
295
  options?.subagentRoleCeiling,
68
296
  ),
69
297
  };
70
- this.definitions.set(definition.name, registered);
298
+ }
299
+
300
+ registerNamespace(namespace: ToolNamespaceRegistration): () => void {
301
+ if (!namespace.name.trim()) {
302
+ throw new Error("tool namespace name must be non-empty");
303
+ }
304
+ if (this.namespaces.has(namespace.name)) {
305
+ throw new Error(
306
+ `tool namespace "${namespace.name}" is already registered`,
307
+ );
308
+ }
309
+ const registered = { ...namespace };
310
+ this.namespaces.set(namespace.name, registered);
311
+ return () => {
312
+ if (this.namespaces.get(namespace.name) === registered) {
313
+ this.namespaces.delete(namespace.name);
314
+ }
315
+ };
316
+ }
317
+
318
+ register(
319
+ definition: ToolDefinition,
320
+ options?: ToolRegistrationOptions,
321
+ ): () => void {
322
+ const namespace = definition.namespace;
323
+ if (namespace !== undefined && !namespace.trim()) {
324
+ throw new Error("tool namespace must be non-empty");
325
+ }
326
+ const definitions =
327
+ namespace === undefined
328
+ ? this.nativeDefinitions
329
+ : (this.dynamicDefinitions.get(namespace) ?? new Map());
330
+ if (definitions.has(definition.name)) {
331
+ const identity =
332
+ namespace === undefined
333
+ ? definition.name
334
+ : `${namespace}/${definition.name}`;
335
+ throw new Error(`tool "${identity}" is already registered`);
336
+ }
337
+ if (namespace !== undefined && !this.dynamicDefinitions.has(namespace)) {
338
+ this.dynamicDefinitions.set(namespace, definitions);
339
+ }
340
+ const registered = this.registered(definition, options);
341
+ definitions.set(definition.name, registered);
71
342
  return () => {
72
- if (this.definitions.get(definition.name) === registered) {
73
- this.definitions.delete(definition.name);
343
+ if (definitions.get(definition.name) === registered) {
344
+ definitions.delete(definition.name);
345
+ if (namespace !== undefined && definitions.size === 0) {
346
+ this.dynamicDefinitions.delete(namespace);
347
+ }
74
348
  }
75
349
  };
76
350
  }
77
351
 
78
352
  registeredNames(): string[] {
79
- return [...this.definitions.keys()].toSorted();
353
+ return [
354
+ ...this.nativeDefinitions.keys(),
355
+ ...[...this.dynamicDefinitions].flatMap(([namespace, definitions]) =>
356
+ [...definitions.keys()].map((name) => `${namespace}/${name}`),
357
+ ),
358
+ ].toSorted();
80
359
  }
81
360
 
82
361
  guard(guard: ToolGuard): () => void {
@@ -91,9 +370,11 @@ export class ToolRegistry extends Service implements ToolExecution {
91
370
  turnType: TurnTypeV1;
92
371
  subagentRole?: string;
93
372
  }): ToolSchema[] {
94
- return [...this.definitions.values()]
373
+ const exposed = [...this.nativeDefinitions.values()]
95
374
  .filter(
96
375
  (registered) =>
376
+ registered.definition.name !== GET_DYNAMIC_TOOLS_NAME &&
377
+ registered.definition.name !== CALL_DYNAMIC_TOOL_NAME &&
97
378
  registered.admitted.includes(admission.turnType) &&
98
379
  isSubagentRoleAdmittedV1(
99
380
  registered.admittedRoles,
@@ -105,18 +386,76 @@ export class ToolRegistry extends Service implements ToolExecution {
105
386
  description,
106
387
  inputSchema,
107
388
  }));
389
+ return [
390
+ exposed,
391
+ [GET_DYNAMIC_TOOLS_SCHEMA, CALL_DYNAMIC_TOOL_SCHEMA],
392
+ ].flat();
108
393
  }
109
394
 
110
395
  async prepare(
111
396
  call: ToolCall,
112
397
  context: ToolExecutionContext,
398
+ ): Promise<ToolPreparation> {
399
+ if (call.name === CALL_DYNAMIC_TOOL_NAME) {
400
+ const resolved = this.resolveDynamicCall(call);
401
+ if ("error" in resolved) return this.denied(call, resolved.error);
402
+ const metadata = this.namespaces.get(
403
+ resolved.registered.definition.namespace!,
404
+ );
405
+ if (metadata?.status && metadata.status !== "ready") {
406
+ return this.denied(
407
+ call,
408
+ `Namespace is not ready: ${metadata.name} (${metadata.status})`,
409
+ );
410
+ }
411
+ const input = call.input as Record<string, unknown>;
412
+ if (
413
+ metadata?.external === true &&
414
+ (!isRecord(input.mcpDetails) ||
415
+ typeof input.mcpDetails.description !== "string" ||
416
+ !input.mcpDetails.description.trim())
417
+ ) {
418
+ return this.denied(
419
+ call,
420
+ `External namespace "${metadata.name}" requires mcpDetails.description`,
421
+ );
422
+ }
423
+ if (
424
+ input.mcpDetails !== undefined &&
425
+ !validMcpDetails(input.mcpDetails)
426
+ ) {
427
+ return this.denied(
428
+ call,
429
+ `Invalid input for tool: ${CALL_DYNAMIC_TOOL_NAME}`,
430
+ );
431
+ }
432
+ return this.prepareRegistered(
433
+ resolved.call,
434
+ resolved.registered,
435
+ context,
436
+ );
437
+ }
438
+ return this.prepareRegistered(
439
+ call,
440
+ this.nativeDefinitions.get(call.name),
441
+ context,
442
+ );
443
+ }
444
+
445
+ private denied(call: ToolCall, content: string): ToolPreparation {
446
+ return { kind: "denied", call, result: { content, isError: true } };
447
+ }
448
+
449
+ private async prepareRegistered(
450
+ call: ToolCall,
451
+ registered: RegisteredTool | undefined,
452
+ context: ToolExecutionContext,
113
453
  ): Promise<ToolPreparation> {
114
454
  const prepared = await this.ctx.waterfall(
115
455
  "tools/pre-execute",
116
456
  call,
117
457
  context,
118
458
  async () => {
119
- const registered = this.definitions.get(call.name);
120
459
  if (!registered) {
121
460
  return {
122
461
  kind: "denied",
@@ -186,6 +525,7 @@ export class ToolRegistry extends Service implements ToolExecution {
186
525
  result: { content: denial.reason, isError: true },
187
526
  };
188
527
  }
528
+ this.preparedDefinitions.set(prepared, registered!);
189
529
  return prepared;
190
530
  }
191
531
 
@@ -193,7 +533,12 @@ export class ToolRegistry extends Service implements ToolExecution {
193
533
  preparation: Extract<ToolPreparation, { kind: "ready" }>,
194
534
  context: ToolExecutionContext,
195
535
  ): Promise<ToolExecutionResult> {
196
- const definition = this.definitions.get(preparation.call.name)?.definition;
536
+ const registered =
537
+ this.preparedDefinitions.get(preparation) ??
538
+ this.nativeDefinitions.get(preparation.call.name);
539
+ const definition = this.isRegistered(registered)
540
+ ? registered.definition
541
+ : undefined;
197
542
  const initial = await this.ctx.waterfall(
198
543
  "tools/execute",
199
544
  preparation.call,
@@ -238,16 +583,42 @@ export class ToolRegistry extends Service implements ToolExecution {
238
583
  ),
239
584
  };
240
585
  }
241
- if (!sameToolCall(preparation.call, expectedCall)) {
586
+ const expected =
587
+ expectedCall.name === CALL_DYNAMIC_TOOL_NAME
588
+ ? this.resolveDynamicCall(expectedCall)
589
+ : {
590
+ call: expectedCall,
591
+ registered: this.nativeDefinitions.get(expectedCall.name),
592
+ };
593
+ if ("error" in expected || !expected.registered) {
594
+ return {
595
+ status: "unavailable",
596
+ reason: boundedReconciliationReason(
597
+ "error" in expected
598
+ ? expected.error
599
+ : `Tool ${expectedCall.name} is unavailable for effect reconciliation`,
600
+ expectedCall.name,
601
+ ),
602
+ };
603
+ }
604
+ const preparedDefinition =
605
+ this.preparedDefinitions.get(preparation) ??
606
+ this.nativeDefinitions.get(preparation.call.name);
607
+ if (
608
+ preparedDefinition !== expected.registered ||
609
+ !sameToolCall(preparation.call, expected.call)
610
+ ) {
242
611
  return {
243
612
  status: "unavailable",
244
613
  reason: boundedReconciliationReason(
245
- `Prepared tool ${preparation.call.name} does not match durable effect ${expectedCall.name}`,
614
+ `Prepared tool ${preparation.call.name} does not match durable effect ${expected.call.name}`,
246
615
  expectedCall.name,
247
616
  ),
248
617
  };
249
618
  }
250
- const definition = this.definitions.get(expectedCall.name)?.definition;
619
+ const definition = this.isRegistered(expected.registered)
620
+ ? expected.registered.definition
621
+ : undefined;
251
622
  if (!definition) {
252
623
  return {
253
624
  status: "unavailable",
@@ -264,10 +635,7 @@ export class ToolRegistry extends Service implements ToolExecution {
264
635
  try {
265
636
  return {
266
637
  status: "recovered",
267
- result: await this.executePrepared(
268
- { ...preparation, call: expectedCall, idempotent: true },
269
- context,
270
- ),
638
+ result: await this.executePrepared(preparation, context),
271
639
  };
272
640
  } catch (error) {
273
641
  return {
@@ -287,11 +655,11 @@ export class ToolRegistry extends Service implements ToolExecution {
287
655
  }
288
656
  try {
289
657
  const outcome = normalizedReconciliation(
290
- await definition.reconcile(expectedCall.input, context),
291
- expectedCall.name,
658
+ await definition.reconcile(expected.call.input, context),
659
+ expected.call.name,
292
660
  );
293
661
  if (outcome.status === "recovered") {
294
- this.ctx.emit("tools/result", expectedCall, outcome.result);
662
+ this.ctx.emit("tools/result", expected.call, outcome.result);
295
663
  }
296
664
  return outcome;
297
665
  } catch (error) {
@@ -301,6 +669,227 @@ export class ToolRegistry extends Service implements ToolExecution {
301
669
  };
302
670
  }
303
671
  }
672
+
673
+ private resolveDynamicCall(
674
+ outer: ToolCall,
675
+ ): ResolvedDynamicCall | { error: string } {
676
+ if (!validCallDynamicToolInput(outer.input)) {
677
+ return { error: `Invalid input for tool: ${CALL_DYNAMIC_TOOL_NAME}` };
678
+ }
679
+ const input = outer.input as Record<string, unknown>;
680
+ const namespace = input.namespace as string;
681
+ const definitions = this.dynamicDefinitions.get(namespace);
682
+ if (!definitions) return { error: "Namespace not found" };
683
+ const toolName = input.toolName as string;
684
+ const registered = definitions.get(toolName);
685
+ if (!registered) return { error: "Tool not found" };
686
+ return {
687
+ registered,
688
+ // One durable effect, one call id. Hooks see the inner name and input;
689
+ // the outer call remains the exact durable intent in context.toolCall.
690
+ call: {
691
+ id: outer.id,
692
+ name: toolName,
693
+ input: input.arguments ?? {},
694
+ },
695
+ };
696
+ }
697
+
698
+ private isRegistered(
699
+ registered: RegisteredTool | undefined,
700
+ ): registered is RegisteredTool {
701
+ if (!registered) return false;
702
+ const namespace = registered.definition.namespace;
703
+ return namespace === undefined
704
+ ? this.nativeDefinitions.get(registered.definition.name) === registered
705
+ : this.dynamicDefinitions
706
+ .get(namespace)
707
+ ?.get(registered.definition.name) === registered;
708
+ }
709
+
710
+ private admitted(
711
+ registered: RegisteredTool,
712
+ admission: { turnType: TurnTypeV1; subagentRole?: string },
713
+ ): boolean {
714
+ return (
715
+ registered.admitted.includes(admission.turnType) &&
716
+ isSubagentRoleAdmittedV1(registered.admittedRoles, admission.subagentRole)
717
+ );
718
+ }
719
+
720
+ private availableNamespaces(admission: {
721
+ turnType: TurnTypeV1;
722
+ subagentRole?: string;
723
+ }): AvailableNamespace[] {
724
+ return [...this.dynamicDefinitions]
725
+ .map(([name, definitions]) => ({
726
+ name,
727
+ metadata: this.namespaces.get(name),
728
+ tools: [...definitions.values()]
729
+ .filter((registered) => this.admitted(registered, admission))
730
+ .toSorted((left, right) =>
731
+ left.definition.name.localeCompare(right.definition.name),
732
+ ),
733
+ }))
734
+ .filter(({ tools }) => tools.length > 0)
735
+ .toSorted((left, right) => left.name.localeCompare(right.name));
736
+ }
737
+
738
+ private catalogNamespace(
739
+ namespace: AvailableNamespace,
740
+ tools: readonly RegisteredTool[] = namespace.tools,
741
+ ): Record<string, unknown> {
742
+ return {
743
+ namespace: namespace.name,
744
+ ...(namespace.metadata?.description
745
+ ? {
746
+ namespaceDescription: truncateCatalogText(
747
+ namespace.metadata.description,
748
+ ),
749
+ }
750
+ : {}),
751
+ ...(namespace.metadata?.status
752
+ ? { namespaceStatus: namespace.metadata.status }
753
+ : {}),
754
+ tools: tools.map(({ definition }) => ({
755
+ tool: definition.name,
756
+ description: truncateCatalogText(definition.description),
757
+ })),
758
+ };
759
+ }
760
+
761
+ private fullNamespace(
762
+ namespace: AvailableNamespace,
763
+ ): Record<string, unknown> {
764
+ return {
765
+ namespace: namespace.name,
766
+ ...(namespace.metadata?.description
767
+ ? { namespaceDescription: namespace.metadata.description }
768
+ : {}),
769
+ ...(namespace.metadata?.status
770
+ ? { namespaceStatus: namespace.metadata.status }
771
+ : {}),
772
+ tools: namespace.tools.map(({ definition }) => ({
773
+ tool: definition.name,
774
+ description: definition.description,
775
+ inputSchema: definition.inputSchema,
776
+ })),
777
+ };
778
+ }
779
+
780
+ private async discover(
781
+ rawInput: unknown,
782
+ context: ToolExecutionContext,
783
+ ): Promise<ToolExecutionResult> {
784
+ if (!validGetDynamicToolsInput(rawInput)) {
785
+ return {
786
+ content: `Invalid input for tool: ${GET_DYNAMIC_TOOLS_NAME}`,
787
+ isError: true,
788
+ };
789
+ }
790
+ const input = isRecord(rawInput) ? rawInput : {};
791
+ const namespaceName = input.namespace as string | undefined;
792
+ const toolName = input.toolName as string | undefined;
793
+ const pattern = input.pattern as string | undefined;
794
+ if (toolName !== undefined && namespaceName === undefined) {
795
+ return {
796
+ content: "toolName requires namespace",
797
+ isError: true,
798
+ };
799
+ }
800
+ if (toolName !== undefined && pattern !== undefined) {
801
+ return {
802
+ content: "toolName and pattern cannot be combined",
803
+ isError: true,
804
+ };
805
+ }
806
+ const namespaces = this.availableNamespaces({
807
+ turnType: context.turnType,
808
+ ...(context.subagentRole === undefined
809
+ ? {}
810
+ : { subagentRole: context.subagentRole }),
811
+ });
812
+ const selected = namespaceName
813
+ ? namespaces.filter(({ name }) => name === namespaceName)
814
+ : namespaces;
815
+ if (namespaceName && selected.length === 0) {
816
+ return { content: "Namespace not found", isError: true };
817
+ }
818
+ if (toolName !== undefined) {
819
+ const registered = selected[0]!.tools.find(
820
+ ({ definition }) => definition.name === toolName,
821
+ );
822
+ if (!registered) return { content: "Tool not found", isError: true };
823
+ return {
824
+ content: JSON.stringify({
825
+ tool: registered.definition.name,
826
+ description: registered.definition.description,
827
+ inputSchema: registered.definition.inputSchema,
828
+ }),
829
+ isError: false,
830
+ };
831
+ }
832
+ if (pattern === undefined && namespaceName !== undefined) {
833
+ return {
834
+ content: JSON.stringify(this.fullNamespace(selected[0]!)),
835
+ isError: false,
836
+ };
837
+ }
838
+ let regex: RegExp | undefined;
839
+ if (pattern !== undefined) {
840
+ const compiled = compilePattern(pattern);
841
+ if ("error" in compiled) {
842
+ return { content: compiled.error, isError: true };
843
+ }
844
+ regex = compiled.regex;
845
+ }
846
+ const catalog = selected.flatMap((namespace) => {
847
+ if (!regex) return [this.catalogNamespace(namespace)];
848
+ const tools = regex.test(namespace.name)
849
+ ? namespace.tools
850
+ : namespace.tools.filter(({ definition }) =>
851
+ regex!.test(definition.name),
852
+ );
853
+ return tools.length > 0 ? [this.catalogNamespace(namespace, tools)] : [];
854
+ });
855
+ return {
856
+ content: JSON.stringify({ mode: "catalog", namespaces: catalog }),
857
+ isError: false,
858
+ };
859
+ }
860
+
861
+ private renderDynamicToolCatalog(turnType: TurnTypeV1): string {
862
+ const namespaces = this.availableNamespaces({ turnType });
863
+ if (namespaces.length === 0) return "";
864
+ const entries = namespaces.map((namespace) => {
865
+ const attributes = [
866
+ `name="${xmlAttribute(namespace.name)}"`,
867
+ `tools="${xmlAttribute(
868
+ namespace.tools.map(({ definition }) => definition.name).join(", "),
869
+ )}"`,
870
+ ...(namespace.metadata?.useInstructions
871
+ ? [
872
+ `namespaceUseInstructions="${xmlAttribute(
873
+ namespace.metadata.useInstructions,
874
+ )}"`,
875
+ ]
876
+ : []),
877
+ ...(namespace.metadata?.status
878
+ ? [`namespaceStatus="${xmlAttribute(namespace.metadata.status)}"`]
879
+ : []),
880
+ ];
881
+ return `<namespace ${attributes.join(" ")} />`;
882
+ });
883
+ return [
884
+ "<dynamic_tool_catalog>",
885
+ `These dynamic tool namespaces were available when this conversation started. Availability may have changed, so use ${GET_DYNAMIC_TOOLS_NAME} to check current state before calling ${CALL_DYNAMIC_TOOL_NAME}.`,
886
+ "",
887
+ "<dynamic_tool_namespaces>",
888
+ ...entries,
889
+ "</dynamic_tool_namespaces>",
890
+ "</dynamic_tool_catalog>",
891
+ ].join("\n");
892
+ }
304
893
  }
305
894
 
306
895
  const TOOL_RECONCILIATION_REASON_MAX_BYTES = 512;