Просмотр исходного кода

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

# Conflicts:
#	packages/core/tools/src/schema.ts
Tianyi Cui 2 месяцев назад
Родитель
Сommit
3d6c3ec2a1

+ 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: ab7bb268407ac26230283172ebb291de80412bf2
-2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5
+2026-07-20-unified-json-value-schema-dsl.md: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea
+2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1

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

+ 16 - 6
packages/core/session/src/json.ts

@@ -12,6 +12,18 @@
  */
 export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
 
+/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
+function hasPlainArrayPrototype(value: unknown[]): boolean {
+  const prototype: unknown = Object.getPrototypeOf(value)
+  return Array.isArray(prototype) && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null
+}
+
+/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
+function hasPlainObjectPrototype(value: object): boolean {
+  const prototype: unknown = Object.getPrototypeOf(value)
+  return prototype === null || Object.getPrototypeOf(prototype) === null
+}
+
 /**
  * Validate and detach lossless JSON in one read per property, so a stateful
  * getter cannot change between validation and copying. Accepts ordinary arrays,
@@ -46,7 +58,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
     ancestors.add(current)
     try {
       if (Array.isArray(current)) {
-        if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
+        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
@@ -62,8 +74,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
         return snapshot
       }
 
-      const prototype = Object.getPrototypeOf(current) as unknown
-      if (prototype !== Object.prototype && prototype !== null) return undefined
+      if (!hasPlainObjectPrototype(current)) return undefined
       const snapshot: { [key: string]: JsonValue } = {}
       for (const key of Object.keys(current)) {
         const item = visit((current as Record<string, unknown>)[key])
@@ -115,7 +126,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
   seen.add(value)
   try {
     if (Array.isArray(value)) {
-      if (Object.getPrototypeOf(value) !== Array.prototype) return false
+      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
@@ -127,8 +138,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
       return true
     }
     // Plain object only (reject Map/Set/Date/class instances).
-    const proto = Object.getPrototypeOf(value) as unknown
-    if (proto !== Object.prototype && proto !== null) return false
+    if (!hasPlainObjectPrototype(value)) return false
     return Object.values(value).every(v => isJsonValue(v, seen))
   } finally {
     seen.delete(value)

+ 25 - 1
packages/core/session/tests/json.spec.ts

@@ -1,5 +1,6 @@
+import { runInNewContext } from 'node:vm'
 import { describe, expect, it } from 'vitest'
-import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
+import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
 
 describe('snapshotJsonValue', () => {
   it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
@@ -36,6 +37,22 @@ describe('snapshotJsonValue', () => {
     expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
   })
 
+  it('accepts intrinsic plain containers from another JavaScript realm', () => {
+    const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
+      object: { nested: number[] }
+      array: JsonValue[]
+    }
+
+    expect(isJsonValue(foreign.object)).toBe(true)
+    expect(isJsonValue(foreign.array)).toBe(true)
+    const objectSnapshot = snapshotJsonValue(foreign.object)!
+    const arraySnapshot = snapshotJsonValue(foreign.array)!
+    expect(objectSnapshot).toEqual({ nested: [1] })
+    expect(arraySnapshot).toEqual([2, { ok: true }])
+    expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype)
+    expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype)
+  })
+
   it('reads each object value and array slot once while materializing', () => {
     class Exotic {
       readonly accepted = false
@@ -77,10 +94,17 @@ describe('snapshotJsonValue', () => {
     Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
     const cyclic: Record<string, unknown> = {}
     cyclic.self = cyclic
+    const foreignExotics = runInNewContext(`(() => {
+      class Box { constructor() { this.value = 1 } }
+      class List extends Array {}
+      return [new Box(), new List(1)]
+    })()`) as [object, unknown[]]
 
     expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
     expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
     expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
+    expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined()
+    expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined()
     expect(snapshotJsonValue(sparse)).toBeUndefined()
     expect(snapshotJsonValue(compensatedSparse)).toBeUndefined()
     expect(snapshotJsonValue(decorated)).toBeUndefined()

+ 11 - 0
packages/core/tools/tests/json-schema.spec.ts

@@ -1,3 +1,4 @@
+import { runInNewContext } from 'node:vm'
 import { describe, expect, it } from 'vitest'
 import {
   assertObjectJsonSchema,
@@ -187,6 +188,16 @@ describe('the enforced raw JSON Schema subset', () => {
       .toEqual(['schema.examples annotation must be lossless JSON data'])
   })
 
+  it('accepts lossless annotation containers from another JavaScript realm', () => {
+    const schema = runInNewContext(`({
+      type: 'object',
+      default: { x: 1 },
+      examples: [[{ ok: true }]],
+    })`) as unknown
+
+    expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
+  })
+
   it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
     const cyclic: Record<string, unknown> = { type: 'object' }
     cyclic.properties = { self: cyclic }

+ 19 - 0
packages/core/tools/tests/tools.spec.ts

@@ -1807,6 +1807,25 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
 })
 
 describe('defineTool presentation (presentCall / presentResult)', () => {
+  it('preserves inline enum and const literals in inferred arguments', () => {
+    defineTool({
+      name: 'literal-args',
+      description: 'literal arguments',
+      parameters: {
+        mode: { type: 'string', enum: ['read', 'write'], required: true },
+        attempt: { type: 'integer', const: 1 },
+      },
+      output: {
+        schema: { type: 'null' },
+        render: () => [],
+      },
+      async execute(args) {
+        expectTypeOf(args).toEqualTypeOf<{ mode: 'read' | 'write'; attempt?: 1 }>()
+        return null
+      },
+    })
+  })
+
   it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
     const tool = defineContentToolFixture({
       name: 'demo',