[eric] agents: AV-repair card for missing CLI, stderr tail on masked exit-1 cards, group-unwrap in the fatal path

This commit is contained in:
ciregenz
2026-07-28 15:49:59 -07:00
parent e16255e06a
commit 2a42a1c460
3 changed files with 90 additions and 10 deletions
+13 -7
View File
@@ -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()
@@ -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,
+44 -2
View File
@@ -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