Agent 循环:三台状态机

v2 的循环由 human 层的 agent、turn、tool 三台状态机与 Agent 级的 AgentLoopService 组成:分工、step 门控、钩子、工具结果侧信道与并发调度。

作者 David更新于 第 5 篇(共 33 篇)

v0.29 时代,引擎的心脏是 packages/agent-core/src/loop/:一个刻意无状态的 runTurn() 函数。v0.43 里 v1 引擎已整体删除,循环由 packages/agent-core-v2 承担,形态也变了——回合的推进交给 xstate 状态机,状态被显式建模。

文件分工

位置拥有什么
src/human/agent/machine.tsagent 机:输入队列、notify、remind、steer、后台工具、prompt gate
src/human/agent/turn.tsturn 机:回合内的 step 推进、重试、recovery、中止
src/human/tool/machine.tstool 机:单个工具调用的生命周期
src/agent/loop/loop.tsIAgentLoopService 接口:submit、steer、cancel、notify 与三个钩子
src/agent/loop/loopService.tsAgentLoopService(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 被判为冲突而串行),方向是安全的。

两处新东西:

  1. 攒批:agent 机为每个 tool call 各起一个 tool 机,machine/tools.ts 则等本 step 的调用全部到齐,再作为一批交给 IAgentToolExecutorService.execute(),调度器因此能看到整批的冲突关系。
  2. 完成顺序与历史顺序分离:结果按完成先后流回;写入回合历史时,turn 机的 collectToolOutcomes() 按 pendingToolCalls(provider 给出的顺序)逐个生成 tool 消息。

不变量

  1. 每个 tool call 都有配对结果:执行失败变成错误文本,中止变成 aborted,执行器没吐出结果的补一条 produced no result;取消回合时 backfillAbortedToolResults() 还会为未决调用补写 tool.result 事件。
  2. 用量先于工具入账:请求服务在流结束、交还 turn 机之前就调用 usage.record(),即使在工具执行阶段中止,已花费的 LLM 用量也已记下。
  3. 取消会给 step 封口:取消路径补一条 finishReason: 'interrupted' 的 step.end;进程崩溃留下的半截 step,由 contextMemory/loopEventFold.ts 折叠时补上「Tool execution was interrupted before its result was recorded」的结果。消费转录的代码(transcript 包、回放)仍要能处理半截记录。

下一篇:回合收敛与异常处理——provider 层的各种失败怎么被吸收。

本页目录