"""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)