/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 = [系统提示, 用户提问]
while True:
回复 = 调用模型(messages)
messages.append(回复)
if 回复里没有工具调用:
break # ← 模型自己决定停
for 每个工具调用:
结果 = 执行工具(调用)
messages.append(结果)

这就是所有 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) {
let pendingMessages = await config.getSteeringMessages?.() ?? []; // 开始前先看用户有没有插话

while (true) { // ── 外环:agent 本来要停了,看有没有 follow-up
let hasMoreToolCalls = true;

while (hasMoreToolCalls || pendingMessages.length > 0) { // ── 内环:一个 turn 一轮
if (lastCompletedTurn) {
await config.prepareNextTurn?.(lastCompletedTurn); // 下一轮开始前:可换上下文/模型/思考等级(如压缩)
emit("turn_start");
}
// 把待处理消息(steering / follow-up / prepare 产生的)写进上下文并发事件
appendAndEmit(pendingMessages); pendingMessages = [];

await config.prepareRequest?.({ context, model, thinkingLevel }); // 每次请求模型前的最后一道钩子

const message = await streamAssistantResponse(...); // 调模型,流式
if (message.stopReason === "error" || "aborted") { // 硬退出
emit("turn_end"); emit("agent_end"); return;
}

const toolCalls = message.content.filter(c => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const batch = message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(...) // 输出被截断:所有工具调用都拒绝执行
: await executeToolCalls(...); // 正常执行(默认并行)
hasMoreToolCalls = !batch.terminate; // 工具可以说"到此为止"
appendToolResults(batch.messages);
}

lastCompletedTurn = { message, toolResults, context, newMessages };
const decision = await config.finishTurn?.(lastCompletedTurn, signal); // ★ 回合收尾钩子
emit("turn_end");
if (decision?.action === "end") { emit("agent_end"); return; }

explicitContinuation = decision?.action === "continue";
pendingMessages = await config.getSteeringMessages?.() ?? []; // ★ 取用户插话
if (hasMoreToolCalls || pendingMessages.length > 0) explicitContinuation = false;
}

const followUps = await config.getFollowUpMessages?.() ?? []; // ★ 要停了:取排队的后续消息
if (followUps.length > 0) { pendingMessages = followUps; continue; }
if (explicitContinuation) { explicitContinuation = false; continue; } // finishTurn 要求续跑,就再来一轮
break;
}
emit("agent_end");
}

两层循环各管什么:

条件 含义
内环 还有工具调用 \|\| 有待处理消息 一个 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
└ turn_start
├ message_start / message_update(流式增量) / message_end ← 用户、assistant、toolResult 都有
├ tool_execution_start / tool_execution_update / tool_execution_end
└ turn_end
└ turn_start …(下一轮)
agent_end

/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")
2. 评估并回应 模型回文字、请求一个或多个工具调用,或两者都有 → 每个内容块一条 AssistantMessage
3. 执行工具 SDK 执行每个工具,把结果收集起来,喂回模型 → 工具结果以 UserMessage 返回;hooks 可在此拦截
4. 重复 第 2、3 步循环;一个完整循环 = 一个 turn
直到模型给出一条没有工具调用的回复
5. 返回结果 最终的文字 AssistantMessage + ResultMessage(结果文本、token 用量、费用、session ID)

官方举的例子是"Fix the failing tests in auth.ts",一共四个 turn:

  1. Turn 1:调 Bash 跑 npm test,拿到输出(三个失败);
  2. Turn 2:调 Read 读 auth.ts 和 auth.test.ts;
  3. Turn 3:调 Edit 修 auth.ts,再调 Bash 重跑测试,三个都过;
  4. 最后一轮:只回文字"修好了,三个测试都通过",没有工具调用,循环结束。

关键点:一个 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 里写"压缩时必须保留什么",或用 PreCompact hook、/compact 手动控制。
  • 一个循环级的熔断器:如果某个文件或工具输出太大,导致每次压缩后上下文立刻又满,Claude Code 在尝试几次后会停止自动压缩并报错,而不是无限循环(文档里叫 thrashing 错误)。
  • 子代理在独立上下文里工作,只有它的最终回复作为工具结果回传,所以主 agent 的上下文只增长一段摘要。
  • MCP 工具定义默认延迟加载(工具搜索),上下文里先只有工具名和服务器说明。

2.8 中途插话

官方文档讲了两种方式:

  • 输入一段话然后回车(不打断):消息进入队列,显示为"已排队"。如果 Claude 正在执行工具调用,等这些调用结束后、在同一个 turn 内读取,并在下一步前调整。
  • 按 Esc:立刻停下,取消正在运行的工具调用,等待下一条指令;已排队的消息会接着发送。

第一种就是 Pi 里的 steering(每个 turn 的工具跑完后取走)。至于 Pi 的另一种插话——follow-up(agent 本来要停时才取走)——我读到的 Claude Code 文档里没有提到直接对应的机制;"要停了再续"这件事,在 Claude Code 里由 Stop hook 完成。

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。

模型停下来,只意味着这一轮它觉得没必要再调工具了。可能的原因:

  1. 真做完了;
  2. 它以为做完了(没跑测试就说"应该可以了");
  3. 遇到困难,用一段"总结"体面地结束;
  4. 上下文快满了,倾向收尾。

对短任务这没问题,你看一眼就能接着说"没做完,继续"。但对长任务(迁移几十个文件、清完一个 issue 队列),每隔几分钟就得有人盯着说"继续"——这正是 /goal 要消灭的人工步骤:

官方文档的说法:/goal 用 per-turn(每轮)的确认取代人工的 per-turn 提示;auto mode 则是去掉 per-tool(每次工具调用)的确认。两者互补。

所以 /goal 要回答三个问题:

  1. 条件怎么表达、存在哪?(一段文字?结构化?存进会话还是数据库?)
  2. 谁来判条件满足了没有?(干活的模型 / 另一个模型 / 脚本?)
  3. 没满足时,怎么让 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 }                                         // 条件满足,放行
{ "ok": false, "reason": "还有 3 个测试没通过" } // 没满足:reason 反馈给 Claude 继续干
{ "ok": false, "reason": "...", "impossible": true } // 判定条件永远不可能满足:允许停止

官方 hooks 指南里有个现成的写法,这就是 /goal 的原型:

{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}." } ] }
]
}
}

/goal 就是把"把条件写进这样一个 hook"这件事做成了一条命令,且只对当前会话生效(普通 Stop hook 写在 settings 文件里,对该范围内的所有会话生效,还可以跑脚本做确定性检查)。

4.3 一次循环里发生了什么

你:/goal <条件>
│
▼
Claude 开始干活(读文件 / 跑命令 / 改代码)… 一个 turn 结束
│
▼ 触发 Stop hook
Claude Code 把【条件 + 对话记录】发给评估模型(默认 Haiku,沿用当前会话的供应商)
│
├ ok:false + reason ──► Claude 带着 reason 开始下一个 turn(回到上面)
├ ok:true ──► 目标清除,记一条"达成"到对话记录,交还控制权
└ impossible ──► 目标清除,记一条"失败"+原因,交还控制权

对应到前两章的 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;
const MAX_IDLE = 3;

const agent = new Agent({
// …model、tools、convertToLlm 等…
finishTurn: async (turn) => {
if (!goal) return; // 没有目标,走默认行为
if (turn.toolResults.length > 0) return; // 这一轮还在调工具,说明没到"要停"的时候

// agent 这一轮没调工具:本来要停了 → 请"判官"看看
const verdict = await judge(goal.condition, turn.context.messages);

if (verdict.met) { goal = null; return; } // 达成:放行
if (verdict.impossible || ++goal.idleRounds > MAX_IDLE) { // 不可能 / 空转太多:硬停
goal = null;
return { action: "end" };
}

// 没达成:造一条 user 消息,排进 follow-up,外环取到就会再来一轮
agent.followUp({
role: "user",
content: [{ type: "text", text: `目标尚未达成:${verdict.reason}。请继续。` }],
timestamp: Date.now(),
});
},
});

这段示意里体现的三个要点,正是前面三种实现的共同骨架:

  1. 挂点:turn 收尾(finishTurn)或"要停了"(follow-up 外环);
  2. 续跑靠造消息:因为 loop 不会凭空续跑,要补一条 user 消息;
  3. 必须有熔断: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. 共同的设计原则(归纳,非任何一家的原话)

  1. 外环与内环分离。 内环是"模型 + 工具",外环是"该不该停"。/goal 只改外环,不动内环。
  2. 完成条件必须可验证。 无论谁来判,都要有能出现在记录里的证据:测试输出、退出码、文件数量。
  3. 一定要有熔断。 三家都做了"无进展就停":无工具调用 → 抑制续跑 / 停止循环。预算(token、轮数、时间)是第二道保险。
  4. 权限不对称。 模型最多能"声明完成",不能自己给自己续命或改预算——暂停、恢复、预算由用户和运行时控制。
  5. 状态要持久并可恢复。 会话重启、分支切换、用户中断之后,目标应该有明确的归宿(保留/暂停/清除),而不是悄悄丢失。
  6. 目标不能冒充系统指令。 目标是用户给的,注入时用 user 角色,别提升权限。
  7. 续跑必须"造一条消息"。 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",
并且 git status 里只有我这次改动的笔记。或者 15 轮后停下并汇报。

对照第 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 章的对比和原则是我的归纳。