跳到主要内容

无状态回合循环:一次工具调用的一生

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 闸门、用量聚合、终态后续跑、映射 TurnResultrunTurn
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.tsloop 唯一的模型/系统提示来源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-36LoopStepStopReasontypes.ts:38LoopTerminalStepStopReason

循环骨架很短,读它一眼就懂节奏(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 不会停在一个半吊子状态。

② 用量立即聚合,中断也要报账。 recordStepUsageaddUsage 把每步用量累加进 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_turnstopReason默认初值(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 钩子

先拼工具表,还是先拼消息?顺序有讲究。 工具表 stepToolsbeforeStep 之后才求值,而且和 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正常收尾
truncatedmax_tokens输出被 token 上限截断
filteredfiltered被内容过滤
pausedpaused流被 provider 暂停/过载中断
otherunknown兜底

流断了但还夹着工具调用怎么办? 若停止原因是 paused/unknown/max_tokens还带着工具调用 (可能是参数被截断在半路),loop 不执行它们,而是走 recordUnexecutedToolCalls (turn-step.ts:406-419):给每个调用补一条 tool.call + 一条合成的「本次未执行」错误结果 (tool-call.ts:199-233)。为什么不能直接丢掉?丢了会丢失模型意图,还可能留下一条严格 provider 会当成「空 assistant 消息」拒收的记录(tool-call.ts:56-59UNEXECUTED_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:239,在 runToolCallBatchcalls = response.toolCalls.map(...),tool-call.ts:144): 解析 JSON 参数、按名字找工具、校验参数 schema。产出两类:runnable(能跑)或 rejected(找不到工具/ 参数非法)。这一相是纯的——只读不写,可放心并行 map。

相二:准备(按 provider 顺序逐个来,准备完就发 tool.call)。 prepareToolCall(tool-call.ts:291) 串行地跑 prepareToolExecution / authorizeToolExecution 钩子、解析出真正的执行体 execution,并在 执行开始前通过 dispatchToolCall 发出 tool.call 事件(tool-call.ts:772)。注意 tool.call 复用 provider 给的工具调用 id 当 uuid,让 transcript 挂在同一个规范身份上(tool-call.ts:768-771 注释)。

相三:执行(交给调度器,冲突串行——§3.4 专讲)。 准备好的任务塞进 ToolScheduler (tool-call.ts:145, 153),调度器决定谁和谁能同时跑。任务携带自己的资源访问声明 accesses (tool-call.ts:382-389)。

相四:回收(结果按 provider 顺序发 tool.result,封 step)。 工具可能乱序完成,但终态事件 仍按 provider 顺序发出:for 遍历 pendingResults(它们本就按 provider 顺序 push),挨个 awaitfinalizeToolResult 收尾、发 tool.result(tool-call.ts:168-178)。

一条重要保证:finallyawait Promise.allSettled(pendingResults)(tool-call.ts:179-184)——哪怕 准备或回收中途抛了错,也要把所有已启动的任务结算掉,免得 rejected 的执行 promise 变成游离的 unhandled rejection。

批次内的「急刹」:stopBatchAfterThis 有的工具一旦成功就改变了回合生命周期状态(比如某个交接类 工具)。这种工具的 execution.stopBatchAfterThis 为真时(tool-call.ts:388),准备阶段一命中就把它之后 的调用全部标记为「跳过」(prepareSkippedToolCall,给一条「因前一个工具停了本回合而跳过」的错误结果), 并置 stopTurn(tool-call.ts:155-162)。这是「一批里前面的人把门关了,后面的人就别进了」。

每个 tool.call 必有配对的 tool.result README 契约:除非 step 在结果派发点之前被打断,否则 每条 tool.call 都要跟一条 tool.result。这就是为什么即使取消,批次也要把配对结果抽干(§3.2 提到的 「封口前再刹车」正是配合这条)。

信任边界:工具返回值要被强制归一。 工具是任意 JS,可能返回 undefined、原始值、或缺 output 字段 的对象。coerceToolResult(tool-call.ts:686)把这些一律转成 isError: true 的结果,好让 loop 仍能发 出配对的 tool.result。注释点破:这是「任意工具实现」与「loop 其余部分」之间的信任边界。


3.4 资源冲突调度:让能并发的并发,会打架的排队(本章巧妙点)

它要解决的小问题: 模型同一批点了 read A.tsread B.tswrite 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)才可能冲突;两个 readsearch 直接判不冲突(tool-access.ts:80-96,fileOperationsConflictfileOperationWrites)。
  • 路径要真的重叠才冲突。 同一路径,或一方是递归目录、另一方落在其子树下,才算重叠 (tool-access.ts:98-109,fileAccessesOverlap);路径先归一化(反斜杠、多斜杠、大小写、尾斜杠) 再比(tool-access.ts:111-118,normalizePath)。

于是那三个工具:read Aread B 不冲突、read Awrite A 冲突、read Bwrite 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:383),即「保守地和谁都串行」,安全优先。


3.5 事件分发:一条路,两种命运,故障隔离

它要解决的小问题: loop 里发生的事,有的必须落进持久 transcript(step/工具/内容块),有的只是 给 UI 看的实时流(打字机 delta、工具进度、重试提示)。而且实时监听器(某个 UI 回调)崩了,绝不能 拖垮回合。

一条路两种命运(events.ts,createLoopEventDispatcher)。 事件分两类:

  • LoopRecordedEvent:step.beginstep.endcontent.parttool.calltool.result (events.ts:137-142)——先 await appendTranscriptRecord 落库,发给 live 监听器 (events.ts:189-195,recordEvent)。落库先行,保证持久顺序。
  • LoopLiveOnlyEvent:turn.interruptedstep.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 导入
2LLM 是模型元数据/能力/系统提示的唯一来源llm.ts:123-129,LLM
3用量在 LLM.chat 返回后立即记录,不等工具turn-step.ts:392-393
4abort 中的工具执行仍要报账已花的模型用量run-turn.ts:219
5provider abort 时信封故意半开(有 begin 无 end)turn-step.ts:423 前的刹车点
6每条 tool.call 必跟一条 tool.result(除非在结果派发点前被打断)tool-call.ts:168-184
7recorded 事件先落库再发 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:469)弹窗、等用户点是 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:168-178

  • 信封可半开是特性不是 bug。 允许 step.beginstep.end,让 abort 能在任意安全点干净停下,而不必 强行补一个假的封口。依据:README Contracts + turn-step.ts:423

  • 工具返回值的信任边界。 任意 JS 返回值一律过 coerceToolResult 归一,保证「每个 call 必有 result」这条 铁律不被一个乱写的工具破坏。依据:tool-call.ts:686-704

  • 流断也不丢模型意图。 流被 provider 掐断却夹着半截工具调用时,补记一条合成的「未执行」结果而非丢弃, 既保 wire 合法又告诉模型「这些没跑,要用请重发」。依据: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


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

主题文件符号
回合级收敛主循环packages/agent-core/src/loop/run-turn.tsrunTurn
中断/异常上报事件packages/agent-core/src/loop/run-turn.tsmakeInterruptedEvent
单步执行与原子信封packages/agent-core/src/loop/turn-step.tsexecuteLoopStep
停止原因归一化packages/agent-core/src/loop/turn-step.tsderiveStepStopReason
流式回调装配packages/agent-core/src/loop/turn-step.tscreateChatStreamingCallbacks
工具批次生命周期packages/agent-core/src/loop/tool-call.tsrunToolCallBatch
分类(纯函数预检)packages/agent-core/src/loop/tool-call.tspreflightToolCall
准备 + 派发 tool.callpackages/agent-core/src/loop/tool-call.tsprepareToolCall, dispatchToolCall
未执行工具调用补记packages/agent-core/src/loop/tool-call.tsrecordUnexecutedToolCalls
工具返回值归一(信任边界)packages/agent-core/src/loop/tool-call.tscoerceToolResult
grace 超时兜底packages/agent-core/src/loop/tool-call.tsraceExecuteWithGraceTimeout
有状态执行调度packages/agent-core/src/loop/tool-scheduler.tsToolScheduler, isBlocked
资源访问冲突判定packages/agent-core/src/loop/tool-access.tsToolAccesses, conflict
事件分发 + 故障隔离packages/agent-core/src/loop/events.tscreateLoopEventDispatcher, safeEmitLive
模型/系统提示唯一来源packages/agent-core/src/loop/llm.tsLLM
单步重试与退避packages/agent-core/src/loop/retry.tschatWithRetry
停止原因/结果契约packages/agent-core/src/loop/types.tsLoopStepStopReason, TurnResult
工具参数解析packages/agent-core/src/loop/tool-args-parse.tsparseToolCallArguments
max-step 错误packages/agent-core/src/loop/errors.tscreateMaxStepsExceededError

本章是「无状态回合循环」这一章。同组其它章见 index.md 的阅读地图: 02-agent.md(拼提示/压缩/权限/子 agent)、03-tools.md(工具套件)、 04-providers.md(kosong / kaos 抹平)、05-v2-architecture.md06-surfaces.md