
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 Hookblocking 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 pausepause暂停目标循环/goal resumeresume恢复目标循环/goal clear含stop/off/reset/none/cancelclear清除当前目标其中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 runninggoal turn 激活时/goal pause、/goal edit revised objective、/goal clear全部被排空而plain user text与/model继续排队leaves goal commands queued at the idle boundary即使传了includeDeferredtrue空闲边界下/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.tsCore 会用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.tsreturn { 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 50judge 连续判定“未达成”超过 50 次后强制清除目标并提示Re-set with /goal condition防止不可达目标无限烧 tokenGOAL_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.tsCLI 流测试覆盖 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.tsCLI 流处理与 steering 结算packages/cli/src/ui/hooks/use-llm-stream.tsCore 双边界采样与阻塞续跑packages/core/src/core/client.tsGoal 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),仅供参考