"插件"在 dsh 里到底是什么形状的一段代码?
一次"跟模型对话"是怎么从一段命令式代码变成N 个插件的装配结果的?
1. 未架构化的形态:我们要打破的对照组
// ❌ 未架构化 —— 强耦合
const openai = new OpenAI({ apiKey: process.env.KEY })
const answer = await openai.chat.completions.create({ model, messages })
for (const call of answer.tool_calls ?? []) {
await executeTool(call)
}
appendToLog(answer)
它把四件事强绑在一起:
- Provider 选择(
new OpenAI) - 凭证注入(
process.env.KEY) - 工具执行(
executeTool) - 日志记录(
appendToLog)
任何一件事想改,都要改这段代码。以及:
- 想换成 DeepSeek?改
new - 想加重试?包一层 try/catch,
answer传参 - 想让工具走审批?在
executeTool前面塞 if - 想把日志换成 event 流?改
appendToLog
这就是所谓的"雪球代码",每加一个能力都在同一个洞里塞。
2. dsh 的答案:把每个能力做成一个"插件"
Cordis(dsh 底层的 vendored 框架)只有两种插件形态,一张表看清:
| 形态 | 用途 | 导出形式 | 一句话记忆 | 例子 |
|---|---|---|---|---|
| Service 类插件 | 定义一种新能力,在 ctx 上开一个 key | export default class Xxx extends Service | “我要在 ctx 上摆一张桌子” | LlmRuntime / Tools / AgentLoop |
| 函数插件 | 消费已有能力,做配置/注册/挂载 | 命名导出 name / inject / Config / apply | “我不摆桌子,我往桌子上放菜” | llm-deepseek / tool-bash / tool-plan |
packages/CLAUDE.md的原文:
service packages default-export their service class; function plugins named-exportname / inject / Config / applyand have no default export. Mixing the forms makes the Loader discard the function plugin’s namespace.
两种形态是有严格分工的,混用会被 Loader 静默丢弃 inject——这是踩过坑总结出来的规矩(详见 docs/postmortem/0001-acp-default-export-drops-inject.md)。
3. Service 类插件是怎么"自动上桌"的
来看 vendor/cordis/src/service.ts 里的 Service 基类构造函数:
// vendor/cordis/src/service.ts:42
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
const tracker: Tracker = { associate: name, property: 'ctx' }
if (self[symbols.invoke]) {
self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
}
self.ctx = ctx
self.name = name
defineProperty(self, symbols.tracker, tracker)
self.ctx.reflect.provide(name, self, this[symbols.check]) // ← 关键一行
return self
}
一句话理解:super(ctx, 'llm') 调用完的瞬间,ctx.llm 就是 this。
ctx.reflect.provide(name, value) 内部做的事,看 vendor/cordis/src/reflect.ts:277:
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
// ...
const key = this.ctx[symbols.isolate][name]
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
if (this.store[key]) {
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
}
this.store[key] = impl
this.ctx.fiber.store![name] = impl
if (this.ctx.fiber.state === FiberState.ACTIVE) {
this.notify([name])
}
return async () => { // ← teardown
delete this.store[key]
const fibers = this.notify([name])
await Promise.allSettled(fibers.map(fiber => fiber.await()))
delete this.ctx.fiber.store![name]
}
}, `ctx.provide(${JSON.stringify(name)})`)
}
三件事,一件不少:
- 写入 store:把服务实例挂到
reflect.store上,同一命名空间里同名 service 会直接抛"重复注册"。 - notify 依赖者:所有
inject了这个名字的插件从 pending 变 active。 - 返回 teardown:一旦 fiber 卸载,反向删掉 store、通知依赖者、再删 fiber 本地缓存。注册本身即副作用。
3.1 LLM 能力的落地:LlmRuntime
以本系列的主线插件为例(packages/llm/llm/src/index.ts:284):
export class LlmRuntime extends Service {
private adapters = new Map<string, AdapterRegistration>()
private directory = new Map<string, LlmConfigurableProvider>()
private discoveries = new Map<
string,
(request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
>()
constructor(ctx: Context) {
super(ctx, 'llm') // ← 一句话就把 ctx.llm 摆好了
}
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { /* … */ }
registerConfigurableProviders(entries): DirectoryRegistrationHandle { /* … */ }
registerModelDiscovery(ns, discover): () => void { /* … */ }
stream(options: GenerateOptions): AsyncIterable<StreamChunk> { /* … */ }
// ...
}
export default LlmRuntime // ← Service 类插件必须 default export
设计上它同时是Service Definition:给出 LlmAdapter 抽象类(packages/llm/llm/src/index.ts:180)——
export abstract class LlmAdapter {
providerInfo(provider: string): LlmProviderInfo { /* … */ }
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { /* … */ }
listModels(_provider: string): Promise<readonly LlmModelInfo[]> { /* … */ }
resolveModel(provider, model, signal?): Promise<LlmResolvedModelInfo> { /* … */ }
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk> // 唯一必须实现的方法
}
Provider 插件(llm-deepseek)实现这个抽象类,Consumer 插件(agent-loop)调用 ctx.llm.stream(...)。这就是完整的 capability seam。
4. 函数插件是怎么"往桌子上放菜"的
函数插件的骨架非常干净(packages/llm/llm-deepseek/src/index.ts:41):
export const name = 'llm-deepseek'
export const inject = ['llm'] // 依赖声明
export const Config: z<Config> = z.object({
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['off', 'low', 'high', 'max']),
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
// ...
})
export function apply(ctx: Context, config: Config): void {
// 到 apply 被调用这一刻,ctx.llm 一定就绪
const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
ctx.llm.registerConfigurableProviders([
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
])
const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
// …
}
四件命名导出,一件不能多、一件不能少:
name— 用于日志/诊断的显示名inject— 声明必需服务(详见 03)Config— schemastery 校验器,Loader 在apply前自动跑apply(ctx, config)— 唯一副作用入口
Loader 认的就是这四件。混入 default export 会让 Loader 认成 object-plugin,name/inject/Config 全丢——这就是那条 postmortem 记录的事故。
5. Consumer 插件示范:tool-bash
只知道 ctx.tools,完全不知道 LLM 在哪(packages/shell/tool-bash/src/index.ts):
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export function apply(ctx: Context, config: Config) {
ctx.systemPrompt.section({
name: 'tool:bash',
order: 105,
text: '…',
})
ctx.tools.register(defineTool({
name: 'bash',
description: bashDescription(/* … */),
parameters: { command: { /* … */ }, description: { /* … */ } },
// …
}))
}
注意它 import 的都是抽象类型和辅助函数,从不 import LlmRuntime、import DeepSeekAdapter。这是"一切皆插件"最直接的验证:如果不同插件之间用 import 互相拉,那就是回到雪球了。
6. 从 cordis.yml 到一次工具调用:完整装配链
cordis.yml
│
▼
Cordis Loader
├─ ctx.plugin(llm) → new LlmRuntime(ctx) ★ ctx.llm 就位
├─ ctx.plugin(llm-deepseek) → apply(ctx, cfg): ctx.llm.registerAdapter([...], adapter)
├─ ctx.plugin(tools) → new Tools(ctx) ★ ctx.tools 就位
├─ ctx.plugin(tool-bash) → apply(ctx, cfg): ctx.tools.register(bashTool)
├─ ctx.plugin(agent-loop) → new AgentLoop(ctx) ★ ctx.agents 就位
└─ …session / sysprompt / credentials / settings / …
│
▼
用户输入:"帮我看下这个目录"
└─ ctx.agents.run(input)
└─ 组装 GenerateOptions → ctx.llm.stream(options)
└─ 触发 llm/stream waterfall → DeepSeekAdapter.stream()
└─ 模型返回 tool_call: bash('ls')
└─ ctx.tools.execute → 触发 tools/pre-execute → tools/execute → tools/post-execute
└─ ctx.shell.run('ls')
这条链上的每一层都是插件,从 ctx.plugin(X) 装进来的。任何一层想换掉、想插一层、想加一层拦截,都不动别人一行代码。
7. “为什么这样拆”
-
换 provider 只改一行 yml。
# 之前 llm-deepseek: config: { apiKeyEnv: DEEPSEEK_API_KEY } # 换成 pi-ai llm-pi-ai: config: { apiKeyEnv: PI_AI_API_KEY }agent-loop/tool-bash/session一个字都不用改。 -
加一层重试/审计不用改产品代码。
写一个新插件,inject: ['llm'],在apply里ctx.on('llm/stream', …)(详见 04 · 类型化事件)。装或不装完全由 yml 决定。 -
卸载一个插件不会留残骸。
ctx.tools.register(...)返回 disposer;插件 fiber 卸载时自动摘工具,agent-loop会通过 inject 联动 得知工具目录变了。详见 05 · 可逆副作用。
8. 源码具体实现
| 主题 | 文件 | 关键位置 |
|---|---|---|
| Service 基类构造函数 | vendor/cordis/src/service.ts | L42-59 |
ctx.reflect.provide 实现 | vendor/cordis/src/reflect.ts | L277-305 |
| Plugin 入口签名 | vendor/cordis/src/registry.ts | Plugin.Function / Plugin.Constructor / Plugin.Object |
| LLM Service Definition | packages/llm/src/index.ts | LlmRuntime L284、LlmAdapter L180 |
| DeepSeek Provider 插件 | packages/llm/llm-deepseek/src/index.ts | apply L200 |
| Bash Consumer 插件 | packages/shell/tool-bash/src/index.ts | apply |
| Service vs 函数插件规范 | packages/CLAUDE.md | “Plugin exports” 段 |
| 混用形式的 postmortem | docs/postmortem/0001-acp-default-export-drops-inject.md | 全文 |

870

被折叠的 条评论
为什么被折叠?



