ligbox-ops-platform/projects/ops-desk/api/app/agents/messages.py
Ligbox Spec Hub fd491e5859 Implement Spec 030 Agentic Ops Mission Board (UI-A/B/C).
Add agent_incidents dedup, overview/incidents/timeline API, mission board UI with fleet rail, kanban, context panel, mobile tabs, poll and keyboard shortcuts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 06:49:38 +00:00

300 lines
9.4 KiB
Python

"""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,
thread_id: int | None = None,
) -> 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")
if thread_id:
tid = thread_id
else:
existing = conn.execute(
"SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'",
(finding_id,),
).fetchone()
if existing:
tid = existing["id"]
else:
inc = conn.execute(
"SELECT thread_id FROM agent_incidents WHERE scenario_id=? AND status='open'",
(scenario_id,),
).fetchone()
if inc and inc["thread_id"]:
tid = inc["thread_id"]
else:
tid = 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=tid,
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=tid,
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 tid
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=tid,
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