数据截至 (上游 commit 565d53515b54)
主循环与回合模型(agent-core)
30 秒导读:
@oh-my-pi/pi-agent-core是整个项目的心跳。它做一件事并把它做到极致: 反复地 向模型要一条回复、把回复里的工具调用跑掉、把结果塞回对话、再要下一条——直到 模型不再要工具、也没有排队的新消息为止。本章讲清这个循环的一圈("回合")长什么样、工具 怎么并发和被打断、中止(abort)时怎么收尾,以及一个贯穿全场的关键设计:内部用AgentMessage, 只在真正调模型那一行才转成 provider 的线格式Message。
本章不讲:具体工具怎么实现(见 工具宇宙)、provider 流式与方言细节 (见 模型接入层)、上下文压缩 compaction(见 长程上下文治理)。
1. 这是什么(零基础也能懂)
-
一句话定义: agent-core 是一个 异步生成器循环——喂进用户的一句话,吐出一串生命周期 事件(模型在说话、要调工具、工具跑完了、这一回合结束了……),循环体自己决定要不要再转一圈。
-
它解决什么问题: 一个"会用工具的 AI"不是调一次模型就完事的。模型说"我要读这个文件", 你得真去读、把内容给它、它才能接着说"好,现在我改第 12 行"。谁来编排这个来回? 就是 agent-core。它是模型(嘴)和工 具(手脚)之间那台不知疲倦的传送带。
-
它能做什么:
- 流式地把模型输出解析成结构化的
AssistantMessage(文本 / 思考 / 工具调用三种块)。 - 把一条消息里的多个工具调用按并发规则调度执行,收集结果。
- 把工具结果配对喂回,自动进入下一回合。
- 中途插话(steering)、回合后追问(follow-up)、旁路通知(aside)的注入。
- 干净地处理中止、超时、provider 报错、以及一类特定的协议泄漏(Harmony leak)。
- 流式地把模型输出解析成结构化的
-
用起来什么样: 宿主几乎不直接碰循环,而是通过
Agent类。最小用法:// 示意,非源码 —— 真实入口见 agent.ts:977 `prompt`const agent = new Agent({ /* model, tools, convertToLlm... */ });agent.subscribe(event => render(event)); // 订阅生命周期事件流await agent.prompt("把 README 里的拼写错误都改掉"); // 一句话,循环自己转到停prompt()内部把这句话包 成一条user消息,交给底层的agentLoop(...),然后for await逐个事件更新内部状态、转发给订阅者(agent.ts:1194-1245)。 -
一句话直觉: 把它想成 REPL(读-求值-打印循环),只不过"求值"是模型生成、"打印"是执行 工具。一圈叫一个 回合(turn);只要模型还在要工具或还有排队消息,REPL 就不退出。
本节不出现底层代码。记住一句话就行:agent-core = 模型与工具之间的传送带,一圈叫一个回合。
2. 顶层全景(它大概怎么转)
2.1 一个回合的数据流
先看一圈发生了什么。从左到右是时间顺序,虚线是"喂回去、再来一圈":
┌──────────────── 再来一圈(还有工具 / 还有排队消息)───────────────┐
│ │
用户/工具结果 ▼ │
─────────► ① 注入排队消息 ──► ② 转成线格式 ──► ③ 流式取一条 ──► ④ 有工具调用? │
(steering/aside) convertToLlm AssistantMessage stopReason 判定 │
(AgentMessage→Message) (边流边发事件) │ │
是 │ │ 否 │
▼ ▼ │
⑤ 执行工具 ⑥ 该停了吗? │
(并发+可打断) 查 follow-up │
│ │ │
└──────┴───────────┘
│ 无
▼
agent_end
怎么读这张图: ②③④⑤ 是一个回合的四步;④ 判断"要不要继续",⑤ 跑完工具后必然回到 ①
再转;只有当模型不再要工具(⑥)且没有任何排队消息时,才落到 agent_end 收尾。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
Agent 类 | 有状态的门面:持有 AgentState、暴露 prompt/steer/abort,把 config 组装好交给循环 | agent.ts:329 |
agentLoop / agentLoopContinue | 循环的两个入口:一个带新 prompt 开场,一个从现有 context 续跑 | agent-loop.ts:526 / :340 |
runLoopBody | 真正的双层 while:外层管"停了又被唤醒",内层管"一回合接一回合" | agent-loop.ts:987 |
streamAssistantResponse | 一个回合的"取回复"半场:转线格式 → 调 provider → 边流边发事件 → 返回一条 AssistantMessage | agent-loop.ts:1567 |
executeToolCalls | 一个回合的"跑工具"半场:按并发规则调度、可被 steering 打断、容错收集结果 | agent-loop.ts:2230 |
EventStream | 循环与消费者之间的异步管道:push 事件、for await 消费、result() 拿终值 | packages/ai/src/utils/event-stream.ts:5 |
2.3 一条贯穿全场的暗线:两种消息
这是理解整章的钥匙,先点破:
AgentMessage——循环内部的"货币"。它是Message(LLM 认识的)加上宿主自定义消息类型 的并集(types.ts:512)。UI 通知、状态条这类东西可以塞进来,循环照样传递,但模型看不到。Message——provider 线格式,只有user/assistant/toolResult三种角色。
循环里从头到尾都用 AgentMessage;只有在即将调模型的那一行,才通过 convertToLlm
把它过滤/转换成 Message[](agent-loop.ts:1525)。这条边界是第 4 节 的主题。
3. 回合模型(一个 turn 的边界)
3.1 什么是一个"回合"
一个回合 = 一条 assistant 响应 + 它触发的所有工具调用/结果。 这不是我的定义,是代码里
AgentEvent 注释的原话(types.ts:698)。事件上,回合由一对 turn_start / turn_end 括起来:
turn_start
├─ message_start/update/end (assistant 消息,流式)
├─ tool_execution_start/end (每个工具)
└─ message_start/end (每条 toolResult)
turn_end { message, toolResults }
turn_end 事件带着这一回合的 assistant 消息和它的工具结果一起发出(agent-loop.ts:637,
emitTurnEnd),宿主可挂 onTurnEnd 钩子做每回合的收尾。
3.2 双层循环:内层跑回合,外层管"复活"
runLoopBody 的骨架是两个嵌套 while(agent-loop.ts:1051、:1055):
外层 while(true): ← 管"agent 本要停了,但又来了新消息"
内层 while(hasMoreToolCalls || 有排队消息): ← 每转一圈 = 一个回合
注入排队消息 → 流式取 assistant → 判 stopReason → 跑工具/收尾 → emitTurnEnd
重新拉 steering,决定下一圈的 pendingMessages
(内层退出:模型不再要工具、也没排队消息)
onBeforeYield() → 再拉一次 late-steering / aside / follow-up
有?→ 设为 pendingMessages, continue(外层再转,复活内层)
无?→ break → endAgentStream
为什么要两层? 因为"agent 停下来"和"agent 结束"是两回事。内层跑完(模型不要工具了),
agent 看起来要停;但用户可能在这空档里追问了一句(follow-up),或有个后台任务刚完成
(aside)。外层的职责就是在真正 break 之前再探一次队列,有货就把内层"复活"再转一轮
(agent-loop.ts:1458-1466)。
3.3 stopReason:决定要不要继续的开关
模型每条回复带一个 stopReason。循环据此决定"跑工具 / 停 / 报错收尾":
| stopReason | 含义 | 循环怎么做 |
|---|---|---|
toolUse | 停在工具调用上 | 有工具就跑(hasMoreToolCalls = true) |
stop(end_turn) | 正常结束 | 仍然跑工具——见下方关键细节 |
stop + pause_turn | 非终止停顿(如 Codex 的进度播报) | 无工具时也重采样续跑,最多 8 次 |
length | 撞了 max_tokens | 不跑尾部工具(参数 可能被截断),配占位结果 |
error / aborted | 出错 / 被中止 | 立即给未完成工具补占位结果,收尾退出 |
关键细节一(为什么 stop 也跑工具): stopReason 是 provider 的元数据,不回到线上。
带着 tool_use 的回合,只要把 tool_results 追加上去续跑,无论它当初停在 tool_use 还是 end_turn
都被接受——交错思考的 Opus 就经常在 end_turn 下发工具调用。所以代码把
runnableStop = stopReason === "toolUse" || stopReason === "stop" 一视同仁(agent-loop.ts:1306)。
唯一不能跑的是 length:尾部工具的参数可能是半截的,强行执行会用截断的 payload 造成破坏,
于是这些调用被丢弃并配一条解释性占位结果(agent-loop.ts:1376-1393、占位文案见 :2084)。
关键细节二(pause_turn 的重采样上限): 有的后端会一直"暂停但不结束",若无脑续跑就会
死循环。MAX_PAUSED_TURN_CONTINUATIONS = 8 给了个闸,且一旦某回合带了真工具调用就清零计数
(agent-loop.ts:102、:1020-1035)。
4. AgentMessage ↔ Message:调用边界的那一次转换
这是全章工程含量最高的一处约束,单独成节。
4.1 转换只发生在一个地方
streamAssistantResponse 调 provider 前的准备阶段(已抽成独立的 prepareProviderCall)有连续几行(agent-loop.ts:1520-1523):
// 示意,贴近源码 —— 见 agent-loop.ts:1520
let messages = context.messages; // 一路都是 AgentMessage[]
if (config.transformContext) messages = await config.transformContext(messages, signal);
const llmMessages = await config.convertToLlm(messages); // ← 唯一的 AgentMessage → Message
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
重点看: convertToLlm 是这条边界的闸门。文件顶部注释把设计意图写死了——"Agent loop that
works with AgentMessage throughout. Transforms to Message[] only at the LLM call boundary."
(agent-loop.ts:2-3)。
4.2 默认转换器丢掉什么
默认的 defaultConvertToLlm 只保留能上线的三类,并额外滤掉一种"看着像对话、其实是终止错误"
的东西(agent.ts:60-65):
// 示意,非源码 —— 见 agent.ts:60 `defaultConvertToLlm`
return messages.filter(m => {
if (m.role === "assistant") return !isProviderRefusalMessage(m); // 拒答不回放
return m.role === "user" || m.role === "toolResult"; // 其余只留这两类
});
isProviderRefusalMessage 判的是 stopReason === "error" 且 stopDetails.type 为 refusal /
sensitive(replay-policy.ts:4)——这是 provider 的审核拒答,是终止错误,不该当成正常
对话再喂回去。宿主可传自己的 convertToLlm 覆盖(比如把自定义消息类型翻成 user 消息)。
4.3 转换之后还有两道"归一化"
转成 Message[] 只是第一步,上线前还有两处 provider 适配:
| 归一化 | 做什么 | 位置 |
|---|---|---|
normalizeMessagesForProvider | Cerebras 不吃 assistant 的 thinking 块 → 就地剥掉 | agent-loop.ts:753 |
normalizeTools | 给工具 schema 注入/裁剪:intent 追踪字段 i、按方言渲染示例、按需去描述省 token | agent-loop.ts:852 |
agentLoopContinue 的入口有一条硬约束值得记:续跑时 context 的最后一条消息必须能转成
user 或 toolResult,否则 provider 会拒。代码在入口挡掉了 assistant 结尾的情况
(agent-loop.ts:575),但"能不能转"这件事没法在这里验证,因为 convertToLlm 每回合只调一次
(agent-loop.ts:565-573 的文档注释明说了这点)。
5. 工具执行:并发与阻断
executeToolCalls 是"跑工具"半场,它要同时兼顾:多个工具怎么排、跑一半能不能被打断、
第三方工具乱返回怎么办。
5.1 并发调度:shared 排排站,exclusive 独占
一条 assistant 消息可能带多个工具调用。每个工具用 concurrency 声明自己的排法(types.ts:606):
"shared"(默认)——可以和别的 shared 工具并肩跑。"exclusive"——独占:它得等前面所有在跑的跑完,后面的也得等它。- 函数形式——按(未校验的)原始参数动态决定;解析器抛错就退回安全的
exclusive。
调度逻辑是一段紧凑的"栅栏"编排(agent-loop.ts:2701-2727):
shared A ─┐
lastExclusive ─────────┼─► (并肩) exclusive C 等 A、B 都完
shared B ─┘ │
▼
之后的 shared 又基于 C 起跑
每个 exclusive 任务 Promise.all([lastExclusive, ...sharedTasks]) 等齐前面所有人,然后成为新的
lastExclusive 并清空 shared 批次;shared 任务只等 lastExclusive。最后 Promise.allSettled(tasks)
等全批落定(agent-loop.ts:2729)。
5.2 打断:steering 如何截停一批工具
用户中途插话(steering)时,不该傻等整批工具跑完。机制分两层:
-
每个工具跑完后探一次队列——
checkSteering()(agent-loop.ts:2341)。若队列非空,就 abort 一个内部的steeringAbortController,后续未开跑的工具被标记 skipped(runTool开头的interruptState.triggered短路,:1757)。注意它用的是非消费式 peek(hasSteeringMessages), 消息的所有权仍归队列,直到外层到达注入边界才真正出队。 -
对"只是在等"的工具,跑的过程中也轮询——若某工具声明了
interruptible: true(如job轮询), 一个 250ms 的定时器(STEERING_INTERRUPT_POLL_MS,:114)会周期性调checkSteering,让 abort 信号提前把等待截短,而不是干等到工具自己的窗口耗尽(agent-loop.ts:2651-2654)。
设计克制之处: 只有纯等待、且干净响应 abort 的工具才该标
interruptible——因为 abort 一 个正在写文件的工具会留下半截副作用。文档在AgentTool.interruptible的注释里反复强调了这点 (types.ts:609-617)。
5.3 容错:第三方工具乱返回怎么办
工具接口是有类型的,但 MCP / 扩展 / 用户自写工具运行期可能违约(返回没有 content 数组、块形状
非法……)。把这种脏结果直接存进会话文件,重载时就会崩。coerceToolResult 在唯一入口把它掰回
合法形状(agent-loop.ts:446):
content不是数组 → 换成一条 "invalid result" 错误文本,标isError。- 逐块校验,非法块计数并塞一条说明。
- Anthropic 不接受
is_error: true且内容为空的 tool_result → 补一句兜底文案(:280)。
afterToolCall 钩子返回的结果同样过一遍 coerceToolResult,因为它也是不可信的用户代码
(agent-loop.ts:2577)。
5.4 软性工具要求:提醒→升级,别急着强制
有时宿主想让模型"在干别的之前先调某个工具"(比如先 resolve 一个待决动作)。直接把
tool_choice 设成强制会作废 provider 的消息缓存,代价高。于是有了 SoftToolRequirement
(types.ts:59)——一套"先礼后兵"的生命周期:
| 阶段 | 行为 | 依据 |
|---|---|---|
| 新 id 首次激活 | 注入一次 reminder 消息,tool_choice 保持 auto | agent-loop.ts:1098-1106 |
| 模型听话(只调了要求的工具) | 什么都不改,零缓存失效 | calledOnlyRequiredTool,:942 |
| 模型不听(调了别的 / 摆烂) | 不执行那些"绕路"工具,配 skipped 结果,下一回合强制 tool_choice | :951-981 |
| 强制若干次仍不满足 | MAX_SOFT_TOOL_ESCALATIONS = 3 兜底抛错,防死循环 | :93、:952 |
一个细节:硬 tool_choice 若和软要求冲突(禁用工具 none、或强制了别的工具),软门就让位——
hardToolChoiceBlocks 判这件事(agent-loop.ts:118)。
6. abort 语义:中止时怎么干净收尾
中止是这类循环最容易出 bug 的地方。核心难点:API 要求 tool_use 和 tool_result 必须配对—— 模型说要调 3 个工具,回放时就必须有 3 条结果,否则续跑直接被 provider 拒。
6.1 三种"停"和各自的收尾
| 触发 | 怎么来的 | 收尾动作 |
|---|---|---|
| 外部 abort | agent.abort(reason) → AbortController | 合成一条 aborted 的 assistant 消息,给未完成工具补占位结果 |
| deadline 超时 | config.deadline 到点,内部 AbortController | 每个检查点 isDeadlineExceeded 提前 endAgentStream |
| provider 报错 | 流里来了 error 事件 | stopReason === "error",同样补占位结果后退出 |
error / aborted 的分支在 agent-loop.ts:1239:先把消息里的工具调用滤出来,逐个用
createAbortedToolResult 造占位结果(:2074),塞进 context 维持配对,再 emitTurnEnd +
agent_end 退出。
6.2 abort 竞速:一次注册,反复复用
流式读取时,循环要同时等"下一个事件"和"abort 信号"谁先到。天真的写法是每次 next() 都
Promise.withResolvers + 加/删监听器——太浪费。这里改成整条流只注册一次 abort 监听,拿一个
abortRacePromise 复用给每一次 responseIterator.next()(agent-loop.ts:1728-1752)。abort 赢了
就走 finishAbortedStream,尝试取消 provider、合成 aborted 消息、结算 span。
6.3 只有"完整"的工具调用能活过 abort
被中止时,流到一半的工具调用参数是半截的,带着不完整的 provider id,回放会炸。
retainCompletedToolCalls 只保留那些已经收到 toolcall_end 的调用(其 id 进了
completedToolCallIds 集合),把没完成的丢掉,并打上 stream_interrupted_after_content 标记
(agent-loop.ts:1910)。
6.4 abort 原因怎么透出
agent.abort("用户按了 Esc") 传的字符串,或一个非 AbortError 的 Error,会被
abortReasonText 提取出来挂到合成消息的 errorMessage 上;裸 abort()(reason 是默认的
AbortError DOMException)则退回通用文案 "Request was aborted"(agent-loop.ts:2032)。
6.5 中止后 steering 队列不清空
有个反直觉但正确的设计:外部 abort 时不排空 steering 队列(agent-loop.ts:1428-1434)。
因为若在这里出队,消息会落在一个"马上就要 abort 的模型调用"之前——消息进了历史,agent 却永远
不回应。正确做法是把队列留着,让 session 层 abort 后重新续跑时,再把队列送进一个新的 run。
7. 状态模型:循环之外 的三样东西
循环本身几乎无状态(参数进、事件出)。有状态的东西挂在 Agent 和几个辅助结构上。
7.1 AgentState:门面持有的一切
AgentState(types.ts:517)是宿主眼里的"当前会话":systemPrompt、model、tools、messages、
是否在流式、待决工具集合、错误。Agent 把它设为私有字段 #state(agent.ts:330),对外只读
get state(),改动都走命名方法(setModel、appendMessage、steer……)。
Agent.#runLoop 每次跑之前,把 #state 里的东西装进一个 AgentLoopConfig 和一份 context 快照
(context.messages 是 slice() 出来的副本,agent.ts:1078-1082),再交给 agentLoop。循环产出的
事件流回来,又逐个更新回 #state(agent.ts:1198-1245)。"config/context 进 → 事件出 → state 更新"
是单向的,循环从不直接改宿主状态。
7.2 两条队列:steer(插话)与 followUp(追问)
| 队列 | 何时投递 | 入队方法 | 出队策略 |
|---|---|---|---|
| steering | 回合中间,打断当前工具批次后 | agent.steer(m)(:860) | all 一次全给 / one-at-a-time 每回合一条 |
| followUp | agent 本要停下时才处理 | agent.followUp(m)(:868) | 同上 |
两条队列都是 Agent 私有数组,循环通过 config 上的 getSteeringMessages / getFollowUpMessages
回调去拉(agent.ts:1177-1186)。这样队列的真相只有一份(在 agent-core),session 层用非消费式
的 peekSteeringQueue 派生显示与计数(agent.ts:893)。
7.3 append-only-context:让 provider 缓存少失效
长会话每回合都把整段历史重新序列化上线,provider 的前缀缓存(DeepSeek/Anthropic 的 KV cache)就
反复失效、反复重算。AppendOnlyContextManager(append-only-context.ts:167)专治这个:
- StablePrefix——system prompt + 工具规格算一次就冻住,除非指纹变了(
:50)。 - AppendOnlyLog——消息只增不改,靠
syncMessages每回合把新增的尾巴 append 上去(:207)。
最巧的是 syncMessages 的"就地改写"分支:当某条历史消息被改写( 裁剪、去图、transformContext
重渲染),早期版本会清空整条日志 → 本地后端每回合被迫重算 ~40k token 的 prefix(issue #3406)。
现在它按逐条 digest 找最长稳定前缀,只从分叉点之后重发,让 KV cache 一直热到分叉点
(append-only-context.ts:214-235、#longestStablePrefix at :277)。这属于本章的边界——深入见
长程上下文治理。
7.4 run-collector:一次 run 的账本
若开了 telemetry,AgentRunCollector(run-collector.ts:147)在循环跑的过程中缓冲每次 chat、每个
工具的记录,最后折叠成一个 AgentRunSummary + AgentRunCoverage,随 agent_end 事件带出
(agent-loop.ts:610 buildAgentEndEvent)。它记的是:各 stopReason 计数、每个工具的
ok/error/skipped/blocked/aborted、token 用量、成本、以及"声明了但没被调用的工具"覆盖率。
有两条绕过 span 的 skip 路径要它直接补记账:pre-run 就被中止的工具、以及批次尾扫时"从没产出
结果消息"的工具——都走公开的 recordSkippedTool(agent-loop.ts:1269、:2055-2065)。另外,
beforeToolCall 返回 { block: true } 时抛的 ToolCallBlockedError(run-collector.ts:619)让
span 能把终态标成 blocked 而非混同普通异常。
8. 巧妙之处(可借鉴的 技术)
-
"AgentMessage 全程、只在调用边界转 Message" —— 让循环能承载 UI-only 消息、拒答标记等 非线上内容,而不污染 provider 请求。转换闸门集中在一处(
agent-loop.ts:1525),改一个地方就改了 全部行为。 -
stopReason 不上线,所以
end_turn和tool_use一视同仁 —— 一句被 live Anthropic API 验证过 的观察,省掉了一整类"模型在 end_turn 下发工具"的兼容分支(agent-loop.ts:1283-1306的长注释)。 -
单次注册的 abort 竞速 —— 把每事件的
withResolvers/监听器增删,降成整条流一次注册、复用同 一个 promise(agent-loop.ts:1728)。热路径上的实打实优化。 -
软性工具要求"先提醒后强制" —— 承认"改 tool_choice = 作废缓存"这个真实代价,用一次内联提醒 把"听话"的常见情况变成零成本,只在模型不听时才付强制的代价(
types.ts:59、agent-loop.ts:1318-1357)。 -
最长稳定前缀 diff —— 用逐条 digest 找分叉点,把"改一条历史消息 = 全量重算"降成"只重算分叉 之后",直接回应了一个真实 issue(#3406,
append-only-context.ts:275)。 -
占位结果维持 tool_use/tool_result 配对 —— 无论中止、超时、还是 length 截断,都给每个悬空的 工具调用补一条结果消息,保证下一次回放不被 provider 拒(
createAbortedToolResult,:2074)。
9. 边界与局限(诚实说)
-
agentLoopContinue的最后一条消息约束无法在入口验证 —— 它必须能转成 user/toolResult, 否则 provider 拒;但因为convertToLlm每回合只调一次,入口只能挡掉assistant结尾这一种显式 情况,其余靠调用方自觉(agent-loop.ts:565-573)。 -
软要求的强制上限是"防御性"的,不是常态 ——
MAX_SOFT_TOOL_ESCALATIONS = 3到顶会抛错终止 整个 run(agent-loop.ts:1328)。强制tool_choice本应保证工具被调,走到这一步说明模型行为异常。 -
pause_turn续跑上限 8 次同理 —— 撞上限后就不再续,把控制权交回;一个永不停止 pause 的后端 会被这个闸挡住而非无限自旋(agent-loop.ts:102)。 -
中止时半截工具调用一律丢弃 —— 只有到达
toolcall_end的调用能存活(retainCompletedToolCalls,:1499)。这是安全取舍:半截参数不可信,宁可丢。 -
telemetry 关掉时循环零 tracer 开销 ——
telemetry字段为 undefined 时,buildAgentEndEvent直接返回裸事件,不做任何 collector 快照(agent-loop.ts:615)。功能诚实地按需付费。 -
本章不涉及:in-band 方言如何把工具调用编/解码(第 2 章)、 工具 本身的 FS 形状接口(第 3 章)、compaction 与转向(第 6 章)。
10. 代码地图(导航索引)
按符号名 grep 比按行号更抗漂移。下表列的是真实符号:
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 循环入口(新 prompt) | packages/agent/src/agent-loop.ts | agentLoop |
| 循环入口(续跑) | packages/agent/src/agent-loop.ts | agentLoopContinue |
| 双层 while 主体 | packages/agent/src/agent-loop.ts | runLoopBody |
| 取回复半场(转线格式+流式) | packages/agent/src/agent-loop.ts | streamAssistantResponse |
| 跑工具半场(并发+打断+容错) | packages/agent/src/agent-loop.ts | executeToolCalls |
| 单个工具执行 | packages/agent/src/agent-loop.ts | runTool |
| steering 打断探测 | packages/agent/src/agent-loop.ts | checkSteering |
| 工具结果容错归一 | packages/agent/src/agent-loop.ts | coerceToolResult |
| 中止/超时占位结果 | packages/agent/src/agent-loop.ts | createAbortedToolResult |
| 半截工具调用剔除 | packages/agent/src/agent-loop.ts | retainCompletedToolCalls |
| abort 原因提取 | packages/agent/src/agent-loop.ts | abortReasonText |
| 线格式归一(Cerebras thinking) | packages/agent/src/agent-loop.ts | normalizeMessagesForProvider |
| 工具 schema 归一(intent/示例/裁剪) | packages/agent/src/agent-loop.ts | normalizeTools |
| pause_turn 续跑上限 | packages/agent/src/agent-loop.ts | MAX_PAUSED_TURN_CONTINUATIONS |
| 有状态门面 / prompt 入口 | packages/agent/src/agent.ts | Agent, prompt, #runLoop |
| 默认线格式转换器 | packages/agent/src/agent.ts | defaultConvertToLlm |
| 插话 / 追问入队 | packages/agent/src/agent.ts | steer, followUp |
| 消息/状态/事件类型 | packages/agent/src/types.ts | AgentMessage, AgentState, AgentEvent |
| 工具接口(并发/可打断/intent) | packages/agent/src/types.ts | AgentTool |
| 软性工具要求 | packages/agent/src/types.ts | SoftToolRequirement, isSoftToolRequirement |
| 拒答识别(不回放) | packages/agent/src/replay-policy.ts | isProviderRefusalMessage |
| run 账本 / 被阻断错误 | packages/agent/src/run-collector.ts | AgentRunCollector, ToolCallBlockedError |
| 稳定前缀 + 追加日志 | packages/agent/src/append-only-context.ts | AppendOnlyContextManager, syncMessages |
| thinking 档位 | packages/agent/src/thinking.ts | ThinkingLevel |
| 事件流管道 | packages/ai/src/utils/event-stream.ts | EventStream |
同组其它章: 总览与阅读地图 · 模型接入层与方言 · 工具宇宙 · hashline 编辑语言 · 原生核心 · 长程上下文治理与转向