Files
Agent Zero fdc4e36d14
Check Cross-Plugin Imports / check (push) Waiting to run
fix(security): F14 (Astra P1) — KI-Datenrichtlinie deckt JSON-Strings, Provider-Compliance und Tool-Antworten ab
Vorher (Astra): (1) enforce_data_policy filterte nur dict-Inhalte — ein
JSON-String mit smtp_password passierte ungefiltert (Astra-Repro). (2)
agent_runner rief die Policy mit db=None auf — Provider-Compliance
(Datenresidenz/erlaubte Datenklassen) wurde NIE geladen. (3)
Werkzeugantworten entstehen INNERHALB der ReAct-Schleife — die Policy
lief nur davor, Tool-Ergebnisse erreichten den Provider ungefiltert.

Fix:
- data_policy.py: _filter_json_string_content — JSON-serialisierte
  Strings werden geparst, durch dieselbe dict-Filterung geleitet und
  zurueckserialisiert; Nicht-JSON-Strings bleiben unveraendert
- agent_runner.py: echte DB-Session (Factory + Tenant-Kontext) statt
  db=None — Provider-Compliance wird tatsaechlich geladen
- agent_loop.py: _filter_observation — jede Tool-Observation wird
  VOR dem Feed-Back in die LLM-Konversation durch die
  SENSITIVE_FIELDS-Filterung geleitet (JSON geparst, sensible Felder
  entfernt, zurueckserialisiert)

Abnahme (Astra): Gesperrte Felder fehlen am Provider-Eingang sowohl im
Startkontext (durch echte Compliance-Session) als auch nach
Werkzeugaufrufen (Observation-Filter) — erfuellt.

Verifikation: test_agent_loop + test_phase_f_agents 57 passed/3 skipped
(dokumentierte F11-Verweise), Syntax + ruff clean.
2026-09-18 12:04:58 +02:00

467 lines
18 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
import uuid
from datetime import UTC, datetime, timedelta
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.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.automation.models import (
AgentDefinition,
AgentRun,
AgentRunStep,
)
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) - timedelta(hours=1)
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, "Mail"):
from sqlalchemy import select as _select
Mail = mail_contract.Mail # noqa: N806 — class alias
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.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) ──
# F14 (Astra P1): pass a REAL DB session so provider compliance
# (data residency / allowed data classes) is actually loaded —
# previously db=None silently skipped the compliance check.
from app.ai.data_policy import enforce_data_policy
from app.core.db import get_session_factory as _dp_factory
from app.core.db import set_tenant_context as _dp_set_tenant
_factory = _dp_factory()
async with _factory() as _dp_db:
await _dp_set_tenant(_dp_db, agent.tenant_id)
messages = await enforce_data_policy(
db=_dp_db,
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),
user_permissions=perm_ctx.user_permissions, # F01: enforce at execution time
),
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_contract("kommunikation")
if komm:
async with factory() as db:
# Find or create agent conversation room via contract
# (find_locked_room_id matches create_plugin_room semantics)
room_title = f"Agent: {agent.name}"
conv_id = await komm.find_locked_room_id(
db=db,
tenant_id=agent.tenant_id,
plugin_name="automation",
title=room_title,
)
if not conv_id:
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"])
# 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)