Source code for src.agents.supervisor_agent

"""
Supervisor agent that coordinates multiple specialized agents.

This agent uses a hierarchical approach - it delegates tasks to specialized
sub-agents (Engineering, Search, etc.) rather than having all tools directly.
"""

import logging
import os
import uuid
from typing import Annotated, Any, Literal, cast

from langchain.chat_models import init_chat_model
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langgraph.errors import GraphRecursionError
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from pydantic import BaseModel, Field
from typing_extensions import TypedDict

from config import config
from src.agents.arxiv_agent import ArXivAgent
from src.agents.base_agent import FORCED_STOP_MARKER, BaseAgent
from src.agents.cli_agent import CLIAgent
from src.agents.engineering_agent import EngineeringAgent
from src.agents.excalidraw_agent import ExcalidrawAgent
from src.agents.hpc_agent import HPCAgent
from src.agents.prusa_agent import PrusaAgent
from src.agents.rag_agent import RAGAgent
from src.agents.search_agent import SearchAgent
from src.checkpoint import get_checkpointer
from src.models.state import MessagesState
from src.utils.prompts import (
    EXCALIDRAW_CAPABILITY_BLOCK,
    SUPERVISOR_AGENT_SYSTEM_PROMPT,
    SUPERVISOR_CAPABILITIES_PROMPT,
    _is_eval_mode,
    strip_suggested_prompts,
)
from src.utils.response_recovery import recover_local_response

logger = logging.getLogger(__name__)

# Maximum consecutive re-routings to the same agent before the supervisor
# forces FINISH.  Smaller models often fail to emit FINISH and keep
# re-routing to the same agent (sometimes with spurious tool calls).
# The first delegation is always allowed; the counter tracks re-routings.
# Limit of 1: if an agent just completed with tool results, re-routing to
# the same agent is almost always a mistake (the LLM failed to recognise
# the step was done).
_MAX_CONSECUTIVE_SAME_AGENT_REROUTINGS = 1


[docs] class RouteDecision(BaseModel): """Structured output for routing decision.""" agent: Literal[ "engineering_agent", "hpc_agent", "search_agent", "rag_agent", "arxiv_agent", "prusa_agent", "excalidraw_agent", "cli_agent", "supervisor_response", "FINISH", ] = Field(description="The agent to route the task to based on the user's request") reasoning: str = Field( description="Brief explanation of why this agent was selected" ) task_instruction: str = Field( default="", description=( "Specific sub-task instruction for the selected agent. For multi-step " "workflows, scope the instruction to ONLY the next step(s) that this " "agent should perform, then stop. E.g. 'Generate the SLURM training " "command. Do NOT download models or simulate designs — that will be " "handled after HPC training completes.' Leave empty for single-step tasks." ), ) more_steps_after: bool = Field( default=False, description=( "True ONLY if the user's request requires a DIFFERENT agent after this " "one completes (e.g. rag_agent finds parameters, then engineering_agent " "optimises). False for single-agent tasks (the vast majority of requests)." ), ) final_response: str = Field( default="", description=( "User-facing reply, written in second person as a direct message to the " "user. REQUIRED when agent='FINISH' AND no sub-agent has produced a " "reply in this turn — e.g. the user is repeating a request that was " "already completed earlier in the conversation, or no action is needed. " "In that case, briefly tell the user what was already done (including " "any file paths or key results) and offer next steps. Leave empty in " "all other cases (when routing to a sub-agent, or when FINISH follows " "a sub-agent that already replied)." ), )
[docs] class SupervisorState(TypedDict): """State for the supervisor agent.""" messages: Annotated[list, add_messages] next: str
[docs] class SupervisorAgent: """ Supervisor agent that coordinates specialized agents. Uses a hierarchical delegation model where the supervisor routes tasks to appropriate specialized agents based on the request. """
[docs] def __init__( self, model_name: str | None = None, temperature: float | None = None, seed: int | None = None, rag_read_only: bool = False, ): """Initialize the supervisor agent. Args: model_name: Name of the LLM model to use (defaults to config.llm_model) temperature: Model temperature (defaults to config.llm_temperature) seed: Random seed for model (defaults to config.llm_seed) rag_read_only: If True, sub-agents only get read-only RAG tools (search, list). """ self.model_name = model_name or config.llm_model self.temperature = ( temperature if temperature is not None else config.llm_temperature ) self.seed = seed if seed is not None else config.llm_seed # Build model kwargs model_kwargs: dict[str, Any] = {"temperature": self.temperature} if self.seed is not None: model_kwargs["seed"] = self.seed is_ollama = self.model_name.startswith("ollama:") if is_ollama: model_kwargs.update( { "num_ctx": config.ollama_num_ctx, "reasoning": False, "num_predict": 1024, } ) self.llm = init_chat_model(self.model_name, **model_kwargs) # Create structured LLM for routing decisions self.router_model_name = config.llm_router_model or self.model_name router_is_ollama = self.router_model_name.startswith("ollama:") routing_model = self.llm if self.router_model_name != self.model_name or router_is_ollama: routing_kwargs: dict[str, Any] = { "temperature": config.llm_router_temperature } if self.seed is not None: routing_kwargs["seed"] = self.seed if router_is_ollama: routing_kwargs.update( { "num_ctx": config.ollama_num_ctx, "reasoning": False, "num_predict": 256, } ) routing_model = init_chat_model( self.router_model_name, **routing_kwargs, ) self.routing_llm = routing_model.with_structured_output( RouteDecision, method="function_calling" ) # Initialize specialized sub-agents self.engineering_agent = EngineeringAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed, ) self.hpc_agent = HPCAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed ) skip_search = os.getenv("SKIP_SEARCH", "false").lower() == "true" self.search_agent: SearchAgent | None = None if not skip_search and config.search_available: self.search_agent = SearchAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed, ) else: logger.info( "SearchAgent disabled (SKIP_SEARCH=%s, provider=%s, available=%s)", skip_search, config.search_provider, config.search_available, ) # Initialize RAG agent with MMORE self.rag_agent = RAGAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed, rag_read_only=rag_read_only, ) # Initialize ArXiv agent with MMORE self.arxiv_agent = ArXivAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed, ) # PrusaAgent will check SKIP_MCP env var automatically self.prusa_agent = PrusaAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed ) # None when the canvas server is disabled (SKIP_CANVAS, or SKIP_MCP as a # master switch). _excalidraw_node answers for it in that case rather # than routing into an agent with no tools -- a drawing agent that # cannot draw would otherwise reply "I've drawn it!" beside a blank # whiteboard. self.excalidraw_agent = ( ExcalidrawAgent( model_name=self.model_name, temperature=self.temperature, seed=self.seed, ) if config.canvas_enabled else None ) # Enabled but unreachable is worse than disabled. The agent absorbs a # connection failure and comes back with no tools, and everything # downstream keys on "is there an agent" -- so it would be advertised in # the capabilities answer, routed to, and then reply in prose about the # diagram it had not drawn. Dropping it here keeps one invariant: # advertised means it works. if self.excalidraw_agent is not None and not self.excalidraw_agent.tools: logger.warning( "Canvas MCP server returned no tools (%s); the whiteboard agent " "will not be offered", config.canvas_mcp_url, ) self.excalidraw_agent = None # CLI confirmation is handled by supervisor-level interrupt, not internally self.cli_agent = CLIAgent( model_name=self.model_name, require_confirmation=False, temperature=self.temperature, seed=self.seed, ) # Loop detection state (reset per invoke) self._last_routed_agent: str | None = None self._consecutive_same_agent_count: int = 0 self._last_delegation_had_tools: bool = True self._expects_followup: bool = False # Build the supervisor graph self.graph = self._build_graph()
def _strip_disabled_capabilities(self, prompt: str) -> str: """Remove capability blocks for agents that are not wired up. Applied to BOTH consumers of AGENT_CAPABILITIES -- the routing prompt and SUPERVISOR_CAPABILITIES_PROMPT. Stripping only the router would fix which agents get *considered* while leaving the assistant telling users it can draw on a whiteboard that is switched off, and would then answer the follow-up with the bypass message. Removal rather than a "do NOT route here" paragraph: AGENT_CAPABILITIES is on the hot path of every benchmark run, so appending would add ~340 characters of context to evals that cannot use the agent anyway. Removing keeps the disabled prompts byte-identical to before this agent existed, so routing numbers stay comparable. Keyed on the agent instance rather than re-reading ``config`` so the prompt and the graph cannot disagree: ``canvas_enabled`` reads the environment on every access, while the agent is built once in ``__init__``. """ if self._canvas_available() is None: prompt = prompt.replace(EXCALIDRAW_CAPABILITY_BLOCK, "") return prompt def _canvas_available(self) -> ExcalidrawAgent | None: """The whiteboard agent, but only if it can actually draw. Not just ``is None``. ``ExcalidrawAgent._create_tools`` returns ``[]`` when the canvas server is unreachable while ``canvas_enabled`` is still true, so the agent exists and can draw nothing -- and a tool-less agent does not merely fail, it answers with an *empty message*, because the model's whole response is a tool call that BaseAgent's guard strips. Both the prompt and the node ask this one question, so "advertised means it works" survives a server that died rather than being switched off. Benchmarks are unaffected: they set SKIP_MCP=true, which leaves the agent None exactly as before. Returns the agent rather than a bool so the caller gets a narrowed, non-optional handle -- a bool would leave the node re-checking for None to satisfy the type checker, which is how the two conditions drift apart in the first place. """ if self.excalidraw_agent is None or not self.excalidraw_agent.tools: return None return self.excalidraw_agent def _build_routing_prompt(self) -> str: """Get the routing system prompt for the supervisor. Returns: Routing prompt from centralized prompts file """ prompt = SUPERVISOR_AGENT_SYSTEM_PROMPT if os.getenv("SKIP_ARXIV", "false").lower() == "true": prompt += ( "\n\nIMPORTANT: ArXiv search is currently unavailable. " "Do NOT route to arxiv_agent under any circumstances. " "For any paper or document lookup, route to rag_agent first. " "After rag_agent returns the information, route to engineering_agent " "for any follow-up design or optimization tasks. " "Keep task_instruction for rag_agent concise: just state which " "parameter values to find and from which papers. Do NOT ask for " "full citations, DOIs, authors, or extensive quotes — just the " "numeric values needed for the next step." ) prompt = self._strip_disabled_capabilities(prompt) if os.getenv("SKIP_SEARCH", "false").lower() == "true": prompt += ( "\n\nIMPORTANT: Web search is currently unavailable. " "Do NOT route to search_agent under any circumstances. " "Use rag_agent for document lookup and engineering_agent " "for design tasks." ) return prompt def _supervisor_node(self, state: SupervisorState): """Supervisor decides which agent should act next using LLM-based routing. After each agent completes, the supervisor re-evaluates the full message history to decide whether to route to another agent or finish. Prior [SUPERVISOR INSTRUCTION] messages are kept so the routing LLM can see which sub-tasks were already delegated and completed. """ messages = [ {"role": "system", "content": self._build_routing_prompt()}, *state["messages"], ] # Use structured output to get routing decision from LLM route_decision = self._invoke_routing_llm(messages) next_agent = route_decision.agent # Store follow-up flag so _route_after_agent knows whether to # loop back to the supervisor or end directly after delegation. self._expects_followup = route_decision.more_steps_after # Log the routing decision with reasoning logger.info( f"[SUPERVISOR ROUTING] Agent: '{next_agent}' " f"| Reasoning: {route_decision.reasoning} " f"| more_steps_after: {route_decision.more_steps_after}" ) # ── Same-agent loop detection ──────────────────────────────── # If the LLM tries to re-route to the agent that just completed, # it likely failed to recognise the step was done. Rather than # forcing FINISH (which would skip remaining steps), re-invoke the # routing LLM with a redirect hint so it picks a different agent. if next_agent not in ("FINISH", "supervisor_response"): if next_agent == self._last_routed_agent: self._consecutive_same_agent_count += 1 else: self._consecutive_same_agent_count = 0 if ( self._consecutive_same_agent_count >= _MAX_CONSECUTIVE_SAME_AGENT_REROUTINGS ): logger.warning( f"[SUPERVISOR] Loop detected: '{next_agent}' re-routed " f"{self._consecutive_same_agent_count} consecutive times. " "Re-invoking routing with redirect hint." ) # Re-invoke routing with a hint to pick a different agent redirect_hint = HumanMessage( content=( f"[SYSTEM] You already delegated to '{next_agent}' and " f"it completed its work (see results above). Do NOT " f"route to '{next_agent}' again. Choose a DIFFERENT " f"agent for the next step, or FINISH if all steps are done." ) ) retry_messages = [*messages, redirect_hint] retry_decision = self._invoke_routing_llm(retry_messages) next_agent = retry_decision.agent self._expects_followup = retry_decision.more_steps_after logger.info( f"[SUPERVISOR REDIRECT] Agent: '{next_agent}' " f"| Reasoning: {retry_decision.reasoning} " f"| more_steps_after: {retry_decision.more_steps_after}" ) # If the LLM STILL picks the same agent, force FINISH if next_agent == self._last_routed_agent: logger.warning( f"[SUPERVISOR] Redirect failed — still '{next_agent}'. " "Forcing FINISH." ) self._consecutive_same_agent_count = 0 self._last_routed_agent = None return { "next": "FINISH", "messages": [ AIMessage( content=( "I've completed the task based on the " "information gathered so far." ) ) ], } # Redirect succeeded — use the new decision going forward route_decision = retry_decision self._consecutive_same_agent_count = 0 self._last_routed_agent = next_agent else: # Routing to FINISH or supervisor_response — reset tracking self._last_routed_agent = None self._consecutive_same_agent_count = 0 # ── Inject scoped task instruction ─────────────────────────── # HumanMessage so the delegated agent's LLM treats it as a new # user directive (not just a prior assistant turn). This # prevents the agent from ignoring the scope and trying to # handle the entire workflow with its own tools. new_messages: list = [] if route_decision.task_instruction and next_agent not in ( "FINISH", "supervisor_response", ): new_messages.append( HumanMessage( content=( f"[SUPERVISOR INSTRUCTION — follow this exactly] " f"{route_decision.task_instruction}" ) ) ) logger.info( f"[SUPERVISOR] Task instruction for {next_agent}: {route_decision.task_instruction}" ) if next_agent == "FINISH": final_response = (route_decision.final_response or "").strip() if final_response: new_messages.append( recover_local_response( AIMessage(content=final_response), [ SystemMessage( content=self._strip_disabled_capabilities( SUPERVISOR_CAPABILITIES_PROMPT ) ), *state["messages"], ], model_name=self.model_name, answer_model=self.llm, allow_tools=False, ) ) else: # Safety net: if the routing LLM omitted final_response and # no sub-agent has replied this turn (last message is the # user's), emit a minimal reply so the UI never renders blank. last_message = state["messages"][-1] if state["messages"] else None if last_message is None or isinstance(last_message, HumanMessage): new_messages.append( AIMessage( content="Done. Let me know if you'd like anything else." ) ) return { "next": next_agent, "messages": new_messages, } def _supervisor_response_node(self, state: SupervisorState): """Supervisor responds directly to informational questions.""" cap_prompt = self._strip_disabled_capabilities(SUPERVISOR_CAPABILITIES_PROMPT) if _is_eval_mode(): cap_prompt = strip_suggested_prompts(cap_prompt) messages = [ SystemMessage(content=cap_prompt), *state["messages"], ] response = self.llm.invoke(messages) message = ( response if isinstance(response, AIMessage) else AIMessage(content=response.content) ) message = recover_local_response( message, messages, model_name=self.model_name, answer_model=self.llm, allow_tools=False, ) return {"messages": [message], "next": "FINISH"} def _invoke_routing_llm(self, messages: list) -> RouteDecision: """Return a bounded routing decision and never crash the user chat.""" for attempt in range(2): try: decision = self.routing_llm.invoke(messages) except Exception as exc: logger.warning( "Routing attempt %d failed with %s", attempt + 1, type(exc).__name__, ) continue if isinstance(decision, RouteDecision): return decision logger.warning("Routing attempt %d returned no decision", attempt + 1) logger.error( "Routing unavailable; falling back to a direct supervisor response" ) return RouteDecision( agent="supervisor_response", reasoning="The structured router was unavailable; respond safely.", ) def _engineering_node(self, state: SupervisorState): """Delegate to engineering agent.""" agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self.engineering_agent.invoke( agent_state, {"configurable": {"thread_id": f"engineering_{uuid.uuid4().hex[:8]}"}}, ) return self._extract_agent_result(state, result) def _invoke_with_salvage( self, agent: BaseAgent, agent_state: MessagesState, label: str ) -> MessagesState: """Invoke a delegated agent, recovering its trace if it runs away. The thread_id is held in a variable rather than inlined so the partial conversation can be read back from the checkpointer when the agent exhausts its recursion limit while self-correcting -- otherwise the whole delegation is lost (see the _build_predict_output salvage in evaluate_agent.py for the outer backstop). Worth knowing how far that limit is: LangGraph's default is 10007 steps, not the 25 it is often assumed to be, so an agent looping on tool calls burns thousands of LLM calls before raising. Nothing else bounds these graphs. """ inner_config = { "configurable": {"thread_id": f"{label}_{uuid.uuid4().hex[:8]}"} } try: return agent.invoke(agent_state, inner_config) except GraphRecursionError: logger.warning( "%s agent hit its recursion limit; salvaging partial trace.", label ) try: snapshot = agent.agent.get_state(inner_config) salvaged = list(snapshot.values.get("messages", [])) except Exception: # salvage must never raise logger.warning( "Failed to salvage %s partial trace", label, exc_info=True ) salvaged = list(agent_state["messages"]) # Recovering nothing new is the blank-reply failure one layer up: # _extract_agent_result slices off the input, so an unchanged list # surfaces zero messages and the user is told nothing at all. if len(salvaged) <= len(agent_state["messages"]): salvaged.append( AIMessage( content=( "I got stuck in a loop working on that and had to " "stop, and I could not recover what I had done. " "Please try again, ideally with a narrower request." ) ) ) return cast(MessagesState, {"messages": salvaged}) def _hpc_node(self, state: SupervisorState): """Delegate to HPC agent.""" agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self._invoke_with_salvage(self.hpc_agent, agent_state, "hpc") return self._extract_agent_result(state, result) def _search_node(self, state: SupervisorState): """Delegate to search agent.""" if ( os.getenv("SKIP_SEARCH", "false").lower() == "true" or self.search_agent is None ): self._last_delegation_had_tools = True return { "messages": [ AIMessage( content=( "Web search is not available in this context. " "Please use the document search tool (search_documents) instead." ) ) ], "next": "", } agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self.search_agent.invoke( agent_state, {"configurable": {"thread_id": f"search_{uuid.uuid4().hex[:8]}"}}, ) return self._extract_agent_result(state, result) def _rag_node(self, state: SupervisorState): """Delegate to RAG agent for document Q&A.""" agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self.rag_agent.invoke( agent_state, {"configurable": {"thread_id": f"rag_{uuid.uuid4().hex[:8]}"}}, ) return self._extract_agent_result(state, result) def _arxiv_node(self, state: SupervisorState): """Delegate to ArXiv agent for paper search and analysis.""" if os.getenv("SKIP_ARXIV", "false").lower() == "true": # System bypass, not an idle agent — supervisor should re-route # (typically to rag_agent), so mark as "productive". self._last_delegation_had_tools = True return { "messages": [ AIMessage( content=( "ArXiv search is not available in this context. " "Please use the document search tool (search_documents) instead." ) ) ], "next": "", } agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self.arxiv_agent.invoke( agent_state, {"configurable": {"thread_id": f"arxiv_{uuid.uuid4().hex[:8]}"}}, ) return self._extract_agent_result(state, result) def _prusa_node(self, state: SupervisorState): """Delegate to Prusa agent. A tool-less agent answers with nothing at all, so it is bypassed here. Measured, not assumed: with the printer server unreachable the model's entire response is a tool call, which BaseAgent's guard strips, leaving an empty message. The user asks about printers and gets a blank reply. This is the ``_search_node`` shape rather than the ``_excalidraw_node`` one -- the capability block stays in the routing prompt instead of being stripped. Benchmarks run with SKIP_MCP=true, so stripping would rewrite the routing prompt for every eval and move routing numbers that are meant to stay comparable across runs. """ if not self.prusa_agent.tools: # System bypass, not an idle agent -- mark productive so the # supervisor re-routes rather than ending the turn. self._last_delegation_had_tools = True return { "messages": [ AIMessage( content=( "3D printer control is not available in this " "context — the Prusa MCP server is either switched " "off or was unreachable at startup. Everything else " "still works." ) ) ], "next": "", } agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self._invoke_with_salvage(self.prusa_agent, agent_state, "prusa") return self._extract_agent_result(state, result) def _excalidraw_node( self, state: SupervisorState, config: RunnableConfig | None = None ): """Delegate to the whiteboard agent. ``config`` is injected by LangGraph for any node that declares a parameter of that name, which is how the conversation's thread_id gets here. Setting it on the agent before delegating is what makes the canvas per-conversation: the tool loader re-reads it on every call, so a chat switch between turns is picked up without rebuilding tools. The name is forced by that injection, so it SHADOWS the module-level ``config`` singleton for the body of this method -- ``config.canvas_enabled`` and friends are unreachable here. Read them before delegating, or via ``self``. """ canvas_agent = self._canvas_available() if canvas_agent is None: # System bypass, not an idle agent -- mark productive so the # supervisor re-routes (e.g. to answer in text) rather than ending. self._last_delegation_had_tools = True return { "messages": [ AIMessage( content=( "The whiteboard is not available in this context. " "Describe the drawing in words instead." ) ) ], "next": "", } thread_id = ((config or {}).get("configurable") or {}).get("thread_id") canvas_agent.canvas_id = thread_id agent_state = cast(MessagesState, {"messages": state["messages"]}) result = canvas_agent.invoke( agent_state, {"configurable": {"thread_id": f"excalidraw_{uuid.uuid4().hex[:8]}"}}, ) return self._extract_agent_result(state, result) def _cli_node(self, state: SupervisorState): """Delegate to CLI agent.""" logger.info("[SUPERVISOR] _cli_node invoked - delegating to CLI agent") agent_state = cast(MessagesState, {"messages": state["messages"]}) result = self.cli_agent.invoke( agent_state, {"configurable": {"thread_id": f"cli_{uuid.uuid4().hex[:8]}"}}, ) logger.info("[SUPERVISOR] CLI agent returned result") return self._extract_agent_result(state, result) def _route_after_agent(self, state: SupervisorState) -> str: # noqa: ARG002 """Decide whether to loop back to supervisor or end directly. Uses two signals: - ``_last_delegation_had_tools``: whether the agent made any tool calls. - ``_expects_followup``: set from ``RouteDecision.more_steps_after`` by the supervisor node before delegation. Returns to supervisor only when both conditions hold (agent was productive AND the supervisor indicated more agents are needed). Otherwise ends directly to avoid wasteful LLM re-evaluation. """ if not self._last_delegation_had_tools: logger.info("[SUPERVISOR] Agent produced no tool calls — ending directly.") return END if self._expects_followup: logger.info( "[SUPERVISOR] Agent completed with tools — more steps expected, " "returning to supervisor." ) return "supervisor" logger.info("[SUPERVISOR] Agent completed with tools — task done, ending.") return END def _build_graph(self): """Build the supervisor workflow graph with agent routing.""" # Build the graph workflow = StateGraph(SupervisorState) # Add nodes workflow.add_node("supervisor", self._supervisor_node) workflow.add_node("supervisor_response", self._supervisor_response_node) workflow.add_node("engineering_agent", self._engineering_node) workflow.add_node("hpc_agent", self._hpc_node) workflow.add_node("search_agent", self._search_node) workflow.add_node("rag_agent", self._rag_node) workflow.add_node("arxiv_agent", self._arxiv_node) workflow.add_node("prusa_agent", self._prusa_node) workflow.add_node("excalidraw_agent", self._excalidraw_node) workflow.add_node("cli_agent", self._cli_node) # Add edges - start with supervisor workflow.add_edge(START, "supervisor") # Build conditional routing dictionary routing_dict = { "supervisor_response": "supervisor_response", "engineering_agent": "engineering_agent", "hpc_agent": "hpc_agent", "search_agent": "search_agent", "rag_agent": "rag_agent", "arxiv_agent": "arxiv_agent", "prusa_agent": "prusa_agent", "excalidraw_agent": "excalidraw_agent", "cli_agent": "cli_agent", "FINISH": END, } # Conditional routing based on supervisor decision workflow.add_conditional_edges( "supervisor", lambda state: state["next"], routing_dict, ) # Agents loop back to supervisor only if they used tools (productive). # If an agent produced no tool calls it had nothing to do — go # directly to END to avoid wasteful supervisor re-evaluation # (common with smaller models that fail to emit FINISH). agent_nodes = [ "engineering_agent", "hpc_agent", "search_agent", "rag_agent", "arxiv_agent", "prusa_agent", "excalidraw_agent", "cli_agent", ] after_agent_map = {"supervisor": "supervisor", END: END} for agent_name in agent_nodes: workflow.add_conditional_edges( agent_name, self._route_after_agent, after_agent_map ) # supervisor_response always goes directly to END workflow.add_edge("supervisor_response", END) # Compile with persistent checkpointer checkpointer = get_checkpointer() # Interrupt before cli_agent for user confirmation of commands return workflow.compile( checkpointer=checkpointer, interrupt_before=["cli_agent"], ) def _extract_agent_result( self, state: SupervisorState, result: MessagesState ) -> dict: """Extract new messages from an agent result and track tool usage. Shared by all agent delegation nodes to avoid duplicated logic. Sets ``_last_delegation_had_tools`` for idle-loop detection in the supervisor node. """ input_len = len(state["messages"]) new_messages = result["messages"][input_len:] # A forced stop closes the call it abandoned with a ToolMessage (see # BaseAgent._force_end_node). Counting it here would report the exact # opposite of what happened -- no tool ran -- and re-route to an agent # that had just given up. self._last_delegation_had_tools = any( isinstance(m, ToolMessage) and not m.additional_kwargs.get(FORCED_STOP_MARKER) for m in new_messages ) return {"messages": new_messages, "next": ""}
[docs] def invoke(self, state, config): """Invoke the supervisor agent. Args: state: Current conversation state with messages, or None to resume from interrupt config: Configuration including thread_id Returns: Updated state with agent responses """ # Reset loop detection state for each top-level invocation self._last_routed_agent = None self._consecutive_same_agent_count = 0 self._last_delegation_had_tools = True self._expects_followup = False # If state is None, we're resuming from an interrupt - pass None to graph if state is None: result = self.graph.invoke(None, config) return {"messages": result["messages"]} # Convert MessagesState to SupervisorState if needed if "next" not in state: supervisor_state = {"messages": state["messages"], "next": ""} else: supervisor_state = state result = self.graph.invoke(supervisor_state, config) # Return in MessagesState format for compatibility return {"messages": result["messages"]}