Skip to content

feat(acp): Protect against repeated tool execution failures - #8469

Merged
doudouOUC merged 10 commits into
QwenLM:mainfrom
doudouOUC:feat/acp-repeated-tool-call-protection-v2
Aug 9, 2026
Merged

feat(acp): Protect against repeated tool execution failures#8469
doudouOUC merged 10 commits into
QwenLM:mainfrom
doudouOUC:feat/acp-repeated-tool-call-protection-v2

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds a conservative, prompt-local guard for repeated typed tool execution failures on the selected live interactive ACP foreground Session. It consumes the frozen execution outcome contract established by #8176 and #8180, counts only terminal execution failures with a resolved tool identity and structured execution error type, and requires eight matching failures across at least two complete model tool batches. The first release keeps one active failure candidate: successes from other resolved tools neither advance nor reset it, while a success from the candidate tool clears that tool before the remaining unique failure key is selected.

The guard supports operator-controlled off, shadow, warn, and enforce modes, with shadow as the default. Warn mode injects one fixed corrective reminder per candidate streak; enforce mode stops only after a later complete matching batch has settled and the candidate tool has not succeeded in between, preserves every current-batch function response in history, suspends automatic Todo continuation, and requires new user input before continuing. Channel-driven prompts are explicitly marked by both channel bridges and forced to off; this client-supplied marker is a routing hint, not a trust or authorization boundary. Stop-hook/Todo automatic continuations, cron, notifications, and background routes also remain off.

The default shadow mode asks the existing mid-turn drain extension for reliable queued-prompt state. Older or third-party ACP hosts that cannot provide that contract emit an unreliable_input diagnostic, disable enforcement for the prompt, and remain fail-open. The rollout mode is documented as operator policy and cannot be injected by project .env, project .qwen/.env, or workspace settings.env; invalid non-empty values warn and fall back to shadow.

The change adds low-cardinality transition metrics and data-minimized OpenTelemetry diagnostics for staged rollout plus a distinct terminal loop reason. Diagnostic events reuse the raw ACP prompt ID already emitted by tool-call telemetry so authorized rollout analysis can join transitions to settled batches without a second identifier space, while metrics omit prompt identity, candidate ordinal, and execution error type. Tool arguments, outputs, paths, raw error messages, MCP server names, user IDs, and the private failure key remain excluded from guard-specific fields; the terminal loop event explicitly bypasses QwenLogger/RUM while retaining standard OpenTelemetry correlation. Deployment cohort and service version come from the OpenTelemetry Resource; each rollout environment must configure a stable dimension such as deployment.environment, while the SDK always supplies service.version.

Why it's needed

Repeated execution failures can keep an ACP foreground turn consuming tool calls and model rounds after the model has stopped making useful progress. The existing provider call-ID deduper, invalid-parameter guard, and total tool-call cap address different failure classes and either do not recognize semantic execution repetition or react too broadly.

Using #8180's final execution status and frozen execution error classification lets this guard exclude user cancellation, permission rejection, pre-execution validation, unknown outcomes, skipped siblings, and post-execution processing failures. The high threshold, two-batch requirement, reminder-before-stop sequence, prompt-local lifecycle, input and cancellation precedence, and shadow-first rollout keep false-positive risk bounded while providing evidence for independent internal and public-cloud rollout decisions.

Reviewer Test Plan

How to verify

Run cd packages/cli && npx vitest run src/acp-integration/session/repeated-tool-failure-guard.test.ts src/acp-integration/session/Session.test.ts src/config/environment.test.ts src/config/shared-env-keys.test.ts. Confirm that shadow changes neither model continuation nor messages; failures remain detectable across successful calls from other tools, while same-tool success clears the candidate; warn injects one reminder for the candidate; enforce settles and preserves the post-reminder matching batch before stopping; cancellation and queued input win; unsupported hosts never enforce; channel-marked prompts remain off; the legacy drain payload remains unchanged in off mode; and project-controlled environment sources cannot select a rollout mode.

Run cd packages/channels/base && npx vitest run src/AcpBridge.test.ts src/DaemonChannelBridge.test.ts. Confirm that both channel prompt paths attach the private route marker without changing text, image, cancellation, or response collection behavior.

Run cd packages/core && npx vitest run src/telemetry/loggers.test.ts src/telemetry/metrics.test.ts. Confirm that metrics contain only bounded transition attributes, structured diagnostics retain standard prompt correlation while excluding sensitive tool fields, metric and log sink failures do not change control flow, and the terminal repeated-execution loop event is explicitly excluded from QwenLogger/RUM.

Run npm run build, npm run typecheck, and npm run lint. The expected result is a clean build, typecheck, and lint.

For manual ACP validation, use a deterministic typed failing tool and record the local plan and results under .qwen/e2e-tests/. Verify concurrent batch settlement, permission cancellation, reconnect/restart and history replay, a fresh prompt after stop, unsupported-host behavior, channel exclusion, and shadow-mode non-interference. The local plan and manual fixture run are still pending and must be completed before merge. Ready for review means the implementation is ready for maintainer review; it does not mean merge-ready or claim completion of these pre-merge checks. After merge and deployment in shadow, collect separate seven-day internal and public-cloud baselines before beginning the staged warn/enforce rollout.

Evidence (Before & After)

Before: a model can continue issuing fresh tool-call IDs for the same typed execution failure until another guard, the configured total cap, or user intervention ends the turn. Channel and older-host boundaries were not represented in the original guard rollout plan, and the guard metric included the execution error type as a label.

After: automated ACP coverage shows shadow mode observes without changing the turn, warn mode injects one fixed reminder for the candidate and continues, enforce mode settles and preserves the ninth matching failure, emits one fixed stop message, records repeated_tool_execution_failure, and opens no fourth model stream. Enforce-configured channel prompts and unsupported hosts continue without a guard stop. The final local run passed 587 CLI tests, 91 channel tests, and 129 Core telemetry tests; full build, typecheck, lint, formatting, and diff checks also passed.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS local workspace, Node.js 26.0.0, npm workspace build, sandbox disabled for local test processes.

Risk & Scope

  • Main risk or tradeoff: A false positive in enforce mode could end an otherwise recoverable automatic continuation. Mitigations are typed execution-only eligibility, a fixed eight-failure/two-batch threshold, one corrective reminder before stopping, same-tool success clearing with unrelated successes ignored, reset on multiple remaining failure keys, full-batch settlement, input and cancellation precedence, fail-open handling for unreliable contracts, interactive-route scoping, and a default shadow mode.
  • Pending validation and rollout gates: The local E2E plan and deterministic manual ACP fixture run are not yet complete and remain pre-merge gates. Separate seven-day internal and public-cloud shadow baselines, followed by staged warn/enforce cohorts, are post-merge rollout gates. Windows/Linux local runs are not completed. TUI, non-ACP, channel-driven, Stop-hook/Todo automatic continuation, cron, notification, background, subagent-internal, durable cross-restart, and global rollout orchestration remain out of scope.
  • Breaking changes / migration notes: No public protocol or user-setting migration is required. Shadow, warn, and enforce add an optional queued-prompt watch field to the existing Qwen ACP drain extension; incompatible hosts fail open and never enforce. Operators may set QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD to off, shadow, warn, or enforce; missing or invalid values resolve to shadow, and project-controlled environment sources cannot set it.

Linked Issues

Related to #8176 and #8180.

中文说明

这个 PR 做了什么

这个 PR 为选中的实时交互式 ACP 前台 Session 增加了一个保守的、prompt 内局部生效的重复工具执行失败保护。它消费 #8176#8180 建立的冻结执行结果契约,只统计具有已解析工具身份和结构化执行错误类型的终态执行失败,并要求至少跨两个完整模型工具批次累计八次相同失败。首版只保留一个活跃失败候选:其他已解析工具的成功既不推进也不清零该候选;候选工具自身成功时,会先清除该工具的失败,再选择剩余唯一失败键。

保护支持由运维控制的 offshadowwarnenforce 模式,默认是 shadow。Warn 模式对每个候选连续失败只注入一次固定纠偏提醒;Enforce 模式仅在后续完整匹配批次全部结束且候选工具期间没有成功后停止,保留当前批次的全部函数响应到历史,暂停自动 Todo 续跑,并要求新的用户输入后才能继续。两个 channel bridge 都会显式标记 channel 驱动的 prompt,Session 会把它们强制设为 off;这个客户端提供的标记只是路由 hint,不是信任或授权边界。Stop-hook/Todo 自动续跑、cron、通知和后台路由也保持关闭。

默认 Shadow 模式会通过现有的 mid-turn drain 扩展请求可靠的排队 prompt 状态。无法提供这个契约的旧版或第三方 ACP host 会记录一次 unreliable_input 诊断、对当前 prompt 禁用强制停止,并保持 fail-open。发布模式已作为运维策略写入文档,项目 .env、项目 .qwen/.env 和工作区 settings.env 都不能注入该值;非空无效值会产生告警并回退到 Shadow。

本次变更增加了用于分阶段发布的低基数状态转换指标和数据最小化 OpenTelemetry 诊断,以及独立的终态循环原因。诊断事件复用工具调用遥测已经上报的原始 ACP prompt ID,使授权发布分析可以直接把状态转换关联到已结算批次;指标不包含 prompt 身份、候选序号或执行错误类型。保护专属字段仍不包含工具参数、输出、路径、原始错误消息、MCP 服务名、用户 ID 或私有失败键;终态循环事件会显式绕过 QwenLogger/RUM,同时保留标准 OpenTelemetry 关联字段。部署分组和服务版本来自 OpenTelemetry Resource;每个发布环境必须配置稳定的 deployment.environment 等维度,SDK 则始终提供 service.version

为什么需要

当模型已经不再取得有效进展时,重复执行失败仍可能让 ACP 前台轮次继续消耗工具调用和模型轮次。现有的 provider call-ID 去重、无效参数保护和总工具调用上限分别解决不同的失败类型,要么无法识别语义层的重复执行,要么粒度过宽。

使用 #8180 的最终执行状态和冻结执行错误分类后,本保护可以排除用户取消、权限拒绝、执行前校验、未知结果、被跳过的 sibling,以及执行后处理失败。较高阈值、双批次要求、先提醒后停止、prompt 局部生命周期、输入与取消优先级和 Shadow 优先发布共同限制误判风险,并为集团内和公有云分别作出发布决策提供证据。

Reviewer 测试计划

如何验证

运行 cd packages/cli && npx vitest run src/acp-integration/session/repeated-tool-failure-guard.test.ts src/acp-integration/session/Session.test.ts src/config/environment.test.ts src/config/shared-env-keys.test.ts。确认 Shadow 不改变模型续跑或消息;其他工具成功时失败仍可累计,而同工具成功会清除候选;Warn 对当前候选注入一次提醒;Enforce 等待提醒后的匹配批次全部结束并保留结果后才停止;取消和排队输入优先;不支持扩展的 host 永不强制停止;标记为 channel 的 prompt 保持关闭;Off 模式保留旧 drain 请求结构;项目控制的环境来源不能选择发布模式。

运行 cd packages/channels/base && npx vitest run src/AcpBridge.test.ts src/DaemonChannelBridge.test.ts。确认两个 channel prompt 路径都会附带私有路由标记,且文本、图片、取消和响应收集行为不变。

运行 cd packages/core && npx vitest run src/telemetry/loggers.test.ts src/telemetry/metrics.test.ts。确认指标只包含有界的状态转换属性,结构化诊断保留标准 prompt 关联但不包含敏感工具字段,指标及日志 sink 失败不会改变控制流,并且重复执行失败的终态循环事件会显式绕过 QwenLogger/RUM。

运行 npm run buildnpm run typechecknpm run lint。预期结果是构建、类型检查和 lint 全部干净通过。

手工 ACP 验证请使用确定性的结构化失败工具,并将本地计划和结果记录在 .qwen/e2e-tests/ 下。验证并发批次结算、权限取消、重连/重启和历史回放、停止后的新 prompt、不支持 host 的行为、channel 排除,以及 Shadow 模式不改变控制流。本地计划和手工 fixture 运行仍待完成,并且是合并前置条件。Ready for review 只表示实现可供 maintainer 评审,不代表已经 merge-ready,也不代表这些合并前检查已经完成。合并并以 Shadow 模式部署后,需分别收集集团内和公有云七天基线,之后才能开始分阶段 Warn/Enforce 发布。

证据(变更前后)

变更前:模型可以持续使用新的 tool-call ID 发起同一种结构化执行失败,直到其他保护、配置的总上限或用户干预结束轮次。原始保护发布方案没有体现 channel 和旧 host 边界,保护指标也把执行错误类型作为标签。

变更后:ACP 自动化覆盖证明 Shadow 模式只观察而不改变轮次;Warn 模式会对当前候选注入一次固定提醒并继续;Enforce 模式会等待并保留第九次相同失败,发出一次固定停止消息,记录 repeated_tool_execution_failure,且不会开启第四个模型流。配置为 Enforce 的 channel prompt 和不支持扩展的 host 都会继续运行而不会被保护强制停止。最终本地运行通过了 587 个 CLI 测试、91 个 channel 测试和 129 个 Core 遥测测试;完整 build、typecheck、lint、格式和 diff 检查也全部通过。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS 本地工作区,Node.js 26.0.0,npm workspace 构建,本地测试进程未启用 sandbox。

风险与范围

  • 主要风险或取舍:Enforce 模式的误判可能结束一个本来可以恢复的自动续跑。缓解措施包括只接受结构化执行失败、固定八次/两个批次阈值、停止前一次纠偏提醒、同工具成功清除候选而其他工具成功忽略、多个剩余失败键时重置、完整批次结算、输入和取消优先、不可靠契约时 fail-open、交互路由范围约束,以及默认 Shadow 模式。
  • 待验证与发布门禁:本地 E2E 计划和确定性手工 ACP fixture 尚未完成,仍是合并前置条件。集团内和公有云各七天 Shadow 基线及后续分阶段 Warn/Enforce 队列属于合并后的发布门禁。Windows/Linux 本地运行尚未完成。TUI、非 ACP、channel 驱动、Stop-hook/Todo 自动续跑、cron、通知、后台、subagent 内部、跨重启持久化和全局发布编排仍在范围外。
  • 破坏性变更/迁移说明:不需要公开协议或用户设置迁移。Shadow、Warn 和 Enforce 会在现有 Qwen ACP drain 扩展中增加一个可选的排队 prompt 观察字段;不兼容的 host 会 fail-open 且永不强制停止。运维可以把 QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD 设置为 offshadowwarnenforce;缺失或无效值会解析为 shadow,项目控制的环境来源不能设置它。

关联问题

关联 #8176#8180

doudouOUC and others added 2 commits August 3, 2026 23:19
Add a conservative prompt-local guard for repeated typed ACP tool execution failures, with shadow/warn/enforce rollout modes, privacy-safe telemetry, and coverage for the final execution outcome contract.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Review: repeated tool execution failure guard

Overview

Adds a prompt-local, in-memory guard that detects the same (policyToolName, executionErrorType) failing repeatedly on the ACP foreground prompt loop, injects one corrective reminder, and (in enforce) ends the turn after the next matching batch.

Structure is good: repeated-tool-failure-guard.ts is a pure reducer with an explicit phase machine (idle → tracking → warned → latched) and a closed set of reset reasons, wired into Session.ts at exactly one decision point (#buildNextMessageAfterToolRun). Eligibility rides on the frozen executionStatus / executionErrorType contract from #8176/#8180 rather than on string matching. Fail-open handling, cancellation precedence, and the privacy-restricted telemetry path are all real and tested. I checked that createDaemonToolLoopState() is called with the default 'off' at #runStopContinuation, #executeCronPromptInner, and #executeBackgroundNotificationPromptInner, so the Stop-hook/Todo/cron/notification loops are genuinely excluded as the design doc requires — good.

The findings below are mostly about scope, operability, and rollout evidence quality, not about the reducer's logic.


Blocking / should fix before leaving Draft

1. enforce will stop unattended channel-driven turns, where the stop message is meaningless

packages/channels/base/src/AcpBridge.ts drives the same foreground loop (conn.prompt(...) at AcpBridge.ts:270) and its ext-method handler returns a hardcoded { messages: [], hasQueuedPrompt: false } (AcpBridge.ts:563). Consequences:

  • reliable is true, so nothing downgrades enforcement on that route;
  • hasQueuedPrompt is never true, so the queued-prompt safety valve can never fire there;
  • a GitHub/GitLab channel turn can therefore be halted in enforce with "New user input is required to continue." — there is no user, and the channel loop has no way to supply one.

The design doc says cron/notification/background routes stay off, and they do. Channel-driven session/prompt turns were not considered and are not excluded. Please gate on an explicit foreground/interactive signal (or plumb a route discriminator through DaemonToolLoopState) rather than "anything that reaches Session.prompt".

2. route is hardcoded 'acp_foreground' for every emission

recordRepeatedToolFailureDecision (Session.ts:~1050) always sets route: 'acp_foreground', and the metric/type unions only admit that one value. Today the guard only runs on Session.prompt, but per (1) that includes channel routes. Since this PR exists to produce a route-segmented rollout baseline, and the design doc explicitly lists "route: ACP foreground or other", hardcoding it makes the baseline unsegmentable exactly where it matters. Widen the union and pass the real route.

3. The guard is completely inert for ACP hosts that don't implement craft/drainMidTurnQueue

If the host doesn't implement the ext method, midTurnDrainUnavailable latches and #drainMidTurnInput returns reliable: false on every batch → reset(state, 'unreliable_input', true) every time → the streak never accumulates past one batch, in any mode. That method is a Qwen-specific extension (only acp-bridge, desktop/qwen-agent, channels/base, and serve implement it), so third-party ACP hosts get zero coverage and zero shadow signal.

This is a correct fail-safe, but a seven-day shadow baseline that reads "no candidates" for those hosts is indistinguishable from "no repeated failures happen". Please state this dependency in the PR body and design doc, and consider counting unreliable_input resets separately so the rollout can tell "guard inert" apart from "guard clean".

4. QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD has no operator documentation

The repo documents operator env vars in docs/users/configuration/settings.md (env-var table starting ~L715). A design doc is not operator documentation. Please add the variable, its four accepted tokens, and the default.

5. Unrecognized values silently resolve to shadow, not off

switch (value?.trim().toLowerCase()) {
  case 'off': case 'shadow': case 'warn': case 'enforce': return ...;
  default: return 'shadow';
}

QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD=false / 0 / disabled / no — all natural attempts to disable — silently enable shadow, with no warning logged. For a flag whose stated purpose is operator control during a staged rollout, a typo should be visible. Log a warning on an unrecognized non-empty value (fall back to shadow if you like, but say so).


Medium

6. shadow latching destroys the evidence the shadow rollout is for

Once a would_stop sets phase: 'latched', reduceRepeatedToolFailureGuard returns { kind: 'none' } forever, and recordRepeatedToolFailureDecision early-returns on none — so nothing at all is emitted for the rest of the prompt. Mirroring enforce semantics is defensible, but the question shadow must answer is "would that stop have been a false positive?", i.e. what the model did after the would-stop point. Right now that's unobservable. Suggest emitting a post-latch counter (e.g. decision: 'latched_suppressed' with the batch bucket), or at minimum recording whether the prompt subsequently made progress.

7. logLoopDetected drops all common attributes for one loop type, with no comment

...(privacyRestricted ? {} : getCommonAttributes(config)),

The stated goal is excluding session-scoped RUM and session.id, but this also drops installation/surface/version attributes, leaving one event type structurally unlike every other log record — and harder to correlate with the rollout it is meant to inform. Prefer omitting the specific identifying keys, or keep the stop signal entirely on the dedicated logRepeatedToolFailureGuard path. Either way, this branch needs a comment explaining why; a future reader will not guess it.

8. Metric cardinality vs. the "low-cardinality" claim

qwen-code.repeated_tool_failure_guard.count carries execution_error_type: ToolErrorType (a large enum) alongside 10 other attributes — mode × phase_before × phase_after × decision × 2 buckets × reset_reason × tool_type. The state machine constrains this heavily in practice, but the theoretical series count is six figures. Consider keeping execution_error_type on the log record only, or bucketing it, and let the metric stay on the transition shape.

9. More than one reminder per prompt is reachable

The PR says "Warn mode injects one fixed corrective reminder". The reducer allows a streak to reset (e.g. one success) and rebuild to warned again, injecting a second reminder — nextCandidateOrdinal increments but nothing caps reminders per prompt. The Session test only covers the single-streak case. Either cap it per prompt or soften the claim.

10. Stop path returns a hardcoded 'end_turn'

Sibling terminal paths use getAbortAwareEndTurnStopReason(signal). The guard checks abortSignal.aborted before the decision, then awaits waitForPendingRewrites() and emitAgentMessage() — a cancellation arriving in that window is reported as end_turn. Small window, easy fix, keeps the paths uniform.


Minor / nits

  • watchQueuedPromptForTodoStopGuard: mode !== 'off' means the shipped default (shadow) changes the craft/drainMidTurnQueue request payload and tightens isValidMidTurnDrainResponse for every user. Contained and fail-open, but it is a default-on wire change worth calling out in the PR body.
  • resolveRepeatedToolFailureGuardMode computes value.trim().toLowerCase() twice; hoist it into a local.
  • runToolTelemetrySink(() => { sdkInitialized = isTelemetrySdkInitialized(); }) wraps a pure predicate in the error-isolating sink and defaults to false on throw. One runToolTelemetrySink around the whole emit block reads better.
  • telemetry/index.ts: export { RepeatedToolFailureGuardEvent } from './types.js'; is a standalone statement dropped after the ./metrics.js block; fold it into the existing ./types.js export.
  • Message construction is asymmetric: REPEATED_TOOL_FAILURE_REMINDER embeds its own System: prefix, while the stop message gets System: added at the call site and is emitted to the UI without it. Make both constants self-contained.
  • The stoppedByRepeatedToolFailure handling in #executeCronPromptInner / #executeBackgroundNotificationPromptInner / #runStopContinuation is unreachable (those states are created with mode off). Fine as future-proofing, but add a comment — as written it reads as if cron/background are in scope, contradicting the design doc.

Test coverage

The reducer suite is strong: table-driven over modes, reset reasons, contract violations, and downgrade paths. The telemetry tests are better than average — the serialized-payload regex assertion against session.id|user.id|policyToolName|... is exactly the right shape for a privacy claim.

Gaps, in priority order:

  1. No Session-level test for shadow — the shipped default. Assert: no reminder part injected, no stop, sendMessageStream still called a 4th time. Right now the only Session-level coverage is warn/enforce, both of which require an explicit env override.
  2. No test for off — specifically that todoStopGuardWatchQueuedPrompt is not sent. That's the regression guard for the wire-payload change, and nothing currently pins it.
  3. No Session-level test for hasQueuedPrompt: true or reliable: false blocking enforcement. Reducer-level coverage exists, but the plumbing from #drainMidTurnInput into the reducer input is untested end-to-end.
  4. The global beforeEach now forces shadow for all of Session.test.ts. Useful incidental coverage, but it means no test in that file exercises the pre-PR drain payload — see (2).

Security & performance

No injection or data-exposure concerns found. Guard text is fixed, never interpolated from tool output or error messages; telemetry excludes args, outputs, paths, raw messages, and MCP server names, and the tests assert it. Runtime cost is O(batch size) per tool batch on an already-async boundary — negligible. The only added I/O is the widened craft/drainMidTurnQueue payload, which the host already round-trips.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

doudouOUC commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Update for current head 41289f6c89: This interim summary is superseded by the current PR description and final disposition. Prompt correlation now uses the raw ACP prompt_id, not a SHA-256 digest. The PR is Ready for maintainer review, not merge-ready; the local E2E plan and manual ACP fixture remain pending pre-merge gates, while separate seven-day internal/public-cloud shadow baselines are post-merge rollout gates.

审计与修复已推送到 955a6923d4,感谢这轮 review。处理结果如下:

  • Channel 范围:两个 channel bridge 都会附带私有 prompt 标记,Session 对该路由强制 off。因此 guard 遥测只可能来自交互式 ACP foreground,route = acp_foreground 现在是事实值而不是混合路由占位。
  • 旧/第三方 host:不支持或不能可靠返回 craft/drainMidTurnQueue.hasQueuedPrompt 时,只记录一次 unreliable_input reset,当前 prompt 禁止 enforce 且无法累计 candidate;设计文档和 PR body 已说明 shadow 基线必须排除这类 host。
  • 运维开关:补充 operator 文档;非空非法值告警并回退 shadow;项目 .env、项目 .qwen/.env 和 workspace settings.env 均不能设置该策略,export/process env 与用户级 env 仍可用。
  • 隐私与基数:guard correlation ID 改为 ACP prompt ID 的 SHA-256 摘要;终态 loop event 复用同一摘要并绕过 session-scoped RUM;execution_error_type 只保留在结构化诊断,不再作为 metric label;隐私分支补了原因注释。
  • 取消与消息:停止消息发送/改写等待期间到达的取消现在返回 cancelled;新增覆盖该竞争窗口。提醒“一次”的表述改为每个 candidate streak 一次,和 reducer 行为一致。
  • 测试缺口:新增 Session 级 off、默认 shadow、channel-off、queued prompt、unsupported host、warn/enforce、两个取消窗口和哈希关联测试;两个 channel bridge 都固定 marker;项目环境来源和低基数指标也有回归测试。
  • 实现清理:mode parser 只规范化一次,telemetry sink 包装和事件导出已收拢。

Shadow 在 would_stop 后继续保持 latch,不新增 post-latch guard 事件,以避免 prompt 内遥测放大并保持与 enforce 的单次决策语义一致。后续是否有有效进展通过授权 trace 采样判断;PR body 已明确这项手工 shadow/E2E 证据尚未完成,因此 PR 保持 Draft。停止常量仍有意区分“写入模型历史的 System: 上下文”和“展示给 ACP 用户的 agent message”,避免 UI 暴露内部角色前缀。

最终本地验证:CLI 554 tests、Channels 91 tests、Core 128 tests,npm run buildnpm run typechecknpm run lint、Prettier 和 git diff --check 全部通过。修复后的完整 diff 又完成了连续两轮开放式审计,没有新增可执行问题。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: repeated tool execution failure guard

The state machine itself is clean, well-isolated, and the test coverage (reducer table tests + 10 Session-level integration tests including cancellation races) is genuinely good. The conservative eligibility rules match the design doc, the off/channel/unreliable-host escape hatches all fail open, and the PROJECT_ENV_HARDCODED_EXCLUSIONS entry is the right call for an operator policy knob.

My concerns are concentrated in the telemetry design (which is the whole point of the shadow rollout) and in a few reachability / single-source-of-truth issues. Details inline; summarized here by priority:

Blocking-ish

  1. The SHA-256 prompt-id digest provides no real privacy but does break correlation. promptId is ${sessionId}########${n} (see your own test asserting sha256('test-session-id########1')). Unsalted SHA-256 over that is trivially confirmable by anyone holding the session id — n is a small integer. Meanwhile the raw promptId is already emitted by logToolCall (prompt_id: promptId) on the same pipeline, with common attributes carrying the session id. So nothing is actually hidden, and in exchange the terminal stop event loses its join to the session, the service version, and the rest of the prompt — exactly the data a staged rollout decision needs. See Session.ts:491.
  2. logLoopDetected now branches on loop_type inside a shared core API and drops both QwenLogger and getCommonAttributes for one loop type. That's hidden coupling in packages/core driven by one CLI feature. See loggers.ts.
  3. Three of the four new stoppedByRepeatedToolFailure branches are unreachable#runStopContinuation, the Stop-hook route and the background route all build their loop state with the default 'off', and the reducer short-circuits on 'off'. Dead + untestable code that reads as if those routes can be stopped. See Session.ts:4510.

Should fix
4. Eligibility is decided by elimination over ToolExecutionStatus, and the telemetry then hardcodes terminal_status: 'error' / execution_status: 'error'. Correct today; silently wrong the day the union gains a member.
5. CHANNEL_PROMPT_META_KEY is duplicated as a bare string literal across packages/channels/base and packages/cli, with no test tying them together — and a desync fails in the dangerous direction (channel prompts start enforcing).

Minor
6. Default shadow changes the drain request payload for every ACP host on day one, under a now-misleading todoStopGuardWatchQueuedPrompt name.
7. reset_reason attribution is order-dependent for mixed batches.
8. guardContext object smuggle + inconsistent executionErrorType gating between the two queueToolResultRecord sites.

Nothing here suggests the guard would misfire in shadow, so the rollout plan is sound. Items 1–3 are what I'd want settled before the shadow baseline starts collecting, since they affect whether that baseline is analyzable at all.

Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/core/src/telemetry/loggers.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: the signal is right, the placement and the eligibility rule are not

The new signal — keying repetition on tool execution outcome rather than on the tool request — is genuinely missing today: LoopDetectionService only consumes ToolCallRequest / Content / Thought / Retry / Finished, so "same tool keeps failing for the same classified reason" is invisible to it. That part is worth having, and the reducer itself is good code: pure, well-tested, clear state machine.

Three concerns at the design level.

1. This belongs in packages/core, not in the ACP session layer

packages/core/src/services/loopDetectionService.ts is already the home for this concept: prompt-local lifecycle, LoopType emission, disableForSession, and an existing two-tier split (checkAlwaysOnSafeties always-on vs addAndCheckHeuristicLoops gated by model.skipLoopDetection) — which is essentially what shadow/warn/enforce re-implements from scratch.

Putting the guard in Session.ts means TUI (core/client.ts), headless (nonInteractiveCli.ts) and subagents (agents/runtime/agent-core.ts) get nothing. A tell: this PR has to add a REPEATED_TOOL_EXECUTION_FAILURE label to nonInteractiveCli.ts, but that branch is unreachable under the current architecture — it exists only to satisfy Record<LoopType, string>.

The natural anchor is CoreToolScheduler.onAllToolCallsComplete (coreToolScheduler.ts:5872) — the settled-batch boundary that already exists in core and is shared by useReactToolScheduler, nonInteractiveCli, agent-core and nonInteractiveToolExecutor. CompletedToolCall already carries request.name, tool (native vs DiscoveredMCPTool), response.errorType, response.executionStatus and status — exactly the fields RepeatedToolFailureObservation defines.

Note for anyone reaching for it: GeminiEventType.ToolCallResponse looks like an easier hook but is a dead enum member — nothing in core ever emits it.

Sketch:

  1. Move the reducer to core/src/services/repeatedToolFailureReducer.ts (near-zero change; derive the observation from CompletedToolCall).
  2. Add LoopDetectionService.recordSettledToolBatch(calls: readonly CompletedToolCall[]): boolean; clear it in the existing reset() so the prompt-local lifecycle comes for free; report through the existing logLoopDetected + lastLoopType.
  3. Call it where onAllToolCallsComplete fires — one site, four consumers.
  4. Wire it into addAndCheckHeuristicLoops (gated by model.skipLoopDetection, default true = off), not checkAlwaysOnSafeties. This makes QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD, the PROJECT_ENV_HARDCODED_EXCLUSIONS entry and the shared-env-keys.ts change unnecessary.
  5. ACP deliberately bypasses CoreToolScheduler (see the comment at Session.ts:7039), so it keeps one feed call from finalizeRunToolResult. Session then only retains what is genuinely ACP-specific: history preservation on stop, emitAgentMessage, todoStopGuard.suspend() — roughly 15 lines instead of 285.
  6. With the guard in core and gated by config, the _meta channel marker can be dropped entirely (see the inline note on ChannelAgentBridge.ts).

2. The eligibility rule gives near-zero recall and misses the dominant loop shape

The reducer requires every non-duplicate observation in a batch to share one key, and any single success resets the whole streak. So the most common expensive loop is undetectable by construction:

batch 1: run_shell("npm test")            -> error
batch 2: read_file(x) ; edit(x)           -> success, success   <-- streak reset
batch 3: run_shell("npm test")            -> error
batch 4: read_file(y) ; edit(y)           -> success, success   <-- reset again
...

What remains is "at least two consecutive batches containing nothing but the same failing tool" — and much of that is already covered by checkToolCallLoop (consecutive identical name+args) and the per-turn cap. The genuine increment is a thin slice: same tool, same ToolErrorType, different args, >= 8 times, >= 2 pure batches, zero successes. That is not enough surface to justify an enforce mode's false-positive budget.

The root cause is mixed, which exists to keep the reducer's "candidate" single-valued — an implementation convenience, not a domain requirement. Suggest replacing it with a per-key counter map that decays only on that key's own success. mixed then disappears and interleaved loops become detectable.

3. Shadow cannot produce the evidence enforce needs

Two independent gaps:

(a) Broken counterfactual. Shadow advances the virtual warned -> latched transition without ever injecting the reminder, so would_stop assumes "the model kept failing after seeing a correction" — a premise that never occurred. It is an upper bound on enforce's stop rate, not an estimate. The design doc acknowledges this (L399-401), but the rollout plan still treats the seven-day shadow baseline as the gate into warn.

(b) Telemetry cannot separate a loop from exploration. Dropping args from the key is a defensible tradeoff, but no field lets an analyst distinguish:

  • read_file failing on the same path 8 times — definitely a loop
  • read_file returning FILE_NOT_FOUND on 8 different guessed paths — plausibly legitimate exploration

Both emit an identical would_stop. After seven days you learn how often it would fire, but not whether firing was correct — and only the latter gates enforce. Suggest a bounded distinct_args_bucket ('1' | '2-3' | '4+') computed from a hash, which adds no argument content.

To be fair: shadow can validate the classifier invariants listed at doc L410+ (zero cancelled counted as eligible, zero not_started, etc.). It validates the classifier, not the intervention. The plan should say so explicitly and derive the enforce gate from warn-phase data.

What is already right

  • Fail-open discipline is thorough: drain failure / timeout / missing hasQueuedPrompt all land on reliable: false -> unreliable_input -> enforcement permanently disabled for the prompt. Verified all three in-repo answerers (bridgeClient.ts:1092, desktop qwen-agent.ts:459/653, channels/base/AcpBridge.ts:562) already return hasQueuedPrompt unconditionally, so shadow's extra request flag changes nothing in-repo.
  • Cancellation and queued-input precedence, plus full-batch settlement before stopping, are correct — and Session.test.ts covers the "cancelled while emitting the stop message" race.
  • The guard's stop path sits inside the try, so the conversation_finished finally invariant is preserved.
  • Keeping the legacy todoStopGuardWatchQueuedPrompt wire name with an explanatory comment is the right compatibility call.

CI: the one failing check is an unrelated flake — glob.test.ts > should allow path outside workspace times out globbing /tmp on the runner (1 failed | 19212 passed). Rerun.

Recommendation

Settle the placement question before polishing the implementation. Several inline findings below (the Todo-reminder regression is a real bug worth fixing regardless), but most of the surrounding machinery would disappear under the core-side design.

中文

结论:信号是对的,落点和判定规则不对

工具执行结果(而非调用请求)做重复性判定,这个信号今天确实缺失 —— LoopDetectionService 只消费 ToolCallRequest/Content/Thought/Retry/Finished,看不到执行结果。这部分值得做,reducer 本身也写得好:纯函数、可测、状态机清晰。

方案层面三个问题。

1. 应该落在 packages/core,不是 ACP session 层

packages/core/src/services/loopDetectionService.ts 已经是这个概念的归属:prompt 局部生命周期、LoopType 上报、disableForSession,以及既有的双层分级(checkAlwaysOnSafeties 常开 vs addAndCheckHeuristicLoopsmodel.skipLoopDetection 门控)—— 后者基本就是 shadow/warn/enforce 重新实现了一遍的东西。

放在 Session.ts 意味着 TUI(core/client.ts)、headless(nonInteractiveCli.ts)、subagent(agents/runtime/agent-core.ts) 都拿不到。一个佐证:本 PR 不得不给 nonInteractiveCli.ts 加一条 REPEATED_TOOL_EXECUTION_FAILURE 文案,但那条分支在当前架构下永远不可达,纯粹是为了让 Record<LoopType, string> 过 typecheck。

真正的锚点是 CoreToolScheduler.onAllToolCallsCompletecoreToolScheduler.ts:5872)—— core 里已有的批次结算边界,被 useReactToolSchedulernonInteractiveCliagent-corenonInteractiveToolExecutor 共用。CompletedToolCall 已经带了 request.nametool(可判 native/mcp)、response.errorTyperesponse.executionStatusstatus,正好是 RepeatedToolFailureObservation 的全部字段。

提醒:GeminiEventType.ToolCallResponse 看起来是更简单的挂点,但它是死枚举 —— core 里没有任何地方发出它。

改动路径:

  1. reducer 搬到 core/src/services/repeatedToolFailureReducer.ts(几乎零改动,observation 改从 CompletedToolCall 派生)。
  2. LoopDetectionService.recordSettledToolBatch(calls);在既有 reset() 里一并清空,prompt 局部生命周期免费获得;命中走既有 logLoopDetected + lastLoopType
  3. onAllToolCallsComplete 触发处调用 —— 一处,覆盖四个消费方。
  4. 接入 addAndCheckHeuristicLoops(受 model.skipLoopDetection 门控,默认 true 即默认关闭),不进 checkAlwaysOnSafeties。这样 QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARDPROJECT_ENV_HARDCODED_EXCLUSIONS 条目和 shared-env-keys.ts 改动全部可以删掉。
  5. ACP 刻意绕过 CoreToolScheduler(见 Session.ts:7039 注释),所以保留一处自己的 feed(在 finalizeRunToolResult 里)。Session 只留真正 ACP 特有的:停止时历史保全、emitAgentMessagetodoStopGuard.suspend() —— 大约 15 行,而非 285 行。
  6. guard 落在 core 且由 config 门控后,_meta channel 标记可以整个删掉(见 ChannelAgentBridge.ts 的行内 comment)。

2. 判定规则让召回接近于零,且漏掉最主要的循环形态

reducer 要求批次内所有非 duplicate observation 同 key,任何一次 success 都会 reset 整条 streak。所以最常见、最贵的那种循环按设计就检测不到:

batch 1: run_shell("npm test")            -> error
batch 2: read_file(x) ; edit(x)           -> success, success   <-- streak 清零
batch 3: run_shell("npm test")            -> error
batch 4: read_file(y) ; edit(y)           -> success, success   <-- 再次清零
...

剩下的「连续 >= 2 个批次里除了同一个失败工具什么都没有」,很大程度已被 checkToolCallLoop(连续相同 name+args)和每轮工具调用上限覆盖。真正的增量只剩很薄一层:同工具、同 ToolErrorType不同 args、>= 8 次、>= 2 个纯净批次、零成功。这撑不起 enforce 的误判风险预算。

根因是 mixed —— 它是为了让 reducer 的 candidate 保持单值(实现便利),不是领域需求。建议换成按 key 计数的 Map,只在该 key 自己成功时清零;mixed 随之消失,交错型循环也能捕获。

3. shadow 产不出 enforce 需要的证据

两个独立缺口:

(a) 反事实断裂。 shadow 不注入 reminder,却照样推进虚拟 warned -> latched,所以 would_stop 假设了「模型看到提醒后仍不改」这个从未发生的前提。它是 enforce 真实停止率的上界,不是估计量。design doc L399-401 承认了这点,但 rollout 计划仍把七天 shadow 基线当成进入 warn 的门槛。

(b) 遥测无法区分循环与探索。 key 不含 args 是可接受的取舍,但没有任何字段能区分:

  • read_file同一路径失败 8 次 —— 确定是循环
  • read_file 对 8 个不同猜测路径 FILE_NOT_FOUND —— 可能是合理探索

两者产生完全相同的 would_stop。七天跑完你知道「会停多少次」,但不知道「停得对不对」—— 而后者才是 enforce 的准入条件。建议加一个有界的 distinct_args_bucket'1' | '2-3' | '4+',由 hash 计算),不引入任何参数内容。

公平地说:shadow 能验证 doc L410+ 列的分类器不变量(零 cancelled 被计入、零 not_started 等)。它验证的是分类器,不是干预行为。计划里应写明这一点,并把 enforce 的门槛建立在 warn 阶段数据上。

已经做对的部分

  • fail-open 很完整:drain 失败/超时/缺 hasQueuedPrompt 都落到 reliable: false -> unreliable_input -> 该 prompt 永久禁用强制停止。仓库内三个 answerer(bridgeClient.ts:1092、desktop qwen-agent.ts:459/653channels/base/AcpBridge.ts:562)都无条件返回 hasQueuedPrompt,所以 shadow 多带的请求标记对内部零影响。
  • 取消与排队输入优先级、停止前的完整批次结算都正确,Session.test.ts 也覆盖了「emit stop message 期间收到取消」的竞态。
  • guard 的 stop 返回在 try 块内,conversation_finishedfinally 不变量没有被破坏。
  • 保留 legacy 线名 todoStopGuardWatchQueuedPrompt 并加注释,兼容性处理正确。

CI:唯一失败的 check 是无关 flake —— glob.test.ts > should allow path outside workspace 在 runner 上 glob 整个 /tmp 超时(1 failed | 19212 passed),rerun 即可。

建议

先定落点,再打磨实现。下面还有几条行内 comment(其中 Todo reminder 回归是真 bug,无论方案怎么定都该修),但大部分周边机制在 core 侧方案下会自然消失。

Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/channels/base/src/ChannelAgentBridge.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/core/src/telemetry/loggers.ts
doudouOUC and others added 2 commits August 6, 2026 14:44
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC doudouOUC self-assigned this Aug 6, 2026

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: the signal is right; the layer, the config axis, and the threshold are not

Re-reviewed at 8cb87ea5. Confirming first what this round fixed, because it changes my earlier assessment:

The recall objection from the previous round no longer applies. A complete batch containing only successes from other tools now returns { kind: 'none' } and preserves the streak (repeated-tool-failure-guard.ts L836-841 of the diff), with preserves a failure streak across successful batches from other tools covering it. So the dominant shape — run_shell fails -> read/edit succeed -> run_shell fails again — is detected now. That was my main design objection and it's resolved. Also fixed since the last round: the SHA-256 digest, the loop_type branch inside the shared core logger, the three unreachable stop branches, the order-dependent reset_reason, and the Todo-reminder drop on abort.

What I verified independently this round:

  • The signal is genuinely missing today. LoopDetectionService consumes only ToolCallRequest / Content / Thought / Retry / Finished — it sees requests, never outcomes. checkGlobalDuplicate keys on name+args (threshold 6) and is gated off by default (config.ts:2318, skipLoopDetection ?? true); the invalid-params guard only covers not_started. None of them can see "same tool, same classified execution error, fresh args." Worth having.
  • The three automatic-continuation routes really do pin 'off' — I checked each createDaemonToolLoopState() call site.
  • Fail-open is thorough: drain timeout / -32601 / missing hasQueuedPrompt all land on reliable: false -> unreliable_input -> enforcement disabled for the prompt.
  • The stop path returns inside the try, so the conversation_finished finally invariant holds.

Four things I'd still settle, in priority order. Details inline.

1. Wrong layer (blocking for the design, not the code)

The guard lives only in ACP Session, so TUI, headless and subagents get nothing. The clearest tell is nonInteractiveCli.ts:175: a LoopType label headless can name but can never emit, present only to satisfy Record<LoopType, string>.

In fairness, moving it to core is not free: ACP deliberately bypasses CoreToolScheduler (see the comment at Session.ts:7196), so a core-side design still needs two feed points — onAllToolCallsComplete plus ACP's own finalizeRunToolResult. Same cost, three more consumers covered, and Session.ts keeps only what is genuinely ACP-specific (history preservation, emitAgentMessage, todoStopGuard.suspend()).

2. A new config axis where one already exists

model.skipLoopDetection + system-scope settings.json (/etc/qwen-code/settings.json, root-owned — storage-paths-lite.ts:48) is already the operator boundary for exactly this class of guard. The new env var has to be manually blacklisted in PROJECT_ENV_HARDCODED_EXCLUSIONS to stop a project .env self-promoting into enforce; a system-scope setting gets that trust boundary for free and deletes the shared-env-keys.ts change plus the docs table row.

3. The threshold undercuts the stated motivation

failureCount accumulates per-batch matching failures. In the common one-call-per-batch loop that is 8 batches to warn, 9 to stop — roughly 9-10 model round-trips. The PR's own motivation is that the existing high cap "wastes model rounds, tool latency, and tokens before doing so." Compare: global-duplicate 6, invalid-params 3.

4. Shadow can't answer the question that gates enforce

Two independent gaps, one acknowledged in the doc and one not:

(a) Shadow advances the virtual warned -> latched without ever injecting the reminder, so would_stop presumes a premise that never occurred. It is an upper bound on enforce's stop rate, not an estimate. Doc L399-401 says this, but the rollout still treats the 7-day shadow baseline as the gate into warn.

(b) Not currently acknowledged: because args are excluded from the key, read_file failing on the same path 8 times (a real loop) and read_file returning FILE_NOT_FOUND on 8 different guessed paths (plausible exploration) emit identical telemetry. After seven days you know how often it would fire, not whether firing was correct — and only the latter gates enforce.

Shadow can validate the classifier invariants at doc L410+ (zero cancelled counted, zero not_started, etc.). That's the classifier, not the intervention. I'd state that explicitly and derive the enforce gate from warn-phase data.

Suggested shape

Keep the reducer as-is — pure, well-tested, correctly built on the frozen executionStatus / executionErrorType contract rather than error-string matching. Change the surroundings: move it to core/src/services/, gate on skipLoopDetection, drop the env var and the _meta marker, and add a bounded distinct_args_bucket ('1' | '2-3' | '4+', from a hash — no argument content) so shadow can separate a loop from exploration.

中文

结论:信号对,但落点、配置轴和阈值都还需要再定

基于 8cb87ea5 复审。先确认这一轮修好的部分,因为它推翻了我上一轮的主要判断:

上一轮「召回接近于零」的意见已经不成立。 现在一个只包含其他工具成功的完整批次会返回 { kind: 'none' } 并保留 streak,对应测试 preserves a failure streak across successful batches from other tools。所以最主要的循环形态(run_shell 失败 -> read/edit 成功 -> run_shell 再失败)现在能检测到了。这是我上轮最主要的设计意见,已解决。同时修好的还有:SHA-256 摘要、core 共享 logger 里按 loop_type 分叉、三处不可达分支、reset_reason 顺序依赖、abort 分支吞掉 Todo reminder。

本轮我独立核实过:这个信号今天确实缺失(LoopDetectionService 只消费请求不消费结果;checkGlobalDuplicate 按 name+args 判重且默认关闭;invalid-params 只覆盖 not_started);三个自动续跑路由确实都传 'off';fail-open 完整;stop 返回在 try 内,conversation_finished 不变量没破。

还需要定的四件事:

1. 落点错层。 只在 ACP Session 生效,TUI/headless/subagent 都拿不到。最清楚的信号是 nonInteractiveCli.ts:175——一个 headless 能命名却永远发不出的 LoopType。公平地说,搬到 core 并非零成本:ACP 刻意绕过 CoreToolSchedulerSession.ts:7196 有注释),core 侧方案仍需两个 feed 点。代价相同,但多覆盖三类消费方。

2. 配置轴重复。 model.skipLoopDetection + system-scope settings.json/etc/qwen-code/settings.json,root 拥有)已经是这类守卫的运维边界。新环境变量需要手工维护 PROJECT_ENV_HARDCODED_EXCLUSIONS 黑名单才能挡住项目 .env 自我提权;system-scope setting 免费获得同样的信任边界。

3. 阈值与动机相悖。 每批一次调用的常见循环需要 8 个批次才 warn、9 个才停,约 9-10 个模型往返。而 PR 的动机恰恰是「高上限兜底前浪费了轮次」。对比:全局重复 6,invalid-params 3。

4. shadow 产不出 enforce 需要的证据。 (a) shadow 从不注入提醒却推进虚拟 warned -> latchedwould_stop 是上界不是估计量(doc L399 已承认,但 rollout 仍拿它当进 warn 的门槛)。(b) 尚未被承认的一点:键不含 args,导致「同一路径失败 8 次」(确定循环)与「8 个不同猜测路径 FILE_NOT_FOUND」(合理探索)产生完全相同的遥测。跑完七天你知道会停多少次,但不知道停得对不对——而后者才是 enforce 的准入条件。

建议形态:reducer 本身保留(纯函数、可测、建立在冻结契约而非错误文本匹配上);改周边——搬进 core/src/services/、由 skipLoopDetection 门控、删掉环境变量与 _meta 标记、补一个有界的 distinct_args_bucket'1' | '2-3' | '4+',由 hash 计算,不含参数内容)。

Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/config/shared-env-keys.ts
Comment thread packages/channels/base/src/ChannelAgentBridge.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review disposition summary after merging current main in 30c50a6b1c:

  • 13 threads are fixed or obsolete on the current branch: raw prompt correlation, positive error eligibility, explicit telemetry sink selection, shared channel marker, neutral queue-watch naming, explicit route-off state, deterministic reset precedence, uniform error-type production, Todo reminder preservation, interleaved-tool recall, routing-hint documentation, and OTel Resource cohort dimensions.
  • 9 threads are intentionally deferred or declined as non-blocking: style-only refactoring, moving the ACP v1 guard into Core, replacing the deployment rollout axis, session-source unification before provenance restoration, threshold/argument tiering before shadow data, future defensive hardening, per-key candidate state, and an optional distinct-arguments metric bucket.

Validation on the merged tree:

  • CLI: 611 tests passed
  • Channels: 91 tests passed
  • Core telemetry: 129 tests passed
  • npm run build, npm run typecheck, and npm run lint passed
  • Two post-merge broad audit rounds completed with no remaining actionable issue

I am resolving the replied threads according to these dispositions.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC marked this pull request as ready for review August 9, 2026 02:06
@doudouOUC
doudouOUC enabled auto-merge August 9, 2026 02:06
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

🚫 Qwen Triage was cancelledview run. The run was cancelled before finishing. Check for a newer run before re-running.

🚫 Qwen Triage 已取消 —— 查看运行。运行未完成即被取消。重跑前请先确认是否有更新的运行。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the third in the series after #8176 and #8180, both merged. A few gate notes before code review:

  • Template: complete ✓
  • Problem: the failure class is plausible — a model re-issuing the same typed execution failure under fresh call IDs until the total tool-call cap or user intervention ends the turn. But nothing linked shows it observed in qwen-code today (no issue, no incident, no telemetry on how often it happens), and the existing total cap already bounds the worst case. The honest framing is "earlier, narrower detection of one failure class" — which is exactly what the default-shadow rollout is for: prove the signal fires on real traffic before enforce can stop anything.
  • Direction: aligned. claude-code has shipped several runaway-loop guards (WebSearch session cap, subagent-spawn cap, stop-hook block cap — CHANGELOG lines 340-341, 1607), so the area is clearly relevant. It touches telemetry, the ACP session core, and three packages, so it is escalated for maintainer awareness regardless of the outcome below.
  • Size: core paths touched (packages/core/src/telemetry/**, packages/*/src/config/**, cross-package). Breakdown: 873 production-logic lines (guard 351, Session wiring 277, telemetry ~193, channel/CLI plumbing 52), 1,291 test lines, 643 doc lines. A feat is not size-blocked, but 500+ production lines in core means maintainer awareness — this PR will not be auto-approved.
  • Approach: the core signal (typed failure key, 8 failures across ≥2 settled batches, one reminder before stop, same-tool success clears, fail-open on unreliable input) is conservative and well-argued. Two genuine questions: (1) if 80% of the scope were cut — guard + env gate + terminal reason + tests, without the drain-extension probe and the rollout metrics/diagnostics suite — would that already deliver the protection, with telemetry added once shadow data justifies warn/enforce? (2) the description still calls this a Draft and says the manual ACP rollout validation is intentionally not claimed — is it ready for review as-is?
  • Risk: high-risk path matched — packages/cli/src/acp-integration/session/ is correlated with post-merge reverts in this repo's history. Full review depth and CI evidence apply before any approval.

Moving on to code review. 🔍

中文说明

感谢贡献——这是 #8176#8180(均已合并)之后的系列第三篇。进入代码审查前的几点门槛意见:

  • 模板:完整 ✓
  • 问题:失败类型是合理的——模型用新的 call ID 反复发起同一种结构化执行失败,直到总工具调用上限或用户干预结束轮次。但没有任何关联证据表明它在 qwen-code 中被实际观测到(无 issue、无事故、无发生频率数据),且现有总量上限已经兜底。更诚实的定位是"对某一类失败更早、更窄的识别"——这正是默认 Shadow 发布的意义:先用真实流量证明信号有效,再谈 Enforce 停止任何东西。
  • 方向:对齐。claude-code 已上线多个防失控循环的保护(WebSearch 会话上限、子代理派生上限、stop-hook 阻塞上限,CHANGELOG 340-341、1607 行),该方向明显相关。本 PR 触及遥测、ACP 会话核心与三个包,无论结论如何都会提请维护者关注。
  • 规模:触及核心路径(packages/core/src/telemetry/**packages/*/src/config/**、跨包)。拆分:873 行生产逻辑(保护本体 351、Session 接线 277、遥测约 193、channel/CLI 管道 52),测试 1,291 行,文档 643 行。feat 类型不因规模被阻塞,但核心路径 500+ 生产行意味着需维护者知悉——本 PR 不会被自动批准。
  • 方案:核心信号(结构化失败键、跨 ≥2 个已结算批次累计 8 次、停止前一次提醒、同工具成功清零、不可靠输入 fail-open)保守且论证充分。两个真正的问题:(1) 如果砍掉 80% 的范围——只保留保护本体 + 环境开关 + 终态原因 + 测试,不带 drain 扩展探测和发布指标/诊断——是否已经足够,遥测可以等 Shadow 数据证明 Warn/Enforce 有必要时再加?(2) 描述中仍称这是 Draft,并声明手工 ACP 发布验证不在本 PR 声称范围内——它现在是否已准备好接受评审?
  • 风险:命中高风险路径——packages/cli/src/acp-integration/session/ 与本仓库合并后回滚的历史相关。在任何批准之前需要完整深度的评审和 CI 证据。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 9c8ccd381cc331c1782f14da2cc478b39224c5f3 · re-run with @qwen-code /triage

@doudouOUC
doudouOUC marked this pull request as draft August 9, 2026 02:45
auto-merge was automatically disabled August 9, 2026 02:45

Pull request was converted to draft

@doudouOUC

doudouOUC commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the gate review. I am not cutting the drain reliability probe or rollout telemetry from this PR: the probe is the fail-open safety boundary for warn/enforce on incompatible ACP hosts, while the default-shadow telemetry is the evidence needed before either mode can be enabled. Removing both would preserve the reducer but break the staged-rollout safety loop. The branch has also passed roughly five review rounds, so I am deferring non-Critical scope churn. The PR is now Ready for maintainer review, but is not merge-ready: the local E2E plan and deterministic manual ACP fixture are pending pre-merge gates. Separate seven-day internal/public-cloud shadow baselines are post-merge rollout gates before staged warn/enforce promotion.

doudouOUC commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Final review disposition for the current head 41289f6c89:

Area Disposition
Existing inline findings All 38 threads have individual replies and are resolved. Earlier 22: thirteen were fixed or became obsolete and nine were deferred or declined. Latest 16: one readiness-wording inconsistency was fixed, three false or redundant suggestions were declined, and twelve non-Critical test-hardening suggestions were deferred under the repository's Critical-only review-round cutoff.
Problem framing Accepted. This is an earlier, narrower guard for one repeated typed-execution-failure class; the PR does not claim a proven production frequency or replace the existing total-call cap.
Proposed 80% scope reduction Not taking. Reliable queued-prompt observation is the fail-open safety boundary for warn/enforce, and guard decision telemetry is what makes the default-shadow rollout measurable. Removing either would break the staged-rollout safety loop.
Threshold, per-key state, argument-tiering, and extra metric buckets Deferred until shadow data exists. Adding these now would increase complexity before calibration.
Moving the guard into Core or replacing operator policy with a user setting Not taking in this PR. Both would broaden runtime behavior beyond the ACP interactive foreground scope or conflate deployment policy with user configuration.
Session-source unification and future defensive hardening Deferred to focused follow-ups; the current routing marker preserves loaded/resumed channel safety while provenance restoration remains incomplete.
Readiness The PR is Ready for maintainer review, not merge-ready. The local E2E plan and deterministic manual ACP fixture are pending pre-merge gates. Separate seven-day internal/public-cloud shadow baselines are post-merge rollout gates before staged warn/enforce promotion.
Size and high-risk-path notes Acknowledged as maintainer-review requirements; they do not identify a remaining correctness defect.

The only additional branch change warranted by the latest review is the readiness wording correction in 41289f6c89; no behavior code changed. The design-doc formatting and diff checks pass, all 38 review threads are resolved, and remote CI is rerunning on the new head.

@doudouOUC
doudouOUC marked this pull request as ready for review August 9, 2026 07:57
@doudouOUC
doudouOUC enabled auto-merge August 9, 2026 07:57

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not explored to full depth (tool budget reached): chunk 3: could not run prettier --check live (prettier not installed in the worktree, no network install attempted); instead verified prettier's table invariants progr…; chunk 3: live prettier --check run not possible (no node_modules in the worktree, no global prettier); substituted the programmatic table-invariant verification above.; chunk 9: couldn't run the unit tests (dependencies aren't installed in the review worktree) — the static review only.; chunk 9: run repeated-tool-failure-guard.test.ts / typecheck — node_modules is not installed in this review worktree ( vitest unresolvable), so verification was sta…. Not reviewed: reverse audit — stopped before round 3 by the review time budget.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 未探索到全部深度(达到工具调用预算):chunk 3:could not run prettier --check live (prettier not installed in the worktree, no network install attempted); instead verified prettier's table invariants progr…;chunk 3:live prettier --check run not possible (no node_modules in the worktree, no global prettier); substituted the programmatic table-invariant verification above.;chunk 9:couldn't run the unit tests (dependencies aren't installed in the review worktree) — the static review only.;chunk 9:run repeated-tool-failure-guard.test.ts / typecheck — node_modules is not installed in this review worktree ( vitest unresolvable), so verification was sta…。 未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

— qwen3.8-max via Qwen Code /review (v0.21.8)

Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread docs/design/acp-repeated-tool-call-protection.md Outdated
Comment thread packages/channels/base/src/DaemonChannelBridge.ts
Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/core/src/telemetry/loggers.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM from a correctness and security standpoint. Verified at head: eligibility is positive-only (terminal error + execution error + resolved identity + non-UNKNOWN frozen type), the 8-across-2-batches boundary is right (8 in one batch tracks but does not warn), candidate semantics match the spec including same-batch success-before-key-selection, and there is no cross-prompt or cross-session leakage — fresh loop state at all four creation sites, stop-hook/cron/notification/background routes forced off, aborted turns return before the reducer. Enforce stops only from warned on a later settled matching batch with every current-batch response preserved, todoStopGuard suspended until the next ordinary prompt, and cancellation winning in both the final-drain and stop-emit windows. The channel marker is strict === true and can only force off (both bridge implementations stamp it), the mode key sits in PROJECT_ENV_HARDCODED_EXCLUSIONS behind the case-folded predicate at all three project-env gates with home-scoped/process env still honored, the unreliable_input path fails open per prompt without leaking content, and telemetry carries only ids/enums/buckets. wenshao's ten review items are addressed or documented at head.

Two non-blocking recommendations before any enforce promotion — currently mutation-survivable and deferred under the Critical-only cutoff: pin the Session-level complete:false receipt path (dropping the ordinal-uniqueness conjunct keeps the suite green today) and the todoStopGuard.suspend() effect on the stop path. Also noted: the author-declared manual E2E gate is still pending pre-merge. CI green on this head. Nothing blocks from my side.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 957 passed · 0 failed · 957 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:957 通过 · 0 失败 · 957 总计

Verification report

PR 8469 — feat(acp): Protect against repeated tool execution failures

Verdict: merge-ready — 957/957 scripted assertions passed, 0 unexpected failures. Verified head: 41289f6c8960421240bd234f9dbf9bb16834d2b6 (merge-ref HEAD^2; matches snapshot headRefOid). No blocking findings; three informational notes below.

中文摘要
  • 结论: merge-ready。957 条脚本化断言全部通过,0 条意外失败;验证头 41289f6c89
  • A/B 结论: 中心声明(load-bearing)成立。同一失控场景(3 个模型批次共 9 次同型执行失败)在 head 的 enforce 模式下第 3 个模型流后停止(注入 1 条提醒、保留第 3 批 function response、发出停止消息、记录 repeated_tool_execution_failure 且绕过 QwenLogger);在仅禁用 guard reducer 的对照构建中,会话开启第 4 个模型流、无提醒、无停止(expected "spy" to be called 3 times, but got 4 times)。Shadow 模式在 head 上不改变轮次(4 流、无提醒),在对照上同样通过(断言的是"无效果",符合预期)。
  • 变异矩阵: 8 个语义变异 + 1 个 A/B 对照全部被 PR 自带测试杀死,归因精确(每个变异恰好红在它应保护的测试上),无存活者;正向对照(reducer 整体禁用 → 33/38 单元 + 5/10 集成红)证明套件有能力变红。
  • 门槛独立复核: 直接驱动编译产物的 reducer harness 30/30,确认 8 次/2 批次阈值、同工具成功清零、mixed/unknown/cancelled/not_started/contract-violation 重置、providerDuplicate 过滤、unreliable_input 锁定 enforcementDisabled、off 惰性、shadow 只产生 would_* 决策。
  • 定向门禁: cli 4 文件 644/644、channels/base 2 文件 91/91、core telemetry 2 文件 129/129,全绿。
  • 未覆盖: 作者自述的合并前手工 ACP E2E fixture(作者已声明待完成);真实 ACP host 端到端(本环境无桌面/host);Windows/macOS;7 天 shadow 基线等合并后发布门禁;typecheck/lint 依赖 CI 预构建(HEAD 构建成功)。

Central claim and A/B proof

Central claim: on the interactive ACP foreground route, enforce mode stops a turn in which the same typed tool execution failure (resolved tool identity + structured ToolErrorType) repeats — 8 matching failures across ≥2 complete batches → one fixed corrective reminder → after the next complete matching batch settles, the turn stops, preserving the current batch's function responses, emitting one fixed stop message, recording LoopType.REPEATED_TOOL_EXECUTION_FAILURE (bypassing QwenLogger), and opening no further model stream. Default shadow observes without changing the turn.

Scenario (identical in both cells): a real Session (packages/cli) driven by the PR's integration harness with a failing tool returning ToolErrorType.EXECUTION_FAILED; the fake model streams 3 batches of failing calls (4+4+1 = 9 matching failures) then an empty stream. Control = same tree with only reduceRepeatedToolFailureGuard mutated to return { kind: 'none', state } (interface-preserving; represents "the PR's guard absent" for this scenario).

cell environment oracle (model streams / stop) result
head (guard active) worktree @​ HEAD, enforce 3 streams; stop message; loop event repeated_tool_execution_failure with recordToQwenLogger:false; batch-3 functionResponse preserved in history 10/10 integration tests green
control (reducer disabled) same worktree, one-hunk mutation 4th model stream opens; no reminder; no stop; no loop event 5/10 — the five tests asserting guard behavior fail, e.g. AssertionError: expected "spy" to be called 3 times, but got 4 times
head, shadow mode same scenario, mode=shadow 4 streams, zero reminders, zero loop events (turn unchanged) green at head — a live assertion because the active guard could have injected but did not

Witnesses: 01-ab-head-cell-enforce-stops.png (head cell, Tests 10 passed), 02-ab-control-cell-guard-disabled.png (control cell, the 3-vs-4 model-stream flip and the missing stop message). The control's failures are exactly the behavioral mismatches the tests exist to catch — not import/compile errors.

Secondary claims verified:

  1. Operator-only rollout mode: QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD is in PROJECT_ENV_HARDCODED_EXCLUSIONS, consumed at all three project-env ingestion sites in environment.ts (project .env load, settings.env overlay, process.env scrub). environment.test.ts + shared-env-keys.test.ts green (part of the 644). Invalid non-empty value → shadow + warning (pinned; mutant m4 kills it).
  2. Channel exclusion: both AcpBridge and DaemonChannelBridge attach _meta['qwen.channel.prompt']=true (channels gate 91/91); Session forces mode off for marked prompts (mutant m3 kills exactly forces channel-routed prompts off…). Stop-hook/Todo continuation and notification routes construct their loop state with 'off' in code.
  3. Fail-open on unreliable hosts: drain without hasQueuedPrompt (or -32601) → reliable=falseunreliable_input reset latches enforcementDisabled for the prompt; enforce degrades to warn-equivalent and never stops (mutant m5 kills the fail-open test; reducer harness pins the latch and the degrade sequence tracked,warn,would_stop). The daemon answerer (bridgeClient.ts, unchanged by this PR) already returns hasQueuedPrompt unconditionally, so the daemon path satisfies the contract; third-party hosts fail open.

Mutation matrix (vacuity)

Suite under mutation: 38 unit tests (repeated-tool-failure-guard.test.ts) + 10 Session integration tests, run in a scratch worktree at HEAD. Witness: 04-mutation-matrix-all-killed.png.

mutant change unit integ attribution (test that went red)
baseline unmutated head 0F 0F GREEN 38/10 (positive control: suite can run)
m0b reduceRepeatedToolFailureGuard → always none (A/B control) 33F 5F stop/reminder/fail-open/queued-reset/cancel-during-stop — the load-bearing set
m0 reset() helper → always none (wrong-target probe, kept) 25F 2F fail-open + queued-prompt diagnostics
m1 threshold 8→9 7F 3F warn-reminder, enforce-stop, cancel-during-stop
m2 batch threshold 2→3 8F 3F same three
m3 channel _meta off-switch removed from Session 0F 1F channel-forced-off test
m4 default mode shadow→off 0F 1F invalid-mode-defaults-to-shadow test
m5 unreliable_input branch deleted 1F 1F fail-open test
m6 recordToQwenLogger:false removed 0F 1F enforce-stop (asserts the option)
m7 same-tool success clearing deleted 3F 0F the three success-reset unit tests
m8 warn reminder injection deleted 0F 1F warn-reminder test

No survivors: every guard the PR introduces is pinned by a test that fails for the intended behavioral reason. The m0b positive control proves the suite can go red; m0 (which killed a different, smaller set) shows the two mutation points are independently load-bearing.

Independent reducer harness (03-reducer-harness-30-of-30.png, harness/reducer-harness.mjs): 30/30 scripted checks against the compiled dist/ reducer — threshold arithmetic bisected at cumulative 7 (no warn) vs 8 (warn) across two batches, 8-in-one-batch stays tracked, latched absorption, same-tool success clears / other-tool success no-op, mixed-key reset, providerDuplicate filtering, incomplete-batch reset, cancelled/not_started keep enforcement while unknown/contract-violation disable it, off-mode inert, mode parsing.

Findings (non-blocking)

  1. Channel bypass is client-supplied, by design. _meta['qwen.channel.prompt'] can be set by any ACP client to force the guard off for a prompt. The PR documents this as a routing hint, not a trust boundary, and the guard is an operator cost/UX control whose absence is fail-open — so bypass costs nothing security-relevant. Informational; the in-code comment states the same constraint.
  2. enforcementDisabled latches for the whole prompt after a single unreliable drain, even if later drains are reliable. Matches the stated "disable enforcement for the prompt" semantics; a warn-level reminder can still fire in enforce mode afterwards (degrade to warn-equivalent, never stop). Reviewers should know the latch is one-way per prompt.
  3. Diagnostic log records carry prompt_id, candidate_ordinal, and execution_error_type (bounded ~50-value enum) while the metric omits them, per the stated design (prompt ID reused for join with tool-call telemetry). No tool args/outputs/paths/raw errors in either. Informational privacy-boundary confirmation, not a defect.

Not covered

  • The author's own declared pre-merge gates: the manual deterministic ACP fixture run and local E2E plan under .qwen/e2e-tests/ (explicitly pending in the PR body), and the post-merge 7-day shadow baselines. My verdict covers the automated evidence only.
  • Real-host end-to-end behavior (a live ACP host answering craft/drainMidTurnQueue): the harness replays the wire contract with mocks of the peer, not of the code under test; this reproduces the handling, not a degraded real host. The daemon answerer's hasQueuedPrompt behavior was verified by reading bridgeClient.ts (unchanged by this PR), not by executing it.
  • Windows/macOS runs (author reports them incomplete as well).
  • npm run typecheck / npm run lint were not re-run here; the CI job built HEAD successfully before this round (build implies tsc emit), and the LOOP_TYPE_LABELS record is exhaustive-checked by that same build.
  • Per-commit attribution across the PR's 10 commits: the depth-2 checkout exposes only the aggregate HEAD^1..HEAD diff; all verification is against the aggregate.
  • The m0 row is a deliberately kept wrong-target probe (it mutated the reset() helper, not the reducer); it is evidence about the reset path, not about the A/B control — m0b is the control.

Methodology

Environment: CI node:22-bookworm container, merge-ref checkout (HEAD 70eee803e9, base tip bf84caf173, PR head 41289f6c89), npm ci + npm run build pre-run. Harnesses: (1) targeted vitest gates exactly as the PR's test plan lists them, run from each package; (2) a scratch git worktree at HEAD with symlinked node_modules (root + per-package) so vitest transforms worktree-local TS while @qwen-code/qwen-code-core resolves to the byte-identical head build (realpath asserted via the symlink layout; core is not mutated by any cell); (3) a mock-free Node harness importing the compiled dist/ guard reducer. Mutations were applied and reverted inside the scratch worktree only; the PR working tree was never modified (final git status clean; worktree removed). Raw logs in logs/, harnesses in harness/, captures in evidence/ (produced with scripts/verify-capture.mjs). Assertion counts: reducer harness 30 + gates 644/91/129 + worktree baseline 48 + 15 mutant-killed-as-predicted assertions = 957 pass, 0 fail.

Evidence images

01-ab-head-cell-enforce-stops

02-ab-control-cell-guard-disabled

03-reducer-harness-30-of-30

04-mutation-matrix-all-killed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not explored to full depth (tool budget reached): chunk 7: typecheck/unit-test run skipped — the review worktree has no node_modules and installing the full monorepo dependency tree was disproportionate to this review…; chunk 8: could not execute the test file — the review worktree has no node_modules and a monorepo install exceeded the tool budget; all assertions were verified by han…. Not reviewed: reverse audit — stopped before round 4 by the review time budget.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 未探索到全部深度(达到工具调用预算):chunk 7:typecheck/unit-test run skipped — the review worktree has no node_modules and installing the full monorepo dependency tree was disproportionate to this review…;chunk 8:could not execute the test file — the review worktree has no node_modules and a monorepo install exceeded the tool budget; all assertions were verified by han…。 未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

— qwen3.8-max via Qwen Code /review (v0.21.8)

Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.test.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs.

@wenshao

wenshao commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification: real ACP stack, local build

I built this PR locally and drove it through a real ACP agent process (dist/cli.js --acp) with a real ACP host on the other end of the stdio pipe, against a scripted OpenAI-compatible SSE model. This is the pre-merge gate the PR description marks as still pending ("the local E2E plan and deterministic manual ACP fixture run are not yet complete").

Result: every behavioural claim in the PR reproduced. No defect found. I consider the ACP-side pre-merge gate satisfied — with the residual gaps listed at the bottom.

Head 41289f6c89 (feat/acp-repeated-tool-call-protection-v2)
Base bb8f2c0129 (merge-base with origin/main)
Host macOS 15 (Darwin 25.6.0), Node v24.18.1 (the PR reports 26.0.0)
Binary under test npm run bundle output dist/cli.js, run as a child process

1. Declared checks, re-run

checks

I widened the CLI leg from the four named files to the whole src/acp-integration directory (1389 tests, 29 files) because Session.ts takes +264/-13 here; nothing regressed. prettier --check over all 24 changed files is clean.

2. Real ACP end-to-end harness

Not mocks — an actual process tree:

harness ACP host (stdio JSON-RPC)          scripted model (HTTP SSE)
  |  initialize / authenticate / session/new     ^
  |  session/prompt                              | OPENAI_BASE_URL
  |  answers session/request_permission,         |
  |    fs/*, craft/drainMidTurnQueue             |
  v                                              |
node dist/cli.js --acp  ------------------------ +

The model is scripted as a pure function of conversation state (immune to the CLI's internal title/classifier/suggestion calls): while the guarded prompt is live it emits 4 read_file calls per round against non-existent paths — terminal execution failures with resolved tool identity and ToolErrorType.FILE_NOT_FOUND. Paths differ per call so the pre-existing GLOBAL_TOOL_CALL_DUPLICATE detector (same tool+args ≥ 6) can never be the thing that fires. After 3 failing rounds the model would answer normally, so "did the guard stop the turn" is observable as the fourth model round never happening.

Evidence per run is taken from three independent places: the model server's request log (what text actually reached the model), the ACP wire log (what the agent told the host), and the agent's own debug log written by the running process.

3. Scenario matrix — 16 runs

matrix

rnd = model rounds inside the guarded prompt · rem = corrective reminders injected · the decision trace is read out of the running agent's debug log.

Reading of the load-bearing rows:

  • S1 vs S2 vs S3 — base build, off, and default shadow produce a byte-identical turn shape: 4 model rounds, 12 tool calls, 0 reminders, no guard message. Shadow observes (tracked → would_warn → would_stop) and changes nothing.
  • S4 warn — exactly one reminder enters the prompt at round 3 and is never re-injected (occurrence count stays 1 in rounds 3 and 4); the turn still completes normally.
  • S5 enforce — reminder at round 3, then the guard stops after the ninth-to-twelfth failure batch has fully settled. The fourth model round never happens. The fixed stop message reaches the host as an agent_message_chunk.
  • S6 / S7 fail-open — a host that answers the drain with -32601, and a legacy host that returns a valid payload without hasQueuedPrompt, both leave the streak permanently reset: enforcement configured, enforcement never applied.
  • S8 channel marker — a prompt carrying _meta: {"qwen.channel.prompt": true} produces zero guard activity even with enforce set.
  • S9QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD=enforce written into both the project .env and .qwen/.env resolves to shadow. The project cannot promote itself out of the default cohort.
  • S10 / S18 — a queued prompt resets the streak; a real session/cancel at the 9th tool call returns stopReason: cancelled with no guard message. Input and cancellation win.
  • S11 vs S12 — a success from a different tool inside batch 2 does not disturb the streak (still stops); a success from the candidate tool clears it (candidate ordinal advances 1 → 2, no stop). This is the asymmetry the PR describes, confirmed on the real stack.
  • S13enforce-typo logs the invalid-value warning and falls back to shadow.
  • S17 — after an enforce stop, a fresh prompt re-arms the guard from ordinal 1 and stops again. Prompt-local lifecycle confirmed.
  • S19 — with session mode default, the host rejects a write_file permission request. The turn ends through the pre-existing permission path; the guard stays at tracked and never attributes it. (Note: reject_once is the only rejecting option this host offers and it terminates the turn, so a rejected tool can never feed the streak at all.)

4. Turn-level detail, history preservation, and telemetry

detail

  • History preservation (panel B) — after the enforce stop I sent a second user prompt and inspected the conversation the agent actually replayed: 3 assistant tool-call rounds, 12 tool calls, 12 tool responses, every call answered, the final failing batch intact, the stop notice present, and exactly one reminder. Nothing was dropped to make room for the stop.
  • Telemetry (panel D) — captured from the running agent via the OTel local exporter, not from tests. The metric qwen-code.repeated_tool_failure_guard.count carries only bounded transition attributes and no prompt_id, no candidate_ordinal, no execution_error_type; the structured log keeps prompt_id for join-back; the terminal event is loop_detected with loop_type=repeated_tool_execution_failure. service.version is always present, and deployment.environment flows through when an operator sets OTEL_RESOURCE_ATTRIBUTES (verified: deployment.environment=verify-cohort-8469) — it is not set by default, so each rollout environment must configure it before the shadow baselines are joinable, exactly as the PR states.

5. One wire-level note for reviewers

Shadow is the default, and shadow asks for queued-prompt state. Confirmed on the wire: in off mode (and for channel-marked prompts) the tool-loop craft/drainMidTurnQueue request carries only {sessionId} — the legacy payload is unchanged — while shadow/warn/enforce add todoStopGuardWatchQueuedPrompt: true. So this PR does change the drain request for every ACP host by default. I checked the consequences:

  • The first-party daemon handler (BridgeClient.handleExtMethod) ignores the flag and already returns hasQueuedPrompt unconditionally, so it is inert there.
  • The new drained.reliable value is consumed only by the guard; the three Todo-Stop-Guard call sites that read reliable were already passing watchQueuedPrompt: true before this PR, so their behaviour is unchanged.
  • A host that cannot supply the field is handled by S7 (fail-open).

Not a blocker — worth stating explicitly in the rollout notes.

6. What this run does not cover

  • Reconnect / restart and history replay across an agent process restart. Not exercised.
  • A real channel bridge end-to-end. I injected the channel marker at the ACP wire level (S8), which is the boundary Session actually reads; that both bridges attach it is covered only by the 91 unit tests.
  • Windows / Linux. macOS only, matching the PR's own table.
  • Node 26. I ran 24.18.1.
  • Post-merge rollout gates (7-day shadow baselines, staged warn/enforce cohorts) are out of scope for a local run by construction.

Recommendation: approve. The implementation behaves as described on a real ACP stack under 16 adversarial configurations, the default mode is provably inert, and every escape hatch (channel, legacy host, project env, queued input, cancellation, same-tool success) fails safe.

中文版本

维护者验证:真实 ACP 栈 + 本地构建

我在本地构建了这个 PR,并用真实的 ACP agent 进程dist/cli.js --acp)跑通了验证:stdio 管道另一端是真实的 ACP host,模型侧是脚本化的 OpenAI 兼容 SSE 服务。这正是 PR 描述里标注为「尚未完成」的合并前置门禁("the local E2E plan and deterministic manual ACP fixture run are not yet complete")。

结论:PR 声明的每一条行为都在真实环境复现,没有发现缺陷。我认为 ACP 侧的合并前置门禁已经满足,遗留缺口列在最后。

Head 41289f6c89feat/acp-repeated-tool-call-protection-v2
Base bb8f2c0129(与 origin/main 的 merge-base)
环境 macOS 15(Darwin 25.6.0),Node v24.18.1(PR 写的是 26.0.0)
被测二进制 npm run bundle 产出的 dist/cli.js,以子进程方式运行

1. 重跑 PR 声明的检查

CLI 这条腿我从 4 个指定文件扩到了整个 src/acp-integration 目录(1389 个测试 / 29 个文件),因为 Session.ts 在这个 PR 里是 +264/-13,需要更大的回归面;没有回归。24 个变更文件的 prettier --check 全部干净。

2. 真实 ACP 端到端脚手架

不是 mock,是真实进程树:harness ACP host 走 stdio JSON-RPC,负责 initialize / authenticate / session/new / session/prompt,并回答 session/request_permissionfs/*craft/drainMidTurnQueue;模型侧是本地 HTTP SSE 服务。

模型脚本是「对话状态的纯函数」(因此不受 CLI 内部的标题生成 / 分类器 / 建议模式调用干扰):在被保护的 prompt 存活期间,每轮发出 4 个指向不存在路径的 read_file,即具有已解析工具身份和 ToolErrorType.FILE_NOT_FOUND 的终态执行失败。每次调用路径都不同,确保触发的绝不是既有的 GLOBAL_TOOL_CALL_DUPLICATE(同 tool+args ≥ 6 次)检测器。3 轮失败之后模型本会正常作答,所以「保护是否停住了这一轮」可以直接观测为第 4 次模型请求有没有发生

每个场景的证据取自三个互相独立的来源:模型服务的请求日志(到底哪些文本进了模型)、ACP 线协议日志(agent 到底对 host 说了什么)、以及运行中 agent 自己写出的调试日志

3. 场景矩阵 —— 16 次运行

rnd = 被保护 prompt 内的模型轮数 · rem = 注入的纠偏提醒数 · decision trace 来自运行中 agent 的调试日志。

关键行的解读:

  • S1 / S2 / S3 —— base 构建、off、默认 shadow 产生完全一致的轮次形状:4 轮模型请求、12 次工具调用、0 次提醒、无保护消息。Shadow 只观察(tracked → would_warn → would_stop),什么都没改。
  • S4 warn —— 第 3 轮恰好注入一次提醒,且不再重复注入(第 3、4 轮里出现次数都稳定为 1);轮次仍正常结束。
  • S5 enforce —— 第 3 轮带提醒,随后在第 9~12 次失败批次完整结算之后才停止。第 4 次模型请求从未发生。 固定停止消息以 agent_message_chunk 到达 host。
  • S6 / S7 fail-open —— 用 -32601 拒绝 drain 的 host,以及返回合法负载但缺 hasQueuedPrompt 的旧 host,连续失败计数都被永久重置:配置了 enforce,但强制停止永不生效。
  • S8 channel 标记 —— 带 _meta: {"qwen.channel.prompt": true} 的 prompt 即使配置 enforce 也产生保护活动。
  • S9 —— 把 QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD=enforce 同时写进项目 .env.qwen/.env,最终解析为 shadow。项目无法把自己从默认队列提权。
  • S10 / S18 —— 排队 prompt 会重置计数;在第 9 次工具调用时发真实 session/cancel,返回 stopReason: cancelled 且无保护消息。输入与取消优先。
  • S11 vs S12 —— 批次 2 中其他工具的成功不影响连续失败(仍然停止);候选工具自身的成功会清除候选(候选序号 1 → 2,不停止)。PR 描述的这个不对称性在真实栈上成立。
  • S13 —— enforce-typo 会打出非法值告警并回退到 shadow。
  • S17 —— enforce 停止之后,新的 prompt 会从序号 1 重新武装保护并再次停止。prompt 局部生命周期成立。
  • S19 —— 会话模式设为 default 后,host 拒绝了一次真实的 write_file 权限请求。轮次走既有权限路径结束,保护停在 tracked,没有把它计入。(补充:这个 host 只提供 reject_once 一个拒绝选项,且它会直接终止轮次,所以被拒绝的工具根本不可能喂进连续失败计数。)

4. 轮次细节、历史保留与遥测

  • 历史保留 —— enforce 停止后我又发了第二条 user prompt,并检查 agent 实际回放的对话:3 个 assistant 工具调用轮、12 个 tool call、12 个 tool response,每个调用都有应答,最后一个失败批次完整保留,停止说明在场,提醒恰好 1 次。没有为了塞停止消息而丢掉任何东西。
  • 遥测 —— 取自运行中 agent 的 OTel 本地导出,不是测试断言。指标 qwen-code.repeated_tool_failure_guard.count 只带有界的状态转换属性,不含 prompt_idcandidate_ordinalexecution_error_type;结构化日志保留 prompt_id 供关联;终态事件为 loop_detected + loop_type=repeated_tool_execution_failureservice.version 始终存在;deployment.environment 在运维设置 OTEL_RESOURCE_ATTRIBUTES 时可正常透传(实测 deployment.environment=verify-cohort-8469),但默认不存在——所以每个发布环境必须先配置它,Shadow 基线才可 join,这与 PR 的说法一致。

5. 给评审的一条线协议提示

Shadow 是默认模式,而 shadow 会请求排队 prompt 状态。线上实测:off 模式(以及 channel 标记的 prompt)下工具循环的 craft/drainMidTurnQueue 只带 {sessionId},旧负载不变;shadow/warn/enforce 则会加上 todoStopGuardWatchQueuedPrompt: true。也就是说这个 PR 默认改变了所有 ACP host 的 drain 请求。我核对了后果:

  • 一方 daemon 的处理函数(BridgeClient.handleExtMethod)完全忽略该字段,本来就无条件返回 hasQueuedPrompt,所以对它是惰性的。
  • 新的 drained.reliable 只被保护消费;三个读取 reliable 的 Todo Stop Guard 调用点在本 PR 之前就已经在传 watchQueuedPrompt: true,行为未变。
  • 无法提供该字段的 host 由 S7 覆盖(fail-open)。

不是阻塞项,但建议在发布说明里写清楚。

6. 本次未覆盖的部分

  • 重连 / 重启与跨进程重启的历史回放。 未验证。
  • 真实 channel bridge 的端到端链路。 我是在 ACP 线协议层注入 channel 标记(S8),也就是 Session 实际读取的那个边界;两个 bridge 是否都会附带该标记,仅由 91 个单测覆盖。
  • Windows / Linux。 仅 macOS,与 PR 自己的表格一致。
  • Node 26。 我跑的是 24.18.1。
  • 合并后的发布门禁(7 天 Shadow 基线、分阶段 warn/enforce 队列)本质上不可能在本地验证。

结论建议:可以合并。 在 16 组对抗性配置下,实现的行为与描述完全一致;默认模式可证明是惰性的;每一条逃生通道(channel、旧 host、项目 env、排队输入、取消、同工具成功)都是 fail-safe 的。

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 9, 2026
Merged via the queue into QwenLM:main with commit 0a3d7bb Aug 9, 2026
237 of 239 checks passed
@doudouOUC
doudouOUC deleted the feat/acp-repeated-tool-call-protection-v2 branch August 9, 2026 15:49
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants