跳到主内容
创建时间:最后更新:作者:白川

运行控制:取消、超时与预算

用 AbortSignal 与事件边界实现可取消的智能体运行,区分单次工具超时、整轮超时和整次运行预算。

运行控制:取消、超时与预算

智能体跑起来之后会卡住——一条 npm install 挂在那里,一个死循环的测试脚本不退,模型开始反复重试同一个失败的操作。这时候你需要的不是"再等等",而是一个能立刻停手、并且停下来之后状态是自洽的控制面。

这篇解决三件事:怎么取消一次运行、取消之后怎么保证工具、会话和界面三者状态一致、以及怎么给运行装上超时与预算。

需要先说清楚一件事:Pi 本身没有权限系统,也没有隔离。取消、超时、预算全是宿主应用的职责。Pi 提供的只有取消信号和几个可挂钩的边界,剩下的是你的代码要写的。

需要的前置只有一条:知道 Pi 会话对象有 abort() / waitForIdle() 两个方法,工具的 execute 会收到一个 AbortSignal。

第一步:分清"取消"和"等待"

Pi 提供两组容易混淆的方法:

方法行为是否等待收尾适用场景
agent.abort()
中止当前运行的 AbortController
否,立即返回
Agent Core 层,需要自己接着 waitForIdle()
agent.waitForIdle()
等待当前运行和所有被 await 的事件监听器结束
是
只想等结果,不希望中途被打断
session.abort()
停止会话的活跃操作,并等待会话变空闲
是
交互式界面上按 Esc
session.waitForIdle()
等待会话空闲,不打断
是
关窗前、切换会话前

session.abort() 的实现是五步:设置"已请求中止"标志、依次中止重试 / 压缩 / 分支摘要、调用 agent.abort() 触发运行中的 AbortSignal、最后 await this.waitForIdle()。

这个顺序有一个实际后果:只调 agent.abort() 不会中止重试队列。测试取消行为时要确认你调的是会话层的方法。

agent.state.isStreaming 在运行完全落定之前一直是 true——包括那些被 await 的 agent_end 监听器还没跑完的那段时间。所以判断"这次真的结束了",看 agent_settled 比看 agent_end 准。

第二步:取消之后,三处状态必须一致

取消不是"按下停止键",它是一次状态收敛。取消完成后,下面三处必须对齐:

观察点期望状态依据
工具执行
正在执行的 execute 收到 signal.aborted === true,抛错或提前返回
工具签名里的 signal 参数由事件循环逐层透传
会话
session.isIdle === true,agent_settled 已发出
agent_settled 表示 Pi 不会自动继续
界面
排队消息回到编辑器,没有静默丢失
官方文档:中止会停止当前运行并把排队消息退回编辑器

官方交互模式的做法是:把 steering 队列和 follow-up 队列清空,把文本拼回编辑器,然后调用 session.abort()。SDK 模式下这一步在你手上——session.clearQueue() 会返回 { steering, followUp } 两个字符串数组,但它只是取出来,不会替你放回任何地方。

typescript
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 时把它作为第三个参数传下去:

typescript
// 第三个参数是 AbortSignal
execute: async (toolCallId, params, signal, onUpdate) => { /* ... */ }

内置的 bash 工具已经处理好了这件事:它监听 signal,一旦中止就调用 killProcessTree 杀掉整个进程树——不只是 shell 本身,还包括 shell 拉起的所有子进程。这一点很重要,因为 npm run build 会派生一串子进程,只 kill 父进程会留下孤儿。

你自己写的工具必须做同样的事。下面的例子是一个跑任意命令的工具:

typescript
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 内置能力。

层次作用范围官方已有的开关由谁执行
单次工具超时
一次 execute 调用
bash 工具的 timeout 参数(秒);扩展工具可自定义自己的超时
工具实现
Provider 请求超时
一次 HTTP 请求
retry.provider.timeoutMs、retry.provider.maxRetries、httpIdleTimeoutMs
Pi 内置
整轮超时
一个 turn(一次模型请求 + 其后的工具执行)
无内置开关,用 abort() + 计时器实现
宿主
整次运行预算
从 prompt() 到 agent_settled
无内置开关,用计时器 + 预算记账实现
宿主

两个内置设置值得注意。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_end
一次底层 agent 运行结束
messages、willRetry
会——自动重试、溢出恢复、压缩、排队工作都可能接上来
agent_settled
整个会话级运行落定
aborted
不会。Pi 不会自动继续

判断"这次真的结束了"用 agent_settled;判断"是否还会自动重试"看 agent_end 的 willRetry。在 agent_end 上宣布任务成功,是把一次底层运行当成了整个任务。

宿主需要在最后一轮之后强制停止(而不是让 Pi 按自己的调度继续)时,可以挂两个官方边界:finishTurn 在助手消息和全部工具结果确定之后、turn_end 之前触发,返回 { action: "end" } 表示这一轮之后立刻结束、不再轮询 steering/follow-up 队列,返回 { action: "continue" } 保证再发一次模型请求,错误和中止的响应是硬出口、其返回值会被忽略;prepareRequest 在每次 provider 请求前触发,适合在请求发出前检查预算并替换上下文。

运行控制状态机

用文字描述一次运行的完整状态迁移:

text
# 一次运行的状态机: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,工具要么抛错要么提前返回;工具结果是错误结果,模型会看到它并重新决策——除非你也切断了后续调度。

谁负责什么

职责由谁承担说明
提供取消信号、传播到工具
Pi
AbortController + execute 的 signal 参数
决定何时取消
宿主
超时、预算、人工交互都是宿主的判断
终止副作用
工具实现
信号到了不代表进程死了,工具必须自己杀
回收排队消息
宿主
clearQueue() 取回后由你决定放哪
重试与压缩的节流
部分内置、部分宿主
retry.* 是内置开关;整轮/整次预算要自己实现
危险操作的隔离
宿主
Pi 无沙箱,以启动它的用户权限运行

你现在能做什么

  • 提交一个会长时间跑命令的任务,运行中途调用 session.abort(),确认 agent_settled 到达、aborted 为 true、session.isIdle 为 true。
  • 在自己的工具里注册 abort 监听器并杀掉子进程,确认取消后子进程真的退出,而不是留在后台占着端口。
  • 用 session.clearQueue() 取回排队消息,确认取消后它们没有静默丢失。
  • 在 settings.json 里关掉 retry.enabled 再做取消测试,确认之前观察到的"以为停了其实还在重试"消失了。
  • 把 agent_end 和 agent_settled 两个事件都记下来,验证在 agent_end 上宣布成功是不是早了。

排障速查

现象检查点处理
取消后 agent_end 一直不来
是否有 await 的事件监听器卡住
agent_end 的监听器会被 await 才算落定;给它加超时或改成不阻塞
只有 agent_end,没有 agent_settled
是否在等错误的边界
改等 agent_settled;agent_end 之后 Pi 还可能自动继续
工具还在跑,界面已经停了
自定义工具是否监听 signal
在 execute 里注册 abort 监听并杀掉子进程
取消后子进程变成孤儿
是否只 kill 了父进程
杀整个进程树,参考 bash 工具的 killProcessTree 做法
取消后命令又跑了一遍
retry.enabled 默认开启
取消时用 session.abort()(会连带中止重试),或临时关掉 retry.enabled
排队消息消失了
只调了 abort()
SDK 里 abort() 不动队列,要自己 clearQueue() 并处理返回值
session.abort() 卡住不返回
工具没有响应中止信号
检查工具是否在 await 一个不响应 signal 的操作
改动后行为没变
扩展是否被缓存
会话内改扩展后执行 /reload

参考资料