Unified JSON-value schema DSL
统一 JSON 值 schema DSL
Tool parameters used a small author DSL while subagent/workflow structured output used a separate raw JSON Schema subset and validator. The two vocabularies disagreed about roots, scalar constraints, and validation, so a typed canonical tool-output contract would either duplicate both paths again or accept schemas that some projection could not enforce.
English
Problem
Tool parameters used a small author DSL while subagent/workflow structured output used a separate raw JSON Schema subset and validator. The two vocabularies disagreed about roots, scalar constraints, and validation, so a typed canonical tool-output contract would either duplicate both paths again or accept schemas that some projection could not enforce.
Decision
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. Schema records contain only own enumerable string keys, schema arrays are dense intrinsic arrays, and supported keywords are read as own properties; custom prototypes, inherited constraints, symbols, and JSON-invisible decorations therefore cannot make compilation, projection, and validation observe different declarations. Intrinsic plain Object and Array containers remain plain across JavaScript realms, while subclasses and forged constructor prototypes remain exotic.
InferValue<S> and InferArgs<P> derive TypeScript values from the same declarations that valueSchemaSpecToJsonSchema() and parameterSchemaSpecToJsonSchema() compile. Exact inference is bounded to 16 container levels and then uses JsonValue, preventing TypeScript's type-instantiation stack from becoming the authoring limit. assertSupportedJsonSchema() rejects unsupported or misplaced keywords, and validateJsonSchemaValue() enforces the accepted subset against the lossless JsonValue boundary: no undefined, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack.
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use assertObjectJsonSchema() and ObjectJsonSchema; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. The dynamic boundary rejects JSON-invisible record keys and exotic schema arrays before normalization, so it cannot silently discard a constraint or consume custom iteration semantics.
Alternatives considered
- Keep separate parameter and structured-output schema systems: rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary.
- Use Schemastery for tool parameters: rejected because Schemastery targets validation and transformation through Standard Schema rather than JSON Schema generation. It would add an adapter layer without producing the model-facing wire schema or the shared output vocabulary.
- Adopt full JSON Schema or Ajv: rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest.
- Make every object implicitly open or closed: rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default.
- Define
oneOfas first-match: rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values.
Consequences
- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary.
- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams.
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to
JsonValue; runtime schema enforcement remains exact at every depth. - Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
- Per-property
required: trueremains the tool-author contract, and type-level regression coverage pins required keys as non-optional after the original inference path exposed an optionality bug. - Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays.
中文
问题
工具参数使用一套精简的作者侧 schema DSL,subagent/工作流的结构化输出则使用另一套原始 JSON Schema 子集和校验器。两套词汇在根类型、标量约束和校验方式上并不一致;如果继续沿用这种划分,类型化的规范工具输出约定要么还需重复实现两条路径,要么只能接受部分投影无法强制执行的 schema。
决策
dsh-tools 以两种表示形式统一管理一套 JSON 值 schema 词汇。ValueSchemaSpec 是可描述任意 JSON 根类型的作者侧形式;ParameterSchemaSpec 是其隐式对象属性映射形式,每个属性可标记 required: true。JsonSchemaNode 是原始协议形式。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 enum/const,以及要求恰好匹配一个分支的 oneOf;{ type: 'json' } 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
显式的作者侧对象必须声明 additionalProperties: true | false。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。schema 记录只能包含自有且可枚举的字符串键,schema 数组必须是稠密的内建数组,系统只从自有属性读取受支持的关键字;因此,自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器,而子类和伪造构造函数的原型仍视为非普通对象。
InferValue<S> 和 InferArgs<P> 根据同一份声明推导 TypeScript 值,valueSchemaSpecToJsonSchema() 和 parameterSchemaSpecToJsonSchema() 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 JsonValue,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。assertSupportedJsonSchema() 会拒绝不受支持或位置错误的关键字;validateJsonSchemaValue() 则以无损 JsonValue 边界校验受支持的子集,不允许 undefined、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表脱离引用,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 assertObjectJsonSchema() 和 ObjectJsonSchema 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为宿主拥有的 JSON 值,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。
备选方案
- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。
- **使用 Schemastery 处理工具参数:**不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。
- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。
- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。
- **把
oneOf定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。
影响
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为
JsonValue;运行时 schema 强制执行在任意深度仍保持精确。 - 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
- 每个属性的
required: true仍是工具作者约定;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。 - 运行时测试和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。