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..e470bc39bdf5e --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-config.ts @@ -0,0 +1,86 @@ +/** + * 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..882be1a19268b 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 { 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 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..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, @@ -21,6 +20,12 @@ import { updateGroupChat } from './group-chat' import type { GroupChatRoom, GroupHoldStamp } from './group-chat' +import { + estimateTokens, + getCurrentModelName, + getEffectiveRoundCap, + getGroupChatConfig +} from './group-chat-config' import { durableGroupChatMembers, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' import { requestForBot } from './routing' @@ -502,8 +507,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 +537,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 )