@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-tools",
3
- "version": "0.3.5",
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.5",
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
- expect(single).toEqual(threadSchema);
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({ content: "Tool not found", isError: true });
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({ content: "Namespace not found", isError: true });
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({ content: "Tool not found", isError: true });
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
- 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))
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
- `Invalid input for tool: ${CALL_DYNAMIC_TOOL_NAME}`,
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
- if (!validCallDynamicToolInput(outer.input)) {
677
- return { error: `Invalid input for tool: ${CALL_DYNAMIC_TOOL_NAME}` };
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
- if (!definitions) return { error: "Namespace not found" };
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) return { error: "Tool not found" };
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) return { content: "Tool not found", isError: true };
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
  };