运行控制:取消、超时与预算
用 AbortSignal 与事件边界实现可取消的智能体运行,区分单次工具超时、整轮超时和整次运行预算。
运行控制:取消、超时与预算
智能体跑起来之后会卡住——一条 npm install 挂在那里,一个死循环的测试脚本不退,模型开始反复重试同一个失败的操作。这时候你需要的不是"再等等",而是一个能立刻停手、并且停下来之后状态是自洽的控制面。
这篇解决三件事:怎么取消一次运行、取消之后怎么保证工具、会话和界面三者状态一致、以及怎么给运行装上超时与预算。
需要先说清楚一件事:Pi 本身没有权限系统,也没有隔离。取消、超时、预算全是宿主应用的职责。Pi 提供的只有取消信号和几个可挂钩的边界,剩下的是你的代码要写的。
需要的前置只有一条:知道 Pi 会话对象有 abort() / waitForIdle() 两个方法,工具的 execute 会收到一个 AbortSignal。
第一步:分清"取消"和"等待"
Pi 提供两组容易混淆的方法:
session.abort() 的实现是五步:设置"已请求中止"标志、依次中止重试 / 压缩 / 分支摘要、调用 agent.abort() 触发运行中的 AbortSignal、最后 await this.waitForIdle()。
这个顺序有一个实际后果:只调 agent.abort() 不会中止重试队列。测试取消行为时要确认你调的是会话层的方法。
agent.state.isStreaming 在运行完全落定之前一直是 true——包括那些被 await 的 agent_end 监听器还没跑完的那段时间。所以判断"这次真的结束了",看 agent_settled 比看 agent_end 准。
第二步:取消之后,三处状态必须一致
取消不是"按下停止键",它是一次状态收敛。取消完成后,下面三处必须对齐:
官方交互模式的做法是:把 steering 队列和 follow-up 队列清空,把文本拼回编辑器,然后调用 session.abort()。SDK 模式下这一步在你手上——session.clearQueue() 会返回 { steering, followUp } 两个字符串数组,但它只是取出来,不会替你放回任何地方。
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
const events: string[] = [];
const unsubscribe = session.subscribe((event) => {
if (event.type === "agent_end") events.push(`agent_end willRetry=${event.willRetry}`);
if (event.type === "agent_settled") events.push(`agent_settled aborted=${event.aborted}`);
if (event.type === "queue_update") {
events.push(`queue steering=${event.steering.length} followUp=${event.followUp.length}`);
}
});
try {
// 触发一次长时间运行的命令
const run = session.prompt("运行 npm run build 并等待它结束");
// 宿主自己决定何时收手
const timer = setTimeout(() => {
void session.abort();
}, 20_000);
try {
await run;
} finally {
clearTimeout(timer);
}
// 取回排队消息
const { steering, followUp } = session.clearQueue();
if (steering.length > 0 || followUp.length > 0) {
console.log("退回编辑器的消息:", [...steering, ...followUp].join("\n\n"));
}
console.log("事件序列:", events.join(" → "));
console.log("会话已空闲:", session.isIdle);
} finally {
unsubscribe();
session.dispose();
}跑完你应该看到 agent_end 后面跟着 agent_settled aborted=true,并且 session.isIdle 为 true。如果只看到 agent_end 没看到 agent_settled,说明你等的是错误的边界。
第三步:让信号真正终止副作用
工具拿到的 signal 是整条链路的同一个 AbortSignal。Agent Core 在调用 execute 时把它作为第三个参数传下去:
// 第三个参数是 AbortSignal
execute: async (toolCallId, params, signal, onUpdate) => { /* ... */ }内置的 bash 工具已经处理好了这件事:它监听 signal,一旦中止就调用 killProcessTree 杀掉整个进程树——不只是 shell 本身,还包括 shell 拉起的所有子进程。这一点很重要,因为 npm run build 会派生一串子进程,只 kill 父进程会留下孤儿。
你自己写的工具必须做同样的事。下面的例子是一个跑任意命令的工具:
import { spawn } from "node:child_process";
import { Type } from "typebox";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const runCommand = defineTool({
name: "run_command",
label: "Run Command",
description: "在当前工作目录执行一条命令,返回退出码与输出尾部。",
parameters: Type.Object({
command: Type.String({ description: "要执行的命令" }),
timeoutSeconds: Type.Optional(Type.Number({ description: "命令自身的超时秒数" })),
}),
async execute(_toolCallId, params, signal, _onUpdate) {
const child = spawn("sh", ["-c", params.command], { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk) => {
output += String(chunk);
});
// 中止时立刻杀掉进程树,避免副作用继续发生
const onAbort = () => {
child.kill("SIGTERM");
};
// signal 可能是 undefined
signal?.addEventListener("abort", onAbort, { once: true });
// 工具自身的超时,与运行级超时分属两层
const timer = params.timeoutSeconds
? setTimeout(() => child.kill("SIGTERM"), params.timeoutSeconds * 1000)
: undefined;
try {
// close 才代表进程真的退出了:exit 事件早于 stdio 读完
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (c) => resolve(c ?? 0));
});
// 只回尾部:错误信息在末尾,开头通常是噪音
return {
content: [{ type: "text", text: `exit=${code}\n${output.slice(-2000)}` }],
details: { exitCode: code },
};
} finally {
// 清理必须放在 finally:正常结束路径也不能残留监听器和定时器
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
}
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(runCommand);
}关键是 signal?.addEventListener("abort", onAbort, { once: true }) 和 finally 里的清理。前者让中止真正生效,后者保证正常结束路径不残留监听器。
注意 signal 可能是 undefined。 工具在会话空闲时被直接调用时拿不到信号,所以要用可选链。
第四步:三层超时与预算
"超时"其实是几个不同的东西,粒度不同,归属也不同。官方只直接提供工具层和 provider 层的开关,整轮与整次预算需要你在宿主侧实现——这是本文的教学设计,不是 Pi 内置能力。
两个内置设置值得注意。retry.enabled 默认 true、retry.maxRetries 默认 3、退避基数 retry.baseDelayMs 默认 2000 毫秒,意味着一次瞬时网络失败会自动重试三次。这在做取消测试时会造成困扰:你以为停了,其实它还在重试队列里。
retry 还有两个 provider 级的旋钮:retry.provider.timeoutMs 控制请求超时,retry.provider.maxRetryDelayMs 默认 60000 毫秒——这是"服务器要求的重试延迟上限",防止服务端让你等太久。另外 httpIdleTimeoutMs 控制 HTTP 头或 body 的空闲超时,设为 0 或 "disabled" 关闭。
session.abort() 会连带中止重试(源码里 abort() 依次调用 abortRetry()、abortCompaction()、abortBranchSummary()),只调 agent.abort() 则不会。
整次运行预算通常是双闸门:时间和成本。成本可以从 usage 条目或会话统计里拿到 token 与 cost 累计。超限时调用 session.abort() 并记一笔账。
第五步:agent_end 不是"任务完成"
这是最容易埋雷的地方。两个事件的差别在官方文档里写得很明确:
判断"这次真的结束了"用 agent_settled;判断"是否还会自动重试"看 agent_end 的 willRetry。在 agent_end 上宣布任务成功,是把一次底层运行当成了整个任务。
宿主需要在最后一轮之后强制停止(而不是让 Pi 按自己的调度继续)时,可以挂两个官方边界:finishTurn 在助手消息和全部工具结果确定之后、turn_end 之前触发,返回 { action: "end" } 表示这一轮之后立刻结束、不再轮询 steering/follow-up 队列,返回 { action: "continue" } 保证再发一次模型请求,错误和中止的响应是硬出口、其返回值会被忽略;prepareRequest 在每次 provider 请求前触发,适合在请求发出前检查预算并替换上下文。
运行控制状态机
用文字描述一次运行的完整状态迁移:
# 一次运行的状态机:Idle → Running → Settling → Settled
┌─────────────────────────────────────────────┐
│ Idle │
│ session.isIdle === true,无 agent 运行中 │
└───────────────┬─────────────────────────────┘
│ prompt() / steer() / followUp()
▼
┌─────────────────────────────────────────────┐
│ Running │
│ agent_start → turn_start → 工具执行 → … │
│ agent.state.isStreaming === true │
└───────┬──────────────────────────┬──────────┘
│ 自然结束 │ abort()
▼ ▼
┌─────────────────────────────────────────────┐
│ Settling(可能空转) │
│ 已发出 agent_end;自动重试 / 溢出恢复 / │
│ 压缩 / 排队工作仍可能在这里发生 │
└───────┬──────────────────────────┬──────────┘
│ 无后续自动工作 │ 仍有后续
▼ └──► 回到 Running
┌─────────────────────────────────────────────┐
│ Settled(终态) │
│ 已发出 agent_settled{aborted} │
│ 排队消息由宿主取回并处理 │
└─────────────────────────────────────────────┘取消路径上还有一条横切规则:取消会一路穿透到工具。abort() 触发 AbortController,中止信号经事件循环传到每个 execute,工具要么抛错要么提前返回;工具结果是错误结果,模型会看到它并重新决策——除非你也切断了后续调度。
谁负责什么
你现在能做什么
- 提交一个会长时间跑命令的任务,运行中途调用
session.abort(),确认agent_settled到达、aborted为 true、session.isIdle为 true。 - 在自己的工具里注册 abort 监听器并杀掉子进程,确认取消后子进程真的退出,而不是留在后台占着端口。
- 用
session.clearQueue()取回排队消息,确认取消后它们没有静默丢失。 - 在
settings.json里关掉retry.enabled再做取消测试,确认之前观察到的"以为停了其实还在重试"消失了。 - 把
agent_end和agent_settled两个事件都记下来,验证在agent_end上宣布成功是不是早了。