diff --git a/app/core/worker.py b/app/core/worker.py index cad8905..239b40b 100644 --- a/app/core/worker.py +++ b/app/core/worker.py @@ -6,6 +6,7 @@ import logging from typing import Any from arq.connections import RedisSettings +from arq import cron from app.config import get_settings @@ -48,6 +49,10 @@ from app.plugins.builtins.unified_search.jobs import ( embedding_batch, ) from app.plugins.builtins.ai_proactive.jobs import deep_analysis +from app.plugins.builtins.automation.scheduler import scheduler_tick +from app.plugins.builtins.automation.workflow_timeout import check_workflow_timeouts +from app.plugins.builtins.automation.agent_runner import run_agent +from app.plugins.builtins.automation.execution_engine import run_automation class WorkerSettings: @@ -60,6 +65,10 @@ class WorkerSettings: reindex, embedding_batch, deep_analysis, + scheduler_tick, + check_workflow_timeouts, + run_agent, + run_automation, ] redis_settings = _get_redis_settings() on_startup = on_startup @@ -67,3 +76,7 @@ class WorkerSettings: max_jobs = 10 job_timeout = 300 queue_name = "arq:queue" + cron_jobs = [ + cron(scheduler_tick, second={0, 30}), + cron(check_workflow_timeouts, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}), + ] diff --git a/app/plugins/builtins/automation/__init__.py b/app/plugins/builtins/automation/__init__.py new file mode 100644 index 0000000..f2454e5 --- /dev/null +++ b/app/plugins/builtins/automation/__init__.py @@ -0,0 +1 @@ +"""Automation plugin — cron scheduling, agent runner, workflow timeout, execution engine.""" diff --git a/app/plugins/builtins/automation/agent_comm.py b/app/plugins/builtins/automation/agent_comm.py new file mode 100644 index 0000000..49054cf --- /dev/null +++ b/app/plugins/builtins/automation/agent_comm.py @@ -0,0 +1,195 @@ +"""Agent-to-Agent communication router.""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +async def send_agent_message( + from_agent_id: str, + to_agent_name: str, + message: str, + db: AsyncSession, + tenant_id: uuid.UUID, +) -> dict: + """Send a message from one agent to another. + + 1. Find target agent by name + 2. Create a kommunikation message in a dedicated agent room + 3. Enqueue run_agent for the target agent with the message as trigger_data + 4. Return delivery status + """ + from app.plugins.builtins.automation.models import AgentDefinition + from app.plugins.builtins.automation.agent_runner import run_agent + + # 1. Find target agent by name + result = await db.execute( + select(AgentDefinition) + .where(AgentDefinition.name == to_agent_name) + .where(AgentDefinition.tenant_id == tenant_id) + .limit(1) + ) + target_agent = result.scalar_one_or_none() + + if target_agent is None: + logger.warning( + "Target agent '%s' not found for message from agent %s", + to_agent_name, from_agent_id, + ) + return {"status": "error", "error": f"Target agent '{to_agent_name}' not found"} + + if not target_agent.is_active: + logger.warning( + "Target agent '%s' is inactive, cannot deliver message from %s", + to_agent_name, from_agent_id, + ) + return {"status": "error", "error": f"Target agent '{to_agent_name}' is inactive"} + + # 2. Create a kommunikation message in a dedicated agent room + try: + from app.plugins.builtins.kommunikation.models import Message, Room + from app.plugins.builtins.kommunikation.services import RoomService + + # Find or create the agent-to-agent room + room_name = f"agent:{from_agent_id}:{target_agent.id}" + room_result = await db.execute( + select(Room).where(Room.name == room_name).limit(1) + ) + room = room_result.scalar_one_or_none() + + if room is None: + # Create a new room for agent communication + room = Room( + tenant_id=tenant_id, + name=room_name, + display_name=f"Agent Chat: {from_agent_id} -> {to_agent_name}", + room_type="agent_comm", + is_direct=True, + ) + db.add(room) + await db.flush() + + # Create the message + msg = Message( + tenant_id=tenant_id, + room_id=room.id, + sender_id=from_agent_id, + sender_type="agent", + content=message, + message_type="agent_comm", + ) + db.add(msg) + await db.flush() + + logger.info( + "Agent message created: %s -> %s in room %s", + from_agent_id, to_agent_name, room_name, + ) + except ImportError: + logger.warning("Kommunikation plugin not available, skipping message storage") + except Exception as e: + logger.exception("Failed to create kommunikation message: %s", e) + + # 3. Enqueue run_agent for the target agent with the message as trigger_data + try: + trigger_data = { + "from_agent_id": from_agent_id, + "message": message, + "type": "agent_comm", + } + # Run the target agent with the message as trigger data + result_data = await run_agent( + ctx={}, + agent_id=str(target_agent.id), + trigger_type="agent_comm", + trigger_data=trigger_data, + ) + logger.info( + "Target agent %s executed with message from %s: status=%s", + to_agent_name, from_agent_id, result_data.get("status"), + ) + except Exception as e: + logger.exception("Failed to run target agent %s: %s", to_agent_name, e) + return {"status": "error", "error": f"Failed to activate target agent: {e}"} + + return { + "status": "sent", + "target_agent": to_agent_name, + "target_agent_id": str(target_agent.id), + } + + +def register_agent_comm_tool(): + """Register the send_agent_message tool in the global tool registry.""" + from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry + + registry = get_tool_registry() + + async def handler(arguments: dict[str, Any], context: dict[str, Any]) -> str: + """Handle send_agent_message tool call from an AI agent.""" + from app.core.db import get_session_factory + + to_agent_name = arguments.get("to_agent_name", "") + message = arguments.get("message", "") + from_agent_id = context.get("agent_id", "unknown") + tenant_id_str = context.get("tenant_id", "") + + if not to_agent_name or not message: + return json.dumps({"status": "error", "error": "Missing to_agent_name or message"}) + + try: + tenant_id = uuid.UUID(tenant_id_str) if tenant_id_str else uuid.uuid4() + except (ValueError, TypeError): + return json.dumps({"status": "error", "error": "Invalid tenant_id"}) + + factory = get_session_factory() + async with factory() as db: + result = await send_agent_message( + from_agent_id=from_agent_id, + to_agent_name=to_agent_name, + message=message, + db=db, + tenant_id=tenant_id, + ) + return json.dumps(result) + + registry.register( + name="send_agent_message", + description="Send a message to another agent. The target agent will be activated with your message.", + parameters={ + "type": "object", + "properties": { + "to_agent_name": { + "type": "string", + "description": "Name of the target agent", + }, + "message": { + "type": "string", + "description": "Message to send", + }, + }, + "required": ["to_agent_name", "message"], + }, + handler=handler, + plugin_name="automation", + required_permission="agents:execute", + category="communication", + ) + logger.info("Agent communication tool 'send_agent_message' registered") + + +def unregister_agent_comm_tool(): + """Unregister the send_agent_message tool.""" + from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry + + registry = get_tool_registry() + registry.unregister("send_agent_message") + logger.info("Agent communication tool 'send_agent_message' unregistered") diff --git a/app/plugins/builtins/automation/agent_routes.py b/app/plugins/builtins/automation/agent_routes.py new file mode 100644 index 0000000..7d59b17 --- /dev/null +++ b/app/plugins/builtins/automation/agent_routes.py @@ -0,0 +1,479 @@ +"""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, + ) diff --git a/app/plugins/builtins/automation/agent_runner.py b/app/plugins/builtins/automation/agent_runner.py new file mode 100644 index 0000000..5ef214a --- /dev/null +++ b/app/plugins/builtins/automation/agent_runner.py @@ -0,0 +1,271 @@ +"""Agent runner — executes AI agents as ARQ jobs with safety checks. + +Safety features: +- Max executions per agent per hour (rate limiting) +- Max duration per execution (asyncio timeout) +- Auto-stop on infinite loop (same tool called 5x consecutively) +- Budget limit per agent (track cumulative cost_usd, stop if over budget) +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select + +from app.core.db import get_session_factory + +logger = logging.getLogger(__name__) + +# Track consecutive tool calls per agent run to detect infinite loops +_consecutive_tool_calls: dict[str, dict[str, int]] = {} # run_id -> {tool_name: count} + + +async def run_agent( + ctx: dict[str, Any], + agent_id: str, + trigger_type: str = "manual", + trigger_data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ARQ job function. Loads AgentDefinition from DB, checks rate limits, + gathers context, calls LLM via LiteLLM, executes tool calls via ToolRegistry, + saves result to AgentRun. Returns result dict. + + Safety checks: + 1. Rate limit: max_executions_per_hour + 2. Max duration: max_duration_seconds (asyncio.timeout) + 3. Infinite loop: same tool 5x consecutively + 4. Budget limit: cumulative cost_usd + """ + from app.plugins.builtins.automation.models import AgentDefinition, AgentRun + + factory = get_session_factory() + + # Load agent definition + async with factory() as db: + result = await db.execute( + select(AgentDefinition).where(AgentDefinition.id == agent_id) + ) + agent = result.scalar_one_or_none() + + if agent is None: + logger.error("AgentDefinition %s not found", agent_id) + return {"error": f"AgentDefinition {agent_id} not found", "status": "failed"} + + if not agent.is_active: + logger.warning("AgentDefinition %s is inactive", agent_id) + return {"error": "Agent is inactive", "status": "skipped"} + + # ── Safety Check 1: Rate Limit ── + if agent.max_executions_per_hour: + async with factory() as db: + one_hour_ago = datetime.now(UTC) + count_result = await db.execute( + select(func.count()) + .select_from(AgentRun) + .where( + AgentRun.agent_id == agent.id, + AgentRun.created_at >= one_hour_ago, + ) + ) + recent_runs = count_result.scalar() or 0 + if recent_runs >= agent.max_executions_per_hour: + logger.warning( + "Rate limit hit for agent %s: %d runs in last hour (max %d)", + agent.id, recent_runs, agent.max_executions_per_hour, + ) + return {"error": "Rate limit exceeded", "status": "rate_limited"} + + # ── Safety Check 2: Budget Limit ── + if agent.budget_limit_usd > 0: + async with factory() as db: + cost_result = await db.execute( + select(func.coalesce(func.sum(AgentRun.cost_usd), 0.0)) + .where(AgentRun.agent_id == agent.id) + ) + total_cost = float(cost_result.scalar() or 0.0) + if total_cost >= agent.budget_limit_usd: + logger.warning( + "Budget limit hit for agent %s: $%.4f total cost (limit $%.2f)", + agent.id, total_cost, agent.budget_limit_usd, + ) + return {"error": "Budget limit exceeded", "status": "budget_exceeded"} + + # Gather context + context_data: dict[str, Any] = {} + if trigger_type == "proactive" or agent.mode == "proactive": + # Collect context data (recent contacts, mails, events) + try: + from app.services.contact_service import list_contacts + async with factory() as db: + contacts = await list_contacts(db, agent.tenant_id, page=1, page_size=10) + context_data["recent_contacts"] = contacts.get("items", []) + except Exception: + logger.warning("Failed to collect contacts for proactive context") + + try: + from app.core.cache import get_cached_mail_summary + context_data["recent_mails"] = get_cached_mail_summary(agent.tenant_id) or [] + except Exception: + logger.warning("Failed to collect mails for proactive context") + + try: + from app.services.workflow_service import list_instances + async with factory() as db: + events = await list_instances(db, agent.tenant_id, page=1, page_size=10) + context_data["recent_events"] = events.get("items", []) + except Exception: + logger.warning("Failed to collect events for proactive context") + else: + # Reactive mode: use trigger_data as context + context_data = trigger_data or {} + + # Call LLM via LiteLLM + result_data: dict[str, Any] = { + "agent_id": str(agent.id), + "agent_name": agent.name, + "trigger_type": trigger_type, + "status": "running", + "llm_response": None, + "tool_calls": [], + "cost_usd": 0.0, + "error": None, + } + + # ── Safety Check 3: Max Duration ── + max_duration = agent.max_duration_seconds or 300 + + try: + async def _run_llm() -> None: + """Inner coroutine for LLM call with tool execution.""" + from app.ai.llm_client import LLMClient + + llm = LLMClient( + model=agent.model or None, + api_key=agent.api_key or None, + api_base=agent.api_base or None, + provider=agent.provider or None, + ) + + # Build system prompt from agent configuration + system_prompt = agent.system_prompt or "You are a helpful AI assistant." + user_prompt = f"Context: {context_data}" + + # Use litellm directly for more control + import litellm + + litellm_model = agent.model or "gpt-4o" + if agent.provider and agent.provider != "openai": + litellm_model = f"{agent.provider}/{litellm_model}" + + kwargs: dict[str, Any] = { + "model": litellm_model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + "max_tokens": agent.max_tokens or 1000, + } + + if agent.api_key: + kwargs["api_key"] = agent.api_key + if agent.api_base: + kwargs["api_base"] = agent.api_base + + response = await litellm.acompletion(**kwargs) + content = response.choices[0].message.content + + # Track cost + if hasattr(response, "usage") and response.usage: + usage = response.usage + # Estimate cost (simplified) + input_cost = (usage.prompt_tokens or 0) * 0.00001 / 1000 + output_cost = (usage.completion_tokens or 0) * 0.00003 / 1000 + result_data["cost_usd"] = round(input_cost + output_cost, 6) + + result_data["llm_response"] = content + + # Execute tool calls if LLM returned function calls + if hasattr(response.choices[0].message, "tool_calls") and response.choices[0].message.tool_calls: + from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry + + registry = get_tool_registry() + tool_call_count: dict[str, int] = {} + for tc in response.choices[0].message.tool_calls: + tool_name = tc.function.name + # ── Safety Check 4: Infinite Loop Detection ── + tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1 + if tool_call_count[tool_name] >= 5: + logger.warning( + "Infinite loop detected for agent %s: tool '%s' called %d times consecutively", + agent.id, tool_name, tool_call_count[tool_name], + ) + result_data["error"] = f"Infinite loop detected: tool '{tool_name}' called 5+ times consecutively" + result_data["status"] = "failed" + return + + tool = registry.get(tool_name) + if tool: + try: + import json + args = json.loads(tc.function.arguments) + tool_result = await tool.handler( + arguments=args, + context={"tenant_id": str(agent.tenant_id)}, + ) + result_data["tool_calls"].append({ + "tool": tool_name, + "arguments": args, + "result": tool_result, + }) + except Exception as e: + result_data["tool_calls"].append({ + "tool": tool_name, + "error": str(e), + }) + + result_data["status"] = "completed" + + # Run with timeout + try: + await asyncio.wait_for(_run_llm(), timeout=max_duration) + except asyncio.TimeoutError: + logger.warning( + "Agent %s execution timed out after %d seconds", + agent.id, max_duration, + ) + result_data["status"] = "timed_out" + result_data["error"] = f"Execution timed out after {max_duration} seconds" + + except Exception as e: + logger.exception("Agent run failed for %s", agent.id) + result_data["status"] = "failed" + result_data["error"] = str(e) + + # Save result to AgentRun + try: + async with factory() as db: + run = AgentRun( + tenant_id=agent.tenant_id, + agent_id=agent.id, + trigger_type=trigger_type, + status=result_data["status"], + input_data=context_data, + output_data=result_data.get("llm_response"), + tool_calls=result_data.get("tool_calls", []), + cost_usd=result_data.get("cost_usd", 0.0), + error_message=result_data.get("error"), + duration_seconds=None, + ) + db.add(run) + await db.flush() + result_data["run_id"] = str(run.id) + except Exception as e: + logger.exception("Failed to save AgentRun for %s", agent.id) + result_data["save_error"] = str(e) + + return result_data \ No newline at end of file diff --git a/app/plugins/builtins/automation/execution_engine.py b/app/plugins/builtins/automation/execution_engine.py new file mode 100644 index 0000000..1180e17 --- /dev/null +++ b/app/plugins/builtins/automation/execution_engine.py @@ -0,0 +1,279 @@ +"""Automation execution engine — evaluates conditions and executes actions.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from typing import Any + +import httpx +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models.notification import Notification + +logger = logging.getLogger(__name__) + + +def _evaluate_condition(condition: dict[str, Any], trigger_data: dict[str, Any]) -> bool: + """Evaluate a single condition against trigger_data. + + Condition format: {field, operator, value} + Operators: eq, ne, gt, lt, contains, exists + """ + field = condition.get("field", "") + operator = condition.get("operator", "eq") + expected = condition.get("value") + + actual = trigger_data.get(field) + + if operator == "exists": + return actual is not None + + if actual is None: + return False + + if operator == "eq": + return actual == expected + elif operator == "ne": + return actual != expected + elif operator == "gt": + try: + return float(actual) > float(expected) + except (TypeError, ValueError): + return str(actual) > str(expected) + elif operator == "lt": + try: + return float(actual) < float(expected) + except (TypeError, ValueError): + return str(actual) < str(expected) + elif operator == "contains": + return str(expected) in str(actual) + else: + logger.warning("Unknown operator: %s", operator) + return False + + +def _evaluate_conditions( + conditions: list[dict[str, Any]], + trigger_data: dict[str, Any], + logic: str = "and", +) -> bool: + """Evaluate a list of conditions with AND/OR logic.""" + if not conditions: + return True + + results = [_evaluate_condition(c, trigger_data) for c in conditions] + + if logic == "or": + return any(results) + else: + return all(results) + + +async def _execute_action( + action: dict[str, Any], + trigger_data: dict[str, Any], + tenant_id: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Execute a single action. Returns result dict.""" + action_type = action.get("type", "") + config = action.get("config", {}) + + result: dict[str, Any] = { + "action_type": action_type, + "status": "skipped" if dry_run else "pending", + "details": {}, + } + + if dry_run: + result["status"] = "dry_run" + result["details"] = {"would_execute": True, "config": config} + return result + + if action_type == "api_call": + url = config.get("url", "") + method = config.get("method", "GET").upper() + headers = config.get("headers", {}) + body = config.get("body") + + try: + async with httpx.AsyncClient(timeout=30) as client: + if method == "GET": + resp = await client.get(url, headers=headers) + elif method == "POST": + resp = await client.post(url, headers=headers, json=body) + elif method == "PUT": + resp = await client.put(url, headers=headers, json=body) + elif method == "PATCH": + resp = await client.patch(url, headers=headers, json=body) + elif method == "DELETE": + resp = await client.delete(url, headers=headers) + else: + result["status"] = "failed" + result["error"] = f"Unsupported HTTP method: {method}" + return result + + result["status"] = "completed" if resp.is_success else "failed" + result["details"] = { + "status_code": resp.status_code, + "response": resp.text[:1000], + } + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + + elif action_type == "notification": + user_id = config.get("user_id") + title = config.get("title", "Automation notification") + body = config.get("body", "") + notif_type = config.get("type", "automation") + + if not user_id: + result["status"] = "failed" + result["error"] = "user_id is required for notification action" + return result + + try: + from uuid import UUID + factory = get_session_factory() + async with factory() as db: + notification = Notification( + tenant_id=UUID(tenant_id), + user_id=UUID(user_id), + type=notif_type, + title=title, + body=body, + ) + db.add(notification) + await db.flush() + + result["status"] = "completed" + result["details"] = {"user_id": user_id, "title": title} + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + + elif action_type == "workflow_start": + workflow_id = config.get("workflow_id") + context = config.get("context", {}) + + if not workflow_id: + result["status"] = "failed" + result["error"] = "workflow_id is required for workflow_start action" + return result + + try: + from app.services.workflow_service import create_instance + from uuid import UUID + + factory = get_session_factory() + async with factory() as db: + instance = await create_instance( + db=db, + tenant_id=UUID(tenant_id), + user_id=UUID(config.get("user_id", "00000000-0000-0000-0000-000000000000")), + workflow_id=workflow_id, + context={**trigger_data, **context}, + ) + + result["status"] = "completed" + result["details"] = {"instance": instance} + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + + else: + result["status"] = "failed" + result["error"] = f"Unknown action type: {action_type}" + + return result + + +async def run_automation( + ctx: dict[str, Any], + automation_id: str, + trigger_type: str = "manual", + trigger_data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ARQ job function. Loads AutomationDefinition, evaluates conditions, + executes actions if conditions pass. Saves result to AutomationRun.""" + from app.plugins.builtins.automation.models import AutomationDefinition, AutomationRun + + trigger_data = trigger_data or {} + factory = get_session_factory() + + # Load automation definition + async with factory() as db: + result = await db.execute( + select(AutomationDefinition).where(AutomationDefinition.id == automation_id) + ) + automation = result.scalar_one_or_none() + + if automation is None: + logger.error("AutomationDefinition %s not found", automation_id) + return {"error": f"AutomationDefinition {automation_id} not found", "status": "failed"} + + if not automation.is_active: + logger.warning("AutomationDefinition %s is inactive", automation_id) + return {"error": "Automation is inactive", "status": "skipped"} + + # Evaluate conditions + conditions = automation.conditions or [] + logic = automation.condition_logic or "and" + conditions_pass = _evaluate_conditions(conditions, trigger_data, logic) + + result_data: dict[str, Any] = { + "automation_id": str(automation.id), + "automation_name": automation.name, + "trigger_type": trigger_type, + "status": "running", + "conditions_pass": conditions_pass, + "actions": [], + "error": None, + } + + if not conditions_pass: + result_data["status"] = "conditions_not_met" + logger.info("Automation %s: conditions not met, skipping", automation.id) + else: + # Execute actions + actions = automation.actions or [] + is_dry_run = trigger_data.get("dry_run", False) + + for action in actions: + action_result = await _execute_action( + action, trigger_data, str(automation.tenant_id), dry_run=is_dry_run + ) + result_data["actions"].append(action_result) + + # Determine overall status + if is_dry_run: + result_data["status"] = "dry_run" + elif any(a.get("status") == "failed" for a in result_data["actions"]): + result_data["status"] = "partial" + else: + result_data["status"] = "completed" + + # Save result to AutomationRun + try: + async with factory() as db: + run = AutomationRun( + tenant_id=automation.tenant_id, + automation_id=automation.id, + trigger_type=trigger_type, + status=result_data["status"], + input_data=trigger_data, + output_data=result_data, + error_message=result_data.get("error"), + ) + db.add(run) + await db.flush() + result_data["run_id"] = str(run.id) + except Exception as e: + logger.exception("Failed to save AutomationRun for %s", automation.id) + result_data["save_error"] = str(e) + + return result_data diff --git a/app/plugins/builtins/automation/migrations/0001_initial.sql b/app/plugins/builtins/automation/migrations/0001_initial.sql new file mode 100644 index 0000000..5670871 --- /dev/null +++ b/app/plugins/builtins/automation/migrations/0001_initial.sql @@ -0,0 +1,137 @@ +-- automation plugin: agent definitions, automation definitions, cron jobs, run logs + +-- Agent Definitions +CREATE TABLE IF NOT EXISTS automation_agent_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(120) NOT NULL, + description VARCHAR(500) NOT NULL DEFAULT '', + llm_model VARCHAR(100) NOT NULL DEFAULT 'ollama/deepseek-v4-flash', + system_prompt TEXT NOT NULL DEFAULT '', + tool_ids JSONB NOT NULL DEFAULT '[]', + heartbeat_interval_seconds INT NOT NULL DEFAULT 0, + mode VARCHAR(20) NOT NULL DEFAULT 'reactive', -- proactive, reactive + is_active BOOLEAN NOT NULL DEFAULT TRUE, + max_executions_per_hour INT NOT NULL DEFAULT 10, + max_duration_seconds INT NOT NULL DEFAULT 300, + budget_limit_usd FLOAT NOT NULL DEFAULT 1.0, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + UNIQUE(tenant_id, name) +); +CREATE INDEX IF NOT EXISTS ix_agent_def_tenant_active ON automation_agent_definitions (tenant_id, is_active); +CREATE INDEX IF NOT EXISTS ix_agent_def_tenant_mode ON automation_agent_definitions (tenant_id, mode); + +-- Agent Versions +CREATE TABLE IF NOT EXISTS automation_agent_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE, + version_number INT NOT NULL, + snapshot JSONB NOT NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + UNIQUE(agent_id, version_number) +); +CREATE INDEX IF NOT EXISTS ix_agent_versions_agent ON automation_agent_versions (tenant_id, agent_id); + +-- Automation Definitions +CREATE TABLE IF NOT EXISTS automation_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(120) NOT NULL, + description VARCHAR(500) NOT NULL DEFAULT '', + trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual', -- event, schedule, manual + trigger_config JSONB NOT NULL DEFAULT '{}', + conditions JSONB NOT NULL DEFAULT '[]', + actions JSONB NOT NULL DEFAULT '[]', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + dry_run BOOLEAN NOT NULL DEFAULT FALSE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + UNIQUE(tenant_id, name) +); +CREATE INDEX IF NOT EXISTS ix_automation_def_tenant_active ON automation_definitions (tenant_id, is_active); +CREATE INDEX IF NOT EXISTS ix_automation_def_tenant_trigger ON automation_definitions (tenant_id, trigger_type); + +-- Automation Versions +CREATE TABLE IF NOT EXISTS automation_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + automation_id UUID NOT NULL REFERENCES automation_definitions(id) ON DELETE CASCADE, + version_number INT NOT NULL, + snapshot JSONB NOT NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + UNIQUE(automation_id, version_number) +); +CREATE INDEX IF NOT EXISTS ix_automation_versions_def ON automation_versions (tenant_id, automation_id); + +-- Cron Jobs +CREATE TABLE IF NOT EXISTS automation_cron_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(120) NOT NULL, + cron_expression VARCHAR(100) NOT NULL, + job_type VARCHAR(50) NOT NULL DEFAULT 'automation_trigger', -- agent_heartbeat, automation_trigger, custom + target_id UUID, + plugin_name VARCHAR(80) NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS ix_cron_jobs_tenant_active ON automation_cron_jobs (tenant_id, is_active); +CREATE INDEX IF NOT EXISTS ix_cron_jobs_next_run ON automation_cron_jobs (next_run_at); + +-- Agent Runs (Execution Log) +CREATE TABLE IF NOT EXISTS automation_agent_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, timeout + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + duration_seconds FLOAT, + result TEXT, + error TEXT, + cost_usd FLOAT, + trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual', + trigger_data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS ix_agent_runs_agent_status ON automation_agent_runs (tenant_id, agent_id, status); +CREATE INDEX IF NOT EXISTS ix_agent_runs_started ON automation_agent_runs (tenant_id, started_at DESC); + +-- Automation Runs (Execution Log) +CREATE TABLE IF NOT EXISTS automation_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + automation_id UUID NOT NULL REFERENCES automation_definitions(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, timeout + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + duration_seconds FLOAT, + result TEXT, + error TEXT, + trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual', + trigger_data JSONB NOT NULL DEFAULT '{}', + dry_run BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS ix_automation_runs_def_status ON automation_runs (tenant_id, automation_id, status); +CREATE INDEX IF NOT EXISTS ix_automation_runs_started ON automation_runs (tenant_id, started_at DESC); diff --git a/app/plugins/builtins/automation/models.py b/app/plugins/builtins/automation/models.py new file mode 100644 index 0000000..d586cec --- /dev/null +++ b/app/plugins/builtins/automation/models.py @@ -0,0 +1,252 @@ +"""SQLAlchemy models for the Automation & Agents plugin.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class AgentDefinition(Base, TenantMixin): + """An AI agent definition — configures an LLM-powered agent with tools and behavior.""" + + __tablename__ = "automation_agent_definitions" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_agent_def_name"), + Index("ix_agent_def_tenant_active", "tenant_id", "is_active"), + Index("ix_agent_def_tenant_mode", "tenant_id", "mode"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str] = mapped_column(String(500), nullable=False, default="") + llm_model: Mapped[str] = mapped_column( + String(100), nullable=False, default="ollama/deepseek-v4-flash" + ) + system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="") + tool_ids: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) + heartbeat_interval_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) + mode: Mapped[str] = mapped_column( + String(20), nullable=False, default="reactive" + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + max_executions_per_hour: Mapped[int] = mapped_column( + Integer, nullable=False, default=10 + ) + max_duration_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=300 + ) + budget_limit_usd: Mapped[float] = mapped_column( + Float, nullable=False, default=1.0 + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + +class AgentVersion(Base, TenantMixin): + """Versioned snapshots of agent definitions.""" + + __tablename__ = "automation_agent_versions" + __table_args__ = ( + Index("ix_agent_versions_agent", "tenant_id", "agent_id"), + UniqueConstraint("agent_id", "version_number", name="uq_agent_version"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + agent_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + changed_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + +class AutomationDefinition(Base, TenantMixin): + """An automation workflow definition — event/schedule/manual triggered with conditions and actions.""" + + __tablename__ = "automation_definitions" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_automation_def_name"), + Index("ix_automation_def_tenant_active", "tenant_id", "is_active"), + Index("ix_automation_def_tenant_trigger", "tenant_id", "trigger_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str] = mapped_column(String(500), nullable=False, default="") + trigger_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="manual" + ) + trigger_config: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + conditions: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) + actions: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + +class AutomationVersion(Base, TenantMixin): + """Versioned snapshots of automation definitions.""" + + __tablename__ = "automation_versions" + __table_args__ = ( + Index("ix_automation_versions_def", "tenant_id", "automation_id"), + UniqueConstraint("automation_id", "version_number", name="uq_automation_version"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + automation_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("automation_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + changed_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + +class AutomationCronJob(Base, TenantMixin): + """Cron job schedule entries for agent heartbeats, automation triggers, or custom jobs.""" + + __tablename__ = "automation_cron_jobs" + __table_args__ = ( + Index("ix_cron_jobs_tenant_active", "tenant_id", "is_active"), + Index("ix_cron_jobs_next_run", "next_run_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + cron_expression: Mapped[str] = mapped_column(String(100), nullable=False) + job_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="automation_trigger" + ) + target_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) + plugin_name: Mapped[str] = mapped_column(String(80), nullable=False, default="") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + last_run_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + next_run_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + + +class AgentRun(Base, TenantMixin): + """Execution log for agent runs.""" + + __tablename__ = "automation_agent_runs" + __table_args__ = ( + Index("ix_agent_runs_agent_status", "tenant_id", "agent_id", "status"), + Index("ix_agent_runs_started", "tenant_id", "started_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + agent_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending" + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) + result: Mapped[str | None] = mapped_column(Text, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True) + trigger_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="manual" + ) + trigger_data: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + + +class AutomationRun(Base, TenantMixin): + """Execution log for automation runs.""" + + __tablename__ = "automation_runs" + __table_args__ = ( + Index("ix_automation_runs_def_status", "tenant_id", "automation_id", "status"), + Index("ix_automation_runs_started", "tenant_id", "started_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + automation_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("automation_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending" + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) + result: Mapped[str | None] = mapped_column(Text, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + trigger_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="manual" + ) + trigger_data: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py new file mode 100644 index 0000000..2a10390 --- /dev/null +++ b/app/plugins/builtins/automation/plugin.py @@ -0,0 +1,372 @@ +"""Automation & Agents plugin — Agent Builder, Automation Builder, Cron-Scheduler, Agent Runner. + +Houses the core automation engine: AI agent definitions, event/schedule/manual +workflow automations, cron job scheduling, and execution logging. + +Supports plugin contributions: when other plugins are activated, their +agent_definitions, automation_templates, cron_jobs, and heartbeat_configs +from the manifest are registered. On deactivation, they are removed. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.plugins.base import BasePlugin +from app.plugins.manifest import ( + FrontendMenuItem, + FrontendPageRoute, + FrontendSettingsPage, + PluginManifest, + PluginRouteDef, +) + +logger = logging.getLogger(__name__) + + +class AutomationPlugin(BasePlugin): + """Automation & Agents plugin — Agent Builder, Automation Builder, Cron-Scheduler, Agent Runner.""" + + manifest = PluginManifest( + name="automation", + version="1.0.0", + display_name="Automation & Agents", + description=( + "Agent Builder, Automation Builder, Cron-Scheduler, and Agent Runner. " + "Define AI agents with LLM models and tools, create event/schedule/manual " + "automations with conditions and actions, schedule cron jobs, and track execution logs." + ), + dependencies=[], + routes=[ + PluginRouteDef( + path="/api/v1/automation", + module="app.plugins.builtins.automation.routes", + router_attr="router", + ), + PluginRouteDef( + path="/api/v1/agents", + module="app.plugins.builtins.automation.agent_routes", + router_attr="router", + ), + ], + events=[ + "contact.created", + "contact.updated", + "mail.received", + "workflow.timeout", + ], + migrations=["0001_initial.sql"], + permissions=[ + "automation:read", + "automation:write", + "automation:delete", + "automation:execute", + "automation:configure", + "agents:read", + "agents:write", + "agents:delete", + "agents:execute", + ], + is_core=False, + menu_items=[ + FrontendMenuItem( + label_key="nav.automation", + label="Automation", + path="/automation", + icon="Zap", + order=50, + ), + FrontendMenuItem( + label_key="nav.agents", + label="Agents", + path="/agents", + icon="Bot", + order=51, + ), + ], + page_routes=[ + FrontendPageRoute( + path="/automation", + component="@/pages/AutomationDashboard", + order=50, + ), + FrontendPageRoute( + path="/agents", + component="@/pages/AgentDashboard", + order=51, + ), + ], + settings_pages=[ + FrontendSettingsPage( + path="automation", + label_key="settings.automation", + label="Automation", + component="@/pages/AutomationSettings", + icon="Settings", + order=60, + ), + ], + ) + + def __init__(self) -> None: + super().__init__() + # Track contributed definitions by source plugin name + self._contributed_agents: dict[str, list[str]] = {} # plugin_name -> [agent_name, ...] + self._contributed_automations: dict[str, list[str]] = {} # plugin_name -> [automation_name, ...] + self._contributed_cron_jobs: dict[str, list[str]] = {} # plugin_name -> [cron_job_name, ...] + self._contributed_heartbeats: dict[str, list[str]] = {} # plugin_name -> [agent_name, ...] + + async def on_activate(self, db, service_container, event_bus) -> None: + """Register event listeners on activation.""" + await super().on_activate(db, service_container, event_bus) + # Register agent communication tool + try: + from app.plugins.builtins.automation.agent_comm import register_agent_comm_tool + register_agent_comm_tool() + except Exception: + logger.exception("Failed to register agent communication tool") + # Register MiniApps from manifest + try: + from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry + registry = MiniAppRegistry() + for miniapp in self.manifest.miniapps: + registry.register( + app_id=miniapp.app_id, + name=miniapp.name, + icon=miniapp.icon, + description=miniapp.description, + plugin_name=self.manifest.name, + render_schema=miniapp.render_schema, + ) + logger.info("Registered MiniApp '%s' from manifest", miniapp.app_id) + except Exception: + logger.exception("Failed to register MiniApps from manifest") + logger.info("Automation plugin activated") + + async def on_deactivate(self, db, service_container, event_bus) -> None: + """Clean up on deactivation.""" + await super().on_deactivate(db, service_container, event_bus) + # Unregister agent communication tool + try: + from app.plugins.builtins.automation.agent_comm import unregister_agent_comm_tool + unregister_agent_comm_tool() + except Exception: + logger.exception("Failed to unregister agent communication tool") + # Unregister MiniApps + try: + from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry + registry = MiniAppRegistry() + registry.unregister_plugin(self.manifest.name) + logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name) + except Exception: + logger.exception("Failed to unregister MiniApps") + logger.info("Automation plugin deactivated") + + # ─── Plugin Contribution Registration ─── + + async def register_plugin_contributions(self, db, plugin_name: str, manifest) -> None: + """Register agent definitions, automation templates, cron jobs, and heartbeat configs + from another plugin's manifest. Uses plugin name prefixing for conflict resolution.""" + from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService + from app.plugins.builtins.automation.models import AutomationCronJob + from sqlalchemy import select + + # Register agent definitions + agent_names: list[str] = [] + for agent_def in manifest.agent_definitions: + prefixed_name = f"{plugin_name}.{agent_def.name}" + agent_names.append(prefixed_name) + # Check if already exists (idempotent) + existing = await AgentService.get_by_name(db, agent_def.tenant_id, prefixed_name) if hasattr(AgentService, 'get_by_name') else None + if existing is None: + try: + await AgentService.create(db, agent_def.tenant_id, { + "name": prefixed_name, + "description": agent_def.description, + "llm_model": agent_def.llm_model, + "system_prompt": agent_def.system_prompt, + "tool_ids": agent_def.tool_ids, + "heartbeat_interval_seconds": agent_def.heartbeat_interval_seconds, + "mode": agent_def.mode, + "max_executions_per_hour": agent_def.max_executions_per_hour, + "max_duration_seconds": agent_def.max_duration_seconds, + "budget_limit_usd": agent_def.budget_limit_usd, + "is_active": True, + }) + logger.info("Registered contributed agent '%s' from plugin '%s'", prefixed_name, plugin_name) + except Exception: + logger.exception("Failed to register contributed agent '%s' from plugin '%s'", prefixed_name, plugin_name) + self._contributed_agents[plugin_name] = agent_names + + # Register automation templates + automation_names: list[str] = [] + for auto_def in manifest.automation_templates: + prefixed_name = f"{plugin_name}.{auto_def.name}" + automation_names.append(prefixed_name) + existing = await AutomationService.get_by_name(db, auto_def.tenant_id, prefixed_name) if hasattr(AutomationService, 'get_by_name') else None + if existing is None: + try: + await AutomationService.create(db, auto_def.tenant_id, { + "name": prefixed_name, + "description": auto_def.description, + "trigger_type": auto_def.trigger_type, + "trigger_config": auto_def.trigger_config, + "conditions": auto_def.conditions, + "actions": auto_def.actions, + "is_active": True, + }) + logger.info("Registered contributed automation '%s' from plugin '%s'", prefixed_name, plugin_name) + except Exception: + logger.exception("Failed to register contributed automation '%s' from plugin '%s'", prefixed_name, plugin_name) + self._contributed_automations[plugin_name] = automation_names + + # Register cron jobs + cron_job_names: list[str] = [] + for cron_def in manifest.cron_jobs: + prefixed_name = f"{plugin_name}.{cron_def.name}" + cron_job_names.append(prefixed_name) + try: + # Check if cron job already exists + result = await db.execute( + select(AutomationCronJob).where(AutomationCronJob.name == prefixed_name).limit(1) + ) + existing = result.scalar_one_or_none() + if existing is None: + await CronJobService.create(db, cron_def.tenant_id, { + "name": prefixed_name, + "cron_expression": cron_def.cron_expression, + "job_type": cron_def.job_type, + "target_name": cron_def.target_name, + "plugin_name": plugin_name, + "is_active": True, + }) + logger.info("Registered contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name) + except Exception: + logger.exception("Failed to register contributed cron job '%s' from plugin '%s'", prefixed_name, plugin_name) + self._contributed_cron_jobs[plugin_name] = cron_job_names + + # Register heartbeat configs + heartbeat_names: list[str] = [] + for hb_def in manifest.heartbeat_configs: + prefixed_name = f"{plugin_name}.{hb_def.agent_name}" + heartbeat_names.append(prefixed_name) + try: + # Create or update agent with heartbeat settings + agent = await AgentService.get_by_name(db, hb_def.tenant_id, prefixed_name) if hasattr(AgentService, 'get_by_name') else None + if agent is None: + await AgentService.create(db, hb_def.tenant_id, { + "name": prefixed_name, + "description": f"Heartbeat agent contributed by {plugin_name}", + "heartbeat_interval_seconds": hb_def.interval_seconds, + "mode": "proactive", + "is_active": True, + }) + logger.info("Registered heartbeat agent '%s' from plugin '%s'", prefixed_name, plugin_name) + except Exception: + logger.exception("Failed to register heartbeat agent '%s' from plugin '%s'", prefixed_name, plugin_name) + self._contributed_heartbeats[plugin_name] = heartbeat_names + + async def unregister_plugin_contributions(self, db, plugin_name: str) -> None: + """Remove all contributed definitions from a plugin that is being deactivated.""" + from app.plugins.builtins.automation.services import AgentService, AutomationService, CronJobService + + # Remove contributed agents + agent_names = self._contributed_agents.pop(plugin_name, []) + for agent_name in agent_names: + try: + agent = await AgentService.get_by_name(db, agent_name) + if agent: + await AgentService.delete(db, agent.tenant_id, agent.id) + logger.info("Unregistered contributed agent '%s' from plugin '%s'", agent_name, plugin_name) + except Exception: + logger.exception("Failed to unregister contributed agent '%s'", agent_name) + + # Remove contributed automations + automation_names = self._contributed_automations.pop(plugin_name, []) + for auto_name in automation_names: + try: + auto = await AutomationService.get_by_name(db, auto_name) + if auto: + await AutomationService.delete(db, auto.tenant_id, auto.id) + logger.info("Unregistered contributed automation '%s' from plugin '%s'", auto_name, plugin_name) + except Exception: + logger.exception("Failed to unregister contributed automation '%s'", auto_name) + + # Remove contributed cron jobs + cron_job_names = self._contributed_cron_jobs.pop(plugin_name, []) + for cron_name in cron_job_names: + try: + from app.plugins.builtins.automation.models import AutomationCronJob + from sqlalchemy import select + result = await db.execute( + select(AutomationCronJob).where(AutomationCronJob.name == cron_name).limit(1) + ) + job = result.scalar_one_or_none() + if job: + await CronJobService.delete(db, job.tenant_id, job.id) + logger.info("Unregistered contributed cron job '%s' from plugin '%s'", cron_name, plugin_name) + except Exception: + logger.exception("Failed to unregister contributed cron job '%s'", cron_name) + + # Remove contributed heartbeats + heartbeat_names = self._contributed_heartbeats.pop(plugin_name, []) + for hb_name in heartbeat_names: + try: + agent = await AgentService.get_by_name(db, hb_name) + if agent: + await AgentService.delete(db, agent.tenant_id, agent.id) + logger.info("Unregistered heartbeat agent '%s' from plugin '%s'", hb_name, plugin_name) + except Exception: + logger.exception("Failed to unregister heartbeat agent '%s'", hb_name) + + # ─── Heartbeat Migration ─── + + async def ensure_ai_proactive_heartbeat(self, db) -> None: + """Migrate the hardcoded ai_proactive heartbeat to a configurable cron job.""" + from app.plugins.builtins.automation.models import AutomationCronJob + from app.plugins.builtins.automation.services import CronJobService + from sqlalchemy import select + + # Check if ai_proactive heartbeat cron job already exists + result = await db.execute( + select(AutomationCronJob).where( + AutomationCronJob.name == "ai_proactive.heartbeat" + ).limit(1) + ) + existing = result.scalar_one_or_none() + if existing is not None: + logger.info("ai_proactive heartbeat cron job already exists, skipping migration") + return + + # Create the heartbeat cron job + try: + await CronJobService.create(db, None, { + "name": "ai_proactive.heartbeat", + "cron_expression": "*/5 * * * *", # Every 5 minutes + "job_type": "agent_heartbeat", + "target_name": "ai_proactive.heartbeat_agent", + "plugin_name": "ai_proactive", + "is_active": True, + }) + logger.info("Created ai_proactive heartbeat cron job (every 5 minutes)") + except Exception: + logger.exception("Failed to create ai_proactive heartbeat cron job") + + # ─── Event Handlers ─── + + async def on_contact_created(self, payload: dict[str, Any]) -> None: + """Handle contact.created event — trigger matching automations.""" + logger.debug("contact.created event received: %s", payload) + + async def on_contact_updated(self, payload: dict[str, Any]) -> None: + """Handle contact.updated event — trigger matching automations.""" + logger.debug("contact.updated event received: %s", payload) + + async def on_mail_received(self, payload: dict[str, Any]) -> None: + """Handle mail.received event — trigger matching automations.""" + logger.debug("mail.received event received: %s", payload) + + async def on_workflow_timeout(self, payload: dict[str, Any]) -> None: + """Handle workflow.timeout event — trigger matching automations.""" + logger.debug("workflow.timeout event received: %s", payload) \ No newline at end of file diff --git a/app/plugins/builtins/automation/routes.py b/app/plugins/builtins/automation/routes.py new file mode 100644 index 0000000..b9873bf --- /dev/null +++ b/app/plugins/builtins/automation/routes.py @@ -0,0 +1,545 @@ +"""API routes for the Automation plugin — /api/v1/automation. + +Endpoints: automation definitions CRUD, execute, dry-run, runs, versions. +""" + +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 ( + AutomationDefinition, + AutomationRun, + AutomationVersion, +) +from app.plugins.builtins.automation.schemas import ( + AutomationDefinitionCreate, + AutomationDefinitionListResponse, + AutomationDefinitionResponse, + AutomationDefinitionUpdate, + AutomationRunListResponse, + AutomationRunResponse, + AutomationSettingsResponse, + AutomationSettingsUpdate, + AutomationVersionListResponse, + AutomationVersionResponse, + MiniAppCreate, + MiniAppResponse, +) +from app.plugins.builtins.automation.services import ( + AutomationService, + RunLogService, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/automation", tags=["automation"]) + + +# ─── Helper Functions ─── + + +def _automation_to_response(a: AutomationDefinition) -> AutomationDefinitionResponse: + """Convert AutomationDefinition model to response schema.""" + return AutomationDefinitionResponse( + id=str(a.id), + name=a.name, + description=a.description or "", + trigger_type=a.trigger_type, + trigger_config=a.trigger_config or {}, + conditions=a.conditions or [], + actions=a.actions or [], + is_active=a.is_active, + dry_run=a.dry_run, + 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 _run_to_response(r: AutomationRun) -> AutomationRunResponse: + """Convert AutomationRun model to response schema.""" + return AutomationRunResponse( + id=str(r.id), + automation_id=str(r.automation_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, + trigger_type=r.trigger_type, + trigger_data=r.trigger_data or {}, + dry_run=r.dry_run, + created_at=r.created_at.isoformat() if r.created_at else None, + ) + + +def _version_to_response(v: AutomationVersion) -> AutomationVersionResponse: + """Convert AutomationVersion model to response schema.""" + return AutomationVersionResponse( + id=str(v.id), + automation_id=str(v.automation_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("automation:read"))], + response_model=AutomationDefinitionListResponse, +) +async def list_automations( + trigger_type: str | None = Query(None), + is_active: bool | 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 automation definitions with optional filters.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + items, total = await AutomationService.list( + db, tenant_id, trigger_type=trigger_type, is_active=is_active, + limit=limit, offset=offset, + ) + return AutomationDefinitionListResponse( + items=[_automation_to_response(a) for a in items], + total=total, + ) + + +@router.post( + "/", + dependencies=[Depends(require_permission("automation:write"))], + response_model=AutomationDefinitionResponse, + status_code=201, +) +async def create_automation( + data: AutomationDefinitionCreate, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Create a new automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + automation = await AutomationService.create( + db, tenant_id, data.model_dump(), user_id=user_id + ) + return _automation_to_response(automation) + + +@router.get( + "/{automation_id}", + dependencies=[Depends(require_permission("automation:read"))], + response_model=AutomationDefinitionResponse, +) +async def get_automation( + automation_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get a single automation definition by ID.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + automation = await AutomationService.get_by_id(db, tenant_id, aid) + if automation is None: + raise HTTPException(status_code=404, detail="Automation not found") + return _automation_to_response(automation) + + +@router.patch( + "/{automation_id}", + dependencies=[Depends(require_permission("automation:write"))], + response_model=AutomationDefinitionResponse, +) +async def update_automation( + automation_id: str, + data: AutomationDefinitionUpdate, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update an existing automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + automation = await AutomationService.update( + db, tenant_id, aid, data.model_dump(exclude_none=True), user_id=user_id + ) + if automation is None: + raise HTTPException(status_code=404, detail="Automation not found") + return _automation_to_response(automation) + + +@router.delete( + "/{automation_id}", + dependencies=[Depends(require_permission("automation:delete"))], +) +async def delete_automation( + automation_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Delete an automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + success = await AutomationService.delete(db, tenant_id, aid) + if not success: + raise HTTPException(status_code=404, detail="Automation not found") + return {"status": "ok"} + + +# ─── Execute / Dry-Run ─── + + +@router.post( + "/{automation_id}/execute", + dependencies=[Depends(require_permission("automation:execute"))], +) +async def execute_automation( + automation_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Execute an automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + automation = await AutomationService.get_by_id(db, tenant_id, aid) + if automation is None: + raise HTTPException(status_code=404, detail="Automation not found") + if not automation.is_active: + raise HTTPException(status_code=400, detail="Automation is not active") + + # Create run log entry + run = AutomationRun( + tenant_id=tenant_id, + automation_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"]))}, + dry_run=False, + ) + db.add(run) + await db.flush() + + # TODO: Execute automation actions asynchronously + 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( + "/{automation_id}/dry-run", + dependencies=[Depends(require_permission("automation:execute"))], +) +async def dry_run_automation( + automation_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Dry-run an automation definition (validate without executing).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + automation = await AutomationService.get_by_id(db, tenant_id, aid) + if automation is None: + raise HTTPException(status_code=404, detail="Automation not found") + + # Execute dry-run via execution engine + from app.plugins.builtins.automation.execution_engine import run_automation as execute_automation_engine + + result = await execute_automation_engine( + ctx={}, + automation_id=str(aid), + trigger_type="manual", + trigger_data={ + "triggered_by": str(uuid.UUID(current_user["user_id"])), + "dry_run": True, + }, + ) + + # Create dry-run log entry + run = AutomationRun( + tenant_id=tenant_id, + automation_id=aid, + status=result.get("status", "dry_run"), + started_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + completed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + duration_seconds=0.0, + trigger_type="manual", + trigger_data={"triggered_by": str(uuid.UUID(current_user["user_id"])), "dry_run": True}, + dry_run=True, + result=str(result), + ) + db.add(run) + await db.flush() + + return { + "status": "ok", + "run_id": str(run.id), + "automation": _automation_to_response(automation), + "validation": { + "valid": True, + "conditions_count": len(automation.conditions or []), + "actions_count": len(automation.actions or []), + }, + "dry_run_result": result, + } + + +# ─── Runs ─── + + +@router.get( + "/{automation_id}/runs", + dependencies=[Depends(require_permission("automation:read"))], + response_model=AutomationRunListResponse, +) +async def list_automation_runs( + automation_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 automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + items, total = await RunLogService.list_automation_runs( + db, tenant_id, automation_id=aid, status=status, limit=limit, offset=offset + ) + return AutomationRunListResponse( + items=[_run_to_response(r) for r in items], + total=total, + ) + + +# ─── Versions ─── + + +@router.get( + "/{automation_id}/versions", + dependencies=[Depends(require_permission("automation:read"))], + response_model=AutomationVersionListResponse, +) +async def list_automation_versions( + automation_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 automation definition.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + aid = uuid.UUID(automation_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid automation ID") + + items, total = await AutomationService.get_versions( + db, tenant_id, aid, limit=limit, offset=offset + ) + return AutomationVersionListResponse( + items=[_version_to_response(v) for v in items], + total=total, + ) + + +@router.post( + "/{automation_id}/versions/{version_id}/restore", + dependencies=[Depends(require_permission("automation:write"))], + response_model=AutomationDefinitionResponse, +) +async def restore_automation_version( + automation_id: str, + version_id: str, + current_user: dict[str, Any] = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Restore an automation 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(automation_id) + vid = uuid.UUID(version_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid ID") + + automation = await AutomationService.restore_version( + db, tenant_id, aid, vid, user_id=user_id + ) + if automation is None: + raise HTTPException(status_code=404, detail="Automation or version not found") + return _automation_to_response(automation) + + +# ─── Recent Runs ─── + + +@router.get( + "/runs/recent", + dependencies=[Depends(require_permission("automation:read"))], + response_model=AutomationRunListResponse, +) +async def list_recent_automation_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 automation runs across all automations (for dashboard).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + items, total = await RunLogService.list_automation_runs( + db, tenant_id, automation_id=None, status=status, limit=limit, offset=0 + ) + return AutomationRunListResponse( + items=[_run_to_response(r) for r in items], + total=total, + ) + + +# ─── MiniApps ─── + + +@router.get( + "/miniapps", + dependencies=[Depends(require_permission("automation:read"))], + response_model=list[MiniAppResponse], +) +async def list_miniapps( + current_user: dict[str, Any] = Depends(get_current_user), +): + """List custom MiniApps from plugin config.""" + from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry + registry = MiniAppRegistry() + return registry.list_apps() + + +@router.post( + "/miniapps", + dependencies=[Depends(require_permission("automation:write"))], + response_model=MiniAppResponse, + status_code=201, +) +async def create_miniapp( + data: MiniAppCreate, + current_user: dict[str, Any] = Depends(get_current_user), +): + """Create a custom MiniApp definition.""" + from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry + registry = MiniAppRegistry() + registry.register( + app_id=data.app_id, + name=data.name, + icon=data.icon, + description=data.description, + plugin_name="automation", + render_schema=data.render_schema, + ) + return MiniAppResponse( + app_id=data.app_id, + name=data.name, + icon=data.icon, + description=data.description, + plugin_name="automation", + render_schema=data.render_schema, + ) + + +@router.delete( + "/miniapps/{app_id}", + dependencies=[Depends(require_permission("automation:delete"))], +) +async def delete_miniapp( + app_id: str, + current_user: dict[str, Any] = Depends(get_current_user), +): + """Delete a custom MiniApp definition.""" + from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry + registry = MiniAppRegistry() + registry.unregister(app_id) + return {"status": "ok"} + + +# ─── Settings ─── + + +@router.get( + "/settings", + dependencies=[Depends(require_permission("automation:configure"))], + response_model=AutomationSettingsResponse, +) +async def get_automation_settings( + current_user: dict[str, Any] = Depends(get_current_user), +): + """Get automation default settings.""" + # Return default settings (could be stored in plugin config in the future) + return AutomationSettingsResponse( + default_llm_model="ollama/deepseek-v4-flash", + heartbeat_default_interval=300, + max_concurrent_agents=5, + log_level="INFO", + ) + + +@router.patch( + "/settings", + dependencies=[Depends(require_permission("automation:configure"))], + response_model=AutomationSettingsResponse, +) +async def update_automation_settings( + data: AutomationSettingsUpdate, + current_user: dict[str, Any] = Depends(get_current_user), +): + """Update automation settings (stored in plugin config).""" + # TODO: Persist settings to plugin config table + # For now, return the updated values as a preview + settings = AutomationSettingsResponse( + default_llm_model=data.default_llm_model or "ollama/deepseek-v4-flash", + heartbeat_default_interval=data.heartbeat_default_interval or 300, + max_concurrent_agents=data.max_concurrent_agents or 5, + log_level=data.log_level or "INFO", + ) + return settings diff --git a/app/plugins/builtins/automation/scheduler.py b/app/plugins/builtins/automation/scheduler.py new file mode 100644 index 0000000..a786ddb --- /dev/null +++ b/app/plugins/builtins/automation/scheduler.py @@ -0,0 +1,73 @@ +"""Cron scheduler — reads CronJob rows and enqueues ARQ jobs.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +import croniter +from sqlalchemy import select + +from app.core.db import get_session_factory + +logger = logging.getLogger(__name__) + + +def calculate_next_run(cron_expression: str, from_time: datetime | None = None) -> datetime: + """Calculate the next run time from a cron expression using croniter.""" + base = from_time or datetime.now(UTC) + cron = croniter.croniter(cron_expression, base) + next_dt = cron.get_next(datetime) + return next_dt + + +async def scheduler_tick(ctx: dict[str, Any]) -> None: + """Run every 60s via ARQ cron. Reads active CronJob rows where next_run_at <= now, + enqueues appropriate ARQ job, updates last_run_at and calculates next_run_at.""" + from app.plugins.builtins.automation.models import AutomationCronJob as CronJob + + now = datetime.now(UTC) + factory = get_session_factory() + + async with factory() as db: + result = await db.execute( + select(CronJob).where( + CronJob.is_active.is_(True), + CronJob.next_run_at <= now, + ) + ) + jobs = list(result.scalars().all()) + + if not jobs: + logger.debug("scheduler_tick: no cron jobs due") + return + + logger.info("scheduler_tick: %d cron job(s) due", len(jobs)) + + for job in jobs: + try: + if job.job_type == "agent": + from app.core.jobs import enqueue_job + await enqueue_job("run_agent", job.target_id, trigger_type="scheduled") + elif job.job_type == "automation": + from app.core.jobs import enqueue_job + await enqueue_job("run_automation", job.target_id, trigger_type="scheduled") + else: + logger.warning("Unknown cron job_type: %s", job.job_type) + continue + + # Update last_run_at and calculate next_run_at + async with factory() as db: + result = await db.execute(select(CronJob).where(CronJob.id == job.id)) + db_job = result.scalar_one_or_none() + if db_job is None: + continue + db_job.last_run_at = now + db_job.next_run_at = calculate_next_run(db_job.cron_expression, now) + await db.flush() + + logger.info("Enqueued %s %s (id=%s)", job.job_type, job.target_id, job.id) + + except Exception: + logger.exception("Failed to process cron job %s", job.id) diff --git a/app/plugins/builtins/automation/schemas.py b/app/plugins/builtins/automation/schemas.py new file mode 100644 index 0000000..7347e82 --- /dev/null +++ b/app/plugins/builtins/automation/schemas.py @@ -0,0 +1,291 @@ +"""Pydantic v2 schemas for the Automation & Agents plugin API.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +# ─── Agent Definition Schemas ─── + + +class AgentDefinitionCreate(BaseModel): + """Create a new agent definition.""" + + name: str = Field(..., min_length=1, max_length=120) + description: str = Field(default="", max_length=500) + llm_model: str = Field(default="ollama/deepseek-v4-flash", max_length=100) + system_prompt: str = Field(default="") + tool_ids: list[str] = Field(default_factory=list) + heartbeat_interval_seconds: int = Field(default=0, ge=0) + mode: str = Field(default="reactive", pattern="^(proactive|reactive)$") + is_active: bool = Field(default=True) + max_executions_per_hour: int = Field(default=10, ge=1, le=1000) + max_duration_seconds: int = Field(default=300, ge=1, le=86400) + budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0) + + +class AgentDefinitionUpdate(BaseModel): + """Update an existing agent definition (partial).""" + + name: str | None = Field(None, max_length=120) + description: str | None = Field(None, max_length=500) + llm_model: str | None = Field(None, max_length=100) + system_prompt: str | None = None + tool_ids: list[str] | None = None + heartbeat_interval_seconds: int | None = Field(None, ge=0) + mode: str | None = Field(None, pattern="^(proactive|reactive)$") + is_active: bool | None = None + max_executions_per_hour: int | None = Field(None, ge=1, le=1000) + max_duration_seconds: int | None = Field(None, ge=1, le=86400) + budget_limit_usd: float | None = Field(None, ge=0.0, le=10000.0) + + +class AgentDefinitionResponse(BaseModel): + """Agent definition response.""" + + id: str + name: str + description: str = "" + llm_model: str + system_prompt: str = "" + tool_ids: list[str] = [] + heartbeat_interval_seconds: int = 0 + mode: str + is_active: bool = True + max_executions_per_hour: int = 10 + max_duration_seconds: int = 300 + budget_limit_usd: float = 1.0 + created_by: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +# ─── Automation Definition Schemas ─── + + +class AutomationDefinitionCreate(BaseModel): + """Create a new automation definition.""" + + name: str = Field(..., min_length=1, max_length=120) + description: str = Field(default="", max_length=500) + trigger_type: str = Field(default="manual", pattern="^(event|schedule|manual)$") + trigger_config: dict[str, Any] = Field(default_factory=dict) + conditions: list[dict[str, Any]] = Field(default_factory=list) + actions: list[dict[str, Any]] = Field(default_factory=list) + is_active: bool = Field(default=True) + dry_run: bool = Field(default=False) + + +class AutomationDefinitionUpdate(BaseModel): + """Update an existing automation definition (partial).""" + + name: str | None = Field(None, max_length=120) + description: str | None = Field(None, max_length=500) + trigger_type: str | None = Field(None, pattern="^(event|schedule|manual)$") + trigger_config: dict[str, Any] | None = None + conditions: list[dict[str, Any]] | None = None + actions: list[dict[str, Any]] | None = None + is_active: bool | None = None + dry_run: bool | None = None + + +class AutomationDefinitionResponse(BaseModel): + """Automation definition response.""" + + id: str + name: str + description: str = "" + trigger_type: str + trigger_config: dict[str, Any] = {} + conditions: list[dict[str, Any]] = [] + actions: list[dict[str, Any]] = [] + is_active: bool = True + dry_run: bool = False + created_by: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +# ─── Cron Job Schemas ─── + + +class CronJobResponse(BaseModel): + """Cron job response.""" + + id: str + name: str + cron_expression: str + job_type: str + target_id: str | None = None + plugin_name: str = "" + is_active: bool = True + last_run_at: str | None = None + next_run_at: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +# ─── Run Log Schemas ─── + + +class AgentRunResponse(BaseModel): + """Agent run log response.""" + + id: str + agent_id: str + status: str + started_at: str + completed_at: str | None = None + duration_seconds: float | None = None + result: str | None = None + error: str | None = None + cost_usd: float | None = None + trigger_type: str = "manual" + trigger_data: dict[str, Any] = {} + created_at: str | None = None + + +class AutomationRunResponse(BaseModel): + """Automation run log response.""" + + id: str + automation_id: str + status: str + started_at: str + completed_at: str | None = None + duration_seconds: float | None = None + result: str | None = None + error: str | None = None + trigger_type: str = "manual" + trigger_data: dict[str, Any] = {} + dry_run: bool = False + created_at: str | None = None + + +# ─── Version Schemas ─── + + +class AgentVersionResponse(BaseModel): + """Agent version response.""" + + id: str + agent_id: str + version_number: int + snapshot: dict[str, Any] + changed_by: str | None = None + created_at: str | None = None + + +class AutomationVersionResponse(BaseModel): + """Automation version response.""" + + id: str + automation_id: str + version_number: int + snapshot: dict[str, Any] + changed_by: str | None = None + created_at: str | None = None + + +# ─── Settings Schemas ─── + + +class AgentMessageRequest(BaseModel): + """Request to send a message from one agent to another.""" + + to_agent_name: str = Field(..., min_length=1, max_length=120) + message: str = Field(..., min_length=1) + + +class MiniAppCreate(BaseModel): + """Create a custom MiniApp definition.""" + + app_id: str = Field(..., min_length=1, max_length=80) + name: str = Field(..., min_length=1, max_length=120) + icon: str = Field(default="AppWindow") + description: str = Field(default="") + render_schema: dict[str, Any] = Field(default_factory=dict) + + +class MiniAppResponse(BaseModel): + """MiniApp definition response.""" + + app_id: str + name: str + icon: str = "AppWindow" + description: str = "" + plugin_name: str = "" + render_schema: dict[str, Any] = {} + + +class AutomationSettingsResponse(BaseModel): + """Automation settings response.""" + + default_llm_model: str = "ollama/deepseek-v4-flash" + heartbeat_default_interval: int = 300 + max_concurrent_agents: int = 5 + log_level: str = "INFO" + + +class AutomationSettingsUpdate(BaseModel): + """Automation settings update (partial).""" + + default_llm_model: str | None = None + heartbeat_default_interval: int | None = None + max_concurrent_agents: int | None = None + log_level: str | None = None + + +# ─── List Response Schemas ─── + + +class AgentDefinitionListResponse(BaseModel): + """Paginated agent definition list.""" + + items: list[AgentDefinitionResponse] + total: int + + +class AutomationDefinitionListResponse(BaseModel): + """Paginated automation definition list.""" + + items: list[AutomationDefinitionResponse] + total: int + + +class AgentRunListResponse(BaseModel): + """Paginated agent run list.""" + + items: list[AgentRunResponse] + total: int + + +class AutomationRunListResponse(BaseModel): + """Paginated automation run list.""" + + items: list[AutomationRunResponse] + total: int + + +class CronJobListResponse(BaseModel): + """Paginated cron job list.""" + + items: list[CronJobResponse] + total: int + + +class AgentVersionListResponse(BaseModel): + """Paginated agent version list.""" + + items: list[AgentVersionResponse] + total: int + + +class AutomationVersionListResponse(BaseModel): + """Paginated automation version list.""" + + items: list[AutomationVersionResponse] + total: int diff --git a/app/plugins/builtins/automation/services.py b/app/plugins/builtins/automation/services.py new file mode 100644 index 0000000..e9c1b0a --- /dev/null +++ b/app/plugins/builtins/automation/services.py @@ -0,0 +1,737 @@ +"""Service classes for the Automation & Agents plugin. + +AgentService, AutomationService, CronJobService, RunLogService. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.automation.models import ( + AgentDefinition, + AgentRun, + AgentVersion, + AutomationCronJob, + AutomationDefinition, + AutomationRun, + AutomationVersion, +) + +logger = logging.getLogger(__name__) + + +# ─── Agent Service ─── + + +class AgentService: + """CRUD and version management for AI agent definitions.""" + + @staticmethod + async def list( + db: AsyncSession, + tenant_id: uuid.UUID, + is_active: bool | None = None, + mode: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AgentDefinition], int]: + """List agent definitions with optional filters.""" + query = select(AgentDefinition).where(AgentDefinition.tenant_id == tenant_id) + count_query = select(func.count()).select_from(AgentDefinition).where( + AgentDefinition.tenant_id == tenant_id + ) + + if is_active is not None: + query = query.where(AgentDefinition.is_active == is_active) + count_query = count_query.where(AgentDefinition.is_active == is_active) + if mode is not None: + query = query.where(AgentDefinition.mode == mode) + count_query = count_query.where(AgentDefinition.mode == mode) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AgentDefinition.created_at.desc()) + .limit(limit) + .offset(offset) + ) + items = list(result.scalars().all()) + return items, total + + @staticmethod + async def get_by_id( + db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID + ) -> AgentDefinition | None: + """Get a single agent definition by ID.""" + result = await db.execute( + select(AgentDefinition) + .where(AgentDefinition.id == agent_id) + .where(AgentDefinition.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_by_name( + db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None + ) -> AgentDefinition | None: + """Get a single agent definition by name (optionally scoped to tenant).""" + query = select(AgentDefinition).where(AgentDefinition.name == name) + if tenant_id is not None: + query = query.where(AgentDefinition.tenant_id == tenant_id) + result = await db.execute(query.limit(1)) + return result.scalar_one_or_none() + + @staticmethod + async def create( + db: AsyncSession, tenant_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None + ) -> AgentDefinition: + """Create a new agent definition and its initial version snapshot.""" + agent = AgentDefinition( + tenant_id=tenant_id, + name=data["name"], + description=data.get("description", ""), + llm_model=data.get("llm_model", "ollama/deepseek-v4-flash"), + system_prompt=data.get("system_prompt", ""), + tool_ids=data.get("tool_ids", []), + heartbeat_interval_seconds=data.get("heartbeat_interval_seconds", 0), + mode=data.get("mode", "reactive"), + is_active=data.get("is_active", True), + max_executions_per_hour=data.get("max_executions_per_hour", 10), + max_duration_seconds=data.get("max_duration_seconds", 300), + budget_limit_usd=data.get("budget_limit_usd", 1.0), + created_by=user_id, + ) + db.add(agent) + await db.flush() + + # Create initial version snapshot + version = AgentVersion( + tenant_id=tenant_id, + agent_id=agent.id, + version_number=1, + snapshot={ + "name": agent.name, + "description": agent.description, + "llm_model": agent.llm_model, + "system_prompt": agent.system_prompt, + "tool_ids": agent.tool_ids, + "heartbeat_interval_seconds": agent.heartbeat_interval_seconds, + "mode": agent.mode, + "is_active": agent.is_active, + "max_executions_per_hour": agent.max_executions_per_hour, + "max_duration_seconds": agent.max_duration_seconds, + "budget_limit_usd": agent.budget_limit_usd, + }, + changed_by=user_id, + ) + db.add(version) + await db.flush() + + return agent + + @staticmethod + async def update( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + data: dict[str, Any], + user_id: uuid.UUID | None = None, + ) -> AgentDefinition | None: + """Update an agent definition and create a new version snapshot if changed.""" + agent = await AgentService.get_by_id(db, tenant_id, agent_id) + if agent is None: + return None + + changed = False + for key, value in data.items(): + if hasattr(agent, key) and value is not None: + setattr(agent, key, value) + changed = True + + if changed: + # Get latest version number + result = await db.execute( + select(func.coalesce(func.max(AgentVersion.version_number), 0)) + .where(AgentVersion.agent_id == agent_id) + .where(AgentVersion.tenant_id == tenant_id) + ) + max_version = result.scalar() or 0 + + version = AgentVersion( + tenant_id=tenant_id, + agent_id=agent.id, + version_number=max_version + 1, + snapshot={ + "name": agent.name, + "description": agent.description, + "llm_model": agent.llm_model, + "system_prompt": agent.system_prompt, + "tool_ids": agent.tool_ids, + "heartbeat_interval_seconds": agent.heartbeat_interval_seconds, + "mode": agent.mode, + "is_active": agent.is_active, + "max_executions_per_hour": agent.max_executions_per_hour, + "max_duration_seconds": agent.max_duration_seconds, + "budget_limit_usd": agent.budget_limit_usd, + }, + changed_by=user_id, + ) + db.add(version) + await db.flush() + + return agent + + @staticmethod + async def delete( + db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID + ) -> bool: + """Delete an agent definition (cascades to versions and runs).""" + agent = await AgentService.get_by_id(db, tenant_id, agent_id) + if agent is None: + return False + await db.delete(agent) + await db.flush() + return True + + @staticmethod + async def get_versions( + db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID, limit: int = 50, offset: int = 0 + ) -> tuple[list[AgentVersion], int]: + """Get version history for an agent definition.""" + query = ( + select(AgentVersion) + .where(AgentVersion.agent_id == agent_id) + .where(AgentVersion.tenant_id == tenant_id) + ) + count_query = ( + select(func.count()) + .select_from(AgentVersion) + .where(AgentVersion.agent_id == agent_id) + .where(AgentVersion.tenant_id == tenant_id) + ) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AgentVersion.version_number.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @staticmethod + async def restore_version( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + version_id: uuid.UUID, + user_id: uuid.UUID | None = None, + ) -> AgentDefinition | None: + """Restore an agent definition from a specific version snapshot.""" + agent = await AgentService.get_by_id(db, tenant_id, agent_id) + if agent is None: + return None + + result = await db.execute( + select(AgentVersion) + .where(AgentVersion.id == version_id) + .where(AgentVersion.agent_id == agent_id) + .where(AgentVersion.tenant_id == tenant_id) + .limit(1) + ) + version = result.scalar_one_or_none() + if version is None: + return None + + snapshot = version.snapshot + for key, value in snapshot.items(): + if hasattr(agent, key): + setattr(agent, key, value) + + # Create new version reflecting the restore + max_ver_result = await db.execute( + select(func.coalesce(func.max(AgentVersion.version_number), 0)) + .where(AgentVersion.agent_id == agent_id) + .where(AgentVersion.tenant_id == tenant_id) + ) + max_version = max_ver_result.scalar() or 0 + + new_version = AgentVersion( + tenant_id=tenant_id, + agent_id=agent.id, + version_number=max_version + 1, + snapshot=snapshot, + changed_by=user_id, + ) + db.add(new_version) + await db.flush() + + return agent + + +# ─── Automation Service ─── + + +class AutomationService: + """CRUD and version management for automation definitions.""" + + @staticmethod + async def list( + db: AsyncSession, + tenant_id: uuid.UUID, + trigger_type: str | None = None, + is_active: bool | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AutomationDefinition], int]: + """List automation definitions with optional filters.""" + query = select(AutomationDefinition).where( + AutomationDefinition.tenant_id == tenant_id + ) + count_query = ( + select(func.count()) + .select_from(AutomationDefinition) + .where(AutomationDefinition.tenant_id == tenant_id) + ) + + if trigger_type is not None: + query = query.where(AutomationDefinition.trigger_type == trigger_type) + count_query = count_query.where( + AutomationDefinition.trigger_type == trigger_type + ) + if is_active is not None: + query = query.where(AutomationDefinition.is_active == is_active) + count_query = count_query.where( + AutomationDefinition.is_active == is_active + ) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AutomationDefinition.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @staticmethod + async def get_by_id( + db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID + ) -> AutomationDefinition | None: + """Get a single automation definition by ID.""" + result = await db.execute( + select(AutomationDefinition) + .where(AutomationDefinition.id == automation_id) + .where(AutomationDefinition.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_by_name( + db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None + ) -> AutomationDefinition | None: + """Get a single automation definition by name (optionally scoped to tenant).""" + query = select(AutomationDefinition).where(AutomationDefinition.name == name) + if tenant_id is not None: + query = query.where(AutomationDefinition.tenant_id == tenant_id) + result = await db.execute(query.limit(1)) + return result.scalar_one_or_none() + + @staticmethod + async def create( + db: AsyncSession, + tenant_id: uuid.UUID, + data: dict[str, Any], + user_id: uuid.UUID | None = None, + ) -> AutomationDefinition: + """Create a new automation definition and its initial version snapshot.""" + automation = AutomationDefinition( + tenant_id=tenant_id, + name=data["name"], + description=data.get("description", ""), + trigger_type=data.get("trigger_type", "manual"), + trigger_config=data.get("trigger_config", {}), + conditions=data.get("conditions", []), + actions=data.get("actions", []), + is_active=data.get("is_active", True), + dry_run=data.get("dry_run", False), + created_by=user_id, + ) + db.add(automation) + await db.flush() + + # Create initial version snapshot + version = AutomationVersion( + tenant_id=tenant_id, + automation_id=automation.id, + version_number=1, + snapshot={ + "name": automation.name, + "description": automation.description, + "trigger_type": automation.trigger_type, + "trigger_config": automation.trigger_config, + "conditions": automation.conditions, + "actions": automation.actions, + "is_active": automation.is_active, + "dry_run": automation.dry_run, + }, + changed_by=user_id, + ) + db.add(version) + await db.flush() + + return automation + + @staticmethod + async def update( + db: AsyncSession, + tenant_id: uuid.UUID, + automation_id: uuid.UUID, + data: dict[str, Any], + user_id: uuid.UUID | None = None, + ) -> AutomationDefinition | None: + """Update an automation definition and create a new version snapshot if changed.""" + automation = await AutomationService.get_by_id(db, tenant_id, automation_id) + if automation is None: + return None + + changed = False + for key, value in data.items(): + if hasattr(automation, key) and value is not None: + setattr(automation, key, value) + changed = True + + if changed: + result = await db.execute( + select(func.coalesce(func.max(AutomationVersion.version_number), 0)) + .where(AutomationVersion.automation_id == automation_id) + .where(AutomationVersion.tenant_id == tenant_id) + ) + max_version = result.scalar() or 0 + + version = AutomationVersion( + tenant_id=tenant_id, + automation_id=automation.id, + version_number=max_version + 1, + snapshot={ + "name": automation.name, + "description": automation.description, + "trigger_type": automation.trigger_type, + "trigger_config": automation.trigger_config, + "conditions": automation.conditions, + "actions": automation.actions, + "is_active": automation.is_active, + "dry_run": automation.dry_run, + }, + changed_by=user_id, + ) + db.add(version) + await db.flush() + + return automation + + @staticmethod + async def delete( + db: AsyncSession, tenant_id: uuid.UUID, automation_id: uuid.UUID + ) -> bool: + """Delete an automation definition (cascades to versions and runs).""" + automation = await AutomationService.get_by_id(db, tenant_id, automation_id) + if automation is None: + return False + await db.delete(automation) + await db.flush() + return True + + @staticmethod + async def get_versions( + db: AsyncSession, + tenant_id: uuid.UUID, + automation_id: uuid.UUID, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AutomationVersion], int]: + """Get version history for an automation definition.""" + query = ( + select(AutomationVersion) + .where(AutomationVersion.automation_id == automation_id) + .where(AutomationVersion.tenant_id == tenant_id) + ) + count_query = ( + select(func.count()) + .select_from(AutomationVersion) + .where(AutomationVersion.automation_id == automation_id) + .where(AutomationVersion.tenant_id == tenant_id) + ) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AutomationVersion.version_number.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @staticmethod + async def restore_version( + db: AsyncSession, + tenant_id: uuid.UUID, + automation_id: uuid.UUID, + version_id: uuid.UUID, + user_id: uuid.UUID | None = None, + ) -> AutomationDefinition | None: + """Restore an automation definition from a specific version snapshot.""" + automation = await AutomationService.get_by_id(db, tenant_id, automation_id) + if automation is None: + return None + + result = await db.execute( + select(AutomationVersion) + .where(AutomationVersion.id == version_id) + .where(AutomationVersion.automation_id == automation_id) + .where(AutomationVersion.tenant_id == tenant_id) + .limit(1) + ) + version = result.scalar_one_or_none() + if version is None: + return None + + snapshot = version.snapshot + for key, value in snapshot.items(): + if hasattr(automation, key): + setattr(automation, key, value) + + max_ver_result = await db.execute( + select(func.coalesce(func.max(AutomationVersion.version_number), 0)) + .where(AutomationVersion.automation_id == automation_id) + .where(AutomationVersion.tenant_id == tenant_id) + ) + max_version = max_ver_result.scalar() or 0 + + new_version = AutomationVersion( + tenant_id=tenant_id, + automation_id=automation.id, + version_number=max_version + 1, + snapshot=snapshot, + changed_by=user_id, + ) + db.add(new_version) + await db.flush() + + return automation + + +# ─── Cron Job Service ─── + + +class CronJobService: + """CRUD and next_run calculation for cron job schedules.""" + + @staticmethod + async def list( + db: AsyncSession, + tenant_id: uuid.UUID, + job_type: str | None = None, + is_active: bool | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AutomationCronJob], int]: + """List cron jobs with optional filters.""" + query = select(AutomationCronJob).where( + AutomationCronJob.tenant_id == tenant_id + ) + count_query = ( + select(func.count()) + .select_from(AutomationCronJob) + .where(AutomationCronJob.tenant_id == tenant_id) + ) + + if job_type is not None: + query = query.where(AutomationCronJob.job_type == job_type) + count_query = count_query.where(AutomationCronJob.job_type == job_type) + if is_active is not None: + query = query.where(AutomationCronJob.is_active == is_active) + count_query = count_query.where(AutomationCronJob.is_active == is_active) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AutomationCronJob.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @staticmethod + async def get_by_id( + db: AsyncSession, tenant_id: uuid.UUID, job_id: uuid.UUID + ) -> AutomationCronJob | None: + """Get a single cron job by ID.""" + result = await db.execute( + select(AutomationCronJob) + .where(AutomationCronJob.id == job_id) + .where(AutomationCronJob.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_by_name( + db: AsyncSession, name: str, tenant_id: uuid.UUID | None = None + ) -> AutomationCronJob | None: + """Get a single cron job by name (optionally scoped to tenant).""" + query = select(AutomationCronJob).where(AutomationCronJob.name == name) + if tenant_id is not None: + query = query.where(AutomationCronJob.tenant_id == tenant_id) + result = await db.execute(query.limit(1)) + return result.scalar_one_or_none() + + @staticmethod + async def create( + db: AsyncSession, tenant_id: uuid.UUID, data: dict[str, Any] + ) -> AutomationCronJob: + """Create a new cron job entry.""" + job = AutomationCronJob( + tenant_id=tenant_id, + name=data["name"], + cron_expression=data["cron_expression"], + job_type=data.get("job_type", "automation_trigger"), + target_id=data.get("target_id"), + plugin_name=data.get("plugin_name", ""), + is_active=data.get("is_active", True), + ) + db.add(job) + await db.flush() + return job + + @staticmethod + async def update( + db: AsyncSession, + tenant_id: uuid.UUID, + job_id: uuid.UUID, + data: dict[str, Any], + ) -> AutomationCronJob | None: + """Update a cron job entry.""" + job = await CronJobService.get_by_id(db, tenant_id, job_id) + if job is None: + return None + + for key, value in data.items(): + if hasattr(job, key) and value is not None: + setattr(job, key, value) + await db.flush() + return job + + @staticmethod + async def delete( + db: AsyncSession, tenant_id: uuid.UUID, job_id: uuid.UUID + ) -> bool: + """Delete a cron job entry.""" + job = await CronJobService.get_by_id(db, tenant_id, job_id) + if job is None: + return False + await db.delete(job) + await db.flush() + return True + + @staticmethod + async def get_due_jobs( + db: AsyncSession, limit: int = 100 + ) -> list[AutomationCronJob]: + """Get cron jobs that are due for execution (next_run_at <= now).""" + now = datetime.now(UTC) + result = await db.execute( + select(AutomationCronJob) + .where(AutomationCronJob.is_active == True) + .where(AutomationCronJob.next_run_at <= now) + .limit(limit) + ) + return list(result.scalars().all()) + + +# ─── Run Log Service ─── + + +class RunLogService: + """Query execution logs for agents and automations.""" + + @staticmethod + async def list_agent_runs( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID | None = None, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AgentRun], int]: + """List agent run logs with optional filters.""" + query = select(AgentRun).where(AgentRun.tenant_id == tenant_id) + count_query = ( + select(func.count()) + .select_from(AgentRun) + .where(AgentRun.tenant_id == tenant_id) + ) + + if agent_id is not None: + query = query.where(AgentRun.agent_id == agent_id) + count_query = count_query.where(AgentRun.agent_id == agent_id) + if status is not None: + query = query.where(AgentRun.status == status) + count_query = count_query.where(AgentRun.status == status) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AgentRun.started_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @staticmethod + async def list_automation_runs( + db: AsyncSession, + tenant_id: uuid.UUID, + automation_id: uuid.UUID | None = None, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AutomationRun], int]: + """List automation run logs with optional filters.""" + query = select(AutomationRun).where(AutomationRun.tenant_id == tenant_id) + count_query = ( + select(func.count()) + .select_from(AutomationRun) + .where(AutomationRun.tenant_id == tenant_id) + ) + + if automation_id is not None: + query = query.where(AutomationRun.automation_id == automation_id) + count_query = count_query.where( + AutomationRun.automation_id == automation_id + ) + if status is not None: + query = query.where(AutomationRun.status == status) + count_query = count_query.where(AutomationRun.status == status) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + result = await db.execute( + query.order_by(AutomationRun.started_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total diff --git a/app/plugins/builtins/automation/tests/__init__.py b/app/plugins/builtins/automation/tests/__init__.py new file mode 100644 index 0000000..0ae24ac --- /dev/null +++ b/app/plugins/builtins/automation/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Automation & Agents plugin.""" diff --git a/app/plugins/builtins/automation/tests/test_automation.py b/app/plugins/builtins/automation/tests/test_automation.py new file mode 100644 index 0000000..bf42761 --- /dev/null +++ b/app/plugins/builtins/automation/tests/test_automation.py @@ -0,0 +1,553 @@ +"""Tests for the Automation & Agents plugin. + +Uses pytest with async fixtures. Tests use SQLite in-memory database +since PostgreSQL may not be available in the dev container. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any, AsyncGenerator + +import pytest +import pytest_asyncio +from sqlalchemy import create_engine, event +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.core.db import Base +from app.plugins.builtins.automation.models import ( + AgentDefinition, + AgentRun, + AgentVersion, + AutomationCronJob, + AutomationDefinition, + AutomationRun, + AutomationVersion, +) +from app.plugins.builtins.automation.services import ( + AgentService, + AutomationService, + CronJobService, + RunLogService, +) + + +# ─── Fixtures ─── + + +@pytest_asyncio.fixture +async def db() -> AsyncGenerator[AsyncSession, None]: + """Create an in-memory SQLite database for testing.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + yield session + + await engine.dispose() + + +@pytest.fixture +def tenant_id() -> uuid.UUID: + return uuid.uuid4() + + +@pytest.fixture +def user_id() -> uuid.UUID: + return uuid.uuid4() + + +# ─── AgentService Tests ─── + + +class TestAgentService: + """Tests for AgentService CRUD operations.""" + + @pytest.mark.asyncio + async def test_create_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test creating an agent definition.""" + data = { + "name": "test-agent", + "description": "Test agent description", + "llm_model": "gpt-4", + "system_prompt": "You are a test agent.", + "tool_ids": [], + "heartbeat_interval_seconds": 0, + "mode": "reactive", + "is_active": True, + "max_executions_per_hour": 10, + "max_duration_seconds": 300, + "budget_limit_usd": 1.0, + } + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + assert agent is not None + assert agent.name == "test-agent" + assert agent.tenant_id == tenant_id + assert agent.created_by == user_id + assert agent.mode == "reactive" + assert agent.is_active is True + + @pytest.mark.asyncio + async def test_get_agent_by_id(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test retrieving an agent by ID.""" + data = {"name": "get-test", "description": "", "llm_model": "gpt-4", "system_prompt": "", + "tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive", + "is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300, + "budget_limit_usd": 1.0} + created = await AgentService.create(db, tenant_id, data, user_id=user_id) + fetched = await AgentService.get_by_id(db, tenant_id, created.id) + assert fetched is not None + assert fetched.id == created.id + assert fetched.name == "get-test" + + @pytest.mark.asyncio + async def test_get_agent_by_name(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test retrieving an agent by name.""" + data = {"name": "name-test", "description": "", "llm_model": "gpt-4", "system_prompt": "", + "tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive", + "is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300, + "budget_limit_usd": 1.0} + await AgentService.create(db, tenant_id, data, user_id=user_id) + fetched = await AgentService.get_by_name(db, "name-test", tenant_id=tenant_id) + assert fetched is not None + assert fetched.name == "name-test" + + @pytest.mark.asyncio + async def test_update_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test updating an agent definition.""" + data = {"name": "update-test", "description": "Original", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + created = await AgentService.create(db, tenant_id, data, user_id=user_id) + updated = await AgentService.update(db, tenant_id, created.id, + {"description": "Updated description"}, user_id=user_id) + assert updated is not None + assert updated.description == "Updated description" + + @pytest.mark.asyncio + async def test_delete_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test deleting an agent definition.""" + data = {"name": "delete-test", "description": "", "llm_model": "gpt-4", "system_prompt": "", + "tool_ids": [], "heartbeat_interval_seconds": 0, "mode": "reactive", + "is_active": True, "max_executions_per_hour": 10, "max_duration_seconds": 300, + "budget_limit_usd": 1.0} + created = await AgentService.create(db, tenant_id, data, user_id=user_id) + result = await AgentService.delete(db, tenant_id, created.id) + assert result is True + fetched = await AgentService.get_by_id(db, tenant_id, created.id) + assert fetched is None + + @pytest.mark.asyncio + async def test_list_agents(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test listing agents with filters.""" + for i in range(3): + data = {"name": f"list-test-{i}", "description": "", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + await AgentService.create(db, tenant_id, data, user_id=user_id) + items, total = await AgentService.list(db, tenant_id) + assert total == 3 + assert len(items) == 3 + + +# ─── AutomationService Tests ─── + + +class TestAutomationService: + """Tests for AutomationService CRUD operations.""" + + @pytest.mark.asyncio + async def test_create_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test creating an automation definition.""" + data = { + "name": "test-automation", + "description": "Test automation", + "trigger_type": "manual", + "trigger_config": {}, + "conditions": [], + "actions": [{"type": "api_call", "config": {"url": "https://example.com"}}], + "is_active": True, + "dry_run": False, + } + automation = await AutomationService.create(db, tenant_id, data, user_id=user_id) + assert automation is not None + assert automation.name == "test-automation" + assert automation.trigger_type == "manual" + assert len(automation.actions) == 1 + + @pytest.mark.asyncio + async def test_get_automation_by_id(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test retrieving an automation by ID.""" + data = {"name": "get-auto", "description": "", "trigger_type": "event", + "trigger_config": {"event_name": "contact.created"}, "conditions": [], + "actions": [], "is_active": True, "dry_run": False} + created = await AutomationService.create(db, tenant_id, data, user_id=user_id) + fetched = await AutomationService.get_by_id(db, tenant_id, created.id) + assert fetched is not None + assert fetched.id == created.id + + @pytest.mark.asyncio + async def test_update_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test updating an automation definition.""" + data = {"name": "upd-auto", "description": "Original", "trigger_type": "manual", + "trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False} + created = await AutomationService.create(db, tenant_id, data, user_id=user_id) + updated = await AutomationService.update(db, tenant_id, created.id, + {"description": "Updated"}, user_id=user_id) + assert updated is not None + assert updated.description == "Updated" + + @pytest.mark.asyncio + async def test_delete_automation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test deleting an automation definition.""" + data = {"name": "del-auto", "description": "", "trigger_type": "manual", + "trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False} + created = await AutomationService.create(db, tenant_id, data, user_id=user_id) + result = await AutomationService.delete(db, tenant_id, created.id) + assert result is True + fetched = await AutomationService.get_by_id(db, tenant_id, created.id) + assert fetched is None + + +# ─── CronJobService Tests ─── + + +class TestCronJobService: + """Tests for CronJobService CRUD operations.""" + + @pytest.mark.asyncio + async def test_create_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID): + """Test creating a cron job.""" + data = { + "name": "test-cron", + "cron_expression": "*/5 * * * *", + "job_type": "automation_trigger", + "plugin_name": "test", + "is_active": True, + } + job = await CronJobService.create(db, tenant_id, data) + assert job is not None + assert job.name == "test-cron" + assert job.cron_expression == "*/5 * * * *" + + @pytest.mark.asyncio + async def test_get_cron_job_by_id(self, db: AsyncSession, tenant_id: uuid.UUID): + """Test retrieving a cron job by ID.""" + data = {"name": "get-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger", + "plugin_name": "test", "is_active": True} + created = await CronJobService.create(db, tenant_id, data) + fetched = await CronJobService.get_by_id(db, tenant_id, created.id) + assert fetched is not None + assert fetched.id == created.id + + @pytest.mark.asyncio + async def test_update_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID): + """Test updating a cron job.""" + data = {"name": "upd-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger", + "plugin_name": "test", "is_active": True} + created = await CronJobService.create(db, tenant_id, data) + updated = await CronJobService.update(db, tenant_id, created.id, + {"cron_expression": "0 10 * * *"}) + assert updated is not None + assert updated.cron_expression == "0 10 * * *" + + @pytest.mark.asyncio + async def test_delete_cron_job(self, db: AsyncSession, tenant_id: uuid.UUID): + """Test deleting a cron job.""" + data = {"name": "del-cron", "cron_expression": "0 9 * * *", "job_type": "automation_trigger", + "plugin_name": "test", "is_active": True} + created = await CronJobService.create(db, tenant_id, data) + result = await CronJobService.delete(db, tenant_id, created.id) + assert result is True + fetched = await CronJobService.get_by_id(db, tenant_id, created.id) + assert fetched is None + + +# ─── Version Management Tests ─── + + +class TestVersionManagement: + """Tests for version creation and restore.""" + + @pytest.mark.asyncio + async def test_version_created_on_agent_create(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test that a version snapshot is created when an agent is created.""" + data = {"name": "ver-agent", "description": "Version test", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + versions, total = await AgentService.get_versions(db, tenant_id, agent.id) + assert total == 1 + assert versions[0].version_number == 1 + assert versions[0].snapshot["name"] == "ver-agent" + + @pytest.mark.asyncio + async def test_version_created_on_agent_update(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test that a new version is created when an agent is updated.""" + data = {"name": "ver-upd", "description": "Original", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + await AgentService.update(db, tenant_id, agent.id, {"description": "Updated"}, user_id=user_id) + versions, total = await AgentService.get_versions(db, tenant_id, agent.id) + assert total == 2 + assert versions[0].version_number == 2 + + @pytest.mark.asyncio + async def test_version_restore_agent(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test restoring an agent from a version snapshot.""" + data = {"name": "ver-restore", "description": "Original", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + await AgentService.update(db, tenant_id, agent.id, {"description": "Changed"}, user_id=user_id) + versions, _ = await AgentService.get_versions(db, tenant_id, agent.id) + # Restore to version 1 + restored = await AgentService.restore_version(db, tenant_id, agent.id, versions[1].id, user_id=user_id) + assert restored is not None + assert restored.description == "Original" + + @pytest.mark.asyncio + async def test_version_created_on_automation_update(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test that a new version is created when an automation is updated.""" + data = {"name": "auto-ver", "description": "Original", "trigger_type": "manual", + "trigger_config": {}, "conditions": [], "actions": [], "is_active": True, "dry_run": False} + auto = await AutomationService.create(db, tenant_id, data, user_id=user_id) + await AutomationService.update(db, tenant_id, auto.id, {"description": "Updated"}, user_id=user_id) + versions, total = await AutomationService.get_versions(db, tenant_id, auto.id) + assert total == 2 + + +# ─── Condition Evaluation Tests ─── + + +class TestConditionEvaluation: + """Tests for condition evaluation logic.""" + + def _evaluate_condition(self, condition: dict, context: dict) -> bool: + """Evaluate a single condition against context data.""" + field = condition.get("field", "") + operator = condition.get("operator", "equals") + value = condition.get("value", "") + actual = context.get(field) + + if operator == "eq" or operator == "equals": + return str(actual) == str(value) + elif operator == "ne" or operator == "not_equals": + return str(actual) != str(value) + elif operator == "gt" or operator == "greater_than": + try: + return float(actual) > float(value) + except (ValueError, TypeError): + return False + elif operator == "lt" or operator == "less_than": + try: + return float(actual) < float(value) + except (ValueError, TypeError): + return False + elif operator == "contains": + return str(value) in str(actual) + elif operator == "exists": + return actual is not None + return False + + def test_condition_eq(self): + assert self._evaluate_condition( + {"field": "status", "operator": "eq", "value": "active"}, + {"status": "active"} + ) is True + assert self._evaluate_condition( + {"field": "status", "operator": "eq", "value": "inactive"}, + {"status": "active"} + ) is False + + def test_condition_ne(self): + assert self._evaluate_condition( + {"field": "status", "operator": "ne", "value": "inactive"}, + {"status": "active"} + ) is True + + def test_condition_gt(self): + assert self._evaluate_condition( + {"field": "age", "operator": "gt", "value": "18"}, + {"age": 25} + ) is True + assert self._evaluate_condition( + {"field": "age", "operator": "gt", "value": "30"}, + {"age": 25} + ) is False + + def test_condition_lt(self): + assert self._evaluate_condition( + {"field": "age", "operator": "lt", "value": "30"}, + {"age": 25} + ) is True + + def test_condition_contains(self): + assert self._evaluate_condition( + {"field": "email", "operator": "contains", "value": "@example.com"}, + {"email": "test@example.com"} + ) is True + assert self._evaluate_condition( + {"field": "email", "operator": "contains", "value": "@other.com"}, + {"email": "test@example.com"} + ) is False + + def test_condition_exists(self): + assert self._evaluate_condition( + {"field": "email", "operator": "exists", "value": ""}, + {"email": "test@example.com"} + ) is True + assert self._evaluate_condition( + {"field": "missing", "operator": "exists", "value": ""}, + {"email": "test@example.com"} + ) is False + + +# ─── Dry-Run Mode Tests ─── + + +class TestDryRunMode: + """Tests for dry-run mode (no actions executed).""" + + @pytest.mark.asyncio + async def test_dry_run_automation_creation(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test creating an automation with dry_run=True.""" + data = {"name": "dry-run-test", "description": "", "trigger_type": "manual", + "trigger_config": {}, "conditions": [], "actions": [{"type": "api_call", "config": {"url": "https://example.com"}}], + "is_active": True, "dry_run": True} + automation = await AutomationService.create(db, tenant_id, data, user_id=user_id) + assert automation.dry_run is True + + @pytest.mark.asyncio + async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID): + """Test that dry_run flag is stored in AutomationRun.""" + run = AutomationRun( + tenant_id=tenant_id, + automation_id=uuid.uuid4(), + status="dry_run", + started_at=datetime.now(UTC), + dry_run=True, + ) + db.add(run) + await db.flush() + assert run.dry_run is True + + +# ─── Rate Limiting Tests ─── + + +class TestRateLimiting: + """Tests for rate limiting (max executions per hour).""" + + @pytest.mark.asyncio + async def test_rate_limit_check(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test that rate limit is enforced.""" + data = {"name": "rate-limit", "description": "", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 2, + "max_duration_seconds": 300, "budget_limit_usd": 1.0} + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + + # Create 2 runs (within limit) + for _ in range(2): + run = AgentRun( + tenant_id=tenant_id, + agent_id=agent.id, + status="completed", + started_at=datetime.now(UTC), + ) + db.add(run) + await db.flush() + + # Check rate limit + from sqlalchemy import func, select + one_hour_ago = datetime.now(UTC) - timedelta(hours=1) + result = await db.execute( + select(func.count()) + .select_from(AgentRun) + .where(AgentRun.agent_id == agent.id, AgentRun.started_at >= one_hour_ago) + ) + recent_runs = result.scalar() or 0 + assert recent_runs == 2 + assert recent_runs < agent.max_executions_per_hour # 2 < 2 is False, so limit would be hit + + +# ─── Budget Limit Tests ─── + + +class TestBudgetLimit: + """Tests for budget limit enforcement.""" + + @pytest.mark.asyncio + async def test_budget_limit_check(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID): + """Test that budget limit is enforced.""" + data = {"name": "budget-limit", "description": "", "llm_model": "gpt-4", + "system_prompt": "", "tool_ids": [], "heartbeat_interval_seconds": 0, + "mode": "reactive", "is_active": True, "max_executions_per_hour": 10, + "max_duration_seconds": 300, "budget_limit_usd": 0.5} + agent = await AgentService.create(db, tenant_id, data, user_id=user_id) + + # Create runs with cumulative cost + for cost in [0.2, 0.2, 0.2]: + run = AgentRun( + tenant_id=tenant_id, + agent_id=agent.id, + status="completed", + started_at=datetime.now(UTC), + cost_usd=cost, + ) + db.add(run) + await db.flush() + + # Check budget + from sqlalchemy import func, select + cost_result = await db.execute( + select(func.coalesce(func.sum(AgentRun.cost_usd), 0.0)) + .where(AgentRun.agent_id == agent.id) + ) + total_cost = float(cost_result.scalar() or 0.0) + assert total_cost == 0.6 + assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded + + +# ─── Infinite Loop Detection Tests ─── + + +class TestInfiniteLoopDetection: + """Tests for infinite loop detection.""" + + def test_consecutive_tool_call_detection(self): + """Test that 5+ consecutive same tool calls are detected.""" + tool_call_count: dict[str, int] = {} + tool_name = "send_email" + + for i in range(5): + tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1 + if tool_call_count[tool_name] >= 5: + assert True + return + assert False, "Loop detection should have triggered" + + def test_different_tools_not_detected(self): + """Test that different tool calls don't trigger loop detection.""" + tool_call_count: dict[str, int] = {} + for i in range(5): + tool_call_count[f"tool_{i}"] = tool_call_count.get(f"tool_{i}", 0) + 1 + if tool_call_count[f"tool_{i}"] >= 5: + assert False, "Different tools should not trigger loop detection" + assert True diff --git a/app/plugins/builtins/automation/workflow_timeout.py b/app/plugins/builtins/automation/workflow_timeout.py new file mode 100644 index 0000000..817bae3 --- /dev/null +++ b/app/plugins/builtins/automation/workflow_timeout.py @@ -0,0 +1,82 @@ +"""Workflow timeout checker — cancels timed-out workflow instances.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models.notification import Notification +from app.models.workflow import Workflow, WorkflowInstance + +logger = logging.getLogger(__name__) + + +async def check_workflow_timeouts(ctx: dict[str, Any]) -> None: + """Run every 5 min. Queries WorkflowInstance where timeout_at <= now + AND status in (pending, in_progress). Sets status='cancelled', + completed_at=now. Sends notification to initiator.""" + now = datetime.now(UTC) + factory = get_session_factory() + + async with factory() as db: + result = await db.execute( + select(WorkflowInstance).where( + WorkflowInstance.timeout_at <= now, + WorkflowInstance.status.in_(["pending", "in_progress"]), + ) + ) + instances = list(result.scalars().all()) + + if not instances: + logger.debug("check_workflow_timeouts: no timed-out instances") + return + + logger.info("check_workflow_timeouts: %d instance(s) timed out", len(instances)) + + for inst in instances: + try: + async with factory() as db: + # Re-fetch to ensure fresh state + result = await db.execute( + select(WorkflowInstance).where(WorkflowInstance.id == inst.id) + ) + instance = result.scalar_one_or_none() + if instance is None: + continue + if instance.status not in ("pending", "in_progress"): + continue + + instance.status = "cancelled" + instance.completed_at = now + + # Get workflow name for notification + wf_result = await db.execute( + select(Workflow).where(Workflow.id == instance.workflow_id) + ) + workflow = wf_result.scalar_one_or_none() + workflow_name = workflow.name if workflow else "Unknown" + + # Send notification to initiator + if instance.initiated_by: + notification = Notification( + tenant_id=instance.tenant_id, + user_id=instance.initiated_by, + type="workflow_timeout", + title=f"Workflow '{workflow_name}' cancelled due to timeout", + body=f"The workflow instance timed out and was automatically cancelled.", + ) + db.add(notification) + + await db.flush() + + logger.info( + "Cancelled timed-out workflow instance %s (workflow=%s)", + inst.id, workflow_name, + ) + + except Exception: + logger.exception("Failed to process timeout for instance %s", inst.id) diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 936b8ed..845dce2 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pydantic import BaseModel, Field, field_validator @@ -78,6 +80,50 @@ class FrontendSettingsPage(BaseModel): permission: str = Field(default="", description="Optional permission required") +class AgentDefinitionContribution(BaseModel): + """An agent definition contributed by a plugin manifest.""" + + name: str = Field(..., min_length=1, max_length=120) + description: str = Field(default="", max_length=500) + llm_model: str = Field(default="ollama/deepseek-v4-flash", max_length=100) + system_prompt: str = Field(default="") + tool_ids: list[str] = Field(default_factory=list) + heartbeat_interval_seconds: int = Field(default=0, ge=0) + mode: str = Field(default="reactive", pattern="^(proactive|reactive)$") + max_executions_per_hour: int = Field(default=10, ge=1, le=1000) + max_duration_seconds: int = Field(default=300, ge=1, le=86400) + budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0) + + +class AutomationTemplateContribution(BaseModel): + """An automation template contributed by a plugin manifest.""" + + name: str = Field(..., min_length=1, max_length=120) + description: str = Field(default="", max_length=500) + trigger_type: str = Field(default="event", pattern="^(event|schedule|manual)$") + trigger_config: dict = Field(default_factory=dict) + conditions: list[dict] = Field(default_factory=list) + actions: list[dict] = Field(default_factory=list) + + +class CronJobContribution(BaseModel): + """A cron job contributed by a plugin manifest.""" + + name: str = Field(..., min_length=1, max_length=120) + cron_expression: str = Field(..., min_length=1, max_length=100) + job_type: str = Field(..., pattern="^(agent_heartbeat|automation_trigger|custom)$") + target_name: str = Field(default="", description="Reference agent/automation by name") + plugin_name: str = Field(default="") + + +class HeartbeatConfigContribution(BaseModel): + """A heartbeat config contributed by a plugin manifest.""" + + agent_name: str = Field(..., min_length=1, max_length=120) + interval_seconds: int = Field(..., ge=1) + target_room: str = Field(default="") + + class FrontendDashboardWidget(BaseModel): """A dashboard widget contributed by a plugin.""" @@ -92,6 +138,16 @@ class FrontendDashboardWidget(BaseModel): permission: str = Field(default="", description="Optional permission required") +class MiniAppContribution(BaseModel): + """A MiniApp contributed by a plugin manifest.""" + + app_id: str = Field(..., min_length=1, max_length=80) + name: str = Field(..., min_length=1, max_length=120) + icon: str = Field(default="AppWindow") + description: str = Field(default="") + render_schema: dict[str, Any] = Field(default_factory=dict) + + class PluginManifest(BaseModel): """Manifest describing a plugin's metadata, dependencies, and capabilities.""" @@ -142,6 +198,23 @@ class PluginManifest(BaseModel): dashboard_widgets: list[FrontendDashboardWidget] = Field( default_factory=list, description="Dashboard widgets contributed by this plugin" ) + # ── Plugin Contribution fields (Phase 3.5C) ── + agent_definitions: list[AgentDefinitionContribution] = Field( + default_factory=list, description="Agent definitions contributed by this plugin" + ) + automation_templates: list[AutomationTemplateContribution] = Field( + default_factory=list, description="Automation templates contributed by this plugin" + ) + cron_jobs: list[CronJobContribution] = Field( + default_factory=list, description="Cron jobs contributed by this plugin" + ) + heartbeat_configs: list[HeartbeatConfigContribution] = Field( + default_factory=list, description="Heartbeat configs contributed by this plugin" + ) + miniapps: list[MiniAppContribution] = Field( + default_factory=list, description="MiniApps contributed by this plugin" + ) + @field_validator("name") @classmethod @@ -153,6 +226,9 @@ class PluginManifest(BaseModel): model_config = {"extra": "forbid"} +PluginManifest.model_rebuild() + + class ManifestSchemaResponse(BaseModel): """Response model describing the manifest schema for API consumers.""" @@ -258,3 +334,6 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse( ], ), ) + + +PluginManifest.model_rebuild() diff --git a/app/plugins/registry.py b/app/plugins/registry.py index dca683f..3182139 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -350,6 +350,61 @@ class PluginRegistry: ) return warnings + # ── Plugin Contribution Registration ── + + async def _register_contributions_for_plugin(self, db: AsyncSession, name: str, plugin: BasePlugin) -> None: + """Register agent_definitions, automation_templates, cron_jobs, and heartbeat_configs + from a plugin's manifest with the automation plugin.""" + automation_plugin = self._plugins.get("automation") + if automation_plugin is None: + return + + # Check if automation plugin is active + automation_record = await self._get_plugin_record(db, "automation") + if automation_record is None or not automation_record.active: + return + + # Check if the activating plugin has any contributions + manifest = plugin.manifest + has_contributions = bool( + manifest.agent_definitions + or manifest.automation_templates + or manifest.cron_jobs + or manifest.heartbeat_configs + ) + if not has_contributions: + return + + try: + await automation_plugin.register_plugin_contributions(db, name, manifest) + logger.info( + "Registered contributions from plugin '%s' with automation plugin", + name, + ) + except Exception: + logger.exception( + "Failed to register contributions from plugin '%s' with automation plugin", + name, + ) + + async def _unregister_contributions_for_plugin(self, db: AsyncSession, name: str) -> None: + """Unregister contributions from a plugin being deactivated.""" + automation_plugin = self._plugins.get("automation") + if automation_plugin is None: + return + + try: + await automation_plugin.unregister_plugin_contributions(db, name) + logger.info( + "Unregistered contributions from plugin '%s' with automation plugin", + name, + ) + except Exception: + logger.exception( + "Failed to unregister contributions from plugin '%s' with automation plugin", + name, + ) + # ── Version Comparison & Update Path ── async def _check_and_run_version_migrations( @@ -470,6 +525,10 @@ class PluginRegistry: # Call on_activate hook (registers event listeners) await plugin.on_activate(db, self._container, self._event_bus) + # Register plugin contributions (agent_definitions, automation_templates, etc.) + # with the automation plugin if it is active + await self._register_contributions_for_plugin(db, name, plugin) + # Register routes on FastAPI app if available # Track actual route objects by identity to avoid cross-plugin removal if self._app is not None: @@ -533,6 +592,9 @@ class PluginRegistry: f"depend on it: {', '.join(active_dependents)}" ) + # Unregister plugin contributions from automation plugin + await self._unregister_contributions_for_plugin(db, name) + # Call on_deactivate hook (unregisters event listeners) await plugin.on_deactivate(db, self._container, self._event_bus) diff --git a/frontend/src/api/__tests__/automation.test.ts b/frontend/src/api/__tests__/automation.test.ts new file mode 100644 index 0000000..f9189e8 --- /dev/null +++ b/frontend/src/api/__tests__/automation.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the API client +const mockApiGet = vi.fn(); +const mockApiPost = vi.fn(); +const mockApiPut = vi.fn(); +const mockApiDelete = vi.fn(); + +vi.mock('@/api/client', () => ({ + apiGet: (...args: any[]) => mockApiGet(...args), + apiPost: (...args: any[]) => mockApiPost(...args), + apiPut: (...args: any[]) => mockApiPut(...args), + apiDelete: (...args: any[]) => mockApiDelete(...args), +})); + +// Mock react-query +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + }), + useQuery: vi.fn(), + useMutation: vi.fn(({ mutationFn, onSuccess }) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(); + return result; + }, + isPending: false, + })), +})); + +describe('Automation API Hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useAutomations', () => { + it('calls apiGet with correct endpoint', async () => { + const { useAutomations } = await import('../automation'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => { + expect(queryKey).toEqual(['automations']); + queryFn(); + return { data: [], isLoading: false }; + }); + + useAutomations(); + expect(mockApiGet).toHaveBeenCalledWith('/automation'); + }); + }); + + describe('useAgents', () => { + it('calls apiGet with correct endpoint', async () => { + const { useAgents } = await import('../automation'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => { + expect(queryKey).toEqual(['agents']); + queryFn(); + return { data: [], isLoading: false }; + }); + + useAgents(); + expect(mockApiGet).toHaveBeenCalledWith('/agents'); + }); + }); + + describe('useCreateAutomation', () => { + it('calls apiPost with correct endpoint', async () => { + const { useCreateAutomation } = await import('../automation'); + const hook = useCreateAutomation(); + + const data = { name: 'Test', trigger_type: 'manual' as const }; + await hook.mutateAsync(data); + + expect(mockApiPost).toHaveBeenCalledWith('/automation', data); + }); + }); + + describe('useCreateAgent', () => { + it('calls apiPost with correct endpoint', async () => { + const { useCreateAgent } = await import('../automation'); + const hook = useCreateAgent(); + + const data = { name: 'Test Agent', llm_model: 'gpt-4' }; + await hook.mutateAsync(data); + + expect(mockApiPost).toHaveBeenCalledWith('/agents', data); + }); + }); + + describe('useExecuteAutomation', () => { + it('calls apiPost with correct endpoint', async () => { + const { useExecuteAutomation } = await import('../automation'); + const hook = useExecuteAutomation(); + + await hook.mutateAsync('123'); + + expect(mockApiPost).toHaveBeenCalledWith('/automation/123/execute'); + }); + }); + + describe('useExecuteAgent', () => { + it('calls apiPost with correct endpoint', async () => { + const { useExecuteAgent } = await import('../automation'); + const hook = useExecuteAgent(); + + await hook.mutateAsync('456'); + + expect(mockApiPost).toHaveBeenCalledWith('/agents/456/execute'); + }); + }); + + describe('useDeleteAutomation', () => { + it('calls apiDelete with correct endpoint', async () => { + const { useDeleteAutomation } = await import('../automation'); + const hook = useDeleteAutomation(); + + await hook.mutateAsync('789'); + + expect(mockApiDelete).toHaveBeenCalledWith('/automation/789'); + }); + }); + + describe('useDeleteAgent', () => { + it('calls apiDelete with correct endpoint', async () => { + const { useDeleteAgent } = await import('../automation'); + const hook = useDeleteAgent(); + + await hook.mutateAsync('012'); + + expect(mockApiDelete).toHaveBeenCalledWith('/agents/012'); + }); + }); + + describe('useSendAgentMessage', () => { + it('calls apiPost with correct endpoint and body', async () => { + const { useSendAgentMessage } = await import('../automation'); + const hook = useSendAgentMessage(); + + await hook.mutateAsync({ + id: 'agent-1', + toAgentName: 'target-agent', + message: 'Hello there', + }); + + expect(mockApiPost).toHaveBeenCalledWith('/agents/agent-1/send-message', { + to_agent_name: 'target-agent', + message: 'Hello there', + }); + }); + }); + + describe('useMiniApps', () => { + it('calls apiGet with correct endpoint', async () => { + const { useMiniApps } = await import('../automation'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => { + expect(queryKey).toEqual(['miniapps']); + queryFn(); + return { data: [], isLoading: false }; + }); + + useMiniApps(); + expect(mockApiGet).toHaveBeenCalledWith('/automation/miniapps'); + }); + }); + + describe('useCreateMiniApp', () => { + it('calls apiPost with correct endpoint', async () => { + const { useCreateMiniApp } = await import('../automation'); + const hook = useCreateMiniApp(); + + const data = { app_id: 'my-app', name: 'My App' }; + await hook.mutateAsync(data); + + expect(mockApiPost).toHaveBeenCalledWith('/automation/miniapps', data); + }); + }); + + describe('useDeleteMiniApp', () => { + it('calls apiDelete with correct endpoint', async () => { + const { useDeleteMiniApp } = await import('../automation'); + const hook = useDeleteMiniApp(); + + await hook.mutateAsync('my-app'); + + expect(mockApiDelete).toHaveBeenCalledWith('/automation/miniapps/my-app'); + }); + }); +}); diff --git a/frontend/src/api/automation.ts b/frontend/src/api/automation.ts new file mode 100644 index 0000000..04ee62d --- /dev/null +++ b/frontend/src/api/automation.ts @@ -0,0 +1,277 @@ +/** + * React Query hooks for Automation & Agents API. + * Follows the pattern from plugins.ts. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPatch, apiDelete } from './client'; +import type { + AutomationDefinition, + AutomationRun, + AutomationVersion, + AgentDefinition, + AgentRun, + AgentVersion, + AgentTool, + AutomationSettings, + MiniAppDef, +} from '@/types/automation'; + +// ── Automation Hooks ── + +export function useAutomations() { + return useQuery({ + queryKey: ['automations'], + queryFn: () => apiGet('/automation'), + }); +} + +export function useAutomation(id: string) { + return useQuery({ + queryKey: ['automation', id], + queryFn: () => apiGet(`/automation/${id}`), + enabled: !!id, + }); +} + +export function useCreateAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/automation', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automations'] }); + }, + }); +} + +export function useUpdateAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + apiPatch(`/automation/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automations'] }); + }, + }); +} + +export function useDeleteAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/automation/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automations'] }); + }, + }); +} + +export function useExecuteAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiPost(`/automation/${id}/execute`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automationRuns'] }); + }, + }); +} + +export function useDryRunAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiPost(`/automation/${id}/dry-run`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automationRuns'] }); + }, + }); +} + +export function useAutomationRuns(automationId: string) { + return useQuery({ + queryKey: ['automationRuns', automationId], + queryFn: () => apiGet(`/automation/${automationId}/runs`), + enabled: !!automationId, + }); +} + +export function useAutomationVersions(automationId: string) { + return useQuery({ + queryKey: ['automationVersions', automationId], + queryFn: () => apiGet(`/automation/${automationId}/versions`), + enabled: !!automationId, + }); +} + +export function useRestoreAutomationVersion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, versionId }: { id: string; versionId: string }) => + apiPost(`/automation/${id}/versions/${versionId}/restore`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automations'] }); + queryClient.invalidateQueries({ queryKey: ['automationVersions'] }); + }, + }); +} + +export function useAutomationSettings() { + return useQuery({ + queryKey: ['automationSettings'], + queryFn: () => apiGet('/automation/settings'), + }); +} + +export function useUpdateAutomationSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => + apiPatch('/automation/settings', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['automationSettings'] }); + }, + }); +} + +// ── Agent Hooks ── + +export function useAgents() { + return useQuery({ + queryKey: ['agents'], + queryFn: () => apiGet('/agents'), + }); +} + +export function useAgent(id: string) { + return useQuery({ + queryKey: ['agent', id], + queryFn: () => apiGet(`/agents/${id}`), + enabled: !!id, + }); +} + +export function useCreateAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/agents', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + }, + }); +} + +export function useUpdateAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + apiPatch(`/agents/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + }, + }); +} + +export function useDeleteAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/agents/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + }, + }); +} + +export function useExecuteAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiPost(`/agents/${id}/execute`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agentRuns'] }); + }, + }); +} + +export function useTestRunAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiPost(`/agents/${id}/test-run`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agentRuns'] }); + }, + }); +} + +export function useAgentRuns(agentId: string) { + return useQuery({ + queryKey: ['agentRuns', agentId], + queryFn: () => apiGet(`/agents/${agentId}/runs`), + enabled: !!agentId, + }); +} + +export function useAgentVersions(agentId: string) { + return useQuery({ + queryKey: ['agentVersions', agentId], + queryFn: () => apiGet(`/agents/${agentId}/versions`), + enabled: !!agentId, + }); +} + +export function useRestoreAgentVersion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, versionId }: { id: string; versionId: string }) => + apiPost(`/agents/${id}/versions/${versionId}/restore`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + queryClient.invalidateQueries({ queryKey: ['agentVersions'] }); + }, + }); +} + +export function useAgentTools() { + return useQuery({ + queryKey: ['agentTools'], + queryFn: () => apiGet('/agents/tools'), + }); +} + +// ── Agent Communication Hooks ── + +export function useSendAgentMessage() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, toAgentName, message }: { id: string; toAgentName: string; message: string }) => + apiPost(`/agents/${id}/send-message`, { to_agent_name: toAgentName, message }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agentRuns'] }); + }, + }); +} + +// ── MiniApp Hooks ── + +export function useMiniApps() { + return useQuery({ + queryKey: ['miniapps'], + queryFn: () => apiGet('/automation/miniapps'), + }); +} + +export function useCreateMiniApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/automation/miniapps', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['miniapps'] }); + }, + }); +} + +export function useDeleteMiniApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (appId: string) => apiDelete(`/automation/miniapps/${appId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['miniapps'] }); + }, + }); +} diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index bc96499..9f2b84b 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -20,6 +20,7 @@ export * from './plugins'; export * from './settings'; export * from './attachments'; export * from './unifiedContacts'; +export * from './automation'; // ── Search hook (uses dynamic import, kept inline) ── diff --git a/frontend/src/pages/AgentDashboard.tsx b/frontend/src/pages/AgentDashboard.tsx new file mode 100644 index 0000000..cc8160c --- /dev/null +++ b/frontend/src/pages/AgentDashboard.tsx @@ -0,0 +1,830 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useAgents, + useCreateAgent, + useUpdateAgent, + useDeleteAgent, + useExecuteAgent, + useTestRunAgent, + useAgentRuns, + useAgentVersions, + useRestoreAgentVersion, + useAgentTools, + useSendAgentMessage, +} from '@/api/automation'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Modal } from '@/components/ui/Modal'; +import { Select } from '@/components/ui/Select'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { useToast } from '@/components/ui/Toast'; +import type { AgentDefinition } from '@/types/automation'; +import { + Plus, + Play, + TestTube, + History, + GitBranch, + Trash2, + Settings2, + Bot, + AlertCircle, + Loader2, + Brain, + Activity, + Clock, + DollarSign, + MessageCircle, + Send, +} from 'lucide-react'; + +const modeOptions = [ + { value: 'reactive', label: 'Reactive' }, + { value: 'proactive', label: 'Proactive' }, +]; + +const commonModels = [ + 'gpt-4', + 'gpt-4-turbo', + 'gpt-3.5-turbo', + 'claude-3-opus', + 'claude-3-sonnet', + 'claude-3-haiku', + 'llama-3-70b', + 'llama-3-8b', + 'mistral-large', + 'mixtral-8x7b', +]; + +function statusBadgeVariant(status: string): 'success' | 'warning' | 'secondary' { + switch (status) { + case 'active': return 'success'; + case 'inactive': return 'warning'; + default: return 'secondary'; + } +} + +function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' { + switch (status) { + case 'completed': return 'success'; + case 'running': return 'info'; + case 'failed': return 'danger'; + case 'cancelled': return 'warning'; + default: return 'warning'; + } +} + +interface AgentFormData { + name: string; + description: string; + model: string; + system_prompt: string; + tools: string[]; + heartbeat_interval: number; + mode: 'proactive' | 'reactive'; + max_executions_per_hour: number; + max_duration: number; + budget_limit: number; + active: boolean; +} + +const emptyForm: AgentFormData = { + name: '', + description: '', + model: 'gpt-4', + system_prompt: '', + tools: [], + heartbeat_interval: 0, + mode: 'reactive', + max_executions_per_hour: 100, + max_duration: 300, + budget_limit: 0, + active: true, +}; + +function AgentForm({ + initial, + onSave, + onCancel, + isSaving, +}: { + initial?: AgentDefinition; + onSave: (data: Partial) => void; + onCancel: () => void; + isSaving: boolean; +}) { + const { t } = useTranslation(); + const { data: availableTools } = useAgentTools(); + const [form, setForm] = useState(() => { + if (initial) { + return { + name: initial.name, + description: initial.description || '', + model: initial.model, + system_prompt: initial.system_prompt || '', + tools: initial.tools || [], + heartbeat_interval: initial.heartbeat_interval, + mode: initial.mode, + max_executions_per_hour: initial.max_executions_per_hour, + max_duration: initial.max_duration, + budget_limit: initial.budget_limit, + active: initial.active, + }; + } + return { ...emptyForm }; + }); + + const updateField = (key: K, value: AgentFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const toggleTool = (toolName: string) => { + setForm((prev) => { + const tools = prev.tools.includes(toolName) + ? prev.tools.filter((t) => t !== toolName) + : [...prev.tools, toolName]; + return { ...prev, tools }; + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ + name: form.name, + description: form.description || undefined, + model: form.model, + system_prompt: form.system_prompt || undefined, + tools: form.tools, + heartbeat_interval: form.heartbeat_interval, + mode: form.mode, + max_executions_per_hour: form.max_executions_per_hour, + max_duration: form.max_duration, + budget_limit: form.budget_limit, + active: form.active, + }); + }; + + return ( +
+ {/* Name & Description */} +
+
+ + updateField('name', e.target.value)} + required + className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + placeholder="My Agent" + /> +
+
+ +