OpenCode 源码解析:从一次提问到工具调用的完整链路
OpenCode 是 SST 团队开源的 AI 编程助手,定位与 Claude Code 类似,但用 TypeScript + Bun 实现,并基于 Effect 与 Vercel AI SDK 构建了一套相当工程化的运行时。这篇以 sst/opencode 的真实源码为依据,逐机制拆解它"为什么是这样设计的"。
本文所有结论均来自 github.com/sst/opencode 默认分支 dev 的源码,并标注 packages/opencode/src/ 下的相对路径。代码块为示意代码(保留真实命名与结构,省略无关细节),而非逐行复制,目的是让你抓住控制流与数据流。
读完这篇,你应该能回答:用户敲下回车后,消息怎样流转、工具怎样被调用、上下文超限时怎样压缩、子 Agent 怎样被委派、MCP 工具怎样被发现、权限怎样在每一步被把关。
先纠正几个常见误解
在进入正文前,先把几个容易被"想当然"的点钉死,因为它们直接决定了你读源码时的导航方向:
- 没有
src/context/目录。上下文管理(压缩、溢出、摘要)全部住在src/session/下(compaction.ts/overflow.ts/summary.ts)。如果你按"经典分层"去找 context 目录,会扑空。 - bash 工具实际叫
shell(tool/shell.ts+tool/shell/),不存在bash.ts。 - 主循环函数是
runLoop(session/prompt.ts),而不是很多人猜测的session.run/session.chat。session/session.ts其实是 Session 的 CRUD 服务(create / getPart / remove / setTitle),不承载循环逻辑。 - “summary” 有三种,不要混淆:
session/summary.ts算的是文件 diff 摘要(基于 snapshot);对话压缩用的是packages/core/src/session/compaction.ts里的SUMMARY_TEMPLATE;而agent/prompt/summary.txt是"会话描述摘要 agent"的 prompt(用于生成会话标题/描述,像 PR description)。三者完全不同。 - MCP 工具命名用下划线连接
sanitize(serverName)_sanitize(toolName),不是mcp__server__tool这种双下划线前缀(那是 Claude Code 的风格)。 - “always allow” 权限只存内存,不落盘,重启即失效。要持久化只能手写
opencode.json的permission字段。 - Provider 基于 Vercel AI SDK(约 20 个
@ai-sdk/*包)+ 自研@opencode-ai/llm原生 HTTP 通路并行,不是从零自研协议适配。
记住这几条,下面读起来会顺很多。
整体架构与分包
OpenCode 是一个 monorepo,packages/ 下有 23 个分包。和"源码解析"最相关的是这几个:
| 分包 | 职责 |
|---|---|
packages/opencode | 核心 agent 运行时,本文主体 |
packages/core | 共享类型 / Schema / 工具(如 v1/config/*、session/compaction.ts、util/token.ts) |
packages/llm | 自研 LLM 抽象层 @opencode-ai/llm,与 AI SDK 并行的原生 HTTP 通路 |
packages/sdk | 对外 SDK @opencode-ai/sdk |
packages/server / packages/tui / packages/app / packages/desktop | 服务端 / TUI / 桌面 / UI |
packages/cli | CLI 命令分发 |
而核心运行时 packages/opencode/src/ 内部的目录职责如下:
| 目录 | 职责 |
|---|---|
session/ | 会话主循环、LLM 调用、压缩、消息、系统提示(最核心) |
tool/ | 工具定义、注册表、schema |
provider/ | Provider/Model 类型、消息与 schema 转换、鉴权 |
permission/ | 权限评估、命令前缀匹配 |
mcp/ | MCP server 接入、工具发现、OAuth |
agent/ | Subagent 定义、子 agent 权限派生、prompt 模板 |
server/ | HTTP API 路由、projectors、事件 |
config/ | 配置解析(agent / command / markdown / plugin / paths) |
storage/ | 文件系统 KV 持久化层 |
auth/ | opencode 账户鉴权(区别于 mcp/auth.ts 的 MCP token 存储) |
整体的调用层次大致是:
1 | |
注意
src/context/与src/subagent/这两个"看起来应该存在"的目录都不存在——上下文在session/,子 Agent 在agent/+tool/task.ts。
下面进入正题。
会话主循环与工具调用
这是 OpenCode 的心脏。理解了它,其余章节都是在不同侧面给它打补丁。
入口链:prompt → loop → runLoop
用户发一条消息,最终走到的是 session/prompt.ts 里的三层函数:
prompt(session/prompt.ts,effect 名"SessionPrompt.prompt"):对外入口,创建用户消息后调用loop。loop(session/prompt.ts,effect 名"SessionPrompt.loop"):通过state.ensureRunning(sessionID, lastAssistant(...), runLoop(sessionID))把runLoop交给运行状态机执行。runLoop(session/prompt.ts):真正的while (true)循环体,签名是(sessionID: SessionID) => Effect.Effect<SessionV1.WithParts>。
ensureRunning 定义在 session/run-state.ts,它的作用是保证同一 session 同一时刻只有一个循环在跑——如果上一次 assistant 还没结束就来了新输入,会按策略合并或排队,而不是并发触发两个 LLM 流。这是一个朴素但关键的并发护栏。
三层入口与运行状态机的协作关系如下:
flowchart TD
U[用户发送消息] --> P["prompt<br/>session/prompt.ts"]
P --> CM[创建用户消息并落库]
CM --> L["loop<br/>session/prompt.ts"]
L --> ER["state.ensureRunning<br/>run-state.ts"]
ER -->|已有循环在跑| QUEUE[按策略合并/排队]
ER -->|空闲| RL["runLoop<br/>session/prompt.ts"]
QUEUE --> RL
RL --> LOOP["while true 循环体"]
LOOP --> RET[返回 SessionV1.WithParts]关键点是 ensureRunning 用 lastAssistant(...) 作为幂等键:同一 session 的并发输入不会触发两个 LLM 流,而是被合并或排队。这是 Effect 结构化并发的典型用法。
一次循环的 17 步
runLoop 的循环体(session/prompt.ts)展开后是这样的一组步骤。我用伪代码把骨架画出来,注释里标注了来源文件:
1 | |
这 17 步里有几个值得停下来看的设计点。
为什么"取消息"要 filterCompactedEffect? 因为压缩不是物理删除旧消息,而是用一条 compaction 消息"覆盖"掉它。MessageV2.filterCompactedEffect 把这些被覆盖的历史过滤掉,让 LLM 看到的是"摘要 + 近期对话",而不是"摘要 + 已被摘要吃掉的原文"。这是压缩与循环解耦的关键——压缩只产生一条记录,循环每次按需过滤。
为什么 step === 1 才 fork 标题? 标题生成是一次独立的 LLM 调用(用 title agent),它和主对话并行跑,但只需要在循环开始时触发一次。fork 出去不阻塞主循环。
为什么 task 要先于工具调用处理? 因为有些"轮次"不是真的让模型继续生成,而是系统塞进去的子任务(subtask)或压缩任务(compaction)。tasks 是一个队列,pop 出来按类型分流,避免它们和普通生成混在一起。
工具是怎么注入到 LLM 调用里的
第 11 步的 SessionTools.resolve(session/tools.ts)是工具系统的总装车间。它把两类工具组装成一个 Record<string, AITool>:
1 | |
注意几个细节:
- 工具的
inputSchema不是直接拿原始 schema,而是先ToolJsonSchema.fromTool(effect Schema → JSON Schema),再ProviderTransform.schema按 model 适配(OpenAI 走sanitizeOpenAISchema、moonshot 去$ref兄弟关键字等)。 - MCP 工具的命名是
sanitize(serverName)_sanitize(toolName),和内置工具混在同一个 dict 里,对 LLM 来说没有区别。 - 每个 execute 内部最终调
item.execute(args, ctx),结果含attachments,并触发tool.execute.before/after插件钩子,再processor.completeToolCall把结果写回 part。
这份 tools 传给 handle.process → llm.stream(session/llm.ts)→ LLMRequestPrep.prepare(session/llm/request.ts)的 resolveTools → 最终 streamText({ tools, activeTools, toolChoice })(session/llm.ts)。
从工具定义到最终下发到 LLM 的完整链路:
flowchart TD
REG[registry.tools<br/>内置+插件] --> RES["SessionTools.resolve<br/>tools.ts"]
MCP[mcp.tools<br/>下划线命名] --> RES
RES --> J1[ToolJsonSchema.fromTool<br/>effect Schema→JSON Schema]
J1 --> J2[ProviderTransform.schema<br/>按 model 适配]
J2 --> J3[ai.tool 包装<br/>inputSchema+execute]
J3 --> TOOLS[Record<string, AITool>]
TOOLS --> HP[handle.process]
HP --> LS[llm.stream<br/>llm.ts]
LS --> PREP["LLMRequestPrep.prepare<br/>resolveTools"]
PREP --> ST["streamText tools/activeTools/toolChoice<br/>llm.ts"]
ST --> LLM[(LLM Provider)]
LLM -->|tool-call| EX[wrapExecute<br/>解码→执行→截断]
EX --> CTC[processor.completeToolCall<br/>结果写回 part]有个有趣的特例:Copilot 模型在没有工具、但历史里含 tool calls 时,会注入一个 _noop 工具(session/llm/request.ts)。这是因为某些模型在历史含 tool calls 时强制要求 tools 参数非空,_noop 是个不会被调用的占位工具,纯粹为了满足协议。
processor:消费 LLM 事件流
handle.process(session/processor.ts)是循环里真正干活的地方。它把 LLM 的流式事件归一化成 LLMEvent,再用 Stream.tap(handleEvent) 消费:
1 | |
handleEvent(processor.ts)按事件类型更新 parts(reasoning / text / tool),并向 V1/V2 双写发布事件。这里有几个机制值得拎出来:
流内压缩(needsCompaction)。Stream.takeUntil(() => ctx.needsCompaction) 会在流式生成中发现"上下文要爆了"时提前终止流,process 返回 "compact",外层 runLoop 收到后触发压缩再 continue。这意味着压缩不一定要等一轮结束——它可以在生成中途就被触发。这是个很聪明的点:与其等模型生成完一长串然后下一轮才发现超限,不如在流式过程中就监测 token 用量。
doom loop 检测。如果连续 DOOM_LOOP_THRESHOLD = 3 次出现"相同工具名 + 相同输入",processor 会触发 permission.ask({ permission: "doom_loop", ... })(processor.ts),把"是否继续"交给用户。这是防止模型陷入"反复读同一个文件"死循环的安全阀。
中断处理。Effect.onInterrupt(() => finalizeInterruptedAssistant) 保证用户 Ctrl+C 时,正在跑的 assistant 消息会被妥善标记为中断态,而不是悬空。中断后 halt(new DOMException("Aborted", "AbortError"))(processor.ts)。
权限拒绝传播。当 experimental.continue_loop_on_deny !== true 时,工具被拒会置 ctx.blocked,下一轮 process 返回 "stop",循环结束。默认行为是"被拒就停",避免模型在被拒后疯狂重试。
processor 内部对 LLM 事件流的消费与三条异常分支(流内压缩 / doom loop / 权限拒绝):
sequenceDiagram
participant RL as runLoop
participant P as processor.process
participant S as LLM.stream
participant H as handleEvent
participant A as 用户/TUI
RL->>P: handle.process(tools, model...)
P->>S: LLM.stream
S-->>H: LLMEvent (text/reasoning/tool-call...)
H->>H: 更新 parts + V1/V2 双写
H-->>P: needsCompaction=true
P->>S: Stream.takeUntil 提前终止
P-->>RL: return "compact"
Note over P: 生成中途触发压缩
H-->>P: 连续 3 次同工具+同输入
P->>A: permission.ask doom_loop
A-->>P: reply
H-->>P: ctx.blocked (工具被拒)
P-->>RL: return "stop"
Note over RL: 默认被拒即停双运行时:AI SDK vs Native
LLM.stream(session/llm.ts)有一个分叉,按条件返回两种运行时:
1 | |
- AI SDK 运行时(默认):调
streamText,fullStream经Stream.fromAsyncIterable→LLMAISDK.toLLMEvents(session/llm/ai-sdk.ts)归一化。toLLMEvents覆盖了 start / start-step / finish-step / finish / text-* / reasoning-* / tool-input-* / tool-call / tool-result / tool-error / error / abort / source 等所有事件。 - Native 运行时(opt-in):
LLMNativeRuntime.stream(session/llm/native-runtime.ts)走自研的@opencode-ai/llm原生 HTTP 通路,目前只支持 openai / opencode / anthropic(native-runtime.ts守门)。它通过Queue产出LLMEvent,工具由ToolRuntime.dispatch(tools, event)执行并回填。
为什么要两套?AI SDK 是通用层,覆盖广但有抽象损耗;Native 通路是为了对某些 provider 做更精细的控制(比如 Codex 的 WebSocket 传输、特定 beta header)。两套都归一化到同一个 LLMEvent 流,所以下游 processor 完全不感知差异——这是一个干净的抽象边界。
双运行时都归一化到 LLMEvent,这是 processor 不感知差异的关键边界:
flowchart LR
subgraph 调用方
HP[handle.process]
end
HP --> LS["LLM.stream<br/>llm.ts"]
LS --> BR{experimentalNativeLlm<br/>且 supported?}
BR -->|否 默认| AI["AI SDK 运行时<br/>streamText.fullStream"]
BR -->|是 opt-in| NV["Native 运行时<br/>@opencode-ai/llm HTTP"]
AI --> N1[toLLMEvents 归一化<br/>ai-sdk.ts]
NV --> N2[Queue 产出 LLMEvent<br/>native-runtime.ts]
N1 --> EV[(统一 LLMEvent 流)]
N2 --> EV
EV --> PC[processor 统一消费<br/>流内压缩/doom loop/中断]
Note1["覆盖广·有抽象损耗"] -.-> AI
Note2["精细控制·Codex WS/beta header"] -.-> NVstop 条件全集
把循环的所有退出路径汇总,方便对照:
| 退出条件 | 触发位置 | 含义 |
|---|---|---|
| 自然结束 | prompt.ts | lastAssistant.finish 且非 tool-calls 且无 tool calls 且 lastUser.id < lastAssistant.id |
result === "stop" | prompt.ts | ctx.blocked(权限拒绝)或 assistantMessage.error |
result === "structured" | prompt.ts | 命中结构化输出 |
result === "finished" + error | prompt.ts | content-filter 或结构化错误 |
| 步数软上限 | prompt.ts | step >= agent.steps,注入 MAX_STEPS_PROMPT 引导收尾(非硬 break) |
| 用户中断 | prompt.ts | onInterrupt → 标记中断态 |
compaction 返回 "stop" | prompt.ts | 压缩后仍超限(ContextOverflowError) |
注意"步数软上限"不是硬性的——它只是在最后一步注入一段提示让模型收尾,模型理论上仍可以继续。真正硬性的终止是 ContextOverflowError(压缩两次都救不回来)和用户中断。
整个主循环可以用一张图概括:
flowchart TD
START([runLoop while true]) --> A[取消息<br/>filterCompactedEffect]
A --> B{退出判定?}
B -->|自然结束/stop/structured| RET([break 返回])
B -->|否| C{task 分流}
C -->|subtask| SUB[handleSubtask]
C -->|compaction| CMP[compaction.process]
C -->|无 task| D{溢出检查<br/>isOverflow?}
SUB --> CONT1([continue])
CMP -->|stop| RET
CMP -->|continue| CONT1
D -->|是| OVF[compaction.create auto]
OVF --> CONT1
D -->|否| E[构造 assistant<br/>+ system + tools]
E --> P[processor.process<br/>消费 LLMEvent 流]
P --> R{process 返回}
R -->|structured| RET
R -->|finished+error| RET
R -->|stop| RET
R -->|compact| OVF2[compaction.create<br/>流内压缩]
OVF2 --> CONT1
R -->|continue| CONTSUB{流内事件}
CONTSUB -->|needsCompaction| OVF2
CONTSUB -->|doom_loop| ASK[permission.ask 用户]
ASK --> CONT1
CONTSUB -->|正常| A图里两条压缩路径(轮次边界的 isOverflow 与生成中途的 needsCompaction)最终都汇入 compaction.create,这是压缩与循环解耦的体现:压缩只产生一条记录,循环每次按需过滤。
Provider 抽象与工具协议
主循环只管"调模型",但"怎么调不同厂家的模型"是 Provider 层的事。
三层适配:工厂映射 + 定制 loader + transform
OpenCode 的 Provider 适配不是一个大 switch,而是三层叠加:
第一层:工厂映射 BUNDLED_PROVIDERS(provider/provider.ts)。它把 npm 包名动态 import 到 AI SDK 的 createXxx 工厂:
1 | |
动态 import 意味着未使用的 provider 不会进启动开销。
第二层:定制 loader custom(dep)(provider.ts)。每个 provider 有自己的怪癖,这里集中处理:
anthropic:注入 beta headerinterleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14(交错思考 + 细粒度工具流)。openai/xai:getModel走sdk.responses(modelID)(Responses API,而非 Chat Completions)。github-copilot:按 gpt 版本号选responses或chat。azure:解析resourceName,按useCompletionUrls选chat/responses/messages。amazon-bedrock:区域前缀注入(us./eu./apac./jp./au.)、bearer token / credential chain。
第三层:消息转换 provider/transform.ts(约 430 行)。normalizeMessages + message() 针对各 provider 做规范化:
- anthropic / bedrock:过滤空消息。
- claude:清洗
toolCallId。 - mistral:修复
tool→user序列。 - deepseek:给 assistant 补 reasoning。
- interleaved-thinking 字段处理。
还有一个 sdkKey(npm) 把 npm 包名映射到 AI SDK 的 providerOptions key。
Provider 与 Model 的类型
1 | |
注意 Info 里没有顶层 url / fetch 字段——baseURL 是通过 options.baseURL 传给底层 SDK 的(见 native-runtime.ts 中 baseURL: input.provider.options.baseURL)。Provider.Interface 暴露 list / getProvider / getModel / getLanguage(返回 LanguageModelV3)/ closest(模型回退)/ getSmallModel / defaultModel。
工具 schema 的定义与下发
工具参数用 effect Schema 定义,下发前要转成 JSON Schema 并按 model 适配。这条链路在 tool/json-schema.ts:
1 | |
带 WeakMap 缓存,避免重复转换。下发时(session/tools.ts)每个工具的 schema 还要过一遍 ProviderTransform.schema(input.model, ...):
- openai / azure:
sanitizeOpenAISchema(去掉 OpenAI 不支持的关键字)。 - moonshot / kimi:去除
$ref的兄弟关键字、合并 tuple items。 - 其他:透传。
这套"effect Schema → JSON Schema → model 适配 → AI SDK tool()"的四段式,让工具定义保持类型安全(effect Schema 编译期校验),同时兼容各模型对 JSON Schema 的不同支持程度。
tool/*.txt:给 LLM 看的说明书
这是 OpenCode 一个很有意思的约定:每个工具基本都有一个同名 .txt,作为给 LLM 看的使用说明,通过 import DESCRIPTION from "./xxx.txt" 以字符串导入,赋给 Tool.define 的 description 字段。
1 | |
其中 shell.txt 不是静态文本,而是模板,含 ${intro} / ${os} / ${shell} / ${workdirSection} / ${commandSection} / ${tmp} 占位符,由 ShellPrompt.render(shell/prompt.ts)按平台动态渲染——所以 shell 工具的描述会随 OS、shell 类型、工作目录变化。
task 工具的描述更进一步:registry.ts 会拼接 describeTask(agent),动态列出当前可用的子 agent 类型。这让 LLM 永远知道"现在能委派哪些 subagent",而不用硬编码。
工具系统
Provider 层讲完了工具的"下发",现在看工具本身的"定义与执行"。
Tool.Def 接口
1 | |
注册用 define(id, init)(tool.ts),返回带 .id 的 Effect<Info>。
execute 的包装:解码 → 执行 → 截断
wrap(tool.ts)是每个工具 execute 外面的统一外壳:
1 | |
三段式很清晰:先解码(参数不对直接 InvalidArgumentsError,不会传到 execute 内部),再执行,最后截断(过长输出落盘到文件,metadata 里记 truncated / outputPath)。截断是保护上下文的重要手段——一次 read 读出几万行不能直接塞回消息。
注册表
tool/registry.ts 的 layer 内各工具经 yield* XxxTool 再 Tool.init(xxx) 初始化。内置工具 builtin 数组(约 225-245 行):
1 | |
tools(model) 会按 model 过滤——比如 gpt-5 系用 ApplyPatchTool 替代 Edit/Write(因为 gpt-5 更适合用 patch 格式),websearch 按 provider 开关。插件工具通过 fromPlugin(id, def) 把插件 Zod args 转 Tool.Def,并扫描 {tool,tools}/*.{js,ts} 动态 import 自定义工具。
关键工具的 execute 要点
| 工具 | 文件 | execute 要点 |
|---|---|---|
| read | tool/read.ts(ReadTool,execute) | 读文件/目录,按行返回 <line>: <content>,默认 2000 行、单行截断 2000 字符;图片/PDF 作附件 |
| edit | tool/edit.ts | 参数 filePath/oldString/newString,字符串替换,带 diff 修正算法、LSP 诊断验证、按文件加 Semaphore 锁 |
| write | tool/write.ts | 参数 content/filePath,写入文件 |
| shell | tool/shell.ts(ShellTool) | web-tree-sitter 解析命令 AST,collect 扫描涉及目录/文件用于权限询问,再 run 执行;description 由 ShellPrompt.render 动态生成 |
| task | tool/task.ts(TaskTool,execute) | 参数 description/prompt/subagent_type/task_id?/command?/background?;经 Agent.Service.get(subagent_type) 取子 agent,派生子会话权限并运行 |
edit 的"按文件加 Semaphore 锁"值得注意:它防止模型对同一文件并发发多个 edit 导致冲突。shell 用 web-tree-sitter 解析命令 AST 是为了权限询问——它能从命令里提取出会涉及的目录/文件(重定向目标、路径参数),用于 external_directory 和 bash 权限的 pattern 匹配。
工具结果回填到消息
工具执行完,结果怎么回到消息里供下一轮 LLM 看?这条链路在 session/processor.ts:
1 | |
流式 tool-result(processor.ts)还会归一化图片附件(image.normalize),发布 SessionEvent.Tool.Success/Failed(V2 双写),再 completeToolCall。运行中状态由 updateToolCall(processor.ts)实时更新 title/metadata/input,而 Context.metadata(tools.ts)让工具能在执行过程中实时回写进度——这就是 TUI 里能看到"正在读文件…"的来源。
上下文管理与压缩
长对话必然会超 context window。OpenCode 的压缩机制是它工程化程度最高的部分之一。
双层持久化
消息存两层:
- 数据库层(主存储):
session/session.ts通过makeRuntime(Database.Service, Database.defaultLayer)建立运行时。消息读取走MessageV2.page({ sessionID, limit, before })(session/session.ts)分页查询 +MessageV2.stream(message-v2.ts)流式读取。底层是packages/effect-drizzle-sqlite/packages/effect-sqlite-node(drizzle + sqlite)。 - 文件 KV 层:
storage/storage.ts提供文件系统 KV(read/write/update/list/remove),存 session/message/diff/summary 文件元数据,带Migration机制。
为什么要两层?sqlite 负责高频读写和查询(分页、流式),文件 KV 负责可移植的元数据快照和迁移。这种"热数据进 DB,元数据进文件"的分层在需要导出/迁移场景下很实用。
overflow.ts:判定是否该压缩
只有 34 行的 session/overflow.ts 是压缩的判定门:
1 | |
关键点:
cfg.compaction?.auto === false时直接关掉自动压缩(用户可配置)。reserved取配置或min(20000, maxOutputTokens),给输出留 buffer。count优先用 provider 返回的tokens.total(精确),否则用 input+output+cache 之和。
compaction.ts:压缩流程
session/compaction.ts 的 Interface 暴露 isOverflow / select / process 等。核心是 process(processCompaction):
1 | |
几个设计要点:
select 按 tail_turns(默认 2)切分。DEFAULT_TAIL_TURNS = 2 保留最近 2 轮对话不压缩,preserveRecentBudget(区间 2000-8000)是保留近期对话的 token 预算。这保证模型始终能看到"刚刚发生了什么",而更早的对话被摘要替代。
摘要由独立 LLM 调用产生,不是某种启发式截断。压缩用的 prompt 模板是 packages/core/src/session/compaction.ts 的 SUMMARY_TEMPLATE + buildPrompt,含 Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context / Relevant Files 等 section。有 previousSummary 时走"更新锚定摘要",没有时走"新建锚定摘要"——所以摘要是增量演进的,不是每次重头来。
压缩后仍超限就报错。result === "compact" 表示压缩完还是超限,这时 ContextOverflowError,process 返回 "stop",主循环 break。这是真正的"上下文耗尽"硬终止。
mode: "compaction" 的消息。压缩产生的是一条特殊 mode 的 assistant 消息,它在 MessageV2.filterCompactedEffect 里会"覆盖"掉它压缩的那些历史——这就是前面循环里"取消息要 filterCompacted"的由来。
流内压缩 vs 主动压缩
压缩有两种触发路径:
- 主动压缩(循环第 6 步):
compaction.isOverflow为真 →compaction.create({ auto: true })→continue。这是轮次开始前的预防性压缩。 - 流内压缩(processor 内):
Stream.takeUntil(() => ctx.needsCompaction)在生成中发现超限 →process返回"compact"→ 主循环compaction.create({ auto: true, overflow: !handle.message.finish })→continue。这是生成中途的紧急压缩。
两者最终都走 process,区别在于"在轮次边界压缩"还是"在轮次中途压缩"。
压缩的内部流程(processCompaction)与触发路径:
flowchart TD
subgraph 触发
T1[主动压缩<br/>轮次边界 isOverflow] --> CR[compaction.create auto]
T2[流内压缩<br/>生成中途 needsCompaction] --> CR
end
CR --> P["compaction.process<br/>compaction.ts"]
P --> S1[completedCompactions<br/>取 previousSummary]
P --> S2["select 切分<br/>tail_turns=2"]
S2 --> H[head 待压缩]
S2 --> T[tail 保留近期]
P --> S3[plugin.trigger compacting]
P --> S4[buildPrompt<br/>新建/更新锚定摘要]
S4 --> LLM[(compaction agent LLM)]
LLM --> M[mode: compaction 消息]
M --> COV[filterCompacted 覆盖历史]
P --> R{压缩后是否超限?}
R -->|是| ERR[ContextOverflowError → stop]
R -->|否| C[return continue]
C --> AC[maybeAutoContinue 续接]token 计数:粗估 vs 精确
OpenCode 的 token 计数有两套:
- 粗估:
packages/core/src/util/token.ts(仅 5 行)——const CHARS_PER_TOKEN = 4; export const estimate = (input) => Math.max(0, Math.round(input.length / 4))。按"每 4 字符 ≈ 1 token"估算。用在compaction.ts估算消息体积、compaction.ts估算工具输出大小(prune 流程)、core/session/compaction.ts判断 prompt 是否超 context。 - 精确:provider 返回的
tokens(SessionV1.Assistant.tokens,含 input/output/reasoning/cache),isOverflow优先用tokens.total(overflow.ts)。
为什么要粗估?因为精确 token 要等 LLM 调用返回才知道,但压缩的 select(切分 head/tail)发生在调用之前,这时只能用粗估来判断"切多少去压缩"。粗估够用,因为 select 只是切个大致边界,真正的超限判定用 tokens.total。
三类 summary 再强调
回到开篇的提醒,这里把三种 summary 的职责钉死:
| 模块 | 文件 | 做什么 |
|---|---|---|
| 文件 diff 摘要 | session/summary.ts | 基于快照算 FileDiff[],存到 message.info.summary.diffs;summarize 在主循环第一步 fork 调用 |
| 对话压缩摘要 | packages/core/src/session/compaction.ts | SUMMARY_TEMPLATE 产生"锚定摘要",压缩历史对话 |
| 会话描述摘要 | agent/prompt/summary.txt + summary agent(agent.ts) | 像 PR description 写 2-3 句、第一人称、描述改动而非过程,用于标题/描述 |
session/summary.ts 的 summarize 流程:先把 summary 置零 → 发布 Session.Event.Diff → 若 config.snapshot === false 直接返回 → 否则取消息,调 computeDiff 算 FileDiff[],写入 target.info.summary.diffs 并 sessions.updateMessage。computeDiff 从消息 parts 的 step-start/step-finish 的 snapshot 字段取 from/to,调 snapshot.diffFull(from, to)。所以文件 diff 摘要依赖 snapshot 机制——每步开始结束都会快照工作区状态。
子 Agent / Subagent
OpenCode 的子 Agent 不是独立的运行时,而是复用 session 机制——这是理解它的关键。
task 工具:唯一的委派入口
子 Agent 的委派完全通过 task 工具(tool/task.ts)完成,没有别的路径。它的 execute 流程(task.ts):
1 | |
第 4 步是精髓:ops.prompt 就是前面讲的 session/prompt.ts 的 prompt 入口。子 Agent 跑的完全是同一个 runLoop,只是用了一个不同的 agent 定义(不同 prompt、不同模型、不同权限)。这意味着主循环的所有机制(压缩、工具调用、doom loop 检测)对子 Agent 全部适用,零额外实现成本。
子 Agent 委派的全过程,注意第 4 步复用了同一个 runLoop:
sequenceDiagram
participant FA as 父 agent runLoop
participant TT as task 工具
participant AS as Agent.Service
participant SS as sessions
participant CR as 子 runLoop
FA->>TT: tool execute(subagent_type, prompt)
TT->>TT: ctx.ask permission: task
TT->>AS: agent.get(subagent_type)
AS-->>TT: 子 agent 定义
TT->>SS: sessions.create parentID + 权限派生
SS-->>TT: nextSession
Note over SS: 继承 deny + external_directory
TT->>CR: ops.prompt 复用同一 runLoop
CR->>CR: 压缩/工具/doom loop 全适用
CR-->>TT: 最后一条 assistant 消息
alt background 模式
TT-->>FA: 异步返回
CR-->>FA: 完成后回调注入父会话
else 同步模式
TT-->>FA: waitForResult 返回结果
endbackground 模式需要环境变量 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,异步跑子 agent,完成后通过 BackgroundJob 回调把结果注入父会话。
Agent.Info 与内置 agent
1 | |
内置 agent(agent.ts 硬编码):
| agent | mode | 用途 |
|---|---|---|
build | primary | 默认主 agent |
plan | primary | 规划,禁编辑 |
general | subagent | 通用子 agent |
explore | subagent | 只读搜索专家 |
compaction | primary (hidden) | 上下文压缩 |
title | primary (hidden) | 标题生成 |
summary | primary (hidden) | 会话描述摘要 |
mode 字段决定 subagent 与 primary 的区别;defaultInfo(agent.ts)拒绝把 subagent 或 hidden agent 设为默认。
权限派生
子会话通过 sessions.create({ parentID, agent, permission }) 创建,带 parentID 关联父会话。权限派生函数 deriveSubagentSessionPermission(agent/subagent-permissions.ts):
1 | |
源码注释明确:“Parent agent restrictions only govern that agent; the subagent’s own permissions determine its capabilities”——父 agent 的限制只约束父 agent 自己,子 agent 的能力由它自己的权限决定。继承的只是 deny 和 external_directory。task.ts 还额外追加 childToolDenies:把 experimental.primary_tools 列出的工具对子 agent deny,防止子 agent 用主 agent 专属工具。
agent/prompt/*.txt 各 prompt 用途
| 文件 | 绑定 agent | 用途 |
|---|---|---|
explore.txt | explore | “file search specialist”,强调用 Glob/Grep/Read/Bash 搜索、返回绝对路径、不修改系统 |
compaction.txt | compaction | “anchored context summarization”,只总结历史、保留文件路径与标识符、不回答对话本身 |
summary.txt | summary | 会话摘要,像 PR description 写 2-3 句、第一人称、描述改动而非过程 |
title.txt | title | 标题生成,单行 ≤50 字符、不含工具名、用对话同语言 |
generate.txt | — | 用于 Agent.generate(LLM 自动生成 agent 配置) |
用户自定义 agent
两种途径:
- 运行时合并(
agent.ts):遍历cfg.agent,支持disable删除内置 agent,或覆盖model/variant/prompt/description/temperature/topP/mode/color/hidden/name/steps/options/permission,新 agent 默认mode: "all"、native: false。 - 文件加载(
config/agent.ts):load(dir)扫描{agent,agents}/**/*.md,解析 markdown frontmatter 为配置、正文为 prompt;loadMode(agent.ts)扫描{mode,modes}/*.md并强制mode: "primary"。
配置 schema ConfigAgentV1.Info(packages/core/src/v1/config/agent.ts)支持 model/variant/temperature/top_p/prompt/tools(已废弃,转permission)/disable/description/mode/hidden/options/color/steps/permission,未知键自动收入 options。
MCP 集成
MCP(Model Context Protocol)让 OpenCode 能接入外部工具服务器。它的实现集中在 mcp/ 目录。
三种 transport
mcp/index.ts 的 create(key, mcp) 按 mcp.type 分发:
1 | |
- local:用
StdioClientTransportspawn 子进程,适合本地 MCP server。 - remote:依次尝试
StreamableHTTPClientTransport再尝试SSEClientTransport,命中 auth 错误即停止尝试下一个。这是为了兼容不同远程 server 的传输实现。
连接用 connectTransport(带 timeout,失败自动 close transport)。连接后 client.getServerCapabilities()?.tools 为真才拉取工具定义 McpCatalog.defs——所以一个 MCP server 可以只提供 resources/prompts 而不提供 tools,OpenCode 会正确处理。
工具发现与动态注册
工具发现:McpCatalog.defs(client, timeout) → listTools → paginate 分页拉取 client.listTools(catalog.ts)。ToolListChangedNotificationSchema 通知触发重新拉取(index.ts)——MCP server 工具列表变了能动态感知。
工具注册:MCP.tools()(index.ts)遍历已连接 client,对每个 mcpTool 生成:
1 | |
sanitize(catalog.ts)把非 [a-zA-Z0-9_-] 字符替换为 _。convertTool(catalog.ts)用 AI SDK 的 dynamicTool 包装,传入 inputSchema 与 execute。注册到 session 工具表发生在 session/tools.ts。
调用路由:闭包捕获 client
convertTool 的 execute 内部直接 client.callTool({ name: mcpTool.name, arguments }, CallToolResultSchema, { timeout, signal, onprogress })(catalog.ts)。这里的 client 是创建工具时绑定的对应 MCP server 的 Client 实例——调用天然路由到正确 server,因为闭包捕获了 client。不需要额外的路由表。
从 MCP server 连接到工具调用的完整路径(闭包捕获 client 是路由的关键):
flowchart TD
subgraph 连接与发现
C[create key, mcp] --> T{mcp.type?}
T -->|local| CL[StdioClientTransport<br/>spawn 子进程]
T -->|remote| CR[StreamableHTTP→SSE 回退]
CL --> CONN[connectTransport timeout]
CR --> CONN
CONN --> CAP{getServerCapabilities<br/>.tools?}
CAP -->|是| DEF[McpCatalog.defs<br/>listTools 分页拉取]
CAP -->|否| SKIP[仅 resources/prompts]
end
DEF --> REG["MCP.tools 注册<br/>sanitize(server)_sanitize(tool)"]
REG --> SESSION[session/tools.ts 工具表]
SESSION --> LLM[(LLM)]
LLM -->|tool-call| EX[convertTool.execute 闭包]
EX --> CT["client.callTool<br/>路由到绑定的 server"]
CT --> SRV[(对应 MCP server)]
SRV --> RES[结果+附件]
RES --> CTC[completeToolCall 写回 part]
DEF -.->|ToolListChanged 通知| DEFsession/tools.ts 包装 MCP 工具时还做 schema 转换、结果截断、附件提取(image/resource → file part)。
完整 OAuth 流程
远程 MCP server 可能需要 OAuth。McpOAuthProvider(mcp/oauth-provider.ts)实现 MCP SDK 的 OAuthClientProvider 接口:
1 | |
完整流程(index.ts):
startAuth生成随机oauthState,起本地回调服务(McpOAuthCallback,mcp/oauth-callback.ts,5 分钟超时)。- 用
StreamableHTTPClientTransport连接,触发 401。 - 捕获
UnauthorizedError拿authorizationUrl。 open(url)打开浏览器。waitForCallback拿 code(回调服务按oauthState匹配 pending 请求,防 CSRF)。- 校验 state。
finishAuth用transport.finishAuth(code)换 token。
支持动态客户端注册(RFC 7591):无 clientId 时触发,结果经 saveClientInformation 持久化;若服务器不支持动态注册返回 needs_client_registration 状态提示用户配 clientId(index.ts)。
Token 持久化 mcp/auth.ts:存到 Global.Path/data/mcp-auth.json(权限 0o600),用文件锁 EffectFlock 保护读写。注意 MCP OAuth token 是落盘的,和权限的 always allow(只存内存)形成对比。
CLI 侧 cli/cmd/mcp.ts 提供 mcp add/list/auth/logout/debug 子命令,add 用 jsonc-parser 的 modify/applyEdits 写回 opencode.json(保留注释和格式)。
catalog.ts 还提供 paginate(分页拉取,处理 nextCursor、防重复 cursor、上限 1000 页)、fetch(通用拉取 prompts/resources 并按 sanitize(client):sanitize(name) 建索引)、prompts/resources。
权限机制
权限是贯穿所有工具调用的横切关注点。OpenCode 用一套"规则集 + 询问"模型实现。
ask / reply 三态
权限服务 permission/index.ts,接口 ask/reply/list。Action 类型是 "ask" | "allow" | "deny"(packages/core/src/v1/config/permission.ts)。
工具执行前通过 ctx.ask 触发询问。ctx.ask 的实现(session/tools.ts)把 agent 权限 + session 权限合并为 ruleset 传入:
1 | |
ask 流程(index.ts):
1 | |
事件流 permission.asked / permission.replied(index.ts),TUI / HTTP 端通过 reply 接口回送结果。
evaluate:后定义优先
evaluate(permission, pattern, ...rulesets)(index.ts)用 Wildcard.match 同时匹配 permission 名和 pattern,findLast 取最后一条命中规则(后定义优先),无命中默认返回 { action: "ask", permission, pattern: "*" }:
1 | |
"后定义优先"意味着配置里后面的规则可以覆盖前面的,这给了用户灵活的"先放行大类再限制小类"或反过来能力。
reply 三态
reply(index.ts):
reject:拒绝并连带取消同 session 其他 pending。once:仅本次放行。always:把always数组里的 pattern 作为 allow 规则 push 到内存approved。
ask 到 reply 的完整交互,以及三态 reply 的不同落点:
sequenceDiagram
participant T as 工具 execute
participant AS as ctx.ask
participant EV as Event.publish
participant U as TUI/HTTP
participant ST as State.approved 内存
T->>AS: ask permission, patterns
AS->>AS: evaluate findLast 后定义优先
alt action=deny
AS-->>T: DeniedError
else action=allow
AS-->>T: 放行
else action=ask
AS->>EV: publish Asked
AS->>AS: Deferred.await pendingReply
U->>EV: reply once/always/reject
alt once
EV-->>AS: 仅本次放行
else always
EV->>ST: approved.push allow
Note over ST: 只存内存 不落盘<br/>重启失效
else reject
EV-->>AS: DeniedError + 取消同 session pending
end
AS-->>T: 结果
endalways allow 只存内存
这是开篇强调过的点。reply 的 always 分支只 approved.push({ permission, pattern, action: "allow" }) 到内存 State.approved(index.ts),无任何文件写入。HTTP handler handlers/permission.ts 的 reply 仅调 svc.reply,无 config 写回。config.ts 中只有 permission 读取(546/562 行),未发现写回逻辑。
结论:always allow 是 InstanceState 级内存持久化,重启后失效。 用户若要持久化需手动在 opencode.json 的 permission 字段预设规则。这是个有意的设计选择——避免"用户随手 always 了一次危险操作然后就永久放行"。
shell 的命令前缀匹配
shell 工具的权限询问(tool/shell.ts)分两段:
1 | |
scan.always 是 BashArity.prefix(tokens).join(" ") + " *"——把命令按 arity 字典截断后加通配符。例如 git checkout main → always 规则 git checkout *。这样用户批一次 git checkout * 后,后续 git checkout 任何分支都不再问。
BashArity.prefix(permission/arity.ts):从最长前缀开始查 ARITY 字典,命中取前 N 个 token;未命中取第 1 个 token。ARITY 字典收录常见命令子命令 arity:
1 | |
flags 不计入 token,最长前缀优先。这个字典是手维护的常见命令表,覆盖了 git/npm/docker 等高频命令的子命令结构。
已知 permission key
packages/core/src/v1/config/permission.ts 定义已知 key:
1 | |
并允许任意扩展键。其中几个特殊的:
doom_loop:前面讲过,processor 检测到死循环时按工具名 ask。external_directory:shell 涉及工作区外目录时询问。task:子 Agent 委派时按subagent_type询问。- MCP 工具每次执行前
ctx.ask({ permission: key, patterns: ["*"], always: ["*"] })(session/tools.ts),key就是sanitize(server)_sanitize(tool)。
Permission.disabled(index.ts)额外提供"哪些工具被 deny *"的判定,把 edit/write/apply_patch 统一归到 edit 权限名——所以 deny edit 会同时禁掉 write 和 apply_patch。
权限规则的两层来源
- 配置 schema
ConfigPermissionV1.Info(core/v1/config/permission.ts):Record<permission, Rule>,Rule = Action | Record<pattern, Action>。 - 配置读取(
config/config.ts):从opencode.json读permission字段并mergeDeep,含OPENCODE_PERMISSION环境变量覆盖。 - agent 默认权限(
agent.ts):由Permission.fromConfig构造defaults(如*: allow、doom_loop: ask、read对*.envask、question/plan_*: deny),再与用户配置usermerge。 - 运行时内存
State.approved: PermissionV1.Rule[](index.ts):存 always 批准的规则。
fromConfig(index.ts):string 值展开为 pattern: "*";object 值按每个 key 作为 pattern(支持 ~、$HOME 展开)。所以配置可以很简洁:
1 | |
配置系统
前面各章节多次提到 opencode.json 与配置加载,这里把配置系统单独梳理。OpenCode 的配置不是单一的 schema 文件,而是分散在 config/ 目录下按领域加载(agent / command / markdown / plugin / mcp / paths),最后合并成一份运行时配置。
配置文件与查找
主配置文件是项目根的 opencode.json(cli/cmd/mcp.ts 的 add 用 jsonc-parser 的 modify/applyEdits 写回它,保留注释和格式)。配置读取在 config/config.ts,各领域读取后 mergeDeep 合并。环境变量可覆盖部分字段,例如 OPENCODE_PERMISSION 覆盖权限、OPENCODE_API_KEY 覆盖密钥。
分领域配置 schema
各领域配置 schema 集中在 packages/core/src/v1/config/,用 effect Schema 定义:
| 领域 | schema 文件 | 要点 |
|---|---|---|
| permission | permission.ts | Record<permission, Rule>,Rule = Action | Record<pattern, Action>,已知 key 见前文 |
| agent | agent.ts | ConfigAgentV1.Info,支持 model/variant/prompt/permission/disable/mode 等,未知键入 options |
| mcp | mcp.ts | Local(command+cwd+environment)与 Remote(url+headers+oauth)两种类型 |
| model | — | 模型列表、cost、limit、capabilities |
agent 默认权限的构造
前面权限章节提到 agent 默认权限由 Permission.fromConfig 构造(agent.ts)。它的流程是:先用 defaults 建立基线规则(*: allow、doom_loop: ask、read 对 *.env ask、question/plan_*: deny),再与用户在配置里写的 user 规则 merge。这意味着即使用户不配任何权限,OpenCode 也有一套安全的默认基线——危险操作(doom loop、读 .env、planning 委派)默认需要确认。
配置加载的合并语义
mergeDeep(config/config.ts)做的是深度合并而非浅覆盖,所以用户配置可以只写要覆盖的字段,其余继承默认。这与权限的"后定义优先"是两套机制:mergeDeep 负责配置文件间的字段级合并,evaluate 的 findLast 负责规则数组内的优先级。理解这两层的区别,才能准确预测一条权限规则最终如何生效。
扩展机制
OpenCode 的扩展点分散在几个层面,分别对应不同复杂度的定制需求。
自定义 Tool:插件机制
工具注册表 tool/registry.ts 除了内置工具,还通过 fromPlugin(id, def) 加载插件工具——把插件的 Zod args 转成 Tool.Def。此外它会扫描 {tool,tools}/*.{js,ts} 动态 import 自定义工具文件。这意味着添加一个自定义工具,只需在约定目录放一个导出 Tool.Def 的 .ts/.js 文件,无需改核心代码。
工具按 model 过滤的机制(tools(model))也让扩展工具能声明"只在某些模型下可用"——例如 gpt-5 系用 ApplyPatchTool 替代 Edit/Write。
自定义 Provider:插件目录
Provider 适配除了 BUNDLED_PROVIDERS 的 AI SDK 工厂映射,还有 plugin/ 目录下的 Provider 插件(azure.ts、xai.ts、github-copilot/copilot.ts、openai/codex.ts、openai/ws.ts 等),负责 OAuth 鉴权与特殊传输(如 Codex 的 WebSocket)。新增一个有特殊鉴权或传输需求的 provider,在 plugin/ 下加一个插件文件即可,不必动 BUNDLED_PROVIDERS。
自定义 Agent:两种途径
前文章节提到过,自定义 agent 有两种途径:运行时合并(cfg.agent 配置覆盖或 disable 内置 agent)和文件加载({agent,agents}/**/*.md 的 frontmatter + 正文 prompt)。markdown 方式尤其友好——一个 .md 文件就是一个 agent,frontmatter 写配置、正文写系统 prompt,降低自定义门槛。
MCP:外部能力接入
MCP 是最重量级的扩展点,允许接入任意外部工具服务器(local stdio / remote HTTP)。MCP 工具自动并入 session 工具表,对 LLM 而言与内置工具无异。cli/cmd/mcp.ts 的 mcp add 把 server 写入 opencode.json,OAuth token 持久化到 mcp-auth.json。
插件钩子
除了"加东西",扩展还能"改行为"。源码里多处 plugin.trigger(...) 钩子允许插件介入核心流程,例如:
experimental.session.compacting(compaction.ts):压缩前允许插件注入上下文或替换 prompt。experimental.compaction.autocontinue:控制压缩后是否自动续接。tool.execute.before/after:工具执行前后钩子。
这套钩子让插件能在不 fork 核心的前提下改变压缩、续接、工具执行等行为,是比单纯"加工具/provider"更深的扩展维度。
总结
把 OpenCode 的设计哲学提炼几条:
Effect 全栈。从
Effect.gen到Layer到Service,整个运行时建立在 Effect 之上。这带来了结构化并发(ensureRunning护栏、onInterrupt清理)、统一的错误通道、可测试的依赖注入。runLoop本身就是一个 effect,可被中断、可被排队。AI SDK 作为适配层,而非协议层。Provider 适配建立在 Vercel AI SDK 之上(约 20 个
@ai-sdk/*包),用工厂映射 + 定制 loader + transform 三层处理各 provider 怪癖。同时保留@opencode-ai/llm原生通路作为 opt-in,两者都归一化到LLMEvent流——下游 processor 完全不感知差异。流式归一化。无论 AI SDK 还是 Native,所有 LLM 事件都归一化成
LLMEvent,由processor统一消费。这让"流内压缩"“doom loop 检测”"中断处理"等横切逻辑可以跨 provider 复用,而不是每个 provider 各写一遍。子 Agent 即 session 复用。子 Agent 不另起运行时,
task工具通过sessions.create({ parentID })创建子会话,复用同一个runLoop。主循环的所有机制(压缩、工具、权限)对子 Agent 自动适用,零额外实现。权限派生只继承 deny 和 external_directory,子 agent 能力由自身权限决定。压缩是增量演进的锚定摘要。不是启发式截断,而是独立 LLM 调用产生结构化摘要(Goal/Constraints/Progress/…),有
previousSummary时增量更新。tail_turns=2保留近期对话,被压缩的历史用mode: "compaction"消息覆盖、循环里filterCompacted过滤。流内压缩让压缩能在生成中途触发,不必等轮次结束。权限即规则集。ask/allow/deny 三态、后定义优先、agent+session 权限合并。always allow 故意只存内存(重启失效),避免永久放行危险操作。shell 用
BashArity.prefix把命令截成可复用的前缀通配符,让"批一次git checkout *"成为可能。工具说明即文本文件。
tool/*.txt作为给 LLM 看的说明书,shell.txt是动态模板,task描述动态拼接可用 subagent。这让工具的使用说明可以随环境变化(OS、shell、可用 agent),而不用改代码。三层 summary 严格区分。文件 diff 摘要(
summary.ts)、对话压缩摘要(SUMMARY_TEMPLATE)、会话描述摘要(summary.txt)各司其职,不要混淆——这是读源码时最容易踩的坑。
OpenCode 的源码是少数把"AI 编程助手"这件事工程化得相当彻底的实现:Effect 提供骨架,AI SDK 提供广度,自研通路提供深度,而 session 复用、流式归一化、增量压缩这几个抽象让复杂度被很好地收敛。如果你正在设计自己的 agent 运行时,它的 runLoop 结构、LLMEvent 归一化、task 复用 session 这几处尤其值得借鉴。

