输入受理、命令队列与引导
一条新输入在会话空闲与忙碌时分别怎样被 AgentRuntime 受理:PromptAdmissionReceipt 的三种结果,运行时命令队列的结构与消费时机,引导(guide)与排队(queue)在回合的哪个边界被消费,控制类回合,提问的自动继续,以及输入意图怎样一次性落进账本与消息。
Agent 正在干活时用户又发来一句话,这句话该怎么办?ZCode 给了三种去处:并进正在跑的回合(引导,guide),排到本轮之后另起一轮(排队,queue),或者直接拒绝。决定去处、并保证同一会话不会同时跑出两个回合的,是 AgentRuntime 自己的受理逻辑,代码在 apps/zcode-cli/packages/core/src/runtime 的 command-queue.ts、methods/prompt-admission.ts、methods/steering.ts 与几个 runtime-command-*.ts 里。
协议层也有一道受理:桌面端与 Web 发来的每条命令先经 CommandInbox 串行登记、分配顺序号,再调到这里(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/command-inbox.ts:1)。那一层的幂等、pin 与重放归 ZCode Protocol V4,这一篇只讲 core 这一侧,以及两边在哪里衔接。
怎么用
桌面端与 Web 的设置里有一项“交互行为”,选项是“队列”与“引导”,默认队列(packages/shared/src/validationAppSettings.ts:453)。设置页的说明是(packages/ui/src/i18n/locales/zh-CN.ts:1816):
在 ZCode 运行时将后续操作加入队列,或引导至下一轮工具调用后运行。
发送时按住主修饰键(macOS 为 Cmd,其他平台为 Ctrl)会临时反转一次:引导模式下变成“加入队列”,队列模式下变成“立即发送”,也就是抢占当前回合、先跑这一条(packages/ui/src/v4/composer/followupModeSettings.ts:11、followupModeSettings.ts:45)。排队中的消息可以编辑、调整顺序、删除、立即发送,整个队列也能暂停自动消费。另一项设置“提问自动继续”默认开启:Agent 提问后 5 分钟没人回答就自动继续(packages/shared/src/validationAppSettings.ts:454、zh-CN.ts:1820)。
这些操作到 core 的落点如下,协议命令的处理器在 apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/queue.ts:293,桥接函数在 apps/zcode-cli/packages/bootstrap/src/app/input-facade.ts:305 一带:
| 操作 | 协议命令 | core 方法 |
|---|---|---|
| 运行中发消息 | sendText | admitPrompt,再分流到 steerTurn 或 enqueueDeferredInput |
| 编辑排队项 | editQueueItem | editPendingInputById:同一 id 重发 TurnSteerQueued,位置不变 |
| 调整顺序 | reorderQueueItem | reorderPendingInput:发 TurnSteerReordered |
| 删除排队项 | deleteQueueItem | removePendingInputById:发 TurnSteerDiscarded,原因 user_removed |
| 立即发送 | sendQueuedNow | 预留、提升租约、停掉当前回合、admitPrompt,最后以 promoted 摘除 |
| 暂停或恢复队列 | setAutoDrain | setQueueAutoDrain |
| 切换交互行为 | setFollowupMode | setFollowupMode:只追加 FollowupModeChanged 事件 |
终端 TUI 没有这个开关:空闲时回车走 submitPrompt,运行中回车走 sendInput 并带上 delivery: "auto" 与当前回合 id,状态栏显示“Input queued.”(apps/zcode-cli/packages/tui/src/app-submit.ts:151、app-submit.ts:185)。
两个入口:executeTurn 与 admitPrompt
executeTurn 是老入口,不判断忙闲,直接把一条 prompt 命令排进运行时命令队列(apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:70);队列串行执行,所以忙时它只是排在后面等。submitPrompt 走的就是它(input-facade.ts:127)。
admitPrompt 是新入口,sendInput 调它(input-facade.ts:216)。文件头的注释说明了为什么判断必须在 runtime 里原子完成(apps/zcode-cli/packages/core/src/runtime/methods/prompt-admission.ts:15):检查忙闲、建立预留、入队这几步若由 bootstrap 拆开做,预留建立之前的异步窗口就会让同一会话出现第二个回合。
“忙”的定义是六个条件之一成立:持有提升租约、有前台执行、命令队列正在排水、队列非空、有活动回合、有回合启动预留(apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:100)。忙时的分流是整个受理的核心(prompt-admission.ts:34):
const busy = this.hasActiveOrQueuedTurnWork() && !promotionLeaseOnly;
if (busy) {
if (options?.requireIdle === true || options?.modelExecution !== undefined) {
return {
activeTurnId: this.activeTurn?.turnId,
kind: "rejected",
reason: this.activeTurn ? "turn_not_steerable" : "no_active_turn",
};
}
const activeTurn = this.activeTurn;
const canSteer =
attachments === undefined &&
activeTurn?.steerable === true &&
(options?.queueDelivery === "guide" ||
options?.delivery === "auto" ||
options?.delivery === "steer_active_turn") &&
options?.queueDelivery !== "queue";
if (canSteer) {
const delivery = options?.queueDelivery === "guide" ? "guide" : undefined;
return await this.steerTurn({
// ...
}
const delivery =
options?.queueDelivery === "guide" && attachments === undefined ? "guide" : "queue";
return await this.enqueueDeferredInput({
// ...
}- 要求空闲或带单次执行模型的输入一律拒绝。
requireIdle是“立即发送”在提升时用的,modelExecution是闲时任务这类自带模型的执行;它们不能被并进别人的回合。唯一例外是:要求空闲的受理到来时,唯一的“忙”只是有人持有提升租约,通常就是调用方自己(prompt-admission.ts:26)。 - 能引导的条件是没有附件、活动回合可引导、调用方要求引导或允许自动。于是
steerTurn把输入推进活动回合的pendingInputs,并追加TurnSteerQueued事件(apps/zcode-cli/packages/core/src/runtime/methods/steering.ts:129、methods/steering.ts:149)。注意只有显式要求 guide 时才打上delivery: "guide";TUI 的auto进来时不带这个标记,按规则落在 queue 车道。 - 不能引导就走
enqueueDeferredInput:它不碰内存,只追加一条TurnSteerQueued,目标回合依次取活动回合、最近一次助手回合、trace 里的回合 id,都没有就记成"deferred"(methods/steering.ts:226)。这条输入从此只活在事件投影的pendingSteerInputs里。
协议层调 sendInput 时固定传 delivery: "start_turn",只有交互行为是引导时才加 queueDelivery: "guide"(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/prompt-turn.ts:111)。所以在桌面端,会话忙时队列模式的新消息一律走 enqueueDeferredInput,引导模式的才可能进活动回合。
空闲时则当场分配 turnId 与 queryId、派生回合 trace、建立启动预留,再把带预留的 prompt 命令入队(prompt-admission.ts:86)。预留保证命令真正开跑之前,任何第二次受理都会看到“忙”。
PromptAdmissionReceipt
受理的返回值是一个联合类型(apps/zcode-cli/packages/core/src/runtime/types.ts:491):
| 结果 | 字段 | 含义 |
|---|---|---|
started | turnId、completion | 命令已入队并占住启动位;completion 在回合结束、含随后的目标续跑结束时兑现 |
queued | pendingInputId、queueLength、turnId | 进了活动回合的待注入列表,或挂在投影里等提升 |
rejected | reason、activeTurnId | 原因取自五种之一 |
拒绝原因只有五种:没有活动回合、期望回合不符、回合不可引导、输入为空、输入过大(apps/zcode-cli/packages/contracts/src/interfaces/session.port.ts:36)。“过大”的门槛是 UTF-8 编码 200000 字节(apps/zcode-cli/packages/core/src/runtime/helpers/steering.ts:11),经 steerTurn 或 enqueueDeferredInput 拒绝时还会追加一条 TurnSteerRejected 事件(methods/steering.ts:383)。
受理即确认:协议层把回执当作命令的 ACK 边界,不等 TurnStarted,也不等投影提交;completion 只用来在后台做收尾(prompt-turn.ts:31)。runtime 这边也先 void completion.catch(() => undefined),免得回合失败时出现未处理的拒绝(prompt-admission.ts:124)。
运行时命令队列
受理之后真正排队的是运行时命令。一共六种(apps/zcode-cli/packages/core/src/runtime/command-queue.ts:11,表中路径相对 apps/zcode-cli/packages/core/src/runtime):
| mode | 谁放进来 | 怎样执行 |
|---|---|---|
prompt | admitPrompt、executeTurn | executeTurnCommand,按需接着跑目标续跑 |
target-continuation | 目标续跑(methods/target.ts:66) | 跑一次续跑回合 |
target-continuation-loop | 目标续跑循环(methods/target-continuation-loop.ts:26) | 跑续跑循环 |
task-notification | 后台任务结束(methods/background-notifications.ts:49) | 同批通知合成一条 model-only 输入,跑一个回合 |
subagent-message | 子 Agent 回话(methods/subagent-messages.ts:56) | 先落一条消息,再跑一个 model-only 回合 |
control-only-turn | GUI 修改工作流设置(methods/dynamic-workflow-run-settings.ts:298) | 不调模型的控制类回合,见下文 |
队列本身很朴素:一个数组加一个“待取消”集合(command-queue.ts:144)。命令有 now、next、later 三档优先级(command-queue.ts:131),出队时取优先级最高者中最早入队的那条;但仓库里所有入队点都写 priority: "next",所以实际就是先进先出。唯一的特殊处理是后台通知的合批(command-queue.ts:184):
dequeueNextBatch(): readonly RuntimeCommand[] {
const index = selectNextIndex();
if (index === -1) return Object.freeze([]);
const selected = commands[index];
if (selected?.mode !== "task-notification") {
const [command] = commands.splice(index, 1);
return Object.freeze(command ? [command] : []);
}
const batch = commands.filter(
(command) => command.mode === "task-notification" && command.priority === selected.priority,
);
for (let commandIndex = commands.length - 1; commandIndex >= 0; commandIndex -= 1) {
const command = commands[commandIndex];
if (command?.mode === "task-notification" && command.priority === selected.priority) {
commands.splice(commandIndex, 1);
}
}
return Object.freeze(batch);
},几个消费规则:
- 入队即尝试排水,排水是单飞的:
runtimeCommandDrainActive为真时直接返回,循环结束后若还有命令且没有提升租约,再排一轮(runtime-command-queue.ts:23、runtime-command-queue.ts:37)。 - 每条命令一个前台执行。执行前新建一个
AbortController挂到activeForegroundExecution,覆盖整条命令,包括回合之后的目标校验与续跑;用户按停止时调用的stopActiveForegroundExecution取消的就是它(runtime-command-queue.ts:396、runtime-command-queue.ts:446)。 - 取消分两种窗口。还在队里就直接摘掉并拒绝;刚出队、尚未开跑的窄窗口里则记一笔“待取消”,执行前检查到就放弃(
apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-submit.ts:47、runtime-command-queue.ts:199)。 - 旧分支的结果直接丢。通知、子 Agent 回话与控制类回合带着入队时的
branchGeneration,回退之后代际变了,执行前再校验一次,不匹配只记诊断日志(apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-generation.ts:6)。 - 提升租约插队。“立即发送”先取得租约,此后排水只放行
inputId与租约匹配的那条命令,出队与消费租约在同一个同步步里完成,注释说这是为了不让后台通知在停止旧回合与提升新输入之间抢先出队(runtime-command-queue.ts:72、runtime-command-queue.ts:83)。
命令队列只在内存里。后台通知入队的同时会在会话输入账本里登记一条 admitted 记录,注释说这是崩溃后唯一的持久痕迹,重启时会被收口为 discarded(background-notifications.ts:58)。
回合进行中的两种插话
同一回合里,别处送来的东西有两条路进来。第一条是运行时命令:回合循环每一轮开头(第一次模型请求之后)会把队里的后台通知与子 Agent 回话直接吸收进当前回合,而不是等回合结束再单开一轮(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:47):
while (true) {
throwIfTurnAborted(state.turnAbortSignal);
const outputTokenRecoveryActive = state.turnRequestState.outputTokenContinuationCount > 0;
// guide 只允许由完整 tool result batch 设置这个一次性诊断;普通 queue 不在
// model roundtrip 起点消费,避免把未来 turn 错并入当前 product turn。
const drainedSteerForNextRequest = state.drainedSteerForNextRequest;
state.drainedSteerForNextRequest = undefined;
if (state.modelStepCount > 0 && !outputTokenRecoveryActive) {
const drainedRuntimeCommands = await this.drainPendingRuntimeCommandsForActiveLoop();
state.backgroundSubagentResultConsumed ||=
drainedRuntimeCommands.backgroundSubagentResultConsumed;
state.workflowResultConsumed ||= drainedRuntimeCommands.workflowResultConsumed;
appendTurnRequestEntries(state.turnRequestState, drainedRuntimeCommands.runtimeEntries);
if (drainedRuntimeCommands.drained > 0) {
state.repeatedToolCallSignature = undefined;
state.repeatedToolCallStreakCount = 0;
}
}吸收只认这两种命令,排在前面的 prompt 命令原地不动;遇到控制类回合就停,因为它之后的通知说的是它刚记下的新工作流,模型得先读到它(apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-active-loop.ts:37)。第二条路是用户输入的 pendingInputs,也就是下面的 guide 与 queue。回合循环本身见回合循环与 TurnMachine。
guide 与 queue
pendingInputs 一个数组同时装着两种车道,delivery 决定消费方式。取 guide 时只在 guide 子序列里保持先来后到(methods/steering.ts:445):
function firstInlineGuideIndex(activeTurn: ActiveTurnSteeringState): number {
// pendingInputs 同时承载 future queue 与 current-turn guide,只检查
// 数组队首,导致先入队的普通消息把后续显式 guide 永久挡住。delivery 才是消费车道;
// 这里只在 guide 子序列内保持 admission FIFO,普通 queue 留在原位等待外层提升。
return activeTurn.pendingInputs.findIndex(
(pendingInput) =>
pendingInput.commandKind !== "sendGoalCommand" &&
pendingInput.commandKind !== "compact" &&
pendingInputDelivery(pendingInput) === "guide",
);
}guide 在两个边界被消费,每个边界最多一条(apps/zcode-cli/packages/core/src/runtime/methods/turn-guide-drain.ts:9):一批工具结果全部回来之后(apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:470),以及模型本步只回了文本、本来要收尾的时候。后一种情况下回合不结束,guide 以 user 角色续上,同一回合接着发下一次请求(apps/zcode-cli/packages/core/src/runtime/methods/turn-stop.ts:195)。完全访问的切换进行中、外层正在按投影恢复暂停队列、这一项已被“立即发送”预留时,都不注入(methods/steering.ts:457)。
注入做的事情(methods/steering.ts:1160):把输入写进消息历史,按普通用户消息落库,并把账本记录原子地置为已提升;追加 TurnSteerDrained;此后的模型请求改挂这条输入的 queryId;输入自带的权限、Plan 或模型选择在下一步生效,隐藏工具名单并入当前回合;重复工具调用的计数清零(turn-guide-drain.ts:27、turn-guide-drain.ts:57)。
guide 没能在回合内被消费时会改投 queue,并追加 TurnSteerDeliveryChanged(methods/steering.ts:478)。触发点有四个:回合被取消,原因 guide.turnInterrupted(turn.ts:734);工具结果要求停止回合(turn-tools.ts:443);自动化任务的创建上限触发、只允许一段文字收尾(turn-stop.ts:180);最后一步收尾时仍有注入不了的 guide(turn-stop.ts:221),原因都是 guide.noToolBoundary。
queue 车道的输入 core 从不在回合内消费。回合结束后,活动回合连同 pendingInputs 一起消失,这些输入只剩事件投影里的 pendingSteerInputs,称为 held。对比:
| guide(引导) | queue(排队) | |
|---|---|---|
| 存在哪里 | 活动回合的 pendingInputs | pendingInputs 或仅在事件投影里 |
| 何时消费 | 完整工具批次之后,或纯文本步收尾时 | 本轮结束、会话空闲后由协议层提升 |
| 属于哪个回合 | 并入当前回合 | 另起一个新回合 |
附件、/goal、/compact | 不走 guide:附件受理时改走 queue,后两者从不行内注入 | 可以排队 |
| 回合被取消 | 改投 queue | 保留,队列转为暂停(turn.ts:751) |
| 回合出错 | 不改投,留在投影里 | 保留,队列转为暂停(turn.ts:745) |
contracts 里 TurnSteerDeliveryMode 的注释说两种车道“runtime 注入机制两者相同(boundary 注入),差异只在产品呈现与账本语义”(session.port.ts:272)。以当前代码为准,这句已经过时:core 的行内注入只取 guide,queue 一律留给外层提升。
与协议层的衔接
把 held 队首变成新回合的是协议层。v4 网关在每次命令引起的状态变更之后(回合收尾也算)以及目标完成时检查队首(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/v4-bridge.ts:1080、v4-bridge.ts:1433):可自动消费、未被预留、会话不忙、没有目标或目标已完成,才以一条内部 sendQueuedNow 命令提升;core 仍忙就 100 毫秒后再看,提升失败则把队列暂停、保留原项(v4-bridge.ts:599、apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/queue-auto-drain.ts:7)。sendQueuedNow 依次调 core 的几个原语:预留队列项、取得提升租约(自动提升时这一步排在最前)、手动触发时先停掉当前回合、标记提升中、以 requireIdle 受理,成功后以 promoted 摘除;开跑前任何一步失败都释放预留,原项留在原位(queue.ts:156)。按住修饰键的“立即发送”也是同一套:先取租约、停掉当前回合,再以 requireIdle 受理,注释说不这样做,新输入会先落进队列、等租约释放后才被自动提升(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/session-flow.ts:208、session-flow.ts:282)。
两边的事实靠事件与账本对齐。CommandInbox 为每条输入分配顺序号 admissionSeq 和 queue_ 前缀的队列项 id(command-inbox.ts:75、command-inbox.ts:162),随 TurnInputIntentMetadata 一路带到 core;用户消息与账本“已提升”在同一个事务里写完之后,core 才追加 SessionInputPromoted,协议层据此解除这条输入的 pin(apps/zcode-cli/packages/core/src/runtime/methods/message-persistence.ts:142、apps/zcode-cli/packages/contracts/src/events/session.events.ts:101)。
setQueueAutoDrain 既追加 QueueAutoDrainChanged 事件,也改 runtime 的 queueAutoDrain 字段;从暂停恢复时置 queueExternalDrainActive,在外层按投影把旧暂停项逐条提升完之前,禁止 core 行内注入新的 guide,免得新消息越过旧项(methods/steering.ts:1015、apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:217)。queueAutoDrain 字段的注释说它为假时“turn-stop 不续跑、roundtrip 间不 drain”(agent-runtime.ts:214),但在 core 里它只在 setQueueAutoDrain 中被读过一次,真正的闸门是协议层投影里的同名状态。
进程内的 TUI 没有 v4 网关。从代码看,TUI 运行中发出的消息会落进 queue 车道,而仓库里负责把 held 项提升成新回合的只有协议层;TUI 自己在 TurnComplete 时把本地队列显示清空(apps/zcode-cli/packages/tui/src/app-events.ts:76)。这些消息在 TUI 里之后是否还会被执行,本文没有找到对应代码,未能核实。
控制类回合
有些用户动作要在对话里留下一条用户消息,却不需要模型回答:设置目标时 /goal 的原文、在工作流中枢直接启动一个已保存的工作流、在 GUI 里修改某个工作流运行的配置。它们共用 emitControlOnlyUserTurn(apps/zcode-cli/packages/core/src/runtime/methods/control-only-turn.ts:34):先确保上下文已初始化、会话已落库,把文本写进消息历史并持久化,再补一对无模型输出的回合边界,即 executionKind: "controlOnly" 的 TurnStarted 与 response 为空的 TurnComplete,最后 turnNumber 加一(control-only-turn.ts:76、control-only-turn.ts:112)。注释说明了为什么非补这对事件不可:否则实时投影收不到这条输入,要等冷恢复才出现,界面还会把 0 毫秒的控制轮显示成“已工作 1 秒”(control-only-turn.ts:23)。
三处入口里,前两处直接执行:/goal 经 recordExternalUserPrompt(control-only-turn.ts:210,调用方在 apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:233),中枢启动在 apps/zcode-cli/packages/core/src/runtime/methods/dynamic-workflow-run-start.ts:216。修改工作流设置则作为 control-only-turn 命令排队,原因写在类型注释里:user 消息插不进正在跑的回合,助手的 tool_use 与 tool_result 之间不能夹 user,所以它要等当前回合结束(command-queue.ts:90)。
提问的自动继续
AskUserQuestion 发出的提问由协议层的交互登记表计时(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/interaction-registry.ts:30):每个会话只有排在最前的那个提问计时;前 60 秒是隐藏的宽限期,之后界面出现倒计时,从提问起满 300 秒自动以“接受、答案为空”应答(interaction-registry.ts:256、interaction-registry.ts:335)。用户对这个提问的第一次有效操作会让它永久暂停计时(interaction-registry.ts:194),关掉“提问自动继续”则当前与之后的提问都不再计时(interaction-registry.ts:212)。
core 这一侧只负责记账。每次阶段变化,协议层回调 recordUserInputAutoResolutionUpdate(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:515),runtime 追加 UserInputAutoResolutionUpdated 事件(apps/zcode-cli/packages/core/src/runtime/methods/interaction-auto-resolution.ts:12),事件管线再按交互 id 覆写一条会话条目。注释说如果绝对时间只留在内存事件里,CLI 重启会错误地重开五分钟窗口(apps/zcode-cli/packages/core/src/runtime/methods/events.ts:302);重新登记时协议层先读这条条目,已过期限就立即应答(interaction-broker.ts:211、interaction-registry.ts:294)。答案为空时工具给模型的结果是(apps/zcode-cli/packages/core/src/tool/handlers/ask-user-question.ts:134):
The user did not provide answers to these questions. Continue using your best judgment; do not treat this as a rejection or invent a user preference.
问卷的结构与呈现见 Todo、提问与 Plan 模式。
输入意图持久化
一条输入从受理到被消费,要在两处留下同样的事实:会话输入账本(session_input)与最终的用户消息。buildPersistedConversationInputIntent 负责把它们一次性拼成完整记录(apps/zcode-cli/packages/core/src/runtime/methods/input-intent-persistence.ts:10),内容包括来源命令 id、队列项 id、客户端 id、规范文本、附件引用、随输入固定的模型选择与权限模式、请求与实际的投递方式和回退原因、admissionSeq 与队列位置、引导状态(未请求、引导中、已引导、已回退)以及派发状态。注释说如果只存文本和零散元数据,恢复端就得重新推断投递与派发,实时投影和冷快照会出现两套状态(input-intent-persistence.ts:3)。
它被调用两次:TurnSteerQueued 经事件管线写账本时以 queued 状态写入(events.ts:328),输入被消费、写用户消息时以 drained 状态写进消息元数据(message-persistence.ts:63)。账本的生命周期写在接口注释里:admitted 之后只会走向 promoted、cancelled、discarded 或 failed(apps/zcode-cli/packages/contracts/src/interfaces/session-store.port.ts:843)。会话恢复时,残留的 admitted 记录与投影里的排队项一律丢弃并留痕为 session_resumed,也就是“重启不保留队列”(methods/steering.ts:1331,调用在 apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:233)。
下一篇:回合循环与 TurnMachine——受理之后的 prompt 命令怎样变成一个回合:准备、循环每一轮做什么、何时停下。