"""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.contracts import get_contract mail_contract = get_contract("mail") if mail_contract and hasattr(mail_contract, "get_recent_mails"): from sqlalchemy import select as _select from app.plugins.builtins.mail.models import Mail 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 [] # ── Resolve agent permissions (Punkt 2+3 der Audit) ── from app.ai.agent_permissions import resolve_agent_permissions from app.ai.agent_tools import get_agent_tools from app.ai.skill_registry import get_skill_registry async with factory() as db: perm_ctx = await resolve_agent_permissions( db=db, tenant_id=agent.tenant_id, user_id=agent.created_by or uuid_mod.uuid4(), agent_definition=agent, ) # Use permission-filtered tools instead of raw tool_ids skill_reg = get_skill_registry() tool_schemas, _skills = get_agent_tools( agent_definition=agent, tool_registry=registry, skill_registry=skill_reg, user_permissions=perm_ctx.user_permissions, ) # ── 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 # ── Build agent context via context_builder (Punkt 1 der Audit) ── from app.ai.context_builder import build_agent_context # Sanitize context_data to remove sensitive fields (Punkt 4: data_policy) from app.core.sensitive_data import sanitize_dict safe_context_data = sanitize_dict(context_data) # Build the user message from sanitized context user_message = f"Context: {safe_context_data}" if safe_context_data else "No additional context provided." # Build full message list (system prompt + context + user message) messages = await build_agent_context( agent_definition=agent, user_message=user_message, db=None, # No DB session available here; context_builder handles gracefully tenant_id=agent.tenant_id, user_id=agent.created_by or uuid_mod.uuid4(), ) # ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ── from app.ai.data_policy import enforce_data_policy messages = await enforce_data_policy( db=None, tenant_id=agent.tenant_id, messages=messages, agent_definition=agent, ) react_result: ReActResult = await asyncio.wait_for( run_react_loop( agent_definition=agent, messages=messages, 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, require_approval=bool(getattr(agent, "require_approval", False)), approval_tools=getattr(agent, "approval_tools", None), ), 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 # ── Mark result as AI-generated (Punkt 6: transparency) ── from app.ai.transparency import mark_as_ai_generated if react_result.final_content: ai_metadata = mark_as_ai_generated( react_result.final_content, metadata={ "model": getattr(agent, "llm_model", "unknown"), "provider": getattr(agent, "provider", "unknown"), "agent_id": str(agent.id), "agent_name": agent.name, "run_id": str(run_id) if run_id else None, }, ) result_data["ai_generated"] = True result_data["ai_metadata"] = ai_metadata.get("ai_metadata", {}) # ── Create oversight decision record (Punkt 5: oversight) ── from app.ai.oversight import DecisionRecord, create_decision_record try: async with factory() as db: record = DecisionRecord( agent_run_id=run_id or uuid_mod.uuid4(), recommendation=react_result.final_content, evidence={ "steps": len(react_result.steps), "cost_usd": react_result.total_cost_usd, "status": react_result.status, }, ) await create_decision_record(db, agent.tenant_id, record) await db.commit() except Exception as e: logger.warning("Failed to create oversight decision record: %s", e) 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, ) # ── Post agent result to Communication (F-COMM) ── try: from app.plugins.builtins.contracts import get_contract_registry komm = get_contract_registry().get("kommunikation") if komm: async with factory() as db: # Find or create agent conversation room from app.plugins.builtins.contracts import get_contract as _get_contract _komm_contract = _get_contract("kommunikation") from app.plugins.builtins.kommunikation.models import CommConversation from sqlalchemy import select as sa_select room_title = f"Agent: {agent.name}" existing = await db.execute( sa_select(CommConversation).where( CommConversation.tenant_id == agent.tenant_id, CommConversation.title == room_title, CommConversation.is_locked.is_(True), CommConversation.locked_by == "automation", CommConversation.deleted_at.is_(None), ) ) conv = existing.scalar_one_or_none() if not conv: room = await komm.create_plugin_room( db=db, tenant_id=agent.tenant_id, user_id=agent.created_by, plugin_name="automation", title=room_title, participant_type="agent", ) conv_id = uuid.UUID(room["conversation_id"]) else: conv_id = conv.id # Post result as message with action_card block status = result_data.get("status", "unknown") result_text = result_data.get("llm_response", result_data.get("error", "No result")) await komm.send_message( db=db, tenant_id=agent.tenant_id, conversation_id=conv_id, sender_id=agent.id, sender_type="agent", content=f"Agent '{agent.name}' completed with status: {status}", content_format="text", blocks=[ { "block_type": "action_card", "block_data": { "title": f"Agent Result: {agent.name}", "description": result_text[:500] if result_text else "No result", "actions": [ {"label": "View Details", "action": "view_agent_run", "data": {"run_id": str(run_id)}}, ], }, "sort_order": 0, } ], metadata={"agent_id": str(agent.id), "run_id": str(run_id), "status": status}, ) await db.commit() logger.info("Posted agent result to conversation %s", conv_id) except Exception as e: logger.warning("Failed to post agent result to communication: %s", e) 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)