${esc(a.name)} ${esc(a.id)}
+ +- ${(a.actions || []).slice(0, 3).map(x => `
- ${esc(x)} `).join('')}
diff --git a/projects/ops-desk/api/app/agents/catalog.py b/projects/ops-desk/api/app/agents/catalog.py
new file mode 100644
index 0000000..2f3508d
--- /dev/null
+++ b/projects/ops-desk/api/app/agents/catalog.py
@@ -0,0 +1,208 @@
+"""Catálogo nomeado dos Agentics A0–A7 — Spec 027 + 029."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class AgentProfile:
+ id: str
+ codename: str
+ name: str
+ role: str
+ reads: tuple[str, ...]
+ actions: tuple[str, ...]
+ approval: str
+ scenarios: tuple[str, ...] = ()
+
+
+AGENT_CATALOG: dict[str, AgentProfile] = {
+ "A0": AgentProfile(
+ id="A0",
+ codename="orchestrator",
+ name="Maestro",
+ role="Orquestrador multi-agente",
+ reads=("todos os feeds", "action_log", "findings abertos", "threads activas"),
+ actions=(
+ "Delegar cenários aos agentes especializados",
+ "Sintetizar estado global do ambiente Ligbox",
+ "Abrir thread de coordenação entre agentes",
+ "Escalar para humano quando confiança < limiar",
+ ),
+ approval="agentic_operator / ops_lead",
+ scenarios=(),
+ ),
+ "A1": AgentProfile(
+ id="A1",
+ codename="node_health",
+ name="Pulso",
+ role="Saúde de nós e serviços Carbonio",
+ reads=("métricas VM112", "CPU/RAM Proxmox", "status containers"),
+ actions=(
+ "Detectar serviço Carbonio/wizard down",
+ "Criar finding + alerta ticket",
+ "Sugerir restart (info auto; restart com ops_lead)",
+ ),
+ approval="auto (info) · ops_lead (restart)",
+ scenarios=("wizard.vm112.bundle", "proxmox.cluster"),
+ ),
+ "A2": AgentProfile(
+ id="A2",
+ codename="infra_mail",
+ name="Trilho",
+ role="DNS, certificados, Traefik, nginx",
+ reads=("DNS Cloudflare", "certs LE", "Traefik CT114", "SNI"),
+ actions=(
+ "Validar propagação DNS pós-onboard",
+ "Detectar cert expirado ou mismatch SNI",
+ "Propor fix DNS/Traefik (nunca aplicar sem devops/ops_lead)",
+ ),
+ approval="devops ou ops_lead antes de aplicar",
+ scenarios=("pfsense.api.system",),
+ ),
+ "A3": AgentProfile(
+ id="A3",
+ codename="deliverability",
+ name="Carta",
+ role="SPF, DKIM, DMARC, reputação mail",
+ reads=("registos DNS mail", "relatórios entregabilidade"),
+ actions=(
+ "Auditar SPF/DKIM/DMARC por domínio tenant",
+ "Gerar relatório de entregabilidade",
+ "Abrir finding para seo/technician revisão",
+ ),
+ approval="seo / technician revisão",
+ scenarios=(),
+ ),
+ "A4": AgentProfile(
+ id="A4",
+ codename="security_mail",
+ name="Escudo Mail",
+ role="amavis, spam, clamav, filas mail",
+ reads=("filas mail", "logs amavis/clamav", "quarentena"),
+ actions=(
+ "Detectar pico spam ou fila bloqueada",
+ "Sugerir quarentena / release",
+ "Correlacionar com alertas segurança VM112",
+ ),
+ approval="security_analyst",
+ scenarios=(),
+ ),
+ "A5": AgentProfile(
+ id="A5",
+ codename="wazuh_soc",
+ name="Sentinela SOC",
+ role="Correlação SIEM Wazuh VM104",
+ reads=("alertas Wazuh", "webhooks vm104", "tickets correlacionados"),
+ actions=(
+ "Correlacionar alerta L≥10 com domínio/sessão",
+ "Enriquecer timeline do chamado",
+ "Propor runbook R0/R1 segurança",
+ ),
+ approval="security_analyst / noc",
+ scenarios=(),
+ ),
+ "A6": AgentProfile(
+ id="A6",
+ codename="support_copilot",
+ name="Copiloto",
+ role="Assistência tickets e janela humana",
+ reads=("tickets Desk", "timeline onboarding", "findings", "KB specs"),
+ actions=(
+ "Rascunhar resposta ao cliente/ticket",
+ "Responder janela /chat do operador humano",
+ "Resumir contexto para technician enviar",
+ ),
+ approval="technician envia · agentic_operator vê tudo",
+ scenarios=("funnel.stuck.onboarding",),
+ ),
+ "A7": AgentProfile(
+ id="A7",
+ codename="remediation",
+ name="Remediador",
+ role="Runbooks aprovados pós-incidente",
+ reads=("playbooks aprovados", "findings critical/high", "aprovações pendentes"),
+ actions=(
+ "Propor runbook com confiança %",
+ "Executar R0 auto (poll/refresh)",
+ "Executar R1+ apenas após OK humano",
+ "Registar action_executed na timeline",
+ ),
+ approval="agentic_operator obrigatório (R2/R3 dupla)",
+ scenarios=(),
+ ),
+ "sentinel": AgentProfile(
+ id="sentinel",
+ codename="sentinel",
+ name="Vigia",
+ role="Health checks T0 — APIs e infra",
+ reads=("endpoints HTTP", "integrações health", "Ollama", "VM123 stack"),
+ actions=(
+ "Executar cenários desk/wizard/pfsense/proxmox/ollama/VM123",
+ "Criar findings com severidade",
+ "Disparar e-mail em high/critical",
+ ),
+ approval="automático (detecção) · humano trata finding",
+ scenarios=(
+ "desk.api.health",
+ "wizard.vm112.bundle",
+ "pfsense.api.system",
+ "integration.webhook.gap",
+ "proxmox.cluster",
+ "ollama.vm123.health",
+ "vm123.finance.stack",
+ "vm123.openpanel.bridge",
+ ),
+ ),
+ "curator": AgentProfile(
+ id="curator",
+ codename="curator",
+ name="Curador",
+ role="Base de conhecimento (RAG)",
+ reads=("specs/**/*.md", "agent_kb_chunks"),
+ actions=("Indexar specs no SQLite", "Fornecer snippets ao Copiloto/Advisor"),
+ approval="automático",
+ scenarios=(),
+ ),
+}
+
+# Map legacy agent_id in scenarios → A* principal
+SCENARIO_AGENT_MAP = {
+ "desk.api.health": "sentinel",
+ "wizard.vm112.bundle": "A1",
+ "pfsense.api.system": "A2",
+ "funnel.stuck.onboarding": "A6",
+ "integration.webhook.gap": "sentinel",
+ "proxmox.cluster": "A1",
+ "ollama.vm123.health": "sentinel",
+ "vm123.finance.stack": "sentinel",
+ "vm123.openpanel.bridge": "sentinel",
+}
+
+
+def resolve_agent(scenario_id: str, agent_id: str | None = None) -> AgentProfile:
+ key = SCENARIO_AGENT_MAP.get(scenario_id) or agent_id or "sentinel"
+ return AGENT_CATALOG.get(key, AGENT_CATALOG["sentinel"])
+
+
+def roster_public() -> list[dict]:
+ order = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "sentinel", "curator"]
+ out = []
+ for k in order:
+ if k not in AGENT_CATALOG:
+ continue
+ p = AGENT_CATALOG[k]
+ out.append(
+ {
+ "id": p.id,
+ "codename": p.codename,
+ "name": p.name,
+ "role": p.role,
+ "reads": list(p.reads),
+ "actions": list(p.actions),
+ "approval": p.approval,
+ "scenarios": list(p.scenarios),
+ }
+ )
+ return out
diff --git a/projects/ops-desk/api/app/agents/messages.py b/projects/ops-desk/api/app/agents/messages.py
new file mode 100644
index 0000000..cc01859
--- /dev/null
+++ b/projects/ops-desk/api/app/agents/messages.py
@@ -0,0 +1,289 @@
+"""Bus de mensagens agente↔agente↔humano — Spec 029."""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from datetime import datetime, timezone
+from typing import Any
+
+from app.agents.catalog import AGENT_CATALOG, resolve_agent
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def init_messages_schema(conn: sqlite3.Connection) -> None:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS agent_threads (
+ id INTEGER PRIMARY KEY,
+ subject TEXT NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'info',
+ status TEXT NOT NULL DEFAULT 'open',
+ primary_agent TEXT NOT NULL,
+ related_finding_id INTEGER,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS agent_messages (
+ id INTEGER PRIMARY KEY,
+ thread_id INTEGER NOT NULL,
+ from_type TEXT NOT NULL,
+ from_id TEXT NOT NULL,
+ to_type TEXT NOT NULL,
+ to_id TEXT NOT NULL,
+ body TEXT NOT NULL,
+ context_json TEXT,
+ requires_human INTEGER NOT NULL DEFAULT 0,
+ human_role_hint TEXT,
+ acknowledged_at TEXT,
+ acknowledged_by TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (thread_id) REFERENCES agent_threads(id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_thread ON agent_messages(thread_id);
+ CREATE INDEX IF NOT EXISTS idx_agent_messages_inbox ON agent_messages(requires_human, acknowledged_at);
+ """)
+
+
+def create_thread(
+ conn: sqlite3.Connection,
+ *,
+ subject: str,
+ primary_agent: str,
+ severity: str = "info",
+ related_finding_id: int | None = None,
+) -> int:
+ now = _now()
+ return int(
+ conn.execute(
+ """INSERT INTO agent_threads (subject, severity, status, primary_agent, related_finding_id, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?)""",
+ (subject, severity, "open", primary_agent, related_finding_id, now, now),
+ ).lastrowid
+ )
+
+
+def post_message(
+ conn: sqlite3.Connection,
+ *,
+ thread_id: int,
+ from_type: str,
+ from_id: str,
+ to_type: str,
+ to_id: str,
+ body: str,
+ context: dict | None = None,
+ requires_human: bool = False,
+ human_role_hint: str | None = None,
+) -> int:
+ now = _now()
+ mid = int(
+ conn.execute(
+ """INSERT INTO agent_messages
+ (thread_id, from_type, from_id, to_type, to_id, body, context_json, requires_human, human_role_hint, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
+ (
+ thread_id,
+ from_type,
+ from_id,
+ to_type,
+ to_id,
+ body,
+ json.dumps(context or {}),
+ 1 if requires_human else 0,
+ human_role_hint,
+ now,
+ ),
+ ).lastrowid
+ )
+ conn.execute("UPDATE agent_threads SET updated_at=? WHERE id=?", (now, thread_id))
+ return mid
+
+
+def notify_finding_to_operators(
+ conn: sqlite3.Connection,
+ *,
+ finding_id: int,
+ scenario_id: str,
+ title: str,
+ severity: str,
+ human_action: str,
+ agent_id: str,
+) -> int:
+ """Abre thread + mensagem para operadores humanos."""
+ profile = resolve_agent(scenario_id, agent_id)
+ role_hint = {
+ "critical": "agentic_operator",
+ "high": "ops_lead",
+ "warn": "technician",
+ }.get(severity, "technician")
+
+ existing = conn.execute(
+ "SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'",
+ (finding_id,),
+ ).fetchone()
+ if existing:
+ thread_id = existing["id"]
+ else:
+ thread_id = create_thread(
+ conn,
+ subject=title,
+ primary_agent=profile.id,
+ severity=severity,
+ related_finding_id=finding_id,
+ )
+
+ agent_name = profile.name
+ body = (
+ f"**{agent_name}** ({profile.id}) detectou: {title}\n\n"
+ f"Acção sugerida: {human_action or 'Investigar manualmente.'}\n\n"
+ f"Cenário: `{scenario_id}` · Severidade: **{severity}**"
+ )
+
+ # Mensagem agente → humanos (inbox operadores)
+ post_message(
+ conn,
+ thread_id=thread_id,
+ from_type="agent",
+ from_id=profile.id,
+ to_type="human",
+ to_id=role_hint,
+ body=body,
+ context={"finding_id": finding_id, "scenario_id": scenario_id, "severity": severity},
+ requires_human=severity in ("high", "critical", "warn"),
+ human_role_hint=role_hint,
+ )
+
+ # Maestro (A0) regista coordenação inter-agente
+ if profile.id not in ("A0", "orchestrator"):
+ post_message(
+ conn,
+ thread_id=thread_id,
+ from_type="agent",
+ from_id="A0",
+ to_type="agent",
+ to_id=profile.id,
+ body=f"Registado finding #{finding_id}. Aguardando acção humana ({role_hint}).",
+ context={"coordination": True},
+ requires_human=False,
+ )
+
+ return thread_id
+
+
+def list_inbox(conn: sqlite3.Connection, *, role: str, limit: int = 50) -> list[dict]:
+ """Mensagens pendentes para operadores humanos."""
+ role_priority = {
+ "super_admin": ("agentic_operator", "ops_lead", "technician", "noc", "devops", "security_analyst"),
+ "agentic_operator": ("agentic_operator", "ops_lead"),
+ "ops_lead": ("ops_lead", "agentic_operator", "technician"),
+ "technician": ("technician",),
+ "security_analyst": ("security_analyst", "agentic_operator"),
+ "devops": ("devops", "ops_lead"),
+ "noc": ("noc",),
+ "developer": ("developer", "ops_lead"),
+ }
+ allowed = role_priority.get(role, (role,))
+
+ q = """
+ SELECT m.*, t.subject AS thread_subject, t.severity AS thread_severity, t.primary_agent
+ FROM agent_messages m
+ JOIN agent_threads t ON t.id = m.thread_id
+ WHERE m.requires_human = 1 AND m.acknowledged_at IS NULL
+ AND m.to_type = 'human'
+ ORDER BY m.id DESC LIMIT ?
+ """
+ rows = [dict(r) for r in conn.execute(q, (limit * 3,))]
+ out = []
+ for r in rows:
+ hint = r.get("human_role_hint") or r.get("to_id") or ""
+ if role == "super_admin" or hint in allowed or role in allowed:
+ r["agent_name"] = AGENT_CATALOG.get(r["from_id"], AGENT_CATALOG.get("sentinel")).name
+ out.append(r)
+ if len(out) >= limit:
+ break
+ return out
+
+
+def list_threads(conn: sqlite3.Connection, *, limit: int = 40) -> list[dict]:
+ rows = conn.execute(
+ "SELECT * FROM agent_threads ORDER BY updated_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ out = []
+ for r in rows:
+ item = dict(r)
+ p = AGENT_CATALOG.get(item["primary_agent"])
+ item["agent_name"] = p.name if p else item["primary_agent"]
+ pending = conn.execute(
+ "SELECT COUNT(*) c FROM agent_messages WHERE thread_id=? AND requires_human=1 AND acknowledged_at IS NULL",
+ (item["id"],),
+ ).fetchone()["c"]
+ item["pending_human"] = pending
+ out.append(item)
+ return out
+
+
+def thread_messages(conn: sqlite3.Connection, thread_id: int) -> list[dict]:
+ rows = conn.execute(
+ "SELECT * FROM agent_messages WHERE thread_id=? ORDER BY id ASC", (thread_id,)
+ ).fetchall()
+ out = []
+ for r in rows:
+ item = dict(r)
+ if item["from_type"] == "agent":
+ p = AGENT_CATALOG.get(item["from_id"])
+ item["from_label"] = f"{p.name} ({item['from_id']})" if p else item["from_id"]
+ else:
+ item["from_label"] = item["from_id"]
+ out.append(item)
+ return out
+
+
+def human_reply(
+ conn: sqlite3.Connection,
+ *,
+ thread_id: int,
+ username: str,
+ body: str,
+ target_agent: str | None = None,
+) -> int:
+ agent_to = target_agent or conn.execute(
+ "SELECT primary_agent FROM agent_threads WHERE id=?", (thread_id,)
+ ).fetchone()["primary_agent"]
+
+ mid = post_message(
+ conn,
+ thread_id=thread_id,
+ from_type="human",
+ from_id=username,
+ to_type="agent",
+ to_id=agent_to,
+ body=body,
+ requires_human=False,
+ )
+
+ # Copiloto (A6) ecoa confirmação para o thread
+ post_message(
+ conn,
+ thread_id=thread_id,
+ from_type="agent",
+ from_id="A6",
+ to_type="human",
+ to_id=username,
+ body=f"Recebi a sua instrução. Vou coordenar com **{AGENT_CATALOG.get(agent_to, AGENT_CATALOG['A6']).name}** e actualizar o finding se aplicável.",
+ requires_human=False,
+ )
+ return mid
+
+
+def ack_message(conn: sqlite3.Connection, message_id: int, username: str) -> bool:
+ row = conn.execute("SELECT id FROM agent_messages WHERE id=?", (message_id,)).fetchone()
+ if not row:
+ return False
+ conn.execute(
+ "UPDATE agent_messages SET acknowledged_at=?, acknowledged_by=? WHERE id=?",
+ (_now(), username, message_id),
+ )
+ return True
diff --git a/projects/ops-desk/api/app/agents/routes.py b/projects/ops-desk/api/app/agents/routes.py
index af71b55..8d8d790 100644
--- a/projects/ops-desk/api/app/agents/routes.py
+++ b/projects/ops-desk/api/app/agents/routes.py
@@ -8,6 +8,8 @@ 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"])
@@ -37,6 +39,12 @@ def _ops_view(user):
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")
@@ -51,6 +59,65 @@ def agents_health():
}
+@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)
@@ -103,7 +170,7 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe
@router.post("/chat")
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
- """T1 context window — copiloto ops para utilizadores Desk."""
+ """Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
_ops_view(user)
kb = store.search_kb(conn, body.question)
findings_summary = ""
@@ -119,15 +186,40 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe
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="advisor",
- payload={"user": user.username, "model": model},
+ 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)}
+ return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id}
@router.post("/internal/tick")
@@ -138,7 +230,7 @@ def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)
conn,
event_type="tick.complete",
message=f"kb={kb} runs={result['total']}",
- agent_id="orchestrator",
+ agent_id="A0",
payload={"kb": kb, **result},
)
conn.commit()
diff --git a/projects/ops-desk/api/app/agents/runner.py b/projects/ops-desk/api/app/agents/runner.py
index ac24179..5b63b95 100644
--- a/projects/ops-desk/api/app/agents/runner.py
+++ b/projects/ops-desk/api/app/agents/runner.py
@@ -3,6 +3,8 @@ from __future__ import annotations
import os
from pathlib import Path
from app.agents import checks, llm_client, notify, registry, store
+from app.agents import messages as agent_messages
+from app.agents.catalog import SCENARIO_AGENT_MAP
SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs"))
OPS_API = os.getenv("OPS_API_URL", "http://api:8080")
@@ -30,7 +32,7 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
sc = store.get_scenario(conn, scenario_id)
if not sc:
return {"ok": False, "error": "not found"}
- agent_id = (sc.get("config") or {}).get("agent_id") or sc.get("agent_id") or "sentinel"
+ agent_id = SCENARIO_AGENT_MAP.get(scenario_id) or (sc.get("config") or {}).get("agent_id") or "sentinel"
fn = checks.SCENARIO_RUNNERS.get(scenario_id)
if not fn:
return {"ok": False, "error": "no runner"}
@@ -51,6 +53,16 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
category=f.get("category","api"), title=f.get("title","Finding"), detail_md=f.get("detail_md",""),
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
fids.append(fid)
+ if f.get("severity") in ("warn", "high", "critical"):
+ agent_messages.notify_finding_to_operators(
+ conn,
+ finding_id=fid,
+ scenario_id=scenario_id,
+ title=f.get("title", "Finding"),
+ severity=f.get("severity", "warn"),
+ human_action=human,
+ agent_id=agent_id,
+ )
if f.get("severity") in ("high", "critical"):
notify.notify_finding({**f, "suggested_human_action": human})
store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id)
diff --git a/projects/ops-desk/api/app/agents/scenarios/registry.yaml b/projects/ops-desk/api/app/agents/scenarios/registry.yaml
index 97629a3..5a9eafb 100644
--- a/projects/ops-desk/api/app/agents/scenarios/registry.yaml
+++ b/projects/ops-desk/api/app/agents/scenarios/registry.yaml
@@ -7,15 +7,15 @@ scenarios:
- id: wizard.vm112.bundle
title: VM112 Wizard
severity_default: high
- agent_id: sentinel
+ agent_id: A1
- id: pfsense.api.system
title: pfSense API
severity_default: warn
- agent_id: sentinel
+ agent_id: A2
- id: funnel.stuck.onboarding
title: Funil travado
severity_default: warn
- agent_id: dispatcher
+ agent_id: A6
- id: integration.webhook.gap
title: Gap webhook VM112
severity_default: high
@@ -23,7 +23,7 @@ scenarios:
- id: proxmox.cluster
title: Proxmox VMs críticas
severity_default: critical
- agent_id: sentinel
+ agent_id: A1
- id: ollama.vm123.health
title: Ollama VM123
severity_default: high
diff --git a/projects/ops-desk/api/app/agents/store.py b/projects/ops-desk/api/app/agents/store.py
index 4afba6a..ec5e9d2 100644
--- a/projects/ops-desk/api/app/agents/store.py
+++ b/projects/ops-desk/api/app/agents/store.py
@@ -4,10 +4,13 @@ import json, sqlite3
from datetime import datetime, timezone
from typing import Any
+from app.agents.messages import init_messages_schema
+
def _now():
return datetime.now(timezone.utc).isoformat()
def init_agent_schema(conn):
+ init_messages_schema(conn)
conn.executescript("""
CREATE TABLE IF NOT EXISTS agent_scenarios (
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,
diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js
index 321c458..2242db1 100644
--- a/projects/ops-desk/frontend/assets/agentic-ops.js
+++ b/projects/ops-desk/frontend/assets/agentic-ops.js
@@ -1,20 +1,59 @@
(function () {
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>');
+ let state = { threadId: null, selectedAgent: 'A6' };
async function api(path, opts = {}) {
- const h = { ...(opts.headers || {}), 'Content-Type': 'application/json' };
+ const h = { ...(opts.headers || {}) };
+ if (!(opts.body instanceof FormData)) h['Content-Type'] = 'application/json';
const t = window.DeskAuth?.getToken?.();
if (t) h.Authorization = `Bearer ${t}`;
const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h });
- if (!r.ok) {
- const err = await r.text();
- throw new Error(`${r.status} ${err.slice(0, 200)}`);
- }
+ if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
return r.json();
}
- async function sendChat(question) {
- return api('/chat', { method: 'POST', body: JSON.stringify({ question, include_findings: true }) });
+ function agentCard(a) {
+ const active = state.selectedAgent === a.id ? ' agentic-agent-active' : '';
+ return ` ${esc((m.body || '').slice(0, 280))}${esc(a.name)} ${esc(a.id)}
+
+ ${(a.actions || []).slice(0, 3).map(x => `
+
+
Carregando thread…
'; + const data = await api(`/threads/${threadId}/messages`); + box.innerHTML = data.messages.map(threadBubble).join('') || 'Sem mensagens.
'; + box.scrollTop = box.scrollHeight; } async function renderAgenticOps() { @@ -22,53 +61,112 @@ if (!el) return; el.innerHTML = 'Carregando Agentic Ops…
'; try { - const [health, scenarios, findings, log] = await Promise.all([ - api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'), + const [health, roster, inbox, threads, findings] = await Promise.all([ + api('/health'), + api('/roster'), + api('/inbox?limit=20'), + api('/threads?limit=15'), + api('/findings?limit=15'), ]); const tier = health.tier === 't1' ? 'T1 LLM' : 'T0'; const ollama = health.ollama - ? `Ollama OK · ${esc(health.model || '')}` - : 'Ollama offline — modo T0'; - const sRows = (scenarios.scenarios || []).map(s => - `${esc(s.id)}Acção: ${esc(f.suggested_human_action)}
` : '') - + `Sem findings abertos.
'; - const lRows = (log.events || []).map(e => - `${esc(e.event_type)}| ID | Título | Último | Quando |
|---|
| Quando | Evento | Mensagem |
|---|
Inbox vazia.
'} +A pensar…
'; try { - const res = await sendChat(q); - out.innerHTML = `Resposta
${esc(res.answer)}
`; + const res = await api('/chat', { + method: 'POST', + body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }), + }); + out.innerHTML = `${esc(state.selectedAgent)}
${esc(res.answer)}
`; + if (res.thread_id) { + state.threadId = res.thread_id; + await loadThread(el, res.thread_id); + } } catch (err) { out.innerHTML = `${esc(err.message)}
`; } }); + if (state.threadId) await loadThread(el, state.threadId); } catch (err) { el.innerHTML = `Erro: ${esc(err.message)}
`; } diff --git a/specs/029-agentic-ops-runbooks/agents-roster.md b/specs/029-agentic-ops-runbooks/agents-roster.md new file mode 100644 index 0000000..e5236e6 --- /dev/null +++ b/specs/029-agentic-ops-runbooks/agents-roster.md @@ -0,0 +1,142 @@ +# Roster Agentics — Nomes, acções e janelas de contexto + +**Spec 029 · Secção agentes** + +--- + +## Mapa A0–A7 (nomes operacionais) + +| ID | Nome | Codename | Operador humano principal | +|----|------|----------|---------------------------| +| **A0** | **Maestro** | orchestrator | agentic_operator / ops_lead | +| **A1** | **Pulso** | node_health | ops_lead (restart) | +| **A2** | **Trilho** | infra_mail | devops / ops_lead | +| **A3** | **Carta** | deliverability | seo / technician | +| **A4** | **Escudo Mail** | security_mail | security_analyst | +| **A5** | **Sentinela SOC** | wazuh_soc | security_analyst / noc | +| **A6** | **Copiloto** | support_copilot | technician (envia) | +| **A7** | **Remediador** | remediation | agentic_operator (obrigatório) | + +### Auxiliares (implementação T0) + +| ID | Nome | Função | +|----|------|--------| +| **Vigia** | sentinel | Health checks APIs/VM123 | +| **Curador** | curator | Indexação RAG das specs | + +--- + +## Acções tocadas por cada agente + +### A0 — Maestro +- Dispara tick 24/7 (worker 5 min) +- Indexa KB via Curador +- Delega cenários ao Vigia/Pulso/Copiloto +- Abre threads de coordenação inter-agente +- Regista `tick.complete` no audit log + +### A1 — Pulso +- `wizard.vm112.bundle` — API + portal onboard +- `proxmox.cluster` — VMs 112/122/123/104 running +- Cria finding → inbox ops_lead se VM parada + +### A2 — Trilho +- `pfsense.api.system` — firewall via Traefik +- (futuro) validação DNS/LE pós-onboard +- Propõe fix — **nunca aplica** sem devops + +### A3 — Carta +- (futuro) audit SPF/DKIM/DMARC por tenant +- Relatórios entregabilidade → inbox seo + +### A4 — Escudo Mail +- (futuro) filas amavis/clamav +- Quarentena sugerida → security_analyst + +### A5 — Sentinela SOC +- (futuro) correlacionar Wazuh L≥10 com ticket +- Enriquecer timeline CH-* + +### A6 — Copiloto +- `funnel.stuck.onboarding` — tickets >24h +- **Janela /chat** — responde operadores humanos +- Ecoa confirmação quando humano responde num thread +- Rascunho resposta ticket (futuro) + +### A7 — Remediador +- (futuro) runbooks R0–R3 +- Execução **só após** OK agentic_operator +- Auditoria `action_executed` + +### Vigia (sentinel) +- desk, pfSense, webhook gap, Ollama, FOSS, Odoo, OpenPanel bridge +- E-mail + inbox em high/critical + +### Curador +- Indexa `specs/**/*.md` no tick +- Alimenta RAG do Copiloto/Advisor + +--- + +## Janelas de contexto (como conversam) + +```text + ┌─────────────────────────────────────┐ + │ A0 Maestro (orchestrator) │ + │ tick → delega → sintetiza │ + └──────────┬──────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + A1 Pulso A6 Copiloto Vigia + finding /chat humano cenários T0 + │ │ │ + └──────────┬──────────┴──────────┬──────────┘ + ▼ ▼ + agent_threads agent_messages + │ │ + └──────────┬──────────┘ + ▼ + Inbox operadores humanos + (agentic_operator, ops_lead, …) +``` + +### Regras de conversa + +1. **Agente → Humano:** finding warn+ cria `agent_thread` + mensagem `requires_human=1` na **Inbox**. +2. **Agente → Agente:** Maestro (A0) regista coordenação no mesmo thread («aguardando acção humana»). +3. **Humano → Agente:** operador responde no painel «Janela de contexto» → Copiloto (A6) confirma e encaminha ao agente primary. +4. **Humano ↔ Copiloto:** chat livre via `/chat` com `target_agent` — grava thread persistente. + +### Papéis que veem a Inbox + +| Role Desk | Vê mensagens destinadas a | +|-----------|---------------------------| +| super_admin | todos | +| agentic_operator | agentic_operator, ops_lead | +| ops_lead | ops_lead, agentic_operator, technician | +| technician | technician | +| security_analyst | security_analyst, agentic_operator | +| devops | devops, ops_lead | + +--- + +## API de mensagens + +| Endpoint | Descrição | +|----------|-----------| +| GET `/roster` | Catálogo nomeado | +| GET `/inbox` | Pendências humanas | +| GET `/threads` | Todas as conversas | +| GET `/threads/{id}/messages` | Histórico thread | +| POST `/threads/{id}/reply` | Humano responde | +| POST `/messages/{id}/ack` | Arquivar inbox | +| POST `/chat` | Nova conversa com agente | + +--- + +## UI Desk — painel Agentic Ops + +1. **Coluna esquerda:** cards A0–A7 (clique selecciona agente destino) +2. **Coluna centro:** Inbox operadores + findings abertos +3. **Coluna direita:** Janela de contexto (thread + reply + chat Copiloto) diff --git a/specs/029-agentic-ops-runbooks/spec.md b/specs/029-agentic-ops-runbooks/spec.md index a3fc597..34e38e7 100644 --- a/specs/029-agentic-ops-runbooks/spec.md +++ b/specs/029-agentic-ops-runbooks/spec.md @@ -24,15 +24,22 @@ Camada **Agentic Ops** para vigilância 24/7, checks determinísticos (T0), advi ## Agentes lógicos (implementação 029) -| ID | Papel | Função | -|----|-------|--------| -| `sentinel` | Health/API | Cenários desk, wizard, pfSense, proxmox, ollama, VM123 | -| `dispatcher` | Funil | Tickets onboarding travados | -| `curator` | KB | Indexa `/specs/**/*.md` em SQLite | -| `advisor` | T1 | Sugestões human_action + `/chat` copiloto | -| `orchestrator` | Tick | Worker cron — dispara todos os cenários | +**Documento completo:** [`agents-roster.md`](agents-roster.md) -Mapeamento futuro Spec 027 A0–A7 permanece na governança RBAC; esta spec entrega o **MVP operacional**. +| ID | Nome | Codename | Inbox humano | +|----|------|----------|--------------| +| A0 | Maestro | orchestrator | agentic_operator | +| A1 | Pulso | node_health | ops_lead | +| A2 | Trilho | infra_mail | devops | +| A3 | Carta | deliverability | seo / technician | +| A4 | Escudo Mail | security_mail | security_analyst | +| A5 | Sentinela SOC | wazuh_soc | security_analyst | +| A6 | Copiloto | support_copilot | technician | +| A7 | Remediador | remediation | agentic_operator | +| — | Vigia | sentinel | ops (findings T0) | +| — | Curador | curator | — (RAG interno) | + +Mensagens: tabelas `agent_threads` + `agent_messages`. UI: Inbox + Janela de contexto no Desk. --- @@ -54,6 +61,12 @@ Mapeamento futuro Spec 027 A0–A7 permanece na governança RBAC; esta spec entr | Método | Path | Auth | |--------|------|------| +| GET | `/roster` | ops view — catálogo A0–A7 | +| GET | `/inbox` | ops view — mensagens pendentes | +| GET | `/threads` | ops view | +| GET | `/threads/{id}/messages` | ops view | +| POST | `/threads/{id}/reply` | ops view — humano responde | +| POST | `/messages/{id}/ack` | ops view — arquivar inbox | | GET | `/health` | público | | GET | `/scenarios` | ops view | | GET | `/findings` | ops view |