跳到主要内容

核心回合循环:从输入到工具执行的主线

30 秒导读: 用户发一句话,Whale 要把它变成"想 → 调工具 → 看结果 → 再想 …"直到给出答案。 这整段编舞就是一个 for 循环,住在 internal/agent/turn_loop.go 里。这一章带你把这个循环 从入口读到收尾:它怎么保证同一会话不并发跑、怎么在预算耗尽时拒开工、每一轮里做哪几件事、 以及在模型陷入死循环时靠哪两条机制把它拉停。这章只讲控制流;上下文/记忆怎么摆进请求见 02 提示词缓存的记忆布局,单个工具怎么执行见 03 工具系统


1. 先建直觉:一个"回合"是什么

先不看代码。想象你和一个助手结对编程:

  • 你说一句需求(一次用户输入)。
  • 助手不是一句话答完——它会先读几个文件、跑个命令、再读结果,然后才回你。
  • 它每"伸一次手"(调一个工具),你就把工具结果递回去,它接着想。
  • 直到它说"好了,这是结论",这一来一回才算结束。

一整段来回,在 Whale 里叫一个回合(turn)。回合内部,模型和工具会往返很多次—— 每一次"请求模型 → 拿到它这轮想说的话和想调的工具 → 执行工具 → 把结果并回历史"叫一轮 (round / model turn)

一句话对应关系:

概念是什么边界
回合 turn一次用户输入到最终答复的全过程runStreamWithNewMessages 一次调用覆盖
轮 round回合内一次"问模型 + 执行它要的工具"for 循环体的一次迭代
工具调用 tool call模型这一轮想伸的一只手round 内可有多只(可并行)

核心难点不是"调用模型",而是"知道什么时候该停、以及怎么在它跑飞时安全地停下"。 这一章的 大半篇幅就在讲"停"的逻辑。

用起来什么样

对外,回合不是"算完再返回",而是一个事件流(channel):调用方拿到一个 <-chan AgentEvent, 边算边收到增量文本、工具调用、工具结果、用量、最终完成等事件。

// 示意,非源码:调用方视角
events, err := agent.RunStream(ctx, sessionID, "帮我修好登录的 bug")
for ev := range events { // 回合一开始就返回 channel,事件实时流出
switch ev.Type {
case AgentEventTypeAssistantDelta: // 模型正在打字
case AgentEventTypeToolCall: // 模型要调一个工具
case AgentEventTypeToolResult: // 工具跑完了
case AgentEventTypeDone: // 回合结束,ev.Message 是最终答复
}
}

重点看: 回合的"生命"就是这个 channel 从打开到 close。channel 一关,回合结束。


2. 顶层全景:一个回合怎么转

下面这张图是本章的地图。从上往下读: 先过三道"开工前"的闸,然后进入 for 循环反复迭代, 最后从若干个"出口"之一离开并关掉 channel。

用户输入 (RunStream*)


┌─────────────────────────────────────────────┐
│ 开工前三道闸 runStreamWithNewMessages │
│ ① 活跃回合互斥:同会话已在跑 → ErrSessionBusy│
│ ② 预算闸:花超上限 → ErrBudgetExceeded │
│ ③ 落盘新消息 + 拉全量 history │
└─────────────────────────────────────────────┘
│ (起 goroutine,defer close(out))

┌─────────────────────────────────────────────┐
│ for { … } 一轮 = 一次迭代 │
│ │
│ drainPending ── 途中注入的新消息并进历史 │
│ autoCompact ── 超阈值就压缩历史 │
│ VerifyFingerprint ── 前缀指纹核对(报漂移) │
│ streamAndHandle ── 流式问模型 + 执行工具 ★ │
│ │ │
│ ├─ 模型还要调工具? → 过 6 道终止闸 → continue
│ └─ 模型给了最终答复? → 走收尾出口 │
└─────────────────────────────────────────────┘


出口之一:Done / TurnCancelled / Error / ForcedSummary
→ emit 事件 → return → close(out)

★ 标的 streamAndHandle 是"问模型 + 跑工具"的那一步,本章讲它在循环里的位置和返回值怎么 被消费;工具具体怎么被派发、修复、沙箱执行,留给 03 章

各部件职责一句话:

部件干什么在哪
RunStream* 系列薄封装:把不同入参统一成"新消息 + RunOptions"turn_loop.go:30-57
runStreamWithNewMessages唯一真入口:闸门 + for 循环 + 收尾turn_loop.go:81
streamAndHandle一轮的"问模型 + 执行工具",返回 assistant/toolMsg/usagestream.go:35
collectAssistantStream消费 provider 事件流,拼出 assistant 消息stream_ingest.go:16
progressTracker / isAllStormBlocked两条防跑飞机制progress_guard.go / force_summary.go:31
forceSummaryAndFinish撞闸时让模型写个"进度小结"再收尾force_summary.go:46

3. 入口:一堆薄封装,一个真实现

对外有一串 RunStreamWith* 方法,看着很多,其实全是漏斗,最终都汇到一个私有函数 runStreamWithNewMessages

RunStream ──► RunStreamWithOptions ──► RunStreamWithTurnOptions

RunStreamWithContentOptions ──────────────────┤
RunStreamWithInjectedInputOptions ────────────┤
RunStreamWithInjectedContentOptions ──────────┘

runStreamWithNewMessages ← 唯一真实现

区别只在于"把用户输入包成什么形状":纯文本、富媒体 parts、还是"可见输入 + 一段隐藏输入" (注入给模型但不显示给用户)。例如 RunStreamWithInjectedContentOptions 会造两条消息——一条 可见、一条 Hidden(turn_loop.go:52-57)。所有形状最后都变成 []core.Message 交给真入口。

为什么这么设计: 循环逻辑只写一遍,入参多样性挡在封装层。读代码只需盯住 runStreamWithNewMessages 一个函数(turn_loop.go:81)。


4. 开工前的三道闸

真入口做的第一件事不是循环,而是三道"能不能开工"的检查。任何一道不过,直接返回错误, 连 channel 都不创建

闸一:活跃回合互斥(一个会话同时只跑一个回合)

// turn_loop.go:83-86 (节选真源码)
turnState := &activeTurnState{}
if _, loaded := a.active.LoadOrStore(sessionID, turnState); loaded {
return nil, ErrSessionBusy
}

a.active 是一个 sync.Map,键是 sessionIDLoadOrStore 是原子的"占坑":坑已被占 (loaded == true)就说明该会话已有回合在跑,直接吐 ErrSessionBusy(agent.go:23)。回合结束时 用 defer a.active.Delete(sessionID) 释放坑(turn_loop.go:112)。

这把"同一会话不能并发两个回合"变成一个无锁的原子操作。注意占坑用的 activeTurnState 不只是 个哨兵——它还挂着一个 pending []core.Message 队列(agent.go:296-299),这是第 6 节"途中注入" 的基础。

闸二:预算闸(花超钱不开工)

// turn_loop.go:87-90 (节选)
if spent, blocked := a.budgetExceeded(sessionID); blocked {
a.active.Delete(sessionID) // 记得把刚占的坑退掉
return nil, fmt.Errorf("%w: spent $%.6f >= cap $%.6f", ErrBudgetExceeded, spent, a.budgetWarningUSD)
}

budgetExceeded 读该会话累计花费,和配置上限比(usage_budget.go:101)。只有配置了正的 budgetWarningUSD 且会话运行时可用时才生效,否则永远返回"没超"——即默认不设上限 (usage_budget.go:102)。注意这里手动 a.active.Delete:因为坑已经在闸一占上了,提前返回必须退坑。

闸三:落盘消息 + 拉历史

过了前两闸,才把新消息逐条 NormalizeMessageContent 规整后写进 store,再 store.List 拉出 该会话的全量历史(turn_loop.go:92-107)。任一步出错同样退坑返回。

三闸都过,才创建缓冲为 16 的事件 channel、起一个 goroutine 跑真正的循环,并 defer close(out) + defer a.active.Delete(sessionID)——channel 一定会关、坑一定会退,无论循环怎么退出 (turn_loop.go:109-112)。


5. 循环启动前:一次性搭好"这一回合的工作台"

goroutine 里,进 for 之前先建好整回合复用的状态:

  1. 刷新工具快照 refreshToolSnapshotForTurn:这一回合模型能用哪些工具,定格成一份 toolSnapshot(turn_loop.go:113)。
  2. 水合运行时 memory.HydrateRuntime:把"不可变系统前缀(system prompt + 工具 schema)"和 "可变历史"组装成 rt(turn_loop.go:121-122)。这块的布局是全项目的招牌,专门为提示词缓存 命中而设计——详见 02 章;本章只把它当黑盒用。
  3. 一堆计数器归零:modelTurns(问了模型几次)、toolIters(执行了几轮工具)、toolCalls (累计工具调用数)、以及两个防跑飞计数器 consecutiveStormRounds / consecutiveRedundantRounds,还有 progress := &progressTracker{}(turn_loop.go:123-131)。
  4. 重置断路器:storm 修复器和分类器电路断路器都清一遍,免得上一个任务的状态污染这一回合 (turn_loop.go:132-139)。

turnPolicy:RunOptions 如何改写这一回合的权限

RunOptions(turn_loop.go:16-28)是调用方对本回合的"旋钮"。其中三个会改写工具策略,决定 哪些工具调用被放行:

// turn_loop.go:143-152 (节选)
turnPolicy := a.policy
if len(opts.ShellAllowPrefixes) > 0 {
turnPolicy = policy.ScopedAllowPolicy{Base: a.policy, ShellAllowPrefixes: ...}
}
if opts.ReadOnly {
turnPolicy = policy.ReadOnlyTurnPolicy{Base: turnPolicy}
}
RunOptions 字段效果机制
ShellAllowPrefixes额外放行指定前缀的 shell 命令包一层 ScopedAllowPolicy
ReadOnly整回合只读:写类工具一律拦再包一层 ReadOnlyTurnPolicy
Plan(经 a.mode == ModePlan)计划模式:执行期拦写操作、update_planmodeBlockedDetailsForCall(stream.go:338)

注意两层策略是装饰器叠加:先 scoped、再 read-only,ReadOnlyTurnPolicy.Base 指向前者。 turnPolicy 在循环里每轮传给 streamAndHandle,是"这一轮工具能不能跑"的裁判。具体的权限 规则与 LLM 自动审查见 04 章

一个细节:Plan 模式不改变对外通告的工具清单,而是在执行期拦截。这样做纯粹是为了缓存 命中——per-mode 的工具 schema 会让整个缓存前缀失效(stream_ingest.go:31-39)。


6. 循环体:一轮里依次做什么

现在进 for {这是本章的心脏。一轮迭代从上到下依次做这几件事(turn_loop.go:155-214):

┌ 一轮迭代 ────────────────────────────────────────────┐
│ 1. drainPending 途中注入的消息 → 并进 history │
│ 2. Scratch.ResetTurn 清掉上一轮的临时草稿 │
│ 3. (非首轮) 重刷 toolSnapshot │
│ 4. autoCompact 估算 token,超阈值就压缩历史 │
│ 5. VerifyFingerprint 前缀指纹核对,漂移就报事件 │
│ 6. streamAndHandle ★ 问模型 + 执行工具(见 03 章) │
│ 7. 记账:modelTurns++、算 turnCost、发 Usage 事件 │
│ 8. 按 streamAndHandle 的返回值分流到各出口 / continue │
└───────────────────────────────────────────────────────┘

6.1 drainPending:回合进行中也能追加输入

循环开头先 turnState.drainPending():把回合运行期间被注入的新消息取出、追加进 rt.Loghistory(turn_loop.go:156-161)。注入靠的是 InjectTurnInput——它找到活跃回合的 activeTurnState, 把消息 appendPending 进那个队列(turn_loop.go:59-79)。

直觉: 模型正在长跑,你又补了一句"顺便也看下 utils.go"。这句不会新开一个回合(会撞 ErrSessionBusy),而是排进当前回合的 pending,下一轮开头被吸收进来。收尾各处也会用 turnState.hasPending() 判断"有没有新料要处理",有就发 ResponseReset 事件并 continue 而不是结束 (如 turn_loop.go:256-261)。

6.2 auto-compact:历史太长就先压缩

// turn_loop.go:173-177 (节选)
if a.autoCompact {
before := compact.EstimateMessagesTokens(rt.BuildProviderHistory())
if float64(before)/float64(max(1, a.contextWindow)) > a.compactThresh {
replacement, info, err := a.compactHistory(ctx, sessionID, history, true, ...)

每轮估算当前历史 token 数,除以上下文窗口,超过 compactThresh(默认阈值见 WithAutoCompact) 就调 compactHistory 把历史压成摘要,并用 rt.Log.RewriteWithReason(RewriteReasonCompact, ...) 原地 换掉(turn_loop.go:182-183),然后发 AgentEventTypeContextCompacted 事件。压缩失败直接报 Error 收尾。

为什么放在轮首: 保证每次真正问模型前,历史都在窗口容量之内,不会中途撑爆。

6.3 VerifyFingerprint:前缀指纹核对

// turn_loop.go:209-213 (节选)
if actual, ok := rt.Prefix.VerifyFingerprint(); !ok {
if !emit(AgentEvent{Type: AgentEventTypePrefixDrift, PrefixDrift: ...}) {
return
}
}

rt.Prefix 是那份"不可变系统前缀"。VerifyFingerprint 重算它的指纹,和记录的期望值比: 不一致说明本该不可变的前缀被动了(潜在的缓存失效 bug),就发一个 PrefixDrift 诊断事件。 它只报警、不阻断——指纹和缓存布局的来龙去脉属于 02 章

6.4 记账

streamAndHandle 返回后:modelTurns++;recordTurnCost 记这一轮花费;emit 一个 AgentEventTypeUsage;有缓存指标再 emit 一个 PrefixCacheMetrics;最后 emitBudgetWarningIfNeeded 在接近预算时发预警(turn_loop.go:227-239)。channel 满且 ctx 结束时 emit 返回 false,任何一处 emit 失败都立即 return 收尾。


7. 循环的几个出口:模型到底说了什么

一轮问完模型,streamAndHandle 返回一组值,其中三个决定"这一轮往哪走":abortTurn(有工具 请求运行时交接)、assistant.FinishReason(模型这轮怎么收的口)、toolMsg(工具结果消息,可能 为 nil)。循环据此分流:

streamAndHandle 返回

├─ abortTurn? → 落盘 → 有 pending 则 reset+continue,否则 Done 收尾

├─ FinishReason==ToolUse 且 toolMsg!=nil?
│ → 把 assistant+toolMsg 并进历史
│ → 过 6 道"该不该停"的闸(见 §8/§9)
│ → 没撞闸 → continue 下一轮

├─ 有 pending? → 并进历史,发 ResponseReset,continue

├─ 泄漏的工具调用文本? → 擦掉 + 轻推一次 + continue(见 §7.2)

└─ 否则(真最终答复) → (Plan 模式再发 PlanCompleted) → Done 收尾

7.1 abortTurn:工具请求"运行时交接"

有些工具(如 request_user_input)执行后需要把控制权交回外层——它们在结果元数据里标 abort_turn_after_tool_result(stream_dispatch.go:717-733)。派发层看到就把这一轮标成 abortTurn, 后续未执行的工具全部填 turn_aborted 占位结果(stream_dispatch.go:284-304)。循环收到 abortTurn 后:落盘、若 ctx 被取消则记中断标记,若有 pending 则 ResponseReset 续跑,否则打 end_turnDone(turn_loop.go:240-266)。

7.2 泄漏的工具调用:模型把工具写成了正文

模型有时不走 API 的工具通道,而是把工具调用当普通文本吐出来(如 <tool_calls><read_file .../></tool_calls>)。这种文本永远不会执行,循环会误以为"它答完了"。 containsLeakedToolCall 检测到这种情况后:把包裹文本从 assistant 正文里擦掉、发 LeakedToolCallScrubbed + ResponseReset、轻推模型改用结构化通道,然后 continue 重来 (turn_loop.go:408-440)。由 maxLeakedToolCallNudges = 2 封顶(leaked_tool_call.go:16), 免得一个恰好引用了这种格式的答复被永远卡住。

7.3 Done:真正的收尾

模型给了非空最终答复且没别的事要做,就是回合结束:Plan 模式下先发 PlanCompleted(此时"最终 答复"本身就是待批准的计划,turn_loop.go:446-448),然后 emit 一个 AgentEventTypeDone 带上最终 assistant 消息,return——goroutine 结束,defer close(out) 关掉 channel(turn_loop.go:449-450)。


8. 防跑飞机制之一:连续 storm 轮

主 agent(交互式)故意不设工具轮数上限(maxToolIters == 0,agent.go:372 注释写明)——真实 任务可能就是要读几十个文件。代价是:没有轮数封顶,一个陷入死循环的模型可以无限调工具。Whale 用 两条重复信号来兜底,这是第一条。

它要抓什么

模型有时会一字不差地反复发同一个工具调用。工具层的"storm 断路器"(见 03 章的修复机制)会拦住这种字节级重复,给结果打 Code == "storm_blocked"。 但断路器只拦单个调用,不会让回合停下——模型可以永远发、永远被拦。

怎么判定

isAllStormBlocked 判断一轮工具结果是否整轮全被 storm 拦:至少一个结果、且每个结果的 Code 都是 "storm_blocked"(force_summary.go:31-41)。

// turn_loop.go:280-285 (节选)
stormRound := isAllStormBlocked(*toolMsg)
if stormRound {
consecutiveStormRounds++
} else {
consecutiveStormRounds = 0 // 任一轮有真进展就清零
}

连续 maxConsecutiveStormRounds = 3 轮(force_summary.go:16)全被拦,就判定死循环,调 forceSummaryAndFinish 收尾(turn_loop.go:321-324)。


9. 防跑飞机制之二:冗余轮(progress guard)

storm 断路器有个盲区:"同一目标、参数每次都变"的空转它看不见。比如模型用 limit: 1 一行一行 地重复读同一个大文件——每次 offset 都不同,所以没有任何两次调用字节相同,storm 永远不触发, 可模型其实毫无进展(这是上游 issue #271 的 stepping loop)。

progressTracker(progress_guard.go:206-302)专门抓这种"按内容覆盖度算,而非按参数形状算"的空转。

思路:按"新覆盖了多少"判断进不进展

它把只读工具分两类处理:

工具类型怎么算"有进展"判定为空转的条件
带行范围的文件读(有 offset/limit)看这次新覆盖了多少之前没读过的行重读已见文件、新增行 < minProgressLines(=4)
其它只读工具(grep/list_dir…)看同一"目标"在滑动窗口里被重访几次同目标重访 ≥ targetRevisitThreshold(=8)

带范围的文件读靠一个"行覆盖"结构 lineCoverage,把读过的行区间合并成不重叠的区间集,新读一段 就 addAndCountNew 算出其中几行是新的(progress_guard.go:168-185)。关键细节:覆盖范围会被 钳到工具实际返回的行数——一次读到文件尾外(请求了新 offset 却啥也没返回)覆盖度增量为 0, 照样算空转(progress_guard.go:270-288)。

其它只读工具没有行范围,退回"目标重访频率":toolCallTarget 把 path、pattern、query、command 等所有识别性参数拼成一个身份(progress_guard.go:71-85),同一身份在窗口里出现 ≥ 8 次算空转。 两个不同 pattern 的 grep 目标不同,各算一次新搜索,不会误伤。

判定与清零

// turn_loop.go:293-297 (节选)
if progress.observe(assistant.ToolCalls, toolMsg.ToolResults, readOnly) {
consecutiveRedundantRounds++
} else {
consecutiveRedundantRounds = 0
}

observe 只有在这一轮至少有一个调用、且每个调用都是 stall 时才判该轮"冗余" (progress_guard.go:224-240)——任何一个变更类调用或有新内容的读都让这轮不算冗余。连续 maxConsecutiveRedundantRounds = 6 轮(progress_guard.go:45)冗余,就 forceSummaryAndFinish 收尾(turn_loop.go:325-328)。

两条机制的分工:storm 抓"字节级重复",progress guard 抓"语义级空转"。前者 3 轮触发、后者 6 轮触发,互补覆盖两类死循环。轮询类工具(如 shell_wait)被显式豁免——它本来就该被反复调 (progress_guard.go:56-63250-260)。

撞闸后:强制小结,而非静默截断

两条机制(以及 §10 的各种 cap)触发时都走 forceSummaryAndFinish:它先发 ForcedSummaryStarted,让模型在不再调工具的前提下写一段"完成了什么、还剩什么"的小结, 再在前面贴一条醒目横幅声明"本回合是被自动打断的,这是进度不是完成,可用 /retry 续跑" (force_summary.go:67-75115),最后发 Done为什么要横幅: 防止被打断的回合被用户 或"读到历史的下一个模型"误当成任务已完成。


10. 其余终止闸:给子代理用的硬上限

主 agent 靠上面两条启发式收敛;而子代理(subagents,见 05 章)会用 WithMaxToolCalls / WithMaxToolIters / WithMaxTurns 配上硬上限。循环里这些闸按顺序检查 (都在 turn_loop.go 的 ToolUse 分支内):

触发条件行为位置
Plan 循环轻推计划模式下检测到 loop 且未推过推一次"该写计划了",清零重来turn_loop.go:304-320
连续 stormconsecutiveStormRounds >= 3forceSummary 收尾turn_loop.go:321
连续冗余consecutiveRedundantRounds >= 6forceSummary 收尾turn_loop.go:325
分类器断路器太多动作被拦、断路器跳闸注入可见 nudge,continueturn_loop.go:334
回合数上限modelTurns >= maxTurnsforceSummary 收尾turn_loop.go:345
工具调用收尾轻推接近 maxToolCalls(留 ~20% 余量)推一次"该收尾了",只发一次turn_loop.go:355
工具调用硬上限toolCalls >= maxToolCallsforceSummary 收尾turn_loop.go:368
工具轮数上限toolIters >= maxToolItersforceSummary 收尾turn_loop.go:372
主 agent 兜底maxToolIters==0 && toolIters >= 500forceSummary 收尾turn_loop.go:380

两个易忽略的细节:

  • 收尾轻推 vs 硬上限的区别。 轻推(toolCallWrapUpThreshold,tool_call_budget.go:41)在还剩约 20% 预算时只发一次软提示"别开新战线、把结论写出来",让模型体面收尾;硬上限则是撞死线后 贴横幅强制截断。前者避免的正是后者那种"话说到一半被砍"。轻推只对设了 cap 的子代理生效 (主 agent maxToolCalls == 0)。
  • 主 agent 兜底 500 是最后防线。 即便前面两条启发式全漏了,mainAgentToolIterBackstop = 500 (force_summary.go:26)保证任何回合终会终止。它设得远高于任何合理任务,正常永不触发;真触发了 横幅也会讲清楚(force_summary.go:18-26 注释)。

11. 对外流式协议:AgentEventType 枚举

回合和外界的唯一契约是它 emit 的事件类型。全部枚举在 agent.go:28-77。按用途归类:

类别事件(节选)
助手输出assistant_deltareasoning_deltaplan_deltaplan_completed
工具生命周期tool_args_deltatool_calltool_resulttool_call_blockedtool_mode_blocked
审批/权限tool_approval_requiredtool_approval_grantedtool_policy_decisionclassifier_review
用户输入交接user_input_requireduser_input_submitteduser_input_cancelled
恢复/修复tool_args_repairedtool_call_scavengedtool_recovery_scheduled/attempt/exhaustedleaked_tool_call_scrubbedresponse_reset
上下文/缓存context_compactedprefix_driftprefix_cache_metricsusagebudget_warning
强制小结forced_summary_startedforced_summary_doneforced_summary_failed
钩子hook_started/blocked/warned/failed/completed
子代理/并行subagent_startedsubagent_completedtask_progressparallel_reason_started/completed
终态turn_cancelleddoneerror

事件载荷统一装在 AgentEvent 结构(agent.go:132-160),不同事件填不同的可选指针字段 (如 ToolCallResultCompactUsageErr)。一条规则: 每个 emit 都检查返回值, false(通常是 ctx 取消)就立即 return 收尾——这保证消费方一断开,循环就停。


12. 边界与局限(诚实)

  • 主 agent 不靠轮数收敛。 它的终止全押在 storm/progress 两条启发式 + 500 兜底上;若模型发明 出一种**既非字节重复、又每轮都有"新覆盖"**的病态循环,只有 500 兜底能救它——代价是要跑满 500 轮。这是刻意的取舍:宁可容忍长任务,不误杀合法的深度工作。
  • 预算闸只在开工前查。 budgetExceeded 在回合入口检查(turn_loop.go:87);回合内部超预算 靠的是 emitBudgetWarningIfNeeded 预警(turn_loop.go:237)+ 下次开工被闸住,而非中途硬停。
  • 前缀漂移只报不救。 PrefixDrift 是诊断信号,循环不会因它自愈或中止;它更多是给上游发现 缓存布局 bug 用的。

13. 代码地图(导航索引)

主题文件符号
唯一真入口 + for 循环internal/agent/turn_loop.gorunStreamWithNewMessages
对外薄封装系列internal/agent/turn_loop.goRunStreamWithOptions / RunStreamWithTurnOptions / RunStreamWithInjectedContentOptions
途中注入输入internal/agent/turn_loop.goInjectTurnInput / activeTurnState.appendPending / drainPending
本回合权限旋钮internal/agent/turn_loop.goRunOptions
活跃回合互斥internal/agent/agent.goErrSessionBusy / Agent.active
预算闸internal/agent/usage_budget.gobudgetExceeded / ErrBudgetExceeded
一轮:问模型+执行工具internal/agent/stream.gostreamAndHandle
消费 provider 事件流internal/agent/stream_ingest.gocollectAssistantStream
派发工具 / abortTurninternal/agent/stream_dispatch.godispatchToolCalls / toolResultRequestsTurnAbort
连续 storm 轮internal/agent/force_summary.goisAllStormBlocked / maxConsecutiveStormRounds
冗余轮进度守卫internal/agent/progress_guard.goprogressTracker / observe / stalled / maxConsecutiveRedundantRounds
撞闸强制小结internal/agent/force_summary.goforceSummaryAndFinish / forcedSummaryBanner
工具调用收尾轻推internal/agent/tool_call_budget.gotoolCallWrapUpThreshold
泄漏工具调用回收internal/agent/leaked_tool_call.gocontainsLeakedToolCall / maxLeakedToolCallNudges
对外事件枚举internal/agent/agent.goAgentEventType / AgentEvent

下一步: 循环里被当黑盒的 rt(记忆/前缀布局)见 02 章;被当黑盒的 streamAndHandle(工具目录组装、模糊编辑、Shell 沙箱)见 03 章;turnPolicy 背后的规则与自动审查见 04 章