Agent 循环契约:把外壳冻结成状态机


文档摘要

Agent 循环契约:把外壳冻结成状态机 本节摘要:外壳就是 Agent,模型只是协处理器。本节把那个可以把任意模型插进去的循环契约冻结下来。一个能无人值守跑 40 轮的编码 Agent 不是聊天循环,而是一个状态机——它的节点运营者可以拦截,它的边运营者可以审计。一旦把契约写下来,换模型、换工具、换策略就不再是重构,而是一次注册调用。本节命名了六个状态、十个钩子主题、两个拉取点、十一种事件类型与一个预算包络,后续四节(工具注册表、JSON-RPC 传输、分发器、规划器)都插进这个形状。 对应原课程:Phase 19 · Lesson 20 · (原英文 )。本节属「Agent Harness 深度构建赛道」第一节。

Agent 循环契约:把外壳冻结成状态机

本节摘要:外壳就是 Agent,模型只是协处理器。本节把那个可以把任意模型插进去的循环契约冻结下来。一个能无人值守跑 40 轮的编码 Agent 不是聊天循环,而是一个状态机——它的节点运营者可以拦截,它的边运营者可以审计。一旦把契约写下来,换模型、换工具、换策略就不再是重构,而是一次注册调用。本节命名了六个状态、十个钩子主题、两个拉取点、十一种事件类型与一个预算包络,后续四节(工具注册表、JSON-RPC 传输、分发器、规划器)都插进这个形状。

对应原课程:Phase 19 · Lesson 20 · agent-harness-loop-contract(原英文 phases/19-capstone-projects/20-agent-harness-loop-contract/docs/en.md)。本节属「Agent Harness 深度构建赛道」第一节。

学习目标

阅读完本节,你应当能够:

  1. 把 Agent 外壳循环指定为一个带显式转移的确定性状态机。
  2. 实现十个生命周期钩子主题,运营者把策略、遥测、护栏插进去。
  3. 定义两个拉取点:循环把控制权交还调用方,并在新输入上恢复。
  4. 强制每会话预算(轮数、工具调用数、墙钟),超限不泄漏部分状态。
  5. 发出十一种事件类型的类型化流,下游 UI 与追踪器无需直接检查循环即可订阅。

一、问题与直觉

一个能无人值守跑 40 轮的编码 Agent 不是聊天循环,而是一个状态机——它的节点运营者可以拦截,它的边运营者可以审计。一旦把契约写下来,换模型、换工具、换策略就不再是重构,而成了一次注册调用。

本节构建这个契约。我们命名六个状态、十个钩子主题、两个拉取点、十一种事件类型、一个预算包络。外壳里的其他一切(工具注册表、JSON-RPC 传输、分发器、规划器)都插进这个形状。

循环有六个状态,五个活跃、一个终态。IDLE 是唯一合法入口,DONE 是唯一合法出口,AWAITING_TOOL 是唯一会产出拉取点的状态,其余转移都是内部的。状态机是确定性的:给定同样的事件日志,外壳会回到同一状态——这正是让你能重放会话调试而无需重调模型的性质。

二、从零实现

钩子是运营者接进循环的缝。外壳触发十个主题,每个主题接受任意数量订阅者,按注册顺序触发。订阅者可改 payload、抛异常中止本轮、或返回哨兵跳过下一步。

钩子主题(功能命名,非品牌):

before_plan after_plan before_tool_call after_tool_call before_step after_step on_error on_pause on_budget_exceeded on_complete

rm -rf 的钩子放 before_tool_call;发 OpenTelemetry span 的放 after_step;暂停会话恢复的放 on_pause

循环两次交还控制权:第一次在 AWAITING_TOOL(没有工具结果无法推进时),第二次在 on_pause(预算耗尽或钩子显式请求人工评审时)。拉取点不是异常,是返回。调用方检查外壳状态、取回外壳要的东西、调 resume(payload)。外壳从停下的地方继续——和 Python 生成器同一个形状。

预算包络(三限,超限是 yield 不是 kill):

class Budget: max_turns: int # 每轮 +1 max_tool_calls: int # 每工具调用 +1 max_wallclock_s: int # 每状态转移检查 # 任一触限 -> on_budget_exceeded + budget.warn # -> 下个拉取点转 IDLE,带 budget-exceeded 原因

预算不是 kill switch,是 yield——调用方决定是延预算续跑还是关会话。

三、事件流与契约边界

循环在契约的特定点往一个类型化流追加事件。流是只追加的,订阅者可从任意偏移重放。十一种事件类型:session.startplan.draftplan.commitstep.startstep.endtool.calltool.resulttool.errorbudget.warnsession.pausesession.complete

事件不复制钩子 payload。钩子是命令式的(改、中止),事件是观察性的(记录、发)。把它们当正交的两条线。

本节不调模型、不注册真工具、不实现传输——那是后续四节。本节钉死契约,让后四节插进来而不重写。main.py 里的确定性规划器是个占位,返回一个硬编码的三步计划(两步需工具结果)。重点是循环,不是计划。

四、可复用产物

  • HarnessLoop:主类,持状态、发钩子、发事件。
  • Budget:跟踪三限。
  • Event:流上的类型化信封。
  • HookRegistry:分发表。
  • _transition:唯一改变状态的函数,状态机不变量集中一处。

code/tests/test_loop.py 钉死每个转移与每个钩子触发顺序。

五、框架对比

Claude Code、Cursor、OpenCode 在 2025 年中都收敛到了这个钩子形状。命名是功能性的而非品牌的。本节的价值在「契约可执行」——它得扛住规划器热重载、扛住返回畸形 JSON 的工具、扛住在 40 轮进行到三分之二时在 before_tool_call 抛异常的钩子。测试就是练这些失败模式。

六、练习

  1. 失败注入:写一个在 before_tool_call 第 27 轮抛异常的钩子,确认状态机正确转 IDLE 且不泄漏部分状态。
  2. 重放:录一段事件日志,只重放日志(不调模型)重建相同状态,验证确定性。
  3. 预算续跑:在预算耗尽的拉取点,让调用方延预算并 resume,确认从中断处继续而非重头。
  4. 事件正交:写一个改 payload 的钩子,确认事件流里看不到该改动(事件是观察性的)。
  5. 状态非法转移:尝试从 DONE 直接到 EXECUTING,确认被 _transition 拒绝。

本节要点回顾

  1. 外壳是状态机:六状态、确定性,可重放调试。
  2. 十个钩子主题:运营者接策略/遥测/护栏,命令式。
  3. 两个拉取点:AWAITING_TOOLon_pause,是返回不是异常。
  4. 十一种事件:观察性,与钩子正交,只追加可重放。
  5. 预算是 yield:三限(轮/工具/墙钟),超限交还调用方决定。
  6. 契约可执行:扛热重载、扛畸形 JSON、扛中途异常钩子。

下一节,我们给循环配上「工具注册表与 schema 校验」——让 Agent 能信任它调的工具。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U