dbeadd8ab1
Check Cross-Plugin Imports / check (push) Has been cancelled
- F-CTX: app/ai/context_builder.py (282 lines) — build_agent_context() + ReActSystemPromptBuilder - F-STR: app/ai/agent_stream.py (155 lines) — stream_react_loop() with SSE events (step, status, done, error) - F-DEF: AgentDefinition fields added (temperature, max_tokens, max_steps, trace_mode, skill_ids, trigger_config, ai_use_case_metadata) + migration 0122 - F-SKILL: app/ai/skill_registry.py (82 lines) — SkillDefinition + SkillRegistry singleton - F-TOOL: app/ai/agent_tools.py (117 lines) — get_agent_tools() with permission intersection - Skill CRUD routes: app/plugins/builtins/automation/skill_routes.py - Tests: test_skill_registry.py (97 lines), test_agent_tools.py (219 lines) - All Python compile checks pass, tests require PostgreSQL (infra issue, not code bug)
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""SSE streaming for the ReAct agent loop.
|
|
|
|
Wraps ``run_react_loop`` from ``app.ai.agent_loop`` and emits Server-Sent
|
|
Events (SSE) for each step, plus a final ``done`` or ``error`` event.
|
|
|
|
Events emitted:
|
|
- ``event: step`` — JSON {step_number, thought, action, action_input, observation, cost_usd}
|
|
- ``event: status`` — JSON {status: "running", step: N}
|
|
- ``event: done`` — JSON {status, total_cost, steps_taken, final_content}
|
|
- ``event: error`` — JSON {error, trace_id}
|
|
|
|
Trace modes:
|
|
- ``standard`` — step events include action + result only (no thought)
|
|
- ``extended`` — step events also include the thought/reasoning
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
|
|
|
from app.ai.agent_loop import ReActStep, run_react_loop
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# SSE helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _sse(event: str, data: dict[str, Any]) -> str:
|
|
"""Format a single SSE event as ``event: <name>\ndata: <json>\n\n``."""
|
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
def _step_event(step: ReActStep, trace_mode: str) -> str:
|
|
"""Build the SSE ``step`` event for a ReAct step."""
|
|
data: dict[str, Any] = {
|
|
"step_number": step.step_number,
|
|
"action": step.action,
|
|
"action_input": step.action_input,
|
|
"observation": step.observation,
|
|
"cost_usd": step.cost_usd,
|
|
}
|
|
if trace_mode == "extended":
|
|
data["thought"] = step.thought
|
|
return _sse("step", data)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Streaming loop
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def stream_react_loop(
|
|
agent_definition: Any,
|
|
user_message: str,
|
|
tools: list[dict],
|
|
tool_registry: Any,
|
|
db: AsyncSession | None,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
agent_run_id: uuid.UUID | None = None,
|
|
max_steps: int = 20,
|
|
timeout_seconds: int = 300,
|
|
trace_id: str | None = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Run the ReAct loop and yield SSE-formatted events.
|
|
|
|
Args:
|
|
agent_definition: AgentDefinition with llm_model, system_prompt, etc.
|
|
user_message: The user's message to the agent.
|
|
tools: OpenAI-format tool schemas for function calling.
|
|
tool_registry: ToolRegistry instance for tool execution.
|
|
db: Async DB session.
|
|
tenant_id: Tenant ID for multi-tenancy.
|
|
user_id: User ID for permission context.
|
|
agent_run_id: Optional AgentRun ID for step persistence.
|
|
max_steps: Maximum loop iterations (default 20).
|
|
timeout_seconds: Overall timeout (default 300).
|
|
trace_id: Optional trace ID for correlation.
|
|
|
|
Yields:
|
|
SSE-formatted event strings.
|
|
"""
|
|
trace_mode = getattr(agent_definition, "trace_mode", "standard") or "standard"
|
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
|
|
async def on_step(step: ReActStep) -> None:
|
|
"""Push step + status events into the queue."""
|
|
await queue.put(_step_event(step, trace_mode))
|
|
await queue.put(
|
|
_sse("status", {"status": "running", "step": step.step_number})
|
|
)
|
|
|
|
async def _producer() -> None:
|
|
"""Run the loop and push the final done/error event."""
|
|
try:
|
|
result = await run_react_loop(
|
|
agent_definition=agent_definition,
|
|
messages=[{"role": "user", "content": user_message}],
|
|
tools=tools,
|
|
tool_registry=tool_registry,
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
agent_run_id=agent_run_id,
|
|
max_steps=max_steps,
|
|
timeout_seconds=timeout_seconds,
|
|
trace_id=trace_id,
|
|
on_step=on_step,
|
|
)
|
|
await queue.put(
|
|
_sse(
|
|
"done",
|
|
{
|
|
"status": result.status,
|
|
"total_cost": result.total_cost_usd,
|
|
"steps_taken": result.steps_taken,
|
|
"final_content": result.final_content,
|
|
},
|
|
)
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — stream must not crash the consumer
|
|
logger.exception("ReAct streaming loop failed")
|
|
await queue.put(
|
|
_sse("error", {"error": str(exc), "trace_id": trace_id})
|
|
)
|
|
finally:
|
|
await queue.put(None) # sentinel
|
|
|
|
producer_task = asyncio.create_task(_producer())
|
|
try:
|
|
while True:
|
|
event = await queue.get()
|
|
if event is None:
|
|
break
|
|
yield event
|
|
finally:
|
|
if not producer_task.done():
|
|
producer_task.cancel()
|
|
try:
|
|
await producer_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
__all__ = ["stream_react_loop"]
|