Agent 循环:三台状态机
v2 的循环由 human 层的 agent、turn、tool 三台状态机与 Agent 级的 AgentLoopService 组成:分工、step 门控、钩子、工具结果侧信道与并发调度。
v0.29 时代,引擎的心脏是 packages/agent-core/src/loop/:一个刻意无状态的 runTurn() 函数。v0.43 里 v1 引擎已整体删除,循环由 packages/agent-core-v2 承担,形态也变了——回合的推进交给 xstate 状态机,状态被显式建模。
文件分工
| 位置 | 拥有什么 |
|---|---|
src/human/agent/machine.ts | agent 机:输入队列、notify、remind、steer、后台工具、prompt gate |
src/human/agent/turn.ts | turn 机:回合内的 step 推进、重试、recovery、中止 |
src/human/tool/machine.ts | tool 机:单个工具调用的生命周期 |
src/agent/loop/loop.ts | IAgentLoopService 接口:submit、steer、cancel、notify 与三个钩子 |
src/agent/loop/loopService.ts | AgentLoopService(2260 行):prompt 生命周期、step 门控、错误处理器、事件落盘 |
src/agent/loop/machine/ | 适配层:engine.ts 翻译状态机事件,requester.ts 接 LLM,tools.ts 接工具执行器 |
human/ 是 v2 的纯内核。packages/agent-core-v2/scripts/check-import-boundaries.mjs 规定它不许 import llm-adapter 或任何 v2 业务域;反过来,只有 llm-adapter、agent/loop/machine、session/agentLifecycle 三个适配层能 import human 的实现,其余代码只能用消息、用量、finish reason 这类「词汇」模块。v1 那条「循环不许 import 宿主层」,在 v2 里成了 lint 强制的目录边界。
turn 机的状态
- 继续与否看 tool call,不看 finish reason:
llm.done的第一个 guard 检查累积出的消息里toolCalls.length > 0,正好绕开 kosong 一章提到的坑——有的 provider 带着工具调用也报completed。 - 空响应当失败:内容为空且没有工具调用,或只有 thinking 内容时,turn 机把它重新
raise成llm.failed.remote,走和网络错误相同的失败级联。 - draining 是注入点:工具收齐后 turn 机发出
turn.drain,agent 机把积压的 notification 与 reminder 一起送回,写进历史后再进入下一步。
step 门控:请求在最后一刻才组装
turn 机有自己的 gating 状态,但现役引擎没给它传 onBeforeStep。真正的门控在适配层的 requester 里(agent/loop/machine/requester.ts:55):
const generate = async (
_config: LlmRequestConfig,
_content: LlmRequestContent,
control: LlmRequestControl,
): Promise<void> => {
const decision =
options?.gate !== undefined
? await options.gate(control.signal)
: ({ type: 'proceed' } as const);gate 就是 AgentLoopService.gate():检查 max-step,运行 onWillBeginStep 钩子(压缩、上下文注入、MCP、工具去重、goal 计数都挂在这里),通过后才调用 IAgentLLMRequesterService.start()。turn 机传来的 _config、_content 被有意忽略——请求消息和工具表由请求服务在门控之后,从上下文内存与工具注册表现取。
v1 靠「推迟 buildMessages 求值」保证工具表与消息来自同一状态(压缩会同时改动两者);v2 用结构达到同样效果:压缩跑完之前,请求根本还不存在。
钩子与错误处理器
IAgentLoopService.hooks 是三个 OrderedHookSlot(src/hooks.ts):onWillBeginStep、onDidFinishStep、onBeforeSubmitPrompt。注册时可用 before / after 指定顺序,例如上下文注入器登记为 before: 'full-compaction',保证提醒先写入、再判断要不要压缩。
失败另走一条链:registerLoopErrorHandler() 登记 { id, match, handle }。回合失败时,loopService 找第一个 match 的处理器;处理器可以调用 retry(),让循环从上下文内存重建历史后继续。上下文溢出就是这样交给压缩的。
回合结果的类型
v1 用 LoopTurnStopReason 联合类型表达结局,错误与 max-step 耗尽走异常。v2 把三种结局都做成了值(loop.ts:101):
export type LoopRunResult =
| {
readonly type: 'completed';
readonly steps: number;
readonly truncated: boolean;
readonly stopReason?: string;
}
| {
readonly type: 'failed';
readonly steps: number;
readonly error: unknown;
}
| {
readonly type: 'cancelled';
readonly steps: number;
readonly reason: unknown;
};max-step 耗尽是 failed 里的 LoopError(错误码 loop.max_steps_exceeded);压缩依旧不是结局,而是钩子与错误处理器的事。
工具结果的侧信道
ExecutableToolSuccessResult(src/tool/toolContract.ts:31):
export interface ExecutableToolSuccessResult {
readonly output: ExecutableToolOutput;
readonly isError?: false | undefined;
readonly stopTurn?: boolean | undefined;
readonly stopTurnReason?: string;
readonly truncated?: boolean | undefined;
readonly note?: string;
readonly delivery?: ToolDelivery | undefined;
readonly spill?: ToolResultSpill;
readonly spillExempt?: true;
}note保留:只给模型看,投影时由renderToolResultForModel()接在输出后面(见上下文与投影)。message移除:v1 那个「只给人看」的字段已不在契约里。- 新增
delivery:工具可随结果递送一条 user 消息,kind目前只有'steer'。loopService在onDidExecuteTool钩子里取出它,以steerIfActive重新提交,正在跑的回合把它当作转向输入吸收。 - 新增
spill:输出超过DEFAULT_TOOL_RESULT_MAX_CHARS(5 万字符)时由截断服务落盘,结果里带outputPath、totalChars。 stopTurnReason给stopTurn补上原因;执行描述上另有stopBatchAfterThis,命中后同一批剩下的调用直接跳过。
并发模型
调度器搬到了 src/agent/toolExecutor/toolScheduler.ts,逻辑与 v1 一致:资源访问不冲突的任务并行,冲突的排队;add() 里那句 void result.catch(() => undefined) 也还在,失败任务不会变成 unhandled rejection。
冲突规则在 src/tool/toolContract.ts 的 ToolAccesses 里,同样没变:all 与一切冲突;读、搜索之间互不冲突;只要一方写就冲突;路径做反斜杠转换、折叠连续斜杠、小写折叠后,再按目录前缀判断包含。
normalizePath 里仍是无条件的 .toLowerCase(),冲突判定按大小写不敏感处理。在大小写敏感的 Linux 上会偏保守(./A.txt 与 ./a.txt 被判为冲突而串行),方向是安全的。
两处新东西:
- 攒批:agent 机为每个 tool call 各起一个 tool 机,
machine/tools.ts则等本 step 的调用全部到齐,再作为一批交给IAgentToolExecutorService.execute(),调度器因此能看到整批的冲突关系。 - 完成顺序与历史顺序分离:结果按完成先后流回;写入回合历史时,turn 机的
collectToolOutcomes()按pendingToolCalls(provider 给出的顺序)逐个生成 tool 消息。
不变量
- 每个 tool call 都有配对结果:执行失败变成错误文本,中止变成
aborted,执行器没吐出结果的补一条produced no result;取消回合时backfillAbortedToolResults()还会为未决调用补写tool.result事件。 - 用量先于工具入账:请求服务在流结束、交还 turn 机之前就调用
usage.record(),即使在工具执行阶段中止,已花费的 LLM 用量也已记下。 - 取消会给 step 封口:取消路径补一条
finishReason: 'interrupted'的step.end;进程崩溃留下的半截 step,由contextMemory/loopEventFold.ts折叠时补上「Tool execution was interrupted before its result was recorded」的结果。消费转录的代码(transcript 包、回放)仍要能处理半截记录。
下一篇:回合收敛与异常处理——provider 层的各种失败怎么被吸收。