"""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 datetime import UTC from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db 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, temperature=a.temperature, max_tokens=a.max_tokens, max_steps=a.max_steps, trace_mode=a.trace_mode, skill_ids=[str(s) for s in (a.skill_ids or [])], trigger_config=a.trigger_config or {}, ai_use_case_metadata=a.ai_use_case_metadata or {}, 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"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) items, total = await AgentService.list( db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset, user_id=user_id, is_system_admin=is_system_admin, ) 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( "/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.contracts 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, ) @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") from None 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") from None 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) # ─── AI Use-Case Metadata ─── class AIUseCaseMetadataUpdate(BaseModel): """Update AI use-case metadata for an agent.""" intended_purpose: str | None = None owner: str | None = None data_categories: list[str] | None = None allowed_providers: list[str] | None = None allowed_models: list[str] | None = None allowed_actions: list[str] | None = None oversight_policy: str | None = None risk_class: str | None = None human_review_required: bool | None = None @router.get( "/{agent_id}/ai-use-case", dependencies=[Depends(require_permission("agents:read"))], ) async def get_ai_use_case( agent_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get AI use-case metadata for an agent.""" 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") from None 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_id": agent_id, "ai_use_case_metadata": agent.ai_use_case_metadata or {}} @router.patch( "/{agent_id}/ai-use-case", dependencies=[Depends(require_permission("agents:write"))], ) async def update_ai_use_case( agent_id: str, data: AIUseCaseMetadataUpdate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update AI use-case metadata for an agent.""" 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") from None agent = await AgentService.get_by_id(db, tenant_id, aid) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") # Merge with existing metadata (partial update). current = dict(agent.ai_use_case_metadata or {}) updates = data.model_dump(exclude_none=True) current.update(updates) # Validate the merged metadata against the agent config. from app.ai.ai_use_case import AIUseCaseMetadata, validate_ai_use_case try: metadata = AIUseCaseMetadata(**current) except Exception as e: raise HTTPException(status_code=400, detail=f"Invalid AI use-case metadata: {e}") from None warnings = validate_ai_use_case(metadata, agent) updated = await AgentService.update( db, tenant_id, aid, {"ai_use_case_metadata": current}, user_id=user_id ) if updated is None: raise HTTPException(status_code=404, detail="Agent not found") return { "agent_id": agent_id, "ai_use_case_metadata": current, "warnings": warnings, } @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") from None 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") from None 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() # Execute agent via agent runner from app.plugins.builtins.automation.agent_runner import run_agent run_id = str(run.id) await db.commit() # Run agent result = await run_agent( ctx={"tenant_id": str(tenant_id), "user_id": current_user["user_id"]}, agent_id=str(aid), trigger_type="manual", trigger_data={"triggered_by": current_user["user_id"]}, ) # Update run with results from datetime import datetime from sqlalchemy import update as sa_update now = datetime.now(UTC) async with db.begin(): await db.execute( sa_update(AgentRun) .where(AgentRun.id == run.id) .values( status=result.get("status", "completed"), completed_at=now, duration_seconds=0.0, result=result, output_data=result, ) ) return {"status": result.get("status", "ok"), "run_id": run_id, "result": result} @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") from None 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") from None 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") from None 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") from None 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") from None # 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 # ─── Punkt 7: SSE Streaming Endpoint (agent_stream.py) ───────────────────── @router.post("/{id}/stream") async def stream_agent_run( id: str, body: AgentMessageRequest, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Stream an agent run via Server-Sent Events (SSE). Uses ``app.ai.agent_stream.stream_react_loop`` to emit step events in real-time as the agent processes. """ from fastapi.responses import StreamingResponse from app.ai.agent_stream import stream_react_loop from app.plugins.builtins.ai_assistant.contracts import get_tool_registry tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: aid = uuid.UUID(id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid agent ID") from None 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") registry = get_tool_registry() tool_ids: list[str] = list(agent.tool_ids or []) tools = registry.get_by_names(tool_ids) if tool_ids else [] tool_schemas = [t.to_openai_schema() for t in tools] if tools else [] return StreamingResponse( stream_react_loop( agent_definition=agent, user_message=body.message, tools=tool_schemas, tool_registry=registry, db=db, tenant_id=tenant_id, user_id=user_id, agent_run_id=aid, ), media_type="text/event-stream", )