ARTICLE · 1247855
DeepSeek-Harness 源码专题(6):没有插件市场,AI 现场写一个给自己装上
摘要:dsh 没有插件市场:模型缺工具先 cordis_inspect_list 查服务,cordis_define 只登记不执行,cordis_run 才激活;带浏览器半边的包卡审批,host 半边跑在 vm 沙箱加 guard 门面里;失败详情经 steer 回注让模型自修,重启清零。
接着数重复那篇的会话。47 个 deprecated 调用清到后半段,模型隔几轮就要统计一次进度:grep、wc、sort 拼一条 bash,跑完把数字抄进上下文,下一轮口径又对不上。它需要的是一个扫源码树、按文件出报表的工具,内置清单翻到底,没有。
有人说,让模型去能力市场装一个嘛。这个仓库里没有市场,也没有一行「安装」代码。dsh 的路径直接得多:自己写一个,写完当场装上。这篇拆这条链路:模型怎么给自己写插件,怎么装上,写错了怎么自己修。
先给结论:自装插件是三个工具接力。cordis_inspect_list 查可用服务,cordis_define 把代码登记成不可变 Package,只登记、不执行;cordis_run 才激活它。三步之间夹着用户审批、node:vm 沙箱和 guard 门面,出错时失败详情顺着 steer 通道回注给模型,让它自己改。我们把三步走完,最后算一笔账:为什么宁可让模型写代码,也不给它插件市场。
一、模型的第一步是查货架,不是写代码
写插件之前,模型得先知道手上有什么牌。dsh 递给它的第一件武器是一份只读的货架清单,连用法都写在工具描述里(tool-cordis/src/index.ts:43-48):
description: 'List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest ' + 'manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and ' + 'input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and ' + 'method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business ' + 'Service that Plugin code can call.',这段是 inspect_list 的自我介绍(tool-cordis/src/index.ts:43-48)。注意看这里:工具描述本身就是一条工作规程,「创建或修改 Package 之前先调我」,末尾还补一句「不要猜名字」。第二层钻取是 cordis_inspect_query(:60-94),拿 list 返回的精确 ID 去读服务的输入输出 schema。想知道 fs 有什么方法、返回什么形状,查一次就有。
回到我们的案例。模型查完货架,确认 fs 的读接口够用,可以动手了。这一步的意图:模型对运行时的认知全部来自查询,不来自猜测,后面每层报错都在延续这个思路。
货架查完了,代码写出来交到谁手里?
二、cordis_define:只登记,不执行
模型交上来的代码,在 define 这一步只是登记入册,一行都不会运行。交付格式在参数里写死:code.host 和 code.client 各是一段纯 JavaScript 函数体,返回一个 Cordis Plugin,没有 TypeScript,没有 import(tool-cordis/src/index.ts:187-195)。案例里的报表工具,host 半边浓缩出来大概这样(示意,非仓库源码):
// 模型交付的 host 半边(示意浓缩,非仓库源码)return { inject: ['fs'], apply(ctx) { const tool = harness.defineTool({ name: 'deprecated_report', execute: async () => { const fs = ctx.get('fs') // …(扫源码树统计 deprecated 调用,略) return { total: 47, byFile: { 'agent.ts': 9, /* … */ } } }, // …(output 声明 schema 与 render,略) }) ctx.tools.register(tool) },}这段是模型产物的骨架(code.host 参数示意)。inject 声明要用的服务,apply 里注册 deprecated_report 工具,执行时读文件、数调用、返回按文件的报表。二十行上下,一个插件。
宿主侧收到这份代码做四件事(cordis-host-runner/src/index.ts:151-202):name 和 purpose 非空、host/client 至少交一边(:154-158);两段代码各过一遍 precheckCode(:159-160),new Function 编译一次、不运行;新插件用 3-6 位小写前缀铸 ID,/^[a-z]{3,6}$/ 校验后 mintPluginId 补唯一后缀(:163-168);最后入册:
const packageId = CordisDynamicPackageId(this.registry.mintPackageId()) const definition: DynamicCordisDefinition = { packageId, name, purpose, ...request.code.host === undefined ? {} : { hostCode: request.code.host }, ...request.code.client === undefined ? {} : { clientCode: request.code.client }, } plugin.packages.set(packageId, definition)这段是 define 的入册动作(cordis-host-runner/src/index.ts:185-193)。注意看版本设计:每个版本铸一个新 packageId,pkg-N 递增,packages.set 只增不改,想改就再 define 一个新 Package 挂到同一个插件上。工具返回的回执也把话说死:Defined xxx/yyy; it is not running yet. Use cordis_run to activate this Package.(tool-cordis/src/index.ts:212-213)
登记完了,代码还躺在注册表里。谁把它拉起来?
三、cordis_run:装上它的那一步,审批卡在浏览器半边
run 才是「装上」:只有 host 半边的包当场激活,带浏览器半边的包先过用户审批。报表工具没有 client 半边,走快速路,run 检查 clientCode 为空就直接 activate(cordis-host-runner/src/index.ts:270-275)。
带 client 半边的包就不一样了。浏览器是用户的地盘,模型要往用户页面上渲染东西,dsh 要用户点头。判定在 :278-279:这个插件没批准过该 packageId,也没开过免审,run 就返回 awaiting-approval,同时 armRequest 把请求存进注册表、emit 'cordis/request-run' 交给 web 端弹给用户(:283-300)。用户批准后走 runHostHalf,记账在 :348-351:packageId 进 approvedClientPackages;勾了信任后续版本,clientVersionUpdatesApproved 置 true,此后同插件的版本升级不再要审批。
案例也走到这条路上:用户在 web 端看到报表工具,想让报表直接显示成页面表格,模型于是在同一个插件上 define 了带 client 半边的 pkg-2,run 返回 awaiting-approval,用户批准。
激活动作在 startHost(:883-915):createSandbox 造沙箱,evaluateHostCode 把函数体塞进 node:vm 求值,返回值先过形状检查(:898-904):
const sandbox = createSandbox(plugin.pluginId, { handle }) const evaluated = await evaluateHostCode(sandbox, hostCode, plugin.pluginId, this.resolved.vmTimeoutMs) if (!isPlugin(evaluated)) { throw new Error(evaluated === undefined ? 'the Host half returned `undefined` — did you forget `return`?' : 'the Host half must return a Plugin function or an object with apply(ctx)') }这段是激活的入口(cordis-host-runner/src/index.ts:898-904)。注意看错误文案:模型忘了 return,报错直接问它 did you forget return。vmTimeoutMs 默认 5000 毫秒,只罩得住同步段,异步代码出不了这个罩(sandbox.ts:244-247 注释自认)。形状过了,startHostHalf 把插件作为子 fiber 挂进 cordis-dynamic 组(:905-909),全部成功才 commitActivation 切换 currentPackageId(:981-992);失败的话,老版本原地不动。
四、vm 不是安全边界,guard 门面才是
沙箱的定位写在 sandbox.ts 模块注释第一段:文件、网络、进程、定时器的活全导向 cordis 服务,让包可检查、可销毁,但注释明说 is not containment,宿主域的辅助函数仍是逃生通道(sandbox.ts:1-12)。管住模型代码的活儿,大头落在 guard 的 ctx 门面上。先看沙箱里放了什么(sandbox.ts:129-138):
export function createSandbox(id: string, harnessExtras: Record<string, unknown> = {}): object { const sandbox = { ...nodeApiTraps(), console: taggedConsole(id), harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras }, // Web APIs absent from fresh vm contexts — made available so the model // can encode/decode base64 without Buffer (which is also absent). Host // closures over Buffer, never Buffer itself. btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'), atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'), // …(TextEncoder、TextDecoder 与 createContext 收尾略)这段是沙箱的内容物清单(sandbox.ts:129-138,收尾略)。第一行 nodeApiTraps() 展开是一张拒绝清单:require、定时器全家、fetch 全换成会抛错的函数(sandbox.ts:96-108)。注意看这里:每个拒绝都带着修法,require 没了报错教你 inject fs,setTimeout 没了报错教你用 cordis 定时器服务;数据值全局 process 直接是 undefined,免得抛错探针把 typeof process 探测炸掉(:90-95 注释)。
第二层包裹在启动时套上。guardedPlugin 把模型写的插件包一层,apply 的入参换成门面(guard.ts:812-819):
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown } return { ...plugin, apply(ctx: Context, config?: unknown) { return objectPlugin.apply(sandboxContext(ctx, reportFailure), config) }, }}这段是门面的套法(guard.ts:812-819)。模型代码以为自己在操作真 ctx,实际拿到的是 sandboxContext 造的 Proxy(:718-781):动词一张白名单,服务必须先在 inject 里声明,定时器还要额外声明 timer;往 ctx 上写属性当场拒绝(:771-773)。注入的服务又被 guardedService 包一层,返回值里混进 cordis Context 就报错(denyContext,:669-678)。工具出口同样收口:harness.defineTool 校验参数,execute 和 render 的返回值都过 cloneJson(guard.ts:551-592),VM 域的对象出不了沙箱,会话日志收到的永远是干净 JSON。
五、写错了怎么办:错误自己会找回来
前面三道卡管越界,第四道管笔误。模型交上来的二十行代码,十有八九死在语法错误、忘 return、名字碰撞上,这套链路对错误的处理是把诊断信息焊进报错文本,运行期的失败则直接回注给模型,修 bug 的活儿派回给闯祸的人。
define 期的语法错误最典型。precheckCode 用 new Function 把函数体编译一遍、不运行(sandbox.ts:212-222),编译失败退回的报错连修法都写好了(:190-193):出错的行原样附上;行里有 as,提示去掉类型标注;结尾写了 });,提示返回对象的结尾该是 } 不是 )。激活期的忘 return 在 §三 见过了。名字碰撞的教学在 lifecycle,插件注册的名字和前一个动态包撞了,报错给出完整操作序列(lifecycle.ts:36-41):
if (message.includes('already registered')) { throw new Error( `${message} — to REPLACE something an earlier dynamic package registered, first cordis_stop that package's id ` + '(find it with cordis_runtime_inspect what:「temporary」), then run the new version.', ) }这段是碰撞报错的教学文本(lifecycle.ts:36-41)。模型照着做就行,不需要理解 cordis 的注册机制。
案例里的 pkg-2 死在更晚的阶段:审批通过、host 半边跑起来了,浏览器里渲染 Slot 时抛了异常。这种失败走 steer 家族。渲染失败由 steerRenderFailure 处理(:1049-1068),把失败详情、出错的 Slot 名打成一条消息送回模型;通用的结算失败走 steerRunOutcome(:1019-1047),回注文本是它拼的(:1036-1041):
const returnedStatus = pending.requiresApproval ? 'awaiting-approval' : 'starting' text = `Cordis ${pending.mode} ${identity} failed after cordis_run returned ${returnedStatus}: ` + `${settled.reason}\n${formatErrorDetails(settled)}\n` + `currentPackageId: ${plugin?.currentPackageId ?? 'none'}\n` + `nextPackageId: ${plugin?.nextPackageId ?? pending.packageId}\n` + 'Inspect the failed Package, correct it on the same Plugin when needed, and retry the activation autonomously.'这段是失败结算的回注文本(cordis-host-runner/src/index.ts:1036-1041)。注意看最后一句,明确指示模型「检查失败的 Package、在同一个 Plugin 上改正、自主重试」。送信的通道就是《正在干的 agent 递条子,它凭什么不停手》拆过的 agent.steer,递条子的从用户换成了 runtime 自己。currentPackageId 和 nextPackageId 也一并附上,模型一眼能看出老版本还在跑、新版本没上去。运行期反复失败由 claimRuntimeFailure 按 key 去重(:1120-1127),同一个错误不会把会话刷屏。
于是 pkg-3 的剧情全自动:steer 回注进来,模型 cordis_inspect_self 读诊断,define 新 Package 修正渲染代码,cordis_run mode:「update」,这次没有审批环节,报表插件在页面上活了。
六、对账:为什么宁可写代码,不给插件市场
六个问题对完,这套机制的可对账面就齐了。
| 问题 | 答案 | 落点 |
|---|---|---|
| 缺工具怎么办 | 查货架、写代码、登记、激活四步 | tool-cordis :41-94 / :148-238 / :240-327 |
| define 会执行代码吗 | 不会,new Function 只编译 | sandbox.ts:212-222 |
| 什么时候要审批 | 包带 client 半边且未被批准过 | host-runner :278-283 |
| 插件跑在哪 | node:vm 沙箱 + guard ctx 门面 | sandbox.ts:129-145;guard.ts:718-781 |
| 写错了谁修 | 失败详情 steer 回模型,指示自主重试 | host-runner :1036-1041 |
| 重启后还在吗 | 不在,registry 是进程内存 | host-runner :1247-1249 |
最后一个属性单独说。动态插件只活在进程里,missingPluginMessage 原文是 no dynamic plugin 「…」 in this process — it may have been removed or lost on DSH restart(:1247-1249)。重启后注册表清零,报表插件连同所有临时插件一起消失。设计是故意的:模型现场写的代码用完即弃,不落盘、不跨会话累积,污染面恒定为零。归属纪律同样生效:define 和 run 都校验 sessionId,别的会话动不了你的插件,《管 agent 生死的注册表,一个 agent 都不造》的 registry 账本把它当普通资产管理。
至于为什么不给插件市场,答案是尺寸:装包引入一整棵依赖树,写代码交付一段可检查的函数体。函数体可以先编译再登记、先审批再运行、逐字检查有没有摸白名单外面的东西,这些关卡对压缩包全部失效。读过《AI 给自己写插件》的老读者,这里看到的是机制层:案例里模型会写插件,这篇拆的是系统凭什么敢让它写。
结语
回到会话。报表工具活了之后,那轮统计交给了 deprecated_report,一次调用拿回 total: 47 和按文件的分布,口径从此只有一份。用户后来关掉终端,第二天重开进程,插件没了,模型重新走一遍 cordis_define,函数体还在会话日志里,抄一遍就是。
这套机制给了 agent 一条少见的路:能力不够,不等着谁来开发,当场写一个。约束这条路的四道卡,审批(host-runner:278-283)、沙箱(sandbox.ts:129)、门面(guard.ts:753)、回注(:1043),在源码里各占一段。下一篇换一个方向:模型也能换。dsh 里换模型不用重启进程,下一次请求现取快照,llm 插件的 thunk 每请求重解析、快照冻结、原地重注册,专题下一篇《换模型不用重启,下一次请求现取快照》见。
你有没有哪一刻,希望手里的 agent 能自己长出一个工具来?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































