DeepSeek Harness:自建类型化工具 Schema DSL——defineTool 如何实现零强转的工具编写
本文基于 DeepSeek Harness 仓库中的一篇已归档架构决策记录(Agent Note: Custom typed tool-schema DSL instead of schemastery),完整还原该决策的问题背景、方案取舍与落地实现。读完本文,你将理解 dsh-tools 包中 defineTool() 背后的 ParameterSchemaSpec / InferArgs<S> / parameterSchemaSpecToJsonSchema() / defineTool() 四件套如何协作:让工具作者在 TypeScript 中拿到带类型的 execute(args) 而无需任何类型断言,同时把模型侧的传输格式严格编译为标准 JSON Schema。
一、问题:同一份参数,两套诉求
在 DeepSeek Harness("Everything is a Plugin" 的插件式 Agent 运行时)中,每个工具(tool)的参数必须同时满足两方诉求:
- 模型侧:参数必须以标准 JSON Schema 的形式进入提示词组装(prompt assembly),这是 LLM function calling 的传输格式;
- 作者侧:工具实现者希望在
execute(args)里拿到带完整类型推断的参数对象,而不是unknown加手动断言。
原文记录的矛盾点在于:JSON Schema 表达"必填"的方式是对象节点下独立平铺的 required 数组(["path"] 与 properties 分离),这迫使工具作者要么手写一份 JSON Schema 再靠运行时校验兜底,要么在运行时把 args 当作 unknown 处理。而仓库中已内嵌(vendored)的 Schemastery 库虽然服务于插件配置(plugin Config)的校验,但它面向的是 StandardSchema 的"校验/转换"方向,与"生成 JSON Schema"的方向不匹配。因此架构决策明确提出:工具作者 API 需要逐属性(per-property)的 required: true 布尔量,而非 JSON Schema 那种分离的 required 数组。
这一约束直接决定了后续所有 API 的形态——"必填"信息必须附着在属性定义本体上。
二、决策:四个构件如何拼出"零强转"
决策记录给出的方案是四个核心构件的组合,它们在今天的 packages/core/tools/src/schema.ts 中依然完整可见:
2.1 ParameterSchemaSpec:必填性附着在属性上
/** One implicit parameter-root property, optionally required. */
export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
/**
* Tool parameter schema. The map itself is an implicit open object root;
* requiredness remains a per-property `required: true` annotation.
*/
export type ParameterSchemaSpec = {
[key: string]: ParameterPropertySpec
[key: symbol]: never
}
(以上引自 schema.ts)
三个细节值得注意:
- 隐式开放对象根(implicit open object root):属性表本身即代表工具参数对象,作者不需要再写一层
{ type: 'object', properties: … }包装;根对象默认对未声明键开放,与 JSON Schema 的开放缺省语义一致; required?: true而非boolean:类型上只接受true或"缺省",把"可选"表达为缺省,语义单一、编译期即可拦截required: false这种二义写法;[key: symbol]: never:显式拒绝 symbol 键,保证属性表可以安全地投影为纯 JSON 的properties记录。
作者侧的实际写法见 dsh-tools 包 README 的示例:
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute file path' },
offset: { type: 'number' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args is typed: { path: string; offset?: number; limit?: number }
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
2.2 InferArgs:把逐属性必填性映射为 TS 可选键
/** Keys of a property map marked `required: true`. */
type RequiredKeys<S> = {
[K in StringKeyOf<S>]: S[K] extends { required: true } ? K : never
}[StringKeyOf<S>]
/** Infer the TypeScript argument object for an implicit parameter schema. */
export type InferArgs<S> = InferProperties<S, []>
(引自 schema.ts、[L174-L175])
InferProperties 使用两个映射类型把属性表拆成两份:required: true 的键映射为非可选属性,其余键映射为带 ? 的可选属性。于是 parameters 中 { path: { type: 'string', required: true }, offset: { type: 'number' } } 自动推导出 execute 参数的类型 { path: string; offset?: number }。
值得注意的是该映射是类型级回归测试守护过的:决策记录"Consequences"一节明确写到"InferArgs 的映射在一次早期的可选性 bug 之后被类型级回归测试覆盖"。对应用例在 packages/core/tools/tests/schema.spec.ts:
it('infers required and optional parameter keys', () => {
expectTypeOf<InferArgs<{
path: { type: 'string'; required: true }
offset: { type: 'integer' }
data: { type: 'json' }
}>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
})
这类 expectTypeOf(...).toEqualTypeOf(...) 断言在编译期执行,一旦映射逻辑回归(例如把必填键误推成可选),CI 的类型检查会直接失败——这正是决策记录中说的"回归测试守护"。
2.3 parameterSchemaSpecToJsonSchema:编译为带 required 数组的对象根
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
const compiled = compilePropertyMap(spec, 'parameters')
const schema: ParameterJsonSchema = {
type: 'object',
properties: compiled.properties,
...(compiled.required === undefined ? {} : { required: compiled.required }),
}
assertSupportedJsonSchema(schema)
return schema
}
(引自 schema.ts)
编译器把逐属性的 required: true 标注收集为 required: string[],重新组装成 JSON Schema 标准形态,并包上隐式的 type: 'object' 开放对象根。也就是说:作者写的是"逐属性布尔",线上发的是"required 数组",两种表达由这一个编译函数转换,双向都不需要作者手工维护。
从 packages/core/tools/tests/schema.spec.ts 可以看到编译产物与嵌套对象开放性的精确行为:
expect(parameterSchemaSpecToJsonSchema({
closed: {
type: 'object',
additionalProperties: false,
required: true,
properties: { id: { type: 'integer', required: true } },
},
open: { type: 'object', additionalProperties: true },
})).toEqual({
type: 'object',
properties: {
closed: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'integer' } },
required: ['id'],
},
open: { type: 'object', additionalProperties: true },
},
required: ['closed'],
})
即:根对象不附加任何"开放性覆盖"(保持开放),而显式声明的嵌套对象节点则原样保留其 additionalProperties 决策。
2.4 defineTool:把推断、编译、校验绑成一个入口
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
options: DefineToolOptions<S, O>,
): ToolDefinition
(签名见 schema.ts)
defineTool 在构造期完成三件事:
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)—— 编译参数 schema;const validate = (args: unknown) => validateJsonSchemaValue(parameters, args, '')—— 用同一份编译产物做模型实参校验;- 包装
execute:先validate(args),违规则抛出ToolArgsError(错误码INVALID_ARGS),校验通过后再把args as InferArgs<S>交给用户函数(见 schema.ts)。
const S 泛型参数是关键一环:它锁定传入字面量对象的精确类型,让 InferArgs<S> 能对 { required: true } 这种字面量做判别。作者只写一次 parameters,模型侧 schema、TS 参数类型、运行时校验三者全部由它推导,"推断—编译—校验"(inference, compilation, and validation)在决策记录中被点名为由 defineTool() 统一绑定。
三、为什么不用已内嵌的 Schemastery
决策记录的"Alternatives considered"一节给出了明确否决理由:
Schemastery(已内嵌、正被插件 Config 使用)被评估后否决于本用途:它面向 StandardSchema 的校验/转换,而不是 JSON Schema 生成,用它会在不产出干净线上格式的情况下增加一层间接性。
方向差异是本质性的:插件配置场景是"已有 schema,校验外部数据";工具参数场景是"已有类型化作者意图,生成线上 schema 并顺带获得 TS 类型"。后者需要的是一条"类型 → schema"的生成链,Schemastery 的 StandardSchema 抽象并不覆盖这一方向,强行套用只会让传输格式的产生路径多绕一层。仓库中 Schemastery 的既有用途(插件配置)保持不变,两条路径各司其职。
四、决策的后续演进:统一的 JSON 值 Schema DSL
决策记录同时注明,本决策已被后续的"统一 JSON 值 schema DSL"取代(superseded)——后者保留了这个小型的作者面(authoring surface),但让工具参数与类型化 JSON 值共享同一套词汇。这一点在 docs/subsystems/tools.md 与当前源码中得到印证:
- 参数表
ParameterSchemaSpec的每个属性就是一个ValueSchemaSpec,支持string、number、integer、boolean、null、array、object、作者专用json节点与"恰好命中一个分支"的oneOf联合(schema.ts); InferValue<S>与InferArgs<S>共用同一条有界推断链:精确推断限制在 16 层容器嵌套内,超出后回退到JsonValue,避免耗尽 TypeScript 类型实例化栈(schema.ts);- 工具的输出声明
output.schema走同一个valueSchemaSpecToJsonSchema()编译到同一套受强制约束的原始 JSON Schema 子集(json-schema.ts)。
因此今天读到 dsh-tools README 中"unified schema DSL supports string … and exact-one oneOf; InferValue preserves exact types through 16 container levels"时,其源头正是本篇决策记录所确立的"逐属性必填 + 隐式开放根 + 统一推断"骨架。
五、源码纵深:这套 DSL 的防御性实现
决策记录承诺"类型体操的代价留在核心包内部"。仓库的 AGENTS.md "Type safety and documentation" 一节确立了前提:全仓库 strict: true、noImplicitAny,"每一个残留的 any 都要解释为何无法收窄"。在这个约束下,把类型映射(如 S[K] extends { required: true })封装进 packages/core/tools 一处,让所有工具作者获得"零强转"体验,正是决策记录所称的"经 AGENTS.md 类型安全政策认可的集中代价"。
运行时编译同样体现了这一防御性,schema.spec.ts 中的几组用例可以佐证:
- 拒绝运行时伪造的作者形态:
{ type: 'object' }缺省additionalProperties、oneOf只有一个分支、enum与const类型不符、required: false、symbol 键、稀疏数组、装饰过的enum数组等,全部抛JsonSchemaError而非"有损编译"(schema.spec.ts); - 拒绝循环 schema:
items自引用、properties自引用均报circular(schema.spec.ts); - 栈安全:5000 层
oneOf嵌套的编译不使用 JavaScript 调用栈完成(编译采用显式任务栈的迭代式下降,见 schema.ts 的runSchemaCompiler)(schema.spec.ts); __proto__属性作为普通数据保留:通过Object.defineProperty而非赋值安装节点,避免原型链注入(schema.ts),并有专例验证(schema.spec.ts)。
六、与外部工具的兼容:原始 ToolDefinition 通道保留
决策记录明确了一个边界:ToolRegistry.register() 继续接受原始 JSON Schema 的 ToolDefinition,MCP 及其他外部工具由此接入。仓库源码印证了这一点:packages/core/tools/src/index.ts 中 ToolDefinition 的 execute 签名是 execute(args: unknown, exec: ToolRunContext): Promise<unknown>——原始定义自行负责入参校验;而 register()(index.ts)对任何 ToolDefinition 一视同仁地注册并返回 disposer。
d docs/subsystems/tools.md 对此的表述与决策记录完全一致:"execute receives args: unknown — a raw ToolDefinition validates its own input. First-party tools don't write that by hand; they use defineTool, which validates and narrow the arguments." 即:
| 接入方式 | 参数类型 | 校验责任 | 典型用户 |
|---|---|---|---|
defineTool({ parameters }) |
InferArgs<S>(零强转) |
注册表自动(ToolArgsError) |
第一方工具插件 |
原始 ToolDefinition + ToolRegistry.register() |
unknown |
定义自身 | MCP / 外部工具 |
七、小结与延伸阅读
这篇架构决策记录虽然篇幅短,但它定义了 dsh-tools 作者体验的完整契约:
- 问题:标准 JSON Schema 的
required数组与作者侧"逐属性必填"的表达错位; - 决策:
ParameterSchemaSpec(逐属性required: true)+InferArgs<S>(必填键 → 非可选属性)+parameterSchemaSpecToJsonSchema()(编译隐式开放对象根)+defineTool()(绑定推断、编译、校验); - 取舍:否决 Schemastery,因其方向是 StandardSchema 校验而非 JSON Schema 生成;
- 后果:第一方作者零强转;类型映射的代价集中且被 AGENTS.md 类型安全政策认可;
InferArgs映射由类型级测试回归守护。
延伸阅读(均为仓库内相对路径):
- packages/core/tools/README.md —— dsh-tools 包使用指南与实现导读
- docs/cookbook/adding-a-tool.md —— 逐步编写工具的实践手册
- docs/subsystems/tools.md —— 工具子系统:管线类型、schema DSL 与生成的服务 API
- packages/core/tools/src/json-schema.ts —— 受强制约束的原始 JSON Schema 子集与校验器
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00