From a767747a683b0bc6014bb78d8745e828809ebc79 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:45:06 -0700 Subject: [PATCH] fix(desktop): byte-bound the Bot Mode group-chat turn window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member's turn prompt rendered only the last 24 room messages since its last turn, so a busy room routinely lost the head of an exchange (#114341 made the cut visible; this makes it rare). The window is now bounded by size instead of a small count: up to 200 entries within a 32 KB character budget, oldest dropped first, the omission marker naming the exact count. One oversized body is cut to 8 KB with the existing '… [truncated]' mark rather than evicting the messages around it. The local room log retains twice the window so a member that skipped a whole window still gets an exact count instead of a clamped watermark. Docs: one sentence in the Bot Mode guide. Tests: the existing window tests now cover the byte bound (exact omitted count, newest kept), a 150-entry delta that fits, and the single-paste truncation. --- .../src/plugins/hermes-bots/group-chat.ts | 22 +++++- .../plugins/hermes-bots/group-round-prompt.ts | 45 +++++++++--- .../plugins/hermes-bots/group-rounds.test.ts | 71 +++++++++++++------ website/docs/user-guide/bot-mode.md | 2 + 4 files changed, 108 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 4458e667f6..5b87510706 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -95,7 +95,8 @@ const groupChatSyncRetryTimers = new Map>( const groupChatSyncRetryCounts = new Map() export let groupChatSyncDisposed = false -/** Cut one sync-projection line to the per-message budget and mark the cut. +/** Cut one room body to a per-message budget and mark the cut (the sync + * projection and the per-turn delta window share the convention). * Receivers used to see a silent mid-sentence slice with no signal that the * body continued. Keep the mark inside the same char budget so CJK/envelope * accounting does not grow. */ @@ -1273,7 +1274,22 @@ export const GROUP_CHAT_MAX_ROUNDS = 3 // #94478 review: continuation rounds are bounded independently of the message cap so a pathological mention chain can't consume the room's whole budget on handoffs. export const GROUP_CHAT_MAX_MESSAGES = 10 export const GROUP_CHAT_MAX_CONTINUATIONS = 2 -export const GROUP_CHAT_HISTORY_LIMIT = 24 +// Per-turn room window (#114341 follow-up): a member sees every message since +// its last turn, up to BOTH ceilings — oldest dropped first, the cut named +// exactly. Room lines are short by construction (the rules ask for 1-3 +// sentences; user lines average ~100-300 chars), so ~200 entries and ~32 KB +// (~8k tokens) bite at about the same place for ordinary traffic; the char +// budget is what keeps a prompt bounded when the lines are long. One body +// is cut to LINE_CHARS (mark: '… [truncated]') rather than evicting whole +// messages, so a single giant paste costs a quarter of the window, not all +// of it, while a multi-paragraph member result still lands intact. +export const GROUP_CHAT_HISTORY_LIMIT = 200 +export const GROUP_CHAT_HISTORY_CHARS = 32_000 +export const GROUP_CHAT_HISTORY_LINE_CHARS = 8_000 +// Room log retained locally: twice the turn window so a member that skipped +// a whole window still receives an exact omitted count, not a clamped +// watermark and a silently shortened room. +export const GROUP_CHAT_LOG_RETAIN = GROUP_CHAT_HISTORY_LIMIT * 2 export const GROUP_CHAT_MAX_MEMBERS = 6 /** Transcript form of a room speaker's identity. Friendly identity wins: @@ -1367,7 +1383,7 @@ export function groupSpeakerLabel(name?: null | string, group?: null | string) { export function trimGroupChatLog( log: GroupMessage[], watermarks: Record, - limit = GROUP_CHAT_HISTORY_LIMIT * 4 + limit = GROUP_CHAT_LOG_RETAIN ) { if (log.length <= limit) { return { diff --git a/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts index a1fe1991a1..0aeae9befd 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-round-prompt.ts @@ -1,5 +1,11 @@ import { botMentionTag } from './data' -import { GROUP_CHAT_HISTORY_LIMIT, groupSpeakerLabel } from './group-chat' +import { + compactGroupChatSyncText, + GROUP_CHAT_HISTORY_CHARS, + GROUP_CHAT_HISTORY_LIMIT, + GROUP_CHAT_HISTORY_LINE_CHARS, + groupSpeakerLabel +} from './group-chat' import { groupMemberKey } from './group-membership' import type { GroupMember, GroupMessage, GroupMessageAuthor } from './types' @@ -51,15 +57,36 @@ export function formatGroupChatLine(entry: GroupMessage, viewer: GroupChatLineVi return `${groupSpeakerLabel(entry.from.name, group)}${suffix}${source}: ${relabelMemberControlFrames(entry.text)}${attached}` } -/** #114341: a member's turn renders only the last GROUP_CHAT_HISTORY_LIMIT - * delta lines while the watermark commit advances past the whole tail, so - * the head of an over-long delta is never delivered on any later turn - * either. Mark the cut — without it a member has no way to know its view - * of the room is partial (typically missing the very user instruction - * that started the exchange). */ +/** #114341: a member's turn renders the newest delta lines that fit the + * window — GROUP_CHAT_HISTORY_LIMIT entries AND GROUP_CHAT_HISTORY_CHARS + * characters, each body first cut to GROUP_CHAT_HISTORY_LINE_CHARS — while + * the watermark commit advances past the whole tail, so the head of an + * over-long delta is never delivered on any later turn either. Mark the + * cut with the exact count — without it a member has no way to know its + * view of the room is partial (typically missing the very user instruction + * that started the exchange). The newest entry is always kept. */ export function formatGroupDeltaLines(delta: GroupMessage[], viewer: GroupChatLineViewer, group?: null | string) { - const omitted = delta.length - GROUP_CHAT_HISTORY_LIMIT - const lines = delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map(entry => formatGroupChatLine(entry, viewer, group)) + const lines: string[] = [] + let chars = 0 + + for (let i = delta.length - 1; i >= 0 && lines.length < GROUP_CHAT_HISTORY_LIMIT; i--) { + const entry = delta[i] + const line = formatGroupChatLine( + { ...entry, text: compactGroupChatSyncText(entry.text, GROUP_CHAT_HISTORY_LINE_CHARS).text }, + viewer, + group + ) + + if (lines.length && chars + line.length > GROUP_CHAT_HISTORY_CHARS) { + break + } + + lines.push(line) + chars += line.length + 1 + } + + lines.reverse() + const omitted = delta.length - lines.length if (omitted > 0) { lines.unshift(`… ${omitted} earlier room message${omitted === 1 ? '' : 's'} omitted since your last turn`) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts index 6e2fa76d5e..bf40048056 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.test.ts @@ -492,7 +492,9 @@ describe('per-member delta', () => { const thread = room.rounds.sendToGroupChat('Trim', members, 'delivered')! await drain(() => room.gateway.calls.length < 1) - for (let i = 0; i < 100; i++) { + const retained = room.chat.GROUP_CHAT_LOG_RETAIN + + for (let i = 0; i < retained; i++) { room.chat.appendGroupChatEntry('Trim', { kind: 'user', name: 'You' }, `unseen-${i}`, thread) } @@ -500,54 +502,83 @@ describe('per-member delta', () => { await settle(room, 'Trim') expect(room.chat.$groupChats.get().Trim.watermarks[`${thread}::research`]).toBe(0) await room.rounds.runGroupChatRounds('Trim', members, thread) - expect(room.gateway.calls.at(-1)?.prompt).toContain('unseen-99') + expect(room.gateway.calls.at(-1)?.prompt).toContain(`unseen-${retained - 1}`) }) - // #114341: the turn renders only the last GROUP_CHAT_HISTORY_LIMIT entries - // of the delta while the watermark advances past the whole tail, so the + // #114341 follow-up: the turn renders the newest delta lines that fit the + // window (GROUP_CHAT_HISTORY_LIMIT entries / GROUP_CHAT_HISTORY_CHARS + // characters) while the watermark advances past the whole tail, so the // head is never delivered later either. The cut must be visible to the - // member (naming how many entries it did not see); a delta that fits - // carries no marker. - it('names the omitted head of an over-long delta in the turn prompt', async () => { + // member and name EXACTLY how many entries it did not see; a delta that + // fits carries no marker. + it('names the exact omitted head of an over-budget delta and keeps the newest', async () => { const room = await loadRoom({ turn: () => '(pass)' }) const members = [MEMBERS[0]] const thread = room.rounds.sendToGroupChat('Head', members, 'seen-0')! await settle(room, 'Head') - const limit = room.chat.GROUP_CHAT_HISTORY_LIMIT + const total = 40 + const body = 'x'.repeat(1000) - for (let i = 1; i <= limit + 5; i++) { - room.chat.appendGroupChatEntry('Head', { kind: 'user', name: 'You' }, `unseen-${i}`, thread) + for (let i = 1; i <= total; i++) { + room.chat.appendGroupChatEntry('Head', { kind: 'user', name: 'You' }, `unseen-${i} ${body}`, thread) } - const seen = room.chat.$groupChats.get().Head.watermarks[`${thread}::research`] || 0 - const omitted = log(room, 'Head').slice(seen).length - limit - expect(omitted).toBeGreaterThan(0) + expect(total * body.length).toBeGreaterThan(room.chat.GROUP_CHAT_HISTORY_CHARS) await room.rounds.runGroupChatRounds('Head', members, thread) const prompt = room.gateway.calls.at(-1)?.prompt || '' + const rendered = prompt.match(/unseen-\d+ /g) || [] + const omitted = Number(prompt.match(/… (\d+) earlier room messages omitted since your last turn/)?.[1]) - expect(prompt).toMatch(new RegExp(`${omitted} earlier room messages omitted`)) - expect(prompt).toContain(`unseen-${limit + 5}`) - expect(prompt).not.toContain(`unseen-${omitted}\n`) + expect(omitted).toBeGreaterThan(0) + expect(omitted + rendered.length).toBe(total) + expect(prompt).toContain(`unseen-${total} `) + expect(prompt).not.toContain('unseen-1 ') + expect(prompt).not.toContain('[truncated]') }) - it('adds no omission marker when the delta fits the window', async () => { + it('does not cut a 150-entry delta that fits the window', async () => { const room = await loadRoom({ turn: () => '(pass)' }) const members = [MEMBERS[0]] const thread = room.rounds.sendToGroupChat('Fits', members, 'seen-0')! await settle(room, 'Fits') - for (let i = 1; i < room.chat.GROUP_CHAT_HISTORY_LIMIT; i++) { - room.chat.appendGroupChatEntry('Fits', { kind: 'user', name: 'You' }, `unseen-${i}`, thread) + for (let i = 1; i <= 150; i++) { + room.chat.appendGroupChatEntry('Fits', { kind: 'user', name: 'You' }, `unseen-${i} short room line`, thread) } await room.rounds.runGroupChatRounds('Fits', members, thread) const prompt = room.gateway.calls.at(-1)?.prompt || '' - expect(prompt).toContain('unseen-1') + expect(prompt).toContain('unseen-1 short') + expect(prompt).toContain('unseen-150 short') expect(prompt).not.toMatch(/omitted/) }) + // One giant paste is cut to the per-line budget instead of evicting the + // ordinary messages around it. + it('truncates one oversized body rather than dropping its neighbours', async () => { + const room = await loadRoom({ turn: () => '(pass)' }) + const members = [MEMBERS[0]] + const thread = room.rounds.sendToGroupChat('Paste', members, 'seen-0')! + await settle(room, 'Paste') + + room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, 'before the paste', thread) + room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, `PASTE-${'y'.repeat(60_000)}-END`, thread) + room.chat.appendGroupChatEntry('Paste', { kind: 'user', name: 'You' }, 'after the paste', thread) + + await room.rounds.runGroupChatRounds('Paste', members, thread) + const prompt = room.gateway.calls.at(-1)?.prompt || '' + + expect(prompt).toContain('before the paste') + expect(prompt).toContain('after the paste') + expect(prompt).toContain('PASTE-yyy') + expect(prompt).toContain('… [truncated]') + expect(prompt).not.toContain('-END') + expect(prompt).not.toMatch(/omitted/) + expect(prompt.length).toBeLessThan(room.chat.GROUP_CHAT_HISTORY_LINE_CHARS + 2000) + }) + it('feeds a second send only the NEW messages', async () => { const room = await loadRoom() const member: GroupMember[] = [{ name: 'research', title: '' }] diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 25b02e1a75..0e52fe5fa4 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -119,6 +119,8 @@ Right-click a local Bot → **Manage groups** to add or remove it from any numbe To edit an existing room as a unit, use **Manage members** — from the room header's people icon or the **Manage members…** button in **Group settings**. The checklist pre-selects the current members and lists every local and Connections Bot; add or remove several at once and **Save members** applies the new roster (still 2–6 Bots) while the room, its history and each member's room session stay in place. A removed Bot takes no further turns; an added Bot reads the recent room history on its first turn. **Cancel** changes nothing. +Each member's turn carries every room message since its own last turn — up to about 200 messages or 32 KB of transcript, whichever fills first — with a single oversized paste cut to a marked excerpt rather than crowding out the messages around it; when the window still overflows, the prompt names exactly how many earlier messages were left out. + **Rooms follow your gateways, not one Desktop.** Each room's recent transcript, members, picture, and name are mirrored into the shared profile metadata of **every** gateway your Desktop is connected to, with per-gateway versioning so two Desktops writing at once merge instead of overwriting each other. Open Hermes Desktop on another machine against the same gateway (local network, Tailscale, anywhere) and the room appears with its history; gateway-only clients see it too. Rooms carry a durable internal identity, so renaming one changes just its display name everywhere, disbanding one removes it permanently on every client — even ones that were offline at the time — and recreating a same-name group starts a genuinely fresh room. If a gateway dies or is removed, nothing is lost: every connected Desktop keeps the full room locally and re-seeds any gateway it reconnects to. (The full orchestration log stays in each Desktop's local storage; the shared mirror is a bounded recent-history projection.) A group's identity is editable, at creation and after: