From 0d851adc3e9b64e3935ed9f978a6072ad8a92c53 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 22 Jul 2026 01:58:38 +0800
Subject: [PATCH 1/4] fix(session): traverse JSON values iteratively
---
...20-unified-json-value-schema-dsl.i18n.yaml | 4 +-
...026-07-20-unified-json-value-schema-dsl.md | 2 +-
...-07-20-unified-json-value-schema-dsl.zh.md | 2 +-
packages/cordis/tool-cordis/src/guard.ts | 29 ++-
.../cordis/tool-cordis/tests/mount.spec.ts | 1 +
packages/core/session/README.md | 2 +-
packages/core/session/src/json.ts | 220 +++++++++---------
packages/core/session/tests/json.spec.ts | 13 ++
8 files changed, 161 insertions(+), 112 deletions(-)
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 0cd154e190..ae5592ef01 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: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea
-2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1
+2026-07-20-unified-json-value-schema-dsl.md: 6a3dc21a7ae76ba8f84f5b3edf8306285a02a683
+2026-07-20-unified-json-value-schema-dsl.zh.md: 77ac599e6713563528d0382dfecff83071488930
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 94c3f5aa5f..6a3dc21a7a 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,7 @@ 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.
+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. Validation and snapshot traversal are iterative, so valid 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.
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 d8362c2c99..77ac599e67 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,7 @@ 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。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。
+显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。校验和快照遍历均以迭代方式执行,因此合法嵌套的深度上限由可用内存决定,而非 JavaScript 调用栈。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts
index e8eb19e524..2ebba0fbd8 100644
--- a/packages/cordis/tool-cordis/src/guard.ts
+++ b/packages/cordis/tool-cordis/src/guard.ts
@@ -29,9 +29,34 @@ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const prototype: unknown = Object.getPrototypeOf(value)
- return prototype === null || Object.getPrototypeOf(prototype) === null
+ return prototype === null
+ || typeof prototype === 'object'
+ && Object.getPrototypeOf(prototype) === null
+ && hasIntrinsicConstructor(prototype, 'Object')
}
+/* 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. */
+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
+}
+
+/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
+function hasPlainArrayPrototype(value: unknown[]): boolean {
+ const prototype: unknown = Object.getPrototypeOf(value)
+ if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
+ const objectPrototype: unknown = Object.getPrototypeOf(prototype)
+ return typeof objectPrototype === 'object'
+ && objectPrototype !== null
+ && Object.getPrototypeOf(objectPrototype) === null
+ && hasIntrinsicConstructor(objectPrototype, 'Object')
+}
+/* jscpd:ignore-end */
+
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string, seen = new Set()): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
@@ -44,7 +69,7 @@ function cloneJson(value: unknown, path: string, seen = new Set()): unkn
seen.add(value)
try {
if (Array.isArray(value)) {
- if (Reflect.ownKeys(value).length !== value.length + 1) {
+ if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
const output: unknown[] = []
diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts
index 64301f2172..22df219b1b 100644
--- a/packages/cordis/tool-cordis/tests/mount.spec.ts
+++ b/packages/cordis/tool-cordis/tests/mount.spec.ts
@@ -348,6 +348,7 @@ describe('cordis_mount', () => {
['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'],
['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'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index bba2a21e06..e01e8072c3 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -46,7 +46,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Lossless JSON utilities
-Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
+Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
### Surface types
diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts
index f7609a1f46..e1452457fd 100644
--- a/packages/core/session/src/json.ts
+++ b/packages/core/session/src/json.ts
@@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | undefined {
return keys as string[]
}
+type SnapshotDestination =
+ | { kind: 'root' }
+ | { kind: 'array'; target: JsonValue[]; index: number }
+ | { kind: 'object'; target: { [key: string]: JsonValue }; key: string }
+
+type JsonWalkTask =
+ | { kind: 'visit'; value: unknown; destination?: SnapshotDestination }
+ | { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] }
+ | { kind: 'object-property'; source: Record; key: string; target?: { [key: string]: JsonValue } }
+ | { kind: 'leave'; source: object }
+
+/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
+function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined {
+ const ancestors = new Set()
+ let root: JsonValue | undefined
+ const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => {
+ if (destination === undefined) return
+ if (destination.kind === 'root') {
+ root = item
+ } else if (destination.kind === 'array') {
+ destination.target[destination.index] = item
+ } else {
+ Object.defineProperty(destination.target, destination.key, {
+ value: item,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ })
+ }
+ }
+
+ const tasks: JsonWalkTask[] = [{
+ kind: 'visit',
+ value,
+ ...(detach ? { destination: { kind: 'root' } as const } : {}),
+ }]
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
+ if (task.kind === 'leave') {
+ ancestors.delete(task.source)
+ continue
+ }
+ if (task.kind === 'array-item') {
+ if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.index],
+ ...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }),
+ })
+ continue
+ }
+ if (task.kind === 'object-property') {
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.key],
+ ...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }),
+ })
+ continue
+ }
+
+ const current = task.value
+ if (current === null) {
+ assign(task.destination, null)
+ continue
+ }
+ if (typeof current === 'boolean' || typeof current === 'string') {
+ assign(task.destination, current)
+ continue
+ }
+ if (typeof current === 'number') {
+ if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
+ assign(task.destination, current)
+ continue
+ }
+ if (typeof current !== 'object') return undefined
+ if (ancestors.has(current)) return undefined
+
+ if (Array.isArray(current)) {
+ if (!hasPlainArrayPrototype(current)) return undefined
+ const length = current.length
+ if (Reflect.ownKeys(current).length !== length + 1) return undefined
+ const target = detach ? [] as JsonValue[] : undefined
+ if (target !== undefined) assign(task.destination, target)
+ ancestors.add(current)
+ tasks.push({ kind: 'leave', source: current })
+ for (let index = length - 1; index >= 0; index--) {
+ tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) })
+ }
+ continue
+ }
+
+ if (!hasPlainObjectPrototype(current)) return undefined
+ const keys = enumerableStringKeys(current)
+ if (keys === undefined) return undefined
+ const target = detach ? {} as { [key: string]: JsonValue } : undefined
+ if (target !== undefined) assign(task.destination, target)
+ ancestors.add(current)
+ tasks.push({ kind: 'leave', source: current })
+ for (let index = keys.length - 1; index >= 0; index--) {
+ const key = keys[index]
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
+ if (key === undefined) return undefined
+ tasks.push({ kind: 'object-property', source: current as Record, key, ...(target === undefined ? {} : { target }) })
+ }
+ }
+ return detach ? root : true
+}
+
/**
* Validate and detach lossless JSON in one read per property, so a stateful
- * getter cannot change between validation and copying. Accepts ordinary arrays,
- * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
- * exotic, negative-zero, and non-finite values. Getter throws propagate.
+ * getter cannot change between validation and copying. Traversal is iterative,
+ * so valid nesting is bounded by available memory rather than the JavaScript
+ * call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON
+ * scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values.
+ * Getter throws propagate.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
* losslessly JSON-serializable.
*/
export function snapshotJsonValue(value: T): T | undefined {
- const ancestors = new Set()
-
- const visit = (current: unknown): JsonValue | undefined => {
- if (current === null) return null
- switch (typeof current) {
- case 'boolean':
- case 'string':
- return current
- case 'number':
- return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
- case 'bigint':
- case 'function':
- case 'symbol':
- case 'undefined':
- return undefined
- case 'object':
- break
- }
-
- if (ancestors.has(current)) return undefined
- ancestors.add(current)
- try {
- if (Array.isArray(current)) {
- if (!hasPlainArrayPrototype(current)) return undefined
- const length = current.length
- // Every ordinary array owns `length`; dense indexed elements account
- // for the remaining keys. Anything else would be lost by JSON and by
- // structured clone, including symbols and non-enumerable properties.
- if (Reflect.ownKeys(current).length !== length + 1) return undefined
- const snapshot: JsonValue[] = []
- for (let index = 0; index < length; index++) {
- if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
- const item = visit(current[index])
- if (item === undefined) return undefined
- snapshot.push(item)
- }
- return snapshot
- }
-
- if (!hasPlainObjectPrototype(current)) return undefined
- const keys = enumerableStringKeys(current)
- if (keys === undefined) return undefined
- const snapshot: { [key: string]: JsonValue } = {}
- for (const key of keys) {
- const item = visit((current as Record)[key])
- if (item === undefined) return undefined
- // Define the key as data so a JSON field literally named "__proto__"
- // cannot mutate the snapshot's prototype through ordinary assignment.
- Object.defineProperty(snapshot, key, {
- value: item,
- enumerable: true,
- configurable: true,
- writable: true,
- })
- }
- return snapshot
- } finally {
- ancestors.delete(current)
- }
- }
-
- return visit(value) as T | undefined
+ return walkJsonValue(value, true) as T | undefined
}
/**
@@ -130,46 +178,8 @@ export function snapshotJsonValue(value: T): T | undefined {
* detaching it. Only own enumerable string properties participate; `toJSON`
* is ignored and getters run, so persistence boundaries use the snapshotter.
* @param value - the candidate event data to test.
- * @param seen - current recursion path; callers omit it.
* @returns whether `value` survives JSON round-trip losslessly.
*/
-export function isJsonValue(value: unknown, seen: Set = new Set()): boolean {
- if (value === null) return true
- switch (typeof value) {
- case 'boolean':
- case 'string':
- return true
- case 'number':
- return Number.isFinite(value) && !Object.is(value, -0)
- case 'bigint':
- case 'function':
- case 'symbol':
- case 'undefined':
- return false
- case 'object':
- break // handled below
- }
- // object
- if (seen.has(value)) return false // circular
- seen.add(value)
- try {
- if (Array.isArray(value)) {
- if (!hasPlainArrayPrototype(value)) return false
- if (Reflect.ownKeys(value).length !== value.length + 1) return false
- // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
- // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
- // lossily. Require every index 0..length-1 to be an OWN property.
- for (let i = 0; i < value.length; i++) {
- if (!Object.prototype.hasOwnProperty.call(value, i)) return false
- if (!isJsonValue(value[i], seen)) return false
- }
- return true
- }
- // Plain object only (reject Map/Set/Date/class instances).
- if (!hasPlainObjectPrototype(value)) return false
- const keys = enumerableStringKeys(value)
- return keys !== undefined && keys.every(key => isJsonValue((value as Record)[key], seen))
- } finally {
- seen.delete(value)
- }
+export function isJsonValue(value: unknown): boolean {
+ return walkJsonValue(value, false) === true
}
diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts
index 90b3ec6928..e8680142f3 100644
--- a/packages/core/session/tests/json.spec.ts
+++ b/packages/core/session/tests/json.spec.ts
@@ -80,6 +80,19 @@ describe('snapshotJsonValue', () => {
expect(arrayReads).toBe(1)
})
+ it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
+ let value: JsonValue = 'leaf'
+ for (let depth = 0; depth < 5_000; depth++) value = [value]
+
+ expect(isJsonValue(value)).toBe(true)
+ let cursor: JsonValue | undefined = snapshotJsonValue(value)
+ for (let depth = 0; depth < 5_000; depth++) {
+ expect(Array.isArray(cursor)).toBe(true)
+ cursor = Array.isArray(cursor) ? cursor[0] : undefined
+ }
+ expect(cursor).toBe('leaf')
+ })
+
it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1
From fb5292cee5bc462694fe2ffb37895e38785fcbc3 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 22 Jul 2026 02:02:46 +0800
Subject: [PATCH 2/4] fix(tools): classify body snapshot failures
---
packages/core/tools/src/index.ts | 19 ++++++++++++++-----
packages/core/tools/tests/tools.spec.ts | 22 ++++++++++++++++++++++
2 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index 37c444bdf5..565e06eb70 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -405,6 +405,18 @@ function snapshotProjection(toolName: string, projector: 'render' | 'presenta
}
}
+/** Snapshot one body or policy value into the canonical invalid-output failure class. */
+function snapshotToolValue(toolName: string, candidate: unknown): JsonValue {
+ try {
+ const detached = snapshotJsonValue(candidate)
+ if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON'])
+ return detached as JsonValue
+ } catch (error: unknown) {
+ if (error instanceof ToolOutputError) throw error
+ throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])
+ }
+}
+
/** Successful canonical tool execution, including its Native/model projection. */
export interface ToolExecutionSuccess {
readonly isError: false
@@ -1316,13 +1328,10 @@ export class ToolRegistry extends Service {
/** Snapshot, validate, render, and optionally project one successful body value. */
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
- const detached = snapshotJsonValue(candidate)
- if (detached === undefined) {
- throw new ToolOutputError(tool.name, ['value is not lossless JSON'])
- }
+ const detached = snapshotToolValue(tool.name, candidate)
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
- const value = deepFreeze(detached as JsonValue)
+ const value = deepFreeze(detached)
let rendered: ContentBlock[]
try {
rendered = tool.output.render(exec.arguments, value)
diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts
index 49ec56d14d..b23d99f405 100644
--- a/packages/core/tools/tests/tools.spec.ts
+++ b/packages/core/tools/tests/tools.spec.ts
@@ -193,6 +193,28 @@ describe('ToolRegistry', () => {
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
})
+ it('classifies a throwing body snapshot as invalid tool output', async () => {
+ const ctx = await setup()
+ const hostile = Object.defineProperty({}, 'value', {
+ enumerable: true,
+ get: () => { throw new Error('body snapshot getter exploded') },
+ })
+ ctx.tools.register(defineTool({
+ name: 'hostile-body',
+ description: 'hostile body',
+ parameters: {},
+ output: { schema: { type: 'json' }, render: () => [] },
+ execute: async () => hostile as JsonValue,
+ }))
+
+ const result = await ctx.tools.execute({
+ signal: testToolSignal,
+ callId: CallId('hostile-body'), name: 'hostile-body', arguments: {},
+ })
+ expect(result.error?.message).toContain('value snapshot failed: body snapshot getter exploded')
+ expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
+ })
+
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
const ctx = await setup()
ctx.tools.register(defineTool({
From 6c887e8bcf69a724c1e334fac1efeeda2da33921 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 22 Jul 2026 02:05:02 +0800
Subject: [PATCH 3/4] docs(tools): refresh generated catalogs
---
docs/config-catalog.md | 2 +-
docs/cordis-catalog/services.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index f1bd8c4594..a8eb73730e 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1451,7 +1451,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
-Source: [`packages/core/tools/src/index.ts:504`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:516`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-tui`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 1f666d1a96..3c9c684b2f 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1456,7 +1456,7 @@ async execute(exec: ToolExecutionInput): Promise
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:578`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:590`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
From f2d86b232e721baff58424e7fd2a01bd817e7537 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 22 Jul 2026 02:13:37 +0800
Subject: [PATCH 4/4] fix(code-runtime): accept deeply nested JSON
---
...-20-code-mode-typed-tool-returns.i18n.yaml | 4 +-
...2026-07-20-code-mode-typed-tool-returns.md | 2 +-
...6-07-20-code-mode-typed-tool-returns.zh.md | 2 +-
.../code-runtime-worker/README.md | 2 +-
.../code-runtime-worker/src/worker-json.ts | 121 ++++++++++++------
.../code-runtime-worker/tests/runtime.spec.ts | 20 +++
.../tests/worker-json.spec.ts | 12 ++
7 files changed, 119 insertions(+), 44 deletions(-)
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 a4cf2dab15..a84e69c18e 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: 089773cc715b9003ab4e1667e5af36a8aa1686d4
-2026-07-20-code-mode-typed-tool-returns.zh.md: 24e41ae49182e2d1976e451601cfc8008b1ed11a
+2026-07-20-code-mode-typed-tool-returns.md: 2f7b39ddaa3d4f2441a9061583bb60dbb4c8e14c
+2026-07-20-code-mode-typed-tool-returns.zh.md: 9842897cb372d04b4b09679ed13062511347cdc2
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 089773cc71..2f7b39ddaa 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
@@ -53,7 +53,7 @@ Before dispatch the bridge snapshots binding arguments as lossless JSON and make
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.
-Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and cross through structured clone with no byte cap. 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.
+Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and cross through structured clone with no byte cap. Both snapshot boundaries traverse iteratively, so valid nesting has no JavaScript call-stack depth cap. 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.
### Outer result and output ledger
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 24e41ae491..9842897cb3 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
@@ -53,7 +53,7 @@ declare const tools: {
worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造函数,因此 `error instanceof ToolCallError` 能够成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。
-绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,再通过结构化克隆传输,且不设字节上限。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。
+绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,再通过结构化克隆传输,且不设字节上限。两处快照边界均采用迭代方式遍历,因此有效嵌套不受 JavaScript 调用栈深度上限限制。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。
### 外层结果与输出账本
diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md
index 6ff75e9fe5..3e69d0ae0f 100644
--- a/packages/code-runtime/code-runtime-worker/README.md
+++ b/packages/code-runtime/code-runtime-worker/README.md
@@ -22,7 +22,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
- **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.
- **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 cross by structured clone after lossless-JSON validation and have no byte cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
+- **Intermediate binding values are complete JSON** — binding arguments and resolutions cross by structured clone after iterative lossless-JSON validation and have no byte or call-stack depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure diagnostic. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
- **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/worker-json.ts b/packages/code-runtime/code-runtime-worker/src/worker-json.ts
index fbc0cd7720..105cc2a646 100644
--- a/packages/code-runtime/code-runtime-worker/src/worker-json.ts
+++ b/packages/code-runtime/code-runtime-worker/src/worker-json.ts
@@ -40,71 +40,114 @@ function enumerableStringKeys(value: object): string[] | undefined {
if (keys.some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined
return keys as string[]
}
-/* jscpd:ignore-end */
+
+type SnapshotDestination =
+ | { kind: 'root' }
+ | { kind: 'array'; target: CodeJsonValue[]; index: number }
+ | { kind: 'object'; target: Record; key: string }
+
+type SnapshotTask =
+ | { kind: 'visit'; value: unknown; destination: SnapshotDestination }
+ | { kind: 'array-item'; source: unknown[]; index: number; target: CodeJsonValue[] }
+ | { kind: 'object-property'; source: Record; key: string; target: Record }
+ | { kind: 'leave'; source: object }
/**
* Validate and detach one worker-boundary value without loading another
* workspace package at runtime. This mirrors the session-owned canonical
* JSON boundary while remaining safe to import from the unbuilt worker.
+ * Its iterative traversal adds no JavaScript call-stack depth limit.
*
* @param value - the candidate completion value.
* @returns a detached lossless-JSON snapshot, or `undefined` when invalid.
*/
export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined {
const active = new Set()
-
- const within = (source: object, build: () => T | undefined): T | undefined => {
- if (active.has(source)) return undefined
- active.add(source)
- try {
- return build()
- } finally {
- active.delete(source)
+ let root: CodeJsonValue | undefined
+ const assign = (destination: SnapshotDestination, item: CodeJsonValue): void => {
+ if (destination.kind === 'root') {
+ root = item
+ } else if (destination.kind === 'array') {
+ destination.target[destination.index] = item
+ } else {
+ Object.defineProperty(destination.target, destination.key, {
+ value: item,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ })
}
}
- const copy = (candidate: unknown): CodeJsonValue | undefined => {
- if (candidate === null) return null
- if (typeof candidate === 'boolean' || typeof candidate === 'string') return candidate
+ const tasks: SnapshotTask[] = [{ kind: 'visit', value, destination: { kind: 'root' } }]
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
+ if (task.kind === 'leave') {
+ active.delete(task.source)
+ continue
+ }
+ if (task.kind === 'array-item') {
+ if (!Object.hasOwn(task.source, task.index)) return undefined
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.index],
+ destination: { kind: 'array', target: task.target, index: task.index },
+ })
+ continue
+ }
+ if (task.kind === 'object-property') {
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.key],
+ destination: { kind: 'object', target: task.target, key: task.key },
+ })
+ continue
+ }
+
+ const candidate = task.value
+ if (candidate === null) {
+ assign(task.destination, null)
+ continue
+ }
+ if (typeof candidate === 'boolean' || typeof candidate === 'string') {
+ assign(task.destination, candidate)
+ continue
+ }
if (typeof candidate === 'number') {
- return Number.isFinite(candidate) && !Object.is(candidate, -0) ? candidate : undefined
+ if (!Number.isFinite(candidate) || Object.is(candidate, -0)) return undefined
+ assign(task.destination, candidate)
+ continue
}
if (typeof candidate !== 'object') return undefined
+ if (active.has(candidate)) return undefined
if (Array.isArray(candidate)) {
if (!hasPlainArrayPrototype(candidate)) return undefined
const length = candidate.length
if (Reflect.ownKeys(candidate).length !== length + 1) return undefined
- return within(candidate, () => {
- const result: CodeJsonValue[] = []
- for (let index = 0; index < length; index++) {
- if (!Object.hasOwn(candidate, index)) return undefined
- const item = copy(candidate[index])
- if (item === undefined) return undefined
- result.push(item)
- }
- return result
- })
+ const target: CodeJsonValue[] = []
+ assign(task.destination, target)
+ active.add(candidate)
+ tasks.push({ kind: 'leave', source: candidate })
+ for (let index = length - 1; index >= 0; index--) {
+ tasks.push({ kind: 'array-item', source: candidate, index, target })
+ }
+ continue
}
if (!hasPlainObjectPrototype(candidate)) return undefined
const keys = enumerableStringKeys(candidate)
if (keys === undefined) return undefined
- return within(candidate, () => {
- const result: Record = {}
- for (const key of keys) {
- const item = copy((candidate as Record)[key])
- if (item === undefined) return undefined
- Object.defineProperty(result, key, {
- value: item,
- enumerable: true,
- configurable: true,
- writable: true,
- })
- }
- return result
- })
+ const target: Record = {}
+ assign(task.destination, target)
+ active.add(candidate)
+ tasks.push({ kind: 'leave', source: candidate })
+ for (let index = keys.length - 1; index >= 0; index--) {
+ const key = keys[index]
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
+ if (key === undefined) return undefined
+ tasks.push({ kind: 'object-property', source: candidate as Record, key, target })
+ }
}
-
- return copy(value)
+ return root
}
+/* jscpd:ignore-end */
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 f8b5210ee9..c4dfefff91 100644
--- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
@@ -74,6 +74,26 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(calls).toEqual([{ n: 1 }])
})
+ it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
+ const { runtime } = await setup()
+ const result = await runtime.run({
+ program: `
+ let value = 'leaf';
+ for (let depth = 0; depth < 3_000; depth++) value = [value];
+ return await tools.echo(value);
+ `,
+ bindings: tools({ echo: async args => args }),
+ })
+
+ expect(result.error).toBeUndefined()
+ let cursor = result.value
+ for (let depth = 0; depth < 3_000; depth++) {
+ expect(Array.isArray(cursor)).toBe(true)
+ cursor = Array.isArray(cursor) ? cursor[0] : undefined
+ }
+ expect(cursor).toBe('leaf')
+ })
+
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
diff --git a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts
index 3d67a67897..8481747c4b 100644
--- a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts
@@ -64,6 +64,18 @@ describe('snapshotCodeJsonValue', () => {
expect(snapshot[0]?.['__proto__']).toEqual({ safe: true })
})
+ it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
+ let value: unknown = 'leaf'
+ for (let depth = 0; depth < 5_000; depth++) value = [value]
+
+ let cursor = snapshotCodeJsonValue(value)
+ for (let depth = 0; depth < 5_000; depth++) {
+ expect(Array.isArray(cursor)).toBe(true)
+ cursor = Array.isArray(cursor) ? cursor[0] : undefined
+ }
+ expect(cursor).toBe('leaf')
+ })
+
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1