@frockbot/plugin-tools 0.0.0 → 0.1.1
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/frockbot.json +15 -0
- package/package.json +27 -6
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/tools.test.ts +463 -0
- package/src/tools.ts +385 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
package/package.json
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./agent": "./src/tools.ts",
|
|
9
|
+
"./manifest": "./src/manifest.ts",
|
|
10
|
+
"./frockbot.json": "./frockbot.json",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"frockbot": {
|
|
14
|
+
"manifest": "./frockbot.json"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
21
|
+
"cordis": "4.0.0-rc.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "1.4.0",
|
|
25
|
+
"typescript": "^7.0.2"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
6
30
|
"repository": {
|
|
7
31
|
"type": "git",
|
|
8
32
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
33
|
"directory": "packages/plugin-tools"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
34
|
}
|
|
14
35
|
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context } from "cordis";
|
|
3
|
+
import { ToolRegistry } from "./tools.js";
|
|
4
|
+
import type {
|
|
5
|
+
ToolCall,
|
|
6
|
+
ToolDefinition,
|
|
7
|
+
ToolExecutionContext,
|
|
8
|
+
} from "@frockbot/kernel-contracts";
|
|
9
|
+
|
|
10
|
+
const roots: Context[] = [];
|
|
11
|
+
|
|
12
|
+
async function registryFixture(tool: ToolDefinition): Promise<{
|
|
13
|
+
root: Context;
|
|
14
|
+
call: ToolCall;
|
|
15
|
+
context: ToolExecutionContext;
|
|
16
|
+
}> {
|
|
17
|
+
const root = new Context();
|
|
18
|
+
roots.push(root);
|
|
19
|
+
await root.plugin(ToolRegistry);
|
|
20
|
+
root.tools.register(tool);
|
|
21
|
+
return {
|
|
22
|
+
root,
|
|
23
|
+
call: { id: "provider-call", name: tool.name, input: {} },
|
|
24
|
+
context: {
|
|
25
|
+
botId: "primary",
|
|
26
|
+
agentId: "primary",
|
|
27
|
+
sessionId: "alice:primary",
|
|
28
|
+
compositionGenerationId: "test-composition-generation",
|
|
29
|
+
effectId: "tool:1:1:0",
|
|
30
|
+
toolCall: { id: "provider-call", name: tool.name, input: {} },
|
|
31
|
+
turnType: "chat" as const,
|
|
32
|
+
signal: new AbortController().signal,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("ToolRegistry effect reconciliation", () => {
|
|
42
|
+
test("retries an idempotent definition with the same durable effect id", async () => {
|
|
43
|
+
const effects: string[] = [];
|
|
44
|
+
const fixture = await registryFixture({
|
|
45
|
+
name: "idempotent",
|
|
46
|
+
description: "Idempotent fixture.",
|
|
47
|
+
inputSchema: { type: "object" },
|
|
48
|
+
idempotent: true,
|
|
49
|
+
execute(_input, context) {
|
|
50
|
+
effects.push(context.effectId);
|
|
51
|
+
return Promise.resolve({ content: "settled", isError: false });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const preparation = await fixture.root.tools.prepare(
|
|
55
|
+
fixture.call,
|
|
56
|
+
fixture.context,
|
|
57
|
+
);
|
|
58
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
await fixture.root.tools.reconcilePrepared(preparation, fixture.context),
|
|
62
|
+
).toEqual({
|
|
63
|
+
status: "recovered",
|
|
64
|
+
result: { content: "settled", isError: false },
|
|
65
|
+
});
|
|
66
|
+
expect(effects).toEqual(["tool:1:1:0"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("retrieves a non-idempotent result without executing the effect", async () => {
|
|
70
|
+
let executions = 0;
|
|
71
|
+
const reconciled: string[] = [];
|
|
72
|
+
const fixture = await registryFixture({
|
|
73
|
+
name: "non-idempotent",
|
|
74
|
+
description: "Non-idempotent fixture.",
|
|
75
|
+
inputSchema: { type: "object" },
|
|
76
|
+
execute() {
|
|
77
|
+
executions += 1;
|
|
78
|
+
return Promise.resolve({ content: "duplicate", isError: false });
|
|
79
|
+
},
|
|
80
|
+
reconcile(_input, context) {
|
|
81
|
+
reconciled.push(context.effectId);
|
|
82
|
+
return Promise.resolve({
|
|
83
|
+
status: "recovered",
|
|
84
|
+
result: { content: "original", isError: false },
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const preparation = await fixture.root.tools.prepare(
|
|
89
|
+
fixture.call,
|
|
90
|
+
fixture.context,
|
|
91
|
+
);
|
|
92
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
await fixture.root.tools.reconcilePrepared(preparation, fixture.context),
|
|
96
|
+
).toEqual({
|
|
97
|
+
status: "recovered",
|
|
98
|
+
result: { content: "original", isError: false },
|
|
99
|
+
});
|
|
100
|
+
expect(executions).toBe(0);
|
|
101
|
+
expect(reconciled).toEqual(["tool:1:1:0"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("does not let middleware elevate a non-idempotent durable effect", async () => {
|
|
105
|
+
let executions = 0;
|
|
106
|
+
let retrievals = 0;
|
|
107
|
+
const fixture = await registryFixture({
|
|
108
|
+
name: "guarded",
|
|
109
|
+
description: "Guarded fixture.",
|
|
110
|
+
inputSchema: { type: "object" },
|
|
111
|
+
execute() {
|
|
112
|
+
executions += 1;
|
|
113
|
+
return Promise.resolve({ content: "duplicate", isError: false });
|
|
114
|
+
},
|
|
115
|
+
reconcile() {
|
|
116
|
+
retrievals += 1;
|
|
117
|
+
return Promise.resolve({
|
|
118
|
+
status: "recovered",
|
|
119
|
+
result: { content: "original", isError: false },
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
fixture.root.on("tools/pre-execute", async (_call, _context, next) => {
|
|
124
|
+
const prepared = await next();
|
|
125
|
+
return prepared.kind === "ready"
|
|
126
|
+
? { ...prepared, idempotent: true }
|
|
127
|
+
: prepared;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const preparation = await fixture.root.tools.prepare(
|
|
131
|
+
fixture.call,
|
|
132
|
+
fixture.context,
|
|
133
|
+
);
|
|
134
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
135
|
+
expect(preparation.idempotent).toBe(true);
|
|
136
|
+
|
|
137
|
+
expect(
|
|
138
|
+
await fixture.root.tools.reconcilePrepared(preparation, fixture.context),
|
|
139
|
+
).toEqual({
|
|
140
|
+
status: "recovered",
|
|
141
|
+
result: { content: "original", isError: false },
|
|
142
|
+
});
|
|
143
|
+
expect(executions).toBe(0);
|
|
144
|
+
expect(retrievals).toBe(1);
|
|
145
|
+
|
|
146
|
+
expect(
|
|
147
|
+
await fixture.root.tools.reconcilePrepared(
|
|
148
|
+
{
|
|
149
|
+
...preparation,
|
|
150
|
+
call: { ...fixture.call, input: { changed: true } },
|
|
151
|
+
},
|
|
152
|
+
fixture.context,
|
|
153
|
+
),
|
|
154
|
+
).toMatchObject({ status: "unavailable" });
|
|
155
|
+
expect(executions).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("normalizes unavailable outcomes to a bounded reason", async () => {
|
|
159
|
+
const fixture = await registryFixture({
|
|
160
|
+
name: "pending",
|
|
161
|
+
description: "Pending fixture.",
|
|
162
|
+
inputSchema: { type: "object" },
|
|
163
|
+
execute: () => Promise.resolve({ content: "duplicate", isError: false }),
|
|
164
|
+
reconcile: () =>
|
|
165
|
+
Promise.resolve({
|
|
166
|
+
status: "unavailable",
|
|
167
|
+
reason: "💥".repeat(1_000),
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
const preparation = await fixture.root.tools.prepare(
|
|
171
|
+
fixture.call,
|
|
172
|
+
fixture.context,
|
|
173
|
+
);
|
|
174
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
175
|
+
|
|
176
|
+
const outcome = await fixture.root.tools.reconcilePrepared(
|
|
177
|
+
preparation,
|
|
178
|
+
fixture.context,
|
|
179
|
+
);
|
|
180
|
+
expect(outcome.status).toBe("unavailable");
|
|
181
|
+
if (outcome.status !== "unavailable") return;
|
|
182
|
+
expect(new TextEncoder().encode(outcome.reason).byteLength).toBe(512);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("returns unavailable when a non-idempotent definition has no retrieval seam", async () => {
|
|
186
|
+
const fixture = await registryFixture({
|
|
187
|
+
name: "opaque",
|
|
188
|
+
description: "Opaque fixture.",
|
|
189
|
+
inputSchema: { type: "object" },
|
|
190
|
+
execute: () => Promise.resolve({ content: "effect", isError: false }),
|
|
191
|
+
});
|
|
192
|
+
const preparation = await fixture.root.tools.prepare(
|
|
193
|
+
fixture.call,
|
|
194
|
+
fixture.context,
|
|
195
|
+
);
|
|
196
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
197
|
+
|
|
198
|
+
expect(
|
|
199
|
+
await fixture.root.tools.reconcilePrepared(preparation, fixture.context),
|
|
200
|
+
).toEqual({
|
|
201
|
+
status: "unavailable",
|
|
202
|
+
reason: "Tool opaque does not support effect reconciliation",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("ToolRegistry turn admission", () => {
|
|
208
|
+
async function admissionRoot(): Promise<Context> {
|
|
209
|
+
const root = new Context();
|
|
210
|
+
roots.push(root);
|
|
211
|
+
await root.plugin(ToolRegistry);
|
|
212
|
+
return root;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const work: ToolDefinition = {
|
|
216
|
+
name: "work",
|
|
217
|
+
description: "A work tool.",
|
|
218
|
+
inputSchema: { type: "object" },
|
|
219
|
+
execute: () => Promise.resolve({ content: "worked", isError: false }),
|
|
220
|
+
};
|
|
221
|
+
const chatOnly: ToolDefinition = {
|
|
222
|
+
name: "send_to_user",
|
|
223
|
+
description: "The voice to the User.",
|
|
224
|
+
inputSchema: { type: "object" },
|
|
225
|
+
admission: { turnTypes: ["chat"] },
|
|
226
|
+
execute: () => Promise.resolve({ content: "sent", isError: false }),
|
|
227
|
+
};
|
|
228
|
+
const automationOnly: ToolDefinition = {
|
|
229
|
+
name: "wake_parent",
|
|
230
|
+
description: "Hands off to the parent conversation.",
|
|
231
|
+
inputSchema: { type: "object" },
|
|
232
|
+
admission: { turnTypes: ["automation", "subagent"] },
|
|
233
|
+
execute: () => Promise.resolve({ content: "woke", isError: false }),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function contextFor(
|
|
237
|
+
name: string,
|
|
238
|
+
turnType: ToolExecutionContext["turnType"],
|
|
239
|
+
): ToolExecutionContext {
|
|
240
|
+
return {
|
|
241
|
+
botId: "primary",
|
|
242
|
+
agentId: "primary",
|
|
243
|
+
sessionId: "alice:primary",
|
|
244
|
+
compositionGenerationId: "test-composition-generation",
|
|
245
|
+
effectId: "tool:1:1:0",
|
|
246
|
+
toolCall: { id: "provider-call", name, input: {} },
|
|
247
|
+
turnType,
|
|
248
|
+
signal: new AbortController().signal,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
test("offers a tool with no declaration on every turn type", async () => {
|
|
253
|
+
const root = await admissionRoot();
|
|
254
|
+
root.tools.register(work);
|
|
255
|
+
for (const turnType of ["chat", "automation", "subagent"] as const) {
|
|
256
|
+
expect(root.tools.schemas({ turnType }).map((s) => s.name)).toEqual([
|
|
257
|
+
"work",
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("trims the catalog to what the turn type admits", async () => {
|
|
263
|
+
const root = await admissionRoot();
|
|
264
|
+
root.tools.register(work);
|
|
265
|
+
root.tools.register(chatOnly);
|
|
266
|
+
root.tools.register(automationOnly);
|
|
267
|
+
|
|
268
|
+
expect(root.tools.schemas({ turnType: "chat" }).map((s) => s.name)).toEqual(
|
|
269
|
+
["work", "send_to_user"],
|
|
270
|
+
);
|
|
271
|
+
expect(
|
|
272
|
+
root.tools.schemas({ turnType: "automation" }).map((s) => s.name),
|
|
273
|
+
).toEqual(["work", "wake_parent"]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("bounds a tool declaration by the manifest ceiling", async () => {
|
|
277
|
+
const root = await admissionRoot();
|
|
278
|
+
root.tools.register(work, { admissionCeiling: ["automation"] });
|
|
279
|
+
root.tools.register(chatOnly, {
|
|
280
|
+
admissionCeiling: ["automation", "subagent"],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(root.tools.schemas({ turnType: "chat" })).toEqual([]);
|
|
284
|
+
expect(
|
|
285
|
+
root.tools.schemas({ turnType: "automation" }).map((s) => s.name),
|
|
286
|
+
).toEqual(["work"]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("denies an out-of-admission call without executing it", async () => {
|
|
290
|
+
let executions = 0;
|
|
291
|
+
const root = await admissionRoot();
|
|
292
|
+
root.tools.register({
|
|
293
|
+
...chatOnly,
|
|
294
|
+
execute: () => {
|
|
295
|
+
executions += 1;
|
|
296
|
+
return Promise.resolve({ content: "sent", isError: false });
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const denied = await root.tools.prepare(
|
|
301
|
+
{ id: "provider-call", name: "send_to_user", input: {} },
|
|
302
|
+
contextFor("send_to_user", "automation"),
|
|
303
|
+
);
|
|
304
|
+
expect(denied).toMatchObject({
|
|
305
|
+
kind: "denied",
|
|
306
|
+
result: { isError: true },
|
|
307
|
+
});
|
|
308
|
+
if (denied.kind !== "denied") throw new Error("expected a denial");
|
|
309
|
+
expect(denied.result.content).toContain("send_to_user");
|
|
310
|
+
expect(executions).toBe(0);
|
|
311
|
+
|
|
312
|
+
const ready = await root.tools.prepare(
|
|
313
|
+
{ id: "provider-call", name: "send_to_user", input: {} },
|
|
314
|
+
contextFor("send_to_user", "chat"),
|
|
315
|
+
);
|
|
316
|
+
expect(ready.kind).toBe("ready");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("denies a call the manifest ceiling excludes even when the tool allows it", async () => {
|
|
320
|
+
const root = await admissionRoot();
|
|
321
|
+
root.tools.register(chatOnly, { admissionCeiling: ["automation"] });
|
|
322
|
+
const denied = await root.tools.prepare(
|
|
323
|
+
{ id: "provider-call", name: "send_to_user", input: {} },
|
|
324
|
+
contextFor("send_to_user", "chat"),
|
|
325
|
+
);
|
|
326
|
+
expect(denied.kind).toBe("denied");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("carries endsTurn through execution and reconciliation", async () => {
|
|
330
|
+
const root = await admissionRoot();
|
|
331
|
+
root.tools.register({
|
|
332
|
+
name: "hand_off",
|
|
333
|
+
description: "Ends the Turn.",
|
|
334
|
+
inputSchema: { type: "object" },
|
|
335
|
+
execute: () =>
|
|
336
|
+
Promise.resolve({
|
|
337
|
+
content: "handed off",
|
|
338
|
+
isError: false,
|
|
339
|
+
endsTurn: true,
|
|
340
|
+
}),
|
|
341
|
+
reconcile: () =>
|
|
342
|
+
Promise.resolve({
|
|
343
|
+
status: "recovered" as const,
|
|
344
|
+
result: { content: "handed off", isError: false, endsTurn: true },
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
const context = contextFor("hand_off", "automation");
|
|
348
|
+
const preparation = await root.tools.prepare(
|
|
349
|
+
{ id: "provider-call", name: "hand_off", input: {} },
|
|
350
|
+
context,
|
|
351
|
+
);
|
|
352
|
+
if (preparation.kind !== "ready") throw new Error("tool was denied");
|
|
353
|
+
expect(await root.tools.executePrepared(preparation, context)).toEqual({
|
|
354
|
+
content: "handed off",
|
|
355
|
+
isError: false,
|
|
356
|
+
endsTurn: true,
|
|
357
|
+
});
|
|
358
|
+
expect(await root.tools.reconcilePrepared(preparation, context)).toEqual({
|
|
359
|
+
status: "recovered",
|
|
360
|
+
result: { content: "handed off", isError: false, endsTurn: true },
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
// The second ceiling dimension: the subagent role (ADR 0017, slice G3).
|
|
365
|
+
//
|
|
366
|
+
// The registry treats a role exactly as it treats a turn type — an opaque
|
|
367
|
+
// string a registration may narrow itself by. It reads no meaning into
|
|
368
|
+
// "browserUse"; it only intersects declaration with manifest ceiling and
|
|
369
|
+
// filters.
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const desktop: ToolDefinition = {
|
|
373
|
+
name: "computer_exec",
|
|
374
|
+
description: "Runs a shell command on the Computer.",
|
|
375
|
+
inputSchema: { type: "object" },
|
|
376
|
+
admission: {
|
|
377
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
378
|
+
subagentRoles: ["executor", "computerUse"],
|
|
379
|
+
},
|
|
380
|
+
execute: () => Promise.resolve({ content: "ran", isError: false }),
|
|
381
|
+
};
|
|
382
|
+
const browser: ToolDefinition = {
|
|
383
|
+
name: "computer_browser",
|
|
384
|
+
description: "Drives the browser on the Computer.",
|
|
385
|
+
inputSchema: { type: "object" },
|
|
386
|
+
admission: {
|
|
387
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
388
|
+
subagentRoles: ["executor", "browserUse", "computerUse"],
|
|
389
|
+
},
|
|
390
|
+
execute: () => Promise.resolve({ content: "snapshot", isError: false }),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
test("a turn that names no role is narrowed by no role", async () => {
|
|
394
|
+
const root = await admissionRoot();
|
|
395
|
+
root.tools.register(work);
|
|
396
|
+
root.tools.register(desktop);
|
|
397
|
+
expect(root.tools.schemas({ turnType: "chat" }).map((s) => s.name)).toEqual(
|
|
398
|
+
["work", "computer_exec"],
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("trims the catalog to what the subagent role admits", async () => {
|
|
403
|
+
const root = await admissionRoot();
|
|
404
|
+
root.tools.register(work);
|
|
405
|
+
root.tools.register(desktop);
|
|
406
|
+
root.tools.register(browser);
|
|
407
|
+
|
|
408
|
+
expect(
|
|
409
|
+
root.tools
|
|
410
|
+
.schemas({ turnType: "subagent", subagentRole: "browserUse" })
|
|
411
|
+
.map((s) => s.name),
|
|
412
|
+
).toEqual(["work", "computer_browser"]);
|
|
413
|
+
expect(
|
|
414
|
+
root.tools
|
|
415
|
+
.schemas({ turnType: "subagent", subagentRole: "computerUse" })
|
|
416
|
+
.map((s) => s.name),
|
|
417
|
+
).toEqual(["work", "computer_exec", "computer_browser"]);
|
|
418
|
+
expect(
|
|
419
|
+
root.tools
|
|
420
|
+
.schemas({ turnType: "subagent", subagentRole: "watchVideo" })
|
|
421
|
+
.map((s) => s.name),
|
|
422
|
+
).toEqual(["work"]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("bounds a role declaration by the manifest role ceiling", async () => {
|
|
426
|
+
const root = await admissionRoot();
|
|
427
|
+
root.tools.register(browser, { subagentRoleCeiling: ["executor"] });
|
|
428
|
+
expect(
|
|
429
|
+
root.tools
|
|
430
|
+
.schemas({ turnType: "subagent", subagentRole: "browserUse" })
|
|
431
|
+
.map((s) => s.name),
|
|
432
|
+
).toEqual([]);
|
|
433
|
+
expect(
|
|
434
|
+
root.tools
|
|
435
|
+
.schemas({ turnType: "subagent", subagentRole: "executor" })
|
|
436
|
+
.map((s) => s.name),
|
|
437
|
+
).toEqual(["computer_browser"]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("denies a call a role was never offered, without executing it", async () => {
|
|
441
|
+
let executions = 0;
|
|
442
|
+
const root = await admissionRoot();
|
|
443
|
+
root.tools.register({
|
|
444
|
+
...desktop,
|
|
445
|
+
execute: () => {
|
|
446
|
+
executions += 1;
|
|
447
|
+
return Promise.resolve({ content: "ran", isError: false });
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
const preparation = await root.tools.prepare(
|
|
451
|
+
{ id: "provider-call", name: "computer_exec", input: {} },
|
|
452
|
+
{
|
|
453
|
+
...contextFor("computer_exec", "subagent"),
|
|
454
|
+
subagentRole: "browserUse",
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
expect(preparation.kind).toBe("denied");
|
|
458
|
+
if (preparation.kind !== "denied") throw new Error("expected a denial");
|
|
459
|
+
expect(preparation.result.isError).toBe(true);
|
|
460
|
+
expect(preparation.result.content).toContain("browserUse");
|
|
461
|
+
expect(executions).toBe(0);
|
|
462
|
+
});
|
|
463
|
+
});
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { type Context, Service } from "cordis";
|
|
2
|
+
import {
|
|
3
|
+
admittedSubagentRolesV1,
|
|
4
|
+
admittedTurnTypesV1,
|
|
5
|
+
isSubagentRoleAdmittedV1,
|
|
6
|
+
type ToolCall,
|
|
7
|
+
type ToolDefinition,
|
|
8
|
+
type ToolEffectReconciliation,
|
|
9
|
+
type ToolExecution,
|
|
10
|
+
type ToolExecutionContext,
|
|
11
|
+
type ToolExecutionResult,
|
|
12
|
+
type ToolPreparation,
|
|
13
|
+
type ToolRegistrationOptions,
|
|
14
|
+
type ToolSchema,
|
|
15
|
+
type TurnTypeV1,
|
|
16
|
+
} from "@frockbot/kernel-contracts";
|
|
17
|
+
|
|
18
|
+
function sameToolCall(left: ToolCall, right: ToolCall): boolean {
|
|
19
|
+
return (
|
|
20
|
+
left.id === right.id &&
|
|
21
|
+
left.name === right.name &&
|
|
22
|
+
JSON.stringify(left.input) === JSON.stringify(right.input)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One registration: the tool, and the turn types it may ever be offered on. */
|
|
27
|
+
interface RegisteredTool {
|
|
28
|
+
definition: ToolDefinition;
|
|
29
|
+
/**
|
|
30
|
+
* The tool's own declaration intersected with its Capability's durable
|
|
31
|
+
* manifest ceiling, resolved once at registration so admission cannot drift
|
|
32
|
+
* between the catalog the model saw and the call the loop admits.
|
|
33
|
+
*/
|
|
34
|
+
admitted: readonly TurnTypeV1[];
|
|
35
|
+
/**
|
|
36
|
+
* The second ceiling dimension, resolved the same way and at the same
|
|
37
|
+
* moment: the subagent roles this tool may be offered to. `undefined` is
|
|
38
|
+
* every role — a tool that declares nothing is narrowed by nothing.
|
|
39
|
+
*/
|
|
40
|
+
admittedRoles: readonly string[] | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ToolRegistry extends Service implements ToolExecution {
|
|
44
|
+
private definitions = new Map<string, RegisteredTool>();
|
|
45
|
+
|
|
46
|
+
constructor(ctx: Context) {
|
|
47
|
+
super(ctx, "tools");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
register(
|
|
51
|
+
definition: ToolDefinition,
|
|
52
|
+
options?: ToolRegistrationOptions,
|
|
53
|
+
): () => void {
|
|
54
|
+
if (this.definitions.has(definition.name)) {
|
|
55
|
+
throw new Error(`tool "${definition.name}" is already registered`);
|
|
56
|
+
}
|
|
57
|
+
const registered: RegisteredTool = {
|
|
58
|
+
definition,
|
|
59
|
+
admitted: admittedTurnTypesV1(
|
|
60
|
+
definition.admission?.turnTypes,
|
|
61
|
+
options?.admissionCeiling,
|
|
62
|
+
),
|
|
63
|
+
admittedRoles: admittedSubagentRolesV1(
|
|
64
|
+
definition.admission?.subagentRoles,
|
|
65
|
+
options?.subagentRoleCeiling,
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
this.definitions.set(definition.name, registered);
|
|
69
|
+
return () => {
|
|
70
|
+
if (this.definitions.get(definition.name) === registered) {
|
|
71
|
+
this.definitions.delete(definition.name);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
schemas(admission: {
|
|
77
|
+
turnType: TurnTypeV1;
|
|
78
|
+
subagentRole?: string;
|
|
79
|
+
}): ToolSchema[] {
|
|
80
|
+
return [...this.definitions.values()]
|
|
81
|
+
.filter(
|
|
82
|
+
(registered) =>
|
|
83
|
+
registered.admitted.includes(admission.turnType) &&
|
|
84
|
+
isSubagentRoleAdmittedV1(
|
|
85
|
+
registered.admittedRoles,
|
|
86
|
+
admission.subagentRole,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
.map(({ definition: { name, description, inputSchema } }) => ({
|
|
90
|
+
name,
|
|
91
|
+
description,
|
|
92
|
+
inputSchema,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
prepare(
|
|
97
|
+
call: ToolCall,
|
|
98
|
+
context: ToolExecutionContext,
|
|
99
|
+
): 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)) {
|
|
141
|
+
return {
|
|
142
|
+
kind: "denied",
|
|
143
|
+
call,
|
|
144
|
+
result: {
|
|
145
|
+
content: `Invalid input for tool: ${call.name}`,
|
|
146
|
+
isError: true,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kind: "ready",
|
|
152
|
+
call,
|
|
153
|
+
idempotent: definition.idempotent ?? false,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async executePrepared(
|
|
159
|
+
preparation: Extract<ToolPreparation, { kind: "ready" }>,
|
|
160
|
+
context: ToolExecutionContext,
|
|
161
|
+
): Promise<ToolExecutionResult> {
|
|
162
|
+
const definition = this.definitions.get(preparation.call.name)?.definition;
|
|
163
|
+
const initial = await this.ctx.waterfall(
|
|
164
|
+
"tools/execute",
|
|
165
|
+
preparation.call,
|
|
166
|
+
context,
|
|
167
|
+
() => {
|
|
168
|
+
if (!definition) {
|
|
169
|
+
return Promise.resolve({
|
|
170
|
+
content: `Tool became unavailable: ${preparation.call.name}`,
|
|
171
|
+
isError: true,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return definition.execute(preparation.call.input, context);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
const result = await this.ctx.waterfall(
|
|
178
|
+
"tools/post-execute",
|
|
179
|
+
preparation.call,
|
|
180
|
+
initial,
|
|
181
|
+
context,
|
|
182
|
+
() => Promise.resolve(initial),
|
|
183
|
+
);
|
|
184
|
+
this.ctx.emit("tools/result", preparation.call, result);
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Settles one durably open effect without exposing provider selection to the
|
|
190
|
+
* Agent loop. Idempotent definitions retry execution with the same effectId;
|
|
191
|
+
* other definitions must retrieve their original result.
|
|
192
|
+
*/
|
|
193
|
+
async reconcilePrepared(
|
|
194
|
+
preparation: Extract<ToolPreparation, { kind: "ready" }>,
|
|
195
|
+
context: ToolExecutionContext,
|
|
196
|
+
): Promise<ToolEffectReconciliation> {
|
|
197
|
+
const expectedCall = context.toolCall;
|
|
198
|
+
if (!expectedCall) {
|
|
199
|
+
return {
|
|
200
|
+
status: "unavailable",
|
|
201
|
+
reason: boundedReconciliationReason(
|
|
202
|
+
`Tool ${preparation.call.name} has no durable call identity for effect reconciliation`,
|
|
203
|
+
preparation.call.name,
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!sameToolCall(preparation.call, expectedCall)) {
|
|
208
|
+
return {
|
|
209
|
+
status: "unavailable",
|
|
210
|
+
reason: boundedReconciliationReason(
|
|
211
|
+
`Prepared tool ${preparation.call.name} does not match durable effect ${expectedCall.name}`,
|
|
212
|
+
expectedCall.name,
|
|
213
|
+
),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const definition = this.definitions.get(expectedCall.name)?.definition;
|
|
217
|
+
if (!definition) {
|
|
218
|
+
return {
|
|
219
|
+
status: "unavailable",
|
|
220
|
+
reason: boundedReconciliationReason(
|
|
221
|
+
`Tool ${expectedCall.name} is unavailable for effect reconciliation`,
|
|
222
|
+
expectedCall.name,
|
|
223
|
+
),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// Preparation is middleware-visible and therefore cannot be the authority
|
|
227
|
+
// for retry safety. Only the registered definition may declare an effect
|
|
228
|
+
// idempotent.
|
|
229
|
+
if (definition.idempotent === true) {
|
|
230
|
+
try {
|
|
231
|
+
return {
|
|
232
|
+
status: "recovered",
|
|
233
|
+
result: await this.executePrepared(
|
|
234
|
+
{ ...preparation, call: expectedCall, idempotent: true },
|
|
235
|
+
context,
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return {
|
|
240
|
+
status: "unavailable",
|
|
241
|
+
reason: boundedReconciliationReason(error, expectedCall.name),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!definition.reconcile) {
|
|
246
|
+
return {
|
|
247
|
+
status: "unavailable",
|
|
248
|
+
reason: boundedReconciliationReason(
|
|
249
|
+
`Tool ${expectedCall.name} does not support effect reconciliation`,
|
|
250
|
+
expectedCall.name,
|
|
251
|
+
),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const outcome = normalizedReconciliation(
|
|
256
|
+
await definition.reconcile(expectedCall.input, context),
|
|
257
|
+
expectedCall.name,
|
|
258
|
+
);
|
|
259
|
+
if (outcome.status === "recovered") {
|
|
260
|
+
this.ctx.emit("tools/result", expectedCall, outcome.result);
|
|
261
|
+
}
|
|
262
|
+
return outcome;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
status: "unavailable",
|
|
266
|
+
reason: boundedReconciliationReason(error, expectedCall.name),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const TOOL_RECONCILIATION_REASON_MAX_BYTES = 512;
|
|
273
|
+
const RECONCILIATION_REASON_ENCODER = new TextEncoder();
|
|
274
|
+
|
|
275
|
+
function ownStringKeys(
|
|
276
|
+
value: Record<PropertyKey, unknown>,
|
|
277
|
+
): string[] | undefined {
|
|
278
|
+
const keys = Reflect.ownKeys(value);
|
|
279
|
+
return keys.every((key): key is string => typeof key === "string")
|
|
280
|
+
? keys.sort()
|
|
281
|
+
: undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function hasExactKeys(
|
|
285
|
+
value: Record<PropertyKey, unknown>,
|
|
286
|
+
expected: readonly string[],
|
|
287
|
+
): boolean {
|
|
288
|
+
const keys = ownStringKeys(value);
|
|
289
|
+
const sortedExpected = [...expected].sort();
|
|
290
|
+
return (
|
|
291
|
+
keys !== undefined &&
|
|
292
|
+
keys.length === sortedExpected.length &&
|
|
293
|
+
keys.every((key, index) => key === sortedExpected[index])
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizedReconciliation(
|
|
298
|
+
input: unknown,
|
|
299
|
+
toolName: string,
|
|
300
|
+
): ToolEffectReconciliation {
|
|
301
|
+
if (typeof input !== "object" || input === null) {
|
|
302
|
+
return invalidReconciliation(toolName);
|
|
303
|
+
}
|
|
304
|
+
const record = input as Record<PropertyKey, unknown>;
|
|
305
|
+
if (
|
|
306
|
+
hasExactKeys(record, ["result", "status"]) &&
|
|
307
|
+
record.status === "recovered"
|
|
308
|
+
) {
|
|
309
|
+
const result = record.result;
|
|
310
|
+
if (
|
|
311
|
+
typeof result === "object" &&
|
|
312
|
+
result !== null &&
|
|
313
|
+
(hasExactKeys(result as Record<PropertyKey, unknown>, [
|
|
314
|
+
"content",
|
|
315
|
+
"isError",
|
|
316
|
+
]) ||
|
|
317
|
+
hasExactKeys(result as Record<PropertyKey, unknown>, [
|
|
318
|
+
"content",
|
|
319
|
+
"isError",
|
|
320
|
+
"endsTurn",
|
|
321
|
+
]))
|
|
322
|
+
) {
|
|
323
|
+
const resultRecord = result as Record<PropertyKey, unknown>;
|
|
324
|
+
if (
|
|
325
|
+
typeof resultRecord.content === "string" &&
|
|
326
|
+
typeof resultRecord.isError === "boolean" &&
|
|
327
|
+
(resultRecord.endsTurn === undefined ||
|
|
328
|
+
typeof resultRecord.endsTurn === "boolean")
|
|
329
|
+
) {
|
|
330
|
+
return {
|
|
331
|
+
status: "recovered",
|
|
332
|
+
result: {
|
|
333
|
+
content: resultRecord.content,
|
|
334
|
+
isError: resultRecord.isError,
|
|
335
|
+
// A recovered hand-off still ends the Turn it was recorded on.
|
|
336
|
+
...(resultRecord.endsTurn === undefined
|
|
337
|
+
? {}
|
|
338
|
+
: { endsTurn: resultRecord.endsTurn }),
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
hasExactKeys(record, ["reason", "status"]) &&
|
|
346
|
+
record.status === "unavailable" &&
|
|
347
|
+
typeof record.reason === "string"
|
|
348
|
+
) {
|
|
349
|
+
return {
|
|
350
|
+
status: "unavailable",
|
|
351
|
+
reason: boundedReconciliationReason(record.reason, toolName),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return invalidReconciliation(toolName);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function invalidReconciliation(toolName: string): ToolEffectReconciliation {
|
|
358
|
+
return {
|
|
359
|
+
status: "unavailable",
|
|
360
|
+
reason: boundedReconciliationReason(
|
|
361
|
+
`Tool ${toolName} returned an invalid reconciliation outcome`,
|
|
362
|
+
toolName,
|
|
363
|
+
),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function boundedReconciliationReason(error: unknown, toolName: string): string {
|
|
368
|
+
const reason =
|
|
369
|
+
typeof error === "string"
|
|
370
|
+
? error
|
|
371
|
+
: error instanceof Error
|
|
372
|
+
? error.message
|
|
373
|
+
: "Tool effect is not currently retrievable";
|
|
374
|
+
const normalized = reason.trim() || `Tool ${toolName} effect is unavailable`;
|
|
375
|
+
let bounded = "";
|
|
376
|
+
let bytes = 0;
|
|
377
|
+
for (const character of normalized) {
|
|
378
|
+
const characterBytes =
|
|
379
|
+
RECONCILIATION_REASON_ENCODER.encode(character).byteLength;
|
|
380
|
+
if (bytes + characterBytes > TOOL_RECONCILIATION_REASON_MAX_BYTES) break;
|
|
381
|
+
bounded += character;
|
|
382
|
+
bytes += characterBytes;
|
|
383
|
+
}
|
|
384
|
+
return bounded || `Tool ${toolName} effect is unavailable`;
|
|
385
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED