跳到主要内容

第 1 章 · 中央回复循环

这一章讲 goose 的「心跳」:一条用户消息进来后,reply / reply_internal 怎么把「反复问模型、反复跑工具」变成一个可以边跑边往 UI 推送的流。读懂这个循环,你就读懂了 goose 的主线。

1.1 入口:reply 做的几件「门口的事」

Agent::reply(agents/agent.rs:1522)是公开入口,它不直接进循环,而是先处理几类「在真正开跑之前必须先解决」的情况,然后才把活交给 reply_internal:

  • elicitation 回复:如果这条消息其实是用户对某个工具「请你确认一下」弹窗的回应,就走 complete_elicitation_with_message 把那个被挂起的工具调用解锁,然后返回一个空流(agents/agent.rs:1534-1566)。
  • 生命周期 hook UserPromptSubmit:有注册就先触发(agents/agent.rs:1572)。
  • 斜杠命令:execute_command 处理 /goal/grind 这类命令(agents/agent.rs:1586)。设定 goal/grind 的命令会「立刻开一回合」让 agent 马上去追这个目标。
  • 自动压缩预检:在进循环前先看上下文是否已超阈值,超了就先压(check_if_compaction_needed,agents/agent.rs:1702),细节见第 4 章。

这一层的设计意图:把「不需要问模型就能处理」的分支挡在循环之外,保持核心循环的纯粹。

1.2 核心数据流:一个 async_stream,而不是一个返回值

reply_internal(agents/agent.rs:1788)返回的是 BoxStream<Result<AgentEvent>>——一个事件流,不是一次性的结果。整个循环包在 async_stream::try_stream! { ... } 里(agents/agent.rs:1871),每产生一点东西就 yield 出去。

事件类型 AgentEvent(agents/agent.rs:264)只有四种:

事件含义
Message(Message)一段助手文本 / 思考 / 工具请求 / 工具结果 / 系统通知
Usage(ProviderUsage)这次模型调用的 token 用量
McpNotification((String, ServerNotification))某个 MCP 扩展在工具执行期间发来的通知
HistoryReplaced(Conversation)历史被整体替换(压缩 / retry 重置后)

CLI 侧就是 while let Some(event) = stream.next() 地消费它(goose-cli/src/session/mod.rs:1205-1209),据此实时渲染。「流」这个选择让 goose 能边想边显示、能在工具执行中途插播通知、能随时被取消(cancel_token)。

1.3 一圈循环里发生什么(主算法)

循环的骨架在 agents/agent.rs:1888loop { ... }。把一圈拆成几步看:

① 圈首检查

  • 取消了?is_token_cancelled 为真就 break(agents/agent.rs:1889)。
  • 有没有「插话」(steer)?用户在 agent 干活途中又发了消息,会进 pending_steers 队列,在圈首被 drain 进 conversation(agents/agent.rs:1893drain_pending_steersagents/agent.rs:482)。
  • 回合数 +1;超过 max_turns(默认 1000,DEFAULT_MAX_TURNSagents/agent.rs:68)就抛「我已经做到上限了,要继续吗」并退出(agents/agent.rs:1958)。

② 问模型(流式)

先把「turn context」注入(inject_moim,agents/agent.rs:1964,见 §1.6),再调:

// agents/agent.rs:1972 — 不是一次 complete,而是拿到一个子流
let mut stream = Self::stream_response_from_provider(
self.provider().await?, model_config.clone(),
&session_config.id, &system_prompt,
conversation_with_moim.messages(), &tools, &toolshim_tools,
).await?;

stream_response_from_provider(agents/reply_parts.rs:256)在转发给 provider 前,只把「对 agent 可见」的消息喂给模型(is_agent_visible() 过滤,reply_parts.rs:267-271)——这是第 4 章「可见性双轨」的关键。

③ 消费模型子流 + 分类工具

while let Some(next) = stream.next().await(agents/agent.rs:2014)逐块处理模型输出。拿到一块 response 后:

  • categorize_tools 把工具请求分成前端工具(交给 UI 执行)和其它工具(agents/agent.rs:2029),并产出一个「过滤后的可见消息」yield 给 UI(agents/agent.rs:2062)。
  • 如果这块没有工具调用,就累加文本、记进 messages_to_addcontinue(agents/agent.rs:2066-2073)。

④ 安检 + 权限(非 Chat 模式)

// agents/agent.rs:2113 — 工具执行前,先过整条检查器流水线
let inspection_results = self.tool_inspection_manager
.inspect_tools(&session_config.id, &remaining_requests,
conversation.messages(), goose_mode).await?;

结果被折算成「批准 / 需审批 / 拒绝」三组(PermissionCheckResult)。Chat 模式则跳过执行、回一句「已跳过」(agents/agent.rs:2094)。检查器链细节见第 3 章。

⑤ 执行工具(并发收集)

批准的工具立即派发,需审批的工具走审批流;所有工具的输出流被 stream::select_all 合到一起并发收集(agents/agent.rs:2169-2239)。每个工具的结果作为一条 user 角色的 ToolResponse 配对回它的 ToolRequest,塞进 messages_to_add(agents/agent.rs:2298-2364)。

这里有个保命细节:循环里 tokio::select! 还挂了个 100ms 的 sleep 分支(agents/agent.rs:2237),保证即使工具长时间不出声,循环也能周期性检查取消标志。

⑥ 收尾判定

一圈结束时,关键变量是 no_tools_called:

  • 调了工具 → 不进收尾分支,把 messages_to_add 落库、conversation.extend,回到 ①。
  • 没调工具(agents/agent.rs:2513)→ 进收尾判定:有没有 final_output 工具待填?有没有 goal/grind 需要提醒「还没干完接着干」?要不要按 recipe 的 retry 配置重试?都没有就 exit_chat = true

1.4 「调了工具就继续」的本质

把上面浓缩成一张状态图(从上到下是一圈,右侧箭头表示「回到圈首」):

┌──────────────────────────────────────┐
│ 一圈 (turn) │
│ │
圈首检查 ─┼─► 问模型 ─► 有工具调用? ──否──► 收尾判定 │
(取消? │ │ │ │
插话? │ 是│ 有 goal/ │
超turn?)│ ▼ grind/retry?│
│ 安检+权限 │ │
│ │ 否│ 是│ │
│ ▼ ▼ │ │
│ 执行工具 exit 继续 ────┼──► (回圈首)
│ │ │
└────────────────┴──── 回圈首 ◄───────────┘

直觉:模型只要还在「点工具」,就说明它认为任务没完,循环就继续把现实结果喂回去;一旦它「只说话不点工具」,goose 才考虑收场。

1.5 收尾不是简单的 break:goal / grind / retry / stop hook

「没调工具」并不一定结束。agents/agent.rs:2521match final_output 罗列了所有收尾分支:

  • /goal 已设:注入一条「结束前先检查目标是否达成,没达成就继续」的隐藏 user 消息(agents/agent.rs:2536),再转一圈。
  • /grind 已设:更强硬,「没干完就接着干」(agents/agent.rs:2555)。
  • recipe 的 retry_config:handle_retry_logic(agents/agent.rs:666 / 2576)可能重置历史并重试。
  • Stop hook:真要退出前,触发可阻断的 Stop hook(emit_stop_hook_blocking,agents/agent.rs:2664)。hook 可以「拒绝结束」并给出理由,goose 就把理由作为隐藏消息塞回再转一圈;但为防死循环,连续阻断超过 GOOSE_STOP_HOOK_BLOCK_CAP(默认 8)次就强制结束(agents/agent.rs:2675)。这个环境变量在 agents/agent.rs:415 读取,缺省回落到常量 DEFAULT_STOP_HOOK_BLOCK_CAP = 8(agents/agent.rs:69)。

这些「隐藏 user 消息」用的是 with_visibility(false, true)——对 agent 可见、对用户不可见,是 goose 引导模型却不打扰用户的常用手法。

1.6 turn context 注入(moim)

每圈问模型前,inject_moim(agents/moim.rs:38)会在最新一条 user 消息前拼一个 <turn-context> 块,装入当前时间、工作目录、压缩状态、剩余回合预算、扩展提供的上下文。系统提示里专门解释了这个块的含义(SYSTEM_PROMPT_BLOCK_TEMPLATE,agents/moim.rs:9):随着回合预算变少,提示模型「少探索、批量调用、尽快收尾」。仅当上下文 ≥ 32k 时启用(MIN_CONTEXT_FOR_MOIM,agents/moim.rs:6)。

1.7 错误分支:循环里对 provider 错误是分类处理的

模型调用的错误不会无脑冒泡,而是按类型在 match nextErr 臂里分别处理(agents/agent.rs:2371-2492):

错误处理
ContextLengthExceeded触发恢复式压缩(compact_messages),最多试 2 次,还超就明确告知用户换模型/开新会话
CreditsExhausted弹「请充值」通知(带 top-up 链接)
Refusal(模型拒答)视为终止:不再 retry,提示开新会话
NetworkError提示「请重发」
其它通用错误提示

关键判断:上下文超限被当作「可恢复」(压缩后重试),模型拒答被当作「终止」(重发只会再被拒)。这种区别对待是长跑 agent 稳定性的关键。

1.8 小结

  • goose 的核心是一个 async 流式 loop,事件类型只有 4 种(AgentEvent)。
  • 一圈 = 问模型 → 分类工具 → 安检权限 → 执行 → 喂回;调了工具就继续,没调就收尾
  • 收尾本身很「重」:goal/grind 续推、recipe retry、可阻断的 Stop hook(带防死循环上限)。
  • 整条链路对取消(cancel_token)、插话(steer)、上下文超限都有显式处理。

下一章看这些「工具」到底从哪来、怎么被路由执行 → 02-tools-and-mcp.md