Agent 不是代码是数据:14 字段 Schema 定义 7 个角色

场景:拆解 opencode 的 Agent 定义体系——一个 Schema 如何统一 7 个内置 Agent 与任意自定义 Agent 路径:packages/opencode/src/agent/agent.ts / packages/opencode/src/config/agent.ts / packages/core/src/v1/config/agent.ts

上篇我们把第三章串成"三源合流"的因果链。这篇进入第四章 Agent 系统——前 3 章定义了"命令",本章定义执行命令的"角色":默认 build、切 plan 只看不改、explore 只读探索,7 个内置角色 + 任意数量的自定义角色。

如果你要设计一个 AI Agent 系统——它需要有默认角色、能一键切换角色、还要允许用户自定义角色——你会怎么定义"一个 Agent"?

一个直观的想法是:class BuildAgent extends BaseAgent,每个角色一个类,用继承表达差异。build 能写文件、plan 只能看不能改、explore 只读——每次加一个角色就加一个子类。

opencode 的选择完全不同:Agent 不是一个类,是一份数据。它用 14 个字段的 Schema 定义一切——权限、模型、温度、prompt 全部塞进一个纯对象。7 个内置 Agent + 任意数量的用户自定义 Agent,共用同一份 Schema,从三个来源合流注册,再被同一个 get()/list() 接口发现。

Agent 定义体系全景:三源注册 → Info Schema → 消费发现

开篇:Agent 系统的全景图

Agent 决定什么:权限边界、大脑、和台词

一个 Agent 在 opencode 里决定三件事:

  • 权限边界permission):能碰哪些文件、能跑哪些工具、哪些要问你
  • 大脑参数model/temperature/topP/steps):用哪个模型、多激进、最多跑几步
  • 台词prompt):system prompt,决定它的行为风格

每次对话开始,系统都会问一个问题:"这轮用哪个 Agent?" 默认是 build——全能型。你输入 @plan 切换到计划模式,它就变成"只看不改"。Agent 不是功能模块,而是行为配置的最小单位:换一个 Agent,等于换一套权限 + 一套系统提示词。

7 个内置 Agent 及其模式分类

跨项目对比:Agent 是数据,还是文件,还是配置?

同一个问题,三家给出了三种完全不同的答案:

opencode Claude Code codex
Agent 的定义 Info Schema 数据对象 Markdown 文件 config.toml 内嵌
权限绑定 内置权限模型,必填 无权限概念 弱权限
自定义方式 config + 目录文件 + LLM 生成 .claude/agents/*.md config 内
LLM 动态生成 generate() 接口

opencode vs Claude Code vs codex 三角对比

差异的本质是:opencode 把 Agent 当作一等数据对象,并与权限系统深度绑定。Claude Code 的 subagent 只是"一段 prompt",没有独立的权限边界;codex 的 agent 只是 config 里的一行配置。而 opencode 的 Agent 是一个"有权限、有模型、有台词"的完整单位——因为你让 LLM 干活的边界,天然就是权限的边界。

章节路线图

第四章要拆 6 篇,本篇是入口:

  1. Agent 定义体系:Info Schema、注册与发现(本篇)
  2. 子代理 SubAgent 机制:权限隔离与调用链
  3. Agent 生成:LLM 动态创建 agent
  4. 编译器 Prompts:四个核心 txt 的设计意图
  5. 代理调度:primary vs subagent vs all 模式
  6. 上下文压缩策略:token 溢出时的降级

为什么需要 Agent 定义体系?

场景:Agent 是权限的最小单位

想象一个真实场景:你的团队要一个只读代码审查机器人——它能搜遍整个代码库找模式、查文件、看变更,但绝对不许改任何文件。你会怎么保证它"绝对写不了"?

靠自觉不行,靠约束才行。而 opencode 的答案,就是把这个约束做进 Agent 的定义里。先想清楚一个关键问题:Agent 为什么是权限的最小单位?

因为 opencode 的权限系统(permission)不是全局一份,而是每个 Agent 一份plan Agent 的 permission 里写着 edit: "*" deny——一切编辑工具拒绝;explore Agent 的 permission 是 "*": "deny" 加白名单:只有 grepgloblistreadbashwebfetchwebsearch 放行。同一份权限规则,换一个 Agent 就换一套行为边界。上面那个"只读审查机器人",在 opencode 里就是给 explore 这类 Agent 写一条白名单而已。

这就带来一个设计决策:权限必须挂在 Agent 上,而不是挂在代码上。用户说"我的 subagent 只能读文件",改的是一份数据(config),不是代码。

build / plan / explore 三种权限配置差异

naive 方案:class 继承的陷阱

如果用继承表达这些差异——class ExploreAgent extends BaseAgent,覆盖 permission 属性——会陷入两个问题:

一是组合爆炸。权限有几十个维度(读写改、grep、bash、webfetch、外部目录、plan 进入……),子类之间各种组合,继承树会越来越深。opencode 现在有 7 个内置 Agent,每个差异 1-2 个维度,class 方案勉强能撑;但用户一旦自定义几十个 Agent,每个都要 new 一个子类——这还没算上权限对象的重构:改一个权限维度,所有相关子类都要动。

二是用户无法扩展。用户想在项目里加一个"只读 React 专家" Agent,他不可能改 opencode 的源码去加子类。

所以作者选了组合而非继承:Agent 是数据,差异用数据表达,扩展用注册表达。

核心抽象:一个 Info 定义走天下

Info 的 14 个字段

整个 Agent 体系的心脏是 Info 这个 Schema,位于 packages/opencode/src/agent/agent.ts:35-55。骨架一眼扫过去就能抓住核心:

Info Schema 完整代码截图

14 个字段里 10 个可选,必填的只有 4 个:namemodepermissionoptions——其中 permission唯一没有默认值、必须显式给出的行为字段。为什么?因为 Agent 一旦被选中,系统立刻要回答"它能做什么",权限不能缺席。其余三个(name/mode/options)由注册路径自动兜底填充——config 里新建 Agent 时,代码会自动补一个 mode: "all" 的默认骨架(agent.ts:272-278)。

注意 mode 的三个取值:subagent(只能被别的 Agent 调用)、primary(能直接对话的主 Agent)、all(两者皆可)。这是第四章后面"代理调度"篇的核心——先记住这个枚举。

Info Schema 完整代码截图

为什么用 Schema 而不是 interface?

这里有个值得停下来看的点:Info 用的是 Effect Schema 的 Schema.Struct,而不是 TypeScript 的 interface

原因是——Schema 既能编译成类型,也能编译成运行时校验器。config 文件解析时,用户写的 agent 配置要经过 ConfigParse.schema() 严格校验(packages/opencode/src/config/parse.ts:35-72),不认识的 key 直接报错;这个校验用的就是 Schema 的运行时能力。interface 在 TS 类型层面消失,给不了这一步。

一个定义,两处受益:写代码时是类型,跑起来时是校验器。这和第六章讲工具系统时的 buildTool() + Zod Schema 是同一个思路——这是 opencode 反复出现的模式。

注册:三个来源如何合流

来源一:内置 7 个 Agent

第一个来源是代码里的硬编码,agent.ts:138-263 一口气注册了 7 个内置 Agent:

  • build:默认全能 Agent,mode: "primary",几乎放行所有工具
  • plan:计划模式,edit: deny,只允许写项目内 .opencode/plans/*.md 及全局数据目录下的 plans 路径
  • general:通用 subagent,禁用 todowrite
  • explore:只读探索 subagent,白名单 grep/glob/read/bash/webfetch/websearch
  • compaction:内部用,上下文压缩,hidden: true
  • title:内部用,生成会话标题,temperature: 0.5
  • summary:内部用,会话摘要,hidden: true

每个内置 Agent 的权限都是三层合并的结果:Permission.merge(defaults, Permission.fromConfig(...), user)——系统默认规则打底,Agent 专属规则覆盖,用户 config 规则最后兜底。

Permission.merge() 的实现极简(packages/opencode/src/permission/index.ts:211-213)——就是把几组规则数组扁平拼接,后注册的规则排在后面。没有复杂的合并算法,规则解析交给下游。这又是一个"数据驱动"的味道:合并是纯数据操作,不涉及任何类层次。

7 个内置 Agent 注册代码

来源二:config 里的 agent 字段

第二个来源是用户在 opencode.json 里写的 agent 字段。作者在这里做了一个关键设计——同一个字段,三种语义agent.ts:265-292):disable 删掉一个 Agent,没见过的 key 新建一个,已存在的 key 逐字段覆盖。

config agent 字段覆盖循环

用户想改内置 plan 的温度?写 agent.plan.temperature 覆盖。想加一个"只读 React 专家"?写 agent["react-expert"],系统自动补一个 mode: "all" 的默认骨架。想废掉 explore?写 agent.explore.disable: true

用户是配置的增量,不是配置的覆盖者——普通标量字段(prompt/mode/temperature/color...)用 ?? 保留已有值,只改用户明确写的那一项;optionsmergeDeep(深层递归合并,后者覆盖同名键),permissionPermission.merge(追加式合并)。模型字段特殊:config 里写的是字符串,要经 Provider.parseModel() 解析成结构化对象才能覆盖。

config agent 字段覆盖循环

来源三:Markdown 文件发现

第三个来源最妙——丢个文件就生效,零注册packages/opencode/src/config/agent.ts:11-32Glob.scan("{agent,agents}/**/*.md") 扫描项目里的 agent/agents/ 目录,每个 .md 文件解析 frontmatter 后就地注册:

Markdown 文件发现机制

一个 .md 文件就是一个 Agent:frontmatter(md 文件顶部的 --- 分隔元数据区)里写字段,正文就是 system prompt。文件名成为 Agent 名。同样,{mode,modes}/*.md 会被加载为 mode: "primary" 的 Agent(config/agent.ts:34-59)。

Markdown 文件发现机制

这三个来源在 config.ts 里被 mergeDeep(深层递归合并,后者覆盖同名键)层层合流(packages/opencode/src/config/config.ts:411,459-460,535-542):jsonc 配置 → agent 目录文件 → mode 目录文件 → result.mode 里的定义并入 result.agent。后面的覆盖前面的。

强制白名单:保护伞规则

合流之后还有一个兜底(agent.ts:294-308):强制每个 Agent 允许 external_directoryTruncate.GLOB 路径(临时文件目录),除非用户显式 deny。这个"默认安全+允许显式覆盖"的模式,保证压缩临时文件永远不会被权限系统拦在门外。

发现:get / list / defaultInfo

四个查询接口

定义好 Agent 之后,消费端通过四个接口访问(agent.ts:310-342):

list 排序与 defaultInfo 校验代码

list() 的排序值得注意:default_agent(默认 build)永远排第一,其余按名称字母序。这个排序直接决定了 TUI 里 @ 菜单的展示顺序——默认 Agent 第一个出现,用户不用翻找。

defaultInfo 的三重校验

defaultInfo() 找默认 Agent 时做了三重防御(agent.ts:326-338):

  1. 配置的 default_agent 不存在 → 抛错 default agent "X" not found
  2. 它是 subagent → 抛错 default agent "X" is a subagent
  3. 它被 hidden → 抛错 default agent "X" is hidden

为什么 subagent 和 hidden 不能当默认?因为默认 Agent 要直接面对用户对话——subagent 是给别的 Agent 调用的内部角色,hidden 是不想让用户看到的内部工具角色。这三重校验是"数据驱动"下失去类型安全后的补偿:数据可以自由组合,但语义边界必须显式守住。

list 排序与 defaultInfo 校验代码

InstanceState:会话级缓存

所有查询结果都包在一层 InstanceState.make<State>() 里(agent.ts:98)。这意味着 Agent 的注册表是按工作目录缓存的——每个项目有自己的 Agent 集合,切目录即重新计算。这保证了 .opencode/agent/ 目录里的项目级 Agent 只对当前项目生效,不会串到别的项目。

权衡:为什么是数据驱动而不是代码驱动

隐性对手:三份配置三份解析

把视角拉回开头。如果走 naive 的 class 继承路线,7 个内置 Agent 的权限差异要靠 7 个子类表达;用户自定义要改源码;@plan 的切换要写 switch-case。更致命的是每次加 Agent 都是一次编译 + 发布 + 回滚风险——线上跑的东西要跟着代码一起动。而数据驱动方案,新增一个 Agent 就是一次 git commit,甚至不用重启。收益清晰可见:

  • 新增一个 Agent:写一个 md 文件,或 config 里加一段,或让 LLM 生成——零代码
  • 改一个权限:改配置里的 permission,不改代码
  • 类型安全:Schema 编译成类型,运行时还能校验

代价:兼容层与历史包袱

但数据驱动不是免费的。代价有三:

  • Schema 必须有兼容层packages/core/src/v1/config/agent.ts:62-81normalize() 要把老字段映射到新字段:tools 字段(已废弃)转成 permissionmaxSteps(已废弃)转成 steps,未知 key 收进 options。一份数据要同时兼容新旧两代 API,代码比直接 interface 复杂得多。

ConfigAgentV1 normalize 兼容层代码 - 配置的合并顺序要小心。三个来源合流,后面的覆盖前面,用户如果不清楚优先级,会困惑"为什么我的 config 没生效"。 - 隐藏的内部 Agent 与用户 Agent 混在一个表里compactiontitlesummary 这些内部角色和用户角色共用 list(),只能靠 hidden: true 标记区分——这是"数据驱动"的丑陋面:数据不区分内部与外部,区分责任靠约定

一句话总结权衡

opencode 用"数据可组合"换来了"代码不用改",代价是"数据必须自解释、语义靠校验兜底"。 这是几乎所有"配置驱动"系统的同一个等式。

锚点:设计 Agent 系统的三个心智模型

心智模型一:Schema 即契约

Agent 的定义就是一份 Schema,不是一组类。 字段可扩展(options 兜底)、历史可兼容(normalize 迁移)、运行时可校验(ConfigParse.schema)。下次你做任何"可插拔角色/策略"系统,先问自己:能不能用一份 Schema 描述它?

心智模型二:注册合流,而非代码分散

内置、配置文件、目录扫描、LLM 生成——四种来源最终汇入同一个 Record<string, Info>。来源可以无限增加,合并逻辑不动。这是"开放-封闭原则"(OCP)的又一次实践——和第三章的命令三源合流是同一个模式。

心智模型三:语义边界靠校验守住

数据可以自由组合,但"subagent 不能当默认 Agent"、"hidden 不能当默认 Agent"这种语义边界,必须显式用代码校验。自由数据的边界,必须由校验器画出来。

锚点金句卡片

如果让你重写一个 Agent 定义体系,记住:权限别挂代码,挂数据;扩展别改源码,走注册;边界别靠自觉,靠校验。

如果你也在设计可插拔的角色/策略系统,评论区聊聊你踩过的坑。觉得有用点个赞,下篇 SubAgent 权限隔离更硬核 👇

下篇我们聊子代理 SubAgent 机制——exploregeneral 这些 subagent 被主 Agent 调用时,权限隔离和调用链是怎么走的,为什么 subagent 拿不到主 Agent 的全部工具。