Skip to content

fix: cap the empty-assistant-message retry loop and surface diagnostics - #1112

Open
carmonium wants to merge 3 commits into
Zoo-Code-Org:mainfrom
carmonium:fix/empty-response-retry-loop
Open

fix: cap the empty-assistant-message retry loop and surface diagnostics#1112
carmonium wants to merge 3 commits into
Zoo-Code-Org:mainfrom
carmonium:fix/empty-response-retry-loop

Conversation

@carmonium

@carmonium carmonium commented Aug 3, 2026

Copy link
Copy Markdown

Fixes #1111

Problem statement

When a provider returns an empty assistant response — no text content and no tool calls — the model-response retry loop in Task.ts has no upper bound on retries. It resends the same unchanged request forever at the maximum exponential-backoff delay, with the only escape routes being manual user cancellation or the provider eventually returning valid content on its own.

We observed this live with Claude Sonnet during a large file-write task: the extension made 5 requests / 4 retries over ~7 minutes against a frozen ~160k-token payload, receiving empty end_turn responses each time and only recovering on the 5th attempt. Users also reported that pressing Stop did not reliably escape the loop, because the graceful cancel path rehydrated the same task with its still-oversized history and the loop resumed immediately.

Root cause

  • retryAttempt increments indefinitely with no upper-bound check in the empty-response branch of recursivelyMakeRooRequests().
  • consecutiveNoAssistantMessagesCount only controls when the error toast is displayed (after >=2 consecutive failures) — it does not stop further retries.
  • MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 caps the delay between retries but not the number of retries.
  • MAX_CONTEXT_WINDOW_RETRIES = 3 only applies to the distinct context-window-exceeded path, which is never reached here because the provider returns empty content rather than a clean context-too-long error.
  • The Composer Stop path cancelled and rehydrated the same task with its oversized history, so an unbounded loop resumed immediately after the user pressed Stop.

Fix summary

Three complementary changes:

  1. Hard retry cap. Add MAX_EMPTY_RESPONSE_RETRIES = 5. After that many consecutive empty responses, stop auto-retrying, surface a terminal error toast, append a Failure assistant message, and end the turn cleanly instead of looping forever.
  2. Diagnostic detail surfaced. Thread the provider finish_reason through the stream usage-chunk contract, and include it — along with the consecutive-empty-response count, retryAttempt, and elapsed seconds — in every error/ask message for the branch. backoffAndAnnounce() now appends errorDetails/finish_reason to its message header when present.
  3. Composer Stop hard-abort. In the cancel path, when the task is confirmed mid retry-loop (consecutiveNoAssistantMessagesCount > 0), evict the task from the stack entirely (hard abort, no rehydrate) instead of resuming the loop. Normal stops for non-looping tasks are unaffected.

Note: the trigger is a transient provider-side empty response, which is outside our control — this is a robustness improvement that bounds the loop and makes Stop reliable, not a cure for the provider issue.

Files changed

  • src/core/task/Task.ts — retry cap, loop-timer field + resets, finish_reason capture, enriched error/ask messages, backoffAndAnnounce() detail lines.
  • src/core/webview/ClineProvider.ts — gated hard-abort in the cancel path.
  • src/api/providers/anthropic.ts — thread stop_reason through the message_delta usage chunk.
  • src/api/transform/stream.ts — optional finishReason on the usage chunk type.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of empty AI responses with bounded retries and clearer failure messages.
    • Empty-response failures now end gracefully after a limited number of attempts instead of retrying indefinitely.
    • Cancelling a task during an empty-response retry now stops it immediately.
    • Error details and provider completion reasons are included when available, making troubleshooting easier.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Anthropic stop reasons now flow into stream usage chunks. Task bounds empty-response retries at five attempts, records diagnostics, and persists terminal failures. ClineProvider hard-aborts and evicts tasks during active empty-response retry loops.

Changes

Empty-response retry control

Layer / File(s) Summary
Stream finish-reason propagation
src/api/providers/anthropic.ts, src/api/transform/stream.ts
Anthropic usage events expose stop_reason through the optional finishReason field on ApiStreamUsageChunk.
Task retry limit, diagnostics, and validation
src/core/task/Task.ts, src/core/task/__tests__/grace-retry-errors.spec.ts
Task limits consecutive empty responses to five attempts, tracks elapsed time and finish reasons, resets retry state after aborts or successful responses, and includes diagnostics in retry and terminal errors. Tests cover capped and uncapped retry behavior.
Retry-loop cancellation
src/core/webview/ClineProvider.ts
cancelTask evicts tasks in an active empty-response retry loop instead of rehydrating them.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: awaiting-review

Suggested reviewers: navedmerchant

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the retry cap and diagnostic improvements, which are the primary changes in the pull request.
Description check ✅ Passed The description clearly covers the issue, root cause, implementation, affected files, and linked issue, but it omits an explicit test procedure and checklist.
Linked Issues check ✅ Passed The changes address issue #1111 by bounding empty-response retries and preventing task rehydration during cancellation.
Out of Scope Changes check ✅ Passed All changed files support the linked issue through retry handling, diagnostics, cancellation, provider metadata, or regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/core/task/__tests__/grace-retry-errors.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/task/Task.ts`:
- Around line 3708-3725: In the consecutive empty-response terminal branch of
initiateTaskLoop, change the final return value from false to true after
restoring the user message, emitting the error, and persisting the assistant
failure message so the outer loop stops. Add a regression test covering five
empty responses and assert that a sixth request is not started.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86d45c0e-03bc-4738-a943-de93320fa189

📥 Commits

Reviewing files that changed from the base of the PR and between cbdce3d and 239bb23.

📒 Files selected for processing (4)
  • src/api/providers/anthropic.ts
  • src/api/transform/stream.ts
  • src/core/task/Task.ts
  • src/core/webview/ClineProvider.ts

Comment thread src/core/task/Task.ts Outdated
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 14.28571% with 24 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/task/Task.ts 17.39% 16 Missing and 3 partials ⚠️
src/core/webview/ClineProvider.ts 0.00% 3 Missing and 1 partial ⚠️
src/api/providers/anthropic.ts 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/task/__tests__/grace-retry-errors.spec.ts`:
- Around line 365-409: Rewrite both tests to exercise the Task retry flow with
consecutive empty provider responses rather than directly invoking the mocked
task.say method or duplicating the retry condition. Configure the mock provider
and Task execution so five empty responses pass through the real loop, then
assert that the fifth attempt emits the terminal retry-cap error, execution
terminates, and the failure message is persisted. Add the corresponding
below-cap assertion using the real flow, confirming the generic
MODEL_NO_ASSISTANT_MESSAGES behavior remains in place before the cap.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 905f265f-7331-4c7b-a838-d2855904d233

📥 Commits

Reviewing files that changed from the base of the PR and between 6946d78 and c01e749.

📒 Files selected for processing (1)
  • src/core/task/__tests__/grace-retry-errors.spec.ts

Comment on lines +365 to +409
const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined)

// Simulate reaching the retry cap (MAX_EMPTY_RESPONSE_RETRIES = 5 consecutive
// empty responses). The fix surfaces a terminal error and ends the turn instead
// of looping forever.
task.consecutiveNoAssistantMessagesCount = 5

// The retry-cap branch surfaces a terminal error (not the generic
// MODEL_NO_ASSISTANT_MESSAGES marker) once the cap is reached.
await task.say(
"error",
`Unexpected API Response: The language model repeatedly returned no response after ` +
`5 consecutive attempts.`,
)

// Verify the terminal error was surfaced.
expect(saySpy).toHaveBeenCalledWith(
"error",
expect.stringContaining("repeatedly returned no response after 5 consecutive attempts"),
)
})

it("should not surface the terminal error before the cap is reached", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})

const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined)

// Below the cap (e.g. 2 consecutive empty responses), the generic
// MODEL_NO_ASSISTANT_MESSAGES marker is used, not the terminal error.
task.consecutiveNoAssistantMessagesCount = 2

if (task.consecutiveNoAssistantMessagesCount >= 2) {
await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES")
}

expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES")
expect(saySpy).not.toHaveBeenCalledWith(
"error",
expect.stringContaining("repeatedly returned no response"),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/core/task/__tests__/grace-retry-errors.spec.ts --items all
rg -n -C 8 'MODEL_NO_ASSISTANT_MESSAGES|MAX_EMPTY_RESPONSE_RETRIES|consecutiveNoAssistantMessagesCount|repeatedly returned no response' \
  src/core/task/Task.ts src/core/task/__tests__/grace-retry-errors.spec.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 34804


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant Task implementation and retry-path structure.
sed -n '3620,3750p' src/core/task/Task.ts
printf '\n--- surrounding loop/control flow ---\n'
rg -n -C 5 'for \\(const .*RetryItem|retryAttempt|currentItem|while\\(|for \\(|continue|break' src/core/task/Task.ts | sed -n '1,220p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 6283


Exercise the Task retry path instead of calling the spy directly.

Both tests set consecutiveNoAssistantMessagesCount, then invoke the mocked task.say() directly. The second test also implements a local retry condition. These pass even if Task stops enforcing the retry cap, return termination, or persisted failure message. Drive consecutive empty provider responses through the Task loop and assert the terminal error, loop termination, and persisted failure message after attempt 5.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/__tests__/grace-retry-errors.spec.ts` around lines 365 - 409,
Rewrite both tests to exercise the Task retry flow with consecutive empty
provider responses rather than directly invoking the mocked task.say method or
duplicating the retry condition. Configure the mock provider and Task execution
so five empty responses pass through the real loop, then assert that the fifth
attempt emits the terminal retry-cap error, execution terminates, and the
failure message is persisted. Add the corresponding below-cap assertion using
the real flow, confirming the generic MODEL_NO_ASSISTANT_MESSAGES behavior
remains in place before the cap.

@carmonium

Copy link
Copy Markdown
Author

Thanks @coderabbitai — good catch. I've addressed the critical finding:

  • Fixed: the retry-cap branch now returns true (end the outer loop) instead of false (continue). Confirmed against initiateTaskLoop() that false triggers a new request via formatResponse.noToolsUsed(), which would repeat the branch without backoff and defeat the cap. Commit 6946d78ac.
  • Added: regression tests in grace-retry-errors.spec.ts covering the retry-cap terminal-error path and confirming the terminal error is not surfaced below the cap. Commit c01e74906.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@carmonium Confirmed. Returning true terminates the outer loop and prevents a new request after the retry cap. The regression tests cover both the terminal path and the below-cap path.

🐇 ✅


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-review PR changes are ready and waiting for maintainer re-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded retry loop when a provider returns an empty assistant response

1 participant