"""Supervisor — plans, fans out in parallel, retries once, and knows when to stop. Two independent brakes guarantee termination: * a hard iteration cap (default 15), and * the per-run budget guard, which trips ``BUDGET_ABORT`` from inside any node. A ticker gets exactly one retry. Once its attempt count reaches two it is dropped from the pending set, so a permanently broken symbol can never loop the graph. """ from __future__ import annotations import logging from typing import Any, Literal from langgraph.graph import END from app.core.budget import BudgetExceededError from app.core.claude import AgentRole, LLMRequest, PromptHint from app.core.events import EventKind from app.graph.context import current_context from app.graph.llm import call_model from app.graph.prompts import SUPERVISOR_SYSTEM from app.graph.state import ( RunState, completeness, pending_market_tickers, pending_news_tickers, ) from app.models.run import RunError, RunStatus logger = logging.getLogger(__name__) MAX_ATTEMPTS_PER_TICKER = 2 DispatchTarget = Literal["data_agent", "news_agent", "writer", "__end__"] async def supervisor_node(state: RunState) -> dict[str, Any]: """Graph node: decide what still needs doing and dispatch the workers.""" ctx = current_context() settings = ctx.settings current_iteration = int(state.get("iterations", 0)) next_iteration = current_iteration + 1 # ---- brake 1: hard iteration cap ------------------------------------- if next_iteration > settings.max_iterations: reason = ( f"supervisor hit the hard iteration cap of {settings.max_iterations} " f"without completing the watchlist" ) await ctx.emit(EventKind.RUN_FAILED, reason, {"iterations": current_iteration}) return { "status": RunStatus.ITERATION_ABORT, "abort_reason": reason, "errors": [RunError(stage="supervisor", message=reason)], "plan": {"dispatch": [], "market_tickers": [], "news_tickers": []}, } # ---- brake 2: a worker already tripped the budget guard --------------- if state.get("status") in (RunStatus.BUDGET_ABORT, RunStatus.ITERATION_ABORT): return {"plan": {"dispatch": [], "market_tickers": [], "news_tickers": []}} market_pending = pending_market_tickers(state, MAX_ATTEMPTS_PER_TICKER) news_pending = pending_news_tickers(state, MAX_ATTEMPTS_PER_TICKER) dispatch: list[str] = [] if market_pending: dispatch.append("data_agent") if news_pending: dispatch.append("news_agent") plan: dict[str, Any] = { "dispatch": dispatch, "market_tickers": market_pending, "news_tickers": news_pending, "iteration": next_iteration, } update: dict[str, Any] = {"iterations": 1, "plan": plan} # The supervisor is routed by Haiku for cost discipline. Its reasoning is # advisory: the dispatch set itself is computed from state, so a model hiccup # can slow the run down but can never send it somewhere unsafe. if dispatch: try: outcome = await call_model( ctx, LLMRequest( role=AgentRole.SUPERVISOR, hint=PromptHint.SUPERVISOR_PLAN, system=SUPERVISOR_SYSTEM, messages=[ { "role": "user", "content": ( f"Iteration {next_iteration} of {settings.max_iterations}.\n" f"Watchlist: {', '.join(state.get('tickers', []))}\n" f"Missing market data: {', '.join(market_pending) or 'none'}\n" f"Missing news/sentiment: {', '.join(news_pending) or 'none'}\n" "State one sentence on what you are dispatching and why." ), } ], max_tokens=300, context={"pending_tickers": sorted(set(market_pending) | set(news_pending))}, ), ) plan["reason"] = outcome.result.text[:400] update["token_spend"] = outcome.spend except BudgetExceededError as exc: await ctx.emit(EventKind.RUN_FAILED, "Budget exhausted during planning", {}) return { "status": RunStatus.BUDGET_ABORT, "abort_reason": str(exc), "errors": [RunError(stage="supervisor", message=str(exc))], "plan": {"dispatch": [], "market_tickers": [], "news_tickers": []}, } await ctx.emit( EventKind.SUPERVISOR_PLAN, ( f"Dispatching {' + '.join(dispatch)} (parallel)" if len(dispatch) > 1 else ( f"Dispatching {dispatch[0]}" if dispatch else "Watchlist complete — routing to writer" ) ), { "iteration": next_iteration, "dispatch": dispatch, "market_tickers": market_pending, "news_tickers": news_pending, "reason": plan.get("reason"), }, ) await ctx.emit( EventKind.STATE_PROGRESS, "State completeness", {"completeness": completeness(state), "iteration": next_iteration}, ) return update def route_from_supervisor(state: RunState) -> list[str] | str: """Conditional edge: parallel fan-out, straight to the writer, or abort. Returning a *list* of node names is what makes the two workers run in the same LangGraph superstep — that is the parallelism the reducers exist for. """ status = state.get("status") if status in (RunStatus.BUDGET_ABORT, RunStatus.ITERATION_ABORT): return END dispatch = list((state.get("plan") or {}).get("dispatch", [])) if not dispatch: return "writer" return dispatch