372 lines
17 KiB
Python
372 lines
17 KiB
Python
|
|
"""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)
|