数据截至 (上游 commit 676a0a228882)
核心回合循环:从输入到工具执行的主线
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/usage | stream.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,键是 sessionID。LoadOrStore 是原子的"占坑":坑已被占
(loaded == true)就说明该会话已有回合在跑,直接吐 ErrSessionBusy(agent.go:23)。回合结束时
用 defer a.active.Delete(sessionID) 释放坑(turn_loop.go:112)。
这把"同一会话不能并发两个回合"变成一个无锁的原子操作。注意占坑用的 activeTurnState 不只是
个哨兵——它还挂着一个 pending []core.Message 队列(agent.go:297-300),这是第 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 之前先建好整回合复用的状态:
- 刷新工具快照
refreshToolSnapshotForTurn:这一回合模型能用哪些工具,定格成一份toolSnapshot(turn_loop.go:113)。 - 水合运行时
memory.HydrateRuntime:把"不可变系统前缀(system prompt + 工具 schema)"和 "可变历史"组装成rt(turn_loop.go:121-122)。这块的布局是全项目的招牌,专门为提示词缓存 命中而设计——详见 02 章;本章只把它当黑盒用。 - 一堆计数器归零:
modelTurns(问了模型几次)、toolIters(执行了几轮工具)、toolCalls(累计工具调用数)、以及两个防跑飞计数器consecutiveStormRounds/consecutiveRedundantRounds,还有progress := &progressTracker{}(turn_loop.go:123-132)。 - 重置断路器:storm 修复器和分类器电路断路器都清一遍,免得上一个任务的状态污染这一回合
(
turn_loop.go:133-140)。
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_plan 等 | 见 modeBlockedDetailsForCall(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:156-215):
┌ 一轮迭代 ────────────────────────────────────────────┐
│ 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.Log 和
history(turn_loop.go:157-162)。注入靠的是 InjectTurnInput——它找到活跃回合的 activeTurnState,
把消息 appendPending 进那个队列(turn_loop.go:59-79)。
直觉: 模型正在长跑,你又补了一句"顺便也看下 utils.go"。这句不会新开一个回合(会撞
ErrSessionBusy),而是排进当前回合的 pending,下一轮开头被吸收进来。收尾各处也会用
turnState.hasPending() 判断"有没有新料要处理",有就发 ResponseReset 事件并 continue 而不是结束
(如 turn_loop.go:261-266)。
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:183-184),然后发 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:230-242)。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_turn
发 Done(turn_loop.go:243-271)。
7.2 泄漏的工具调用:模型把工具写成了正文
模型有时不走 API 的工具通道,而是把工具调用当普通文本吐出来(如
<tool_calls><read_file .../></tool_calls>)。这种文本永远不会执行,循环会误以为"它答完了"。
containsLeakedToolCall 检测到这种情况后:把包裹文本从 assistant 正文里擦掉、发
LeakedToolCallScrubbed + ResponseReset、轻推模型改用结构化通道,然后 continue 重来
(turn_loop.go:413-473)。由 maxLeakedToolCallNudges = 2 封顶(leaked_tool_call.go:16),
免得一个恰好引用了这种格式的真答复被永远卡住。
7.3 Done:真正的收尾
模型给了非空最终答复且没别的事要做,就是回合结束:Plan 模式下先发 PlanCompleted(此时"最终
答复"本身就是待批准的计划,turn_loop.go:479-481),然后 emit 一个 AgentEventTypeDone 带上最终
assistant 消息,return——goroutine 结束,defer close(out) 关掉 channel(turn_loop.go:482-483)。
8. 防跑飞机制之一:连续 storm 轮
主 agent(交互式)故意不设工具轮数上限(maxToolIters == 0,agent.go:373 注释写明)——真实
任务可能就是要读几十个文件。代价是:没有轮数封顶,一个陷入死循环的模型可以无限调工具。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:326-329)。
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:330-333)。
两条机制的分工:storm 抓"字节级重复",progress guard 抓"语义级空转"。前者 3 轮触发、后者 6 轮触发,互补覆盖两类死循环。轮询类工具(如
shell_wait)被显式豁免——它本来就该被反复调 (progress_guard.go:56-63、250-260)。
撞闸后:强制小结,而非 静默截断
两条机制(以及 §10 的各种 cap)触发时都走 forceSummaryAndFinish:它先发
ForcedSummaryStarted,让模型在不再调工具的前提下写一段"完成了什么、还剩什么"的小结,
再在前面贴一条醒目横幅声明"本回合是被自动打断的,这是进度不是完成,可用 /retry 续跑"
(force_summary.go:67-75、115),最后发 Done。为什么要横幅: 防止被打断的回合被用户
或"读到历史的下一个模型"误当成任务已完成。
10. 其余终止闸:给子代理用的硬上限
主 agent 靠上面两条启发式收敛;而子代理(subagents,见 05 章)会用
WithMaxToolCalls / WithMaxToolIters / WithMaxTurns 配上硬上限。循环里这些闸按顺序检查
(都在 turn_loop.go 的 ToolUse 分支内):
| 闸 | 触发条件 | 行为 | 位置 |
|---|---|---|---|
| Plan 循环轻推 | 计划模式下检测到 loop 且未推过 | 推一次"该写计划了",清零重来 | turn_loop.go:309-325 |
| 连续 storm | consecutiveStormRounds >= 3 | forceSummary 收尾 | turn_loop.go:326 |
| 连续冗余 | consecutiveRedundantRounds >= 6 | forceSummary 收尾 | turn_loop.go:330 |
| 分类器断路器 | 太多动作被拦、断路器跳闸 | 注入可见 nudge,continue | turn_loop.go:339 |
| 回合数上限 | modelTurns >= maxTurns | forceSummary 收尾 | turn_loop.go:350 |
| 工具调用收尾轻推 | 接近 maxToolCalls(留 ~20% 余量) | 推一次"该收尾了",只发一次 | turn_loop.go:360 |
| 工具调用硬上限 | toolCalls >= maxToolCalls | forceSummary 收尾 | turn_loop.go:373 |
| 工具轮数上限 | toolIters >= maxToolIters | forceSummary 收尾 | turn_loop.go:377 |
| 主 agent 兜底 | maxToolIters==0 && toolIters >= 500 | forceSummary 收尾 | turn_loop.go:385 |
两个易忽略的细节:
- 收尾轻推 vs 硬上限的区别。 轻推(
toolCallWrapUpThreshold,tool_call_budget.go:41)在还剩约 20% 预算时只发一次软提示"别开新战线、把结论写出来",让模型体面收尾;硬上限则是撞死线后 贴横幅强制截断。前者避免的正是后者那种"话说到一半被砍"。轻推只对设了 cap 的子代理生效 (主 agentmaxToolCalls == 0)。 - 主 agent 兜底 500 是最后防线。 即便前面两条启发式全漏了,
mainAgentToolIterBackstop = 500(force_summary.go:26)保证任何回合终会终止。它设得远高于任何合理任务,正常永不触发;真触发了 横幅也会讲清楚(force_summary.go:18-26注释)。
11. 对外流式协议:AgentEventType 枚举
回合和外界的唯一契约是它 emit 的事件类型。全部枚举在 agent.go:28-78。按用途归类:
| 类别 | 事件(节选) |
|---|---|
| 助手输出 | assistant_delta、reasoning_delta、plan_delta、plan_completed |
| 工具生命周期 | tool_args_delta、tool_call、tool_result、tool_call_blocked、tool_mode_blocked |
| 审批/权限 | tool_approval_required、tool_approval_granted、tool_policy_decision、classifier_review |
| 用户输入交接 | user_input_required、user_input_submitted、user_input_cancelled |
| 恢复/修复 | tool_args_repaired、tool_call_scavenged、tool_recovery_scheduled/attempt/exhausted、leaked_tool_call_scrubbed、response_reset |
| 上下文/缓存 | context_compacted、prefix_drift、prefix_cache_metrics、usage、budget_warning |
| 强制小结 | forced_summary_started、forced_summary_done、forced_summary_failed |
| 钩子 | hook_started/blocked/warned/failed/completed |
| 子代理/并行 | subagent_started、subagent_completed、task_progress、parallel_reason_started/completed |
| 终态 | turn_cancelled、done、error |
事件载荷统一装在 AgentEvent 结构(agent.go:133-161),不同事件填不同的可选指针字段
(如 ToolCall、Result、Compact、Usage、Err)。一条规则: 每个 emit 都检查返回值,
false(通常是 ctx 取消)就立即 return 收尾——这保证消费方一断开,循环就停。
12. 边界与局限(诚实)
- 主 agent 不靠轮数收敛。 它的终止全押在 storm/progress 两条启发式 + 500 兜底上;若模型发明 出一种**既非字节重复、又每轮都有"新覆盖"**的病态循环,只有 500 兜底能救它——代价是要跑满 500 轮。这是刻意的取舍:宁可容忍长任务,不误杀合法的深度工作。
- 预算闸只在开工前查。
budgetExceeded在回合入口检查(turn_loop.go:87);回合内部超预算 靠的是emitBudgetWarningIfNeeded预警(turn_loop.go:240)+ 下次开工被闸住,而非中途硬停。 - 前缀漂移只报不救。
PrefixDrift是诊断信号,循环不会因它自愈或中止;它更多是给上游发现 缓存布局 bug 用的。
13. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| 唯一真入口 + for 循环 | internal/agent/turn_loop.go | runStreamWithNewMessages |
| 对外薄封装系列 | internal/agent/turn_loop.go | RunStreamWithOptions / RunStreamWithTurnOptions / RunStreamWithInjectedContentOptions |
| 途中注入输入 | internal/agent/turn_loop.go | InjectTurnInput / activeTurnState.appendPending / drainPending |
| 本回合权限旋钮 | internal/agent/turn_loop.go | RunOptions |
| 活跃回合互斥 | internal/agent/agent.go | ErrSessionBusy / Agent.active |
| 预算闸 | internal/agent/usage_budget.go | budgetExceeded / ErrBudgetExceeded |
| 一轮:问模型+执行工具 | internal/agent/stream.go | streamAndHandle |
| 消费 provider 事件流 | internal/agent/stream_ingest.go | collectAssistantStream |
| 派发工具 / abortTurn | internal/agent/stream_dispatch.go | dispatchToolCalls / toolResultRequestsTurnAbort |
| 连续 storm 轮 | internal/agent/force_summary.go | isAllStormBlocked / maxConsecutiveStormRounds |
| 冗余轮进度守卫 | internal/agent/progress_guard.go | progressTracker / observe / stalled / maxConsecutiveRedundantRounds |
| 撞闸强制小结 | internal/agent/force_summary.go | forceSummaryAndFinish / forcedSummaryBanner |
| 工具调用收尾轻推 | internal/agent/tool_call_budget.go | toolCallWrapUpThreshold |
| 泄漏工具调用回收 | internal/agent/leaked_tool_call.go | containsLeakedToolCall / maxLeakedToolCallNudges |
| 对外事件枚举 | internal/agent/agent.go | AgentEventType / AgentEvent |
下一步: 循环里被当黑盒的 rt(记忆/前缀布局)见 02 章;被当黑盒的
streamAndHandle(工具目录组装、模糊编辑、Shell 沙箱)见 03 章;turnPolicy
背后的规则与自动审查见 04 章。