ClearCast-AI / agent /runtime.py
abhinavvathadi's picture
Upgrade ClearCast: Docker Space with Node.js gateway, FastAPI orchestrator, validated plans (GitHub 84b0cf9)
79c4c46 verified
Raw History Blame Contribute Delete
6.09 kB
"""Agent runtime: safe, lazy, race-free graph initialisation and readiness.
The compiled graph is built once per process and reused. Initialisation is
guarded by an asyncio lock so concurrent first requests cannot build it
twice, does not call the LLM or weather providers (it only starts the MCP
subprocess and lists tools), and reports a friendly service-unavailable error
instead of crashing the process when it fails.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Awaitable, Callable
from langgraph.checkpoint.memory import MemorySaver
from agent.errors import ClearCastError, ConfigurationError, ServiceUnavailableError
from agent.graph import build_graph_from_tools
from agent.llm import DRAFTING_TEMPERATURE, PlanDrafter, create_chat_model, model_name
from agent.weather_client import MCPToolClient, get_langchain_tools
logger = logging.getLogger("clearcast.runtime")
FIXTURE_ENV_VAR = "CLEARCAST_WEATHER_FIXTURES"
def default_required_env() -> tuple[str, ...]:
required = ["OPENAI_API_KEY"]
if not (os.getenv(FIXTURE_ENV_VAR) or "").strip():
required.append("OPENWEATHERMAP_API_KEY")
return tuple(required)
class AgentRuntime:
def __init__(
self,
*,
llm_factory: Callable[[], object] | None = None,
drafter_llm_factory: Callable[[], object] | None = None,
tools_factory: Callable[[], Awaitable[list]] | None = None,
mcp_client: MCPToolClient | None = None,
checkpointer=None,
required_env: tuple[str, ...] | None = None,
retry_cooldown_seconds: float = 15.0,
) -> None:
self._llm_factory = llm_factory or create_chat_model
self._drafter_llm_factory = drafter_llm_factory or (lambda: create_chat_model(temperature=DRAFTING_TEMPERATURE))
self._tools_factory = tools_factory
self._required_env = default_required_env() if required_env is None else required_env
self._cooldown = retry_cooldown_seconds
self.checkpointer = checkpointer or MemorySaver()
self.mcp_client: MCPToolClient | None = mcp_client
self.model = model_name()
self.drafter: PlanDrafter | None = None
self.tool_names: list[str] = []
self.status = "not_started"
self.detail = "The agent has not been initialised yet."
self._graph = None
self._failed_at: float | None = None
self._lock = asyncio.Lock()
def missing_configuration(self) -> list[str]:
return [name for name in self._required_env if not (os.getenv(name) or "").strip()]
def readiness(self) -> dict:
return {
"ready": self._graph is not None,
"status": self.status,
"detail": self.detail,
"model": self.model,
"tools": list(self.tool_names),
"missing_configuration": self.missing_configuration(),
}
async def _discover_tools(self) -> list:
if self._tools_factory is not None:
return await self._tools_factory()
if self.mcp_client is None:
self.mcp_client = MCPToolClient()
await self.mcp_client.start()
return await get_langchain_tools(self.mcp_client)
async def get_graph(self):
if self._graph is not None:
return self._graph
async with self._lock:
if self._graph is not None:
return self._graph
missing = self.missing_configuration()
if missing:
self.status = "misconfigured"
self.detail = f"Missing required configuration: {', '.join(missing)}."
raise ConfigurationError(
f"ClearCast is missing required configuration: {', '.join(missing)}. "
"Add these secrets to the host environment and restart."
)
if self._failed_at is not None and time.monotonic() - self._failed_at < self._cooldown:
raise ServiceUnavailableError("The agent is restarting after an error. Try again shortly.")
self.status, self.detail = "initializing", "Starting the MCP weather tools."
started = time.perf_counter()
try:
tools = await self._discover_tools()
graph = build_graph_from_tools(tools, self._llm_factory(), checkpointer=self.checkpointer)
self.drafter = PlanDrafter(self._drafter_llm_factory())
except Exception as exc:
self._failed_at = time.monotonic()
self.status, self.detail = "failed", "Agent initialisation failed; it will be retried."
logger.error(
"agent initialisation failed",
extra={"event": "runtime.init.failed", "error_type": type(exc).__name__},
)
if isinstance(exc, ClearCastError):
raise
raise ServiceUnavailableError("The agent could not be initialised. Try again shortly.") from None
self._graph, self._failed_at = graph, None
self.tool_names = [tool.name for tool in tools]
self.status, self.detail = "ready", "Agent ready."
logger.info(
"agent initialised",
extra={
"event": "runtime.init.ready",
"tools": self.tool_names,
"model": self.model,
"duration_ms": int((time.perf_counter() - started) * 1000),
},
)
return graph
async def warm_up(self) -> None:
"""Best-effort startup initialisation; failures stay visible via readiness."""
with contextlib.suppress(ClearCastError):
await self.get_graph()
async def aclose(self) -> None:
if self.mcp_client is not None:
await self.mcp_client.aclose()