@frockbot/plugin-tools 0.3.5 → 0.3.6
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 +111 -4
- package/src/tools.ts +154 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-tools",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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.6",
|
|
21
21
|
"cordis": "4.0.0-rc.8"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
@@ -273,7 +273,9 @@ describe("progressive tool disclosure", () => {
|
|
|
273
273
|
})
|
|
274
274
|
).content,
|
|
275
275
|
);
|
|
276
|
-
|
|
276
|
+
// Plus the envelope the schema has to be wrapped in; see the dedicated
|
|
277
|
+
// test below.
|
|
278
|
+
expect(single).toMatchObject(threadSchema as Record<string, unknown>);
|
|
277
279
|
});
|
|
278
280
|
|
|
279
281
|
test("returns tool errors for invalid patterns and unknown lookups", async () => {
|
|
@@ -288,12 +290,18 @@ describe("progressive tool disclosure", () => {
|
|
|
288
290
|
expect(
|
|
289
291
|
await invoke(root, GET_DYNAMIC_TOOLS_NAME, { namespace: "missing" }),
|
|
290
292
|
).toEqual({ content: "Namespace not found", isError: true });
|
|
293
|
+
// A name that was not found says which names there are, so the model can
|
|
294
|
+
// correct a spelling in one step instead of re-reading the catalogue.
|
|
291
295
|
expect(
|
|
292
296
|
await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
|
|
293
297
|
namespace: "mail",
|
|
294
298
|
toolName: "missing",
|
|
295
299
|
}),
|
|
296
|
-
).toEqual({
|
|
300
|
+
).toEqual({
|
|
301
|
+
content:
|
|
302
|
+
'Tool not found: "missing" in namespace "mail". Tools in this namespace: search',
|
|
303
|
+
isError: true,
|
|
304
|
+
});
|
|
297
305
|
expect(
|
|
298
306
|
await invoke(root, GET_DYNAMIC_TOOLS_NAME, { toolName: "search" }),
|
|
299
307
|
).toEqual({ content: "toolName requires namespace", isError: true });
|
|
@@ -302,13 +310,112 @@ describe("progressive tool disclosure", () => {
|
|
|
302
310
|
namespace: "missing",
|
|
303
311
|
toolName: "search",
|
|
304
312
|
}),
|
|
305
|
-
).toEqual({
|
|
313
|
+
).toEqual({
|
|
314
|
+
content: 'Namespace not found: "missing". Available namespaces: mail',
|
|
315
|
+
isError: true,
|
|
316
|
+
});
|
|
306
317
|
expect(
|
|
307
318
|
await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
|
|
308
319
|
namespace: "mail",
|
|
309
320
|
toolName: "missing",
|
|
310
321
|
}),
|
|
311
|
-
).toEqual({
|
|
322
|
+
).toEqual({
|
|
323
|
+
content:
|
|
324
|
+
'Tool not found: "missing" in namespace "mail". Tools in this namespace: search',
|
|
325
|
+
isError: true,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("names the wrong field in a malformed call_dynamic_tool envelope", async () => {
|
|
330
|
+
// F3: every one of these answered with the single string "Invalid input
|
|
331
|
+
// for tool: call_dynamic_tool". The Bot re-read the schema twice, ran a
|
|
332
|
+
// deliberate plumbing test against `echo`, failed identically, and spent
|
|
333
|
+
// seven steps without authoring anything. These are the exact envelopes it
|
|
334
|
+
// sent (user `packages-2`, Bot `smith-b867c90c`, run events seq 18-52).
|
|
335
|
+
const root = await rootWithTools();
|
|
336
|
+
root.tools.register(dynamicTool("mail", "search"));
|
|
337
|
+
|
|
338
|
+
const recorded: Array<[unknown, string[]]> = [
|
|
339
|
+
// `{"args": "<json string>", "packageId": …}`
|
|
340
|
+
[
|
|
341
|
+
{ args: '{"text":"plumbing test"}', packageId: "frockbot" },
|
|
342
|
+
[
|
|
343
|
+
'"namespace" is missing — you sent "packageId"; the field is "namespace"',
|
|
344
|
+
'"toolName" is missing; it must be a non-empty string',
|
|
345
|
+
'the tool\'s own input goes in "arguments" as an object, not in "args"',
|
|
346
|
+
],
|
|
347
|
+
],
|
|
348
|
+
// `{"args":"{\"text\":\"plumbing test\"}","name":"echo","namespace":"frockbot"}`
|
|
349
|
+
[
|
|
350
|
+
{
|
|
351
|
+
args: '{"text":"plumbing test"}',
|
|
352
|
+
name: "echo",
|
|
353
|
+
namespace: "frockbot",
|
|
354
|
+
},
|
|
355
|
+
[
|
|
356
|
+
'"toolName" is missing — you sent "name"; the field is "toolName"',
|
|
357
|
+
'the tool\'s own input goes in "arguments" as an object, not in "args"',
|
|
358
|
+
],
|
|
359
|
+
],
|
|
360
|
+
// The same mistake made with the right key: JSON text, not an object.
|
|
361
|
+
[
|
|
362
|
+
{
|
|
363
|
+
namespace: "mail",
|
|
364
|
+
toolName: "search",
|
|
365
|
+
arguments: '{"value":"x"}',
|
|
366
|
+
},
|
|
367
|
+
[
|
|
368
|
+
'"arguments" must be a JSON object, not a string — send the object itself, not JSON text',
|
|
369
|
+
],
|
|
370
|
+
],
|
|
371
|
+
[
|
|
372
|
+
"namespace=mail",
|
|
373
|
+
[
|
|
374
|
+
"call_dynamic_tool input is invalid: it must be an object, not a string",
|
|
375
|
+
],
|
|
376
|
+
],
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
for (const [input, fragments] of recorded) {
|
|
380
|
+
const result = await invoke(root, CALL_DYNAMIC_TOOL_NAME, input);
|
|
381
|
+
expect(result.isError).toBe(true);
|
|
382
|
+
for (const fragment of fragments) {
|
|
383
|
+
expect(result.content).toContain(fragment);
|
|
384
|
+
}
|
|
385
|
+
// Always the worked envelope, so the next attempt has a shape to copy.
|
|
386
|
+
expect(result.content).toContain(
|
|
387
|
+
'Expected {"namespace":"<namespace>","toolName":"<tool>","arguments":',
|
|
388
|
+
);
|
|
389
|
+
expect(result.content).not.toBe(
|
|
390
|
+
"Invalid input for tool: call_dynamic_tool",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("single-tool discovery echoes the envelope the schema goes inside", async () => {
|
|
396
|
+
// F3: `get_dynamic_tools({namespace, toolName})` returned the inner
|
|
397
|
+
// `inputSchema` and nothing else, and the model then sent that inner shape
|
|
398
|
+
// as the whole `call_dynamic_tool` input.
|
|
399
|
+
const root = await rootWithTools();
|
|
400
|
+
root.tools.register(dynamicTool("mail", "search"));
|
|
401
|
+
const single = JSON.parse(
|
|
402
|
+
(
|
|
403
|
+
await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
|
|
404
|
+
namespace: "mail",
|
|
405
|
+
toolName: "search",
|
|
406
|
+
})
|
|
407
|
+
).content,
|
|
408
|
+
) as { namespace: string; callWith: unknown };
|
|
409
|
+
|
|
410
|
+
expect(single.namespace).toBe("mail");
|
|
411
|
+
expect(single.callWith).toEqual({
|
|
412
|
+
tool: CALL_DYNAMIC_TOOL_NAME,
|
|
413
|
+
input: {
|
|
414
|
+
namespace: "mail",
|
|
415
|
+
toolName: "search",
|
|
416
|
+
arguments: "<an object matching inputSchema>",
|
|
417
|
+
},
|
|
418
|
+
});
|
|
312
419
|
});
|
|
313
420
|
|
|
314
421
|
test("prepares and executes the inner call through every registry hook", async () => {
|
package/src/tools.ts
CHANGED
|
@@ -135,15 +135,120 @@ function validMcpDetails(input: unknown): boolean {
|
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/** The envelope every dynamic call must be wrapped in, as a worked example. */
|
|
139
|
+
const CALL_DYNAMIC_TOOL_ENVELOPE =
|
|
140
|
+
'{"namespace":"<namespace>","toolName":"<tool>","arguments":{ … the tool\'s own input … }}';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The names a model reaches for instead of the real ones. Naming the field it
|
|
144
|
+
* *did* send is what turns a refusal into a one-step recovery: a Bot that sent
|
|
145
|
+
* `{"args":"…","name":"echo"}` re-read the schema twice and still never
|
|
146
|
+
* recovered, because the refusal was the single string "Invalid input for
|
|
147
|
+
* tool: call_dynamic_tool" (finding F3).
|
|
148
|
+
*/
|
|
149
|
+
const CALL_DYNAMIC_TOOL_ALIASES: Readonly<Record<string, string>> = {
|
|
150
|
+
name: "toolName",
|
|
151
|
+
tool: "toolName",
|
|
152
|
+
tool_name: "toolName",
|
|
153
|
+
toolname: "toolName",
|
|
154
|
+
function: "toolName",
|
|
155
|
+
args: "arguments",
|
|
156
|
+
arguments_: "arguments",
|
|
157
|
+
input: "arguments",
|
|
158
|
+
parameters: "arguments",
|
|
159
|
+
params: "arguments",
|
|
160
|
+
packageId: "namespace",
|
|
161
|
+
package: "namespace",
|
|
162
|
+
package_id: "namespace",
|
|
163
|
+
ns: "namespace",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function jsonTypeOf(value: unknown): string {
|
|
167
|
+
if (value === null) return "null";
|
|
168
|
+
if (Array.isArray(value)) return "an array";
|
|
169
|
+
const type = typeof value;
|
|
170
|
+
if (type === "string") return "a string";
|
|
171
|
+
if (type === "number") return "a number";
|
|
172
|
+
if (type === "boolean") return "a boolean";
|
|
173
|
+
if (type === "object") return "an object";
|
|
174
|
+
return type;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The key the caller sent that it probably meant as `field`. */
|
|
178
|
+
function aliasFor(
|
|
179
|
+
input: Record<string, unknown>,
|
|
180
|
+
field: string,
|
|
181
|
+
): string | undefined {
|
|
182
|
+
return Object.keys(input).find(
|
|
183
|
+
(key) => CALL_DYNAMIC_TOOL_ALIASES[key] === field && key !== field,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Why this `call_dynamic_tool` input cannot be used, naming the offending
|
|
189
|
+
* field and the shape it should have — or `undefined` when it is valid.
|
|
190
|
+
*/
|
|
191
|
+
function explainCallDynamicToolInput(input: unknown): string | undefined {
|
|
192
|
+
const preamble = `${CALL_DYNAMIC_TOOL_NAME} input is invalid`;
|
|
193
|
+
const expected = `Expected ${CALL_DYNAMIC_TOOL_ENVELOPE}`;
|
|
194
|
+
if (!isRecord(input)) {
|
|
195
|
+
return `${preamble}: it must be an object, not ${jsonTypeOf(input)}. ${expected}`;
|
|
196
|
+
}
|
|
197
|
+
const problems: string[] = [];
|
|
198
|
+
for (const field of ["namespace", "toolName"] as const) {
|
|
199
|
+
const value = input[field];
|
|
200
|
+
if (typeof value === "string" && value.length > 0) continue;
|
|
201
|
+
const alias = aliasFor(input, field);
|
|
202
|
+
if (value === undefined) {
|
|
203
|
+
problems.push(
|
|
204
|
+
alias === undefined
|
|
205
|
+
? `"${field}" is missing; it must be a non-empty string`
|
|
206
|
+
: `"${field}" is missing — you sent "${alias}"; the field is "${field}"`,
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
problems.push(
|
|
210
|
+
`"${field}" must be a non-empty string, not ${jsonTypeOf(value)}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (input.arguments === undefined) {
|
|
215
|
+
const alias = aliasFor(input, "arguments");
|
|
216
|
+
if (alias !== undefined) {
|
|
217
|
+
problems.push(
|
|
218
|
+
`the tool's own input goes in "arguments" as an object, not in "${alias}"`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} else if (!isRecord(input.arguments)) {
|
|
222
|
+
problems.push(
|
|
223
|
+
typeof input.arguments === "string"
|
|
224
|
+
? `"arguments" must be a JSON object, not a string — send the object itself, not JSON text`
|
|
225
|
+
: `"arguments" must be a JSON object, not ${jsonTypeOf(input.arguments)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
input.mcpDetails !== undefined &&
|
|
230
|
+
!validMcpDetailsShape(input.mcpDetails)
|
|
231
|
+
) {
|
|
232
|
+
problems.push(
|
|
233
|
+
`"mcpDetails" must be an object with an optional string "description", boolean "requestSmartModeApproval" and string "smartModeBlockReason"`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (problems.length === 0) return undefined;
|
|
237
|
+
return `${preamble}: ${problems.join("; ")}. ${expected}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
138
240
|
function validCallDynamicToolInput(input: unknown): boolean {
|
|
241
|
+
return explainCallDynamicToolInput(input) === undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A bounded, sorted name list for a refusal that has to stay readable. */
|
|
245
|
+
function listOrNone(names: readonly string[]): string {
|
|
246
|
+
if (names.length === 0) return "none";
|
|
247
|
+
const sorted = [...names].sort();
|
|
248
|
+
const shown = sorted.slice(0, 40);
|
|
139
249
|
return (
|
|
140
|
-
|
|
141
|
-
|
|
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))
|
|
250
|
+
shown.join(", ") +
|
|
251
|
+
(sorted.length > shown.length ? `, … (${sorted.length} in total)` : "")
|
|
147
252
|
);
|
|
148
253
|
}
|
|
149
254
|
|
|
@@ -426,7 +531,8 @@ export class ToolRegistry extends Service implements ToolExecution {
|
|
|
426
531
|
) {
|
|
427
532
|
return this.denied(
|
|
428
533
|
call,
|
|
429
|
-
|
|
534
|
+
explainCallDynamicToolInput(call.input) ??
|
|
535
|
+
`${CALL_DYNAMIC_TOOL_NAME} input is invalid: "mcpDetails.description" must be a non-empty string. Expected ${CALL_DYNAMIC_TOOL_ENVELOPE}`,
|
|
430
536
|
);
|
|
431
537
|
}
|
|
432
538
|
return this.prepareRegistered(
|
|
@@ -673,16 +779,30 @@ export class ToolRegistry extends Service implements ToolExecution {
|
|
|
673
779
|
private resolveDynamicCall(
|
|
674
780
|
outer: ToolCall,
|
|
675
781
|
): ResolvedDynamicCall | { error: string } {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
782
|
+
const invalid = explainCallDynamicToolInput(outer.input);
|
|
783
|
+
if (invalid !== undefined) return { error: invalid };
|
|
679
784
|
const input = outer.input as Record<string, unknown>;
|
|
680
785
|
const namespace = input.namespace as string;
|
|
681
786
|
const definitions = this.dynamicDefinitions.get(namespace);
|
|
682
|
-
|
|
787
|
+
// "Namespace not found" and "Tool not found" alone leave the model
|
|
788
|
+
// guessing at spelling; the names it may use are cheap to say and are
|
|
789
|
+
// what let it correct itself in one step.
|
|
790
|
+
if (!definitions) {
|
|
791
|
+
return {
|
|
792
|
+
error: `Namespace not found: "${namespace}". Available namespaces: ${listOrNone(
|
|
793
|
+
[...this.dynamicDefinitions.keys()],
|
|
794
|
+
)}`,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
683
797
|
const toolName = input.toolName as string;
|
|
684
798
|
const registered = definitions.get(toolName);
|
|
685
|
-
if (!registered)
|
|
799
|
+
if (!registered) {
|
|
800
|
+
return {
|
|
801
|
+
error: `Tool not found: "${toolName}" in namespace "${namespace}". Tools in this namespace: ${listOrNone(
|
|
802
|
+
[...definitions.keys()],
|
|
803
|
+
)}`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
686
806
|
return {
|
|
687
807
|
registered,
|
|
688
808
|
// One durable effect, one call id. Hooks see the inner name and input;
|
|
@@ -819,12 +939,32 @@ export class ToolRegistry extends Service implements ToolExecution {
|
|
|
819
939
|
const registered = selected[0]!.tools.find(
|
|
820
940
|
({ definition }) => definition.name === toolName,
|
|
821
941
|
);
|
|
822
|
-
if (!registered)
|
|
942
|
+
if (!registered) {
|
|
943
|
+
return {
|
|
944
|
+
content: `Tool not found: "${toolName}" in namespace "${namespaceName}". Tools in this namespace: ${listOrNone(
|
|
945
|
+
selected[0]!.tools.map(({ definition }) => definition.name),
|
|
946
|
+
)}`,
|
|
947
|
+
isError: true,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
// The `inputSchema` alone describes the *inner* input, and a model that
|
|
951
|
+
// reads it here goes on to send it as the whole `call_dynamic_tool`
|
|
952
|
+
// input. Echoing the envelope the schema has to be wrapped in is what
|
|
953
|
+
// closes that gap (finding F3).
|
|
823
954
|
return {
|
|
824
955
|
content: JSON.stringify({
|
|
825
956
|
tool: registered.definition.name,
|
|
957
|
+
namespace: namespaceName,
|
|
826
958
|
description: registered.definition.description,
|
|
827
959
|
inputSchema: registered.definition.inputSchema,
|
|
960
|
+
callWith: {
|
|
961
|
+
tool: CALL_DYNAMIC_TOOL_NAME,
|
|
962
|
+
input: {
|
|
963
|
+
namespace: namespaceName,
|
|
964
|
+
toolName: registered.definition.name,
|
|
965
|
+
arguments: "<an object matching inputSchema>",
|
|
966
|
+
},
|
|
967
|
+
},
|
|
828
968
|
}),
|
|
829
969
|
isError: false,
|
|
830
970
|
};
|