myrmidon / python /src /agents /server.py
tek Atrust
chore(deploy): build monolithic server for Hugging Face
d5ef46f
Raw
History Blame Contribute Delete
12.2 kB
"""
Agents Service - Lightweight FastAPI server for PydanticAI agents
This service ONLY hosts PydanticAI agents. It does NOT contain:
- ML models or embeddings (those are in Server)
- Direct database access (use MCP tools)
- Business logic (that's in Server)
The agents use MCP tools for all data operations.
"""
import asyncio
import json
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any, cast
import httpx
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
# Import our PydanticAI agents
from .document_agent import DocumentAgent
from .rag_agent import RagAgent
from .rerank_router import router as rerank_router
from .workflow_engine import WorkflowEngine
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logging.getLogger("google_genai._api_client").setLevel(logging.ERROR)
# Request/Response models
class AgentRequest(BaseModel):
"""Request model for agent interactions"""
agent_type: str # "document", "rag", etc.
prompt: str
context: dict[str, Any] | None = None
options: dict[str, Any] | None = None
class AgentResponse(BaseModel):
"""Response model for agent interactions"""
success: bool
result: Any | None = None
error: str | None = None
metadata: dict[str, Any] | None = None
# Agent registry
AVAILABLE_AGENTS = {
"document": DocumentAgent,
"rag": RagAgent,
}
# Global credentials storage
AGENT_CREDENTIALS = {}
async def fetch_credentials_from_server():
"""Fetch credentials from the server's internal API."""
max_retries = 30 # Try for up to 5 minutes (30 * 10 seconds)
retry_delay = 10 # seconds
for attempt in range(max_retries):
try:
async with httpx.AsyncClient() as client:
# Call the server's internal credentials endpoint
server_port = os.getenv("ARCHON_SERVER_PORT")
if not server_port:
raise ValueError(
"ARCHON_SERVER_PORT environment variable is required. "
"Please set it in your .env file or environment."
)
server_host = os.getenv("ARCHON_SERVER_HOST") or os.getenv("ARCHON_HOST") or "127.0.0.1"
response = await client.get(
f"http://{server_host}:{server_port}/internal/credentials/agents", timeout=10.0
)
response.raise_for_status()
credentials = response.json()
# Set credentials as environment variables
for key, value in credentials.items():
if value is not None:
os.environ[key] = str(value)
logger.info(f"Set credential: {key}")
# Store credentials globally for agent initialization
global AGENT_CREDENTIALS
AGENT_CREDENTIALS = credentials
logger.info(f"Successfully fetched {len(credentials)} credentials from server")
return credentials
except (httpx.HTTPError, httpx.RequestError) as e:
if attempt < max_retries - 1:
logger.warning(f"Failed to fetch credentials (attempt {attempt + 1}/{max_retries}): {e}")
logger.info(f"Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
else:
logger.error(f"Failed to fetch credentials after {max_retries} attempts")
raise Exception("Could not fetch credentials from server") from e
# Lifespan context manager
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize and cleanup resources"""
logger.info("Starting Agents service...")
# Fetch credentials from server first
try:
await fetch_credentials_from_server()
except Exception as e:
logger.error(f"Failed to fetch credentials: {e}")
# Continue with defaults if we can't get credentials
# Initialize agents with fetched credentials
app.state.agents = {}
for name, agent_class in AVAILABLE_AGENTS.items():
try:
# Pass model configuration from credentials, fallback to ENV
model_key = f"{name.upper()}_AGENT_MODEL"
model = AGENT_CREDENTIALS.get(model_key) or os.getenv(model_key)
if not model:
raise ValueError(f"❌ [SSOT Violation] Model configuration '{model_key}' missing from DB and ENV.")
app.state.agents[name] = agent_class(model=model)
logger.info(f"Initialized {name} agent with model: {model}")
except Exception as e:
logger.error(f"Failed to initialize {name} agent: {e}")
yield
# Cleanup
logger.info("Shutting down Agents service...")
# Create FastAPI app
app = FastAPI(
title="Archon Agents Service",
description="Lightweight service hosting PydanticAI agents",
version="1.0.0",
lifespan=lifespan,
)
app.include_router(rerank_router, prefix="/ml", tags=["ml"])
# --- Workflow Endpoint (Phase 5.2) ---
class WorkflowRequest(BaseModel):
prompt: str
context: dict[str, Any] | None = None
@app.post("/agents/workflow/run", response_model=AgentResponse)
async def run_workflow(request: WorkflowRequest):
"""
Run a multi-agent workflow using the pydantic-graph State Router.
Phase 5.2: Supervisor / Worker Topology.
Phase 5.0.2: Pass context for Dynamic Prompt Governance.
"""
try:
task_type = "General"
if request.context:
task_type = request.context.get("task_type", "General")
engine = WorkflowEngine()
result = await engine.run_workflow(request.prompt, task_type)
if result["success"]:
return AgentResponse(
success=True,
result=result["final_result"],
metadata={"step_count": result["step_count"], "messages": result["messages"]},
)
else:
return AgentResponse(
success=False,
error=result.get("error", "Unknown error in workflow"),
metadata={"step_count": result["step_count"]},
)
except Exception as e:
logger.error(f"Error in workflow endpoint: {e}")
return AgentResponse(success=False, error=str(e))
@app.get("/")
@app.head("/")
async def root():
"""Root endpoint for the agents service"""
return {"status": "healthy", "service": "agents"}
@app.get("/health")
@app.head("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "agents",
"agents_available": list(AVAILABLE_AGENTS.keys()),
"note": "This service only hosts PydanticAI agents",
}
@app.post("/agents/run", response_model=AgentResponse)
async def run_agent(request: AgentRequest):
"""
Run a specific agent with the given prompt.
The agent will use MCP tools for any data operations.
"""
try:
# Get the requested agent
if request.agent_type not in app.state.agents:
raise HTTPException(status_code=400, detail=f"Unknown agent type: {request.agent_type}")
agent = app.state.agents[request.agent_type]
# Prepare dependencies for the agent
deps = {
"context": request.context or {},
"options": request.options or {},
"mcp_endpoint": os.getenv("MCP_SERVICE_URL", "http://archon-mcp:8051"),
}
# Run the agent
result = await agent.run(request.prompt, deps)
return AgentResponse(
success=True,
result=result,
metadata={"agent_type": request.agent_type, "model": agent.model},
)
except Exception as e:
logger.error(f"Error running {request.agent_type} agent: {e}")
return AgentResponse(success=False, error=str(e))
@app.get("/agents/list")
async def list_agents():
"""List all available agents and their capabilities"""
agents_info = {}
for name, agent in app.state.agents.items():
agents_info[name] = {
"name": agent.name,
"model": agent.model,
"description": agent.__class__.__doc__ or "No description available",
"available": True,
}
return {"agents": agents_info, "total": len(agents_info)}
@app.post("/agents/{agent_type}/stream")
async def stream_agent(agent_type: str, request: AgentRequest):
"""
Stream responses from an agent using Server-Sent Events (SSE).
This endpoint streams the agent's response in real-time, allowing
for a more interactive experience.
"""
# Get the requested agent
if agent_type not in app.state.agents:
raise HTTPException(status_code=400, detail=f"Unknown agent type: {agent_type}")
agent = app.state.agents[agent_type]
async def generate() -> AsyncGenerator[str, None]:
try:
# Prepare dependencies based on agent type
# Import dependency classes
from .base_agent import ArchonDependencies
deps: ArchonDependencies
if agent_type == "rag":
from .rag_agent import RagDependencies
deps = RagDependencies(
source_filter=request.context.get("source_filter") if request.context else None,
match_count=request.context.get("match_count", 5) if request.context else 5,
project_id=cast(str, request.context.get("project_id")) if request.context else None,
)
elif agent_type == "document":
from .document_agent import DocumentDependencies
deps = DocumentDependencies(
project_id=cast(str, (request.context.get("project_id") if request.context else "") or ""),
user_id=cast(str, request.context.get("user_id")) if request.context else None,
)
else:
# Default dependencies
deps = ArchonDependencies()
# Use PydanticAI's run_stream method
# run_stream returns an async context manager directly
async with agent.run_stream(request.prompt, deps) as stream:
# Stream text chunks as they arrive
async for chunk in stream.stream_text():
event_data = json.dumps({"type": "stream_chunk", "content": chunk})
yield f"data: {event_data}\n\n"
# Get the final structured result
try:
final_result = await stream.get_data()
event_data = json.dumps({"type": "stream_complete", "content": final_result})
yield f"data: {event_data}\n\n"
except Exception:
# If we can't get structured data, just send completion
event_data = json.dumps({"type": "stream_complete", "content": ""})
yield f"data: {event_data}\n\n"
except Exception as e:
logger.error(f"Error streaming {agent_type} agent: {e}")
event_data = json.dumps({"type": "error", "error": str(e)})
yield f"data: {event_data}\n\n"
# Return SSE response
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable Nginx buffering
},
)
# Main entry point
if __name__ == "__main__":
agents_port = os.getenv("ARCHON_AGENTS_PORT")
if not agents_port:
raise ValueError(
"ARCHON_AGENTS_PORT environment variable is required. "
"Please set it in your .env file or environment. "
"Default value: 8052"
)
port = int(agents_port)
uvicorn.run(
"server:app",
host="0.0.0.0",
port=port,
log_level="info",
reload=False, # Disable reload in production
)