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:
@@ -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)
|
||||
Reference in New Issue
Block a user