From 16f96420c5b3b565b37970136c7974edc5e9b500 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 15 Sep 2026 15:37:29 +0800 Subject: [PATCH 1/3] docs(goal): retire the Stop-hook era prose after the implementation was removed The first-generation Goal implementation was deleted in #11900, but a design doc, an i18n key, two comments and a setting description still describe it: the input-control design for a Goal loop that ran as a blocking Stop hook, the `/goal` description string nothing reads any more, an ACP control comment naming an in-memory store and a type that no longer exist, a "judge's verdict" field comment, and the per-turn tool-call cap saying Goal iterations are Stop-hook continuations. --- docs/design/goal-loop-input-control.md | 48 ------------------- docs/users/configuration/settings.md | 2 +- packages/acp-bridge/src/bridgeTypes.ts | 2 +- packages/acp-bridge/src/status.ts | 8 ++-- packages/cli/src/config/settingsSchema.ts | 2 +- packages/cli/src/i18n/locales/ca.js | 2 - packages/cli/src/i18n/locales/en.js | 2 - packages/cli/src/i18n/locales/zh-TW.js | 2 - packages/cli/src/i18n/locales/zh.js | 2 - .../schemas/settings.schema.json | 2 +- 10 files changed, 8 insertions(+), 64 deletions(-) delete mode 100644 docs/design/goal-loop-input-control.md diff --git a/docs/design/goal-loop-input-control.md b/docs/design/goal-loop-input-control.md deleted file mode 100644 index 1a8183c9b76..00000000000 --- a/docs/design/goal-loop-input-control.md +++ /dev/null @@ -1,48 +0,0 @@ -# Goal loop input control - -## Problem - -An active `/goal` is implemented as a blocking Stop hook. While the model is -running, the interactive queue normally defers slash commands until the stream -becomes idle. A goal loop may never reach that idle boundary, so `/goal clear` -and replacement `/goal` commands cannot take effect. - -The Stop response can also aggregate the goal hook with unrelated configured -hooks. Clearing a goal must not discard a blocking decision owned by another -hook. - -## Design - -During an active turn, the message queue drains `/goal` commands alongside -plain-text steering messages. Other slash commands remain queued for normal -idle processing. - -The CLI executes drained goal commands through the existing slash-command -processor: - -- Clear commands apply their side effect without producing model input. -- Replacement commands replace the pending goal instruction. -- When multiple goal commands are drained together, only the instruction for - the final active goal is sent. -- The surviving instruction keeps its position relative to plain-text steering - messages. -- Executed goal commands are not restored if later steering preparation is - cancelled; unexecuted plain-text messages are restored. - -Core samples the queue before Stop hooks and again after a blocking Stop hook -returns. A blocking goal output carries its goal hook ID and keeps its -continuation reason separate from ordinary hook reasons. The hook bridge also -reports whether another Stop output is blocking. If the goal changes at the -second boundary, core removes only the old goal continuation; it still follows -an independent blocking reason. Non-blocking hook outputs do not force an extra -goal iteration. - -## Verification - -- Queue tests cover active-turn goal draining and idle-boundary deferral. -- CLI stream tests cover clear, replacement, batched commands, ordering, and - restore behavior. -- Core tests cover clear and replacement during Stop-hook evaluation, including - an aggregated independent blocker. -- A local tmux session exercises clear and replacement against the built - interactive CLI. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 484b85dbf75..0f55eb3e3ad 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -197,7 +197,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau | `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `20` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `true` | | `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. In daemon/ACP sessions, which run none of the other streaming detectors, re-enabling also activates a global-duplicate tool-call halt; the always-on per-turn tool-call cap and an invalid-tool-params stagnation guard run there regardless of this setting. | `true` | -| `model.maxToolCallsPerTurn` | integer | Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as `/goal` iterations start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (`-p` / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. Always-on circuit breaker against runaway turns, independent of `model.skipLoopDetection`. Set to `0` or a negative value to disable the cap. Choosing "Disable loop detection for this session" in the loop-detected dialog also suppresses it for the rest of the session. | `100` | +| `model.maxToolCallsPerTurn` | integer | Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations and runtime-scheduled Goal turns each start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (`-p` / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. Always-on circuit breaker against runaway turns, independent of `model.skipLoopDetection`. Set to `0` or a negative value to disable the cap. Choosing "Disable loop detection for this session" in the loop-detected dialog also suppresses it for the rest of the session. | `100` | | `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` | | `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` | | `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` | diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index be4f8337f60..ed647736744 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -940,7 +940,7 @@ export interface BridgeSessionGoal { /** Canonical Goal turns completed so far. */ iterations: number; setAt: number; - /** The judge's verdict on the most recent turn, when it has run. */ + /** Why the Goal last stopped, or the verifier's most recent reason. */ lastReason?: string; } | null; } diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 5a433092d81..d17ddd73b4e 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -220,10 +220,10 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionGoalControl: 'qwen/control/session/goal/control', sessionGoalClear: 'qwen/control/session/goal/clear', /** - * Read a live session's `/goal` state. The active goal lives only in the - * child's in-memory store, so this is the sole authoritative source for the - * condition, its running turn count and the judge's last verdict. Params: - * `{ sessionId }`; result: `{ active: ActiveGoalView | null }`. + * Read a live session's `/goal` state from the child's Goal runtime. Params: + * `{ sessionId }`; result: `BridgeSessionGoal` — the runtime's + * `GoalSnapshotV2` plus `active`, a projection of it for clients that still + * read the older shape. */ sessionGoalGet: 'qwen/control/session/goal/get', sessionMcpRuntimeAdd: 'qwen/control/session/mcp/runtime-add', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 076d226a9c6..e805222e95e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1814,7 +1814,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: DEFAULT_MAX_TOOL_CALLS_PER_TURN, description: - 'Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as /goal iterations start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (-p / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.', + 'Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations and runtime-scheduled Goal turns each start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (-p / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.', showInDialog: false, }, skipStartupContext: { diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 2ac4152a038..b2eb6331dfb 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2738,8 +2738,6 @@ export default { // ============================================================================ // Ordres diverses // ============================================================================ - 'Set a goal — keep working until the condition is met': - 'Estableix un objectiu — continua treballant fins que es compleixi la condició', 'Show current process memory diagnostics': 'Mostra els diagnòstics de memòria del procés actual', 'Record a CPU profile for Chrome DevTools analysis': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 56552a4710c..6628acf5471 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2526,8 +2526,6 @@ export default { 'Choose the output style that shapes how responses are written ({{styles}}, or a custom style name).', 'It is saved but does not apply while this workspace is untrusted.': 'It is saved but does not apply while this workspace is untrusted.', - 'Set a goal — keep working until the condition is met': - 'Set a goal — keep working until the condition is met', 'Set or control a session goal': 'Set or control a session goal', 'Exited plan mode. Previous approval mode restored.': 'Exited plan mode. Previous approval mode restored.', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index e7a533403b9..79a521c00dc 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2052,8 +2052,6 @@ export default { '選擇用於塑造回答表達方式的輸出風格({{styles}},或自訂風格名稱)。', 'It is saved but does not apply while this workspace is untrusted.': '已儲存,但此工作區不受信任時不會生效。', - 'Set a goal — keep working until the condition is met': - '設定目標 — 持續工作直到條件滿足', 'Set or control a session goal': '設定或控制工作階段目標', 'Exited plan mode. Previous approval mode restored.': '已退出計劃模式,已恢復之前的審批模式。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 0fde915dd01..e0024932bcf 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2258,8 +2258,6 @@ export default { '选择用于塑造回答表达方式的输出风格({{styles}},或自定义风格名称)。', 'It is saved but does not apply while this workspace is untrusted.': '已保存,但此工作区不受信任时不会生效。', - 'Set a goal — keep working until the condition is met': - '设定目标 — 持续工作直到条件满足', 'Set or control a session goal': '设定或控制会话目标', 'Exited plan mode. Previous approval mode restored.': '已退出计划模式,已恢复之前的审批模式。', diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 0cda9865760..ba826232e72 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -811,7 +811,7 @@ "default": true }, "maxToolCallsPerTurn": { - "description": "Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations such as /goal iterations start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (-p / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.", + "description": "Per-turn tool-call cap (one model turn plus its tool-result continuations; blocking Stop-hook continuations and runtime-scheduled Goal turns each start a fresh budget). When set explicitly, this value is a hard cap: the turn halts on the next tool call after it is reached (the released behavior). When left unset (default 100), the cap is adaptive: once the turn exceeds 100 it halts only when the model keeps repeating the same call (a stuck loop); a productive turn (diverse calls) continues up to a hard backstop of 1000, which always halts. The adaptive default applies to the interactive TUI, non-interactive (-p / JSON / stream-JSON) core-client runs, and daemon/ACP sessions alike. Daemon/ACP sessions evaluate the cap once per tool batch, before execution: a batch that would cross an explicit cap or the hard backstop is skipped whole, so a turn never executes past either (it can halt up to one batch short), while the adaptive soft cap is exceeded by design, up to the backstop. They also have no in-session disable. An always-on circuit breaker against runaway turns, independent of model.skipLoopDetection. Set to 0 or a negative value to disable the cap.", "type": "integer", "default": 100 }, From d5e34b107c8466cd62214af0d3b0891cf82c10d8 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 15 Sep 2026 15:44:03 +0800 Subject: [PATCH 2/3] docs(web-shell): correct the Goal reason comment the workspace types copied from the bridge --- packages/web-shell/client/daemon/workspace/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 847347b54d3..59460347bee 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -371,7 +371,7 @@ export interface DaemonGoal { /** Judge turns completed; 0 before the first stop-hook evaluation. */ iterations: number; setAt: number; - /** The judge's verdict on the most recent turn, when it has run. */ + /** Why the Goal last stopped, or the verifier's most recent reason. */ lastReason?: string; /** * The owning session is mid-turn. For a goal session that is almost always From 24610428b89e9777b523defaee16f8211d882b38 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:41:35 +0800 Subject: [PATCH 3/3] docs(goal): address remaining review accuracy gaps --- docs/users/features/hooks.md | 2 +- packages/cli/src/ui/hooks/use-llm-stream.ts | 2 ++ packages/cli/src/ui/hooks/useMessageQueue.ts | 2 ++ packages/core/src/services/loopDetectionService.ts | 6 +++--- packages/web-shell/client/daemon/workspace/types.ts | 7 +++---- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 78103cc6f83..092281ff38d 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -30,7 +30,7 @@ Hooks are user-defined scripts or programs that are automatically executed by Qw ### Browsing your hooks -Run `/hooks` to open a read-only browser of the hooks this session runs. It moves from events to matchers to the individual hooks under a matcher; events without matcher support go straight to their hooks. A hook's details show its type, where it comes from, whether it is enabled, its command, URL or prompt, and, when they are set, its timeout, status message, HTTP `if` condition and whether it runs once or in the background. Hooks registered for the current session by skills, `/goal` or the SDK are listed with the source Session. +Run `/hooks` to open a read-only browser of the hooks this session runs. It moves from events to matchers to the individual hooks under a matcher; events without matcher support go straight to their hooks. A hook's details show its type, where it comes from, whether it is enabled, its command, URL or prompt, and, when they are set, its timeout, status message, HTTP `if` condition and whether it runs once or in the background. Hooks registered for the current session by skills or the SDK are listed with the source Session. When hooks are turned off by `disableAllHooks`, `--safe-mode` or `--bare`, the browser says so at the top. It does not change anything: to add, edit or remove a hook, edit `settings.json`. diff --git a/packages/cli/src/ui/hooks/use-llm-stream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts index 995fc4f313a..12b00bb0418 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -3290,6 +3290,8 @@ export const useLlmStream = ( const message = messages[index]; if (GOAL_COMMAND_RE.test(message)) { await handleSlashCommand(message); + // The command has already taken effect; restoring it after cancelled + // steering preparation would execute that side effect again. continue; } diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index 26e0ee5556f..2bc9ad357cd 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -254,6 +254,8 @@ export function useMessageQueue(): UseMessageQueueReturn { ( goalControlMode: GoalQueueControlMode = 'normal', ): QueuedSubmission | null => { + // Goal controls bypass ordinary input so queued text cannot prevent + // clearing or replacing the Goal that is holding that input. if (goalControlMode !== 'normal') { const goalCommandIndex = queueRef.current.findIndex(({ text }) => GOAL_COMMAND_RE.test(text), diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 6c4cd6ab74a..110491df3c5 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -145,9 +145,9 @@ const ALTERNATING_PATTERN_CYCLES = 3; // Not gated by skipLoopDetection, but configurable via the // `model.maxToolCallsPerTurn` setting (values <= 0 disable the cap) and // suppressed by an explicit in-session disable. A "turn" for cap purposes -// is one model turn plus its ToolResult continuations; a blocking Stop-hook -// continuation (e.g. a /goal iteration) starts a fresh budget via -// loopDetector.reset() in client.ts, so the cap bounds each iteration +// is one model turn plus its ToolResult continuations. Blocking Stop-hook +// continuations and runtime-scheduled Goal turns each start a fresh budget via +// loopDetector.reset() in client.ts, so the cap bounds each turn // rather than an entire goal chain. // // This default is a *soft* cap: once the turn exceeds it, the cap only halts diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 59460347bee..2e60259c83e 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -358,9 +358,8 @@ export interface DaemonAddWorkspaceResult { } /** - * One session's active `/goal`. Goals live in the owning session's memory and - * only advance while it is resident, so this list covers exactly the goals that - * are actually running — a session that isn't loaded contributes nothing. + * One resident session's incomplete `/goal`, read from its persisted Goal + * runtime. Paused and blocked goals are included; unloaded sessions are not. */ export interface DaemonGoal { /** The session driving this goal; its transcript is the goal's history. */ @@ -368,7 +367,7 @@ export interface DaemonGoal { /** The session's label, or null — the UI falls back to the id. */ displayName: string | null; condition: string; - /** Judge turns completed; 0 before the first stop-hook evaluation. */ + /** Canonical Goal turns completed so far. */ iterations: number; setAt: number; /** Why the Goal last stopped, or the verifier's most recent reason. */