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

显示执行进度,并停止正在运行的任务

消费流式文本和工具事件,接入 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:

typescript
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)
}

运行:

bash
node --env-file=.env --import tsx src/progress.ts

正常情况下先后看到工具状态和逐步生成的解释。运行中按 Ctrl+C,会记录用户取消并请求中止。任务若已经完成,便没有正在进行的请求可供取消;要观察中止,应在等待或生成阶段操作。

文本增量与完整结果承担不同职责

text_delta 是新增的一段文本,使用 process.stdout.write() 直接追加,不要对每个分片都调用带换行的 console.log()。也不要把完整累积文本和增量同时打印,否则会出现重复输出。

别把 message_update 的 message 当增量用。 它是截至目前的累计值,每次事件都包含之前所有的文本。取增量只有一条路:

typescript
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
  // event.assistantMessageEvent.delta 才是这一段新增的文本
  process.stdout.write(event.assistantMessageEvent.delta)
}

原因在 provider 侧的累加逻辑:

typescript
// 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() 的实现只有一行:

typescript
// packages/agent/src/agent.ts
/** Abort the current run, if one is active. */
abort(): void {
	this.activeRun?.abortController.abort();
}

它触发的是本次运行自己的 AbortController。没有正在进行的运行时,这一行什么也不做——不会抛异常,不会清空任何状态。

这个信号会流到两个地方:

一是模型请求。streamAssistantResponse 把 signal 传给 streamFunction:

typescript
const response = await streamFunction(config.model, llmContext, {
	...config,
	apiKey: resolvedApiKey,
	identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),
	signal,
});

二是工具。executePreparedToolCall 把它传给工具的 execute:

typescript
const result = await prepared.tool.execute(
	prepared.toolCall.id,
	prepared.args as never,
	signal,
	(partialResult) => { /* ... */ },
);

所以取消是协作式的:运行时发出信号,工具自己决定要不要停下来。工具里那个 signal 参数就是为此存在的。

取消信号怎样到达工具

text
Ctrl+C 或超时
  → 记录取消原因
  → agent.abort()
  → 模型请求与工具收到取消信号
  → 等待运行结束
  → 清除订阅、计时器和信号监听

前面工具的 readFile() 已接收 signal,因此工具有机会响应取消:

typescript
const text = await readFile(path, { encoding: "utf8", signal })

Node 的 fs/promises.readFile 接受 signal 选项,文件读取可以被中断。

换成其他网络或文件 API 时,要确认执行器也传递信号;如果第三方操作忽略信号,外层不能靠打印状态保证它已停止。

取消也会自动回滚已完成的操作。本例只有读取,没有写入;以后添加修改文件、发送请求等副作用时,需要单独设计恢复与去重。

工具执行期间被取消,结果不是失败而是被拦下。源码里在准备阶段和执行阶段各有一处检查:

typescript
// 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_end is the final emitted event for a run, but the agent does not become idle until all awaited listeners for that event have settled.

原因是事件监听器是按注册顺序被 await 的:

typescript
// packages/agent/src/agent.ts
for (const listener of this.listeners) {
	await listener(event, signal);
}

所以 agent_end 之后,你的监听器还可能在跑异步收尾工作。这时候读 agent.state 可能读到中间状态。

waitForIdle() 等的就是这个真正的空闲点:

typescript
/** 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() 是在执行体完全结束后才调用的:

typescript
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 字段也从另一侧印证了这件事:

typescript
/**
 * 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 事件。两者语义不同:

事件层级含义
agent_end
Agent Core
底层循环结束,不再有循环事件
agent_settled
会话层
这次运行彻底落定,不会再有自动重试、压缩或排队续跑

会话层的定义写得很精确:

typescript
// 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 字段,告诉你这次结束之后会不会自动重试:

typescript
// 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" 时,这条消息里的所有工具调用都会被拒绝执行。源码注释解释了原因:

typescript
// 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.

被拒绝时写回的错误文案是固定的一句:

text
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工具结果里的文案后续动作
用户取消或超时
aborted
"Operation aborted"
你的程序决定是否重试
响应撞到输出上限
length
"...hit the output token limit..."
模型重新发起调用

所以判断"是不是被取消了",不要只看工具结果里有没有错误文案,要看最终助手消息的 stopReason。

顺带说一句,源码注释里用了 "borked" 这个词——它形容的是"参数可能被截断成看起来合法、实际残缺"的调用。这类调用如果被执行,副作用会基于错误的参数发生,所以宁可全部拒绝。

为什么单独保存取消原因

abort() 是一个动作,不是业务结论。程序用 cancelReason 区分用户取消与时间上限,并用 ??= 保留第一个原因,避免清理过程覆盖它。

typescript
function cancel(reason: string) {
  cancelReason ??= reason   // 保留第一个原因
  agent.abort()
}

清理中的 abort() 用于确保没有活动工作,不再把它标成"用户取消"。如果请求失败,lastReply() 会识别错误消息,进入失败分支,设置非零退出码。

本例等待的是当前运行和被运行时等待的监听器,不是整个操作系统上的所有活动。不要在监听回调里阻塞,或等待只有当前运行结束才会出现的条件。

退出前清理什么

计时器不清理,正常回答后也可能继续触发超时;订阅不移除,复用时会出现重复输出;SIGINT 监听不移除,后续任务可能被旧处理器影响。因此这三项都放进 finally。

abort() 先发出信号,waitForIdle() 再等待收尾。不要把"已经发出中止请求"和"所有受管理操作已经结束"当成同一个时刻。

退订函数来自 subscribe() 的返回值:

typescript
const unsubscribe = agent.subscribe((event) => { /* ... */ })
// 清理时
unsubscribe()

验收正常、失败与取消

正常任务应输出内容并完成清理;无效凭据应报告失败;在生成过程中按 Ctrl+C,应报告取消并退出;超过时间上限,应报告超时而不是成功。

本轮随文示例完成了类型检查,以及模拟流上的中止、空闲和监听移除验证;真实服务中的网络响应、终端信号交互仍需在你的环境检查。更完整的运行指标、追踪与告警进入"调试与可观测性"主题,不在入门阶段接入监控平台。

参考资料