docs/design/acp-repeated-tool-call-protection.md
Date: 2026-07-31 Status: Implemented on the PR branch; pre-merge manual ACP validation and post-merge shadow rollout pending Revision: Updated for PR #8176 and PR #8180 Area: Interactive ACP foreground prompt loop
ACP should stop an automatic model loop when the same resolved tool repeatedly reaches the same trusted execution failure. The protection is conservative: it observes only finalized, fully settled tool batches; gives the model one fixed corrective reminder per candidate streak; and stops only if a later matching batch repeats the same failure without that tool succeeding in between.
The first version is an in-memory, per-prompt semantic guard. It does not replace the existing protections for duplicate provider call IDs, invalid parameters, or the per-turn tool-call cap. It also does not attempt to provide cross-restart exactly-once execution.
Two telemetry changes are prerequisites:
status authoritative and normalizes cancellation and error fields.executionStatus axis and fixes ACP permission-cancellation classification.Enforcement must remain disabled when either contract is unavailable or
unknown. ACP's internal batch receipt must also preserve the structured
execution error type that PR #8180 already freezes while invocation.execute()
settles; the public response and JSON protocols do not need another field.
The current ACP path has three useful but incomplete circuit breakers:
They do not catch the common semantic loop in which the model keeps issuing fresh call IDs for a tool that is actually entered and repeatedly fails for the same structured reason. A high total-call cap stops the eventual runaway but wastes model rounds, tool latency, and tokens before doing so.
Terminal error alone is not a safe signal. It currently includes calls that
never executed, such as validation and permission failures, as well as failures
that happen after successful execution. Treating all of those as repeated tool
failures creates false positives and, in particular, turns user cancellation
into a product stability problem. The 468 recent permission cancellations that
were recorded as errors are a concrete example.
Those are separate problems with different trust and durability boundaries. Combining them with semantic loop detection would make the first release harder to validate without improving its decision signal.
The reducer consumes the integrated result of PR #8176 and PR #8180. It must
not reconstruct execution state from legacy success, error strings, UI
frames, or spans.
Terminal status | executionStatus | Meaning for this guard |
|---|---|---|
success | success | Reset a candidate for the same resolved tool |
success | not_started | Reset the same-tool candidate; protocol-level synthetic result |
error | not_started | Reset; validation, permission rejection, hook block, or lookup failure |
error | error | Eligible only with a trusted frozen executionErrorType |
error | success | Reset; execution succeeded and later processing failed |
cancelled | any | Reset |
| any | cancelled | Reset |
| any | missing or unknown | Reset and downgrade the prompt to at most warn |
The two invalid combinations defined by PR #8180,
success/error and success/cancelled, are treated as contract violations:
reset the guard, emit a diagnostic, and do not enforce.
status wins cancellation arbitration. If an execution error races with a
user or parent cancellation and the terminal status is cancelled, the call
does not count.
Terminal errorType is not the repeated-failure key. A post-execution hook,
image bridge, or other finalization step may replace it even when
executionStatus remains error. The internal receipt therefore copies the
executionErrorType captured at execution settle. Missing or
ToolErrorType.UNKNOWN execution classifications are ineligible.
An eligible failure has:
status = error;executionStatus = error;executionErrorType other than
ToolErrorType.UNKNOWN;The failure key is:
(policyToolName, executionErrorType)
policyToolName is the existing resolved tool.name value that ACP uses for
permission checks, not a model-provided display name. MCP registered names
already include their server-qualified identity. executionErrorType is frozen
at execution settle, independently from the terminal call error.
Arguments are deliberately excluded. This catches parameter thrashing against the same execution boundary, while the threshold and two-batch requirement provide false-positive headroom. Raw arguments, output, paths, and error text must not be stored in the guard or emitted to central telemetry.
Unknown tools, blank identities, unclassified errors, and third-party events with incomplete outcome fields are not eligible.
The Session owns one guard per interactive foreground ACP prompt:
type RepeatedToolFailureState =
| { phase: 'idle' }
| {
phase: 'tracking';
key: FailureKey;
failureCount: number;
batchCount: number;
}
| {
phase: 'warned';
key: FailureKey;
failureCount: number;
batchCount: number;
}
| {
phase: 'latched';
key: FailureKey;
failureCount: number;
batchCount: number;
};
The threshold is fixed at eight eligible failures across at least two complete model batches. It is a code constant in the first release, not a user setting.
After every runToolCalls batch has fully settled:
idle and
leave the existing input and FIFO behavior authoritative. If the drain is
unreliable, reset and disable enforcement for the rest of the prompt.idle if the batch is incomplete, violates the outcome contract,
or contains cancellation, unknown, not-started, or post-execution failure.idle. A successful observation resets only a
candidate for the same resolved tool; successful observations from other
tools neither advance nor reset the candidate. A success has no execution
error type, so it clears every failure classification for that tool before
the remaining unique failure key is selected.failureCount >= 8 and batchCount >= 2, transition to warned and
request the mode-specific reminder action shown below.latched.
Intervening successes from other tools do not hide the repeated failure; a
success from the candidate tool resets it. The configured mode determines
whether another model request is sent.The state transition and control action are separate:
| Mode | Threshold reached | Next matching batch |
|---|---|---|
shadow | Record would_warn; inject nothing | Record would_stop, latch, and continue |
warn | Inject once and record warned | Record would_stop, latch, and continue |
enforce | Inject once and record warned | Record stopped, latch, and stop before another send |
Once latched, the guard emits no further decisions for that prompt. This avoids repeated reminders and telemetry amplification in shadow and warn modes. Resetting a candidate and later tracking a different failure key may produce a new reminder; the one-reminder guarantee is per candidate streak, not per top-level prompt.
The first release intentionally retains one active candidate instead of a map of independent streaks. After same-tool successes are removed, multiple remaining failure keys reset because choosing which concurrent failure should own a reminder or stop is ambiguous. This keeps enforcement conservative while still detecting the dominant interleaved shape in which one tool keeps failing as read, edit, or inspection tools succeed between attempts.
The reminder is:
System: the same tool execution has failed repeatedly for the same classified reason. Do not repeat the same approach. Inspect the returned result, change the approach or required preconditions, or explain the blocker.
The stopped message is:
System: Automatic continuation stopped because the same tool execution failure continued after a corrective reminder. New user input is required to continue.
The messages contain neither raw arguments nor raw error text. They are fixed system context, not fabricated user input.
The unit of reduction is a completed model tool batch, not an individual streaming event. This is required because Agent calls may execute concurrently and terminal frames may arrive in a different order from the model's function calls.
runToolCalls returns a narrow batch receipt only after all admitted calls have
settled. Each receipt entry contains callId, policyToolName, terminal
status, frozen executionStatus, frozen executionErrorType, and whether it
was a provider duplicate; it contains no arguments, result, or raw error.
#buildNextMessageAfterToolRun drains mid-turn input first, then passes that
receipt and the drain's parts, hasQueuedPrompt, and reliable state to the
reducer before constructing the next model message. This preserves the existing
external-input priority. Outcomes are kept in original model call order,
although the reduction is order-independent.
The guard never:
If the Session cannot prove that the batch is complete, it resets and does not
enforce. PR #8180's frozen execution status is necessary but does not by itself
prove batch completion; the reducer is called only from the settled
runToolCalls boundary.
The checks remain ordered from most specific to broadest:
executionStatus = not_started).Only the first guard that stops the turn records the terminal loop reason.
This design adds a distinct LoopType.REPEATED_TOOL_EXECUTION_FAILURE so
operators can distinguish it from invalid parameters, duplicate IDs, and the
total cap.
When this guard stops, ACP must:
The guard stays latched until the active prompt finishes. A later top-level prompt, including an explicit retry or continue request, creates a fresh guard under the existing Session lifecycle.
The first release applies only to the selected live Session owner processing an
interactive foreground ACP prompt. Both channel bridge implementations mark
their prompts explicitly, and Session forces those marked prompts to off
even when the process is configured for enforcement. It is not process-global
and must not fall back to a legacy or primary runtime when workspace ownership
is unknown. The marker is client-asserted ACP metadata, so another client can
also opt its prompt out of this conservative protection; it is a routing hint,
not a trust signal or authorization boundary.
Modes:
off: no reducer or telemetry.shadow: compute decisions but do not inject or stop.warn: inject the reminder but never stop.enforce: inject and stop according to the state machine.Default is shadow. The deployment control plane must not assign enforce to
unknown ownership, untrusted producers, or mixed deployment versions; the
runtime does not infer those deployment properties. A missing
executionStatus or an unsupported outcome combination resets the streak and
downgrades the rest of that prompt to at most warn. Cron, notification, and
background routes remain off in the first release.
The mode is an operator-controlled deployment policy, not a user-facing
setting. QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD selects off, shadow,
warn, or enforce when the Session starts; missing or invalid values resolve
to shadow, and a non-empty invalid value records an operator diagnostic.
Project .env, project .qwen/.env, and workspace settings.env sources are
not allowed to set this policy; an exported process value or a user-level
environment file remains valid. The deployment control plane must set it above
shadow only on the assigned version-pinned cohort. This feature does not
introduce a second rollout or owner-assignment service.
The guard depends on the ACP host implementing craft/drainMidTurnQueue with a
boolean hasQueuedPrompt when the guard is enabled. Older or third-party hosts
that reject, time out, or return an incomplete drain response produce one
unreliable_input reset diagnostic, disable enforcement for that prompt, and
remain unable to accumulate a candidate until the input boundary is reliable.
This fail-open behavior is compatibility-safe but must be segmented from a
supported-host shadow baseline.
Emit low-cardinality counters plus one data-minimized structured diagnostic log per reducer transition:
success,
cancelled, not_started, unknown, mixed, incomplete,
external_input, or contract_violation;0, 1-2, 3-4, 5-7, or 8+;0, 1, 2, or 3+;prompt_id already emitted by tool-call telemetry, in the
diagnostic log only, so authorized rollout analysis can join guard
transitions to the settled tool batch without a second identifier space; andThe prompt ID and candidate ordinal are never metric labels. The ordinal cannot
correlate a tool across prompts and does not reveal its identity. An
idle-to-idle observation emits nothing.
The terminal repeated_tool_execution_failure loop event uses the same
OpenTelemetry prompt ID and explicitly bypasses QwenLogger/RUM at the Session
call site. The shared Core logger does not infer destinations from the loop
type. Other loop types keep their existing telemetry behavior.
Do not emit tool arguments, results, raw error messages, stack traces, paths, MCP server names, user IDs, or the private failure key in guard-specific fields. The existing OpenTelemetry prompt correlation and the terminal loop event's session field retain their normal meaning and access policy.
Cancellation is excluded from every failure-rate numerator. The primary execution SLI uses PR #8180's contract:
execution_status = error
────────────────────────────────────────
execution_status in {success, error}
The legacy success field and pre-PR deployment data must not be used to
validate this guard.
status dimension
from #8176 and the independent execution counter from #8180.executionErrorType in the ACP batch receipt;
do not infer it later from terminal errorType.The 468 historical permission-cancellation records remain evidence of the old producer bug, not eligible guard failures. Recalculate the seven-day baseline only for deployment versions containing the integrated contract, separately for internal and public cloud. Do not mix old and new versions.
Implement the pure reducer and wire it to the settled ACP batch boundary in
shadow mode. Run for at least seven complete days in both environments.
Shadow mode advances a virtual warned state without injecting the reminder, so
would_warn and would_stop estimate volume only. They cannot establish how a
model behaves after seeing the reminder.
Before the baseline starts, each deployment must configure a stable
OpenTelemetry Resource attribute such as deployment.environment that
distinguishes internal from public cloud. The SDK always supplies
service.version; it deliberately does not invent a deployment environment.
Without that Resource dimension, Phase 1 cannot produce a per-environment
conclusion and rollout must not advance.
The default shadow mode does not change model continuation or injected
messages. It does add todoStopGuardWatchQueuedPrompt: true to the existing
mid-turn drain request so the reducer can prove that no full prompt is queued.
Hosts without that response contract are counted through unreliable_input
and excluded from supported-host shadow conclusions.
Required invariants:
not_started, unknown execution classifications, or post-execution
failures counted;would_stop is preceded by would_warn for the same candidate ordinal
and prompt; andManually review a privacy-safe sample of would-stop sessions using authorized local trace access. Classify whether the unmodified continuation made useful progress; use this to reject a clearly unsafe threshold, not to approve enforcement.
Enable warn for internal interactive ACP foreground prompts. Hold for seven days and
confirm that reminder injection does not increase cancellation, reconnect,
latency, token, or round-count regressions. Public cloud remains in shadow.
Only warn-mode prompts show whether the model repeats the failure after the
actual corrective reminder; that cohort supplies the semantic evidence for
enforcement.
Enable enforcement for at most 5% of stable, version-pinned internal
interactive ACP foreground owners. Assignment is deterministic by owner so one
prompt cannot switch treatment mid-run. The remaining 95% stays in warn, so
both treatment and control receive the same corrective reminder and differ only
in whether the post-reminder matching batch stops. Hold each wave for seven
days.
Promote only if:
Use owner-level blocked analysis and confidence intervals; calls from one owner
are not independent samples. Any contract violation or cancellation
misclassification immediately returns the environment to shadow.
Do not ramp beyond 5% until treatment saturation has been checked at higher assignment levels through capacity testing at projected full traffic. If interference cannot be ruled out, keep a permanent control and cap enforcement at 5%.
Public cloud repeats shadow, warn, and limited enforcement independently after the internal gate passes. It never inherits an internal pass.
Keep the change small:
repeated-tool-failure-guard.ts reducer beside ACP Session code;Because the change touches Core telemetry types and ACP Session orchestration, it requires maintainer ownership under the repository's core-infrastructure gate.
Suggested delivery sequence:
Automated unit tests for the pure reducer cover:
not_started, unknown, post-processing
failure, mixed failure keys, incomplete batch, unreliable drain, queued
prompt, and new input reset;Automated ACP Session and channel tests cover:
unreliable_input; andTelemetry tests cover:
The behavioral change will use a local E2E plan under .qwen/e2e-tests/.
Preparing that plan and completing its manual ACP fixture run are still pending
and are required before merge. Ready for review means maintainer review may
proceed; it does not claim that the pre-merge manual ACP fixture validation is
complete. The fixture must cover one typed failing tool, permission
cancellation, successful recovery after the reminder, a repeated failure that
stops, concurrent siblings, unsupported hosts, channel exclusion, reconnect and
restart behavior, history replay, a fresh prompt after stop, and shadow-mode
non-interference. Separate seven-day internal and public-cloud
shadow baselines start after merge and deployment; they gate later promotion to
warn or enforce rather than merge.
Before delivery, run targeted Core and CLI Vitest files from their package
directories, then npm run build, npm run typecheck, and npm run lint.
Adopt the two-axis outcome contract and implement the conservative per-prompt state machine after PR #8176 and PR #8180 are integrated. Do not count any legacy, cancelled, pre-execution, unknown, or post-execution outcome. Keep exactly-once transport, durable cross-restart state, and global rollout orchestration outside the first implementation unless production evidence shows that the bounded in-memory guard is insufficient.