@opengeni/runtime 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- package/src/image-history.ts +0 -149
package/src/sandbox-computer.ts
CHANGED
|
@@ -33,19 +33,27 @@
|
|
|
33
33
|
|
|
34
34
|
import { computerTool, tool, type Computer, type Tool } from "@openai/agents";
|
|
35
35
|
import { Capability, type SandboxSessionLike } from "@openai/agents/sandbox";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
KeyAction,
|
|
38
|
+
PointerAction,
|
|
39
|
+
PointerButton,
|
|
40
|
+
type DesktopInputRequest,
|
|
41
|
+
} from "@opengeni/agent-proto";
|
|
37
42
|
|
|
38
43
|
import { sandboxCommandExitCode, sandboxCommandOutput, sandboxCommandStillRunning } from "./index";
|
|
39
44
|
// `stripExecBanner` is the SAME pure helper recording.ts uses to recover the raw
|
|
40
45
|
// command body from Modal's execCommand banner ("…Output:\n<body>"). Imported from
|
|
41
46
|
// the agent-loop-free leaf (importing a pure parser FROM the leaf is allowed — the
|
|
42
47
|
// leaf boundary only forbids the leaf importing the agent loop, not the reverse).
|
|
43
|
-
import { stripExecBanner } from "./sandbox";
|
|
48
|
+
import { ensureDisplayStack, stripExecBanner } from "./sandbox";
|
|
44
49
|
|
|
45
50
|
// `requireBoundSession` lives in @openai/agents-core/sandbox/capabilities/base
|
|
46
51
|
// but is NOT re-exported from the public @openai/agents/sandbox barrel, so we
|
|
47
52
|
// inline the trivial bound-session guard (parity with the SDK's own helper).
|
|
48
|
-
function requireBoundSession(
|
|
53
|
+
function requireBoundSession(
|
|
54
|
+
capabilityType: string,
|
|
55
|
+
session?: SandboxSessionLike,
|
|
56
|
+
): SandboxSessionLike {
|
|
49
57
|
if (!session) {
|
|
50
58
|
throw new ComputerUnavailableError(`capability "${capabilityType}" used before bind(session)`);
|
|
51
59
|
}
|
|
@@ -103,16 +111,43 @@ export type SandboxComputerOptions = {
|
|
|
103
111
|
// exposed mainly so tests can shrink it (a real caller wants the full budget).
|
|
104
112
|
screenshotWarmupBudgetMs?: number;
|
|
105
113
|
screenshotRetryDelayMs?: number;
|
|
114
|
+
/**
|
|
115
|
+
* One turn-scoped preparation hook, invoked by the first real computer action.
|
|
116
|
+
* The agent may advertise computer-use on every turn, but merely advertising the
|
|
117
|
+
* tool must not boot Xvfb/XFCE or start a recording. The runtime capability owns
|
|
118
|
+
* this hook and uses it to bring up :0 (and optionally notify the worker) only
|
|
119
|
+
* after the model actually chooses a computer tool.
|
|
120
|
+
*/
|
|
121
|
+
prepare?: (session: SandboxSessionLike) => Promise<void>;
|
|
106
122
|
};
|
|
107
123
|
|
|
108
124
|
// X keysym map for keypress(): model key names → xdotool keysyms.
|
|
109
125
|
const KEYSYM: Record<string, string> = {
|
|
110
|
-
ctrl: "ctrl",
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
ctrl: "ctrl",
|
|
127
|
+
control: "ctrl",
|
|
128
|
+
alt: "alt",
|
|
129
|
+
option: "alt",
|
|
130
|
+
shift: "shift",
|
|
131
|
+
cmd: "super",
|
|
132
|
+
meta: "super",
|
|
133
|
+
win: "super",
|
|
134
|
+
super: "super",
|
|
135
|
+
enter: "Return",
|
|
136
|
+
return: "Return",
|
|
137
|
+
tab: "Tab",
|
|
138
|
+
esc: "Escape",
|
|
139
|
+
escape: "Escape",
|
|
140
|
+
backspace: "BackSpace",
|
|
141
|
+
delete: "Delete",
|
|
142
|
+
space: "space",
|
|
143
|
+
up: "Up",
|
|
144
|
+
down: "Down",
|
|
145
|
+
left: "Left",
|
|
146
|
+
right: "Right",
|
|
147
|
+
pageup: "Prior",
|
|
148
|
+
pagedown: "Next",
|
|
149
|
+
home: "Home",
|
|
150
|
+
end: "End",
|
|
116
151
|
};
|
|
117
152
|
function toKeysym(k: string): string {
|
|
118
153
|
const low = k.toLowerCase();
|
|
@@ -120,40 +155,75 @@ function toKeysym(k: string): string {
|
|
|
120
155
|
if (/^f([1-9]|1[0-2])$/.test(low)) return low.toUpperCase();
|
|
121
156
|
return low.length === 1 ? low : k;
|
|
122
157
|
}
|
|
123
|
-
const BUTTON_NUM: Record<ComputerButton, number> = {
|
|
158
|
+
const BUTTON_NUM: Record<ComputerButton, number> = {
|
|
159
|
+
left: 1,
|
|
160
|
+
wheel: 2,
|
|
161
|
+
right: 3,
|
|
162
|
+
back: 8,
|
|
163
|
+
forward: 9,
|
|
164
|
+
};
|
|
124
165
|
|
|
125
166
|
// The structural slice of a provider session computer-use drives. exec and
|
|
126
167
|
// execCommand are optional because the SDK's SandboxSessionLike leaves them
|
|
127
168
|
// optional (Modal implements execCommand, not exec — F1). readFile is intentionally
|
|
128
169
|
// NOT in this type: screenshots read the /tmp PNG via `base64 <path>` over
|
|
129
170
|
// exec/execCommand (readFile path-validates against /workspace and rejects /tmp).
|
|
130
|
-
type ExecResultLike = {
|
|
171
|
+
type ExecResultLike = {
|
|
172
|
+
output?: string;
|
|
173
|
+
stdout?: string;
|
|
174
|
+
stderr?: string;
|
|
175
|
+
exitCode?: number | null;
|
|
176
|
+
sessionId?: number;
|
|
177
|
+
};
|
|
131
178
|
type ComputerSession = {
|
|
132
179
|
// A native provider exec returns a structured `ExecResultLike`; the routing proxy
|
|
133
180
|
// fronting a Modal box returns the execCommand banner STRING. Both are handled.
|
|
134
|
-
exec?: (args: {
|
|
135
|
-
|
|
181
|
+
exec?: (args: {
|
|
182
|
+
cmd: string;
|
|
183
|
+
runAs?: string;
|
|
184
|
+
yieldTimeMs?: number;
|
|
185
|
+
maxOutputTokens?: number;
|
|
186
|
+
}) => Promise<ExecResultLike | string>;
|
|
187
|
+
execCommand?: (args: {
|
|
188
|
+
cmd: string;
|
|
189
|
+
runAs?: string;
|
|
190
|
+
yieldTimeMs?: number;
|
|
191
|
+
maxOutputTokens?: number;
|
|
192
|
+
}) => Promise<string>;
|
|
136
193
|
};
|
|
137
194
|
|
|
138
195
|
/** No exec/execCommand on the session, or the display is not up. */
|
|
139
196
|
export class ComputerUnavailableError extends Error {
|
|
140
|
-
constructor(message: string) {
|
|
197
|
+
constructor(message: string) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "ComputerUnavailableError";
|
|
200
|
+
}
|
|
141
201
|
}
|
|
142
202
|
/** The screenshot file read back SHORT of its on-box byte size — a chunk was truncated
|
|
143
203
|
* by the provider's exec-output cap. This is deterministic, NOT a warm-up transient, so
|
|
144
204
|
* it fails LOUD immediately with the byte counts (never a silent blank the model would
|
|
145
205
|
* read as a real empty screen) and is not retried. */
|
|
146
206
|
export class ScreenshotReadError extends Error {
|
|
147
|
-
constructor(message: string) {
|
|
207
|
+
constructor(message: string) {
|
|
208
|
+
super(message);
|
|
209
|
+
this.name = "ScreenshotReadError";
|
|
210
|
+
}
|
|
148
211
|
}
|
|
149
212
|
/** A write action attempted while readOnly. */
|
|
150
213
|
export class ComputerReadOnlyError extends Error {
|
|
151
|
-
constructor() {
|
|
214
|
+
constructor() {
|
|
215
|
+
super("computer-use is read-only — write actions are disabled");
|
|
216
|
+
this.name = "ComputerReadOnlyError";
|
|
217
|
+
}
|
|
152
218
|
}
|
|
153
219
|
/** A nonzero xdotool/scrot exit, OR a command that did not finish before the
|
|
154
220
|
* yield window (F3 — "still running" is a failure, not a silent success). */
|
|
155
221
|
export class ComputerActionError extends Error {
|
|
156
|
-
constructor(
|
|
222
|
+
constructor(
|
|
223
|
+
public cmd: string,
|
|
224
|
+
public exitCode: number,
|
|
225
|
+
public stderr: string,
|
|
226
|
+
) {
|
|
157
227
|
super(`computer action failed (${exitCode}): ${cmd}${stderr ? `\n${stderr}` : ""}`);
|
|
158
228
|
this.name = "ComputerActionError";
|
|
159
229
|
}
|
|
@@ -171,7 +241,7 @@ export class ComputerActionError extends Error {
|
|
|
171
241
|
export class SandboxComputer implements Computer {
|
|
172
242
|
readonly environment = "ubuntu" as const;
|
|
173
243
|
readonly dimensions: [number, number];
|
|
174
|
-
private session: ComputerSession;
|
|
244
|
+
private session: ComputerSession & SandboxSessionLike;
|
|
175
245
|
private readonly display: string;
|
|
176
246
|
private readonly runAs?: string;
|
|
177
247
|
private readonly typeDelayMs: number;
|
|
@@ -179,9 +249,12 @@ export class SandboxComputer implements Computer {
|
|
|
179
249
|
private readonly tmp: string;
|
|
180
250
|
private readonly screenshotWarmupBudgetMs: number;
|
|
181
251
|
private readonly screenshotRetryDelayMs: number;
|
|
252
|
+
private readonly prepare: ((session: SandboxSessionLike) => Promise<void>) | undefined;
|
|
253
|
+
private prepared = false;
|
|
254
|
+
private preparation: Promise<void> | null = null;
|
|
182
255
|
|
|
183
256
|
constructor(session: SandboxSessionLike, opts: SandboxComputerOptions = {}) {
|
|
184
|
-
this.session = session as
|
|
257
|
+
this.session = session as ComputerSession & SandboxSessionLike;
|
|
185
258
|
this.display = opts.display ?? DEFAULT_DISPLAY;
|
|
186
259
|
this.dimensions = opts.dimensions ?? DEFAULT_DIMENSIONS;
|
|
187
260
|
if (opts.runAs !== undefined) {
|
|
@@ -192,15 +265,42 @@ export class SandboxComputer implements Computer {
|
|
|
192
265
|
this.tmp = opts.screenshotTmpDir ?? "/tmp";
|
|
193
266
|
this.screenshotWarmupBudgetMs = opts.screenshotWarmupBudgetMs ?? SCREENSHOT_WARMUP_BUDGET_MS;
|
|
194
267
|
this.screenshotRetryDelayMs = opts.screenshotRetryDelayMs ?? SCREENSHOT_RETRY_DELAY_MS;
|
|
268
|
+
this.prepare = opts.prepare;
|
|
195
269
|
}
|
|
196
270
|
|
|
197
271
|
/** Rebind to a freshly resumed-by-id session after a box rollover / re-establish. */
|
|
198
|
-
rebind(session: SandboxSessionLike) {
|
|
272
|
+
rebind(session: SandboxSessionLike) {
|
|
273
|
+
this.session = session as ComputerSession & SandboxSessionLike;
|
|
274
|
+
this.prepared = false;
|
|
275
|
+
this.preparation = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async prepareOnce(): Promise<void> {
|
|
279
|
+
if (this.prepared) return;
|
|
280
|
+
if (!this.prepare) {
|
|
281
|
+
// No lazy hook means the caller handed us an already-ready display, which
|
|
282
|
+
// is the historical direct-construction behavior used by embedders/tests.
|
|
283
|
+
this.prepared = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.preparation ??= this.prepare(this.session);
|
|
287
|
+
try {
|
|
288
|
+
await this.preparation;
|
|
289
|
+
this.prepared = true;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// A viewer may have contended the display flock or a provider operation may
|
|
292
|
+
// have failed transiently. Let a later computer call retry; never cache a
|
|
293
|
+
// rejected preparation as the permanent state of the turn.
|
|
294
|
+
this.preparation = null;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
199
298
|
|
|
200
299
|
// The single command primitive. Dual-paths exec/execCommand (F1), then uses the
|
|
201
300
|
// established string-aware parsers (F2/F3): exitCode from the preamble, and
|
|
202
301
|
// "still running" → a retriable failure. Returns the command OUTPUT body.
|
|
203
302
|
private async x(cmd: string): Promise<string> {
|
|
303
|
+
await this.prepareOnce();
|
|
204
304
|
const args = {
|
|
205
305
|
cmd: `DISPLAY=${this.display} ${cmd}`,
|
|
206
306
|
...(this.runAs ? { runAs: this.runAs } : {}),
|
|
@@ -253,7 +353,7 @@ export class SandboxComputer implements Computer {
|
|
|
253
353
|
// path against the /workspace root and THROWS for /tmp ("Sandbox path
|
|
254
354
|
// /tmp/og-shot-*.png escapes the workspace root"), so the scrot could never be
|
|
255
355
|
// read → empty frame → `image_url: ''` → the model 400s. The base64-over-exec
|
|
256
|
-
// mechanism (mirroring
|
|
356
|
+
// mechanism (mirroring Channel A's bounded file reads
|
|
257
357
|
// fsReadViaExec) is /tmp-readable and binary-safe. We do NOT use execCommand's
|
|
258
358
|
// body via the `this.x()` parser — that drops the execCommand string body; the
|
|
259
359
|
// banner is stripped explicitly here so the base64 payload survives intact.
|
|
@@ -285,7 +385,9 @@ export class SandboxComputer implements Computer {
|
|
|
285
385
|
if (bytes.length === 0) {
|
|
286
386
|
// A cold/not-yet-painting :0 yields a zero-byte frame. Retry rather than
|
|
287
387
|
// hand the model an empty image_url; throw once the budget is spent.
|
|
288
|
-
throw new ComputerUnavailableError(
|
|
388
|
+
throw new ComputerUnavailableError(
|
|
389
|
+
"scrot produced an empty screenshot (display not up?)",
|
|
390
|
+
);
|
|
289
391
|
}
|
|
290
392
|
return Buffer.from(bytes).toString("base64");
|
|
291
393
|
} catch (error) {
|
|
@@ -299,7 +401,12 @@ export class SandboxComputer implements Computer {
|
|
|
299
401
|
} finally {
|
|
300
402
|
// Best-effort cleanup on every attempt (success OR failure); never mask the
|
|
301
403
|
// screenshot result.
|
|
302
|
-
|
|
404
|
+
// If preparation itself failed, scrot never ran and no file exists. Do
|
|
405
|
+
// not route cleanup through x(), because that would retry the expensive
|
|
406
|
+
// display preparation merely to delete a nonexistent path.
|
|
407
|
+
if (this.prepared) {
|
|
408
|
+
await this.x(`rm -f ${f}`).catch(() => undefined);
|
|
409
|
+
}
|
|
303
410
|
}
|
|
304
411
|
// Stop once the warm-up budget is spent — the NEXT sleep would push us past it.
|
|
305
412
|
if (Date.now() + this.screenshotRetryDelayMs >= deadline) {
|
|
@@ -351,7 +458,9 @@ export class SandboxComputer implements Computer {
|
|
|
351
458
|
if (typeof this.session.execCommand === "function") {
|
|
352
459
|
return stripExecBanner(await this.session.execCommand(args));
|
|
353
460
|
}
|
|
354
|
-
throw new ComputerUnavailableError(
|
|
461
|
+
throw new ComputerUnavailableError(
|
|
462
|
+
"session cannot run commands (no exec/execCommand) — screenshots unavailable",
|
|
463
|
+
);
|
|
355
464
|
}
|
|
356
465
|
|
|
357
466
|
// Read the screenshot PNG bytes off the box. A SINGLE `base64 <file>` over Modal's
|
|
@@ -402,7 +511,8 @@ export class SandboxComputer implements Computer {
|
|
|
402
511
|
async scroll(xp: number, yp: number, sx: number, sy: number) {
|
|
403
512
|
this.guardWrite();
|
|
404
513
|
// F5: model deltas are PIXELS — convert to wheel notches, clamp.
|
|
405
|
-
const notches = (px: number): number =>
|
|
514
|
+
const notches = (px: number): number =>
|
|
515
|
+
Math.min(SCROLL_MAX_CLICKS, Math.max(0, Math.round(Math.abs(px) / SCROLL_NOTCH_PIXELS)));
|
|
406
516
|
const vBtn = sy < 0 ? 4 : 5;
|
|
407
517
|
const hBtn = sx < 0 ? 6 : 7;
|
|
408
518
|
const vN = notches(sy);
|
|
@@ -455,7 +565,13 @@ export type NativeDesktopSession = {
|
|
|
455
565
|
// width/height when the agent did not have to shrink the PNG to fit the transport
|
|
456
566
|
// budget). NativeDesktopComputer scales model clicks from the ENCODED pixel space
|
|
457
567
|
// back to native pixels using their ratio before injecting.
|
|
458
|
-
screenshot(): Promise<{
|
|
568
|
+
screenshot(): Promise<{
|
|
569
|
+
png: Uint8Array;
|
|
570
|
+
width: number;
|
|
571
|
+
height: number;
|
|
572
|
+
nativeWidth: number;
|
|
573
|
+
nativeHeight: number;
|
|
574
|
+
}>;
|
|
459
575
|
};
|
|
460
576
|
|
|
461
577
|
/** Model `Button` → wire `PointerButton`. The proto has no back/forward button, so
|
|
@@ -495,7 +611,11 @@ export type NativeDesktopComputerOptions = {
|
|
|
495
611
|
* "no shareable content (Screen Recording denied?)"). */
|
|
496
612
|
function isTerminalCaptureDenial(error: unknown): boolean {
|
|
497
613
|
const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
|
|
498
|
-
return
|
|
614
|
+
return (
|
|
615
|
+
msg.includes("screen recording") ||
|
|
616
|
+
msg.includes("permission is not granted") ||
|
|
617
|
+
msg.includes("consent")
|
|
618
|
+
);
|
|
499
619
|
}
|
|
500
620
|
|
|
501
621
|
/**
|
|
@@ -536,12 +656,15 @@ export class NativeDesktopComputer implements Computer {
|
|
|
536
656
|
// should pass "mac" so the model uses ⌘-based shortcuts — see the coordinate TODO.
|
|
537
657
|
this.environment = opts.environment ?? "ubuntu";
|
|
538
658
|
this.readOnly = opts.readOnly ?? false;
|
|
539
|
-
this.screenshotWarmupBudgetMs =
|
|
659
|
+
this.screenshotWarmupBudgetMs =
|
|
660
|
+
opts.screenshotWarmupBudgetMs ?? NATIVE_SCREENSHOT_WARMUP_BUDGET_MS;
|
|
540
661
|
this.screenshotRetryDelayMs = opts.screenshotRetryDelayMs ?? NATIVE_SCREENSHOT_RETRY_DELAY_MS;
|
|
541
662
|
}
|
|
542
663
|
|
|
543
664
|
/** Rebind to a freshly resumed-by-id session after a box rollover / re-establish. */
|
|
544
|
-
rebind(session: NativeDesktopSession) {
|
|
665
|
+
rebind(session: NativeDesktopSession) {
|
|
666
|
+
this.session = session;
|
|
667
|
+
}
|
|
545
668
|
|
|
546
669
|
private guardWrite() {
|
|
547
670
|
if (this.readOnly) throw new ComputerReadOnlyError();
|
|
@@ -564,7 +687,12 @@ export class NativeDesktopComputer implements Computer {
|
|
|
564
687
|
};
|
|
565
688
|
}
|
|
566
689
|
|
|
567
|
-
private async pointer(
|
|
690
|
+
private async pointer(
|
|
691
|
+
x: number,
|
|
692
|
+
y: number,
|
|
693
|
+
action: PointerAction,
|
|
694
|
+
button: PointerButton,
|
|
695
|
+
): Promise<void> {
|
|
568
696
|
// COORDINATE SEAM: the model computes x/y against the pixels of the screenshot it
|
|
569
697
|
// just saw — which the agent may have DOWNSCALED to fit the transport's max
|
|
570
698
|
// payload (a full-res Retina/busy screen exceeds NATS's 1 MiB default). We scale
|
|
@@ -574,7 +702,10 @@ export class NativeDesktopComputer implements Computer {
|
|
|
574
702
|
// and the coordinates pass through unchanged. Self-hosted Linux (XTEST/x11) and
|
|
575
703
|
// any non-downscaled frame are 1:1 and unaffected.
|
|
576
704
|
const n = this.toNative(x, y);
|
|
577
|
-
await this.session.desktopInput({
|
|
705
|
+
await this.session.desktopInput({
|
|
706
|
+
$case: "pointer",
|
|
707
|
+
pointer: { x: n.x, y: n.y, action, button },
|
|
708
|
+
});
|
|
578
709
|
}
|
|
579
710
|
|
|
580
711
|
async screenshot(): Promise<string> {
|
|
@@ -605,7 +736,9 @@ export class NativeDesktopComputer implements Computer {
|
|
|
605
736
|
if (png.length === 0) {
|
|
606
737
|
// A warming capture yields an empty frame — retry rather than hand the model
|
|
607
738
|
// a blank; throw once the budget is spent.
|
|
608
|
-
throw new ComputerUnavailableError(
|
|
739
|
+
throw new ComputerUnavailableError(
|
|
740
|
+
"native desktop screenshot returned an empty frame (display not up?)",
|
|
741
|
+
);
|
|
609
742
|
}
|
|
610
743
|
// Record the encoded (what the model sees) vs native geometry of THIS frame so
|
|
611
744
|
// the next click/move/scroll/drag scales its coordinates back to native pixels.
|
|
@@ -626,24 +759,43 @@ export class NativeDesktopComputer implements Computer {
|
|
|
626
759
|
// reason so the failure is DIAGNOSABLE (not a silent blank the model misreads),
|
|
627
760
|
// then rethrow — never return "".
|
|
628
761
|
const reason = lastError instanceof Error ? lastError.message : String(lastError);
|
|
629
|
-
console.warn(
|
|
762
|
+
console.warn(
|
|
763
|
+
`[NativeDesktopComputer] screenshot failed after ${attempt} attempt(s): ${reason}`,
|
|
764
|
+
);
|
|
630
765
|
if (lastError instanceof Error) {
|
|
631
766
|
throw lastError;
|
|
632
767
|
}
|
|
633
|
-
throw new ComputerUnavailableError(
|
|
768
|
+
throw new ComputerUnavailableError(
|
|
769
|
+
"native desktop screenshot returned an empty frame (display not up?)",
|
|
770
|
+
);
|
|
634
771
|
}
|
|
635
772
|
|
|
636
773
|
async click(x: number, y: number, button: ComputerButton) {
|
|
637
774
|
this.guardWrite();
|
|
638
|
-
await this.pointer(
|
|
775
|
+
await this.pointer(
|
|
776
|
+
x,
|
|
777
|
+
y,
|
|
778
|
+
PointerAction.POINTER_ACTION_CLICK,
|
|
779
|
+
POINTER_BUTTON[button] ?? PointerButton.POINTER_BUTTON_LEFT,
|
|
780
|
+
);
|
|
639
781
|
}
|
|
640
782
|
async doubleClick(x: number, y: number) {
|
|
641
783
|
this.guardWrite();
|
|
642
|
-
await this.pointer(
|
|
784
|
+
await this.pointer(
|
|
785
|
+
x,
|
|
786
|
+
y,
|
|
787
|
+
PointerAction.POINTER_ACTION_DOUBLE_CLICK,
|
|
788
|
+
PointerButton.POINTER_BUTTON_LEFT,
|
|
789
|
+
);
|
|
643
790
|
}
|
|
644
791
|
async move(x: number, y: number) {
|
|
645
792
|
this.guardWrite();
|
|
646
|
-
await this.pointer(
|
|
793
|
+
await this.pointer(
|
|
794
|
+
x,
|
|
795
|
+
y,
|
|
796
|
+
PointerAction.POINTER_ACTION_MOVE,
|
|
797
|
+
PointerButton.POINTER_BUTTON_UNSPECIFIED,
|
|
798
|
+
);
|
|
647
799
|
}
|
|
648
800
|
async scroll(x: number, y: number, sx: number, sy: number) {
|
|
649
801
|
this.guardWrite();
|
|
@@ -652,13 +804,19 @@ export class NativeDesktopComputer implements Computer {
|
|
|
652
804
|
// owns the platform-appropriate wheel translation, so they pass through unscaled
|
|
653
805
|
// (no xdotool "notch" quantization here — that is an xdotool-specific artifact).
|
|
654
806
|
const n = this.toNative(x, y);
|
|
655
|
-
await this.session.desktopInput({
|
|
807
|
+
await this.session.desktopInput({
|
|
808
|
+
$case: "scroll",
|
|
809
|
+
scroll: { x: n.x, y: n.y, deltaX: sx, deltaY: sy },
|
|
810
|
+
});
|
|
656
811
|
}
|
|
657
812
|
async type(text: string) {
|
|
658
813
|
this.guardWrite();
|
|
659
814
|
// A literal text burst: isText:true tells the agent to type the string verbatim
|
|
660
815
|
// (Unicode-aware) rather than interpret it as a key name.
|
|
661
|
-
await this.session.desktopInput({
|
|
816
|
+
await this.session.desktopInput({
|
|
817
|
+
$case: "key",
|
|
818
|
+
key: { key: text, isText: true, action: KeyAction.KEY_ACTION_PRESS },
|
|
819
|
+
});
|
|
662
820
|
}
|
|
663
821
|
async keypress(keys: string[]) {
|
|
664
822
|
this.guardWrite();
|
|
@@ -667,7 +825,10 @@ export class NativeDesktopComputer implements Computer {
|
|
|
667
825
|
// NOT xdotool X keysyms (SandboxComputer's toKeysym maps to "Return"/"super"/
|
|
668
826
|
// "Prior", which are X-specific and wrong for the macOS CGEvent path). The agent
|
|
669
827
|
// owns the per-platform key-name → keycode mapping (the KeyEvent.key contract).
|
|
670
|
-
await this.session.desktopInput({
|
|
828
|
+
await this.session.desktopInput({
|
|
829
|
+
$case: "key",
|
|
830
|
+
key: { key: keys.join("+"), isText: false, action: KeyAction.KEY_ACTION_PRESS },
|
|
831
|
+
});
|
|
671
832
|
}
|
|
672
833
|
async drag(path: [number, number][]) {
|
|
673
834
|
this.guardWrite();
|
|
@@ -675,9 +836,19 @@ export class NativeDesktopComputer implements Computer {
|
|
|
675
836
|
// Press at the start, move through each waypoint with the button held, release at
|
|
676
837
|
// the last point. The agent tracks button state across the DOWN → MOVE… → UP.
|
|
677
838
|
const [sx, sy] = path[0]!;
|
|
678
|
-
await this.pointer(
|
|
839
|
+
await this.pointer(
|
|
840
|
+
sx,
|
|
841
|
+
sy,
|
|
842
|
+
PointerAction.POINTER_ACTION_DOWN,
|
|
843
|
+
PointerButton.POINTER_BUTTON_LEFT,
|
|
844
|
+
);
|
|
679
845
|
for (const [px, py] of path.slice(1)) {
|
|
680
|
-
await this.pointer(
|
|
846
|
+
await this.pointer(
|
|
847
|
+
px,
|
|
848
|
+
py,
|
|
849
|
+
PointerAction.POINTER_ACTION_MOVE,
|
|
850
|
+
PointerButton.POINTER_BUTTON_LEFT,
|
|
851
|
+
);
|
|
681
852
|
}
|
|
682
853
|
const [ex, ey] = path[path.length - 1]!;
|
|
683
854
|
await this.pointer(ex, ey, PointerAction.POINTER_ACTION_UP, PointerButton.POINTER_BUTTON_LEFT);
|
|
@@ -695,7 +866,9 @@ export class NativeDesktopComputer implements Computer {
|
|
|
695
866
|
* agent-loop ↔ sandbox-leaf import coupling) and is future-proof: any backend that
|
|
696
867
|
* grows native inject/capture is picked up automatically.
|
|
697
868
|
*/
|
|
698
|
-
export function isNativeDesktopSession(
|
|
869
|
+
export function isNativeDesktopSession(
|
|
870
|
+
session: SandboxSessionLike,
|
|
871
|
+
): session is SandboxSessionLike & NativeDesktopSession {
|
|
699
872
|
const s = session as Partial<NativeDesktopSession>;
|
|
700
873
|
return typeof s.desktopInput === "function" && typeof s.screenshot === "function";
|
|
701
874
|
}
|
|
@@ -724,20 +897,32 @@ type ToolOutputImage = { type: "image"; image: { data: Uint8Array; mediaType: st
|
|
|
724
897
|
* (shared/media). Screenshots are always PNG (scrot / ScreenCaptureKit / x11), so
|
|
725
898
|
* an unrecognized header defaults to image/png rather than failing the frame. */
|
|
726
899
|
function sniffScreenshotMediaType(bytes: Uint8Array): string {
|
|
727
|
-
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
900
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
901
|
+
return "image/png";
|
|
728
902
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
729
|
-
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
|
|
903
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
|
|
904
|
+
return "image/gif";
|
|
730
905
|
if (
|
|
731
|
-
bytes[0] === 0x52 &&
|
|
732
|
-
bytes[
|
|
733
|
-
|
|
906
|
+
bytes[0] === 0x52 &&
|
|
907
|
+
bytes[1] === 0x49 &&
|
|
908
|
+
bytes[2] === 0x46 &&
|
|
909
|
+
bytes[3] === 0x46 &&
|
|
910
|
+
bytes[8] === 0x57 &&
|
|
911
|
+
bytes[9] === 0x45 &&
|
|
912
|
+
bytes[10] === 0x42 &&
|
|
913
|
+
bytes[11] === 0x50
|
|
914
|
+
)
|
|
915
|
+
return "image/webp";
|
|
734
916
|
return "image/png";
|
|
735
917
|
}
|
|
736
918
|
|
|
737
919
|
/** Build the SDK `ToolOutputImage` from raw screenshot bytes — the structured shape
|
|
738
920
|
* the SDK's `imageOutputFromBytes` produces (`{type:'image', image:{data,mediaType}}`). */
|
|
739
921
|
function imageOutputFromScreenshotBytes(bytes: Uint8Array): ToolOutputImage {
|
|
740
|
-
return {
|
|
922
|
+
return {
|
|
923
|
+
type: "image",
|
|
924
|
+
image: { data: Uint8Array.from(bytes), mediaType: sniffScreenshotMediaType(bytes) },
|
|
925
|
+
};
|
|
741
926
|
}
|
|
742
927
|
|
|
743
928
|
/** Render an image tool-output as a text-transport string, in lockstep with the
|
|
@@ -747,7 +932,8 @@ function imageOutputFromScreenshotBytes(bytes: Uint8Array): ToolOutputImage {
|
|
|
747
932
|
function renderImageForTextTransport(output: ToolOutputImage | string): string {
|
|
748
933
|
if (typeof output === "string") return output;
|
|
749
934
|
const { image } = output;
|
|
750
|
-
const mediaType =
|
|
935
|
+
const mediaType =
|
|
936
|
+
typeof image.mediaType === "string" ? image.mediaType : "application/octet-stream";
|
|
751
937
|
return `data:${mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
|
|
752
938
|
}
|
|
753
939
|
|
|
@@ -760,7 +946,9 @@ function renderImageForTextTransport(output: ToolOutputImage | string): string {
|
|
|
760
946
|
function supportsStructuredToolOutputTransport(modelInstance: unknown): boolean {
|
|
761
947
|
if (!modelInstance) return false;
|
|
762
948
|
const constructorName =
|
|
763
|
-
typeof modelInstance === "object" &&
|
|
949
|
+
typeof modelInstance === "object" &&
|
|
950
|
+
modelInstance &&
|
|
951
|
+
typeof (modelInstance as { constructor?: unknown }).constructor === "function"
|
|
764
952
|
? ((modelInstance as { constructor: { name?: string } }).constructor.name ?? "")
|
|
765
953
|
: "";
|
|
766
954
|
return !constructorName.includes("ChatCompletions");
|
|
@@ -773,11 +961,20 @@ const COMPUTER_READ_ONLY_MESSAGE =
|
|
|
773
961
|
// zod: zod is not a @opengeni/runtime dependency) with `strict:false`, mirroring the
|
|
774
962
|
// SDK's own `apply_patch` function-tool schema style.
|
|
775
963
|
const COORD_PROPS = {
|
|
776
|
-
x: {
|
|
777
|
-
|
|
964
|
+
x: {
|
|
965
|
+
type: "integer",
|
|
966
|
+
description: "X coordinate in the pixels of the most recent computer_screenshot",
|
|
967
|
+
},
|
|
968
|
+
y: {
|
|
969
|
+
type: "integer",
|
|
970
|
+
description: "Y coordinate in the pixels of the most recent computer_screenshot",
|
|
971
|
+
},
|
|
778
972
|
} as const;
|
|
779
973
|
|
|
780
|
-
function objectSchema(
|
|
974
|
+
function objectSchema(
|
|
975
|
+
properties: Record<string, unknown>,
|
|
976
|
+
required: string[],
|
|
977
|
+
): Record<string, unknown> {
|
|
781
978
|
return { type: "object", properties, required, additionalProperties: false };
|
|
782
979
|
}
|
|
783
980
|
|
|
@@ -809,7 +1006,10 @@ export function computerFunctionTools(
|
|
|
809
1006
|
// Perform a WRITE action, surfacing read-only / failures as a model-readable
|
|
810
1007
|
// string rather than an uncaught throw (an uncaught throw becomes a tool error
|
|
811
1008
|
// the backend may 400 on, or kills the step).
|
|
812
|
-
const write = async (
|
|
1009
|
+
const write = async (
|
|
1010
|
+
confirmation: string,
|
|
1011
|
+
action: () => void | Promise<void>,
|
|
1012
|
+
): Promise<string> => {
|
|
813
1013
|
if (readOnly) return COMPUTER_READ_ONLY_MESSAGE;
|
|
814
1014
|
try {
|
|
815
1015
|
await action();
|
|
@@ -845,19 +1045,29 @@ export function computerFunctionTools(
|
|
|
845
1045
|
description:
|
|
846
1046
|
"Click the mouse at (x, y). `button` is one of left|right|wheel|back|forward (default left). Take a computer_screenshot first to find coordinates.",
|
|
847
1047
|
parameters: objectSchema(
|
|
848
|
-
{
|
|
1048
|
+
{
|
|
1049
|
+
...COORD_PROPS,
|
|
1050
|
+
button: {
|
|
1051
|
+
type: "string",
|
|
1052
|
+
enum: ["left", "right", "wheel", "back", "forward"],
|
|
1053
|
+
description: "Mouse button; defaults to left",
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
849
1056
|
["x", "y"],
|
|
850
1057
|
) as never,
|
|
851
1058
|
strict: false,
|
|
852
1059
|
...approval,
|
|
853
1060
|
execute: async (input) => {
|
|
854
1061
|
const { x, y, button } = input as { x: number; y: number; button?: ComputerButton };
|
|
855
|
-
return write(`clicked ${button ?? "left"} at (${x}, ${y})`, () =>
|
|
1062
|
+
return write(`clicked ${button ?? "left"} at (${x}, ${y})`, () =>
|
|
1063
|
+
computer.click(x, y, button ?? "left"),
|
|
1064
|
+
);
|
|
856
1065
|
},
|
|
857
1066
|
}),
|
|
858
1067
|
tool({
|
|
859
1068
|
name: "computer_double_click",
|
|
860
|
-
description:
|
|
1069
|
+
description:
|
|
1070
|
+
"Double-click the left mouse button at (x, y). Take a computer_screenshot first to find coordinates.",
|
|
861
1071
|
parameters: objectSchema({ ...COORD_PROPS }, ["x", "y"]) as never,
|
|
862
1072
|
strict: false,
|
|
863
1073
|
...approval,
|
|
@@ -884,22 +1094,39 @@ export function computerFunctionTools(
|
|
|
884
1094
|
parameters: objectSchema(
|
|
885
1095
|
{
|
|
886
1096
|
...COORD_PROPS,
|
|
887
|
-
scroll_x: {
|
|
888
|
-
|
|
1097
|
+
scroll_x: {
|
|
1098
|
+
type: "integer",
|
|
1099
|
+
description: "Horizontal scroll amount in pixels (positive = right)",
|
|
1100
|
+
},
|
|
1101
|
+
scroll_y: {
|
|
1102
|
+
type: "integer",
|
|
1103
|
+
description: "Vertical scroll amount in pixels (positive = down)",
|
|
1104
|
+
},
|
|
889
1105
|
},
|
|
890
1106
|
["x", "y", "scroll_x", "scroll_y"],
|
|
891
1107
|
) as never,
|
|
892
1108
|
strict: false,
|
|
893
1109
|
...approval,
|
|
894
1110
|
execute: async (input) => {
|
|
895
|
-
const { x, y, scroll_x, scroll_y } = input as {
|
|
896
|
-
|
|
1111
|
+
const { x, y, scroll_x, scroll_y } = input as {
|
|
1112
|
+
x: number;
|
|
1113
|
+
y: number;
|
|
1114
|
+
scroll_x: number;
|
|
1115
|
+
scroll_y: number;
|
|
1116
|
+
};
|
|
1117
|
+
return write(`scrolled (${scroll_x}, ${scroll_y}) at (${x}, ${y})`, () =>
|
|
1118
|
+
computer.scroll(x, y, scroll_x, scroll_y),
|
|
1119
|
+
);
|
|
897
1120
|
},
|
|
898
1121
|
}),
|
|
899
1122
|
tool({
|
|
900
1123
|
name: "computer_type",
|
|
901
|
-
description:
|
|
902
|
-
|
|
1124
|
+
description:
|
|
1125
|
+
"Type a literal text string at the current keyboard focus. Click the target field first.",
|
|
1126
|
+
parameters: objectSchema(
|
|
1127
|
+
{ text: { type: "string", description: "The literal text to type" } },
|
|
1128
|
+
["text"],
|
|
1129
|
+
) as never,
|
|
903
1130
|
strict: false,
|
|
904
1131
|
...approval,
|
|
905
1132
|
execute: async (input) => {
|
|
@@ -912,7 +1139,13 @@ export function computerFunctionTools(
|
|
|
912
1139
|
description:
|
|
913
1140
|
'Press a key or chord. `keys` is an ordered list pressed together, e.g. ["ctrl","c"] or ["Enter"]. Use key names (ctrl, alt, shift, cmd, enter, tab, esc, arrows…), not characters.',
|
|
914
1141
|
parameters: objectSchema(
|
|
915
|
-
{
|
|
1142
|
+
{
|
|
1143
|
+
keys: {
|
|
1144
|
+
type: "array",
|
|
1145
|
+
items: { type: "string" },
|
|
1146
|
+
description: "Keys pressed together as a chord",
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
916
1149
|
["keys"],
|
|
917
1150
|
) as never,
|
|
918
1151
|
strict: false,
|
|
@@ -991,6 +1224,8 @@ export type ComputerUseArgs = {
|
|
|
991
1224
|
// legacy sniff behaviour is preserved byte-for-byte (back-compat for any embedder
|
|
992
1225
|
// that constructs the capability without threading a mode).
|
|
993
1226
|
toolMode?: ComputerToolMode;
|
|
1227
|
+
/** Called after the display is ready on the first actual computer action. */
|
|
1228
|
+
onReady?: (session: SandboxSessionLike) => Promise<void>;
|
|
994
1229
|
};
|
|
995
1230
|
|
|
996
1231
|
export function computerUse(args: ComputerUseArgs = {}): ComputerUseCapability {
|
|
@@ -1017,7 +1252,9 @@ export function computerUse(args: ComputerUseArgs = {}): ComputerUseCapability {
|
|
|
1017
1252
|
*/
|
|
1018
1253
|
export class ComputerUseCapability extends Capability {
|
|
1019
1254
|
readonly type = "computer-use";
|
|
1020
|
-
constructor(private args: ComputerUseArgs = {}) {
|
|
1255
|
+
constructor(private args: ComputerUseArgs = {}) {
|
|
1256
|
+
super();
|
|
1257
|
+
}
|
|
1021
1258
|
|
|
1022
1259
|
override tools(): Tool<unknown>[] {
|
|
1023
1260
|
const session = requireBoundSession("computer-use", this._session);
|
|
@@ -1037,6 +1274,14 @@ export class ComputerUseCapability extends Capability {
|
|
|
1037
1274
|
...(this.args.display ? { display: this.args.display } : {}),
|
|
1038
1275
|
// The SDK base exposes the bound runAs as a protected field.
|
|
1039
1276
|
...(typeof this._runAs === "string" ? { runAs: this._runAs } : {}),
|
|
1277
|
+
// Crucially lazy: tool registration does no provider work. The first
|
|
1278
|
+
// click/type/screenshot boots the display, then lets the worker start
|
|
1279
|
+
// any computer-use-only recording. One SandboxComputer instance caches
|
|
1280
|
+
// this for the rest of the turn.
|
|
1281
|
+
prepare: async (preparedSession) => {
|
|
1282
|
+
await ensureDisplayStack(preparedSession);
|
|
1283
|
+
await this.args.onReady?.(preparedSession);
|
|
1284
|
+
},
|
|
1040
1285
|
});
|
|
1041
1286
|
// HARDENING: when the caller declares an EXPLICIT toolMode, obey it and NEVER
|
|
1042
1287
|
// consult `supportsStructuredToolOutputTransport` — tool selection must not
|
|
@@ -1048,9 +1293,19 @@ export class ComputerUseCapability extends Capability {
|
|
|
1048
1293
|
case "hosted":
|
|
1049
1294
|
return [this.hostedComputerTool(computer)];
|
|
1050
1295
|
case "function-image":
|
|
1051
|
-
return computerFunctionTools(
|
|
1296
|
+
return computerFunctionTools(
|
|
1297
|
+
computer,
|
|
1298
|
+
this.args.readOnly ?? false,
|
|
1299
|
+
this.args.needsApproval,
|
|
1300
|
+
true,
|
|
1301
|
+
);
|
|
1052
1302
|
case "function-text":
|
|
1053
|
-
return computerFunctionTools(
|
|
1303
|
+
return computerFunctionTools(
|
|
1304
|
+
computer,
|
|
1305
|
+
this.args.readOnly ?? false,
|
|
1306
|
+
this.args.needsApproval,
|
|
1307
|
+
false,
|
|
1308
|
+
);
|
|
1054
1309
|
case undefined:
|
|
1055
1310
|
break; // fall through to the legacy sniff (back-compat), preserved byte-for-byte
|
|
1056
1311
|
}
|
|
@@ -1059,7 +1314,12 @@ export class ComputerUseCapability extends Capability {
|
|
|
1059
1314
|
if (supportsStructuredToolOutputTransport(this._modelInstance)) {
|
|
1060
1315
|
return [this.hostedComputerTool(computer)];
|
|
1061
1316
|
}
|
|
1062
|
-
return computerFunctionTools(
|
|
1317
|
+
return computerFunctionTools(
|
|
1318
|
+
computer,
|
|
1319
|
+
this.args.readOnly ?? false,
|
|
1320
|
+
this.args.needsApproval,
|
|
1321
|
+
this.args.imageFunctionResults ?? false,
|
|
1322
|
+
);
|
|
1063
1323
|
}
|
|
1064
1324
|
|
|
1065
1325
|
/** The single HOSTED `computer_use_preview` tool bound to `computer` — identical
|
|
@@ -1067,7 +1327,9 @@ export class ComputerUseCapability extends Capability {
|
|
|
1067
1327
|
private hostedComputerTool(computer: Computer): Tool<unknown> {
|
|
1068
1328
|
return computerTool({
|
|
1069
1329
|
computer,
|
|
1070
|
-
...(this.args.needsApproval !== undefined
|
|
1330
|
+
...(this.args.needsApproval !== undefined
|
|
1331
|
+
? { needsApproval: this.args.needsApproval as never }
|
|
1332
|
+
: {}),
|
|
1071
1333
|
}) as unknown as Tool<unknown>;
|
|
1072
1334
|
}
|
|
1073
1335
|
}
|