mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
1f10ef8050
* 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
107 lines
5.0 KiB
YAML
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()}`"
|