diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
index 0e78a7ed90..d28fb0da87 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-20-canonical-tool-output-contract.md: 9ebb566a746fcdea5b0c289f7e70cd0aeafbd954
-2026-07-20-canonical-tool-output-contract.zh.md: e7d31881cace02745b395fe62851737cf30ad9d0
+2026-07-20-canonical-tool-output-contract.md: 8cd7df98758d6d4ac240fea21e7bbe0c89f26d1b
+2026-07-20-canonical-tool-output-contract.zh.md: 0920e0c2c331a247ecd9a039a05c19c9dd2871bc
diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
index 9ebb566a74..8cd7df9875 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
@@ -24,7 +24,7 @@ output: {
`defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path.
-For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content.
+For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. Canonical-result provenance is scoped to the immutable dispatch token, so returning a cached result from another call or tool triggers normalization under the active declaration rather than bypassing it.
```ts ignore-check
type ToolExecutionResult =
diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md
index e7d31881ca..0920e0c2c3 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md
@@ -24,7 +24,7 @@ output: {
`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。
-每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。
+每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果只归属于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。
```ts ignore-check
type ToolExecutionResult =
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
index 2f7a4b1adb..a5273c9e3d 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-20-unified-json-value-schema-dsl.md: e735472ee0ac6a696462aa7598d57fce04aa9c3e
-2026-07-20-unified-json-value-schema-dsl.zh.md: dd34817a6d047b14346b35ba6c4bce0290ad7feb
+2026-07-20-unified-json-value-schema-dsl.md: 3e35bce6eb48afeb31c564e9b5d7b84ff91a7b1f
+2026-07-20-unified-json-value-schema-dsl.zh.md: 77a20d17aab61e759de6e490510b14b5bc408726
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
index e735472ee0..3e35bce6eb 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
@@ -12,7 +12,9 @@ Tool parameters used a small author DSL while subagent/workflow structured outpu
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
-An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs
` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so valid nesting is limited by available memory rather than the JavaScript call stack.
+An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. Schema records contain only own enumerable string keys, schema arrays are dense intrinsic arrays, and supported keywords are read as own properties; custom prototypes, inherited constraints, symbols, and JSON-invisible decorations therefore cannot make compilation, projection, and validation observe different declarations. Intrinsic plain Object and Array containers remain plain across JavaScript realms, while subclasses and forged constructor prototypes remain exotic.
+
+`InferValue` and `InferArgs
` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. Exact inference is bounded to 16 container levels and then uses `JsonValue`, preventing TypeScript's type-instantiation stack from becoming the authoring limit. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack.
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
@@ -28,5 +30,6 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary.
- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams.
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
+- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth.
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, and deep nesting across core and dynamic projections.
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
index dd34817a6d..77a20d17aa 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
@@ -12,7 +12,9 @@ Status: implemented
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
-显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
@@ -28,5 +30,6 @@ Status: implemented
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
+- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导,以及核心投影和动态投影中的深层嵌套。
diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md
index 4182089478..b6d2816f24 100644
--- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md
+++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md
@@ -59,7 +59,7 @@ Each sub-dispatch appends a log-only `tool/code-dispatch` event containing paren
`packages/code-runtime/code-runtime/` — `@deepseek-ai/dsh-code-runtime`, depending only on `cordis`. An abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) plus the vocabulary:
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
-- `CodeBindingNamespace = { global: string; functions: Record Promise> }` — the runtime exposes each namespace as a global object of async functions inside the program; `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole.
+- `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole.
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them.
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
@@ -72,7 +72,7 @@ Requests contain every runtime input; implementations own validated timeout and
1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker.
2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable.
-3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, the real `ToolCallError` class, and a capturing `console` shim, so top-level `await` and `return` work. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute.
+3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. Code Mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute.
4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code.
5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. `maxOutputBytes` bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures.
6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../../docs/defensive-patterns.md).
diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
index dacba07bbc..760213ce07 100644
--- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-20-code-mode-typed-tool-returns.md: e25021a89e4d60e2704383fb4514fadd889ba301
-2026-07-20-code-mode-typed-tool-returns.zh.md: a73cbbd966863b32c3de2118889b27dfbf3698ae
+2026-07-20-code-mode-typed-tool-returns.md: 8411392dce9c70b1d652a339d8b45e66d51e6ec3
+2026-07-20-code-mode-typed-tool-returns.zh.md: c787255f63ef5bb1469843f650711b311ec17175
diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md
index e25021a89e..8411392dce 100644
--- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md
+++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md
@@ -49,9 +49,9 @@ declare const tools: {
### Binding values and failures
-Before dispatch the bridge snapshots binding arguments as lossless JSON and makes independent clones for execution and the durable summary event. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
+Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
-The worker exposes the actual `ToolCallError` constructor used for `tools` binding failures, so `error instanceof ToolCallError` works. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.
+Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime seam treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.
Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. The dependency-light runtime seam names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful.
@@ -59,7 +59,7 @@ Binding arguments and resolutions are revalidated as lossless JSON on both sides
The runtime accepts an exact lossless JSON completion of any root. Returning `undefined` omits the completion; returning `null` is an explicit result. `run_code` exposes the canonical outer value `{ logs: string[], result?: JsonValue }`. Its Native renderer emits logs first, renders a string result raw, and renders every other JSON root with an iterative pretty printer. Total indentation is capped at ten characters and deeper subtrees remain compact, preserving the established shallow text while keeping traversal stack-safe and formatted size linear in the canonical JSON size.
-`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. The worker preflights the detached completion with bounded JSON measurement, and one host-side hostile-peer ledger accounts the JSON serialization of the outer log-array plus either the completion-value or failure-message payload. Fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are deliberately outside this variable-payload ledger. Neither stage materializes an over-limit serialized completion. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value or combined logs/value outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text.
+`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. The worker charges captured logs by their exact JSON-string serialization and preflights the detached completion or program exception against the remaining combined budget before posting a terminal message. A giant thrown string or stack therefore crosses the worker port only as the fixed `output-limit` diagnostic. The host repeats the hostile-peer ledger for forged traffic and native pipe writes the worker cannot observe. Fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are deliberately outside this variable-payload ledger. Neither stage materializes an over-limit serialized completion. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value, diagnostic, or combined outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text.
Logs stream eagerly so a terminated run can retain output already admitted. Native stdout and stderr writes that bypass the worker's patched stream slots use independent pipes, so terminal settlement continues bounded capture until worker termination completes before materializing the result. When the cap is crossed, the runtime returns an explicit bounded failure with the fitting captured prefix. That outer result then traverses the ordinary `run_code` rendering and spill policy, which may save the captured text and expose its configured head/tail preview. The spill layer cannot recover bytes the runtime rejected beyond the hard cap.
@@ -79,7 +79,7 @@ The opaque `exec.parent` token marks nested calls. Presentation metadata and gen
## Testing
-Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; the real `ToolCallError`; invalid arguments and completions; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value accounting; bounded failure spill; hostile forged traffic; and built-package execution.
+Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution.
Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its task id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `task_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text.
diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md
index a73cbbd966..c787255f63 100644
--- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md
@@ -49,9 +49,9 @@ declare const tools: {
### 绑定值与失败
-分发前,桥接层会把绑定参数快照为无损 JSON,并为执行和持久摘要事件分别创建独立副本。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
+分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
-worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造函数,因此 `error instanceof ToolCallError` 能够成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。
+Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其 reject 异常能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。
绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。
@@ -59,7 +59,7 @@ worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造
运行时接受以任意 JSON 类型为根的精确无损完成值。返回 `undefined` 表示省略完成值;返回 `null` 则是显式结果。`run_code` 暴露规范外层值 `{ logs: string[], result?: JsonValue }`。其 Native 渲染器先输出日志;字符串结果保持原文,其他所有 JSON 根值则使用迭代式美化渲染器。总缩进长度上限为 10 个字符,更深的子树保持紧凑格式,既保留既有的浅层文本,又确保遍历不受调用栈深度限制,且格式化输出大小与规范 JSON 大小呈线性关系。
-`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会先用有界 JSON 计量对分离后的完成值执行预检,宿主侧则为不可信对端维护一份统一账本,计入外层日志数组的 JSON 序列化大小,以及完成值或失败消息的可变负载。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值本身或日志与值的组合超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。
+`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会将已捕获日志序列化为 JSON 字符串后的精确字节数计入账本,并在发送终态消息前,根据组合账本的剩余额度预检分离后的完成值或程序异常。因此,即使抛出的字符串或堆栈极大,通过 worker 端口的也只会是固定的 `output-limit` 诊断。宿主侧会针对伪造流量以及 worker 无法观察的原生管道写入,重复执行这套不可信对端计账。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值、诊断或包含日志的组合结果超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。
日志会在产生时立即流出,因此运行被终止时仍可保留已经纳入额度的输出。绕过 worker 中已改写流写入入口的原生 stdout 和 stderr 写入会经由彼此独立的管道传输,因此运行时在终态结算期间仍会继续在上限内捕获输出,直至 worker 完全终止,然后才组装结果。超过上限后,运行时会返回一个显式的有界失败,并携带可容纳的已捕获前缀。该外层结果随后通过普通的 `run_code` 渲染与输出落盘策略;策略可以保存已捕获的文本,并暴露其配置指定的头尾预览。输出落盘层无法恢复运行时在硬上限之外拒绝的字节。
@@ -79,7 +79,7 @@ worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造
## 测试
-编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;真正的 `ToolCallError`;无效参数与完成值;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志与值的组合计量;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。
+编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。
无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index d73d5505fa..da87616f6f 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -321,7 +321,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
-Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
+Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
```ts cordis-catalog
/**
@@ -338,7 +338,7 @@ abstract run(request: CodeRunRequest): Promise
Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md)
-Source: [`packages/code-runtime/code-runtime/src/index.ts:31`](../../packages/code-runtime/code-runtime/src/index.ts)
+Source: [`packages/code-runtime/code-runtime/src/index.ts:33`](../../packages/code-runtime/code-runtime/src/index.ts)
## `ctx.commands` — `CommandService`
diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md
index d9ddc6c9eb..41009f065e 100644
--- a/docs/core-data-structures/code-runtime.md
+++ b/docs/core-data-structures/code-runtime.md
@@ -59,7 +59,23 @@ interface CodeRunResult {
## Bindings: host functions as program globals
-Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision):
+Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision):
+
+```ts type-equiv
+/**
+ * Program-visible typed rejection for one binding namespace. The runtime
+ * injects a real error constructor under `name`; rejected member calls become
+ * its instances and expose the exact member name through
+ * `memberNameProperty`. Both strings are runtime data rather than knowledge
+ * of a particular consumer such as Code Mode.
+ */
+interface CodeBindingErrorClass {
+ /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
+ name: string
+ /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
+ memberNameProperty: string
+}
+```
```ts type-equiv
/**
@@ -74,6 +90,8 @@ interface CodeBindingNamespace {
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record
+ /** Optional program-visible typed rejection contract for this namespace. */
+ errorClass?: CodeBindingErrorClass
}
```
diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md
index 1362e98c64..008361cd49 100644
--- a/docs/core-data-structures/tools.md
+++ b/docs/core-data-structures/tools.md
@@ -111,36 +111,28 @@ type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
* Tool parameter schema. The map itself is an implicit open object root;
* requiredness remains a per-property `required: true` annotation.
*/
-type ParameterSchemaSpec = Record
+type ParameterSchemaSpec = {
+ [key: string]: ParameterPropertySpec
+ [key: symbol]: never
+}
```
-`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue` honors literal constraints and object openness; `InferArgs
` turns per-property requiredness into required and optional keys:
+`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue` honors literal constraints and object openness through 16 container levels, then falls back to `JsonValue` instead of exhausting TypeScript's type-instantiation stack. `InferArgs
` turns per-property requiredness into required and optional string keys:
```ts type-equiv
/**
- * Infer the TypeScript value accepted by an author-facing value schema.
- * Output schemas may therefore infer object, array, scalar, or null roots.
+ * Infer the TypeScript value accepted by an author-facing value schema. Exact
+ * inference is bounded to 16 container levels, then falls back to `JsonValue`.
*/
-type InferValue =
- D['length'] extends 12 ? JsonValue :
- S extends StringValueSchemaSpec ? InferScalar :
- S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar :
- S extends BooleanValueSchemaSpec ? InferScalar :
- S extends NullValueSchemaSpec ? null :
- S extends ArrayValueSchemaSpec
- ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue>[] : JsonValue[]
- : S extends ObjectValueSchemaSpec ? InferObject> :
- S extends JsonValueSchemaSpec ? JsonValue :
- S extends OneOfValueSchemaSpec ? InferValue> :
- never
+type InferValue = InferValueAt
```
```ts type-equiv
/** Infer the TypeScript argument object for an implicit parameter schema. */
-type InferArgs = InferProperties
+type InferArgs = InferProperties
```
-`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Inference widens to `JsonValue` after twelve nested nodes so large schemas remain compilable; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.
+`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.
diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md
index 4936903d2a..8175a403c9 100644
--- a/packages/code-runtime/code-runtime-worker/README.md
+++ b/packages/code-runtime/code-runtime-worker/README.md
@@ -21,9 +21,10 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
- **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 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.
+- **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns.
- **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-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.
+- **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. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement 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.
diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts
index bb021370ca..3b0db6b59c 100644
--- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts
+++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts
@@ -7,7 +7,7 @@
import { inspect } from 'node:util'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
-import { jsonValueBytesUpTo } from './output-json.ts'
+import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
@@ -27,25 +27,28 @@ 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 the fitting prefix and reports the limit once; the host
- * turns that condition into an explicit `output-limit` run failure.
+ * Ordered text capture under the shared outer JSON-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). It includes the log
+ * array syntax and string escaping in its accounting. Once 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
+ private bytes = 2 // JSON serialization of the empty logs array: []
+ private entries = 0
private truncated = false
// 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 sink: (text: string) => void
private readonly onLimit: () => void
+ private readonly maxBytes: number
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
+ this.maxBytes = maxBytes
this.sink = sink
this.onLimit = onLimit
- this.remaining = maxBytes
}
/**
@@ -54,18 +57,32 @@ export class LogBuffer {
*/
push(text: string): void {
if (this.truncated) return
- const cost = Buffer.byteLength(text, 'utf8')
- if (cost > this.remaining) {
+ const separatorBytes = this.entries > 0 ? 1 : 0
+ const availableBytes = this.maxBytes - this.bytes - separatorBytes
+ const stringBytes = jsonStringBytesUpTo(text, availableBytes)
+ if (stringBytes === undefined) {
this.truncated = true
- const prefix = truncateUtf8Bytes(text, this.remaining)
- if (prefix.length > 0) this.sink(prefix)
- this.remaining = 0
+ const prefix = truncateJsonStringBytes(text, availableBytes)
+ if (prefix.length > 0) {
+ const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
+ /* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
+ if (prefixBytes === undefined) throw new Error('worker output ledger produced an oversized log prefix')
+ this.bytes += prefixBytes + separatorBytes
+ this.entries += 1
+ this.sink(prefix)
+ }
this.onLimit()
return
}
- this.remaining -= cost
+ this.bytes += stringBytes + separatorBytes
+ this.entries += 1
this.sink(text)
}
+
+ /** Remaining exact JSON-byte budget for the completion value or failure message. */
+ remainingOutputBytes(): number {
+ return this.maxBytes - this.bytes
+ }
}
/** The five console methods the shim captures, in the seam's level vocabulary. */
@@ -123,38 +140,22 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): (
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
-/**
- * The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
- * a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
- * caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
- * than what a multibyte string actually costs across the boundary.
- * @param text - the string to bound.
- * @param maxBytes - the UTF-8 byte budget the prefix must fit.
- * @returns the prefix (all of `text` when it already fits).
- */
-export function truncateUtf8Bytes(text: string, maxBytes: number): string {
- if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
- let bytes = 0
- let end = 0
- for (const char of text) {
- const cost = Buffer.byteLength(char, 'utf8')
- if (bytes + cost > maxBytes) break
- bytes += cost
- end += char.length
- }
- return text.slice(0, end)
-}
-
/**
* 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.
+ * JSON crosses, and a value that does not fit the remaining combined outer
+ * budget reports `output-limit`; the host revalidates hostile traffic and
+ * remains authoritative for native pipe writes the worker cannot observe.
*
* @param value - the program's completion value.
- * @param maxOutputBytes - the byte cap for the outer result.
+ * @param remainingOutputBytes - exact bytes left after captured logs.
+ * @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
*/
-export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit {
+export function prepareCompletion(
+ value: unknown,
+ remainingOutputBytes: number,
+ maxOutputBytes: number = remainingOutputBytes,
+): Omit {
if (value === undefined) return {}
let snapshot: ReturnType
try {
@@ -163,35 +164,102 @@ export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<
snapshot = undefined
}
if (snapshot === undefined) {
- return { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }
+ return prepareFailure(
+ 'invalid-output',
+ 'program completion must be lossless JSON',
+ remainingOutputBytes,
+ maxOutputBytes,
+ )
}
- if (jsonValueBytesUpTo(snapshot, maxOutputBytes) === undefined) {
- return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
+ if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
+ return outputLimit(maxOutputBytes)
}
return { value: encodeWorkerJson(snapshot) }
}
+/** Build the fixed overflow fragment without carrying rejected variable bytes. */
+function outputLimit(maxOutputBytes: number): Omit {
+ return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
+}
+
+/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
+function prepareFailure(
+ kind: 'exception' | 'invalid-output',
+ message: string,
+ remainingOutputBytes: number,
+ maxOutputBytes: number,
+): Omit {
+ if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
+ return { error: { kind, message } }
+}
+
+/**
+ * Prepare a thrown program value without sending an unbounded stack or
+ * string across the worker port.
+ * @param error - the value thrown by the program.
+ * @param remainingOutputBytes - exact bytes left after captured logs.
+ * @param maxOutputBytes - the configured cap named in an overflow diagnostic.
+ * @returns a bounded exception or fixed output-limit fragment.
+ */
+export function prepareException(
+ error: unknown,
+ remainingOutputBytes: number,
+ maxOutputBytes: number = remainingOutputBytes,
+): Omit {
+ let message: string
+ try {
+ const detail: unknown = error instanceof Error ? error.stack ?? error.message : error
+ message = typeof detail === 'string' ? detail : String(detail)
+ } catch {
+ message = 'program threw an unrenderable value'
+ }
+ return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
+}
+
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
export interface PendingCall {
resolve(value: unknown): void
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 shape for one program-visible binding rejection class. */
+export type BindingErrorConstructor = new (memberName: string, message: string) => Error
- constructor(toolName: string, message: string) {
- super(message)
- this.toolName = toolName
+/**
+ * Materialize the real error constructor declared by one namespace.
+ * @param descriptor - program-global class name and member-name property.
+ * @returns the constructor injected into the program and used for rejections.
+ */
+function makeBindingErrorClass(
+ descriptor: { name: string; memberNameProperty: string },
+): BindingErrorConstructor {
+ return class BindingCallError extends Error {
+ constructor(memberName: string, message: string) {
+ super(message)
+ Object.defineProperty(this, 'name', { enumerable: true, value: descriptor.name })
+ Object.defineProperty(this, descriptor.memberNameProperty, { enumerable: true, value: memberName })
+ }
}
}
-/** Create the namespace-specific rejection for one lossy binding argument. */
-function bindingArgumentFailure(global: string, name: string): Error {
- const message = 'binding arguments must be lossless JSON'
- return global === 'tools' ? new ToolCallError(name, message) : new Error(message)
+/** Create the namespace-specific rejection for one failed binding call. */
+function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
+ return errorClass ? new errorClass(memberName, message) : new Error(message)
+}
+
+/**
+ * Build each declared error class once so calls and `instanceof` share constructor identity.
+ * @param data - binding namespace declarations from the boot payload.
+ * @returns constructors keyed by their owning namespace global.
+ */
+export function makeBindingErrorClasses(
+ data: Pick,
+): Map {
+ const classes = new Map()
+ for (const namespace of data.namespaces) {
+ if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
+ }
+ return classes
}
/**
@@ -229,6 +297,7 @@ export function wireReplies(port: BootstrapPort, pending: Map,
nextId: { value: number },
+ errorClasses: Map = makeBindingErrorClasses(data),
): Record[] {
return data.namespaces.map(({ global, names }) => {
+ const errorClass = errorClasses.get(global)
const namespace = Object.create(null) as Record
for (const name of names) {
Object.defineProperty(namespace, name, {
@@ -249,13 +320,15 @@ export function makeNamespaces(
} catch {
detached = undefined
}
- if (detached === undefined) return Promise.reject(bindingArgumentFailure(global, name))
+ if (detached === undefined) {
+ return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
+ }
return new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
resolve,
reject: (error) => {
- reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
+ reject(bindingFailure(errorClass, name, error.message))
},
})
try {
@@ -263,7 +336,7 @@ export function makeNamespaces(
} catch (error: unknown) {
pending.delete(id)
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))
+ reject(bindingFailure(errorClass, name, message))
}
})
},
@@ -298,7 +371,18 @@ export async function runWorkerMain(
wireReplies(port, pending)
const nextId = { value: 1 }
- const namespaces = makeNamespaces(data, port, pending, nextId)
+ const errorClasses = makeBindingErrorClasses(data)
+ const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
+ const errorClassParameters: string[] = []
+ const errorClassValues: BindingErrorConstructor[] = []
+ for (const namespace of data.namespaces) {
+ if (!namespace.errorClass) continue
+ errorClassParameters.push(namespace.errorClass.name)
+ const errorClass = errorClasses.get(namespace.global)
+ /* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
+ if (!errorClass) throw new Error(`missing binding error class for ${namespace.global}`)
+ errorClassValues.push(errorClass)
+ }
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
@@ -307,12 +391,22 @@ 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
- 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) }
+ const fn = new AsyncFunction(
+ ...data.namespaces.map(namespace => namespace.global),
+ ...errorClassParameters,
+ 'console',
+ `'use strict';\n${data.code}`,
+ )
+ const value = await fn(...namespaces, ...errorClassValues, consoleShim)
+ done = {
+ type: 'done',
+ ...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
+ }
} catch (error: unknown) {
- const message = error instanceof Error ? error.stack ?? error.message : String(error)
- done = { type: 'done', error: { kind: 'exception', message } }
+ done = {
+ type: 'done',
+ ...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
+ }
}
port.postMessage(done)
}
diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts
index 4f842e69eb..e7eae65f2b 100644
--- a/packages/code-runtime/code-runtime-worker/src/index.ts
+++ b/packages/code-runtime/code-runtime-worker/src/index.ts
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
-import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
+import type { CodeBindingNamespace, 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'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
@@ -74,6 +74,9 @@ const RESERVED_WORDS = new Set([
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
+/** Error properties whose binding-member replacement would destroy the promised Error contract. */
+const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
+
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
@@ -312,17 +315,33 @@ export class WorkerCodeRuntime extends CodeRuntime {
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
}
- /** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
- private validateBindings(request: CodeRunRequest): Map> {
- const bindings = new Map>()
+ /** Reject malformed binding globals or typed-error declarations as seam misuse. */
+ private validateBindings(request: CodeRunRequest): Map {
+ const bindings = new Map()
for (const namespace of request.bindings) {
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' || namespace.global === 'ToolCallError' || bindings.has(namespace.global)) {
+ if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
- bindings.set(namespace.global, namespace.functions)
+ bindings.set(namespace.global, namespace)
+ }
+
+ const errorClassNames = new Set()
+ for (const namespace of request.bindings) {
+ const descriptor = namespace.errorClass
+ if (!descriptor) continue
+ if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
+ throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
+ }
+ if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
+ throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
+ }
+ if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
+ throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
+ }
+ errorClassNames.add(descriptor.name)
}
return bindings
}
@@ -331,11 +350,15 @@ export class WorkerCodeRuntime extends CodeRuntime {
private execute(
request: CodeRunRequest,
code: string,
- bindings: Map>,
+ bindings: Map,
): Promise {
const bootData: WorkerBootData = {
code,
- namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
+ namespaces: [...bindings].map(([global, namespace]) => ({
+ global,
+ names: Object.keys(namespace.functions),
+ ...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
+ })),
maxOutputBytes: this.config.maxOutputBytes,
}
const worker = new Worker(WORKER_PATH, {
@@ -435,7 +458,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
// this point, so this payload is structured-cloneable by contract.
worker.postMessage(payload)
}
- const record = bindings.get(message.global)
+ const record = bindings.get(message.global)?.functions
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
diff --git a/packages/code-runtime/code-runtime-worker/src/protocol.ts b/packages/code-runtime/code-runtime-worker/src/protocol.ts
index 8d8ec54b60..11559d23b7 100644
--- a/packages/code-runtime/code-runtime-worker/src/protocol.ts
+++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts
@@ -11,8 +11,12 @@ import type { WorkerJsonWire } from './worker-json.ts'
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
code: string
- /** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
- namespaces: { global: string; names: string[] }[]
+ /** Binding namespaces to materialize; functions themselves stay host-side. */
+ namespaces: {
+ global: string
+ names: string[]
+ errorClass?: { name: string; memberNameProperty: string }
+ }[]
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
maxOutputBytes: number
}
@@ -42,12 +46,12 @@ interface OutputLimitMessage {
}
/**
- * Worker → host: the program settled. `error` carries a program exception
- * (the only failure the bootstrap itself can report — budgets, aborts, and
- * substrate death are observed host-side). `value` is present only on a
- * clean completion that produced one, as a flat wire value already
- * size-capped and lossless per the bootstrap. Logs are NOT carried here —
- * they streamed eagerly as {@link LogMessage}s.
+ * Worker → host: the program settled. `error` carries a program exception,
+ * invalid completion, or output overflow (budgets, aborts, and substrate death
+ * are observed host-side). `value` is present only on a clean completion that
+ * produced one, as a flat wire value already lossless and admitted against
+ * the remaining combined output cap. Logs are NOT carried here — they streamed
+ * eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'
diff --git a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts
index 78af789169..a2aac6d9c2 100644
--- a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
-import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, runWorkerMain, ToolCallError, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
+import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
@@ -60,23 +60,30 @@ async function rejectionOf(promise: Promise): Promise {
}
const BOOT = { maxOutputBytes: 65_536 }
+const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
+
+/** One worker declaration for the Code Mode tools namespace. */
+function toolNamespace(names: string[]) {
+ return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
+}
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
const seen: string[] = []
let limits = 0
- const buffer = new LogBuffer(10, text => seen.push(text), () => { limits += 1 })
+ const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
buffer.push('12345')
buffer.push('123456')
buffer.push('dropped')
- expect(seen).toEqual(['12345', '12345'])
+ expect(seen).toEqual(['12345', '123'])
expect(limits).toBe(1)
+ expect(buffer.remainingOutputBytes()).toBe(0)
const exactlyFull: string[] = []
- const fullBuffer = new LogBuffer(4, text => exactlyFull.push(text))
- fullBuffer.push('1234')
+ const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
+ fullBuffer.push('12')
fullBuffer.push('no-prefix-fits')
- expect(exactlyFull).toEqual(['1234'])
+ expect(exactlyFull).toEqual(['12'])
})
})
@@ -167,19 +174,32 @@ describe('prepareCompletion', () => {
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
})
})
+
+ it('uses the remaining combined budget for invalid-output diagnostics', () => {
+ expect(prepareCompletion(() => 1, 4, 64)).toEqual({
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
+ })
})
-describe('truncateUtf8Bytes', () => {
- it('returns a fitting string whole', () => {
- expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
+describe('prepareException', () => {
+ it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
+ expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
+ expect(prepareException('boom', 5, 64)).toEqual({
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
})
- it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
- // Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
- // budget fits exactly one — and never leaves a lone surrogate behind.
- const cut = truncateUtf8Bytes('😀😀', 5)
- expect(cut).toBe('😀')
- expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
+ it('contains a thrown value whose string conversion fails', () => {
+ const thrown = { toString() { throw new Error('cannot render') } }
+ expect(prepareException(thrown, 1_000)).toEqual({
+ error: { kind: 'exception', message: 'program threw an unrenderable value' },
+ })
+
+ const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
+ expect(prepareException(strangeStack, 1_000)).toEqual({
+ error: { kind: 'exception', message: '42' },
+ })
})
})
@@ -219,7 +239,16 @@ describe('makeNamespaces', () => {
on: () => {},
}
const pending = new Map()
- const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record Promise>]
+ const data = { namespaces: [toolNamespace(['x'])] }
+ const errorClasses = makeBindingErrorClasses(data)
+ const ToolCallError = errorClasses.get('tools')
+ const [tools] = makeNamespaces(
+ data,
+ throwingPort,
+ pending,
+ { value: 1 },
+ errorClasses,
+ ) as [Record Promise>]
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
@@ -237,7 +266,7 @@ describe('makeNamespaces', () => {
const pending = new Map()
const nextId = { value: 1 }
const [tools] = makeNamespaces(
- { namespaces: [{ global: 'tools', names: ['x'] }] }, port, pending, nextId,
+ { namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
) as [Record Promise>]
const decorated = [1]
Object.defineProperty(decorated, 'extra', { value: true })
@@ -267,18 +296,18 @@ describe('makeNamespaces', () => {
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record Promise>]
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
expect(denied).toBeInstanceOf(Error)
- expect(denied).not.toBeInstanceOf(ToolCallError)
+ expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
+ expect(denied).not.toHaveProperty('toolName')
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
expect(invalid).toBeInstanceOf(Error)
- expect(invalid).not.toBeInstanceOf(ToolCallError)
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
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 Promise>]
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
expect(cloneFailure).toBeInstanceOf(Error)
- expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
+ expect(cloneFailure).not.toHaveProperty('toolName')
})
})
@@ -306,9 +335,12 @@ describe('runWorkerMain', () => {
code: 'console.log("12345"); return null',
namespaces: [],
}, fakeStreams())
- expect(port.sent).toContainEqual({ type: 'log', text: '1234' })
+ expect(port.logs()).toEqual([])
expect(port.sent).toContainEqual({ type: 'output-limit' })
- expect(port.doneValue()).toBeNull()
+ expect(port.done()).toEqual({
+ type: 'done',
+ error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
+ })
})
it('reports a thrown program error on the done message', async () => {
@@ -331,16 +363,56 @@ describe('runWorkerMain', () => {
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
})
+ it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
+ const rawPort = new FakePort()
+ await runWorkerMain(rawPort, {
+ maxOutputBytes: 64,
+ code: 'throw "x".repeat(1_000_000)',
+ namespaces: [],
+ }, fakeStreams())
+ expect(rawPort.done()).toEqual({
+ type: 'done',
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
+
+ const stackPort = new FakePort()
+ await runWorkerMain(stackPort, {
+ maxOutputBytes: 64,
+ code: 'throw new Error("x".repeat(1_000_000))',
+ namespaces: [],
+ }, fakeStreams())
+ expect(stackPort.done()).toEqual({
+ type: 'done',
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
+ })
+
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
const port = new FakePort()
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 instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
- namespaces: [{ global: 'tools', names: ['x'] }],
+ namespaces: [toolNamespace(['x'])],
}, fakeStreams())
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
- expect(new ToolCallError('x', 'nope')).toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'nope' })
+ })
+
+ it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
+ const port = new FakePort()
+ port.respond = message => message.type === 'call'
+ ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
+ : undefined
+ await runWorkerMain(port, {
+ ...BOOT,
+ code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
+ namespaces: [{
+ global: 'helpers',
+ names: ['x'],
+ errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
+ }],
+ }, fakeStreams())
+ expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
})
it('ignores replies for unknown pending ids', async () => {
diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
index ff68fd09fe..4c6098a2ec 100644
--- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
@@ -23,8 +23,15 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, {})
const result = await ctx.codeRuntime.run({
- program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
- bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
+ program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
+ bindings: [{
+ global: 'tools',
+ functions: {
+ double: async args => args.n * 2,
+ fail: async () => { throw new Error('denied') },
+ },
+ errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
+ }],
})
console.log(JSON.stringify(result))
process.exit(0)
@@ -40,7 +47,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
expect(result.error).toBeUndefined()
- expect(result.value).toBe(42)
+ expect(result.value).toEqual({
+ doubled: 42,
+ failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' },
+ })
expect(result.logs).toContain('halfway 42')
})
})
diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
index 45b45520e0..a58675d1ed 100644
--- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
@@ -18,7 +18,11 @@ async function setup(config: Config = {}) {
/** Convenience: one namespace `tools` with the given functions. */
function tools(functions: Record Promise>): CodeBindingNamespace[] {
- return [{ global: 'tools', functions: functions as Record }]
+ return [{
+ global: 'tools',
+ functions: functions as Record,
+ errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
+ }]
}
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
@@ -74,6 +78,33 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(calls).toEqual([{ n: 1 }])
})
+ it('materializes a typed rejection from a generic namespace descriptor', async () => {
+ const { runtime } = await setup()
+ const result = await runtime.run({
+ program: `
+ try { await helpers.fail({}) } catch (error) {
+ return {
+ isTyped: error instanceof HelperCallError,
+ name: error.name,
+ helperName: error.helperName,
+ message: error.message,
+ };
+ }
+ `,
+ bindings: [{
+ global: 'helpers',
+ functions: { fail: async () => { throw new Error('nope') } },
+ errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
+ }],
+ })
+ expect(result.value).toEqual({
+ isTyped: true,
+ name: 'HelperCallError',
+ helperName: 'fail',
+ message: 'nope',
+ })
+ })
+
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
const { runtime } = await setup()
const result = await runtime.run({
@@ -304,6 +335,31 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
})
+ it('accounts logs and exception diagnostics before the worker port boundary', 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"); throw "xy"', bindings: [] }))
+ .toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
+
+ const over = await setup({ maxOutputBytes: 10 })
+ const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
+ 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('does not send a giant Error stack across the worker port', async () => {
+ const { runtime } = await setup({ maxOutputBytes: 64 })
+ const result = await runtime.run({
+ program: 'throw new Error("x".repeat(1_000_000))',
+ bindings: [],
+ })
+ expect(result).toEqual({
+ logs: [],
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
+ })
+
it('completes a program that awaits its write callback, capturing the chunk', async () => {
// Node's write(chunk[, encoding][, callback]) contract: dropping the
// callback would leave this promise pending until the wall ceiling and
@@ -448,6 +504,22 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
})
+ it('re-caps an oversized forged done value at the host boundary', async () => {
+ const { runtime } = await setup({ maxOutputBytes: 64 })
+ const result = await runtime.run({
+ program: `
+ const { parentPort } = await import('node:worker_threads');
+ parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
+ for (;;) {}
+ `,
+ bindings: [],
+ })
+ expect(result).toEqual({
+ logs: [],
+ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
+ })
+ })
+
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
const { runtime } = await setup({ maxOutputBytes: 96 })
const result = await runtime.run({
@@ -634,13 +706,12 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
})
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
- it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, reserved injected globals)', async () => {
+ it('rejects invalid and duplicate binding globals loudly', 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)
@@ -649,6 +720,32 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
program: 'return 1',
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
})).rejects.toThrow(/duplicate binding global/)
+
+ await expect(runtime.run({
+ program: 'return typeof ToolCallError',
+ bindings: [{ global: 'ToolCallError', functions: {} }],
+ })).resolves.toMatchObject({ value: 'object' })
+ })
+
+ it('rejects malformed or colliding binding error-class declarations', async () => {
+ const { runtime } = await setup()
+ const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
+ const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
+ global,
+ functions: {},
+ errorClass: { name, memberNameProperty },
+ })
+
+ await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
+ await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
+ await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
+ await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
+ await expect(run([
+ namespace('tools', 'CallError'),
+ namespace('helpers', 'CallError'),
+ ])).rejects.toThrow(/duplicate injected global/)
+ await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
+ await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
})
it('rejects config values that are not positive numbers', async () => {
diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md
index 24e1fb51a1..f8e09e301e 100644
--- a/packages/code-runtime/code-runtime/README.md
+++ b/packages/code-runtime/code-runtime/README.md
@@ -16,7 +16,7 @@ Semantics every implementation must honor (contract details in the class JSDoc):
## Vocabulary
-`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.
+`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` + optional `errorClass`), 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. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `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
diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts
index 9b9fd0d48e..bd52b9ed29 100644
--- a/packages/code-runtime/code-runtime/src/index.ts
+++ b/packages/code-runtime/code-runtime/src/index.ts
@@ -8,6 +8,7 @@ import { Context, Service } from 'cordis'
import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
+ CodeBindingErrorClass,
CodeBindingFunction,
CodeBindingNamespace,
CodeJsonValue,
@@ -25,8 +26,9 @@ declare module 'cordis' {
/**
* Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate
* failures resolve in {@link CodeRunResult}; only seam misuse rejects. Implementations bridge
- * structured-cloneable bindings while treating programs as hostile peers, isolate runs from
- * one another, and terminate and await in-flight runs during disposal.
+ * structured-cloneable bindings, materialize each declared namespace rejection
+ * class, treat programs as hostile peers, isolate runs from one another, and
+ * terminate and await in-flight runs during disposal.
*/
export abstract class CodeRuntime extends Service {
/**
diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts
index 259e497e14..a53353799b 100644
--- a/packages/code-runtime/code-runtime/src/types.ts
+++ b/packages/code-runtime/code-runtime/src/types.ts
@@ -20,6 +20,20 @@ export type CodeBindingFunction = (args: unknown) => Promise
/** A lossless JSON value transferable across the dependency-light code-runtime seam. */
export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue }
+/**
+ * Program-visible typed rejection for one binding namespace. The runtime
+ * injects a real error constructor under `name`; rejected member calls become
+ * its instances and expose the exact member name through
+ * `memberNameProperty`. Both strings are runtime data rather than knowledge
+ * of a particular consumer such as Code Mode.
+ */
+export interface CodeBindingErrorClass {
+ /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
+ name: string
+ /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
+ memberNameProperty: string
+}
+
/**
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
* program as one global object (e.g. `tools`). Function names are arbitrary
@@ -32,6 +46,8 @@ export interface CodeBindingNamespace {
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record
+ /** Optional program-visible typed rejection contract for this namespace. */
+ errorClass?: CodeBindingErrorClass
}
/**
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 118a67d554..7230fdb832 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1243,13 +1243,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CallId',
declaration: 'export type CallId = Branded<\'CallId\'>;',
},
+ {
+ name: 'CodeBindingErrorClass',
+ declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}',
+ },
{
name: 'CodeBindingFunction',
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise;',
},
{
name: 'CodeBindingNamespace',
- declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n}',
+ declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}',
},
{
name: 'CodeJsonValue',
diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts
index 60c2b8d0dd..ba22b76c8f 100644
--- a/packages/cordis/tool-cordis/src/guard.ts
+++ b/packages/cordis/tool-cordis/src/guard.ts
@@ -38,13 +38,18 @@ function isPlainRecord(value: unknown): value is Record {
}
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
-/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
+/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
- return typeof constructor === 'function'
- && constructor.name === name
- && constructor.prototype === prototype
+ if (typeof constructor !== 'function') return false
+ try {
+ return constructor.name === name
+ && constructor.prototype === prototype
+ && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
+ } catch {
+ return false
+ }
}
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts
index d2a9f83cf9..4f361a1ef1 100644
--- a/packages/cordis/tool-cordis/tests/mount.spec.ts
+++ b/packages/cordis/tool-cordis/tests/mount.spec.ts
@@ -452,6 +452,9 @@ describe('cordis_mount', () => {
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
+ ['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
+ ['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
+ ['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts
index e1452457fd..43e9f0625f 100644
--- a/packages/core/session/src/json.ts
+++ b/packages/core/session/src/json.ts
@@ -12,13 +12,18 @@
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
-/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
+/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
- return typeof constructor === 'function'
- && constructor.name === name
- && constructor.prototype === prototype
+ if (typeof constructor !== 'function') return false
+ try {
+ return constructor.name === name
+ && constructor.prototype === prototype
+ && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
+ } catch {
+ return false
+ }
}
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts
index e8680142f3..d81266440e 100644
--- a/packages/core/session/tests/json.spec.ts
+++ b/packages/core/session/tests/json.spec.ts
@@ -2,6 +2,17 @@ import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
+function objectWithForgedIntrinsicPrototype(revoked = false): Record {
+ const prototype = Object.create(null) as Record
+ const ForgedObject = function ForgedObject(): void {}
+ Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
+ ForgedObject.prototype = prototype
+ const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
+ if (constructor !== undefined) constructor.revoke()
+ Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
+ return Object.assign(Object.create(prototype) as Record, { value: 1 })
+}
+
describe('snapshotJsonValue', () => {
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
const unsupportedFunction = (): void => {}
@@ -109,6 +120,8 @@ describe('snapshotJsonValue', () => {
const symbolObject = { [Symbol('extra')]: true }
const customPrototype = Object.create(null) as Record
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 })
+ const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
+ const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
const forgedPrototype: unknown[] = []
Object.setPrototypeOf(forgedPrototype, null)
const forgedArray = [1]
@@ -133,6 +146,8 @@ describe('snapshotJsonValue', () => {
expect(snapshotJsonValue(hiddenObject)).toBeUndefined()
expect(snapshotJsonValue(symbolObject)).toBeUndefined()
expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined()
+ expect(snapshotJsonValue(forgedIntrinsicObject)).toBeUndefined()
+ expect(snapshotJsonValue(revokedIntrinsicObject)).toBeUndefined()
expect(snapshotJsonValue(forgedArray)).toBeUndefined()
expect(snapshotJsonValue(cyclic)).toBeUndefined()
expect(snapshotJsonValue([undefined])).toBeUndefined()
@@ -206,6 +221,8 @@ describe('isJsonValue', () => {
const symbolObject = { [Symbol('extra')]: true }
const customPrototype = Object.create(null) as Record
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 })
+ const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
+ const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
const forgedPrototype: unknown[] = []
Object.setPrototypeOf(forgedPrototype, null)
const forgedArray = [1]
@@ -220,6 +237,8 @@ describe('isJsonValue', () => {
expect(isJsonValue(hiddenObject)).toBe(false)
expect(isJsonValue(symbolObject)).toBe(false)
expect(isJsonValue(customPrototypeObject)).toBe(false)
+ expect(isJsonValue(forgedIntrinsicObject)).toBe(false)
+ expect(isJsonValue(revokedIntrinsicObject)).toBe(false)
expect(isJsonValue(forgedArray)).toBe(false)
expect(isJsonValue(new ExoticArray(1))).toBe(false)
expect(isJsonValue([undefined])).toBe(false)
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index 225b14ea75..d752a84bba 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -52,7 +52,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
-- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration.
+- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Canonical-result provenance belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration.
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
@@ -87,7 +87,7 @@ ctx.tools.register(defineTool({
}))
```
-The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so valid deep schemas are memory-bounded rather than call-stack-bounded.
+The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Schema records accept only own enumerable string keys, and schema arrays must be dense ordinary arrays. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so runtime processing of valid deep schemas is memory-bounded rather than call-stack-bounded; `InferValue` preserves exact types through 16 container levels and then falls back to `JsonValue` so TypeScript itself remains stack-safe.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts
index 09686f7019..8f7bb904f7 100644
--- a/packages/core/tools/src/code-mode.ts
+++ b/packages/core/tools/src/code-mode.ts
@@ -85,9 +85,9 @@ function summarize(text: string, cwd: string | undefined): string {
}
/**
- * 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.
+ * Snapshot one binding call's argument as lossless JSON, then snapshot that
+ * detached value again so dispatch and logging stay independent without
+ * reintroducing structured-clone's platform-specific nesting limit.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
let snapshot: JsonValue | undefined
@@ -99,7 +99,12 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
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) }
+ const logged = snapshotJsonValue(snapshot)
+ /* v8 ignore next -- snapshot is already a detached lossless JSON value. */
+ if (logged === undefined) {
+ throw new Error('tool arguments could not be detached for durable logging')
+ }
+ return { dispatched: snapshot, logged }
}
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
@@ -332,7 +337,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
try {
result = await runtime.run({
program: args.code,
- bindings: [{ global: 'tools', functions }],
+ bindings: [{
+ global: 'tools',
+ functions,
+ errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
+ }],
signal: runController.signal,
})
} finally {
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index 2f4e4cbd5f..ebefeb2cf2 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -864,10 +864,17 @@ export class ToolRegistry extends Service {
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),
- }))
+ .map((definition): ToolSdkSchema => {
+ const output = snapshotJsonValue(definition.output.schema)
+ /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
+ if (output === undefined) {
+ throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
+ }
+ return {
+ ...this.schemaOf(definition, true),
+ output,
+ }
+ })
}
/** Project one definition onto the model-facing schema fields. */
@@ -1104,7 +1111,7 @@ export class ToolRegistry extends Service {
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? normalized
- : this.markCanonical({
+ : this.markCanonical(exec, {
...normalized,
additionalContexts: [
...deferredContexts,
@@ -1255,7 +1262,7 @@ export class ToolRegistry extends Service {
const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') {
const message = failureMessageFromContent(decision.feedback)
- return this.markCanonical({
+ return this.markCanonical(exec, {
content: decision.feedback,
isError: true,
error: { message },
@@ -1276,24 +1283,24 @@ export class ToolRegistry extends Service {
const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value)
- return this.markCanonical({
+ return this.markCanonical(exec, {
...replaced,
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
- return this.markCanonical({
+ return this.markCanonical(exec, {
...result,
...decision.content !== undefined ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
- /** Results created by the registry already own a validated, frozen canonical value. */
- private readonly canonicalResults = new WeakSet