Files
leocrm/app/plugins/builtins/automation/agent_routes.py
T
Agent Zero 04e92794de
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(M6): agents/tools-Endpoint — list_for_api statt nichtexistenter list_tools (#364)
- Vorbestands-Bug (live gemessen: 500 "ToolRegistry has no attribute list_tools"):
  automation/agent_routes.py rief registry.list_tools() auf, ToolRegistry
  bietet get_all()/list_for_api() — Route auf list_for_api() mit korrektem
  Feld-Mapping (plugin_name -> plugin) umgestellt
- Regressionstest gesichert (test_agents_tools_route_uses_list_for_api)
- M6-Suite 8/8 gruen
2026-08-31 01:07:56 +02:00

657 lines
21 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 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_for_api()
return {
"items": [
{
"id": t.get("name", ""),
"name": t.get("name", ""),
"description": t.get("description", ""),
"plugin": t.get("plugin_name", ""),
}
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",
)