Files
leocrm/app/plugins/builtins/automation/agent_routes.py
T

480 lines
15 KiB
Python

"""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(
"/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,
)
@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