"""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}