Source code for src.agents.base_agent

"""
Base agent class for LangGraph-based agents.

Provides common functionality for all agents including:
- LLM initialization
- Tool management
- Graph building with standard structure
- Common node implementations (_llm_call, _tool_node, _should_continue)
"""

import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Literal

from langchain.chat_models import init_chat_model
from langchain_core.messages import AIMessage, AnyMessage, SystemMessage, ToolMessage
from langgraph.graph import END, START, StateGraph

from config import config
from src.checkpoint import get_checkpointer
from src.models.state import MessagesState
from src.utils.prompts import _is_eval_mode, strip_suggested_prompts
from src.utils.response_recovery import recover_local_response

logger = logging.getLogger(__name__)

# Marks the ToolMessage ``_force_end_node`` appends to close a call the forced
# stop abandoned. The supervisor's idle-loop detector asks "did any tool run
# during this delegation" by looking for ToolMessages -- and this one means the
# exact opposite, so it has to be distinguishable from a real tool result.
FORCED_STOP_MARKER = "engiai_forced_stop"


[docs] class BaseAgent(ABC): """Abstract base class for LangGraph agents with common functionality."""
[docs] def __init__( self, model_name: str | None = None, tools: list | None = None, require_confirmation: bool = False, temperature: float | None = None, seed: int | None = None, ): """Initialize the base agent. Args: model_name: Name of the LLM model to use (defaults to config.llm_model) tools: List of LangChain tools for this agent require_confirmation: Whether to require user confirmation before tool execution temperature: Model temperature (defaults to config.llm_temperature) seed: Random seed for model (defaults to config.llm_seed, None for non-deterministic) """ 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 logger.debug( f"Initializing {self.__class__.__name__} with temperature={self.temperature}, seed={self.seed}" ) # Build kwargs for model initialization model_kwargs: dict = {"temperature": self.temperature} # Add seed if provided if self.seed is not None: model_kwargs["seed"] = self.seed # Add Ollama-specific configuration if using an Ollama model if self.model_name.startswith("ollama:"): model_kwargs["num_ctx"] = config.ollama_num_ctx logger.debug( f"Ollama model detected, using num_ctx={config.ollama_num_ctx}" ) self.llm = init_chat_model(self.model_name, **model_kwargs) self.require_confirmation = require_confirmation # Initialize tools self.tools = tools or self._create_tools() self.tools_by_name = {tool.name: tool for tool in self.tools} self.llm_with_tools = self.llm.bind_tools(self.tools) # Build the agent graph self.agent = self._build_agent()
@abstractmethod def _create_tools(self) -> list: """Create the list of tools for this agent. Returns: List of LangChain tools Note: Subclasses must implement this method to define their specific tools. """ @abstractmethod def _get_system_prompt(self) -> str: """Get the system prompt for this agent. Returns: System prompt string Note: Subclasses must implement this method to define their system prompt. """ def _llm_call(self, state: MessagesState) -> dict: """LLM decides whether to call a tool or not. Args: state: Current conversation state Returns: Updated state with LLM response """ prompt = self._get_system_prompt() if _is_eval_mode(): prompt = strip_suggested_prompts(prompt) messages: list[AnyMessage] = [SystemMessage(content=prompt)] + state["messages"] response = self._invoke_llm_with_tools(messages) # Some locally served agent models have learned the common ``web_fetch`` # tool name even when only EngiAI's ``web_search`` tool is bound. Repair # that narrow alias before it reaches the graph. Searching for the URL # plus the model's extraction prompt provides useful page snippets while # preserving the keyless search tool's existing network safeguards. if ( isinstance(response, AIMessage) and response.tool_calls and "web_search" in self.tools_by_name ): for tool_call in response.tool_calls: if tool_call.get("name") != "web_fetch": continue args = tool_call.get("args", {}) query_parts = [ str(args.get(key, "")).strip() for key in ("prompt", "query", "url") if str(args.get(key, "")).strip() ] tool_call["name"] = "web_search" tool_call["args"] = {"query": " ".join(query_parts)} logger.warning( "%s: repaired hallucinated web_fetch call to web_search", self.__class__.__name__, ) return { "messages": [response], "llm_calls": state.get("llm_calls", 0) + 1, } def _invoke_llm_with_tools(self, messages: list[AnyMessage]) -> AIMessage: """Recover an invisible local answer without replaying tool calls.""" response = self.llm_with_tools.invoke(messages) return recover_local_response( response, messages, model_name=self.model_name, answer_model=self.llm, allow_tools=bool(self.tools), ) def _tool_node(self, state: MessagesState) -> dict: """Execute tool calls from the LLM. Args: state: Current conversation state Returns: Updated state with tool results """ result = [] last_message = state["messages"][-1] # Only AIMessage has tool_calls if isinstance(last_message, AIMessage) and last_message.tool_calls: for tool_call in last_message.tool_calls: try: tool = self.tools_by_name[tool_call["name"]] observation = tool.invoke(tool_call["args"]) result.append( ToolMessage( content=str(observation), tool_call_id=tool_call["id"], name=tool_call["name"], ) ) except Exception as e: # CRITICAL: Always return a ToolMessage, even for errors # Otherwise OpenAI API will fail with "tool_call_id did not have response" # # Logged as well as returned: the agent sees only the # formatted string and usually just retries, so without this # a tool failing every time is indistinguishable from the # model choosing not to use it. # warning, not exception: the retry loop exists to absorb # these (malformed arguments from the model are routine), and # now that the eval harness configures logging an ERROR-level # traceback per occurrence would drown its stderr. exc_info # keeps the traceback for when it is a real failure. logger.warning( "Tool %s raised during execution: %s", tool_call["name"], e, exc_info=True, ) error_content = ( f"❌ Error executing tool '{tool_call['name']}': {e!s}" ) result.append( ToolMessage( content=error_content, tool_call_id=tool_call["id"], name=tool_call["name"], ) ) return {"messages": result} def _should_force_end(self, state: MessagesState) -> bool: # noqa: ARG002 """Hook: force the agent loop to END regardless of any pending tool calls. Returns ``False`` by default (no forced termination). Subclasses override it to add an agent-specific stop condition -- e.g. ``HPCAgent`` ends the loop a few turns after the per-trial submission budget is exhausted, so a self-correcting agent stops churning instead of running until the recursion limit (which would otherwise kill the whole trial). """ return False def _force_end_node(self, state: MessagesState) -> dict: """Answer any tool calls the forced stop is cutting off, then end. ``_should_force_end`` deliberately preempts a pending tool call -- ``HPCAgent`` inspects the call itself to decide whether to allow it -- which leaves an AIMessage carrying ``tool_calls`` that nothing responds to. That history is invalid: OpenAI rejects an assistant message whose ``tool_call_id``s have no matching tool response, so the *next* request on that thread fails rather than the current one. The state reducer is ``operator.add``, so the message cannot be replaced in place -- the only way to close the pair is to append a response, the same way ``_tool_node`` always answers even on error. The response carries ``FORCED_STOP_MARKER`` because appending a ToolMessage otherwise tells the supervisor a tool *ran*: its idle-loop detector reads "any ToolMessage" as a productive delegation, and would re-route to an agent that had just given up. """ last_message = state["messages"][-1] if not isinstance(last_message, AIMessage) or not last_message.tool_calls: return {"messages": []} logger.warning( "%s: forced stop with %d unanswered tool call(s) %s — closing them", self.__class__.__name__, len(last_message.tool_calls), [tc.get("name") for tc in last_message.tool_calls], ) return { "messages": [ ToolMessage( status="error", additional_kwargs={FORCED_STOP_MARKER: True}, content=( "Not executed — the agent stopped before this call ran. " "Do not assume any effect from it." ), tool_call_id=tool_call["id"], name=tool_call["name"], ) for tool_call in last_message.tool_calls ] } def _should_continue( self, state: MessagesState ) -> Literal["tool_node", "force_end", "__end__"]: """Decide whether to continue to tool execution or end. Args: state: Current conversation state Returns: Next node to execute ("tool_node", "force_end" or "__end__") """ messages = state["messages"] last_message = messages[-1] # Agent-specific hard stop (default no-op). Lets a subclass terminate a # runaway loop -- e.g. churning after the submission budget is exhausted. if self._should_force_end(state): # Route via force_end when the stop is cutting a tool call off, so # the abandoned call still gets a response. Plain END otherwise. if isinstance(last_message, AIMessage) and last_message.tool_calls: return "force_end" return "__end__" # If the LLM makes a tool call, then perform an action if isinstance(last_message, AIMessage) and last_message.tool_calls: # Guard: some models (e.g. Gemini) hallucinate tool calls even when # bind_tools([]) was used and no tool definitions were provided. # Routing to tool_node would just produce KeyError ToolMessages and # cause an infinite loop. End immediately instead. if not self.tools: logger.warning( "%s: LLM emitted tool calls but agent has no tools — ignoring", self.__class__.__name__, ) return "__end__" return "tool_node" # Otherwise, we stop (reply to the user) return "__end__" def _after_tools( self, state: MessagesState ) -> Literal["llm_call", "clarification_response", "__end__"]: """Route after tool execution. If the current batch of tool results contains a successful ask_human_for_clarification call, route to clarification_response which synthesizes an AIMessage from the tool result — no additional LLM round-trip needed. This avoids a ~30-60s unnecessary LLM call. Args: state: Current conversation state Returns: Next node to execute """ messages = state["messages"] # Check only the current batch of ToolMessages (trailing ToolMessages # at the end of the message list, before any non-ToolMessage). for message in reversed(messages): if not isinstance(message, ToolMessage): break if message.name != "ask_human_for_clarification": continue try: content = message.content if not isinstance(content, str): continue payload = json.loads(content) if payload.get("success") is True: return "clarification_response" except (json.JSONDecodeError, AttributeError): pass return "llm_call" def _clarification_response(self, state: MessagesState) -> dict: """Synthesize an AIMessage from the clarification tool result. Extracts the question from the last successful ask_human_for_clarification ToolMessage and returns it as a proper AIMessage so the user sees the question. No LLM call needed. """ for message in reversed(state["messages"]): if not isinstance(message, ToolMessage): break if message.name != "ask_human_for_clarification": continue try: content = message.content if isinstance(content, str): payload = json.loads(content) question = payload.get("question", "") if question: return {"messages": [AIMessage(content=question)]} except (json.JSONDecodeError, AttributeError): pass return {"messages": []} def _build_agent(self) -> Any: """Build the agent workflow graph. Returns: Compiled agent graph """ # Build workflow agent_builder = StateGraph(MessagesState) # Add nodes agent_builder.add_node("llm_call", self._llm_call) agent_builder.add_node("tool_node", self._tool_node) agent_builder.add_node("clarification_response", self._clarification_response) agent_builder.add_node("force_end", self._force_end_node) # Add edges to connect nodes agent_builder.add_edge(START, "llm_call") agent_builder.add_conditional_edges( "llm_call", self._should_continue, ["tool_node", "force_end", END] ) agent_builder.add_edge("force_end", END) agent_builder.add_conditional_edges( "tool_node", self._after_tools, ["llm_call", "clarification_response", END], ) agent_builder.add_edge("clarification_response", END) # Compile with persistent checkpointer for conversation memory checkpointer = get_checkpointer() # Use interrupt_before for human-in-the-loop confirmation if required if self.require_confirmation: return agent_builder.compile( checkpointer=checkpointer, interrupt_before=["tool_node"], ) else: return agent_builder.compile(checkpointer=checkpointer)
[docs] def invoke(self, state: MessagesState, config: dict | None = None) -> MessagesState: """Invoke the agent with a given state. Args: state: Input conversation state config: Optional configuration (e.g., thread_id for conversation tracking) Returns: Updated conversation state """ return self.agent.invoke(state, config) # type: ignore[return-value]