数据截至 (上游 commit cdaa80b77807)
无状态回合循环:一次工具调用的一生
30 秒导读:
packages/agent-core/src/loop/是 Kimi Code 的心脏——一段无状态的循环代码。 你喂给它「怎么拼消息、有哪些工具、模型是谁」,它就负责把 「调一次模型 → 跑模型点名的工具 → 带着结果再调模型」 这条骨架反复跑,直到模型说「我说完了」或撞上某个停止条件。它刻意不管 会话存哪、字节怎么走线、权限弹窗谁批、上下文怎么压缩——那些是上层(host)的活。本章只讲这副骨架。
1. 这是什么(零基础也能懂)
一句话定义: loop 是「跑一个回合(turn)」的纯逻辑——把模型的一次次发言和一次次工具调用,
按固定节奏推到收尾。
先厘清三个层层嵌套的词,后文一直用:
| 词 | 白话 | 边界在哪 |
|---|---|---|
| 回合 turn | 用户说一句话后,Agent 忙活到再次把话筒交还给用户的整段过程 | runTurn 一次调用 |
| 步 step | 回合里的一次模型调用(可能带一批工具调用) | executeLoopStep 一次调用 |
| 工具调用批次 batch | 一个 step 里模型一口气点名的那一组工具 | runToolCallBatch 一次调用 |
解决什么问题 / 给谁用: 假设你在终端里让 AI 改一个大 项目的代码。模型不会一句话就改完——它要
先「读文件」、再「改文件」、再「跑测试」,每一步都得先把上一步的真实结果拿回来再决定下一步。
有人得当这个「反复问模型、老实跑工具、把结果回灌」的调度员。loop 就是这个调度员最内核的那圈。
它能做什么(职责):
- 一个回合里反复跑 step,直到模型停下(
end_turn)或撞上限制。 - 每个 step:调模型 → 判断模型是想收尾还是想调工具 → 若调工具就跑完这批 → 再进下一 step。
- 一批工具里,互不干扰的并发跑,会互相踩的串行跑。
- 全程把 token 用量累加、把中断/异常如实上报、把「谁调了谁、结果是啥」按顺序写进 transcript(逐字记录)。
它刻意不做什么(这条同样重要,§5 详述): 不拥有 session、不接传输层、不弹权限 UI、不执行上下文压缩。
一句话直觉: 把 loop 想成一台只会转圈的马达——它只管「进一格、出一格」的机械节奏和刹车安全,
至于油箱(上下文)怎么加、方向盘(提示词)怎么打、仪表盘(UI)怎么显示,全接在马达外面。马达自己
不存油、不认路。这正是「无状态」的含义:runTurn 跑完就干净返回,状态都在调用方手里。
本节不出现底层代码。记住一件事:loop = 只跑骨架,不持有世界。
2. 顶层全景(它大概怎么转)
2.1 一张图看清三层嵌套
怎么读这张图: 从上到下是三层调用嵌套;每层的「循环/批量」用回折箭头标出;最内层跑完把停止原因 一路交回最外层,决定是「再转一圈」还是「收尾」。
用户一句话 → runTurn(一个回合) run-turn.ts:89
│
│ while(true): 每圈 = 一个 step ──────────────────────────┐
│ ├─ 刹车点: signal.throwIfAborted() │ run-turn.ts:137
│ ├─ 闸门: steps >= maxSteps? 抛错 │ run-turn.ts:139
│ │ │
│ └─ executeLoopStep(一个 step) ────────────┐ │ turn-step.ts:79
│ ├─ beforeStep 钩子(可拦、可压缩) │ │
│ ├─ 拼工具表 + 拼消息(同一份状态) │ │ turn-step.ts:136
│ ├─ 发 step.begin ← transcript 开封 │ │ turn-step.ts:157
│ ├─ chatWithRetry → 调模型 ★立即记账用量 │ │ turn-step.ts:392
│ ├─ 停止原因是 tool_use? │ │
│ │ 是 → runToolCallBatch(一批工具)─┐│ │ tool-call.ts:138
│ │ ├─ 分类(纯函数) ││ │
│ │ ├─ 按 provider 顺序发 tool.call │
│ │ ├─ 调度执行(冲突串行) ││ │ tool-scheduler.ts
│ │ └─ 按 provider 顺序收 tool.result │
│ │ 否 → 终态(end_turn / max_tokens…) │
│ ├─ 刹车点 → 发 step.end ← transcript 封口 │ │ turn-step.ts:423
│ └─ afterStep 钩子(只观察,改不了结果) │ │
│ │
│ 停止原因 = tool_use? ── 是 → continue(再转一圈)────────┘ run-turn.ts:183
│ └ 否 → 问 shouldContinueAfterStop 钩子
│ 要续 → 转;不续 → break
↓
返回 TurnResult { stopReason, steps, usage } run-turn.ts:235
2.2 部件一句话职责
| 文件 | 干什么 | 关键符号 |
|---|---|---|
run-turn.ts | 回合级收敛:刹车点、max-step 闸门、用量聚合、终态后续跑、映射 TurnResult | runTurn |
turn-step.ts | 单个 provider step:钩子、拼消息、原子信封、调模型、流式回调、交棒工具批次 | executeLoopStep |
tool-call.ts | 工具批次生命周期:分类→准备→执行→按序回收结果 | runToolCallBatch |
tool-scheduler.ts | 有状态执行调度:无冲突可重叠、冲突则串行 | ToolScheduler |
tool-access.ts | 资源访问模型:什么样的两把访问算「冲突」 | ToolAccesses, conflict |
events.ts | 事件分发器:recorded 事件落 transcript + live 事件发布,故障隔离 | createLoopEventDispatcher |
llm.ts | loop 唯一的模型/系统提示来源 | LLM |
retry.ts | 单步内的重试与指数退避 | chatWithRetry |
types.ts | 停止原因、工具结果、钩子等窄接口契约 | LoopStepStopReason, TurnResult |
2.3 主线走一遍(高层,不进代码)
输入是「模型是谁(LLM)+ 怎么拼消息(buildMessages)+ 有哪些工具(tools)+ 事件往哪发
(dispatchEvent)」。runTurn 起一个 while 循环:每圈调一次模型;模型若点了工具,就把那批工具
跑完、把结果通过 dispatchEvent 写进 transcript,然后再转一圈——此时上层的 buildMessages 会把
刚才的工具结果拼进新消息,模型于是「看到」了结果。模型哪天说「不调工具了」,回合收尾,返回一个
TurnResult(停止原因、跑了几步、总用量)。
3. 核心原理(逐个机制,由浅入深)
3.1 回合级收敛:while 循环怎么知道该停
它要解决的小问题: 一个回合要转几圈,事先不知道——取决于模型点几次工具。得有个循环,并想清楚 「什么时候继续、什么时候停、停的时候算清账」。
思路: tool_use 是唯一的「继续信号」;其余停止原因都是这一回合的终态(除非上层钩子明确
要求续跑)。见 types.ts:30-36 的 LoopStepStopReason 与 types.ts:38 的 LoopTerminalStepStopReason。
循环骨架很短,读它一眼就懂节奏(run-turn.ts:136-201,runTurn):
while (true) {
signal.throwIfAborted(); // 刹车点①:每圈进门先看有没有被取消
if (maxSteps 已达标) throw MaxStepsError; // 闸门:步数封顶,抛错走 catch
steps += 1;
const stepResult = await executeLoopStep(/* … */);
if (stepResult.stopReason === 'tool_use') continue; // 继续信号:再转一圈
stopReason = stepResult.stopReason; // 终态:记下来
const cont = await hooks?.shouldContinueAfterStop?.(/* … */);
if (cont?.continue !== true) break; // 上层不续跑就收尾
}
三个不变量,一条条看:
① 刹车点在循环边界(safe point)。 取消(abort)不是随处生效,而是只在确定的安全点检查:
每圈开头 signal.throwIfAborted()(run-turn.ts:137),step 内部拼消息前后各查一次,封 step.end
前再查一次(turn-step.ts:423)。这样 transcript 不会停在一个半吊子状态。
② 用量立即聚合,中断也要报账。 recordStepUsage 用 addUsage 把每步用量累加进 usage
(run-turn.ts:124-129)。关键在异常路径:即便被取消,catch 分支仍 return { stopReason: 'aborted', steps, usage }(run-turn.ts:219)——已经花掉的模型用量必须报账,不能因为中断就丢账。
③ 中断分两类,如实上报。 catch 里区分「用户主动取消」和「超时/程序性 abort」:靠
isUserCancellation(signal.reason) 判定,前者 interruptReason: 'user_cancelled',后者 'aborted'
(run-turn.ts:203-219);撞 max-step 则是 'max_steps',其它异常是 'error'(run-turn.ts:221)。
无论哪种,都先 dispatchEvent 一个 turn.interrupted 事件再决定返回还是重抛。
关键细节: end_turn 是 stopReason 的默认初值(run-turn.ts:111),正常退出会被真实终态覆盖。
tool_use 永远不会出现在 TurnResult 里——它按定义就不是「回合的最终结果」(types.ts:48 注释)。
3.2 单步信封:一次模型调用的原子外壳
它要解决的小问题: 一个 step 里发生的所有事——模型说的话、点的工具、工具的结果——在 transcript 上
得被一对 step.begin / step.end 括起来,像一个信封。而且用量记账不能等到工具跑完才记(工具可能
中途被取消,那就丢了已花的模型钱)。
信封的开与封(turn-step.ts,executeLoopStep):
beforeStep 钩子 ─→ 拼工具表+拼消息 ─→ [step.begin] ─→ 调模型 ─→ ★记用量 ─→ 判停止原因
│
┌── tool_use → 跑工具批次(发 tool.call / tool.result)───────────────┘
└── 终态/流断 → 视情况补记未执行的工具调用
↓
刹车点 ─→ [step.end] ─→ afterStep 钩子
先拼工具表,还是先拼消息?顺序有讲究。 工具表 stepTools 在 beforeStep 之后才求值,而且和
buildMessages 挨着(turn-step.ts:136-137)。原因很实在:beforeStep 可能触发压缩(compaction),
压缩会把中途加载的动态工具 schema 从上下文和账本里清掉——若工具表在更早时候就抓好了,就会派发一个
「模型其实已经没有的工具」。工具表和请求消息必须来自同一份状态。
用量:模型一返回就记,不等工具(核心不变量)。 拿到 response 后立刻
const usage = response.usage; const usageResult = await recordUsage(usage)(turn-step.ts:392-393),
在跑工具之前。README 的契约把这条写死了:「Provider usage is recorded immediately after LLM.chat
returns, not after tool execution completes」。
停止原因怎么从模型响应归一化。 provider 各说各话的 finish reason,统一收敛成 loop 认的那几个
(turn-step.ts:501-521,deriveStepStopReason):
| provider 说 | loop 归一化为 | 含义 |
|---|---|---|
completed/未给 + 有工具调用 | tool_use | 继续:跑工具再转一圈 |
completed/未给 + 无工具调用 | end_turn | 正常收尾 |
truncated | max_tokens | 输出被 token 上限截断 |
filtered | filtered | 被内容过滤 |
paused | paused | 流被 provider 暂停/过载中断 |
other | unknown | 兜底 |
流断了但还夹着工具调用怎么办? 若停止原因是 paused/unknown/max_tokens 却还带着工具调用
(可能是参数被截断在半路),loop 不执行它们,而是走 recordUnexecutedToolCalls
(turn-step.ts:406-419):给每个调用补一条 tool.call + 一条合成的「本次未执行」错误结果
(agent-core/src/loop/tool-call.ts:199-233)。为什么不能直接丢掉?丢了会丢失模型意图,还可能留下一条严格 provider
会当成「空 assistant 消息」拒收的记录(agent-core/src/loop/tool-call.ts:56-59 的 UNEXECUTED_TOOL_CALL_OUTPUT 讲明了这点)。
信封封口前的最后一次刹车。 工具批次即便在取消时也会把配对的 tool.result 抽干(见 §3.3),所以
封 step.end 之前再查一次 signal.throwIfAborted()(turn-step.ts:423)。README 的契约允许信封
故意半开:provider abort 时,可以只有 step.begin 没有 step.end。
afterStep 只能观察。 封口后才跑 afterStep,且它抛错被吞掉——step 已经封了,观察类钩子改不了结果
(turn-step.ts:446-460)。这与 beforeStep(能 block、能改状态)形成对照。
本章不展开
turn-step.ts里那一大段 media-degraded / media-stripped / strict 的请求体降级重发 (HTTP 413、图片格式被拒、结构不合规时只重发一次的容错)。那 是「把历史投影成 provider 收得下的样子」 的活,属于拼提示/投影范畴,留给 02-agent.md。这里只需知道:重发成功后,本回合 后续 step 会直接沿用降级投影(run-turn.ts:119-123, 180-181),避免每步都白挨一次拒绝。
3.3 工具调用批次生命周期:分类 → 准备 → 执行 → 按序回收
它要解决的小问题: 模型一口气点了好几个工具。这批工具的 transcript 事件顺序、钩子时机、并发与 中断处理互相耦合,必须放在一处、按一条铁律走。那条铁律叫 provider 顺序不变量。
tool-call.ts 开头的注释直接把这条铁律列成清单(tool-call.ts:1-14)。拆成四相看:
相一:分类(纯函数,不碰钩子不发事件)。 对每个工具调用先做 preflightToolCall
(tool-call.ts:242,在 runToolCallBatch 里 calls = response.toolCalls.map(...),tool-call.ts:148):
解析 JSON 参数、按名字找工具、校验参数 schema。产出两类:runnable(能跑)或 rejected(找不到工具/
参数非法)。这一相是纯的——只读不写,可放心并行 map。
相二:准备(按 provider 顺序逐个来,准备完就发 tool.call)。 prepareToolCall(tool-call.ts:294)
串行地跑 prepareToolExecution / authorizeToolExecution 钩子、解析出真正的执行体 execution,并在
执行开始前通过 dispatchToolCall 发出 tool.call 事件(tool-call.ts:779)。注意 tool.call 复用
provider 给的工具调用 id 当 uuid,让 transcript 挂在同一个规范身份上(tool-call.ts:775-778 注释)。
相三:执行(交给调度器,冲突串行——§3.4 专讲)。 准备好的任务塞进 ToolScheduler
(tool-call.ts:145, 153),调度器决定谁和谁能同时跑。任务携带自己的资源访问声明 accesses
(tool-call.ts:385-392)。
相四:回收(结果按 provider 顺序发 tool.result,封 step)。 工具可能乱序完成,但终态事件
仍按 provider 顺序发出:for 遍历 pendingResults(它们本就按 provider 顺序 push),挨个 await、
finalizeToolResult 收尾、发 tool.result(tool-call.ts:172-182)。
一条重要保证:finally 里 await Promise.allSettled(pendingResults)(tool-call.ts:183-188)——哪怕
准备或回收中途抛了错,也要把所有已启动的任务结算掉,免得 rejected 的执行 promise 变成游离的
unhandled rejection。
批次内的「急刹」:stopBatchAfterThis。 有的工具一旦成功就改变了回合生命周期状态(比如某个交接类
工具)。这种工具的 execution.stopBatchAfterThis 为真时(tool-call.ts:391),准备阶段一命中就把它之后
的调用全部标记为「跳过」(prepareSkippedToolCall,给一条「因前一个工具停了本回合而跳过」的错误结果),
并置 stopTurn(tool-call.ts:159-166)。这是「一批里前面的人把门关了,后面的人就别进了」。
每个 tool.call 必有配对的 tool.result。 README 契约:除非 step 在结果派发点之前被打断,否则
每条 tool.call 都要跟一条 tool.result。这就是为什么即使取消,批次也要把配对结果抽干(§3.2 提到的
「封口前再刹车」正是配合这条)。
信任边界:工具返回值要被强制归一。 工具是任意 JS,可能返回 undefined、原始值、或缺 output 字段
的对象。coerceToolResult(tool-call.ts:693)把这些一律转成 isError: true 的结果,好让 loop 仍能发
出配对的 tool.result。注释点破:这是「任意工具实现」与「loop 其余部分」之间的信任边界。
3.4 资源冲突调度:让能并发的并发,会打架的排队(本章巧妙点)
它要解决的小问题: 模型同一批点了 read A.ts、read B.ts、write A.ts 三个工具。全串行太慢;全并发
又危险——两个都写 A.ts 会互相踩。怎么既快又安全?
思路: 给每个工具声明它要碰哪些资源、怎么碰(读/写/搜索/某路径),然后:访问互不冲突的任务可以 重叠跑,冲突的任务在 provider 顺序边界上串行等待。 这套「访问模型 + 调度器」是 loop 最精巧的一处。
第一步:什么算「冲突」(tool-access.ts)。 冲突判定规则,一条条看:
all访问全局互斥。 无法用文件访问表达的任意副作用(跑 shell、改环境)声明为{ kind: 'all' }(tool-access.ts:10-17),它和任何访问都冲突(tool-access.ts:75,resourceAccessesConflict)。- 读/搜索之间永不冲突。 只有至少一方是写(
write/readwrite)才可能冲突;两个read或search直接判不冲突(tool-access.ts:80-96,fileOperationsConflict→fileOperationWrites)。 - 路径要真的重叠才冲突。 同一路径,或一方是递归目录、另一方落在其子树下,才算重叠
(
tool-access.ts:98-109,fileAccessesOverlap);路径先归一化(反斜杠、多斜杠、大小写、尾斜杠) 再比(tool-access.ts:111-118,normalizePath)。
于是那三个工具:read A 与 read B 不冲突、read A 与 write A 冲突、read B 与 write A 不冲突。
第二步:调度器怎么用这个判定(tool-scheduler.ts,ToolScheduler)。 它只管执行排序,校验/钩子/
建事件都不碰(tool-scheduler.ts:1-11 注释划清了边界)。核心就三招:
add(task): tool-scheduler.ts:32
若 task 与【已在跑的】或【排在它前面的队列任务】冲突 → 入队 queuedTasks
否则 → 立刻 start()
start(task): 放进 activeTasks,跑,完成后 finish() tool-scheduler.ts:64
finish(task): 从 activeTasks 摘掉,再 startQueuedTasks() tool-scheduler.ts:83
→ 重扫队列,现在不冲突的启动,仍冲突的留队
isBlocked 同时看「正在跑的」和「排在自己前面的队列任务」(tool-scheduler.ts:46-53),后者保证
队列内也守 provider 顺序——不会让排在后面的任务插队越过一个和它冲突的前任。
怎么读这条时间线(承接上面三个工具):
provider 顺序: [1] read A [2] read B [3] write A
时间 →
add(1) read A ─ 不冲突 → 立即跑 ┐
add(2) read B ─ 不冲突 → 立即跑 ┤ 1、2 并发(都是读,永不冲突)
add(3) write A ─ 与①read A 冲突 → 入队等待
│
① 完成 → finish → 重扫队列 → ③ 现在不冲突 → 启动
为什么这很妙: 大多数编码 agent 的一批工具是「读一堆、偶尔写一处」。这套规则让所有读并发、
写与相关读之间自动串行,既不用工具作者手写锁,也不牺牲 transcript 的 provider 顺序——顺序由 §3.3
的回收相统一保证,调度器只在执行这一层做重叠。默认地,没声明 accesses 的可执行体退回
ToolAccesses.all()(tool-call.ts:386),即「保守地和谁都串行」,安全优先。
3.5 事件分发:一条路,两种命运,故障隔离
它要解决的小问题: loop 里发生的事,有的必须落进持久 transcript(step/工具/内容块),有的 只是 给 UI 看的实时流(打字机 delta、工具进度、重试提示)。而且实时监听器(某个 UI 回调)崩了,绝不能 拖垮回合。
一条路两种命运(events.ts,createLoopEventDispatcher)。 事件分两类:
LoopRecordedEvent:step.begin、step.end、content.part、tool.call、tool.result(events.ts:137-142)——先await appendTranscriptRecord落库,再发给 live 监听器 (events.ts:189-195,recordEvent)。落库先行,保证持久顺序。LoopLiveOnlyEvent:turn.interrupted、step.retrying、各种 delta、tool.progress(events.ts:144-150)——只发布,不落库。
分发器用函数重载把两者的返回类型分开:recorded 返回 Promise<void>,live-only 返回 void
(events.ts:155-158)。所以 loop 里对 recorded 事件写 await dispatchEvent(...),对 live 事件不 await。
故障隔离(safeEmitLive,events.ts:197-215)。 发布 live 事件时,同步抛错被 try/catch 吞掉,返回的
promise 也挂一个 .catch(() => {})。契约原话:live 监听器是尽力而为,它们的失败不得影响回合。
4. 关键不变量清单(loop 的「宪法」)
这些是 README「Contracts」逐条,配上代码锚点。改 loop 之前先背下来:
| # | 不变量 | 代码依据 |
|---|---|---|
| 1 | 核心 loop 不得 import host 层实现 | index.ts:1-6 注释 + 无 host 导入 |
| 2 | LLM 是模型元数据/能力/系统提示的唯一来源 | llm.ts:123-129,LLM |
| 3 | 用量在 LLM.chat 返回后立即记录,不等工具 | turn-step.ts:392-393 |
| 4 | abort 中的工具执行仍要报账已花的模型用量 | run-turn.ts:219 |
| 5 | provider abort 时信封故意半开(有 begin 无 end) | turn-step.ts:423 前的刹车点 |
| 6 | 每条 tool.call 必跟一条 tool.result(除非在结果派发点前被打断) | tool-call.ts:172-188 |
| 7 | recorded 事件先落库再发 live;live 失败被隔离 | events.ts:189-215 |
| 8 | 工具表在 beforeStep 之后、与消息同一状态下求值 | turn-step.ts:136-137 |
5. 边界契约:loop 刻意不拥有什么
这是理解 loop 定位的最关键一节。README 第一句就划线:「loop is the stateless agent loop. It does not
own sessions, wire transport, compaction execution, permissions UI, or durable protocol bridging.」
loop 只碰骨架,以下全是 host(上层,主要是 02-agent.md 讲的 Agent 主机)的活:
| host 拥有 | loop 怎么「借」到它 | 为什么不放进 loop |
|---|---|---|
| 会话状态、历史、持久化 | loop 无状态,runTurn 跑完即返回 | 无状态才能被反复、并发、可测地调用 |
| 拼提示 / 投影历史 | 由 buildMessages 回调注入(run-turn.ts:37) | loop 不认识「消息该怎么拼」这件事 |
| 上下文压缩(compaction)执行 | 只在 beforeStep 钩子里由 host 触发(turn-step.ts:118-128) | loop 只提供「安全点」,不决定压什么 |
| 权限 UI / 批准 | 由 authorizeToolExecution 钩子回调(tool-call.ts:472) | 弹窗、等用户点是 host/UI 职责 |
| 传输、协议桥接、transcript 落盘 | 由 dispatchEvent / appendTranscriptRecord 注入 | loop 只产事件,不管字节去哪 |
| 系统提示、模型选择 | 由 LLM 对象携带(不变量 #2) | 单一来源,loop 不自己拼提示 |
一句话记牢:loop 提供的是「安全点 + 事件 + 收敛骨架」,所有「持有状态、拼内容、和外界打交道」的活
都通过回调(buildMessages / dispatchEvent / LLM / hooks)注入。 这种「窄接口 + 依赖注入」正是它
能无状态、可单测的根。测试边界见 README「Test Boundaries」列的 test/loop/*.e2e.test.ts。
6. 巧妙之处(可借鉴的技术)
-
用量早记账,中断也报账。 模型钱一花(
chat一返回)就记,和工具执行解耦;abort 路径仍返回usage。 账目永远对得上。依据:turn-step.ts:392-393+run-turn.ts:219。 -
tool_use作为唯一「继续信号」。 把「回合是否继续」压缩成一个布尔判断,循环骨架因此极短、极好读。 依据:run-turn.ts:183-185,types.ts:30-48。 -
资源访问模型让并发免锁。 工具作者只声明「碰哪个路径、怎么碰」,并发安全由
conflict判定 + 调度器自动兜底;读永不互斥、写自动串行。依据:tool-access.ts:67-109+tool-scheduler.ts:32-99。 -
provider 顺序不变量集中在一处。 transcript 上的顺序(
tool.call/tool.result)与执行上的重叠被解耦: 执行可乱序完成,回收严格按序发事件。依据:tool-call.ts:172-182。 -
信封可半开是特性不是 bug。 允许
step.begin无step.end,让 abort 能在任意安全点干净停下,而不必 强行补一个假的封口。依据:README Contracts +turn-step.ts:423。 -
工具返回值的信任边界。 任意 JS 返回值一律过
coerceToolResult归一,保证「每个 call 必有 result」这条 铁律不被一个乱写的工具破坏。依据:tool-call.ts:693-711。 -
流断也不丢模型意图。 流被 provider 掐断却夹着半截工具调用时,补记一条合成的「未执行」结果而非丢弃, 既保 wire 合法又告诉 模型「这些没跑,要用请重发」。依据:
agent-core/src/loop/tool-call.ts:199-233。
7. 边界与局限(诚实说)
-
无状态是把双刃剑。 loop 自己不记任何跨回合的东西;若 host 的
buildMessages没把上一批工具结果 拼进新消息,模型就「看不见」结果——loop 不会替你兜。回灌结果的责任在 host。 -
重试只在单步内。
chatWithRetry的指数退避(默认 10 次,retry.ts:16, 38)只覆盖一次模型调用; 跨回合的策略、压缩后重试等更大颗粒的恢复是 host 的事。 -
abort 靠「安全点」而非抢占。 忽略
AbortSignal的工具可能永不结束;loop 用 2 秒 grace 超时 (tool-call.ts:45, 634-673,raceExecuteWithGraceTimeout)兜底给一个合成错误结果,但那个卡死的 真实执行体可能仍在后台跑——loop 管不了它。 -
本章不覆盖的两块: 请求体降级重发(413/图片格式/结构不合规)与投影细节留给 02-agent.md; 工具本身怎么落到真实目标(改文件、跑命令)见 03-tools.md;模型层与执行环境的抹平见 04-providers.md。