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

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

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

先纠正几个常见误解

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

  1. 没有 src/context/ 目录。上下文管理(压缩、溢出、摘要)全部住在 src/session/ 下(compaction.ts / overflow.ts / summary.ts)。如果你按"经典分层"去找 context 目录,会扑空。
  2. bash 工具实际叫 shelltool/shell.ts + tool/shell/),不存在 bash.ts
  3. 主循环函数是 runLoopsession/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.jsonpermission 字段。
  7. 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.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/cliCLI 命令分发

而核心运行时 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

下面进入正题。

会话主循环与工具调用

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

入口链: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 流。这是一个朴素但关键的并发护栏。

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

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]

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

一次循环的 17 步

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

// 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)
})
)

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

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

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

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

工具是怎么注入到 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 的完整链路:

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&lt;string, AITool&gt;]
    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.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 / 权限拒绝):

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.streamsession/llm.ts)有一个分叉,按条件返回两种运行时:

1
2
3
4
5
// session/llm.ts  run(简化)
if (experimentalNativeLlm && LLMNativeRuntime.status(input).supported) {
return { type: "native", stream: LLMNativeRuntime.stream(...) } // native-runtime.ts
}
return { type: "ai-sdk", stream: streamText({ ... }).fullStream... } // llm.ts
  • AI SDK 运行时(默认):调 streamTextfullStreamStream.fromAsyncIterableLLMAISDK.toLLMEventssession/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.streamsession/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"] -.-> NV

stop 条件全集

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

退出条件触发位置含义
自然结束prompt.tslastAssistant.finish 且非 tool-calls 且无 tool calls 且 lastUser.id < lastAssistant.id
result === "stop"prompt.tsctx.blocked(权限拒绝)或 assistantMessage.error
result === "structured"prompt.ts命中结构化输出
result === "finished" + errorprompt.tscontent-filter 或结构化错误
步数软上限prompt.tsstep >= agent.steps,注入 MAX_STEPS_PROMPT 引导收尾(非硬 break
用户中断prompt.tsonInterrupt → 标记中断态
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_PROVIDERSprovider/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,按 useCompletionUrlschat/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。

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

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

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

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",而不用硬编码。

工具系统

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

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>

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 读出几万行不能直接塞回消息。

注册表

tool/registry.tslayer 内各工具经 yield* XxxToolTool.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 自定义工具。

关键工具的 execute 要点

工具文件execute 要点
readtool/read.tsReadTool,execute)读文件/目录,按行返回 <line>: <content>,默认 2000 行、单行截断 2000 字符;图片/PDF 作附件
edittool/edit.ts参数 filePath/oldString/newString,字符串替换,带 diff 修正算法、LSP 诊断验证、按文件加 Semaphore
writetool/write.ts参数 content/filePath,写入文件
shelltool/shell.tsShellToolweb-tree-sitter 解析命令 AST,collect 扫描涉及目录/文件用于权限询问,再 run 执行;description 由 ShellPrompt.render 动态生成
tasktool/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 匹配。

工具结果回填到消息

工具执行完,结果怎么回到消息里供下一轮 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 里能看到"正在读文件…"的来源。

上下文管理与压缩

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

双层持久化

消息存两层:

  • 数据库层(主存储)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,元数据进文件"的分层在需要导出/迁移场景下很实用。

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 之和。

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"的由来。

流内压缩 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)与触发路径:

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

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

三类 summary 再强调

回到开篇的提醒,这里把三种 summary 的职责钉死:

模块文件做什么
文件 diff 摘要session/summary.ts基于快照算 FileDiff[],存到 message.info.summary.diffssummarize 在主循环第一步 fork 调用
对话压缩摘要packages/core/src/session/compaction.tsSUMMARY_TEMPLATE 产生"锚定摘要",压缩历史对话
会话描述摘要agent/prompt/summary.txt + summary agent(agent.ts像 PR description 写 2-3 句、第一人称、描述改动而非过程,用于标题/描述

session/summary.tssummarize 流程:先把 summary 置零 → 发布 Session.Event.Diff → 若 config.snapshot === false 直接返回 → 否则取消息,调 computeDiffFileDiff[],写入 target.info.summary.diffssessions.updateMessagecomputeDiff 从消息 parts 的 step-start/step-finishsnapshot 字段取 from/to,调 snapshot.diffFull(from, to)。所以文件 diff 摘要依赖 snapshot 机制——每步开始结束都会快照工作区状态。

子 Agent / Subagent

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

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

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 返回结果
    end

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

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 硬编码):

agentmode用途
buildprimary默认主 agent
planprimary规划,禁编辑
generalsubagent通用子 agent
exploresubagent只读搜索专家
compactionprimary (hidden)上下文压缩
titleprimary (hidden)标题生成
summaryprimary (hidden)会话描述摘要

mode 字段决定 subagent 与 primary 的区别;defaultInfoagent.ts)拒绝把 subagent 或 hidden agent 设为默认。

权限派生

子会话通过 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 专属工具。

agent/prompt/*.txt 各 prompt 用途

文件绑定 agent用途
explore.txtexplore“file search specialist”,强调用 Glob/Grep/Read/Bash 搜索、返回绝对路径、不修改系统
compaction.txtcompaction“anchored context summarization”,只总结历史、保留文件路径与标识符、不回答对话本身
summary.txtsummary会话摘要,像 PR description 写 2-3 句、第一人称、描述改动而非过程
title.txttitle标题生成,单行 ≤50 字符、不含工具名、用对话同语言
generate.txt用于 Agent.generate(LLM 自动生成 agent 配置)

用户自定义 agent

两种途径:

  1. 运行时合并agent.ts):遍历 cfg.agent,支持 disable 删除内置 agent,或覆盖 model/variant/prompt/description/temperature/topP/mode/color/hidden/name/steps/options/permission,新 agent 默认 mode: "all"native: false
  2. 文件加载config/agent.ts):load(dir) 扫描 {agent,agents}/**/*.md,解析 markdown frontmatter 为配置、正文为 prompt;loadModeagent.ts)扫描 {mode,modes}/*.md 并强制 mode: "primary"

配置 schema ConfigAgentV1.Infopackages/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.tscreate(key, mcp)mcp.type 分发:

1
2
3
4
5
6
7
8
// mcp/index.ts  create(骨架)
function create(key, mcp) {
if (mcp.enabled === false) return disabled(key)

return mcp.type === "local"
? connectLocal(key, mcp) // StdioClientTransport,spawn 子进程
: connectRemote(key, mcp) // StreamableHTTP → SSE 回退
}
  • local:用 StdioClientTransport spawn 子进程,适合本地 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)listToolspaginate 分页拉取 client.listToolscatalog.ts)。ToolListChangedNotificationSchema 通知触发重新拉取(index.ts)——MCP server 工具列表变了能动态感知。

工具注册:MCP.tools()index.ts)遍历已连接 client,对每个 mcpTool 生成:

1
2
3
4
5
6
7
8
9
10
11
// mcp/index.ts  MCP.tools(示意)
function tools() {
const result = {}
for (const [clientKey, client] of connected) {
for (const mcpTool of client.tools) {
const key = sanitize(client.name) + "_" + sanitize(mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
}
}
return result
}

sanitizecatalog.ts)把非 [a-zA-Z0-9_-] 字符替换为 _convertToolcatalog.ts)用 AI SDK 的 dynamicTool 包装,传入 inputSchemaexecute。注册到 session 工具表发生在 session/tools.ts

调用路由:闭包捕获 client

convertToolexecute 内部直接 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 通知| DEF

session/tools.ts 包装 MCP 工具时还做 schema 转换、结果截断、附件提取(image/resource → file part)。

完整 OAuth 流程

远程 MCP server 可能需要 OAuth。McpOAuthProvidermcp/oauth-provider.ts)实现 MCP SDK 的 OAuthClientProvider 接口:

1
2
3
4
5
6
7
// mcp/oauth-provider.ts  (示意)
export class McpOAuthProvider {
redirectUrl = "http://127.0.0.1:19876/mcp/oauth/callback"
clientMetadata = { /* client_name, redirect_uris, grant_types, ... */ }
clientInformation = /* 优先用配置 clientId,否则用动态注册存的 */
tokens = /* 读写 token */
}

完整流程(index.ts):

  1. startAuth 生成随机 oauthState,起本地回调服务(McpOAuthCallbackmcp/oauth-callback.ts,5 分钟超时)。
  2. StreamableHTTPClientTransport 连接,触发 401。
  3. 捕获 UnauthorizedErrorauthorizationUrl
  4. open(url) 打开浏览器。
  5. waitForCallback 拿 code(回调服务按 oauthState 匹配 pending 请求,防 CSRF)。
  6. 校验 state。
  7. finishAuthtransport.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 子命令,addjsonc-parsermodify/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
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 接口回送结果。

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: "*" }
}

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

reply 三态

replyindex.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: 结果
    end

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.jsonpermission 字段预设规则。这是个有意的设计选择——避免"用户随手 always 了一次危险操作然后就永久放行"。

shell 的命令前缀匹配

shell 工具的权限询问(tool/shell.ts)分两段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// tool/shell.ts  (示意)
function ask(ctx, scan) {
return Effect.gen(function* () {
// 1. 涉及外部目录时先问 external_directory
if (scan.externalDirs.length) {
yield* ctx.ask({
permission: "external_directory",
patterns: scan.externalDirs,
always: scan.externalDirs,
})
}
// 2. 再问 bash 权限
yield* ctx.ask({
permission: ShellID.ToolID,
patterns: scan.patterns, // 每条命令完整 source(含重定向)
always: scan.always, // BashArity.prefix(tokens).join(" ") + " *"
})
})
}

scan.alwaysBashArity.prefix(tokens).join(" ") + " *"——把命令按 arity 字典截断后加通配符。例如 git checkout main → always 规则 git checkout *。这样用户批一次 git checkout * 后,后续 git checkout 任何分支都不再问。

BashArity.prefixpermission/arity.ts):从最长前缀开始查 ARITY 字典,命中取前 N 个 token;未命中取第 1 个 token。ARITY 字典收录常见命令子命令 arity:

1
2
3
4
5
6
7
8
9
// permission/arity.ts  ARITY 字典(节选)
const ARITY = {
"git": 2, // → "git checkout"
"npm": 2, // → "npm install"
"npm run": 3, // → "npm run build"
"docker": 2, // → "docker compose"
"docker compose": 3,
// ...
}

flags 不计入 token,最长前缀优先。这个字典是手维护的常见命令表,覆盖了 git/npm/docker 等高频命令的子命令结构。

已知 permission key

packages/core/src/v1/config/permission.ts 定义已知 key:

1
2
read, edit, glob, grep, list, bash, task, external_directory,
todowrite, question, webfetch, websearch, lsp, doom_loop, skill

并允许任意扩展键。其中几个特殊的:

  • 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.disabledindex.ts)额外提供"哪些工具被 deny *"的判定,把 edit/write/apply_patch 统一归到 edit 权限名——所以 deny edit 会同时禁掉 write 和 apply_patch。

权限规则的两层来源

  • 配置 schema ConfigPermissionV1.Infocore/v1/config/permission.ts):Record<permission, Rule>Rule = Action | Record<pattern, Action>
  • 配置读取config/config.ts):从 opencode.jsonpermission 字段并 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" }
}
}

配置系统

前面各章节多次提到 opencode.json 与配置加载,这里把配置系统单独梳理。OpenCode 的配置不是单一的 schema 文件,而是分散在 config/ 目录下按领域加载(agent / command / markdown / plugin / mcp / paths),最后合并成一份运行时配置。

配置文件与查找

主配置文件是项目根的 opencode.jsoncli/cmd/mcp.tsaddjsonc-parsermodify/applyEdits 写回它,保留注释和格式)。配置读取在 config/config.ts,各领域读取后 mergeDeep 合并。环境变量可覆盖部分字段,例如 OPENCODE_PERMISSION 覆盖权限、OPENCODE_API_KEY 覆盖密钥。

分领域配置 schema

各领域配置 schema 集中在 packages/core/src/v1/config/,用 effect Schema 定义:

领域schema 文件要点
permissionpermission.tsRecord<permission, Rule>Rule = Action | Record<pattern, Action>,已知 key 见前文
agentagent.tsConfigAgentV1.Info,支持 model/variant/prompt/permission/disable/mode 等,未知键入 options
mcpmcp.tsLocal(command+cwd+environment)与 Remote(url+headers+oauth)两种类型
model模型列表、cost、limit、capabilities

agent 默认权限的构造

前面权限章节提到 agent 默认权限由 Permission.fromConfig 构造(agent.ts)。它的流程是:先用 defaults 建立基线规则(*: allowdoom_loop: askread*.env ask、question/plan_*: deny),再与用户在配置里写的 user 规则 merge。这意味着即使用户不配任何权限,OpenCode 也有一套安全的默认基线——危险操作(doom loop、读 .env、planning 委派)默认需要确认。

配置加载的合并语义

mergeDeepconfig/config.ts)做的是深度合并而非浅覆盖,所以用户配置可以只写要覆盖的字段,其余继承默认。这与权限的"后定义优先"是两套机制:mergeDeep 负责配置文件间的字段级合并,evaluatefindLast 负责规则数组内的优先级。理解这两层的区别,才能准确预测一条权限规则最终如何生效。

扩展机制

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.tsxai.tsgithub-copilot/copilot.tsopenai/codex.tsopenai/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.tsmcp add 把 server 写入 opencode.json,OAuth token 持久化到 mcp-auth.json

插件钩子

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

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

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

总结

把 OpenCode 的设计哲学提炼几条:

  1. Effect 全栈。从 Effect.genLayerService,整个运行时建立在 Effect 之上。这带来了结构化并发(ensureRunning 护栏、onInterrupt 清理)、统一的错误通道、可测试的依赖注入。runLoop 本身就是一个 effect,可被中断、可被排队。

  2. AI SDK 作为适配层,而非协议层。Provider 适配建立在 Vercel AI SDK 之上(约 20 个 @ai-sdk/* 包),用工厂映射 + 定制 loader + transform 三层处理各 provider 怪癖。同时保留 @opencode-ai/llm 原生通路作为 opt-in,两者都归一化到 LLMEvent 流——下游 processor 完全不感知差异。

  3. 流式归一化。无论 AI SDK 还是 Native,所有 LLM 事件都归一化成 LLMEvent,由 processor 统一消费。这让"流内压缩"“doom loop 检测”"中断处理"等横切逻辑可以跨 provider 复用,而不是每个 provider 各写一遍。

  4. 子 Agent 即 session 复用。子 Agent 不另起运行时,task 工具通过 sessions.create({ parentID }) 创建子会话,复用同一个 runLoop。主循环的所有机制(压缩、工具、权限)对子 Agent 自动适用,零额外实现。权限派生只继承 deny 和 external_directory,子 agent 能力由自身权限决定。

  5. 压缩是增量演进的锚定摘要。不是启发式截断,而是独立 LLM 调用产生结构化摘要(Goal/Constraints/Progress/…),有 previousSummary 时增量更新。tail_turns=2 保留近期对话,被压缩的历史用 mode: "compaction" 消息覆盖、循环里 filterCompacted 过滤。流内压缩让压缩能在生成中途触发,不必等轮次结束。

  6. 权限即规则集。ask/allow/deny 三态、后定义优先、agent+session 权限合并。always allow 故意只存内存(重启失效),避免永久放行危险操作。shell 用 BashArity.prefix 把命令截成可复用的前缀通配符,让"批一次 git checkout *"成为可能。

  7. 工具说明即文本文件tool/*.txt 作为给 LLM 看的说明书,shell.txt 是动态模板,task 描述动态拼接可用 subagent。这让工具的使用说明可以随环境变化(OS、shell、可用 agent),而不用改代码。

  8. 三层 summary 严格区分。文件 diff 摘要(summary.ts)、对话压缩摘要(SUMMARY_TEMPLATE)、会话描述摘要(summary.txt)各司其职,不要混淆——这是读源码时最容易踩的坑。

OpenCode 的源码是少数把"AI 编程助手"这件事工程化得相当彻底的实现:Effect 提供骨架,AI SDK 提供广度,自研通路提供深度,而 session 复用、流式归一化、增量压缩这几个抽象让复杂度被很好地收敛。如果你正在设计自己的 agent 运行时,它的 runLoop 结构、LLMEvent 归一化、task 复用 session 这几处尤其值得借鉴。