fix(code-mode): render deep results iteratively
This commit is contained in:
@@ -23,7 +23,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
- **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'`).
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, flatten into a bounded-depth pre-order wire value for structured clone, and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth 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. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `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.
|
||||
- **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. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. 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.
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export interface Config {
|
||||
* nobody will resolve).
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/** Hard cap for the combined serialized outer logs, completion value, and failure diagnostic. */
|
||||
/**
|
||||
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
|
||||
* fixed result-envelope syntax is excluded.
|
||||
*/
|
||||
maxOutputBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
@@ -56,7 +59,7 @@ 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. */
|
||||
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
|
||||
|
||||
@@ -656,7 +656,7 @@ 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 () => {
|
||||
it('requires maxOutputBytes to fit the smallest counted outer payloads', 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/)
|
||||
|
||||
@@ -117,7 +117,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
|
||||
- **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.
|
||||
- **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 through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `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 log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
|
||||
@@ -102,9 +102,94 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
|
||||
return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
|
||||
}
|
||||
|
||||
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
|
||||
const JSON_INDENT = ' '
|
||||
|
||||
/**
|
||||
* ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
|
||||
* renderer also caps TOTAL indentation there, compacting deeper subtrees, so
|
||||
* formatted output remains linear in the canonical JSON size.
|
||||
*/
|
||||
const MAX_JSON_INDENT_CHARS = 10
|
||||
|
||||
/** A pending fragment in the iterative JSON presentation traversal. */
|
||||
type JsonRenderTask =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
|
||||
|
||||
/** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
|
||||
function renderJsonValue(value: Exclude<JsonValue, string>): string {
|
||||
const chunks: string[] = []
|
||||
const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'text') {
|
||||
chunks.push(task.text)
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null || typeof current === 'boolean' || typeof current === 'number') {
|
||||
chunks.push(String(current))
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'string') {
|
||||
chunks.push(JSON.stringify(current))
|
||||
continue
|
||||
}
|
||||
|
||||
const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
|
||||
const childDepth = task.depth + 1
|
||||
if (Array.isArray(current)) {
|
||||
chunks.push('[')
|
||||
if (current.length === 0) {
|
||||
chunks.push(']')
|
||||
continue
|
||||
}
|
||||
tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
const item = current[index]
|
||||
/* v8 ignore next -- canonical JsonValue arrays are dense. */
|
||||
if (item === undefined) throw new Error('cannot render a sparse JSON array')
|
||||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||||
tasks.push({
|
||||
kind: 'text',
|
||||
text: compact
|
||||
? index === 0 ? '' : ','
|
||||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const keys = Object.keys(current)
|
||||
chunks.push('{')
|
||||
if (keys.length === 0) {
|
||||
chunks.push('}')
|
||||
continue
|
||||
}
|
||||
tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||||
if (key === undefined) throw new Error('cannot render a missing JSON object key')
|
||||
const item = current[key]
|
||||
/* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
|
||||
if (item === undefined) throw new Error('cannot render an undefined JSON object property')
|
||||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||||
tasks.push({
|
||||
kind: 'text',
|
||||
text: compact
|
||||
? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
|
||||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
|
||||
})
|
||||
}
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
/** Render one present program completion value for the model-facing result text. */
|
||||
function renderValue(value: JsonValue): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
return typeof value === 'string' ? value : renderJsonValue(value)
|
||||
}
|
||||
|
||||
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
|
||||
|
||||
@@ -10,7 +10,7 @@ import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DI
|
||||
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'
|
||||
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -860,10 +860,17 @@ describe('the run_code dispatch bridge', () => {
|
||||
|
||||
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 } })
|
||||
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42\n}' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } })
|
||||
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: {} })
|
||||
expect((await runCode(ctx, 'empty object')).content[0]).toEqual({ type: 'text', text: '{}' })
|
||||
const nested = { outer: [{ inner: true }] }
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: nested })
|
||||
expect((await runCode(ctx, 'nested')).content[0]).toEqual({ type: 'text', text: JSON.stringify(nested, null, 2) })
|
||||
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: [] })
|
||||
expect((await runCode(ctx, 'empty array')).content[0]).toEqual({ type: 'text', text: '[]' })
|
||||
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' })
|
||||
@@ -874,6 +881,27 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] })
|
||||
})
|
||||
|
||||
it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
let value: JsonValue = {
|
||||
emptyArray: [],
|
||||
emptyObject: {},
|
||||
pair: ['leaf', 2],
|
||||
record: { first: true, second: null },
|
||||
}
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value })
|
||||
|
||||
const result = await runCode(ctx, 'deep result')
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const text = (result.content[0] as { type: 'text'; text: string }).text
|
||||
expect(text.startsWith('[\n [\n [')).toBe(true)
|
||||
expect(text).toContain('"leaf"')
|
||||
expect(text.endsWith(']')).toBe(true)
|
||||
expect(text.length).toBeLessThan(11_000)
|
||||
})
|
||||
|
||||
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
|
||||
Reference in New Issue
Block a user