feat(F-LOOP): true ReAct loop with structured Thought/Action/Observation step tracking
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user