| """ |
| Base Agent class for all PydanticAI agents in the Archon system. |
| |
| This provides common functionality and dependency injection for all agents. |
| """ |
|
|
| import asyncio |
| import logging |
| import time |
| from abc import ABC, abstractmethod |
| from dataclasses import dataclass |
| from typing import Any, TypeVar |
|
|
| from pydantic import BaseModel |
| from pydantic_ai import Agent |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass |
| class ArchonDependencies: |
| """Base dependencies for all Archon agents.""" |
|
|
| request_id: str | None = None |
| user_id: str | None = None |
| trace_id: str | None = None |
|
|
|
|
| |
| DepsT = TypeVar("DepsT", bound=ArchonDependencies) |
| OutputT = TypeVar("OutputT") |
|
|
|
|
| class BaseAgentOutput(BaseModel): |
| """Base output model for all agent responses.""" |
|
|
| success: bool |
| message: str |
| data: dict[str, Any] | None = None |
| errors: list[str] | None = None |
|
|
|
|
| class RateLimitHandler: |
| """Handles OpenAI rate limiting with exponential backoff and proactive throttling.""" |
|
|
| def __init__(self, max_retries: int = 5, base_delay: float = 1.0): |
| self.max_retries = max_retries |
| self.base_delay = base_delay |
| self.last_request_time: float = 0.0 |
| self.min_request_interval = 0.5 |
|
|
| async def _log_rate_limit_alert(self, error_message: str, retry_count: int, wait_time: float): |
| """Log the rate limit hit as a system ALERT in archon_logs.""" |
| try: |
| from ..utils import get_supabase_client |
|
|
| supabase = get_supabase_client() |
| supabase.table("archon_logs").insert( |
| { |
| "level": "ALERT", |
| "source": "RateLimitHandler", |
| "type": "system", |
| "message": f"Rate limit hit: {error_message[:200]}", |
| "details": { |
| "retry_count": retry_count, |
| "max_retries": self.max_retries, |
| "wait_time": wait_time, |
| "error": error_message, |
| }, |
| } |
| ).execute() |
| except Exception as e: |
| logger.warning(f"Failed to log rate limit alert to DB: {e}") |
|
|
| async def execute_with_rate_limit(self, func, *args, progress_callback=None, **kwargs): |
| """Execute a function with rate limiting protection.""" |
| retries = 0 |
|
|
| while retries <= self.max_retries: |
| try: |
| |
| current_time = time.time() |
| time_since_last = current_time - self.last_request_time |
| if time_since_last < self.min_request_interval: |
| delay = self.min_request_interval - time_since_last |
| await asyncio.sleep(delay) |
|
|
| self.last_request_time = time.time() |
| return await func(*args, **kwargs) |
|
|
| except Exception as e: |
| error_str = str(e).lower() |
| full_error = str(e) |
|
|
| logger.debug(f"Agent error caught: {full_error}") |
|
|
| |
| is_rate_limit = ( |
| "rate limit" in error_str |
| or "429" in error_str |
| or "request_limit" in error_str |
| or "exceed" in error_str |
| or "resource_exhausted" in error_str |
| ) |
|
|
| if is_rate_limit: |
| retries += 1 |
| if retries > self.max_retries: |
| logger.debug(f"Max retries exceeded for rate limit: {full_error}") |
| await self._log_rate_limit_alert(full_error, retries, 0) |
| if progress_callback: |
| await progress_callback( |
| { |
| "step": "ai_generation", |
| "log": f"❌ Rate limit exceeded after {self.max_retries} retries", |
| } |
| ) |
| raise Exception(f"Rate limit exceeded after {self.max_retries} retries: {full_error}") from e |
|
|
| |
| wait_time = self._extract_wait_time(full_error) |
| if wait_time is None: |
| |
| wait_time = self.base_delay * (2 ** (retries - 1)) |
|
|
| logger.warning( |
| f"Rate limit hit. Waiting {wait_time:.2f}s before retry {retries}/{self.max_retries}" |
| ) |
|
|
| |
| await self._log_rate_limit_alert(full_error, retries, wait_time) |
|
|
| |
| if progress_callback: |
| await progress_callback( |
| { |
| "step": "ai_generation", |
| "log": f"⏱️ Rate limit hit. Waiting {wait_time:.0f}s before retry {retries}/{self.max_retries}", |
| } |
| ) |
|
|
| await asyncio.sleep(wait_time) |
| continue |
| else: |
| |
| logger.debug(f"Non-rate-limit error, re-raising: {full_error}") |
| if progress_callback: |
| await progress_callback( |
| { |
| "step": "ai_generation", |
| "log": f"❌ Error: {str(e)}", |
| } |
| ) |
| raise |
|
|
| raise Exception(f"Failed after {self.max_retries} retries") |
|
|
| def _extract_wait_time(self, error_message: str) -> float | None: |
| """Extract wait time from OpenAI error message.""" |
| try: |
| |
| import re |
|
|
| match = re.search(r"try again in (\d+(?:\.\d+)?)s", error_message) |
| if match: |
| return float(match.group(1)) |
| except Exception: |
| pass |
| return None |
|
|
|
|
| class BaseAgent[DepsT, OutputT](ABC): |
| """ |
| Base class for all PydanticAI agents in the Archon system. |
| |
| Provides common functionality like: |
| - Error handling and retries |
| - Rate limiting protection |
| - Logging and monitoring |
| - Standard dependency injection |
| - Common tools and utilities |
| """ |
|
|
| def __init__( |
| self, |
| model: str | None = None, |
| name: str | None = None, |
| retries: int = 3, |
| enable_rate_limiting: bool = True, |
| **agent_kwargs, |
| ): |
| if not model: |
| raise ValueError( |
| f"No model specified for {self.__class__.__name__}. Please set the appropriate environment variable." |
| ) |
| self.model = model |
| self.name = name or self.__class__.__name__ |
| self.retries = retries |
| self.enable_rate_limiting = enable_rate_limiting |
|
|
| |
| self.rate_limiter: RateLimitHandler | None |
| if self.enable_rate_limiting: |
| self.rate_limiter = RateLimitHandler(max_retries=retries) |
| else: |
| self.rate_limiter = None |
|
|
| |
| self._agent: Agent[DepsT, OutputT] = self._create_agent(**agent_kwargs) |
|
|
| |
| self.logger = logging.getLogger(f"agents.{self.name}") |
|
|
| @abstractmethod |
| def _create_agent(self, **kwargs) -> Agent[DepsT, OutputT]: |
| """Create and configure the PydanticAI agent. Must be implemented by subclasses.""" |
| pass |
|
|
| @abstractmethod |
| def get_system_prompt(self) -> str: |
| """Get the system prompt for this agent. Must be implemented by subclasses.""" |
| pass |
|
|
| async def run(self, user_prompt: str, deps: DepsT) -> OutputT: |
| """ |
| Run the agent with rate limiting protection. |
| |
| Args: |
| user_prompt: The user's input prompt |
| deps: Dependencies for the agent |
| |
| Returns: |
| The agent's structured output |
| """ |
| if self.rate_limiter: |
| |
| progress_callback = getattr(deps, "progress_callback", None) |
| return await self.rate_limiter.execute_with_rate_limit( |
| self._run_agent, user_prompt, deps, progress_callback=progress_callback |
| ) |
| else: |
| return await self._run_agent(user_prompt, deps) |
|
|
| async def _run_agent(self, user_prompt: str, deps: DepsT) -> OutputT: |
| """Internal method to run the agent with global resilience and token logging.""" |
| import os |
|
|
| import httpx |
|
|
| from src.agents.utils.resilience import get_pydantic_ai_output, run_agent_with_global_resilience |
|
|
| try: |
| |
| result = await asyncio.wait_for( |
| run_agent_with_global_resilience(self._agent, user_prompt, deps=deps), |
| timeout=180.0, |
| ) |
| self.logger.info(f"Agent {self.name} completed successfully") |
|
|
| |
| try: |
| if result.usage(): |
| server_port = os.getenv("ARCHON_SERVER_PORT", "8181") |
| async with httpx.AsyncClient() as client: |
| payload = { |
| "model": self.model |
| if isinstance(self.model, str) |
| else getattr(self.model, "model_name", "unknown"), |
| "provider": "google", |
| "input_tokens": result.usage().request_tokens or 0, |
| "output_tokens": result.usage().response_tokens or 0, |
| "context_type": f"agent_{self.name.lower()}", |
| } |
| server_host = os.getenv("ARCHON_SERVER_HOST") or os.getenv("ARCHON_HOST") or "127.0.0.1" |
| await client.post( |
| f"http://{server_host}:{server_port}/internal/stats/token-usage", |
| json=payload, |
| timeout=5.0, |
| ) |
| except Exception as e: |
| self.logger.warning(f"Failed to log token usage: {e}") |
|
|
| return get_pydantic_ai_output(result) |
| except TimeoutError as e: |
| self.logger.error(f"Agent {self.name} timed out after 180 seconds") |
| raise Exception(f"Agent {self.name} operation timed out - taking too long to respond") from e |
| except Exception as e: |
| self.logger.error(f"Agent {self.name} failed: {str(e)}") |
| raise |
|
|
| def run_stream(self, user_prompt: str, deps: DepsT): |
| """ |
| Run the agent with streaming output. |
| |
| Args: |
| user_prompt: The user's input prompt |
| deps: Dependencies for the agent |
| |
| Returns: |
| Async context manager for streaming results |
| """ |
| |
| |
| self.logger.info(f"Starting streaming for agent {self.name}") |
| |
| return self._agent.run_stream(user_prompt, deps=deps) |
|
|
| def add_tool(self, func, **tool_kwargs): |
| """ |
| Add a tool function to the agent. |
| |
| Args: |
| func: The function to register as a tool |
| **tool_kwargs: Additional arguments for the tool decorator |
| """ |
| return self._agent.tool(**tool_kwargs)(func) |
|
|
| def add_system_prompt_function(self, func): |
| """ |
| Add a dynamic system prompt function to the agent. |
| |
| Args: |
| func: The function to register as a system prompt |
| """ |
| return self._agent.system_prompt(func) |
|
|
| @property |
| def agent(self) -> Agent[DepsT, OutputT]: |
| """Get the underlying PydanticAI agent instance.""" |
| return self._agent |
|
|