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

引导消息与后续输入

区分运行中补充与运行后追加,让忙碌时的输入有明确的调度语义。

引导消息与后续输入

Agent 跑起来之后,你还能说话吗?能,但有两种完全不同的说法:

  • "停一下,改用另一种做法" —— 这条要在当前这轮工具跑完后立刻插进去。
  • "顺便把这个也总结一下" —— 这条要等它把活干完再说。

Pi 把这两种分别叫 steering(引导) 和 follow-up(后续)。它们进的是不同的队列,注入时机不同,优先级不同。

对宿主来说,这不只是 API 名字的区别:忙碌时输入必须有明确语义,否则你不知道用户的话该在哪一轮生效。这篇把语义、时机和队列模式讲清楚。

需要的前置只有一条:知道 Pi 会话在空闲时可以直接 prompt(),忙碌时需要另选一条路。

本主题的输入、输出与责任边界

维度定义
输入
会话正忙时到达的一条用户消息,加上一个明确的选择:steer 还是 followUp
输出
消息进入对应队列,在下一个注入点被取出并作为用户消息发给模型
状态
两个独立的队列;每个队列有自己的投递模式
责任边界(Pi 负责)
队列存储、注入时机判断、按模式批量取出
责任边界(宿主负责)
决定每条输入走 steer 还是 followUp;把排队状态呈现给用户;必要时清队列

一、两个队列,两个注入点

Steering(引导)Follow-up(后续)
队列
steeringQueue
followUpQueue
注入时机
当前助手轮次及其工具调用全部结束之后
智能体把待办工作都做完之后
典型用途
纠正方向、补充约束
追加任务、要求总结
优先级
高
低

官方 README 里的原话很精确:

当一个轮次完成后检测到引导消息时:

  1. 当前助手消息的所有工具调用都已经结束
  2. 引导消息被注入
  3. LLM 在下一轮响应

后续消息只在没有更多工具调用、也没有引导消息时才被检查。如果有,它们被注入,然后另一个轮次运行。

这就是两者的核心差别:引导消息在"这一轮干完了但要接着干"的位置注入,后续消息在"没事干了"的位置注入。

因为引导队列优先被检查,所以只要队列里有引导消息,后续消息就会被压住,直到引导消息被消费完。

prompt() 在忙的时候必须指明

官方 SDK 文档写得很明确:

会话已经在流式输出时发送的提示,必须指明它是要引导当前运行还是跟随它。不带这个选择调用 prompt() 会被拒绝,而不是猜测。

源码里的报错信息是:

text
Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.

对应的参数是 streamingBehavior:

typescript
// 运行中发送的两条消息,语义不同
await session.prompt("换个思路", { streamingBehavior: "steer" });
await session.prompt("顺便总结一下", { streamingBehavior: "followUp" });

不指明就抛错,是刻意的设计。 猜测的话,宿主就得替用户猜他想要的是"立刻打断"还是"排队等会儿",猜错的表现是模型按错误的时机收到消息——比报错难查得多。

二、两种 API:直接调用和走 prompt

session.steer() 和 session.followUp() 把两种语义直接暴露出来:

typescript
// 两个入口直接暴露两种语义:返回值是 "queued"(已入队)或 "handled"(被扩展消费掉了)
const disposition = await session.steer("改用方案 B");
const disposition2 = await session.followUp("顺便把结果总结成一段话");

返回值有两种:

返回值含义
"queued"
输入已入队(包括被扩展转换过之后入队的)
"handled"
扩展消费掉了这条消息,它没有进入队列

"handled" 是给扩展用的:input 事件的处理器可以拦截并改写用户输入。宿主看到 "handled" 就知道这条消息不需要排队,也就不需要为它做任何展示。

注意 session.prompt() 的返回类型是 Promise<void>——它不返回 disposition,只在运行结束时 resolve。PromptDisposition("handled" | "queued" | "started")是内部钩子 preflightResult 用的,RPC 模式靠它观察一次提示被怎样分发,宿主自己不会拿到它。

一个细节:排队扩展命令会报错。 steer() 和 followUp() 在文本以 / 开头时会检查那是不是扩展命令,是的话抛错——因为扩展命令需要立即执行,不能排队。

队列模式:one-at-a-time 与 all

两个队列各自有一个投递模式:

模式行为
"one-at-a-time"(默认)
每次只取一条,等模型回应完再取下一条
"all"
一次把所有排队消息都取出来,一起发给模型

"one-at-a-time" 的效果是:用户连着说了三句话,模型会分三轮回应,每轮针对一条。"all" 的效果是三句话合并成一次请求。

这两个模式在三个地方都能改:

位置写法
settings.json
"steeringMode": "all" / "followUpMode": "one-at-a-time"
Agent 构造选项
steeringMode、followUpMode
AgentSession
session.setSteeringMode(mode) / session.setFollowUpMode(mode),或直接读写 session.steeringMode / session.followUpMode

AgentSession 在创建时会从 SettingsManager 同步这两个模式:syncQueueModesFromSettings() 把 settingsManager.getSteeringMode() 和 getFollowUpMode() 写进 agent。会话层的 setter 会同时更新 agent 和设置。

三、清队列

三个方法,作用范围不同:

方法作用
agent.clearSteeringQueue()
只清引导队列
agent.clearFollowUpQueue()
只清后续队列
agent.clearAllQueues()
两个都清

agent.reset() 也会清两个队列,但它在运行中会抛错("Agent is already processing")。

AgentSession 层面还有 session.clearQueue(),它返回被清掉的内容:

typescript
const { steering, followUp } = session.clearQueue();
// steering 和 followUp 是被清掉的队列内容,交还给用户界面

这正好用来实现"把排队的消息退回编辑器"——用户改主意了,想重新编辑排队的内容。

abort() 会把排队消息退回编辑器

官方文档说:abort() 停掉当前操作并等待会话空闲。

在交互式 TUI 里,abort() 还会把排队的消息取回编辑器,让用户可以修改后重新提交。实现上,TUI 先清队列拿到内容,把内容拼回编辑器文本,然后再调 session.abort()。

在 SDK 宿主里,abort() 只做"停掉并等空闲"这一半。退回编辑器是你要自己实现的:

typescript
// 先清队列拿到内容,再中止运行
const { steering, followUp } = session.clearQueue();
await session.abort();
// 把 steering 和 followUp 的文本交还给用户界面

顺序很重要:先 clearQueue(),再 abort()。 abort() 会等待会话空闲,这期间当前运行可能把队列里的消息消费掉。如果先 abort(),clearQueue() 拿到的就可能是空数组,用户排队的输入就丢了。

注意 agent.abort() 本身不清队列——它只是中止当前运行。agent.reset() 才清两个队列,但它在运行中会抛错。

四、调度语义表

这是本篇最该记住的一张表:

场景用什么时机队列
会话空闲时发消息
session.prompt(text)
立即开启新运行
不入队
正在跑,要立刻纠正方向
session.steer(text)
当前轮次的工具调用结束后
steeringQueue
正在跑,要追加任务
session.followUp(text)
智能体做完待办工作后
followUpQueue
正在跑,用 prompt() 但没指明
session.prompt(text)
—
抛错
正在跑,用 prompt() 指明了
session.prompt(text, { streamingBehavior: "steer" | "followUp" })
同对应队列
对应队列
想让用户改排队内容
session.clearQueue()
随时
两个队列,返回被清的内容
想彻底中止
session.abort()
立即
不清队列(TUI 会清)
扩展拦截了输入
返回 "handled"
—
不入队

五、完整示例

typescript
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});

const unsubscribe = session.subscribe((event) => {
	// 队列变化:宿主用它更新"有 N 条待处理消息"的提示
	if (event.type === "queue_update") {
		const total = event.steering.length + event.followUp.length;
		if (total > 0) {
			console.log(
				`\n[queue] steering=${event.steering.length} followUp=${event.followUp.length}`,
			);
		}
	}
	if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
		process.stdout.write(event.assistantMessageEvent.delta);
	}
});

try {
	// 1. 空闲时直接提示
	const run = session.prompt("Read package.json and summarize the dependencies.");

	// 2. 运行中:选一种语义。两个入口二选一。
	await new Promise((r) => setTimeout(r, 200));
	const steerResult = await session.steer("Also read pnpm-lock.yaml first.");
	console.log("\nsteer ->", steerResult);   // "queued" 或 "handled"

	await new Promise((r) => setTimeout(r, 200));
	const followResult = await session.followUp("Finally summarize both files.");
	console.log("followUp ->", followResult);

	// 3. 等待运行结束
	await run;

	// 4. 用户改主意:把还没送出的内容取回来
	// 先清队列拿到内容,再决定要不要中止运行
	const leftover = session.clearQueue();
	if (leftover.steering.length || leftover.followUp.length) {
		console.log("returned to editor:", leftover);
	}
} finally {
	unsubscribe();
	session.dispose();
}

六、反例:忙的时候直接 prompt()

typescript
// 反例:会话正在流式输出,没给 streamingBehavior
await session.prompt("等一下,先别改那个文件");
// → 抛出 "Agent is already processing. Specify streamingBehavior ..."

这一行会抛出:

text
Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.

这个错误是特性不是 bug。它把"运行中输入的语义"从宿主的隐式猜测变成显式声明。想修这个反例,只有两条路:

  1. 加上 streamingBehavior: "steer" 或 streamingBehavior: "followUp"。
  2. 捕获这个错误,退回到 session.steer() / session.followUp()。

第 2 条在你想把"用户明确说了什么"和"技术上怎么投递"分开时更合适——比如 UI 上有"打断"和"追加"两个按钮,就直接映射到两个方法;只有一个输入框时,才需要在捕获异常后按某种规则决定。

你现在能做什么

  • 在会话空闲时发一条 prompt(),在运行中分别用 steer() 和 followUp() 各发一条,观察它们进入不同队列、在不同注入点生效。
  • 在运行中调用不带 streamingBehavior 的 prompt(),确认它抛出明确的错误而不是猜一个语义。
  • 用 session.setSteeringMode("all") 切换投递模式,连发三条引导消息,确认它们被合并成一次请求而不是分三轮。
  • 用 session.clearQueue() 取回排队内容并检查返回的两个数组,确认消息没有静默丢失。
  • 按"先 clearQueue() 再 abort()"的顺序实现一次"退回编辑器",确认取消后排队消息回到了用户手上。

排障速查

现象检查点处理
prompt() 抛 "Agent is already processing"
streamingBehavior
补上 "steer" 或 "followUp";或者改用 steer() / followUp()
用户的话总是下一轮才生效
走错了队列
需要立刻纠正用 steer(),需要追加用 followUp()
后续消息一直没被处理
引导队列非空
引导队列优先被检查;清掉或消费引导消息
连发三句只回应了一句
队列模式
"one-at-a-time" 每次只取一条;改 steeringMode / followUpMode 为 "all"
消息不入队
扩展拦截
steer() 返回 "handled" 说明扩展消费了它
排队扩展命令报错
命令语义
扩展命令不能排队,需要立即执行
abort() 后消息不见了
宿主没有实现退回
SDK 的 abort() 不退回内容;要自己调 clearQueue() 拿回来
排队内容想改但拿不回来
顺序
先 clearQueue() 再 abort();反过来会丢消息
队列提示不更新
没订阅事件
订阅 session.subscribe() 并处理 queue_update

参考资料