"""API routes for the Agents plugin — /api/v1/agents. Endpoints: agent definitions CRUD, execute, test-run, runs, versions, tools. """ from __future__ import annotations import logging import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db, set_tenant_context from app.deps import get_current_user, require_permission from app.plugins.builtins.automation.models import ( AgentDefinition, AgentRun, AgentVersion, ) from app.plugins.builtins.automation.schemas import ( AgentDefinitionCreate, AgentDefinitionListResponse, AgentDefinitionResponse, AgentDefinitionUpdate, AgentMessageRequest, AgentRunListResponse, AgentRunResponse, AgentVersionListResponse, AgentVersionResponse, ) from app.plugins.builtins.automation.services import ( AgentService, RunLogService, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) # ─── Helper Functions ─── def _agent_to_response(a: AgentDefinition) -> AgentDefinitionResponse: """Convert AgentDefinition model to response schema.""" return AgentDefinitionResponse( id=str(a.id), name=a.name, description=a.description or "", llm_model=a.llm_model, system_prompt=a.system_prompt or "", tool_ids=[str(t) for t in (a.tool_ids or [])], heartbeat_interval_seconds=a.heartbeat_interval_seconds, mode=a.mode, is_active=a.is_active, max_executions_per_hour=a.max_executions_per_hour, max_duration_seconds=a.max_duration_seconds, budget_limit_usd=a.budget_limit_usd, created_by=str(a.created_by) if a.created_by else None, created_at=a.created_at.isoformat() if a.created_at else None, updated_at=a.updated_at.isoformat() if a.updated_at else None, ) def _agent_run_to_response(r: AgentRun) -> AgentRunResponse: """Convert AgentRun model to response schema.""" return AgentRunResponse( id=str(r.id), agent_id=str(r.agent_id), status=r.status, started_at=r.started_at.isoformat() if r.started_at else "", completed_at=r.completed_at.isoformat() if r.completed_at else None, duration_seconds=r.duration_seconds, result=r.result, error=r.error, cost_usd=r.cost_usd, trigger_type=r.trigger_type, trigger_data=r.trigger_data or {}, created_at=r.created_at.isoformat() if r.created_at else None, ) def _agent_version_to_response(v: AgentVersion) -> AgentVersionResponse: """Convert AgentVersion model to response schema.""" return AgentVersionResponse( id=str(v.id), agent_id=str(v.agent_id), version_number=v.version_number, snapshot=v.snapshot or {}, changed_by=str(v.changed_by) if v.changed_by else None, created_at=v.created_at.isoformat() if v.created_at else None, ) # ─── CRUD Endpoints ─── @router.get( "/", dependencies=[Depends(require_permission("agents:read"))], response_model=AgentDefinitionListResponse, ) async def list_agents( is_active: bool | None = Query(None), mode: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List agent definitions with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) items, total = await AgentService.list( db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset ) return AgentDefinitionListResponse( items=[_agent_to_response(a) for a in items], total=total, ) @router.post( "/", dependencies=[Depends(require_permission("agents:write"))], response_model=AgentDefinitionResponse, status_code=201, ) async def create_agent( data: AgentDefinitionCreate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a new agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) agent = await AgentService.create(db, tenant_id, data.model_dump(), user_id=user_id) return _agent_to_response(agent) @router.get( "/{agent_id}", dependencies=[Depends(require_permission("agents:read"))], response_model=AgentDefinitionResponse, ) async def get_agent( agent_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get a single agent definition by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") agent = await AgentService.get_by_id(db, tenant_id, aid) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") return _agent_to_response(agent) @router.patch( "/{agent_id}", dependencies=[Depends(require_permission("agents:write"))], response_model=AgentDefinitionResponse, ) async def update_agent( agent_id: str, data: AgentDefinitionUpdate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update an existing agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") agent = await AgentService.update( db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") return _agent_to_response(agent) @router.delete( "/{agent_id}", dependencies=[Depends(require_permission("agents:delete"))], ) async def delete_agent( agent_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete an agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") success = await AgentService.delete(db, tenant_id, aid) if not success: raise HTTPException(status_code=404, detail="Agent not found") return {"status": "ok"} # ─── Execute / Test-Run ─── @router.post( "/{agent_id}/execute", dependencies=[Depends(require_permission("agents:execute"))], ) async def execute_agent( agent_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Execute an agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") agent = await AgentService.get_by_id(db, tenant_id, aid) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") if not agent.is_active: raise HTTPException(status_code=400, detail="Agent is not active") # Create run log entry run = AgentRun( tenant_id=tenant_id, agent_id=aid, status="running", started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), trigger_type="manual", trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"]))}, ) db.add(run) await db.flush() # TODO: Execute agent asynchronously via Agent Runner run.status = "completed" run.completed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc) run.duration_seconds = 0.0 run.result = "Executed successfully (stub)" await db.flush() return {"status": "ok", "run_id": str(run.id)} @router.post( "/{agent_id}/test-run", dependencies=[Depends(require_permission("agents:execute"))], ) async def test_run_agent( agent_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Test-run an agent definition (validates config without full execution).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") agent = await AgentService.get_by_id(db, tenant_id, aid) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") return { "status": "ok", "agent": _agent_to_response(agent), "validation": { "valid": True, "llm_model": agent.llm_model, "tool_count": len(agent.tool_ids or []), "mode": agent.mode, }, } # ─── Runs ─── @router.get( "/{agent_id}/runs", dependencies=[Depends(require_permission("agents:read"))], response_model=AgentRunListResponse, ) async def list_agent_runs( agent_id: str, status: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List run logs for an agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") items, total = await RunLogService.list_agent_runs( db, tenant_id, agent_id=aid, status=status, limit=limit, offset=offset ) return AgentRunListResponse( items=[_agent_run_to_response(r) for r in items], total=total, ) # ─── Versions ─── @router.get( "/{agent_id}/versions", dependencies=[Depends(require_permission("agents:read"))], response_model=AgentVersionListResponse, ) async def list_agent_versions( agent_id: str, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List version history for an agent definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(agent_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") items, total = await AgentService.get_versions( db, tenant_id, aid, limit=limit, offset=offset ) return AgentVersionListResponse( items=[_agent_version_to_response(v) for v in items], total=total, ) @router.post( "/{agent_id}/versions/{version_id}/restore", dependencies=[Depends(require_permission("agents:write"))], response_model=AgentDefinitionResponse, ) async def restore_agent_version( agent_id: str, version_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Restore an agent definition from a specific version.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: aid = uuid.UUID(agent_id) vid = uuid.UUID(version_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid ID") agent = await AgentService.restore_version( db, tenant_id, aid, vid, user_id=user_id ) if agent is None: raise HTTPException(status_code=404, detail="Agent or version not found") return _agent_to_response(agent) # ─── Tools ─── @router.post( "/{id}/send-message", dependencies=[Depends(require_permission("agents:execute"))], ) async def send_agent_message_endpoint( id: str, body: AgentMessageRequest, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Send a message from one agent to another.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: aid = uuid.UUID(id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") # Verify the source agent exists agent = await AgentService.get_by_id(db, tenant_id, aid) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") if not agent.is_active: raise HTTPException(status_code=400, detail="Agent is not active") from app.plugins.builtins.automation.agent_comm import send_agent_message result = await send_agent_message( from_agent_id=str(aid), to_agent_name=body.to_agent_name, message=body.message, db=db, tenant_id=tenant_id, ) return result @router.get( "/tools", dependencies=[Depends(require_permission("agents:read"))], ) async def list_tools( current_user: dict[str, Any] = Depends(get_current_user), ): """List available tools from the tool registry.""" try: from app.plugins.builtins.ai_assistant.tool_registry import ( get_tool_registry, ) registry = get_tool_registry() tools = registry.list_tools() return { "items": [ { "id": t.get("id", t.get("name", "")), "name": t.get("name", ""), "description": t.get("description", ""), "plugin": t.get("plugin", ""), } for t in tools ], "total": len(tools), } except ImportError: return {"items": [], "total": 0, "note": "Tool registry not available"} except Exception as e: logger.exception("Failed to list tools") return {"items": [], "total": 0, "error": str(e)} # ─── Recent Runs ─── @router.get( "/runs/recent", dependencies=[Depends(require_permission("agents:read"))], response_model=AgentRunListResponse, ) async def list_recent_agent_runs( limit: int = Query(20, ge=1, le=100), status: str | None = Query(None), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List recent agent runs across all agents (for dashboard).""" tenant_id = uuid.UUID(current_user["tenant_id"]) items, total = await RunLogService.list_agent_runs( db, tenant_id, agent_id=None, status=status, limit=limit, offset=0 ) return AgentRunListResponse( items=[_agent_run_to_response(r) for r in items], total=total, )