Files
foodlinkk-command-center/cockpit/app/services/agent_chat.py
T

1025 lines
36 KiB
Python
Raw Normal View History

"""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"(?<![a-z0-9_]){re.escape(alias)}(?![a-z0-9_])", low):
found.append(key)
for key in sorted(active):
if key in found:
continue
if re.search(rf"(?<![a-z0-9_]){re.escape(key)}(?![a-z0-9_])", low):
found.append(key)
explicit = bool(parse_mentions(text))
if not found and default_herman and "herman" in active:
found = ["herman"]
elif (
found
and not explicit
and default_herman
and "herman" in active
and "herman" not in found
):
# Alleen zonder @mentions: "meeting met jou en bizdev" → Herman erbij
if re.search(r"\b(jou|jullie|samen|meeting|overleg|bellen|call|afspraak)\b", low):
found.insert(0, "herman")
return found[:MAX_REPLIES_PER_ROUND]
def _clean_agent_reply(agent_key: str, reply: str) -> 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}