260908_OpenCode源码分析

Haoliang Tang Lv3

OpenCode 是 SST 团队开源的 AI 编程助手,定位与 Claude Code 类似,但用 TypeScript + Bun 实现,并基于 Effect 与 Vercel AI SDK
构建了一套相当工程化的运行时。这篇以 sst/opencode 的真实源码为依据,逐机制拆解它 “为什么是这样设计的”。

本文所有结论均来自 github.com/sst/opencode 默认分支 dev 的源码,并标注
packages/opencode/src/ 下的相对路径。代码块为示意代码(保留真实命名与结构,省略无关细节),而非逐行复制,目的是让你抓住控制流与数据流。

读完这篇,你应该能回答:用户敲下回车后,消息怎样流转、工具怎样被调用、上下文超限时怎样压缩、子 Agent 怎样被委派、MCP 工具怎样被发现、权限怎样在每一步被把关。

1. 先纠正几个常见误解

在进入正文前,先把几个容易被 “想当然” 的点钉死,因为它们直接决定了你读源码时的导航方向:

  1. 没有 src/context/ 目录。上下文管理(压缩、溢出、摘要)全部住在 src/session/
    下(compaction.ts / overflow.ts / summary.ts)。如果你按 “经典分层” 去找
    context 目录,会扑空。
  2. **bash 工具实际叫 shell**(tool/shell.ts +
    tool/shell/),不存在 bash.ts
  3. **主循环函数是 runLoop**(session/prompt.ts),而不是很多人猜测的
    session.run / session.chatsession/session.ts 其实是 Session 的
    CRUD 服务(create / getPart / remove / setTitle),不承载循环逻辑。
  4. “summary” 有三种,不要混淆session/summary.ts 算的是文件 diff
    摘要
    (基于 snapshot);对话压缩用的是 packages/core/src/session/compaction.ts 里的
    SUMMARY_TEMPLATE;而 agent/prompt/summary.txt 是 “会话描述摘要 agent” 的
    prompt(用于生成会话标题 / 描述,像 PR description)。三者完全不同。
  5. MCP 工具命名用下划线连接
    sanitize(serverName)_sanitize(toolName)不是
    mcp__server__tool 这种双下划线前缀(那是 Claude Code 的风格)。
  6. “always allow” 权限只存内存,不落盘,重启即失效。要持久化只能手写 opencode.json
    permission 字段。
  7. Provider 基于 Vercel AI SDK(约 20 个 @ai-sdk/* 包)+ 自研
    @opencode-ai/llm 原生 HTTP 通路并行,不是从零自研协议适配。

记住这几条,下面读起来会顺很多。

2. 整体架构与分包

OpenCode 是一个 monorepo,packages/ 下有 23 个分包。和 “源码解析” 最相关的是这几个:

分包 职责
packages/opencode 核心 agent 运行时,本文主体
packages/core 共享类型 / Schema / 工具(如 v1/config/*session/compaction.tsutil/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
2
3
4
5
6
7
8
9
10
11
CLI (packages/cli)
└─ opencode 核心 (packages/opencode/src)
├─ index.ts 注册 effect command(SessionCommand / ProvidersCommand ...)
├─ server/ HTTP API + 事件投影
├─ session/ ★ 主循环 runLoop + processor + llm + compaction
│ ├─ tool/ 工具系统(read/edit/write/shell/task...)
│ ├─ provider/ AI SDK 工厂 + transform
│ ├─ agent/ subagent 定义与权限派生
│ ├─ permission/ ask/reply + arity 匹配
│ └─ mcp/ transport + catalog + oauth
└─ storage/ drizzle+sqlite(主) + 文件 KV(元数据)

注意 src/context/src/subagent/ 这两个 “看起来应该存在” 的目录都不存在 —— 上下文在
session/,子 Agent 在 agent/ + tool/task.ts

下面进入正题。

3. 会话主循环与工具调用

这是 OpenCode 的心脏。理解了它,其余章节都是在不同侧面给它打补丁。

3.1 入口链:prompt → loop → runLoop

用户发一条消息,最终走到的是 session/prompt.ts 里的三层函数:

  • promptsession/prompt.ts,effect 名
    "SessionPrompt.prompt"):对外入口,创建用户消息后调用 loop
  • loopsession/prompt.ts,effect 名 "SessionPrompt.loop"):通过
    state.ensureRunning(sessionID, lastAssistant(...), runLoop(sessionID))
    runLoop 交给运行状态机执行。
  • runLoopsession/prompt.ts):真正的 while (true) 循环体,签名是
    (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts>

ensureRunning 定义在 session/run-state.ts,它的作用是保证同一 session 同一时刻只有一个循环在跑 —— 如果上一次assistant 还没结束就来了新输入,会按策略合并或排队,而不是并发触发两个 LLM 流。这是一个朴素但关键的并发护栏。

三层入口与运行状态机的协作关系如下:

Mermaid diagram 1

关键点是 ensureRunninglastAssistant(...) 作为幂等键:同一 session 的并发输入不会触发两个 LLM流,而是被合并或排队。这是 Effect 结构化并发的典型用法。

3.2 一次循环的 16 步

runLoop 的循环体(session/prompt.ts)展开后是这样的一组步骤。我用伪代码把骨架画出来,注释里标注了来源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// session/prompt.ts
export const runLoop = Effect.fn("SessionPrompt.run")((sessionID) =>
Effect.gen(function* () {
let step = 0
while (true) {
yield* status.set(sessionID, { type: "busy" })

// 1. 取消息:过滤掉已被压缩覆盖的历史
const msgs = yield* MessageV2.filterCompactedEffect(sessionID)
const last = MessageV2.latest(msgs)

// 2. 退出判定(见下文"stop 条件全集")
if (shouldBreak(last)) break
step++

// 3. 第一步时 fork 出标题生成
if (step === 1) yield* Effect.fork(title(sessionID))

// 4. 取模型
const model = yield* getModel(...)

// 5. 处理"任务":subtask 或 compaction
const task = last.tasks.pop()
if (task?.type === "subtask") { yield* handleSubtask(...); continue }
if (task?.type === "compaction") {
const r = yield* compaction.process(...)
if (r === "stop") break
continue
}

// 6. 溢出检查 → 自动压缩
if (yield* compaction.isOverflow(...)) {
yield* compaction.create({ auto: true }); continue
}

// 7. 取 agent、步数软上限
const agent = agents.get(last.lastUser.agent)
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps

// 8. 注入提醒(如"你已到最后一步")
yield* SessionReminders.apply(...)

// 9. 构造 assistant 占位消息并落库
const msg = makeAssistantMessage(...)
yield* sessions.updateMessage(msg)

// 10. 创建 processor handle
const handle = yield* processor.create({ assistantMessage, sessionID, model })

// 11. 解析可用工具集
const tools = yield* SessionTools.resolve(...)

// 12. 组装 system prompt
const system = sys.skills(agent) + sys.environment(model) + instruction.system()

// 13. 转模型消息
const messages = yield* MessageV2.toModelMessagesEffect(msgs, model)

// 14. 真正调 LLM 流
const result = yield* handle.process({ user, agent, system, messages, tools, model, ... })

// 15. 处理返回值
if (result === "structured") break
if (result === "finished" && isError) break
if (result === "stop") break
if (result === "compact") {
yield* compaction.create({ auto: true, overflow: !handle.message.finish })
continue
}
}

// 16. 收尾:fork 修剪压缩记录
yield* Effect.fork(compaction.prune(sessionID))
return lastAssistant(sessionID)
})
)

这 16 步里有几个值得停下来看的设计点。

为什么 “取消息” 要 filterCompactedEffect 因为压缩不是物理删除旧消息,而是用一条 compaction 消息 “覆盖”掉它。MessageV2.filterCompactedEffect 把这些被覆盖的历史过滤掉,让 LLM 看到的是 “摘要 + 近期对话”,而不是 “摘要 + 已被摘要吃掉的原文”。这是压缩与循环解耦的关键 —— 压缩只产生一条记录,循环每次按需过滤。

为什么 step === 1 才 fork 标题? 标题生成是一次独立的 LLM 调用(用 title agent),它和主对话并行跑,但只需要在循环开始时触发一次。fork 出去不阻塞主循环。

为什么 task 要先于工具调用处理? 因为有些 “轮次”不是真的让模型继续生成,而是系统塞进去的子任务(subtask)或压缩任务(compaction)。tasks 是一个队列,pop出来按类型分流,避免它们和普通生成混在一起。

3.3 工具是怎么注入到 LLM 调用里的

第 11 步的 SessionTools.resolvesession/tools.ts)是工具系统的总装车间。它把两类工具组装成一个Record<string, AITool>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// session/tools.ts  SessionTools.resolve
export const resolve = Effect.fn("SessionTools.resolve")((input) =>
Effect.gen(function* () {
const tools: Record<string, AITool> = {}

// 1. registry 工具:内置 + 插件,用 AI SDK tool() 包装
for (const item of yield* registry.tools(input.model)) {
tools[item.id] = ai.tool({
description: item.description,
inputSchema: jsonSchema(ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))),
execute: (args) => wrapExecute(item, args, ctx),
})
}

// 2. MCP 工具:下划线连接命名
for (const [key, item] of Object.entries(yield* mcp.tools())) {
tools[key] = wrapMcpTool(key, item, ctx)
}

return tools
})
)

注意几个细节:

  • 工具的 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.processllm.streamsession/llm.ts)→
LLMRequestPrep.preparesession/llm/request.ts)的 resolveTools → 最终
streamText({ tools, activeTools, toolChoice })session/llm.ts)。

从工具定义到最终下发到 LLM 的完整链路:

Mermaid diagram 2

有个有趣的特例:Copilot 模型在没有工具、但历史里含 tool calls 时,会注入一个 _noop工具(session/llm/request.ts)。这是因为某些模型在历史含 tool calls 时强制要求 tools 参数非空,_noop是个不会被调用的占位工具,纯粹为了满足协议。

3.4 processor:消费 LLM 事件流

handle.processsession/processor.ts)是循环里真正干活的地方。它把 LLM 的流式事件归一化成LLMEvent,再用 Stream.tap(handleEvent) 消费:

1
2
3
4
5
6
7
8
9
10
11
// session/processor.ts  process(简化)
const stream = yield* LLM.stream({ ...input, tools })
const events = Stream.tap(stream, handleEvent)

yield* Stream.runDrain(events)

if (ctx.blocked || ctx.assistantMessage.error) return "stop"
if (ctx.needsCompaction) return "compact"
if (ctx.structured) return "structured"
if (ctx.assistantMessage.finish) return "finished"
return "continue"

handleEventprocessor.ts)按事件类型更新 parts(reasoning / text / tool),并向 V1/V2
双写发布事件。这里有几个机制值得拎出来:

流内压缩(needsCompactionStream.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 / 权限拒绝):

Mermaid diagram 3

3.5 stop 条件全集

把循环的所有退出路径汇总,方便对照:

退出条件 触发位置 含义
自然结束 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(压缩两次都救不回来)和用户中断。

整个主循环可以用一张图概括:

Mermaid diagram 5

图里两条压缩路径(轮次边界的 isOverflow 与生成中途的 needsCompaction)最终都汇入
compaction.create,这是压缩与循环解耦的体现:压缩只产生一条记录,循环每次按需过滤。

4. Provider 抽象与工具协议

主循环只管 “调模型”,但 “怎么调不同厂家的模型” 是 Provider 层的事。

4.1 三层适配:工厂映射 + 定制 loader + transform

OpenCode 的 Provider 适配不是一个大 switch,而是三层叠加:

**第一层:工厂映射 BUNDLED_PROVIDERS**(provider/provider.ts)。它把 npm 包名动态
import 到 AI SDK 的 createXxx 工厂:

1
2
3
4
5
6
7
8
9
10
// provider/provider.ts  (示意)
const BUNDLED_PROVIDERS = {
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then(m => m.createAnthropic),
"@ai-sdk/openai": () => import("@ai-sdk/openai").then(m => m.createOpenAI),
"@ai-sdk/google": () => import("@ai-sdk/google").then(m => m.createGoogleGenerativeAI),
"@ai-sdk/azure": () => import("@ai-sdk/azure").then(m => m.createAzure),
"@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then(m => m.createAmazonBedrock),
"@ai-sdk/google-vertex/anthropic": () => import(...).then(m => m.createVertexAnthropic),
// ...约 20 个
}

动态 import 意味着未使用的 provider 不会进启动开销。

**第二层:定制 loader custom(dep)**(provider.ts)。每个 provider有自己的怪癖,这里集中处理:

  • anthropic:注入 beta header
    interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14(交错思考 + 细粒度工具流)。
  • openai / xaigetModelsdk.responses(modelID)(Responses API,而非 Chat Completions)。
  • github-copilot:按 gpt 版本号选 responseschat
  • 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:修复 tooluser 序列。
  • deepseek:给 assistant 补 reasoning。
  • interleaved-thinking 字段处理。

还有一个 sdkKey(npm) 把 npm 包名映射到 AI SDK 的 providerOptions key。

4.2 Provider 与 Model 的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// provider/provider.ts  (示意)
const Model = Schema.Struct({
id: Schema.String,
providerID: Schema.String,
api: ProviderApiInfo,
name: Schema.String,
family: Schema.String.optional,
capabilities: ProviderCapabilities, // 能力集
cost: ProviderCost, // 计费
limit: ProviderLimit, // context / output 上限
status: ModelStatus,
options: Schema.Record({ key: Schema.String, value: Schema.Any }),
headers: Schema.Record(...),
release_date: Schema.String.optional,
variants: Schema.Array(Schema.String).optional,
})

const Info = Schema.Struct({ // 即 Provider 类型
id: ProviderV2.ID,
name: Schema.String,
source: Schema.Literal("env", "config", "custom", "api"),
env: Schema.Array(Schema.String), // 需要哪些环境变量
key: Schema.String.optional,
options: Schema.Record(...), // baseURL 藏在这里
models: Schema.Record({ key: Schema.String, value: Model }),
})

注意 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

4.3 工具 schema 的定义与下发

工具参数用 effect Schema 定义,下发前要转成 JSON Schema 并按 model 适配。这条链路在 tool/json-schema.ts

1
2
3
4
5
6
7
8
9
// tool/json-schema.ts  (示意)
export function fromSchema(schema: Schema.Top): JSONSchema7 {
const doc = Schema.toJsonSchemaDocument(schema) // effect → JSON Schema
return normalize(inlineLocalReferences(dropDefinitionsIfResolved(doc)))
}

export function fromTool(tool: Tool.Def): JSONSchema7 {
return tool.jsonSchema ?? fromSchema(tool.parameters) // 优先预计算
}

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 的不同支持程度。

4.4 tool/*.txt:给 LLM 看的说明书

这是 OpenCode 一个很有意思的约定:每个工具基本都有一个同名 .txt,作为给 LLM 看的使用说明,通过
import DESCRIPTION from "./xxx.txt" 以字符串导入,赋给 Tool.define
description 字段。

1
2
3
4
read.txt  edit.txt  write.txt  glob.txt  grep.txt
webfetch.txt websearch.txt skill.txt question.txt
todowrite.txt plan-enter.txt plan-exit.txt apply_patch.txt
shell.txt

其中 shell.txt 不是静态文本,而是模板,含 ${intro} / ${os} /
${shell} / ${workdirSection} / ${commandSection} /
${tmp} 占位符,由 ShellPrompt.rendershell/prompt.ts)按平台动态渲染 —— 所以
shell 工具的描述会随 OS、shell 类型、工作目录变化。

task 工具的描述更进一步:registry.ts 会拼接 describeTask(agent),动态列出当前可用的子
agent 类型。这让 LLM 永远知道 “现在能委派哪些 subagent”,而不用硬编码。

5. 工具系统

Provider 层讲完了工具的 “下发”,现在看工具本身的 “定义与执行”。

5.1 Tool.Def 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// tool/tool.ts
export interface Def<Parameters, M> {
id: string
description: string
parameters: Parameters // effect Schema.Decoder<unknown>
jsonSchema?: JSONSchema7 // 可选预计算
execute(args, ctx): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error): string
}

// tool/tool.ts
export interface ExecuteResult<M> {
title: string
metadata: M
output: string
attachments?: FilePart[]
}

// tool/tool.ts 工具执行上下文
export interface Context<M> {
sessionID, messageID, agent
abort: AbortSignal
callID?: string
extra?: unknown
messages: SessionV1.WithParts[]
metadata(input): Effect<void> // 实时回写进度
ask(input): Effect<void> // 触发权限询问
}

注册用 define(id, init)tool.ts),返回带 .idEffect<Info>

5.2 execute 的包装:解码 → 执行 → 截断

wraptool.ts)是每个工具 execute 外面的统一外壳:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// tool/tool.ts  wrap(示意)
function wrap(execute, parameters) {
return (args, ctx) =>
Effect.gen(function* () {
// 1. 解码:effect Schema 校验参数
const decoded = yield* Schema.decodeUnknownEffect(parameters)(args).pipe(
Effect.catchAll(() => Effect.fail(new InvalidArgumentsError()))
)
// 2. 执行原 execute
const result = yield* execute(decoded, ctx)
// 3. 截断过长输出
const truncated = truncate.output(result.output)
return { ...result, output: truncated.output, metadata: { ...result.metadata, ...truncated } }
})
}

三段式很清晰:先解码(参数不对直接 InvalidArgumentsError,不会传到 execute 内部),再执行,最后截断(过长输出落盘到文件,metadata 里记
truncated / outputPath)。截断是保护上下文的重要手段 —— 一次 read 读出几万行不能直接塞回消息。

5.3 注册表

tool/registry.tslayer 内各工具经 yield* XxxTool
Tool.init(xxx) 初始化。内置工具 builtin 数组(约 225-245 行):

1
2
invalid, question(条件), shell, read, glob, grep, edit, write,
task, fetch, todo, search, skill, patch, lsp(实验), plan(实验)

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 自定义工具。

5.4 关键工具的 execute 要点

工具 文件 execute 要点
read tool/read.tsReadTool,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.tsShellTool web-tree-sitter 解析命令 AST,collect 扫描涉及目录 / 文件用于权限询问,再 run 执行;description 由 ShellPrompt.render 动态生成
task tool/task.tsTaskTool,execute) 参数 description/prompt/subagent_type/task_id?/command?/background?;经 Agent.Service.get(subagent_type) 取子 agent,派生子会话权限并运行

edit 的 “ 按文件加 Semaphore 锁 “ 值得注意:它防止模型对同一文件并发发多个 edit 导致冲突。shell
web-tree-sitter 解析命令 AST 是为了权限询问 —— 它能从命令里提取出会涉及的目录 / 文件(重定向目标、路径参数),用于
external_directorybash 权限的 pattern 匹配。

5.5 工具结果回填到消息

工具执行完,结果怎么回到消息里供下一轮 LLM 看?这条链路在 session/processor.ts

1
2
3
4
5
6
7
8
9
10
11
12
// session/processor.ts  completeToolCall(示意)
function completeToolCall(toolCallID, output) {
return Effect.gen(function* () {
const part = yield* readToolCall(toolCallID) // 定位 message part
yield* session.updatePart(part.id, {
status: "completed",
input, output, metadata, title,
time: { start, end },
attachments,
})
})
}

流式 tool-result(processor.ts)还会归一化图片附件(image.normalize),发布
SessionEvent.Tool.Success/Failed(V2 双写),再 completeToolCall。运行中状态由
updateToolCallprocessor.ts)实时更新 title/metadata/input,而
Context.metadatatools.ts)让工具能在执行过程中实时回写进度 —— 这就是 TUI 里能看到 “正在读文件…” 的来源。

6. 上下文管理与压缩

长对话必然会超 context window。OpenCode 的压缩机制是它工程化程度最高的部分之一。

6.1 双层持久化

消息存两层:

  • 数据库层(主存储)session/session.ts 通过
    makeRuntime(Database.Service, Database.defaultLayer) 建立运行时。消息读取走
    MessageV2.page({ sessionID, limit, before })session/session.ts)分页查询 +
    MessageV2.streammessage-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,元数据进文件” 的分层在需要导出 / 迁移场景下很实用。

6.2 overflow.ts:判定是否该压缩

只有 34 行的 session/overflow.ts 是压缩的判定门:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// session/overflow.ts  (示意)
const COMPACTION_BUFFER = 20000

function usable({ cfg, model, outputTokenMax }) {
// 模型有 limit.input 则 input - reserved,否则 context - maxOutputTokens
const reserved = cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, maxOutputTokens)
return model.limit.input ? model.limit.input - reserved : model.context - maxOutputTokens
}

export function isOverflow({ cfg, tokens, model, outputTokenMax }) {
if (cfg.compaction?.auto === false || model.context === 0) return false
const count = tokens.total ?? (input + output + cache)
return count >= usable({ cfg, model, outputTokenMax })
}

关键点:

  • cfg.compaction?.auto === false 时直接关掉自动压缩(用户可配置)。
  • reserved 取配置或 min(20000, maxOutputTokens),给输出留 buffer。
  • count 优先用 provider 返回的 tokens.total(精确),否则用 input + output + cache 之和。

6.3 compaction.ts:压缩流程

session/compaction.tsInterface 暴露 isOverflow /
select / process 等。核心是 processprocessCompaction):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// session/compaction.ts  process(骨架)
export const process = Effect.fn("SessionCompaction.process")((input) =>
Effect.gen(function* () {
// 1. 取 compaction agent 与模型
const agent = agents.get("compaction")
const model = yield* getModel(...)

// 2. 找出历史已完成压缩,隐藏其 user/assistant,取 previousSummary
const { history, previousSummary } = completedCompactions(input.history)

// 3. 切分 head(待压缩)/ tail(保留近期)
const { head, tailStartId } = yield* select({
history, tailTurns: DEFAULT_TAIL_TURNS /* 2 */, ...input
})

// 4. 插件钩子:允许注入上下文或替换 prompt
const pluginCtx = yield* plugin.trigger("experimental.session.compacting", ...)

// 5. 构建压缩指令(有 previousSummary 则"更新锚定摘要",否则"新建")
const prompt = buildPrompt({ previousSummary, context: pluginCtx })

// 6. 创建 mode: "compaction" 的 assistant 消息,跑 LLM(tools: {}, system: [])
const msg = makeCompactionMessage(...)
const handle = yield* processor.create(...)
const result = yield* handle.process({ tools: {}, system: [], messages: [prompt], ... })

// 7. 压缩后仍超限 → ContextOverflowError → 返回 "stop"
if (result === "compact") {
yield* Effect.fail(new ContextOverflowError())
return "stop"
}

// 8. auto 模式下可选 replay 或注入 "Continue if you have next steps..."
if (input.auto && result === "continue") {
yield* maybeAutoContinue(...)
}
return "continue"
})
)

几个设计要点:

selecttail_turns(默认2)切分DEFAULT_TAIL_TURNS = 2 保留最近 2 轮对话不压缩,preserveRecentBudget(区间2000-8000)是保留近期对话的 token 预算。这保证模型始终能看到 “刚刚发生了什么”,而更早的对话被摘要替代。

摘要由独立 LLM 调用产生,不是某种启发式截断。压缩用的 prompt 模板是
packages/core/src/session/compaction.tsSUMMARY_TEMPLATE +
buildPrompt,含 Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context
/ Relevant Files 等 section。有 previousSummary 时走 “更新锚定摘要”,没有时走 “新建锚定摘要”——
所以摘要是增量演进的,不是每次重头来。

压缩后仍超限就报错result === "compact" 表示压缩完还是超限,这时
ContextOverflowErrorprocess 返回 "stop",主循环 break。这是真正的 “上下文耗尽”
硬终止。

mode: "compaction" 的消息。压缩产生的是一条特殊 mode 的 assistant 消息,它在
MessageV2.filterCompactedEffect 里会 “覆盖” 掉它压缩的那些历史 —— 这就是前面循环里 “取消息要 filterCompacted” 的由来。

6.4 流内压缩 vs 主动压缩

压缩有两种触发路径:

  1. 主动压缩(循环第 6 步):compaction.isOverflow 为真 →
    compaction.create({ auto: true })continue。这是轮次开始前的预防性压缩。
  2. 流内压缩(processor 内):Stream.takeUntil(() => ctx.needsCompaction)
    在生成中发现超限 → process 返回 "compact" → 主循环
    compaction.create({ auto: true, overflow: !handle.message.finish })
    continue。这是生成中途的紧急压缩。

两者最终都走 process,区别在于 “在轮次边界压缩” 还是 “在轮次中途压缩”。

压缩的内部流程(processCompaction)与触发路径:

Mermaid diagram 6

6.5 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 返回的 tokensSessionV1.Assistant.tokens,含
    input/output/reasoning/cache),isOverflow 优先用
    tokens.totaloverflow.ts)。

为什么要粗估?因为精确 token 要等 LLM 调用返回才知道,但压缩的 select(切分 head / tail)发生在调用之前,这时只能用粗估来判断
“切多少去压缩”。粗估够用,因为 select 只是切个大致边界,真正的超限判定用 tokens.total

7. 子 Agent / Subagent

OpenCode 的子 Agent 不是独立的运行时,而是复用 session 机制 —— 这是理解它的关键。

7.1 task 工具:唯一的委派入口

子 Agent 的委派完全通过 task 工具(tool/task.ts)完成,没有别的路径。它的 execute
流程(task.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// tool/task.ts  TaskTool execute(骨架)
execute: (params, ctx) =>
Effect.gen(function* () {
// 1. 权限检查:按 subagent_type 询问
yield* ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
}) // 104-114,可被 ctx.extra?.bypassAgentCheck 跳过

// 2. 取子 agent 定义
const next = yield* agent.get(params.subagent_type)

// 3. 创建或复用子会话
const nextSession = params.task_id
? yield* sessions.get(params.task_id) // 复用
: yield* sessions.create({ // 新建
parentID: ctx.sessionID, // 关联父会话
agent: next.name,
permission: [...],
})

// 4. 把任务 prompt 注入子会话并驱动运行——复用 session.prompt!
yield* ops.prompt({
sessionID: nextSession.id,
agent: next.name,
parts: [{ type: "text", text: params.prompt }],
})

// 5. background 模式:异步跑,完成后回调注入父会话
if (params.background) {
return yield* runInBackground(nextSession, ctx)
}

// 6. 同步模式:等子会话结束,返回最后一条 assistant 消息
return yield* waitForResult(nextSession)
})

第 4 步是精髓:ops.prompt 就是前面讲的 session/prompt.tsprompt 入口。**子
Agent 跑的完全是同一个 runLoop**,只是用了一个不同的 agent 定义(不同
prompt、不同模型、不同权限)。这意味着主循环的所有机制(压缩、工具调用、doom loop 检测)对子 Agent 全部适用,零额外实现成本。

子 Agent 委派的全过程,注意第 4 步复用了同一个 runLoop

Mermaid diagram 7

background 模式需要环境变量 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,异步跑子
agent,完成后通过 BackgroundJob 回调把结果注入父会话。

7.2 Agent.Info 与内置 agent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// agent/agent.ts
export interface Info {
name: string
description: string
mode: "subagent" | "primary" | "all" // 决定是子 agent 还是主 agent
native: boolean
hidden: boolean // 是否在 agent 选择器隐藏
topP, temperature, color
permission: PermissionV1.Ruleset // 该 agent 的权限规则
model?: { modelID, providerID } // 可选专属模型
variant: string
prompt: string // 系统 prompt
options: Record<string, any>
steps?: number // 步数上限
}

内置 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 的区别;defaultInfoagent.ts)拒绝把 subagent
或 hidden agent 设为默认。

7.3 权限派生

子会话通过 sessions.create({ parentID, agent, permission }) 创建,带 parentID
关联父会话。权限派生函数 deriveSubagentSessionPermissionagent/subagent-permissions.ts):

1
2
3
4
5
6
7
8
9
10
11
12
// agent/subagent-permissions.ts  (示意)
export function deriveSubagentSessionPermission(parent, child) {
return {
// 继承父 session 的 deny 规则和 external_directory 规则
...(filterDeny(parent.permission)),
...(filterExternalDir(parent.permission)),
// 子 agent 自身规则
...child.permission,
// 若子 agent 未显式允许 todowrite/task,追加 deny(防止嵌套委派)
...maybeDeny(child, ["todowrite", "task"]),
}
}

源码注释明确:“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 专属工具。

7.3 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 配置)

8. 权限机制

权限是贯穿所有工具调用的横切关注点。OpenCode 用一套 “规则集 + 询问” 模型实现。

8.1 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
2
3
4
5
6
7
8
9
// session/tools.ts  ctx.ask(示意)
function ask(req) {
return permission.ask({
...req,
sessionID,
tool: { messageID, callID },
ruleset: Permission.merge(agent.permission, session.permission ?? []),
})
}

ask 流程(index.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// permission/index.ts  ask(骨架)
function ask(req) {
return Effect.gen(function* () {
let needsAsk = false
for (const pattern of req.patterns) {
const result = evaluate(req.permission, pattern, ...rulesets)
if (result.action === "deny") yield* Effect.fail(new DeniedError())
if (result.action === "ask") needsAsk = true
// allow → 跳过
}
if (needsAsk) {
yield* Event.publish({ type: "Asked", req }) // 发布询问事件
yield* Deferred.await(pendingReply(req.id)) // 阻塞等用户回复
}
})
}

事件流 permission.asked / permission.repliedindex.ts),TUI / HTTP 端通过
reply 接口回送结果。

8.2 evaluate:后定义优先

evaluate(permission, pattern, ...rulesets)index.ts)用
Wildcard.match 同时匹配 permission 名和 patternfindLast
最后一条命中规则(后定义优先),无命中默认返回 { action: "ask", permission, pattern: "*" }

1
2
3
4
5
6
7
8
// permission/index.ts  evaluate(示意)
function evaluate(permission, pattern, ...rulesets) {
const allRules = rulesets.flat()
const matched = findLast(allRules, r =>
Wildcard.match(r.permission, permission) && Wildcard.match(r.pattern, pattern)
)
return matched ?? { action: "ask", permission, pattern: "*" }
}

“后定义优先” 意味着配置里后面的规则可以覆盖前面的,这给了用户灵活的 “先放行大类再限制小类” 或反过来能力。

8.3 reply 三态

replyindex.ts):

  • reject:拒绝并连带取消同 session 其他 pending。
  • once:仅本次放行。
  • always:把 always 数组里的 pattern 作为 allow 规则 push 到内存 approved

ask 到 reply 的完整交互,以及三态 reply 的不同落点:

Mermaid diagram 9

8.4 always allow 只存内存

这是开篇强调过的点。replyalways 分支只
approved.push({ permission, pattern, action: "allow" }) 到内存
State.approvedindex.ts),无任何文件写入。HTTP handler
handlers/permission.tsreply 仅调 svc.reply,无 config
写回。config.ts 中只有 permission 读取(546 / 562 行),未发现写回逻辑。

结论:always allow 是 InstanceState 级内存持久化,重启后失效。 用户若要持久化需手动在 opencode.json
permission 字段预设规则。这是个有意的设计选择 —— 避免 “用户随手 always 了一次危险操作然后就永久放行”。

8.5 权限规则的两层来源

  • 配置 schema
    ConfigPermissionV1.Infocore/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(如 *: allowdoom_loop: askread
    *.env ask、question/plan_*: deny),再与用户配置 user
    merge。
  • 运行时内存 State.approved: PermissionV1.Rule[]index.ts):存
    always 批准的规则。

fromConfigindex.ts):string 值展开为 pattern: "*";object 值按每个 key 作为
pattern(支持 ~$HOME 展开)。所以配置可以很简洁:

1
2
3
4
5
6
7
{
"permission": {
"bash": "ask",
"edit": { "*.env": "deny", "*": "allow" },
"read": { "*.env": "ask" }
}
}

9. 扩展机制

OpenCode 的扩展点分散在几个层面,分别对应不同复杂度的定制需求。

9.1 自定义 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

9.2 自定义 Provider:插件目录

Provider 适配除了 BUNDLED_PROVIDERS 的 AI SDK 工厂映射,还有 plugin/ 目录下的 Provider
插件(azure.tsxai.tsgithub-copilot/copilot.tsopenai/codex.tsopenai/ws.ts
等),负责 OAuth 鉴权与特殊传输(如 Codex 的 WebSocket)。新增一个有特殊鉴权或传输需求的 provider,在 plugin/ 下加一个插件文件即可,不必动
BUNDLED_PROVIDERS

9.3 自定义 Agent:两种途径

前文章节提到过,自定义 agent 有两种途径:运行时合并(cfg.agent 配置覆盖或 disable 内置
agent)和文件加载({agent,agents}/**/*.md 的 frontmatter + 正文 prompt)。markdown 方式尤其友好 —— 一个.md 文件就是一个 agent,frontmatter 写配置、正文写系统 prompt,降低自定义门槛。

9.4 MCP:外部能力接入

MCP 是最重量级的扩展点,允许接入任意外部工具服务器(local stdio / remote HTTP)。MCP 工具自动并入 session 工具表,对 LLM
而言与内置工具无异。cli/cmd/mcp.tsmcp add 把 server 写入
opencode.json,OAuth token 持久化到 mcp-auth.json

9.5 插件钩子

除了 “加东西”,扩展还能 “改行为”。源码里多处 plugin.trigger(...) 钩子允许插件介入核心流程,例如:

  • experimental.session.compactingcompaction.ts):压缩前允许插件注入上下文或替换 prompt。
  • experimental.compaction.autocontinue:控制压缩后是否自动续接。
  • tool.execute.before/after:工具执行前后钩子。

这套钩子让插件能在不 fork 核心的前提下改变压缩、续接、工具执行等行为,是比单纯 “加工具 / provider” 更深的扩展维度。

  • Title: 260908_OpenCode源码分析
  • Author: Haoliang Tang
  • Created at : 2026-09-08 00:00:00
  • Updated at : 2026-09-08 19:39:12
  • Link: https://hl-tang.github.io/2026/09/08/260908_OpenCode源码分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments