OpenCode V2 核心架构指南:用"薄容器 + 插件钩子"重构 packages/core
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
本文基于 specs/v2/instructions.md 完整讲解 OpenCode v2 移植期间packages/core的开发规范:核心服务应当长什么样、插件钩子(Plugin Hooks)的约定与适用边界、内置插件的启动(Plugin Boot)组装方式,以及 schema、状态、事件与代码风格的统一约束。读完本文,你能按官方规范写出符合 v2 架构的领域服务(service)、为服务添加扩展钩子,并理解packages/opencode与packages/core之间的依赖边界。
一、方向:把行为从大服务里"移出来"
文档开宗明义给出了 v2 移植的总方向(Direction):把行为从大型应用服务中移出,交给插件实现;核心服务变成小型的、带类型(typed)的容器——它们拥有状态(own state)、暴露简单操作(expose simple operations),并在策略(policy)或集成(integration)逻辑真正属于它们的地方触发钩子。
目标形态可以概括为四条:
packages/core承载:领域 schema、带类型的错误(typed errors)、状态容器、事件,以及插件钩子契约(plugin hook contracts);- 插件(plugins)实现:provider 特定的、配置特定的、鉴权特定的、模型发现的,以及生成(generation)类行为;
- 服务按设计支持热重载(hot-reloadable):更新是细粒度的、可观测的,不需要拆掉整个进程;
packages/opencode会越来越薄:UI、服务端路由、CLI、存储胶水与遗留兼容代码应调用核心服务,而不是自己持有领域逻辑。
这条方向与仓库中另一个规格 specs/v2/catalog-config-plugin-lifecycle.md 呼应:catalog 与 config 的划分本身就是"状态归容器、行为归插件"的落地。
二、服务形态(Service Shape):七个要素与"哑容器" API
文档指定了核心服务的标准外形,要求向Catalog、AccountV2、AgentV2看齐。一个合格的核心服务模块应包含七个要素:
- 在模块顶部定义 schema 与 branded id(带品牌标识的 id 类型);
- 为预期内的失败定义带类型的
Schema.TaggedErrorClass错误; - 定义一个只包含小操作的
Interface; - 暴露一个
Context.Service(Effect 的服务令牌); - 实现
layer,内部使用私有内存状态; - 暴露显式声明依赖的
defaultLayer; - 以自导出形式收口模块:
export * as Name from "./file"。
源码印证:Catalog 就是范本
packages/core/src/catalog.ts 完整体现了上述形态。文件首行即自导出:
export * as Catalog from "./catalog"随后定义领域类型(ProviderRecord、DefaultModel)、复用 schema 包中的事件定义(export const Event = Catalog.Event),并给出Interface(catalog.ts#L47-L60):
export interface Interface extends State.Transformable<Draft> { readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined> readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]> } readonly model: { readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined> readonly all: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]> readonly default: () => Effect.Effect<ModelV2.Info | undefined> readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined> } }这正是文档所说的"优先哑容器 API(dumb container API)":操作只由get、all、available、default、update、remove、activate这类小领域动词组成;注册与变更统一走update(id, draft => ...)。Service则是标准的Context.Service声明(catalog.ts#L62):
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}AgentV2 同样遵循"Interface + Draft + Service"的三段式结构。
钩子在前,事件在后
文档还给出了容器内部两条时序规则:
- 提交变更之前调用钩子:当插件需要增强(enrich)、取消(cancel)或校验变更时;
- 提交变更之后发布事件:当其他服务或前端需要对该变更作出反应时。
并且明确警告:除非是领域不变量(domain invariant),否则不要把应用策略直接写进核心服务。文档举了一个例子——"模型 endpoint 继承的解析属于 catalog 的职责;决定注册哪些 provider 则属于插件的职责"。在源码里,Catalog的projectModel()(catalog.ts#L78-L97)正是在做前者:把 provider 级的api/request默认值合并进 model 记录,这是纯粹的领域归一化逻辑;而注册哪些 provider,则由packages/core/src/plugin/provider/目录下的 30 余个 provider 插件文件完成,两者分得泾渭分明。
三、插件钩子(Plugin Hooks):v2 的扩展边界
文档将插件定义为 v2 的扩展边界:当某段逻辑应当由集成方而非容器本身提供时,就向PluginV2.HookSpec增加钩子。
钩子的六条约定:
| 约定 | 说明 |
|---|---|
| 不可变输入 + 可变输出 | 钩子接收 immutable input 与 mutable output 两类数据 |
| Immer draft | 可变对象输出以 Immer draft 形式暴露,插件可直接写属性 |
cancel: boolean | 当插件可以阻止某次变更时,输出中必须包含取消标记 |
| 顺序触发 | 钩子按注册顺序串行执行,保证顺序确定性 |
| 领域导向命名 | 钩子名要像provider.update、model.update、account.activate、agent.generate这样围绕领域动词命名 |
| 小载荷、强类型 | 钩子载荷保持精简,并用核心 schema 做类型约束 |
适合用钩子的场景:注册 provider 与 model;应用由环境变量/账户/配置推导出来的启用(enablement)状态;转换 SDK/provider 选项;实现 agent 生成等"生成式"行为;在"选择默认值属于策略而非状态"时做默认值决策。
文档同时划了红线:不要把钩子当作传输层细节、UI 行为或兼容 shims 的垃圾场。
源码印证:transform 与 runtime 两类钩子
插件侧的 packages/plugin/src/v2/effect/README.md 展示了当前钩子体系的实际形态:状态型领域通过transform钩子参与状态重建,运行期操作通过 runtime 钩子拦截。例如 transform 钩子以 draft 风格直接修改领域对象:
yield* ctx.agent.transform((agent) => { agent.update("reviewer", (item) => { item.description = "Reviews code for regressions" item.mode = "subagent" }) })可用领域包括agent、catalog、command、integration、reference、skill六个命名空间(对应ctx.catalog.transform等)。runtime 钩子则拦截实时操作,例如为特定包注入 AISDK 的 SDK 实例:
yield* ctx.aisdk.sdk(Effect.fn(function* (event) { if (event.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) event.sdk = mod.createXai(event.options) }))README 明确说明"钩子按注册顺序串行执行,后注册的钩子能看到先注册钩子做的修改"(Hooks run sequentially in registration order),与规范中"顺序触发、确定性排序"的约定完全一致。此外,transform 领域支持按域reload(如ctx.catalog.reload()),即"重跑该域所有活跃 transform 并重新发布重建后的领域状态"——这正是下一节"细粒度重配置"目标在插件侧的入口。
四、插件启动(Plugin Boot):只做组合,不做策略
规范要求:内置核心插件由 packages/core/src/plugin/boot.ts 统一注册(文档写作时路径如此指定;从当前源码结构看,同一套启动组合现位于 packages/core/src/plugin/internal.ts,其PluginInternal层即承担 boot 职责)。当一个新的核心服务希望暴露给插件时,boot 层需要四步配合:
- 把服务加入 boot 层依赖类型;
- 在 layer 内
yield*取到该服务实例; - 在
add中为每个插件 effect 通过Effect.provideService提供该服务; - 只有在不引入循环依赖时,才把该服务的 default layer 加入 boot 的默认 layer。
internal.ts的源码是这四步的直接体现。它先声明Requirements联合类型(internal.ts#L37-L52)列齐所有可用服务:
export type Requirements = | AgentV2.Service | Catalog.Service | CommandV2.Service | Config.Service | EventV2.Service | ...layer 内部逐一yield*取服务,并在add中为每个插件 effect 批量provideService(internal.ts#L81-L106)。最后用State.batch批量注册全部内置插件——ConfigReferencePlugin、AgentPlugin、CommandPlugin、SkillPlugin、ModelsDevPlugin、各Config*Plugin、ProviderPlugins(30 余个 provider 插件)、ConfigExternalPlugin、ConfigProviderPlugin、VariantPlugin(internal.ts#L108-L123):
yield* State.batch( Effect.gen(function* () { yield* add(ConfigReferencePlugin.Plugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) // ... for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigProviderPlugin.Plugin) yield* add(VariantPlugin.Plugin) }), ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))文档对 boot 的定性只有一句但很关键:"保持 boot 为纯组合(composition only),它自身不应包含 provider、account、agent 或 model 的策略"。上面的代码里看不到任何策略分支,只有依赖装配与插件注册,正是这句话的执行标准。
插件生命周期底座
真正负责"加载/卸载/等待插件"的是 packages/core/src/plugin.ts 中的PluginV2服务,其Interface只有三个操作:add、remove、wait。从实现看(plugin.ts#L43-L126):
add通过KeyedMutex按插件 id 加锁,检测并拒绝加载循环(Plugin load cycle detected),为每个插件 fork 一个子 Scope,加载完成后发布Event.Added并唤醒wait的等待者;remove关闭对应子 Scope 完成卸载;wait借助Deferred挂起调用者,直到指定插件加载完成或返回其失败Exit。
这解释了为什么"热重载"不需要拆进程:替换一个插件就是"关旧 Scope + 注册新 effect",容器状态本身不动。
五、边界(Boundaries):core 与 opencode 的单向依赖
文档划定的边界规则:
packages/core不得 importpackages/opencode。如果核心需要某个类型或概念,先把领域形状(domain shape)在 core 中移动或重新建模;- 不要整包搬运遗留服务(Avoid moving legacy services over wholesale)。正确姿势是移植领域形状与容器 API,把具体行为留在钩子后面,交给插件实现。
文档还给出了移植一个 opencode 服务的六步清单:
- 识别它拥有的状态;
- 识别调用方真正需要的操作;
- 识别哪些分支是策略或集成行为;
- 在
packages/core中对状态与操作建模; - 为策略/集成分支添加钩子;
- 在调用方完成渐进迁移之前,让旧包代码继续可用。
这条清单把"最小正确移植"(smallest correct port)落成了可执行的动作序列,第 6 步尤其重要——它允许 v2 与旧代码并行演进,而不是要求一次性切换。
六、Schema 与类型:Effect Schema 即公共契约
文档要求以 Effect Schema 作为对外契约:
- id 使用 branded schema(带品牌类型,杜绝裸字符串/数字 id 的误用);
- 领域数据用
Schema.Class或Schema.Struct; - 预期错误用
Schema.TaggedErrorClass; - 合理处复用 core 现有辅助工具,如
DeepMutable、statics与整数 schema。
在数据结构上,优先使用Info对象作为持久化的领域记录;当 update API 需要在首次变更时创建记录时,为其添加静态empty(...)构造器。Catalog 源码正是这样做的:draft 更新中若 provider 尚不存在,就现场用ProviderV2.Info.empty(providerID)创建(catalog.ts#L112-L120):
update: (providerID, fn) => { let current = draft.providers.get(providerID) if (!current) { current = { provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, models: new Map<ModelV2.ID, ModelV2.MutableInfo>(), } draft.providers.set(providerID, current) } // ...文档最后强调:保持 schema 稳定且显式;除非配置形状本身就是领域模型,否则不要把 opencode 的配置形状当成 core 的领域形状。这一条防止了"配置对象即领域对象"的常见腐化路径。
七、状态与事件:细粒度重配置的目标
- 状态私有:状态保持在对服务层的私有访问范围内;当持久化或并发需要时,使用不可变替换(immutable replacement)或 Effect refs。Catalog 即通过
State.create<Data, Draft>管理私有数据与 draft 视图(catalog.ts#L105)。 - 事件只描述已提交的事实:为"已提交的领域变更"发布事件,而不是为"尝试中的变更"发布。事件命名应描述领域事实,例如
catalog.model.updated。 - v2 的目标是细粒度重配置(granular reconfiguration):一次模型更新应让依赖方只对这次模型更新作出反应,而不需要触发全局重载。
这正是第三节插件侧reload机制(ctx.catalog.reload()只重建 catalog 域)的动机——重载的最小单位是"一个领域",而不是"整个进程"。
八、代码风格:向最小正确移植收敛
文档给出的风格清单,逐条对应 Effect 生态的惯用法:
| 规则 | 用途 |
|---|---|
Effect.gen(function* () { ... }) | 服务组合 |
Effect.fn("Domain.method") | 公开服务方法(自带可观测性 span 命名) |
Effect.fnUntraced | 小型内部变更助手 |
yield* new ErrorClass(...) | 带类型的失败(配合 TaggedErrorClass) |
| 最小化 helper | 除非它命名了一个真实概念,否则不抽 |
禁用any | 除非既有插件边界确实要求 |
| 不做无依据的兼容代码 | 没有具体的持久化或外部消费者需求,就不写兼容层 |
结尾一句值得单独记下来:"优先最小正确移植。目标是让服务更容易被替换、更容易推理,而不是把旧架构搬进一个新包。"这既是风格要求,也是整个 v2 移植的验收标准。
小结
specs/v2/instructions.md 把packages/core的 v2 移植压缩为五个可检查的要点:服务是"schema + TaggedErrorClass + Interface + Context.Service + 私有状态 layer"的薄容器;行为外移为插件钩子,钩子顺序确定、载荷小且强类型;boot 层只做组合不做策略;依赖方向严格单向(core 不 import opencode);schema、状态与事件都服务于"细粒度、可热重载、可推理"的总目标。对照 packages/core/src/catalog.ts、packages/core/src/plugin.ts、packages/core/src/plugin/internal.ts 与 packages/plugin/src/v2/effect/README.md 的源码实现可以看到,这套规范并非纸面约束,而是当前代码库已经运行其上的实际架构。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考