Spaces:
Runtime error
Runtime error
| """Always-on monitor that auto-continues the agent when a background job | |
| (see src/bg_jobs.py) finishes. | |
| Reliability is the whole point: completion β agent re-invocation must never | |
| silently no-op. The monitor drains `bg_jobs.pending_followups()` every tick and | |
| only calls `mark_followed_up()` AFTER the agent run succeeds β so a transient | |
| failure is simply retried on the next tick. A timed-out/dead job still produces | |
| a follow-up ("the job failed/timed out"), so the user always hears back. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| from src import bg_jobs | |
| logger = logging.getLogger(__name__) | |
| _monitor_task = None | |
| POLL_INTERVAL_S = 5 | |
| # The follow-up agent run is allowed a few rounds to actually continue the task | |
| # (e.g. after `pip install` finishes, run the transcription). | |
| _FOLLOWUP_MAX_ROUNDS = 12 | |
| async def _drain_agent(sess, messages): | |
| """Run the agent loop headless against a session. Returns | |
| (final_prose, tool_events) β tool_events in the same shape the live chat | |
| saves, so the frontend rebuilds them as standard agent-thread tool cards.""" | |
| from src.agent_loop import stream_agent_loop | |
| full = "" | |
| tool_events = [] | |
| round_num = 1 | |
| async for chunk in stream_agent_loop( | |
| sess.endpoint_url, sess.model, messages, | |
| headers=getattr(sess, "headers", None), | |
| context_length=getattr(sess, "context_length", 0) or 0, | |
| session_id=sess.id, | |
| max_rounds=_FOLLOWUP_MAX_ROUNDS, | |
| owner=getattr(sess, "owner", None), | |
| ): | |
| if not chunk.startswith("data: "): | |
| continue | |
| body = chunk[6:].strip() | |
| if not body or body == "[DONE]": | |
| continue | |
| try: | |
| d = json.loads(body) | |
| except (ValueError, TypeError): | |
| continue | |
| if not isinstance(d, dict): | |
| continue | |
| if "delta" in d: | |
| delta = d.get("delta") | |
| if isinstance(delta, str): | |
| full += delta | |
| elif d.get("type") == "agent_step": | |
| round_num = d.get("round", round_num) | |
| elif d.get("type") == "tool_output": | |
| # Mirror the live chat's tool_event shape (chat_routes / chatRenderer). | |
| tool_events.append({ | |
| "round": round_num, | |
| "tool": d.get("tool"), | |
| "command": d.get("command"), | |
| "output": d.get("output"), | |
| "exit_code": d.get("exit_code"), | |
| }) | |
| return full, tool_events | |
| async def _run_followup(rec: dict) -> bool: | |
| """Re-invoke the agent in the job's session with the result. Returns True | |
| if the follow-up completed (or there's nothing to do) β i.e. it's safe to | |
| mark followed_up. Returns False to retry on the next tick.""" | |
| from src.ai_interaction import get_session_manager | |
| from core.models import ChatMessage | |
| sm = get_session_manager() | |
| if not sm: | |
| return False # not ready yet β retry | |
| sess = sm.get_session(rec["session_id"]) | |
| if not sess: | |
| # Session was deleted β nothing to continue. Consider it handled so we | |
| # don't retry forever. | |
| logger.info("bg-followup: session %s gone for job %s β skipping", rec.get("session_id"), rec.get("id")) | |
| return True | |
| # Don't write into a session that's mid-stream. The followup appends to | |
| # history + save_sessions(); a concurrent live turn does the same, and with | |
| # no per-session lock the two interleave (reordered/clobbered messages). | |
| # Defer β return False so we retry on the next tick once the turn finishes. | |
| try: | |
| from src import agent_runs | |
| if agent_runs.is_active(sess.id): | |
| logger.info("bg-followup: session %s busy (live turn) β deferring job %s", sess.id, rec.get("id")) | |
| return False | |
| except Exception: | |
| pass | |
| inject = ( | |
| f"[Background job {rec['id']} finished]\n\n" | |
| f"{bg_jobs.result_text(rec)}\n\n" | |
| "Continue the task using this output. Don't repeat work that's already done. " | |
| "If the task is now complete, give the user the final result." | |
| ) | |
| context = sess.get_context_messages() | |
| context.append({"role": "user", "content": inject}) | |
| full, tool_events = await _drain_agent(sess, context) | |
| # Persist ONLY the assistant continuation so it renders as a normal agent | |
| # turn β a standard chat bubble plus `tool_events` that the frontend | |
| # rebuilds into the usual agent-thread tool cards (chatRenderer:1494). The | |
| # trigger isn't saved as its own message (it'd be an out-of-place bubble); | |
| # the raw job output is stashed in metadata for traceability instead. | |
| sm.add_message(sess.id, ChatMessage( | |
| "assistant", full, | |
| metadata={ | |
| "tool_events": tool_events, | |
| "model": sess.model, | |
| "bg_job_id": rec["id"], | |
| "bg_result": bg_jobs.result_text(rec)[:4000], | |
| }, | |
| )) | |
| sm.save_sessions() | |
| logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)", | |
| sess.id, rec["id"], len(full), len(tool_events)) | |
| return True | |
| async def _loop(): | |
| while True: | |
| try: | |
| for rec in bg_jobs.pending_followups(): | |
| try: | |
| if await _run_followup(rec): | |
| bg_jobs.mark_followed_up(rec["id"]) | |
| except Exception as e: | |
| # Idempotent: leave followed_up=False so the next tick retries. | |
| logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e) | |
| except Exception as e: | |
| logger.warning("bg-monitor tick error: %s", e) | |
| await asyncio.sleep(POLL_INTERVAL_S) | |
| def start_bg_monitor(): | |
| """Idempotent β start the always-on background-job monitor.""" | |
| global _monitor_task | |
| if _monitor_task and not _monitor_task.done(): | |
| return _monitor_task | |
| _monitor_task = asyncio.create_task(_loop()) | |
| logger.info("Background-job monitor started (poll %ds)", POLL_INTERVAL_S) | |
| return _monitor_task | |