Files
leocrm/app/plugins/builtins/automation/plugin.py
T

372 lines
17 KiB
Python
Raw Normal View History

2026-07-23 20:00:37 +02:00
"""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)