feat: return typed values from Code Mode
This commit is contained in:
@@ -10,20 +10,20 @@ Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-ru
|
||||
config:
|
||||
computeMs: 60000 # busy-time budget (measured event-loop active time)
|
||||
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
|
||||
maxLogBytes: 65536 # shared byte budget for captured log text
|
||||
maxValueBytes: 32768 # rendered-completion-value cap
|
||||
maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB)
|
||||
maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits)
|
||||
```
|
||||
|
||||
Every field is validated (positive numbers) and defaulted; there are no other tunables.
|
||||
Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at least four bytes, the remaining fields are positive finite numbers, and there are no other tunables.
|
||||
|
||||
## Design
|
||||
|
||||
- **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
|
||||
- **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 validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. 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'`).
|
||||
- **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.
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions cross by structured clone after lossless-JSON validation and have no byte cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure diagnostic. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **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.
|
||||
|
||||
@@ -35,7 +35,7 @@ The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. Th
|
||||
|
||||
## 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 the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure. Only the outer `run_code` result enters model context and its ordinary spill policy; binding traffic and intermediate values remain execution-local.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -47,4 +47,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts.
|
||||
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
|
||||
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface.
|
||||
- **A non-cloneable or oversize completion value does not cross as a value** — it arrives as a bounded, truncation-marked `util.inspect` rendering in `value`'s place.
|
||||
- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output.
|
||||
- **The 64 MiB default is a rejection boundary, not recoverable storage** — outer spill can save only the bounded logs and diagnostic returned after `output-limit`; bytes rejected beyond the runtime cap never reach the spill layer.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,6 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import { serialize } from 'node:v8'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
@@ -30,9 +29,8 @@ export interface PatchableStream {
|
||||
* Ordered text capture under one shared byte budget, delivered to a sink as
|
||||
* each item lands (the real sink streams text over the port eagerly, so
|
||||
* captured output survives a mid-run termination). Once the budget is
|
||||
* exhausted it emits exactly one in-band marker and silently drops everything
|
||||
* after. The cap is a blast-radius bound, so "how much was lost" intentionally
|
||||
* stays unmeasured.
|
||||
* exhausted it emits the fitting prefix and reports the limit once; the host
|
||||
* turns that condition into an explicit `output-limit` run failure.
|
||||
*/
|
||||
export class LogBuffer {
|
||||
private remaining: number
|
||||
@@ -40,12 +38,12 @@ export class LogBuffer {
|
||||
// Explicit fields, not constructor parameter properties: this module loads
|
||||
// under Node's native strip-only mode, which rejects non-erasable syntax —
|
||||
// and parameter properties are non-erasable.
|
||||
private readonly maxBytes: number
|
||||
private readonly sink: (text: string) => void
|
||||
private readonly onLimit: () => void
|
||||
|
||||
constructor(maxBytes: number, sink: (text: string) => void) {
|
||||
this.maxBytes = maxBytes
|
||||
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
|
||||
this.sink = sink
|
||||
this.onLimit = onLimit
|
||||
this.remaining = maxBytes
|
||||
}
|
||||
|
||||
@@ -58,7 +56,10 @@ export class LogBuffer {
|
||||
const cost = Buffer.byteLength(text, 'utf8')
|
||||
if (cost > this.remaining) {
|
||||
this.truncated = true
|
||||
this.sink(logTruncationMarker(this.maxBytes))
|
||||
const prefix = truncateUtf8Bytes(text, this.remaining)
|
||||
if (prefix.length > 0) this.sink(prefix)
|
||||
this.remaining = 0
|
||||
this.onLimit()
|
||||
return
|
||||
}
|
||||
this.remaining -= cost
|
||||
@@ -144,37 +145,30 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the program's completion value for the done message: a value whose MEASURED
|
||||
* cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the
|
||||
* structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose
|
||||
* bounded inspect rendering happens to be small cannot smuggle itself past the cap. Oversized
|
||||
* or non-cloneable values are replaced by a bounded string rendering with an in-band marker.
|
||||
* Prepare the program's completion value for the done message. Only lossless
|
||||
* JSON crosses, and an individually oversized value reports `output-limit`;
|
||||
* the host revalidates both and accounts for the combined outer envelope.
|
||||
*
|
||||
* @param value - the program's completion value.
|
||||
* @param maxValueBytes - the byte cap for the value.
|
||||
* @param maxOutputBytes - the byte cap for the outer result.
|
||||
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
|
||||
*/
|
||||
export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
|
||||
export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
||||
if (value === undefined) return {}
|
||||
if (typeof value === 'string') {
|
||||
if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value }
|
||||
} else {
|
||||
let size: number | undefined
|
||||
try {
|
||||
size = serialize(value).byteLength
|
||||
} catch {
|
||||
// Only the verdict matters: the value has parts the structured-clone
|
||||
// algorithm rejects (functions, classes, …) and must cross as its
|
||||
// rendering instead.
|
||||
size = undefined
|
||||
}
|
||||
if (size !== undefined && size <= maxValueBytes) return { value }
|
||||
let snapshot: unknown
|
||||
try {
|
||||
snapshot = snapshotJsonValue(value)
|
||||
} catch {
|
||||
snapshot = undefined
|
||||
}
|
||||
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
|
||||
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
|
||||
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
|
||||
: rendered
|
||||
return { value: capped }
|
||||
if (snapshot === undefined) {
|
||||
return { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }
|
||||
}
|
||||
const size = Buffer.byteLength(JSON.stringify(snapshot), 'utf8')
|
||||
if (size > maxOutputBytes) {
|
||||
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
|
||||
}
|
||||
return { value: snapshot }
|
||||
}
|
||||
|
||||
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
|
||||
@@ -183,6 +177,17 @@ export interface PendingCall {
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
/** Program-visible typed rejection for a failed member of the `tools` namespace. */
|
||||
export class ToolCallError extends Error {
|
||||
override readonly name = 'ToolCallError'
|
||||
readonly toolName: string
|
||||
|
||||
constructor(toolName: string, message: string) {
|
||||
super(message)
|
||||
this.toolName = toolName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route host replies into the pending-call map: each reply settles its call
|
||||
* at most once, and a reply for an unknown id (stray, or a duplicate answer
|
||||
@@ -227,12 +232,18 @@ export function makeNamespaces(
|
||||
enumerable: true,
|
||||
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
pending.set(id, { resolve, reject })
|
||||
pending.set(id, {
|
||||
resolve,
|
||||
reject: (error) => {
|
||||
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
|
||||
},
|
||||
})
|
||||
try {
|
||||
port.postMessage({ type: 'call', id, global, name, args })
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
|
||||
const message = `binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`
|
||||
reject(global === 'tools' ? new ToolCallError(name, message) : new Error(message))
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -254,7 +265,11 @@ export async function runWorkerMain(
|
||||
data: WorkerBootData,
|
||||
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
||||
): Promise<void> {
|
||||
const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
|
||||
const logs = new LogBuffer(
|
||||
data.maxOutputBytes,
|
||||
(text) => { port.postMessage({ type: 'log', text }) },
|
||||
() => { port.postMessage({ type: 'output-limit' }) },
|
||||
)
|
||||
captureStreamWrites(logs, streams.stdout)
|
||||
captureStreamWrites(logs, streams.stderr)
|
||||
|
||||
@@ -271,12 +286,12 @@ export async function runWorkerMain(
|
||||
// `AsyncFunction` is not a global. The program body is strict-mode.
|
||||
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
|
||||
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
|
||||
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`)
|
||||
const value = await fn(...namespaces, consoleShim)
|
||||
done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
|
||||
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'ToolCallError', 'console', `'use strict';\n${data.code}`)
|
||||
const value = await fn(...namespaces, ToolCallError, consoleShim)
|
||||
done = { type: 'done', ...prepareCompletion(value, data.maxOutputBytes) }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
done = { type: 'done', error: { message } }
|
||||
done = { type: 'done', error: { kind: 'exception', message } }
|
||||
}
|
||||
port.postMessage(done)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } 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 { logTruncationMarker } from './protocol.ts'
|
||||
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
@@ -35,14 +34,8 @@ export interface Config {
|
||||
* nobody will resolve).
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
|
||||
maxLogBytes?: number
|
||||
/**
|
||||
* Byte cap for the completion value, measured by its real cross-boundary
|
||||
* size (string bytes, or structured-clone wire size); an oversized or
|
||||
* non-cloneable value crosses as a capped string rendering.
|
||||
*/
|
||||
maxValueBytes?: number
|
||||
/** Hard cap for the combined serialized outer logs, completion value, and failure diagnostic. */
|
||||
maxOutputBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
}
|
||||
@@ -59,6 +52,9 @@ type ResolvedConfig = Required<Config>
|
||||
*/
|
||||
const ELU_POLL_INTERVAL_MS = 25
|
||||
|
||||
/** Smallest cap that can represent the empty logs array plus an empty JSON failure diagnostic. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
@@ -130,25 +126,74 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
if (typeof m.text !== 'string') return undefined
|
||||
return { type: 'log', text: m.text }
|
||||
}
|
||||
case 'output-limit': return { type: 'output-limit' }
|
||||
case 'done': {
|
||||
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
|
||||
const error = m.error
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
const message = (error as Record<string, unknown>).message
|
||||
if (typeof message !== 'string') return undefined
|
||||
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
|
||||
const { kind, message } = error as Record<string, unknown>
|
||||
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
|
||||
return { type: 'done', error: { kind, message } }
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
|
||||
* truncation suffix {@link prepareValue} appends, so a value the WORKER
|
||||
* already capped (byte-exact prefix + this marker) passes through unchanged
|
||||
* instead of being marked twice.
|
||||
*/
|
||||
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
|
||||
|
||||
/** Serialized byte size of one lossless JSON value. */
|
||||
function jsonBytes(value: CodeJsonValue): number {
|
||||
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||
}
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
||||
admit(text: string, sink: string[]): boolean {
|
||||
const cost = Buffer.byteLength(JSON.stringify(text), 'utf8') + (this.entries > 0 ? 1 : 0)
|
||||
if (this.bytes + cost > this.maxBytes) return false
|
||||
this.bytes += cost
|
||||
this.entries += 1
|
||||
sink.push(text)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Finalize a successful absent-or-JSON completion against the combined cap. */
|
||||
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
||||
if (value !== undefined && this.bytes + jsonBytes(value) > this.maxBytes) return this.limit(logs)
|
||||
return { logs, ...value !== undefined ? { value } : {} }
|
||||
}
|
||||
|
||||
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (this.bytes + Buffer.byteLength(JSON.stringify(error.message), 'utf8') > this.maxBytes) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining the fitting log prefix. */
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
let retainedBytes = this.bytes
|
||||
const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8')
|
||||
while (logs.length > 0 && retainedBytes + messageBytes > this.maxBytes) {
|
||||
const removed = logs.pop()
|
||||
/* v8 ignore next -- the while guard proves pop cannot return undefined. */
|
||||
if (removed === undefined) throw new Error('output ledger lost its final log entry')
|
||||
retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + (logs.length > 0 ? 1 : 0)
|
||||
}
|
||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||
// This fixed diagnostic is ASCII with no JSON escapes, so two bytes are
|
||||
// the surrounding quotes and every retained character costs one byte.
|
||||
const message = messageBytes <= availableMessageBytes
|
||||
? fullMessage
|
||||
: fullMessage.slice(0, availableMessageBytes - 2)
|
||||
return { logs, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
|
||||
@@ -161,8 +206,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
static Config: z<Config> = z.object({
|
||||
computeMs: z.number().default(60_000),
|
||||
maxWallMs: z.number().default(600_000),
|
||||
maxLogBytes: z.number().default(65_536),
|
||||
maxValueBytes: z.number().default(32_768),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
})
|
||||
|
||||
@@ -181,6 +225,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
|
||||
throw new Error(`dsh-code-runtime-worker: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
|
||||
}
|
||||
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
|
||||
}
|
||||
|
||||
@@ -232,7 +279,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
if (namespace.global === 'console' || namespace.global === 'ToolCallError' || bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace.functions)
|
||||
@@ -249,8 +296,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const bootData: WorkerBootData = {
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
|
||||
maxLogBytes: this.config.maxLogBytes,
|
||||
maxValueBytes: this.config.maxValueBytes,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: bootData,
|
||||
@@ -274,28 +320,13 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const answered = new Set<number>()
|
||||
const logs: string[] = []
|
||||
const strayLogs: string[] = []
|
||||
|
||||
// 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.
|
||||
let logBudget = this.config.maxLogBytes
|
||||
let logsTruncated = false
|
||||
const admit = (text: string, sink: string[]): void => {
|
||||
if (logsTruncated) return
|
||||
const cost = Buffer.byteLength(text, 'utf8')
|
||||
if (cost > logBudget) {
|
||||
logsTruncated = true
|
||||
sink.push(logTruncationMarker(this.config.maxLogBytes))
|
||||
return
|
||||
}
|
||||
logBudget -= cost
|
||||
sink.push(text)
|
||||
}
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
|
||||
// No settled guard: `finish` snapshots the arrays when it resolves, so
|
||||
// a chunk flushing after settlement mutates only the discarded buffers,
|
||||
// and the ledger bounds that growth until the pipes close.
|
||||
const captureStray = (chunk: Buffer): void => {
|
||||
admit(chunk.toString('utf8'), strayLogs)
|
||||
if (!settled && !output.admit(chunk.toString('utf8'), strayLogs)) finish(output.limit([...logs, ...strayLogs]))
|
||||
}
|
||||
worker.stdout.on('data', captureStray)
|
||||
worker.stderr.on('data', captureStray)
|
||||
@@ -304,7 +335,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// logs captured before timeout, abort, or failure remain in the result.
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
|
||||
const finish = (result: CodeRunResult): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(eluTimer)
|
||||
@@ -313,18 +344,28 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
this.live.delete(live)
|
||||
void worker.terminate().then(() => {
|
||||
finishResolve()
|
||||
resolve({ ...result, logs: [...logs, ...strayLogs] })
|
||||
resolve(result)
|
||||
})
|
||||
}
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
// Re-cap forged completion traffic at the hostile boundary. Honest worker-capped values
|
||||
// pass unchanged via VALUE_RENDER_SLACK; error text is bounded too.
|
||||
finish({
|
||||
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
|
||||
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
|
||||
})
|
||||
const captured = [...logs, ...strayLogs]
|
||||
if (message.error) {
|
||||
finish(output.failure(captured, message.error))
|
||||
return
|
||||
}
|
||||
if (message.value === undefined) {
|
||||
finish(output.success(captured))
|
||||
return
|
||||
}
|
||||
// The worker-thread boundary has already structured-cloned this
|
||||
// hostile value, so accessors and proxies cannot survive to throw
|
||||
// during the lossless-JSON snapshot.
|
||||
const value = snapshotJsonValue(message.value) as CodeJsonValue | undefined
|
||||
finish(value === undefined
|
||||
? output.failure(captured, { kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
: output.success(captured, value))
|
||||
}
|
||||
|
||||
const onCall = (message: WorkerToHost): void => {
|
||||
@@ -336,13 +377,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
answered.add(message.id)
|
||||
const reply = (payload: ReplyMessage): void => {
|
||||
if (settled) return
|
||||
try {
|
||||
worker.postMessage(payload)
|
||||
} catch {
|
||||
// The reply value failed structured clone; renegotiate as an error
|
||||
// reply, which is always clone-plain. Nothing else throws here.
|
||||
worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
|
||||
}
|
||||
// Canonical resolutions were snapshotted as lossless JSON before
|
||||
// this point, so this payload is structured-cloneable by contract.
|
||||
worker.postMessage(payload)
|
||||
}
|
||||
const record = bindings.get(message.global)
|
||||
// Own-property lookup only: a forged name like 'constructor' or
|
||||
@@ -355,7 +392,18 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
|
||||
const resolved = await fn(message.args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
|
||||
} else {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
}
|
||||
@@ -367,15 +415,22 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// this listener would crash the host process. Junk drops silently.
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled) admit(message.text, logs)
|
||||
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
|
||||
finish(output.limit([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit' && !settled) {
|
||||
finish(output.limit([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
})
|
||||
worker.on('exit', (exitCode: number) => {
|
||||
finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
})
|
||||
|
||||
// The compute budget reads the worker's own measured busy time, so a
|
||||
@@ -384,21 +439,21 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const eluTimer = setInterval(() => {
|
||||
const elu = worker.performance.eventLoopUtilization()
|
||||
if (elu.active > this.config.computeMs) {
|
||||
finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
}
|
||||
}, ELU_POLL_INTERVAL_MS)
|
||||
const wallTimer = setTimeout(() => {
|
||||
finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
const onAbort = (): void => {
|
||||
finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const live: LiveRun = {
|
||||
worker,
|
||||
finished,
|
||||
settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
|
||||
settle: (failure: CodeRunFailure) => { finish(output.failure([...logs, ...strayLogs], failure)) },
|
||||
}
|
||||
this.live.add(live)
|
||||
})
|
||||
|
||||
@@ -11,10 +11,8 @@ export interface WorkerBootData {
|
||||
code: string
|
||||
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
|
||||
namespaces: { global: string; names: string[] }[]
|
||||
/** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */
|
||||
maxLogBytes: number
|
||||
/** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */
|
||||
maxValueBytes: number
|
||||
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
|
||||
maxOutputBytes: number
|
||||
}
|
||||
|
||||
/** Worker → host: one bridged binding call. */
|
||||
@@ -36,6 +34,11 @@ interface LogMessage {
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
|
||||
interface OutputLimitMessage {
|
||||
type: 'output-limit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker → host: the program settled. `error` carries a program exception
|
||||
* (the only failure the bootstrap itself can report — budgets, aborts, and
|
||||
@@ -47,26 +50,13 @@ interface LogMessage {
|
||||
export interface DoneMessage {
|
||||
type: 'done'
|
||||
value?: unknown
|
||||
error?: { message: string }
|
||||
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
|
||||
}
|
||||
|
||||
/** Every message the worker sends. */
|
||||
export type WorkerToHost = CallMessage | LogMessage | DoneMessage
|
||||
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
|
||||
|
||||
/** Host → worker: the answer to one {@link CallMessage}. */
|
||||
export type ReplyMessage =
|
||||
| { type: 'reply'; id: number; ok: true; value: unknown }
|
||||
| { type: 'reply'; id: number; ok: false; message: string }
|
||||
|
||||
/**
|
||||
* The in-band marker entry text announcing that log capture stopped at the
|
||||
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
|
||||
* ITS budget exhausts, and the host emits the identical text when its own
|
||||
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
|
||||
* a truncated run reads the same however the cap was hit.
|
||||
* @param maxBytes - the configured `maxLogBytes` the marker names.
|
||||
* @returns the marker line.
|
||||
*/
|
||||
export function logTruncationMarker(maxBytes: number): string {
|
||||
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
|
||||
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, runWorkerMain, ToolCallError, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
|
||||
@@ -43,19 +43,34 @@ function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
|
||||
return { stdout: { write: () => true }, stderr: { write: () => true } }
|
||||
}
|
||||
|
||||
const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
|
||||
/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise
|
||||
return undefined
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
const BOOT = { maxOutputBytes: 65_536 }
|
||||
|
||||
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 fitting prefix and reports the limit once', () => {
|
||||
const seen: string[] = []
|
||||
const buffer = new LogBuffer(10, text => seen.push(text))
|
||||
let limits = 0
|
||||
const buffer = new LogBuffer(10, text => seen.push(text), () => { limits += 1 })
|
||||
buffer.push('12345')
|
||||
buffer.push('123456')
|
||||
buffer.push('dropped')
|
||||
expect(seen).toEqual([
|
||||
'12345',
|
||||
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
|
||||
])
|
||||
expect(seen).toEqual(['12345', '12345'])
|
||||
expect(limits).toBe(1)
|
||||
|
||||
const exactlyFull: string[] = []
|
||||
const fullBuffer = new LogBuffer(4, text => exactlyFull.push(text))
|
||||
fullBuffer.push('1234')
|
||||
fullBuffer.push('no-prefix-fits')
|
||||
expect(exactlyFull).toEqual(['1234'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,45 +124,42 @@ describe('captureStreamWrites', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareValue', () => {
|
||||
it('omits undefined, passes small cloneable values raw', () => {
|
||||
expect(prepareValue(undefined, 100)).toEqual({})
|
||||
expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
|
||||
describe('prepareCompletion', () => {
|
||||
it('omits undefined and passes lossless JSON values exactly', () => {
|
||||
expect(prepareCompletion(undefined, 100)).toEqual({})
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
|
||||
})
|
||||
|
||||
it('replaces a non-cloneable value with its rendering', () => {
|
||||
const { value } = prepareValue({ fn: () => 1 }, 1_000)
|
||||
expect(typeof value).toBe('string')
|
||||
expect(value).toContain('fn')
|
||||
it('turns every lossy completion shape into invalid-output', () => {
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const sparse = Array(2)
|
||||
class Exotic { readonly marker = true }
|
||||
for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) {
|
||||
expect(prepareCompletion(value, 1_000)).toEqual({
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an oversized value with a truncation-marked capped rendering', () => {
|
||||
const { value } = prepareValue('x'.repeat(50), 10)
|
||||
expect(value).toBe(`${'x'.repeat(10)}… [truncated]`)
|
||||
it('reports an oversized value instead of substituting rendered text', () => {
|
||||
expect(prepareCompletion('x'.repeat(50), 10)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('measures a container by its structured-clone wire size, not its bounded rendering', () => {
|
||||
// The bounded inspect rendering of a huge array is tiny ("... N more
|
||||
// items"), but its real cross-boundary size is not — the cap must catch
|
||||
// it, replacing the value with that bounded rendering.
|
||||
const huge = new Array(50_000).fill(7)
|
||||
const { value } = prepareValue(huge, 1_000)
|
||||
expect(typeof value).toBe('string')
|
||||
expect(value).toContain('more items')
|
||||
it('measures the exact JSON serialization at and over the boundary', () => {
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: '€' })
|
||||
expect(prepareCompletion('€', 4)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => {
|
||||
// 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the
|
||||
// full string through untruncated.
|
||||
expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' })
|
||||
})
|
||||
|
||||
it('caps a multibyte rendering by UTF-8 bytes too', () => {
|
||||
// Wire size (24-byte string inside an array) exceeds the cap, so the
|
||||
// value crosses as its rendering — whose truncation must also be
|
||||
// byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would
|
||||
// overflow the 10-byte budget.
|
||||
expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" })
|
||||
it('contains a getter failure as invalid-output', () => {
|
||||
const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } })
|
||||
expect(prepareCompletion(value, 1_000)).toEqual({
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,10 +203,35 @@ describe('makeNamespaces', () => {
|
||||
}
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/)
|
||||
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/)
|
||||
const first = await rejectionOf(tools.x?.(() => 1) ?? Promise.resolve())
|
||||
const second = await rejectionOf(tools.x?.(() => 1) ?? Promise.resolve())
|
||||
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
expect(first).toBeInstanceOf(ToolCallError)
|
||||
expect(second).toBeInstanceOf(ToolCallError)
|
||||
expect((first as Error).message).toMatch(/DataCloneError-ish/)
|
||||
expect((second as Error).message).toMatch(/raw-clone-failure/)
|
||||
expect(pending.size).toBe(0)
|
||||
})
|
||||
|
||||
it('uses ordinary Error for non-tools namespace failures', async () => {
|
||||
const deniedPort = new FakePort()
|
||||
deniedPort.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
const deniedPending = new Map<number, PendingCall>()
|
||||
wireReplies(deniedPort, deniedPending)
|
||||
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
|
||||
expect(denied).toBeInstanceOf(Error)
|
||||
expect(denied).not.toBeInstanceOf(ToolCallError)
|
||||
|
||||
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
|
||||
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const cloneFailure = await rejectionOf(cloneHelpers.x?.(() => 1) ?? Promise.resolve())
|
||||
expect(cloneFailure).toBeInstanceOf(Error)
|
||||
expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorkerMain', () => {
|
||||
@@ -210,11 +247,24 @@ describe('runWorkerMain', () => {
|
||||
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
|
||||
})
|
||||
|
||||
it('reports worker-side log capture overflow before completing', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, {
|
||||
maxOutputBytes: 4,
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(port.sent).toContainEqual({ type: 'log', text: '1234' })
|
||||
expect(port.sent).toContainEqual({ type: 'output-limit' })
|
||||
expect(port.done()).toEqual({ type: 'done', value: null })
|
||||
})
|
||||
|
||||
it('reports a thrown program error on the done message', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
|
||||
const done = port.done()
|
||||
expect(done?.type).toBe('done')
|
||||
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
|
||||
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
|
||||
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
|
||||
})
|
||||
@@ -222,11 +272,11 @@ describe('runWorkerMain', () => {
|
||||
it('renders non-Error throws and stack-less Errors on the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } })
|
||||
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
|
||||
|
||||
const barePort = new FakePort()
|
||||
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } })
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
@@ -234,10 +284,14 @@ describe('runWorkerMain', () => {
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }',
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
}, fakeStreams())
|
||||
expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' })
|
||||
expect(port.done()).toEqual({
|
||||
type: 'done',
|
||||
value: { caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' },
|
||||
})
|
||||
expect(new ToolCallError('x', 'nope')).toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'nope' })
|
||||
})
|
||||
|
||||
it('ignores replies for unknown pending ids', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/**
|
||||
* Integration suite over REAL worker threads (no mocks — workers are cheap
|
||||
@@ -17,8 +17,8 @@ async function setup(config: Config = {}) {
|
||||
}
|
||||
|
||||
/** Convenience: one namespace `tools` with the given functions. */
|
||||
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>) {
|
||||
return [{ global: 'tools', functions }]
|
||||
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
|
||||
return [{ global: 'tools', functions: functions as Record<string, CodeBindingFunction> }]
|
||||
}
|
||||
|
||||
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
@@ -52,10 +52,10 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const first = await tools.echo({ n: 1 });
|
||||
let caught = '';
|
||||
try { await tools.fail({}) } catch (error) { caught = error.message }
|
||||
let caughtRaw = '';
|
||||
try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
|
||||
let caught = {};
|
||||
try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
|
||||
let caughtRaw = {};
|
||||
try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
|
||||
return { first, caught, caughtRaw };
|
||||
`,
|
||||
bindings: tools({
|
||||
@@ -66,7 +66,11 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
}),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
|
||||
expect(result.value).toEqual({
|
||||
first: { echoed: { n: 1 } },
|
||||
caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
|
||||
caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
|
||||
})
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
@@ -90,10 +94,11 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
expect(result.value).toBe('{}')
|
||||
})
|
||||
|
||||
it('replaces a non-cloneable return value with a string rendering', async () => {
|
||||
it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('completes a program that returns nothing with no value at all', async () => {
|
||||
@@ -201,30 +206,47 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
expect(after.value).toBe('alive')
|
||||
}, 30_000)
|
||||
|
||||
it('truncates runaway log output at the byte budget with an in-band marker', async () => {
|
||||
const { runtime } = await setup({ maxLogBytes: 300 })
|
||||
it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 300 })
|
||||
const result = await runtime.run({
|
||||
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.logs.at(-1)).toContain('truncated at 300 bytes')
|
||||
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
|
||||
expect(total).toBeLessThan(1_000)
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.logs.length).toBeGreaterThan(0)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
|
||||
})
|
||||
|
||||
it('caps an oversized return value with a truncation marker', async () => {
|
||||
const { runtime } = await setup({ maxValueBytes: 64 })
|
||||
it('fails an oversized return value without substituting a string', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
|
||||
expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('caps a multibyte return value by UTF-8 bytes, not string length', async () => {
|
||||
// 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full
|
||||
// string cross. The worker's byte-exact capped rendering then passes the
|
||||
// host re-cap unchanged (cap + marker is exactly the granted slack).
|
||||
const { runtime } = await setup({ maxValueBytes: 4 })
|
||||
const result = await runtime.run({ program: 'return "€€€€"', bindings: [] })
|
||||
expect(result.value).toBe('€… [truncated]')
|
||||
it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
|
||||
const exact = await setup({ maxOutputBytes: 7 })
|
||||
const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
// [] costs two bytes and JSON serialization of "€" costs five.
|
||||
expect(exactResult).toEqual({ logs: [], value: '€' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 6 })
|
||||
const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
expect(overResult.error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('accounts logs and completion in one exact combined ledger', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], value: 'xy' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('completes a program that awaits its write callback, capturing the chunk', async () => {
|
||||
@@ -241,32 +263,48 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
expect(result.logs).toContain('flushed')
|
||||
})
|
||||
|
||||
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
|
||||
it('returns a large JSON container exactly when the outer cap permits it', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value).toContain('more items')
|
||||
expect(result.value).toEqual(new Array(50_000).fill(7))
|
||||
})
|
||||
|
||||
it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
|
||||
const { runtime } = await setup({ maxLogBytes: 4 })
|
||||
it('returns an exact completion at the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
// [] costs two bytes and the JSON string contributes two quotes, leaving
|
||||
// exactly this many payload bytes under the 67_108_864-byte default.
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toEqual([])
|
||||
expect(result.value).toHaveLength(67_108_860)
|
||||
}, 60_000)
|
||||
|
||||
it('fails one byte over the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
|
||||
}, 60_000)
|
||||
|
||||
it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 80 })
|
||||
const result = await runtime.run({
|
||||
// The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
|
||||
// writes in separate chunks and let both reach the host before settlement.
|
||||
program: `
|
||||
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
|
||||
write('abcd');
|
||||
write('a'.repeat(20));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
write('ef');
|
||||
write('b'.repeat(100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return 1;
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toContain('abcd')
|
||||
expect(result.logs).not.toContain('ef')
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.logs).toContain('a'.repeat(20))
|
||||
expect(result.logs).not.toContain('b'.repeat(100))
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -305,7 +343,8 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
{ type: 'log', text: 7 },
|
||||
{ type: 'log', text: {} },
|
||||
{ type: 'done', error: 5 },
|
||||
{ type: 'done', error: { message: 5 } },
|
||||
{ type: 'done', error: { kind: 'exception', message: 5 } },
|
||||
{ type: 'done', error: { kind: 'invented', message: 'bad kind' } },
|
||||
]) parentPort.postMessage(junk);
|
||||
return await tools.real({});
|
||||
`,
|
||||
@@ -316,10 +355,10 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
expect(result.logs).toEqual([])
|
||||
})
|
||||
|
||||
it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => {
|
||||
const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 })
|
||||
it('fails forged log floods and forged done values through the same outer cap', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200 })
|
||||
const result = await runtime.run({
|
||||
// Forged messages bypass the worker-side LogBuffer and prepareValue
|
||||
// Forged messages bypass the worker-side LogBuffer and completion check
|
||||
// entirely — only the host-side ledger and re-cap stand between model
|
||||
// code and an unbounded result.
|
||||
program: `
|
||||
@@ -330,53 +369,79 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(typeof result.value).toBe('string')
|
||||
const value = result.value as string
|
||||
expect(value.startsWith('V'.repeat(64))).toBe(true)
|
||||
expect(value.endsWith('… [truncated]')).toBe(true)
|
||||
expect(value.length).toBeLessThan(120)
|
||||
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
|
||||
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
|
||||
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
|
||||
expect(result.logs.at(-1)).toBe(marker)
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {
|
||||
it('drops a malformed forged done carrying both value and error', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } });
|
||||
for (;;) {}
|
||||
parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
|
||||
return 'honest';
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.value).toBe('lied')
|
||||
expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' })
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
|
||||
})
|
||||
|
||||
it('byte-bounds forged multibyte error text at the host', async () => {
|
||||
// Forged error text bypasses the worker entirely; the host bound is a
|
||||
// BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not).
|
||||
const { runtime } = await setup({ maxValueBytes: 8 })
|
||||
it('turns forged over-limit error text into output-limit at the host', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } });
|
||||
parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'exception', message: '€€' })
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
|
||||
it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return error.message }',
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: tools({ bad: async () => (() => 1) }),
|
||||
})
|
||||
expect(result.value).toContain('not structured-cloneable')
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('contains throwing getters while snapshotting binding resolutions', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
|
||||
})
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('revalidates a forged lossy completion at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: -0 });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
|
||||
})
|
||||
|
||||
it('honors a forged worker-side output-limit signal', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'output-limit' });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
|
||||
})
|
||||
|
||||
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
|
||||
@@ -392,12 +457,13 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
})
|
||||
|
||||
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
|
||||
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, reserved injected globals)', async () => {
|
||||
const { runtime } = await setup()
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
['ToolCallError', /duplicate binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
@@ -413,6 +479,12 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
|
||||
})
|
||||
|
||||
it('requires maxOutputBytes to fit the smallest outer failure envelope', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
})
|
||||
|
||||
it('keeps runs isolated: no state survives from one run to the next', async () => {
|
||||
const { runtime } = await setup()
|
||||
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
|
||||
@@ -8,15 +8,15 @@ This package is the interface third of the capability (the bash trio is the temp
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. |
|
||||
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. |
|
||||
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
|
||||
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
|
||||
|
||||
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
|
||||
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
|
||||
|
||||
## 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?`, ordered capped `logs: string[]`, 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 and outer-output cap) 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 returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -31,3 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
|
||||
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
|
||||
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
|
||||
- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound.
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CodeRunRequest, CodeRunResult } from './types.ts'
|
||||
export type {
|
||||
CodeBindingFunction,
|
||||
CodeBindingNamespace,
|
||||
CodeJsonValue,
|
||||
CodeRunFailure,
|
||||
CodeRunRequest,
|
||||
CodeRunResult,
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
/**
|
||||
* One host-side function exposed to the program as an async callable. The
|
||||
* runtime bridges calls to it (possibly across a serialization boundary), so
|
||||
* `args` and the resolution value MUST be structured-cloneable; a runtime
|
||||
* rejects a non-cloneable value with a descriptive error rather than
|
||||
* corrupting the run. A rejection of this function surfaces inside the
|
||||
* program as a rejection of the corresponding call.
|
||||
* `args` and the resolution value MUST be lossless JSON. A runtime rejects a
|
||||
* lossy or non-cloneable value with a descriptive error rather than corrupting
|
||||
* the run. No seam-level byte cap applies to a binding resolution. A rejection
|
||||
* of this function surfaces inside the program as a rejection of the
|
||||
* corresponding call.
|
||||
*/
|
||||
export type CodeBindingFunction = (args: unknown) => Promise<unknown>
|
||||
export type CodeBindingFunction = (args: unknown) => Promise<CodeJsonValue>
|
||||
|
||||
/** A lossless JSON value transferable across the dependency-light code-runtime seam. */
|
||||
export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue }
|
||||
|
||||
/**
|
||||
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
|
||||
@@ -63,10 +67,12 @@ export interface CodeRunRequest {
|
||||
* - `'timeout'` — an implementation-owned budget expired; the message says which.
|
||||
* - `'abort'` — {@link CodeRunRequest.signal} fired.
|
||||
* - `'worker-exit'` — the execution substrate died without settling (e.g. OOM).
|
||||
* - `'invalid-output'` — the completion value was not lossless JSON.
|
||||
* - `'output-limit'` — the serialized outer logs/value/diagnostic exceeded the configured cap.
|
||||
*/
|
||||
export interface CodeRunFailure {
|
||||
/** The failure class (see the interface doc for each kind's meaning). */
|
||||
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'
|
||||
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'
|
||||
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
|
||||
message: string
|
||||
}
|
||||
@@ -79,12 +85,12 @@ export interface CodeRunFailure {
|
||||
export interface CodeRunResult {
|
||||
/**
|
||||
* The program's completion value (its top-level `return`), when it ran to
|
||||
* completion and the value survived the runtime's serialization boundary;
|
||||
* a non-transferable value is replaced by a string rendering, and a failed
|
||||
* or value-less run leaves this absent.
|
||||
* completion and the value crossed the runtime's lossless-JSON boundary.
|
||||
* Invalid or over-limit completions fail the run instead of substituting a
|
||||
* rendered string; a failed or value-less run leaves this absent.
|
||||
*/
|
||||
value?: unknown
|
||||
/** Text the program emitted, in order (capped by the implementation). */
|
||||
value?: CodeJsonValue
|
||||
/** Text the program emitted, in order, bounded only as part of the outer result. */
|
||||
logs: string[]
|
||||
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
|
||||
error?: CodeRunFailure
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('CodeRuntime service seam', () => {
|
||||
const calls: unknown[] = []
|
||||
const result = await runtime.run({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }],
|
||||
bindings: [{ global: 'tools', functions: { probe: async (args) => { calls.push(args); return null } } }],
|
||||
})
|
||||
expect(result).toEqual({ logs: [] })
|
||||
expect(calls).toEqual([{ from: 'stub' }])
|
||||
|
||||
@@ -1126,15 +1126,19 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingFunction',
|
||||
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<unknown>;',
|
||||
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<CodeJsonValue>;',
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingNamespace',
|
||||
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeJsonValue',
|
||||
declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};',
|
||||
},
|
||||
{
|
||||
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\' | \'invalid-output\' | \'output-limit\';\n message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeRunRequest',
|
||||
@@ -1142,7 +1146,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CodeRunResult',
|
||||
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}',
|
||||
declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CollectedOutput',
|
||||
|
||||
@@ -108,11 +108,12 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>` and a bounded Native-content summary; `deriveMessages()` does not surface that event or persist the value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders as pretty JSON, `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer logs, completion, or failure diagnostic; invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
@@ -147,8 +148,8 @@ Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.m
|
||||
|
||||
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
|
||||
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
|
||||
- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Calls execute sequentially, even under `Promise.all`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
@@ -181,8 +182,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input.
|
||||
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
|
||||
- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.
|
||||
- **Code Mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*/
|
||||
|
||||
import { parse } from 'node:path'
|
||||
import { inspect } from 'node:util'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from './schema.ts'
|
||||
import type { ToolDefinition, ToolRegistry } from './index.ts'
|
||||
@@ -62,10 +62,7 @@ export class CodeRunFailedError extends HarnessError {
|
||||
*/
|
||||
const SUMMARY_MAX_CHARS = 200
|
||||
|
||||
/** Bounded inspect for rendering a program's completion value into the model-facing text. */
|
||||
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
|
||||
|
||||
/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */
|
||||
/** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */
|
||||
function textOf(content: ContentBlock[]): string {
|
||||
return content
|
||||
.map((block) => {
|
||||
@@ -88,32 +85,26 @@ function summarize(text: string, cwd: string | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-normalize one binding call's argument into TWO independent parses of the same canonical
|
||||
* text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical
|
||||
* by construction (the runtime's structured-clone boundary is wider than JSON; the session log
|
||||
* accepts only JSON), and separate objects, so a tool mutating its args can neither desync the
|
||||
* log from what was dispatched nor re-poison the append.
|
||||
* Snapshot one binding call's argument as lossless JSON, then clone it into
|
||||
* independent dispatch/log values so a tool mutation cannot desynchronize the
|
||||
* durable event from what was called.
|
||||
*/
|
||||
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
|
||||
if (value === undefined) {
|
||||
throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
|
||||
}
|
||||
let text: string | undefined
|
||||
let snapshot: JsonValue | undefined
|
||||
try {
|
||||
text = JSON.stringify(value)
|
||||
snapshot = snapshotJsonValue(value) as JsonValue | undefined
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
// JSON.stringify's lib type claims `string`, but a bare function or symbol
|
||||
// root really yields `undefined` at runtime — the guard is live.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
|
||||
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
|
||||
if (snapshot === undefined) {
|
||||
throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
|
||||
}
|
||||
return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
|
||||
}
|
||||
|
||||
/** Render one present program completion value for the model-facing result text. */
|
||||
function renderValue(value: JsonValue): string {
|
||||
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
|
||||
@@ -203,7 +194,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
// would be narrowed away by control flow analysis.
|
||||
const runOver = (): boolean => runController.signal.aborted
|
||||
|
||||
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<unknown> => {
|
||||
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<JsonValue> => {
|
||||
if (runOver()) {
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
|
||||
}
|
||||
@@ -234,7 +225,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
isError: result.isError,
|
||||
resultSummary: summarize(text, exec.agent.session.header.cwd),
|
||||
})
|
||||
return { text, isError: result.isError }
|
||||
return result.isError
|
||||
? { isError: true as const, message: result.error.message }
|
||||
: { isError: false as const, value: result.value }
|
||||
})
|
||||
// A budget expiry or outer cancel that lands while this call was in
|
||||
// flight already aborted the dispatch; stop the program now rather
|
||||
@@ -242,11 +235,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
if (runOver()) {
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
|
||||
}
|
||||
// A failed tool call REJECTS — real code signals failure by throwing,
|
||||
// so try/catch and Promise.all short-circuiting behave as models
|
||||
// expect (the error text is the tool's model-facing result text).
|
||||
if (outcome.isError) throw new Error(outcome.text)
|
||||
return outcome.text
|
||||
// The worker turns a binding rejection into ToolCallError and adds
|
||||
// only the binding name. Native content and internal error metadata
|
||||
// stay outside the program-facing failure contract.
|
||||
if (outcome.isError) throw new Error(outcome.message)
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
// Null-prototype + defineProperty, mirroring the worker-side namespace
|
||||
@@ -283,12 +276,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
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}`)
|
||||
}
|
||||
// The runtime seam is wider than JSON until PR 3 makes this boundary
|
||||
// lossless. The registry immediately snapshots and rejects any value
|
||||
// that does not satisfy the declared JSON output.
|
||||
return {
|
||||
logs: result.logs,
|
||||
...result.value !== undefined ? { result: result.value as JsonValue } : {},
|
||||
...result.value !== undefined ? { result: result.value } : {},
|
||||
}
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onOuterAbort)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schem
|
||||
import type { JsonSchemaNode } from './json-schema.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -550,7 +551,7 @@ export class ToolRegistry extends Service {
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
return renderToolsSdk(this.sdkSchemas(context.scope))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -815,6 +816,16 @@ export class ToolRegistry extends Service {
|
||||
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
|
||||
}
|
||||
|
||||
/** Project visible callable tools onto the generated Code Mode SDK contract. */
|
||||
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
|
||||
return [...this.view(scope).visible.values()]
|
||||
.filter(definition => definition.name !== RUN_CODE_NAME)
|
||||
.map((definition): ToolSdkSchema => ({
|
||||
...this.schemaOf(definition, true),
|
||||
output: structuredClone(definition.output.schema),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Project one definition onto the model-facing schema fields. */
|
||||
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
|
||||
const { name, description, parameters } = definition
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||
import type { JsonSchemaScalar } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
|
||||
|
||||
/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */
|
||||
export interface ToolSdkSchema extends ToolSchema {
|
||||
/** Validated canonical value returned by the tool binding. */
|
||||
output: JsonSchemaNode
|
||||
}
|
||||
|
||||
/** Property names that are valid bare TS identifiers; anything else is quoted. */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
@@ -107,8 +113,8 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code
|
||||
|
||||
Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
|
||||
|
||||
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
|
||||
- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue.
|
||||
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
|
||||
- Calls execute sequentially, even under \`Promise.all\`.
|
||||
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
@@ -123,16 +129,24 @@ The available tools:`
|
||||
* `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
*/
|
||||
export function renderToolsSdk(schemas: ToolSchema[]): string {
|
||||
export function renderToolsSdk(schemas: ToolSdkSchema[]): string {
|
||||
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
const members: string[] = []
|
||||
const argsMembers: string[] = []
|
||||
const outputMembers: string[] = []
|
||||
for (const schema of sorted) {
|
||||
members.push(...docLines(schema.description, 1))
|
||||
members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise<string>;`)
|
||||
argsMembers.push(...docLines(schema.description, 1))
|
||||
argsMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.parameters, 1)};`)
|
||||
outputMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.output, 1)};`)
|
||||
}
|
||||
const declaration = members.length > 0
|
||||
? `declare const tools: {\n${members.join('\n')}\n}`
|
||||
: 'declare const tools: {}'
|
||||
const argsMap = `interface ToolArgsMap {${argsMembers.length > 0 ? `\n${argsMembers.join('\n')}\n` : ''}}`
|
||||
const outputMap = `interface ToolOutputMap {${outputMembers.length > 0 ? `\n${outputMembers.join('\n')}\n` : ''}}`
|
||||
const declaration = [
|
||||
argsMap,
|
||||
outputMap,
|
||||
'type ToolName = keyof ToolOutputMap',
|
||||
['declare class ToolCallError extends Error {', ' readonly name: "ToolCallError";', ' readonly toolName: ToolName;', '}'].join('\n'),
|
||||
['declare const tools: {', ' [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;', '}'].join('\n'),
|
||||
].join('\n\n')
|
||||
const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }'
|
||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\``
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -68,13 +68,17 @@ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: S
|
||||
/** Register a trivial echo tool; returns the calls it received. */
|
||||
function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
const calls: unknown[] = []
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name,
|
||||
description: `Echo tool ${name}.`,
|
||||
parameters: { value: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(args) {
|
||||
calls.push(args)
|
||||
return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }])
|
||||
return Promise.resolve(`${name}:${args.value}`)
|
||||
},
|
||||
}))
|
||||
return calls
|
||||
@@ -119,8 +123,8 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk?.text).toContain('declare const tools: {')
|
||||
expect(sdk?.text).toContain('echo(args:')
|
||||
expect(sdk?.text).not.toContain('run_code(args:')
|
||||
expect(sdk?.text).toContain('echo: {')
|
||||
expect(sdk?.text).not.toContain('run_code:')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
|
||||
@@ -172,8 +176,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['echo', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).toContain('echo(args:')
|
||||
expect(sdk).not.toContain('hidden(args:')
|
||||
expect(sdk).toContain('echo: {')
|
||||
expect(sdk).not.toContain('hidden:')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -202,8 +206,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['kept', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).not.toContain('denied(args:')
|
||||
expect(sdk).toContain('kept(args:')
|
||||
expect(sdk).not.toContain('denied:')
|
||||
expect(sdk).toContain('kept: {')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -241,7 +245,7 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
|
||||
@@ -328,7 +332,8 @@ describe('the run_code dispatch bridge', () => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const first = await tools.echo!({ value: 'one' })
|
||||
const second = await tools.echo!({ value: 'two' })
|
||||
return { logs: [`saw ${String(first)}`], value: second }
|
||||
if (typeof first !== 'string' || typeof second !== 'string') throw new Error('echo returned a non-string')
|
||||
return { logs: [`saw ${first}`], value: second }
|
||||
}
|
||||
const result = await runCode(ctx, 'const …: string = …', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -376,10 +381,14 @@ describe('the run_code dispatch bridge', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const intervals: [string, string][] = []
|
||||
let active = 0
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'probe',
|
||||
description: 'Records execution overlap.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args) {
|
||||
active++
|
||||
expect(active, 'probe executions overlapped').toBe(1)
|
||||
@@ -387,12 +396,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
intervals.push(['exit', args.id])
|
||||
active--
|
||||
return [{ type: 'text' as const, text: args.id }]
|
||||
return args.id
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })])
|
||||
if (!values.every(value => typeof value === 'string')) throw new Error('probe returned a non-string')
|
||||
return { logs: [], value: values.join(',') }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
@@ -422,7 +432,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' })
|
||||
})
|
||||
|
||||
it('a tools/pre-execute deny reaches the program as a binding rejection', async () => {
|
||||
@@ -445,7 +455,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((result.content[0] as { text: string }).text).toContain('not on my watch')
|
||||
})
|
||||
|
||||
it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => {
|
||||
it('rejects a binding argument that is not lossless JSON, dispatching nothing', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -458,25 +468,24 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect((result.content[0] as { text: string }).text).toContain('JSON-serializable')
|
||||
expect((result.content[0] as { text: string }).text).toContain('lossless JSON')
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => {
|
||||
it('dispatches and logs independent snapshots of the same lossless JSON value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
runtime.behavior = async (request) => {
|
||||
// A Date survives structured clone but is not JSON; the bridge
|
||||
// normalizes it to its JSON form (an ISO string) BEFORE dispatch.
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined)
|
||||
const args = Object.assign(Object.create(null) as Record<string, unknown>, { value: 'x', nested: ['same'] })
|
||||
await request.bindings[0]!.functions.echo!(args)
|
||||
return { logs: [] }
|
||||
}
|
||||
await runCode(ctx, 'program', { agent })
|
||||
expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }])
|
||||
expect(calls).toEqual([{ value: 'x', nested: ['same'] }])
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', nested: ['same'] })
|
||||
})
|
||||
|
||||
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
|
||||
@@ -691,15 +700,19 @@ describe('the run_code dispatch bridge', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
const long = 'x'.repeat(300)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'mixed',
|
||||
description: 'Returns mixed content.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => [
|
||||
{ type: 'text', text: long },
|
||||
{ type: 'reasoning', text: 'hidden' },
|
||||
],
|
||||
},
|
||||
execute() {
|
||||
return Promise.resolve([
|
||||
{ type: 'text' as const, text: long },
|
||||
{ type: 'reasoning' as const, text: 'hidden' },
|
||||
])
|
||||
return Promise.resolve('mixed-value')
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
@@ -708,7 +721,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`)
|
||||
expect((result.content[0] as { text: string }).text).toBe('mixed-value')
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.resultSummary.length).toBe(201)
|
||||
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
|
||||
@@ -716,13 +729,17 @@ describe('the run_code dispatch bridge', () => {
|
||||
|
||||
it('normalizes the session workspace root before bounding durable result summaries', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'workspace_path',
|
||||
description: 'Return a path beneath the session workspace.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? ''
|
||||
return Promise.resolve([{ type: 'text' as const, text: `<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}` }])
|
||||
return Promise.resolve(`<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}`)
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async request => ({
|
||||
@@ -760,7 +777,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
})
|
||||
|
||||
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
|
||||
it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -773,8 +790,9 @@ describe('the run_code dispatch bridge', () => {
|
||||
// Root undefined must reject up front: the event log rejects it as
|
||||
// data, and nothing may execute unlogged.
|
||||
await catchMessage(echo(undefined)),
|
||||
// A toJSON that throws a NON-Error propagates out of JSON.stringify.
|
||||
await catchMessage(echo({ toJSON() { throw 'raw-throw' } })),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw 'raw-throw' } }))),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('error-throw') } }))),
|
||||
await catchMessage(echo(new Date(0))),
|
||||
// A bare function is a value JSON cannot represent at all.
|
||||
await catchMessage(echo(() => 1)),
|
||||
].join(' | '),
|
||||
@@ -783,9 +801,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('call the tool with an arguments object')
|
||||
expect(text).toContain('JSON-serializable: raw-throw')
|
||||
expect(text).toContain('a value JSON cannot represent')
|
||||
// None of the three dispatched, none logged.
|
||||
expect(text).toContain('lossless JSON: raw-throw')
|
||||
expect(text).toContain('lossless JSON: error-throw')
|
||||
expect(text.match(/tool arguments must be lossless JSON/g)).toHaveLength(5)
|
||||
// None dispatched or logged.
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
@@ -816,11 +835,15 @@ describe('the run_code dispatch bridge', () => {
|
||||
|
||||
it('exposes a tool named __proto__ as an ordinary own binding', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: '__proto__',
|
||||
description: 'A prototype-colliding tool name.',
|
||||
parameters: {},
|
||||
execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute() { return Promise.resolve('proto-tool-ok') },
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const functions = request.bindings[0]!.functions
|
||||
@@ -833,11 +856,20 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' })
|
||||
})
|
||||
|
||||
it('renders a non-string completion value inspect-style', async () => {
|
||||
it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } })
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
|
||||
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42\n}' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: ['x', 2] })
|
||||
expect((await runCode(ctx, 'array')).content[0]).toEqual({ type: 'text', text: '[\n "x",\n 2\n]' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: null })
|
||||
expect((await runCode(ctx, 'null')).content[0]).toEqual({ type: 'text', text: 'null' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: 'raw' })
|
||||
expect((await runCode(ctx, 'string')).content[0]).toEqual({ type: 'text', text: 'raw' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [] })
|
||||
const absent = await runCode(ctx, 'undefined')
|
||||
expect(absent.content[0]).toEqual({ type: 'text', text: '(run_code completed with no output)' })
|
||||
expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] })
|
||||
})
|
||||
|
||||
it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
describe('jsonSchemaToTs', () => {
|
||||
it('maps every unified schema construct', () => {
|
||||
@@ -96,31 +96,45 @@ describe('jsonSchemaToTs', () => {
|
||||
})
|
||||
|
||||
describe('renderToolsSdk', () => {
|
||||
const bash: ToolSchema = {
|
||||
const bash: ToolSdkSchema = {
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
||||
output: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { exitCode: { type: 'integer' } },
|
||||
required: ['exitCode'],
|
||||
},
|
||||
}
|
||||
const exotic: ToolSchema = {
|
||||
const exotic: ToolSdkSchema = {
|
||||
name: 'my-mcp.tool',
|
||||
description: 'Exotic name.',
|
||||
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||
output: { type: 'array', items: { type: 'string' } },
|
||||
}
|
||||
|
||||
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
|
||||
const text = renderToolsSdk([exotic, bash])
|
||||
expect(text).toContain('interface ToolArgsMap {')
|
||||
expect(text).toContain('interface ToolOutputMap {')
|
||||
expect(text).toContain('type ToolName = keyof ToolOutputMap')
|
||||
expect(text).toContain('declare class ToolCallError extends Error')
|
||||
expect(text).toContain('readonly toolName: ToolName;')
|
||||
expect(text).toContain('declare const tools: {')
|
||||
expect(text).toContain('type JsonValue = null | boolean | number | string')
|
||||
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool"(args:')
|
||||
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
|
||||
expect(text).toContain('): Promise<string>;')
|
||||
expect(text.indexOf('bash: {')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool":')
|
||||
expect(text.indexOf('bash:')).toBeLessThan(text.indexOf('"my-mcp.tool":'))
|
||||
expect(text).toContain('exitCode: number;')
|
||||
expect(text).toContain('"my-mcp.tool": string[];')
|
||||
expect(text).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
|
||||
expect(text).toContain('/** Run a shell command. */')
|
||||
// The fixed instruction lines the model relies on.
|
||||
expect(text).toContain('erasable syntax only')
|
||||
expect(text).toContain('rejects with an `Error`')
|
||||
expect(text).toContain('rejects with `ToolCallError`')
|
||||
expect(text).toContain('sequentially, even under `Promise.all`')
|
||||
expect(text).toContain('JSON-serializable')
|
||||
expect(text).toContain('lossless JSON')
|
||||
})
|
||||
|
||||
it('is deterministic: same tool set, byte-identical text regardless of input order', () => {
|
||||
@@ -130,6 +144,8 @@ describe('renderToolsSdk', () => {
|
||||
})
|
||||
|
||||
it('renders an empty declaration for an empty tool set', () => {
|
||||
expect(renderToolsSdk([])).toContain('declare const tools: {}')
|
||||
const text = renderToolsSdk([])
|
||||
expect(text).toContain('interface ToolArgsMap {}')
|
||||
expect(text).toContain('interface ToolOutputMap {}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
|
||||
class StubStore extends SpillStore {
|
||||
@@ -173,6 +174,42 @@ describe('oversized plain-text replacement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('outer Code Mode failure capture', () => {
|
||||
it('spills the bounded output-limit diagnostic through the ordinary outer-result policy', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: 200 })
|
||||
await ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 500 })
|
||||
const events: unknown[] = []
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: SessionId('code-spill'), cwd: '/workspace' },
|
||||
append: (_type: string, data: unknown) => { events.push(data) },
|
||||
},
|
||||
}
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('code-output-limit'),
|
||||
name: 'run_code',
|
||||
arguments: {
|
||||
code: 'console.log("HEAD-" + "x".repeat(300)); console.log("TAIL-" + "y".repeat(300)); return "unreachable";',
|
||||
},
|
||||
agent: agent as never,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
const saved = (ctx.spillStore as StubStore).saves
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0]?.source.toolName).toBe('run_code')
|
||||
expect(saved[0]?.content).toContain('code run failed (output-limit)')
|
||||
expect(saved[0]?.content).toContain('HEAD-')
|
||||
expect(textOf(result.content)).toContain('Full formatted result stored at: /spill/run_code.txt')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('read skip', () => {
|
||||
it('never spills the read tool result (avoids a read → spill → read loop)', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
||||
|
||||
@@ -443,8 +443,10 @@ describe('in-process structured output', () => {
|
||||
expect(result.structured).toEqual({ answer: 12 })
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
|
||||
expect(request.system).toContain('declare const tools:')
|
||||
expect(request.system).toContain('structured_output(args:')
|
||||
expect(request.system).toContain('interface ToolArgsMap')
|
||||
expect(request.system).toContain('interface ToolOutputMap')
|
||||
expect(request.system).toContain('recorded: true;')
|
||||
expect(request.system).toContain('Promise<ToolOutputMap[K]>')
|
||||
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user