第 4 章:SessionActor 与真正的 Agent Loop
如果只能选一个模块理解 Grok Build,应选 xai-grok-shell/src/session。这一章沿 SessionActor 的状态、mailbox、外层事件循环和内层 conversation turn,重建一次会话如何运行。
4.1 为什么每个 session 有一个 actor
一个 session 同时会收到:
- 用户 prompt、插话和取消;
- 模型 token、tool call delta、usage 和终止;
- 多个并发工具的进度与完成;
- MCP server 状态、文件系统事件;
- 后台子代理和命令完成;
- memory flush、dream timer;
- model/mode/plugin/hook/skill 更新;
- goal/workflow 自动继续。
若这些回调直接共享可变状态,就很难保证顺序。SessionActor 把它们收敛到单一所有者,外部通过 mailbox 发送命令。
SessionActor 定义在 acp_session.rs 约 581 行。行为被拆到 acp_session_impl 下的多个文件,而不是把一个 impl 写到数万行。
4.2 SessionActor 持有什么
可以把字段分为九组:
| 组 | 代表状态 | 作用 |
|---|---|---|
| 身份 | session id、cwd、parent | 定位会话与继承关系 |
| 对话 | ChatStateHandle、当前 prompt id | 规范历史与回合相关性 |
| 模型 | sampler handle、model/config | 请求、重试、取消 |
| 工具 | ToolContext、ToolBridge、override | 工具发现与执行 |
| 安全 | PermissionHandle、file tracker | 授权与可回退副作用 |
| 扩展 | MCP、plugins、hooks、skills | 动态能力 |
| 长任务 | subagents、goal、workflow | 跨回合编排 |
| 上下文 | compaction、memory、reminders | 控制上下文窗口 |
| 生命周期 | mailbox、phase、timers、interjections | 串行化异步事件 |
字段多不一定表示职责失控。关键在于 SessionActor 多数时候只做“何时调用哪个子系统”,具体采样、工具、workspace、memory 仍在各自 crate。
4.3 为什么放在独立 OS 线程
spawn_session_on_thread 为每个 session 创建独立 OS thread,在其中建立 current-thread Tokio runtime 和 LocalSet。Actor 可以持有 Rc、RefCell 或其他 !Send 状态;跨线程边界只传可发送的 command/handle。
收益:
- session 内天然串行;
- 慢 session 的本地状态竞争不污染其他 session;
!Send依赖可被安全组合;- thread 退出可作为强生命周期边界。
成本:
- session 数量与 OS thread 数量相关;
- 跨 actor 查询必须异步;
- shutdown/flush 必须显式设计。
4.4 两层循环
“Agent Loop”其实有两层。
外层:run_session
run_loop.rs 中的 run_session 是长期存在的事件循环,使用 tokio::select! 监听:
- session mailbox;
- chat-state event;
- replay buffer;
- 当前 prompt task 完成;
- model switch;
- idle memory flush 与 dream timer;
- filesystem/MCP/extension 事件。
它决定当前 session 应处理哪类事件。
内层:process_conversation_turn
turn.rs 中约 1880 行的 process_conversation_turn 处理一个用户回合内的多次“模型—工具”迭代。它决定当前 prompt 怎样一直运行到稳定终点。
把两层混为一谈,会误以为模型请求期间 actor 什么都做不了。实际上外层仍可接收取消等控制事件,内层任务也通过 handle/channel 与 actor 协作。
4.5 一轮开始前
进入模型调用前,turn 需要建立本轮不变量:
- 处理 pre-turn metadata;
- 应用待生效的模型切换;
- 确认 compact/turn-start 状态;
- 获取当前工具定义与动态能力;
- 设置 prompt id、phase、usage 账本;
- 准备 persistence 与客户端可见的生命周期事件。
“工具列表”不是进程启动时固定一次。MCP、模式、插件、goal/workflow gate 都可能使本轮工具集合不同。
4.6 每次采样前注入什么
内层 loop 每次调用模型前,会先吸收异步世界:
- 用户 interjection;
- 新发现的技能或规则提醒;
- monitor、后台命令和 subagent completion;
- memory/MCP reminder;
- goal continuation;
- stop hook 或自动恢复产生的 synthetic message。
这些内容不是伪装成普通历史。SyntheticReason 会标记 CompactionMeta、SystemReminder、AutoContinue、Interjection、TaskCompleted、SubagentCompleted、GoalSummary 等来源,便于后续转换、分析和策略判断。
随后执行:
- prefire/预处理;
- 认证刷新;
- 自动压缩检查;
- 解析当前可用工具;
- 从 ChatState 构建
ConversationRequest; - phase 切换到 WaitingForModel/Sampling;
run_turn_via_sampler。
4.7 采样完成后的分叉
模型响应被持久化,并记录 token/usage。之后有两个主分支。
分支 A:有工具调用
assistant response
-> phase = ToolExecution
-> preflight all calls
-> execute approved calls
-> persist tool results
-> process hook/interjection/follow-up
-> maybe compact
-> next sampling iteration2
3
4
5
6
7
8
分支 B:没有工具调用
“没有工具”不等于立即结束。还要检查:
- Todo/plan 完成 gate;
- stop hooks;
- 用户插话;
- 后台任务完成提醒;
- goal 是否需要继续或验证;
- workflow 是否有恢复动作;
- 是否触发 auto-continue/auto-wake。
只有所有 gate 都允许结束,才发出回合完成。
4.8 Sampler 的 stream-drained 屏障
run_turn_via_sampler 不只等待“模型 Completed 事件”。它还等待 stream consumer 确认已排空。
为什么需要这个屏障?假设 terminal event 先到,最后几个 token 仍在另一个 channel 排队,SessionActor 若立即持久化/完成回合,客户端会看到丢尾或完成标记超前。
Sampling Completed
│
├─ 不足以完成回合
▼
wait stream-drained acknowledgement
▼
safe to persist/finalize2
3
4
5
6
7
这是事件系统中的同步栅栏:终止信号描述生产端完成,drained 描述消费端也追上了。
4.9 Stationarity Guard:防止工具原地打转
Agent 可能反复发出完全相同的工具调用。Turn loop 维护 stationarity 信号:
- 相同调用达到约 8 次时注入 nudge;
- 达到约 16 次时停止;
- 对无副作用的
true一类调用,阈值更低(约 4 次)。
这不是简单的模型 retry。网络 retry 处理“请求没成功”;stationarity 处理“请求成功但行为没有进展”。
设计上把 nudge 与 hard stop 分开很重要:偶尔重复可能是合理验证,立即停止过于激进;无限重复又会浪费 token 和副作用预算。
4.10 取消、拒绝与失败不是同一种终点
| 情况 | 典型处理 |
|---|---|
| 用户取消 | 传播 cancellation,终止当前采样/工具,发出取消语义 |
| Permission reject | 生成可供模型理解的工具结果,后续调用可能取消 |
| Hook deny | 在工具前阻断并形成受控结果 |
| 网络/限流 | Sampler 分类后重试或返回失败 |
| context overflow | 触发压缩路径后重新采样 |
| max turn | 明确终止,避免无限代理循环 |
| content filter / length | 根据 stop reason 进入相应恢复或结束路径 |
| goal 未完成 | 可能构造下一轮 continuation,而非结束整个目标 |
“错误即消息”只适用于模型有能力调整的业务错误。进程不变量破坏、持久化不可判定状态等仍需要显式错误边界,不能全部塞回对话。
4.11 Prompt、Turn、Sampling Attempt 的区别
这三个概念常被混用:
| 单位 | 含义 | 可能包含 |
|---|---|---|
| Prompt | 一次用户发起的 session 请求 | 多个 turn iteration |
| Conversation turn loop | 为完成该 prompt 的模型/工具循环 | 多次模型采样 |
| Sampling attempt | 一次具体 HTTP 后端尝试 | retry 前的一次网络流 |
因此:
- sampler retry 不应增加用户 prompt index;
- 一次 tool call 后再次采样仍属于同一 prompt;
- goal auto-continue 可能创建新的 synthetic 回合语义;
- event 必须带 prompt/request/session 相关性,不能只依赖时间顺序。
4.12 一份接近源码的伪代码
async fn process_conversation_turn() {
prepare_turn().await;
loop {
drain_async_inputs().await;
guard_against_stationarity()?;
refresh_auth_and_dynamic_tools().await?;
maybe_auto_compact().await?;
let request = chat_state.build_request(tool_definitions).await?;
let response = run_turn_via_sampler(request).await?;
persist_response_and_usage(&response).await?;
if response.tool_calls.is_empty() {
match evaluate_stop_gates().await? {
Finalize => return finish_turn().await,
Continue(extra_context) => inject(extra_context).await,
Pause(reason) => return pause_turn(reason).await,
}
} else {
let results = execute_tool_calls(response.tool_calls).await;
persist_tool_results(results).await?;
maybe_post_tool_compact().await?;
}
}
}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
它不是可编译摘录,而是对 turn.rs、sampler_turn.rs 和 tool_calls.rs 控制流的压缩。
4.13 源码阅读卡
| 问题 | 文件 |
|---|---|
| Actor 有哪些状态 | session/acp_session.rs |
| Actor 如何创建 | acp_session_impl/spawn.rs |
| 长期 select loop | acp_session_impl/run_loop.rs |
| 模型—工具内循环 | acp_session_impl/turn.rs |
| 采样收集与屏障 | acp_session_impl/sampler_turn.rs |
| 工具执行 | acp_session_impl/tool_calls.rs |
| 插话/通知 | notification_drain.rs、reminders.rs |
4.14 本章结论
Grok Build 的 Agent Loop 不是 while tool_calls 的放大版,而是两个嵌套生命周期:
外层 SessionActor 维护会话生命与异步事件;内层 turn loop 推进一个 prompt 的模型—工具状态机。
模型调用只是其中一个 phase。压缩、安全、后台完成、插话、目标验证和持久化都在 phase 边界参与决策。
下一章把视角推进到 Sampler:模型供应商差异怎样被挡在 SessionActor 之外。