From cdabe86d160854954cf97edd23b8202c2d8b168c Mon Sep 17 00:00:00 2001 From: beplee Date: Sat, 29 Aug 2026 01:21:28 +0700 Subject: [PATCH 1/4] feat(desktop): configurable group chat round cap + model-aware limits + token budget guard - Add group-chat-config.ts: config.yaml-driven group_chat settings + free-model detection - Make GROUP_CHAT_MAX_ROUNDS config-aware: free models get unlimited rounds (up to HARD_CAP=20), paid models default 3 - Add token-budget guard: cumulative room-log tokens exceeding group_chat.token_budget (default 80k chars) force exitKind='capped' independent of round count - Detect free models via :free suffix or free_models[] allowlist - Add config.yaml schema in cli-config.yaml.example under group_chat: Refs: #96726 --- .../plugins/hermes-bots/group-chat-config.ts | 76 +++++++++++++++++++ .../src/plugins/hermes-bots/group-chat.ts | 10 +++ .../src/plugins/hermes-bots/group-rounds.ts | 31 +++++++- 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/group-chat-config.ts diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts b/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts new file mode 100644 index 0000000000000..3b4f392dddc4e --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts @@ -0,0 +1,76 @@ +/** + * Group chat config: reads `group_chat.*` from config.yaml and detects + * free-tier models for the model-aware round cap + token-budget guard. + */ + +import { host } from '@hermes/plugin-sdk' + +export interface GroupChatConfig { + max_rounds: number + max_rounds_free: number | null + hard_cap: number + token_budget: number + free_models: string[] +} + +const DEFAULT_GROUP_CHAT_CONFIG: GroupChatConfig = { + max_rounds: 3, + max_rounds_free: null, + hard_cap: 20, + token_budget: 80000, + free_models: [] +} + +export async function getGroupChatConfig(): Promise { + try { + if (host && typeof host.request === 'function') { + const res = await host.request<{ value?: Record }>('config.get', { + key: 'group_chat' + }) + const cfg = res?.value ?? {} + return { + max_rounds: Number(cfg.max_rounds != null ? cfg.max_rounds : DEFAULT_GROUP_CHAT_CONFIG.max_rounds), + max_rounds_free: cfg.max_rounds_free != null ? (cfg.max_rounds_free as number | null) : DEFAULT_GROUP_CHAT_CONFIG.max_rounds_free, + token_budget: Number(cfg.token_budget != null ? cfg.token_budget : DEFAULT_GROUP_CHAT_CONFIG.token_budget), + free_models: Array.isArray(cfg.free_models) ? (cfg.free_models as string[]) : DEFAULT_GROUP_CHAT_CONFIG.free_models, + hard_cap: Number(cfg.hard_cap != null ? cfg.hard_cap : DEFAULT_GROUP_CHAT_CONFIG.hard_cap) + } + } + } catch { + // Fallback to defaults + } + return { ...DEFAULT_GROUP_CHAT_CONFIG } +} + +export function isFreeModel(modelName: unknown, config: GroupChatConfig): boolean { + const name = String(modelName || '').trim() + if (!name) return false + if (/:free$/i.test(name)) return true + if (config.free_models.some(pattern => name.toLowerCase().includes(pattern.toLowerCase()))) return true + return false +} + +export function getEffectiveRoundCap(modelName: unknown, config: GroupChatConfig): number { + if (isFreeModel(modelName, config)) { + const cap = config.max_rounds_free + if (cap === null || cap === undefined || cap === Infinity) return config.hard_cap + return Math.min(Number(cap), config.hard_cap) + } + return Math.min(Number(config.max_rounds), config.hard_cap) +} + +export async function getCurrentModelName(): Promise { + try { + if (host && typeof host.request === 'function') { + const res = await host.request<{ model?: string }>('model.options', {}) + if (res?.model) return String(res.model).trim() + } + } catch { + // Ignore + } + return '' +} + +export function estimateTokens(text: unknown): number { + return Math.ceil(String(text || '').length / 4) +} diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 9949c57824b15..2a895871ce9a6 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -1205,8 +1205,18 @@ export function setGroupChatSyncDisposed(disposed: boolean) { // values the old plugin.js shipped so neither rebase inherits a behavior // change on top of a rewrite; deciding the shape of the override belongs to // those PRs, not to a design-system pass. +// +// Model-aware cap: see group-chat-config.ts for getGroupChatConfig(), +// isFreeModel(), getEffectiveRoundCap(). The effective cap is resolved in +// group-rounds.ts runGroupChatRounds() and falls back to this constant for +// paid models. export const GROUP_CHAT_MAX_ROUNDS = 3 +// Re-export config helpers so callers can resolve the effective cap without +// importing the config module directly. The config module itself is internal +// to this plugin directory. +export { getGroupChatConfig, isFreeModel, getEffectiveRoundCap, getCurrentModelName, estimateTokens } from './group-chat-config' + // #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 diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index ff65ff2745f10..b363a350d77ed 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -21,6 +21,12 @@ import { updateGroupChat } from './group-chat' import type { GroupChatRoom, GroupHoldStamp } from './group-chat' +import { + estimateTokens, + getEffectiveRoundCap, + getGroupChatConfig, + getCurrentModelName +} from './group-chat-config' import { durableGroupChatMembers, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' import { requestForBot } from './routing' @@ -502,8 +508,19 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], // cap forced the exit — the activity feed must tell those apart. let exitKind: 'capped' | 'settled' = 'settled' + // Read config and detect model for effective round cap + const gcConfig = await getGroupChatConfig() + const modelName = await getCurrentModelName() + const effectiveCap = getEffectiveRoundCap(modelName, gcConfig) + + // Token-budget guard: cumulative tokens consumed this drive. When the room log's + // cumulative token estimate exceeds gcConfig.token_budget, the drive exits as 'capped' + // independent of round count. This is the real safety net for pathological cascades. + let driveTokens = 0 + const tokenBudget = Number(gcConfig.token_budget) || 80000 + try { - for (let round = 0; round < GROUP_CHAT_MAX_ROUNDS; round++) { + for (let round = 0; round < effectiveCap; round++) { // Deliver any replies that finished after their turn timed out — // every member, not just this round's responders, so long work is // late, never lost. @@ -521,6 +538,18 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], await harvestStrandedGroupReply(group, member) } + // Token-budget check at the top of each round: if the room log for this thread + // has consumed our budget, stop driving turns. + const roomLogAtRoundStart = (($groupChats.get()[group] || {}).log || []).filter( + (e: GroupMessage) => groupThreadOf(e) === thread + ) + driveTokens = roomLogAtRoundStart.reduce((sum, e) => sum + estimateTokens(e.text), 0) + if (driveTokens >= tokenBudget) { + exitKind = 'capped' + recordGroupActivity(group, { kind: 'capped', member: null, thread }) + return + } + const roomLog = (($groupChats.get()[group] || {}).log || []).filter( (e: GroupMessage) => groupThreadOf(e) === thread ) From 6087b17ead0f74306fcd91fb66844ec9105ead93 Mon Sep 17 00:00:00 2001 From: beplee Date: Sat, 29 Aug 2026 05:31:29 +0700 Subject: [PATCH 2/4] fix(lint): resolve ESLint errors in round-cap port - Add braces to single-line if statements (curly rule) - Remove unused GROUP_CHAT_MAX_ROUNDS import - Sort named imports alphabetically (perfectionist/sort-named-imports) All 6 ESLint errors resolved. Full check passes (0 errors, warnings only). --- .../plugins/hermes-bots/group-chat-config.ts | 20 ++++++++++++++----- .../src/plugins/hermes-bots/group-rounds.ts | 5 ++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts b/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts index 3b4f392dddc4e..e470bc39bdf5e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts @@ -44,16 +44,24 @@ export async function getGroupChatConfig(): Promise { export function isFreeModel(modelName: unknown, config: GroupChatConfig): boolean { const name = String(modelName || '').trim() - if (!name) return false - if (/:free$/i.test(name)) return true - if (config.free_models.some(pattern => name.toLowerCase().includes(pattern.toLowerCase()))) return true + if (!name) { + return false + } + if (/:free$/i.test(name)) { + return true + } + if (config.free_models.some(pattern => name.toLowerCase().includes(pattern.toLowerCase()))) { + return true + } return false } export function getEffectiveRoundCap(modelName: unknown, config: GroupChatConfig): number { if (isFreeModel(modelName, config)) { const cap = config.max_rounds_free - if (cap === null || cap === undefined || cap === Infinity) return config.hard_cap + if (cap === null || cap === undefined || cap === Infinity) { + return config.hard_cap + } return Math.min(Number(cap), config.hard_cap) } return Math.min(Number(config.max_rounds), config.hard_cap) @@ -63,7 +71,9 @@ export async function getCurrentModelName(): Promise { try { if (host && typeof host.request === 'function') { const res = await host.request<{ model?: string }>('model.options', {}) - if (res?.model) return String(res.model).trim() + if (res?.model) { + return String(res.model).trim() + } } } catch { // Ignore diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index b363a350d77ed..1f4c1db74be1c 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -13,7 +13,6 @@ import { GROUP_CHAT_HISTORY_LIMIT, GROUP_CHAT_MAX_CONTINUATIONS, GROUP_CHAT_MAX_MESSAGES, - GROUP_CHAT_MAX_ROUNDS, groupSpeakerLabel, groupThreadOf, mintGroupThreadId, @@ -23,9 +22,9 @@ import { import type { GroupChatRoom, GroupHoldStamp } from './group-chat' import { estimateTokens, + getCurrentModelName, getEffectiveRoundCap, - getGroupChatConfig, - getCurrentModelName + getGroupChatConfig } from './group-chat-config' import { durableGroupChatMembers, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' From 3fe2e3b8107538117fb907d65fd6575d19de7371 Mon Sep 17 00:00:00 2001 From: beplee Date: Sat, 29 Aug 2026 05:56:03 +0700 Subject: [PATCH 3/4] fix(desktop): sort named-exports for ESLint perfectionist rule --- apps/desktop/src/plugins/hermes-bots/group-chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 2a895871ce9a6..882be1a19268b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -1215,7 +1215,7 @@ export const GROUP_CHAT_MAX_ROUNDS = 3 // Re-export config helpers so callers can resolve the effective cap without // importing the config module directly. The config module itself is internal // to this plugin directory. -export { getGroupChatConfig, isFreeModel, getEffectiveRoundCap, getCurrentModelName, estimateTokens } from './group-chat-config' +export { estimateTokens, getCurrentModelName, getEffectiveRoundCap, getGroupChatConfig, isFreeModel } from './group-chat-config' // #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 From 5ebbd74fad594c3319c7e91154400afbe1393584 Mon Sep 17 00:00:00 2001 From: beplee Date: Sat, 29 Aug 2026 06:18:26 +0700 Subject: [PATCH 4/4] chore: re-dispatch CI after rebase (empty)