fix(session): traverse JSON values iteratively

This commit is contained in:
Tianyi Cui
2026-07-22 01:58:38 +08:00
parent 1f7e1765a5
commit 0d851adc3e
8 changed files with 161 additions and 112 deletions

View File

@@ -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

View File

@@ -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<S>` and `InferArgs<P>` 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<S>` and `InferArgs<P>` 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.

View File

@@ -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<S>``InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()``parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue<S>``InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()``parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。校验和快照遍历均以迭代方式执行,因此合法嵌套的深度上限由可用内存决定,而非 JavaScript 调用栈。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()``ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON保留原始包装层的默认开放语义并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。

View File

@@ -29,9 +29,34 @@ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record<string, unknown> {
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<object>()): 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<object>()): 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[] = []

View File

@@ -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()

View File

@@ -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

View File

@@ -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<string, unknown>; 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<object>()
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<string, unknown>, 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<T>(value: T): T | undefined {
const ancestors = new Set<object>()
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<string, unknown>)[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<T>(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<object> = 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<string, unknown>)[key], seen))
} finally {
seen.delete(value)
}
export function isJsonValue(value: unknown): boolean {
return walkJsonValue(value, false) === true
}

View File

@@ -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