@frockbot/plugin-tools 0.1.3 → 0.2.0

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.1.3",
3
+ "version": "0.2.0",
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.1.3",
20
+ "@frockbot/kernel-contracts": "0.2.0",
21
21
  "cordis": "4.0.0-rc.8"
22
22
  },
23
23
  "devDependencies": {
package/src/tools.test.ts CHANGED
@@ -39,6 +39,43 @@ afterEach(async () => {
39
39
  });
40
40
 
41
41
  describe("ToolRegistry effect reconciliation", () => {
42
+ test("deny-only guards run after pre-execute and cannot be lifted", async () => {
43
+ const order: string[] = [];
44
+ const fixture = await registryFixture({
45
+ name: "guarded_order",
46
+ description: "Guard ordering fixture.",
47
+ inputSchema: { type: "object" },
48
+ execute: () => {
49
+ order.push("execute");
50
+ return Promise.resolve({ content: "ran", isError: false });
51
+ },
52
+ });
53
+ fixture.root.on("tools/pre-execute", async (_call, _context, next) => {
54
+ order.push("pre-execute");
55
+ return next();
56
+ });
57
+ fixture.root.tools.guard(() => {
58
+ order.push("deny");
59
+ return { reason: "first guard denied the call" };
60
+ });
61
+ fixture.root.tools.guard(() => {
62
+ order.push("later-guard");
63
+ return undefined;
64
+ });
65
+
66
+ const preparation = await fixture.root.tools.prepare(
67
+ fixture.call,
68
+ fixture.context,
69
+ );
70
+ expect(preparation).toEqual({
71
+ kind: "denied",
72
+ call: fixture.call,
73
+ result: { content: "first guard denied the call", isError: true },
74
+ });
75
+ expect(order).toEqual(["pre-execute", "deny"]);
76
+ expect(order).not.toContain("execute");
77
+ });
78
+
42
79
  test("retries an idempotent definition with the same durable effect id", async () => {
43
80
  const effects: string[] = [];
44
81
  const fixture = await registryFixture({
package/src/tools.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type ToolExecution,
10
10
  type ToolExecutionContext,
11
11
  type ToolExecutionResult,
12
+ type ToolGuard,
12
13
  type ToolPreparation,
13
14
  type ToolRegistrationOptions,
14
15
  type ToolSchema,
@@ -42,6 +43,7 @@ interface RegisteredTool {
42
43
 
43
44
  export class ToolRegistry extends Service implements ToolExecution {
44
45
  private definitions = new Map<string, RegisteredTool>();
46
+ private guards: ToolGuard[] = [];
45
47
 
46
48
  constructor(ctx: Context) {
47
49
  super(ctx, "tools");
@@ -73,6 +75,18 @@ export class ToolRegistry extends Service implements ToolExecution {
73
75
  };
74
76
  }
75
77
 
78
+ registeredNames(): string[] {
79
+ return [...this.definitions.keys()].toSorted();
80
+ }
81
+
82
+ guard(guard: ToolGuard): () => void {
83
+ this.guards.push(guard);
84
+ return () => {
85
+ const index = this.guards.indexOf(guard);
86
+ if (index >= 0) this.guards.splice(index, 1);
87
+ };
88
+ }
89
+
76
90
  schemas(admission: {
77
91
  turnType: TurnTypeV1;
78
92
  subagentRole?: string;
@@ -93,66 +107,86 @@ export class ToolRegistry extends Service implements ToolExecution {
93
107
  }));
94
108
  }
95
109
 
96
- prepare(
110
+ async prepare(
97
111
  call: ToolCall,
98
112
  context: ToolExecutionContext,
99
113
  ): Promise<ToolPreparation> {
100
- return this.ctx.waterfall("tools/pre-execute", call, context, async () => {
101
- const registered = this.definitions.get(call.name);
102
- if (!registered) {
103
- return {
104
- kind: "denied",
105
- call,
106
- result: { content: `Unknown tool: ${call.name}`, isError: true },
107
- };
108
- }
109
- // Defence in depth: the catalog was already trimmed, so a call that
110
- // arrives here names a tool the model was never offered.
111
- if (!registered.admitted.includes(context.turnType)) {
112
- return {
113
- kind: "denied",
114
- call,
115
- result: {
116
- content: `Tool is not available on a ${context.turnType} turn: ${call.name}`,
117
- isError: true,
118
- },
119
- };
120
- }
121
- // The same defence on the second dimension. A `browserUse` subagent that
122
- // names `computer_exec` was never offered it, and the ceiling says so
123
- // here as well as in the catalog.
124
- if (
125
- !isSubagentRoleAdmittedV1(
126
- registered.admittedRoles,
127
- context.subagentRole,
128
- )
129
- ) {
130
- return {
131
- kind: "denied",
132
- call,
133
- result: {
134
- content: `Tool is not available to a ${context.subagentRole} subagent: ${call.name}`,
135
- isError: true,
136
- },
137
- };
138
- }
139
- const definition = registered.definition;
140
- if (definition.validate && !definition.validate(call.input)) {
114
+ const prepared = await this.ctx.waterfall(
115
+ "tools/pre-execute",
116
+ call,
117
+ context,
118
+ async () => {
119
+ const registered = this.definitions.get(call.name);
120
+ if (!registered) {
121
+ return {
122
+ kind: "denied",
123
+ call,
124
+ result: { content: `Unknown tool: ${call.name}`, isError: true },
125
+ };
126
+ }
127
+ // Defence in depth: the catalog was already trimmed, so a call that
128
+ // arrives here names a tool the model was never offered.
129
+ if (!registered.admitted.includes(context.turnType)) {
130
+ return {
131
+ kind: "denied",
132
+ call,
133
+ result: {
134
+ content: `Tool is not available on a ${context.turnType} turn: ${call.name}`,
135
+ isError: true,
136
+ },
137
+ };
138
+ }
139
+ // The same defence on the second dimension. A `browserUse` subagent that
140
+ // names `computer_exec` was never offered it, and the ceiling says so
141
+ // here as well as in the catalog.
142
+ if (
143
+ !isSubagentRoleAdmittedV1(
144
+ registered.admittedRoles,
145
+ context.subagentRole,
146
+ )
147
+ ) {
148
+ return {
149
+ kind: "denied",
150
+ call,
151
+ result: {
152
+ content: `Tool is not available to a ${context.subagentRole} subagent: ${call.name}`,
153
+ isError: true,
154
+ },
155
+ };
156
+ }
157
+ const definition = registered.definition;
158
+ if (definition.validate && !definition.validate(call.input)) {
159
+ return {
160
+ kind: "denied",
161
+ call,
162
+ result: {
163
+ content: `Invalid input for tool: ${call.name}`,
164
+ isError: true,
165
+ },
166
+ };
167
+ }
141
168
  return {
142
- kind: "denied",
169
+ kind: "ready",
143
170
  call,
144
- result: {
145
- content: `Invalid input for tool: ${call.name}`,
146
- isError: true,
147
- },
171
+ idempotent: definition.idempotent ?? false,
148
172
  };
149
- }
173
+ },
174
+ );
175
+ // A pre-execute listener can add a denial. Once denied, neither a guard
176
+ // nor anything registered later can turn the call back into executable
177
+ // work. Guards themselves return only a reason, so they have no vocabulary
178
+ // with which to lift another guard's denial.
179
+ if (prepared.kind === "denied") return prepared;
180
+ for (const guard of this.guards) {
181
+ const denial = await guard(prepared.call, context);
182
+ if (!denial) continue;
150
183
  return {
151
- kind: "ready",
152
- call,
153
- idempotent: definition.idempotent ?? false,
184
+ kind: "denied",
185
+ call: prepared.call,
186
+ result: { content: denial.reason, isError: true },
154
187
  };
155
- });
188
+ }
189
+ return prepared;
156
190
  }
157
191
 
158
192
  async executePrepared(