Files
leocrm/app/plugins/builtins/automation/agent_runner.py
T
Agent Zero c760b5961c
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(F-LOOP): true ReAct loop with structured Thought/Action/Observation step tracking
- app/ai/agent_loop.py: ReActStep + ReActResult dataclasses, run_react_loop()
  with LLM→Tool→Observe loop, max_steps/timeout graceful stop, ErrorCategory
  retry (TRANSIENT→retry, PERMANENT→stop, PARTIAL→continue), cost accumulation,
  agent.step hook, on_step callback
- app/plugins/builtins/automation/models.py: AgentRunStep model
- alembic/versions/0121_agent_run_steps.py: migration for agent run steps table
- app/plugins/builtins/automation/agent_runner.py: refactored to use run_react_loop(),
  saves steps to DB, updates AgentRun with cost/status/duration
- tests/test_agent_loop.py: 11 tests (all passing, mocked, no DB/LLM needed)
- PROGRESS.md: Phase F started, F-LOOP marked done
2026-08-17 16:11:26 +02:00

309 lines
11 KiB
Python

"""Agent runner — executes AI agents as ARQ jobs with safety checks.
Safety features:
- Max executions per agent per hour (rate limiting)
- Max duration per execution (asyncio timeout)
- Auto-stop on infinite loop (same tool called 5x consecutively)
- Budget limit per agent (track cumulative cost_usd, stop if over budget)
- ReAct loop with structured Thought/Action/Observation step tracking
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from app.ai.agent_loop import ReActResult, run_react_loop
from app.core.db import get_session_factory
logger = logging.getLogger(__name__)
async def run_agent(
ctx: dict[str, Any],
agent_id: str,
trigger_type: str = "manual",
trigger_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ARQ job function. Loads AgentDefinition from DB, checks rate limits,
gathers context, runs the ReAct loop, saves steps and result to AgentRun.
Safety checks:
1. Rate limit: max_executions_per_hour
2. Max duration: max_duration_seconds (asyncio.timeout)
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
4. Budget limit: cumulative cost_usd
"""
from app.plugins.builtins.automation.models import (
AgentDefinition,
AgentRun,
AgentRunStep,
)
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
factory = get_session_factory()
# Load agent definition
async with factory() as db:
result = await db.execute(
select(AgentDefinition).where(AgentDefinition.id == agent_id)
)
agent = result.scalar_one_or_none()
if agent is None:
logger.error("AgentDefinition %s not found", agent_id)
return {"error": f"AgentDefinition {agent_id} not found", "status": "failed"}
if not agent.is_active:
logger.warning("AgentDefinition %s is inactive", agent_id)
return {"error": "Agent is inactive", "status": "skipped"}
# ── Safety Check 1: Rate Limit ──
if agent.max_executions_per_hour:
async with factory() as db:
one_hour_ago = datetime.now(UTC)
count_result = await db.execute(
select(func.count())
.select_from(AgentRun)
.where(
AgentRun.agent_id == agent.id,
AgentRun.created_at >= one_hour_ago,
)
)
recent_runs = count_result.scalar() or 0
if recent_runs >= agent.max_executions_per_hour:
logger.warning(
"Rate limit hit for agent %s: %d runs in last hour (max %d)",
agent.id, recent_runs, agent.max_executions_per_hour,
)
return {"error": "Rate limit exceeded", "status": "rate_limited"}
# ── Safety Check 2: Budget Limit ──
if agent.budget_limit_usd > 0:
async with factory() as db:
cost_result = await db.execute(
select(func.coalesce(func.sum(AgentRun.cost_usd), 0.0))
.where(AgentRun.agent_id == agent.id)
)
total_cost = float(cost_result.scalar() or 0.0)
if total_cost >= agent.budget_limit_usd:
logger.warning(
"Budget limit hit for agent %s: $%.4f total cost (limit $%.2f)",
agent.id, total_cost, agent.budget_limit_usd,
)
return {"error": "Budget limit exceeded", "status": "budget_exceeded"}
# Gather context
context_data: dict[str, Any] = {}
if trigger_type == "proactive" or agent.mode == "proactive":
try:
from app.services.contact_service import list_contacts
async with factory() as db:
contacts = await list_contacts(db, agent.tenant_id, page=1, page_size=10)
context_data["recent_contacts"] = contacts.get("items", [])
except Exception:
logger.warning("Failed to collect contacts for proactive context")
try:
from app.plugins.builtins.mail.models import Mail
from sqlalchemy import select as _select
async with factory() as db:
mail_q = await db.execute(
_select(Mail)
.where(Mail.tenant_id == agent.tenant_id)
.order_by(Mail.date.desc())
.limit(5)
)
context_data["recent_mails"] = [
{"id": str(m.id), "subject": m.subject, "from": m.sender}
for m in mail_q.scalars()
]
except Exception:
logger.warning("Failed to collect mails for proactive context")
try:
from app.services.workflow_service import list_instances
async with factory() as db:
events = await list_instances(db, agent.tenant_id, page=1, page_size=10)
context_data["recent_events"] = events.get("items", [])
except Exception:
logger.warning("Failed to collect events for proactive context")
else:
context_data = trigger_data or {}
# ── Lifecycle: before run ──
from app.core.hooks import do_action
await do_action("agent.before_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), trigger_type=trigger_type)
from app.core.outbox import enqueue_outbox_event
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_started',
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'trigger_type': trigger_type},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
# ── Prepare tools ──
registry = get_tool_registry()
tool_ids: list[str] = list(agent.tool_ids or [])
tools = registry.get_by_names(tool_ids) if tool_ids else []
tool_schemas = [t.to_openai_schema() for t in tools] if tools else []
# ── Create AgentRun record ──
run_id: uuid.UUID | None = None
started_at = datetime.now(UTC)
async with factory() as db:
run = AgentRun(
tenant_id=agent.tenant_id,
agent_id=agent.id,
status="running",
started_at=started_at,
trigger_type=trigger_type,
trigger_data=context_data,
)
db.add(run)
await db.flush()
run_id = run.id
await db.commit()
# ── Run ReAct loop ──
result_data: dict[str, Any] = {
"agent_id": str(agent.id),
"agent_name": agent.name,
"trigger_type": trigger_type,
"status": "running",
"run_id": str(run_id) if run_id else None,
"llm_response": None,
"tool_calls": [],
"cost_usd": 0.0,
"steps": [],
"error": None,
}
max_duration = agent.max_duration_seconds or 300
try:
import asyncio
import uuid as uuid_mod
react_result: ReActResult = await asyncio.wait_for(
run_react_loop(
agent_definition=agent,
messages=[{"role": "user", "content": f"Context: {context_data}"}],
tools=tool_schemas,
tool_registry=registry,
db=None, # ReAct loop doesn't need DB session for LLM calls directly
tenant_id=agent.tenant_id,
user_id=agent.created_by or uuid_mod.uuid4(),
agent_run_id=run_id,
max_steps=20,
timeout_seconds=max_duration,
),
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
)
result_data["status"] = react_result.status
result_data["llm_response"] = react_result.final_content
result_data["cost_usd"] = react_result.total_cost_usd
result_data["error"] = react_result.error
result_data["steps"] = [
{
"step_number": s.step_number,
"thought": s.thought,
"action": s.action,
"action_input": s.action_input,
"observation": s.observation,
"cost_usd": s.cost_usd,
}
for s in react_result.steps
]
result_data["tool_calls"] = [
{"tool": s.action, "arguments": s.action_input, "result": s.observation}
for s in react_result.steps if s.action
]
except TimeoutError:
logger.warning("Agent %s execution timed out after %d seconds", agent.id, max_duration)
result_data["status"] = "stopped_timeout"
result_data["error"] = f"Execution timed out after {max_duration} seconds"
except Exception as e:
logger.exception("Agent run failed for %s", agent.id)
result_data["status"] = "stopped_error"
result_data["error"] = str(e)
# ── Save steps to DB ──
completed_at = datetime.now(UTC)
duration_seconds = (completed_at - started_at).total_seconds()
try:
async with factory() as db:
# Save each step
for step_data in result_data.get("steps", []):
step = AgentRunStep(
tenant_id=agent.tenant_id,
agent_run_id=run_id,
step_number=step_data["step_number"],
thought=step_data.get("thought"),
action=step_data.get("action"),
action_input=step_data.get("action_input"),
observation=step_data.get("observation"),
cost_usd=step_data.get("cost_usd", 0.0),
)
db.add(step)
# Update AgentRun with final results
run_result = await db.execute(
select(AgentRun).where(AgentRun.id == run_id)
)
run = run_result.scalar_one_or_none()
if run:
run.status = result_data["status"]
run.completed_at = completed_at
run.duration_seconds = duration_seconds
run.result = result_data.get("llm_response")
run.error = result_data.get("error")
run.cost_usd = result_data.get("cost_usd", 0.0)
await db.commit()
except Exception as e:
logger.exception("Failed to save agent run steps for %s", agent.id)
result_data["save_error"] = str(e)
# ── Lifecycle: after run ──
await do_action(
"agent.after_run",
agent_id=str(agent.id),
tenant_id=str(agent.tenant_id),
status=result_data.get("status"),
result=result_data,
)
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_completed',
{
'agent_id': str(agent.id),
'tenant_id': str(agent.tenant_id),
'status': result_data.get('status'),
'cost_usd': result_data.get('cost_usd', 0.0),
},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
return result_data
# Register all job functions with the job registry
from app.core.job_registry import register_job # noqa: E402
register_job("run_agent", run_agent)