Procházet zdrojové kódy

Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

Tianyi Cui před 2 měsíci
rodič
revize
a6e4bc7bcb

+ 2 - 2
.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

+ 1 - 1
.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<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.
 

+ 1 - 1
.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<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 声明的对象明确选择开放方式,然后再调用同一编译器。
 

+ 27 - 2
packages/cordis/tool-cordis/src/guard.ts

@@ -31,9 +31,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
@@ -46,7 +71,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[] = []

+ 1 - 0
packages/cordis/tool-cordis/tests/mount.spec.ts

@@ -396,6 +396,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()

+ 1 - 1
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
 

+ 113 - 103
packages/core/session/src/json.ts

@@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | undefined {
   return keys as string[]
 }
 
-/**
- * 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.
- *
- * @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 {
+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 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
+  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
-    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,
-        })
+    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 }) })
       }
-      return snapshot
-    } finally {
-      ancestors.delete(current)
+      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
+}
 
-  return visit(value) as T | undefined
+/**
+ * Validate and detach lossless JSON in one read per property, so a stateful
+ * 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 {
+  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
 }

+ 13 - 0
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