@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
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import {
|
|
19
19
|
ControlRequest,
|
|
20
20
|
ControlResponse,
|
|
21
|
+
ErrorCode,
|
|
21
22
|
FsEntryKind,
|
|
22
23
|
StreamKind,
|
|
23
24
|
type DesktopInputRequest,
|
|
@@ -36,9 +37,22 @@ import { Manifest } from "@openai/agents/sandbox";
|
|
|
36
37
|
import type { ExposedPortEndpoint } from "../stream-port";
|
|
37
38
|
import {
|
|
38
39
|
agentErrorToControlError,
|
|
40
|
+
drainingExhaustedError,
|
|
41
|
+
execDeadlineHint,
|
|
42
|
+
SelfhostedControlError,
|
|
39
43
|
subjectFor,
|
|
40
44
|
type ControlRpc,
|
|
41
45
|
} from "./control-rpc";
|
|
46
|
+
import {
|
|
47
|
+
decideSelfhostedRetry,
|
|
48
|
+
defaultSelfhostedRetryClock,
|
|
49
|
+
type SelfhostedRetryClock,
|
|
50
|
+
} from "./retry-policy";
|
|
51
|
+
import type { SelfhostedOpObservation, SelfhostedOpObserver } from "./op-observer";
|
|
52
|
+
import { selfhostedFaultClass } from "./fault-rendering";
|
|
53
|
+
import { nextDurableOpId } from "../op-correlation";
|
|
54
|
+
import { OpStreamExecClient, type OpStreamJournal } from "./op-stream";
|
|
55
|
+
import { OpStreamUnavailableError, type OpStreamTransport } from "./op-transport";
|
|
42
56
|
|
|
43
57
|
const decoder = new TextDecoder();
|
|
44
58
|
const encoder = new TextEncoder();
|
|
@@ -116,7 +130,11 @@ function toMachinePath(p: string | undefined, workingDir: string): string {
|
|
|
116
130
|
* runtime barrel (`packages/runtime/src/index.ts`, which DOES import that root)
|
|
117
131
|
* injects it via `setSelfhostedApplyDiff` at module load. Until injected,
|
|
118
132
|
* `createEditor()` surfaces a clear error rather than a silent wrong-edit. */
|
|
119
|
-
export type SelfhostedApplyDiff = (
|
|
133
|
+
export type SelfhostedApplyDiff = (
|
|
134
|
+
input: string,
|
|
135
|
+
diff: string,
|
|
136
|
+
mode?: "default" | "create",
|
|
137
|
+
) => string;
|
|
120
138
|
let injectedApplyDiff: SelfhostedApplyDiff | undefined;
|
|
121
139
|
|
|
122
140
|
/** Register the SDK's `applyDiff` so `SelfhostedSession.createEditor()` can apply
|
|
@@ -128,8 +146,14 @@ export function setSelfhostedApplyDiff(fn: SelfhostedApplyDiff): void {
|
|
|
128
146
|
/** The structural Editor surface the SDK's filesystem capability consumes (the
|
|
129
147
|
* three apply_patch operations). Mirrors `@openai/agents-core`'s `Editor`. */
|
|
130
148
|
export interface SelfhostedEditor {
|
|
131
|
-
createFile(
|
|
132
|
-
|
|
149
|
+
createFile(
|
|
150
|
+
operation: { path: string; diff: string },
|
|
151
|
+
context?: unknown,
|
|
152
|
+
): Promise<{ output?: string } | void>;
|
|
153
|
+
updateFile(
|
|
154
|
+
operation: { path: string; diff: string; moveTo?: string },
|
|
155
|
+
context?: unknown,
|
|
156
|
+
): Promise<{ output?: string } | void>;
|
|
133
157
|
deleteFile(operation: { path: string }, context?: unknown): Promise<{ output?: string } | void>;
|
|
134
158
|
}
|
|
135
159
|
|
|
@@ -143,6 +167,13 @@ export interface SelfhostedImageOutput {
|
|
|
143
167
|
/** Default control-op timeout. A transient miss surfaces as `agent_reconnecting`
|
|
144
168
|
* (the turn pauses + retries); it is NOT a hard failure. */
|
|
145
169
|
export const SELFHOSTED_DEFAULT_TIMEOUT_MS = 30_000;
|
|
170
|
+
/** Reply grace after an agent-side exec deadline. The child is terminated at
|
|
171
|
+
* `ExecRequest.timeoutMs`; this outer window lets the typed `timedOut` response
|
|
172
|
+
* cross NATS before request/reply gives up. */
|
|
173
|
+
const SELFHOSTED_EXEC_REPLY_GRACE_MS = 5_000;
|
|
174
|
+
/** nats.js ultimately schedules a JS timer; stay inside the signed 32-bit timer
|
|
175
|
+
* range while also fitting the reply grace. */
|
|
176
|
+
const SELFHOSTED_MAX_EXEC_TIMEOUT_MS = 2_147_483_647 - SELFHOSTED_EXEC_REPLY_GRACE_MS;
|
|
146
177
|
|
|
147
178
|
/**
|
|
148
179
|
* The ONLY manifest-environment keys exported into a selfhosted machine's exec
|
|
@@ -153,7 +184,10 @@ export const SELFHOSTED_DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
153
184
|
* and the token-FILE PATH to reach the tool surface. The token VALUE never rides
|
|
154
185
|
* here — it is seeded to that file off-manifest over the same exec channel.
|
|
155
186
|
*/
|
|
156
|
-
const SELFHOSTED_EXEC_ENV_ALLOWLIST = [
|
|
187
|
+
const SELFHOSTED_EXEC_ENV_ALLOWLIST = [
|
|
188
|
+
"OPENGENI_TOOLSPACE_URL",
|
|
189
|
+
"OPENGENI_TOOLSPACE_TOKEN_FILE",
|
|
190
|
+
] as const;
|
|
157
191
|
|
|
158
192
|
function selfhostedExecEnv(environment: Record<string, string>): Record<string, string> {
|
|
159
193
|
const env: Record<string, string> = {};
|
|
@@ -184,17 +218,55 @@ export interface SelfhostedRelayConfig {
|
|
|
184
218
|
/** The relay's default wss dial path (the `opengeni-relay` server route). */
|
|
185
219
|
export const SELFHOSTED_RELAY_STREAM_PATH = "/stream";
|
|
186
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The op-stream injection (op-stream protocol v1.1 — see op-stream.ts). PRESENT
|
|
223
|
+
* = the streaming exec transport is enabled for this session (the worker
|
|
224
|
+
* injects it only when the runner advertised `Capabilities.op_stream` AND the
|
|
225
|
+
* server flag is on — the leaf carries no flag logic). ABSENT = the legacy
|
|
226
|
+
* monolithic exec, byte-identical to before. The timing knobs exist for tests.
|
|
227
|
+
*/
|
|
228
|
+
export interface SelfhostedOpStreamDeps {
|
|
229
|
+
transport: OpStreamTransport;
|
|
230
|
+
/** The durable-resume journal (attach generation + settled-frontier persist).
|
|
231
|
+
* Absent (tests, non-activity callers) ⇒ generation "1", no persistence. */
|
|
232
|
+
journal?: OpStreamJournal;
|
|
233
|
+
windowBytes?: number;
|
|
234
|
+
ackIntervalMs?: number;
|
|
235
|
+
silenceTimeoutMs?: number;
|
|
236
|
+
reconnectHoldMs?: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
187
239
|
export interface SelfhostedSessionDeps {
|
|
188
240
|
workspaceId: string;
|
|
189
241
|
agentId: string;
|
|
190
242
|
controlRpc: ControlRpc;
|
|
191
243
|
relay: SelfhostedRelayConfig;
|
|
244
|
+
/** Op-stream exec transport (present = enabled; see SelfhostedOpStreamDeps). */
|
|
245
|
+
opStream?: SelfhostedOpStreamDeps;
|
|
192
246
|
/** The lease/active epoch this session is fenced under (echoed on every
|
|
193
247
|
* ControlRequest so the agent can reject a stale op with ERROR_CODE_FENCED).
|
|
194
248
|
* Defaults to 0 (no fence) for the negotiation-only / test path. */
|
|
195
249
|
epoch?: number;
|
|
196
|
-
/**
|
|
250
|
+
/** The CONTROL-op timeout (ping/fs/desktop/pty and the exec wire fallback). The
|
|
251
|
+
* agent's own control liveness must stay responsive, so this is short (the 30s
|
|
252
|
+
* default). Override for tests or per-deployment tuning. */
|
|
197
253
|
timeoutMs?: number;
|
|
254
|
+
/**
|
|
255
|
+
* The EXEC process deadline — distinct from `timeoutMs`. A real command
|
|
256
|
+
* (compile, test run, install) routinely outlives the short control timeout, so
|
|
257
|
+
* exec gets its own, much larger budget: the agent kills the child at this wall,
|
|
258
|
+
* and the wire waits `execTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS`. Omitted ⇒
|
|
259
|
+
* falls back to `timeoutMs` (byte-identical to before this split), so any caller
|
|
260
|
+
* that does not opt in keeps today's behavior.
|
|
261
|
+
*/
|
|
262
|
+
execTimeoutMs?: number;
|
|
263
|
+
/** The clock the bounded control-op retry loop drives (sleep + jitter). Injected
|
|
264
|
+
* so tests are deterministic; defaults to a real timer + `Math.random()`. */
|
|
265
|
+
retryClock?: SelfhostedRetryClock;
|
|
266
|
+
/** A fire-and-forget observer invoked once per completed control op (out-of-band
|
|
267
|
+
* telemetry — metrics + `machine.*` events). The worker/api wire it to the sinks;
|
|
268
|
+
* the leaf only invokes it (guarded so it can never break an op). Omitted ⇒ no-op. */
|
|
269
|
+
onOp?: SelfhostedOpObserver;
|
|
198
270
|
/**
|
|
199
271
|
* The run's declared sandbox environment — the SAME `Record<string,string>` the
|
|
200
272
|
* worker turn passes to `runtime.buildAgent`'s `sandboxEnvironment` (and that the
|
|
@@ -226,6 +298,10 @@ export interface SelfhostedExecResult {
|
|
|
226
298
|
stdout: string;
|
|
227
299
|
stderr: string;
|
|
228
300
|
exitCode: number | null;
|
|
301
|
+
/** True when the agent killed the child at the exec deadline. Additive to the
|
|
302
|
+
* Channel-A superset (consumers that don't read it are unaffected); `execCommand`
|
|
303
|
+
* reads it to surface the deadline hint on the stdout-only SDK path. */
|
|
304
|
+
timedOut?: boolean;
|
|
229
305
|
}
|
|
230
306
|
|
|
231
307
|
/** The `exec` args the structural surface accepts (mirrors ChannelAExecArgs). */
|
|
@@ -260,7 +336,14 @@ export class SelfhostedSession {
|
|
|
260
336
|
private readonly relay: SelfhostedRelayConfig;
|
|
261
337
|
private readonly epoch: number;
|
|
262
338
|
private readonly timeoutMs: number;
|
|
339
|
+
/** The exec process deadline (falls back to `timeoutMs` when unset). */
|
|
340
|
+
private readonly execTimeoutMs: number | undefined;
|
|
341
|
+
private readonly retryClock: SelfhostedRetryClock;
|
|
342
|
+
private readonly onOp: SelfhostedOpObserver | undefined;
|
|
263
343
|
private readonly subject: string;
|
|
344
|
+
/** The op-stream exec client — constructed iff `deps.opStream` was injected
|
|
345
|
+
* (= the streaming transport is enabled for this session). */
|
|
346
|
+
private readonly opStreamClient: OpStreamExecClient | undefined;
|
|
264
347
|
/** The session working directory — the path/cwd base every op is rooted under
|
|
265
348
|
* (see `toMachinePath`). "" by default ⇒ today's workspace_root behavior. */
|
|
266
349
|
private readonly workingDir: string;
|
|
@@ -292,7 +375,12 @@ export class SelfhostedSession {
|
|
|
292
375
|
* selfhosted (resume re-addresses the machine by agentId via the lease pointer,
|
|
293
376
|
* never from this SDK envelope), so its only job is to not crash the serialize.
|
|
294
377
|
*/
|
|
295
|
-
readonly state: {
|
|
378
|
+
readonly state: {
|
|
379
|
+
agentId: string;
|
|
380
|
+
instanceId: string;
|
|
381
|
+
manifest: Manifest;
|
|
382
|
+
environment: Record<string, string>;
|
|
383
|
+
};
|
|
296
384
|
|
|
297
385
|
constructor(deps: SelfhostedSessionDeps) {
|
|
298
386
|
this.workspaceId = deps.workspaceId;
|
|
@@ -301,8 +389,36 @@ export class SelfhostedSession {
|
|
|
301
389
|
this.relay = deps.relay;
|
|
302
390
|
this.epoch = deps.epoch ?? 0;
|
|
303
391
|
this.timeoutMs = deps.timeoutMs ?? SELFHOSTED_DEFAULT_TIMEOUT_MS;
|
|
392
|
+
this.execTimeoutMs = deps.execTimeoutMs;
|
|
393
|
+
this.retryClock = deps.retryClock ?? defaultSelfhostedRetryClock;
|
|
394
|
+
this.onOp = deps.onOp;
|
|
304
395
|
this.subject = subjectFor(deps.workspaceId, deps.agentId);
|
|
305
396
|
this.workingDir = deps.workingDir ?? "";
|
|
397
|
+
this.opStreamClient = deps.opStream
|
|
398
|
+
? new OpStreamExecClient({
|
|
399
|
+
workspaceId: deps.workspaceId,
|
|
400
|
+
agentId: deps.agentId,
|
|
401
|
+
epoch: this.epoch,
|
|
402
|
+
controlRpc: deps.controlRpc,
|
|
403
|
+
rpcSubject: this.subject,
|
|
404
|
+
transport: deps.opStream.transport,
|
|
405
|
+
controlTimeoutMs: this.timeoutMs,
|
|
406
|
+
retryClock: this.retryClock,
|
|
407
|
+
...(deps.opStream.journal !== undefined ? { journal: deps.opStream.journal } : {}),
|
|
408
|
+
...(deps.opStream.windowBytes !== undefined
|
|
409
|
+
? { windowBytes: deps.opStream.windowBytes }
|
|
410
|
+
: {}),
|
|
411
|
+
...(deps.opStream.ackIntervalMs !== undefined
|
|
412
|
+
? { ackIntervalMs: deps.opStream.ackIntervalMs }
|
|
413
|
+
: {}),
|
|
414
|
+
...(deps.opStream.silenceTimeoutMs !== undefined
|
|
415
|
+
? { silenceTimeoutMs: deps.opStream.silenceTimeoutMs }
|
|
416
|
+
: {}),
|
|
417
|
+
...(deps.opStream.reconnectHoldMs !== undefined
|
|
418
|
+
? { reconnectHoldMs: deps.opStream.reconnectHoldMs }
|
|
419
|
+
: {}),
|
|
420
|
+
})
|
|
421
|
+
: undefined;
|
|
306
422
|
// A valid Manifest mirroring the Modal create-manifest shape (sandbox/index.ts
|
|
307
423
|
// `createManifest`: `new Manifest({ root: "/workspace", environment })`). `root`
|
|
308
424
|
// is "/workspace" to match `buildManifest`'s declared root (the root-delta guard
|
|
@@ -320,7 +436,11 @@ export class SelfhostedSession {
|
|
|
320
436
|
this.state = {
|
|
321
437
|
agentId: deps.agentId,
|
|
322
438
|
instanceId: deps.agentId,
|
|
323
|
-
manifest: new Manifest({
|
|
439
|
+
manifest: new Manifest({
|
|
440
|
+
root: "/workspace",
|
|
441
|
+
entries: {},
|
|
442
|
+
environment: deps.environment ?? {},
|
|
443
|
+
}),
|
|
324
444
|
// The SDK `SandboxSessionState.environment` — the run's threaded env (or `{}`).
|
|
325
445
|
// The group client's end-of-turn serialize reads `state.environment` directly
|
|
326
446
|
// (Object.entries), so it must be a defined object, not absent.
|
|
@@ -328,32 +448,145 @@ export class SelfhostedSession {
|
|
|
328
448
|
};
|
|
329
449
|
}
|
|
330
450
|
|
|
331
|
-
/**
|
|
332
|
-
*
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (res.error) {
|
|
342
|
-
throw agentErrorToControlError(res.error);
|
|
451
|
+
/** Invoke the injected per-op observer, guarded: a telemetry tap must NEVER break
|
|
452
|
+
* an op (a throwing sink is swallowed). No-op when no observer is wired. */
|
|
453
|
+
private emitOp(observation: SelfhostedOpObservation): void {
|
|
454
|
+
if (!this.onOp) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
this.onOp(observation);
|
|
459
|
+
} catch {
|
|
460
|
+
// A telemetry sink must never surface into the op path.
|
|
343
461
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Issue a control op, decoding the agent's reply or throwing the mapped
|
|
466
|
+
* `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
|
|
467
|
+
* timeout error from the transport).
|
|
468
|
+
*
|
|
469
|
+
* Two failure modes are retried in-place (the decision is the pure
|
|
470
|
+
* `decideSelfhostedRetry` policy):
|
|
471
|
+
* - DRAINING — a pre-admission host-work backpressure rejection; the op never
|
|
472
|
+
* started, so it is safe to re-issue for ANY op kind (bounded).
|
|
473
|
+
* - a TIMEOUT / `agent_reconnecting` blip — re-issued ONCE, and ONLY for a
|
|
474
|
+
* read-only idempotent-safe op. A timed-out MUTATION is NEVER re-issued: it
|
|
475
|
+
* may already have executed on the machine (at-least-once), which would
|
|
476
|
+
* duplicate the write/command. FENCED is intentionally NOT retried here — the
|
|
477
|
+
* routing proxy retries it against a re-resolved backend under a fresh epoch.
|
|
478
|
+
* Each attempt carries a FRESH `requestId` (a retry is a distinct request).
|
|
479
|
+
*/
|
|
480
|
+
private async call(
|
|
481
|
+
op: NonNullable<ControlRequest["op"]>,
|
|
482
|
+
timeoutMs = this.timeoutMs,
|
|
483
|
+
): Promise<NonNullable<ControlResponse["result"]>> {
|
|
484
|
+
const opKind = op.$case;
|
|
485
|
+
const startedAt = Date.now();
|
|
486
|
+
let drainingRetries = 0;
|
|
487
|
+
let timeoutRetries = 0;
|
|
488
|
+
let neverSentRetries = 0;
|
|
489
|
+
for (;;) {
|
|
490
|
+
const req: ControlRequest = {
|
|
491
|
+
requestId: crypto.randomUUID(),
|
|
492
|
+
epoch: this.epoch,
|
|
493
|
+
op,
|
|
494
|
+
};
|
|
495
|
+
const res = await this.controlRpc.request(this.subject, req, { timeoutMs });
|
|
496
|
+
if (!res.error && res.result) {
|
|
497
|
+
const retries = drainingRetries + timeoutRetries + neverSentRetries;
|
|
498
|
+
this.emitOp({
|
|
499
|
+
op: opKind,
|
|
500
|
+
outcome: "ok",
|
|
501
|
+
healed: retries > 0,
|
|
502
|
+
retries,
|
|
503
|
+
durationMs: Date.now() - startedAt,
|
|
504
|
+
machineId: this.agentId,
|
|
505
|
+
// A healed op's class is whichever retry budget it recovered from.
|
|
506
|
+
...(retries > 0
|
|
507
|
+
? {
|
|
508
|
+
faultClass:
|
|
509
|
+
neverSentRetries > 0
|
|
510
|
+
? "offline"
|
|
511
|
+
: drainingRetries > 0
|
|
512
|
+
? "draining"
|
|
513
|
+
: "reconnecting",
|
|
514
|
+
}
|
|
515
|
+
: {}),
|
|
516
|
+
});
|
|
517
|
+
return res.result;
|
|
518
|
+
}
|
|
519
|
+
const error = res.error
|
|
520
|
+
? agentErrorToControlError(res.error)
|
|
521
|
+
: agentErrorToControlError({
|
|
522
|
+
code: 7, // ERROR_CODE_PROTOCOL — an empty result is a protocol violation
|
|
523
|
+
message: "agent returned an empty control response",
|
|
524
|
+
retryable: false,
|
|
525
|
+
detail: {},
|
|
526
|
+
});
|
|
527
|
+
const decision = decideSelfhostedRetry({
|
|
528
|
+
opKind,
|
|
529
|
+
error,
|
|
530
|
+
drainingRetries,
|
|
531
|
+
timeoutRetries,
|
|
532
|
+
neverSentRetries,
|
|
533
|
+
jitter: this.retryClock.jitter(),
|
|
350
534
|
});
|
|
535
|
+
if (decision.action === "fail") {
|
|
536
|
+
const retries = drainingRetries + timeoutRetries + neverSentRetries;
|
|
537
|
+
const encoded = Number(error.detail.encoded_bytes);
|
|
538
|
+
this.emitOp({
|
|
539
|
+
op: opKind,
|
|
540
|
+
outcome: "failed",
|
|
541
|
+
healed: false,
|
|
542
|
+
retries,
|
|
543
|
+
durationMs: Date.now() - startedAt,
|
|
544
|
+
code: error.code,
|
|
545
|
+
reason: error.reason,
|
|
546
|
+
neverSent: error.neverSent,
|
|
547
|
+
machineId: this.agentId,
|
|
548
|
+
faultClass: selfhostedFaultClass(error),
|
|
549
|
+
// Known only on a PAYLOAD_TOO_LARGE fault (the agent's encoded_bytes detail).
|
|
550
|
+
...(Number.isFinite(encoded) ? { replyBytes: encoded } : {}),
|
|
551
|
+
});
|
|
552
|
+
// Fold the retry count into the DRAINING copy when we actually retried, so
|
|
553
|
+
// the surfaced message reads "…retried N times first…".
|
|
554
|
+
if (error.draining && drainingRetries > 0) {
|
|
555
|
+
throw drainingExhaustedError(error, drainingRetries);
|
|
556
|
+
}
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
await this.retryClock.sleep(decision.delayMs);
|
|
560
|
+
// Advance the counter for the class that was retried (separate budgets).
|
|
561
|
+
if (error.neverSent) {
|
|
562
|
+
neverSentRetries += 1;
|
|
563
|
+
} else if (error.draining) {
|
|
564
|
+
drainingRetries += 1;
|
|
565
|
+
} else {
|
|
566
|
+
timeoutRetries += 1;
|
|
567
|
+
}
|
|
351
568
|
}
|
|
352
|
-
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The clamped exec process deadline (ms): the exec-specific budget when threaded,
|
|
572
|
+
* else the control timeout. Shared by `exec()` (the wire deadline) and
|
|
573
|
+
* `execCommand()` (the timed-out hint's "N-second limit"), so the two never drift. */
|
|
574
|
+
private execDeadlineMs(): number {
|
|
575
|
+
// exec gets its OWN (much larger) deadline distinct from the short control
|
|
576
|
+
// timeout — a real command routinely outlives 30s. Falls back to `timeoutMs`
|
|
577
|
+
// when no exec deadline is threaded (unchanged for those callers).
|
|
578
|
+
return Math.max(
|
|
579
|
+
1,
|
|
580
|
+
Math.min(Math.trunc(this.execTimeoutMs ?? this.timeoutMs), SELFHOSTED_MAX_EXEC_TIMEOUT_MS),
|
|
581
|
+
);
|
|
353
582
|
}
|
|
354
583
|
|
|
355
584
|
/** Channel-A `exec`: run a command on the machine and return its output. */
|
|
356
585
|
async exec(args: SelfhostedExecArgs): Promise<SelfhostedExecResult> {
|
|
586
|
+
// Keep the process deadline inside the request/reply deadline. Previously the
|
|
587
|
+
// wire carried timeoutMs=0 (unbounded) while the caller stopped waiting after
|
|
588
|
+
// ~30s, leaving accepted work invisible and able to starve control liveness.
|
|
589
|
+
const executionTimeoutMs = this.execDeadlineMs();
|
|
357
590
|
const execReq: ExecRequest = {
|
|
358
591
|
// The agent does NOT shell-interpret unless `shell` — Channel-A passes a
|
|
359
592
|
// single shell command string, so run it through the platform shell.
|
|
@@ -370,13 +603,116 @@ export class SelfhostedSession {
|
|
|
370
603
|
// seed hook can find the tool surface. See SELFHOSTED_EXEC_ENV_ALLOWLIST.
|
|
371
604
|
env: selfhostedExecEnv(this.state.environment),
|
|
372
605
|
stdin: new Uint8Array(0),
|
|
373
|
-
timeoutMs:
|
|
606
|
+
timeoutMs: executionTimeoutMs,
|
|
374
607
|
};
|
|
375
|
-
|
|
608
|
+
if (this.opStreamClient) {
|
|
609
|
+
try {
|
|
610
|
+
return await this.execViaOpStream(this.opStreamClient, execReq, executionTimeoutMs);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
// The transport had no live connection, or the runner refused the START
|
|
613
|
+
// itself (capability/downgrade race) — the op provably never ran, so
|
|
614
|
+
// the legacy monolithic exec (the permanent fallback wire form) is safe.
|
|
615
|
+
if (!(error instanceof OpStreamUnavailableError)) {
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return this.execLegacy(execReq, executionTimeoutMs);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The legacy monolithic exec request/reply — the permanent fallback wire
|
|
624
|
+
* form (and the only form for runners without `op_stream`). */
|
|
625
|
+
private async execLegacy(
|
|
626
|
+
execReq: ExecRequest,
|
|
627
|
+
executionTimeoutMs: number,
|
|
628
|
+
): Promise<SelfhostedExecResult> {
|
|
629
|
+
const result = await this.call(
|
|
630
|
+
{ $case: "exec", exec: execReq },
|
|
631
|
+
executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS,
|
|
632
|
+
);
|
|
376
633
|
if (result.$case !== "exec") {
|
|
377
634
|
throw new Error(`selfhosted exec: unexpected result ${result.$case}`);
|
|
378
635
|
}
|
|
379
|
-
return execResultToChannelA(result.exec);
|
|
636
|
+
return execResultToChannelA(result.exec, executionTimeoutMs);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The op-stream exec path: the SAME `ExecRequest`, streamed (see
|
|
641
|
+
* op-stream.ts). The durable op id comes from the tool-call correlation
|
|
642
|
+
* context (`{callId}:{ordinal}` — B1) when this exec runs inside an SDK tool
|
|
643
|
+
* invocation; a non-tool caller falls back to a random unique id (never
|
|
644
|
+
* collides, merely not stable across a turn re-dispatch). Emits the SAME
|
|
645
|
+
* per-op observation the legacy path does, with `replyBytes` filled from the
|
|
646
|
+
* reassembled stream (the field the framed transport was designed to own).
|
|
647
|
+
*/
|
|
648
|
+
private async execViaOpStream(
|
|
649
|
+
client: OpStreamExecClient,
|
|
650
|
+
execReq: ExecRequest,
|
|
651
|
+
executionTimeoutMs: number,
|
|
652
|
+
): Promise<SelfhostedExecResult> {
|
|
653
|
+
const startedAt = Date.now();
|
|
654
|
+
const opId = nextDurableOpId() ?? `anon_${crypto.randomUUID()}`;
|
|
655
|
+
try {
|
|
656
|
+
const outcome = await client.exec(
|
|
657
|
+
opId,
|
|
658
|
+
execReq,
|
|
659
|
+
executionTimeoutMs,
|
|
660
|
+
executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS,
|
|
661
|
+
);
|
|
662
|
+
const retries = outcome.heals + outcome.startRetries;
|
|
663
|
+
this.emitOp({
|
|
664
|
+
op: "exec",
|
|
665
|
+
outcome: "ok",
|
|
666
|
+
healed: retries > 0,
|
|
667
|
+
retries,
|
|
668
|
+
durationMs: Date.now() - startedAt,
|
|
669
|
+
machineId: this.agentId,
|
|
670
|
+
replyBytes: outcome.replyBytes,
|
|
671
|
+
// A healed op's class: attach heals are link blips (reconnecting);
|
|
672
|
+
// start retries without heals were admission backpressure (draining).
|
|
673
|
+
...(retries > 0 ? { faultClass: outcome.heals > 0 ? "reconnecting" : "draining" } : {}),
|
|
674
|
+
});
|
|
675
|
+
return execResultToChannelA(outcome.response, executionTimeoutMs);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (error instanceof OpStreamUnavailableError) {
|
|
678
|
+
// Not a fault: the caller falls back to the legacy exec, which emits
|
|
679
|
+
// its own observation.
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
const controlError =
|
|
683
|
+
error instanceof SelfhostedControlError
|
|
684
|
+
? error
|
|
685
|
+
: new SelfhostedControlError({
|
|
686
|
+
message: error instanceof Error ? error.message : String(error),
|
|
687
|
+
code: ErrorCode.ERROR_CODE_PROTOCOL,
|
|
688
|
+
reason: null,
|
|
689
|
+
retryable: false,
|
|
690
|
+
});
|
|
691
|
+
this.emitOp({
|
|
692
|
+
op: "exec",
|
|
693
|
+
outcome: "failed",
|
|
694
|
+
healed: false,
|
|
695
|
+
retries: 0,
|
|
696
|
+
durationMs: Date.now() - startedAt,
|
|
697
|
+
code: controlError.code,
|
|
698
|
+
reason: controlError.reason,
|
|
699
|
+
neverSent: controlError.neverSent,
|
|
700
|
+
machineId: this.agentId,
|
|
701
|
+
faultClass: selfhostedFaultClass(controlError),
|
|
702
|
+
});
|
|
703
|
+
throw controlError;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The turn-end op-stream durability hook: persists each settled op's frontier
|
|
709
|
+
* to the journal, then final-acks it on the wire (licensing the runner to GC
|
|
710
|
+
* its retained frames). The WORKER calls this after the turn's results are
|
|
711
|
+
* durably recorded — never mid-turn (the durable-before-wire-ack ordering).
|
|
712
|
+
* A no-op for sessions without the op-stream transport.
|
|
713
|
+
*/
|
|
714
|
+
async finalizeOpStreamOps(): Promise<void> {
|
|
715
|
+
await this.opStreamClient?.finalizeSettledOps();
|
|
380
716
|
}
|
|
381
717
|
|
|
382
718
|
// ── The agent-turn provided-session contract (over the SAME NATS primitives) ──
|
|
@@ -386,9 +722,22 @@ export class SelfhostedSession {
|
|
|
386
722
|
|
|
387
723
|
/** SDK shell capability `execCommand`: run a command and return its stdout (the
|
|
388
724
|
* `exec_command` tool). Selfhosted exec is non-interactive (no PTY) — `tty` is
|
|
389
|
-
* ignored; `supportsPty()` is false so the SDK never offers a stdin session.
|
|
725
|
+
* ignored; `supportsPty()` is false so the SDK never offers a stdin session.
|
|
726
|
+
*
|
|
727
|
+
* On a DEADLINE timeout the SDK path is stdout-only (the model sees `result.output`
|
|
728
|
+
* and nothing else), so the deadline hint that `exec()` puts on stderr would be
|
|
729
|
+
* invisible — a >deadline command would return `{"output":""}` and the model would
|
|
730
|
+
* conclude "no output" with no idea it was killed. So when the result timed out we
|
|
731
|
+
* surface the hint HERE, on stdout: the hint alone when stdout is empty, or appended
|
|
732
|
+
* after a newline when there is partial output (a timed-out result is already not
|
|
733
|
+
* cleanly parseable — silence is worse than an explanatory suffix). The structured
|
|
734
|
+
* `exec()` result is left untouched for the Channel-A parsers. */
|
|
390
735
|
async execCommand(args: { cmd: string; workdir?: string; runAs?: string }): Promise<string> {
|
|
391
736
|
const result = await this.exec({ cmd: args.cmd, workdir: args.workdir, runAs: args.runAs });
|
|
737
|
+
if (result.timedOut) {
|
|
738
|
+
const hint = execDeadlineHint(Math.round(this.execDeadlineMs() / 1000));
|
|
739
|
+
return result.output ? `${result.output}\n${hint}` : hint;
|
|
740
|
+
}
|
|
392
741
|
return result.output;
|
|
393
742
|
}
|
|
394
743
|
|
|
@@ -403,7 +752,10 @@ export class SelfhostedSession {
|
|
|
403
752
|
* and wrap them in the tool-output image shape (magic-byte sniff + path fallback,
|
|
404
753
|
* mirroring the SDK's `imageOutputFromBytes`). */
|
|
405
754
|
async viewImage(args: { path: string; runAs?: string }): Promise<SelfhostedImageOutput> {
|
|
406
|
-
const bytes = await this.readFile({
|
|
755
|
+
const bytes = await this.readFile({
|
|
756
|
+
path: args.path,
|
|
757
|
+
...(args.runAs ? { runAs: args.runAs } : {}),
|
|
758
|
+
});
|
|
407
759
|
const mediaType = sniffImageMediaType(bytes, args.path);
|
|
408
760
|
if (!mediaType) {
|
|
409
761
|
throw new Error(`selfhosted view_image: unsupported image format for ${args.path}`);
|
|
@@ -418,7 +770,10 @@ export class SelfhostedSession {
|
|
|
418
770
|
}
|
|
419
771
|
|
|
420
772
|
/** SDK skills `listDir`: list a directory as `{name, path, type}[]`. */
|
|
421
|
-
async listDir(args: {
|
|
773
|
+
async listDir(args: {
|
|
774
|
+
path: string;
|
|
775
|
+
runAs?: string;
|
|
776
|
+
}): Promise<Array<{ name: string; path: string; type: "file" | "dir" | "other" }>> {
|
|
422
777
|
const result = await this.listFiles({ path: args.path });
|
|
423
778
|
return result.fsList.entries.map((entry) => ({
|
|
424
779
|
name: entry.name,
|
|
@@ -468,7 +823,10 @@ export class SelfhostedSession {
|
|
|
468
823
|
// EMPTY base) — prefixing workingDir here too would DOUBLE it (the cwd is
|
|
469
824
|
// already workingDir). A non-virtual absolute path passes through and rm
|
|
470
825
|
// uses it as-is; an empty workingDir is byte-identical to before.
|
|
471
|
-
await this.exec({
|
|
826
|
+
await this.exec({
|
|
827
|
+
cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`,
|
|
828
|
+
...(runAs ? { runAs } : {}),
|
|
829
|
+
});
|
|
472
830
|
};
|
|
473
831
|
return {
|
|
474
832
|
async createFile(operation) {
|
|
@@ -512,7 +870,12 @@ export class SelfhostedSession {
|
|
|
512
870
|
}
|
|
513
871
|
|
|
514
872
|
/** Write a file onto the machine (the fs surface the descriptor advertises). */
|
|
515
|
-
async writeFile(args: {
|
|
873
|
+
async writeFile(args: {
|
|
874
|
+
path: string;
|
|
875
|
+
content: string | Uint8Array;
|
|
876
|
+
createParents?: boolean;
|
|
877
|
+
append?: boolean;
|
|
878
|
+
}): Promise<number> {
|
|
516
879
|
const content = typeof args.content === "string" ? encoder.encode(args.content) : args.content;
|
|
517
880
|
const result = await this.call({
|
|
518
881
|
$case: "fsWrite",
|
|
@@ -531,10 +894,16 @@ export class SelfhostedSession {
|
|
|
531
894
|
}
|
|
532
895
|
|
|
533
896
|
/** List a directory on the machine. */
|
|
534
|
-
async listFiles(args: {
|
|
897
|
+
async listFiles(args: {
|
|
898
|
+
path: string;
|
|
899
|
+
recursive?: boolean;
|
|
900
|
+
}): Promise<NonNullable<ControlResponse["result"]> & { $case: "fsList" }> {
|
|
535
901
|
const result = await this.call({
|
|
536
902
|
$case: "fsList",
|
|
537
|
-
fsList: {
|
|
903
|
+
fsList: {
|
|
904
|
+
path: toMachinePath(args.path, this.workingDir),
|
|
905
|
+
recursive: args.recursive ?? false,
|
|
906
|
+
},
|
|
538
907
|
});
|
|
539
908
|
if (result.$case !== "fsList") {
|
|
540
909
|
throw new Error(`selfhosted listFiles: unexpected result ${result.$case}`);
|
|
@@ -544,7 +913,10 @@ export class SelfhostedSession {
|
|
|
544
913
|
|
|
545
914
|
/** Stat a path on the machine. */
|
|
546
915
|
async statFile(args: { path: string }): Promise<{ exists: boolean }> {
|
|
547
|
-
const result = await this.call({
|
|
916
|
+
const result = await this.call({
|
|
917
|
+
$case: "fsStat",
|
|
918
|
+
fsStat: { path: toMachinePath(args.path, this.workingDir) },
|
|
919
|
+
});
|
|
548
920
|
if (result.$case !== "fsStat") {
|
|
549
921
|
throw new Error(`selfhosted statFile: unexpected result ${result.$case}`);
|
|
550
922
|
}
|
|
@@ -648,7 +1020,9 @@ export class SelfhostedSession {
|
|
|
648
1020
|
desktopEnsure: { width: 0, height: 0 },
|
|
649
1021
|
});
|
|
650
1022
|
if (result.$case !== "desktopEnsure") {
|
|
651
|
-
throw new Error(
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`,
|
|
1025
|
+
);
|
|
652
1026
|
}
|
|
653
1027
|
channel = result.desktopEnsure.channel;
|
|
654
1028
|
} else {
|
|
@@ -660,10 +1034,19 @@ export class SelfhostedSession {
|
|
|
660
1034
|
// Open the terminal in the session workingDir (default "" ⇒ the agent's
|
|
661
1035
|
// workspace_root, byte-identical to before). A relative workingDir resolves
|
|
662
1036
|
// under workspace_root; an absolute one is used as-is by the agent.
|
|
663
|
-
ptyOpen: {
|
|
1037
|
+
ptyOpen: {
|
|
1038
|
+
command: [],
|
|
1039
|
+
cwd: this.workingDir,
|
|
1040
|
+
env: {},
|
|
1041
|
+
cols: 0,
|
|
1042
|
+
rows: 0,
|
|
1043
|
+
term: "xterm-256color",
|
|
1044
|
+
},
|
|
664
1045
|
});
|
|
665
1046
|
if (result.$case !== "ptyOpen") {
|
|
666
|
-
throw new Error(
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`,
|
|
1049
|
+
);
|
|
667
1050
|
}
|
|
668
1051
|
channel = result.ptyOpen.channel;
|
|
669
1052
|
}
|
|
@@ -716,8 +1099,11 @@ export class SelfhostedSandboxClient {
|
|
|
716
1099
|
private readonly defaultAgentId: string | undefined;
|
|
717
1100
|
private readonly epoch: number | undefined;
|
|
718
1101
|
private readonly timeoutMs: number | undefined;
|
|
1102
|
+
private readonly execTimeoutMs: number | undefined;
|
|
719
1103
|
private readonly environment: Record<string, string> | undefined;
|
|
720
1104
|
private readonly workingDir: string | undefined;
|
|
1105
|
+
private readonly onOp: SelfhostedOpObserver | undefined;
|
|
1106
|
+
private readonly opStream: SelfhostedOpStreamDeps | undefined;
|
|
721
1107
|
private controlRpcMemo: ControlRpc | undefined;
|
|
722
1108
|
|
|
723
1109
|
constructor(opts: {
|
|
@@ -729,7 +1115,11 @@ export class SelfhostedSandboxClient {
|
|
|
729
1115
|
* resume path supplies it via deserializeSessionState. */
|
|
730
1116
|
agentId?: string;
|
|
731
1117
|
epoch?: number;
|
|
1118
|
+
/** The control-op timeout threaded into every bound session. */
|
|
732
1119
|
timeoutMs?: number;
|
|
1120
|
+
/** The exec process deadline threaded into every bound session (distinct from
|
|
1121
|
+
* `timeoutMs`; falls back to it when unset). See SelfhostedSessionDeps.execTimeoutMs. */
|
|
1122
|
+
execTimeoutMs?: number;
|
|
733
1123
|
/** The run's declared sandbox environment, threaded into every bound session's
|
|
734
1124
|
* `state.manifest.environment` so the SDK's per-turn manifest-env delta is
|
|
735
1125
|
* empty (validateNoEnvironmentDelta). See SelfhostedSessionDeps.environment.
|
|
@@ -739,6 +1129,11 @@ export class SelfhostedSandboxClient {
|
|
|
739
1129
|
* cwd base; see SelfhostedSessionDeps.workingDir). Omitted/empty ⇒ the default
|
|
740
1130
|
* workspace_root behavior. */
|
|
741
1131
|
workingDir?: string;
|
|
1132
|
+
/** The per-op observer threaded into every bound session (out-of-band telemetry). */
|
|
1133
|
+
onOp?: SelfhostedOpObserver;
|
|
1134
|
+
/** The op-stream exec transport threaded into every bound session (present
|
|
1135
|
+
* = enabled; see SelfhostedOpStreamDeps). */
|
|
1136
|
+
opStream?: SelfhostedOpStreamDeps;
|
|
742
1137
|
}) {
|
|
743
1138
|
this.workspaceId = opts.workspaceId;
|
|
744
1139
|
this.relay = opts.relay;
|
|
@@ -746,8 +1141,11 @@ export class SelfhostedSandboxClient {
|
|
|
746
1141
|
this.defaultAgentId = opts.agentId;
|
|
747
1142
|
this.epoch = opts.epoch;
|
|
748
1143
|
this.timeoutMs = opts.timeoutMs;
|
|
1144
|
+
this.execTimeoutMs = opts.execTimeoutMs;
|
|
749
1145
|
this.environment = opts.environment;
|
|
750
1146
|
this.workingDir = opts.workingDir;
|
|
1147
|
+
this.onOp = opts.onOp;
|
|
1148
|
+
this.opStream = opts.opStream;
|
|
751
1149
|
}
|
|
752
1150
|
|
|
753
1151
|
private controlRpc(): ControlRpc {
|
|
@@ -765,8 +1163,11 @@ export class SelfhostedSandboxClient {
|
|
|
765
1163
|
relay: this.relay,
|
|
766
1164
|
...(this.epoch !== undefined ? { epoch: this.epoch } : {}),
|
|
767
1165
|
...(this.timeoutMs !== undefined ? { timeoutMs: this.timeoutMs } : {}),
|
|
1166
|
+
...(this.execTimeoutMs !== undefined ? { execTimeoutMs: this.execTimeoutMs } : {}),
|
|
768
1167
|
...(this.environment !== undefined ? { environment: this.environment } : {}),
|
|
769
1168
|
...(this.workingDir !== undefined ? { workingDir: this.workingDir } : {}),
|
|
1169
|
+
...(this.onOp !== undefined ? { onOp: this.onOp } : {}),
|
|
1170
|
+
...(this.opStream !== undefined ? { opStream: this.opStream } : {}),
|
|
770
1171
|
});
|
|
771
1172
|
}
|
|
772
1173
|
|
|
@@ -777,13 +1178,18 @@ export class SelfhostedSandboxClient {
|
|
|
777
1178
|
}
|
|
778
1179
|
|
|
779
1180
|
/** Resume = re-address the subject. Identical to create — no provider state. */
|
|
780
|
-
async resume(
|
|
1181
|
+
async resume(
|
|
1182
|
+
state: SelfhostedSessionState | Record<string, unknown>,
|
|
1183
|
+
_options?: unknown,
|
|
1184
|
+
): Promise<SelfhostedSession> {
|
|
781
1185
|
const agentId = readAgentId(state) ?? this.requireAgentId();
|
|
782
1186
|
return this.bind(agentId);
|
|
783
1187
|
}
|
|
784
1188
|
|
|
785
1189
|
/** Serialize a live session's state → `{agentId}` ONLY. */
|
|
786
|
-
async serializeSessionState(
|
|
1190
|
+
async serializeSessionState(
|
|
1191
|
+
state: SelfhostedSessionState | { agentId?: string } | unknown,
|
|
1192
|
+
): Promise<SelfhostedSessionState> {
|
|
787
1193
|
const agentId = readAgentId(state) ?? this.requireAgentId();
|
|
788
1194
|
return { agentId };
|
|
789
1195
|
}
|
|
@@ -802,7 +1208,9 @@ export class SelfhostedSandboxClient {
|
|
|
802
1208
|
|
|
803
1209
|
private requireAgentId(): string {
|
|
804
1210
|
if (!this.defaultAgentId) {
|
|
805
|
-
throw new Error(
|
|
1211
|
+
throw new Error(
|
|
1212
|
+
"selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)",
|
|
1213
|
+
);
|
|
806
1214
|
}
|
|
807
1215
|
return this.defaultAgentId;
|
|
808
1216
|
}
|
|
@@ -832,8 +1240,17 @@ export interface SelfhostedSessionBuild {
|
|
|
832
1240
|
environment?: Record<string, string>;
|
|
833
1241
|
/** The session working directory (the path/cwd base). Null/absent ⇒ workspace_root. */
|
|
834
1242
|
workingDir?: string | null;
|
|
835
|
-
/**
|
|
1243
|
+
/** The control-op timeout (ping/fs/desktop/pty). Absent ⇒ the 30s default. */
|
|
836
1244
|
timeoutMs?: number;
|
|
1245
|
+
/** The exec process deadline, distinct from `timeoutMs`. Absent ⇒ falls back to
|
|
1246
|
+
* `timeoutMs` (a real turn threads the long exec budget here). */
|
|
1247
|
+
execTimeoutMs?: number;
|
|
1248
|
+
/** The per-op observer (out-of-band telemetry). Absent ⇒ no-op. */
|
|
1249
|
+
onOp?: SelfhostedOpObserver;
|
|
1250
|
+
/** The op-stream exec transport (present = enabled for the session; the
|
|
1251
|
+
* worker injects it only when the runner advertised the capability AND the
|
|
1252
|
+
* server flag is on). Absent ⇒ the legacy exec, byte-identical to before. */
|
|
1253
|
+
opStream?: SelfhostedOpStreamDeps;
|
|
837
1254
|
}
|
|
838
1255
|
|
|
839
1256
|
/**
|
|
@@ -861,8 +1278,11 @@ export async function buildSelfhostedBackendSession(
|
|
|
861
1278
|
agentId: deps.agentId,
|
|
862
1279
|
epoch: deps.epoch,
|
|
863
1280
|
...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
|
|
1281
|
+
...(deps.execTimeoutMs !== undefined ? { execTimeoutMs: deps.execTimeoutMs } : {}),
|
|
1282
|
+
...(deps.onOp !== undefined ? { onOp: deps.onOp } : {}),
|
|
864
1283
|
...(deps.environment !== undefined ? { environment: deps.environment } : {}),
|
|
865
1284
|
...(deps.workingDir ? { workingDir: deps.workingDir } : {}),
|
|
1285
|
+
...(deps.opStream !== undefined ? { opStream: deps.opStream } : {}),
|
|
866
1286
|
});
|
|
867
1287
|
const session = await client.resume({ agentId: deps.agentId });
|
|
868
1288
|
return { client, session };
|
|
@@ -870,8 +1290,9 @@ export async function buildSelfhostedBackendSession(
|
|
|
870
1290
|
|
|
871
1291
|
function readAgentId(state: unknown): string | undefined {
|
|
872
1292
|
if (state && typeof state === "object") {
|
|
873
|
-
const candidate =
|
|
874
|
-
|
|
1293
|
+
const candidate =
|
|
1294
|
+
(state as { agentId?: unknown }).agentId ??
|
|
1295
|
+
(state as { providerState?: { agentId?: unknown } }).providerState?.agentId;
|
|
875
1296
|
if (typeof candidate === "string" && candidate.length > 0) {
|
|
876
1297
|
return candidate;
|
|
877
1298
|
}
|
|
@@ -879,14 +1300,23 @@ function readAgentId(state: unknown): string | undefined {
|
|
|
879
1300
|
return undefined;
|
|
880
1301
|
}
|
|
881
1302
|
|
|
882
|
-
function execResultToChannelA(res: ExecResponse): SelfhostedExecResult {
|
|
1303
|
+
function execResultToChannelA(res: ExecResponse, execDeadlineMs: number): SelfhostedExecResult {
|
|
883
1304
|
const stdout = decoder.decode(res.stdout);
|
|
884
|
-
|
|
1305
|
+
let stderr = decoder.decode(res.stderr);
|
|
1306
|
+
if (res.timedOut) {
|
|
1307
|
+
// The agent killed the child at the exec deadline. Surface an actionable hint
|
|
1308
|
+
// on STDERR ONLY — stdout is left byte-exact because the structural Channel-A
|
|
1309
|
+
// surface parses command stdout (find/stat/git listings); a hint injected there
|
|
1310
|
+
// would corrupt those parsers.
|
|
1311
|
+
const hint = execDeadlineHint(Math.round(execDeadlineMs / 1000));
|
|
1312
|
+
stderr = stderr ? `${stderr}\n${hint}` : hint;
|
|
1313
|
+
}
|
|
885
1314
|
return {
|
|
886
1315
|
output: stdout,
|
|
887
1316
|
stdout,
|
|
888
1317
|
stderr,
|
|
889
1318
|
exitCode: res.exitCode,
|
|
1319
|
+
timedOut: res.timedOut,
|
|
890
1320
|
};
|
|
891
1321
|
}
|
|
892
1322
|
|
|
@@ -904,24 +1334,37 @@ function shellQuote(value: string): string {
|
|
|
904
1334
|
* mirroring @openai/agents-core's `sniffImageMediaType` so `viewImage` returns the
|
|
905
1335
|
* SAME media types the SDK would. Returns undefined for an unrecognized format. */
|
|
906
1336
|
function sniffImageMediaType(bytes: Uint8Array, path: string): string | undefined {
|
|
907
|
-
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
1337
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
1338
|
+
return "image/png";
|
|
908
1339
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
909
|
-
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
|
|
1340
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
|
|
1341
|
+
return "image/gif";
|
|
910
1342
|
if (
|
|
911
|
-
bytes[0] === 0x52 &&
|
|
912
|
-
bytes[
|
|
913
|
-
|
|
1343
|
+
bytes[0] === 0x52 &&
|
|
1344
|
+
bytes[1] === 0x49 &&
|
|
1345
|
+
bytes[2] === 0x46 &&
|
|
1346
|
+
bytes[3] === 0x46 &&
|
|
1347
|
+
bytes[8] === 0x57 &&
|
|
1348
|
+
bytes[9] === 0x45 &&
|
|
1349
|
+
bytes[10] === 0x42 &&
|
|
1350
|
+
bytes[11] === 0x50
|
|
1351
|
+
)
|
|
1352
|
+
return "image/webp";
|
|
914
1353
|
if (bytes[0] === 0x42 && bytes[1] === 0x4d) return "image/bmp";
|
|
915
1354
|
if (
|
|
916
1355
|
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
|
|
917
1356
|
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
|
|
918
|
-
)
|
|
1357
|
+
)
|
|
1358
|
+
return "image/tiff";
|
|
919
1359
|
if (looksLikeSvg(bytes)) return "image/svg+xml";
|
|
920
1360
|
return mediaTypeFromPath(path);
|
|
921
1361
|
}
|
|
922
1362
|
|
|
923
1363
|
function looksLikeSvg(bytes: Uint8Array): boolean {
|
|
924
|
-
const prefix = decoder
|
|
1364
|
+
const prefix = decoder
|
|
1365
|
+
.decode(bytes.subarray(0, Math.min(bytes.byteLength, 512)))
|
|
1366
|
+
.trimStart()
|
|
1367
|
+
.toLowerCase();
|
|
925
1368
|
return prefix.startsWith("<svg") || /^<\?xml[\s\S]*<svg/u.test(prefix);
|
|
926
1369
|
}
|
|
927
1370
|
|