"""War Room + 1:1 agent chat — Hermes/mimo only, soul prompts, A2A rounds.""" from __future__ import annotations import asyncio import logging import re from typing import Any from app.config import settings from app.db import execute, fetch_all, fetch_one from app.services import agent_souls, hermes_memory, llm_router from app.services.agent_names import normalize_agent_key log = logging.getLogger("cockpit.agent_chat") MENTION_RE = re.compile(r"@([a-zA-Z][a-zA-Z0-9_-]{1,63})") MAX_ROUNDS = 2 MAX_REPLIES_PER_ROUND = 3 HISTORY_LIMIT = 16 WARROOM_MAX_TOKENS = 512 WARROOM_TIMEOUT = 55.0 def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None: if not row: return None out = dict(row) for k, v in list(out.items()): if hasattr(v, "isoformat"): out[k] = v.isoformat() if isinstance(out.get("metadata"), str): try: import json out["metadata"] = json.loads(out["metadata"]) except Exception: out["metadata"] = {} return out def ensure_warroom() -> dict[str, Any]: row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1") if row: return _iso(row) # type: ignore row = fetch_one( """INSERT INTO warroom_rooms (kind, title) VALUES ('warroom', 'War Room') RETURNING *""" ) return _iso(row) # type: ignore def get_or_create_dm(agent_key: str) -> dict[str, Any]: key = normalize_agent_key(agent_key) or (agent_key or "").strip().lower() soul = agent_souls.get_soul(key) if not soul: raise ValueError(f"Onbekende agent: {agent_key}") row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'dm' AND agent_key = %s", (key,)) if row: return _iso(row) # type: ignore title = f"DM · {soul.get('display_name') or key}" row = fetch_one( """INSERT INTO warroom_rooms (kind, agent_key, title) VALUES ('dm', %s, %s) RETURNING *""", (key, title), ) return _iso(row) # type: ignore def list_rooms() -> list[dict[str, Any]]: ensure_warroom() rows = fetch_all( """SELECT r.*, (SELECT COUNT(*) FROM warroom_messages m WHERE m.room_id = r.id) AS message_count, (SELECT content FROM warroom_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message FROM warroom_rooms r ORDER BY CASE WHEN r.kind = 'warroom' THEN 0 ELSE 1 END, r.title""" ) return [_iso(r) for r in rows] # type: ignore def get_room(room_id: int) -> dict[str, Any] | None: return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,))) def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None) -> list[dict[str, Any]]: if after_id: rows = fetch_all( """SELECT * FROM warroom_messages WHERE room_id = %s AND id > %s ORDER BY created_at ASC, id ASC LIMIT %s""", (room_id, after_id, limit), ) else: rows = fetch_all( """SELECT * FROM ( SELECT * FROM warroom_messages WHERE room_id = %s ORDER BY created_at DESC, id DESC LIMIT %s ) t ORDER BY created_at ASC, id ASC""", (room_id, limit), ) return [_iso(r) for r in rows] # type: ignore def _insert_message( room_id: int, *, sender_type: str, content: str, agent_key: str | None = None, username: str | None = None, sender_avatar_url: str | None = None, metadata: dict | None = None, ) -> dict[str, Any]: import json row = fetch_one( """INSERT INTO warroom_messages (room_id, sender_type, agent_key, username, sender_avatar_url, content, metadata) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) RETURNING *""", ( room_id, sender_type, agent_key, username, sender_avatar_url, content, json.dumps(metadata or {}), ), ) execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,)) return _iso(row) # type: ignore def _active_agent_keys() -> set[str]: return {str(s.get("agent_key")) for s in agent_souls.list_souls() if s.get("is_active", True)} def parse_mentions(text: str) -> list[str]: keys = _active_agent_keys() found: list[str] = [] for m in MENTION_RE.findall(text or ""): k = normalize_agent_key(m) or m.lower() if k in keys and k not in found: found.append(k) return found def _history_as_chat(messages: list[dict[str, Any]], *, limit: int = 20) -> list[dict[str, str]]: out: list[dict[str, str]] = [] for m in messages[-limit:]: st = m.get("sender_type") content = (m.get("content") or "").strip() if not content: continue if st == "user": name = m.get("username") or "User" out.append({"role": "user", "content": f"{name}: {content}"}) elif st == "agent": key = m.get("agent_key") or "agent" out.append({"role": "assistant", "content": f"@{key}: {content}"}) else: out.append({"role": "system", "content": content}) return out async def _hermes_reply( messages: list[dict[str, str]], *, hermes_user: str, timeout: float | None = None, ) -> tuple[str, dict[str, Any]]: """Force Hermes/mimo — never DeepSeek/OpenRouter fallback.""" prov = llm_router.resolve_provider(None) ptype = (prov.get("provider_type") or "").lower() if ptype != "hermes": hermes = llm_router._env_hermes_provider() if not hermes: raise RuntimeError("Hermes/mimo niet geconfigureerd (HERMES_API_KEY / :8642).") raise RuntimeError( f"War Room vereist Hermes/mimo, huidige provider is '{ptype or 'onbekend'}'. " "Zet HERMAN_LLM_BACKEND=hermes." ) reply, meta = await llm_router.chat_messages( messages, timeout=float(timeout if timeout is not None else WARROOM_TIMEOUT), user=hermes_user, max_tokens=WARROOM_MAX_TOKENS, temperature=0.35, ) meta = dict(meta or {}) if meta.get("provider_type") != "hermes": raise RuntimeError("Antwoord kwam niet van Hermes/mimo — afgebroken.") return reply, meta def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str: name = soul.get("display_name") or soul.get("agent_key") key = soul.get("agent_key") role = soul.get("role_title") or "agent" soul_md = (soul.get("soul_md") or "")[:900] resp = (soul.get("responsibilities") or "")[:400] extra = ( "War Room: antwoord in max 4 korte zinnen (NL). Alleen @mention als écht nodig." if room_kind == "warroom" else "1:1 DM: bondig in het Nederlands (max 5 zinnen)." ) return ( f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n" f"Focus: {resp or '—'}\n" f"{soul_md}\n\n{extra}" ) async def _agent_speak( room: dict[str, Any], agent_key: str, *, trigger: str, hermes_user: str, in_reply_to: str | None = None, ) -> dict[str, Any]: soul = agent_souls.get_soul(agent_key) if not soul: return _insert_message( int(room["id"]), sender_type="system", content=f"Agent @{agent_key} niet gevonden.", metadata={"error": True}, ) hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT) messages: list[dict[str, str]] = [ {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, ] messages.extend(_history_as_chat(hist, limit=8)) cue = trigger if in_reply_to: cue = f"(Reageer op @{in_reply_to}) {trigger}" messages.append({"role": "user", "content": cue}) try: reply, meta = await _hermes_reply(messages, hermes_user=hermes_user) except Exception as exc: log.exception("agent speak failed %s", agent_key) return _insert_message( int(room["id"]), sender_type="system", content=f"@{agent_key}: Hermes/mimo fout — {exc}", metadata={"error": True, "agent_key": agent_key}, ) return _insert_message( int(room["id"]), sender_type="agent", agent_key=agent_key, content=reply.strip() or "(geen antwoord)", metadata={ "llm": meta, "in_reply_to": in_reply_to, "model": "hermes-agent/mimo", }, ) async def _orchestrate_warroom( room: dict[str, Any], user_text: str, *, hermes_user: str, username: str | None, ) -> list[dict[str, Any]]: """Herman facilitates; mentioned agents reply; limited A2A rounds.""" produced: list[dict[str, Any]] = [] mentions = parse_mentions(user_text) speakers = mentions[:] if not speakers: # Herman opens, then may suggest others — still let Herman speak first speakers = ["herman"] # Round 0: initial speakers in parallel (sneller bij meerdere @mentions) keys = speakers[:MAX_REPLIES_PER_ROUND] if len(keys) == 1: produced.append( await _agent_speak(room, keys[0], trigger=user_text, hermes_user=hermes_user) ) elif keys: produced.extend( await asyncio.gather( *[_agent_speak(room, k, trigger=user_text, hermes_user=hermes_user) for k in keys] ) ) # A2A follow-up rounds from @mentions in agent replies for _round in range(MAX_ROUNDS - 1): next_speakers: list[tuple[str, str]] = [] # (agent, replied_to) seen = {m.get("agent_key") for m in produced if m.get("sender_type") == "agent"} for m in produced[-MAX_REPLIES_PER_ROUND:]: if m.get("sender_type") != "agent": continue from_key = m.get("agent_key") or "" for target in parse_mentions(m.get("content") or ""): if target == from_key: continue if target in seen and (target, from_key) in [(a, b) for a, b in next_speakers]: continue next_speakers.append((target, from_key)) # unique preserve order uniq: list[tuple[str, str]] = [] used: set[str] = set() for t, src in next_speakers: if t in used: continue used.add(t) uniq.append((t, src)) uniq = uniq[:MAX_REPLIES_PER_ROUND] if not uniq: break for target, src in uniq: msg = await _agent_speak( room, target, trigger=( f"War Room topic van {username or 'user'}: {user_text}\n" f"@{src} noemde je. Reageer kort en concreet." ), hermes_user=hermes_user, in_reply_to=src, ) produced.append(msg) return produced def post_system_line(content: str, *, agent_key: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any]: """Proactive system/agent bubble into #war-room (no LLM).""" room = ensure_warroom() return _insert_message( int(room["id"]), sender_type="system" if not agent_key else "agent", agent_key=agent_key, content=content[:2000], metadata=metadata or {"proactive": True}, ) def announce_handoff(from_agent: str, to_agent: str, handoff_type: str = "partner", *, correlation_id: str | None = None) -> None: """Best-effort War Room line for scheduler/API handoffs.""" try: src = (from_agent or "").strip() dst = (to_agent or "").strip() if not src or not dst: return label = (handoff_type or "partner").strip() or "partner" text = f"Handoff: {src} → {dst} ({label})" post_system_line( text, agent_key=src, metadata={ "proactive": True, "kind": "handoff", "from_agent": src, "to_agent": dst, "handoff_type": label, "correlation_id": correlation_id, }, ) except Exception: log.exception("warroom announce_handoff failed") def _normalize_attachments(attachments: list[dict[str, Any]] | None) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for a in attachments or []: if not isinstance(a, dict): continue url = str(a.get("url") or "").strip() if not url.startswith("/static/uploads/warroom/"): continue out.append({ "url": url, "name": str(a.get("name") or "bestand")[:180], "mime": str(a.get("mime") or "application/octet-stream")[:120], "size": int(a.get("size") or 0), }) if len(out) >= 5: break return out def _trigger_text(text: str, attachments: list[dict[str, Any]]) -> str: if not attachments: return text names = ", ".join(a.get("name") or "bestand" for a in attachments) if text: return f"{text}\n\n[Bijlage(n): {names}]" return f"[Deelde bestand(en): {names}]" async def post_user_message( room_id: int, content: str, *, username: str | None = None, role: str | None = None, sender_avatar_url: str | None = None, attachments: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: text = (content or "").strip() files = _normalize_attachments(attachments) if not text and not files: raise ValueError("Leeg bericht") room = get_room(room_id) if not room: raise ValueError("Room niet gevonden") hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") meta: dict[str, Any] = {"hermes_user": hermes_user} if files: meta["attachments"] = files user_msg = _insert_message( room_id, sender_type="user", content=text or ("📎 " + ", ".join(a["name"] for a in files)), username=username, sender_avatar_url=sender_avatar_url, metadata=meta, ) trigger = _trigger_text(text, files) agent_msgs: list[dict[str, Any]] = [] kind = room.get("kind") try: if kind == "dm": key = room.get("agent_key") if not key: raise ValueError("DM zonder agent") agent_msgs.append( await _agent_speak(room, str(key), trigger=trigger, hermes_user=hermes_user) ) else: agent_msgs = await _orchestrate_warroom( room, trigger, hermes_user=hermes_user, username=username ) except Exception as exc: log.exception("warroom post failed") agent_msgs.append( _insert_message( room_id, sender_type="system", content=f"Hermes/mimo fout: {exc}", metadata={"error": True}, ) ) return { "ok": True, "room": room, "user_message": user_msg, "messages": [user_msg, *agent_msgs], "hermes_user": hermes_user, } async def _agent_speak_stream( room: dict[str, Any], agent_key: str, *, trigger: str, hermes_user: str, in_reply_to: str | None = None, ): """Yield ('start'|'token'|'done'|'error', payload).""" soul = agent_souls.get_soul(agent_key) if not soul: msg = _insert_message( int(room["id"]), sender_type="system", content=f"Agent @{agent_key} niet gevonden.", metadata={"error": True}, ) yield ("error", msg) return hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT) messages: list[dict[str, str]] = [ {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, ] messages.extend(_history_as_chat(hist, limit=8)) cue = trigger if in_reply_to: cue = f"(Reageer op @{in_reply_to}) {trigger}" messages.append({"role": "user", "content": cue}) yield ("start", {"agent_key": agent_key, "display_name": soul.get("display_name") or agent_key}) chunks: list[str] = [] try: async for delta in llm_router.stream_chat_messages( messages, timeout=WARROOM_TIMEOUT, user=hermes_user, max_tokens=WARROOM_MAX_TOKENS, temperature=0.35, ): chunks.append(delta) yield ("token", {"agent_key": agent_key, "delta": delta}) reply = "".join(chunks).strip() or "(geen antwoord)" msg = _insert_message( int(room["id"]), sender_type="agent", agent_key=agent_key, content=reply, metadata={ "llm": {"provider_type": "hermes", "hermes_user": hermes_user, "streamed": True}, "in_reply_to": in_reply_to, "model": "hermes-agent/mimo", }, ) yield ("done", msg) except Exception as exc: log.exception("agent stream failed %s", agent_key) msg = _insert_message( int(room["id"]), sender_type="system", content=f"@{agent_key}: Hermes/mimo fout — {exc}", metadata={"error": True, "agent_key": agent_key}, ) yield ("error", msg) async def post_user_message_stream( room_id: int, content: str, *, username: str | None = None, role: str | None = None, sender_avatar_url: str | None = None, attachments: list[dict[str, Any]] | None = None, ): """Async generator of SSE-ready event dicts for live typing.""" text = (content or "").strip() files = _normalize_attachments(attachments) if not text and not files: raise ValueError("Leeg bericht") room = get_room(room_id) if not room: raise ValueError("Room niet gevonden") hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") meta: dict[str, Any] = {"hermes_user": hermes_user} if files: meta["attachments"] = files user_msg = _insert_message( room_id, sender_type="user", content=text or ("📎 " + ", ".join(a["name"] for a in files)), username=username, sender_avatar_url=sender_avatar_url, metadata=meta, ) yield {"event": "user", "message": user_msg} trigger = _trigger_text(text, files) kind = room.get("kind") speakers: list[str] = [] if kind == "dm": key = room.get("agent_key") if key: speakers = [str(key)] else: speakers = parse_mentions(trigger) or ["herman"] speakers = speakers[:MAX_REPLIES_PER_ROUND] for key in speakers: async for kind_ev, payload in _agent_speak_stream( room, key, trigger=trigger, hermes_user=hermes_user ): if kind_ev == "start": yield {"event": "typing_start", **payload} elif kind_ev == "token": yield {"event": "typing", **payload} elif kind_ev == "done": yield {"event": "agent", "message": payload} elif kind_ev == "error": yield {"event": "system", "message": payload} # light A2A: one follow-up round max from last agent reply mentions # (non-streamed for simplicity / speed) yield {"event": "done", "ok": True}