Stop War Room A2A loops and unlock full chat history with dates.

Detect repeating topics and pause A2A, load older messages beyond the old ~120 cap, and show clear date/time stamps on every bubble.
This commit is contained in:
Aissa
2026-08-16 23:23:48 +00:00
parent c5ea8cbb63
commit f925042a93
5 changed files with 307 additions and 25 deletions
+23 -3
View File
@@ -572,16 +572,36 @@ async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
async def warroom_list_messages(
room_id: int,
limit: int = 500,
after_id: int | None = None,
before_id: int | None = None,
) -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id)
lim = min(max(1, limit), 5000)
items = agent_chat.list_messages(
room_id, limit=lim, after_id=after_id, before_id=before_id
)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids}
total = agent_chat.count_messages(room_id)
oldest = agent_chat.oldest_message_id(room_id)
first_id = int(items[0]["id"]) if items else None
has_more = bool(oldest is not None and first_id is not None and first_id > oldest)
return {
"ok": True,
"room": room,
"items": items,
"deleted_ids": deleted_ids,
"total": total,
"has_more": has_more,
"oldest_id": oldest,
}
@router.post("/warroom/rooms/{room_id}/messages")
+154 -12
View File
@@ -197,13 +197,30 @@ 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]]:
def list_messages(
room_id: int,
*,
limit: int = 100,
after_id: int | None = None,
before_id: int | None = None,
) -> list[dict[str, Any]]:
"""List messages. Default = newest page. before_id = older page (scroll up)."""
lim = max(1, min(int(limit), 5000))
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),
(room_id, after_id, lim),
)
elif before_id:
rows = fetch_all(
"""SELECT * FROM (
SELECT * FROM warroom_messages
WHERE room_id = %s AND 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, before_id, lim),
)
else:
rows = fetch_all(
@@ -212,11 +229,29 @@ def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None
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),
(room_id, lim),
)
return [_iso(r) for r in rows] # type: ignore
def count_messages(room_id: int) -> int:
row = fetch_one(
"SELECT COUNT(*) AS c FROM warroom_messages WHERE room_id = %s AND deleted_at IS NULL",
(room_id,),
)
return int((row or {}).get("c") or 0)
def oldest_message_id(room_id: int) -> int | None:
row = fetch_one(
"""SELECT id FROM warroom_messages
WHERE room_id = %s AND deleted_at IS NULL
ORDER BY id ASC LIMIT 1""",
(room_id,),
)
return int(row["id"]) if row and row.get("id") is not None else None
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(
@@ -508,8 +543,10 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
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"
+ "- Geen @zelf-prefix.\n"
+ "- GEEN herhalingen: zeg niet opnieuw wat net al besloten/gezegd is.\n"
+ "- Peer @mentions alleen als je écht iets nieuws toevoegt; anders geen @mention-ketting.\n"
+ "- Bij gesloten besluiten (gestopt/klaar): max 1 bevestiging, daarna ander dossier of stop.\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"
@@ -1200,7 +1237,9 @@ async def post_user_message_stream(
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."
f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. "
f"Geen herhaling van eerdere berichten. Max 3 zinnen. "
f"@mention alleen als je iets nieuws toevoegt."
)
for key in speakers:
async for ev in _emit_speak(key, trig=f"{trigger}\n\n{user_focus}"):
@@ -1220,6 +1259,74 @@ async def post_user_message_stream(
yield {"event": "done", "ok": True, "a2a": True}
def _norm_msg(text: str) -> str:
t = (text or "").lower()
t = re.sub(r"@\w+", " ", t)
t = re.sub(r"[^a-z0-9àáäâèéëêìíïîòóöôùúüû\s]+", " ", t)
t = re.sub(r"\s+", " ", t).strip()
return t
def _token_set(text: str) -> set[str]:
stop = {
"de", "het", "een", "en", "of", "op", "voor", "van", "naar", "met", "zijn", "niet",
"geen", "wel", "dat", "die", "dit", "dan", "ook", "als", "bij", "uit", "aan", "er",
"ik", "je", "we", "jij", "hij", "zij", "aissa", "herman", "even", "check", "checken",
"begrepen", "helder", "prima", "punt", "dus", "nog", "meer", "kun", "kan", "moet",
}
return {w for w in _norm_msg(text).split() if len(w) > 2 and w not in stop}
def _overlap_ratio(a: str, b: str) -> float:
sa, sb = _token_set(a), _token_set(b)
if not sa or not sb:
return 0.0
return len(sa & sb) / max(1, min(len(sa), len(sb)))
def _is_conversation_looping(messages: list[dict], *, min_msgs: int = 4, threshold: float = 0.42) -> bool:
"""True if recent agent messages keep restating the same decision/theme."""
agents = [m for m in messages if m.get("sender_type") == "agent"][-8:]
if len(agents) < min_msgs:
return False
texts = [str(m.get("content") or "") for m in agents]
# pairwise overlap among last messages
hits = 0
pairs = 0
for i in range(len(texts)):
for j in range(i + 1, len(texts)):
pairs += 1
if _overlap_ratio(texts[i], texts[j]) >= threshold:
hits += 1
if pairs and (hits / pairs) >= 0.45:
return True
# keyword lock: same closed decision repeated
lock_words = ("website stopt", "website stop", "geen website", "dielines", "label-mock", "op de nas", "scheelt", "oprui")
recent_join = " ".join(_norm_msg(t) for t in texts[-5:])
lock_hits = sum(1 for w in lock_words if w in recent_join)
return lock_hits >= 3
def _anti_repeat_block(messages: list[dict], *, limit: int = 4) -> str:
agents = [m for m in messages if m.get("sender_type") == "agent"][-limit:]
if not agents:
return ""
lines = ["ANTI-HERHALING (hard):"]
lines.append("- Herhaal NIET wat collega's net al zeiden (besluit, mop, of check-vraag).")
lines.append("- Als het besluit al gevallen is: 1 korte bevestiging MAX, daarna NIEUW onderwerp of stilte.")
lines.append("- Verboden: opnieuw 'website stopt', 'dielines op NAS', 'even checken @product' als dat al 2x gezegd is.")
lines.append("- Lever 1 nieuw feit/actie uit JOUW rol, of zeg niks nieuws en @mention iemand met een écht nieuwe vraag.")
lines.append("Recent al gezegd (NIET napraten):")
for m in agents:
who = m.get("agent_key") or "?"
excerpt = (m.get("content") or "").strip().replace("\n", " ")
if len(excerpt) > 140:
excerpt = excerpt[:139] + "…"
lines.append(f"- @{who}: {excerpt}")
return "\n".join(lines)
async def continue_warroom_stream(
room_id: int,
*,
@@ -1267,7 +1374,40 @@ async def continue_warroom_stream(
if not topic:
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."
produced = [m for m in recent if m.get("sender_type") == "agent"][-6:]
produced = [m for m in recent if m.get("sender_type") == "agent"][-8:]
anti = _anti_repeat_block(recent, limit=5)
looping = _is_conversation_looping(recent)
# Bij herhalingslus: stop autonome A2A i.p.v. nog meer napraters
if looping:
yield {
"event": "continue_start",
"topic": "Herhaling gedetecteerd — A2A pauzeert dit onderwerp",
}
# Herman mag 1x afronden en doorpakken, daarna klaar
close_trig = (
f"Topic van Aissa: {topic}\n"
f"{anti}\n"
"De War Room herhaalt zich. Jij bent @herman.\n"
"Schrijf MAX 3 zinnen: (1) besluit is al genomen — stop dit onderwerp, "
"(2) noem 1 concreet ander dossier uit cockpit (CRM/retail/pitch), "
"(3) GEEN @mention-ketting over website/packaging/NAS-opruimen.\n"
"Geen mop verplicht. Natuurlijk NL."
)
async for kind_ev, payload in _agent_speak_stream(
room, "herman", trigger=close_trig, 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, "loop_break": True}
return
yield {"event": "continue_start", "topic": topic[:200]}
for turn in range(max(1, min(int(max_turns), 5))):
@@ -1295,16 +1435,18 @@ async def continue_warroom_stream(
src = last
trig = (
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."
f"{anti}\n"
f"Jij bent @{target}. Reageer op @{src} met NIEUWE info uit jouw rol.\n"
f"Verboden: napraten van hetzelfde besluit. Max 3 zinnen. "
f"@mention alleen bij écht nieuwe vraag. Spreek Aissa aan als Aissa."
)
else:
target, src = nxt[0]
trig = (
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."
f"{anti}\n"
f"@{src} noemde je. Geef 1 NIEUW punt vanuit jouw rol — geen echo.\n"
f"Max 3 zinnen. Geen herhaling van website-stop/NAS-check als dat al gezegd is."
)
async for kind_ev, payload in _agent_speak_stream(
+34
View File
@@ -662,3 +662,37 @@
background: rgba(14, 40, 64, 0.98);
border-color: rgba(56, 189, 248, 0.7);
}
.wr-history-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.45rem 0.35rem 0.65rem;
position: sticky;
top: 0;
z-index: 5;
background: linear-gradient(180deg, rgba(8,12,18,0.96), rgba(8,12,18,0.72) 70%, transparent);
}
.wr-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0.85;
}
.wr-day-sep {
display: flex;
justify-content: center;
margin: 0.75rem 0 0.5rem;
}
.wr-day-sep span {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.2);
opacity: 0.9;
}
+85 -6
View File
@@ -29,6 +29,10 @@ window.WarRoomHub = (function () {
replyHighlightId: null,
stickToBottom: true,
showJumpLatest: false,
hasMoreHistory: false,
loadingHistory: false,
historyTotal: 0,
_loadingOlder: false,
_typingScrollAt: 0,
rosterCollapsed: true,
a2aPausedUntil: 0,
@@ -158,13 +162,15 @@ window.WarRoomHub = (function () {
try {
if (!(this.rooms || []).length) await this.loadRooms();
var meta = (this.rooms || []).find(function (r) { return Number(r.id) === id; }) || null;
var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=120').then(function (x) {
var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=800').then(function (x) {
return x.json();
});
// messages API returns {items}, NOT {room} — keep room from rooms list
this.room = meta || r.room || { id: id, kind: 'warroom', title: 'War Room' };
this.messages = this.normalizeList(r.items || []);
this.lastId = this.maxId(this.messages);
this.historyTotal = r.total || this.messages.length;
this.hasMoreHistory = !!r.has_more;
this.stickToBottom = true;
this.showJumpLatest = false;
this.$nextTick(function () {
@@ -174,7 +180,7 @@ window.WarRoomHub = (function () {
}.bind(this));
var last = (this.messages || [])[(this.messages || []).length - 1];
if (last && last.sender_type === 'agent' && this.room && this.room.kind === 'warroom') {
if (this.autoDebate) this.scheduleContinue(60000);
if (this.autoDebate) this.scheduleContinue(120000);
}
} catch (e) {
this.status = e.message || 'Laden mislukt';
@@ -246,6 +252,58 @@ window.WarRoomHub = (function () {
var near = this.isNearBottom(el, 100);
this.stickToBottom = near;
this.showJumpLatest = !near;
// bovenaan: oudere geschiedenis laden
if (el.scrollTop < 80) this.loadOlderMessages();
},
minId(items) {
var n = null;
(items || []).forEach(function (m) {
var id = Number(m && m.id);
if (!isNaN(id) && (n === null || id < n)) n = id;
});
return n;
},
async loadOlderMessages() {
if (!this.room || !this.room.id) return;
if (this.loadingHistory || this._loadingOlder || !this.hasMoreHistory) return;
if (!(this.messages || []).length) return;
this._loadingOlder = true;
this.loadingHistory = true;
var el = this.$refs && this.$refs.wrLog;
var prevHeight = el ? el.scrollHeight : 0;
var prevTop = el ? el.scrollTop : 0;
try {
var before = this.minId(this.messages);
var r = await fetch(
'/api/agents/warroom/rooms/' + this.room.id +
'/messages?limit=400&before_id=' + before,
{ credentials: 'same-origin' }
).then(function (x) { return x.json(); });
var older = this.normalizeList(r.items || []);
if (older.length) {
var existing = {};
(this.messages || []).forEach(function (m) { existing[String(m.id)] = true; });
var merged = older.filter(function (m) { return !existing[String(m.id)]; })
.concat(this.messages || []);
this.messages = merged;
this.historyTotal = r.total || this.historyTotal;
this.hasMoreHistory = !!r.has_more;
var self = this;
this.$nextTick(function () {
self.paintAvatars();
if (el) {
// scrollpositie behouden na prepend
el.scrollTop = el.scrollHeight - prevHeight + prevTop;
}
});
} else {
this.hasMoreHistory = false;
}
} catch (e) { /* quiet */ }
this.loadingHistory = false;
this._loadingOlder = false;
},
scrollLog(force) {
@@ -538,7 +596,7 @@ window.WarRoomHub = (function () {
setTimeout(function () { self.replyHighlightId = null; }, 4000);
});
}
if (this.autoDebate) this.scheduleContinue(60000);
if (this.autoDebate) this.scheduleContinue(120000);
} catch (e) {
// Keep optimistic message; restore draft only on hard fail
this.status = e.message || 'Verzenden mislukt';
@@ -642,6 +700,10 @@ window.WarRoomHub = (function () {
if (window.Cockpit) Cockpit.toast(this.status, 'error');
} else if (type === 'done') {
this.typing = null;
if (ev.loop_break) {
this.pauseA2A(180000);
this.status = 'A2A pauze — onderwerp herhaalde zich';
}
}
},
@@ -733,13 +795,15 @@ window.WarRoomHub = (function () {
opts = opts || {};
if (!this.room || !this.room.id) return;
try {
var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=200', {
var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=800', {
credentials: 'same-origin',
}).then(function (x) { return x.json(); });
var items = this.normalizeList(r.items || []);
if (items.length) {
this.messages = items;
this.lastId = this.maxId(items);
this.historyTotal = r.total || items.length;
this.hasMoreHistory = !!r.has_more;
}
if (!opts.silent) {
this.$nextTick(function () {
@@ -793,11 +857,11 @@ window.WarRoomHub = (function () {
this.busy = false;
this.status = 'A2A live';
// keep conversation alive
this.scheduleContinue(55000);
this.scheduleContinue(90000);
} catch (e) {
this.busy = false;
this.status = 'A2A hervatten…';
this.scheduleContinue(45000);
this.scheduleContinue(75000);
}
this._continueBusy = false;
},
@@ -871,6 +935,21 @@ window.WarRoomHub = (function () {
return d.toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
},
/** Compact stamp on each bubble: 16 aug · 00:53 */
formatMsgStamp(m) {
var raw = (m && (m.created_at || m.createdAt || m.ts)) || '';
if (!raw) return '';
var d = new Date(raw);
if (isNaN(d.getTime())) return '';
var today = new Date();
var sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
var time = d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
if (sameDay) return time;
var date = d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined });
return date + ' · ' + time;
},
dayKey(m) {
var raw = (m && (m.created_at || m.createdAt || m.ts)) || '';
if (!raw) return '';
+11 -4
View File
@@ -5,7 +5,7 @@
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=8" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=40" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=42" />
<script src="/static/js/agent-avatars.js?v=5"></script>
{% endblock %}
{% block content %}
@@ -313,7 +313,7 @@
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
<small class="wr-msg-count muted" x-show="messages.length"
x-text="(screenCleared ? '0' : visibleMessages().length) + ' / ' + messages.length + ' berichten'"></small>
x-text="(screenCleared ? '0' : visibleMessages().length) + ' geladen' + (historyTotal ? (' · ' + historyTotal + ' totaal') : '')"></small>
</div>
<div class="wr-head-actions">
<button type="button" class="btn btn-sm wr-tool-btn" :class="{active: searchOpen}" @click="toggleSearch()" title="Zoeken in chat">🔍 Zoek</button>
@@ -352,6 +352,13 @@
<button type="button" class="btn btn-sm" @click="toggleSearch()">Sluit</button>
</div>
<div class="wr-log-wrap"><div class="wr-log" x-ref="wrLog" @scroll.passive="onLogScroll()">
<div class="wr-history-bar" x-show="!screenCleared && (hasMoreHistory || loadingHistory)" x-cloak>
<button type="button" class="btn btn-sm" @click="loadOlderMessages()" :disabled="loadingHistory">
<span x-show="!loadingHistory">↑ Oudere berichten laden</span>
<span x-show="loadingHistory">Laden…</span>
</button>
<small class="muted" x-show="historyTotal" x-text="'Totaal in archief: ' + historyTotal"></small>
</div>
<p class="wr-empty" x-show="screenCleared && !typing">
Scherm is geleegd.
<button type="button" class="btn btn-sm" @click="restoreScreen()">Herstellen</button>
@@ -383,7 +390,7 @@
<div class="wr-bubble-meta">
<span class="wr-who" x-text="labelForMessage(m)"></span>
<span class="wr-handle" x-show="m.agent_key" x-text="'@'+m.agent_key"></span>
<time class="wr-time" x-text="formatMsgTime(m)" :title="formatMsgDate(m) + ' ' + formatMsgTime(m)"></time>
<time class="wr-time" x-text="formatMsgStamp(m)" :title="formatMsgDate(m) + ' ' + formatMsgTime(m)"></time>
<button type="button" class="wr-msg-reply" title="Beantwoorden"
@click.stop="setReply(m)">↩ Reply</button>
<button type="button" class="wr-msg-del" title="Bericht verwijderen"
@@ -622,7 +629,7 @@
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=40"></script>
<script src="/static/js/agents-warroom.js?v=42"></script>
<script>
function agentsSoulHub() {
return {