refactor: prune code runtime surface

This commit is contained in:
Tianyi Cui
2026-07-14 03:07:41 +08:00
parent 0236a12324
commit 01da49a3ab
19 changed files with 100 additions and 163 deletions

View File

@@ -98,7 +98,7 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult>
Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md)
Source: [`packages/code-runtime/code-runtime/src/index.ts:59`](../../packages/code-runtime/code-runtime/src/index.ts) Source: [`packages/code-runtime/code-runtime/src/index.ts:58`](../../packages/code-runtime/code-runtime/src/index.ts)
## `ctx.compact` — `CompactService` (abstract seam) ## `ctx.compact` — `CompactService` (abstract seam)

View File

@@ -39,8 +39,8 @@ interface CodeRunResult {
* or value-less run leaves this absent. * or value-less run leaves this absent.
*/ */
value?: unknown value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */ /** Text the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[] logs: string[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure error?: CodeRunFailure
} }
@@ -65,18 +65,7 @@ type CodeBindingFunction = (args: unknown) => Promise<unknown>
## Captured output and the failure taxonomy ## Captured output and the failure taxonomy
Logs arrive in emission order, attributed to their channel (the runtime's `console` shim, or stray writes to the underlying streams): Logs are plain strings in emission order. The runtime captures the program's console and stream output, but channel and console-method metadata are not part of the seam because consumers render only the text. Implementations cap the aggregate output and mark truncation in-band.
```ts type-equiv
interface CodeLogEntry {
/** Which channel produced the text. */
source: 'console' | 'stdout' | 'stderr'
/** The console method used; present only when `source` is `'console'`. */
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
text: string
}
```
Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither:

View File

@@ -40,7 +40,7 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat
1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. 1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope.
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime.
3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results. 3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus the capped log strings as presentation metadata. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results.
**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. **Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode.
@@ -58,8 +58,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` - `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does). - `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does).
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeRunResult = { value?: unknown; logs: string[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout.
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).

View File

@@ -23,10 +23,12 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work. - **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys. - **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`). - **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Logs stream eagerly** — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once. - **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags. - **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving. - **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
## The worker entry, unbuilt and built ## The worker entry, unbuilt and built
`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). `worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.

View File

@@ -15,7 +15,6 @@
"types": "./lib/types/worker.d.ts", "types": "./lib/types/worker.d.ts",
"default": "./lib/worker.cjs" "default": "./lib/worker.cjs"
}, },
"./src/*": "./src/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [ "files": [

View File

@@ -12,7 +12,6 @@
import { inspect } from 'node:util' import { inspect } from 'node:util'
import { serialize } from 'node:v8' import { serialize } from 'node:v8'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { logTruncationMarker } from './protocol.ts' import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
@@ -33,12 +32,12 @@ export interface PatchableStream {
} }
/** /**
* Ordered log capture under one shared byte budget, delivered to a sink as * Ordered text capture under one shared byte budget, delivered to a sink as
* each entry lands (the real sink streams entries over the port eagerly, so * each item lands (the real sink streams text over the port eagerly, so
* captured output survives a mid-run termination). Once the budget is * captured output survives a mid-run termination). Once the budget is
* exhausted it emits exactly one in-band marker entry (on the `stderr` * exhausted it emits exactly one in-band marker and silently drops everything
* diagnostics channel) and silently drops everything after — the cap is a * after. The cap is a blast-radius bound, so "how much was lost" intentionally
* blast-radius bound, so "how much was lost" intentionally stays unmeasured. * stays unmeasured.
*/ */
export class LogBuffer { export class LogBuffer {
private remaining: number private remaining: number
@@ -47,28 +46,28 @@ export class LogBuffer {
// under Node's native strip-only mode, which rejects non-erasable syntax — // under Node's native strip-only mode, which rejects non-erasable syntax —
// and parameter properties are non-erasable. // and parameter properties are non-erasable.
private readonly maxBytes: number private readonly maxBytes: number
private readonly sink: (entry: CodeLogEntry) => void private readonly sink: (text: string) => void
constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) { constructor(maxBytes: number, sink: (text: string) => void) {
this.maxBytes = maxBytes this.maxBytes = maxBytes
this.sink = sink this.sink = sink
this.remaining = maxBytes this.remaining = maxBytes
} }
/** /**
* Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted). * Emit text to the sink, charging it against the budget (drops + marks once exhausted).
* @param entry - the log entry to deliver. * @param text - the captured text to deliver.
*/ */
push(entry: CodeLogEntry): void { push(text: string): void {
if (this.truncated) return if (this.truncated) return
const cost = Buffer.byteLength(entry.text, 'utf8') const cost = Buffer.byteLength(text, 'utf8')
if (cost > this.remaining) { if (cost > this.remaining) {
this.truncated = true this.truncated = true
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) }) this.sink(logTruncationMarker(this.maxBytes))
return return
} }
this.remaining -= cost this.remaining -= cost
this.sink(entry) this.sink(text)
} }
} }
@@ -89,7 +88,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ') args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
for (const level of CONSOLE_LEVELS) { for (const level of CONSOLE_LEVELS) {
shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) } shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
} }
return shim return shim
} }
@@ -104,17 +103,16 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
* even for writes the exhausted budget drops. * even for writes the exhausted budget drops.
* @param logs - the buffer captured writes are pushed into. * @param logs - the buffer captured writes are pushed into.
* @param stream - the stream whose `write` slot is patched. * @param stream - the stream whose `write` slot is patched.
* @param source - the log source the captured writes are attributed to.
* @returns the restore function (the in-process tests un-patch; the real * @returns the restore function (the in-process tests un-patch; the real
* worker never needs to). * worker never needs to).
*/ */
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void { export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
// The slot's VALUE is stored for restore and reassigned — never invoked // The slot's VALUE is stored for restore and reassigned — never invoked
// detached, so the unbound-method concern does not apply. // detached, so the unbound-method concern does not apply.
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const original = stream.write const original = stream.write
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => { stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) }) logs.push(typeof chunk === 'string' ? chunk : String(chunk))
// Node's optional-encoding shape: the callback is whichever of the next // Node's optional-encoding shape: the callback is whichever of the next
// two positions holds a function (a non-function there is the encoding). // two positions holds a function (a non-function there is the encoding).
const callback = [rest[0], rest[1]].find( const callback = [rest[0], rest[1]].find(
@@ -273,9 +271,9 @@ export async function runWorkerMain(
data: WorkerBootData, data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream }, streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> { ): Promise<void> {
const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) }) const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
captureStreamWrites(logs, streams.stdout, 'stdout') captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr, 'stderr') captureStreamWrites(logs, streams.stderr)
const pending = new Map<number, PendingCall>() const pending = new Map<number, PendingCall>()
wireReplies(port, pending) wireReplies(port, pending)

View File

@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'
import { Context } from 'cordis' import { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts' import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts' import { logTruncationMarker } from './protocol.ts'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
@@ -118,10 +118,6 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
/** /**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and * Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so * can post anything — `null`, primitives, objects with poisoned fields — so
@@ -140,20 +136,8 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args } return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
} }
case 'log': { case 'log': {
const entry = m.entry if (typeof m.text !== 'string') return undefined
if (typeof entry !== 'object' || entry === null) return undefined return { type: 'log', text: m.text }
const e = entry as Record<string, unknown>
if (typeof e.text !== 'string') return undefined
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
return {
type: 'log',
entry: {
source: e.source as CodeLogEntry['source'],
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
text: e.text,
},
}
} }
case 'done': { case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} } if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
@@ -299,8 +283,8 @@ export class WorkerCodeRuntime extends CodeRuntime {
return new Promise<CodeRunResult>((resolve) => { return new Promise<CodeRunResult>((resolve) => {
let settled = false let settled = false
const answered = new Set<number>() const answered = new Set<number>()
const logs: CodeLogEntry[] = [] const logs: string[] = []
const strayLogs: CodeLogEntry[] = [] const strayLogs: string[] = []
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`, // ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
// whatever the path: honest port entries, FORGED port entries (model // whatever the path: honest port entries, FORGED port entries (model
@@ -310,26 +294,26 @@ export class WorkerCodeRuntime extends CodeRuntime {
// so the documented cap is one shared `maxLogBytes` however it is hit. // so the documented cap is one shared `maxLogBytes` however it is hit.
let logBudget = this.config.maxLogBytes let logBudget = this.config.maxLogBytes
let logsTruncated = false let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => { const admit = (text: string, sink: string[]): void => {
if (logsTruncated) return if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8') const cost = Buffer.byteLength(text, 'utf8')
if (cost > logBudget) { if (cost > logBudget) {
logsTruncated = true logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) }) sink.push(logTruncationMarker(this.config.maxLogBytes))
return return
} }
logBudget -= cost logBudget -= cost
sink.push(entry) sink.push(text)
} }
// No settled guard: `finish` snapshots the arrays when it resolves, so // No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers, // a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close. // and the ledger bounds that growth until the pipes close.
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => { const captureStray = (chunk: Buffer): void => {
admit({ source, text: chunk.toString('utf8') }, strayLogs) admit(chunk.toString('utf8'), strayLogs)
} }
worker.stdout.on('data', captureStray('stdout')) worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray('stderr')) worker.stderr.on('data', captureStray)
// Settlement: exactly one outcome wins; every path funnels through // Settlement: exactly one outcome wins; every path funnels through
// here, cleans up the timers/listeners, terminates the worker, and // here, cleans up the timers/listeners, terminates the worker, and
@@ -404,7 +388,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
// this listener would crash the host process. Junk drops silently. // this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw) const message = parseWorkerMessage(raw)
if (!message) return if (!message) return
if (message.type === 'log' && !settled) admit(message.entry, logs) if (message.type === 'log' && !settled) admit(message.text, logs)
onCall(message) onCall(message)
onDone(message) onDone(message)
}) })

View File

@@ -9,8 +9,6 @@
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol * @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/ */
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/** What the host hands the worker at spawn, via `workerData`. */ /** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData { export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */ /** The type-stripped (plain JS) program body. */
@@ -36,10 +34,10 @@ export interface CallMessage {
args: unknown args: unknown
} }
/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ /** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
export interface LogMessage { interface LogMessage {
type: 'log' type: 'log'
entry: CodeLogEntry text: string
} }
/** /**

View File

@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts' import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts' import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '@deepseek-ai/dsh-code-runtime-worker/src/protocol.ts' import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/** /**
* An in-process stand-in for the worker's parentPort: the test plays the * An in-process stand-in for the worker's parentPort: the test plays the
@@ -31,8 +30,8 @@ class FakePort implements BootstrapPort {
this.emitter.emit('message', message) this.emitter.emit('message', message)
} }
logs(): CodeLogEntry[] { logs(): string[] {
return this.sent.filter(message => message.type === 'log').map(message => message.entry) return this.sent.filter(message => message.type === 'log').map(message => message.text)
} }
done(): WorkerToHost | undefined { done(): WorkerToHost | undefined {
@@ -48,12 +47,12 @@ const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
describe('LogBuffer', () => { describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => { it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
const seen: CodeLogEntry[] = [] const seen: string[] = []
const buffer = new LogBuffer(10, entry => seen.push(entry)) const buffer = new LogBuffer(10, text => seen.push(text))
buffer.push({ source: 'console', level: 'log', text: '12345' }) buffer.push('12345')
buffer.push({ source: 'console', level: 'log', text: '123456' }) buffer.push('123456')
buffer.push({ source: 'console', level: 'log', text: 'dropped' }) buffer.push('dropped')
expect(seen.map(entry => entry.text)).toEqual([ expect(seen).toEqual([
'12345', '12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes', '[dsh-code-runtime-worker] log capture truncated at 10 bytes',
]) ])
@@ -61,40 +60,37 @@ describe('LogBuffer', () => {
}) })
describe('makeConsoleShim', () => { describe('makeConsoleShim', () => {
it('captures the five levels and renders non-strings inspect-style', () => { it('captures the five methods and renders non-strings inspect-style', () => {
const seen: CodeLogEntry[] = [] const seen: string[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, entry => seen.push(entry))) const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
shim.log('plain', { a: 1 }) shim.log('plain', { a: 1 })
shim.info('i') shim.info('i')
shim.warn('w') shim.warn('w')
shim.error('e') shim.error('e')
shim.debug('d') shim.debug('d')
expect(seen.map(entry => entry.level)).toEqual(['log', 'info', 'warn', 'error', 'debug']) expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
expect(seen[0]?.text).toBe('plain { a: 1 }')
expect(seen.every(entry => entry.source === 'console')).toBe(true)
}) })
}) })
describe('captureStreamWrites', () => { describe('captureStreamWrites', () => {
it('redirects writes into the buffer and restores on request', () => { it('redirects writes into the buffer and restores on request', () => {
const seen: CodeLogEntry[] = [] const seen: string[] = []
const buffer = new LogBuffer(1_000, entry => seen.push(entry)) const buffer = new LogBuffer(1_000, text => seen.push(text))
let underlying = '' let underlying = ''
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } } const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
const restore = captureStreamWrites(buffer, stream, 'stdout') const restore = captureStreamWrites(buffer, stream)
stream.write('captured', 'utf8') stream.write('captured', 'utf8')
stream.write(Buffer.from('bytes')) stream.write(Buffer.from('bytes'))
restore() restore()
stream.write('after') stream.write('after')
expect(seen.map(entry => entry.text)).toEqual(['captured', 'bytes']) expect(seen).toEqual(['captured', 'bytes'])
expect(seen[0]).toMatchObject({ source: 'stdout' })
expect(underlying).toBe('after') expect(underlying).toBe('after')
}) })
it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => { it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
const buffer = new LogBuffer(1_000, () => {}) const buffer = new LogBuffer(1_000, () => {})
const stream: PatchableStream = { write: () => true } const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout') captureStreamWrites(buffer, stream)
const calls: (Error | null | undefined)[] = [] const calls: (Error | null | undefined)[] = []
stream.write('two-arg', (error?: Error | null) => calls.push(error)) stream.write('two-arg', (error?: Error | null) => calls.push(error))
stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error)) stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
@@ -107,7 +103,7 @@ describe('captureStreamWrites', () => {
it('still fires the callback for a write the exhausted budget drops', async () => { it('still fires the callback for a write the exhausted budget drops', async () => {
const buffer = new LogBuffer(4, () => {}) const buffer = new LogBuffer(4, () => {})
const stream: PatchableStream = { write: () => true } const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout') captureStreamWrites(buffer, stream)
stream.write('this write overflows the budget and is dropped') stream.write('this write overflows the budget and is dropped')
await new Promise<void>(resolve => stream.write('also dropped', resolve)) await new Promise<void>(resolve => stream.write('also dropped', resolve))
}) })
@@ -210,7 +206,7 @@ describe('runWorkerMain', () => {
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }], namespaces: [{ global: 'tools', names: ['double'] }],
}, fakeStreams()) }, fakeStreams())
expect(port.logs()).toEqual([{ source: 'console', level: 'log', text: 'got 42' }]) expect(port.logs()).toEqual(['got 42'])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } }) expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
}) })
@@ -268,6 +264,6 @@ describe('runWorkerMain', () => {
// The patch stays installed for the worker's lifetime; writes during the // The patch stays installed for the worker's lifetime; writes during the
// program landed in order. Here the program wrote nothing via streams, so // program landed in order. Here the program wrote nothing via streams, so
// only the post-run write above went through the patched slot. // only the post-run write above went through the patched slot.
expect(port.logs().at(-1)).toMatchObject({ source: 'stdout' }) expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
}) })
}) })

View File

@@ -47,9 +47,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
expect(exitCode, `stderr:\n${stderr}`).toBe(0) expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? '' const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: { source: string; level?: string; text: string }[]; error?: unknown } const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
expect(result.error).toBeUndefined() expect(result.error).toBeUndefined()
expect(result.value).toBe(42) expect(result.value).toBe(42)
expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' }) expect(result.logs).toContain('halfway 42')
}) })
}) })

View File

@@ -28,7 +28,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(runtime.isolation).toBe('worker-thread') expect(runtime.isolation).toBe('worker-thread')
}) })
it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => { it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
const { runtime } = await setup() const { runtime } = await setup()
const result = await runtime.run({ const result = await runtime.run({
program: ` program: `
@@ -43,12 +43,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
}) })
expect(result.error).toBeUndefined() expect(result.error).toBeUndefined()
expect(result.value).toBe(3) expect(result.value).toBe(3)
expect(result.logs.map(entry => [entry.source, entry.level ?? null])).toEqual([ expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
['console', 'log'],
['stdout', null],
['console', 'warn'],
])
expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }')
}) })
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => { it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
@@ -115,7 +110,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
bindings: [], bindings: [],
}) })
expect(result.error?.kind).toBe('exception') expect(result.error?.kind).toBe('exception')
expect(result.logs.map(entry => entry.text)).toContain('before') expect(result.logs).toContain('before')
}) })
}) })
@@ -210,8 +205,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1', program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [], bindings: [],
}) })
expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes') expect(result.logs.at(-1)).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0) const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThan(1_000) expect(total).toBeLessThan(1_000)
}) })
@@ -241,7 +236,7 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
}) })
expect(result.error).toBeUndefined() expect(result.error).toBeUndefined()
expect(result.value).toBe('done') expect(result.value).toBe('done')
expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' }) expect(result.logs).toContain('flushed')
}) })
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => { it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
@@ -270,8 +265,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
bindings: [], bindings: [],
}) })
expect(result.error).toBeUndefined() expect(result.error).toBeUndefined()
expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' }) expect(result.logs).toContain('abcd')
expect(result.logs.map(entry => entry.text)).not.toContain('ef') expect(result.logs).not.toContain('ef')
}, 15_000) }, 15_000)
}) })
@@ -306,11 +301,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} }, { type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} }, { type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
{ type: 'log' }, { type: 'log' },
{ type: 'log', entry: null }, { type: 'log', text: null },
{ type: 'log', entry: { source: 'stdout', text: 7 } }, { type: 'log', text: 7 },
{ type: 'log', entry: { source: 'nope', text: 'x' } }, { type: 'log', text: {} },
{ type: 'log', entry: { source: 'console', level: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 7, text: 'x' } },
{ type: 'done', error: 5 }, { type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } }, { type: 'done', error: { message: 5 } },
]) parentPort.postMessage(junk); ]) parentPort.postMessage(junk);
@@ -331,7 +324,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
// code and an unbounded result. // code and an unbounded result.
program: ` program: `
const { parentPort } = await import('node:worker_threads'); const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', entry: { source: 'stdout', text: 'F'.repeat(100), forged: true } }); for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) }); parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
for (;;) {} for (;;) {}
`, `,
@@ -343,10 +336,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
expect(value.endsWith('… [truncated]')).toBe(true) expect(value.endsWith('… [truncated]')).toBe(true)
expect(value.length).toBeLessThan(120) expect(value.length).toBeLessThan(120)
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes' const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0) const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8')) expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
expect(result.logs.at(-1)?.text).toBe(marker) expect(result.logs.at(-1)).toBe(marker)
expect(result.logs.every(entry => !('forged' in entry))).toBe(true)
}) })
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => { it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {

View File

@@ -16,4 +16,4 @@ Semantics every implementation must honor (contract details in the class JSDoc):
## Vocabulary ## Vocabulary
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. `CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.

View File

@@ -22,7 +22,6 @@ import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type { export type {
CodeBindingFunction, CodeBindingFunction,
CodeBindingNamespace, CodeBindingNamespace,
CodeLogEntry,
CodeRunFailure, CodeRunFailure,
CodeRunRequest, CodeRunRequest,
CodeRunResult, CodeRunResult,

View File

@@ -54,20 +54,6 @@ export interface CodeRunRequest {
signal?: AbortSignal signal?: AbortSignal
} }
/**
* One captured output entry, in emission order. `source` says which channel
* produced it: the program's `console` (shimmed by the runtime), or a stray
* write to the underlying stdout/stderr streams.
*/
export interface CodeLogEntry {
/** Which channel produced the text. */
source: 'console' | 'stdout' | 'stderr'
/** The console method used; present only when `source` is `'console'`. */
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
text: string
}
/** /**
* Why a run failed. The kinds are orthogonal outcomes reported independently * Why a run failed. The kinds are orthogonal outcomes reported independently
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an * (per docs/defensive-patterns.md): a budget expiry is not an exception, an
@@ -98,8 +84,8 @@ export interface CodeRunResult {
* or value-less run leaves this absent. * or value-less run leaves this absent.
*/ */
value?: unknown value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */ /** Text the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[] logs: string[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure error?: CodeRunFailure
} }

View File

@@ -55,7 +55,7 @@ describe('CodeRuntime service seam', () => {
it('reports a failed run as an error field on a resolved result, never a rejection', async () => { it('reports a failed run as an error field on a resolved result, never a rejection', async () => {
const { runtime } = await setup() const { runtime } = await setup()
runtime.nextResult = { runtime.nextResult = {
logs: [{ source: 'console', level: 'error', text: 'boom' }], logs: ['boom'],
error: { kind: 'exception', message: 'boom' }, error: { kind: 'exception', message: 'boom' },
} }
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] }) const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })

View File

@@ -598,10 +598,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CodeBindingNamespace', name: 'CodeBindingNamespace',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
}, },
{
name: 'CodeLogEntry',
declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}',
},
{ {
name: 'CodeRunFailure', name: 'CodeRunFailure',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}', declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
@@ -612,7 +608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'CodeRunResult', name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}', declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}',
}, },
{ {
name: 'CollectedOutput', name: 'CollectedOutput',

View File

@@ -126,14 +126,13 @@ function renderValue(value: unknown): string {
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */ /** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta { interface RunCodeMeta {
logs: CodeRunResult['logs'] logs: CodeRunResult['logs']
dispatches: number
} }
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown> const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
return m as unknown as RunCodeMeta return m as unknown as RunCodeMeta
} }
@@ -283,12 +282,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
} }
if (result.error) { if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : '' const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
} }
const rendered = renderValue(result.value) const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0) const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches } const meta: RunCodeMeta = { logs: result.logs }
return { return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta, meta,
@@ -316,7 +315,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
presentResult: (_args, result) => { presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta) const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n') const output = meta.logs.join('\n')
return { return {
card: 'generic', card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {}, ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},

View File

@@ -328,7 +328,7 @@ describe('the run_code dispatch bridge', () => {
const tools = request.bindings[0]!.functions const tools = request.bindings[0]!.functions
const first = await tools.echo!({ value: 'one' }) const first = await tools.echo!({ value: 'one' })
const second = await tools.echo!({ value: 'two' }) const second = await tools.echo!({ value: 'two' })
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second } return { logs: [`saw ${String(first)}`], value: second }
} }
const result = await runCode(ctx, 'const …: string = …', { agent }) const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false) expect(result.isError).toBe(false)
@@ -339,7 +339,7 @@ describe('the run_code dispatch bridge', () => {
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' }, { parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' }, { parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
]) ])
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 }) expect(result.meta).toEqual({ logs: ['saw echo:one'] })
}) })
it('exposes only an opaque parent token to nested result observers', async () => { it('exposes only an opaque parent token to nested result observers', async () => {
@@ -505,7 +505,7 @@ describe('the run_code dispatch bridge', () => {
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => { it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' }) const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({ runtime.behavior = () => Promise.resolve({
logs: [{ source: 'console', level: 'log', text: 'got this far' }], logs: ['got this far'],
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' }, error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
}) })
const result = await runCode(ctx, 'program') const result = await runCode(ctx, 'program')
@@ -632,7 +632,7 @@ describe('the run_code dispatch bridge', () => {
const view = tool.presentResult?.({ code: 'return 1' }, { const view = tool.presentResult?.({ code: 'return 1' }, {
content: [{ type: 'text', text: 'model-facing' }], content: [{ type: 'text', text: 'model-facing' }],
isError: false, isError: false,
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 }, meta: { logs: ['printed'] },
}) })
// The result omits the title — an update replaces only provided fields, // The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion. // so the pending card's program title persists through completion.
@@ -641,9 +641,10 @@ describe('the run_code dispatch bridge', () => {
content: [{ type: 'text', text: 'printed' }], content: [{ type: 'text', text: 'printed' }],
}) })
// No captured output → no content either; everything pending persists. // No captured output → no content either; everything pending persists.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } })) expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
.toEqual({ card: 'generic' }) .toEqual({ card: 'generic' })
// Replay with an unrecognizable meta falls back to the generic rendering. // Replay with an unrecognizable meta falls back to the generic rendering.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
}) })

View File

@@ -92,7 +92,6 @@
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },