Skip to content

fix: retry CrewAI's first-call empty completion before failing - #624

Merged
AlexanderZ-Band merged 4 commits into
mainfrom
fix/crewai-first-call-empty-response-retry-INT-1427
Sep 12, 2026
Merged

AlexanderZ-Band merged 4 commits into
mainfrom
fix/crewai-first-call-empty-response-retry-INT-1427

Conversation

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

Summary

  • CrewAI's native OpenAI tool-calling path can return an empty completion (finish_reason='stop', no content, no tool_calls) on a turn's very first LLM call, before any tool has run — reproduced 8/8 times across local live runs, root-caused and filed upstream at crewAIInc/crewAI#7349.
  • That case is indistinguishable from a genuine provider outage from the caller's side, so CrewAIAdapter always failed the whole delivery — even though nothing ran yet this turn, so nothing is duplicated by retrying.
  • Adds one bounded, in-process retry (_EMPTY_RESPONSE_FIRST_CALL_RETRIES = 1) via a new _kickoff_with_empty_response_retry helper, only for _is_empty_llm_response(e) and not reply_tracker.any_tool_ran. Any other failure, or exhausting the retry, still reports the error and raises exactly as before.
  • No change to the existing any_tool_ran swallow logic (PR fix: treat CrewAI's empty final answer as a finished turn, not a failure #621).

Traced from last night's failed nightly E2E run (34307490909) → root-caused → planned and independently fact-verified → implemented, per INT-1427 (plan attached to the ticket).

Test plan

  • uv run --extra dev-crewai pytest tests/adapters/test_crewai_adapter.py -v — 82 passed
    • Updated test_empty_answer_with_no_tool_call_still_raises to assert the retry happens (kickoff_async.call_count == 2) before the final raise
    • Added test_empty_first_call_recovers_on_retry: first call empty, second call replies — turn completes, no error event
  • uv run ruff check . / uv run ruff format . — clean
  • uv run pyrefly check — 0 errors
  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ — 5590 passed, 146 skipped

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fw8AZwnoa3Fen6HBhHEQ7H

…427)

CrewAI's native OpenAI tool-calling path can return an empty completion
(finish_reason='stop', no content, no tool_calls) on a turn's very first
LLM call, before any tool has run. That's indistinguishable from a real
provider outage, so the adapter always failed the delivery outright --
even though nothing ran yet this turn, so a single retry duplicates
nothing and often recovers the turn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fw8AZwnoa3Fen6HBhHEQ7H
@linear-code

linear-code Bot commented Sep 9, 2026

Copy link
Copy Markdown

INT-1427

band.adapters.crewai is already safely imported at module scope in this
file (it defers its own crewai imports to TYPE_CHECKING/function-local),
so importlib.import_module inside the test just re-fetched the same
cached module object for no reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fw8AZwnoa3Fen6HBhHEQ7H

@AlexanderZ-Band AlexanderZ-Band 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.

Automated multi-lens review (my-code-review, 6 groups: Style & Hygiene, Design & Reuse, Runtime Safety, Test Quality, Simplification, Logical Bugs; --fix).

3 inline comments below. One additional finding couldn't be placed inline (it's on pre-existing code outside this diff's hunks, per GitHub's line-anchoring rules):

suggestion: src/band/adapters/crewai.py:423 (and the debug log at :428) -- when the retry is exhausted, logger.error("Error processing message: %s", e, exc_info=True) logs the same text CrewAI would raise on a plain first-call failure, with no attempt count. The only other evidence a retry happened is the logger.info at line 500, which fires before the attempt and isn't tied to the eventual outcome -- so a log pipeline filtered to WARNING/ERROR can't tell, from line 423 alone, whether a delivery failed after one attempt or after the retry fired and failed again.

No blocking findings. Logical Bugs (Group F) built 3 targeted repros against the exact head SHA (retry-cap off-by-one, tool-runs-mid-first-call, exception-type confusion) -- none reproduced. Design & Reuse (Group B) confirmed the retry-loop shape matches this repo's existing precedent for small bounded retries (gemini.py, codex/rpc_base.py, crewai_flow.py).

Comment thread tests/adapters/test_crewai_adapter.py
Comment thread src/band/adapters/crewai.py Outdated
Comment thread src/band/adapters/crewai.py Outdated
AlexanderZ-Band and others added 2 commits September 9, 2026 13:09
Code review findings on PR #624:
- The retry loop's while/attempt-counter machinery was sized for a
  general N-retry problem the ticket doesn't have (bound is fixed at
  1) -- collapsed to a single inline try/except. The now-unused
  _EMPTY_RESPONSE_FIRST_CALL_RETRIES constant and the comment
  restating the docstring's rationale go with it.
- The retry's own failure had no distinct log line, so an operator
  filtering at WARNING+ couldn't tell a delivery failed after one
  attempt from failing after the retry also came back empty -- added
  a warning log at the point the retry itself fails.
- Test assertions on the resulting call count no longer reference a
  governing constant (removed above), so they stay literal with a
  short comment instead of drifting toward a fake single source of
  truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fw8AZwnoa3Fen6HBhHEQ7H
/simplify pass on PR #624:
- The retry's second attempt logged "came back empty" unconditionally
  on any exception, not just another empty-response ValueError -- a
  real failure (timeout, auth, etc.) during the retry would be
  mislabeled. Now re-checks _is_empty_llm_response on the retry's own
  exception before choosing the log wording.
- The new test's manual `calls` counter duplicated state the mock
  already tracks (kickoff_async.call_count); reads that instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fw8AZwnoa3Fen6HBhHEQ7H
@AlexanderZ-Band
AlexanderZ-Band requested a review from a team September 9, 2026 10:23
@AlexanderZ-Band
AlexanderZ-Band added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 83ccf8d Sep 12, 2026
20 checks passed
@AlexanderZ-Band
AlexanderZ-Band deleted the fix/crewai-first-call-empty-response-retry-INT-1427 branch September 12, 2026 19:48
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.

2 participants