Agent Harness Loop 契约
harness 才是 agent。模型只是协处理器。这节课把 loop 契约钉死,好让你把任何模型接进去。
类型: Build 语言: Python 前置要求: 第 13 阶段第 01-07 课、第 14 阶段第 01 课 预计时间: ~90 分钟
学习目标
- 把 agent harness loop 指定成一个带显式转移的确定性状态机。
- 实现 10 个生命周期 hook topic,供运维把策略、遥测和护栏接进去。
- 定义 2 个 pull point,让 loop 把控制权还给调用方,并在收到新输入后恢复。
- 对每个 session 强制执行预算限制(turn、tool call、wall-clock),且超限时不泄漏半截状态。
- 发出一条带类型的 11 种事件流,让下游 UI 和 tracer 不用直接窥探 loop 就能订阅。
cf-loop-contract框架
一个能无人值守跑 40 个 turn 的 coding agent,不是聊天循环。它是一个状态机:运维可以拦它的节点,可以审它的边。一旦你把契约写清楚,换模型、换工具、换策略都不再是重构,而是一次注册调用。
这节课就做这件事。我们把 6 个状态、10 个 hook topic、2 个 pull point、11 种事件类型和一层预算外壳都定下来。harness 里的其他东西(tool registry、JSON-RPC transport、dispatcher、planner)都往这个形状里插。
状态
loop 有 6 个状态。5 个是活动态,1 个是终止态。
stateDiagram-v2
[*] --> IDLE
IDLE --> PLANNING: run(goal)
PLANNING --> EXECUTING: plan 已提交
EXECUTING --> AWAITING_TOOL: 需要 tool_call
AWAITING_TOOL --> REFLECTING: 收到结果
EXECUTING --> REFLECTING: 无工具步骤完成
REFLECTING --> EXECUTING: 下一步
REFLECTING --> PLANNING: 重新规划
REFLECTING --> DONE: 目标达成
PLANNING --> DONE: 无 plan
DONE --> [*]IDLE 是唯一合法入口。DONE 是唯一合法出口。AWAITING_TOOL 是唯一会让出 pull point 的状态,其他转移全是内部行为。
这个状态机必须是确定性的。给定同一份事件日志,harness 必须重回同一个状态。正是这个性质,才让你能在调试时 replay session,而不用重新调用模型。
Hook Topic
hook 是运维插进 loop 的接缝。harness 会触发 10 个 topic。每个 topic 都能挂任意多个 subscriber。subscriber 按注册顺序触发;它可以改 payload、抛错中止本轮,也可以返回一个 sentinel 跳过下一步。
before_plan after_plan
before_tool_call after_tool_call
before_step after_step
on_error
on_pause
on_budget_exceeded
on_complete这个形状,基本就是 Claude Code、Cursor、OpenCode 到 2025 年中收敛出来的公共套路。名字是功能性的,不是品牌性的。拦 rm -rf 的 hook 放在 before_tool_call。发 OpenTelemetry span 的 hook 放在 after_step。处理暂停恢复的 hook 放在 on_pause。
Pull Point
loop 会让出控制权两次。第一次是在 AWAITING_TOOL,因为没有工具结果它没法继续。第二次是在 on_pause,因为预算耗尽,或者某个 hook 明确要求人工 review。
pull point 不是异常,而是 return。调用方检查 harness 状态,取回 harness 要的东西,然后调用 resume(payload)。harness 从停下来的地方继续。这和 Python generator 是同一种形状。pull point 之上的 transport 由你选:TUI 里是按键;MCP 里是 tools/call;队列里是 job poll。
事件流
loop 会在契约里的特定位置把事件追加到一条带类型的流里。流是 append-only,subscriber 可以从任意 offset replay。实现的 11 种事件如下:
session.start:调用run(goal)时发一次plan.draft:planner 返回草案时发plan.commit:草案被提交成当前 plan 后发step.start:每个执行 step 开始时发step.end:每个执行 step 结束时发tool.call:需要工具的 step 把控制权让回调用方时发tool.result:带着 tool result 恢复时发tool.error:带着错误恢复,或 hook 中止调用时发budget.warn:打到预算上限时发session.pause:loop 因预算或 hook 暂停时发session.complete:loop 进入DONE时发一次
事件不复制 hook payload。hook 是命令式的,负责改、拦、跳过;事件是观察式的,负责记录和外发。两者别混。
预算外壳
一个 session 带 3 个限制:turn 数、tool call 数、wall-clock 秒数。每个 turn 把 turn 计数加一,每次 tool call 把 tool call 计数加一,wall-clock 在每次状态转移时检查。任何一个限制被打满时,loop 会先触发 on_budget_exceeded,再发 budget.warn,然后在下一个 pull point 上带着预算超限原因转回 IDLE。
预算不是 kill switch,而是 yield。到底是扩预算后恢复,还是直接结束 session,由调用方决定。
这节课不做什么
它不调用模型,不注册真实工具,也不实现 transport。那是后面 4 节课的事。这节课先把契约钉死,好让后面 4 节可以接进来而不用重写。
main.py 里的 deterministic planner 只是占位符。它会返回一个硬编码的 3 步 plan,其中两步需要 tool result。重点是 loop,不是 plan。
怎么读代码
HarnessLoop 是主类,负责持有状态、触发 hook、发事件。Budget 跟踪预算。Event 是事件流里的类型化信封。HookRegistry 是分发表。_transition 是唯一能改状态的函数,所以状态机约束全收口在这一处。
先把 main.py 从头到尾读一遍,再读 code/tests/test_loop.py。测试把每条状态转移和每种 hook 触发顺序都钉住了。
往前走
生产里最难的不是写状态机,而是让契约真的可执行。它得扛住 planner 热重载,扛住工具返回坏 JSON,也得扛住一个 hook 在 40 turn session 跑到三分之二时于 before_tool_call 抛错。这节课的测试就在打这些故障模式。跑它们,拆它们,再补 case。
下一课加 tool registry。再下一课加 JSON-RPC transport。再下一课加 dispatcher。到第 24 课,这个文件里的 loop 就会带着真实 plan、真实工具和真实预算跑起来。