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>
289 lines
9 KiB
Python
289 lines
9 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,
|
|
) -> 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
|