Add agent_incidents dedup, overview/incidents/timeline API, mission board UI with fleet rail, kanban, context panel, mobile tabs, poll and keyboard shortcuts. Co-authored-by: Cursor <cursoragent@cursor.com>
305 lines
9.9 KiB
Python
305 lines
9.9 KiB
Python
"""Agentic API — Spec 029."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app import auth
|
|
from app.agents import llm_client, runner, store
|
|
from app.agents import messages as agent_messages
|
|
from app.agents.catalog import roster_public
|
|
|
|
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
|
|
|
|
|
def _db():
|
|
conn = auth.db()
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _ops_view(user):
|
|
if user.role not in (
|
|
"super_admin",
|
|
"ops_lead",
|
|
"technician",
|
|
"noc",
|
|
"agentic_operator",
|
|
"developer",
|
|
"devops",
|
|
"security_analyst",
|
|
):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
question: str = Field(..., min_length=2, max_length=4000)
|
|
include_findings: bool = True
|
|
target_agent: str = Field(default="A6", description="Agente destino — default Copiloto")
|
|
|
|
|
|
class ReplyRequest(BaseModel):
|
|
body: str = Field(..., min_length=1, max_length=8000)
|
|
target_agent: str | None = None
|
|
|
|
|
|
@router.get("/health")
|
|
def agents_health():
|
|
return {
|
|
"status": "ok",
|
|
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
|
|
"ollama": llm_client.ollama_available(),
|
|
"ollama_url": llm_client.OLLAMA_BASE_URL,
|
|
"model": llm_client.AGENTIC_LLM_MODEL,
|
|
"embed_model": llm_client.AGENTIC_EMBED_MODEL,
|
|
}
|
|
|
|
|
|
@router.get("/overview")
|
|
def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
return store.get_overview(conn)
|
|
|
|
|
|
@router.get("/incidents")
|
|
def agents_incidents(
|
|
user=Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
status: str = Query("open"),
|
|
severity: str | None = None,
|
|
agent_id: str | None = None,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
):
|
|
_ops_view(user)
|
|
return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)}
|
|
|
|
|
|
@router.get("/incidents/{incident_id}")
|
|
def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
inc = store.get_incident(conn, incident_id)
|
|
if not inc:
|
|
raise HTTPException(404, "incident not found")
|
|
return {
|
|
"incident": inc,
|
|
"recent_runs": store.recent_runs_for_scenario(conn, inc["scenario_id"]),
|
|
"thread_id": inc.get("thread_id"),
|
|
}
|
|
|
|
|
|
@router.post("/incidents/{incident_id}/ack")
|
|
def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
inc = store.ack_incident(conn, incident_id, user.username)
|
|
if not inc:
|
|
raise HTTPException(404, "incident not found")
|
|
store.log_event(conn, event_type="incident.ack", message=f"#{incident_id}", payload={"by": user.username})
|
|
conn.commit()
|
|
return {"ok": True, "incident_id": incident_id, "thread_id": inc.get("thread_id")}
|
|
|
|
|
|
@router.get("/timeline")
|
|
def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)):
|
|
_ops_view(user)
|
|
ticks = [
|
|
dict(r)
|
|
for r in conn.execute(
|
|
"""SELECT ts, message, payload_json FROM agent_action_log
|
|
WHERE event_type='tick.complete' ORDER BY id DESC LIMIT ?""",
|
|
(limit,),
|
|
)
|
|
]
|
|
out = []
|
|
for t in ticks:
|
|
payload = {}
|
|
try:
|
|
payload = json.loads(t.get("payload_json") or "{}")
|
|
except json.JSONDecodeError:
|
|
pass
|
|
runs = payload.get("runs") or []
|
|
findings = sum(r.get("findings_count", 0) for r in runs if isinstance(r, dict))
|
|
out.append({"at": t["ts"], "scenarios": payload.get("total", len(runs)), "findings": findings})
|
|
return {"ticks": out}
|
|
|
|
|
|
@router.get("/roster")
|
|
def agents_roster(user=Depends(auth.get_current_user)):
|
|
_ops_view(user)
|
|
return {"agents": roster_public()}
|
|
|
|
|
|
@router.get("/inbox")
|
|
def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)):
|
|
_ops_view(user)
|
|
return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)}
|
|
|
|
|
|
@router.get("/threads")
|
|
def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)):
|
|
_ops_view(user)
|
|
return {"threads": agent_messages.list_threads(conn, limit=limit)}
|
|
|
|
|
|
@router.get("/threads/{thread_id}/messages")
|
|
def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
|
|
raise HTTPException(404, "thread not found")
|
|
return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)}
|
|
|
|
|
|
@router.post("/threads/{thread_id}/reply")
|
|
def thread_reply(
|
|
thread_id: int,
|
|
body: ReplyRequest,
|
|
user=Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
_ops_view(user)
|
|
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
|
|
raise HTTPException(404, "thread not found")
|
|
mid = agent_messages.human_reply(
|
|
conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=body.target_agent
|
|
)
|
|
store.log_event(
|
|
conn,
|
|
event_type="human.reply",
|
|
message=body.body[:120],
|
|
agent_id=body.target_agent or "A6",
|
|
payload={"thread_id": thread_id, "user": user.username},
|
|
)
|
|
conn.commit()
|
|
return {"ok": True, "message_id": mid}
|
|
|
|
|
|
@router.post("/messages/{message_id}/ack")
|
|
def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
if not agent_messages.ack_message(conn, message_id, user.username):
|
|
raise HTTPException(404, "not found")
|
|
conn.commit()
|
|
return {"ok": True, "id": message_id}
|
|
|
|
|
|
@router.get("/scenarios")
|
|
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
runner.sync_registry(conn)
|
|
conn.commit()
|
|
return {"scenarios": store.list_scenarios(conn)}
|
|
|
|
|
|
@router.get("/findings")
|
|
def list_findings(
|
|
user=Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
severity: str | None = None,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
open_only: bool = True,
|
|
):
|
|
_ops_view(user)
|
|
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
|
|
|
|
|
|
@router.post("/findings/{finding_id}/ack")
|
|
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
_ops_view(user)
|
|
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
|
|
raise HTTPException(404, "not found")
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
conn.execute(
|
|
"UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?",
|
|
(now, user.username, finding_id),
|
|
)
|
|
store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username})
|
|
conn.commit()
|
|
return {"ok": True, "id": finding_id}
|
|
|
|
|
|
@router.get("/action-log")
|
|
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)):
|
|
_ops_view(user)
|
|
return {"events": store.list_action_log(conn, limit=limit)}
|
|
|
|
|
|
@router.post("/runs/{scenario_id}")
|
|
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
if user.role not in ("super_admin", "ops_lead", "agentic_operator"):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
|
|
conn.commit()
|
|
return r
|
|
|
|
|
|
@router.post("/chat")
|
|
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
"""Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
|
|
_ops_view(user)
|
|
kb = store.search_kb(conn, body.question)
|
|
findings_summary = ""
|
|
if body.include_findings:
|
|
open_f = store.list_findings(conn, limit=8, open_only=True)
|
|
if open_f:
|
|
findings_summary = "\n".join(
|
|
f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f
|
|
)
|
|
answer, model = llm_client.chat_context(
|
|
question=body.question,
|
|
kb_snippets=[k["snippet"] for k in kb],
|
|
findings_summary=findings_summary,
|
|
user_role=user.role,
|
|
)
|
|
thread_id = agent_messages.create_thread(
|
|
conn,
|
|
subject=f"Chat: {body.question[:60]}",
|
|
primary_agent=body.target_agent,
|
|
severity="info",
|
|
)
|
|
agent_messages.post_message(
|
|
conn,
|
|
thread_id=thread_id,
|
|
from_type="human",
|
|
from_id=user.username,
|
|
to_type="agent",
|
|
to_id=body.target_agent,
|
|
body=body.question,
|
|
)
|
|
agent_messages.post_message(
|
|
conn,
|
|
thread_id=thread_id,
|
|
from_type="agent",
|
|
from_id=body.target_agent,
|
|
to_type="human",
|
|
to_id=user.username,
|
|
body=answer,
|
|
context={"model": model, "kb_hits": len(kb)},
|
|
)
|
|
store.log_event(
|
|
conn,
|
|
event_type="chat.query",
|
|
message=body.question[:120],
|
|
agent_id=body.target_agent,
|
|
payload={"user": user.username, "model": model, "thread_id": thread_id},
|
|
)
|
|
conn.commit()
|
|
return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id}
|
|
|
|
|
|
@router.post("/internal/tick")
|
|
def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)):
|
|
kb = runner.index_specs_kb(conn)
|
|
result = runner.run_all_enabled(conn, trigger="cron")
|
|
store.log_event(
|
|
conn,
|
|
event_type="tick.complete",
|
|
message=f"kb={kb} runs={result['total']}",
|
|
agent_id="A0",
|
|
payload={"kb": kb, **result},
|
|
)
|
|
conn.commit()
|
|
return {"ok": True, "kb_indexed": kb, **result}
|