| """ |
| 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 |
|
|
| |
| from .document_agent import DocumentAgent |
| from .rag_agent import RagAgent |
| from .rerank_router import router as rerank_router |
| from .workflow_engine import WorkflowEngine |
|
|
| |
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
| logging.getLogger("google_genai._api_client").setLevel(logging.ERROR) |
|
|
|
|
| |
| class AgentRequest(BaseModel): |
| """Request model for agent interactions""" |
|
|
| agent_type: str |
| 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 |
|
|
|
|
| |
| AVAILABLE_AGENTS = { |
| "document": DocumentAgent, |
| "rag": RagAgent, |
| } |
|
|
| |
| AGENT_CREDENTIALS = {} |
|
|
|
|
| async def fetch_credentials_from_server(): |
| """Fetch credentials from the server's internal API.""" |
| max_retries = 30 |
| retry_delay = 10 |
|
|
| for attempt in range(max_retries): |
| try: |
| async with httpx.AsyncClient() as client: |
| |
| 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() |
|
|
| |
| for key, value in credentials.items(): |
| if value is not None: |
| os.environ[key] = str(value) |
| logger.info(f"Set credential: {key}") |
|
|
| |
| 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 |
|
|
|
|
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| """Initialize and cleanup resources""" |
| logger.info("Starting Agents service...") |
|
|
| |
| try: |
| await fetch_credentials_from_server() |
| except Exception as e: |
| logger.error(f"Failed to fetch credentials: {e}") |
| |
|
|
| |
| app.state.agents = {} |
| for name, agent_class in AVAILABLE_AGENTS.items(): |
| try: |
| |
| 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 |
|
|
| |
| logger.info("Shutting down Agents service...") |
|
|
|
|
| |
| 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"]) |
|
|
|
|
| |
| 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: |
| |
| 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] |
|
|
| |
| deps = { |
| "context": request.context or {}, |
| "options": request.options or {}, |
| "mcp_endpoint": os.getenv("MCP_SERVICE_URL", "http://archon-mcp:8051"), |
| } |
|
|
| |
| 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. |
| """ |
| |
| 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: |
| |
| |
| 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: |
| |
| deps = ArchonDependencies() |
|
|
| |
| |
| async with agent.run_stream(request.prompt, deps) as stream: |
| |
| async for chunk in stream.stream_text(): |
| event_data = json.dumps({"type": "stream_chunk", "content": chunk}) |
| yield f"data: {event_data}\n\n" |
|
|
| |
| 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: |
| |
| 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 StreamingResponse( |
| generate(), |
| media_type="text/event-stream", |
| headers={ |
| "Cache-Control": "no-cache", |
| "X-Accel-Buffering": "no", |
| }, |
| ) |
|
|
|
|
| |
| 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, |
| ) |
|
|