Name Agentics A0-A7, add inter-agent messaging and operator inbox UI.

Adds catalog with Maestro/Pulso/Trilho etc., agent_threads/messages bus,
inbox and context window API, and complete Desk Agentic Ops panel for
human operators to read, reply, and chat with agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ligbox Spec Hub 2026-06-19 23:24:48 +00:00
parent e0959e6fd7
commit 2a5273201b
9 changed files with 932 additions and 67 deletions

View file

@ -0,0 +1,208 @@
"""Catálogo nomeado dos Agentics A0A7 — 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

View file

@ -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

View file

@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from app import auth from app import auth
from app.agents import llm_client, runner, store 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"]) router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
@ -37,6 +39,12 @@ def _ops_view(user):
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
question: str = Field(..., min_length=2, max_length=4000) question: str = Field(..., min_length=2, max_length=4000)
include_findings: bool = True 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") @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") @router.get("/scenarios")
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)): def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user) _ops_view(user)
@ -103,7 +170,7 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe
@router.post("/chat") @router.post("/chat")
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): 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) _ops_view(user)
kb = store.search_kb(conn, body.question) kb = store.search_kb(conn, body.question)
findings_summary = "" findings_summary = ""
@ -119,15 +186,40 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe
findings_summary=findings_summary, findings_summary=findings_summary,
user_role=user.role, 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( store.log_event(
conn, conn,
event_type="chat.query", event_type="chat.query",
message=body.question[:120], message=body.question[:120],
agent_id="advisor", agent_id=body.target_agent,
payload={"user": user.username, "model": model}, payload={"user": user.username, "model": model, "thread_id": thread_id},
) )
conn.commit() 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") @router.post("/internal/tick")
@ -138,7 +230,7 @@ def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)
conn, conn,
event_type="tick.complete", event_type="tick.complete",
message=f"kb={kb} runs={result['total']}", message=f"kb={kb} runs={result['total']}",
agent_id="orchestrator", agent_id="A0",
payload={"kb": kb, **result}, payload={"kb": kb, **result},
) )
conn.commit() conn.commit()

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from app.agents import checks, llm_client, notify, registry, store 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")) SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs"))
OPS_API = os.getenv("OPS_API_URL", "http://api:8080") 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) sc = store.get_scenario(conn, scenario_id)
if not sc: if not sc:
return {"ok": False, "error": "not found"} 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) fn = checks.SCENARIO_RUNNERS.get(scenario_id)
if not fn: if not fn:
return {"ok": False, "error": "no runner"} 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",""), 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]) evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
fids.append(fid) 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"): if f.get("severity") in ("high", "critical"):
notify.notify_finding({**f, "suggested_human_action": human}) 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) store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id)

View file

@ -7,15 +7,15 @@ scenarios:
- id: wizard.vm112.bundle - id: wizard.vm112.bundle
title: VM112 Wizard title: VM112 Wizard
severity_default: high severity_default: high
agent_id: sentinel agent_id: A1
- id: pfsense.api.system - id: pfsense.api.system
title: pfSense API title: pfSense API
severity_default: warn severity_default: warn
agent_id: sentinel agent_id: A2
- id: funnel.stuck.onboarding - id: funnel.stuck.onboarding
title: Funil travado title: Funil travado
severity_default: warn severity_default: warn
agent_id: dispatcher agent_id: A6
- id: integration.webhook.gap - id: integration.webhook.gap
title: Gap webhook VM112 title: Gap webhook VM112
severity_default: high severity_default: high
@ -23,7 +23,7 @@ scenarios:
- id: proxmox.cluster - id: proxmox.cluster
title: Proxmox VMs críticas title: Proxmox VMs críticas
severity_default: critical severity_default: critical
agent_id: sentinel agent_id: A1
- id: ollama.vm123.health - id: ollama.vm123.health
title: Ollama VM123 title: Ollama VM123
severity_default: high severity_default: high

View file

@ -4,10 +4,13 @@ import json, sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from app.agents.messages import init_messages_schema
def _now(): def _now():
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def init_agent_schema(conn): def init_agent_schema(conn):
init_messages_schema(conn)
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS agent_scenarios ( CREATE TABLE IF NOT EXISTS agent_scenarios (
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT, id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,

View file

@ -1,20 +1,59 @@
(function () { (function () {
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let state = { threadId: null, selectedAgent: 'A6' };
async function api(path, opts = {}) { 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?.(); const t = window.DeskAuth?.getToken?.();
if (t) h.Authorization = `Bearer ${t}`; if (t) h.Authorization = `Bearer ${t}`;
const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h }); const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h });
if (!r.ok) { if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
const err = await r.text();
throw new Error(`${r.status} ${err.slice(0, 200)}`);
}
return r.json(); return r.json();
} }
async function sendChat(question) { function agentCard(a) {
return api('/chat', { method: 'POST', body: JSON.stringify({ question, include_findings: true }) }); const active = state.selectedAgent === a.id ? ' agentic-agent-active' : '';
return `<article class="card agentic-agent-card${active}" data-agent-id="${esc(a.id)}" tabindex="0">
<h4>${esc(a.name)} <span class="pill pill-sm">${esc(a.id)}</span></h4>
<p class="ticket-meta">${esc(a.role)}</p>
<ul class="agentic-action-list">${(a.actions || []).slice(0, 3).map(x => `<li>${esc(x)}</li>`).join('')}</ul>
<p class="ticket-meta"><strong>Aprovação:</strong> ${esc(a.approval)}</p>
</article>`;
}
function inboxRow(m) {
return `<article class="card agentic-inbox-item" data-msg-id="${m.id}" data-thread-id="${m.thread_id}">
<div class="agentic-inbox-head">
<strong>${esc(m.agent_name || m.from_id)}</strong>
<span class="pill">${esc(m.thread_severity || 'info')}</span>
</div>
<p class="ticket-meta">${esc(m.thread_subject || m.message)} · ${esc(m.created_at)}</p>
<p class="agentic-inbox-body">${esc((m.body || '').slice(0, 280))}</p>
<div class="agentic-inbox-actions">
<button type="button" class="btn btn-primary btn-sm" data-open-thread="${m.thread_id}">Abrir conversa</button>
<button type="button" class="btn btn-ghost btn-sm" data-ack-msg="${m.id}">Arquivar</button>
</div>
</article>`;
}
function threadBubble(m) {
const isHuman = m.from_type === 'human';
const cls = isHuman ? 'agentic-bubble-human' : 'agentic-bubble-agent';
return `<div class="agentic-bubble ${cls}">
<div class="agentic-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}</div>
<div class="agentic-bubble-body">${esc(m.body).replace(/\n/g, '<br>')}</div>
</div>`;
}
async function loadThread(el, threadId) {
state.threadId = threadId;
const box = el.querySelector('#agentic-thread-messages');
if (!box) return;
box.innerHTML = '<p class="loading">Carregando thread…</p>';
const data = await api(`/threads/${threadId}/messages`);
box.innerHTML = data.messages.map(threadBubble).join('') || '<p class="empty">Sem mensagens.</p>';
box.scrollTop = box.scrollHeight;
} }
async function renderAgenticOps() { async function renderAgenticOps() {
@ -22,53 +61,112 @@
if (!el) return; if (!el) return;
el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>'; el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>';
try { try {
const [health, scenarios, findings, log] = await Promise.all([ const [health, roster, inbox, threads, findings] = await Promise.all([
api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'), 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 tier = health.tier === 't1' ? 'T1 LLM' : 'T0';
const ollama = health.ollama const ollama = health.ollama
? `<span class="pill pill-ok">Ollama OK · ${esc(health.model || '')}</span>` ? `<span class="pill pill-ok">Ollama · ${esc(health.model)}</span>`
: '<span class="pill pill-warn">Ollama offline — modo T0</span>'; : '<span class="pill pill-warn">Ollama offline</span>';
const sRows = (scenarios.scenarios || []).map(s => const agents = roster.agents || [];
`<tr><td><code>${esc(s.id)}</code></td><td>${esc(s.title)}</td><td>${esc(s.last_run_status||'—')}</td><td class="ticket-meta">${esc(s.last_run_at||'—')}</td></tr>` const inboxItems = inbox.messages || [];
const threadOpts = (threads.threads || []).map(t =>
`<option value="${t.id}"${state.threadId === t.id ? ' selected' : ''}>#${t.id} ${esc(t.subject)} (${esc(t.agent_name)})</option>`
).join(''); ).join('');
const fRows = (findings.findings || []).map(f => const fRows = (findings.findings || []).map(f =>
`<article class="card agentic-finding"><h3>${esc(f.title)} <span class="pill">${esc(f.severity)}</span></h3>` `<li><strong>${esc(f.title)}</strong> <span class="pill">${esc(f.severity)}</span>
+ `<p class="ticket-meta">${esc(f.created_at)}</p>` ${f.suggested_human_action ? `<br><span class="ticket-meta">${esc(f.suggested_human_action)}</span>` : ''}</li>`
+ (f.suggested_human_action ? `<p><strong>Acção:</strong> ${esc(f.suggested_human_action)}</p>` : '') ).join('') || '<li class="empty">Nenhum finding aberto.</li>';
+ `<button type="button" class="btn btn-ghost btn-sm" data-ack="${f.id}">Marcar visto</button></article>`
).join('') || '<p class="empty">Sem findings abertos.</p>';
const lRows = (log.events || []).map(e =>
`<tr><td class="ticket-meta">${esc(e.ts)}</td><td><code>${esc(e.event_type)}</code></td><td>${esc(e.message)}</td></tr>`
).join('');
el.innerHTML = ` el.innerHTML = `
<div class="toolbar agentic-toolbar"> <style>
<div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama}</p></div> .agentic-layout{display:grid;grid-template-columns:minmax(200px,1fr) minmax(260px,1.2fr) minmax(280px,1.4fr);gap:1rem;margin-top:.5rem}
@media(max-width:1100px){.agentic-layout{grid-template-columns:1fr}}
.agentic-agent-card{cursor:pointer;border:1px solid var(--border,#333);margin-bottom:.5rem;padding:.75rem}
.agentic-agent-active{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f680}
.agentic-action-list{font-size:.85rem;margin:.4rem 0;padding-left:1.1rem;color:var(--muted,#aaa)}
.agentic-inbox-item{margin-bottom:.75rem}
.agentic-inbox-body{font-size:.9rem;margin:.35rem 0;white-space:pre-wrap}
.agentic-thread-panel{display:flex;flex-direction:column;min-height:420px}
.agentic-thread-messages{flex:1;overflow-y:auto;max-height:360px;padding:.5rem;background:rgba(0,0,0,.15);border-radius:6px;margin:.5rem 0}
.agentic-bubble{margin:.5rem 0;padding:.6rem .8rem;border-radius:8px;max-width:95%}
.agentic-bubble-agent{background:rgba(59,130,246,.12);border-left:3px solid #3b82f6}
.agentic-bubble-human{background:rgba(34,197,94,.1);border-left:3px solid #22c55e;margin-left:auto}
.agentic-bubble-meta{font-size:.75rem;color:var(--muted,#888);margin-bottom:.25rem}
.agentic-chat-box{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}
.agentic-chat-box textarea{width:100%;min-height:72px}
</style>
<div class="toolbar agentic-toolbar">
<div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama} · ${inboxItems.length} pendente(s)</p></div>
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button> <button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button>
</div> </div>
<div class="agentic-grid"> <div class="agentic-layout">
<div class="card"><h3>Cenários</h3> <section>
<table class="data-table"><thead><tr><th>ID</th><th>Título</th><th>Último</th><th>Quando</th></tr></thead><tbody>${sRows}</tbody></table> <h3>Agentes (A0A7)</h3>
</div> <p class="ticket-meta">Clique para seleccionar destino do chat.</p>
<div class="agentic-findings-col"><h3>Findings</h3>${fRows}</div> ${agents.map(agentCard).join('')}
</div> </section>
<section class="card agentic-chat-card" style="margin-top:1rem"> <section>
<h3>Copiloto Ops (T1)</h3> <h3>Inbox operadores</h3>
<p class="ticket-meta">Pergunte sobre infra, VM123, findings ou procedimentos resposta contextual pt-BR.</p> <p class="ticket-meta">Mensagens dos agentes que exigem acção humana.</p>
${inboxItems.length ? inboxItems.map(inboxRow).join('') : '<p class="empty">Inbox vazia.</p>'}
<h3 style="margin-top:1rem">Findings abertos</h3>
<ul class="agentic-findings-list">${fRows}</ul>
</section>
<section class="card agentic-thread-panel">
<h3>Janela de contexto</h3>
<label class="ticket-meta">Thread
<select id="agentic-thread-select" class="input">${threadOpts || '<option value="">—</option>'}</select>
</label>
<div id="agentic-thread-messages" class="agentic-thread-messages"><p class="empty">Seleccione uma thread ou abra da inbox.</p></div>
<textarea id="agentic-reply-input" rows="2" class="input" placeholder="Responder ao agente…"></textarea>
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-reply">Enviar resposta</button>
<hr style="margin:1rem 0;opacity:.3">
<h4>Chat Copiloto (${esc(state.selectedAgent)})</h4>
<div class="agentic-chat-box"> <div class="agentic-chat-box">
<textarea id="agentic-chat-input" rows="3" placeholder="Ex.: O que fazer se Ollama VM123 estiver offline?" class="input"></textarea> <textarea id="agentic-chat-input" rows="2" placeholder="Pergunta ao agente seleccionado…"></textarea>
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-chat">Perguntar</button> <button type="button" class="btn btn-ghost btn-sm" id="btn-agentic-chat">Perguntar</button>
</div> </div>
<div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div> <div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div>
</section> </section>
<section class="card" style="margin-top:1rem"><h3>Audit log</h3> </div>`;
<table class="data-table data-table-compact"><thead><tr><th>Quando</th><th>Evento</th><th>Mensagem</th></tr></thead><tbody>${lRows}</tbody></table>
</section>`;
el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps); el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps);
el.querySelectorAll('[data-ack]').forEach(btn => btn.addEventListener('click', async () => { el.querySelectorAll('.agentic-agent-card').forEach(card => {
await api(`/findings/${btn.dataset.ack}/ack`, { method: 'POST' }); card.addEventListener('click', () => {
state.selectedAgent = card.dataset.agentId;
renderAgenticOps();
});
});
el.querySelectorAll('[data-open-thread]').forEach(btn => {
btn.addEventListener('click', () => loadThread(el, parseInt(btn.dataset.openThread, 10)));
});
el.querySelectorAll('[data-ack-msg]').forEach(btn => {
btn.addEventListener('click', async () => {
await api(`/messages/${btn.dataset.ackMsg}/ack`, { method: 'POST' });
await renderAgenticOps(); await renderAgenticOps();
})); });
});
el.querySelector('#agentic-thread-select')?.addEventListener('change', (e) => {
const id = parseInt(e.target.value, 10);
if (id) loadThread(el, id);
});
el.querySelector('#btn-agentic-reply')?.addEventListener('click', async () => {
const input = el.querySelector('#agentic-reply-input');
const tid = state.threadId || parseInt(el.querySelector('#agentic-thread-select')?.value, 10);
const body = (input?.value || '').trim();
if (!tid || !body) return;
await api(`/threads/${tid}/reply`, {
method: 'POST',
body: JSON.stringify({ body, target_agent: state.selectedAgent }),
});
input.value = '';
await loadThread(el, tid);
});
el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => { el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => {
const input = el.querySelector('#agentic-chat-input'); const input = el.querySelector('#agentic-chat-input');
const out = el.querySelector('#agentic-chat-answer'); const out = el.querySelector('#agentic-chat-answer');
@ -77,12 +175,20 @@
out.hidden = false; out.hidden = false;
out.innerHTML = '<p class="loading">A pensar…</p>'; out.innerHTML = '<p class="loading">A pensar…</p>';
try { try {
const res = await sendChat(q); const res = await api('/chat', {
out.innerHTML = `<p><strong>Resposta</strong> <span class="ticket-meta">(${esc(res.model)})</span></p><p>${esc(res.answer)}</p>`; method: 'POST',
body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }),
});
out.innerHTML = `<p><strong>${esc(state.selectedAgent)}</strong> <span class="ticket-meta">(${esc(res.model)})</span></p><p>${esc(res.answer)}</p>`;
if (res.thread_id) {
state.threadId = res.thread_id;
await loadThread(el, res.thread_id);
}
} catch (err) { } catch (err) {
out.innerHTML = `<p class="error">${esc(err.message)}</p>`; out.innerHTML = `<p class="error">${esc(err.message)}</p>`;
} }
}); });
if (state.threadId) await loadThread(el, state.threadId);
} catch (err) { } catch (err) {
el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`; el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
} }

View file

@ -0,0 +1,142 @@
# Roster Agentics — Nomes, acções e janelas de contexto
**Spec 029 · Secção agentes**
---
## Mapa A0A7 (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 R0R3
- 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 A0A7 (clique selecciona agente destino)
2. **Coluna centro:** Inbox operadores + findings abertos
3. **Coluna direita:** Janela de contexto (thread + reply + chat Copiloto)

View file

@ -24,15 +24,22 @@ Camada **Agentic Ops** para vigilância 24/7, checks determinísticos (T0), advi
## Agentes lógicos (implementação 029) ## Agentes lógicos (implementação 029)
| ID | Papel | Função | **Documento completo:** [`agents-roster.md`](agents-roster.md)
|----|-------|--------|
| `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 |
Mapeamento futuro Spec 027 A0A7 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 A0A7 permanece na governança RBAC; esta spec entr
| Método | Path | Auth | | Método | Path | Auth |
|--------|------|------| |--------|------|------|
| GET | `/roster` | ops view — catálogo A0A7 |
| 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 | `/health` | público |
| GET | `/scenarios` | ops view | | GET | `/scenarios` | ops view |
| GET | `/findings` | ops view | | GET | `/findings` | ops view |