feat(bots): make group chat round, message, member and history limits per room - #92213
oliverhees wants to merge 8 commits into
Conversation
… per room
Bot Mode caps every group chat at 3 round-robin rounds and 10 bot messages
per user send, with 6 members and 24 history lines per turn. Those numbers
are module constants, so a room that wants a longer conversation cannot ask
for one. With 6 members the message cap alone ends most rooms inside the
second round, well before the round cap is even reached.
Each of the four axes is now a per-room override with three states: a
number, off, or absent (inherit the shipped default). Rooms that never
touch the settings behave exactly as before, and the stored form omits every
axis left on its default, so an untouched room stores nothing at all.
Turning rounds or messages off hands control to a safety brake, which
defaults to 50 rounds / 200 messages and is itself switchable. Switching it
off too gives a genuinely unbounded room. That is a deliberate choice rather
than an oversight: the drive already ends on its own when every member
passes a round, and a new user message bumps the room epoch, which the loop
honors at the next member boundary. An unlimited room is therefore never a
room you cannot get out of. The editor says so in as many words before the
user gets there.
A room that stops on its brake records a `safety` activity event naming the
axis and the count, so the room does not simply go quiet in a way that reads
as "the conversation settled".
Three places edit the budget, all writing the same shape through one
component: the create dialog (behind a disclosure, where raising the member
cap immediately widens the picker), group settings, and the room header,
whose new label doubles as the current budget at a glance ("3 rounds · 10
msgs", "≤50 rounds", "∞ rounds").
Overrides persist with the room and ride the existing gateway sync under the
same newest-revision rule as the room picture, so a room keeps its rules on
every machine that mirrors it.
Verified: the plugin suite goes from 362/362 to 374/374, eslint reports no
problems. The 12 added tests cover the resolver (defaults, explicit values,
off vs absent, clamping at the ceiling, nonsense values falling back rather
than disabling a limit), the brake's dependence on its axis, the stored
round trip, the header label in all three states, and two source assertions
that the drive loop reads the room instead of the constants.
First live test on a dark theme: every limit was active, but all four switches read as off. Radix renders the on state as a slightly different dark track plus a shifted thumb, and at this size neither is legible against the panel. A control whose state cannot be read is worse than no control, because the user then changes a setting to find out what it currently is. Each switch now carries the state in words next to it: "limit" or "no limit" for an axis, "brake" or "none" for its safety stop, with the off state in the warning color so a room running without a bound stands out in the list. A switched-off axis shows the infinity sign as its placeholder instead of an empty box. Plugin suite 376/376 to 378/378. The two added tests assert that every switch has a state word beside it, so a future control cannot go back to being unreadable.
|
Real-world repro on current Desktop main: a two-bot room reached the shipped 3-round cap with the final member reply explicitly addressing the other member. The room logged that directed handoff at 12:35:14, but the callee got no turn; only a new user message at 12:47:45 restarted the drive and delivered it (12m31s silent stall). This PR gives the room a way to raise its budget, which is useful for deliberate long-form sessions. Two review points from the repro:
I verified the narrower grace behavior locally with a regression test plus the full Bot Mode plugin suite and TypeScript checks. I am not opening a competing PR while #92213 and #92041 are active; happy to contribute the test/logic here if desired. |
ae52dcb to
a92bd9b
Compare
Review point 1 from AllanGamal on NousResearch#92213, with a repro on current main: a two-bot room hit the shipped 3-round cap on a directed handoff, logged nothing, and stayed silent for 12m31s until a new user message restarted the drive. From the room, that is indistinguishable from the conversation having settled, so the user has no way to tell completion from a dropped handoff. The first version of this branch only reported a stop when the axis had been switched off, which is exactly backwards: the room most likely to stop surprisingly is the one still on the defaults. Both counts now report whenever they end a drive. `capped` is the ordinary limit and names the way out ("stopped at the rounds limit (3), raise it in the room budget"); `safety` stays the brake behind a switched-off axis. Both render in the warning colour, so neither reads as routine. Plugin suite 378/378 to 379/379.
a92bd9b to
9f3f569
Compare
|
Thanks for the repro. The 12m31s stall is exactly the case I missed, and your first point is a real hole in this branch. Fixed in I had the reporting backwards. A stop was only announced when the axis had been switched off, which means the room most likely to stop without warning, the one still on the shipped defaults, was the one that said nothing. That is your two-bot room. Both counts now report whenever they end a drive. On your second point: yes, please open it here. It is a different mechanism from this PR and the two do not overlap much. Raising a budget is a deliberate act by someone who knows the room needs longer. Your grace path catches the case where nobody changed anything and a specific member was addressed but never got a turn. That should hold on the default budget, which is where it matters most. The three constraints are what make it safe. Newest member entry, explicit @member, only while unseen. None of that can grow into "@everyone keeps the loop alive". I will rebase around whatever you land. If opening against my branch is easier for you, that works too. One thing worth settling early: if a grace turn wants to fire while the room is already at its message cap, my instinct is to let the cap win and still log |
First live test: with every limit active, all four switches read as off. I had invented a row layout instead of following the one the app already uses for exactly this, a labelled control with a description. src/app/settings/primitives.tsx has ListRow and ToggleRow for that shape: a container-queried grid, label and description on the left in --conversation-text-font-size, the control right-aligned via @2XL:justify-self-end, and a haptic on toggle. Plugins cannot import those primitives, so the structure is mirrored here rather than reinvented, down to the class names, and both switches now fire haptic('tap') like every other toggle in the app. The number field moves next to its switch instead of sitting in a separate column, which is what pushed the switch into a cramped corner where its state was hard to read at all. Three tests hold the shape: the grid classes, the two haptics, and the infinity placeholder on a switched-off axis. Suite stays green at 380.
Second live test: the switches are readable now, but every row stacked, and the number field's border was hard to see. Both came from copying the app's settings row without adapting it to where it lives. ListRow splits at @2XL, which is 672px of container width. These rows sit in a 448px dialog, so the split never fired and each row rendered label over control. The rows now split at @xs with a fixed 11rem control column, which fits the dialog and still stacks in anything narrower. The field carried a hard w-20. The standard number field in src/app/settings/config-field.tsx has no width at all: it fills its column and takes CONTROL_TEXT ('text-xs'). At 5rem the border had almost no edge to read against. The field now fills the control column next to its switch. The label keeps the full left column and may wrap to two lines, which is what the description text needs anyway. Tests assert the early breakpoint and the absence of both the @2XL split and the fixed width, so this cannot drift back. Suite green at 380.
…ast round My first answer to NousResearch#92213 review point 1 put the report in the wrong place. It fired at the top of the final round, before it was known whether the budget would end the drive at all. A room that settles in that round reported a limit stop that never happened, and a room that ended earlier reported nothing. The real problem was one level up: `finally` recorded `settled` for every ending, which is what made a budget cutoff indistinguishable from a finished conversation in the first place. The drive now records why it exits at each exit (`settled` when everyone passes, `messages` at the message cap, `rounds` when the loop runs out) and `finally` reports that once. `capped` for an ordinary limit, `safety` for the brake behind a switched-off axis, `settled` only when the room really settled. Checked against the live case that prompted this: two bots agreeing after three messages still reports `settled`, correctly. Two bots talking through the budget reports `capped` on rounds; six bots report `capped` on messages; with an axis off, the brake reports `safety`. The test now asserts the reason is recorded at the exits and that `round === caps.rounds - 1` does not come back.
Live test found this one: with rounds set to 1, the room correctly reported
"stopped at the rounds limit (1)", dated "20688 days ago".
The event carried the count as `at`, and recordGroupActivity builds its entry
as `{ at: Date.now(), ...event }`, so the caller's field won. `at: 1` is one
millisecond past the epoch, which relativeTime renders as five decades.
The count now travels as `count`, and recordGroupActivity spreads the event
first so its own `at` and `epoch` always win. Any future caller that ships an
`at` gets its timestamp stamped correctly instead of silently backdating the
event.
Two tests: the record's fields must come after the spread, and the limit event
must not carry `at`. Neither is reachable from a render test, since nothing
asserts on the displayed age.
Screenshot aus einem echten Lauf am 22.08.2026: Zwei Bots streiten ueber TDD, der letzte Beitrag spricht @Hermes namentlich an und stellt zwei Fragen. Hermes antwortet nicht mehr, der Raum meldet turn settled. Genau der Fall, den AllanGamal in NousResearch/hermes-agent#92213 beschrieben hat.
|
Tested both points on a real desktop today rather than in a harness, and both reproduced. Point 1 works after the fix. With rounds set to 1, a two-bot room ends like this:
The same run said The live run also showed my first attempt was in the wrong place. I had the report firing at the top of the final round, before it was known whether the budget would end the drive at all. A room that settled in that round would have claimed a limit stop that never happened. The real problem sat one level up: That run turned up a second bug too, and it is one no test of mine would have caught. The event carried its count as Point 2 reproduced separately. Default budget, two bots arguing about TDD: The last message is from One caveat on reading this screenshot: it predates the fix above, so If the rounds were not spent, that is a slightly wider case than yours: a member can pass, be addressed by name straight after, and the room still ends. Your three constraints would still cover it, since the addressed entry is the newest one and it is unseen. But it would mean the grace path cannot key on the last slot being consumed. Still happy to have you land the test and logic here. |
Live test: a room set to 6 rounds / 30 messages came back as 3 / 10 after a restart. The budget was written and synced correctly; it was never read. A persisted room field has three sites: durableGroupChatRooms writes it, the gateway merge carries it, and the hydrate path rebuilds the room from storage field by field. I extended the first two and missed the third, so every budget silently reset on the next launch, which makes the whole feature pointless. Beyond adding the field, there is now a test that walks the durable shape and asserts the hydrate path reads every field it writes, minus epoch and running, which are reset by design. Removing the line again fails it with "these fields are written but never read back", so the next field to be added cannot repeat this.
…liegen Lauf vom 22.08.2026 mit einem Budget von 6 Runden und 30 Nachrichten. Nach drei Bot-Nachrichten endet der Raum. Der letzte Beitrag spricht @Hermes namentlich an und stellt eine Frage. Die Aktivitaetsliste zeigt drei replied und kein passed, Hermes kam also nicht an die Reihe. Anders als im Repro zu NousResearch/hermes-agent#92213 war das Rundenbudget hier nicht erschoepft.
|
@AllanGamal One more run, and it narrows your point 2 quite a bit. Same two-bot room, but with the budget raised to 6 rounds and 30 messages. It still ends after three bot messages. The last message is from Here is the activity list for that run: Three That third detail is what makes this worth reporting, because it rules things out. So Hermes did not decline, and the delta did not skip him. He was not among the responders in the first place, which puts it in That is a different trigger than your repro suggests. Yours reads as "the final slot went to the handoff", which a grace path could key on the budget running out. This room had 27 of 30 messages left. Whatever drops the addressed member happens earlier than the budget, so keying on exhaustion would miss this case entirely. None of that path is touched by this PR: Reproducing takes about two minutes: two bots, ask them to argue opposite positions and not concede until the other refutes. Three separate runs here ended after three messages, on budgets of 3, 6 and 6 rounds. |
The multiplicative framing is what makes this PR convincing: N members × up to 3 rounds means the message cap is the effective constraint (6 members → 2 rounds, 10 messages), so raising limits without per-room control would either stay useless for small rooms or explode large ones. Making all four caps per-room configuration while keeping module constants as defaults is the right shape, and the measured before/after table shows the actual behavior rather than asserting intent. Two suggestions:
|
|
Both of these were already in the diff, and the fact that you had to ask means the description was doing a poor job. I have added two sections to it rather than answer only here. On the ceilings: every numeric value is clamped, not merely validated.
A value above the ceiling lands on the ceiling instead of erroring at the user. A value that is not a positive number falls back to the default instead of reading as "no limit". That second one is not theoretical: One honest qualification, since your concern is a token furnace. The ceilings bound numbers. They do not bound the off state, which is a separate feature and deliberate: switching an axis off hands control to a safety brake that defaults to 50 rounds and 200 messages, and switching that brake off as well takes a second, separate control. The reasoning, including why an unbounded room is still not a room you cannot get out of, is in the section above the new ones, written against #91481. On which cap binds first, you read the table correctly, and it is worse than "message cap usually wins":
Above four members, raising rounds alone does nothing at all. Leaving that to documentation felt like leaving a rake in the grass, so the editor computes it live and says it under the rows: "With 6 bots, the message budget runs out after about 2 rounds, raise it too, or the round setting will not change anything." That line is the first thing a user hits when they try to make a room talk longer. |
GROUP_CHAT_MAX_ROUNDS and its four siblings carry over at the values plugin.js shipped, so no rebase inherits a behavior change on top of a rewrite. Making them configurable is live contributor work — #92213 for per-room limits, #96842 for config plus a token budget — and both want the same single seam, so say so where the constants are instead of adding a config hook this PR has no consumer for.
A Bot Mode room stops after 3 serial rounds and 10 member messages. That suits a quick two-bot exchange and cuts short a workflow whose hops are genuinely serial: a coordinator triaging to a domain expert, to a data operator, and back needs more passes than three, so the room reports 'turn stopped at the round/message cap' with a bot still holding an addressed handoff. Read the ceilings from a group_chat block in config.yaml, falling back to today's values when it is absent, so the person paying for the model calls decides what a room may spend. Config is advisory, never authoritative: each value is clamped to a hard ceiling (20 rounds / 100 messages / 20 continuations), and anything below 1 or unparseable reads as unset rather than as zero. A typo can neither silence a room nor uncap it. The limits resolve once at plugin load and are snapshotted per drive rather than read inside the round loop, which is hot; a gateway round-trip there would be a stall the user feels. A config edit reaches a room on its next send. Reading fails silently to the shipped defaults, because a room that runs beats a room that refuses to start because config could not be read. Ports the design from NousResearch#96842 (beplee), which was closed unmerged when NousResearch#96726 deleted the plugin.js it patched, onto the new modules. Uses the existing config.get 'full' key rather than adding a gateway method. Per-room overrides (NousResearch#92213) can layer on the same seam. Refs NousResearch#98004, NousResearch#96842, NousResearch#89545
|
Operating evidence for the persistence/survival half of this PR, from a six-member team room. We run a six-bot orchestration room (coordinator -> researcher -> architect -> builder -> verifier). Our stop condition is the message cap, exactly as this PR's table predicts: six members one round is six messages, so the 10-message budget seats barely two rounds and the tail of the roster never speaks on a multi-pass handoff. Raising rounds alone would change nothing. Confirmed live, not in a harness. The point I have not seen raised yet is what happens to the room's budget across an app update, and that is where per-room persistence earns its keep over a bundle-level override. Before this PR was on our radar we raised the caps by patching the shipped renderer bundle (the constants are inlined into the minified assets). Two consequences we hit:
Per-room overrides that persist with the room and ride the existing gateway sync are the durable answer to both: the budget survives an app update because it lives in room state, not in the bundle, and a room that stops on its (visible) budget reports |
GROUP_CHAT_MAX_ROUNDS and its four siblings carry over at the values plugin.js shipped, so no rebase inherits a behavior change on top of a rewrite. Making them configurable is live contributor work — NousResearch#92213 for per-room limits, NousResearch#96842 for config plus a token budget — and both want the same single seam, so say so where the constants are instead of adding a config hook this PR has no consumer for.
|
Confirmed against current upstream |
|
I have a current modular TypeScript implementation of the core per-room runtime limits, rebased onto current main. It exposes max rounds, max Bot replies, and follow-on mention rounds in Group Settings; persists and gateway-syncs them per room; keeps the existing 3/10/2 defaults; and raises the group member picker/sync cap from 6 to 12. It is intentionally narrower than #92213: no unlimited mode/safety brake, history override, per-room member override, header budget summary, or create-dialog advanced editor. The branch passes all 62 Hermes Bots UI test files (580 tests), desktop typecheck and lint, and the contributor attribution audit. Since #92213 is currently conflicting and still targets the former plugin.js implementation, would you prefer that I open this as a clearly labelled smaller successor/replacement PR against the current modular TypeScript code, or coordinate it another way? I have pushed the ready branch here: https://github.com/DataJockey13/hermes-agent/tree/feat/group-chat-configurable-limits. I do not want to open a silent duplicate. |



The problem
A group chat is capped at
GROUP_CHAT_MAX_ROUNDS = 3andGROUP_CHAT_MAX_MESSAGES = 10per user send, withGROUP_CHAT_MAX_MEMBERS = 6and
GROUP_CHAT_HISTORY_LIMIT = 24. All four are module constants.The message cap binds first, and it binds harder than the numbers suggest,
because the two multiply: with N members one round costs N messages. Running
the current loop shows what a room actually gets:
At six members the round cap is unreachable. At four it is reached only
nominally: eight messages are gone after two rounds, so the third round seats
two members and the other two never speak.
That last line is #89545, closed as fixed by #91084. The stranded-harvest fix
addresses the symptom, that late replies were lost and the room looked
abandoned. It does not change the arithmetic: an
Orchestrator → Scout → Builder → Verifier room spends one full round per pass,
and a verifier BLOCK plus a correction handoff plus the re-build is three
passes minimum. Ten messages does not fit that shape, whatever the harvest
does afterwards.
What this changes
Each of the four axes becomes a per-room override with three states: a number,
off, or absent. Absent inherits the shipped default, and the stored form omits
every axis left alone, so a room that never opens the settings stores nothing
and behaves exactly as it does today. Defaults are unchanged.
Turning rounds or messages off hands control to a safety brake, defaulting to
50 rounds / 200 messages, which is itself switchable.
On unbounded rooms, given #91481
#91481 is open: two Telegram bots exchanged 132 messages with no loop guard
anywhere in the admission path. Offering an unlimited group room in that
climate deserves an explicit answer rather than silence, so:
"no limit at all" takes two deliberate steps on two separate controls, and
the editor states in plain words what that means before the user gets there.
on a counter.
spokeThisRound === 0ends the drive when every member passesa round, and a new user message bumps the room epoch, which the loop honors
at the next member boundary. An unlimited room is a room that stops counting,
not a room you cannot get out of.
safetyactivity event naming theaxis and the count, so it does not go quiet in a way that reads as "the
conversation settled".
If you would rather not ship the fully-unbounded state at all, the brake can
be made non-nullable in one line and everything else here still stands. I
would rather ask than guess.
Bounds on what a room can ask for
Every numeric value is clamped, not just validated. A room can raise an axis
but not past these:
A value above the ceiling is clamped rather than rejected, so a typo in a
number field lands on the ceiling instead of erroring at the user. A value
that is not a positive number falls back to the default rather than reading as
"no limit", which matters because a stray
truecoerces to1underNumber()and would otherwise pass as a real setting. Both are tested.The ceilings bound numbers. They do not bound the off state, which is the
whole point of the section above: switching an axis off hands control to the
brake, and switching the brake off too is the deliberate two-step described
there.
Which cap binds first
The message cap, in almost every room, because the two multiply: with N
members one round costs N messages.
So raising rounds alone changes nothing above four members. Rather than leave
that to the release notes, the editor computes it live and says so under the
rows: "With 6 bots, the message budget runs out after about 2 rounds, raise it
too, or the round setting will not change anything."
Where it is edited
One component, three surfaces, all writing the same shape:
picker immediately rather than forcing a create-then-edit round trip.
3 rounds · 10 msgs,≤50 rounds · 10 msgs,∞ rounds · ∞ msgs.Because the axes multiply, the editor warns when raising rounds alone would do
nothing: "With 6 bots, the message budget runs out after about 2 rounds, raise
it too, or the round setting will not change anything." That case is the one a
user hits first and the one that would otherwise read as a broken setting.
Overrides persist with the room and ride the existing gateway sync under the
same newest-revision rule as the room picture.
Verification
The plugin suite goes from 362/362 to 376/376;
eslintreports no problems.The 14 added tests cover the resolver (defaults, explicit values, off versus
absent, clamping at the ceiling, and nonsense values falling back rather than
silently disabling a limit), the brake's dependence on its axis, the stored
round trip, the header label in all three states, the multiplication warning,
and two source assertions that the drive loop reads the room instead of the
constants.
Two of those tests were red on the first run and caught real defects: a
boolean
truecoerced to1and read as a valid limit, and a stale referenceto a constant in the sync path. Both are fixed here.
Not in this change
The four constants keep their current values as the defaults. This PR does not
argue that 3 and 10 are wrong for a new room, only that a room should be able
to say otherwise.