myrmidon / python /src /agents /base_agent.py
tek Atrust
chore(deploy): build monolithic server for Hugging Face
d5ef46f
Raw
History Blame Contribute Delete
12.6 kB
"""
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
# Type variables for generic agent typing
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 # Increased from 0.1 to 0.5s (2 requests/sec max) for stability
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:
# Proactive Throttling: Ensure minimum interval between requests
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}")
# Check for different types of rate limits
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
# Extract wait time from error message if available
wait_time = self._extract_wait_time(full_error)
if wait_time is None:
# Use exponential backoff
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}"
)
# Log to Database as ALERT for Charlie (Manager) to see
await self._log_rate_limit_alert(full_error, retries, wait_time)
# Send progress update if callback provided
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:
# Non-rate-limit error, re-raise immediately
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:
# Look for patterns like "Please try again in 1.242s"
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
# Initialize rate limiting
self.rate_limiter: RateLimitHandler | None
if self.enable_rate_limiting:
self.rate_limiter = RateLimitHandler(max_retries=retries)
else:
self.rate_limiter = None
# Initialize the PydanticAI agent
self._agent: Agent[DepsT, OutputT] = self._create_agent(**agent_kwargs)
# Setup logging
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:
# Extract progress callback from deps if available
progress_callback = getattr(deps, "progress_callback", None)
return await self.rate_limiter.execute_with_rate_limit( # type: ignore[no-any-return]
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:
# Add timeout to prevent hanging, use global resilience
result = await asyncio.wait_for(
run_agent_with_global_resilience(self._agent, user_prompt, deps=deps),
timeout=180.0, # Increased timeout for backoffs
)
self.logger.info(f"Agent {self.name} completed successfully")
# Phase 5.4.4: Fix Token Logging Gap for all generic agent runs
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) # type: ignore[no-any-return]
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
"""
# Note: Rate limiting not supported for streaming to avoid complexity
# The async context manager pattern doesn't work well with rate limiting
self.logger.info(f"Starting streaming for agent {self.name}")
# run_stream returns an async context manager directly, not a coroutine
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