Agent 循环契约:把外壳冻结成状态机 本节摘要:外壳就是 Agent,模型只是协处理器。本节把那个可以把任意模型插进去的循环契约冻结下来。一个能无人值守跑 40 轮的编码 Agent 不是聊天循环,而是一个状态机——它的节点运营者可以拦截,它的边运营者可以审计。一旦把契约写下来,换模型、换工具、换策略就不再是重构,而是一次注册调用。本节命名了六个状态、十个钩子主题、两个拉取点、十一种事件类型与一个预算包络,后续四节(工具注册表、JSON-RPC 传输、分发器、规划器)都插进这个形状。 对应原课程:Phase 19 · Lesson 20 · (原英文 )。本节属「Agent Harness 深度构建赛道」第一节。
本节摘要:外壳就是 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 深度构建赛道」第一节。
阅读完本节,你应当能够:
一个能无人值守跑 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.start、plan.draft、plan.commit、step.start、step.end、tool.call、tool.result、tool.error、budget.warn、session.pause、session.complete。
事件不复制钩子 payload。钩子是命令式的(改、中止),事件是观察性的(记录、发)。把它们当正交的两条线。
本节不调模型、不注册真工具、不实现传输——那是后续四节。本节钉死契约,让后四节插进来而不重写。main.py 里的确定性规划器是个占位,返回一个硬编码的三步计划(两步需工具结果)。重点是循环,不是计划。
HarnessLoop:主类,持状态、发钩子、发事件。Budget:跟踪三限。Event:流上的类型化信封。HookRegistry:分发表。_transition:唯一改变状态的函数,状态机不变量集中一处。code/tests/test_loop.py 钉死每个转移与每个钩子触发顺序。
Claude Code、Cursor、OpenCode 在 2025 年中都收敛到了这个钩子形状。命名是功能性的而非品牌的。本节的价值在「契约可执行」——它得扛住规划器热重载、扛住返回畸形 JSON 的工具、扛住在 40 轮进行到三分之二时在 before_tool_call 抛异常的钩子。测试就是练这些失败模式。
before_tool_call 第 27 轮抛异常的钩子,确认状态机正确转 IDLE 且不泄漏部分状态。resume,确认从中断处继续而非重头。DONE 直接到 EXECUTING,确认被 _transition 拒绝。AWAITING_TOOL 与 on_pause,是返回不是异常。下一节,我们给循环配上「工具注册表与 schema 校验」——让 Agent 能信任它调的工具。