"""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 = 4 MAX_REPLIES_PER_ROUND = 3 HISTORY_LIMIT = 16 WARROOM_MAX_TOKENS = 512 WARROOM_TIMEOUT = 55.0 A2A_CONTINUE_MAX = 3 COCKPIT_SNAPSHOT_TTL = 60.0 _snapshot_cache: dict[str, Any] = {"at": 0.0, "text": ""} def cockpit_snapshot(*, max_chars: int = 2200) -> str: """Compact live cockpit facts for War Room agents — accuracy first.""" import time now = time.time() if _snapshot_cache.get("text") and (now - float(_snapshot_cache.get("at") or 0)) < COCKPIT_SNAPSHOT_TTL: return str(_snapshot_cache["text"]) lines: list[str] = [ "COCKPIT FEITEN (alleen dit mag je citeren; verzin niets erbuiten):", "SCHEIDING: CRM-klanten/deals ≠ retail-supermarktlocaties. Nooit CRM-namen als AH-filiaal presenteren.", ] # CRM try: row = fetch_one( """SELECT (SELECT COUNT(*) FROM clients) AS clients, (SELECT COUNT(*) FROM deals) AS deals, (SELECT COALESCE(SUM(value),0) FROM deals WHERE stage NOT IN ('won','lost')) AS pipeline """ ) or {} lines.append( f"- CRM: {row.get('clients', 0)} klanten · {row.get('deals', 0)} deals · " f"pipeline €{float(row.get('pipeline') or 0):,.0f}" ) deals = fetch_all( """SELECT d.title, d.stage, d.value, c.name AS client_name FROM deals d LEFT JOIN clients c ON c.id = d.client_id WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC NULLS LAST LIMIT 6""" ) or [] if deals: bits = [] for d in deals: bits.append( f"{d.get('client_name') or d.get('title') or '?'}[{d.get('stage')}" f"/€{float(d.get('value') or 0):,.0f}]" ) lines.append("- Open deals: " + "; ".join(bits)) clients = fetch_all("SELECT name, stage FROM clients ORDER BY updated_at DESC NULLS LAST LIMIT 6") or [] if clients: lines.append( "- CRM-klanten: " + ", ".join(f"{c.get('name')}({c.get('stage')})" for c in clients) ) except Exception as exc: log.warning("snapshot CRM failed: %s", exc) lines.append("- CRM: (niet beschikbaar)") # Real AH / retail opportunities — exclude test rows try: ah = fetch_all( """ SELECT s.id, s.name, s.address, s.postcode, s.city, ros.halal_opportunity_score AS score, COALESCE(s.partnership_status, ros.factors->>'partnership_status', 'none') AS status FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id WHERE s.chain ILIKE '%albert%heijn%' AND s.name NOT ILIKE '%masala%' AND s.name NOT ILIKE '%TEST%' AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%' ORDER BY ros.halal_opportunity_score DESC NULLS LAST, s.city, s.postcode LIMIT 8 """ ) or [] if ah: lines.append("- Top Albert Heijn (halal-kans score, echte filialen):") for s in ah: lines.append( f" · AH {s.get('address')}, {s.get('postcode')} {s.get('city')} " f"— score {float(s.get('score') or 0):.1f} · status {s.get('status')}" ) else: lines.append("- Top Albert Heijn: geen geldige scores (geen testrecords).") except Exception as exc: log.warning("snapshot AH failed: %s", exc) lines.append("- Top Albert Heijn: (niet beschikbaar)") text = "\n".join(lines)[:max_chars] _snapshot_cache["at"] = now _snapshot_cache["text"] = text return text 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 AND deleted_at IS NULL 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 AND deleted_at IS NULL 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 delete_message(room_id: int, message_id: int) -> dict[str, Any] | None: """Soft-delete a warroom message. Returns the row or None if missing/already deleted.""" row = fetch_one( """UPDATE warroom_messages SET deleted_at = NOW(), content = '', metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb WHERE id = %s AND room_id = %s AND deleted_at IS NULL RETURNING *""", (message_id, room_id), ) if row: execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,)) return _iso(row) if row else None # type: ignore def list_deleted_ids(room_id: int) -> list[int]: """Recently soft-deleted message ids (for live UI sync across clients).""" rows = fetch_all( """SELECT id FROM warroom_messages WHERE room_id = %s AND deleted_at IS NOT NULL AND deleted_at > NOW() - INTERVAL '6 hours' ORDER BY deleted_at DESC LIMIT 200""", (room_id,), ) return [int(r["id"]) for r in rows] 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 _live_roster_line() -> str: keys = sorted(_active_agent_keys()) return ", ".join(keys) if keys else "(geen)" 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 "")[:700] resp = (soul.get("responsibilities") or "")[:350] roster = _live_roster_line() hard = ( "HARDE FEITEN (overschrijven alle eerdere aannames/geheugen):\n" f"- LIVE ROSTER ONLINE: {roster}.\n" "- Alle agents hierboven zijn NU actief in de War Room / cockpit.\n" "- Verboden zinnen: 'staat uit', 'niet actief', 'niet beschikbaar', 'weer inschakelen', " "'weer activeren', 'alleen Halal is actief', 'agent is offline'.\n" "- Als je twijfelt: behandel elke agent op de roster als online en antwoord inhoudelijk.\n" ) snap = cockpit_snapshot() accuracy = ( "NAUWKEURIGHEID (hard):\n" "- Antwoord ALLEEN met feiten uit COCKPIT FEITEN hieronder of uit recente War Room-berichten.\n" "- Weet je het niet 100% zeker: zeg expliciet 'Dat staat zo niet in de cockpit' en @mention de specialist " "(@retail / @bizdev / @finance). Verzin GEEN locaties, scores of klantnamen.\n" "- CRM-klanten (Reesha, MELEDI, …) zijn GEEN Albert Heijn-filialen.\n" "- Negeer testdata / namen met Masala, TEST, of test_exclude.\n" "- Geen aannames uit Telegram-geheugen als die botsen met COCKPIT FEITEN.\n" ) if (key or "").lower() == "herman": accuracy += ( "- Jij bent orchestrator: bij feitelijke retail/CRM-vragen eerst @retail of @bizdev laten antwoorden, " "of alleen samenvatten wat zij/data al zeiden. Liever geen antwoord dan een verkeerd antwoord.\n" ) extra = ( accuracy + "War Room-regels:\n" "- Max 4 korte zinnen, Nederlands.\n" "- Geen @zelf-prefix / geen herhalingen.\n" "- Peer @mentions oké; zeg nooit dat agents uit staan.\n" f"{snap}\n" if room_kind == "warroom" else accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n" ) return ( f"{hard}\n" f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n" f"Spreek ALLEEN als {name} — niet als een andere agent.\n" f"Focus: {resp or '—'}\n" f"{soul_md}\n\n{extra}" ) _OFFLINE_CLAIM_RE = re.compile( r"(staat\s+uit|niet\s+actief|niet\s+beschikbaar|weer\s+inschakel|weer\s+activeer|" r"agent\s+is\s+niet|alleen\s+halal|offline\s+in\s+cockpit|uit\s+in\s+cockpit|" r"geen\s+actieve\s+\w*\s*agent)", re.I, ) _BAD_TEST_NAME_RE = re.compile(r"chai\s*n\s*masala|masala\s*regio", re.I) def _sanitize_warroom_reply(agent_key: str, reply: str) -> str: text = _clean_agent_reply(agent_key, reply) if _OFFLINE_CLAIM_RE.search(text or ""): roster = _live_roster_line() return ( f"Correctie: alle War Room-agents zijn online ({roster}). " "Zeg kort wat je wél kunt doen — geen 'staat uit'-praat." ) if _BAD_TEST_NAME_RE.search(text or ""): return ( "Correctie: 'Chai N Masala' is testdata, geen echte Albert Heijn. " "Voor AH-locaties alleen echte filialen uit de cockpit-supermarktlijst gebruiken " "(adres + postcode + halal-score). @retail kan de actuele top-lijst geven." ) return text _ALIAS_MAP = { "herman": "herman", "bizdev": "bizdev", "biz": "bizdev", "business": "bizdev", "finance": "finance", "financien": "finance", "financiën": "finance", "cfo": "finance", "marketing": "marketing", "retail": "retail", "product": "product", "sourcing": "sourcing", "sysops": "sysops", "hr": "hr", "halal": "halal", "design": "design", "browser": "browser", "email": "email", "knowledge": "knowledge", "packaging": "packaging", "research": "research", "sales": "sales", } def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]: """@mentions + soft name matches (bizdev/finance zonder @).""" active = _active_agent_keys() found: list[str] = [] for key in parse_mentions(text): if key in active and key not in found: found.append(key) low = (text or "").lower() # Expliciete @mentions winnen — voorkomt dat 'Herman' in een zin extra speakers toevoegt if not found: for alias, key in _ALIAS_MAP.items(): if key not in active or key in found: continue if re.search(rf"(? str: text = (reply or "").strip() # strip @handle / Name / **Name** prefixes (incl. wrong Herman: from memory bleed) for _ in range(4): text2 = re.sub( r"^\*{0,2}\s*@?[A-Za-z][\w .:-]{0,40}?\*{0,2}\s*:\s*", "", text, count=1, ).strip() if text2 == text: break text = text2 return text or "(geen antwoord)" def _agent_hermes_user(base_user: str, agent_key: str) -> str: """Separate Hermes session per agent — avoids Herman-memory bleed into BizDev/Finance.""" base = (base_user or "warroom").strip() or "warroom" key = (agent_key or "agent").strip().lower() or "agent" return f"{base}:wr:{key}" 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)) roster = _live_roster_line() trig_l = (trigger or "").lower() addressed = f"@{agent_key}".lower() in trig_l if addressed: address_line = ( f"Je bent DIRECT aangesproken als @{agent_key}. " "Antwoord ZELF in de War Room — niet doorverwijzen naar een andere agent." ) elif in_reply_to: address_line = f"Reageer op @{in_reply_to}." else: address_line = f"Beantwoord als @{agent_key} in de War Room." cue = ( f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" f"[{address_line}]\n" f"{trigger}" ) if in_reply_to: cue = ( f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" f"(Reageer op @{in_reply_to}) {trigger}" ) messages.append({"role": "user", "content": cue}) agent_user = _agent_hermes_user(hermes_user, agent_key) try: reply, meta = await _hermes_reply(messages, hermes_user=agent_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=_sanitize_warroom_reply(agent_key, reply), metadata={ "llm": meta, "in_reply_to": in_reply_to, "model": "hermes-agent/mimo", }, ) def _next_a2a_speakers( produced: list[dict[str, Any]], *, exclude: set[str] | None = None, ) -> list[tuple[str, str]]: """From recent agent messages, find @targets to speak next.""" exclude = exclude or set() next_speakers: list[tuple[str, str]] = [] recent = [m for m in produced if m.get("sender_type") == "agent"][-MAX_REPLIES_PER_ROUND:] for m in recent: from_key = str(m.get("agent_key") or "") for target in parse_mentions(m.get("content") or ""): if not target or target == from_key or target in exclude: continue next_speakers.append((target, from_key)) # If nobody @mentioned, Herman can nudge a relevant peer once 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)) return uniq[:MAX_REPLIES_PER_ROUND] 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]] = [] speakers = resolve_speakers(user_text, default_herman=True) # 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)) roster = _live_roster_line() trig_l = (trigger or "").lower() addressed = f"@{agent_key}".lower() in trig_l if addressed: address_line = ( f"Je bent DIRECT aangesproken als @{agent_key}. " "Antwoord ZELF in de War Room — niet doorverwijzen naar een andere agent." ) elif in_reply_to: address_line = f"Reageer op @{in_reply_to}." else: address_line = f"Beantwoord als @{agent_key} in de War Room." cue = ( f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" f"[{address_line}]\n" f"{trigger}" ) if in_reply_to: cue = ( f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" f"(Reageer op @{in_reply_to}) {trigger}" ) messages.append({"role": "user", "content": cue}) agent_user = _agent_hermes_user(hermes_user, agent_key) 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=agent_user, max_tokens=WARROOM_MAX_TOKENS, temperature=0.35, ): chunks.append(delta) yield ("token", {"agent_key": agent_key, "delta": delta}) reply = _sanitize_warroom_reply(agent_key, "".join(chunks)) msg = _insert_message( int(room["id"]), sender_type="agent", agent_key=agent_key, content=reply, metadata={ "llm": {"provider_type": "hermes", "hermes_user": agent_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 = resolve_speakers(trigger, default_herman=True) produced: list[dict[str, Any]] = [] async def _emit_speak(key: str, *, trig: str, reply_to: str | None = None): async for kind_ev, payload in _agent_speak_stream( room, key, trigger=trig, hermes_user=hermes_user, in_reply_to=reply_to ): if kind_ev == "start": yield {"event": "typing_start", **payload} elif kind_ev == "token": yield {"event": "typing", **payload} elif kind_ev == "done": produced.append(payload) yield {"event": "agent", "message": payload} elif kind_ev == "error": produced.append(payload) yield {"event": "system", "message": payload} for key in speakers: async for ev in _emit_speak(key, trig=trigger): yield ev # A2A rounds: agents @mention each other and answer with cockpit data for _round in range(MAX_ROUNDS - 1): nxt = _next_a2a_speakers(produced) if not nxt: # Force a light peer handoff so the room stays collaborative last_agents = [m.get("agent_key") for m in produced if m.get("sender_type") == "agent"] last = str(last_agents[-1]) if last_agents else (speakers[0] if speakers else "herman") peers = [k for k in ("bizdev", "finance", "retail", "marketing", "herman") if k != last and k in _active_agent_keys()] if not peers: break target = peers[_round % len(peers)] nudge = ( f"War Room topic van {username or 'user'}: {trigger}\n" f"@{last} heeft gesproken. Jij bent @{target}. Stel 1 korte vraag aan een peer OF geef 1 cockpit-feit + @mention wie moet antwoorden." ) async for ev in _emit_speak(target, trig=nudge, reply_to=last): yield ev continue for target, src in nxt: follow = ( f"War Room topic van {username or 'user'}: {trigger}\n" f"@{src} noemde je. Antwoord met cockpit-data en @mention zo nodig een andere agent." ) async for ev in _emit_speak(target, trig=follow, reply_to=src): yield ev yield {"event": "done", "ok": True, "a2a": True} async def continue_warroom_stream( room_id: int, *, username: str | None = None, role: str | None = None, max_turns: int = A2A_CONTINUE_MAX, ): """Autonomous continuation: agents keep talking using last topic + cockpit data.""" room = get_room(room_id) if not room: raise ValueError("Room niet gevonden") if room.get("kind") == "dm": raise ValueError("Continue alleen in #war-room") hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") recent = list_messages(room_id, limit=16) if not recent: raise ValueError("Geen gesprek om te vervolgen") # Find last user topic topic = "" for m in reversed(recent): if m.get("sender_type") == "user": topic = (m.get("content") or "").strip() break if not topic: topic = "Blijf het Foodlinkk cockpit-dossier bespreken (pipeline, deals, retail, finance)." produced = [m for m in recent if m.get("sender_type") == "agent"][-6:] yield {"event": "continue_start", "topic": topic[:200]} for turn in range(max(1, min(int(max_turns), 5))): nxt = _next_a2a_speakers(produced) if not nxt: last = str((produced[-1] or {}).get("agent_key") or "herman") if produced else "herman" peers = [k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product") if k != last and k in _active_agent_keys()] if not peers: break target = peers[turn % len(peers)] src = last trig = ( f"Autonoom War Room-overleg (user kan meelezen). Topic: {topic}\n" f"Jij bent @{target}. Reageer op @{src} met 1 cockpit-feit en @mention 1 collega met een gerichte vraag." ) else: target, src = nxt[0] trig = ( f"Autonoom War Room-overleg. Topic: {topic}\n" f"@{src} vroeg jouw input. Antwoord met cockpit-data en @mention desnoods een andere agent." ) async for kind_ev, payload in _agent_speak_stream( room, target, trigger=trig, hermes_user=hermes_user, in_reply_to=src ): if kind_ev == "start": yield {"event": "typing_start", **payload} elif kind_ev == "token": yield {"event": "typing", **payload} elif kind_ev == "done": produced.append(payload) yield {"event": "agent", "message": payload} elif kind_ev == "error": yield {"event": "system", "message": payload} return yield {"event": "done", "ok": True, "continued": True}