Merge branch 'master' into codex/rfc-subagent-background-tasks

This commit is contained in:
Yichen Jiang
2026-07-15 15:33:53 +08:00
committed by GitHub
24 changed files with 106 additions and 173 deletions

View File

@@ -217,7 +217,7 @@ export interface Config {
} }
``` ```
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../packages/code-runtime/code-runtime-worker/src/index.ts) Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../packages/code-runtime/code-runtime-worker/src/index.ts)
## `@deepseek-ai/dsh-compact-basic` ## `@deepseek-ai/dsh-compact-basic`

View File

@@ -83,7 +83,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:31`](../../packages/code-runtime/code-runtime/src/index.ts) Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../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

@@ -21,7 +21,7 @@
{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} {"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}
{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} {"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}
{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} {"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}

View File

@@ -87,7 +87,7 @@
{"type":"assistant/message","seq":85,"time":1783611775503,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."},{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}],"usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84],"surfaceOp":"append"} {"type":"assistant/message","seq":85,"time":1783611775503,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."},{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}],"usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84],"surfaceOp":"append"}
{"type":"tool/call","seq":86,"time":1783611775504,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}} {"type":"tool/call","seq":86,"time":1783611775504,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}}
{"type":"tool/code-dispatch","seq":87,"time":1783611775590,"data":{"parentCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","subCallId":"call_00_AZFzvUwuC4vAUoICrfke5147:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} {"type":"tool/code-dispatch","seq":87,"time":1783611775590,"data":{"parentCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","subCallId":"call_00_AZFzvUwuC4vAUoICrfke5147:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}}
{"type":"tool/result","seq":88,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[86],"surfaceOp":"append"} {"type":"tool/result","seq":88,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[86],"surfaceOp":"append"}
{"type":"step/end","seq":89,"time":1783611775592,"data":{"turn":1,"step":1}} {"type":"step/end","seq":89,"time":1783611775592,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":90,"time":1783611775592,"data":{"turn":1,"step":2}} {"type":"step/start","seq":90,"time":1783611775592,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":91,"time":1783611776183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":91,"time":1783611776183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -112,7 +112,7 @@
{"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} {"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}
{"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} {"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}}
{"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} {"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}}
{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[110],"surfaceOp":"append"} {"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[110],"surfaceOp":"append"}
{"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}} {"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}} {"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -23,7 +23,7 @@ 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.
@@ -31,6 +31,8 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [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.
## Model Experience ## Model Experience
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.

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

@@ -7,7 +7,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'
@@ -28,12 +27,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
@@ -42,28 +41,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)
} }
} }
@@ -84,7 +83,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
} }
@@ -98,17 +97,16 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
* *
* @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(
@@ -256,9 +254,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

@@ -12,14 +12,11 @@ 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'
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */ /** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config { export interface Config {
/** /**
@@ -112,10 +109,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
@@ -134,20 +127,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 } : {} }
@@ -291,33 +272,33 @@ 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 budget covers normal, forged, and stray-pipe log entries. The first // One host-side budget covers normal, forged, and stray-pipe log entries. The first
// overflow emits the shared in-band marker and drops everything after it. // overflow emits the shared in-band marker and drops everything after it.
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)
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker; // Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
// logs captured before timeout, abort, or failure remain in the result. // logs captured before timeout, abort, or failure remain in the result.
@@ -386,7 +367,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

@@ -5,8 +5,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. */
@@ -20,7 +18,7 @@ export interface WorkerBootData {
} }
/** Worker → host: one bridged binding call. */ /** Worker → host: one bridged binding call. */
export interface CallMessage { interface CallMessage {
type: 'call' type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */ /** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number id: number
@@ -32,10 +30,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

@@ -38,9 +38,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 () => {
@@ -268,8 +263,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)
}) })
@@ -304,11 +299,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);
@@ -329,7 +322,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 (;;) {}
`, `,
@@ -341,10 +334,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,7 +16,7 @@ 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.
## Model Experience ## Model Experience

View File

@@ -10,7 +10,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

@@ -609,10 +609,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}',
@@ -623,7 +619,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

@@ -108,14 +108,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
} }
@@ -251,12 +250,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,
@@ -278,7 +277,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 () => {
@@ -503,7 +503,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')
@@ -627,7 +627,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.
@@ -636,9 +636,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

@@ -408,8 +408,7 @@
], ],
"isError": false, "isError": false,
"meta": { "meta": {
"logs": [], "logs": []
"dispatches": 1
} }
}, },
"sourceEventSeqs": [ "sourceEventSeqs": [
@@ -1606,8 +1605,7 @@
], ],
"isError": false, "isError": false,
"meta": { "meta": {
"logs": [], "logs": []
"dispatches": 1
} }
}, },
"sourceEventSeqs": [ "sourceEventSeqs": [

View File

@@ -22,7 +22,7 @@
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} {"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} {"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} {"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}

View File

@@ -107,7 +107,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" },