@frockbot/plugin-tools 0.1.4 → 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 +2 -2
- package/src/tools.test.ts +37 -0
- package/src/tools.ts +86 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-tools",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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: "
|
|
169
|
+
kind: "ready",
|
|
143
170
|
call,
|
|
144
|
-
|
|
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: "
|
|
152
|
-
call,
|
|
153
|
-
|
|
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(
|