
本章摘要这一章是全书从「看懂」转向「做出东西」的分水岭核心技能只有一句话——先问「我要加的行为挂在哪个扩展点」再问「怎么写」。全章包含六个可直接照做的实操写一个工具、写一个权限门禁、写一个模型适配器、让界面显示实时输出、写一个协议驱动、委派给子 agent。每个实操都按「它挂在哪 → 最小可用代码 → 跑起来看什么 → 容易踩的坑」的节奏展开最后用一张「加新行为的完整清单」和三句话收尾。读完这一章第九章之前提到的每一个扩展点你都会亲手碰到一遍。10.1 第一步学会问「挂在哪」这是全部开发工作的核心技能。不要一开始就想「我要写什么代码」先问「我要加的这个行为应该挂在哪个扩展点上」官方在架构文档里给了一张「新行为的归属位置」表在实操手册里给了另一张更贴近产品的「功能→机制」映射表。两者合起来就是一张完整的地图。下面按「你想做什么」重组一次方便检索。这张图请记住。它回答的是这本书一开始提出的那个问题。10.2 零基础环境搭建先把东西跑起来在写任何插件之前先让它跑起来。官方给了两条路。路线一直接用 npm最快npx deepseek-ai/dsh web这会默认在http://127.0.0.1:3080启动 Web UI本机启动时还会用默认浏览器打开页面。两个实用参数参数作用--no-open只运行服务器不打开浏览器--patch 路径临时叠加一层 patch。开发插件时最常用它因为你不想每次都改 profile官方还提到一个 SSH 场景的细节通过 SSH 启动时只打印宿主机 URL因为本地转发地址由 SSH 客户端或编辑器持有。路线二从源码运行要读源码、写插件时必须gitclone https://github.com/deepseek-ai/deepseek-harness.gitcddeepseek-harnesspnpminstallpnpmrun buildpnpmdsh web两个命令的差别值得注意pnpm run build准备仓库产物pnpm dsh web直接使用这些已构建产物不会重新构建。开发时你大概想要的是pnpm run dev:web——它在源码修改时重建客户端 bundle。陷阱官方在 README 里用加粗写了一条**运行本项目前请阅读安全说明。**另外项目处于「开发者预览」阶段并且明确写着「未来将出现破坏兼容性的变更」。所以不要在关键生产环境上直接依赖它读到的一切请以你手上那份代码为准。学 Cordis 之后再动 harness官方给了一条很明确的路径建议Cordis 有一份七章的动手教程第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR → 进入 harness每一章都是一个可以运行的示例而且全程不需要 API 密钥。如果你想认真做插件把这七章过一遍的收益远高于直接读架构文档。教程的第一章从tmp/cordis-tutorial目录里跑node --import tsx ../../vendor/cordis/bin.js开始。10.3 实操一写一个工具最完整的例子这是最常做的开发。第 6 章给了骨架这里给一个完整、带解释的版本。import{readFile}fromnode:fs/promisesimporttype{Context}fromdeepseek-ai/cordisimport{defineTool}fromdeepseek-ai/dsh-toolsexportconstnamemy-toolsexportconstinject[tools]exportfunctionapply(ctx:Context){ctx.tools.register(defineTool({name:read_file,description:Read a UTF-8 text file from disk. Use this before editing a file. Returns the file content as text.,parameters:{path:{type:string,required:true,description:Absolute path},limit:{type:number,description:Max lines to return},},output:{schema:{type:string},render:(_args,value)[{type:text,text:value}],},timeoutMs:10_000,asyncexecute(args,exec){consttextawaitreadFile(args.path,{encoding:utf8,signal:exec.signal})constlinestext.split(\n)returnargs.limit?lines.slice(0,args.limit).join(\n):text},}))}逐段解释这份代码「为什么这么写」片段为什么inject [tools]让 Cordis 等工具注册表就绪。少了它你的ctx.tools可能是 undefineddescription写了三句话**这是模型理解这个工具的唯一依据。**第一句说做什么第二句说什么时候用很重要能显著减少误用第三句说返回什么parameters里path标了requireddefineTool会在执行前校验。execute收到的args已经是强类型且合法的output.schema是{ type: string }函数体返回一个字符串。注册表会按这个 schema 校验返回值——返回不对的类型会被转成isErrorrender把字符串包成一个 text 块规范值 → 模型可见内容的投影。这个分离让 PTC 里的代码能拿到裸字符串而不是一段包装过的内容块timeoutMs: 10_000声明超时预算。注意它同时是一份承诺——你声明了它就意味着这个工具会把exec.signal转给一个能收敛的实现。readFile支持 signal所以这里成立signal: exec.signal把取消信号传下去。用户按停止时这个读操作会被真正取消怎么跑起来官方教程给的方式是把插件写在一个目录里用--patch指过去pnpmdsh web--patch./scratch-plugin/cordis.yml然后在界面里对模型说Use the greet tool to greet Ada.这是官方教程里的原话把 greet 换成你的工具名即可。进阶要点速查需求怎么做参数校验更复杂非空、正数、跨字段schema DSL 表达不了的在execute里手动检查让模型看到结构化结果把output.schema设计成实用的程序化 API直接返回句柄与字段给界面做漂亮卡片用presentCall/presentResult返回渲染意图。必须是纯函数让卡片在回放时也能还原用output.presentationMeta(args, value)投影出可回放的 JSON跑很久的任务用ctx.jobs.start({ kind, label, owner: exec.agent, run })工具执行后给模型补一段说明exec.deferContext(...)——会在tool/result之后追加一条 user/message让这个工具结束整个轮次exec.concludeTurn()只在成功结果上有效异步通知模型不唤醒exec.agent.inject({ content, source: { kind: plugin, plugin: 你的名字 } })并 try/catch 防已销毁的 agent10.4 实操二写一个权限门禁钩子插件第 1 章已经给过这个例子。这里补充「完整版」要考虑的几件事。importtype{Context}fromdeepseek-ai/cordisimporttype{PreToolDecision,ToolExecution}fromdeepseek-ai/dsh-tools// 危险命令清单示意constDANGEROUS[/rm\s-rf\s\//,/mkfs/,/:\(\)\{:\|:\};:/]functioninspect(exec:ToolExecution):{deny?:string;ask?:boolean}{if(exec.name!bash)return{}constcmdString((exec.argumentsas{command?:string})?.command??)if(DANGEROUS.some(rere.test(cmd))){return{deny:This command matches a destructive pattern and is blocked.}}if(/\bgit\spush\b/.test(cmd)){return{ask:true}// 让它走人工确认}return{}}exportconstnamemy-permission-gateexportconstinject[tools]exportfunctionapply(ctx:Context){ctx.on(tools/pre-execute,async(exec,next):PromisePreToolDecision{constverdictinspect(exec)if(verdict.deny){return{kind:deny,reason:verdict.deny}}if(verdict.ask){return{kind:ask,reason:push-to-remote,// 审计用的原因displayReason:{en:Push to remote?,zh:要推送到远端吗},}}returnnext()// 没意见交给下游})// 一条不可撤销的底线无论谁放行都不允许在根目录做破坏性写操作ctx.tools.guard((exec){if(exec.namebash/\brm\s-rf\s\/\s*$/.test(String((exec.argumentsasany)?.command??))){returnRefusing to remove the filesystem root.}returnundefined})}这个版本比第 1 章多了三个要点要点为什么deny和ask分开用deny是「绝对不行」ask是「让用户决定」。ask会走ctx.approval只有allowed-once才继续而且没有回答方时直接拒绝displayReason做了本地化它是给用户看的文案而reason是审计用的稳定标识。两者的读者不同不要混用最后挂了一条guard**守卫没有 allow所以这条底线无法被任何后续监听器翻案。**这就是「单调」的价值10.5 实操三写一个模型适配器接入一家新的模型厂商需要实现LlmAdapter。它的接口设计得很精简——只有一个抽象方法必须实现。import{LlmAdapter}fromdeepseek-ai/dsh-llmimporttype{Context}fromdeepseek-ai/cordisclassMyProviderAdapterextendsLlmAdapter{/** 唯一的必填方法把一次调用变成原始分片流 */async*stream(options){constresawaitfetch(this.baseURL,{method:POST,headers:{...this.headers,User-Agent:attributionHeaders().UserAgent},body:JSON.stringify(toProviderPayload(options)),signal:options.signal,// 必须遵守})// 把提供方的 SSE 逐条翻译成 StreamChunkforawait(constevtofparseSSE(res.body)){yieldtoStreamChunk(evt)// block-start / *-delta / block-end / usage / finish}}/** 展示元信息 */providerInfo(provider){return{id:provider,name:My Provider}}/** 可发现模型列表给界面用的参考目录不是请求白名单 */asynclistModels(){return[{provider:this.id,id:my-model,name:My Model}]}/** 精确模型能力上下文容量、默认输出上限、推理档位、更新模式 */asyncresolveModel(provider,model){return{provider,id:model,name:model,context:{contextWindow:128_000}}}}exportconstnamemy-llm-providerexportconstinject[llm]exportfunctionapply(ctx:Context){ctx.llm.registerAdapter([my-provider],newMyProviderAdapter())}写适配器时要记住的约定第 7 章那张表的浓缩版必须做usage在finish之前发finish之后什么都不发工具调用的arguments全程保持原始 JSON 字符串每个 HTTP 请求带上attributionHeaders()把上下文溢出归一化为CONTEXT_WINDOW_EXCEEDED把无内容块的终止性 stop 映射为EMPTY_RESPONSE错误遵守options.signal不要做不要在适配器里实现重试——那是 agent 层的职责不要自己拼装块——用共享的BlockAssembler不要用提供方文本做错误路由——按 code不要把 catalog 当请求白名单——它只是参考目录适配器才是权威不要把私有元数据当共享词汇——它是不透明的只有「切分方式」是共享的官方还给了一个很实用的提醒**一次适配器调用就是一次提供方尝试。**agent 层的恢复会打开另一个持久、带编号的轮次直接调ctx.llm.stream()的调用方仍然只尝试一次。所以如果你在写一个直接调用层的东西要知道重试不是自动的。10.6 实操四让界面显示实时输出如果你在写 UI 或编辑器集成模式是这样的import{brandString}fromdeepseek-ai/dsh-brandimport{createUserMessage}fromdeepseek-ai/dsh-llmexportconstnamemy-uiexportconstinject[agents]exportfunctionapply(ctx){// 1) 实时 token 流给「打字机效果」用ctx.on(agent/assistant-stream,({frame}){if(frame.typechunkframe.chunk.typetext-delta){render(frame.chunk.text)}})// 2) 持久事实给「历史记录」「工具卡片」「审计」用ctx.on(session/event,(session,event){if(event.typetool/call)showToolCard(event.data)if(event.typetool/result)finishToolCard(event.data)if(event.typeturn/end)markTurnDone(event.data.reason)})// 3) 把输入送回去onUserInput(textctx.agents.get(brandString(client-session))?.followup(createUserMessage({content:[{type:text,text}],source:{kind:user},})))}关键官方给了两条不同用途的通道别用错·实时 token 呈现→agent/assistant-stream瞬态不写日志唯一的远程消费方是 Web Session-follow 适配器·可回放的持久数据→session/event持久可以重放、可以审计、可以做 trace官方原话「需要可回放 transcript 数据的 SDK 用户应当消费session/eventagent/*是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。」还有一个专门针对 Web Client 的细节如果要往里加「业务行」你要注册ConversationNodeDefinition加一个 keyed renderer。而且官方有一条要求**如果同一个插件事件族里的多条事件要组装成一个 Conversation Node那么这个族里的每条 startupdateresultresourceinterruption 事件都必须携带或独立推导出同一个稳定的业务 id。**理由很直接——不想让客户端靠「相邻关系」去猜归属也不想让它去扫历史。10.7 实操五写一个协议驱动「协议驱动」是把一个外部协议的对端接到ctx.agents上——它可能服务于界面也可能服务于自动化客户端。官方在实操手册里给了标准做法stdio 驱动拥有 stdout通过工厂创建或恢复 agent把协议请求映射成followup()或cancel()。底层提示词请求返回的是「持久的入队回执」它不会通过把MessageId和turn/end关联起来获得结果。整个 agent 的状态要单独发布。拆卸要用AgentHandle.dispose()这样 dispose 才能达到完全停稳。官方点名了一个完整的参考实现packages/acp/acp——它通过 ACPAgent Client Protocol的 JSON-RPC stdio 提供全新文本会话发出已提交的助手文本并为其拥有的 agent 注册一次性机器权限应答器。其中有一段关于「等一下还是持续观察」的说明很有价值自动化方法可以从回执等待到下一次 idle并概括这一显式拥有的区间UI 通常则会持续观察开放式事件流。10.8 实操六委派给子 agent「让主 agent 把一部分工作交给子 agent」是一个常见需求。它挂在一个独立的 seam 上。官方在归属位置表里的说法是子 agent 委派用ctx.subagents提供方注册表然后用dsh-tool-subagent向模型暴露一个已配置的提供方。可选的提供方包括提供方它做什么subagent-spawn-in-process在同一进程里新建一个子 agentsubagent-fork-in-process在同一进程里从当前会话 fork 出一个子 agentsubagent-acp通过 ACP 协议委派给另一个产品subagent-codex委派给 Codexsubagent-claude-code委派给 Claude Codesubagent-dsh-sdk通过 dsh 自己的 SDK 委派官方对ctx.subagents的职责描述是**提供方实现传输该服务还负责可选的、基于 Activation 的延续编排。**而且消费方的分工也很清楚tool-subagent选择「一次性」或「可延续」委派。tool-subagent-control传递后续消息。tool-ralph要求一条全新的结构化输出路由。反直觉子 agent 和主 agent 的关系在运行时的所有权和持久会话的血缘上是两件独立的事。官方在注册表里专门说明isOwnedBy(id, owner)判断的是「运行时所有权」它「与持久会话血缘无关并且在不相关的提供方复用一个 id 时依然无歧义」。所以恢复出来的一个 fork在运行时可能仍然是一个 root。10.9 加新行为的完整清单把这一章的内容压缩成一张可以照着走的清单**先确定归属。**用 10.1 的图找到「能力 → ctx 键」或「策略 → 事件」。**决定是否需要持久化。**这个事实要在重启后还在吗在 → 扩展SessionEventMap不在 → 用 Agent 事件。写插件骨架。nameinjectapply(ctx)。名字带前缀。**注册行为。**用ctx.xxx.register()或ctx.on(...)。不要忘记注册都是副作用会自动撤销。**写好 description。**如果你的能力是面向模型的description 就是提示词认真写。**遵守 signal。**所有异步工作都要响应exec.signal或轮次的 signal。用--patch挂上去跑一次。dsh web --patch ./你的/cordis.yml。**用--dump-config确认它真的加载了。**如果没生效先看它有没有出现在列表里。**验证撤销。**把 patch 拿掉确认行为完全消失、没有残留。10.10 这一章要带走的三句话这一章要带走的三句话**先问「挂在哪」再问「怎么写」。**90% 的迷路都发生在第一步。**用--patch开发用--dump-config验证。**这两个命令能省你大量时间。**注册与撤销是一对。**写完注册立刻想撤销会发生什么。这一章之后六个实操覆盖了本书提到的全部主要扩展点。做完之后建议回到第 1 章重新读一遍 Cordis 的五种分发模式——你会发现那些当时抽象的概念现在都对应着你刚写过的某行代码。如果你只想留一张纸在桌上那就是 10.9 节的「加新行为的完整清单」先问挂在哪再查该扩展点用什么分发模式最后确认副作用要不要可逆。内容整理自 DeepSeek Harness 官方仓库docs/architecture.zh.md及其引用的 Cookbook、开发文档。官方项目处于开发者预览阶段具体命令、包名与字段请以你手上的代码为准。