Compare commits

...

1 Commits

Author SHA1 Message Date
Aissa f925042a93 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.
2026-08-16 23:23:48 +00:00
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 {