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:
parent
e0959e6fd7
commit
2a5273201b
9 changed files with 932 additions and 67 deletions
208
projects/ops-desk/api/app/agents/catalog.py
Normal file
208
projects/ops-desk/api/app/agents/catalog.py
Normal file
|
|
@ -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
|
||||||
289
projects/ops-desk/api/app/agents/messages.py
Normal file
289
projects/ops-desk/api/app/agents/messages.py
Normal 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
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,59 @@
|
||||||
(function () {
|
(function () {
|
||||||
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
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}
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button>
|
@media(max-width:1100px){.agentic-layout{grid-template-columns:1fr}}
|
||||||
</div>
|
.agentic-agent-card{cursor:pointer;border:1px solid var(--border,#333);margin-bottom:.5rem;padding:.75rem}
|
||||||
<div class="agentic-grid">
|
.agentic-agent-active{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f680}
|
||||||
<div class="card"><h3>Cenários</h3>
|
.agentic-action-list{font-size:.85rem;margin:.4rem 0;padding-left:1.1rem;color:var(--muted,#aaa)}
|
||||||
<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>
|
.agentic-inbox-item{margin-bottom:.75rem}
|
||||||
</div>
|
.agentic-inbox-body{font-size:.9rem;margin:.35rem 0;white-space:pre-wrap}
|
||||||
<div class="agentic-findings-col"><h3>Findings</h3>${fRows}</div>
|
.agentic-thread-panel{display:flex;flex-direction:column;min-height:420px}
|
||||||
</div>
|
.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}
|
||||||
<section class="card agentic-chat-card" style="margin-top:1rem">
|
.agentic-bubble{margin:.5rem 0;padding:.6rem .8rem;border-radius:8px;max-width:95%}
|
||||||
<h3>Copiloto Ops (T1)</h3>
|
.agentic-bubble-agent{background:rgba(59,130,246,.12);border-left:3px solid #3b82f6}
|
||||||
<p class="ticket-meta">Pergunte sobre infra, VM123, findings ou procedimentos — resposta contextual pt-BR.</p>
|
.agentic-bubble-human{background:rgba(34,197,94,.1);border-left:3px solid #22c55e;margin-left:auto}
|
||||||
<div class="agentic-chat-box">
|
.agentic-bubble-meta{font-size:.75rem;color:var(--muted,#888);margin-bottom:.25rem}
|
||||||
<textarea id="agentic-chat-input" rows="3" placeholder="Ex.: O que fazer se Ollama VM123 estiver offline?" class="input"></textarea>
|
.agentic-chat-box{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-chat">Perguntar</button>
|
.agentic-chat-box textarea{width:100%;min-height:72px}
|
||||||
</div>
|
</style>
|
||||||
<div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div>
|
<div class="toolbar agentic-toolbar">
|
||||||
</section>
|
<div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama} · ${inboxItems.length} pendente(s)</p></div>
|
||||||
<section class="card" style="margin-top:1rem"><h3>Audit log</h3>
|
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button>
|
||||||
<table class="data-table data-table-compact"><thead><tr><th>Quando</th><th>Evento</th><th>Mensagem</th></tr></thead><tbody>${lRows}</tbody></table>
|
</div>
|
||||||
</section>`;
|
<div class="agentic-layout">
|
||||||
|
<section>
|
||||||
|
<h3>Agentes (A0–A7)</h3>
|
||||||
|
<p class="ticket-meta">Clique para seleccionar destino do chat.</p>
|
||||||
|
${agents.map(agentCard).join('')}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Inbox operadores</h3>
|
||||||
|
<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">
|
||||||
|
<textarea id="agentic-chat-input" rows="2" placeholder="Pergunta ao agente seleccionado…"></textarea>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-agentic-chat">Perguntar</button>
|
||||||
|
</div>
|
||||||
|
<div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div>
|
||||||
|
</section>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
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', () => {
|
||||||
await renderAgenticOps();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
specs/029-agentic-ops-runbooks/agents-roster.md
Normal file
142
specs/029-agentic-ops-runbooks/agents-roster.md
Normal file
|
|
@ -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)
|
||||||
|
|
@ -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 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 |
|
| 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 | `/health` | público |
|
||||||
| GET | `/scenarios` | ops view |
|
| GET | `/scenarios` | ops view |
|
||||||
| GET | `/findings` | ops view |
|
| GET | `/findings` | ops view |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue