@frockbot/plugin-computer 0.3.16 → 0.3.17
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 +12 -12
- package/src/agent.test.ts +72 -0
- package/src/agent.ts +80 -14
- package/src/sync.test.ts +35 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-computer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,21 +29,21 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@cordisjs/client": "0.8.2",
|
|
32
|
-
"@frockbot/client-core": "0.3.
|
|
33
|
-
"@frockbot/client-ui": "0.3.
|
|
34
|
-
"@frockbot/computer-core": "0.3.
|
|
35
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
36
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
37
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
38
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
39
|
-
"@frockbot/plugin-shell": "0.3.
|
|
40
|
-
"@frockbot/plugin-tools": "0.3.
|
|
32
|
+
"@frockbot/client-core": "0.3.17",
|
|
33
|
+
"@frockbot/client-ui": "0.3.17",
|
|
34
|
+
"@frockbot/computer-core": "0.3.17",
|
|
35
|
+
"@frockbot/computer-host-runtime": "0.3.17",
|
|
36
|
+
"@frockbot/kernel-agent-loop": "0.3.17",
|
|
37
|
+
"@frockbot/kernel-contracts": "0.3.17",
|
|
38
|
+
"@frockbot/plugin-prompt": "0.3.17",
|
|
39
|
+
"@frockbot/plugin-shell": "0.3.17",
|
|
40
|
+
"@frockbot/plugin-tools": "0.3.17",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"vue": "3.5.41"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@frockbot/plugin-models": "0.3.
|
|
46
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
45
|
+
"@frockbot/plugin-models": "0.3.17",
|
|
46
|
+
"@frockbot/plugin-testkit": "0.3.17",
|
|
47
47
|
"@types/bun": "1.4.0",
|
|
48
48
|
"@types/node": "26.2.0",
|
|
49
49
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.test.ts
CHANGED
|
@@ -108,6 +108,78 @@ describe("computer agent contribution", () => {
|
|
|
108
108
|
await harness.dispose();
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("computer_browser says which field a click is missing and takes label as name", async () => {
|
|
112
|
+
const calls: string[] = [];
|
|
113
|
+
const provider: ComputerProvider = {
|
|
114
|
+
id: "fixture",
|
|
115
|
+
open: async (identity, tenant, assignment) => ({
|
|
116
|
+
assignment,
|
|
117
|
+
identity,
|
|
118
|
+
tenant,
|
|
119
|
+
exec: {
|
|
120
|
+
execute: async () => ({
|
|
121
|
+
exitCode: 0,
|
|
122
|
+
stdout: new Uint8Array(),
|
|
123
|
+
stderr: new Uint8Array(),
|
|
124
|
+
outputTruncated: false,
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
browser: {
|
|
128
|
+
perform: async (action) => {
|
|
129
|
+
calls.push(JSON.stringify(action));
|
|
130
|
+
return { accessibilitySnapshot: 'checkbox "Mark done"' };
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
close: () => Promise.resolve(),
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
const harness = await createPluginHarness([
|
|
137
|
+
ComputerRegistry,
|
|
138
|
+
ToolRegistry,
|
|
139
|
+
SystemPromptRegistry,
|
|
140
|
+
SessionStore,
|
|
141
|
+
]);
|
|
142
|
+
harness.root.computers.register(provider);
|
|
143
|
+
await harness.mount(
|
|
144
|
+
createComputerAgentPlugin({
|
|
145
|
+
userId: "user-1",
|
|
146
|
+
defaultProviderId: "fixture",
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Bob's first three attempts on production, in order.
|
|
151
|
+
const onlyName = await execute(harness, "computer_browser", {
|
|
152
|
+
action: "click",
|
|
153
|
+
name: "Add",
|
|
154
|
+
});
|
|
155
|
+
expect(onlyName.isError).toBe(true);
|
|
156
|
+
expect(onlyName.content).toContain("role and name are both required");
|
|
157
|
+
expect(onlyName.content).toContain('"role":"button"');
|
|
158
|
+
|
|
159
|
+
const onlyLabel = await execute(harness, "computer_browser", {
|
|
160
|
+
action: "click",
|
|
161
|
+
label: "Add",
|
|
162
|
+
});
|
|
163
|
+
expect(onlyLabel.isError).toBe(true);
|
|
164
|
+
|
|
165
|
+
const labelWithRole = await execute(harness, "computer_browser", {
|
|
166
|
+
action: "click",
|
|
167
|
+
label: "Mark done",
|
|
168
|
+
role: "checkbox",
|
|
169
|
+
});
|
|
170
|
+
expect(labelWithRole.isError).toBe(false);
|
|
171
|
+
expect(calls).toEqual([
|
|
172
|
+
JSON.stringify({ type: "click", role: "checkbox", name: "Mark done" }),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const unknown = await execute(harness, "computer_browser", {
|
|
176
|
+
action: "hover",
|
|
177
|
+
});
|
|
178
|
+
expect(unknown.isError).toBe(true);
|
|
179
|
+
expect(unknown.content).toContain('"action" must be one of');
|
|
180
|
+
await harness.dispose();
|
|
181
|
+
});
|
|
182
|
+
|
|
111
183
|
test("computer_exec during an update returns an actionable tool failure", async () => {
|
|
112
184
|
const provider: ComputerProvider = {
|
|
113
185
|
id: "fixture",
|
package/src/agent.ts
CHANGED
|
@@ -256,6 +256,46 @@ function decodeProcessId(input: unknown): string | undefined {
|
|
|
256
256
|
return isComputerProcessIdV1(value) ? value : undefined;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* What each `computer_browser` action needs, in the words the refusal uses.
|
|
261
|
+
*
|
|
262
|
+
* Bob on production (2026-09-04) clicked a button with `{name}`, then
|
|
263
|
+
* `{label}`, then `{label, role}` before landing on `{role, name}` — three
|
|
264
|
+
* wasted steps per click, because the loop's generic "Invalid input for tool"
|
|
265
|
+
* names no field. The snapshot lists elements as `button "Add"` and
|
|
266
|
+
* `checkbox "Mark done"`, so a click takes that role and that accessible name;
|
|
267
|
+
* `label` is accepted as a synonym for `name` (and `name` for `label` on
|
|
268
|
+
* `fill`) since the model reaches for both.
|
|
269
|
+
*/
|
|
270
|
+
export const BROWSER_ACTION_SHAPES_V1: Readonly<Record<string, string>> = {
|
|
271
|
+
snapshot: '{"action":"snapshot"}',
|
|
272
|
+
navigate: '{"action":"navigate","url":"http://127.0.0.1:8944/"}',
|
|
273
|
+
click:
|
|
274
|
+
'{"action":"click","role":"button","name":"Add"} — role and name are both required; take them from the snapshot line (button "Add" → role "button", name "Add"; a checkbox line → role "checkbox")',
|
|
275
|
+
fill: '{"action":"fill","label":"New todo","text":"Buy milk"} — label is the field\'s accessible label from the snapshot',
|
|
276
|
+
press: '{"action":"press","key":"Enter"}',
|
|
277
|
+
wait: '{"action":"wait","milliseconds":500} (0 to 30000)',
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Why a `computer_browser` input could not be used, and what to send. */
|
|
281
|
+
export function browserInputRefusalV1(input: unknown): string {
|
|
282
|
+
const value = record(input);
|
|
283
|
+
const action = typeof value?.action === "string" ? value.action : undefined;
|
|
284
|
+
const shape = action ? BROWSER_ACTION_SHAPES_V1[action] : undefined;
|
|
285
|
+
if (!shape) {
|
|
286
|
+
return `computer_browser input is invalid: "action" must be one of ${Object.keys(
|
|
287
|
+
BROWSER_ACTION_SHAPES_V1,
|
|
288
|
+
)
|
|
289
|
+
.map((name) => `"${name}"`)
|
|
290
|
+
.join(", ")}. For example ${BROWSER_ACTION_SHAPES_V1.click}.`;
|
|
291
|
+
}
|
|
292
|
+
return `computer_browser input is invalid for "${action}". Expected ${shape}.`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function optionalString(value: unknown): string | undefined {
|
|
296
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
259
299
|
function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
|
|
260
300
|
const value = record(input);
|
|
261
301
|
switch (value?.action) {
|
|
@@ -265,24 +305,28 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
|
|
|
265
305
|
return typeof value.url === "string" && value.url
|
|
266
306
|
? { type: "navigate", url: value.url }
|
|
267
307
|
: undefined;
|
|
268
|
-
case "click":
|
|
269
|
-
|
|
308
|
+
case "click": {
|
|
309
|
+
const name = optionalString(value.name) ?? optionalString(value.label);
|
|
310
|
+
return typeof value.role === "string" && name !== undefined
|
|
270
311
|
? {
|
|
271
312
|
type: "click",
|
|
272
313
|
role: value.role,
|
|
273
|
-
name
|
|
314
|
+
name,
|
|
274
315
|
exact: typeof value.exact === "boolean" ? value.exact : undefined,
|
|
275
316
|
}
|
|
276
317
|
: undefined;
|
|
277
|
-
|
|
278
|
-
|
|
318
|
+
}
|
|
319
|
+
case "fill": {
|
|
320
|
+
const label = optionalString(value.label) ?? optionalString(value.name);
|
|
321
|
+
return label !== undefined && typeof value.text === "string"
|
|
279
322
|
? {
|
|
280
323
|
type: "fill",
|
|
281
|
-
label
|
|
324
|
+
label,
|
|
282
325
|
text: value.text,
|
|
283
326
|
exact: typeof value.exact === "boolean" ? value.exact : undefined,
|
|
284
327
|
}
|
|
285
328
|
: undefined;
|
|
329
|
+
}
|
|
286
330
|
case "press":
|
|
287
331
|
return typeof value.key === "string"
|
|
288
332
|
? { type: "press", key: value.key }
|
|
@@ -394,9 +438,11 @@ export async function syncWorkspaceRootNowV1(request: {
|
|
|
394
438
|
sessionId: string;
|
|
395
439
|
turn: number;
|
|
396
440
|
root: WorkspaceRootV1;
|
|
441
|
+
requiredPaths?: readonly string[];
|
|
397
442
|
signal?: AbortSignal;
|
|
398
443
|
}): Promise<ComputerSyncSummaryV1> {
|
|
399
|
-
const { computer, sessions, sessionId, turn, root, signal } =
|
|
444
|
+
const { computer, sessions, sessionId, turn, root, requiredPaths, signal } =
|
|
445
|
+
request;
|
|
400
446
|
const sync = computer.sync;
|
|
401
447
|
let summary: ComputerSyncSummaryV1;
|
|
402
448
|
if (!sync?.reconcileRoot) {
|
|
@@ -409,7 +455,12 @@ export async function syncWorkspaceRootNowV1(request: {
|
|
|
409
455
|
summary = await sync.reconcileRoot(
|
|
410
456
|
root,
|
|
411
457
|
"publish",
|
|
412
|
-
signal
|
|
458
|
+
signal || requiredPaths
|
|
459
|
+
? {
|
|
460
|
+
...(signal ? { signal } : {}),
|
|
461
|
+
...(requiredPaths ? { requiredPaths } : {}),
|
|
462
|
+
}
|
|
463
|
+
: undefined,
|
|
413
464
|
);
|
|
414
465
|
} catch (error) {
|
|
415
466
|
summary = computerSyncSummaryV1(
|
|
@@ -1470,7 +1521,7 @@ export function createComputerAgentPlugin(
|
|
|
1470
1521
|
},
|
|
1471
1522
|
idempotent: config.idempotentEffects === true,
|
|
1472
1523
|
description:
|
|
1473
|
-
|
|
1524
|
+
'Control the browser in the Bot\'s selected Computer and return an accessibility snapshot. Shapes: {"action":"snapshot"}; {"action":"navigate","url":...}; {"action":"click","role":"button","name":"Add"} (role AND name, both from the snapshot line, e.g. checkbox "Mark done"); {"action":"fill","label":"New todo","text":...}; {"action":"press","key":"Enter"}; {"action":"wait","milliseconds":500}.',
|
|
1474
1525
|
inputSchema: {
|
|
1475
1526
|
type: "object",
|
|
1476
1527
|
properties: {
|
|
@@ -1479,9 +1530,20 @@ export function createComputerAgentPlugin(
|
|
|
1479
1530
|
enum: ["snapshot", "navigate", "click", "fill", "press", "wait"],
|
|
1480
1531
|
},
|
|
1481
1532
|
url: { type: "string" },
|
|
1482
|
-
role: {
|
|
1483
|
-
|
|
1484
|
-
|
|
1533
|
+
role: {
|
|
1534
|
+
type: "string",
|
|
1535
|
+
description:
|
|
1536
|
+
"click: the element's role from the snapshot (button, checkbox, link, textbox…)",
|
|
1537
|
+
},
|
|
1538
|
+
name: {
|
|
1539
|
+
type: "string",
|
|
1540
|
+
description:
|
|
1541
|
+
"click: the element's accessible name from the snapshot",
|
|
1542
|
+
},
|
|
1543
|
+
label: {
|
|
1544
|
+
type: "string",
|
|
1545
|
+
description: "fill: the field's accessible label from the snapshot",
|
|
1546
|
+
},
|
|
1485
1547
|
text: { type: "string" },
|
|
1486
1548
|
key: { type: "string" },
|
|
1487
1549
|
exact: { type: "boolean" },
|
|
@@ -1490,11 +1552,15 @@ export function createComputerAgentPlugin(
|
|
|
1490
1552
|
required: ["action"],
|
|
1491
1553
|
additionalProperties: false,
|
|
1492
1554
|
},
|
|
1493
|
-
|
|
1555
|
+
// Deliberately permissive: a wrong shape reaches `execute`, which says
|
|
1556
|
+
// which field is missing and shows the shape. A bare `false` here becomes
|
|
1557
|
+
// the loop's generic "Invalid input for tool", which cost Bob three
|
|
1558
|
+
// steps per click. Same reasoning as `skill_load`.
|
|
1559
|
+
validate: (input) => !!record(input),
|
|
1494
1560
|
execute: async (input, context) => {
|
|
1495
1561
|
const action = decodeBrowser(input);
|
|
1496
1562
|
if (!action)
|
|
1497
|
-
return { content:
|
|
1563
|
+
return { content: browserInputRefusalV1(input), isError: true };
|
|
1498
1564
|
try {
|
|
1499
1565
|
return await useComputer(
|
|
1500
1566
|
await open(context.botId, context.sessionId, context.signal),
|
package/src/sync.test.ts
CHANGED
|
@@ -254,6 +254,34 @@ describe("the Computer Package as the sync's caller", () => {
|
|
|
254
254
|
]);
|
|
255
255
|
expect(syncEvents(events)[0]?.detail).toContain("paused");
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
test("records every incomplete sync operation in one Turn", async () => {
|
|
259
|
+
const { provider, signal } = fixture(() => ({
|
|
260
|
+
...computerSyncSummaryV1(
|
|
261
|
+
"degraded",
|
|
262
|
+
"Excluded 1 reproducible Workspace item from sync.",
|
|
263
|
+
),
|
|
264
|
+
ignored: 1,
|
|
265
|
+
}));
|
|
266
|
+
const model = modelRunning(["first", "second"], (step) => {
|
|
267
|
+
if (step === 2) signal.value = "signal-2";
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const events = await runTurn(provider, model);
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
syncEvents(events).map((event) => ({
|
|
274
|
+
reason: event.reason,
|
|
275
|
+
status: event.status,
|
|
276
|
+
ignored: event.ignored,
|
|
277
|
+
omitted: event.omitted,
|
|
278
|
+
})),
|
|
279
|
+
).toEqual([
|
|
280
|
+
{ reason: "open", status: "degraded", ignored: 1, omitted: 0 },
|
|
281
|
+
{ reason: "signal", status: "degraded", ignored: 1, omitted: 0 },
|
|
282
|
+
{ reason: "turn-end", status: "degraded", ignored: 1, omitted: 0 },
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
257
285
|
});
|
|
258
286
|
|
|
259
287
|
/**
|
|
@@ -298,8 +326,9 @@ describe("syncWorkspaceRootNowV1", () => {
|
|
|
298
326
|
calls.push("reconcile");
|
|
299
327
|
return Promise.resolve(computerSyncSummaryV1("ok"));
|
|
300
328
|
},
|
|
301
|
-
reconcileRoot: (asked, reason) => {
|
|
329
|
+
reconcileRoot: (asked, reason, options) => {
|
|
302
330
|
calls.push(`reconcileRoot:${reason}:${asked.kind}`);
|
|
331
|
+
calls.push(`required:${options?.requiredPaths?.join(",") ?? ""}`);
|
|
303
332
|
return Promise.resolve({ ...computerSyncSummaryV1("ok"), pushed: 1 });
|
|
304
333
|
},
|
|
305
334
|
signal: () => Promise.resolve(undefined),
|
|
@@ -312,12 +341,16 @@ describe("syncWorkspaceRootNowV1", () => {
|
|
|
312
341
|
sessionId: "session-1",
|
|
313
342
|
turn: 3,
|
|
314
343
|
root: appletsRoot,
|
|
344
|
+
requiredPaths: ["todo/dist/server.js", "todo/dist/ui.html"],
|
|
315
345
|
});
|
|
316
346
|
|
|
317
347
|
expect(summary.status).toBe("ok");
|
|
318
348
|
// One root, never the whole Workspace: the Turn's own policy still owns
|
|
319
349
|
// `open`, `signal`, and `turn-end`, and this borrows none of them.
|
|
320
|
-
expect(calls).toEqual([
|
|
350
|
+
expect(calls).toEqual([
|
|
351
|
+
"reconcileRoot:publish:package-declared",
|
|
352
|
+
"required:todo/dist/server.js,todo/dist/ui.html",
|
|
353
|
+
]);
|
|
321
354
|
const recorded = syncEvents([...harness.session.events]);
|
|
322
355
|
expect(recorded).toHaveLength(1);
|
|
323
356
|
expect(recorded[0]).toMatchObject({
|