Files
leocrm/app/plugins/builtins/automation/plugin.py
T
Agent Zero fbcfbbced6
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(J): Phase J Self-Improvement Plugin — controlled improvement loop
- New self_improvement plugin: models, services, routes, plugin
- 4 SQLAlchemy models: ImprovementSignal, ImprovementPattern, ImprovementProposal, ImpactMeasurement
- Migration 0132: 4 tables with RLS
- Services: collect_signals, detect_patterns, create_proposal, evaluate_proposal, request_approval, activate_proposal, rollback_proposal, measure_impact
- 11 API routes under /api/v1/improvement/
- Frontend: improvement.ts API client, ImprovementPanel.tsx in AISidebar proactive tab
- 24/24 integration tests pass
- Fix: __init__.py imports Plugin class for discover_builtins() (self_improvement + knowledge)
- Fix: automation plugin register_plugin_contributions skips when no tenant exists
2026-08-21 01:34:48 +02:00

543 lines
24 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 (
CronJobContribution,
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",
),
PluginRouteDef(
path="/api/v1/skills",
module="app.plugins.builtins.automation.skill_routes",
router_attr="router",
),
],
events=[
"contact.created",
"contact.updated",
"mail.received",
"workflow.timeout",
],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"],
permissions=[
"automation:read",
"automation:write",
"automation:delete",
"automation:execute",
"automation:configure",
"agents:read",
"agents:write",
"agents:delete",
"agents:execute",
],
is_core=True,
menu_items=[
FrontendMenuItem(
label_key="nav.workflows",
label="Workflows",
path="/workflows",
icon="Workflow",
order=52,
),
FrontendMenuItem(
label_key="nav.importExport",
label="Import / Export",
path="/import-export",
icon="ArrowUpDown",
order=53,
),
FrontendMenuItem(
label_key="nav.dedupMerge",
label="Duplikate",
path="/contacts/dedup",
icon="Copy",
order=54,
),
FrontendMenuItem(
label_key="nav.tags",
label="Tags",
path="/tags",
icon="Tag",
order=55,
),
FrontendMenuItem(
label_key="nav.activity",
label="Aktivitäten",
path="/activity",
icon="Activity",
order=56,
),
],
page_routes=[
FrontendPageRoute(
path="/automation",
component="@/pages/AutomationDashboard",
order=50,
),
FrontendPageRoute(
path="/agents",
component="@/pages/AgentDashboard",
order=51,
),
FrontendPageRoute(
path="/workflows",
component="@/pages/Workflows",
order=52,
),
FrontendPageRoute(
path="/import-export",
component="@/pages/ImportExport",
order=53,
),
],
settings_pages=[
FrontendSettingsPage(
path="automation",
label_key="settings.automation",
label="Automation",
component="@/pages/AutomationSettings",
icon="Settings",
order=60,
),
],
cron_jobs=[
CronJobContribution(
name="backup_check",
cron_expression="0 2 * * *",
job_type="custom",
target_name="backup_check",
plugin_name="automation",
),
CronJobContribution(
name="search_index_check",
cron_expression="0 3 * * *",
job_type="custom",
target_name="search_index_check",
plugin_name="automation",
),
CronJobContribution(
name="check_workflow_timeouts",
cron_expression="*/5 * * * *",
job_type="custom",
target_name="check_workflow_timeouts",
plugin_name="automation",
),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.before_create", "contact.after_create"],
contract_version="1.0.0",
)
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, ...]
def get_entity_models(self) -> dict[str, type]:
from app.plugins.builtins.automation.models import AgentDefinition, AutomationDefinition
return {"agent_definition": AgentDefinition, "automation_definition": AutomationDefinition}
def get_job_modules(self) -> list[str]:
return [
"app.plugins.builtins.automation.scheduler",
"app.plugins.builtins.automation.workflow_timeout",
"app.plugins.builtins.automation.agent_runner",
"app.plugins.builtins.automation.execution_engine",
]
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 agent coordinator tools
try:
from app.plugins.builtins.automation.agent_coordinator import (
register_agent_coordinator_tools,
)
register_agent_coordinator_tools()
except Exception:
logger.exception("Failed to register agent coordinator tools")
# Register integration tools (I-AW: Agent→Workflow, I-AK: Agent→Knowledge)
try:
from app.ai.integration_tools import register_integration_tools
register_integration_tools()
except Exception:
logger.exception("Failed to register integration tools")
# Register MiniApps from manifest
try:
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
registry = get_miniapp_registry()
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")
# Register own cron jobs from manifest
try:
await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
logger.info("Registered own cron jobs from manifest")
except Exception:
logger.exception("Failed to register own cron jobs")
# Register pre-built agents in DB (if not already present)
try:
from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.builtins.automation.prebuilt.email_triage_agent import create_email_triage_agent
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import create_contact_enrichment_agent
from app.plugins.builtins.automation.prebuilt.follow_up_agent import create_follow_up_agent
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
from sqlalchemy import select as sa_select
# Get first tenant + admin user for seeding
from app.models.user import User
from app.models.tenant import Tenant
tenant_result = await db.execute(sa_select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if tenant:
user_result = await db.execute(
sa_select(User).where(User.tenant_id == tenant.id).limit(1)
)
user = user_result.scalar_one_or_none()
if user:
prebuilt_factories = [
("E-Mail-Triage-Agent", create_email_triage_agent),
("Kontakt-Anreicherungs-Agent", create_contact_enrichment_agent),
("Follow-Up-Agent", create_follow_up_agent),
("Berichts-Agent", create_report_agent),
]
for agent_name, factory in prebuilt_factories:
# Check if agent already exists
existing = await db.execute(
sa_select(AgentDefinition).where(
AgentDefinition.tenant_id == tenant.id,
AgentDefinition.name == agent_name,
)
)
if not existing.scalar_one_or_none():
agent = factory(tenant_id=tenant.id, user_id=user.id)
db.add(agent)
logger.info("Registered pre-built agent '%s'", agent_name)
await db.commit()
logger.info("Pre-built agents registration complete")
except Exception:
logger.exception("Failed to register pre-built agents")
logger.info("Automation plugin activated")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up on deactivation."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
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 agent coordinator tools
try:
from app.plugins.builtins.automation.agent_coordinator import (
unregister_agent_coordinator_tools,
)
unregister_agent_coordinator_tools()
except Exception:
logger.exception("Failed to unregister agent coordinator tools")
# Unregister MiniApps
try:
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
registry = get_miniapp_registry()
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 sqlalchemy import select
# Get default tenant_id from the first tenant in the DB
from app.models.tenant import Tenant
from app.plugins.builtins.automation.models import AutomationCronJob
from app.plugins.builtins.automation.services import (
AgentService,
AutomationService,
CronJobService,
)
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
default_tenant_id = tenant.id if tenant else None
if default_tenant_id is None:
logger.warning("No tenant found — skipping plugin contributions registration")
return
# 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, default_tenant_id, prefixed_name) if hasattr(AgentService, 'get_by_name') else None
if existing is None:
try:
await AgentService.create(db, default_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, default_tenant_id, prefixed_name) if hasattr(AutomationService, 'get_by_name') else None
if existing is None:
try:
await AutomationService.create(db, default_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, default_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 sqlalchemy import select
from app.plugins.builtins.automation.models import AutomationCronJob
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 sqlalchemy import select
from app.plugins.builtins.automation.models import AutomationCronJob
from app.plugins.builtins.automation.services import CronJobService
# 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)