diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index a80bdb1..33c985c 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -72,6 +72,12 @@ export function groupContextTokensWithFixedOverhead( return Math.floor(fixedContextTokens) + estimateGroupHistoryMessageTokens(history) } +export function groupBridgeReasoningDeltaFromEvent(event: Record): string | null { + if (String(event.event || '') !== 'reasoning.delta') return null + const text = String(event.text || '') + return text ? text : null +} + interface MemberData { id: string name: string @@ -698,10 +704,12 @@ class AgentClient { approval_id: (ev as any).approval_id, choice: (ev as any).choice, }) - } else if (eventType === 'reasoning.delta' || eventType === 'thinking.delta') { - const text = String((ev as any)?.text || '') - reasoning += text - this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text) + } else { + const text = groupBridgeReasoningDeltaFromEvent(ev as Record) + if (text) { + reasoning += text + this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text) + } } } return reasoning diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index 4b9108b..5050919 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -142,6 +142,12 @@ function normalizeMentionDepth(depth: unknown): number { return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 } +function maxAgentMentionDepth(): number { + const value = Number(process.env.HERMES_GROUP_CHAT_MAX_AGENT_MENTION_DEPTH) + if (!Number.isFinite(value) || value <= 0) return 4 + return Math.min(10, Math.floor(value)) +} + function groupRunOrder(id: string): { baseId: string; phase: number } { const value = String(id || '') const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/) @@ -965,10 +971,14 @@ export class GroupChatServer { ack?.({ id: savedMsg.id }) const mentionDepth = normalizeMentionDepth(data.mentionDepth) - const shouldRouteMentions = savedMsg.role === 'user' + const isAgentReply = savedMsg.role === 'assistant' && member?.source === 'agent' + const shouldRouteMentions = savedMsg.role === 'user' || + (isAgentReply && mentionDepth < maxAgentMentionDepth()) if (shouldRouteMentions) { - // Server-side @mention routing — parse user mentions and invoke agents directly. + // Server-side @mention routing — parse mentions and invoke agents directly. + // Agent replies are allowed to mention other agents, but mentionDepth + // bounds chained agent-to-agent handoffs so one prompt cannot loop forever. this.agentClients.processMentions(roomId, { content: contentToText(savedMsg.content), input: Array.isArray(data.content) ? data.content : undefined, diff --git a/tests/server/group-chat-context-cache.test.ts b/tests/server/group-chat-context-cache.test.ts index 1988504..49bc931 100644 --- a/tests/server/group-chat-context-cache.test.ts +++ b/tests/server/group-chat-context-cache.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { countTokens } from '../../packages/server/src/lib/context-compressor' import { estimateGroupHistoryMessageTokens, + groupBridgeReasoningDeltaFromEvent, groupContextTokensWithFixedOverhead, } from '../../packages/server/src/services/hermes/group-chat/agent-clients' @@ -22,4 +23,19 @@ describe('group chat fixed context cache helpers', () => { expect(groupContextTokensWithFixedOverhead(undefined, [{ content: 'hello' }])).toBeUndefined() expect(groupContextTokensWithFixedOverhead(null, [{ content: 'hello' }])).toBeUndefined() }) + + it('keeps spinner thinking events out of persisted group-chat reasoning', () => { + expect(groupBridgeReasoningDeltaFromEvent({ + event: 'thinking.delta', + text: '(◕‿◕✿) pondering...', + })).toBeNull() + expect(groupBridgeReasoningDeltaFromEvent({ + event: 'reasoning.delta', + text: 'real reasoning', + })).toBe('real reasoning') + expect(groupBridgeReasoningDeltaFromEvent({ + event: 'reasoning.delta', + text: '', + })).toBeNull() + }) }) diff --git a/tests/server/group-chat-member-sync.test.ts b/tests/server/group-chat-member-sync.test.ts index 27e9e13..72035d2 100644 --- a/tests/server/group-chat-member-sync.test.ts +++ b/tests/server/group-chat-member-sync.test.ts @@ -270,15 +270,15 @@ describe('Group Chat member/agent identity sync', () => { expect(ctx.body).toEqual({ rooms }) }) - it('routes @mentions only from user messages, not agent replies', () => { + it('routes @mentions from users and bounded agent replies', () => { const server = Object.create(GroupChatServer.prototype) as any const emit = vi.fn() server.rooms = new Map([ ['room-1', { hasOnlineMember: vi.fn(() => true), getOnlineMemberBySocketId: vi.fn((socketId: string) => socketId === 'agent-socket' - ? { userId: 'agent-1', name: '丫鬟' } - : { userId: 'human-1', name: 'Human' }), + ? { userId: 'agent-1', name: '丫鬟', source: 'agent' } + : { userId: 'human-1', name: 'Human', source: 'human' }), }], ]) server.socketUserMap = new Map([ @@ -297,9 +297,23 @@ describe('Group Chat member/agent identity sync', () => { server.handleMessage({ id: 'human-socket' }, { roomId: 'room-1', content: '@all hi', role: 'user' }, vi.fn()) expect(server.agentClients.processMentions).toHaveBeenCalledTimes(1) + expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({ + content: '@all hi', + senderId: 'human-1', + mentionDepth: 0, + })) server.agentClients.processMentions.mockClear() server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all agent says hi', role: 'assistant', mentionDepth: 1 }, vi.fn()) + expect(server.agentClients.processMentions).toHaveBeenCalledTimes(1) + expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({ + content: '@all agent says hi', + senderId: 'agent-1', + mentionDepth: 1, + })) + + server.agentClients.processMentions.mockClear() + server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all too deep', role: 'assistant', mentionDepth: 4 }, vi.fn()) expect(server.agentClients.processMentions).not.toHaveBeenCalled() }) })