diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index f49924fe1..ba7047123 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -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 -- @@ -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: diff --git a/tests/adapters/test_crewai_adapter.py b/tests/adapters/test_crewai_adapter.py index 92c53642a..48ccae12a 100644 --- a/tests/adapters/test_crewai_adapter.py +++ b/tests/adapters/test_crewai_adapter.py @@ -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 @@ -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 + + @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(