引导消息与后续输入
区分运行中补充与运行后追加,让忙碌时的输入有明确的调度语义。
引导消息与后续输入
Agent 跑起来之后,你还能说话吗?能,但有两种完全不同的说法:
- "停一下,改用另一种做法" —— 这条要在当前这轮工具跑完后立刻插进去。
- "顺便把这个也总结一下" —— 这条要等它把活干完再说。
Pi 把这两种分别叫 steering(引导) 和 follow-up(后续)。它们进的是不同的队列,注入时机不同,优先级不同。
对宿主来说,这不只是 API 名字的区别:忙碌时输入必须有明确语义,否则你不知道用户的话该在哪一轮生效。这篇把语义、时机和队列模式讲清楚。
需要的前置只有一条:知道 Pi 会话在空闲时可以直接 prompt(),忙碌时需要另选一条路。
本主题的输入、输出与责任边界
一、两个队列,两个注入点
官方 README 里的原话很精确:
当一个轮次完成后检测到引导消息时:
- 当前助手消息的所有工具调用都已经结束
- 引导消息被注入
- LLM 在下一轮响应
后续消息只在没有更多工具调用、也没有引导消息时才被检查。如果有,它们被注入,然后另一个轮次运行。
这就是两者的核心差别:引导消息在"这一轮干完了但要接着干"的位置注入,后续消息在"没事干了"的位置注入。
因为引导队列优先被检查,所以只要队列里有引导消息,后续消息就会被压住,直到引导消息被消费完。
prompt() 在忙的时候必须指明
官方 SDK 文档写得很明确:
会话已经在流式输出时发送的提示,必须指明它是要引导当前运行还是跟随它。不带这个选择调用
prompt()会被拒绝,而不是猜测。
源码里的报错信息是:
Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.对应的参数是 streamingBehavior:
// 运行中发送的两条消息,语义不同
await session.prompt("换个思路", { streamingBehavior: "steer" });
await session.prompt("顺便总结一下", { streamingBehavior: "followUp" });不指明就抛错,是刻意的设计。 猜测的话,宿主就得替用户猜他想要的是"立刻打断"还是"排队等会儿",猜错的表现是模型按错误的时机收到消息——比报错难查得多。
二、两种 API:直接调用和走 prompt
session.steer() 和 session.followUp() 把两种语义直接暴露出来:
// 两个入口直接暴露两种语义:返回值是 "queued"(已入队)或 "handled"(被扩展消费掉了)
const disposition = await session.steer("改用方案 B");
const disposition2 = await session.followUp("顺便把结果总结成一段话");返回值有两种:
"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" 的效果是三句话合并成一次请求。
这两个模式在三个地方都能改:
AgentSession 在创建时会从 SettingsManager 同步这两个模式:syncQueueModesFromSettings() 把 settingsManager.getSteeringMode() 和 getFollowUpMode() 写进 agent。会话层的 setter 会同时更新 agent 和设置。
三、清队列
三个方法,作用范围不同:
agent.reset() 也会清两个队列,但它在运行中会抛错("Agent is already processing")。
AgentSession 层面还有 session.clearQueue(),它返回被清掉的内容:
const { steering, followUp } = session.clearQueue();
// steering 和 followUp 是被清掉的队列内容,交还给用户界面这正好用来实现"把排队的消息退回编辑器"——用户改主意了,想重新编辑排队的内容。
abort() 会把排队消息退回编辑器
官方文档说:abort() 停掉当前操作并等待会话空闲。
在交互式 TUI 里,abort() 还会把排队的消息取回编辑器,让用户可以修改后重新提交。实现上,TUI 先清队列拿到内容,把内容拼回编辑器文本,然后再调 session.abort()。
在 SDK 宿主里,abort() 只做"停掉并等空闲"这一半。退回编辑器是你要自己实现的:
// 先清队列拿到内容,再中止运行
const { steering, followUp } = session.clearQueue();
await session.abort();
// 把 steering 和 followUp 的文本交还给用户界面顺序很重要:先 clearQueue(),再 abort()。 abort() 会等待会话空闲,这期间当前运行可能把队列里的消息消费掉。如果先 abort(),clearQueue() 拿到的就可能是空数组,用户排队的输入就丢了。
注意 agent.abort() 本身不清队列——它只是中止当前运行。agent.reset() 才清两个队列,但它在运行中会抛错。
四、调度语义表
这是本篇最该记住的一张表:
五、完整示例
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()
// 反例:会话正在流式输出,没给 streamingBehavior
await session.prompt("等一下,先别改那个文件");
// → 抛出 "Agent is already processing. Specify streamingBehavior ..."这一行会抛出:
Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.这个错误是特性不是 bug。它把"运行中输入的语义"从宿主的隐式猜测变成显式声明。想修这个反例,只有两条路:
- 加上
streamingBehavior: "steer"或streamingBehavior: "followUp"。 - 捕获这个错误,退回到
session.steer()/session.followUp()。
第 2 条在你想把"用户明确说了什么"和"技术上怎么投递"分开时更合适——比如 UI 上有"打断"和"追加"两个按钮,就直接映射到两个方法;只有一个输入框时,才需要在捕获异常后按某种规则决定。
你现在能做什么
- 在会话空闲时发一条
prompt(),在运行中分别用steer()和followUp()各发一条,观察它们进入不同队列、在不同注入点生效。 - 在运行中调用不带
streamingBehavior的prompt(),确认它抛出明确的错误而不是猜一个语义。 - 用
session.setSteeringMode("all")切换投递模式,连发三条引导消息,确认它们被合并成一次请求而不是分三轮。 - 用
session.clearQueue()取回排队内容并检查返回的两个数组,确认消息没有静默丢失。 - 按"先
clearQueue()再abort()"的顺序实现一次"退回编辑器",确认取消后排队消息回到了用户手上。