From 183faccbefd4fc551a18735f891a2d75dfedc1d8 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 24 Jul 2026 18:25:43 -0600 Subject: [PATCH 1/3] feat(runtime): stream persistent sandbox events --- src/runtime/run-loop.ts | 76 ++---------------------- src/runtime/sandbox-events.ts | 49 ++++++++++++++++ src/runtime/sandbox-run.ts | 26 +++++++- tests/runtime/sandbox-run.test.ts | 98 +++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 73 deletions(-) diff --git a/src/runtime/run-loop.ts b/src/runtime/run-loop.ts index 31edfc38..9de866c7 100644 --- a/src/runtime/run-loop.ts +++ b/src/runtime/run-loop.ts @@ -30,7 +30,7 @@ import { notifyRuntimeHookEvent } from '../runtime-hooks' import { acquireSandbox } from './sandbox-acquire' import { buildBackendOptions } from './sandbox-backend' import { probeSandboxCapabilities } from './sandbox-capabilities' -import { extractLlmCallEvent } from './sandbox-events' +import { extractLlmCallEvent, notifySandboxEventObserver } from './sandbox-events' import { createSandboxLineage, promptEvents, @@ -749,28 +749,10 @@ async function executeIteration(args: ExecuteIterationArgs).then === 'function') { - void (result as PromiseLike).then(undefined, () => {}) - } - } catch { - // Non-critical telemetry — never let it interrupt the stream. - } - } + notifySandboxEventObserver(event, args.ctx.onSandboxEvent, { + iterationIndex: args.item.index, + agentRunName: slot.agentRunName, + }) const llmCall = extractLlmCallEvent(event, slot.agentRunName) if (llmCall) { slot.costUsd += llmCall.costUsd ?? 0 @@ -1232,54 +1214,6 @@ async function emitTrace( await emitter.emit(event) } -/** - * Defensive copy of a sandbox event for the per-event observer tee. Prefers - * `structuredClone` for full deep isolation. `event.data` is - * `Record`, so it may carry a non-cloneable leaf (a function - * or stream) that makes `structuredClone` throw; in that case fall back to a - * recursive copy of the plain-object/array spine. That still isolates every - * field the run reads (output.parse reads `event.data.*`; cost accounting reads - * `event.data.usage.*` and `event.data.tokenUsage.*`). Function leaves are - * shared by reference (the run never reads them) and non-plain containers are - * replaced by inert placeholders, so the observer shares no mutable object the - * run consumes. - */ -function cloneEventForObserver(event: SandboxEvent): SandboxEvent { - try { - return structuredClone(event) - } catch { - return copyPlainSpine(event, new WeakMap()) as SandboxEvent - } -} - -/** - * Recursively copy the plain-object/array spine of `value`, sharing only - * primitives and functions (which the run never reads) by reference. `seen` - * maps each original object to its copy and is populated before recursing into - * children, so a cycle or a repeated reference resolves to the copy — never the - * original. A non-plain object (a Map, class instance, etc. — the kind of value - * that made `structuredClone` throw and that can't be generically deep-copied) - * is replaced by an inert empty object rather than shared by reference, so the - * observer can never reach a mutable container the run reads. - */ -function copyPlainSpine(value: unknown, seen: WeakMap): unknown { - if (value === null || typeof value !== 'object') return value - const existing = seen.get(value) - if (existing !== undefined) return existing - if (Array.isArray(value)) { - const copy: unknown[] = [] - seen.set(value, copy) - for (const item of value) copy.push(copyPlainSpine(item, seen)) - return copy - } - const proto = Object.getPrototypeOf(value) - if (proto !== Object.prototype && proto !== null) return {} - const copy: Record = {} - seen.set(value, copy) - for (const [key, v] of Object.entries(value)) copy[key] = copyPlainSpine(v, seen) - return copy -} - /** * Stable hash for the trace payload. Not cryptographic — only used so * downstream eval pipelines can group iterations whose task / output is the diff --git a/src/runtime/sandbox-events.ts b/src/runtime/sandbox-events.ts index 78397825..67f67bf7 100644 --- a/src/runtime/sandbox-events.ts +++ b/src/runtime/sandbox-events.ts @@ -15,6 +15,55 @@ import type { SandboxEvent } from '@tangle-network/sandbox' import type { RuntimeStreamEvent } from '../types' +/** + * Forward a sandbox event to an optional observer without letting observer + * behavior affect the run. The observer receives a defensive copy, synchronous + * throws are swallowed, and returned promises are deliberately not awaited. + */ +export function notifySandboxEventObserver( + event: SandboxEvent, + observer: ((event: SandboxEvent, meta: Meta) => void | PromiseLike) | undefined, + meta: Meta, +): void { + if (!observer) return + try { + const result = observer(cloneEventForObserver(event), meta) + if (result && typeof result.then === 'function') { + void result.then(undefined, () => {}) + } + } catch { + // Live observation is optional and must never interrupt the event stream. + } +} + +function cloneEventForObserver(event: SandboxEvent): SandboxEvent { + try { + return structuredClone(event) + } catch { + return copyPlainSpine(event, new WeakMap()) as SandboxEvent + } +} + +function copyPlainSpine(value: unknown, seen: WeakMap): unknown { + if (value === null || typeof value !== 'object') return value + const existing = seen.get(value) + if (existing !== undefined) return existing + if (Array.isArray(value)) { + const copy: unknown[] = [] + seen.set(value, copy) + for (const item of value) copy.push(copyPlainSpine(item, seen)) + return copy + } + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) return {} + const copy: Record = {} + seen.set(value, copy) + for (const [key, child] of Object.entries(value)) { + copy[key] = copyPlainSpine(child, seen) + } + return copy +} + /** * Extract a `RuntimeStreamEvent`-shaped `llm_call` from a sandbox event when * the event carries usage/cost data. Returns `undefined` for non-cost events diff --git a/src/runtime/sandbox-run.ts b/src/runtime/sandbox-run.ts index 8dc2b4da..1f97125a 100644 --- a/src/runtime/sandbox-run.ts +++ b/src/runtime/sandbox-run.ts @@ -35,6 +35,7 @@ import type { PromptOptions, SandboxEvent, SandboxInstance } from '@tangle-netwo import type { RuntimeHooks, RuntimeHookTarget } from '../runtime-hooks' import { notifyRuntimeHookEvent } from '../runtime-hooks' import { probeSandboxCapabilities } from './sandbox-capabilities' +import { notifySandboxEventObserver } from './sandbox-events' import { createSandboxLineage, type SandboxLineageHandle } from './sandbox-lineage' import type { AgentRunSpec, SandboxClient } from './types' import { isAbortError, randomSuffix, sleep } from './util' @@ -137,6 +138,16 @@ export interface OpenSandboxRunOptions { * box/session and before the first prompt stream is consumed. A thrown error * fails the turn before the agent spends tokens. */ beforeStart?: (ctx: OpenSandboxRunBeforeStartContext) => Promise | void + /** Receives a defensive copy of every streamed event. Observer work is + * non-blocking; synchronous throws and rejected promises never fail the run. */ + onSandboxEvent?: ( + event: SandboxEvent, + meta: { + turnIndex: number + turnKind: 'start' | 'resume' + agentRunName: string + }, + ) => void | PromiseLike /** Test seam for deterministic hook timestamps. Defaults to `Date.now`. */ now?: () => number /** Bounds box-creation bursts inside lineage fanout. Default from lineage. */ @@ -234,12 +245,21 @@ export async function openSandboxRun( async function settle( box: SandboxInstance, events: AsyncIterable, + turnIndex: number, + turnKind: 'start' | 'resume', ): Promise> { const collected: SandboxEvent[] = [] // The stream itself can throw an AbortError when the run is cancelled mid-drain; // re-throw it carrying the events drained so far so the partial trace is not lost. try { - for await (const ev of events) collected.push(ev) + for await (const ev of events) { + collected.push(ev) + notifySandboxEventObserver(ev, options.onSandboxEvent, { + turnIndex, + turnKind, + agentRunName: options.agentRun.name ?? options.agentRun.profile.name ?? 'agent', + }) + } } catch (err) { if (isAbortError(err)) throw new SandboxRunAbortError(collected) throw err @@ -321,7 +341,7 @@ export async function openSandboxRun( sessionId: handle.sessionId, signal: options.signal, }) - const result = await settle(handle.box, r.events) + const result = await settle(handle.box, r.events, stepIndex, 'start') turnCount += 1 emit({ target: 'agent.turn', @@ -364,6 +384,8 @@ export async function openSandboxRun( const result = await settle( handle.box, await lineage.continue(handle, prompt, options.signal, options.promptOptions), + stepIndex, + 'resume', ) turnCount += 1 emit({ diff --git a/tests/runtime/sandbox-run.test.ts b/tests/runtime/sandbox-run.test.ts index 46be5735..7bffd1f5 100644 --- a/tests/runtime/sandbox-run.test.ts +++ b/tests/runtime/sandbox-run.test.ts @@ -156,6 +156,104 @@ describe('openSandboxRun — events deliverable', () => { }) }) +describe('openSandboxRun — live sandbox event observer', () => { + it('forwards start and resume events with stable turn metadata', async () => { + const streamed = [ + { type: 'token', data: { text: 'working' } } as SandboxEvent, + { type: 'result', data: { text: 'done' } } as SandboxEvent, + ] + const { client } = createFakeClient({ events: streamed, sessionLive: true }) + const seen: Array<{ + type: string + turnIndex: number + turnKind: 'start' | 'resume' + agentRunName: string + }> = [] + const run = await openSandboxRun( + client, + { + agentRun: spec('researcher'), + signal: new AbortController().signal, + onSandboxEvent: (event, meta) => { + seen.push({ type: event.type, ...meta }) + }, + }, + eventsDeliverable, + ) + + await run.start('turn 1') + await run.resume('turn 2') + + expect(seen).toEqual([ + { type: 'token', turnIndex: 0, turnKind: 'start', agentRunName: 'researcher' }, + { type: 'result', turnIndex: 0, turnKind: 'start', agentRunName: 'researcher' }, + { type: 'token', turnIndex: 1, turnKind: 'resume', agentRunName: 'researcher' }, + { type: 'result', turnIndex: 1, turnKind: 'resume', agentRunName: 'researcher' }, + ]) + }) + + it('protects collected events from observer mutation', async () => { + const streamed = [ + { + type: 'result', + data: { text: 'original', usage: { inputTokens: 3 } }, + } as SandboxEvent, + ] + const { client } = createFakeClient({ events: streamed }) + const run = await openSandboxRun( + client, + { + agentRun: spec(), + signal: new AbortController().signal, + onSandboxEvent: (event) => { + const data = event.data as { + text: string + usage: { inputTokens: number } + } + data.text = 'mutated' + data.usage.inputTokens = 999 + }, + }, + eventsDeliverable, + ) + + const turn = await run.start('go') + + expect(turn.events[0]?.data).toEqual({ + text: 'original', + usage: { inputTokens: 3 }, + }) + expect(turn.out).toEqual({ text: 'original' }) + }) + + it('does not wait for observers or fail when they throw or reject', async () => { + const streamed = [ + { type: 'token', data: { text: 'one' } } as SandboxEvent, + { type: 'token', data: { text: 'two' } } as SandboxEvent, + { type: 'result', data: { text: 'done' } } as SandboxEvent, + ] + const { client } = createFakeClient({ events: streamed }) + let calls = 0 + const run = await openSandboxRun( + client, + { + agentRun: spec(), + signal: new AbortController().signal, + onSandboxEvent: () => { + calls += 1 + if (calls === 1) throw new Error('observer threw') + if (calls === 2) return Promise.reject(new Error('observer rejected')) + return new Promise(() => {}) + }, + }, + eventsDeliverable, + ) + + await expect(run.start('go')).resolves.toMatchObject({ out: { text: 'done' } }) + expect(calls).toBe(3) + }) +}) + describe('openSandboxRun — artifact deliverable (the file-read seam over OutputAdapter)', () => { it('reads the workspace-relative artifact after the turn drains and maps it', async () => { const { client, readPaths } = createFakeClient({ fsRead: () => 'PATCH-CONTENT' }) From bed58a760bfb005b30529f7b9fb723a6ba75e2a2 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 24 Jul 2026 18:34:24 -0600 Subject: [PATCH 2/3] docs(api): refresh runtime reference --- docs/api/runtime.md | 119 ++++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 177295c3..2794fc02 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -514,7 +514,7 @@ Query accreted facts by filter — most-confident first. Returns the matching re ### SandboxRunAbortError -Defined in: [src/runtime/sandbox-run.ts:79](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L79) +Defined in: [src/runtime/sandbox-run.ts:80](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L80) **`Experimental`** @@ -536,7 +536,7 @@ loop kernel, scope, supervise runtime) keep matching it unchanged. > **new SandboxRunAbortError**(`events`, `readError?`): [`SandboxRunAbortError`](#sandboxrunaborterror) -Defined in: [src/runtime/sandbox-run.ts:85](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L85) +Defined in: [src/runtime/sandbox-run.ts:86](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L86) **`Experimental`** @@ -564,7 +564,7 @@ Defined in: [src/runtime/sandbox-run.ts:85](https://github.com/tangle-network/ag > `readonly` **name**: `"AbortError"` = `'AbortError'` -Defined in: [src/runtime/sandbox-run.ts:80](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L80) +Defined in: [src/runtime/sandbox-run.ts:81](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L81) **`Experimental`** @@ -576,7 +576,7 @@ Defined in: [src/runtime/sandbox-run.ts:80](https://github.com/tangle-network/ag > `readonly` **events**: `SandboxEvent`[] -Defined in: [src/runtime/sandbox-run.ts:82](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L82) +Defined in: [src/runtime/sandbox-run.ts:83](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L83) **`Experimental`** @@ -586,7 +586,7 @@ Events drained from the stream before the abort interrupted the turn. > `readonly` `optional` **readError?**: `string` -Defined in: [src/runtime/sandbox-run.ts:84](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L84) +Defined in: [src/runtime/sandbox-run.ts:85](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L85) **`Experimental`** @@ -6676,7 +6676,7 @@ Defined in: [src/runtime/sandbox-capabilities.ts:75](https://github.com/tangle-n ### SandboxToolPartState -Defined in: [src/runtime/sandbox-events.ts:147](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L147) +Defined in: [src/runtime/sandbox-events.ts:196](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L196) **`Experimental`** @@ -6693,7 +6693,7 @@ state per turn via [createSandboxToolPartState](#createsandboxtoolpartstate). > **statusByCall**: `Map`\<`string`, `string`\> -Defined in: [src/runtime/sandbox-events.ts:150](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L150) +Defined in: [src/runtime/sandbox-events.ts:199](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L199) **`Experimental`** @@ -6704,7 +6704,7 @@ Last seen status per tool call id. A terminal status is sticky — later > **seq**: `number` -Defined in: [src/runtime/sandbox-events.ts:152](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L152) +Defined in: [src/runtime/sandbox-events.ts:201](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L201) **`Experimental`** @@ -7027,7 +7027,7 @@ Defined in: [src/runtime/sandbox-lineage.ts:416](https://github.com/tangle-netwo ### TurnResult -Defined in: [src/runtime/sandbox-run.ts:62](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L62) +Defined in: [src/runtime/sandbox-run.ts:63](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L63) **`Experimental`** @@ -7047,7 +7047,7 @@ nothing" from a transport/FS fault. > **out**: `Out` -Defined in: [src/runtime/sandbox-run.ts:63](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L63) +Defined in: [src/runtime/sandbox-run.ts:64](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L64) **`Experimental`** @@ -7055,7 +7055,7 @@ Defined in: [src/runtime/sandbox-run.ts:63](https://github.com/tangle-network/ag > **events**: `SandboxEvent`[] -Defined in: [src/runtime/sandbox-run.ts:64](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L64) +Defined in: [src/runtime/sandbox-run.ts:65](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L65) **`Experimental`** @@ -7063,7 +7063,7 @@ Defined in: [src/runtime/sandbox-run.ts:64](https://github.com/tangle-network/ag > `optional` **readError?**: `string` -Defined in: [src/runtime/sandbox-run.ts:65](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L65) +Defined in: [src/runtime/sandbox-run.ts:66](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L66) **`Experimental`** @@ -7071,7 +7071,7 @@ Defined in: [src/runtime/sandbox-run.ts:65](https://github.com/tangle-network/ag ### SandboxRun -Defined in: [src/runtime/sandbox-run.ts:94](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L94) +Defined in: [src/runtime/sandbox-run.ts:95](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L95) **`Experimental`** @@ -7090,7 +7090,7 @@ A live run over ONE persistent artifact (box + session). Close it > `readonly` **box**: `SandboxInstance` -Defined in: [src/runtime/sandbox-run.ts:95](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L95) +Defined in: [src/runtime/sandbox-run.ts:96](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L96) **`Experimental`** @@ -7098,7 +7098,7 @@ Defined in: [src/runtime/sandbox-run.ts:95](https://github.com/tangle-network/ag > `readonly` **sessionId**: `string` -Defined in: [src/runtime/sandbox-run.ts:96](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L96) +Defined in: [src/runtime/sandbox-run.ts:97](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L97) **`Experimental`** @@ -7108,7 +7108,7 @@ Defined in: [src/runtime/sandbox-run.ts:96](https://github.com/tangle-network/ag > **start**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> -Defined in: [src/runtime/sandbox-run.ts:98](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L98) +Defined in: [src/runtime/sandbox-run.ts:99](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L99) **`Experimental`** @@ -7128,7 +7128,7 @@ First turn over the fresh box (mints the session). Throws if already started. > **resume**(`prompt`): `Promise`\<[`TurnResult`](#turnresult)\<`Out`\>\> -Defined in: [src/runtime/sandbox-run.ts:100](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L100) +Defined in: [src/runtime/sandbox-run.ts:101](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L101) **`Experimental`** @@ -7148,7 +7148,7 @@ Continue THE SAME session over THE SAME artifact — a resumed turn/rollout. > **close**(): `Promise`\<`void`\> -Defined in: [src/runtime/sandbox-run.ts:101](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L101) +Defined in: [src/runtime/sandbox-run.ts:102](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L102) **`Experimental`** @@ -7160,7 +7160,7 @@ Defined in: [src/runtime/sandbox-run.ts:101](https://github.com/tangle-network/a ### OpenSandboxRunBeforeStartContext -Defined in: [src/runtime/sandbox-run.ts:116](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L116) +Defined in: [src/runtime/sandbox-run.ts:117](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L117) Context available after the box/session exists and before the first prompt is drained. Intended for benchmark-owned workspace setup such as cloning a repo @@ -7172,25 +7172,25 @@ into a fixed path. > `readonly` **box**: `SandboxInstance` -Defined in: [src/runtime/sandbox-run.ts:117](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L117) +Defined in: [src/runtime/sandbox-run.ts:118](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L118) ##### sessionId > `readonly` **sessionId**: `string` -Defined in: [src/runtime/sandbox-run.ts:118](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L118) +Defined in: [src/runtime/sandbox-run.ts:119](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L119) ##### signal > `readonly` **signal**: `AbortSignal` -Defined in: [src/runtime/sandbox-run.ts:119](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L119) +Defined in: [src/runtime/sandbox-run.ts:120](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L120) *** ### OpenSandboxRunOptions -Defined in: [src/runtime/sandbox-run.ts:123](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L123) +Defined in: [src/runtime/sandbox-run.ts:124](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L124) **`Experimental`** @@ -7200,7 +7200,7 @@ Defined in: [src/runtime/sandbox-run.ts:123](https://github.com/tangle-network/a > **agentRun**: [`AgentRunSpec`](#agentrunspec)\<`string`\> -Defined in: [src/runtime/sandbox-run.ts:125](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L125) +Defined in: [src/runtime/sandbox-run.ts:126](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L126) **`Experimental`** @@ -7210,7 +7210,7 @@ Profile + sandbox env/overrides. `sandboxOverrides.backend.type` is the harness. > **signal**: `AbortSignal` -Defined in: [src/runtime/sandbox-run.ts:126](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L126) +Defined in: [src/runtime/sandbox-run.ts:127](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L127) **`Experimental`** @@ -7218,7 +7218,7 @@ Defined in: [src/runtime/sandbox-run.ts:126](https://github.com/tangle-network/a > `optional` **hooks?**: [`RuntimeHooks`](index.md#runtimehooks) -Defined in: [src/runtime/sandbox-run.ts:128](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L128) +Defined in: [src/runtime/sandbox-run.ts:129](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L129) **`Experimental`** @@ -7228,7 +7228,7 @@ Optional execution-scoped observers. Hook failures never fail the run. > `optional` **runId?**: `string` -Defined in: [src/runtime/sandbox-run.ts:130](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L130) +Defined in: [src/runtime/sandbox-run.ts:131](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L131) **`Experimental`** @@ -7238,7 +7238,7 @@ Stable run id for trace joins. Defaults to a short runtime-minted id. > `optional` **scenarioId?**: `string` -Defined in: [src/runtime/sandbox-run.ts:132](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L132) +Defined in: [src/runtime/sandbox-run.ts:133](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L133) **`Experimental`** @@ -7248,7 +7248,7 @@ Optional benchmark/scenario id carried into emitted hook events. > `optional` **promptOptions?**: [`OpenSandboxRunPromptOptions`](#opensandboxrunpromptoptions) -Defined in: [src/runtime/sandbox-run.ts:135](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L135) +Defined in: [src/runtime/sandbox-run.ts:136](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L136) **`Experimental`** @@ -7259,7 +7259,7 @@ Per-prompt sandbox SDK options forwarded to both `start()` and `resume()`. > `optional` **beforeStart?**: (`ctx`) => `void` \| `Promise`\<`void`\> -Defined in: [src/runtime/sandbox-run.ts:139](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L139) +Defined in: [src/runtime/sandbox-run.ts:140](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L140) **`Experimental`** @@ -7277,11 +7277,46 @@ fails the turn before the agent spends tokens. `void` \| `Promise`\<`void`\> +##### onSandboxEvent? + +> `optional` **onSandboxEvent?**: (`event`, `meta`) => `void` \| `PromiseLike`\<`void`\> + +Defined in: [src/runtime/sandbox-run.ts:143](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L143) + +**`Experimental`** + +Receives a defensive copy of every streamed event. Observer work is +non-blocking; synchronous throws and rejected promises never fail the run. + +###### Parameters + +###### event + +`SandboxEvent` + +###### meta + +###### turnIndex + +`number` + +###### turnKind + +`"start"` \| `"resume"` + +###### agentRunName + +`string` + +###### Returns + +`void` \| `PromiseLike`\<`void`\> + ##### now? > `optional` **now?**: () => `number` -Defined in: [src/runtime/sandbox-run.ts:141](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L141) +Defined in: [src/runtime/sandbox-run.ts:152](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L152) **`Experimental`** @@ -7295,7 +7330,7 @@ Test seam for deterministic hook timestamps. Defaults to `Date.now`. > `optional` **maxConcurrency?**: `number` -Defined in: [src/runtime/sandbox-run.ts:143](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L143) +Defined in: [src/runtime/sandbox-run.ts:154](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L154) **`Experimental`** @@ -7305,7 +7340,7 @@ Bounds box-creation bursts inside lineage fanout. Default from lineage. > `optional` **readRetryDelayMs?**: `number` -Defined in: [src/runtime/sandbox-run.ts:146](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L146) +Defined in: [src/runtime/sandbox-run.ts:157](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L157) **`Experimental`** @@ -18117,7 +18152,7 @@ Use [RunAgentRoundsOptions](#runagentroundsoptions). Removed in the next major. > **Deliverable**\<`Out`\> = \{ `kind`: `"events"`; `fromEvents`: (`events`) => `Out`; \} \| \{ `kind`: `"artifact"`; `path`: `string`; `fromArtifact`: (`raw`, `events`) => `Out`; \} -Defined in: [src/runtime/sandbox-run.ts:51](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L51) +Defined in: [src/runtime/sandbox-run.ts:52](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L52) **`Experimental`** @@ -18139,7 +18174,7 @@ How a typed deliverable `Out` is materialized from a finished turn. > **OpenSandboxRunPromptOptions** = `Omit`\<`PromptOptions`, `"signal"` \| `"sessionId"`\> -Defined in: [src/runtime/sandbox-run.ts:111](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L111) +Defined in: [src/runtime/sandbox-run.ts:112](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L112) **`Experimental`** @@ -21208,7 +21243,7 @@ rounds, no winner selection. > **defaultSelectWinner**\<`Task`, `Output`\>(`iterations`): [`LoopWinner`](#loopwinner)\<`Task`, `Output`\> \| `undefined` -Defined in: [src/runtime/run-loop.ts:1160](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/run-loop.ts#L1160) +Defined in: [src/runtime/run-loop.ts:1142](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/run-loop.ts#L1142) The kernel's winner argmax — best-valid-score, ties broken by earliest index, falling back to the best-scoring non-errored output when none is valid. Exported @@ -21297,7 +21332,7 @@ promise is cached so concurrent fanout branches share one round-trip. > **extractLlmCallEvent**(`event`, `agentRunName`): RuntimeStreamEvent & \{ type: "llm\_call"; \} \| `undefined` -Defined in: [src/runtime/sandbox-events.ts:32](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L32) +Defined in: [src/runtime/sandbox-events.ts:81](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L81) Extract a `RuntimeStreamEvent`-shaped `llm_call` from a sandbox event when the event carries usage/cost data. Returns `undefined` for non-cost events @@ -21332,7 +21367,7 @@ RuntimeStreamEvent & \{ type: "llm\_call"; \} \| `undefined` > **sumSandboxUsage**(`events`, `agentRunName?`): `object` -Defined in: [src/runtime/sandbox-events.ts:91](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L91) +Defined in: [src/runtime/sandbox-events.ts:140](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L140) Sum the token usage + USD cost of a sandbox turn's events — the one honest way to meter an `openSandboxRun` cell. Folds `extractLlmCallEvent` over the stream (which reads usage off EVERY backend @@ -21379,7 +21414,7 @@ readonly `SandboxEvent`[] > **createSandboxToolPartState**(): [`SandboxToolPartState`](#sandboxtoolpartstate) -Defined in: [src/runtime/sandbox-events.ts:161](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L161) +Defined in: [src/runtime/sandbox-events.ts:210](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L210) **`Experimental`** @@ -21396,7 +21431,7 @@ empty call-status map so each turn projects tool frames independently. > **mapSandboxToolEvent**(`event`, `state`): [`RuntimeStreamEvent`](index.md#runtimestreamevent) & `object`[] -Defined in: [src/runtime/sandbox-events.ts:192](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L192) +Defined in: [src/runtime/sandbox-events.ts:241](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L241) **`Experimental`** @@ -21440,7 +21475,7 @@ Returns `[]` for every non-tool event. > **mapSandboxEvent**(`event`, `opts?`): [`RuntimeStreamEvent`](index.md#runtimestreamevent) \| `undefined` -Defined in: [src/runtime/sandbox-events.ts:319](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L319) +Defined in: [src/runtime/sandbox-events.ts:368](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-events.ts#L368) Project one `SandboxEvent` onto the `RuntimeStreamEvent` chat-UX vocabulary, for runtimes that bridge a sandbox `streamPrompt` into the @@ -21529,7 +21564,7 @@ Run provenance recorder forwarded to every `prepareBox` the lineage runs > **openSandboxRun**\<`Out`\>(`client`, `options`, `deliverable`): `Promise`\<[`SandboxRun`](#sandboxrun)\<`Out`\>\> -Defined in: [src/runtime/sandbox-run.ts:156](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L156) +Defined in: [src/runtime/sandbox-run.ts:167](https://github.com/tangle-network/agent-runtime/blob/main/src/runtime/sandbox-run.ts#L167) **`Experimental`** From dc59c715b1f27a75dfac838ac1c38ce96c9081ba Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 24 Jul 2026 18:44:52 -0600 Subject: [PATCH 3/3] test(runtime): cover event observer aborts --- src/runtime/sandbox-run.ts | 5 +++-- tests/runtime/sandbox-run.test.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/runtime/sandbox-run.ts b/src/runtime/sandbox-run.ts index 1f97125a..a3d8db70 100644 --- a/src/runtime/sandbox-run.ts +++ b/src/runtime/sandbox-run.ts @@ -171,6 +171,7 @@ export async function openSandboxRun( ): Promise> { const runId = options.runId ?? `sandbox-run-${randomSuffix()}` const now = options.now ?? Date.now + const agentRunName = options.agentRun.name ?? options.agentRun.profile.name ?? 'agent' const capabilities = await probeSandboxCapabilities(client) const lineage = createSandboxLineage(client, capabilities, { ...(options.maxConcurrency !== undefined ? { maxConcurrency: options.maxConcurrency } : {}), @@ -208,7 +209,7 @@ export async function openSandboxRun( } const runPayload = (): Record => ({ - agentName: options.agentRun.name ?? options.agentRun.profile.name ?? 'agent', + agentName: agentRunName, profileName: options.agentRun.profile.name, backendType: backendType(options.agentRun), deliverableKind: deliverable.kind, @@ -257,7 +258,7 @@ export async function openSandboxRun( notifySandboxEventObserver(ev, options.onSandboxEvent, { turnIndex, turnKind, - agentRunName: options.agentRun.name ?? options.agentRun.profile.name ?? 'agent', + agentRunName, }) } } catch (err) { diff --git a/tests/runtime/sandbox-run.test.ts b/tests/runtime/sandbox-run.test.ts index 7bffd1f5..f2355de6 100644 --- a/tests/runtime/sandbox-run.test.ts +++ b/tests/runtime/sandbox-run.test.ts @@ -493,6 +493,7 @@ describe('openSandboxRun — abort-aware artifact read preserves the partial tra it('carries ONLY the events drained before a mid-stream abort (never-started stays empty)', async () => { const controller = new AbortController() + const observed: string[] = [] const streamed: SandboxEvent[] = [ { type: 'step', data: { i: 0 } } as SandboxEvent, { type: 'step', data: { i: 1 } } as SandboxEvent, @@ -508,7 +509,14 @@ describe('openSandboxRun — abort-aware artifact read preserves the partial tra }) const run = await openSandboxRun( client, - { agentRun: spec(), signal: controller.signal }, + { + agentRun: spec(), + signal: controller.signal, + onSandboxEvent: (event) => { + observed.push(event.type) + throw new Error('observer failure must not mask the abort') + }, + }, eventsDeliverable, ) const err = await run.start('go').then( @@ -519,6 +527,7 @@ describe('openSandboxRun — abort-aware artifact read preserves the partial tra ) expect(err).toBeInstanceOf(SandboxRunAbortError) expect((err as SandboxRunAbortError).events).toEqual([streamed[0]]) + expect(observed).toEqual(['step']) }) it('preserves partial events when the read loop is aborted mid-retry, with the last readError', async () => {