@frockbot/plugin-tools 0.3.1 → 0.3.3
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 +2 -2
- package/src/dynamic-tools.test.ts +485 -0
- package/src/tools.test.ts +50 -31
- package/src/tools.ts +613 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-tools",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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.
|
|
20
|
+
"@frockbot/kernel-contracts": "0.3.3",
|
|
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&"'work" tools="search<"mail" namespaceUseInstructions="Use <schema> & "call". 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
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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
|
|
435
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
460
|
+
admittedNames(root, {
|
|
461
|
+
turnType: "subagent",
|
|
462
|
+
subagentRole: "browserUse",
|
|
463
|
+
}),
|
|
449
464
|
).toEqual(["work", "computer_browser"]);
|
|
450
465
|
expect(
|
|
451
|
-
root
|
|
452
|
-
|
|
453
|
-
|
|
466
|
+
admittedNames(root, {
|
|
467
|
+
turnType: "subagent",
|
|
468
|
+
subagentRole: "computerUse",
|
|
469
|
+
}),
|
|
454
470
|
).toEqual(["work", "computer_exec", "computer_browser"]);
|
|
455
471
|
expect(
|
|
456
|
-
root
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
483
|
+
admittedNames(root, {
|
|
484
|
+
turnType: "subagent",
|
|
485
|
+
subagentRole: "browserUse",
|
|
486
|
+
}),
|
|
469
487
|
).toEqual([]);
|
|
470
488
|
expect(
|
|
471
|
-
root
|
|
472
|
-
|
|
473
|
-
|
|
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("&", "&")
|
|
191
|
+
.replaceAll('"', """)
|
|
192
|
+
.replaceAll("'", "'")
|
|
193
|
+
.replaceAll("<", "<")
|
|
194
|
+
.replaceAll(">", ">")
|
|
195
|
+
.replaceAll("\t", "	")
|
|
196
|
+
.replaceAll("\n", " ")
|
|
197
|
+
.replaceAll("\r", " ");
|
|
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
|
|
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
|
-
|
|
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
|
-
):
|
|
56
|
-
|
|
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
|
-
|
|
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 (
|
|
73
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
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(
|
|
291
|
-
|
|
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",
|
|
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;
|