OpenCode V2 核心架构指南:用“薄容器 + 插件钩子“重构 packages/core
2026/9/7 19:35:22 网站建设 项目流程

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/opencodepackages/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

文档指定了核心服务的标准外形,要求向CatalogAccountV2AgentV2看齐。一个合格的核心服务模块应包含七个要素:

  1. 在模块顶部定义 schema 与 branded id(带品牌标识的 id 类型);
  2. 为预期内的失败定义带类型的Schema.TaggedErrorClass错误;
  3. 定义一个只包含小操作的Interface
  4. 暴露一个Context.Service(Effect 的服务令牌);
  5. 实现layer,内部使用私有内存状态;
  6. 暴露显式声明依赖的defaultLayer
  7. 以自导出形式收口模块:export * as Name from "./file"

源码印证:Catalog 就是范本

packages/core/src/catalog.ts 完整体现了上述形态。文件首行即自导出:

export * as Catalog from "./catalog"

随后定义领域类型(ProviderRecordDefaultModel)、复用 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)":操作只由getallavailabledefaultupdateremoveactivate这类小领域动词组成;注册与变更统一走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 则属于插件的职责"。在源码里,CatalogprojectModel()(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.updatemodel.updateaccount.activateagent.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" }) })

可用领域包括agentcatalogcommandintegrationreferenceskill六个命名空间(对应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 层需要四步配合:

  1. 把服务加入 boot 层依赖类型;
  2. 在 layer 内yield*取到该服务实例;
  3. add中为每个插件 effect 通过Effect.provideService提供该服务;
  4. 只有在不引入循环依赖时,才把该服务的 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批量注册全部内置插件——ConfigReferencePluginAgentPluginCommandPluginSkillPluginModelsDevPlugin、各Config*PluginProviderPlugins(30 余个 provider 插件)、ConfigExternalPluginConfigProviderPluginVariantPlugin(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只有三个操作:addremovewait。从实现看(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 服务的六步清单:

  1. 识别它拥有的状态;
  2. 识别调用方真正需要的操作;
  3. 识别哪些分支是策略或集成行为;
  4. packages/core中对状态与操作建模;
  5. 为策略/集成分支添加钩子;
  6. 在调用方完成渐进迁移之前,让旧包代码继续可用。

这条清单把"最小正确移植"(smallest correct port)落成了可执行的动作序列,第 6 步尤其重要——它允许 v2 与旧代码并行演进,而不是要求一次性切换。

六、Schema 与类型:Effect Schema 即公共契约

文档要求以 Effect Schema 作为对外契约:

  • id 使用 branded schema(带品牌类型,杜绝裸字符串/数字 id 的误用);
  • 领域数据用Schema.ClassSchema.Struct
  • 预期错误用Schema.TaggedErrorClass
  • 合理处复用 core 现有辅助工具,如DeepMutablestatics与整数 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询