@frockbot/plugin-computer 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 +25 -0
- package/package.json +54 -6
- package/src/agent.test.ts +271 -0
- package/src/agent.ts +1419 -0
- package/src/backend.test.ts +149 -0
- package/src/backend.ts +163 -0
- package/src/bot.test.ts +411 -0
- package/src/bot.ts +831 -0
- package/src/client/ComputerCard.test.ts +96 -0
- package/src/client/ComputerCard.vue +60 -0
- package/src/client/ComputerStrip.test.ts +54 -0
- package/src/client/ComputerStrip.vue +55 -0
- package/src/client/ComputerViewerOverlay.vue +252 -0
- package/src/client/application.test.ts +403 -0
- package/src/client/application.ts +359 -0
- package/src/client/cordis-client-shim.d.ts +16 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.ts +28 -0
- package/src/client/state-machine.test.ts +200 -0
- package/src/client/state-machine.ts +172 -0
- package/src/client/styles.css +594 -0
- package/src/client/viewer.ts +58 -0
- package/src/control-record.ts +57 -0
- package/src/doctor.test.ts +247 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +3 -0
- package/src/process-records.test.ts +178 -0
- package/src/process-records.ts +278 -0
- package/src/process-store.ts +96 -0
- package/src/processes.test.ts +388 -0
- package/src/protocol.ts +405 -0
- package/src/roots.ts +6 -0
- package/src/screenshot.test.ts +253 -0
- package/src/shared-provider.test.ts +56 -0
- package/src/shared-provider.ts +121 -0
- package/src/shared.ts +54 -0
- package/src/sync.test.ts +255 -0
- package/src/workspace-fixture.ts +126 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
- package/README.md +0 -3
package/src/agent.ts
ADDED
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
// The Package that gives a Bot its Computer tools, and the caller of the
|
|
2
|
+
// durable-root sync (ADR 0013).
|
|
3
|
+
//
|
|
4
|
+
// "Bots invoke Computers only through the provider-neutral Computer
|
|
5
|
+
// interface", so the sync is reached here as `handle.sync` and never as a
|
|
6
|
+
// provider type: this Package does not know which Computer it is driving, and
|
|
7
|
+
// the reconciliation itself lives in the provider Package that does.
|
|
8
|
+
//
|
|
9
|
+
// WHEN THE SYNC RUNS. Three points, and no others:
|
|
10
|
+
//
|
|
11
|
+
// open before this Turn's first Computer tool call, so the Workspace
|
|
12
|
+
// the Bot is about to look at is the one object storage holds.
|
|
13
|
+
// signal before a later tool call in the same Turn, when the on-Computer
|
|
14
|
+
// watcher's change signal has moved.
|
|
15
|
+
// turn-end after a Turn that used the Computer, so a shell write on the
|
|
16
|
+
// Computer becomes a durable generation.
|
|
17
|
+
//
|
|
18
|
+
// It never runs to *reach* a Computer. Every one of those points is inside a
|
|
19
|
+
// Turn that already has the Computer open for this Bot: "The Agent loop,
|
|
20
|
+
// Memory, Skills, Package composition, and Routines function correctly while
|
|
21
|
+
// the Computer is hibernated and do not wake it. The Computer wakes only when
|
|
22
|
+
// a Bot uses it" — and while it sleeps the object-storage side is
|
|
23
|
+
// authoritative on its own.
|
|
24
|
+
//
|
|
25
|
+
// A sync that could not run is an outcome, not an error. "Connections to the
|
|
26
|
+
// Computer are expected to drop on every pause; every Computer client
|
|
27
|
+
// reconnects and resumes rather than treating a dropped connection as
|
|
28
|
+
// failure." Every run appends `computer/sync` to the session event log with
|
|
29
|
+
// what it moved, and nothing on this path can fail a Turn.
|
|
30
|
+
import {
|
|
31
|
+
type Session,
|
|
32
|
+
type SessionStore,
|
|
33
|
+
type ToolAttachmentV1,
|
|
34
|
+
type ToolDefinition,
|
|
35
|
+
type ToolExecutionContext,
|
|
36
|
+
type WorkspacePathV1,
|
|
37
|
+
type WorkspaceRootV1,
|
|
38
|
+
type WorkspaceWriterV1,
|
|
39
|
+
} from "@frockbot/kernel-contracts";
|
|
40
|
+
import {
|
|
41
|
+
computerBotPathKeyV1,
|
|
42
|
+
ComputerError,
|
|
43
|
+
type ComputerDoctorReportV1,
|
|
44
|
+
type ComputerBackgroundStateV1,
|
|
45
|
+
type ComputerBrowserAction,
|
|
46
|
+
type ComputerHandle,
|
|
47
|
+
computerSyncSummaryV1,
|
|
48
|
+
type ComputerSyncReasonV1,
|
|
49
|
+
type ComputerSyncSummaryV1,
|
|
50
|
+
} from "@frockbot/computer-core";
|
|
51
|
+
import {
|
|
52
|
+
computerGuiRefusalV1,
|
|
53
|
+
SCRATCH_ROOT,
|
|
54
|
+
shellGuiCommandV1,
|
|
55
|
+
} from "@frockbot/computer-host-runtime";
|
|
56
|
+
// Merges the Agent loop's event declarations into the cordis Context type.
|
|
57
|
+
import type {} from "@frockbot/kernel-agent-loop/agent";
|
|
58
|
+
import type { Plugin } from "cordis";
|
|
59
|
+
import {
|
|
60
|
+
computerProcessStatusV1,
|
|
61
|
+
COMPUTER_PROCESS_COMMAND_MAX,
|
|
62
|
+
isComputerProcessIdV1,
|
|
63
|
+
type ComputerProcessRecordV1,
|
|
64
|
+
type ComputerProcessStatusV1,
|
|
65
|
+
} from "./process-records.js";
|
|
66
|
+
import {
|
|
67
|
+
ComputerProcessLimitError,
|
|
68
|
+
ComputerProcessStore,
|
|
69
|
+
type ComputerProcessStorageV1,
|
|
70
|
+
} from "./process-store.js";
|
|
71
|
+
import {
|
|
72
|
+
COMPUTER_DOCTOR_ROOT_ID,
|
|
73
|
+
COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
74
|
+
COMPUTER_SCREENSHOT_RETENTION,
|
|
75
|
+
} from "./roots.js";
|
|
76
|
+
import {
|
|
77
|
+
COMPUTER_CONTROL_RECORD_KEY,
|
|
78
|
+
decodeStoredComputerControlV1,
|
|
79
|
+
isStoredComputerControlFreshV1,
|
|
80
|
+
} from "./control-record.js";
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
COMPUTER_DOCTOR_ROOT_ID,
|
|
84
|
+
COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
85
|
+
COMPUTER_SCREENSHOT_RETENTION,
|
|
86
|
+
} from "./roots.js";
|
|
87
|
+
|
|
88
|
+
export type { ComputerProcessStorageV1 };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The Session and Turn a durable Workspace write records as its writer.
|
|
92
|
+
*
|
|
93
|
+
* Supplied by the Bot Durable Object for one admitted Turn. Absent, and
|
|
94
|
+
* `computer_screenshot` is not registered: "every write to a durable root
|
|
95
|
+
* records its writer", and outside a Turn there is no writer to record.
|
|
96
|
+
*/
|
|
97
|
+
export interface ComputerWriterIdentityV1 {
|
|
98
|
+
sessionId: string;
|
|
99
|
+
turnId: string;
|
|
100
|
+
runId: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ComputerAgentPluginConfig {
|
|
104
|
+
userId: string;
|
|
105
|
+
defaultProviderId: string;
|
|
106
|
+
idempotentEffects?: boolean;
|
|
107
|
+
writer?: ComputerWriterIdentityV1;
|
|
108
|
+
/**
|
|
109
|
+
* The Bot Durable Object storage a background process's record is written
|
|
110
|
+
* to. Absent, and `computer_exec{background:true}` and the three process
|
|
111
|
+
* tools are not offered at all: "record durable execution intent before
|
|
112
|
+
* invoking an external side effect", and with nowhere to record it there is
|
|
113
|
+
* no honest way to launch one.
|
|
114
|
+
*/
|
|
115
|
+
processes?: ComputerProcessStorageV1;
|
|
116
|
+
/**
|
|
117
|
+
* Read-only access to this Bot Durable Object's Computer records. The
|
|
118
|
+
* dynamic prompt reads the human lease here rather than asking the
|
|
119
|
+
* Computer, so assembling a model request cannot wake one.
|
|
120
|
+
*/
|
|
121
|
+
controlRecords?: {
|
|
122
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
123
|
+
now?(): Date;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const HUMAN_CONTROL_PROMPT_LINE =
|
|
128
|
+
"Your User is currently controlling the Computer; do not use it this Turn.";
|
|
129
|
+
|
|
130
|
+
/** The wake-free, Turn-scoped projection shared by prompt render and its log. */
|
|
131
|
+
class ComputerControlPromptProjection {
|
|
132
|
+
#line = "";
|
|
133
|
+
#loadedTurn: number | undefined;
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
private readonly records: NonNullable<
|
|
137
|
+
ComputerAgentPluginConfig["controlRecords"]
|
|
138
|
+
>,
|
|
139
|
+
) {}
|
|
140
|
+
|
|
141
|
+
current(): string {
|
|
142
|
+
return this.#line;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
loadedTurn(): number | undefined {
|
|
146
|
+
return this.#loadedTurn;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async refresh(turn: number, session: Session): Promise<void> {
|
|
150
|
+
const value = await this.records.get<unknown>(COMPUTER_CONTROL_RECORD_KEY);
|
|
151
|
+
const record =
|
|
152
|
+
value === undefined ? undefined : decodeStoredComputerControlV1(value);
|
|
153
|
+
const active =
|
|
154
|
+
record &&
|
|
155
|
+
isStoredComputerControlFreshV1(record, this.records.now?.() ?? new Date())
|
|
156
|
+
? record
|
|
157
|
+
: undefined;
|
|
158
|
+
this.#line = active ? HUMAN_CONTROL_PROMPT_LINE : "";
|
|
159
|
+
this.#loadedTurn = turn;
|
|
160
|
+
session.append({
|
|
161
|
+
type: "computer/injected",
|
|
162
|
+
turn,
|
|
163
|
+
text: this.#line,
|
|
164
|
+
...(active
|
|
165
|
+
? { ownerId: active.ownerId, expiresAt: active.expiresAt }
|
|
166
|
+
: {}),
|
|
167
|
+
});
|
|
168
|
+
await session.flush();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The width and height a PNG declares in its IHDR chunk.
|
|
174
|
+
*
|
|
175
|
+
* Read here rather than asked of the Computer: `identify` is another package
|
|
176
|
+
* to provision and another exec to guard, and the two numbers are eight bytes
|
|
177
|
+
* at a fixed offset of the file the tool already holds.
|
|
178
|
+
*/
|
|
179
|
+
export function pngDimensionsV1(
|
|
180
|
+
bytes: Uint8Array,
|
|
181
|
+
): { width: number; height: number } | undefined {
|
|
182
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
183
|
+
if (bytes.byteLength < 24) return undefined;
|
|
184
|
+
if (signature.some((byte, index) => bytes[index] !== byte)) return undefined;
|
|
185
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
186
|
+
const width = view.getUint32(16);
|
|
187
|
+
const height = view.getUint32(20);
|
|
188
|
+
if (width === 0 || height === 0) return undefined;
|
|
189
|
+
return { width, height };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function base64Of(bytes: Uint8Array): string {
|
|
193
|
+
let binary = "";
|
|
194
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
195
|
+
return btoa(binary);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface ExecInput {
|
|
199
|
+
command: string;
|
|
200
|
+
background: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function record(input: unknown): Record<string, unknown> | undefined {
|
|
204
|
+
return typeof input === "object" && input !== null
|
|
205
|
+
? (input as Record<string, unknown>)
|
|
206
|
+
: undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const MAX_EXEC_COMMAND_LENGTH = 20_000;
|
|
210
|
+
|
|
211
|
+
function decodeExec(input: unknown): ExecInput | undefined {
|
|
212
|
+
const value = record(input);
|
|
213
|
+
const command = value?.command;
|
|
214
|
+
if (typeof command !== "string" || !command.trim()) return undefined;
|
|
215
|
+
if (command.length > MAX_EXEC_COMMAND_LENGTH) return undefined;
|
|
216
|
+
const background = value?.background;
|
|
217
|
+
if (background !== undefined && typeof background !== "boolean") {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
return { command, background: background === true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The durable root a finished process's log tail is mirrored into. */
|
|
224
|
+
export const COMPUTER_PROCESSES_ROOT_ID = "processes";
|
|
225
|
+
/** Log bytes mirrored into the durable root on a completion. */
|
|
226
|
+
export const COMPUTER_PROCESS_MIRROR_BYTES = 64_000;
|
|
227
|
+
|
|
228
|
+
function decodeProcessId(input: unknown): string | undefined {
|
|
229
|
+
const value = record(input)?.processId;
|
|
230
|
+
return isComputerProcessIdV1(value) ? value : undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
|
|
234
|
+
const value = record(input);
|
|
235
|
+
switch (value?.action) {
|
|
236
|
+
case "snapshot":
|
|
237
|
+
return { type: "snapshot" };
|
|
238
|
+
case "navigate":
|
|
239
|
+
return typeof value.url === "string" && value.url
|
|
240
|
+
? { type: "navigate", url: value.url }
|
|
241
|
+
: undefined;
|
|
242
|
+
case "click":
|
|
243
|
+
return typeof value.role === "string" && typeof value.name === "string"
|
|
244
|
+
? {
|
|
245
|
+
type: "click",
|
|
246
|
+
role: value.role,
|
|
247
|
+
name: value.name,
|
|
248
|
+
exact: typeof value.exact === "boolean" ? value.exact : undefined,
|
|
249
|
+
}
|
|
250
|
+
: undefined;
|
|
251
|
+
case "fill":
|
|
252
|
+
return typeof value.label === "string" && typeof value.text === "string"
|
|
253
|
+
? {
|
|
254
|
+
type: "fill",
|
|
255
|
+
label: value.label,
|
|
256
|
+
text: value.text,
|
|
257
|
+
exact: typeof value.exact === "boolean" ? value.exact : undefined,
|
|
258
|
+
}
|
|
259
|
+
: undefined;
|
|
260
|
+
case "press":
|
|
261
|
+
return typeof value.key === "string"
|
|
262
|
+
? { type: "press", key: value.key }
|
|
263
|
+
: undefined;
|
|
264
|
+
case "wait": {
|
|
265
|
+
const milliseconds = value.milliseconds ?? 500;
|
|
266
|
+
return typeof milliseconds === "number" &&
|
|
267
|
+
milliseconds >= 0 &&
|
|
268
|
+
milliseconds <= 30_000
|
|
269
|
+
? { type: "wait", milliseconds }
|
|
270
|
+
: undefined;
|
|
271
|
+
}
|
|
272
|
+
default:
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function failure(error: unknown): { content: string; isError: true } {
|
|
278
|
+
if (error instanceof ComputerError) {
|
|
279
|
+
if (error.code === "human-control-active") {
|
|
280
|
+
return {
|
|
281
|
+
content:
|
|
282
|
+
"The user is controlling this Computer; do not retry this Turn",
|
|
283
|
+
isError: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (error.code === "updating") {
|
|
287
|
+
const label = error.message.trim();
|
|
288
|
+
return {
|
|
289
|
+
content: `The Computer is updating (${label}); try again shortly`,
|
|
290
|
+
isError: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return { content: error.message, isError: true };
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
content: error instanceof Error ? error.message : String(error),
|
|
297
|
+
isError: true,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function text(bytes: Uint8Array): string {
|
|
302
|
+
return new TextDecoder().decode(bytes);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The Turn's sync state, and the only place this Package decides to sync.
|
|
307
|
+
*
|
|
308
|
+
* Deep and small on purpose: `beforeUse` and `afterTurn` are the whole
|
|
309
|
+
* surface, they never throw, and every path through them either records a
|
|
310
|
+
* `computer/sync` event or has nothing to record. A caller cannot get the
|
|
311
|
+
* policy wrong because there is no way to ask for a sync at another time.
|
|
312
|
+
*/
|
|
313
|
+
class ComputerTurnSync {
|
|
314
|
+
#turn = 0;
|
|
315
|
+
#pulled = false;
|
|
316
|
+
#used = false;
|
|
317
|
+
#signal: string | undefined;
|
|
318
|
+
|
|
319
|
+
constructor(private readonly sessions: SessionStore) {}
|
|
320
|
+
|
|
321
|
+
/** A new Turn forgets the last one's pull, its signal, and its use. */
|
|
322
|
+
beginTurn(turn: number): void {
|
|
323
|
+
if (turn === this.#turn) return;
|
|
324
|
+
this.#turn = turn;
|
|
325
|
+
this.#pulled = false;
|
|
326
|
+
this.#used = false;
|
|
327
|
+
this.#signal = undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
turnUsedTheComputer(turn: number): boolean {
|
|
331
|
+
return this.#used && turn === this.#turn;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Pull before the Turn's first Computer tool call; on later calls, sync
|
|
336
|
+
* again only when the on-Computer watcher says something changed.
|
|
337
|
+
*/
|
|
338
|
+
async beforeUse(
|
|
339
|
+
computer: ComputerHandle,
|
|
340
|
+
sessionId: string,
|
|
341
|
+
signal: AbortSignal,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
this.#used = true;
|
|
344
|
+
const sync = computer.sync;
|
|
345
|
+
if (!sync) return;
|
|
346
|
+
try {
|
|
347
|
+
if (!this.#pulled) {
|
|
348
|
+
this.#pulled = true;
|
|
349
|
+
await this.record(
|
|
350
|
+
sessionId,
|
|
351
|
+
"open",
|
|
352
|
+
await sync.reconcile("open", { signal }),
|
|
353
|
+
);
|
|
354
|
+
this.#signal = await sync.signal({ signal });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const current = await sync.signal({ signal });
|
|
358
|
+
if (current === undefined || current === this.#signal) return;
|
|
359
|
+
this.#signal = current;
|
|
360
|
+
await this.record(
|
|
361
|
+
sessionId,
|
|
362
|
+
"signal",
|
|
363
|
+
await sync.reconcile("signal", { signal }),
|
|
364
|
+
);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
// The Turn is never blocked by its sync, whatever the provider did.
|
|
367
|
+
await this.record(
|
|
368
|
+
sessionId,
|
|
369
|
+
"open",
|
|
370
|
+
computerSyncSummaryV1(
|
|
371
|
+
"unavailable",
|
|
372
|
+
error instanceof Error ? error.message : String(error),
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Push after a Turn that used the Computer, and only then. */
|
|
379
|
+
async afterTurn(computer: ComputerHandle, sessionId: string): Promise<void> {
|
|
380
|
+
this.#used = false;
|
|
381
|
+
const sync = computer.sync;
|
|
382
|
+
if (!sync) return;
|
|
383
|
+
await this.record(sessionId, "turn-end", await sync.reconcile("turn-end"));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The Turn could not be given a Computer at all; that is also an outcome. */
|
|
387
|
+
unavailable(sessionId: string, reason: unknown): Promise<void> {
|
|
388
|
+
return this.record(
|
|
389
|
+
sessionId,
|
|
390
|
+
"turn-end",
|
|
391
|
+
computerSyncSummaryV1(
|
|
392
|
+
"unavailable",
|
|
393
|
+
reason instanceof Error ? reason.message : String(reason),
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async record(
|
|
399
|
+
sessionId: string,
|
|
400
|
+
reason: ComputerSyncReasonV1,
|
|
401
|
+
summary: ComputerSyncSummaryV1,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
const session = this.sessions.get(sessionId);
|
|
404
|
+
if (!session || session.disposed) return;
|
|
405
|
+
session.append({
|
|
406
|
+
type: "computer/sync",
|
|
407
|
+
turn: Math.max(1, this.#turn),
|
|
408
|
+
reason,
|
|
409
|
+
...summary,
|
|
410
|
+
});
|
|
411
|
+
// The record is durable before anything reports the sync happened.
|
|
412
|
+
await session.flush();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Keeps the newest {@link COMPUTER_SCREENSHOT_RETENTION} captures for one Bot.
|
|
418
|
+
*
|
|
419
|
+
* A screenshot of a logged-in page is User-scoped content that outlives its
|
|
420
|
+
* Turn, so the root is bounded rather than allowed to grow for the life of the
|
|
421
|
+
* Computer. Pruning is a best effort: a capture that was recorded is never
|
|
422
|
+
* failed because an older one could not be removed.
|
|
423
|
+
*/
|
|
424
|
+
async function prune(
|
|
425
|
+
workspace: NonNullable<ComputerHandle["workspace"]>,
|
|
426
|
+
root: WorkspaceRootV1,
|
|
427
|
+
botKey: string,
|
|
428
|
+
writer: WorkspaceWriterV1,
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
const listed = await workspace.list({
|
|
431
|
+
root,
|
|
432
|
+
prefix: botKey,
|
|
433
|
+
limit: COMPUTER_SCREENSHOT_RETENTION * 4,
|
|
434
|
+
});
|
|
435
|
+
if (listed.status !== "ok") return;
|
|
436
|
+
// Ordered by when each capture was written, not by its path: a path names
|
|
437
|
+
// the Turn and the ordinal within it, and neither sorts chronologically
|
|
438
|
+
// across Turns. The generation is the record of when, so it decides.
|
|
439
|
+
const sorted = [...listed.entries].sort((left, right) => {
|
|
440
|
+
const order = left.generation.writtenAt.localeCompare(
|
|
441
|
+
right.generation.writtenAt,
|
|
442
|
+
);
|
|
443
|
+
return order !== 0 ? order : left.path.path.localeCompare(right.path.path);
|
|
444
|
+
});
|
|
445
|
+
const excess = sorted.length - COMPUTER_SCREENSHOT_RETENTION;
|
|
446
|
+
for (let index = 0; index < excess; index += 1) {
|
|
447
|
+
const entry = sorted[index]!;
|
|
448
|
+
await workspace.delete({
|
|
449
|
+
path: entry.path,
|
|
450
|
+
writer,
|
|
451
|
+
expectedGenerationId: entry.generation.generationId,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function useComputer<T>(
|
|
457
|
+
computer: ComputerHandle,
|
|
458
|
+
run: (computer: ComputerHandle) => Promise<T>,
|
|
459
|
+
): Promise<T> {
|
|
460
|
+
try {
|
|
461
|
+
return await run(computer);
|
|
462
|
+
} finally {
|
|
463
|
+
await computer.close();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function createComputerAgentPlugin(
|
|
468
|
+
config: ComputerAgentPluginConfig,
|
|
469
|
+
): Plugin.Function {
|
|
470
|
+
const userId = config.userId.trim();
|
|
471
|
+
const defaultProviderId = config.defaultProviderId.trim();
|
|
472
|
+
if (!userId) throw new Error("Computer user id must be non-empty");
|
|
473
|
+
if (!defaultProviderId) {
|
|
474
|
+
throw new Error("Computer default provider id must be non-empty");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
478
|
+
// One Computer per User (ADR 0012): the assignment is keyed by the User,
|
|
479
|
+
// and the Bot attaches to it as a tenant.
|
|
480
|
+
const identity = { userId };
|
|
481
|
+
const turnSync = new ComputerTurnSync(ctx.sessions);
|
|
482
|
+
const controlPrompt = config.controlRecords
|
|
483
|
+
? new ComputerControlPromptProjection(config.controlRecords)
|
|
484
|
+
: undefined;
|
|
485
|
+
// The Turn ordinal a `computer/process` event is recorded under. The
|
|
486
|
+
// Agent loop knows it; a tool context does not, so it is caught where the
|
|
487
|
+
// loop already announces it.
|
|
488
|
+
let currentTurn = 1;
|
|
489
|
+
const turnOf = (_context: ToolExecutionContext): number => currentTurn;
|
|
490
|
+
const attach = async (botId: string, signal: AbortSignal) => {
|
|
491
|
+
if (!ctx.computers.assignment(identity)) {
|
|
492
|
+
ctx.computers.assign(identity, defaultProviderId);
|
|
493
|
+
}
|
|
494
|
+
return ctx.computers.open(identity, { botId }, { signal });
|
|
495
|
+
};
|
|
496
|
+
/**
|
|
497
|
+
* Opens the Computer for one tool call and reconciles the durable roots
|
|
498
|
+
* before the Bot looks at them. The sync is inside `open` rather than
|
|
499
|
+
* beside each tool so no Computer tool can be added that skips it.
|
|
500
|
+
*/
|
|
501
|
+
const open = async (
|
|
502
|
+
botId: string,
|
|
503
|
+
sessionId: string,
|
|
504
|
+
signal: AbortSignal,
|
|
505
|
+
) => {
|
|
506
|
+
const computer = await attach(botId, signal);
|
|
507
|
+
await turnSync.beforeUse(computer, sessionId, signal);
|
|
508
|
+
await selfCheck(computer, botId, signal);
|
|
509
|
+
return computer;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const execTool: ToolDefinition = {
|
|
513
|
+
name: "computer_exec",
|
|
514
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
515
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
516
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
517
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
518
|
+
// and not the box, and never to the two video roles, which have no
|
|
519
|
+
// Computer at all.
|
|
520
|
+
admission: {
|
|
521
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
522
|
+
subagentRoles: ["executor", "computerUse"],
|
|
523
|
+
},
|
|
524
|
+
idempotent: config.idempotentEffects === true,
|
|
525
|
+
description: [
|
|
526
|
+
"Run a shell command in the Bot's selected persistent Computer. New calls are blocked while the user has taken control.",
|
|
527
|
+
"With background:true the command keeps running after this call returns and after this Turn ends, and you get a processId to check later.",
|
|
528
|
+
"A background process runs only while the Computer is awake. Nothing keeps it awake for you: if the Computer hibernates first, the outcome is reported as unknown, with whatever log was durable at the time.",
|
|
529
|
+
`${SCRATCH_ROOT} (also $FROCKBOT_SCRATCH) is scratch shared with your User's other Bots: it survives hibernation but is not durable and never reaches storage, so keep nothing there you cannot lose.`,
|
|
530
|
+
"The Computer's GUI is never driven from the shell; use computer_browser and computer_screenshot instead of launching or poking at a browser yourself.",
|
|
531
|
+
].join(" "),
|
|
532
|
+
inputSchema: {
|
|
533
|
+
type: "object",
|
|
534
|
+
properties: {
|
|
535
|
+
command: { type: "string", maxLength: MAX_EXEC_COMMAND_LENGTH },
|
|
536
|
+
background: {
|
|
537
|
+
type: "boolean",
|
|
538
|
+
description:
|
|
539
|
+
"Start the command and return a processId instead of waiting for it.",
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
required: ["command"],
|
|
543
|
+
additionalProperties: false,
|
|
544
|
+
},
|
|
545
|
+
validate: (input) => decodeExec(input) !== undefined,
|
|
546
|
+
execute: async (input, context) => {
|
|
547
|
+
const decoded = decodeExec(input);
|
|
548
|
+
if (!decoded)
|
|
549
|
+
return {
|
|
550
|
+
content: `A command of at most ${MAX_EXEC_COMMAND_LENGTH} characters is required`,
|
|
551
|
+
isError: true,
|
|
552
|
+
};
|
|
553
|
+
// "The GUI is never driven from the shell" (parity row 33), refused at
|
|
554
|
+
// the seam where the model can be told why. This is policy and not a
|
|
555
|
+
// boundary — a regex over a shell string is defeatable, and the
|
|
556
|
+
// Computer is the User's trust boundary anyway — so it is paired with
|
|
557
|
+
// a PATH shim on the Computer that prints the same sentence, and both
|
|
558
|
+
// exist to make the sanctioned surface the easy one.
|
|
559
|
+
const guiCommand = shellGuiCommandV1(decoded.command);
|
|
560
|
+
if (guiCommand) {
|
|
561
|
+
return { content: computerGuiRefusalV1(guiCommand), isError: true };
|
|
562
|
+
}
|
|
563
|
+
if (decoded.background) {
|
|
564
|
+
return processes
|
|
565
|
+
? launchBackground(decoded.command, context)
|
|
566
|
+
: {
|
|
567
|
+
content:
|
|
568
|
+
"A background process is recorded before it is launched; this runtime has nowhere durable to record it",
|
|
569
|
+
isError: true,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
return await useComputer(
|
|
574
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
575
|
+
async (computer) => {
|
|
576
|
+
if (!computer.exec) {
|
|
577
|
+
throw new ComputerError(
|
|
578
|
+
"capability-unavailable",
|
|
579
|
+
"The selected Computer does not support command execution",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const result = await computer.exec.execute(
|
|
583
|
+
{
|
|
584
|
+
executable: "/bin/bash",
|
|
585
|
+
args: ["-lc", decoded.command],
|
|
586
|
+
timeoutMs: 120_000,
|
|
587
|
+
maxOutputBytes: 30_000,
|
|
588
|
+
},
|
|
589
|
+
{ signal: context.signal, effectId: context.effectId },
|
|
590
|
+
);
|
|
591
|
+
return {
|
|
592
|
+
content: [text(result.stdout), text(result.stderr)]
|
|
593
|
+
.filter(Boolean)
|
|
594
|
+
.join("\n"),
|
|
595
|
+
isError: result.exitCode !== 0,
|
|
596
|
+
};
|
|
597
|
+
},
|
|
598
|
+
);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
return failure(error);
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const writer = config.writer;
|
|
606
|
+
const processes = config.processes
|
|
607
|
+
? new ComputerProcessStore(config.processes)
|
|
608
|
+
: undefined;
|
|
609
|
+
|
|
610
|
+
/** Appends one `computer/process` line to the durable session log. */
|
|
611
|
+
const noteProcess = async (
|
|
612
|
+
sessionId: string,
|
|
613
|
+
turn: number,
|
|
614
|
+
note: {
|
|
615
|
+
processId: string;
|
|
616
|
+
action: "launch" | "check" | "logs" | "stop";
|
|
617
|
+
status: ComputerProcessStatusV1;
|
|
618
|
+
exitCode?: number;
|
|
619
|
+
},
|
|
620
|
+
): Promise<void> => {
|
|
621
|
+
const session = ctx.sessions.get(sessionId);
|
|
622
|
+
if (!session || session.disposed) return;
|
|
623
|
+
session.append({
|
|
624
|
+
type: "computer/process",
|
|
625
|
+
turn: Math.max(1, turn),
|
|
626
|
+
...note,
|
|
627
|
+
});
|
|
628
|
+
await session.flush();
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Mirrors a finished process's log tail into the Package-declared
|
|
633
|
+
* `processes` root.
|
|
634
|
+
*
|
|
635
|
+
* Without it an image rebuild erases the only evidence a long job ever
|
|
636
|
+
* ran, which is an unobservable failure state. Written through the
|
|
637
|
+
* Workspace, so the Bot is recorded as its writer; best effort, because a
|
|
638
|
+
* process outcome that was read is never withheld because the mirror
|
|
639
|
+
* could not be written.
|
|
640
|
+
*/
|
|
641
|
+
const mirrorLog = async (
|
|
642
|
+
computer: ComputerHandle,
|
|
643
|
+
context: ToolExecutionContext,
|
|
644
|
+
record: ComputerProcessRecordV1,
|
|
645
|
+
status: ComputerProcessStatusV1,
|
|
646
|
+
logTail: string,
|
|
647
|
+
): Promise<void> => {
|
|
648
|
+
if (!writer || !computer.workspace) return;
|
|
649
|
+
if (status !== "exited" && status !== "unknown") return;
|
|
650
|
+
const botKey = computerBotPathKeyV1(context.botId);
|
|
651
|
+
const body = [
|
|
652
|
+
`# ${record.processId}`,
|
|
653
|
+
`command: ${record.command}`,
|
|
654
|
+
`started: ${record.startedAt}`,
|
|
655
|
+
`status: ${status}`,
|
|
656
|
+
...(record.exitCode === undefined
|
|
657
|
+
? []
|
|
658
|
+
: [`exit: ${String(record.exitCode)}`]),
|
|
659
|
+
"",
|
|
660
|
+
logTail.slice(-COMPUTER_PROCESS_MIRROR_BYTES),
|
|
661
|
+
].join("\n");
|
|
662
|
+
const existing = await computer.workspace.stat({
|
|
663
|
+
root: {
|
|
664
|
+
kind: "package-declared",
|
|
665
|
+
userId,
|
|
666
|
+
packageId: "computer",
|
|
667
|
+
rootId: COMPUTER_PROCESSES_ROOT_ID,
|
|
668
|
+
},
|
|
669
|
+
path: `${botKey}/${record.processId}.log`,
|
|
670
|
+
});
|
|
671
|
+
await computer.workspace.write({
|
|
672
|
+
path: {
|
|
673
|
+
root: {
|
|
674
|
+
kind: "package-declared",
|
|
675
|
+
userId,
|
|
676
|
+
packageId: "computer",
|
|
677
|
+
rootId: COMPUTER_PROCESSES_ROOT_ID,
|
|
678
|
+
},
|
|
679
|
+
path: `${botKey}/${record.processId}.log`,
|
|
680
|
+
},
|
|
681
|
+
bytes: new TextEncoder().encode(body),
|
|
682
|
+
writer: {
|
|
683
|
+
kind: "bot",
|
|
684
|
+
botId: context.botId,
|
|
685
|
+
sessionId: writer.sessionId,
|
|
686
|
+
turnId: writer.turnId,
|
|
687
|
+
runId: writer.runId,
|
|
688
|
+
},
|
|
689
|
+
expectedGenerationId:
|
|
690
|
+
existing.status === "ok"
|
|
691
|
+
? existing.entry.generation.generationId
|
|
692
|
+
: null,
|
|
693
|
+
mediaType: "text/plain",
|
|
694
|
+
});
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Launches a command that outlives the Turn.
|
|
699
|
+
*
|
|
700
|
+
* The record is written *before* the launch, carrying the effect id: an
|
|
701
|
+
* interrupted launch leaves an intent to reconcile rather than a process
|
|
702
|
+
* nothing remembers, and a recovery reads its outcome instead of starting
|
|
703
|
+
* a second one.
|
|
704
|
+
*/
|
|
705
|
+
const launchBackground = async (
|
|
706
|
+
command: string,
|
|
707
|
+
context: ToolExecutionContext,
|
|
708
|
+
) => {
|
|
709
|
+
if (!processes || !writer) {
|
|
710
|
+
return {
|
|
711
|
+
content:
|
|
712
|
+
"A background process is recorded before it is launched; this runtime has nowhere durable to record it",
|
|
713
|
+
isError: true,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const store = processes;
|
|
717
|
+
try {
|
|
718
|
+
return await useComputer(
|
|
719
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
720
|
+
async (computer) => {
|
|
721
|
+
if (!computer.processes) {
|
|
722
|
+
throw new ComputerError(
|
|
723
|
+
"capability-unavailable",
|
|
724
|
+
"The selected Computer does not support background processes",
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const processId = `p-${context.effectId.replaceAll(/[^a-zA-Z0-9._-]/g, "-")}`;
|
|
728
|
+
const generation = await computer.processes.generation({
|
|
729
|
+
signal: context.signal,
|
|
730
|
+
});
|
|
731
|
+
const intent: ComputerProcessRecordV1 = {
|
|
732
|
+
schemaVersion: 1,
|
|
733
|
+
processId,
|
|
734
|
+
botId: context.botId,
|
|
735
|
+
sessionId: context.sessionId,
|
|
736
|
+
turnId: writer.turnId,
|
|
737
|
+
command,
|
|
738
|
+
cwd: "",
|
|
739
|
+
startedAt: new Date().toISOString(),
|
|
740
|
+
status: "starting",
|
|
741
|
+
generation,
|
|
742
|
+
effectId: context.effectId,
|
|
743
|
+
logPath: "",
|
|
744
|
+
};
|
|
745
|
+
await store.record({ ...intent, cwd: "/", logPath: "/" });
|
|
746
|
+
const launched = await computer.processes.launch(
|
|
747
|
+
{ processId, command },
|
|
748
|
+
{ signal: context.signal, effectId: context.effectId },
|
|
749
|
+
);
|
|
750
|
+
const running: ComputerProcessRecordV1 = {
|
|
751
|
+
...intent,
|
|
752
|
+
status: "running",
|
|
753
|
+
generation: launched.generation || generation,
|
|
754
|
+
cwd: launched.cwd,
|
|
755
|
+
logPath: launched.logPath,
|
|
756
|
+
pid: launched.pid,
|
|
757
|
+
};
|
|
758
|
+
await store.update(running);
|
|
759
|
+
await noteProcess(context.sessionId, turnOf(context), {
|
|
760
|
+
processId,
|
|
761
|
+
action: "launch",
|
|
762
|
+
status: "running",
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
content: JSON.stringify({
|
|
766
|
+
processId,
|
|
767
|
+
pid: launched.pid,
|
|
768
|
+
status: "running",
|
|
769
|
+
command,
|
|
770
|
+
cwd: launched.cwd,
|
|
771
|
+
startedAt: running.startedAt,
|
|
772
|
+
note: "This process runs while the Computer is awake and outlives this Turn. Nothing keeps the Computer awake for it; if it hibernates first, computer_process_check answers unknown.",
|
|
773
|
+
}),
|
|
774
|
+
isError: false,
|
|
775
|
+
};
|
|
776
|
+
},
|
|
777
|
+
);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
if (error instanceof ComputerProcessLimitError) {
|
|
780
|
+
return { content: error.message, isError: true };
|
|
781
|
+
}
|
|
782
|
+
return failure(error);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Reads one process's outcome and records it. The reconciliation rule —
|
|
788
|
+
* a moved generation means the process is gone, never running — lives in
|
|
789
|
+
* `computerProcessStatusV1`, so no caller here can decide it differently.
|
|
790
|
+
*/
|
|
791
|
+
const settle = async (
|
|
792
|
+
context: ToolExecutionContext,
|
|
793
|
+
processId: string,
|
|
794
|
+
action: "check" | "logs" | "stop",
|
|
795
|
+
tailBytes?: number,
|
|
796
|
+
) => {
|
|
797
|
+
if (!processes) {
|
|
798
|
+
return {
|
|
799
|
+
content: "This runtime holds no background process records",
|
|
800
|
+
isError: true,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const store = processes;
|
|
804
|
+
const held = await store.read(processId);
|
|
805
|
+
if (!held || held.botId !== context.botId) {
|
|
806
|
+
return {
|
|
807
|
+
content: `No background process "${processId}" is recorded for this Bot`,
|
|
808
|
+
isError: true,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
return await useComputer(
|
|
813
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
814
|
+
async (computer) => {
|
|
815
|
+
if (!computer.processes) {
|
|
816
|
+
throw new ComputerError(
|
|
817
|
+
"capability-unavailable",
|
|
818
|
+
"The selected Computer does not support background processes",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const currentGeneration = await computer.processes.generation({
|
|
822
|
+
signal: context.signal,
|
|
823
|
+
});
|
|
824
|
+
let observed: ComputerBackgroundStateV1;
|
|
825
|
+
if (action === "stop") {
|
|
826
|
+
observed = await computer.processes.stop(processId, {
|
|
827
|
+
signal: context.signal,
|
|
828
|
+
effectId: context.effectId,
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
observed = await computer.processes.inspect(processId, {
|
|
832
|
+
signal: context.signal,
|
|
833
|
+
...(tailBytes === undefined ? {} : { tailBytes }),
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const settled = computerProcessStatusV1({
|
|
837
|
+
recorded: held,
|
|
838
|
+
currentGeneration,
|
|
839
|
+
observed,
|
|
840
|
+
});
|
|
841
|
+
const next: ComputerProcessRecordV1 = {
|
|
842
|
+
...held,
|
|
843
|
+
status: settled.status,
|
|
844
|
+
...(settled.exitCode === undefined
|
|
845
|
+
? {}
|
|
846
|
+
: { exitCode: settled.exitCode }),
|
|
847
|
+
};
|
|
848
|
+
await store.update(next);
|
|
849
|
+
await noteProcess(context.sessionId, turnOf(context), {
|
|
850
|
+
processId,
|
|
851
|
+
action,
|
|
852
|
+
status: settled.status,
|
|
853
|
+
...(settled.exitCode === undefined
|
|
854
|
+
? {}
|
|
855
|
+
: { exitCode: settled.exitCode }),
|
|
856
|
+
});
|
|
857
|
+
// The evidence outlives the Computer only if it leaves it.
|
|
858
|
+
try {
|
|
859
|
+
await mirrorLog(
|
|
860
|
+
computer,
|
|
861
|
+
context,
|
|
862
|
+
next,
|
|
863
|
+
settled.status,
|
|
864
|
+
observed.logTail,
|
|
865
|
+
);
|
|
866
|
+
} catch {
|
|
867
|
+
// A mirror that could not be written never withholds an outcome
|
|
868
|
+
// that was read.
|
|
869
|
+
}
|
|
870
|
+
if (action === "logs") {
|
|
871
|
+
return {
|
|
872
|
+
content: observed.logTail || "(no output yet)",
|
|
873
|
+
isError: false,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
content: JSON.stringify({
|
|
878
|
+
processId,
|
|
879
|
+
status: settled.status,
|
|
880
|
+
...(settled.exitCode === undefined
|
|
881
|
+
? {}
|
|
882
|
+
: { exitCode: settled.exitCode }),
|
|
883
|
+
command: held.command,
|
|
884
|
+
startedAt: held.startedAt,
|
|
885
|
+
...(held.pid === undefined ? {} : { pid: held.pid }),
|
|
886
|
+
logTail: observed.logTail.slice(-4_000),
|
|
887
|
+
...(settled.status === "unknown"
|
|
888
|
+
? {
|
|
889
|
+
note: "The Computer this process was launched on is not the one answering now, or its process is gone with no recorded exit. It is not running; treat its outcome as unknown.",
|
|
890
|
+
}
|
|
891
|
+
: {}),
|
|
892
|
+
}),
|
|
893
|
+
isError: false,
|
|
894
|
+
};
|
|
895
|
+
},
|
|
896
|
+
);
|
|
897
|
+
} catch (error) {
|
|
898
|
+
return failure(error);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
let captureSequence = 0;
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Captures the Bot's own desktop into the Package-declared `screenshots`
|
|
906
|
+
* root.
|
|
907
|
+
*
|
|
908
|
+
* The bytes are written through the Workspace rather than left where
|
|
909
|
+
* `scrot` put them, because "every write to a durable root records its
|
|
910
|
+
* writer": a file a shell left on the Computer reaches object storage
|
|
911
|
+
* `unattributed`, which is data and never provenance. The result the model
|
|
912
|
+
* reads is JSON — where the capture is and exactly which bytes it is — and
|
|
913
|
+
* the image itself travels as an attachment, shown by a model-invocation
|
|
914
|
+
* adapter that can show it and named in the text by one that cannot.
|
|
915
|
+
*
|
|
916
|
+
* Declared read-only: it observes the Computer and changes nothing, so it
|
|
917
|
+
* records no durable intent. It is still refused while a human holds the
|
|
918
|
+
* takeover lease, because during a takeover the screen is theirs.
|
|
919
|
+
*/
|
|
920
|
+
const screenshotTool: ToolDefinition = {
|
|
921
|
+
name: "computer_screenshot",
|
|
922
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
923
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
924
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
925
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
926
|
+
// and not the box, and never to the two video roles, which have no
|
|
927
|
+
// Computer at all.
|
|
928
|
+
admission: {
|
|
929
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
930
|
+
subagentRoles: ["executor", "computerUse"],
|
|
931
|
+
},
|
|
932
|
+
idempotent: true,
|
|
933
|
+
description:
|
|
934
|
+
"Capture a PNG of your own desktop on the Computer and file it in your durable screenshots root. Refused while the user has taken control of the Computer.",
|
|
935
|
+
inputSchema: {
|
|
936
|
+
type: "object",
|
|
937
|
+
properties: {},
|
|
938
|
+
additionalProperties: false,
|
|
939
|
+
},
|
|
940
|
+
validate: (input) =>
|
|
941
|
+
input === undefined ||
|
|
942
|
+
input === null ||
|
|
943
|
+
(typeof input === "object" && Object.keys(input).length === 0),
|
|
944
|
+
execute: async (_input, context) => {
|
|
945
|
+
if (!writer) {
|
|
946
|
+
return {
|
|
947
|
+
content:
|
|
948
|
+
"A screenshot is filed under the Turn that took it; this runtime has no Turn to record as its writer",
|
|
949
|
+
isError: true,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
return await useComputer(
|
|
954
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
955
|
+
async (computer) => {
|
|
956
|
+
if (!computer.screenshot) {
|
|
957
|
+
throw new ComputerError(
|
|
958
|
+
"capability-unavailable",
|
|
959
|
+
"The selected Computer does not support screenshots",
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const workspace = computer.workspace;
|
|
963
|
+
if (!workspace) {
|
|
964
|
+
throw new ComputerError(
|
|
965
|
+
"capability-unavailable",
|
|
966
|
+
"The selected Computer exposes no Workspace to file a screenshot in",
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
const captured = await computer.screenshot.capture({
|
|
970
|
+
signal: context.signal,
|
|
971
|
+
effectId: context.effectId,
|
|
972
|
+
});
|
|
973
|
+
const root: WorkspaceRootV1 = {
|
|
974
|
+
kind: "package-declared",
|
|
975
|
+
userId,
|
|
976
|
+
packageId: "computer",
|
|
977
|
+
rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
978
|
+
};
|
|
979
|
+
const botKey = computerBotPathKeyV1(context.botId);
|
|
980
|
+
captureSequence += 1;
|
|
981
|
+
const path: WorkspacePathV1 = {
|
|
982
|
+
root,
|
|
983
|
+
path: `${botKey}/${writer.turnId}-${captureSequence}.png`,
|
|
984
|
+
};
|
|
985
|
+
const botWriter: WorkspaceWriterV1 = {
|
|
986
|
+
kind: "bot",
|
|
987
|
+
botId: context.botId,
|
|
988
|
+
sessionId: writer.sessionId,
|
|
989
|
+
turnId: writer.turnId,
|
|
990
|
+
runId: writer.runId,
|
|
991
|
+
};
|
|
992
|
+
const written = await workspace.write({
|
|
993
|
+
path,
|
|
994
|
+
bytes: captured.bytes,
|
|
995
|
+
writer: botWriter,
|
|
996
|
+
expectedGenerationId: null,
|
|
997
|
+
mediaType: captured.mediaType,
|
|
998
|
+
});
|
|
999
|
+
if (written.status !== "ok") {
|
|
1000
|
+
return {
|
|
1001
|
+
content: `The screenshot could not be filed: ${written.status}: ${written.reason}`,
|
|
1002
|
+
isError: true,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
await prune(workspace, root, botKey, botWriter);
|
|
1006
|
+
const dimensions = pngDimensionsV1(captured.bytes);
|
|
1007
|
+
const attachment: ToolAttachmentV1 = {
|
|
1008
|
+
kind: "image",
|
|
1009
|
+
mediaType: captured.mediaType,
|
|
1010
|
+
workspacePath: path,
|
|
1011
|
+
contentHash: written.generation.contentHash,
|
|
1012
|
+
bytes: written.generation.size,
|
|
1013
|
+
};
|
|
1014
|
+
// The bytes are offered to the resident Session so this Turn's
|
|
1015
|
+
// next model request can show them. They are never recorded:
|
|
1016
|
+
// the event log holds the reference, the Workspace holds the
|
|
1017
|
+
// image.
|
|
1018
|
+
ctx.sessions
|
|
1019
|
+
.get(context.sessionId)
|
|
1020
|
+
?.offerAttachmentBytes(
|
|
1021
|
+
attachment.contentHash,
|
|
1022
|
+
base64Of(captured.bytes),
|
|
1023
|
+
);
|
|
1024
|
+
return {
|
|
1025
|
+
content: JSON.stringify({
|
|
1026
|
+
path: path.path,
|
|
1027
|
+
rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
1028
|
+
contentHash: attachment.contentHash,
|
|
1029
|
+
bytes: attachment.bytes,
|
|
1030
|
+
...(dimensions ?? {}),
|
|
1031
|
+
display: captured.display,
|
|
1032
|
+
capturedAt: captured.capturedAt,
|
|
1033
|
+
}),
|
|
1034
|
+
isError: false,
|
|
1035
|
+
attachments: [attachment],
|
|
1036
|
+
};
|
|
1037
|
+
},
|
|
1038
|
+
);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
return failure(error);
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Files one self-check report in the Package-declared `doctor` root.
|
|
1047
|
+
*
|
|
1048
|
+
* Through the Workspace, for the same reason a screenshot is: a file left
|
|
1049
|
+
* on the Computer by a shell reaches object storage `unattributed`, and a
|
|
1050
|
+
* report nobody can attribute is a report nobody can act on. One path per
|
|
1051
|
+
* Bot, overwritten: the log on the Computer is the history, and this is
|
|
1052
|
+
* the last answer, readable while the Computer sleeps.
|
|
1053
|
+
*/
|
|
1054
|
+
const fileDoctorReport = async (
|
|
1055
|
+
computer: ComputerHandle,
|
|
1056
|
+
botId: string,
|
|
1057
|
+
report: ComputerDoctorReportV1,
|
|
1058
|
+
): Promise<string | undefined> => {
|
|
1059
|
+
if (!writer || !computer.workspace) return undefined;
|
|
1060
|
+
const root: WorkspaceRootV1 = {
|
|
1061
|
+
kind: "package-declared",
|
|
1062
|
+
userId,
|
|
1063
|
+
packageId: "computer",
|
|
1064
|
+
rootId: COMPUTER_DOCTOR_ROOT_ID,
|
|
1065
|
+
};
|
|
1066
|
+
const path = `${computerBotPathKeyV1(botId)}/latest.json`;
|
|
1067
|
+
const existing = await computer.workspace.stat({ root, path });
|
|
1068
|
+
const written = await computer.workspace.write({
|
|
1069
|
+
path: { root, path },
|
|
1070
|
+
bytes: new TextEncoder().encode(`${JSON.stringify(report, null, 2)}\n`),
|
|
1071
|
+
writer: {
|
|
1072
|
+
kind: "bot",
|
|
1073
|
+
botId,
|
|
1074
|
+
sessionId: writer.sessionId,
|
|
1075
|
+
turnId: writer.turnId,
|
|
1076
|
+
runId: writer.runId,
|
|
1077
|
+
},
|
|
1078
|
+
expectedGenerationId:
|
|
1079
|
+
existing.status === "ok"
|
|
1080
|
+
? existing.entry.generation.generationId
|
|
1081
|
+
: null,
|
|
1082
|
+
mediaType: "application/json",
|
|
1083
|
+
});
|
|
1084
|
+
return written.status === "ok" ? path : undefined;
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* The self-check, run once for the Computer this Package instance opened.
|
|
1089
|
+
*
|
|
1090
|
+
* "box-doctor runs at startup and on demand" (parity row 27). Startup here
|
|
1091
|
+
* is the first time this Bot reaches its Computer after this Package
|
|
1092
|
+
* loaded — which is the first Turn after a cold provisioning, and after a
|
|
1093
|
+
* Durable Object eviction as well. Repeating it costs one read-only exec
|
|
1094
|
+
* and no effect, so a second run is waste and never damage.
|
|
1095
|
+
*
|
|
1096
|
+
* Nothing here can fail a Turn: a Computer that cannot answer a self-check
|
|
1097
|
+
* is a Computer the next tool call will report on anyway.
|
|
1098
|
+
*/
|
|
1099
|
+
let selfChecked = false;
|
|
1100
|
+
const selfCheck = async (
|
|
1101
|
+
computer: ComputerHandle,
|
|
1102
|
+
botId: string,
|
|
1103
|
+
signal: AbortSignal,
|
|
1104
|
+
): Promise<void> => {
|
|
1105
|
+
if (selfChecked || !computer.doctor || !writer) return;
|
|
1106
|
+
selfChecked = true;
|
|
1107
|
+
try {
|
|
1108
|
+
const report = await computer.doctor.run({ signal });
|
|
1109
|
+
await fileDoctorReport(computer, botId, report);
|
|
1110
|
+
} catch {
|
|
1111
|
+
// An unreadable self-check is not a reason to refuse the tool call the
|
|
1112
|
+
// Bot actually made.
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* `computer_doctor` — the Computer's self-check, on demand (row 27).
|
|
1118
|
+
*
|
|
1119
|
+
* Declared read-only: every check reads and none repairs, so it records no
|
|
1120
|
+
* durable intent. It is admitted on every turn type, because a Routine
|
|
1121
|
+
* that finds a Computer misbehaving must be able to say what is wrong with
|
|
1122
|
+
* it, and it is *not* refused under a human takeover — a Computer somebody
|
|
1123
|
+
* has taken over is exactly a Computer somebody is debugging.
|
|
1124
|
+
*/
|
|
1125
|
+
const doctorTool: ToolDefinition = {
|
|
1126
|
+
name: "computer_doctor",
|
|
1127
|
+
idempotent: true,
|
|
1128
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
1129
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
1130
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
1131
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
1132
|
+
// and not the box, and never to the two video roles, which have no
|
|
1133
|
+
// Computer at all.
|
|
1134
|
+
admission: {
|
|
1135
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
1136
|
+
subagentRoles: ["executor", "computerUse"],
|
|
1137
|
+
},
|
|
1138
|
+
description:
|
|
1139
|
+
"Run the Computer's self-check and read the report: disk, the shared scratch, the desktop gateway, your display, the browser profile and what the browser announces itself as, the durable-root sync and its conflicts, the reference docs, the browser launcher, the clock, and DNS. Read-only; it changes nothing and repairs nothing.",
|
|
1140
|
+
inputSchema: {
|
|
1141
|
+
type: "object",
|
|
1142
|
+
properties: {},
|
|
1143
|
+
additionalProperties: false,
|
|
1144
|
+
},
|
|
1145
|
+
validate: (input) =>
|
|
1146
|
+
input === undefined ||
|
|
1147
|
+
input === null ||
|
|
1148
|
+
(typeof input === "object" && Object.keys(input).length === 0),
|
|
1149
|
+
execute: async (_input, context) => {
|
|
1150
|
+
try {
|
|
1151
|
+
return await useComputer(
|
|
1152
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
1153
|
+
async (computer) => {
|
|
1154
|
+
if (!computer.doctor) {
|
|
1155
|
+
throw new ComputerError(
|
|
1156
|
+
"capability-unavailable",
|
|
1157
|
+
"The selected Computer does not support a self-check",
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
const report = await computer.doctor.run({
|
|
1161
|
+
signal: context.signal,
|
|
1162
|
+
});
|
|
1163
|
+
let path: string | undefined;
|
|
1164
|
+
try {
|
|
1165
|
+
path = await fileDoctorReport(computer, context.botId, report);
|
|
1166
|
+
} catch {
|
|
1167
|
+
// A report that could not be filed is still a report that was
|
|
1168
|
+
// read, and withholding it would hide the very failure it
|
|
1169
|
+
// describes.
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
content: JSON.stringify({
|
|
1173
|
+
...report,
|
|
1174
|
+
...(path ? { rootId: COMPUTER_DOCTOR_ROOT_ID, path } : {}),
|
|
1175
|
+
}),
|
|
1176
|
+
isError: false,
|
|
1177
|
+
};
|
|
1178
|
+
},
|
|
1179
|
+
);
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
return failure(error);
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The three background-process tools.
|
|
1188
|
+
*
|
|
1189
|
+
* `check` and `logs` declare their turn types explicitly — every one of
|
|
1190
|
+
* them — because a Routine must be able to collect the outcome of a job a
|
|
1191
|
+
* chat Turn started, and that has to stay true if this Package ever gains
|
|
1192
|
+
* a manifest ceiling that narrows the default. `stop` is left undeclared,
|
|
1193
|
+
* exactly like `computer_exec`, so ending a process is admitted wherever
|
|
1194
|
+
* starting one is. None of them ends a Turn, and none of them keeps a
|
|
1195
|
+
* Computer awake.
|
|
1196
|
+
*/
|
|
1197
|
+
const processCheckTool: ToolDefinition = {
|
|
1198
|
+
name: "computer_process_check",
|
|
1199
|
+
idempotent: true,
|
|
1200
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
1201
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
1202
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
1203
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
1204
|
+
// and not the box, and never to the two video roles, which have no
|
|
1205
|
+
// Computer at all.
|
|
1206
|
+
admission: {
|
|
1207
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
1208
|
+
subagentRoles: ["executor", "computerUse"],
|
|
1209
|
+
},
|
|
1210
|
+
description:
|
|
1211
|
+
"Read the status of a background process started with computer_exec{background:true}. Answers running, exited with its code, or unknown when the Computer that held it is gone.",
|
|
1212
|
+
inputSchema: {
|
|
1213
|
+
type: "object",
|
|
1214
|
+
properties: { processId: { type: "string" } },
|
|
1215
|
+
required: ["processId"],
|
|
1216
|
+
additionalProperties: false,
|
|
1217
|
+
},
|
|
1218
|
+
validate: (input) => decodeProcessId(input) !== undefined,
|
|
1219
|
+
execute: async (input, context) => {
|
|
1220
|
+
const processId = decodeProcessId(input);
|
|
1221
|
+
if (!processId)
|
|
1222
|
+
return { content: "A processId is required", isError: true };
|
|
1223
|
+
return settle(context, processId, "check");
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
const processLogsTool: ToolDefinition = {
|
|
1228
|
+
name: "computer_process_logs",
|
|
1229
|
+
idempotent: true,
|
|
1230
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
1231
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
1232
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
1233
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
1234
|
+
// and not the box, and never to the two video roles, which have no
|
|
1235
|
+
// Computer at all.
|
|
1236
|
+
admission: {
|
|
1237
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
1238
|
+
subagentRoles: ["executor", "computerUse"],
|
|
1239
|
+
},
|
|
1240
|
+
description:
|
|
1241
|
+
"Read the bounded log of a background process. The log keeps its first and last 128 KiB; the middle of a very long run is dropped.",
|
|
1242
|
+
inputSchema: {
|
|
1243
|
+
type: "object",
|
|
1244
|
+
properties: {
|
|
1245
|
+
processId: { type: "string" },
|
|
1246
|
+
tailBytes: { type: "number", minimum: 1, maximum: 64_000 },
|
|
1247
|
+
},
|
|
1248
|
+
required: ["processId"],
|
|
1249
|
+
additionalProperties: false,
|
|
1250
|
+
},
|
|
1251
|
+
validate: (input) => decodeProcessId(input) !== undefined,
|
|
1252
|
+
execute: async (input, context) => {
|
|
1253
|
+
const processId = decodeProcessId(input);
|
|
1254
|
+
if (!processId)
|
|
1255
|
+
return { content: "A processId is required", isError: true };
|
|
1256
|
+
const tailBytes = record(input)?.tailBytes;
|
|
1257
|
+
return settle(
|
|
1258
|
+
context,
|
|
1259
|
+
processId,
|
|
1260
|
+
"logs",
|
|
1261
|
+
typeof tailBytes === "number" ? tailBytes : undefined,
|
|
1262
|
+
);
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
const processStopTool: ToolDefinition = {
|
|
1267
|
+
name: "computer_process_stop",
|
|
1268
|
+
// The desktop half of the Computer: the shell, the screen, and the
|
|
1269
|
+
// processes a shell left running. Offered to an `executor` subagent,
|
|
1270
|
+
// which has the full work toolset, and to a `computerUse` one, whose
|
|
1271
|
+
// whole job is the desktop; never to `browserUse`, which drives pages
|
|
1272
|
+
// and not the box, and never to the two video roles, which have no
|
|
1273
|
+
// Computer at all.
|
|
1274
|
+
admission: {
|
|
1275
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
1276
|
+
subagentRoles: ["executor", "computerUse"],
|
|
1277
|
+
},
|
|
1278
|
+
idempotent: config.idempotentEffects === true,
|
|
1279
|
+
description:
|
|
1280
|
+
"End a background process. Its process group is signalled TERM and then KILL after a grace period.",
|
|
1281
|
+
inputSchema: {
|
|
1282
|
+
type: "object",
|
|
1283
|
+
properties: { processId: { type: "string" } },
|
|
1284
|
+
required: ["processId"],
|
|
1285
|
+
additionalProperties: false,
|
|
1286
|
+
},
|
|
1287
|
+
validate: (input) => decodeProcessId(input) !== undefined,
|
|
1288
|
+
execute: async (input, context) => {
|
|
1289
|
+
const processId = decodeProcessId(input);
|
|
1290
|
+
if (!processId)
|
|
1291
|
+
return { content: "A processId is required", isError: true };
|
|
1292
|
+
return settle(context, processId, "stop");
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
const browserTool: ToolDefinition = {
|
|
1297
|
+
name: "computer_browser",
|
|
1298
|
+
// Page-level browser control, which `browserUse` exists for.
|
|
1299
|
+
admission: {
|
|
1300
|
+
turnTypes: ["chat", "automation", "subagent"],
|
|
1301
|
+
subagentRoles: ["executor", "browserUse", "computerUse"],
|
|
1302
|
+
},
|
|
1303
|
+
idempotent: config.idempotentEffects === true,
|
|
1304
|
+
description:
|
|
1305
|
+
"Control the browser in the Bot's selected Computer and return an accessibility snapshot.",
|
|
1306
|
+
inputSchema: {
|
|
1307
|
+
type: "object",
|
|
1308
|
+
properties: {
|
|
1309
|
+
action: {
|
|
1310
|
+
type: "string",
|
|
1311
|
+
enum: ["snapshot", "navigate", "click", "fill", "press", "wait"],
|
|
1312
|
+
},
|
|
1313
|
+
url: { type: "string" },
|
|
1314
|
+
role: { type: "string" },
|
|
1315
|
+
name: { type: "string" },
|
|
1316
|
+
label: { type: "string" },
|
|
1317
|
+
text: { type: "string" },
|
|
1318
|
+
key: { type: "string" },
|
|
1319
|
+
exact: { type: "boolean" },
|
|
1320
|
+
milliseconds: { type: "number", minimum: 0, maximum: 30_000 },
|
|
1321
|
+
},
|
|
1322
|
+
required: ["action"],
|
|
1323
|
+
additionalProperties: false,
|
|
1324
|
+
},
|
|
1325
|
+
validate: (input) => decodeBrowser(input) !== undefined,
|
|
1326
|
+
execute: async (input, context) => {
|
|
1327
|
+
const action = decodeBrowser(input);
|
|
1328
|
+
if (!action)
|
|
1329
|
+
return { content: "Invalid browser action", isError: true };
|
|
1330
|
+
try {
|
|
1331
|
+
return await useComputer(
|
|
1332
|
+
await open(context.botId, context.sessionId, context.signal),
|
|
1333
|
+
async (computer) => {
|
|
1334
|
+
if (!computer.browser) {
|
|
1335
|
+
throw new ComputerError(
|
|
1336
|
+
"capability-unavailable",
|
|
1337
|
+
"The selected Computer does not support browser automation",
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
const result = await computer.browser.perform(action, {
|
|
1341
|
+
signal: context.signal,
|
|
1342
|
+
effectId: context.effectId,
|
|
1343
|
+
});
|
|
1344
|
+
return {
|
|
1345
|
+
content: result.accessibilitySnapshot,
|
|
1346
|
+
isError: false,
|
|
1347
|
+
};
|
|
1348
|
+
},
|
|
1349
|
+
);
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
return failure(error);
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
return [
|
|
1357
|
+
ctx.tools.register(execTool),
|
|
1358
|
+
...(writer ? [ctx.tools.register(screenshotTool)] : []),
|
|
1359
|
+
ctx.tools.register(doctorTool),
|
|
1360
|
+
...(processes && writer
|
|
1361
|
+
? [
|
|
1362
|
+
ctx.tools.register(processCheckTool),
|
|
1363
|
+
ctx.tools.register(processLogsTool),
|
|
1364
|
+
ctx.tools.register(processStopTool),
|
|
1365
|
+
]
|
|
1366
|
+
: []),
|
|
1367
|
+
ctx.tools.register(browserTool),
|
|
1368
|
+
// A Turn's first step is where the Turn's sync state begins; a Turn that
|
|
1369
|
+
// never touches the Computer never syncs and never wakes one.
|
|
1370
|
+
ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
|
|
1371
|
+
currentTurn = turn;
|
|
1372
|
+
turnSync.beginTurn(turn);
|
|
1373
|
+
if (controlPrompt?.loadedTurn() !== turn) {
|
|
1374
|
+
await controlPrompt?.refresh(turn, agent.session);
|
|
1375
|
+
}
|
|
1376
|
+
return next();
|
|
1377
|
+
}),
|
|
1378
|
+
// "after a Turn that used the Computer": the Computer is already awake
|
|
1379
|
+
// for this Bot, so the push costs no wake, and a Sprite that paused
|
|
1380
|
+
// mid-Turn answers `unavailable` and the next run finishes the work.
|
|
1381
|
+
ctx.on("agent/turn-stopping", async (agent, turn) => {
|
|
1382
|
+
if (!turnSync.turnUsedTheComputer(turn)) return;
|
|
1383
|
+
let computer;
|
|
1384
|
+
try {
|
|
1385
|
+
computer = await attach(agent.botId, new AbortController().signal);
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
await turnSync.unavailable(agent.session.id, error);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
try {
|
|
1391
|
+
await turnSync.afterTurn(computer, agent.session.id);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
await turnSync.unavailable(agent.session.id, error);
|
|
1394
|
+
} finally {
|
|
1395
|
+
await computer.close();
|
|
1396
|
+
}
|
|
1397
|
+
}),
|
|
1398
|
+
ctx.systemPrompt.register({
|
|
1399
|
+
id: "persistent-computer",
|
|
1400
|
+
order: 80,
|
|
1401
|
+
render: () =>
|
|
1402
|
+
[
|
|
1403
|
+
"## Persistent Computer",
|
|
1404
|
+
"You share a persistent Linux Computer with your User's other Bots. You have your own directories and desktop on it; the browser profile is shared.",
|
|
1405
|
+
"Use computer_exec to inspect the filesystem before claiming that a path or file exists.",
|
|
1406
|
+
"Use computer_screenshot to see your own desktop; each capture is filed in your durable screenshots root.",
|
|
1407
|
+
"For a job that outlasts this Turn, use computer_exec with background:true and check it later with computer_process_check. Do not poll it in a loop.",
|
|
1408
|
+
"Use computer_doctor when the Computer misbehaves; it reports disk, desktop, sync, and network in one read-only call.",
|
|
1409
|
+
...(controlPrompt?.current() ? [controlPrompt.current()] : []),
|
|
1410
|
+
"Never invent a directory listing.",
|
|
1411
|
+
].join("\n"),
|
|
1412
|
+
}),
|
|
1413
|
+
];
|
|
1414
|
+
};
|
|
1415
|
+
plugin.inject = ["computers", "tools", "systemPrompt", "sessions"];
|
|
1416
|
+
return plugin;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
export default createComputerAgentPlugin;
|