2026-08-16 11:01:58 +00:00
"""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
2026-08-16 22:40:32 +00:00
from app.services import agent_nas , agent_souls , hermes_memory , llm_router
2026-08-16 11:01:58 +00:00
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})" )
2026-08-16 15:49:16 +00:00
MAX_ROUNDS = 4
2026-08-16 11:01:58 +00:00
MAX_REPLIES_PER_ROUND = 3
HISTORY_LIMIT = 16
WARROOM_MAX_TOKENS = 512
WARROOM_TIMEOUT = 55.0
2026-08-16 22:40:32 +00:00
A2A_CONTINUE_MAX = 1
2026-08-16 15:49:16 +00:00
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 ' %a lbert %he ijn%'
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
2026-08-16 11:01:58 +00:00
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
2026-08-16 22:40:32 +00:00
def _bootstrap_agents_nas () -> None :
try :
keys = [ str ( s . get ( "agent_key" )) for s in agent_souls . list_souls ()]
agent_nas . bootstrap_all ( keys )
except Exception :
log . exception ( "agents NAS bootstrap failed" )
2026-08-16 11:01:58 +00:00
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
2026-08-16 22:40:32 +00:00
def get_message ( message_id : int , room_id : int | None = None ) -> dict [ str , Any ] | None :
if room_id is not None :
return _iso ( fetch_one (
"SELECT * FROM warroom_messages WHERE id = %s AND room_id = %s AND deleted_at IS NULL" ,
( message_id , room_id ),
))
return _iso ( fetch_one (
"SELECT * FROM warroom_messages WHERE id = %s AND deleted_at IS NULL" ,
( message_id ,),
))
2026-08-16 11:01:58 +00:00
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
2026-08-16 15:49:16 +00:00
WHERE room_id = %s AND id > %s AND deleted_at IS NULL
2026-08-16 11:01:58 +00:00
ORDER BY created_at ASC, id ASC LIMIT %s """ ,
( room_id , after_id , limit ),
)
else :
rows = fetch_all (
"""SELECT * FROM (
2026-08-16 15:49:16 +00:00
SELECT * FROM warroom_messages
WHERE room_id = %s AND deleted_at IS NULL
2026-08-16 11:01:58 +00:00
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
2026-08-16 15:49:16 +00:00
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
2026-08-16 22:40:32 +00:00
def clear_messages ( room_id : int ) -> int :
"""Soft-delete all messages in a room. Returns count cleared."""
rows = fetch_all (
"""UPDATE warroom_messages
SET deleted_at = NOW(),
content = '',
metadata = COALESCE(metadata, ' {} '::jsonb) || '{"deleted": true, "cleared": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
RETURNING id""" ,
( room_id ,),
)
execute ( "UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s " , ( room_id ,))
return len ( rows or [])
2026-08-16 15:49:16 +00:00
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 ]
2026-08-16 11:01:58 +00:00
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 )}
2026-08-16 22:40:32 +00:00
CEO_ALIASES = { "aissa" , "ceo" , "eigenaar" , "ervoorz" } # ervoorz = legacy alias
CEO_MENTION_RE = re . compile ( r "@([A-Za-z0-9_\-]{2,40})" , re . I )
def parse_ceo_mentions ( text : str ) -> bool :
"""True if message is directed at CEO via @aissa (legacy: @ervoorz / @ceo)."""
for m in CEO_MENTION_RE . findall ( text or "" ):
if m . lower () in CEO_ALIASES :
return True
return False
def _ceo_excerpt ( text : str , limit : int = 280 ) -> str :
t = ( text or "" ) . strip ()
if len ( t ) <= limit :
return t
return t [: limit - 1 ] + "…"
def record_ceo_direct ( message_row : dict [ str , Any ] | None ) -> dict [ str , Any ] | None :
"""If an agent message @mentions CEO, store in ceo inbox."""
if not message_row or message_row . get ( "sender_type" ) != "agent" :
return None
content = message_row . get ( "content" ) or ""
if not parse_ceo_mentions ( content ):
return None
ensure_warroom ()
# ensure table exists (idempotent)
try :
execute (
"""CREATE TABLE IF NOT EXISTS warroom_ceo_inbox (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT,
room_id BIGINT,
agent_key VARCHAR(64),
content TEXT NOT NULL,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
recipient TEXT NOT NULL DEFAULT 'aissa'
)"""
)
except Exception :
pass
row = fetch_one (
"""INSERT INTO warroom_ceo_inbox (message_id, room_id, agent_key, content, excerpt, recipient)
VALUES ( %s , %s , %s , %s , %s , 'aissa')
RETURNING *""" ,
(
message_row . get ( "id" ),
message_row . get ( "room_id" ),
message_row . get ( "agent_key" ),
content ,
_ceo_excerpt ( content ),
),
)
return _iso ( row ) if row else None
def list_ceo_inbox ( * , unread_only : bool = False , limit : int = 50 ) -> list [ dict [ str , Any ]]:
ensure_warroom ()
try :
if unread_only :
rows = fetch_all (
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa' AND i.read_at IS NULL
ORDER BY i.created_at DESC LIMIT %s """ ,
( limit ,),
)
else :
rows = fetch_all (
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa'
ORDER BY i.created_at DESC LIMIT %s """ ,
( limit ,),
)
except Exception :
return []
return [ _iso ( r ) for r in ( rows or [])] # type: ignore
def ceo_inbox_unread_count () -> int :
try :
row = fetch_one (
"""SELECT COUNT(*) AS n FROM warroom_ceo_inbox
WHERE recipient = 'aissa' AND read_at IS NULL"""
)
return int (( row or {}) . get ( "n" ) or 0 )
except Exception :
return 0
def mark_ceo_inbox_read ( ids : list [ int ] | None = None , * , all_unread : bool = False ) -> int :
if all_unread :
rows = fetch_all (
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND read_at IS NULL
RETURNING id"""
)
return len ( rows or [])
if not ids :
return 0
rows = fetch_all (
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND id = ANY( %s ) AND read_at IS NULL
RETURNING id""" ,
( ids ,),
)
return len ( rows or [])
2026-08-16 11:01:58 +00:00
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
2026-08-16 15:49:16 +00:00
def _live_roster_line () -> str :
keys = sorted ( _active_agent_keys ())
return ", " . join ( keys ) if keys else "(geen)"
2026-08-16 11:01:58 +00:00
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"
2026-08-16 22:40:32 +00:00
soul_md = ( soul . get ( "soul_md" ) or "" )[: 1600 ]
2026-08-16 15:49:16 +00:00
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 "
)
2026-08-16 22:40:32 +00:00
if room_kind == "warroom" :
extra = (
accuracy
+ "War Room-regels: \n "
+ "- Max 4 korte zinnen, Nederlands. \n "
+ "- Geen @zelf-prefix / geen herhalingen. \n "
+ "- Peer @mentions verplicht: eindig met @iemand + gerichte vraag of reactie. \n "
+ "- Zeg nooit dat agents uit staan. \n "
+ "- Menselijk & echt: reageer concreet op de vorige spreker (feit, mop of vraag). \n "
+ "- Lichte food-moppen / Foodlinkk-humor mag (respectvol, geen geloof/politiek/grof). \n "
+ "- Mix: meestal inhoud, af en toe 1 mop — daarna weer professioneel. \n "
+ "- Na een mop van een collega: lach kort terug of bouw erop voort, en @mention door. \n "
+ "- CEO heet Aissa. Spreek hem normaal aan met 'Aissa' (of je/jij). \n "
+ "- Volg Aissa's laatste input als prioriteit. Bouw daarop voort, niet ernaast. \n "
+ "- Escalatie naar CEO-inbox: gebruik @aissa + concrete vraag/actie (alleen als echt nodig). \n "
+ "- Goed: 'Aissa, hier is de status…' of '@aissa we hebben jouw go nodig voor X'. \n "
+ "- Gebruik NOOIT @ervoorz (verouderd/verwarrend). \n "
+ "- Nodig collega's uit: @mention wie iets kan toevoegen (finance/bizdev/retail/…). \n "
+ f " { snap } \n "
)
else :
extra = accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten. \n "
2026-08-16 11:01:58 +00:00
return (
2026-08-16 15:49:16 +00:00
f " { hard } \n "
2026-08-16 11:01:58 +00:00
f "Je bent { name } (@ { key } ), { role } bij Foodlinkk. \n "
2026-08-16 15:49:16 +00:00
f "Spreek ALLEEN als { name } — niet als een andere agent. \n "
2026-08-16 11:01:58 +00:00
f "Focus: { resp or '—' } \n "
2026-08-16 22:40:32 +00:00
f " { soul_md } \n\n { extra } \n\n { agent_nas . memory_rule_text () } " f "- Jouw NAS-werkmap: { agent_nas . agent_paths ( str ( key ))[ 'smb' ] } \n "
2026-08-16 11:01:58 +00:00
)
2026-08-16 15:49:16 +00:00
_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" ,
}
2026-08-16 22:40:32 +00:00
def _topic_contributors (
text : str ,
* ,
exclude : set [ str ] | None = None ,
limit : int = 2 ,
) -> list [ str ]:
"""Pick agents who can meaningfully add to the topic (beyond Herman)."""
exclude = set ( exclude or set ())
active = _active_agent_keys ()
low = ( text or "" ) . lower ()
rules : list [ tuple [ tuple [ str , ... ], str ]] = [
(( "omzet" , "marge" , "kosten" , "factuur" , "cash" , "btw" , "budget" , "winst" , "prijs" ), "finance" ),
(( "klant" , "deal" , "pipeline" , "lead" , "offerte" , "sales" , "partner" , "b2b" ), "bizdev" ),
(( "retail" , "supermarkt" , "schap" , "ah " , "jumbo" , "plus " , "lidl" , "listing" ), "retail" ),
(( "campagne" , "merk" , "content" , "social" , "branding" , "advert" ), "marketing" ),
(( "sku" , "assortiment" , "recept" , "product" , "formul" ), "product" ),
(( "inkoop" , "leverancier" , "sourcing" , "moq" , "supplier" ), "sourcing" ),
(( "halal" , "certific" , "kosher" ), "halal" ),
(( "verpakking" , "label" , "packaging" , "design" ), "packaging" ),
(( "onderzoek" , "marktanalyse" , "research" , "concurrent" ), "research" ),
(( "hr" , "personeel" , "werving" , "team" ), "hr" ),
(( "sysops" , "server" , "deploy" , "infra" , "bug" ), "sysops" ),
(( "web" , "site" , "landing" , "seo" ), "webbuilder" ),
(( "kennis" , "document" , "wiki" ), "knowledge" ),
(( "email" , "mail" , "newsletter" ), "email" ),
(( "browser" , "scrape" , "website check" ), "browser" ),
]
picked : list [ str ] = []
for words , key in rules :
if key in exclude or key not in active or key in picked :
continue
if any ( w in low for w in words ):
picked . append ( key )
if len ( picked ) >= limit :
return picked
# fallback: rotate through core team not yet speaking
core = [ "bizdev" , "finance" , "retail" , "marketing" , "product" , "sourcing" , "research" ]
for key in core :
if key in exclude or key not in active or key in picked :
continue
picked . append ( key )
if len ( picked ) >= limit :
break
return picked
2026-08-16 15:49:16 +00:00
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" )
2026-08-16 22:40:32 +00:00
# Zonder expliciete @mentions: laat 1– 2 relevante agents meedoen naast Herman
if found and not explicit :
for extra in _topic_contributors ( text , exclude = set ( found ), limit = 2 ):
if extra not in found :
found . append ( extra )
2026-08-16 15:49:16 +00:00
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 } "
2026-08-16 11:01:58 +00:00
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 ))
2026-08-16 15:49:16 +00:00
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 } "
)
2026-08-16 11:01:58 +00:00
if in_reply_to :
2026-08-16 15:49:16 +00:00
cue = (
f "[LIVE ROSTER ONLINE: { roster } . Nooit zeggen dat een agent uit/offline staat.] \n "
f "(Reageer op @ { in_reply_to } ) { trigger } "
)
2026-08-16 11:01:58 +00:00
messages . append ({ "role" : "user" , "content" : cue })
2026-08-16 15:49:16 +00:00
agent_user = _agent_hermes_user ( hermes_user , agent_key )
2026-08-16 11:01:58 +00:00
try :
2026-08-16 15:49:16 +00:00
reply , meta = await _hermes_reply ( messages , hermes_user = agent_user )
2026-08-16 11:01:58 +00:00
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 ,
2026-08-16 15:49:16 +00:00
content = _sanitize_warroom_reply ( agent_key , reply ),
2026-08-16 11:01:58 +00:00
metadata = {
"llm" : meta ,
"in_reply_to" : in_reply_to ,
"model" : "hermes-agent/mimo" ,
},
)
2026-08-16 15:49:16 +00:00
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 ]
2026-08-16 11:01:58 +00:00
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 ]] = []
2026-08-16 15:49:16 +00:00
speakers = resolve_speakers ( user_text , default_herman = True )
2026-08-16 11:01:58 +00:00
# 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 ,
2026-08-16 22:40:32 +00:00
reply_to_id : int | None = None ,
2026-08-16 11:01:58 +00:00
) -> 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
2026-08-16 22:40:32 +00:00
if reply_to_id :
parent = get_message ( int ( reply_to_id ), room_id )
if parent :
excerpt = ( parent . get ( "content" ) or "" ) . strip ()
if len ( excerpt ) > 220 :
excerpt = excerpt [: 219 ] + "…"
meta [ "reply_to" ] = {
"id" : parent . get ( "id" ),
"agent_key" : parent . get ( "agent_key" ),
"username" : parent . get ( "username" ),
"sender_type" : parent . get ( "sender_type" ),
"excerpt" : excerpt ,
}
2026-08-16 11:01:58 +00:00
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 ))
2026-08-16 15:49:16 +00:00
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 } "
)
2026-08-16 11:01:58 +00:00
if in_reply_to :
2026-08-16 15:49:16 +00:00
cue = (
f "[LIVE ROSTER ONLINE: { roster } . Nooit zeggen dat een agent uit/offline staat.] \n "
f "(Reageer op @ { in_reply_to } ) { trigger } "
)
2026-08-16 11:01:58 +00:00
messages . append ({ "role" : "user" , "content" : cue })
2026-08-16 15:49:16 +00:00
agent_user = _agent_hermes_user ( hermes_user , agent_key )
2026-08-16 11:01:58 +00:00
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 ,
2026-08-16 15:49:16 +00:00
user = agent_user ,
2026-08-16 11:01:58 +00:00
max_tokens = WARROOM_MAX_TOKENS ,
temperature = 0.35 ,
):
chunks . append ( delta )
yield ( "token" , { "agent_key" : agent_key , "delta" : delta })
2026-08-16 15:49:16 +00:00
reply = _sanitize_warroom_reply ( agent_key , "" . join ( chunks ))
2026-08-16 22:40:32 +00:00
meta = {
"llm" : { "provider_type" : "hermes" , "hermes_user" : agent_user , "streamed" : True },
"in_reply_to" : in_reply_to ,
"model" : "hermes-agent/mimo" ,
}
if parse_ceo_mentions ( reply ):
meta [ "ceo_direct" ] = True
meta [ "ceo_alias" ] = "aissa"
2026-08-16 11:01:58 +00:00
msg = _insert_message (
int ( room [ "id" ]),
sender_type = "agent" ,
agent_key = agent_key ,
content = reply ,
2026-08-16 22:40:32 +00:00
metadata = meta ,
2026-08-16 11:01:58 +00:00
)
2026-08-16 22:40:32 +00:00
try :
inbox = record_ceo_direct ( msg )
if inbox :
msg = dict ( msg )
msg [ "ceo_inbox_id" ] = inbox . get ( "id" )
except Exception :
log . exception ( "ceo inbox record failed" )
2026-08-16 11:01:58 +00:00
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 ,
2026-08-16 22:40:32 +00:00
reply_to_id : int | None = None ,
2026-08-16 11:01:58 +00:00
):
"""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
2026-08-16 22:40:32 +00:00
parent = None
if reply_to_id :
parent = get_message ( int ( reply_to_id ), room_id )
if parent :
excerpt = ( parent . get ( "content" ) or "" ) . strip ()
if len ( excerpt ) > 220 :
excerpt = excerpt [: 219 ] + "…"
meta [ "reply_to" ] = {
"id" : parent . get ( "id" ),
"agent_key" : parent . get ( "agent_key" ),
"username" : parent . get ( "username" ),
"sender_type" : parent . get ( "sender_type" ),
"excerpt" : excerpt ,
}
2026-08-16 11:01:58 +00:00
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 )
2026-08-16 22:40:32 +00:00
if parent :
who = parent . get ( "agent_key" ) or parent . get ( "username" ) or "bericht"
excerpt = ( meta . get ( "reply_to" ) or {}) . get ( "excerpt" ) or ""
trigger = (
f "CEO Aissa REPLY op bericht van @ { who } : \n "
f ' \" { excerpt } \"\n\n '
f "Antwoord van Aissa: { trigger } \n\n "
f "BELANGRIJK: reageer DIRECT op dit reply van Aissa. Geen losse A2A-chat."
)
2026-08-16 11:01:58 +00:00
kind = room . get ( "kind" )
speakers : list [ str ] = []
if kind == "dm" :
key = room . get ( "agent_key" )
if key :
speakers = [ str ( key )]
2026-08-16 22:40:32 +00:00
elif parent and parent . get ( "sender_type" ) == "agent" and parent . get ( "agent_key" ):
# reply to agent → die agent eerst, max 1 extra contributor
primary = str ( parent . get ( "agent_key" ))
speakers = [ primary ]
for extra in _topic_contributors ( text , exclude = { primary }, limit = 1 ):
speakers . append ( extra )
2026-08-16 11:01:58 +00:00
else :
2026-08-16 22:40:32 +00:00
# op user-bericht: max 2 speakers (Herman + 1 relevant) — geen storm
speakers = resolve_speakers ( trigger , default_herman = True )[: 2 ]
2026-08-16 15:49:16 +00:00
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 }
2026-08-16 11:01:58 +00:00
2026-08-16 22:40:32 +00:00
user_focus = (
f "Prioriteit: beantwoord EERST het bericht van { username or 'Aissa' } . "
f "Spreek hem aan als Aissa. "
f "Voor escalatie naar CEO-inbox: @aissa + concrete vraag. Daarna optioneel 1 @mention naar een collega."
)
2026-08-16 11:01:58 +00:00
for key in speakers :
2026-08-16 22:40:32 +00:00
async for ev in _emit_speak ( key , trig = f " { trigger } \n\n { user_focus } " ):
2026-08-16 15:49:16 +00:00
yield ev
2026-08-16 22:40:32 +00:00
# Max 1 A2A follow-up — alleen bij expliciete @mention (geen geforceerde storm)
nxt = _next_a2a_speakers ( produced )
for target , src in nxt [: 1 ]:
follow = (
f "Bericht van { username or 'Aissa' } : { text } \n "
f "@ { src } noemde je. Reageer kort op @ { src } EN blijf relevant voor Aissa's bericht. "
f "Geen ellenlange ketting."
)
async for ev in _emit_speak ( target , trig = follow , reply_to = src ):
yield ev
2026-08-16 15:49:16 +00:00
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 :
2026-08-16 22:40:32 +00:00
# stil kanaal: toch A2A kickstarten via Herman
topic = "Open A2A check-in: korte status + lichte food-mop, daarna @mention een collega."
produced = []
yield { "event" : "continue_start" , "topic" : topic }
async for kind_ev , payload in _agent_speak_stream (
room , "herman" , trigger = (
"Het is stil in de War Room. Start A2A: 1 korte professionele check-in "
"of milde food-mop. Spreek de CEO aan als Aissa; escalatie via @aissa. @mention @bizdev of @retail."
), hermes_user = hermes_user , in_reply_to = None
):
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 }
yield { "event" : "done" , "ok" : True , "continued" : True }
return
2026-08-16 15:49:16 +00:00
# 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 :
2026-08-16 22:40:32 +00:00
topic = "Volg Aissa's input als leidraad; spreek hem aan als Aissa; wissel cockpit-dossier met lichte humor; escaleer alleen met @aissa als echt nodig."
2026-08-16 15:49:16 +00:00
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"
2026-08-16 22:40:32 +00:00
spoken = { str ( m . get ( "agent_key" ) or "" ) for m in produced }
spoken . add ( last )
contrib = _topic_contributors ( topic , exclude = spoken , limit = 2 )
peers = contrib or [
k for k in (
"herman" , "bizdev" , "finance" , "retail" , "marketing" ,
"product" , "sourcing" , "research" , "halal" , "packaging" ,
)
if k not in spoken and k in _active_agent_keys ()
]
if not peers :
peers = [
k for k in ( "herman" , "bizdev" , "finance" , "retail" , "marketing" , "product" )
if k != last and k in _active_agent_keys ()
]
2026-08-16 15:49:16 +00:00
if not peers :
break
target = peers [ turn % len ( peers )]
src = last
trig = (
2026-08-16 22:40:32 +00:00
f "Autonoom War Room A2A (user kan meelezen). Topic: { topic } \n "
f "Jij bent @ { target } . Reageer ECHT op @ { src } . \n "
f "Kies passend: (a) 1 cockpit-feit + gerichte vraag, OF (b) korte respectvolle food-mop + @mention terug naar de inhoud. \n "
f "Breng JOUW expertise in (niet herhalen wat @ { src } zei). Spreek de CEO aan als Aissa. Altijd @mention 1 collega; escalatie naar CEO-inbox via @aissa. Max 4 zinnen. Natuurlijk NL."
2026-08-16 15:49:16 +00:00
)
else :
target , src = nxt [ 0 ]
trig = (
2026-08-16 22:40:32 +00:00
f "Autonoom War Room A2A. Topic: { topic } \n "
f "@ { src } sprak je aan. Reageer op die inhoud (of mop) alsof je in een echt teamoverleg zit. \n "
f "Geef waarde vanuit JOUW rol (feit/actie/vraag). Trek er 1 collega bij die iets kan toevoegen via @mention — niet alleen Herman. Korte food-humor mag."
2026-08-16 15:49:16 +00:00
)
2026-08-16 11:01:58 +00:00
async for kind_ev , payload in _agent_speak_stream (
2026-08-16 15:49:16 +00:00
room , target , trigger = trig , hermes_user = hermes_user , in_reply_to = src
2026-08-16 11:01:58 +00:00
):
if kind_ev == "start" :
yield { "event" : "typing_start" , ** payload }
elif kind_ev == "token" :
yield { "event" : "typing" , ** payload }
elif kind_ev == "done" :
2026-08-16 15:49:16 +00:00
produced . append ( payload )
2026-08-16 11:01:58 +00:00
yield { "event" : "agent" , "message" : payload }
elif kind_ev == "error" :
yield { "event" : "system" , "message" : payload }
2026-08-16 15:49:16 +00:00
return
2026-08-16 11:01:58 +00:00
2026-08-16 15:49:16 +00:00
yield { "event" : "done" , "ok" : True , "continued" : True }