/goal 是如何实现的:从 Pi 与 Claude Code 的 Agent Loop 说起
写于 2026-10-01。Pi 的部分直接读了
badlogic/pi-mono主分支的packages/agent/src/源码(agent-loop.ts约 940 行、agent.ts约 613 行、types.ts约 529 行,2026-10-01 拉取);Claude Code 的 agent loop 与/goal部分依据官方文档(Claude Code 本身不开源,所以只有文档层面的描述);Codex 与 Pi 扩展的部分来自第三方的实现分析和包主页,文中会标明。源码和功能迭代很快,细节以你读到的版本为准。
0. 先说结论
/goal 不是什么新能力,它是在 Agent Loop
的外面再套了一层"外环":把"什么时候停"的决定权,从干活的模型手里拿走,交给一个可验证的条件。
- Agent Loop 的默认规则是:模型这一轮不再调用工具,就算做完了,停。 这个判断由模型自己做,所以它会早停、偷懒、或者"自我感觉良好"地宣布完成。
/goal给这个"停"加了一道关卡:每次要停的时候,先检查"目标条件满足了吗";没满足,就再塞一条消息,让它继续。
三个工具的实现路线不同,但骨架一致:
| 工具 | 谁来判断"做完了" | 怎么让它继续 |
|---|---|---|
| Claude Code | 独立的小模型(默认 Haiku)读对话记录来评估 | 借 Stop hook:评估说"没完成"就把理由喂回去 |
| Codex | 干活的模型自己,通过
update_goal(complete) 工具声明 |
运行时在空闲时自动续跑,目标状态存 SQLite |
| Pi(社区扩展) | 同样是模型自己声明(有证据要求) | 往 follow-up 队列里放一条续跑消息 |
要看懂这三种做法,得先知道 Agent Loop 本身长什么样——所以本文先拆 Pi
的源码(第 1 章),再看官方文档描述的 Claude Code loop 并与 Pi 对照(第
2 章),然后才回头看 /goal(第 4 章起)。
1. Agent Loop:最小模型
1.1 一段伪代码
messages = [系统提示, 用户提问] |
这就是所有 Coding Agent 的内核。我之前的笔记 [[Pi/Pi
Architecture:拆解 Coding Agent 的内部架构|Pi Architecture]]
从视频角度讲过这个结构,这次直接看源码实现,重点是:真实的 loop
比这段伪代码多了哪些东西,这些东西为什么恰好是做 /goal
需要的。
1.2 Pi 把 loop 分成了两层
Pi 的 agent 包(packages/agent)里有两层 API:
| 层 | 文件 | 形态 |
|---|---|---|
| 底层 loop | agent-loop.ts |
纯函数:agentLoop(prompts, context, config, signal, streamFn)
返回一个事件流 |
| 有状态包装 | agent.ts |
Agent 类:持有对话记录,提供 prompt() /
continue() / steer() / followUp()
/ abort() / waitForIdle() |
底层 loop 不保存任何东西:给它一份 context(消息 +
工具)和一份
config(一堆回调),它跑完,把新产生的消息通过事件流吐出来。Agent
类才负责"记住对话、排队、并发保护、把事件分发给界面"。
入口有两个:
agentLoop:带着新的用户消息开始。先发agent_start、turn_start,再把用户消息作为事件发出,然后进入runLoop。agentLoopContinue:不加新消息,直接从现有上下文继续,用于重试。源码里有两道检查——上下文不能为空,且最后一条消息不能是 assistant(否则模型提供商会拒绝请求,因为对话必须以用户或工具结果结尾)。
这个"最后一条不能是 assistant"的限制,是后面理解
/goal的关键:要让一个已经说完话的 agent 继续,必须在它后面补一条 user 消息。
1.3 runLoop:双层循环
核心是
runLoop,结构(我把源码精简成示意,保留了真实的变量名和顺序):
async function runLoop(context, newMessages, config, signal, emit, streamFn) { |
两层循环各管什么:
| 条件 | 含义 | |
|---|---|---|
| 内环 | 还有工具调用 \|\| 有待处理消息 |
一个 turn = 一次模型回复 + 它调用的所有工具。只要模型还在调工具,或者用户插了话,就继续 |
| 外环 | 内环结束后,followUp 队列非空 |
模型已经没有工具要调了(本来要停),但还有排队的后续消息,就把它们塞进去再来一轮 |
这个外环就是 /goal 的天然挂点:agent
"本来要停了"的那个瞬间,正好是评估"目标达成了吗"的时机。
1.4 steering 和 follow-up:两种"插话"
这两个都是往对话里塞用户消息,区别在什么时候被取走:
| steering(转向) | follow-up(后续) | |
|---|---|---|
| 取走时机 | 每个 turn 结束后(工具都执行完) | agent 完全没事做、本来要停的时候 |
| 对当前工作的影响 | 打断:下一次模型请求就会看到它 | 不打断:等当前工作做完 |
| 用途 | 用户在 agent 干活时按回车补充一句"别动那个文件" | 排队下一件事 |
Agent 类里两者各有一个
PendingMessageQueue,有两种取出方式(QueueMode):"one-at-a-time"(默认,一次取一条)和
"all"(一次全取)。注释里还有一个小细节:准备阶段(比如压缩)可能很久,所以
loop 在准备完后会再取一次
steering,但"只在之前那次没取到东西时才再取,否则一条一条模式会在同一轮塞两条消息"。
1.5
几个钩子(AgentLoopConfig)
loop 本身只管"调模型、执行工具、判断要不要继续",其余都交给配置里的回调:
| 钩子 | 时机 | 能做什么 |
|---|---|---|
transformContext |
调模型前,消息层面 | 修剪/压缩上下文、注入外部内容 |
convertToLlm |
调模型前 | 把应用层消息(含 UI 专用消息)转成模型能懂的
Message[];不得抛异常 |
getApiKey |
每次请求前 | 动态取 Key(应对会过期的 OAuth 令牌) |
prepareRequest |
每次请求模型前(含第一次) | 替换本次请求的上下文、模型、思考等级 |
beforeToolCall |
工具参数校验后、执行前 | 返回 {block: true} 阻止执行(权限检查) |
afterToolCall |
工具执行后 | 改写工具结果,或设 terminate |
finishTurn |
一个 turn 的工具都执行完后、turn_end 前 |
返回 {action: "end"} 结束整个 run,或
{action: "continue"} 保证再来一次请求 |
prepareNextTurn |
turn_end 后、下一轮开始前 |
换上下文/模型,追加消息(源码注释里点名了"例如压缩") |
getSteeringMessages /
getFollowUpMessages |
见上 | 提供排队消息 |
其中 finishTurn 是最像"给 /goal
预留的接口"的一个:它的注释写得很明白——{ action: "end" }
结束运行,{ action: "continue" }
在正常回合里"保证再来一次模型请求"(如果已经有工具结果、steering 或
follow-up
要来,就由它们满足这次请求,不额外多请求;否则用当前上下文再续一次)。出错或被中止的回复则是硬退出,钩子也拦不住。
1.6 工具执行与几个防御性细节
- 默认并行:
toolExecution默认"parallel"——先按顺序做预检(校验参数、跑beforeToolCall),通过的再并发执行;tool_execution_end按完成顺序发,但写回对话的工具结果按模型调用的顺序排。任何一个工具标了executionMode: "sequential",整批就串行。 - 截断保护:模型回复因为输出 token
上限被截断(
stopReason === "length")时,工具调用的参数可能是不完整的 JSON(源码用"尽力修复的 JSON 解析器",所以可能解析成功却缺内容)。于是这一条消息里的所有工具调用一律不执行,每个都返回错误,让模型重新发。 terminate:工具结果可以带terminate: true;只有一批工具全都带才会结束(every)。- 中止:每一步都检查
signal.aborted,工具返回"Operation aborted"。 - 异常变消息:
Agent类的handleRunFailure把 loop 里抛出的异常,转成一条stopReason为error/aborted的 assistant 消息,再补发turn_end和agent_end——界面永远能收到完整的事件序列。
1.7 事件流:UI 和 loop 的唯一接口
loop 不碰界面,只发事件:
agent_start |
/goal 的各种实现,本质上都是监听这些事件 +
在合适的钩子上动手。
1.8 Agent
类:并发保护与续跑
prompt()/continue()在已有一次运行时会直接抛错:"Agent is already processing a prompt. Use steer() or followUp() to queue messages"——同一时间只能有一次运行,想在运行中加消息只能走队列。continue():如果最后一条是 assistant,就先尝试取 steering 队列,再取 follow-up 队列,用它们当新消息跑;两个都空就报错 "Cannot continue from message role: assistant"。
这正好说明了 Pi 的设计立场:loop
自己不"凭空续跑",续跑一定要有一条新的输入消息(或工具结果)。
这也决定了 /goal 的所有实现都必须"造一条消息"。
顺带一提:源码注释说
prepareNextTurn的准备工作可能很耗时(例如压缩)。压缩的完整流程见 [[Pi/Pi Compaction:上下文压缩的完整流程|Pi Compaction]]。
2. Claude Code 的 Agent Loop
先说明一件事:Claude Code 不是开源的,下面全部来自官方文档("How Claude Code works",以及 Agent SDK 文档里的"How the agent loop works")。官方明确说,Agent SDK 运行的是"驱动 Claude Code 的同一个执行循环",所以 SDK 文档里的消息类型、停止条件,就是 Claude Code 的 loop 对外暴露的形态。我读不到它的内部实现,因此这一章只有"可观察的行为和接口",没有源码级的细节。
2.1 官方的三阶段描述
官方这样概括:Claude 拿到任务后,经历三个阶段——收集上下文(gather context)、采取行动(take action)、验证结果(verify results)。这三个阶段是混在一起的:搜文件是在收集上下文,改代码是行动,跑测试是验证,全都靠工具完成。一个问题可能只需要收集上下文;修一个 bug 则会把三个阶段反复循环好几遍。
循环由两个部分驱动:会推理的模型和会行动的工具。Claude Code 是包在模型外面、负责提供工具和管理上下文的那一层,官方把这层叫作 agentic harness(智能体外壳)。用户也是循环的一部分:可以在任何时刻打断、补充或改方向。
对照上一章 Pi 的 loop:同样的结构,只是 Pi 的 loop 是一个很薄的纯函数,Claude Code 的"外壳"要厚得多——权限、检查点、会话、压缩、子代理都包在里面。
2.2 一轮循环发生了什么(SDK 视角)
Agent SDK 文档把一次会话拆成五步:
1. 接收提示 系统提示 + 工具定义 + 对话历史一起交给模型 → 产出 SystemMessage(subtype: "init") |
官方举的例子是"Fix the failing tests in auth.ts",一共四个 turn:
- Turn 1:调
Bash跑npm test,拿到输出(三个失败); - Turn 2:调
Read读auth.ts和auth.test.ts; - Turn 3:调
Edit修auth.ts,再调Bash重跑测试,三个都过; - 最后一轮:只回文字"修好了,三个测试都通过",没有工具调用,循环结束。
关键点:一个 turn
内部,工具执行和把结果喂回模型是自动完成的,不会把控制权交还给你的代码。只有整个循环结束才会交付
ResultMessage。
2.3 它吐出的消息
| 类型 | 含义 |
|---|---|
SystemMessage |
会话生命周期事件,靠 subtype
区分:init(会话元数据)、compact_boundary(压缩发生后)、informational(状态横幅)、worker_shutting_down
等 |
AssistantMessage |
模型回复的每个内容块一条(一段文字、一个工具调用请求各是一条),同一次回复的几条共享消息 ID |
UserMessage |
每次工具执行后发回模型的工具结果;也包括你在循环中途流式输入的用户消息 |
StreamEvent |
仅在开启部分消息时出现:原始的 API 流式事件(文字增量、工具参数片段) |
ResultMessage |
循环的终点:最终文字、token 用量、费用、session
ID;用 subtype 判断成功还是撞到上限 |
对比 Pi:Pi 的事件粒度更细(
message_start/update/end、tool_execution_start/update/end、turn_start/end、agent_start/end);Claude Code 的 SDK 消息是"内容块级"的,另有一个明确的"结果消息"收尾。
2.4 怎样才算"停"
- 默认规则和 Pi 一样:模型这一轮的回复里没有工具调用,就结束。
- 外部上限(默认都没有):
maxTurns:最多多少次"用了工具的 turn"(只数带工具调用的回合);maxBudgetUsd:花费上限,子代理的花费也计入;超限后再起子代理会失败,并停止还在后台跑的子代理(官方说明这些执行行为需要 v2.1.217 以上)。
- 撞到上限时,
ResultMessage的subtype是对应的错误类型:
subtype |
含义 |
|---|---|
success |
正常完成(只有这个带 result 字段) |
error_max_turns |
撞到 maxTurns |
error_max_budget_usd |
撞到 maxBudgetUsd |
error_during_execution |
循环被错误打断(例如请求被取消) |
error_max_structured_output_retries |
结构化输出多次校验失败 |
另有 stop_reason 说明模型最后一轮为什么停:常见
end_turn(正常结束)、max_tokens(撞到输出上限)、refusal(拒绝)。
一个有意为之的细节:单次 query()
在产出错误结果之后会抛异常,需要自己包
try;而流式输入的会话在错误后仍然活着(会话崩溃除外)。
没有上限时,循环会一直跑到模型自己停。官方原话大意:这对边界清楚的任务没问题,对"改进这个代码库"这类开放任务可能跑很久;生产环境设预算是个好默认。
2.5 工具执行与权限
并行策略:同一个 turn 里模型请求多个工具时:
- 只读工具(
Read、Glob、Grep,以及标记为只读的 MCP 工具)可以并发; - 会改状态的工具(
Edit、Write、Bash)串行,避免冲突; - 自定义工具默认串行,设置
readOnlyHint才能并行。
对比 Pi:Pi 默认是"先顺序预检、再并发执行",只有被标成
sequential的工具才让整批串行。两家的默认方向正好相反:Claude Code 默认保守(写操作串行),Pi 默认激进(除非声明)。
权限由三项配置共同决定:allowedTools(自动批准)、disallowedTools(一律拒绝)、permissionMode(总体放权程度)。模式有:
| 模式 | 行为 |
|---|---|
default |
需要批准的调用交给你的回调,没有回调就拒绝 |
acceptEdits |
自动批准文件编辑和常见文件系统命令 |
plan |
只探索和规划,不改源文件 |
dontAsk |
从不弹确认:预批准的照跑,其余一律拒绝 |
auto |
用一个模型分类器审查每个动作,放行或拦截 |
bypassPermissions |
全部放行(仅限隔离环境) |
被拒绝的工具调用不会让循环崩溃:模型收到一条"被拒绝"的消息作为工具结果,通常会换个办法或汇报做不了。这和
Pi 的 beforeToolCall 返回 {block: true}、loop
发一条错误工具结果的做法是同一个思路。
此外,ToolSearch
工具可以按需加载工具定义而不是全部预载,这正是上一篇 MCP
笔记里提到的"工具定义吃上下文"的解法。
2.6 Hooks:loop 上的插槽
Hooks 是在循环特定位置触发的回调:
| Hook | 触发时机 | 常见用途 |
|---|---|---|
PreToolUse |
工具执行前 | 校验输入、拦截危险命令(可短路:拒绝后工具不执行,模型收到拒绝消息) |
PostToolUse |
工具返回后 | 审计输出、触发副作用 |
UserPromptSubmit |
提交提示时 | 注入额外上下文 |
Stop |
agent 要结束时 | 验证结果、保存状态——也是 /goal
的挂点 |
SubagentStart / SubagentStop |
子代理启动/结束 | 汇总并行任务结果 |
PreCompact |
压缩前 | 归档完整对话记录 |
Hooks 运行在你的应用进程里,不在模型的上下文窗口中,所以不占上下文。
对照 Pi:
beforeToolCall/afterToolCall≈PreToolUse/PostToolUse;finishTurn和"要停了"的外环 ≈Stop。差别是 Pi 把钩子做成库的回调参数,Claude Code 把它做成用户可在 settings 里配置的机制——这决定了后面/goal一个能"配置出来",一个要"写扩展"。
2.7 上下文与压缩
- 上下文窗口不会在 turn
之间重置,一切不断累积:系统提示、工具定义、对话历史、工具输入输出。不变的部分(系统提示、工具定义、
CLAUDE.md)会被自动 prompt cache。 - 快满时自动压缩:官方的描述是先清理较旧的工具输出,再在必要时对对话做摘要;你的请求和关键代码片段会保留,早期的详细指令可能丢失。压缩发生时流里会出现
compact_boundary的SystemMessage。 - 持久规则应写进
CLAUDE.md,因为它每次请求都会重新注入,不会在压缩中丢掉;也可以在CLAUDE.md里写"压缩时必须保留什么",或用PreCompacthook、/compact手动控制。 - 一个循环级的熔断器:如果某个文件或工具输出太大,导致每次压缩后上下文立刻又满,Claude Code 在尝试几次后会停止自动压缩并报错,而不是无限循环(文档里叫 thrashing 错误)。
- 子代理在独立上下文里工作,只有它的最终回复作为工具结果回传,所以主 agent 的上下文只增长一段摘要。
- MCP 工具定义默认延迟加载(工具搜索),上下文里先只有工具名和服务器说明。
2.8 中途插话
官方文档讲了两种方式:
- 输入一段话然后回车(不打断):消息进入队列,显示为"已排队"。如果 Claude 正在执行工具调用,等这些调用结束后、在同一个 turn 内读取,并在下一步前调整。
- 按
Esc:立刻停下,取消正在运行的工具调用,等待下一条指令;已排队的消息会接着发送。
第一种就是 Pi 里的 steering(每个 turn 的工具跑完后取走)。至于 Pi 的另一种插话——follow-up(agent 本来要停时才取走)——我读到的 Claude Code 文档里没有提到直接对应的机制;"要停了再续"这件事,在 Claude Code 里由
Stophook 完成。
2.9 会话与回退(loop 之外的东西)
- 每条消息、工具调用、结果都写进
~/.claude/projects/下的纯文本 JSONL,所以能恢复(--continue/--resume,沿用同一个 session ID)和分叉(--fork-session,复制历史到新 ID)。 - 改文件之前会做检查点快照,按两次
Esc可回退;但只覆盖文件改动,数据库、API、部署这类远程动作无法回退,靠权限模式控制。 - 每个新会话都是全新的上下文窗口;跨会话的东西靠
CLAUDE.md和自动记忆。
2.10 两个 loop 放在一起看
| Pi(读源码) | Claude Code(读官方文档) | |
|---|---|---|
| 循环单位 | turn = 一次模型回复 + 它的所有工具调用 | 同 |
| 默认的停止判定 | 回复里没有工具调用 | 同 |
| 内置的上限 | agent-loop.ts 里没有轮数/预算上限,交给上层 |
maxTurns、maxBudgetUsd(含子代理) |
| 工具并行策略 | 默认并行,标 sequential 的串行 |
只读并发,改状态的串行 |
| 工具前/后钩子 | beforeToolCall /
afterToolCall(库回调) |
PreToolUse / PostToolUse(可配置
hook) |
| 被拒绝的工具调用 | 返回错误工具结果,模型继续 | 返回拒绝消息作为工具结果,模型继续 |
| 回合收尾钩子 | finishTurn(可 end /
continue) |
Stop hook(可阻止停止并反馈理由) |
| 中途插话 | steering 队列 + follow-up 队列 | 排队消息(类似 steering);"要停了再续"靠 Stop hook |
| 压缩的挂点 | prepareNextTurn /
transformContext(库回调) |
自动压缩 + PreCompact hook |
| 对外形态 | 细粒度事件流(message_*、tool_execution_*、turn_*、agent_*) |
消息流(5 种消息)+ ResultMessage 收尾 |
| 防死循环 | 调用方负责 | 压缩 thrashing 熔断;Stop hook 连续阻止 8 次的上限(见 4.5) |
| 可读性 | 开源,约 940 行可读完 | 闭源,只有文档描述 |
结论:两个 loop 的骨架完全一样("模型 → 工具 →
回灌,直到没有工具调用"),差异在外壳:Claude Code
把钩子、权限、检查点、会话做成产品功能,让用户用配置去扩展;Pi
把同样的插槽留成库的回调,让开发者用代码去扩展。理解了这一点,下面三种
/goal 实现的区别就很自然了。
3. 为什么需要
/goal:loop 的"停"是谁说了算
回到伪代码里那句 if 没有工具调用: break。
模型停下来,只意味着这一轮它觉得没必要再调工具了。可能的原因:
- 真做完了;
- 它以为做完了(没跑测试就说"应该可以了");
- 遇到困难,用一段"总结"体面地结束;
- 上下文快满了,倾向收尾。
对短任务这没问题,你看一眼就能接着说"没做完,继续"。但对长任务(迁移几十个文件、清完一个
issue 队列),每隔几分钟就得有人盯着说"继续"——这正是 /goal
要消灭的人工步骤:
官方文档的说法:
/goal用 per-turn(每轮)的确认取代人工的 per-turn 提示;auto mode 则是去掉 per-tool(每次工具调用)的确认。两者互补。
所以 /goal 要回答三个问题:
- 条件怎么表达、存在哪?(一段文字?结构化?存进会话还是数据库?)
- 谁来判条件满足了没有?(干活的模型 / 另一个模型 / 脚本?)
- 没满足时,怎么让 loop 继续、又怎么防止它无限空转?
下面三章是三种不同的答案。
4. Claude Code:Stop hook + 独立评估模型
4.1 一句话
官方文档写得直接:/goal
是一个"会话级、基于提示的 Stop hook"的封装。
用法:
/goal all tests in test/auth pass and the lint step is clean |
设置之后立刻开始一个 turn,条件本身就是指令,不用另发提示。条件最长 4000 字符;一个会话同一时间只有一个目标,新的覆盖旧的。据第三方文章,需要 Claude Code v2.1.139 以上。
4.2 Stop hook 是什么
Claude Code 的 hooks
机制允许在生命周期事件上挂处理程序。Stop 事件在
Claude 要结束这一轮回复时触发。普通的 Stop hook
是一个命令:返回 {"decision": "block", "reason": "..."}
或以退出码 2 结束,就阻止停下,并把 reason 作为下一步指令喂给
Claude。
还有一种 type: "prompt" 的
hook——不跑脚本,而是把你写的提示词和 hook
的输入交给一个小模型,它只需要回 JSON:
{ "ok": true } // 条件满足,放行 |
官方 hooks 指南里有个现成的写法,这就是 /goal
的原型:
{ |
/goal 就是把"把条件写进这样一个
hook"这件事做成了一条命令,且只对当前会话生效(普通
Stop hook 写在 settings
文件里,对该范围内的所有会话生效,还可以跑脚本做确定性检查)。
4.3 一次循环里发生了什么
你:/goal <条件> |
对应到前两章的 loop:这就是 2.6 里说的
Stop插槽,相当于 Pi 的finishTurn/ 外环里的一次评估——turn 结束 → 评估 → 要么放行,要么塞一条消息再来一轮。
4.4 最关键的设计约束:评估者不调工具
评估模型不调用工具,不读文件,不跑命令,只能判断 Claude 已经放进对话里的东西。
这直接决定了怎么写条件:
- ✅
npm test 退出码为 0——Claude 会跑,输出会出现在记录里,评估者能看到; - ❌ "代码质量很好"——没有可展示的证据,评估者无从判断;
- ✅ 要包含一个可度量的终态 + 一个说明如何证明的检查 +
不能动的约束,比如"
git status干净,且没有修改其他测试文件"。
想限制跑多久,也是写进条件里:... or stop after 20 turns——由
Claude
每轮汇报进度,评估者据此判断。(也就是说,turn/时间上限本身也是"被评估的文字",不是运行时的硬限制。)
4.5 防止空转与出错处理(官方文档的几条规则)
| 情形 | 行为 |
|---|---|
| Claude 一直回答评估者却毫无进展(连续几轮没有工具调用) | 停止循环,打印警告,目标保留,等你下次输入再继续评估 |
| 普通 Stop hook 连续阻止 8 次没进展 | Claude Code 覆盖该 hook(可用环境变量
CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 调整);脚本型 hook
应检查输入里的 stop_hook_active 避免死循环 |
| 还有子代理或后台命令在跑 | 跳过本轮评估,等没有后台任务的下一个 turn 结束再评估;后台任务结束时作为新 turn 送回结果。等了 30 分钟会做一次"check-in",之后间隔翻倍(最多 4 倍) |
| 需要你修的错误(认证失败、余额耗尽、上下文溢出压缩不掉、模型不可用) | 清除目标并警告,修好后重新 /goal |
| 其他错误(服务过载、断线、限流等) | 目标保留;可恢复的自动重试 3 次后暂停,限流类直接暂停 |
恢复会话(--resume / --continue) |
条件恢复,但轮数、计时、token 基线重置;已达成/已清除的不恢复 |
非交互模式也能用:claude -p "/goal CHANGELOG.md has an entry for every PR merged this week"
会一直跑到结束;默认文本输出在结束前什么都不打印,看起来像卡住,要加
--output-format stream-json --verbose 才能看到过程。
4.6 一个值得注意的前提
/goal 依赖 hooks 系统,所以和 hooks
受同样的限制:工作区未被信任、disableAllHooks
为真、或托管设置里启用了 allowManagedHooksOnly
时不可用,命令会明确告诉你原因,而不是默默失效。
4.7
/goal、/loop、Stop hook 的区别
| 下一轮何时开始 | 何时结束 | |
|---|---|---|
/goal |
上一轮结束时(或空闲 check-in、自动重试到期) | 模型确认达成/判定不可能,或出现需要修的错误,或
/goal clear |
/loop |
经过一段时间间隔 | 你停止,或 Claude 认为做完 |
| Stop hook | 上一轮结束时 | 你自己的脚本或提示决定 |
5. Codex:持久化的目标状态机
这一章依据一份对 Codex 相关 PR 的第三方实现分析(GitHub Gist)和几篇介绍文章,不是官方文档;据这些来源,该功能随 Codex CLI 0.128.0(2026-04-30)加入。内部细节可能与现行版本有出入。
5.1 路线:不靠"外部评审",靠"状态 + 工具 + 事件"
Codex 把 /goal 做成了一个五层系统:
| 层 | 内容 |
|---|---|
| ① 持久化 | SQLite 表
thread_goals,每个线程一条:goal_id(UUID)、objective、status、token_budget、tokens_used、time_used_seconds、时间戳 |
| ② 服务端 API | thread/goal/set / get / clear
三个 JSON-RPC
方法,加两个通知(updated、cleared)让各客户端同步 |
| ③ 模型工具 | 只给模型三个:create_goal、update_goal(只能标记
complete)、get_goal |
| ④ 运行时核算 | 事件总线(core/src/goals.rs):记录每个 turn 的 token
增量和墙钟时间,自动暂停/恢复/续跑 |
| ⑤ 界面 | 注册 /goal 命令;状态栏显示目标、状态、耗时、token |
状态有四种:active(进行中、计量)、paused(暂停、不计量)、budget_limited(预算用完,终态)、complete(完成,终态)。
5.2 三个值得学的设计
① 不对称的控制权。 模型只能"创建目标"和"把目标标记为完成",不能暂停、恢复、改预算——这些转换由系统控制。工具说明里还特意写了:"只有用户明确要求时才创建目标,不要从普通任务里推断目标。"
② 事件总线驱动。 运行时监听会话生命周期事件:
| 事件 | 行为 |
|---|---|
TurnStarted |
记录 token 基线 |
ToolCompleted |
累计增量,必要时注入"转向"消息 |
TaskAborted(Interrupted) |
用户中断 → 自动暂停当前目标 |
ThreadResumed |
恢复会话 → 重新激活暂停的目标 |
MaybeContinueIfIdle |
空闲时判断是否自动续跑 |
ExternalSet/ExternalClear |
用户手动改目标 |
③ 保守的续跑 + 防空转。
自动续跑是一次一轮的:触发条件是会话恢复或外部激活目标;如果某次续跑的那一轮一个工具都没调用,就设置
continuation_suppressed
标志,阻止下一次自动续跑;用户操作、工具调用或外部变更才会重置它。再加上一个信号量保证同一时间只有一次续跑在飞。
此外:
- 预算转向:token
用量越过预算时,往模型的响应流里注入一条
budget_limiting消息,让它收尾汇报;在完成的那一轮和已提示过之后不再重复提示。 - 防过期更新:目标被替换时生成新的
goal_id,旧目标的核算调用到达时直接丢弃,不会覆盖新目标。 - 原子预算判断:用 SQL 的
CASE在写入时判断是否超预算,避免竞态。
5.3 与 Claude Code 的核心区别
谁说"做完了"? Codex 是干活的模型自己调用
update_goal(complete);Claude Code 是另一个模型读记录来判。
| 独立评审(Claude Code) | 模型自评(Codex、Pi 扩展) | |
|---|---|---|
| 优点 | 不被干活的模型自己的"自信"带偏;便宜的小模型就够 | 能看到完整上下文,知道刚才发生了什么;不需要额外模型调用 |
| 缺点 | 只能看记录;条件要写成"能被记录证明";多一次模型调用 | 有"自己给自己打分"的偏见;要靠提示词强迫它提供证据 |
| 防御手段 | 条件里写明检查方式 | 在系统提示里要求"调用 update_goal
前必须有当前的文件/命令/测试/产物证据" |
6. Pi:社区扩展怎么挂在 loop 上
6.1 先说明一件事
据我查到的范围,Pi 核心没有内置
/goal,是社区用扩展做的。Pi 的包站点 pi.dev
上至少有几个:pi-agent-goal、@tian.zuo/pi-goal(自述为"Codex
风格的持久化目标模式")、@ramarivera/pi-goal。下面的实现细节来自它们的包主页描述,我没有逐个读源码,所以只谈它们声称怎么做。
6.2
@tian.zuo/pi-goal 的做法(据其说明)
| 问题 | 做法 |
|---|---|
| 状态存哪 | 用 Pi 的自定义条目:每次状态变化 pi.appendEntry()
追加一条。状态随会话分支回放,所以重启、fork、用
/tree 导航后都能重建;目标就归属于"当前分支" |
| 怎么让模型知道目标 | 每次调模型前,把目标作为一条临时的 user 角色消息注入(续跑、重试也一样),不提升到 system/developer 权限;系统提示里只放扩展自己的可信指引(怎么审计证据、工具规则) |
| 模型能做什么 | 两个工具:get_goal、update_goal(只能标记完成或阻塞);暂停、恢复、预算、清除由用户/运行时控制 |
| 怎么续跑 | run 结束后,只在线程空闲且没有待处理的用户输入时,排入恰好一个 follow-up 回合 |
| 防空转 | 如果一次续跑没有任何工具调用,抑制下一次自动续跑 |
| 预算 | 累计 assistant 与工具结果的 token;超预算状态变
budget-limited,注入"停止并汇报"消息;预算错误在整个 run
最终稳定、没有重试后才算终态 |
| 完成证据 | 完成指引要求在调用 update_goal
之前有当前的文件、命令、测试、基准、产物或调研证据 |
对照第 1 章的 loop,几乎每一项都能找到对应的钩子:
/goal 要做的事 |
对应 loop 机制 |
|---|---|
| 目标注入每次请求 | transformContext /
prepareRequest(请求前钩子) |
| 要停了,续一轮 | follow-up 队列(外环)或 finishTurn
返回 continue |
| 用户打断 → 暂停 | abort() + 监听 agent_end 里的
aborted |
| token 核算 | 监听 turn_end / message_end 里的
usage |
| 持久化与恢复 | 会话里的自定义条目(Pi 的 session 层,不在 loop 里) |
工具 get_goal/update_goal |
普通 AgentTool;完成时可以带
terminate |
6.3 示意:在 Pi
上做一个"判官式"/goal
⚠️ 下面是我根据第 1 章读到的接口写的示意代码,没有运行过,也不是任何现有扩展的源码,只用来说明"外环怎么接"。类型和 API 名字以你使用的 Pi 版本为准。
let goal: { condition: string; idleRounds: number } | null = null; |
这段示意里体现的三个要点,正是前面三种实现的共同骨架:
- 挂点:turn
收尾(
finishTurn)或"要停了"(follow-up 外环); - 续跑靠造消息:因为 loop 不会凭空续跑,要补一条 user 消息;
- 必须有熔断:
impossible与空转计数,否则"判官"一直说"没完成"就会无限循环。
7. 横向对比
| Claude Code | Codex | Pi 社区扩展(据说明) | |
|---|---|---|---|
| 实现位置 | 内置,封装 Stop hook | 内置,五层系统 | 扩展,挂在 loop 钩子/队列上 |
| 谁判定完成 | 独立小模型读对话记录 | 模型自己 update_goal(complete) |
模型自己(要求给证据) |
| 条件/目标存哪 | 会话里(hook 状态) | SQLite(thread_goals) |
会话自定义条目(随分支回放) |
| 续跑触发 | Stop hook 返回 ok:false |
事件总线 MaybeContinueIfIdle |
空闲时排一个 follow-up |
| 空转保护 | 连续几轮无工具调用 → 停并警告;hook 8 次上限 | 续跑无工具调用 → continuation_suppressed |
续跑无工具调用 → 抑制下一次 |
| 预算 | 写在条件里(or stop after 20 turns),由评估者判断 |
运行时硬预算:token、时间 | token 预算,越界注入"停止并汇报" |
| 中断/恢复 | 恢复会话时条件保留、计数重置 | 中断自动暂停,恢复自动激活 | 暂停/恢复命令;状态随分支回放 |
| 目标的"权威层级" | 就是一条被评估的条件 | 工具规范限制模型只能创建/完成 | 刻意不提升到 system,放 user 角色 |
8. 共同的设计原则(归纳,非任何一家的原话)
- 外环与内环分离。 内环是"模型 +
工具",外环是"该不该停"。
/goal只改外环,不动内环。 - 完成条件必须可验证。 无论谁来判,都要有能出现在记录里的证据:测试输出、退出码、文件数量。
- 一定要有熔断。 三家都做了"无进展就停":无工具调用 → 抑制续跑 / 停止循环。预算(token、轮数、时间)是第二道保险。
- 权限不对称。 模型最多能"声明完成",不能自己给自己续命或改预算——暂停、恢复、预算由用户和运行时控制。
- 状态要持久并可恢复。 会话重启、分支切换、用户中断之后,目标应该有明确的归宿(保留/暂停/清除),而不是悄悄丢失。
- 目标不能冒充系统指令。 目标是用户给的,注入时用 user 角色,别提升权限。
- 续跑必须"造一条消息"。 loop 不会凭空再转一圈,需要新的输入。
9. 局限与风险
| 风险 | 说明 |
|---|---|
| 评估者被"记录"骗 | Claude Code 的评估者只看对话记录。如果模型在记录里声称"测试全过"但并没真跑,评估者无从发现。所以条件要写成必须有命令输出作证据 |
| 模型自评偏乐观 | Codex/Pi 扩展由干活的模型自己声明完成,靠提示词要求证据,没有独立复核 |
| 目标漂移 | 长时间运行中上下文被压缩,早期的约束可能丢失;把关键约束写进条件里,并让它每轮汇报进度 |
| 成本失控 | 每个 turn 都消耗 token;评估也有成本(通常相对主任务可忽略,官方这么说)。务必设 turn/时间/token 上限 |
| 副作用 | 目标不改变权限模式。无人值守需要配合 auto mode;否则每次工具调用仍要你确认。而一旦放开,就要考虑它会不会做出不可逆的事——删除、强推、发布 |
| 条件写得不可达 | 条件永远不可能满足时,只靠评估者判断 impossible
或熔断;写条件时最好有"达不到就停下并报告"的出口 |
10.
给这个博客的启示:什么条件适合当 /goal
我的博客发布流程里,有现成的可验证终态,非常适合当
/goal 条件:
/goal python3 tools/blog.py 退出码为 0(0 个 ERROR),并且 ./run.sh 输出了 "Deploy done", |
对照第 4.4 节的写法:一个可度量的终态(检查通过 + 部署成功)、一个可展示的证据(命令输出)、一个约束(不动别的文件)、一个上限(15 轮)。
而不适合的是"把这篇文章写好"这类没有客观终态的目标——评估者没有东西可判,会要么过早放行,要么无限要求"再改改"。
11. 术语表
| 术语 | 含义 |
|---|---|
| Agent Loop | "调模型 → 执行工具 → 把结果喂回模型"的循环,直到模型不再调工具 |
| Turn(回合) | 一次模型回复 + 它发起的所有工具调用与结果 |
| 内环 / 外环 | Pi 的 runLoop 里:内环处理工具与 steering,外环处理
follow-up |
| Steering | 在 agent 干活时插入的消息,每个 turn 结束后被取走,影响下一次请求 |
| Follow-up | 排队的后续消息,等 agent 本来要停时才被取走 |
finishTurn |
Pi 的回合收尾钩子,可返回
end/continue |
| Harness(外壳) | 包在模型外面、提供工具并管理上下文的那一层,Claude Code 官方用这个词称呼自己 |
ResultMessage |
Claude Code / Agent SDK 里标志一次循环结束的消息,带
subtype、费用、用量、session ID |
| Stop hook | Claude Code 在模型要结束一轮回复时触发的钩子,可阻止停止并反馈理由 |
| Prompt-based hook | 用小模型而非脚本做判断的 hook,返回 {ok, reason} |
| 评估者(evaluator) | /goal 里读对话记录、判断条件是否满足的小模型 |
update_goal |
Codex/Pi 扩展里让模型声明目标完成的工具 |
continuation_suppressed |
Codex 中防止"无进展"续跑连环触发的标志 |
| Compaction(压缩) | 上下文过长时对旧内容做摘要,完整流程见我的 Pi Compaction 笔记 |
延伸阅读:[[AI Agent/Agent Skill 完全指南:它是什么、怎么来的、为什么管用|Agent Skill 完全指南]]、[[AI Agent/MCP 完全指南:它是什么、怎么来的、为什么管用|MCP 完全指南]]、[[Pi/Pi Architecture:拆解 Coding Agent 的内部架构|Pi Architecture]]。
附:参考来源
- Pi 源码(本文读取的是 2026-10-01 的主分支):badlogic/pi-mono,
packages/agent/src/agent-loop.ts、agent.ts、types.ts - Claude Code 官方文档:How Claude Code works、How the agent loop works(Agent SDK)、Keep Claude working toward a goal、Automate actions with hooks
- Claude Code
/goal的第三方解读:Bartłomiej Krupa - Codex
/goal实现分析(第三方,GitHub Gist):How OpenAI Codex implements the /goal slash command;功能发布信息参考 Goal Mode in Codex CLI - Pi 社区扩展(包主页):@tian.zuo/pi-goal、@ramarivera/pi-goal、pi-agent-goal
关于可信度的说明:第 1 章(Pi 的 loop)来自我直接阅读的源码,但第 1.3 节的代码是我精简过的示意,不是原文;第 2 章(Claude Code 的 loop)和第 4 章(Claude Code 的
/goal)依据官方文档,Claude Code 闭源,我没有读过它的实现,2.10 的对比表里 Claude Code 一列只代表文档所述;第 5 章(Codex)与第 6.2 节(Pi 扩展)来自第三方分析和包主页描述,我没有读过对应的源码;第 6.3 节的代码是我写的、未运行过的示意;第 7、8、9 章的对比和原则是我的归纳。