Files
Hermes-ui/packages/server/src/services/hermes/group-chat/agent-clients.ts
T

895 lines
34 KiB
TypeScript
Raw Normal View History

import { io, Socket } from 'socket.io-client'
import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
// ─── Types ────────────────────────────────────────────────────
interface AgentConfig {
profile: string
name: string
description: string
invited: number
}
interface MessageData {
id: string
roomId: string
senderId: string
senderName: string
content: string
timestamp: number
}
type MentionMessage = {
content: string
senderName: string
senderId: string
timestamp: number
input?: string | ContentBlock[]
mentionDepth?: number
}
interface MemberData {
id: string
name: string
joinedAt: number
}
interface JoinResult {
roomId: string
roomName: string
members: MemberData[]
messages: MessageData[]
rooms: string[]
}
export interface AgentEventHandler {
onMessage?: (data: { roomId: string; msg: MessageData }) => void
onTyping?: (data: { roomId: string; userId: string; userName: string }) => void
onStopTyping?: (data: { roomId: string; userId: string; userName: string }) => void
onMemberJoined?: (data: { roomId: string; memberId: string; memberName: string; members: MemberData[] }) => void
onMemberLeft?: (data: { roomId: string; memberId: string; memberName: string; members: MemberData[] }) => void
}
// ─── Agent Client (single connection) ─────────────────────────
class AgentClient {
readonly agentId: string
readonly profile: string
readonly name: string
readonly description: string
private socket: Socket | null = null
private joinedRooms = new Set<string>()
private handlers: AgentEventHandler
private _reconnecting = false
private contextEngine: any = null
private storage: any = null
private pendingToolCallIds = new Map<string, string[]>()
private pendingToolBaseIds = new Map<string, string>()
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
this.profile = config.profile
this.name = config.name
this.description = config.description
this.handlers = handlers
}
get connected(): boolean {
return this.socket?.connected ?? false
}
get id(): string | undefined {
return this.socket?.id
}
setContextEngine(engine: any): void {
this.contextEngine = engine
}
setStorage(storage: any): void {
this.storage = storage
}
async connect(port?: number): Promise<void> {
const actualPort = port ?? parseInt(process.env.PORT || '8648', 10)
const token = await getToken()
this.socket = io(`http://127.0.0.1:${actualPort}/group-chat`, {
auth: {
token: token || undefined,
name: this.name,
},
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
2026-05-14 09:03:57 +08:00
randomizationFactor: 0.5,
timeout: 30000,
})
this.bindEvents()
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000)
this.socket!.on('connect', () => {
clearTimeout(timeout)
logger.debug(`[AgentClient] ${this.name} connected, socket id: ${this.socket!.id}`)
resolve()
})
this.socket!.on('connect_error', (err) => {
clearTimeout(timeout)
logger.error(err, `[AgentClient] ${this.name} connect_error`)
reject(err)
})
})
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect()
this.socket = null
this.joinedRooms.clear()
}
}
async joinRoom(roomId: string): Promise<JoinResult> {
this.ensureConnected()
return new Promise((resolve, reject) => {
this.socket!.emit('join', { roomId }, (res: JoinResult | { error: string }) => {
if ('error' in res) {
reject(new Error(res.error))
} else {
this.joinedRooms.add(roomId)
resolve(res)
}
})
})
}
sendMessage(roomId: string, content: string, messageId?: string, extra?: Record<string, unknown>): Promise<string> {
this.ensureConnected()
return new Promise((resolve, reject) => {
this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => {
if (res.error) {
reject(new Error(res.error))
} else {
resolve(res.id!)
}
})
})
}
startTyping(roomId: string): void {
this.ensureConnected()
this.socket!.emit('typing', { roomId })
}
stopTyping(roomId: string): void {
this.ensureConnected()
this.socket!.emit('stop_typing', { roomId })
}
emitContextStatus(roomId: string, status: 'compressing' | 'replying' | 'ready'): void {
this.ensureConnected()
this.socket!.emit('context_status', { roomId, agentName: this.name, status })
}
emitApprovalRequested(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload })
}
emitApprovalResolved(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload })
}
async interrupt(roomId: string): Promise<void> {
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile)
this.stopTyping(roomId)
this.emitContextStatus(roomId, 'ready')
}
emitMessageStreamStart(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_start', {
roomId,
id: messageId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
timestamp: Date.now(),
})
}
emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta })
}
emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta })
}
emitMessageStreamEnd(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_end', { roomId, id: messageId })
}
getJoinedRooms(): string[] {
return Array.from(this.joinedRooms)
}
private ensureConnected(): void {
if (!this.socket?.connected) {
throw new Error(`Agent "${this.name}" is not connected`)
}
}
// ─── Hermes Gateway Integration ────────────────────────────
/**
* Handle an @mention from the server side.
* Called by AgentClients.processMentions() — no socket round-trip needed.
* onStatus is called to report context compression progress.
*/
async replyToMention(
roomId: string,
msg: MentionMessage,
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
): Promise<void> {
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
try {
// Notify room that agent is typing
this.startTyping(roomId)
// Build compressed context if context engine is available
let conversationHistory: Array<{ role: string; content: string }> = []
let instructions: string | undefined
if (this.contextEngine && this.storage) {
try {
logger.debug(`[AgentClients] ${this.name}: building context...`)
onStatus?.('compressing')
// Get room members with descriptions for context
const roomMembers: Array<{ userId: string; name: string; description: string }> = this.storage.getRoomMembers(roomId) || []
const memberNames = roomMembers.map((m: any) => m.name)
const members = roomMembers.map((m: any) => ({ userId: m.userId, name: m.name, description: m.description }))
// Get room compression config
const roomInfo = this.storage.getRoom(roomId)
const compression = roomInfo ? {
triggerTokens: roomInfo.triggerTokens,
maxHistoryTokens: roomInfo.maxHistoryTokens,
tailMessageCount: roomInfo.tailMessageCount,
} : undefined
const ctx = await this.contextEngine.buildContext({
roomId,
agentId: this.agentId,
agentName: this.name,
agentDescription: this.description,
agentSocketId: this.socket?.id || '',
roomName: roomId,
memberNames,
members,
upstream: '',
apiKey: null,
currentMessage: msg,
compression,
profile: this.profile,
})
conversationHistory = ctx.conversationHistory
instructions = ctx.instructions
logger.debug(`[AgentClients] ${this.name}: context built — historyLen=${conversationHistory.length}, meta=%j`, ctx.meta)
onStatus?.('replying')
} catch (err: any) {
logger.warn(`[AgentClients] ${this.name}: context engine failed: ${err.message}`)
onStatus?.('replying')
// Degrade: continue without context
}
}
// Keep the original mentions visible and add an explicit routing note.
// When a user mentions multiple agents, stripping only this agent's
// name can make the remaining input look like it was meant for
// someone else.
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
const rawInput = msg.input || msg.content
const input = isContentBlockArray(rawInput)
? rawInput.map((block) => {
if (block.type !== 'text') return block
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
const bridge = new AgentBridgeClient()
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
const flushedAssistantParts = new Set<string>()
let lastChunk: AgentBridgeOutput | null = null
const started = await bridge.chat(
sessionId,
bridgeInput,
conversationHistory,
instructions,
this.profile,
{
source: 'api_server',
},
)
this.emitMessageStreamStart(roomId, streamMessageId)
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
lastChunk = chunk
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
const toolBaseId = streamMessageId
if (currentContent.trim()) {
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
flushedAssistantParts.add(streamMessageId)
currentContent = ''
}
this.emitMessageStreamEnd(roomId, toolBaseId)
partIndex += 1
streamMessageId = groupMessagePartId(runMessageId, partIndex)
this.emitMessageStreamStart(roomId, streamMessageId)
return toolBaseId
})
if (chunk.delta) {
currentContent += chunk.delta
totalContent += chunk.delta
this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta)
}
}
if (lastChunk?.status === 'error') {
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (!totalContent) {
currentContent = extractBridgeFinalText(lastChunk)
totalContent = currentContent
}
recordBridgeUsage(roomId, this.profile, lastChunk?.result)
logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`)
if (currentContent) {
this.stopTyping(roomId)
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
this.emitMessageStreamEnd(roomId, streamMessageId)
onStatus?.('ready')
return
}
logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
} catch (err: any) {
logger.error(`[AgentClients] ${this.name}: error handling message: ${err.message}`)
this.stopTyping(roomId)
onStatus?.('ready')
}
}
private async recordBridgeEvents(
roomId: string,
chunk: AgentBridgeOutput,
getCurrentMessageId: () => string,
beforeToolStarted: () => Promise<string>,
): Promise<string> {
let reasoning = ''
for (const ev of chunk.events || []) {
const eventType = String((ev as any)?.event || '')
if (eventType === 'tool.started') {
const toolBaseId = await beforeToolStarted()
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
} else if (eventType === 'tool.completed') {
this.recordToolCompleted(roomId, ev as Record<string, unknown>)
} else if (eventType === 'approval.requested') {
this.emitApprovalRequested(roomId, {
event: 'approval.requested',
approval_id: (ev as any).approval_id,
command: (ev as any).command,
description: (ev as any).description,
choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined,
allow_permanent: (ev as any).allow_permanent,
})
} else if (eventType === 'approval.resolved') {
this.emitApprovalResolved(roomId, {
event: 'approval.resolved',
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)
}
}
return reasoning
}
private recordToolStarted(roomId: string, ev: Record<string, unknown>, runMessageId: string): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName))
this.trackPendingToolCall(roomId, toolName, toolCallId)
this.pendingToolBaseIds.set(toolCallId, runMessageId)
const timestamp = Date.now()
const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {}
const args = normalizeToolArgs(rawArgs)
const toolCall = {
id: toolCallId,
type: 'function',
function: {
name: toolName,
arguments: JSON.stringify(args),
},
}
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolcall_${safeId(toolCallId)}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: '',
timestamp,
role: 'assistant',
tool_calls: [toolCall],
finish_reason: 'tool_calls',
}
this.sendMessage(roomId, '', msg.id, {
role: 'assistant',
tool_calls: msg.tool_calls,
finish_reason: 'tool_calls',
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`))
}
private recordToolCompleted(roomId: string, ev: Record<string, unknown>): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const rawId = String(ev.tool_call_id || '').trim()
const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName))
const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0)
this.pendingToolBaseIds.delete(toolCallId)
const output = bridgeToolOutput(ev)
const timestamp = Date.now()
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: output,
timestamp,
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
}
this.sendMessage(roomId, output, msg.id, {
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`))
}
private pendingToolKey(roomId: string, toolName: string): string {
return `${roomId}::${toolName || 'tool'}`
}
private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key) || []
list.push(toolCallId)
this.pendingToolCallIds.set(key, list)
}
private takePendingToolCall(roomId: string, toolName: string): string | undefined {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key)
if (!list?.length) return undefined
const id = list.shift()
if (list.length) this.pendingToolCallIds.set(key, list)
else this.pendingToolCallIds.delete(key)
return id
}
private nextToolIndex(roomId: string, toolName: string): number {
const key = this.pendingToolKey(roomId, toolName)
return (this.pendingToolCallIds.get(key)?.length || 0) + 1
}
private bindEvents(): void {
const s = this.socket!
s.on('typing', (data: any) => {
this.handlers.onTyping?.(data)
})
s.on('stop_typing', (data: any) => {
this.handlers.onStopTyping?.(data)
})
s.on('member_joined', (data: any) => {
this.handlers.onMemberJoined?.(data)
})
s.on('member_left', (data: any) => {
this.handlers.onMemberLeft?.(data)
})
// Auto rejoin rooms on reconnect
s.io.on('reconnect', async () => {
if (this._reconnecting) return
this._reconnecting = true
logger.info(`[AgentClients] ${this.name} reconnecting, rejoining ${this.joinedRooms.size} rooms...`)
const rooms = Array.from(this.joinedRooms)
for (const roomId of rooms) {
try {
await this.joinRoom(roomId)
} catch (err: any) {
logger.error(`[AgentClients] ${this.name} failed to rejoin room ${roomId}: ${err.message}`)
}
}
this._reconnecting = false
})
}
}
function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string {
const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120)
}
function groupMessageId(roomId: string, profile: string, name: string): string {
const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160)
}
function groupMessagePartId(runMessageId: string, partIndex: number): string {
return `${safeId(runMessageId)}_part_${partIndex}`
}
function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string {
const raw = String(rawToolCallId || '').trim()
if (raw) return raw
return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}`
}
function safeId(value: string): string {
return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
}
function bridgeToolOutput(ev: Record<string, unknown>): string {
const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? ''
return typeof value === 'string' ? value : JSON.stringify(value ?? '')
}
function normalizeToolArgs(value: unknown): Record<string, unknown> {
if (!value) return {}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : { value }
} catch {
return { value }
}
}
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
}
function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string {
const result = chunk?.result as any
const output = result?.final_response || chunk?.output || ''
return typeof output === 'string' ? output.trim() : ''
}
function recordBridgeUsage(roomId: string, profile: string, result: unknown): void {
const payload = result as any
const usage = payload?.usage || payload?.response?.usage
if (!usage) return
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: payload?.model || payload?.response?.model || '',
profile,
})
}
// ─── AgentClients (roomId -> agents) ──────────────────────────
export class AgentClients {
private rooms = new Map<string, Map<string, AgentClient>>()
private _contextEngine: any = null
private _storage: any = null
// Per-room processing lock + mention queue
private _processingRooms = new Set<string>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: MentionMessage }>>()
/**
* Create an agent client and connect it to the server.
* The agent will NOT auto-join any room — call addAgentToRoom separately.
*/
async createAgent(config: AgentConfig, handlers?: AgentEventHandler, port?: number): Promise<AgentClient> {
const client = new AgentClient(config, handlers)
await client.connect(port)
// Auto-apply stored references (fixes propagation for agents created after set*)
if (this._contextEngine) client.setContextEngine(this._contextEngine)
if (this._storage) client.setStorage(this._storage)
logger.info(`[AgentClients] Connected: ${client.name} (${client.agentId})`)
return client
}
/**
* Connect an agent to a room.
*/
async addAgentToRoom(roomId: string, client: AgentClient): Promise<JoinResult> {
let room = this.rooms.get(roomId)
if (!room) {
room = new Map()
this.rooms.set(roomId, room)
}
room.set(client.agentId, client)
const result = await client.joinRoom(roomId)
logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`)
return result
}
/**
* Remove an agent from a room and disconnect it.
*/
removeAgentFromRoom(roomId: string, agentId: string): void {
const room = this.rooms.get(roomId)
if (!room) return
const client = room.get(agentId)
if (client) {
client.disconnect()
room.delete(agentId)
logger.info(`[AgentClients] ${client.name} left room: ${roomId}`)
// Invalidate context engine cache for this agent
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
}
if (room.size === 0) {
this.rooms.delete(roomId)
}
}
/**
* Get all agents in a room.
*/
getAgents(roomId: string): AgentClient[] {
const room = this.rooms.get(roomId)
return room ? Array.from(room.values()) : []
}
/**
* Get a specific agent in a room.
*/
getAgent(roomId: string, agentId: string): AgentClient | undefined {
return this.rooms.get(roomId)?.get(agentId)
}
/**
* Get all room IDs that have agents.
*/
getRoomIds(): string[] {
return Array.from(this.rooms.keys())
}
/**
* Send a message from a specific agent in a room.
*/
async sendMessage(roomId: string, agentId: string, content: string): Promise<string> {
const client = this.getAgent(roomId, agentId)
if (!client) {
throw new Error(`Agent "${agentId}" not found in room "${roomId}"`)
}
return client.sendMessage(roomId, content)
}
/**
* Broadcast a message from all agents in a room.
*/
async broadcastFromRoom(roomId: string, content: string): Promise<string[]> {
const agents = this.getAgents(roomId)
return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content)))
}
async interruptAgent(roomId: string, agentName: string): Promise<void> {
const agent = this.getAgents(roomId).find(a => a.name === agentName)
if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`)
this._mentionQueue.delete(`${roomId}:${agent.name}`)
await agent.interrupt(roomId)
}
/**
* Disconnect all agents in a room.
*/
disconnectRoom(roomId: string): void {
const room = this.rooms.get(roomId)
if (!room) return
room.forEach((client) => client.disconnect())
this.rooms.delete(roomId)
logger.info(`[AgentClients] All agents disconnected from room: ${roomId}`)
// Invalidate context engine cache for this room
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
}
resetRoomContext(roomId: string): void {
this._mentionQueue.delete(roomId)
for (const key of Array.from(this._mentionQueue.keys())) {
if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key)
}
for (const key of Array.from(this._processingRooms)) {
if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key)
}
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
}
/**
* Disconnect all agents in all rooms.
*/
disconnectAll(): void {
this.rooms.forEach((room) => {
room.forEach((client) => client.disconnect())
})
this.rooms.clear()
logger.info('[AgentClients] All agents disconnected')
}
/**
* Set context engine for all existing and future agents.
*/
setContextEngine(engine: any): void {
this._contextEngine = engine
this.rooms.forEach((room) => {
room.forEach((client) => client.setContextEngine(engine))
})
}
/**
* Set message storage for all existing and future agents.
*/
setStorage(storage: any): void {
this._storage = storage
this.rooms.forEach((room) => {
room.forEach((client) => client.setStorage(storage))
})
}
/**
* Server-side: parse @mentions and forward to matching agents directly.
* If the room is already processing (compressing/replying), queue the mention.
*/
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
const agents = this.getAgents(roomId)
const senderName = msg.senderName.toLowerCase()
const mentioned = agents.filter(a => (
a.name.toLowerCase() !== senderName &&
isAgentMentioned(msg.content, a.name)
))
if (mentioned.length === 0) return
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
for (const agent of mentioned) {
this._processAgentMention(roomId, agent, msg).catch((err) => {
logger.error(`[AgentClients] error processing mention for ${agent.name}: ${err.message}`)
})
}
}
/**
* Process a single agent mention with status reporting and queue drain.
*/
private async _processAgentMention(
roomId: string,
agent: AgentClient,
msg: MentionMessage,
): Promise<void> {
const agentKey = `${roomId}:${agent.name}`
if (this._processingRooms.has(agentKey)) {
// Queue for this specific agent
let queue = this._mentionQueue.get(agentKey)
if (!queue) {
queue = []
this._mentionQueue.set(agentKey, queue)
}
queue.push({ agent, msg })
logger.debug(`[AgentClients] agent ${agent.name} is processing, queued mention in room ${roomId}`)
return
}
this._processingRooms.add(agentKey)
const onStatus = (status: 'compressing' | 'replying' | 'ready') => {
agent.emitContextStatus(roomId, status)
logger.debug(`[AgentClients] room ${roomId} agent ${agent.name} status: ${status}`)
}
try {
await agent.replyToMention(roomId, msg, onStatus)
} finally {
this._processingRooms.delete(agentKey)
await this._drainQueue(agentKey, roomId)
}
}
/**
* Drain queued mentions for a room after processing completes.
*/
private async _drainQueue(agentKey: string, roomId: string): Promise<void> {
const queue = this._mentionQueue.get(agentKey)
if (!queue || queue.length === 0) return
this._mentionQueue.delete(agentKey)
logger.debug(`[AgentClients] draining ${queue.length} queued mention(s) for ${agentKey}`)
// Process the last queued mention only (most recent, discards stale intermediate ones)
const last = queue[queue.length - 1]
await this._processAgentMention(roomId, last.agent, last.msg)
}
}
function nextMentionDepth(msg: MentionMessage): number {
return Math.max(0, msg.mentionDepth || 0) + 1
}
function isAgentMentioned(content: string, agentName: string): boolean {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
return pattern.test(content)
}