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
120 changes: 61 additions & 59 deletions src/band/adapters/crewai.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,29 @@
)


# CrewAI offers no error type or code for an empty completion, so its message
# is the only discriminator. One definition, matched here and faked in tests.
EMPTY_LLM_RESPONSE_MARKER = "Invalid response from LLM call"


def _is_empty_llm_response(exc: Exception) -> bool:
"""Whether ``exc`` is CrewAI reporting that an LLM call came back empty.

``crewai.utilities.agent_utils`` raises this bare ``ValueError`` for every
empty completion in its loop, not only the forced final-answer step — so a
match means "no text came back", never "the turn is healthy".
"""
return isinstance(exc, ValueError) and EMPTY_LLM_RESPONSE_MARKER in str(exc)


def _silence_lite_agent_error_panel() -> None:
"""Deregister CrewAI's benign red "LiteAgent Failed" console panel.

The agent replies via the band_send_message tool, so CrewAI's post-tool step
returns an empty final answer and raises the "Invalid response from LLM call"
ValueError that on_message already swallows — yet its global console listener
prints an alarming panel anyway (regardless of verbose). Remove only that
handler; tracing and genuine errors are untouched. Idempotent (a later call
finds nothing) and best-effort (leave the panel if CrewAI internals move).
This agent answers only through band_send_message, so most turns end on an
empty completion and CrewAI's global console listener prints an alarming
panel anyway (regardless of verbose). Remove only that handler; tracing and
genuine errors are untouched. Idempotent (a later call finds nothing) and
best-effort (leave the panel if CrewAI internals move).
"""
try:
# event_listener is imported for its side effect: registering the handlers.
Expand Down Expand Up @@ -318,7 +332,7 @@ async def _process_message(
*,
is_session_bootstrap: bool,
room_id: str,
reply_tracker: ReplyTracker | None = None,
reply_tracker: ReplyTracker,
) -> None:
"""Internal message processing logic."""
assert self._crewai_agent is not None, "on_message already checked this"
Expand Down Expand Up @@ -393,63 +407,51 @@ async def _process_message(
prompt = "\n\n".join(sections)
result = await self._crewai_agent.kickoff_async(prompt)

if result and result.raw:
self._message_history[room_id].append(
{
"role": "assistant",
"content": result.raw,
}
)
except Exception as e:
# An empty response is benign only once some tool ran this turn --
# otherwise the model's very first call came back empty, which is
# indistinguishable from a genuine provider failure and must keep
# failing the delivery so the platform retries it.
if not (_is_empty_llm_response(e) and reply_tracker.any_tool_ran):
logger.error("Error processing message: %s", e, exc_info=True)
await self._report_error(tools, str(e))
raise
# Keep the exception text: it is the only record that CrewAI raised,
# and this turn is no longer marked failed for the runtime to log.
logger.debug("Room %s: CrewAI returned no text: %s", room_id, e)
result = None

final_text = (result.raw or "") if result else ""
if final_text:
self._message_history[room_id].append(
{
"role": "assistant",
"content": final_text,
}
)

if not (reply_tracker is not None and reply_tracker.replied):
await self._report_error(
tools,
missing_reply_error(
"CrewAI",
detail=(
"Repeated tool failures may also have exhausted "
f"max_iter={self.max_iter}."
),
if not reply_tracker.did_productive_work:
# Warn, not debug: nothing reached the room, and the delivery is
# still acked as processed, so this log is the only operator signal.
logger.warning(
"Room %s: CrewAI turn produced nothing for the room", room_id
)
await self._report_error(
tools,
missing_reply_error(
"CrewAI",
detail=(
"Repeated tool failures may also have exhausted "
f"max_iter={self.max_iter}."
),
)

logger.info(
"Room %s: CrewAI agent completed (output_length=%s)",
room_id,
len(result.raw) if result and result.raw else 0,
),
)

except Exception as e:
# CrewAI raises ValueError("Invalid response from LLM call - None or
# empty.") when its ReAct loop yields an empty final answer. In this
# adapter the agent acts via tools (band_send_message to reply,
# band_store_memory, etc.), so an empty final answer AFTER the agent
# already did productive work is benign noise — a reply went out, or a
# tool-only turn (e.g. a memory store the user told it not to follow
# with a message) completed and there is simply nothing left to say.
# Match that specific ValueError narrowly so genuine no-response
# failures (the LLM returned empty without doing anything) still
# surface as error events and propagate.
if (
reply_tracker is not None
and (reply_tracker.replied or reply_tracker.tool_executed)
and isinstance(e, ValueError)
and "Invalid response from LLM call" in str(e)
):
logger.warning(
"Room %s: CrewAI returned an empty final answer after the agent "
"already did productive work this turn; treating as non-fatal: %s",
room_id,
e,
)
return
logger.error("Error processing message: %s", e, exc_info=True)
await self._report_error(tools, str(e))
raise

logger.debug(
"Message %s processed successfully (history now has %s messages)",
logger.info(
"Room %s: CrewAI turn over for %s (output=%s chars, history=%s)",
room_id,
msg.id,
len(final_text),
len(self._message_history[room_id]),
)

Expand Down
7 changes: 4 additions & 3 deletions src/band/adapters/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,9 +1032,10 @@ async def on_message(
# other response the run cannot turn into output can still spend the
# refused output budget. Once a terminal tool has run (a
# band_send_message reply, a band_store_memory, ...) the work already went
# out, so that exhaustion is benign — mirror the crewai adapter and
# swallow it. Genuine no-response failures (no terminal tool ran — only
# read-only lookups or failed tools) still propagate.
# out, so that exhaustion is benign — swallow it. Genuine no-response
# failures (no terminal tool ran — only read-only lookups or failed
# tools) still propagate here, unlike the crewai adapter, which cannot
# tell them apart from the empty completion that ends its every turn.
if tool_executed and _is_output_retries_exhausted(e):
logger.warning(
"Room %s: Pydantic AI exhausted its output retries after "
Expand Down
9 changes: 8 additions & 1 deletion src/band/integrations/crewai/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@ class ReplyTracker:
"""Mutable per-turn markers shared (by reference) with the tool wrappers.
``replied`` flips once ``band_send_message`` succeeds; ``tool_executed`` flips
once any terminal tool succeeds.
once any terminal tool succeeds; ``any_tool_ran`` flips on any tool call at
all, success or failure, terminal or not.
"""

replied: bool = False
tool_executed: bool = False
any_tool_ran: bool = False

@property
def did_productive_work(self) -> bool:
"""Whether the turn left something behind for the room to show for it."""
return self.replied or self.tool_executed


@dataclass(frozen=True)
Expand Down
6 changes: 5 additions & 1 deletion src/band/integrations/crewai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,12 @@ def _mark_productive_work(
CrewAI raises on an empty final answer; that is a genuine no-response
failure only when nothing terminal ran. ``is_terminal_success`` is the
shared rule for what counts (read-only Band tools and undeclared custom
tools do not).
tools do not) toward the missing-reply decision. ``any_tool_ran`` is
separate and coarser: it flips on this call alone, whether the tool
succeeded or not, so a turn that tried *something* is never mistaken for
one where the model's very first response came back empty.
"""
tracker.any_tool_ran = True
try:
if json.loads(result).get("status") != "success":
return
Expand Down
Loading