@frockbot/plugin-tools 0.3.9 → 0.3.11

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.9",
3
+ "version": "0.3.11",
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.9",
20
+ "@frockbot/kernel-contracts": "0.3.11",
21
21
  "cordis": "4.0.0-rc.8"
22
22
  },
23
23
  "devDependencies": {
@@ -590,3 +590,114 @@ describe("progressive tool disclosure", () => {
590
590
  );
591
591
  });
592
592
  });
593
+
594
+ describe("the envelope discovery hands back", () => {
595
+ test("is the envelope dispatch accepts, for a non-external namespace", async () => {
596
+ // The Applets Package mounts through the isolate host, whose namespaces
597
+ // used to register `external: true`. The dispatch guard then refused every
598
+ // call without `mcpDetails.description`, while discovery's `callWith`
599
+ // never mentioned the field — so the envelope offered was the envelope
600
+ // refused, and no Applet could be created by chat at all.
601
+ const root = await rootWithTools();
602
+ root.tools.register(dynamicTool("applets", "applet_create"));
603
+ root.tools.registerNamespace({ name: "applets", status: "ready" });
604
+
605
+ const discovered = await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
606
+ namespace: "applets",
607
+ toolName: "applet_create",
608
+ });
609
+ const envelope = JSON.parse(discovered.content as string).callWith as {
610
+ tool: string;
611
+ input: Record<string, unknown>;
612
+ };
613
+ expect(envelope.tool).toBe(CALL_DYNAMIC_TOOL_NAME);
614
+ expect(envelope.input.mcpDetails).toBeUndefined();
615
+
616
+ // The exact envelope, with only the placeholder arguments made real.
617
+ const dispatched = await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
618
+ ...envelope.input,
619
+ arguments: { value: "a todo list" },
620
+ });
621
+
622
+ expect(dispatched.isError).toBe(false);
623
+ expect(dispatched.content).toContain("a todo list");
624
+ });
625
+
626
+ test("carries mcpDetails when the namespace really is external", async () => {
627
+ const root = await rootWithTools();
628
+ root.tools.register(dynamicTool("linear", "issue_create"));
629
+ root.tools.registerNamespace({ name: "linear", external: true });
630
+
631
+ const discovered = await invoke(root, GET_DYNAMIC_TOOLS_NAME, {
632
+ namespace: "linear",
633
+ toolName: "issue_create",
634
+ });
635
+ const envelope = JSON.parse(discovered.content as string).callWith as {
636
+ input: Record<string, unknown>;
637
+ };
638
+ expect(envelope.input.mcpDetails).toEqual({
639
+ description: "<one sentence saying why you are calling this>",
640
+ });
641
+
642
+ const dispatched = await invoke(root, CALL_DYNAMIC_TOOL_NAME, {
643
+ ...envelope.input,
644
+ arguments: { value: "an issue" },
645
+ mcpDetails: { description: "Filing the bug the User just described." },
646
+ });
647
+
648
+ expect(dispatched.isError).toBe(false);
649
+ });
650
+ });
651
+
652
+ describe("a dynamic tool called by its bare name", () => {
653
+ test("is answered with the envelope, not a dead-end Unknown tool", async () => {
654
+ // `applet_list` is listed to the model by bare name in
655
+ // `<dynamic_tool_namespaces>`, so the model reaches for it that way. The
656
+ // refusal used to be `Unknown tool: applet_list` and nothing else — a dead
657
+ // end for a tool that was right there.
658
+ const root = await rootWithTools();
659
+ root.tools.register(dynamicTool("applets", "applet_list"));
660
+
661
+ const result = await invoke(root, "applet_list", {});
662
+
663
+ expect(result.isError).toBe(true);
664
+ expect(result.content).toContain("applet_list");
665
+ expect(result.content).toContain(CALL_DYNAMIC_TOOL_NAME);
666
+ expect(result.content).toContain(
667
+ '{"namespace":"applets","toolName":"applet_list"',
668
+ );
669
+ expect(result.content).toContain(GET_DYNAMIC_TOOLS_NAME);
670
+ });
671
+
672
+ test("names every namespace the bare name is in", async () => {
673
+ const root = await rootWithTools();
674
+ root.tools.register(dynamicTool("mail-one", "search"));
675
+ root.tools.register(dynamicTool("mail-two", "search"));
676
+
677
+ const result = await invoke(root, "search", {});
678
+
679
+ expect(result.isError).toBe(true);
680
+ expect(result.content).toContain("mail-one");
681
+ expect(result.content).toContain("mail-two");
682
+ });
683
+
684
+ test("routes an external namespace with the mcpDetails its guard demands", async () => {
685
+ const root = await rootWithTools();
686
+ root.tools.register(dynamicTool("linear", "issue_create"));
687
+ root.tools.registerNamespace({ name: "linear", external: true });
688
+
689
+ const result = await invoke(root, "issue_create", {});
690
+
691
+ expect(result.content).toContain("mcpDetails");
692
+ expect(result.content).toContain("description");
693
+ });
694
+
695
+ test("a name in no namespace still says only that it is unknown", async () => {
696
+ const root = await rootWithTools();
697
+ root.tools.register(dynamicTool("applets", "applet_list"));
698
+
699
+ const result = await invoke(root, "not_a_tool", {});
700
+
701
+ expect(result.content).toBe("Unknown tool: not_a_tool");
702
+ });
703
+ });
package/src/tools.ts CHANGED
@@ -552,6 +552,37 @@ export class ToolRegistry extends Service implements ToolExecution {
552
552
  return { kind: "denied", call, result: { content, isError: true } };
553
553
  }
554
554
 
555
+ /**
556
+ * Why a name is not callable — and, when it *is* callable through the
557
+ * envelope, how.
558
+ *
559
+ * A dynamic tool is listed to the model by bare name in
560
+ * `<dynamic_tool_namespaces>`, so the model reaches for it by that name and
561
+ * used to get `Unknown tool: applet_list` and nothing else: a dead end for a
562
+ * tool that was right there, costing a step every time. The refusal now
563
+ * hands back the exact envelope for the namespace the name is actually in.
564
+ */
565
+ private unknownToolRefusal(name: string): string {
566
+ const namespaces = [...this.dynamicDefinitions]
567
+ .filter(([, definitions]) => definitions.has(name))
568
+ .map(([namespace]) => namespace)
569
+ .sort();
570
+ const first = namespaces[0];
571
+ if (first === undefined) return `Unknown tool: ${name}`;
572
+ const where =
573
+ namespaces.length === 1
574
+ ? `namespace "${first}"`
575
+ : `namespaces ${listOrNone(namespaces)} — pick one`;
576
+ // Same rule as the discovery envelope: an external namespace's dispatch
577
+ // guard refuses a call without `mcpDetails.description`, so a route that
578
+ // omits it just moves the dead end one step later.
579
+ const details =
580
+ this.namespaces.get(first)?.external === true
581
+ ? `,"mcpDetails":{"description":"<one sentence saying why you are calling this>"}`
582
+ : "";
583
+ return `Unknown tool: ${name}. It is a dynamic tool in ${where}; call it through ${CALL_DYNAMIC_TOOL_NAME} as {"namespace":"${first}","toolName":"${name}","arguments":{ … the tool's own input … }${details}}. Call ${GET_DYNAMIC_TOOLS_NAME}({"namespace":"${first}","toolName":"${name}"}) first if you do not have its schema.`;
584
+ }
585
+
555
586
  private async prepareRegistered(
556
587
  call: ToolCall,
557
588
  registered: RegisteredTool | undefined,
@@ -566,7 +597,10 @@ export class ToolRegistry extends Service implements ToolExecution {
566
597
  return {
567
598
  kind: "denied",
568
599
  call,
569
- result: { content: `Unknown tool: ${call.name}`, isError: true },
600
+ result: {
601
+ content: this.unknownToolRefusal(call.name),
602
+ isError: true,
603
+ },
570
604
  };
571
605
  }
572
606
  // Defence in depth: the catalog was already trimmed, so a call that
@@ -963,6 +997,20 @@ export class ToolRegistry extends Service implements ToolExecution {
963
997
  namespace: namespaceName,
964
998
  toolName: registered.definition.name,
965
999
  arguments: "<an object matching inputSchema>",
1000
+ // The envelope handed back has to be the envelope accepted. An
1001
+ // external namespace's dispatch guard refuses a call without
1002
+ // `mcpDetails.description`, and a model that copied this
1003
+ // envelope verbatim was denied for a field the envelope never
1004
+ // mentioned.
1005
+ ...(this.namespaces.get(registered.definition.namespace ?? "")
1006
+ ?.external === true
1007
+ ? {
1008
+ mcpDetails: {
1009
+ description:
1010
+ "<one sentence saying why you are calling this>",
1011
+ },
1012
+ }
1013
+ : {}),
966
1014
  },
967
1015
  },
968
1016
  }),