首页
/ DeepSeek Harness:自建类型化工具 Schema DSL——defineTool 如何实现零强转的工具编写

DeepSeek Harness:自建类型化工具 Schema DSL——defineTool 如何实现零强转的工具编写

2026-09-03 15:37:25作者:彭桢灵Jeremy

本文基于 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)的参数必须同时满足两方诉求:

  1. 模型侧:参数必须以标准 JSON Schema 的形式进入提示词组装(prompt assembly),这是 LLM function calling 的传输格式;
  2. 作者侧:工具实现者希望在 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 在构造期完成三件事:

  1. const parameters = parameterSchemaSpecToJsonSchema(options.parameters) —— 编译参数 schema;
  2. const validate = (args: unknown) => validateJsonSchemaValue(parameters, args, '') —— 用同一份编译产物做模型实参校验;
  3. 包装 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,支持 stringnumberintegerbooleannullarrayobject、作者专用 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: truenoImplicitAny,"每一个残留的 any 都要解释为何无法收窄"。在这个约束下,把类型映射(如 S[K] extends { required: true })封装进 packages/core/tools 一处,让所有工具作者获得"零强转"体验,正是决策记录所称的"经 AGENTS.md 类型安全政策认可的集中代价"。

运行时编译同样体现了这一防御性,schema.spec.ts 中的几组用例可以佐证:

  • 拒绝运行时伪造的作者形态{ type: 'object' } 缺省 additionalPropertiesoneOf 只有一个分支、enumconst 类型不符、required: false、symbol 键、稀疏数组、装饰过的 enum 数组等,全部抛 JsonSchemaError 而非"有损编译"(schema.spec.ts);
  • 拒绝循环 schemaitems 自引用、properties 自引用均报 circularschema.spec.ts);
  • 栈安全:5000 层 oneOf 嵌套的编译不使用 JavaScript 调用栈完成(编译采用显式任务栈的迭代式下降,见 schema.tsrunSchemaCompiler)(schema.spec.ts);
  • __proto__ 属性作为普通数据保留:通过 Object.defineProperty 而非赋值安装节点,避免原型链注入(schema.ts),并有专例验证(schema.spec.ts)。

六、与外部工具的兼容:原始 ToolDefinition 通道保留

决策记录明确了一个边界:ToolRegistry.register() 继续接受原始 JSON Schema 的 ToolDefinition,MCP 及其他外部工具由此接入。仓库源码印证了这一点:packages/core/tools/src/index.tsToolDefinitionexecute 签名是 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 映射由类型级测试回归守护。

延伸阅读(均为仓库内相对路径):

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384