显示执行进度,并停止正在运行的任务
消费流式文本和工具事件,接入 Ctrl+C 与超时取消,并区分运行结束与彻底空闲这两个边界。
显示执行进度,并停止正在运行的任务
智能体执行时,如果终端一直空白,使用者无法判断是在等待模型、读取文件还是已经失败。本篇把输出接到事件流,并接入 Ctrl+C,让用户能结束正在等待的任务。
项目沿用工具接入里的运行入口、只读工具和练习文件。本篇使用 Agent Core,取消方法是 agent.abort(),随后通过 waitForIdle() 等待结束;不要混用会话 SDK 中签名不同的方法。
本文的取消与事件语义核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/agent.ts、packages/agent/src/agent-loop.ts 与 packages/coding-agent/src/core/agent-session.ts。
在提交任务前订阅事件
创建 src/progress.ts:
import { readLessonFile } from "./read-tool.js"
import { createLabAgent, lastReply } from "./runtime.js"
const agent = createLabAgent("先读取练习文件,再面向初学者解释代码。", [readLessonFile])
let cancelReason: string | undefined
function cancel(reason: string) {
cancelReason ??= reason
agent.abort()
}
const onInterrupt = () => cancel("用户取消")
process.on("SIGINT", onInterrupt)
const timer = setTimeout(() => cancel("运行超时"), 30_000)
const unsubscribe = agent.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta)
}
if (event.type === "tool_execution_start") {
console.error("\n正在执行:", event.toolName)
}
if (event.type === "tool_execution_end")
console.error("\n工具结果:", event.isError ? "失败" : "成功")
})
try {
await agent.prompt("读取 sample.ts,解释函数的行为、调用例子和空数组的结果。")
if (cancelReason) {
console.error(`\n${cancelReason},本次运行未确认完成`)
process.exitCode = 1
} else {
lastReply(agent)
console.error("\n本次运行结束,请核对回答是否满足任务")
}
} catch (error) {
console.error("\n运行失败:", error instanceof Error ? error.message : String(error))
process.exitCode = 1
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
unsubscribe()
process.off("SIGINT", onInterrupt)
}运行:
node --env-file=.env --import tsx src/progress.ts正常情况下先后看到工具状态和逐步生成的解释。运行中按 Ctrl+C,会记录用户取消并请求中止。任务若已经完成,便没有正在进行的请求可供取消;要观察中止,应在等待或生成阶段操作。
文本增量与完整结果承担不同职责
text_delta 是新增的一段文本,使用 process.stdout.write() 直接追加,不要对每个分片都调用带换行的 console.log()。也不要把完整累积文本和增量同时打印,否则会出现重复输出。
别把 message_update 的 message 当增量用。 它是截至目前的累计值,每次事件都包含之前所有的文本。取增量只有一条路:
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
// event.assistantMessageEvent.delta 才是这一段新增的文本
process.stdout.write(event.assistantMessageEvent.delta)
}原因在 provider 侧的累加逻辑:
// packages/ai/src/api/anthropic-messages.ts
if (open?.block.type === "text") {
open.block.text += event.delta.text;
stream.push({
type: "text_delta",
contentIndex: open.contentIndex,
delta: event.delta.text,
partial: output,
});
}partial 是累加出来的完整消息,delta 才是这一次的增量。两者的关系是 partial = 之前所有 delta 之和。
示例将内容写到标准输出,将工具状态和错误写到标准错误。它们在终端可能相邻出现,但在重定向或接入界面时可以分别处理。
增量只用于展示。最后一段文字出现时,不能立即把任务标为成功。等待 prompt() 结束后,还要检查中止原因及最终助手状态;任务是否满足业务要求则需要另外核对。
abort() 做了什么
abort() 的实现只有一行:
// packages/agent/src/agent.ts
/** Abort the current run, if one is active. */
abort(): void {
this.activeRun?.abortController.abort();
}它触发的是本次运行自己的 AbortController。没有正在进行的运行时,这一行什么也不做——不会抛异常,不会清空任何状态。
这个信号会流到两个地方:
一是模型请求。streamAssistantResponse 把 signal 传给 streamFunction:
const response = await streamFunction(config.model, llmContext, {
...config,
apiKey: resolvedApiKey,
identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),
signal,
});二是工具。executePreparedToolCall 把它传给工具的 execute:
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => { /* ... */ },
);所以取消是协作式的:运行时发出信号,工具自己决定要不要停下来。工具里那个 signal 参数就是为此存在的。
取消信号怎样到达工具
Ctrl+C 或超时
→ 记录取消原因
→ agent.abort()
→ 模型请求与工具收到取消信号
→ 等待运行结束
→ 清除订阅、计时器和信号监听前面工具的 readFile() 已接收 signal,因此工具有机会响应取消:
const text = await readFile(path, { encoding: "utf8", signal })Node 的 fs/promises.readFile 接受 signal 选项,文件读取可以被中断。
换成其他网络或文件 API 时,要确认执行器也传递信号;如果第三方操作忽略信号,外层不能靠打印状态保证它已停止。
取消也会自动回滚已完成的操作。本例只有读取,没有写入;以后添加修改文件、发送请求等副作用时,需要单独设计恢复与去重。
工具执行期间被取消,结果不是失败而是被拦下。源码里在准备阶段和执行阶段各有一处检查:
// packages/agent/src/agent-loop.ts
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}所以取消后的工具结果里会出现 "Operation aborted" 这段文字,且 isError 为 true。看到它就知道是取消,不是工具本身出错。
waitForIdle() 与 agent_end 不是一回事
这是本篇最容易被跳过、但直接影响清理正确性的一处。
agent_end 是这次循环最后一个事件。但它出现的时候,Agent 还没有进入空闲。源码注释写得很清楚:
agent_endis the final emitted event for a run, but the agent does not become idle until all awaited listeners for that event have settled.
原因是事件监听器是按注册顺序被 await 的:
// packages/agent/src/agent.ts
for (const listener of this.listeners) {
await listener(event, signal);
}所以 agent_end 之后,你的监听器还可能在跑异步收尾工作。这时候读 agent.state 可能读到中间状态。
waitForIdle() 等的就是这个真正的空闲点:
/** Resolve when the current run and all awaited event listeners have finished. */
waitForIdle(): Promise<void> {
return this.activeRun?.promise ?? Promise.resolve();
}activeRun.promise 在 finishRun() 里 resolve,而 finishRun() 是在执行体完全结束后才调用的:
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {
// ...
try {
await executor(abortController.signal);
} catch (error) {
await this.handleRunFailure(error, abortController.signal.aborted);
} finally {
this.finishRun();
}
}finishRun() 还会把 activeRun 清成 undefined。这解释了一个细节:运行结束后再调 waitForIdle() 会立刻 resolve,因为 activeRun 已经不存在,走的是 ?? Promise.resolve() 那条分支。
同一个 AgentState 字段也从另一侧印证了这件事:
/**
* True while the agent is processing a prompt or continuation.
*
* This remains true until awaited `agent_end` listeners settle.
*/
readonly isStreaming: boolean;isStreaming 在 agent_end 之后还会继续为 true,直到监听器都跑完。
更上层还有一道边界
agent_end 之上,完整 CLI 的会话层还有一个 agent_settled 事件。两者语义不同:
会话层的定义写得很精确:
// packages/coding-agent/src/core/extensions/types.ts
/** Fired after an agent run has fully settled and no automatic retry, compaction, or queued continuation will run. */
export interface AgentSettledEvent {
type: "agent_settled";
/** Whether the run ended because it was aborted, for example with Escape. */
aborted: boolean;
}会话层还会给 agent_end 加一个 willRetry 字段,告诉你这次结束之后会不会自动重试:
// packages/coding-agent/src/core/agent-session.ts
| {
type: "agent_end";
messages: AgentMessage[];
willRetry: boolean;
}用本篇的 Agent Core 写法,只需要认 waitForIdle() 这一个边界。等你开始用完整 CLI 或会话 SDK 时,agent_settled 才是"彻底没有后续自动工作"的信号。这两个层次不要混用:Agent Core 里没有 agent_settled 这个事件。
输出被截断时,工具一个都不执行
还有一种"看起来像取消、其实是失败"的情况:响应撞到了输出上限。
当助手消息的 stopReason 是 "length" 时,这条消息里的所有工具调用都会被拒绝执行。源码注释解释了原因:
// packages/agent/src/agent-loop.ts
// A "length" stop means the output was cut off by the token limit, so
// every tool call in the message may carry truncated arguments. Fail
// them all instead of executing potentially borked calls.被拒绝时写回的错误文案是固定的一句:
Tool call "X" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.这条结果的 isError 是 true,但它不是取消。区别在于:
所以判断"是不是被取消了",不要只看工具结果里有没有错误文案,要看最终助手消息的 stopReason。
顺带说一句,源码注释里用了 "borked" 这个词——它形容的是"参数可能被截断成看起来合法、实际残缺"的调用。这类调用如果被执行,副作用会基于错误的参数发生,所以宁可全部拒绝。
为什么单独保存取消原因
abort() 是一个动作,不是业务结论。程序用 cancelReason 区分用户取消与时间上限,并用 ??= 保留第一个原因,避免清理过程覆盖它。
function cancel(reason: string) {
cancelReason ??= reason // 保留第一个原因
agent.abort()
}清理中的 abort() 用于确保没有活动工作,不再把它标成"用户取消"。如果请求失败,lastReply() 会识别错误消息,进入失败分支,设置非零退出码。
本例等待的是当前运行和被运行时等待的监听器,不是整个操作系统上的所有活动。不要在监听回调里阻塞,或等待只有当前运行结束才会出现的条件。
退出前清理什么
计时器不清理,正常回答后也可能继续触发超时;订阅不移除,复用时会出现重复输出;SIGINT 监听不移除,后续任务可能被旧处理器影响。因此这三项都放进 finally。
abort() 先发出信号,waitForIdle() 再等待收尾。不要把"已经发出中止请求"和"所有受管理操作已经结束"当成同一个时刻。
退订函数来自 subscribe() 的返回值:
const unsubscribe = agent.subscribe((event) => { /* ... */ })
// 清理时
unsubscribe()验收正常、失败与取消
正常任务应输出内容并完成清理;无效凭据应报告失败;在生成过程中按 Ctrl+C,应报告取消并退出;超过时间上限,应报告超时而不是成功。
本轮随文示例完成了类型检查,以及模拟流上的中止、空闲和监听移除验证;真实服务中的网络响应、终端信号交互仍需在你的环境检查。更完整的运行指标、追踪与告警进入"调试与可观测性"主题,不在入门阶段接入监控平台。