SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
"""Agents API — souls & activity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import fetch_all
|
||||
from app.services import agent_approvals, agent_souls
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_approvals, agent_integration, agent_souls
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
from app.services.agent_terminal import execute_terminal_command
|
||||
|
||||
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
|
||||
|
||||
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class HandoffBody(BaseModel):
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
handoff_type: str = "partner"
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
|
||||
class TerminalCommandBody(BaseModel):
|
||||
command: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
|
||||
)
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None and created is not None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
return by_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
@router.get("/collaboration")
|
||||
def api_collaboration() -> dict[str, Any]:
|
||||
items = agent_integration.list_collaboration()
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/handoff")
|
||||
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
|
||||
try:
|
||||
handoff = agent_integration.create_handoff(
|
||||
body.from_agent,
|
||||
body.to_agent,
|
||||
handoff_type=body.handoff_type,
|
||||
payload=body.payload,
|
||||
correlation_id=body.correlation_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"ok": True, "handoff": handoff}
|
||||
|
||||
|
||||
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
meta = ev.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
ev["metadata"] = meta or {}
|
||||
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
|
||||
if str(ev.get("event_type") or "").startswith("terminal_"):
|
||||
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
|
||||
if ev["metadata"].get("target_agent"):
|
||||
line_type = "handoff_out"
|
||||
if ev["metadata"].get("source_agent"):
|
||||
line_type = "handoff_in"
|
||||
if str(ev.get("status") or "") in ("error", "rejected"):
|
||||
line_type = "error"
|
||||
return {
|
||||
"type": line_type,
|
||||
"id": ev.get("id"),
|
||||
"agent": normalize_agent_key(ev.get("agent_name")),
|
||||
"message": ev.get("title") or ev.get("event_type"),
|
||||
"detail": (ev.get("body") or "")[:500],
|
||||
"status": ev.get("status"),
|
||||
"correlation_id": ev["metadata"].get("correlation_id"),
|
||||
"at": ev.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/souls/{agent_key}/terminal/stream")
|
||||
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
hist = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
for row in reversed(hist):
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
try:
|
||||
if correlation_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE metadata->>'correlation_id' = %s
|
||||
AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(correlation_id, last_id),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(key, last_id),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/live/stream")
|
||||
async def api_agents_live_stream():
|
||||
"""Single realtime stream for all agent terminals."""
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
recent = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 minutes'
|
||||
ORDER BY id ASC
|
||||
LIMIT 80
|
||||
"""
|
||||
)
|
||||
for row in recent:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
payload["replay"] = True
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 100
|
||||
""",
|
||||
(last_id,),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/souls")
|
||||
def api_list_souls() -> dict[str, Any]:
|
||||
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
|
||||
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
|
||||
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/souls/{agent_key}/terminal/command")
|
||||
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
|
||||
try:
|
||||
return await execute_terminal_command(agent_key, body.command.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/souls/{agent_key}")
|
||||
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
@router.get("/mesh")
|
||||
def api_agents_mesh() -> dict[str, Any]:
|
||||
souls = agent_souls.list_souls()
|
||||
stats_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS agent_key,
|
||||
MAX(created_at) AS last_event_at,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
|
||||
COUNT(*) FILTER (
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND status IN ('error', 'rejected')
|
||||
) AS errors_24h
|
||||
FROM agent_events
|
||||
GROUP BY LOWER(agent_name)
|
||||
"""
|
||||
)
|
||||
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
|
||||
by_key = _aggregate_agent_stats()
|
||||
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for soul in souls:
|
||||
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
|
||||
row = by_key.get(key, {})
|
||||
events_6h = int(row.get("events_6h") or 0)
|
||||
errors_24h = int(row.get("errors_24h") or 0)
|
||||
health = "offline"
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
health = "healthy"
|
||||
elif events_6h > 0:
|
||||
health = "warn"
|
||||
elif int(soul.get("event_count") or 0) > 0:
|
||||
health = "idle"
|
||||
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
|
||||
is_active = health == "healthy" and events_6h > 0
|
||||
node = dict(soul)
|
||||
node["health"] = health
|
||||
node["events_6h"] = events_6h
|
||||
node["errors_24h"] = errors_24h
|
||||
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
|
||||
node["last_event_at"] = row["last_event_at"].isoformat()
|
||||
node["is_active"] = is_active
|
||||
node["last_event_title"] = row.get("last_event_title")
|
||||
lat = row.get("last_event_at")
|
||||
if lat is not None and hasattr(lat, "isoformat"):
|
||||
node["last_event_at"] = lat.isoformat()
|
||||
nodes.append(node)
|
||||
|
||||
edge_rows = fetch_all(
|
||||
report_edges: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
key = str(node.get("agent_key") or "").lower()
|
||||
if key == "herman" or not node.get("is_active"):
|
||||
continue
|
||||
report_edges.append(
|
||||
{
|
||||
"source": key,
|
||||
"target": "herman",
|
||||
"type": "report",
|
||||
"active": True,
|
||||
"weight": int(node.get("events_6h") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
delegate_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
|
||||
SELECT metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND LOWER(agent_name) <> 'herman'
|
||||
GROUP BY LOWER(agent_name)
|
||||
ORDER BY weight DESC
|
||||
WHERE agent_name = 'herman'
|
||||
AND created_at >= NOW() - INTERVAL '6 hours'
|
||||
AND (
|
||||
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
|
||||
OR metadata ? 'delegated'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
|
||||
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
|
||||
delegate_edges: list[dict[str, Any]] = []
|
||||
seen_delegate: set[tuple[str, str]] = set()
|
||||
for r in delegate_rows:
|
||||
meta = r.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
|
||||
if isinstance(delegated, str):
|
||||
delegated = [delegated]
|
||||
channel = meta.get("channel") or "herman"
|
||||
for agent in delegated:
|
||||
tgt = normalize_agent_key(str(agent))
|
||||
if not tgt or tgt == "herman":
|
||||
continue
|
||||
pair = ("herman", tgt)
|
||||
if pair in seen_delegate:
|
||||
continue
|
||||
seen_delegate.add(pair)
|
||||
delegate_edges.append(
|
||||
{
|
||||
"source": "herman",
|
||||
"target": tgt,
|
||||
"type": "delegate",
|
||||
"active": True,
|
||||
"channel": channel,
|
||||
}
|
||||
)
|
||||
|
||||
peer_static = [
|
||||
{
|
||||
"source": str(r["from_agent"]),
|
||||
"target": str(r["to_agent"]),
|
||||
"type": "static",
|
||||
"handoff_type": r.get("handoff_type"),
|
||||
}
|
||||
for r in agent_integration.list_collaboration()
|
||||
]
|
||||
peer_live = agent_integration.peer_edges_live(hours=6)
|
||||
peer_edges = peer_static + peer_live
|
||||
|
||||
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
|
||||
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
|
||||
executive_edges = []
|
||||
if herman_active:
|
||||
executive_edges = [
|
||||
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
|
||||
{"source": "herman", "target": "cto", "type": "executive", "active": True},
|
||||
]
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"report_edges": report_edges,
|
||||
"delegate_edges": delegate_edges,
|
||||
"peer_edges": peer_edges,
|
||||
"executive_edges": executive_edges,
|
||||
"edges": report_edges,
|
||||
"executives": [
|
||||
{"id": "ceo", "label": "CEO", "role": "Aissa"},
|
||||
{"id": "cto", "label": "CTO", "role": "Platform"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
|
||||
auto = None
|
||||
rid = (req or {}).get("id")
|
||||
if rid:
|
||||
try:
|
||||
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
|
||||
except Exception:
|
||||
auto = None
|
||||
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
|
||||
if auto:
|
||||
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
|
||||
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/approve")
|
||||
|
||||
Reference in New Issue
Block a user