opencode 会话全流程:消息组织、单轮步骤与中断重试的落点
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
在 opencode 里,一次会话不是一个常驻内存的对话对象,而是一串可回放的消息与部件写入;真正决定下一步做什么的,是循环每轮重新把历史读出来之后当场做的判断。 想清楚这一点,很多现象就不奇怪了:中断之后再发一句话它能接着干;同一个会话你连发两条 prompt 不会跑出两轮;工具跑到一半被打断,历史里会留下一个明确标记为”被中断”的工具部件,而不是凭空消失。
opencode 是一个跑在终端里的开源编码 Agent,采用 MIT 许可证(LICENSE,Copyright 2025 opencode),仓库在 https://github.com/anomalyco/opencode 。packages/ 下有 32 个包,其中会话这条主链路集中在 packages/opencode/src/session/。这篇只讲会话本身的生命周期。站内另外三篇是不同的分工:Hermes Agent 的会话生命周期讲的是另一个常驻自托管项目怎么管会话,pi 的 Agent 主循环拆的是另一套实现的循环骨架,状态机与自由循环的取舍是脱离具体项目谈设计选择——本文只回答一个问题:opencode 这份代码里,一次会话从头到尾具体发生了什么。
一、会话由什么构成:会话行、消息、部件三层
最外层是会话本身。packages/opencode/src/session/session.ts 里的 Info 定义了一行会话记录:id、slug、projectID、directory、path、parentID、title、agent、model、version、cost、tokens、share、metadata、permission、revert,以及一个 time 结构(created/updated/compacting/archived)。fromRow 和 toRow 负责行与结构之间的互转。注意 parentID:子会话(比如子任务派生出来的)是同一张表里的另一行,靠 parentID 挂上去,children 就是按它查。
中间层是消息。用户消息与助手消息共用一张消息表,助手消息带 providerID/modelID/agent/cost/tokens/finish/error 这些执行侧字段,并用 parentID 指回触发它的那条用户消息。
最里层是部件(part)。一条消息不是一段文本,而是一串有类型的部件:text、reasoning、tool、step-start、step-finish、patch、file、compaction、subtask、agent。你在终端里看到的流式输出、工具卡片、改了哪些文件的摘要,本质都是这些部件的渲染。
这里有个容易被忽略的设计:session.ts 里的 updateMessage 和 updatePart 并不直接写库,它们只是 events.publish 发出事件,把事件写进消息表与部件表的是投影侧的 packages/core/src/session/projector.ts;读路径则走 message-v2.ts 的 page,按 time_created 与 id 倒序分页、再用 hydrate 把 parts 拼回去,分页游标是把 { id, time } 序列化成 base64url。写是事件、读是投影,这是后面所有”中断了还能接着跑”的前提。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 会话行与消息读写 | 会话字段定义、消息/部件的事件发布、分页读取、fork | packages/opencode/src/session/session.ts | 查会话列表、fork、发现会话字段不对 |
| 消息到请求的降维 | 把部件转成模型消息,处理工具结果、媒体、跨模型回放 | packages/opencode/src/session/message-v2.ts | 供应商报”缺少 tool_result”、附件没送进去 |
| 一轮循环的编排 | 重读历史、判断退出、装配工具与系统提示、发起一轮 | packages/opencode/src/session/prompt.ts | 循环不停、循环提前退出、命令与子任务行为 |
| 流事件落盘 | 把模型流事件变成部件,结算用量,收尾清理 | packages/opencode/src/session/processor.ts | 工具状态卡住、文本没落盘、快照/改动摘要缺失 |
| 重试判定与退避 | 判断错误可不可重试、算等待时间、推重试状态 | packages/opencode/src/session/retry.ts | 一直在重试或该重试却没重试 |
| 会话并发与取消 | 同一会话的运行态机、取消、忙碌拒绝 | packages/opencode/src/session/run-state.ts 与 packages/opencode/src/effect/runner.ts | 并发发消息、取消不干净、报会话忙 |
| 契约与未尽事项 | V2 会话 API、上下文纪元、压缩、遗留缺口清单 | specs/v2/session.md | 想知道某个行为是有意为之还是还没做 |
二、一轮循环走哪几步
入口在 prompt.ts。prompt(input) 先取会话、清理 revert 状态,再走 createUserMessage 把你输入的内容变成一条用户消息加一串部件,然后 touch 更新时间,最后交给 loop。loop 本身只做一件事:把真正的循环 runLoop 交给 state.ensureRunning,也就是交给并发层去决定”是新起一轮还是并入正在跑的那一轮”。
runLoop 是一个 while (true),每轮的顺序是固定的,值得逐条记住:
- 把会话状态置为
busy,打一条loop日志(带step序号)。 - 用
MessageV2.filterCompactedEffect重新读一遍历史。这是每轮都做的,不是缓存的。 MessageV2.latest从历史里挑出最后一条用户消息、最后一条助手消息、最后一条完成的助手消息,以及尚未处理的compaction/subtask任务。- 判断要不要退出:最后一条助手消息有
finish、finish不是tool-calls、消息里没有未完成的工具部件、且最后一条用户消息比它旧——四个条件同时满足才 break。这里专门注释了一个真实坑:有些供应商在消息里明明带着工具调用却返回stop,所以不能只看finish。 step++。第一轮会顺带 fork 一个后台任务去生成会话标题(用title智能体或小模型),失败被忽略,不阻塞主循环。- 取出待处理任务:
subtask走handleSubtask起子任务,做完continue;compaction走compaction.process,返回"stop"时直接退出循环,否则continue。两条路都不进入本轮的模型调用。 - 检查上一轮完成的助手消息是否已经溢出(
compaction.isOverflow),是就compaction.create生成一个压缩任务,continue。 - 取 agent,算
maxSteps(agent.steps ?? Infinity);到达最后一步时,会在请求消息尾部追加一条内容为MAX_STEPS_PROMPT的 assistant 消息。 SessionReminders.apply处理提醒类注入,然后建一条新的 assistant 消息落盘。processor.create拿到本轮的处理句柄,装配工具(SessionTools.resolve)、系统提示(环境、指令、MCP 说明、技能说明四段拼起来),把历史toModelMessagesEffect降成模型消息,调handle.process。- 按返回值决定去向:
"stop"就 break,"compact"就建压缩任务后继续,"continue"就下一轮。
循环退出后,fork 一个 compaction.prune,返回最后一条助手消息。
对你的意义:这个循环没有隐藏状态。每轮的判断依据全部来自刚从存储里读出来的历史,所以”上一轮跑到哪了”永远写在数据里。相应地,任何你希望它下一轮看到的东西,都必须先变成消息或部件落下去——这也是子任务完成后要额外插一条”总结上面 task 工具的输出并继续”合成用户消息的原因。
三、模型流事件是怎么变成部件的
processor.ts 的 handleEvent 是一个大 switch,按事件类型把流转成写入:
- 文本:
text-start建一个text部件,text-delta往上累加并调updatePartDelta发增量事件(不重写整段),text-end时先触发experimental.text.complete插件钩子,允许插件改写最终文本,再整体落盘。 - 推理:
reasoning-start/reasoning-delta/reasoning-end同构,孤儿增量(没有对应 start)会被静默丢掉。 - 工具:
ensureToolCall先建一个status: "pending"的工具部件并挂一个Deferred用于结算;tool-call把它推进到running并写入入参;tool-result走completeToolCall转completed,tool-error走failToolCall转error。图片附件会先过image.normalize,压不下去的会被剔除并在输出里注明省略了几张。 - 死循环兜底:
tool-call时会看最近DOOM_LOOP_THRESHOLD(当前是 3)个部件是否全是同名同入参的工具调用,是就发起一次doom_loop权限询问,把决定权交回给你。 - 步骤与快照:
step-start记一次snapshot.track();step-finish结算用量与成本(Session.getUsage),写step-finish部件,再用snapshot.patch算出这一步改了哪些文件,有改动就写一个patch部件。同时按isOverflow判断是否置needsCompaction。 - 流的消费用
Stream.takeUntil(() => ctx.needsCompaction),也就是一旦判定需要压缩,本轮流就不再往下消费了。
process 的返回值只有三种:"compact"、"stop"、"continue"。这三个值把”要不要继续跑”这件事收敛成了一个明确的契约,循环层不需要理解流事件的细节。
四、中断与重试分别落在哪一层
这是最值得单独看的部分,因为它被有意拆到了四层,出问题时你要知道去哪一层查。
第一层,会话并发与取消:run-state.ts 借助 effect/runner.ts 的状态机,把一个会话的运行态收敛为 Idle/Running/Shell/ShellThenRun。ensureRunning 在已经 Running 时不会新起一轮,而是让调用方 await 同一个 done;startShell 在非 Idle 时直接失败为 RunnerBusy,上层转成 Session.BusyError。cancel 会中断 fiber、把 done 置为 Cancelled,并通过 onInterrupt 回退成”返回最后一条助手消息”。SessionPrompt.cancel 本身只有一行实质逻辑——调 state.cancel。
第二层,单轮执行的收尾:processor.process 的管道结构本身就是答案(packages/opencode/src/session/processor.ts):
Effect.onInterrupt(() =>
Effect.gen(function* () {
aborted = true
if (!ctx.assistantMessage.error) {
yield* halt(new DOMException("Aborted", "AbortError"))
}
}),
),
// …
Effect.retry(SessionRetry.policy({ /* … */ })),
Effect.catch(halt),
Effect.ensuring(cleanup()),
cleanup() 是无论成功、失败还是被打断都会跑的:补上未落盘的 patch 部件、给未结束的文本与推理部件补 end 时间、对还挂着的工具调用等待各自的 Deferred(有一个短超时),然后把仍处于运行态的工具部件改成 status: "error"、error: "Tool execution aborted",并在 metadata 里打上 interrupted: true。这个标记后面会被两处用到。
第三层,重试:retry.ts 的 policy 把”这个错误值不值得重试”和”该等多久”分开。retryable 明确规定:ContextOverflowError 不重试;APIError 只在被标为可重试、或状态码 5xx 时重试(5xx 即使 SDK 没标也重试);此外还会从纯文本错误里识别限流类措辞。等待时间优先读响应头里的 retry-after-ms,其次 retry-after(同时支持秒数和 HTTP 日期格式),都没有才退回指数退避。每次重试都会通过 status.set 推一个 { type: "retry", attempt, message, action, next },所以终端能显示”第几次重试、下一次什么时候”。各家供应商的限流与配额规则不同且会调整,以官方最新说明为准,这一层只负责按响应做机制性反应。
第四层,请求装配时的兜底:中断留下的痕迹最终要能被重新发给模型。message-v2.ts 在把历史降成模型消息时,对没有结果的工具调用做了显式处理:
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
// …
})
反过来,带 interrupted 标记且保留了字符串输出的失败工具,会被还原成 output-available,让模型看到那部分已经产生的输出。prompt.ts 里的 isOrphanedInterruptedTool 则在循环退出判定时把这类”已被清理标记的孤儿工具”排除掉,避免它们让循环误以为还有活要干。
还有一条特殊路径:上下文溢出不算失败。halt 里如果解析出 ContextOverflowError,且配置没把 compaction.auto 关掉,它不会终止会话,而是置 needsCompaction,让循环转去做压缩再重来。把 compaction.auto 设成 false,同一个错误就直接变成终止错误。
五、边界与代价:它明确不管的事
- 不做沙箱。
specs/v2/session.md写得很直白:bash 不沙箱,spawn 出来的 shell 拥有宿主用户的文件系统、进程与网络权限;对绝对路径参数的扫描只产生建议性警告,不是安全边界。也就是说,权限规则放宽到什么程度,你就把多大的机器控制权交出去了。默认无匹配规则时是ask,把它改成全量放行之前请想清楚后果。 - 改文件没有原子回滚。
apply_patch会解析全部 hunk、预检目标,然后顺序提交;后面某一步失败时,前面已经落盘的改动不会回退,只会返回一份显式的部分应用报告。移动与原子回滚在规格里被列为后续项,不是既有能力。快照产生的patch部件是”记录改了什么”,不等于”能一键撤销”。 - 你引用的每个文件都会进请求。用
@引到的文件会被读出来变成合成文本部件,图片和 PDF 会以 base64 data URL 进消息。私有代码与密钥的外泄面,等于你在会话里引用过的全部内容加上工具读过的全部内容。仓库里对 MCP 资源附件设了体积与类型限制,但对”该不该给模型看”这件事,它不替你判断。 - 崩溃后的续跑没做。规格明确写了 post-crash continuation recovery 是有意推迟的:唤醒不会去推断一段语义模糊的供应商侧工作可以安全重放。集群化的会话执行归属、陈旧运行时围栏同样列在待办里。
- 本地工具的急切执行不设上限。规格里写明这是当前切片有意为之,先把工具延迟压下来,是否加每轮调用上限、输出截断与背压要等真实负载数据。
session.next.*事件族仍是实验性未发布的,早期实验版本建的数据库被明确定义为可丢弃,不是兼容目标。
六、上手与避坑清单
- 别把”我按了中断”等同于”工具没跑”。为什么会踩:
cleanup只是把还在运行的工具部件标成中断,它无法撤销工具已经产生的外部副作用(文件已改、命令已执行)。怎么避:中断后第一件事是看git status和这一轮的patch部件,而不是直接补一句”刚才那个别做了”。 - 别只看
finish判断一轮是否结束。为什么会踩:部分供应商在带工具调用的消息上也返回stop,只看finish会以为该收尾了。怎么避:跟着runLoop的四条件一起看,日志里的exiting loop才是真正的退出点。 - 同一会话别并发投两条 prompt。为什么会踩:
ensureRunning在已运行时会把新调用并到同一次运行上,你以为发了两轮,实际只有一轮,第二条输入的时序也不由你控制。怎么避:要串行就等上一次返回;要真正的排队语义,看规格里steer与queue两种投递模式的定义再决定怎么用。 - 关掉自动压缩前先想好兜底。为什么会踩:
compaction.auto为false时,溢出会从”自动压缩再来一轮”变成”直接报错终止”。怎么避:要么保留自动压缩,要么自己在会话变长前主动开新会话。 - 同一个工具同参数连调三次会被拦。为什么会踩:
DOOM_LOOP_THRESHOLD是防打转的兜底,连续三个同名同入参的工具部件就会触发doom_loop权限询问。怎么避:这通常说明提示或工具返回没给出有效反馈,先改工具描述与返回内容,而不是一路点允许。 - 一轮里别换模型。为什么会踩:
toModelMessagesEffect判定历史消息的模型与当前模型不同时,推理部件会降级成普通文本,供应商元数据也不再回放。怎么避:把换模型放在一轮结束之后;换完就别指望带签名的推理内容还能原样接续。 - 项目级指令文件别堆太多。为什么会踩:环境事实、日期、逐级向上发现的
AGENTS.md、所选智能体的技能说明都会进上下文纪元的基线,堆得越多每轮固定开销越大。怎么避:定期精简;确实需要临时关闭项目配置发现时,规格里给了OPENCODE_DISABLE_PROJECT_CONFIG这个开关。 - 权限别一把全放行。为什么会踩:会话级
permission规则集会和 agent 的规则合并生效,一处放宽会影响整条链路,包括子任务。怎么避:按工具粒度配,把可能改文件、执行命令、访问外部目录的部分留在需要确认的档位上。工具权限该怎么切分,可以对照最小权限设计那篇的做法。
最后:接下来该读哪个文件
如果你要继续往下挖,建议按这个顺序:先把 packages/opencode/src/session/prompt.ts 里的 runLoop 从头读到尾,它是整条链路的骨架;再读 packages/opencode/src/session/processor.ts 的 handleEvent 和 cleanup,理解写入与收尾;然后 packages/opencode/src/session/message-v2.ts 的 toModelMessagesEffect,理解历史是怎么被降成请求的;最后回到 specs/v2/session.md,它不仅描述目标形态,还诚实列了哪些能力尚未具备。
排查问题时可以用这份自检:现象出现在写入(部件状态不对)还是读取(请求里少了东西)?如果是循环不停,先确认最后一条助手消息的 finish 与工具部件状态;如果是重试异常,先看状态事件里的 attempt 与错误分类;如果是中断后行为怪,先找带 interrupted 标记的工具部件。定位到层,剩下的就只是读那一个文件的事。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 终端 Agent 项目 opencode 仓库结构导读:32 个包里哪些是主干,改功能从哪进 和 opencode 上下文压缩拆解:压缩与溢出是两条线,压完之后你会丢什么。