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