Skip to content

fix: treat CrewAI's empty final answer as a finished turn, not a failure - #621

Merged
AlexanderZ-Band merged 4 commits into
mainfrom
fix/crewai-empty-final-answer-is-normal-turn-end
Sep 7, 2026
Merged

fix: treat CrewAI's empty final answer as a finished turn, not a failure#621
AlexanderZ-Band merged 4 commits into
mainfrom
fix/crewai-empty-final-answer-is-normal-turn-end

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

CrewAI raises ValueError("Invalid response from LLM call - None or empty.") on every turn (this agent replies only via band_send_message, so the model has nothing left to say once its tools ran — measured 61/61 turns in one lane run). The except handler guessed from tool bookkeeping whether that was benign, and re-raised when no terminal (write) tool had run — failing the whole delivery for a turn instructed to do read-only work only (test_task_lifecycle_across_task_adapters's second turn: "call band_list_tasks ... do not call any other tool").

Looked like flakiness / a platform difference, but wasn't: both crewai/ubuntu and crewai/windows ran the same 3 attempts; ubuntu's model obeyed every time and failed every time, windows only passed the attempt where the model disobeyed and sent a message anyway.

Fix

Catch the empty final answer, normalize it to "no final text", and let the existing missing-reply policy (previously unreachable) decide the outcome instead of the except handler guessing. No change to is_terminal_success or read-only classification. Genuine errors still report and raise.

Verification

  • New unit test reproduces the failure: fails on old code, passes with the fix.
  • Full unit suite: 5587 passed. Ruff + pyrefly clean.
  • Real CI: scoped crewai E2E green on both ubuntu and windows, first attempt, no retry needed for the target test (runs 34147284182, 34148069857).

🤖 Generated with Claude Code

https://claude.ai/code/session_01W8z6z1muD8yg9Y3pre92ec

AlexanderZ-Band and others added 4 commits September 7, 2026 20:21
CrewAI raises ValueError("Invalid response from LLM call - None or empty.")
whenever the model returns no final text. This agent answers only through
band_send_message and its system prompt says so, so the model has nothing
left to say once its tools have run: kickoff_async raised on 61 of 61 turns
in the last crewai lane run. The success path after it was unreachable.

Every turn's outcome was therefore decided by the except handler, which
guessed from tool bookkeeping whether the failure was benign and re-raised
when no terminal tool had run. A turn asked to do only read work ("call
band_list_tasks ... do not call any other tool") flips neither replied nor
tool_executed, since fetching state is not terminal -- so the adapter marked
the delivery failed for a turn that did exactly what it was asked. The test
passed only when the model disobeyed and sent a message anyway, which is why
it looked flaky and why it "passed on windows": that job needed all three
attempts and only survived the one where the model called band_send_message.

Catch the empty final answer where it happens and normalize it to "no final
text", then let the post-turn logic run for real. The missing-reply policy
that already existed 20 lines above -- unreachable until now -- becomes the
single place that judges a silent turn, and it keeps the is_terminal_success
semantics the swallow condition used. Genuine errors still report and raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8z6z1muD8yg9Y3pre92ec
Code review on #621 found the swallow was unconditional: a turn where the
very first LLM call came back empty -- no tool call at all, indistinguishable
from a genuine provider failure -- was silently marked processed instead of
failing the delivery for a retry. That also widened the missing-reply gate on
the clean-return path with no coverage, left the exception's own detail
untraced at default log level, and typed the same error string in five places.

ReplyTracker gets any_tool_ran, flipped on any tool call this turn (success or
failure, terminal or not) -- coarser than tool_executed/replied, which only
count terminal work. The empty-answer swallow now requires it: a turn that ran
some tool (even read-only) before going quiet is a finished turn; a turn that
ran nothing at all keeps failing the delivery, same as before this PR.

Also: EMPTY_LLM_RESPONSE_MARKER is now the one definition the matcher and all
four test call sites reference; ReplyTracker.did_productive_work replaces the
inline replied-or-tool_executed check and is now a required parameter; the
missing-reply log moved to WARNING with the exception text kept at DEBUG; the
crewai/pydantic_ai cross-reference and the memory-rehydration E2E exclusion
reason (post-#621, the turn now finishes -- it just never replies) are updated
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8z6z1muD8yg9Y3pre92ec
test_suppresses_empty_final_answer_after_reply and ..._after_tool_only_turn
hand-set ReplyTracker.replied/.tool_executed/.any_tool_ran directly, baking
in the assumption that SEND_MESSAGE and a terminal tool always set
any_tool_ran alongside them -- true today, but nothing would catch it
breaking. Route both through the real _mark_productive_work, like the
read-only sibling test already does. Mutation-verified: all three now fail
if any_tool_ran is removed from _mark_productive_work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8z6z1muD8yg9Y3pre92ec
The test now exercises the real _mark_productive_work path rather than
reporting a bug, so the regression-report framing no longer fits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8z6z1muD8yg9Y3pre92ec
@AlexanderZ-Band
AlexanderZ-Band requested review from a team and a lite review from Copilot and removed request for Copilot September 7, 2026 18:05
@AlexanderZ-Band
AlexanderZ-Band added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 4f62a19 Sep 7, 2026
21 checks passed
@AlexanderZ-Band
AlexanderZ-Band deleted the fix/crewai-empty-final-answer-is-normal-turn-end branch September 7, 2026 18:16
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.

1 participant