opencode 会话全流程:消息组织、单轮步骤与中断重试的落点

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

在 opencode 里,一次会话不是一个常驻内存的对话对象,而是一串可回放的消息与部件写入;真正决定下一步做什么的,是循环每轮重新把历史读出来之后当场做的判断。 想清楚这一点,很多现象就不奇怪了:中断之后再发一句话它能接着干;同一个会话你连发两条 prompt 不会跑出两轮;工具跑到一半被打断,历史里会留下一个明确标记为”被中断”的工具部件,而不是凭空消失。

opencode 是一个跑在终端里的开源编码 Agent,采用 MIT 许可证(LICENSE,Copyright 2025 opencode),仓库在 https://github.com/anomalyco/opencodepackages/ 下有 32 个包,其中会话这条主链路集中在 packages/opencode/src/session/。这篇只讲会话本身的生命周期。站内另外三篇是不同的分工:Hermes Agent 的会话生命周期讲的是另一个常驻自托管项目怎么管会话,pi 的 Agent 主循环拆的是另一套实现的循环骨架,状态机与自由循环的取舍是脱离具体项目谈设计选择——本文只回答一个问题:opencode 这份代码里,一次会话从头到尾具体发生了什么。

一、会话由什么构成:会话行、消息、部件三层

最外层是会话本身。packages/opencode/src/session/session.ts 里的 Info 定义了一行会话记录:idslugprojectIDdirectorypathparentIDtitleagentmodelversioncosttokenssharemetadatapermissionrevert,以及一个 time 结构(created/updated/compacting/archived)。fromRowtoRow 负责行与结构之间的互转。注意 parentID:子会话(比如子任务派生出来的)是同一张表里的另一行,靠 parentID 挂上去,children 就是按它查。

中间层是消息。用户消息与助手消息共用一张消息表,助手消息带 providerID/modelID/agent/cost/tokens/finish/error 这些执行侧字段,并用 parentID 指回触发它的那条用户消息。

最里层是部件(part)。一条消息不是一段文本,而是一串有类型的部件:textreasoningtoolstep-startstep-finishpatchfilecompactionsubtaskagent。你在终端里看到的流式输出、工具卡片、改了哪些文件的摘要,本质都是这些部件的渲染。

这里有个容易被忽略的设计:session.ts 里的 updateMessageupdatePart 并不直接写库,它们只是 events.publish 发出事件,把事件写进消息表与部件表的是投影侧的 packages/core/src/session/projector.ts;读路径则走 message-v2.tspage,按 time_createdid 倒序分页、再用 hydrate 把 parts 拼回去,分页游标是把 { id, time } 序列化成 base64url。写是事件、读是投影,这是后面所有”中断了还能接着跑”的前提。

组成部分它负责什么对应仓库位置你什么时候会碰到它
会话行与消息读写会话字段定义、消息/部件的事件发布、分页读取、forkpackages/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.tspackages/opencode/src/effect/runner.ts并发发消息、取消不干净、报会话忙
契约与未尽事项V2 会话 API、上下文纪元、压缩、遗留缺口清单specs/v2/session.md想知道某个行为是有意为之还是还没做

二、一轮循环走哪几步

入口在 prompt.tsprompt(input) 先取会话、清理 revert 状态,再走 createUserMessage 把你输入的内容变成一条用户消息加一串部件,然后 touch 更新时间,最后交给 looploop 本身只做一件事:把真正的循环 runLoop 交给 state.ensureRunning,也就是交给并发层去决定”是新起一轮还是并入正在跑的那一轮”。

runLoop 是一个 while (true),每轮的顺序是固定的,值得逐条记住:

  1. 把会话状态置为 busy,打一条 loop 日志(带 step 序号)。
  2. MessageV2.filterCompactedEffect 重新读一遍历史。这是每轮都做的,不是缓存的。
  3. MessageV2.latest 从历史里挑出最后一条用户消息、最后一条助手消息、最后一条完成的助手消息,以及尚未处理的 compaction/subtask 任务。
  4. 判断要不要退出:最后一条助手消息有 finishfinish 不是 tool-calls、消息里没有未完成的工具部件、且最后一条用户消息比它旧——四个条件同时满足才 break。这里专门注释了一个真实坑:有些供应商在消息里明明带着工具调用却返回 stop,所以不能只看 finish
  5. step++。第一轮会顺带 fork 一个后台任务去生成会话标题(用 title 智能体或小模型),失败被忽略,不阻塞主循环。
  6. 取出待处理任务:subtaskhandleSubtask 起子任务,做完 continuecompactioncompaction.process,返回 "stop" 时直接退出循环,否则 continue。两条路都不进入本轮的模型调用。
  7. 检查上一轮完成的助手消息是否已经溢出(compaction.isOverflow),是就 compaction.create 生成一个压缩任务,continue
  8. 取 agent,算 maxStepsagent.steps ?? Infinity);到达最后一步时,会在请求消息尾部追加一条内容为 MAX_STEPS_PROMPT 的 assistant 消息。
  9. SessionReminders.apply 处理提醒类注入,然后建一条新的 assistant 消息落盘。
  10. processor.create 拿到本轮的处理句柄,装配工具(SessionTools.resolve)、系统提示(环境、指令、MCP 说明、技能说明四段拼起来),把历史 toModelMessagesEffect 降成模型消息,调 handle.process
  11. 按返回值决定去向:"stop" 就 break,"compact" 就建压缩任务后继续,"continue" 就下一轮。

循环退出后,fork 一个 compaction.prune,返回最后一条助手消息。

对你的意义:这个循环没有隐藏状态。每轮的判断依据全部来自刚从存储里读出来的历史,所以”上一轮跑到哪了”永远写在数据里。相应地,任何你希望它下一轮看到的东西,都必须先变成消息或部件落下去——这也是子任务完成后要额外插一条”总结上面 task 工具的输出并继续”合成用户消息的原因。

三、模型流事件是怎么变成部件的

processor.tshandleEvent 是一个大 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-resultcompleteToolCallcompletedtool-errorfailToolCallerror。图片附件会先过 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/ShellThenRunensureRunning 在已经 Running 时不会新起一轮,而是让调用方 await 同一个 donestartShell 在非 Idle 时直接失败为 RunnerBusy,上层转成 Session.BusyErrorcancel 会中断 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.tspolicy 把”这个错误值不值得重试”和”该等多久”分开。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.* 事件族仍是实验性未发布的,早期实验版本建的数据库被明确定义为可丢弃,不是兼容目标。

六、上手与避坑清单

  1. 别把”我按了中断”等同于”工具没跑”。为什么会踩:cleanup 只是把还在运行的工具部件标成中断,它无法撤销工具已经产生的外部副作用(文件已改、命令已执行)。怎么避:中断后第一件事是看 git status 和这一轮的 patch 部件,而不是直接补一句”刚才那个别做了”。
  2. 别只看 finish 判断一轮是否结束。为什么会踩:部分供应商在带工具调用的消息上也返回 stop,只看 finish 会以为该收尾了。怎么避:跟着 runLoop 的四条件一起看,日志里的 exiting loop 才是真正的退出点。
  3. 同一会话别并发投两条 prompt。为什么会踩:ensureRunning 在已运行时会把新调用并到同一次运行上,你以为发了两轮,实际只有一轮,第二条输入的时序也不由你控制。怎么避:要串行就等上一次返回;要真正的排队语义,看规格里 steerqueue 两种投递模式的定义再决定怎么用。
  4. 关掉自动压缩前先想好兜底。为什么会踩:compaction.autofalse 时,溢出会从”自动压缩再来一轮”变成”直接报错终止”。怎么避:要么保留自动压缩,要么自己在会话变长前主动开新会话。
  5. 同一个工具同参数连调三次会被拦。为什么会踩:DOOM_LOOP_THRESHOLD 是防打转的兜底,连续三个同名同入参的工具部件就会触发 doom_loop 权限询问。怎么避:这通常说明提示或工具返回没给出有效反馈,先改工具描述与返回内容,而不是一路点允许。
  6. 一轮里别换模型。为什么会踩:toModelMessagesEffect 判定历史消息的模型与当前模型不同时,推理部件会降级成普通文本,供应商元数据也不再回放。怎么避:把换模型放在一轮结束之后;换完就别指望带签名的推理内容还能原样接续。
  7. 项目级指令文件别堆太多。为什么会踩:环境事实、日期、逐级向上发现的 AGENTS.md、所选智能体的技能说明都会进上下文纪元的基线,堆得越多每轮固定开销越大。怎么避:定期精简;确实需要临时关闭项目配置发现时,规格里给了 OPENCODE_DISABLE_PROJECT_CONFIG 这个开关。
  8. 权限别一把全放行。为什么会踩:会话级 permission 规则集会和 agent 的规则合并生效,一处放宽会影响整条链路,包括子任务。怎么避:按工具粒度配,把可能改文件、执行命令、访问外部目录的部分留在需要确认的档位上。工具权限该怎么切分,可以对照最小权限设计那篇的做法。

最后:接下来该读哪个文件

如果你要继续往下挖,建议按这个顺序:先把 packages/opencode/src/session/prompt.ts 里的 runLoop 从头读到尾,它是整条链路的骨架;再读 packages/opencode/src/session/processor.tshandleEventcleanup,理解写入与收尾;然后 packages/opencode/src/session/message-v2.tstoModelMessagesEffect,理解历史是怎么被降成请求的;最后回到 specs/v2/session.md,它不仅描述目标形态,还诚实列了哪些能力尚未具备。

排查问题时可以用这份自检:现象出现在写入(部件状态不对)还是读取(请求里少了东西)?如果是循环不停,先确认最后一条助手消息的 finish 与工具部件状态;如果是重试异常,先看状态事件里的 attempt 与错误分类;如果是中断后行为怪,先找带 interrupted 标记的工具部件。定位到层,剩下的就只是读那一个文件的事。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 终端 Agent 项目 opencode 仓库结构导读:32 个包里哪些是主干,改功能从哪进opencode 上下文压缩拆解:压缩与溢出是两条线,压完之后你会丢什么

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。