Qwen Code 目标循环输入控制:活跃 /goal 期间的命令排空与 Stop Hook 边界采样设计解析
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
本文围绕 Qwen Code(开源终端 AI 编码代理)中/goal指令在活跃目标循环(Goal Loop)期间的输入控制机制展开,剖析交互式消息队列如何在模型持续运行(可能永不进入 idle 状态)时仍能及时执行/goal clear与替换型/goal命令,以及 Core 层如何通过 Stop Hook 边界采样避免误丢弃其他 Hook 的阻塞决策。读完本文,你将理解该机制的完整数据流:从队列双车道排空、CLI 侧命令执行,到 Core 侧前后两次队列采样与 hookId 防串扰校验,并掌握对应的源码位置与测试验证方式。
问题背景:阻塞式 Stop Hook 与永不来临的 idle 边界
在 Qwen Code 中,一个处于激活状态的/goal被实现为阻塞式 Stop Hook(blocking Stop hook)。目标循环的运作方式是:每次模型尝试停止时,goal hook 都会调用一个快速的 judge 模型判定目标条件是否达成;若判定为“未达成”,hook 返回{ decision: 'block', reason: <固定续跑指令> },Core 会把该 reason 作为新的用户消息回灌给模型,从而开启下一轮迭代,直到 judge 判定“达成”或达到迭代上限。
问题在于交互式消息队列的默认行为:模型运行期间,普通斜杠命令会被推迟到流进入 idle 边界后再处理。而 goal 循环由阻塞 hook 驱动,可能一轮接一轮地持续输出,永远不经过 idle 边界。其直接后果是:
/goal clear无法生效——用户想取消目标,但命令一直被压在队列里;- 替换型
/goal <新条件>同样无法生效——用户想换目标,旧循环却停不下来; - 此外,Stop 响应可能把 goal hook 与其他用户配置的 hook 聚合在一起,清除 goal 时绝不能连带丢弃另一个 hook 拥有的阻塞决策。
这正是 docs/design/goal-loop-input-control.md 要解决的两类问题:命令可达性(goal 循环期间/goal指令必须能插队执行)与决策隔离(清除旧 goal 不得误伤无关的阻塞原因)。
/goal命令模型:set / edit / pause / resume / clear
在展开输入控制之前,先明确/goal命令的语义边界。CLI 侧的解析器位于 packages/cli/src/ui/commands/goalCommand.ts,其parseGoalCommand支持以下形态:
| 输入 | 解析结果 | 说明 |
|---|---|---|
/goal(无参数) | status | 仅查询当前目标状态 |
/goal <目标文本>//goal set <目标文本> | set | 无目标时创建;有目标时以replace动作替换 |
/goal edit <目标文本> | edit | 修改当前目标的目标文本 |
/goal pause | pause | 暂停目标循环 |
/goal resume | resume | 恢复目标循环 |
/goal clear(含stop/off/reset/none/cancel) | clear | 清除当前目标 |
其中CLEAR_KEYWORDS集合(clear、stop、off、reset、none、cancel)在源码中与 Web Shell 侧的GOAL_CLEAR_KEYWORDS保持镜像同步(见 packages/cli/src/ui/commands/goalCommand.ts 的注释)。此外:
set/edit/resume属于“会增加或恢复自主工作”的操作,被限定在可信工作区内可用(源码中通过config.isTrustedFolder()校验,不满足时提示先执行/trust);- 无参数或仅有关键字时
set会因缺少 objective 报错; - 执行层面通过
GoalRuntime.dispatch()提交create/replace/edit/pause/clear动作,并携带expectedGoalId/expectedRevision做乐观并发控制。
双车道消息队列:Goal Turn 期间只放行/goal命令
输入控制的核心实现是 CLI 层的消息队列,位于 packages/cli/src/ui/hooks/useMessageQueue.ts。队列用正则GOAL_COMMAND_RE = /^\/goal(?:\s|$)/识别 goal 指令,并通过drainQueue(includeDeferred, goalTurnActive)实现按场景切换的排空策略:
- 普通回合(goal turn 未激活):
drainQueue只排空普通文本 steering 消息,所有斜杠命令(包括/goal)都留在队列中,等待常规 idle 边界处理——避免在模型正常工作时插队; - Goal turn 激活时:排空条件翻转——只排空匹配
GOAL_COMMAND_RE的/goal命令,普通文本与其余斜杠命令继续排队。这样用户在目标循环进行中键入的/goal clear、/goal pause、/goal edit ...都能被及时取走执行。
队列测试 packages/cli/src/ui/hooks/useMessageQueue.test.ts 用三组用例精确锁定了这一行为:
keeps Goal creation queued until an ordinary turn reaches idle:普通回合中,/goal ship the release与/model一起留在队列,只有steer now被排空;drains only Goal controls while a Goal turn is running:goal turn 激活时,/goal pause、/goal edit revised objective、/goal clear全部被排空,而plain user text与/model继续排队;leaves goal commands queued at the idle boundary:即使传了includeDeferred=true,空闲边界下/goal clear也不会被排空(它走的是常规斜杠命令处理通道)。
除drainQueue外,队列还提供popNextSubmission(goalControlMode),支持'normal' | 'priority' | 'only'三种抽取模式:'priority'模式下会优先抽出队列中的/goal命令,'only'模式则只允许 goal 续跑、屏蔽一切用户提交。goal 续跑本身(QueuedGoalTurn)存放在独立的隐藏通道(goalQueueRef)中,不进入公开的messageQueue,只有用户预处理成功后才通过claimGoalTurn/claimDirectUserAdmission被取出(相关测试见 useMessageQueue.test.ts)。
端到端执行链:从排空到斜杠命令处理器
排空出来的消息在 CLI 流处理钩子 packages/cli/src/ui/hooks/use-llm-stream.ts 中被进一步处理:
drainSteerAtBoundary(signal)调用midTurnDrainRef.current?.(false, Boolean(activeGoalAdmissionRef.current)),即按“当前是否有活跃 goal 准入(admission)”决定排空策略;- 排空结果交给
resolveDrainedSteerMessages,内部逐条走resolveSteeredMessages; - 在
resolveSteeredMessages中,凡匹配GOAL_COMMAND_RE的消息直接调用handleSlashCommand(message)执行,其余普通文本则收集进restoreMessages用于失败回滚; - 最终封装成
SteerInput(含accept/restore两个结算钩子)作为getSteerInput回调传给 Core 的client.ts。
这一设计带来了文档所述的几条关键语义:
- Clear 命令只应用副作用,不产生模型输入:
/goal clear直接走斜杠命令处理器完成GoalRuntime.dispatch({ action: 'clear' }),其副作用是移除活跃目标并卸载 goal Stop hook,不会作为用户消息进入模型上下文; - 替换命令替换待处理的 goal 指令:
/goal <新条件>在有活跃目标时以replace动作执行,旧 hook 被注销、新 hook 注册(见 goalHook.ts 中unregisterGoalHook先行、再addFunctionHook的逻辑); - 批量排空只保留最终活跃目标的指令:当多个 goal 命令被一起排空时(例如
pause后紧跟clear),只有最后一个生效目标的指令会被真正送入目标运行时,中间态不会重复产生模型输入; - 幸存指令保持相对位置:goal 命令在队列中的相对顺序相对于普通文本 steering 消息保持不变,保证“先输入的目标调整”不会被后输入的目标覆盖;
- 已执行的 goal 命令不恢复,未执行的纯文本才恢复:若后续 steering 准备被取消(如用户按 ESC 或 admission 失败),已被执行的
/goal命令不会被放回队列(其副作用已生效,无法也无须回滚),而未执行的纯文本消息则通过restoreMessages恢复到队列头部(测试restores interrupted steer messages ahead of newer queued input验证了恢复时排在新输入之前)。
Core 侧双边界采样:Stop Hook 前后各取一次队列
队列排空结果最终在 Core 层消费。核心逻辑位于 packages/core/src/core/client.ts 的sendMessageStream流程中,围绕 Stop Hook 评估设置了两个取样边界:
- 第一个边界:Stop hooks 执行前。进入 hook 评估前调用
takeSteerInput(hookTurnBudget)(见 client.ts),此刻取走的是用户在当前回合结束前键入的 goal 调整指令; - 第二个边界:阻塞型 Stop hook 返回后。当
stopOutput.isBlockingDecision() || stopOutput.shouldStopExecution()成立时(见 client.ts),Core 会用stopOutput.getEffectiveReason()构造续跑请求,并在发送前再次调用takeSteerInput(hookTurnBudget)(client.ts),把阻塞返回之后、续跑开始之前新到达的 goal 指令也纳入本次边界。
每个SteerInput通过settleSteerInput(steerInput, pushCountBefore)结算:以用户内容推送计数器为判据,确认续跑请求确实把内容推入了对话历史则accept(),否则restore()回滚(client.ts)。这保证“取样后被取消”不会丢消息。
双边界采样的目的正是文档中的核心论断:若在第二个边界处目标发生了变化(如用户清除了旧目标或替换了新目标),Core 只移除旧的 goal continuation,但仍会遵循独立的阻塞原因继续续跑——也就是说,/goal clear只会撤销 goal 自己注册的续跑,不会吞掉其他配置 hook 返回的阻塞决策。阻塞型 Stop hook 的迭代同时受stopHookBlockingCap上限约束,每次续跑都会重置 per-turn 的循环检测与工具调用预算,防止 hook 链累积超限(client.ts)。
hookId 防串扰:qwenGoalHookId 与独立阻塞者
“清除 goal 不得丢弃其他 hook 的阻塞决策”这一要求在实现上分为两层:
第一层:goal hook 输出携带独立标识。goal hook 在返回阻塞决策时,会通过hookSpecificOutput写入GOAL_HOOK_ID_OUTPUT_KEY = 'qwenGoalHookId'(见 packages/core/src/goals/goalHook.ts):
return { decision: 'block', reason: continuationReasonForGoal(condition), hookSpecificOutput: { [GOAL_HOOK_ID_OUTPUT_KEY]: evaluated.hookId, }, };同时getStopHookContinuationReason会区分“是否携带 goal 输出”来决定 continuation reason 的拼接方式,确保 goal 的续跑文案(固定文本:Continue working toward the active /goal condition...+ 目标条件)与普通 hook 的 reason 彼此独立。这里刻意不使用 judge 的自由文本作为续跑指令,因为续跑 reason 会被回灌给模型,必须来自原始目标而非不可信的转录内容(源码注释见 goalHook.ts)。
第二层:hookId 身份校验防止陈旧回调误清除。goal hook 回调通过isCurrentGoal检查当前活跃目标的hookId是否等于本次调用期望的 hookId。goalHook.test.ts中有两个针对性用例:
does not clear a replacement goal when the old judge call resolves later:旧目标(hookIdold-hook)的 judge 调用在飞行中时,用户替换成新目标(hookIdnew-hook),旧 judge 返回met后不会清除新目标;- 同条件替换场景下同样不误清除:即使新旧条件文本相同,仅凭 hookId 不同也能区分。
至于“另一个 Stop 输出是否阻塞”的判断,则在 Core 层通过聚合后的stopOutput.isBlockingDecision() / shouldStopExecution()统一完成,聚合逻辑见 packages/core/src/hooks/hookAggregator.test.ts(例如should preserve PostToolBatch stop decisions across multiple hooks验证多个 hook 的阻塞决策在聚合后仍然保留、getEffectiveReason取第一个阻塞者的 reason)。goal 的阻塞与非阻塞输出语义因此与普通 hook 完全一致:非阻塞的 hook 输出不会强制产生额外的 goal 迭代,只有decision: 'block'才会驱动续跑。
安全阀:迭代上限、judge 超时与中断清理
与输入控制配套的还有三条兜底路径(均在 goalHook.ts 中):
MAX_GOAL_ITERATIONS = 50:judge 连续判定“未达成”超过 50 次后强制清除目标并提示Re-set with /goal <condition>,防止不可达目标无限烧 token;GOAL_JUDGE_TIMEOUT_MS = 25_000:单次 judge 调用带超时,超时后暂停目标循环(目标保持激活)并中止底层 API 请求,避免每个超时泄漏一个后台请求(源码中专门用AbortSignal.any把超时信号与 hook 上下文信号合并);- 中断清理:goal 回合被 ESC 打断或失败时,Core 通过
releaseGoalPermitOnInterruptedExit以GOAL_PAUSE_REASON_USER_INTERRUPT等原因暂停目标并结算未决事件(client.ts)。
集成测试 packages/core/src/goals/goalLoop.integration.test.ts 用真实的HookSystem验证了完整闭环:注册 goal hook 后 Stop 事件被拦截,judge 判定not_met时返回{ decision: 'block', reason: <受控文案> },判定met时返回{ continue: true }、清除 store 并通知 terminal observer,随后 hook 被移除(后续 Stop 不再触发 judge)。
验证体系与手工复现
按 docs/design/goal-loop-input-control.md 的 Verification 章节,该机制的验证分为四层:
- 队列测试:覆盖活跃 turn 期间的 goal 命令排空与 idle 边界延迟(useMessageQueue.test.ts);
- CLI 流测试:覆盖 clear、替换、批量命令、排序与 restore 行为(
use-llm-stream.test.tsx与goalCommand.test.ts中对parseGoalCommand的逐项参数化断言); - Core 测试:覆盖 Stop hook 评估期间的 clear 与替换,包括“聚合的独立阻塞者”场景(goalHook.test.ts、
client.test.ts中 goal 相关用例); - 手工复现:在本地 tmux 会话中对构建好的交互式 CLI 实际操作
/goal clear与替换型/goal,确认目标循环运行中指令即时生效。
源码导航
- 设计文档:docs/design/goal-loop-input-control.md
- 双车道队列与排空策略:packages/cli/src/ui/hooks/useMessageQueue.ts
- CLI 流处理与 steering 结算:packages/cli/src/ui/hooks/use-llm-stream.ts
- Core 双边界采样与阻塞续跑:packages/core/src/core/client.ts
- Goal Stop hook 注册与 hookId 校验:packages/core/src/goals/goalHook.ts
/goal命令解析与执行:packages/cli/src/ui/commands/goalCommand.ts- 集成验证:packages/core/src/goals/goalLoop.integration.test.ts
小结
/goal目标循环的输入控制本质上是两条车道、两道边界的协作:CLI 队列在 goal turn 激活时只放行/goal指令,其余输入按正常 idle 语义处理;Core 在 Stop hook 评估前后各取样一次队列,让目标调整能穿越“永不 idle”的循环边界及时生效。而qwenGoalHookId身份校验与聚合 hook 的阻塞判定,保证了清理旧目标永远不会连带丢弃其他 hook 的独立决策。对使用 Qwen Code 的用户而言,这意味着在长时自主目标执行过程中,随时可以键入/goal clear或替换目标,且不会破坏用户自定义 Stop hook 的拦截语义。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考