Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/desktop/src/plugins/hermes-bots/group-chat-config.ts
Original file line number Diff line number Diff line change
@@ -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<GroupChatConfig> {
try {
if (host && typeof host.request === 'function') {
const res = await host.request<{ value?: Record<string, unknown> }>('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<string> {
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)
}
10 changes: 10 additions & 0 deletions apps/desktop/src/plugins/hermes-bots/group-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions apps/desktop/src/plugins/hermes-bots/group-rounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ import {
GROUP_CHAT_HISTORY_LIMIT,
GROUP_CHAT_MAX_CONTINUATIONS,
GROUP_CHAT_MAX_MESSAGES,
GROUP_CHAT_MAX_ROUNDS,
groupSpeakerLabel,
groupThreadOf,
mintGroupThreadId,
shouldCommitMemberTurn,
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'
Expand Down Expand Up @@ -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.
Expand All @@ -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
)
Expand Down
Loading