feat(F-LOOP): true ReAct loop with structured Thought/Action/Observation step tracking
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-17 16:11:26 +02:00
parent da9be1e2f2
commit c760b5961c
6 changed files with 1100 additions and 133 deletions
+139 -132
View File
@@ -5,25 +5,22 @@ Safety features:
- 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 asyncio
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from app.ai.llm_client import llm_complete
from app.ai.agent_loop import ReActResult, run_react_loop
from app.core.db import get_session_factory
logger = logging.getLogger(__name__)
# Track consecutive tool calls per agent run to detect infinite loops
_consecutive_tool_calls: dict[str, dict[str, int]] = {} # run_id -> {tool_name: count}
async def run_agent(
ctx: dict[str, Any],
@@ -32,16 +29,20 @@ async def run_agent(
trigger_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ARQ job function. Loads AgentDefinition from DB, checks rate limits,
gathers context, calls LLM via LiteLLM, executes tool calls via ToolRegistry,
saves result to AgentRun. Returns result dict.
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
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
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()
@@ -61,10 +62,6 @@ async def run_agent(
return {"error": "Agent is inactive", "status": "skipped"}
# ── Safety Check 1: Rate Limit ──
# Uses DB-based counting (AgentRun rows in last hour) rather than
# check_rate_limit() because this counts actual executions per agent,
# not just attempts. This is more accurate for per-agent execution caps
# and respects the agent-specific max_executions_per_hour setting.
if agent.max_executions_per_hour:
async with factory() as db:
one_hour_ago = datetime.now(UTC)
@@ -102,7 +99,6 @@ async def run_agent(
# Gather context
context_data: dict[str, Any] = {}
if trigger_type == "proactive" or agent.mode == "proactive":
# Collect context data (recent contacts, mails, events)
try:
from app.services.contact_service import list_contacts
async with factory() as db:
@@ -114,16 +110,17 @@ async def run_agent(
try:
from app.plugins.builtins.mail.models import Mail
from sqlalchemy import select as _select
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()
]
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")
@@ -135,7 +132,6 @@ async def run_agent(
except Exception:
logger.warning("Failed to collect events for proactive context")
else:
# Reactive mode: use trigger_data as context
context_data = trigger_data or {}
# ── Lifecycle: before run ──
@@ -153,145 +149,156 @@ async def run_agent(
)
await db.commit()
# Call LLM via LiteLLM
# ── 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,
}
# ── Safety Check 3: Max Duration ──
max_duration = agent.max_duration_seconds or 300
try:
async def _run_llm() -> None:
"""Inner coroutine for LLM call with tool execution."""
# Build system prompt from agent configuration
system_prompt = agent.system_prompt or "You are a helpful AI assistant."
user_prompt = f"Context: {context_data}"
import asyncio
import uuid as uuid_mod
litellm_model = agent.model or "gpt-4o"
if agent.provider and agent.provider != "openai":
litellm_model = f"{agent.provider}/{litellm_model}"
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 = await llm_complete(
model=litellm_model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
max_tokens=agent.max_tokens or 1000,
api_key=agent.api_key or None,
api_base=agent.api_base or None,
)
content = result["content"]
# Track cost
result_data["cost_usd"] = result["cost_usd"]
result_data["llm_response"] = content
# Execute tool calls if LLM returned function calls
raw_response = result["raw_response"]
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
tool_call_count: dict[str, int] = {}
for tc in raw_response.choices[0].message.tool_calls:
tool_name = tc.function.name
# ── Safety Check 4: Infinite Loop Detection ──
tool_call_count[tool_name] = tool_call_count.get(tool_name, 0) + 1
if tool_call_count[tool_name] >= 5:
logger.warning(
"Infinite loop detected for agent %s: tool '%s' called %d times consecutively",
agent.id, tool_name, tool_call_count[tool_name],
)
result_data["error"] = f"Infinite loop detected: tool '{tool_name}' called 5+ times consecutively"
result_data["status"] = "failed"
return
tool = registry.get(tool_name)
if tool:
try:
import json
args = json.loads(tc.function.arguments)
tool_result = await tool.handler(
arguments=args,
context={"tenant_id": str(agent.tenant_id)},
)
result_data["tool_calls"].append({
"tool": tool_name,
"arguments": args,
"result": tool_result,
})
except Exception as e:
result_data["tool_calls"].append({
"tool": tool_name,
"error": str(e),
})
result_data["status"] = "completed"
# Run with timeout
try:
await asyncio.wait_for(_run_llm(), timeout=max_duration)
except TimeoutError:
logger.warning(
"Agent %s execution timed out after %d seconds",
agent.id, max_duration,
)
result_data["status"] = "timed_out"
result_data["error"] = f"Execution timed out after {max_duration} seconds"
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"] = "failed"
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 ──
from app.core.hooks import do_action
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)
from app.core.outbox import enqueue_outbox_event
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)},
{
'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()
# Save result to AgentRun
try:
async with factory() as db:
run = AgentRun(
tenant_id=agent.tenant_id,
agent_id=agent.id,
trigger_type=trigger_type,
status=result_data["status"],
input_data=context_data,
output_data=result_data.get("llm_response"),
tool_calls=result_data.get("tool_calls", []),
cost_usd=result_data.get("cost_usd", 0.0),
error_message=result_data.get("error"),
duration_seconds=None,
)
db.add(run)
await db.flush()
result_data["run_id"] = str(run.id)
except Exception as e:
logger.exception("Failed to save AgentRun for %s", agent.id)
result_data["save_error"] = str(e)
return result_data
+29
View File
@@ -16,6 +16,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
@@ -254,6 +255,34 @@ class AutomationRun(Base, TenantMixin):
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class AgentRunStep(Base, TenantMixin):
"""Individual step in a ReAct loop execution (Thought → Action → Observation)."""
__tablename__ = "automation_agent_run_steps"
__table_args__ = (
Index("ix_agent_run_steps_run", "tenant_id", "agent_run_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
agent_run_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("automation_agent_runs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
step_number: Mapped[int] = mapped_column(Integer, nullable=False)
thought: Mapped[str | None] = mapped_column(Text, nullable=True)
action: Mapped[str | None] = mapped_column(String(255), nullable=True)
action_input: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
observation: Mapped[str | None] = mapped_column(Text, nullable=True)
cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class AgentSubtask(Base, TenantMixin):
"""A subtask delegated from one agent to another for multi-agent orchestration."""