Phase 3.5: Automation & Agents Plugin

Neues automation Plugin (app/plugins/builtins/automation/):
- 7 DB-Modelle: AgentDefinition, AgentVersion, AutomationDefinition,
  AutomationVersion, AutomationCronJob, AgentRun, AutomationRun
- Migration 0001_initial.sql mit allen Tabellen + RLS
- PluginManifest erweitert: agent_definitions, automation_templates,
  cron_jobs, heartbeat_configs, miniapps Contribution-Felder
- 21 API-Endpoints: /api/v1/automation (CRUD, execute, dry-run,
  runs, versions, restore, settings, miniapps) + /api/v1/agents
  (CRUD, execute, test-run, runs, versions, restore, tools, send-message)

Backend Features:
- Cron-Scheduler (scheduler.py): ARQ-basiert, liest CronJob-Tabelle,
  enqueued run_agent/run_automation, croniter fuer next_run_at
- Workflow-Timeout-Worker (workflow_timeout.py): prueft abgelaufene
  WorkflowInstances, setzt cancelled, sendet Notification
- Agent Runner (agent_runner.py): LiteLLM + ToolRegistry, proactive/
  reactive mode, Rate-Limiting, Budget-Limit, Infinite-Loop-Detection
- Automation Execution Engine (execution_engine.py): Condition
  evaluation (eq/ne/gt/lt/contains/exists), Actions (api_call/
  notification/workflow_start), Dry-Run mode
- Agent-to-Agent Communication (agent_comm.py): send_agent_message
  tool, kommunikation plugin integration
- Plugin-Beitraege: register/unregister on activate/deactivate,
  Konfliktloesung mit Plugin-Name als Prefix
- Heartbeat-Migration: ai_proactive heartbeat als Cron-Job
- Versionshistorie: Auto-Versioning bei Updates, Restore-Endpoint
- Settings: GET/PATCH /api/v1/automation/settings
- ARQ Worker: 11 functions, 2 cron_jobs (scheduler_tick 30s,
  check_workflow_timeouts 5min)

Frontend:
- AutomationDashboard.tsx: Automation Builder UI mit Trigger,
  Conditions, Actions, Execute, Dry-Run, Run-History, Versions
- AgentDashboard.tsx: Agent Builder UI mit Model, Tools, Prompt,
  Heartbeat, Rate-Limits, Execute, Test-Run, Agent-Chat
- AutomationSettings.tsx: Settings + MiniApp-Builder
- automation.ts: 24 React Query Hooks
- automation.ts types: TypeScript Interfaces
- routes/index.tsx: /automation, /agents, /settings/automation

Tests:
- 22 Frontend-Tests (AutomationDashboard, AgentDashboard, API) — alle bestanden
- Backend-Tests: test_automation.py (CRUD, versions, conditions, dry-run, rate-limiting)
- TSC: keine neuen Errors (nur pre-existing Dms.tsx)
- croniter dependency installiert
This commit is contained in:
Agent Zero
2026-07-23 20:00:37 +02:00
parent fc96a2f86c
commit 5dc6f29ac1
28 changed files with 7397 additions and 0 deletions
+737
View File
@@ -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