590 lines
19 KiB
Python
590 lines
19 KiB
Python
|
|
"""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}
|