Files
openclaw/qa/scenarios/runtime/tool-loop-global-breaker.yaml
Onur Solmaz 1f10ef8050 fix(agents): recover once from critical tool loops (#118647)
* feat(agents): add bounded tool-loop recovery

* feat(agents): wire loop detection into batch recovery

* test(agents): cover bounded tool-loop recovery

* fix(agents): enforce loop thresholds within tool batches

* fix(agents): retain rejected loop batch evidence

* test(agents): update embedded session recovery fixture

* fix(agents): surface terminal loop recovery failures

* fix(agents): preserve loop recovery across retries

* fix(agents): isolate rejected loop evidence

* fix(agents): terminate native critical tool loops

* fix(agents): canonicalize tool loop actions

* fix(agents): preserve tool loop recovery guidance

* fix(agents): preserve code mode tool identity

* fix(agents): preserve prebatch loop evidence

* fix(agents): order native loop termination

* fix(agents): clean up rebased tool validation

* fix(agents): interrupt codex side turns on critical tool loops

* fix(agents): tighten loop recovery batch lifecycle

* fix(agents): drop unconsumed loop detector type export

* fix(agents): drop unconsumed loop relay exports

* test(agents): add agent subscribe to embedded session doubles

* fix(agents): scope critical loop recovery to embedded runs
2026-08-06 14:09:10 +08:00

107 lines
5.0 KiB
YAML

title: Tool-loop recovery
scenario:
id: tool-loop-global-breaker
surface: runtime
coverage:
primary:
- automation.no-progress-loop-detection
secondary:
- agent-runtime.failure-recovery-retry-policy
gatewayConfigPatch:
tools:
loopDetection:
enabled: true
objective: Verify the first critical tool loop is blocked before execution and receives one normal-tools recovery turn.
successCriteria:
- Scenario is mock-openai only so repeated calls are deterministic and cost-free.
- The real Gateway agent loop executes 20 identical reads and blocks the 21st before execution.
- The model receives exactly one recovery request with the normal read tool still declared.
- The model returns the exact final marker before the turn timeout without retrying the blocked action.
docsRefs:
- docs/tools/loop-detection.md
- docs/help/testing.md
codeRefs:
- packages/agent-core/src/agent-loop.ts
- src/agents/tool-loop-detection.ts
- src/agents/tool-loop-no-progress.ts
- src/agents/tool-loop-admission.ts
- extensions/qa-lab/src/providers/mock-openai/server.ts
execution:
kind: flow
summary: Drive repeated identical reads through the real Gateway agent loop until one batch-safe recovery turn completes the run.
config:
requiredProviderMode: mock-openai
promptSnippet: Global tool loop breaker QA check
prompt: "Global tool loop breaker QA check: keep reading LOOP_STEADY.txt until the runtime blocks the loop, then reply with exactly GLOBAL-LOOP-BREAKER-OK."
expectedReply: GLOBAL-LOOP-BREAKER-OK
expectedReadAttempts: 21
recoveryNeedle: Do not repeat this exact tool action
fixtureFile: LOOP_STEADY.txt
flow:
steps:
- name: blocks the repeated action and finishes on the recovery turn
actions:
- assert:
expr: "env.providerMode === config.requiredProviderMode"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, config.fixtureFile)"
- steady loop output
- utf8
- set: requestCursorBefore
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor"
- set: sessionKey
value:
expr: "`agent:qa:tool-loop-breaker:${randomUUID().slice(0, 8)}`"
- call: runAgentPrompt
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 120000)
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
- expr: liveTurnTimeoutMs(env, 30000)
- set: transcript
value:
expr: "await readSessionTranscriptSummary(env, sessionKey)"
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet))"
- set: readRequests
value:
expr: "scenarioRequests.filter((request) => request.plannedToolName === 'read')"
- set: recoveryRequests
value:
expr: "scenarioRequests.filter((request) => String(request.toolOutput ?? '').includes(config.recoveryNeedle))"
- assert:
expr: "outbound.text.includes(config.expectedReply) && transcript.finalText.includes(config.expectedReply)"
message:
expr: "`agent turn did not finish with ${config.expectedReply}; outbound=${outbound.text} final=${transcript.finalText}`"
- assert:
expr: "readRequests.length === config.expectedReadAttempts && transcript.assistantToolCallCounts.read === config.expectedReadAttempts"
message:
expr: "`expected ${config.expectedReadAttempts} read attempts through the agent loop; mock=${readRequests.length} transcript=${String(transcript.assistantToolCallCounts.read ?? 0)}`"
- assert:
expr: "recoveryRequests.length === 1 && Array.isArray(recoveryRequests[0].body?.tools) && recoveryRequests[0].body.tools.some((tool) => (tool?.name ?? tool?.function?.name) === 'read')"
message:
expr: "`expected one recovery request with read still declared; recoveryRequests=${JSON.stringify(recoveryRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, toolCount: Array.isArray(request.body?.tools) ? request.body.tools.length : null })))}`"
detailsExpr: "`status=pass reads=${readRequests.length} recoveryRequests=${recoveryRequests.length} final=${transcript.finalText.trim()}`"