@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 +2 -2
- package/src/dynamic-tools.test.ts +111 -0
- package/src/tools.ts +49 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-tools",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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: {
|
|
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
|
}),
|