这是《DeepSeek Harness 权威指南 》系列第 9 篇,也是二开线(B 线)的第 2 篇。源码基线为 deepseek-harness @
47f9438(v0.1.0-rc.5)。本文把官方源码、官方 Cordis 教程、作者 Windows 本机 demo 严格分开:官方材料说明框架契约;作者 demo 只证明给定工作区、Node / pnpm 安装和命令下的观察;自制图是源码解释,并非官方生成图或兼容性认证。
B0 解决的是“一个模块能否进插件树”。但模块能被 loader import,和它成为一个可被模型请求、可被策略拦截、可被测试的工具,是四件不同的事:
| 需要验证的事实 | 谁负责 | 不能由它单独推出什么 |
|---|---|---|
| 文件在配置树中 | patch / Loader / --dump-config | 不证明 Node 能完成 module import |
ctx.tools 已可用 | inject: ['tools'] | 不代表审批、沙箱或模型调用已配置 |
| 工具 schema 可见 | ctx.tools.register() 与 registry projection | 不代表模型一定选择这个工具 |
| 一次调用经过真实 pipeline | ctx.tools.execute() | 不代表真实 provider、UI 或外部 I/O 已验证 |
这篇文章用官方的无密钥 greet 教程跑完最小闭环:静态插件模块 → tools registry → 模型可见 schema → registry 执行 → canonical value → rendered result。 它刻意不把 bash、fs、MCP、Code Mode、升权、后台任务和自定义 UI 卡片塞进第一个示例;它们各自有更多外部能力和策略边界,留给后续文章。
本系列全部 demo 的完整代码见 rex-dhs-core/dsh-b1-tool (公开仓库,含运行证据)。
一、先给结论:工具不是“让模型直接调用一个函数”
defineTool() 定义的是一个受 registry 管理的契约。模型侧看到的是受允许列表限制的 schema;宿主侧保留执行函数、输出验证、取消信号、策略、guard 与 UI 展示信息。真正进入工具主体前,调用还会经过 execution pipeline。

图:依据 47f9438 的 schema.ts 与 index.ts 绘制。inject 是服务依赖;native 模型投影只含 name、description、parameters。图不表示模型一定会调用工具,也不表示工具自动获得 approval 或 sandbox。
因此,下面四句话必须区分:
- 插件已注册:当前 scope 的工具 registry 有该定义。
- 模型可见:native schema projection 中有该工具的
name、description和parameters。 - 模型可请求:模型输出了相应 tool call;这仍取决于模型、prompt、agent scope 和 presentation mode。
- 工具实际执行:registry 通过策略与 guard 后调用 body,并把结果物化为
ToolExecutionResult。
把这四层混成“注册成功,所以模型调用了我的函数”是插件开发中最常见的误判。
二、官方最小闭环:greet-tool.ts
官方 Cordis 教程第 7 章给出的 greet 例子正好适合做第一个工具:不读文件、不发网络请求、不需要 API Key,也不启动 LLM。以下是官方教程的完整工具片段(docs/cordis-tutorial/07-into-the-harness.zh.md:11-46):
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true, description: 'Who to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
// Drive one call through the real execution pipeline, standing in for
// the model. CallId brands the correlation id a provider would issue.
void (async () => {
const result = await ctx.tools.execute({
callId: CallId('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
})()
}
这里有两个容易被忽略的英文注释:
standing in for the model:这次ctx.tools.execute()是替代模型发起一次调用的 demo driver,不是说apply()本身是生产工具每次 mount 都应执行的行为。CallId brands the correlation id a provider would issue:CallId('demo-1')是给关联 id 加上类型品牌;它不是 provider 请求已发生的证据。
真实交付的工具插件通常只注册定义;把主动调用放在独立测试或 demo driver 中,避免每次服务加载都产生动作。本文保留官方写法,是为了让“模型的替身也应走 registry”这个关键事实可观察。
name、inject、apply 分别负责什么
| 导出 | 在本例中的职责 | 不应赋予它的含义 |
|---|---|---|
name | 插件标识,便于 loader / 诊断识别 | 不是模型侧 tool name;本例真正的工具名是 greet |
inject = ['tools'] | 声明对 ctx.tools 的硬依赖 | 不是审批、权限、沙箱或 YAML 排序配置 |
apply(ctx) | 在依赖已满足的 context 中贡献 effect | 不等于“model execution callback” |
Cordis 的 inject 语义是硬依赖:服务缺失时 fiber 保持 PENDING;服务以后出现时才启动;服务又消失时,依赖方会卸载。官方教程明确说明,决定启动的是服务依赖关系而不是 YAML 文件顺序(docs/cordis-tutorial/03-services.zh.md:44-76)。
所以,inject: ['tools'] 可以让 apply() 中的 ctx.tools 可用,却不能让工具自动拥有用户批准、文件写入权限、网络权限或某个 runner。
三、注册不是普通 Map.set:它返回可撤销 effect
ToolRuntime.register() 的官方实现同时说明了冲突、必需 output 契约、保留名称和 disposer(packages/core/tools/src/index.ts:1031-1062):
/**
* Register globally or in the calling agent scope. Scoped tools shadow
* globals; duplicates within one layer and the reserved `run_code` name fail.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const name = definition.name
const output = (definition as Partial<ToolDefinition>).output
if (output === undefined || typeof output !== 'object'
|| typeof output.render !== 'function'
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
}
assertSupportedJsonSchema(output.schema)
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
// Reserved unconditionally: any agent may select a code mode for itself,
// so a name free to take under the deployment default would become a
// collision the moment a preset mounted.
if (name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
中文解读:
- 这不是无限制的全局 map:agent scope 中的定义可遮蔽 global 定义,同一层重复名称会失败。
run_code是保留 transport 名称,不能把普通工具注册为它。output、output.schema和output.render是必需契约;缺一个,注册会直接失败。- 返回的函数是注销 disposer。
ctx.tools.register()在 pluginapply()中创建的是 effect,因此当此 plugin fiber dispose 时,工具注册会被一并撤销。
作者本机测试会实际断言最后一点;它不是只读源码推断。
四、三个数据形状:输入 schema、canonical value、模型结果 content
初学者最容易把这三者混为一层:
| 形状 | greet 中的例子 | 谁消费 | 关键边界 |
|---|---|---|---|
| 参数 schema | parameters: { name: ... } | native 模型协议与 registry 参数验证 | 参数根是隐式开放对象;不要假设多余顶层字段一律被拒绝 |
| canonical value | "Hello, Cordis!" | output schema 验证、稳定业务结果 | execute() 应返回这个值,不是 UI block |
| rendered content | [{ type: 'text', text: value }] | 模型结果、session 结果、展示层 | 由 output.render() 投影得到,不等同于 UI 卡片 API |
defineTool() 在实际调用作者的 typed body 前,先将参数 DSL 编译为 JSON schema 并检查参数。关键包装段如下(packages/core/tools/src/schema.ts:566-590):
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: parameters as unknown as Record<string, unknown>,
output: {
schema: outputSchema,
render(args: unknown, value: JsonValue): ContentBlock[] {
return userRender(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
...userPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: JsonValue): JsonValue {
return userPresentationMeta(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
} : {},
},
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
const violations = validate(args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
},
}
所以更严谨的说法是:schema-invalid 参数不会进入你在 defineTool({ execute(args) { ... } }) 中写的 typed body;但 pre-execute 和 guard 位于这个 wrapper 外层,应继续把 exec.arguments 视为未受类型信任的数据。
native 模型只拿到允许列表字段
registry 对 native 模型 schema 的投影代码如下(packages/core/tools/src/index.ts:1228-1267):
/**
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @returns one deep-cloned schema per visible tool.
*/
schemas(scope?: ScopeKey): ToolSchema[] {
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
}
/** Project visible callable tools onto the generated Code Mode SDK contract. */
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
return [...this.view(scope).visible.values()]
.filter(definition => definition.name !== RUN_CODE_NAME)
.map((definition): ToolSdkSchema => {
const output = snapshotJsonValue(definition.output.schema)
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
if (output === undefined) {
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
}
return {
...this.schemaOf(definition, true),
output,
}
})
}
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
const { name, description, parameters } = definition
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
if (detached === undefined) {
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
}
return {
name,
description,
parameters: detached,
}
}
这里要读清 native 与 Code Mode SDK 是两条不同投影:上面的 schemas() 只返回 name、description、parameters;sdkSchemas() 才额外携带 output schema。不要从“工具定义中存在 output”推出“native 模型请求一定收到 output schema”。
同理,下列宿主侧信息不会随着 native schema 投影泄漏给模型:
output / execute / timeoutMs / isConcurrencySafe /
finalizeContent / presentCall / presentResult
execute 返回值先被验证,再被 render
成功 body 返回值的实际处理顺序是(packages/core/tools/src/index.ts:1792-1823):
/** Snapshot, validate, render, and optionally project one successful body value. */
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
const detached = snapshotToolValue(tool.name, candidate)
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
const value = deepFreeze(detached)
let rendered: ContentBlock[]
try {
rendered = tool.output.render(exec.arguments, value)
} catch (error: unknown) {
throw projectionError(tool.name, 'render', error)
}
const content = snapshotProjection(tool.name, 'render', rendered)
let meta: JsonValue | undefined
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
let projected: JsonValue
try {
projected = tool.output.presentationMeta(exec.arguments, value)
} catch (error: unknown) {
throw projectionError(tool.name, 'presentationMeta', error)
}
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
}
const concludesTurn = this.concludingExecutions.has(exec)
return this.markCanonical(exec, this.materializeFinalResult({
isError: false,
value,
content,
...meta !== undefined ? { meta } : {},
...concludesTurn ? { concludesTurn: true as const } : {},
}) as ToolExecutionSuccess)
}
因此,生产工具不要模仿 defineContentToolFixture() 这种测试辅助物,把 ContentBlock[] 当 canonical value 返回。正常工具的 body 应返回领域值(字符串、对象、数组、null 等无损 JSON),再让 render 生成 content。
UI 还有独立边界:presentCall、presentResult 与 presentationMeta 用于 UI render intent / metadata;它们不等于 output.render,而且应是可回放的纯函数。B1 的最小 greet 刻意不使用它们。
五、调用必须走 registry:它才是工具流水线
下面这张图把一个 ctx.tools.execute() 的主要阶段按源码顺序展开:

图:依据 47f9438 的 ToolRuntime 执行路径绘制。图中的 ask 只有在额外 approval seam、agent 和 answerer 都满足时才可能放行;纯 greet demo 没有试图验证这一交互。
在 pre-execute 与 guard 阶段,代码会先得到 strategy 的决定,再查询单调 guard(packages/core/tools/src/index.ts:1463-1507):
private async prepareExecution<T>(
input: ToolExecutionInput,
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
): Promise<T> {
const created = this.createExecution(input)
if (created.kind !== 'ready') return next(created)
const exec = created.exec
if (this.callerCancelled(exec)) {
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
}
try {
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const askResolution: ToolAskResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
: decision.reason
if (denialReason !== undefined) {
return await next({
kind: 'post-result',
exec,
result: this.materializeFinalResult({
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
error: { message: denialReason },
}),
})
}
if (this.callerCancelled(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
}
这段源码给工具作者带来四个实际约束:
tools/pre-execute默认决定是allow,但组合中的 listener 可以改成deny或ask。ctx.tools.guard()在 waterfall 后运行;返回拒绝原因时,body 不会执行。它是单调 deny,不是“后一个 listener 可以强制 allow”的投票系统。ask不是自动弹出 UI。若没有 approval service、没有 agent、answerer 不可用、用户拒绝或取消,都会得到拒绝结果。纯 direct demo 没有 agent,所以不能把它误写成 approval UI 的实测。- schema-invalid 参数不会进入
defineTool作者写的 typedexecute(args)body;但 pre-execute / guard 看到的exec.arguments仍应被视为未受类型信任的数据。
后续的 tools/execute wrapper、body、post-execute、finalizeContent 和 tools/result 也都由 registry 编排。I/O 工具应该把 exec.signal 传给 fetch、子进程或下游 API;registry 会保留取消语义,却不能硬杀同一 JavaScript 进程中已经开始的任意工作。
tool plugin 不自动附带权限、审批或沙箱
这一点值得单列,因为它直接影响安全表述:
defineTool()负责 schema、typed body 和 output contract;它不安装 approval service。inject: ['tools']只等待工具 registry;它不声明 filesystem、shell、sandbox 或网络权限。tools/pre-execute是可组合的 allow / deny / ask policy seam。ctx.tools.guard()是最终同步、单调的拒绝 seam。- sandbox 属于具体 shell / fs / subprocess 能力的组合,详见 A6:能力 seam 与沙箱 。
如果你的工具涉及付费 API、文件写入、命令执行或用户数据,不要为了“第一个插件”把 policy 直接塞进 execute() 然后声称完成了授权。下一篇权限专题会单独验证 pre-execute、approval 和 agent 事件的组合。
六、无模型、本机可运行的官方教程副本
为了把文章中的命令与实际输出逐字核对,作者在本机创建了官方教程第 7 章的同形副本:
E:/coding/deepseek-harness/scratch-plugin/b1-greet/
├── cordis.yml
├── greet-tool.ts
├── tool-logger.ts
└── greet-tool.spec.ts
这不是 47f9438 中提交的测试目录,也不是官方发布插件;它是作者 Windows 工作区内的临时 demo。它没有 API Key、没有模型请求、没有文件 / 网络 / shell I/O。其 greet-tool.ts 和 tool-logger.ts 以官方教程源码为模板,便于验证本文中的 code block 与 stdout。
组合文件
官方教程的最小组合是(docs/cordis-tutorial/07-into-the-harness.zh.md:76-81):
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'
这里需要显式包含 dsh-system-prompt,因为 tools 会向它贡献 schema;若缺少服务提供方,依赖 tools 的 plugin 会保持 PENDING,而不会变成一个“部分成功”的工具。
运行命令与实测输出
作者实际在下列目录执行:
E:/coding/deepseek-harness/scratch-plugin/b1-greet
命令:
node --import tsx ../../vendor/cordis/bin.js
stdout:
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
这两个输出分别证明:
tools/resultlistener 观察到了greet的 materialized content;- caller 收到的
result.content是output.render()的投影。
它们不证明 DeepSeek 或任何 provider 完成过请求,更不证明模型曾自主选择 greet。
Web 能启动,是另一道更窄的验证门
作者还把本地 demo module 放入 --patch 组合并启动 Web profile。下图是在跳过 API Key 配置、没有工作区和会话数据时取得:

图:本机 dsh web 已启动并能访问初始工作台。画面不含 API Key、会话、工作区或工具结果;它只验证 profile boot / 页面可达,不能证明模型推理、provider 连通或工具实际调用成功。
与 B0 一样,把验证拆开更可靠:
| 检查 | 可证明 | 不可证明 |
|---|---|---|
--dump-config | patch 条目进入配置树 | 动态 import 与 module body 一定成功 |
dsh web --patch ... | 配置、import 和 boot 能走到 Web 服务 | 模型、用户交互、工具调用成功 |
本节 ctx.tools.execute() | registry 跑完一次具体调用 | 真实 provider 会产生同一个调用 |
ctx.tools.schemas() / system prompt | 工具处于某 scope 的可见 schema 面 | 模型必定选择这个 tool call |
当 dump 成功而 boot 失败时,优先检查 file:/// 路径、Node module resolution、导出形状和 module 初始化错误;不要误诊成 parameters schema 的问题。
七、测试要断言 registry 结果,而不是直调定义对象
官方 tools 单测的最小 setup 是创建 Context、挂载 SystemPrompt 和 ToolRuntime,再注册定义(packages/core/tools/tests/tools.spec.ts:16-34):
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
return ctx
}
const echoTool = defineTool({
name: 'echo',
description: 'echo arguments back',
parameters: { text: { type: 'string' } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return args.text ?? ''
},
})
真实执行则调用 registry(packages/core/tools/tests/tools.spec.ts:86-94):
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observed: ToolExecutionResult | undefined
ctx.on('tools/result', (_exec, result) => { observed = result })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' })
expect(observed).toEqual(result)
})
不要把下面两种调用等同:
// 不足以覆盖 registry pipeline:只触及定义对象。
await someTool.execute(args, exec)
// 覆盖注册表参数、策略、guard、wrapper、输出校验、render、result。
await ctx.tools.execute({ callId, name, arguments, signal })
作者本机的 greet-tool.spec.ts 做了四类断言:
| 场景 | 应检查的事实 |
|---|---|
| 注册后 schema | ctx.tools.schemas() 只有模型可见字段 |
| 成功调用 | isError: false、canonical value、render 后 content 都匹配 |
| 缺必填参数 | 得到归一的 isError 与 INVALID_ARGS,不是普通 promise rejection 断言 |
| dispose | plugin fiber 释放后 ctx.tools.schemas() 为空 |
实际命令:
pnpm exec vitest run --config scratch-plugin/vitest.b1.config.ts
实测结果摘要:
✓ scratch-plugin/b1-greet/greet-tool.spec.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
这个作者本机测试不取代 upstream 的测试矩阵;它只证明这个 tutorial copy 在该源码工作区与依赖安装状态下完成了最小 registry 往返。
八、工具插件的检查清单
在增加真实 I/O 之前,先确保下面每一项都有独立证据:
| 检查项 | 推荐观察点 | 常见误判 |
|---|---|---|
| 模块能加载 | 真正 boot,不只 dump | “配置里有 id”不等于 import 成功 |
inject 满足 | plugin 不再 PENDING,ctx.tools 可用 | 以为 YAML 顺序就是依赖管理 |
| schema 正确 | ctx.tools.schemas()、system prompt assembly | 把 host callbacks 暴露给模型 |
| output 正确 | value 通过 output.schema,content 来自 render | body 直接返回 ContentBlock[] |
| 策略结果正确 | ctx.tools.execute() 的 isError / error text | 直调 body 后宣布 guard 已覆盖 |
| 生命周期正确 | dispose 后工具消失 | 热更新后旧注册永远存在 |
| I/O 可取消 | 底层 API 收到 exec.signal | 以为 registry 可以硬终止任意 JS |
这里的 timeoutMs 也要额外谨慎:它只是工具声明的协作式 timeout budget;真正的 deadline 需要组合 @deepseek-ai/dsh-tool-call-timeout-policy 的 tools/execute wrapper。不要仅仅填写一个数字就承诺“工具必然在 N ms 后停止”。
九、这篇故意没有做的事
B1 的价值在于把最小闭环说准,因此以下问题不在本篇下结论:
- 模型选工具质量:schema 进入可见面,不等于任何模型都会遵从 description 或选中工具。
- 审批交互:
ask需要 agent、approval service 和 answerer;无模型 direct demo 不构成 UI 批准测试。 - 文件与 shell 权限:应走对应 fs / shell / sandbox seam,不是
inject: ['tools']的副作用。 - UI 卡片:
presentCall/presentResult是可回放的 UI projection,需要独立设计与验证。 - Code Mode:模型直接工具面会被
run_codetransport 改写,不能拿 native 示例直接套用。 - 外部 API、数据库或支付动作:必须补充 schema 之外的业务校验、错误分类、审计、取消和 policy 测试。
这不是保守到无法开发,而是把每个可证明的连接点单独命名。这样从 greet 升级到有副作用的工具时,新增的风险不会藏在“注册成功”四个字里。
十、总结
写一个 DeepSeek Harness 工具插件,最小但完整的思维链是:
静态 TypeScript module
-> inject tools 服务
-> ctx.tools.register(defineTool(...))
-> 模型可见 schema(受 allowlist 限制)
-> ctx.tools.execute() 进入 registry pipeline
-> canonical value 经 output.schema 验证
-> output.render() 形成模型 result content
-> tools/result 与调用方收到同一物化结果
请记住三个边界:
- 可见不等于必调用:模型得到 schema 后仍可能不选它。
- 执行不等于无条件放行:pre-execute、approval、guard、wrapper 和取消都在 body 外层。
- 输出值不等于 UI:canonical value、model content 和 presentation metadata 各有自己的契约。
下一篇将从工具接缝转向模型接缝:适配器如何把 provider、endpoint、credentials、请求 / 响应协议与流式事件接到 LlmRuntime,而不把“换一个 model 字段”误写成通用兼容保证。
下一篇:开发 LLM 适配器:把 provider 路由变成可验证 seam
如果你已在项目里写过自己的 dsh tool:它的 execute() 返回的是 canonical value,还是直接拼给模型 / UI 的文本?它的 guard、approval、timeout 与 I/O cancellation 分别在哪里测试?欢迎带着这些边界来 review 你的第一个工具插件。
