第 5 章 · 01 Session 生命周期与 harness loop 主循环 本节摘要:本节精读 Reasonix 的核心引擎 ——全项目最大的包(约 59K 行,含测试)。它把一个 (模型流式接口)、一个 (工具集)和一个 (会话历史)织成 harness loop 主循环:接收用户输入 → 组装请求 → 调 → 处理 → 执行工具 → 返回响应 → 循环。Session 管理会话的创建/初始化/运行/结束全生命周期,并用一把 保护"运行时单写、前端并发读"的消息数组。
本节摘要:本节精读 Reasonix 的核心引擎
internal/agent——全项目最大的包(约 59K 行,含测试)。它把一个Provider(模型流式接口)、一个tool.Registry(工具集)和一个Session(会话历史)织成 harness loop 主循环:接收用户输入 →control.Compose组装请求 → 调Provider.Stream→ 处理tool_call→ 执行工具 → 返回响应 → 循环。Session 管理会话的创建/初始化/运行/结束全生命周期,并用一把sync.RWMutex保护"运行时单写、前端并发读"的消息数组。本节最值得揣摩的是 agent 包如何做到"一个大包、多个子结构(Session/harness/compact/cache_shape/fleet/coordinator/branch)各管一摊又严守 Cache-first 契约"。
内容来源:原项目源码
internal/agent/(agent.go、session.go、run_loop.go、execute_one.go、coordinator.go、fleet.go、compact.go、cache_shape.go、branch.go),精读并套用体系化模板。
⚠️ 注意:agent 包里"工具失败后的自愈"(重读文件、回显 schema、循环保护)与
internal/repair包(Reasonix 自身升级事务的安全网)是两个不同概念,本节只讲前者,后者在第 5 章 03 节澄清。
阅读完本节,你应当能够:
internal/agent 包的核心子结构(Session/harness loop/compact/cache_shape/fleet/coordinator/branch)各自管什么。Messages 数组的并发模型。rewriteVersion 与 version 两个计数器在会话保存中的作用。runLoopState / perTurnState 两个状态结构为何要分开。internal/agent 是 Reasonix 的"发动机舱"。包注释开宗明义:
// Package agent wires a Provider, a tool Registry, and a Session into the // harness loop that drives a coding task to completion.
翻译过来就是:把一个模型流(Provider)、一个工具表(tool.Registry)、一份会话历史(Session)接到一起,装进一个 harness loop(驾驭循环),把一个编码任务一直推到完成。这个包有多大?目录里非测试文件就有 agent.go、session.go、run_loop.go、execute_one.go、compact.go、prune.go、cache_shape.go、coordinator.go、fleet.go、branch.go、scheduler.go、parallel_tasks.go、subagent_*.go、recovery_*.go、interrupted_recovery.go 等近 40 个,算上测试约 59K 行。它是全项目最大、最核心的包。
包内部按职责切成几块:
本节聚焦左边三条主线:Session(数据)、harness loop(控制)、execute_one(单步执行)。压缩(cache_shape/compact)留给第 6 章,coordinator/fleet/branch 留给后续。
Session 是"一个编码任务的对话历史"。源码 session.go:
// Session holds the conversation history for one task. The run loop (one turn at // a time) is the only writer, but a frontend can read History/Save from another // goroutine while a turn appends, so mu guards Messages. Direct Messages reads // on the run-loop goroutine stay lock-free (serial with its own writes); cross- // goroutine access goes through Snapshot. type Session struct { mu sync.RWMutex Messages []provider.Message version uint64 rewriteVersion int persistedRewriteVersion int persisted sessionPersistState normalizedDirty bool eventLogDamaged bool rawMessages []provider.Message pendingContentReasons []string }
这段注释把并发模型讲透了:主循环是 Messages 的唯一写者(一次一 turn),但前端可以在 turn 进行中从别的 goroutine 读历史/触发保存,所以 mu 这把读写锁保护 Messages。但锁不是"每次读都加",而是分两种路径:
Snapshot() 方法——加读锁,返回一份拷贝。这是 Go 里很经典的"单写者 + 读者要么同 goroutine 要么走快照"模式,能大幅减少锁竞争。
Session 有两个计数器,初学者容易混:
| 计数器 | 谁会 bump 它 | 用途 |
|---|---|---|
version |
每次 append 消息 | 普通的"历史变长了"计数,粗粒度 |
rewriteVersion |
每次重写消息字节(compact/prune/snip/summarize/rewind/guardian merge) | 精确标记"provider 看到的消息字节变了"——这是 Cache-first 契约的关键计数器 |
为什么要把"重写"单独拎出来计数?因为第 6 章会讲:DeepSeek 的 prefix cache 命中靠的是"前缀字节稳定"。append(加新消息)只动尾部,前缀不变,缓存仍然命中;但 rewrite(改老消息字节,比如压缩)会让前缀字节变化,缓存必然 miss。所以 rewriteVersion 是判断"这次操作会不会打掉缓存"的精确信号。pendingContentReasons 数组就是每次重写时记一个原因字符串,run_loop.go 每次请求前消费一次,用于诊断"为什么这一 turn 缓存没命中"。
Session 还带两个"脏标记":
normalizedDirty:加载会话时如果修复了历史(空的工具调用名、悬挂的 call、被截断的 args 等)就置位。修复已经写进 Messages,下次 Save 自然持久化;这个 flag 只是给观测用,并让调用方跳过"脏会话才需要做"的重复工作。eventLogDamaged:加载时发现磁盘上的事件日志(.jsonl)撕裂或损坏,只回放出了可重放的前缀(或退回到 checkpoint)。下次保存会"重写 + 压缩"来愈合日志。rawMessages 保存"修复前的原始转录"——只在刚加载时有意义:保存路径要拿"磁盘上实际是什么字节"跟"待写的快照"比对(避免覆盖外部改动),而修复后的视图已经不代表那些字节了。
💡 契约要点:Session 的字段不是随便堆的——
version/rewriteVersion是缓存诊断的两把尺子,normalizedDirty/eventLogDamaged是"加载即修复"的韧性标记,rawMessages是修复比对的锚点。每个字段都对应一个真实的设计决策。要改 agent 包,先确认你动的字段不会破坏这些不变量。
harness loop 的核心在 run_loop.go。它一次处理一个 turn(一条用户消息 + 模型可能产生的若干轮工具调用),推到模型给出最终文本回复为止。一个 turn 内部其实是一个"模型 ↔ 工具"的子循环:
注意三个关键点。
第一,Compose 在 control 包不在 agent 包。 agent 的主循环通过 Controller(控制层)调 Compose(text),把"原始用户文本"变成"真正发给模型的文本"。Compose 会把记忆更新、后台任务完成通知、自动召回的相关事实、计划模式标记、目标运行时块等都追加到这一 turn 的尾部(第 6 章 02 节详讲为什么是"追加尾部"而不是改系统提示)。agent 包本身只负责"拿到组装好的文本 → 跑模型 → 跑工具"。
第二,工具调用是一个子循环。 模型一次流式响应里可能给出多个 tool_call,agent 逐个执行(execute_one.go),把每个工具的输出作为一条 tool 角色的消息追加回 Messages,然后再次请求模型。模型看到工具结果后,要么继续调工具,要么给出最终文本结束这一 turn。所以一个 turn 内部可能有 N 轮"模型 → 工具 → 模型"。
第三,文本流和工具调用流是同一条流。 Provider.Stream 一边吐文本 token(给 TextSink 实时渲染给用户看),一边可能吐 tool_call_start / tool_call_args_delta / tool_call(增量拼装工具调用)。等流结束,agent 拿到完整的工具调用列表,决定执行。源码 agent.go 顶部就定义了 maxStreamRecoveries = 5(采样阶段流式重试,1 + 5 = 6 次)和 maxEmptyFinalBlocks = 3(连续空最终块上限)等保护常量——主循环不能因为模型偶尔抽风就死循环。
主循环的状态被拆成两个结构体(run_loop.go):
type runLoopState struct { runMaxSteps int runMaxStepsKey string runLimitHostOwned bool emptyFinalBlocks int handoffNudges int usedAnyTool bool goalToolRepairs int graceRound bool recoveryGraceRound bool todoProgress int // ... } type perTurnState struct { deliveryCriteriaEstablished bool deliveryTaskExpected bool deliveryMutationExpected bool deliveryPersistentExpected bool // ... }
注释点明分工:runLoopState 是"一次 Agent.Run 内的循环计数器和标志",包私有、不跨 goroutine;perTurnState 是"恰好对一个 Agent.Run 有效的宿主状态",嵌在 Agent 里让字段访问保持扁平,beginRunTurn 会在算新 turn 的值之前用一次赋值把它清零。注释特意强调:"一个加在这里的字段绝不会在重置时被忘记。要跨 turn 存活的(交付检查点/作用域、失败预算、风暴计数器)直接放 Agent 上。"
这是 Go 里"显式生命周期"的好习惯:把"每 turn 都要重置"的状态集中到一个结构体,reset 时一行 *p = perTurnState{},绝不会漏;而"跨 turn 累积"的状态(失败次数、storm 计数)单独放 Agent 字段,语义清晰。加新字段时,你被迫先回答"它该不该跨 turn"——这正是设计意图。
模型给出一个 tool_call,agent 进 execute_one.go 执行。一个工具调用看着简单,实际要过一套很完整的流程:
几个值得注意的细节。
Preview 与 checkpoint 是同一道缝。 tool.Previewer 接口(文件类工具实现它)在工具真正执行前调 Preview(args),拿到"这个工具打算改哪个文件、改成什么样"。这个 Preview 结果有两个用途:一是写入 checkpoint(第 5 章 02 节讲),记录"编辑前的文件内容"供回放;二是给权限/UI 看 diff。execute_one.go 里这段逻辑是集中在一处的,不需要每个文件工具各自写。
总是 re-read。 源码注释直接写:"Always re-read after post hooks — partial writes and hook side effects can change the previewed path even when the concrete tool returned an error." 翻译:工具执行后(哪怕工具返回了错误),都要重新读一遍预览路径。因为部分写(partial write)和钩子副作用可能在工具报错的情况下改了文件。这是"工具失败自愈"的一部分——下一节详讲。
失败时回显 schema。 如果模型给的 tool_call.Arguments 不是合法 JSON(模型偶尔会把 options 写成 ["a":"b"] 这种坏形状),agent 检测到后会把工具的 schema 拼进错误信息回给模型,让下一次重试能落在合法形状上,而不是重复同样的坏形状。这是"让重试有效"而非"无效重试"的关键。
循环保护。 repeat_failure_guard.go 里 repeatFailureBreakThreshold = 2:同一个写类工具、同样的写意图、同样的失败类别,连续失败 2 次后,第 3 次直接 block,并给出明确的"别再这么试了"提示。注释强调"读操作不续这个预算,因为读不能让过期的写参数变有效"。这把"模型死磕一个改不对的 edit"这种死循环挡住了。
agent 包自己不直接碰配置、不直接碰前端、不直接定义工具,它通过三个抽象与外界解耦:
| 协作者 | 接口 | agent 怎么用 |
|---|---|---|
| Provider | provider.Provider(Stream 方法流式返回 token + tool_call) |
主循环每轮调 Stream,把 Messages 喂进去,拿回文本流和工具调用 |
| tool.Registry | 工具表,按名字查 tool.Tool |
execute_one 解析 tool_call.Name,从 Registry 拿到工具对象执行 |
| control.Controller | 组装请求(Compose)、管理会话生命周期、暴露给三个前端 |
agent 通过 Controller 拿到"组装好的请求文本",并回报事件让前端渲染 |
这套分工的妙处在于:agent 完全不知道前端是谁。终端 TUI、HTTP/SSE 服务器、Wails 桌面,三个前端都只管调 Controller,Controller 再驱动 agent。所以 agent 包里没有任何 TUI/HTTP/Wails 的代码——它只认 Provider/Registry/Controller 这三个抽象。这正是 REASONIX.md 里"Conventions"那条强调的:"One transport-agnostic control.Controller sits behind every frontend."
主循环之外,agent 包还有两个更高层的结构,本节只做预告:
recovery_gc.go 负责清理冲突恢复产生的分支拷贝(24 小时宽限期后才回收)。这些机制建立在本节讲清的 Session + harness loop 之上——子代理本质上就是"复制一份 Session,跑一个独立的 harness loop"。理解了单代理主循环,子代理就是"多个并行的单代理"。
Snapshot();同 goroutine 直接读无锁,跨 goroutine 加读锁。version(append 粗计数)与 rewriteVersion(改字节精计数,Cache-first 关键);pendingContentReasons 记重写原因供诊断。下一节,我们看主循环里两道最重要的"闸门":permission Policy(每个工具调用独立判 allow/ask/deny)与 checkpoint 检查点(每 turn 一个文件快照,支持回放与分支)。它们是 agent 安全性与可恢复性的基石。