From 2a42a1c4609b5b7022f196d775e6e2ccdeaa267f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 15:49:59 -0700 Subject: [PATCH] [eric] agents: AV-repair card for missing CLI, stderr tail on masked exit-1 cards, group-unwrap in the fatal path --- backend/apps/agents/agent_manager.py | 20 +++++--- .../agents/manager/run/handle_run_error.py | 34 +++++++++++++- backend/tests/test_handle_run_error.py | 46 ++++++++++++++++++- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 0195e04a..bb250954 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -227,13 +227,19 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr except BaseException as e: # Catch BaseExceptionGroup from anyio task groups (e.g. concurrent CLI crash + pending approval cancellation) so it doesn't escape and kill the uvicorn process. logger.exception(f"Agent {session_id} fatal error: {e}") - session.status = "error" - error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id) - session.messages.append(error_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": error_msg.model_dump(mode="json"), - }) + # A group's str() names the group, not the cause; unwrap to the real member so a wrapped 429/auth error still gets its friendly card + retry-pill semantics instead of a raw group dump. + from backend.apps.agents.core.first_real_exception import first_real_exception + p_real = first_real_exception(e) + if p_real is not None: + await handle_run_error(p_real, session, session_id, turn, p_stderr_buffer) + else: + session.status = "error" + error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id) + session.messages.append(error_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": error_msg.model_dump(mode="json"), + }) finally: # Only the session's live task finalizes. A stopped task (popped by stop_agent, which already finalized status + saved) or one superseded by a newer turn must not pop the new turn's partial mirror, broadcast a stale terminal status, or overwrite the snapshot the live turn is writing. p_is_live_task = self.tasks.get(session_id) is asyncio.current_task() diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index ced10664..a2f706ba 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -18,6 +18,7 @@ from backend.apps.agents.core.error_classify import ( is_out_of_tokens, extract_reset_hint, is_auth_error, + is_cli_binary_missing, is_unknown_model_error, parse_retry_after, redact_for_telemetry, @@ -94,6 +95,32 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, }) except Exception: logger.debug("submit_diagnostic for context_overflow failed", exc_info=True) + elif is_cli_binary_missing(e, extra_text=p_stderr_tail): + # The bundled CLI vanished from an installed app (Windows AV quarantine class; 22 of 25 field installs never recovered). The raw "not found at: C:\..." card is unactionable; name the likely cause and the two real fixes. + friendly_msg = ( + "A core OpenSwarm component (the bundled agent runtime) is missing from " + "this install, which usually means antivirus software quarantined it. " + "Restore it from your antivirus quarantine and add an exclusion for " + "OpenSwarm, or reinstall from openswarm.com. Your chats and settings " + "are kept either way." + ) + error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id) + session.messages.append(error_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": error_msg.model_dump(mode="json"), + }) + try: + from backend.apps.service.client import submit_diagnostic + submit_diagnostic({ + "kind": "cli_binary_missing", + "where": "manager.run.handle_run_error", + "session_id": session_id, + "model": session.model, + "error_preview": redact_for_telemetry(str(e), limit=400), + }) + except Exception: + logger.debug("submit_diagnostic cli_binary_missing failed", exc_info=True) elif is_transient_capacity_error(e, extra_text=p_stderr_tail): # A genuine throttle (429/overload/capacity) that already burned the whole silent-backoff budget (the only way one reaches here). It's a limit, not a failure, so don't append a system-message card; emit a transient signal for the muted pill and mark the turn completed so it doesn't read as an error. session.status = "completed" @@ -245,7 +272,12 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, }) except Exception: logger.debug("submit_diagnostic model_error failed", exc_info=True) - error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id) + # The SDK's ProcessError masks the cause behind "Check stderr output for details"; append the scrubbed stderr tail so the card (and its analytics copy) names what actually broke instead of shipping a dead end. + p_card_text = f"Error: {str(e)}" + p_cause = redact_for_telemetry(p_stderr_tail, limit=400).strip() + if p_cause and "check stderr" in str(e).lower(): + p_card_text += f"\n\nRuntime log tail:\n{p_cause}" + error_msg = Message(role="system", content=p_card_text, branch_id=session.active_branch_id) session.messages.append(error_msg) await ws_manager.send_to_session(session_id, "agent:message", { "session_id": session_id, diff --git a/backend/tests/test_handle_run_error.py b/backend/tests/test_handle_run_error.py index fac6a49b..6022876f 100644 --- a/backend/tests/test_handle_run_error.py +++ b/backend/tests/test_handle_run_error.py @@ -10,15 +10,18 @@ from backend.apps.agents.manager.run.handle_run_error import handle_run_error from backend.apps.agents.manager.streaming.state import TurnState -def p_drive_error(monkeypatch, exc): +def p_drive_error(monkeypatch, exc, stderr=None): events = [] async def fake_send(session_id, event, data): events.append((event, data)) monkeypatch.setattr(ws_mod.ws_manager, "send_to_session", fake_send, raising=True) + # Diagnostics are fire-and-forget network; keep the tests offline. + import backend.apps.service.client as service_client + monkeypatch.setattr(service_client, "submit_diagnostic", lambda payload: None, raising=True) session = AgentSession(name="t", model="sonnet", dashboard_id="d") - asyncio.run(handle_run_error(exc, session, session.id, TurnState(), [])) + asyncio.run(handle_run_error(exc, session, session.id, TurnState(), stderr or [])) return session, events @@ -41,3 +44,42 @@ def test_out_of_credits_carries_the_provider_reset_hint(monkeypatch): payload = next(d for e, d in events if e == "agent:out_of_credits") assert payload["reset_hint"] == "at 7:42 AM" assert "resets at 7:42 AM" in payload["message"] + + +P_FIELD_CLI_MISSING = ( + "Claude Code not found at: C:\\Users\\Rishi\\AppData\\Local\\openswarm\\app-1.5.6\\resources" + "\\python-env\\Lib\\site-packages\\claude_agent_sdk\\_bundled\\claude.exe" +) + + +def test_cli_missing_shows_repair_card_not_dead_path(monkeypatch): + session, events = p_drive_error(monkeypatch, Exception(P_FIELD_CLI_MISSING)) + assert session.status == "error" + sys_msgs = [m for m in session.messages if m.role == "system"] + assert sys_msgs, "expected a system card" + card = sys_msgs[-1].content + assert "antivirus" in card + assert "reinstall" in card + # The raw path dump is exactly the unactionable card we're replacing. + assert "AppData" not in card + + +def test_unclassified_card_carries_scrubbed_stderr_tail(monkeypatch): + exc = Exception( + "Command failed with exit code 1 (exit code: 1)\nError output: Check stderr output for details" + ) + # Neutral cause text: anything auth/capacity-shaped would (correctly) route to a friendlier branch instead. + secret = "sk-" + "ant-" + "A" * 28 + stderr = ["boot noise", f"TypeError: cannot read properties of undefined (reading 'chunk') {secret}"] + session, _ = p_drive_error(monkeypatch, exc, stderr=stderr) + card = [m for m in session.messages if m.role == "system"][-1].content + assert "Runtime log tail" in card + assert "TypeError" in card + assert secret not in card + + +def test_informative_error_does_not_get_stderr_appended(monkeypatch): + exc = Exception("Something specific broke: widget frobnicator misconfigured") + session, _ = p_drive_error(monkeypatch, exc, stderr=["irrelevant tail"]) + card = [m for m in session.messages if m.role == "system"][-1].content + assert "Runtime log tail" not in card