Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/band/adapters/crewai.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,9 @@ async def _process_message(
# "already handled" / "act on this now" markers, matches what
# CrewAI actually does with the input either way.
prompt = "\n\n".join(sections)
result = await self._crewai_agent.kickoff_async(prompt)
result = await self._kickoff_with_empty_response_retry(
self._crewai_agent, prompt, reply_tracker, room_id
)

except Exception as e:
# An empty response is benign only once some tool ran this turn --
Expand Down Expand Up @@ -461,6 +463,48 @@ async def on_cleanup(self, room_id: str) -> None:
del self._message_history[room_id]
logger.debug("Room %s: Cleaned up CrewAI session", room_id)

async def _kickoff_with_empty_response_retry(
self,
agent: "CrewAIAgent",
prompt: str,
reply_tracker: ReplyTracker,
room_id: str,
) -> Any:
"""Retry a first-call empty completion once before giving up.

CrewAI raises the identical ValueError whether the model correctly has
nothing left to say (fine once a tool has run -- handled by the
caller) or the turn's very first call came back empty. The second case
has run no tool yet, so there is nothing to duplicate: one immediate
retry absorbs a single-call fluke instead of failing the whole
delivery and leaving CrewAI to improvise on a cold redelivery.
"""
try:
return await agent.kickoff_async(prompt)
except Exception as e:
if not (_is_empty_llm_response(e) and not reply_tracker.any_tool_ran):
raise
logger.info(
"Room %s: CrewAI's first LLM call came back empty before "
"any tool ran; retrying",
room_id,
)
try:
return await agent.kickoff_async(prompt)
except Exception as retry_exc:
if _is_empty_llm_response(retry_exc):
logger.warning(
"Room %s: CrewAI's retry also came back empty; giving up",
room_id,
)
else:
logger.warning(
"Room %s: CrewAI's retry failed with a different error: %s",
room_id,
retry_exc,
)
raise

async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None:
"""Send error event (best effort)."""
try:
Expand Down
46 changes: 46 additions & 0 deletions tests/adapters/test_crewai_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import pytest
from pydantic import BaseModel, Field

import band.adapters.crewai as crewai_adapter
from band.adapters.crewai import EMPTY_LLM_RESPONSE_MARKER
from band.core.types import Capability, Emit, PlatformMessage
from band.runtime.prompts import render_system_prompt
Expand Down Expand Up @@ -741,6 +742,51 @@ async def test_empty_answer_with_no_tool_call_still_raises(
)

mock_tools.send_event.assert_awaited_once()
# First call plus the one retry -- proves the retry actually happens
# before the final raise, not a bare pass-through of the first failure.
assert mock_crewai_agent.kickoff_async.call_count == 2
Comment thread
AlexanderZ-Band marked this conversation as resolved.

@pytest.mark.asyncio
async def test_empty_first_call_recovers_on_retry(
self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent
):
"""A turn's first LLM call coming back empty is retried once before
giving up -- a successful retry must complete the turn normally.

Nothing ran before the empty completion, so there is no side effect
for the retry to duplicate; the retry either behaves exactly like the
first attempt would have or, as here, recovers and replies.
"""
mock_result = MagicMock()
mock_result.raw = "Hello! I'm here to help."

async def _kickoff(_prompt):
if mock_crewai_agent.kickoff_async.call_count == 1:
raise ValueError(EMPTY_LLM_RESPONSE_ERROR)
tracker = crewai_adapter._reply_tracker_var.get()
if tracker is not None:
tracker.replied = True
return mock_result

mock_crewai_agent.kickoff_async = AsyncMock(side_effect=_kickoff)

adapter = CrewAIAdapter()
await adapter.on_started("TestBot", "Test bot")
adapter._crewai_agent = mock_crewai_agent

await adapter.on_message(
msg=sample_message,
tools=mock_tools,
history=[],
participants_msg=None,
contacts_msg=None,
is_session_bootstrap=True,
room_id="room-123",
)

assert error_events(mock_tools) == []
# First call plus the one retry that recovered it.
assert mock_crewai_agent.kickoff_async.call_count == 2

@pytest.mark.asyncio
async def test_no_missing_reply_error_after_clean_tool_only_return(
Expand Down