OpenCode 源码解析
OpenCode 的核心不是把用户文本转发给模型,而是一个以 Session 为边界的本地 Agent Runtime。它在本地保存任务事实,构造每轮上下文,限制模型可用工具,执行真实动作,再依据结果决定继续还是结束。
本文按运行时依赖方向,从底向上讲:上一层只建立在下一层已经提供的能力之上。packages/opencode/src/session 承载当前 Loop 主线;packages/core/src/session 是正在演进的 V2 Session 持久化实现,文中单独标注。
第一部分:整体架构——六层主栈
| 自底向上 | 一句话说明 | 主要代码 |
|---|---|---|
| 1. 本地操作与会话存档 | 真正读写文件、运行命令、操作 Git;同时保存会话与工具结果。 | src/storage/、src/git/、shell/PTY 工具、packages/core/src/database/ |
| 2. 模型和外部服务连接 | 连接不同模型厂商和 MCP 等外部服务。 | src/session/llm/、src/mcp/、request.ts |
| 3. 工具准备与安全校验 | 决定模型能用什么工具,并在执行前做权限校验。 | tools.ts、src/tool/、permission |
| 4. 会话整理与本轮输入准备 | 整理历史、规则与工具结果,形成模型输入;也接收模型输出。 | session.ts、processor.ts、message-v2.ts |
| 5. 任务循环与下一步判断 | 决定调模型、执行工具、压缩、启动子任务或停止。 | prompt.ts、run-state.ts |
| 6. 用户入口与界面同步 | 接收 CLI、UI、SDK 输入,并把过程状态同步给界面。 | server.ts、agent.ts、prompt.ts |
第二部分:端到端流程——一次任务如何运行
房子图解释“能力放在哪里”;主流程解释“一条输入如何跑完”。两者通过 Session 汇合:每个模块都向 Session 读写事实,Agent Loop 每次回环都重新从它取数。
后文每一层都会附“最小代码骨架”:保留真实源码的入口、核心数据和下一跳;/* ... */ 代表为阅读而省略的字段或分支,示例用于对照流程,不是可直接粘贴运行的完整实现。
1. 六阶段主流程
以“读取 src/a.ts 并解释”为例:
- 用户把任务交进来:L6 接收 CLI、Web 或 Desktop 输入,并交给当前 Session 的 Agent。
- 先把用户要求记下来:L5 启动本次运行;L1 将“用户要读取什么”保存为 Session 事实,重启后仍能恢复。
- 整理本轮模型输入:L5 触发 L4 读取历史、摘要、规则和已有工具结果;L3 筛选当前允许的工具;L2 接收编译后的模型请求。
- 模型开始生成:L2 返回文字、推理或工具请求;L4 将这些过程写入当前会话,L1 持久化,界面因此可以实时显示。
- 若模型需要工具,就执行后回到模型:L3 校验权限;L1 真实读取文件、运行命令或调用 MCP,并保存结果;L5 发现任务未完成,回到第 3 步重新调用模型。
- 模型给出最终答案,结束本次运行:L5 确认没有待执行工具、压缩或子任务后停止;L6 将 Session 的最终变化同步到 CLI、Web、Desktop。
第 3~5 步构成主循环。L1 也不是只在流程底部经过一次:每当用户输入、模型输出、工具状态或结果变化时,它都会作为会话事实的存档点。
写入会话事实(对应第 2 步)
这一阶段不调用模型。它做的是把“读取 src/a.ts 并解释”放进已有的 Session,而不是把这句话只留在当前 HTTP 请求或界面内存里。
写入后的内容可以简单理解为三部分:
- 用户这次输入的文字;
- 随输入带来的图片、文件或其他内容;
- 本次选择的 Agent、模型以及临时工具开关。
其中,文字和附件是这条用户消息的内容;Agent、模型、权限是后续运行这条消息时要采用的条件。它们都以 Session 为归属:刷新页面、退出再打开后,系统仍知道用户问了什么、上一次走到了哪里。
写完才开始本次运行。若这是“只记入会话、不要求立即回复”的输入,就到这里为止;正常输入才进入下一步的模型准备。
编译模型工作包(对应第 3 步)
这一阶段的目标不是“把全部数据库记录原样发给模型”,而是生成本轮真正要使用的上下文包。它由 L5 发起,L4、L3、L2 分别补齐不同部分:
- L4 会话整理:取这条用户要求、相关历史、已有摘要、项目规则和已经拿到的工具结果;过长的旧历史不会无条件全部带上。
- L3 工具准备:从全部工具中筛出当前 Agent 和当前权限允许使用的工具,并附上每个工具的名称、用途和参数说明。
- L2 模型连接:将整理后的系统规则、对话消息、工具说明、模型参数交给所选 Provider,由它适配具体模型厂商的协议并发起请求。
所以模型看到的不是 OpenCode 的原始 Session 数据库对象,而是一份“本轮工作包”:该做什么、此前已经知道什么、可以调用什么、应遵守什么规则。第 4 步开始的流式输出,也会反过来继续补充这份 Session 事实。
2. 三条特殊分支
这三件事不是另一套主流程,而是在不同阶段暂时改道;最终仍回到 Session,再由 L5 决定是否进入下一轮。
| 分支 | 从哪里分出 | 这条支路做什么 | 怎样回到主流程 |
|---|---|---|---|
| 上下文压缩 | 第 3 步准备输入时发现历史过长,或第 4 步模型返回“上下文超限” | 暂停普通模型请求,使用专门的压缩 Agent 把较早历史整理成摘要,同时保留最近几轮和必要的工具调用 | 摘要与压缩标记写回同一 Session;回到第 3 步,重新生成“摘要 + 最近历史”的工作包 |
| 子 Agent | 第 4 步模型输出 task 工具请求,随后进入第 5 步工具执行 | 建立一个带父 Session 关系的子 Session;子 Agent 用自己的任务输入跑完整的小循环,而不是共享父会话的全部历史 | 前台子 Agent 完成后作为父工具结果,回到父任务第 3 步;后台子 Agent 结束后把结果写成父会话的一条系统生成输入,再启动父会话的新一轮 |
| revert(回退) | 用户在 L6 主动要求回退;它不是模型正常推理中自动触发的分支 | 在没有运行中的任务时,恢复对应的工作区快照,并标记 Session 应回到的消息或消息片段边界 | 下一次用户输入进入第 2 步时,先清除被放弃分支的消息和 Part,再以回退后的事实进入第 3 步 |
可以把三者的差别记成一句话:压缩是“历史太长,先换一种历史表示”;子 Agent 是“任务太大,另开一个独立会话完成子问题”;revert 是“用户改变历史,丢弃后续事实后再继续”。
其中,压缩和前台子 Agent 都会让当前任务暂时等待;后台子 Agent 不等待父任务当前模型回合,所以它完成后不能硬插回已经结束的模型流,只能以一条新的会话输入重新唤醒父 Loop。revert 则要求当前 Session 不处于运行中,避免文件快照和会话历史落在不一致的中间状态。
3. 贯穿六层的状态与 Memory
这里的 Memory 不是单独的向量库层,而是四类状态的组合:
| 类型 | owner | 下一轮是否直接送模型 |
|---|---|---|
| 会话记忆 | SQLite 中的 Session、Message、Part、Todo | 是,重放后进入 messages |
| 压缩记忆 | compaction 生成的摘要 Message 与保留边界 | 是,以摘要替代旧历史 |
| 外部事实 | 工作区文件、Git、快照、终端输出 | 否;模型通过工具再次读取 |
| 进程运行态 | runner、AbortController、MCP client、流式拼接缓冲 | 否;进程重启即丢失 |
因此,Session 是 Agent 的主要持久记忆;工作区是外部事实源;进程内 Map 只是暂态。当前源码没有“自动抽取偏好并做 embedding / 向量检索、跨 Session 注入”的内建长期记忆链路。MemoryState 仅是流式事件处理期间的内存消息投影,不是长期记忆库。SQLite 表结构;内存投影
第三部分:六层实现与代码
第 1 层:本地操作与会话存档(文件、进程、SQLite)
这一层提供两类本地能力:一类是工具实际操作工作区所需的 filesystem、process/PTY、Git、snapshot;另一类是会话事实的 SQLite 持久化。
一句话:它既保存“这个 Agent 已经知道和做过什么”,也提供“它现在能在电脑上实际做什么”。
默认数据库在 Global.Path.data/opencode.db;OPENCODE_DB 可改为其他绝对路径,也可设为 :memory:。数据库路径
持久化不是一个大 JSON,而是三层关系:
Session
├─ Message[] 用户 / assistant 的一轮消息
│ └─ Part[] text / reasoning / tool / 文件 / step
├─ Todo
└─ 元数据:目录、Agent、模型、权限、token/cost、摘要、revert
session 表保存会话元数据;message 表按 session_id 保存轮次;part 表按 message_id 保存文本、工具调用和工具结果,主体字段以 JSON 保存。表结构
流式中,Text.Delta 是即时事件,供订阅者增量渲染;完整文本或工具结果结束后变成 PartUpdated,由 projector upsert 到 SQLite 的 part 表。模型事件发布;Part 投影
Session 持久化:在什么时候写、写什么、下一轮怎样用
持久化的时机是每个可恢复的状态变化点,而不是等整次 Agent 回答结束后统一保存。一次普通任务可以按下面理解:
创建 Session
→ 收到用户输入:写 U1(user Message + Part)
→ 调模型前:先写空的 A1(assistant Message,parentID=U1)
→ 模型流:更新 A1 下的 text / reasoning / tool Part
→ 工具完成:更新同一个 tool Part 的结果
→ 本轮结束:更新 A1 的 finish、error、token、cost、完成时间
→ 下一轮:重读 U1 + A1,必要时再创建 A2
因此,一轮用户输入或一次模型回合创建一条 Message;流式文字、推理和工具结果补在该 Message 的 Part 中。工具结果不另起 user Message:模型先在 A1 下发起 read,执行后仍把 input / output / state 写回 A1 的同一个 tool Part;外层 Loop 重读 Session 后,才创建 A2 让模型解释该结果。
| 写入阶段 | 主要内容 | 后续用途 |
|---|---|---|
| 建 Session | 目录、工作区、Agent、模型、权限、父 Session、统计信息 | 约束运行环境,并关联子 Agent。 |
| 收用户输入 | user Message、文本/附件/命令等 Part;V2 还记录输入顺序 | latest() 找到本轮目标用户消息。 |
| 创建 assistant 容器 | assistant Message,parentID 指向当前 user Message | 为后续所有流式 Part 提供确定归属。 |
| 流与工具 | text、reasoning、tool 的输入、状态、输出、附件、错误 | UI 实时展示;Loop 判断是否仍有待处理工具。 |
| 完成与控制任务 | finish、usage、cost、压缩任务、摘要、subtask、revert | 决定继续/停止;压缩和子任务可在重启后继续被调度。 |
下一次模型调用并不是从内存接着上一次字符串继续,而是 runLoop() 每轮从数据库读取 Message + Part,先由 filterCompactedEffect() 去掉已被摘要替代的旧历史,再由 toModelMessagesEffect() 翻译成 Provider 可理解的 messages。这就是 Session 同时承担“可恢复存储”和“下一轮 prompt 的事实来源”的原因。Loop 重读 Session;压缩历史过滤
真实文件和代码改动不复制进 SQLite:它们留在工作区、Git 与 snapshot;数据库保存的是“调用了哪个工具、输入是什么、得到了什么结果、当时处于什么状态”。模型连接、AbortController、MCP client 和未完成的流式缓冲则只存在进程内。
因此,重启后可以重读历史 Session;但模型连接、AbortController、MCP client、未完成流式缓冲和当前 loop 协程是进程内运行态,不能原地续跑。每 Session 串行协调器
核心流程
这一层把模型产生的 tool-call(name, args) 翻译为一次可审批、可观测、可回放的本机动作:
tool-call(name, args)
→ 解析输入:command/workdir 或 filePath/content/patch
→ 解析本地目标:cwd、绝对路径、worktree 边界
→ 权限:read / edit / bash;越界时 external_directory
→ 执行器:ChildProcessSpawner.spawn(...) 或 FileSystem API
→ 收集结果:输出流、exit code、diff、diagnostic、截断信息
→ 发布 FileSystem / Watcher / LSP 事件
→ ToolResult
→ 上层写为 Session 的 tool Part,并成为下一轮上下文
最小代码骨架
下面按 shell 工具执行器 删减;省略输出截断、超时、取消和审批细节:
// tool-call 的 args 已在上层转换为 command / cwd / timeout
const handle = yield* spawner.spawn(cmd(shell, command, cwd, env))
// 进程 stdout / stderr 持续更新本次工具调用的运行 metadata
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
ctx.metadata({ metadata: { output: preview(chunk) } }),
)
const exit = yield* handle.exitCode
return {
title: command,
metadata: { exit, truncated: false },
output: collectedOutput,
} satisfies ToolResult
shell 的执行器是 OS 子进程;read、write、apply_patch 直接调用文件系统 API。它们共享同一工具协议:参数 Schema、Tool.Context(Session、abort、审批)、输出截断和 ToolResult 形状。工具协议;shell 启动进程;批量 patch 的审批后落盘
目录边界、动作权限和执行后事件是三件不同的事:前者限制目标位置;中者决定本次 read/edit/bash 是否可执行;后者通知文件观察者/LSP。它们都发生在本地工具层,但 Session 持久化只消费最终的 ToolResult,而不替代文件事件。
第 2 层:模型和外部服务连接(Provider 与 MCP)
这里合并原先的“外部网络接入”和“模型接入”:模型 Provider 负责请求/流式返回,MCP 负责把外部能力接成工具;模型回合编译负责将两者接到 Session。
一句话:无论底下是 OpenAI、Claude、Azure,还是一个远程 MCP,上一层只看到“模型能流式回答、工具能被调用”。
Provider 与 MCP 通道
核心流程
Provider:本轮模型请求 → Provider adapter / AI SDK → 供应商 HTTP 流 → 统一 LLMEvent → Session
MCP :stdio | Streamable HTTP | SSE → MCP Client → 工具发现 → client.callTool → ToolResult → Session
最小代码骨架
网络层只保留“建立外部通道”和“交给统一接口”两件事;Provider 与 MCP 的真实协议细节留在 adapter / client 内部。Provider 流入口;MCP 建连与发现
// Provider:OpenCode 不直接拼各厂商 HTTP body
const result = streamText({ model, messages, tools, headers, providerOptions })
for await (const chunk of result.fullStream) {
yield* LLMAISDK.toLLMEvents(state, chunk) // 统一为 LLMEvent
}
// MCP:先按 stdio / HTTP / SSE 建连,再发现工具定义
const client = yield* connectTransport(transport, timeout)
const defs = yield* McpCatalog.defs(client, timeout)
// defs 后续会转换为普通 Tool 的 Schema + execute
Provider:统一的是模型接口,不是 HTTP body
Provider 层保存模型目录、认证、variant、headers、provider options 等全局接入配置;一次 Session 调用只携带“本轮模型、上下文、工具、采样参数”。LLMRequestPrep.prepare() 先把它们整理为 { system, messages, tools, params, headers },再交给 streamText(...)。请求准备;调用
Session 本轮上下文
→ LLMRequestPrep.prepare()
→ AI SDK streamText({ model, messages, tools, headers, providerOptions, ... })
→ 指定 Provider adapter
→ 实际 URL / 鉴权 / HTTP body / 流协议
→ result.fullStream(异步事件序列)
AI SDK 是这里的适配层:它替 OpenCode 对接不同模型供应商。OpenCode 因而不维护一份跨供应商通用 HTTP JSON;实际 wire-format 由具体 adapter 决定。很多 Provider 使用 SSE,但 OpenCode 消费的是 AI SDK 暴露的 fullStream,不能把 SSE 当作所有 Provider 的固定实现。
fullStream 的每个 chunk 会被 LLMAISDK.toLLMEvents 转成统一 LLMEvent,再进入 Session 处理器。AI SDK 事件适配;流消费
Provider 流事件
→ text / reasoning / tool-input / tool-call / tool-result / step-finish
→ LLMEvent
→ SessionProcessor.handleEvent
→ text、reasoning、tool、usage、finish 等 Session Part / Message 更新
→ 订阅者实时渲染;完成态持久化
这解释了“模型回调后做什么”:不是直接通知 UI,而是先绑定到当前 Session 的 message/part;UI 只是 Session 事件的订阅者。text-delta 形成文本增量,tool-input-* 形成 pending 参数,完整 tool-call 才交给工具层,step-finish 写 token、成本、finish 等轮次状态。处理器
MCP:三种 transport 收敛为同一个工具调用
MCP 的本地模式用子进程 stdio;远程模式优先 Streamable HTTP,必要时退回 SSE。三者都先建立 MCP client,随后发现 server 的工具定义和 instruction;连接对象、工具目录、instruction 缓存在 Instance/MCP 运行态,而不是复制进每个 Session。本地连接;远程连接;发现与缓存
local cmd / Streamable HTTP / SSE
→ MCP Client
→ getServerCapabilities().tools
→ MCP definition:name + inputSchema
→ McpCatalog.convertTool(...)
→ AI SDK dynamicTool({ inputSchema, execute })
→ client.callTool({ name, arguments })
因此模型看到的 MCP 工具和内建工具没有本质差别:都是 description + JSON Schema + execute。模型不需要知道某个工具位于哪个 server,也不需要自己拼 transport 请求。
“三种 MCP 回复在哪里统一处理”分两步:
- catalog.ts 的
convertTool用统一client.callTool(...)接收CallToolResult,处理协议级isError。 - session/tools.ts 的执行包装把 MCP content 统一成 OpenCode
ToolResult:文本、图片、resource/blob 附件都会被规范化;随后进入当前 assistant message 的tool Part。
MCP CallToolResult
→ OpenCode ToolResult / attachments
→ Session tool Part(completed 或 error)
→ 下一轮模型历史
这也是 Provider 与 MCP 的共同边界:连接配置和 client 是运行时全局状态;一次模型输出或一次 MCP 工具结果才是当前 Session 的事实,并在完成后被持久化。
Provider / MCP 通道负责连接、transport 和结果回流边界;本层接着讨论 OpenCode 如何把这些能力组织成统一模型回合。
模型回合:统一请求和统一事件
上一层完成 Provider 网络连接;本层不关心 URL 或 SSE 细节,只定义 OpenCode 的“标准模型回合”如何变成 AI SDK 调用,以及如何把返回流统一为 LLMEvent。
核心流程
Session 历史、system、候选工具和模型选择
→ StreamInput(OpenCode 的标准模型回合)
→ prepare() 编译 system / messages / tools / params / headers
→ run() 选择 native runtime 或 AI SDK runtime
→ Provider 发起流式模型调用
→ 原始流事件转换为 LLMEvent
→ SessionProcessor 消费事件,更新 Message / Part
最小代码骨架
这是 请求编译、默认 runtime 与 流事件适配 的主干;省略 Provider 兼容分支:
const prepared = yield* LLMRequestPrep.prepare(input)
const result = streamText({
model: language, // 已按 Provider / 模型配置解析的 AI SDK LanguageModel
messages: prepared.messages,
tools: prepared.tools,
headers: prepared.headers,
providerOptions: ProviderTransform.providerOptions(model, prepared.params.options),
})
const state = LLMAISDK.adapterState()
for await (const raw of result.fullStream) {
for (const event of LLMAISDK.toLLMEvents(state, raw)) {
yield* processor.handleEvent(event)
}
}
通用模型回合
llm.ts 的 StreamInput 是 Host 侧模型请求,不是 Provider HTTP body:
{
user, sessionID, parentSessionID?,
model, agent, permission?,
system, messages, tools,
retries?, toolChoice?
}
| 数据组 | 字段 | 用途 |
|---|---|---|
| 本轮身份 | user、sessionID、parentSessionID | 当前任务、会话归属、子 Session 关联;还会进入 header、日志、trace。 |
| 模型选择 | model、agent | 确定 Provider 模型、Agent prompt、采样参数和默认权限。 |
| 上下文 | system、messages | 系统规则和由 Session 历史转换出的 ModelMessage[]。 |
| 能力 | tools、permission、toolChoice | 候选工具、可见性规则,以及 auto/required/none 调用策略。 |
| 控制 | retries | 重试、取消、遥测等 Host 控制。 |
其中 sessionID、agent、permission 并不原样发给模型:它们分别影响请求 header/观测、system/options、工具过滤。真正交给 AI SDK 的通用数据是 model + messages + tools + generation params + providerOptions + headers。
prepare():把抽象模型回合编译为本次真实请求
request.ts 的 LLMRequestPrep.prepare() 只负责准备请求:不发网络请求、不写 Session、不控制 loop。
Host 侧标准回合 + Agent / Provider / Session / Plugin 配置
│
▼
prepare()
│
▼
本轮最终请求:system · messages · tools · params · headers
输入来自不同 owner:Agent 提供 prompt、采样参数和默认权限;Session 提供环境、instruction 与历史;User 提供本轮 system、variant、工具开关;Model/Provider 提供默认 options、headers 与能力约束。
输出 Prepared Request 的结构是:
{
system,
messages,
tools,
params: { temperature, topP, topK, maxOutputTokens, options },
messageTransformOptions,
headers,
}
| 输出字段 | 编译规则 | 这一步解决什么 |
|---|---|---|
system | Agent prompt / 模型默认 prompt + input.system + user.system | 得到模型本轮最高优先级规则。 |
messages | 通常为 system message + Session history | 得到模型实际看到的历史;特殊 Provider 可改用专用 instructions。 |
tools | 候选工具 − Agent/Session 禁用项 − user 显式关闭项 | 得到模型本轮真正可调用的能力。 |
params | Provider base → model.options → agent.options → user variant | 得到最终温度、token 上限和 Provider options;右侧覆盖左侧。 |
headers | 模型 headers + Plugin headers + Session 标识 | 将会话关联、客户端标识和 Provider 接入信息带到请求边界。 |
Plugin 的改写点也是分开的,而不是任意改整个请求:
| Hook | 允许改写 |
|---|---|
experimental.chat.system.transform | system |
chat.params | 采样参数、token 上限、Provider options |
chat.headers | headers |
兼容分支的含义是:
| 分支 | 为什么存在 |
|---|---|
OpenAI OAuth:system → instructions | 该接口路径使用专用 instructions 字段,而不是普通 system message。 |
| Azure completion URL:删除字段 | 这类 endpoint 不接受 reasoningSummary、include。 |
OpenAI / Azure / Bedrock:strict: false | 动态/MCP Schema 可能不符合 Provider 的严格 structured-output 约束;OpenCode 仍在工具执行边界校验参数。 |
Copilot:补 _noop | 历史含 tool call 但本轮无可用工具时,该 Provider 仍要求 tools 字段存在。 |
这些是 Provider API 差异的隔离层,不是 Agent Loop 的分支。
run():接入依赖、选择 runtime、发起流
run() 是模型调用的执行入口:它拿到 prepare() 的结果后,选择可用 runtime,并返回模型流;不写 Session,也不判断 loop 是否结束。
LLM.stream(input)
→ 创建本轮 AbortController
→ run(input, abort)
→ 取得 model adapter、Provider、Auth、Config
→ prepare(input)
→ 尝试 Native runtime
→ 否则 AI SDK streamText(...)
→ Native stream 或 AI SDK fullStream
→ 统一为 Stream<LLMEvent>
真正进入 Provider 网络层的边界是 streamText(...):run
streamText 字段组 | 字段 | 用途 |
|---|---|---|
| 模型与上下文 | model、messages | 选择具体 adapter;提供本轮推理上下文。 |
| 工具能力 | tools、activeTools、toolChoice | 工具完整定义、启用工具名、auto/required/none 调用策略。 |
| 生成控制 | temperature、topP、topK、maxOutputTokens、maxRetries | 采样、输出上限、重试。 |
| Provider / Host 控制 | providerOptions、headers、abortSignal | 供应商特有参数、请求头、取消信号。 |
transformParams 在最后发送前,把通用 ModelMessage[] 改写成当前 Provider 所需的消息格式。
LLM.Service 依赖的 Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | EventV2Bridge.Service | LLMClientService 是 Effect 的依赖声明,不是发给 Provider 的请求字段。
| 依赖 | 本层用途 |
|---|---|
| Auth / Config | 凭证、全局配置、遥测开关。 |
| Provider | 根据 model 找语言模型 adapter、Provider 配置。 |
| Plugin / Permission | 改写请求,过滤模型可见工具。 |
| EventV2Bridge | 事件体系桥接;不属于 Provider 网络请求。 |
| LLMClient | 给实验 Native LLM runtime 使用的底层客户端。 |
工具调用在流中发生
模型不必等整条回答结束才调用工具:
text/reasoning delta
→ tool-input-delta(参数 JSON 尚未完整)
→ tool-call(工具名和参数完整)
→ AI SDK 调用 tools[name].execute(args)
→ 本地工具或 MCP 工具返回
→ tool-result
→ 模型基于结果继续下一个推理 step / 后续模型回合
对模型语义来说,工具结果后的推理就是“下一次回答”;对 Runtime 来说,它可能是同一流的后续 step,也可能由外层 Loop 发起下一次模型请求。共同点是工具结果先写入 Session,下一次模型看到的是这份真实历史。
| 流事件字段 | 含义 |
|---|---|
tool-input-{start,delta,end} | { id, name, text };text 是尚未完成的 JSON 参数片段。 |
tool-call | { id, name, input };input 已完整,可执行。 |
tool-result | { id, name, result };与同一 tool-call.id 对应。 |
providerExecuted / providerMetadata | 标识调用是否由 Provider 托管执行,以及 Provider 附带元数据。 |
流事件适配:分发 + 拼接
Stream<LLMEvent> 只是“异步依次产出标准事件”的类型名。实现流程只有一条:
AI SDK fullStream 的一个原始 event
→ 按 event.type 进入 switch 分支
→ 必要时读取/更新 adapterState
→ 输出 0、1 或多个标准 LLMEvent
→ 下一个原始 event
native.stream 已经是 LLMEvent
AI SDK fullStream → toLLMEvents(state, event) → LLMEvent
↑
只用于拼接 text / reasoning / tool 分片
| 原始 AI SDK event | 适配动作 | 输出 |
|---|---|---|
text-*、reasoning-* | 用当前 block ID 关联前后分片 | 对应 text-*、reasoning-*。 |
tool-input-*、tool-call、tool-result/error | 记录或查询 toolCallId → toolName | 对应工具事件。 |
finish-step、finish | 记录 step 序号、读取 usage/finish reason,结束时 reset | step-finish、finish。 |
start、raw、source、file 等 | 不需要交给 SessionProcessor,或只提取内部 metadata | 不输出事件。 |
adapterState 只保存拼接所需的临时索引:当前 text/reasoning block ID、toolCallId → toolName 和 step 序号。连续 text-delta 要归到同一个 text Part;工具结果可能只带 call ID,需靠映射找回工具名。finish 后立刻 reset,不跨模型流保存业务状态。
LLMEvent | 表示什么 | SessionProcessor 的落点 |
|---|---|---|
text-start/delta/end | 普通文本片段 | assistant text part;delta 可即时展示。 |
reasoning-* | 推理片段 | reasoning part。 |
tool-input-* | 工具参数 JSON 正在生成 | pending tool part。 |
tool-call | 工具名和完整参数已就绪 | 工具层开始执行。 |
tool-result/tool-error | 工具结束 | completed/error tool part。 |
step-finish/finish | 一个模型步骤或整条流结束 | finish、token、成本、快照。 |
模型层到此只输出事件,不写 SQLite、不渲染 UI、不决定是否继续 loop;SessionProcessor 消费事件,Agent Loop 再据此判断继续或结束。
第 3 层:工具准备与安全校验(从 JSON Schema 到真实动作)
工具层不负责决定“下一步做什么”;它负责把模型给出的 tool-call(name, args) 变成一次受控的真实动作,并把结果写回本轮 Session。这里有三个不能混淆的动作:
一句话:模型只能提出“调用什么、参数是什么”;这一层负责决定能不能做、怎么做、结果如何留下来。
注册(全局有哪些工具)
≠ resolve(当前这轮允许模型看见哪些工具)
≠ execute(模型已调用某工具后,真正执行动作)
核心流程
1. 启动时注册:ToolRegistry 注册内建 / Plugin 工具;MCP 连接后发现远端工具。
2. 每个模型回合前:SessionTools.resolve() 根据当前 Agent、Session、模型能力和 MCP 状态生成候选工具表。
3. resolve 过滤掉当前 Agent 或 Session 禁用、以及静态规则完全 deny 的工具;其余工具转为 AI SDK schema。
4. 同时为每个工具生成 execute 闭包,闭包绑定 sessionID、messageID、toolCallID、AbortSignal 和权限上下文。
5. 模型流产生完整 tool-call(name, args) 后,AI SDK 才调用这个 execute 闭包。
6. 闭包构造 Tool.Context,进入 Plugin hook、具体本地/MCP 工具的执行包装和细粒度权限判断。
7. 工具返回 ToolResult;模型流产生 tool-result/tool-error,SessionProcessor 将其写成当前 assistant message 的 tool Part。
8. 结果进入下一模型 step 或下一轮上下文;是否继续由 Agent Loop 判断。
最小代码骨架
下面是 SessionTools.resolve() 中最重要的“本轮工具表 + execute 闭包”结构;省略 Plugin hook、附件处理和特殊 MCP resource 工具:
const tools: Record<string, AITool> = {}
for (const item of yield* registry.tools({ agent, modelID, providerID, permission })) {
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(ProviderTransform.schema(model, ToolJsonSchema.fromTool(item))),
execute(args, options) {
return run.promise(Effect.gen(function* () {
const ctx = context(args, options) // 绑定 session / message / call / abort / ask
return yield* item.execute(args, ctx)
}))
},
})
}
MCP 的连接、发现、协议结果归一化已在上一层完成;到这一步,它已被转换为同样的工具定义,和本地工具共享同一工具表、权限边界和 ToolResult 回写路径。
resolve():为本轮编译工具表
SessionTools.resolve() 不是注册工具,也不在此时实际调用工具。它为这一轮模型请求组装 AI SDK 能消费的映射:
Record<toolName, {
description: string
inputSchema: JSONSchema
execute(args, options): Promise<ToolResult>
}>
其中 description 和 inputSchema 会随模型请求发出,使模型知道可调用什么、参数如何组织;execute 不会发给模型,而是留在 OpenCode 进程中,等待模型真的发出同名 tool-call 时被 AI SDK 调用。
resolve() 读取的运行时输入及其用途如下:
| 输入 | 在本轮工具表中的作用 |
|---|---|
| 当前 Agent | 根据 Agent 的 tool / permission 配置筛掉不可用工具。 |
| Session 与当前 message | 把工具执行结果归属到正确的会话和 assistant message。 |
| 当前模型 | 按 Provider / 模型能力把输入 schema 转成兼容格式。 |
| MCP client | 把已发现的远端工具加入同一候选集合。 |
processor、AbortSignal | 让工具能够发布执行进度、响应取消并回写本轮事件。 |
模型调用后:执行包装如何落到真实工具
工具参数并不是一次性凭空拿到的。流中先有 tool-input-delta,表示参数 JSON 仍在增量生成;完整 tool-call 到达后,AI SDK 才执行第 4 步创建的闭包:
model tool-call
→ tools[name].execute(args, options)
→ 由闭包构造 Tool.Context
→ plugin before hook
→ 本地工具 item.execute(args, context) 或 MCP client.callTool(...)
→ plugin after hook
→ ToolResult
→ tool-result / tool-error 事件
→ Session 的 tool Part
Tool.Context 是真实执行的运行时边界,包含 sessionID、messageID、callID、agent、abort、历史消息,以及两个回调:metadata() 用于更新执行中的工具 Part,ask() 用于在需要时发起权限审批。也就是说,resolve() 创建的是“带会话身份的执行入口”;第 5 步的模型调用才会触发真正的 shell、读写文件或 MCP RPC。
权限的两道边界
| 时机 | 规则 | 结果 |
|---|---|---|
resolve() | 工具被规则完全 deny | 不进入本轮 schema,模型根本看不见。 |
execute() | 对具体命令、文件路径或参数判断 allow / ask / deny | allow 直接执行;deny 拒绝;ask 发布审批事件并等待 UI/SDK 回复。 |
前一道是在降低模型可见的能力面;后一道面对的是实际参数,因此能精确限制“哪条 shell 命令”“哪个文件路径”。ask 不是 UI 线程阻塞:权限服务发布请求事件后等待一个 Deferred 结果,收到用户的 allow/deny 回复再恢复这次工具调用。权限判断
结果如何回到模型与 Session
工具的统一返回形状包含 title、metadata、文本 output 和可选 attachments。它不是由工具直接“回调模型”;先进入 AI SDK 的流,再成为 Session 的可持久化事实,最后由下一次模型请求重放。
1. 模型输出 tool-call(name, args)
2. 找到本轮 resolve() 预先包装好的 execute 闭包
3. 闭包绑定 sessionID / assistant messageID / toolCallID / 权限上下文
4. 执行真实工具
5. 工具结果包装为 ToolResult
6. ToolResult → tool-result 事件 → 写入原 assistant message 下的 tool Part
7. Loop 重新读取 Session,把该 tool Part 编译进下一次模型请求
8. 模型根据工具结果:
- 再发 tool-call:重复 2–7
- 输出正常文本并 stop:Loop 结束
其中第 2–6 步是一次工具调用的执行链;第 7–8 步将工具结果送回模型,构成“模型 → 工具 → Session → 模型”的外层循环。
真实工具执行
→ ToolResult
→ AI SDK fullStream: tool-result / tool-error
→ OpenCode LLMEvent
→ SessionProcessor 更新 tool Part
→ Agent Loop 重新读取 Session
→ tool Part 编译为 ModelMessage
→ 下一次模型请求
工具执行包装已经持有三类关联键:sessionID 指明会话,messageID 指明这次 assistant 回答,toolCallID 对应模型发出的具体调用。SessionProcessor 用 toolCallID 找到此前由 tool-input-* 创建的 pending Part;收到 tool-call 后将其更新为 running,收到 tool-result 后将其更新为 completed,收到 tool-error 则标记 error。
tool-input-start → pending
tool-call → running
tool-result → completed
tool-error → error
完成后的事实形状可理解为:
{
type: "tool",
tool: "read",
callID: "call_xxx",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "...文件内容...",
attachments: []
}
}
这份 tool Part 挂在当前 assistant message 下,因此 UI/TUI 可以显示运行中状态和最终结果;更重要的是,外层 runLoop() 不会仅因 Provider 返回 stop 就退出。它重新读取 Session:只要还发现工具调用,就继续编译历史。继续条件
MessageV2.toModelMessagesEffect() 会把 completed tool Part 转成模型通用的工具输出块:
{
type: "tool-read",
state: "output-available",
toolCallId: "call_xxx",
input: { filePath: "src/a.ts" },
output: "...文件内容..."
}
Provider adapter 再将这个通用块转换成对应厂商的 tool-result 协议。因此模型在下一次请求中看到的是已绑定、已完成的工具结果;它可以再发 tool-call,流程从执行包装再次开始,也可以输出普通文本并终止 Loop。工具调用形成的是“模型 → 工具 → Session → 模型”的外层循环,而不是工具和模型之间的一次直接回调。
工具主干的代码锚点为:全局注册、每轮 resolve 与执行闭包、工具 ABI、流事件转换、Session 回写、历史重放、权限服务。
代码定位也属于这一层的工具反馈回路:
glob:按文件名模式找候选路径
grep:按内容返回真实路径、行号、命中片段
read:读取已定位文件或目录
相对路径由后端锚定到 instance 工作目录。模型依靠工作目录、搜索结果和失败反馈收敛到正确文件,而不是天然记住仓库每个路径。read、grep、glob
第 4 层:会话整理与本轮输入准备(事实中心和 Prompt 编译)
Session 既是持久化事实中心,也是每轮模型输入的来源。它不会把全部本地状态直接送给模型,而是从历史和配置中选择、转换并编译出一轮上下文。组装位置
一句话:它把散落的历史、规则、工具结果整理成模型此刻真正看得懂的一包输入。
核心流程
1. 用户输入先写成 Session 中最新的 User Message(U1)。
2. Agent Loop 重新读取该 Session 的 Message + Part;已被 compaction 摘要替代的旧历史被过滤。
3. 从 U1 取得本轮 Agent、模型和 Session 权限,并创建空的 Assistant Message(A1)作为本次模型流的输出容器。
4. MessageV2.toModelMessagesEffect() 将已持久化的历史转为 ModelMessage[]:文本、推理、历史 tool-call 和 tool-result 都在这里重放。
5. 同时构建本轮运行上下文:环境、项目 instruction、MCP instruction、Skill 引导,并 resolve 可用工具表。
6. 将 system + ModelMessage[] + tools + model / agent 等输入交给 handle.process();随后 prepare() 编译成真实 Provider 请求。
7. 模型流的 text / reasoning / tool 事件持续写入 A1 下的 Part;工具结果也写回 A1 的 tool Part。
8. 若模型结束且无工具待继续,A1 成为终态回答;若仍需工具结果继续推理,Loop 回到第 2 步,重放 U1 + A1,并创建 A2 接收下一模型回合的输出。
最小代码骨架
以下对应 prompt.ts 的每轮编译,省略 Agent 查找、Plugin 注入和结构化输出分支:
const msgs = yield* MessageV2.filterCompactedEffect(sessionID)
const lastUser = MessageV2.latest(msgs).user!
const msg = {
id: MessageID.ascending(),
parentID: lastUser.id,
role: "assistant",
agent: agent.name,
mode: agent.name,
sessionID,
modelID: model.id,
providerID: model.providerID,
time: { created: Date.now() },
// path / tokens / cost 等初始字段省略
}
yield* sessions.updateMessage(msg) // 先创建本轮输出容器 A1
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system(),
sys.mcp(agent, session.permission),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]
const tools = yield* SessionTools.resolve({ agent, session, model, processor: handle, messages: msgs })
yield* handle.process({ user: lastUser, system, messages: modelMsgs, tools, model })
每次实际发给模型的核心输入可简化为:
system = Agent 规则 + 环境 + 项目 instruction + MCP / Skill 引导
messages = Session 历史重放:用户消息、assistant 消息、已完成工具结果
tools = 本轮允许模型调用的工具 description + JSON Schema
params = model、toolChoice、temperature、maxOutputTokens 等控制参数
每个模型回合先创建输出容器
模型流不是结束后才一次性写入 Session。每次发起模型调用前,Loop 会先创建一条空的 assistant message,并将它交给 SessionProcessor;随后到达的 text-delta、reasoning、tool-call、tool-result 都按 messageID 写入它下面的对应 Part。创建容器;流事件回写
U1:请读 a.ts 并解释
│
├─ A1:本次模型回合预先创建的空 assistant message
│ ├─ text Part:我先读取文件
│ └─ tool Part:read(a.ts) → 文件内容
│
└─ A2:工具结果需要继续推理时,下一模型回合创建的空 assistant message
└─ text Part:基于 a.ts 的分析与最终回答
因此“创建 assistant message”不是提前生成下一条用户输入;它是为当前模型流预留的输出归属。A1 的 tool Part 完成后,外层 Loop 从 Session 重放 U1 + A1,并在下一次模型调用前创建 A2 接收继续输出。这样每个流式事件都能确定写到哪一个 assistant message、哪一个 Part;工具结果也成为下一回合可恢复、可重放的历史事实。
Session 存储模型与模型消息之间的重放
模型不能直接理解 OpenCode 的数据库对象,例如 SessionV1.ToolPart。Session 的 Message + Part 是面向持久化、事件订阅和 UI 展示的内部存储模型;Provider 需要的是面向对话协议的 ModelMessage[]。MessageV2.toModelMessagesEffect() 是二者之间的翻译层:每轮都从持久化的 Session 历史重新生成模型可读消息,而不是依赖上一轮请求的内存。消息重放
Session 存储模型 通用模型消息模型
──────────────────────────────────────────────────────────────
User Message + text Part → role=user 的 text content
Assistant Message + text Part → role=assistant 的 text content
Assistant Message + reasoning Part → role=assistant 的 reasoning content
Assistant Message + completed tool → tool-<name> / output-available
File Part / tool attachment → 可被模型支持的 media content
例如 Session 中的一条 read 工具事实:
// 持久化 / UI 使用的 ToolPart
{
type: "tool",
tool: "read",
callID: "call_xxx",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "...文件内容..."
}
}
重放后变成模型通用的工具输出块:
// 发送给 Provider adapter 前的 ModelMessage content
{
type: "tool-read",
state: "output-available",
toolCallId: "call_xxx",
input: { filePath: "src/a.ts" },
output: "...文件内容..."
}
Provider adapter 再将这个通用形状转换为 OpenAI、Anthropic、Gemini 等各自的实际协议。这里的“重放”只是在重建模型上下文,不会再次执行工具;它保证重试、断开恢复、压缩或下一模型回合仍能依据同一份工具结果继续推理。
Agent 系统规则
+ 环境:工作目录、workspace root、仓库、平台、日期
+ 项目 instruction:AGENTS.md / CLAUDE.md / 配置 instruction
+ MCP instruction 与可发现的 Skill
+ 当前 Session 历史与已完成工具结果
+ 本轮获准工具的 description + JSON Schema
+ 最新用户目标
Skill 正文默认不会全量进入 system prompt;模型通常先知道有哪些 Skill 及如何加载,需要时再读取。Skill system prompt
“写 Session”也不是直接调用 UI 的 appendText。模型/工具的事件先变成 Session 的 message/part 更新;UI、TUI 和 SDK 都订阅这些事件。已完成的工具输出属于当前 message 的 tool Part;Provider 配置、模型目录、MCP client 和工具注册表则是全局运行配置,不会为每个 Session 复制一份。
第 5 层:任务循环与下一步判断(Agent Loop)
一句话:它是总调度员,反复驱动“读状态 → 调模型/工具 → 写结果”,直到这一轮任务真的结束。
核心流程
SessionPrompt.prompt() 写入用户消息、取得该 Session 的串行 runner 后进入主循环。入口;循环
用户输入
→ 写 user message
→ 读取 Session 历史和任务状态
→ 优先处理 subtask / compaction / revert
→ 编译本轮上下文与工具表
→ 调模型并消费 LLMEvent
→ 模型 / 工具结果写回 Session
→ 是否有未完成工具或需要 continue?是则回到“读取 Session”
→ 否则 Session idle
最小代码骨架
以下是 runLoop() 的循环骨架;真正代码还包含标题生成、提醒注入、重试和错误处理:
while (true) {
const msgs = yield* MessageV2.filterCompactedEffect(sessionID)
const { user: lastUser, assistant: lastAssistant, tasks } = MessageV2.latest(msgs)
const hasToolCalls = /* 当前 assistant 是否仍有需回传模型的 tool Part */
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastAssistant.parentID === lastUser.id
) break
const task = tasks.pop()
if (task?.type === "subtask") { yield* handleSubtask({ task, msgs }); continue }
if (task?.type === "compaction") { yield* compaction.process({ msgs, sessionID }); continue }
// 以下进入第 5 节:创建 assistant 容器 → 编译上下文 / 工具 → handle.process()
}
形式上是 while (true),但每轮都重新从 Session 读取历史。因此工具结果、子任务结果、压缩结果都会成为下一轮真实模型上下文。
结束条件不只是 Provider 返回 stop:还要确认最近 assistant 是否对应最新 user message、是否已经终态、是否带 tool-calls,以及是否存在 pending/running tool part。只有“正常结束 + 无待处理工具 + 对应当前用户”才 idle。
Loop 状态机
| 状态载体 | 关键状态 | 职责 |
|---|---|---|
| Session runner | idle、busy、retry | 同一 Session 串行、取消和运行态展示。 |
| Assistant message | finish=stop/tool-calls/error | 判断模型轮次是否可终止。 |
| Tool part | pending、running、completed、error | 判断真实动作是否仍在执行。 |
| SessionProcessor 返回值 | continue、compact、stop | 消费完一次模型流后,建议外层 Loop 继续、先压缩或立即停止。 |
空闲(`idle`) → 接收用户输入 → 运行(`busy`)
运行 → 优先处理子任务 / 上下文压缩 → 重新读取会话
运行 → 组装本轮模型输入 → 调用模型与执行工具 → 终态判断
终态判断 → 继续运行(仍有工具或需要继续)| 空闲(满足 `stop` 条件)
图中的“终态判断 → 本次结束”是唯一的 stop 出口。它不是“模型供应商返回了 stop”这一条信号,而是必须同时满足:assistant 已有 finish、finish 不是 tool-calls、没有仍需回传模型的工具 Part,且该 assistant 属于最新 user message。任一条件不满足都沿“继续”回到“读取当前会话”,由下一回合重新编译上下文。
task、控制结果与终态的边界
Loop 从 MessageV2.latest(msgs) 取出的 task,不是泛指“模型做的任务”,而是 Session 中待处理的控制 Part:subtask 表示先执行一条子 Agent 委派,compaction 表示先压缩上下文。它们是持久化历史的一部分,Loop 每次重读 Session 都会优先调度它们。任务提取;调度分支
不要把 continue / compact / stop 与模型事件混为一谈。text-delta、tool-call、finish 是模型流事件;pending/running/completed/error 是工具 Part 状态;而 continue/compact/stop 是 SessionProcessor.process() 消费完一次模型流后返回给外层 Loop 的控制结果。它分别由“正常完成”“需要压缩”“被阻断或模型错误”等处理状态导出。处理器结果
一次外层终止表示“当前 Session 的这一次 prompt run 完成,runner 回到 idle”,并不表示 Agent 模板生命周期结束;用户下一条输入仍可用同一 Agent、同一 Session 重新启动 Loop。子 Agent 也是如此:子 Session 先终止并将结果写回父 Session 的 task tool Part,父 Loop 是否终止仍要由父会话自己的终态条件决定。
因此,正常情况下“一次 Loop 迭代约等于一次父 Agent 的模型调用”,但并不绝对:遇到 subtask 或 compaction 时,这一轮只处理控制任务,不发父 Agent 的正常模型请求。OpenCode 具备 Session 持久化和循环恢复基础,但没有固定的 Planner、统一 Evaluator 或严格确定性重放机制;它是 Agent Runtime,而不是完整的声明式工作流引擎。
第 6 层:用户入口与界面同步(Runtime 接入)
这一章有两个方向:接入回答“用户和 UI 怎样进入、怎样看到 Runtime 的变化”;运行控制回答“同一次 Session run 依据什么策略选择模型、规则、工具和权限”。二者最终都汇入 SessionPrompt → Agent Loop。
一句话:它把同一个 Agent Runtime 交给 CLI、Web、Desktop、SDK 使用,并让它们看到同一份过程状态。
三个关键关系
1. 同步 prompt 和异步 prompt 启动的是同一个 Agent Loop。
区别只是:同步请求等本次 run 完成;异步请求立即返回,随后靠事件看进度和结果。
2. 模型和工具不直接更新 UI。
它们先更新 Session 的 Message / Part;Runtime 发布变化事件;UI 订阅这些变化后再渲染。
3. Provider、MCP、工具、项目规则等基础配置先在初始化阶段建立 Runtime 能力。
每一轮再按当前 Agent、Session 权限和模型筛选出本轮有效策略;最后才编译为模型输入。
核心流程
CLI / TUI / Web / Desktop / SDK
→ 进程内 Server 或 HTTP Server
→ prompt、取消、审批、查询等 Session 服务
→ SessionPrompt / Agent Loop
→ Message / Part 更新并发布事件
→ 进程内订阅或 SSE 订阅
→ UI 渲染同一份 Session 状态
最小代码骨架
同步与异步入口共享同一个 promptSvc.prompt();区别只有 HTTP handler 是否等待。事件端则把 Runtime 事件队列编码为 SSE。prompt handler;事件 handler
// 同步:本 HTTP 请求等待 Session Loop 完成
const message = yield* promptSvc.prompt({ ...payload, sessionID })
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText))
// 异步:同一调用在后台运行,立即返回
yield* promptSvc.prompt({ ...payload, sessionID }).pipe(Effect.forkIn(scope))
return HttpApiSchema.NoContent.make()
// UI 订阅:事件 → 队列 → SSE;UI 再按 Message / Part 重新渲染
const queue = yield* Queue.unbounded<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) => Queue.offerUnsafe(queue, event))
return HttpServerResponse.stream(Stream.fromQueue(queue).pipe(Stream.pipeThroughChannel(Sse.encode())))
请求进入与事件返回
Server 的 HTTP handler 将 prompt payload 直接交给 SessionPrompt.prompt();SDK/CLI 也可调用进程内 Server.Default.fetch(),不需要监听 HTTP 端口。进程内 Server;prompt handler
| 调用方式 | Server 行为 | 客户端如何取得结果 |
|---|---|---|
| 同步 prompt | 等待 promptSvc.prompt() 完成本次 Session run,再返回最终 message。 | HTTP 响应拿最终结果;也可同时订阅事件展示过程。 |
| 异步 prompt | 将同一 promptSvc.prompt() 放到后台执行,HTTP 立即返回。 | 订阅事件或后续查询 Session。 |
同步与异步不代表两套 Agent 执行逻辑;它们启动的是同一个 Session、同一个 Agent Loop、同一套模型/工具/权限路径。异步入口
模型和工具并不直接操作 UI。它们先更新 Session 的 Message / Part,Runtime 再发布事件;进程内客户端可直接监听,Web 等远程客户端由 Server 将同一事件流编码为 SSE 后订阅。事件 handler 在注册监听后建立队列、按 workspace 过滤事件,并发送连接事件与心跳。SSE 事件桥接
模型 / 工具
→ Session Message / Part 更新
→ Runtime 事件
→ 进程内订阅 或 SSE
→ TUI / Web / Desktop 渲染
权限审批也走同一条返回路径:工具发起 ask,UI 订阅到审批事件;用户允许/拒绝后调用 permissionRespond,Server 再调用 Permission.reply() 解除或拒绝被暂停的工具执行。审批回复
运行控制:初始化能力 → 本轮策略 → 模型请求
“配置、Agent、项目规则、Plugin、Skill、MCP、Provider、工作区”不是一个会原样发送给模型的大对象,而是三层来源:
1. 初始化能力:系统有什么能力
Provider / 账户、MCP client、工具注册表、数据库、Server、工作区
2. 本轮有效策略:当前这次允许用什么
当前 Agent + Session 权限 + 项目规则 + 当前模型
3. 模型请求包:真正给模型的内容
system + messages + tools + params
初始化阶段加载并合并基础配置,建立长期运行的 Provider、MCP、工具注册和工作区能力;它们不会整体进入 prompt。Agent 服务再合并默认值与用户配置,得到模型、prompt、采样参数、mode、steps、options、permission 等策略。配置合并;Agent 合并
每次 Loop 运行时,才依据当前 Agent、当前 Session、当前模型再次筛选和编译:项目 instruction、Skill、MCP instruction、Session 历史与工具表汇入模型输入。也就是说,初始化决定“有什么能力”,每轮编译决定“当前能启用什么”,最终只有编译产物进入模型。每轮汇合点
| 控制来源 | 本轮的实际影响 |
|---|---|
| Agent | 选择模型、Agent prompt、采样参数、最大 steps、默认权限。 |
| Session 权限 | 与 Agent 权限合并,筛选工具并约束真实执行。 |
| 项目规则 / Skill / MCP | 形成 system 引导;MCP 已发现工具可加入候选工具表。 |
| Plugin | 改写 messages、system、params、headers,或包裹工具执行。 |
| Provider / 账户 | 提供模型 adapter、认证、headers、provider options。 |
| 工作区 | 提供 cwd、worktree、Git、目录边界和环境提示。 |
子 Agent 与后台任务
Agent 是模板;Session 是一次具体运行。开子 Agent 不只是把父会话加一个角色,而是创建带 parentID 的独立子 Session,并运行自己的 Loop:
父 Session S0 → task(subagent_type, prompt)
→ 子 Session S1(parentID=S0,agent=指定 Agent)
→ S1 的 模型 → 工具 → 模型 loop
→ 最终 text → 父任务的 task tool result
→ 父 Session 下一轮模型调用
TaskTool.execute() 检查委派权限和深度、选择子 Agent 与模型、创建子 Session 并调用它的 prompt()。TaskTool
子 Session 的直接背景来自 task.prompt,不复制父 Session 全量历史;它使用自己的权限,并承接父任务的 deny 与外部目录限制,默认限制再次开 task,避免无限递归。子 Agent 权限
| 模式 | 父任务行为 | 结果回传 |
|---|---|---|
| 前台(默认) | task 等子 Session job 完成 | 作为当前 tool result,父 loop 再调模型。 |
| 后台(实验功能) | task 立即返回 running | job 完成后写 synthetic user message 到父 Session,再唤醒父 loop。 |
第四部分:长任务支撑机制(Agent Loop 的扩展)
这不是第七层;它是第 5 层为了处理长上下文、回退和后台工作而使用的持久化辅助机制。
一句话:任务太长时,不让 Loop 丢失现场,而是把“下一步要恢复什么”也写进 Session。
核心流程
正常 Session Loop
→ 发现上下文溢出、用户 revert、需要标题/摘要等条件
→ 创建或触发对应的持久化辅助任务 / 快照操作
→ 下一次 Loop 优先处理 compaction、subtask、revert 等状态
→ 更新 Session 历史或工作区事实
→ 回到正常模型 → 工具 → Session 循环
最小代码骨架
压缩不是在 Loop 外另开一个不可见任务;它先写成持久化的 compaction Part,随后由 Loop 优先处理。创建压缩任务;Loop 分支
// 1. 先把“需要压缩”写入 Session,重启后仍能发现
const message = yield* session.updateMessage({ role: "user", sessionID, agent, model })
yield* session.updatePart({
messageID: message.id,
sessionID,
type: "compaction",
auto,
})
// 2. 下一次 Loop 读取 task 后优先处理
if (task?.type === "compaction") {
const result = yield* compaction.process({ messages: msgs, sessionID, parentID: lastUser.id })
if (result === "stop") break
continue
}
上下文压缩:生成可重放的摘要历史
压缩本身是一次特殊模型调用,不是直接删除或截断 Session 历史。它先把旧历史划分为需要总结的 head 和原样保留的近期 tail;tail 会按 token 预算和 tail_turns 配置从最新回合向前选择。head / tail 选择
原历史:U1 → A1 → U2 → A2 → U3 → A3 → U4 → A4
1. Loop 写入 C1:系统生成的 compaction pseudo-user message + Compaction Part。
2. Loop 优先调度 C1;compaction Agent 将旧段 head 连同压缩提示交给模型。
3. 模型生成 S1:agent=compaction、summary=true 的 assistant 摘要消息。
4. C1 的 Compaction Part 记录 tail_start_id,指出 U4 / A4 等近期事实从哪里开始保留。
5. 下一次普通模型调用重放:C1 + S1 + tail + 后续用户消息,而不再发送完整旧历史。
因此压缩后的下一轮模型上下文可理解为“系统生成的压缩任务 + 模型生成的摘要 + 最近原始历史”。C1 不是用户真实输入;它是 Runtime 写入的持久化控制任务。自动压缩成功后,Runtime 还可能写入一条 synthetic “继续执行”用户消息来唤醒正常 Loop。
旧历史 head 仍保留在数据库,用于审计和追溯;
filterCompacted() 决定下次模型请求不再重放它,而是发送 summary + tail。
这与展示用途的标题、文件 diff 摘要不同:compaction summary 会改变后续模型看到的上下文;标题和文件 diff 仅用于展示、检索或审计。历史重放过滤
| 机制 | 作用 | 代码 |
|---|---|---|
| Revert | 恢复文件快照,并清理被放弃分支后续历史,保证文件与 prompt 历史一致。 | revert.ts |
| Compaction | 接近上下文窗口时写入 compaction pseudo-user task,优先压缩后继续。 | compaction.ts |
| 标题与摘要 | 后台生成会话标题、文件差异摘要,供展示、恢复与审计。 | summary.ts |
| 消息转换 | 将持久化文本、附件、工具调用/结果转为 provider-ready messages。 | message-v2.ts |
推荐阅读路径
- 本地数据库、Session 表:先明确本地事实边界。
- MCP、LLM 请求:外部通道。
- 工具桥接、权限:动作如何落地。
- 上下文、消息转换:模型实际看到什么。
- 主 loop、子 Agent:最后理解编排。
范围与证据边界
基于静态源码 2859603;未执行真实 Provider/MCP 调用。Loop、工具和子 Agent 主线以 packages/opencode/src/session 的现有 runtime 为准;Session 本地持久化补充 packages/core 的 V2 实现。不同 Provider 的实际 wire-format、不同 MCP server 的行为仍应以 adapter 实现或真实运行证据为准。