Files
leocrm/app/plugins/builtins/automation/agent_routes.py
T
Agent Zero 03dd477899
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(N4): Restliche Module — Tasks/Kommunikation/Wiki/Reports/Agents/Tags/Search + Navigation + Dashboard-Schnittstelle (#368)
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry
- Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M)
- Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard
- apply_entity_type_scope-Helper (requested ∧ scope)
- Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder()
- Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK
- Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
2026-09-01 23:23:15 +02:00

670 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, require_workspace_scope
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),
workspace_scope: dict | None = Depends(require_workspace_scope("agents")),
):
"""List agent definitions with optional filters.
Phase N4: an active workspace scope restricts the list to the
configured agent subset (pure AND — never a grant).
"""
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,
)
# Phase N4: workspace scope — agent subset (pure AND)
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
agent_scope = scope_uuid_set(workspace_scope.get("agent_ids"))
if agent_scope is not None:
items = [a for a in items if a.id in agent_scope]
total = len(items)
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",
)