@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.
Files changed (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -897
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +99 -24
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -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 { KeyAction, PointerAction, PointerButton, type DesktopInputRequest } from "@opengeni/agent-proto";
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(capabilityType: string, session?: SandboxSessionLike): SandboxSessionLike {
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", control: "ctrl", alt: "alt", option: "alt", shift: "shift",
111
- cmd: "super", meta: "super", win: "super", super: "super",
112
- enter: "Return", return: "Return", tab: "Tab", esc: "Escape", escape: "Escape",
113
- backspace: "BackSpace", delete: "Delete", space: "space",
114
- up: "Up", down: "Down", left: "Left", right: "Right",
115
- pageup: "Prior", pagedown: "Next", home: "Home", end: "End",
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> = { left: 1, wheel: 2, right: 3, back: 8, forward: 9 };
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 = { output?: string; stdout?: string; stderr?: string; exitCode?: number | null; sessionId?: number };
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: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<ExecResultLike | string>;
135
- execCommand?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<string>;
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) { super(message); this.name = "ComputerUnavailableError"; }
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) { super(message); this.name = "ScreenshotReadError"; }
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() { super("computer-use is read-only — write actions are disabled"); this.name = "ComputerReadOnlyError"; }
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(public cmd: string, public exitCode: number, public stderr: string) {
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 unknown as ComputerSession;
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) { this.session = session as unknown as ComputerSession; }
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 recording.ts readRecordingBytes + channel-a
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("scrot produced an empty screenshot (display not up?)");
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
- await this.x(`rm -f ${f}`).catch(() => undefined);
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("session cannot run commands (no exec/execCommand) — screenshots unavailable");
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 => Math.min(SCROLL_MAX_CLICKS, Math.max(0, Math.round(Math.abs(px) / SCROLL_NOTCH_PIXELS)));
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<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
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 msg.includes("screen recording") || msg.includes("permission is not granted") || msg.includes("consent");
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 = opts.screenshotWarmupBudgetMs ?? NATIVE_SCREENSHOT_WARMUP_BUDGET_MS;
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) { this.session = session; }
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(x: number, y: number, action: PointerAction, button: PointerButton): Promise<void> {
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({ $case: "pointer", pointer: { x: n.x, y: n.y, action, button } });
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("native desktop screenshot returned an empty frame (display not up?)");
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(`[NativeDesktopComputer] screenshot failed after ${attempt} attempt(s): ${reason}`);
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("native desktop screenshot returned an empty frame (display not up?)");
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(x, y, PointerAction.POINTER_ACTION_CLICK, POINTER_BUTTON[button] ?? PointerButton.POINTER_BUTTON_LEFT);
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(x, y, PointerAction.POINTER_ACTION_DOUBLE_CLICK, PointerButton.POINTER_BUTTON_LEFT);
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(x, y, PointerAction.POINTER_ACTION_MOVE, PointerButton.POINTER_BUTTON_UNSPECIFIED);
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({ $case: "scroll", scroll: { x: n.x, y: n.y, deltaX: sx, deltaY: sy } });
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({ $case: "key", key: { key: text, isText: true, action: KeyAction.KEY_ACTION_PRESS } });
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({ $case: "key", key: { key: keys.join("+"), isText: false, action: KeyAction.KEY_ACTION_PRESS } });
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(sx, sy, PointerAction.POINTER_ACTION_DOWN, PointerButton.POINTER_BUTTON_LEFT);
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(px, py, PointerAction.POINTER_ACTION_MOVE, PointerButton.POINTER_BUTTON_LEFT);
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(session: SandboxSessionLike): session is SandboxSessionLike & NativeDesktopSession {
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) return "image/png";
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) return "image/gif";
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 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
732
- bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
733
- ) return "image/webp";
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 { type: "image", image: { data: Uint8Array.from(bytes), mediaType: sniffScreenshotMediaType(bytes) } };
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 = typeof image.mediaType === "string" ? image.mediaType : "application/octet-stream";
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" && modelInstance && typeof (modelInstance as { constructor?: unknown }).constructor === "function"
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: { type: "integer", description: "X coordinate in the pixels of the most recent computer_screenshot" },
777
- y: { type: "integer", description: "Y coordinate in the pixels of the most recent computer_screenshot" },
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(properties: Record<string, unknown>, required: string[]): Record<string, unknown> {
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 (confirmation: string, action: () => void | Promise<void>): Promise<string> => {
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
- { ...COORD_PROPS, button: { type: "string", enum: ["left", "right", "wheel", "back", "forward"], description: "Mouse button; defaults to left" } },
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})`, () => computer.click(x, y, button ?? "left"));
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: "Double-click the left mouse button at (x, y). Take a computer_screenshot first to find coordinates.",
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: { type: "integer", description: "Horizontal scroll amount in pixels (positive = right)" },
888
- scroll_y: { type: "integer", description: "Vertical scroll amount in pixels (positive = down)" },
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 { x: number; y: number; scroll_x: number; scroll_y: number };
896
- return write(`scrolled (${scroll_x}, ${scroll_y}) at (${x}, ${y})`, () => computer.scroll(x, y, scroll_x, scroll_y));
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: "Type a literal text string at the current keyboard focus. Click the target field first.",
902
- parameters: objectSchema({ text: { type: "string", description: "The literal text to type" } }, ["text"]) as never,
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
- { keys: { type: "array", items: { type: "string" }, description: "Keys pressed together as a chord" } },
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 = {}) { super(); }
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(computer, this.args.readOnly ?? false, this.args.needsApproval, true);
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(computer, this.args.readOnly ?? false, this.args.needsApproval, false);
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(computer, this.args.readOnly ?? false, this.args.needsApproval, this.args.imageFunctionResults ?? false);
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 ? { needsApproval: this.args.needsApproval as never } : {}),
1330
+ ...(this.args.needsApproval !== undefined
1331
+ ? { needsApproval: this.args.needsApproval as never }
1332
+ : {}),
1071
1333
  }) as unknown as Tool<unknown>;
1072
1334
  }
1073
1335
  }