第 2 章:Agent Loop——goose 的心跳
2.1 是什么:一个“流式回复 + 工具回合”的状态机
goose 的 Agent Loop 不是简单的 while model.has_tool_call()。它还要处理:用户 slash command、自动压缩、流式 delta、工具审批、前端工具、MCP 通知、取消、重试、goal/grind、recipe retry、stop hook 和空响应恢复。因此 Agent::reply 返回的是 BoxStream<Result<AgentEvent>>,而不是一个最终字符串。
stateDiagram-v2 [*] --> Prepare Prepare --> Command: slash command Command --> [*]: 只修改历史 / 立即返回 Prepare --> Compact: 超过 context threshold Compact --> Prepare Prepare --> Model Model --> Emit: 文本 / thinking delta Model --> Inspect: tool request Inspect --> Approve: 需要确认 Inspect --> Dispatch: 可直接执行 Approve --> Dispatch: Allow Approve --> Model: Deny / tool response Dispatch --> ToolEvents: result / notification / action-required ToolEvents --> Model: 继续当前 loop Model --> Stop: 没有 tool call Stop --> Model: goal / grind / retry / steer 未完成 Stop --> Compact: recovery compact Stop --> [*]: stop hook 允许退出
2.2 源码怎么做:三层函数把准备、编排和循环分开
reply:唯一的事件身份边界
Agent::reply 调用 reply_impl,再通过 ensure_message_event_id 为同一个逻辑消息的多个事件补齐一致 ID。这一层很关键:CLI、桌面端、ACP 都可以把文本 delta 合并,而不需要重新猜测哪个事件属于哪条消息。
reply_impl:回合前的卫生处理
它先为用户消息补 ID,处理 ActionRequired 的 elicitation response,然后读取 session。接着按顺序处理:
- 不可见输入直接入库但不启动 Agent。
- 首轮触发
SessionStarthook;用户 prompt 触发UserPromptSubmithook。 - 运行 slash command;其中 goal/grind 命令会写入可见确认,再注入 agent-only kickoff,让 Agent 立刻开始工作。
- 调用
check_if_compaction_needed,必要时先完成压缩并发出HistoryReplaced。 - 进入
reply_internal。
reply_internal:真正的循环
它先通过 prepare_reply_context 拿到 conversation、tools、toolshim tools、system prompt、model config。循环体中维护 turns_taken、max_turns、压缩次数、空回合重试、待处理 steer、stop-hook 连续阻断次数等状态。
模型输出被拆成两条路径:
- 文本和 thinking:立即以
AgentEvent::Message产出,并在同一 message ID 下合并。 - 工具请求:写入 assistant tool request,按前端工具和剩余工具分类,再进入 inspection/permission/dispatch。
工具执行通过 tool_stream 把三个来源合并成一个流:MCP server notification、action-required 消息、最终工具结果。这样“工具正在发进度”和“工具最终返回”可以被同一个回合消费。
2.3 为什么这样做:不把“停止”当成单一条件
最有价值的设计是退出逻辑。没有工具调用并不等于马上结束:
- final output tool 没调用时,会注入 continuation message。
- goal/grind 存在时,会注入 agent-only nudge。
- recipe 有 retry config 时,交给
RetryManager判断是否重试。 - 空 assistant response 会有限次数重试,避免 provider 静默导致 UI 无响应。
- stop hook 可以拒绝结束,并把拒绝原因重新注入上下文;同时有 block cap 防止无限循环。
这是一种“显式状态 + 可观测事件”的 runtime 思路:每个继续条件都变成历史中的消息或事件,后续 provider 看到的是一个可解释的上下文,而不是藏在宿主里的魔法状态。
2.4 一次工具回合的伪代码
loop {
response = provider.stream(system, visible_history, tools).await?;
emit_text_and_thinking(response.deltas);
if response.has_tool_requests() {
inspections = inspect_tools(requests, history);
approved = route_permissions(inspections);
results = dispatch(approved).await;
persist(requests, results);
continue;
}
if needs_goal_retry_or_recipe_retry_or_compaction() {
inject_continuation();
continue;
}
break_if_stop_hook_allows();
}
源码定位
crates/goose/src/agents/agent.rs:reply、reply_impl、reply_internal、tool_stream。crates/goose/src/agents/tool_execution.rs:审批工具、前端工具、工具流。crates/goose/src/agents/retry.rs:recipe retry 的判定。crates/goose/src/hooks/mod.rs:session/user/stop hook。