2026-04-24 20:41:14 +08:00
import { io , Socket } from 'socket.io-client'
2026-05-20 11:13:15 +02:00
import { randomBytes } from 'crypto'
2026-04-24 20:41:14 +08:00
import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger'
2026-04-29 16:26:24 +08:00
import { updateUsage } from '../../../db/hermes/usage-store'
2026-05-22 10:20:39 +08:00
import { countTokens } from '../../../lib/context-compressor'
import { AgentBridgeClient , type AgentBridgeContextEstimate , type AgentBridgeMessage , type AgentBridgeOutput } from '../agent-bridge'
2026-05-19 16:09:59 +08:00
import { convertContentBlocksForAgent , isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
2026-05-20 04:21:57 +02:00
import {
isAllAgentsMentioned ,
resolveMentionTargets ,
stripMentionRoutingTokens ,
} from './mention-routing'
2026-04-24 20:41:14 +08:00
2026-05-20 11:13:15 +02:00
export const GROUP_CHAT_AGENT_SOCKET_SECRET = randomBytes ( 32 ) . toString ( 'hex' )
2026-04-24 20:41:14 +08:00
// ─── Types ────────────────────────────────────────────────────
interface AgentConfig {
2026-05-20 11:13:15 +02:00
agentId? : string
2026-04-24 20:41:14 +08:00
profile : string
name : string
description : string
invited : number
}
interface MessageData {
id : string
roomId : string
senderId : string
senderName : string
content : string
timestamp : number
}
2026-05-19 16:09:59 +08:00
type MentionMessage = {
content : string
senderName : string
senderId : string
timestamp : number
input? : string | ContentBlock [ ]
mentionDepth? : number
}
2026-05-22 10:20:39 +08:00
type GroupEstimateMessage = { role : 'user' | 'assistant' ; content : string }
interface BridgeContextCache {
fixedContextTokens : number
instructions? : string
systemPromptTokens? : number
toolTokens? : number
systemPromptChars? : number
toolCount? : number
toolNames? : string [ ]
profile? : string
model? : string
provider? : string
}
export function estimateGroupHistoryMessageTokens ( history : Array < { content? : unknown } > ) : number {
return history . reduce ( ( sum , message ) = > sum + countTokens ( String ( message . content || '' ) ) , 0 )
}
export function groupContextTokensWithFixedOverhead (
fixedContextTokens : number | null | undefined ,
history : Array < { content? : unknown } > ,
) : number | undefined {
if ( typeof fixedContextTokens !== 'number' || ! Number . isFinite ( fixedContextTokens ) || fixedContextTokens < 0 ) {
return undefined
}
return Math . floor ( fixedContextTokens ) + estimateGroupHistoryMessageTokens ( history )
}
2026-05-29 09:02:38 +08:00
export function groupBridgeReasoningDeltaFromEvent ( event : Record < string , unknown > ) : string | null {
if ( String ( event . event || '' ) !== 'reasoning.delta' ) return null
const text = String ( event . text || '' )
return text ? text : null
}
2026-04-24 20:41:14 +08:00
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
2026-05-19 16:09:59 +08:00
private pendingToolCallIds = new Map < string , string [ ] > ( )
private pendingToolBaseIds = new Map < string , string > ( )
2026-05-22 10:20:39 +08:00
private bridgeContextCache = new Map < string , BridgeContextCache > ( )
2026-04-24 20:41:14 +08:00
constructor ( config : AgentConfig , handlers : AgentEventHandler = { } ) {
2026-05-20 11:13:15 +02:00
this . agentId = config . agentId || Date . now ( ) . toString ( 36 ) + Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 )
2026-04-24 20:41:14 +08:00
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
}
2026-05-08 14:04:51 +08:00
async connect ( port? : number ) : Promise < void > {
const actualPort = port ? ? parseInt ( process . env . PORT || '8648' , 10 )
2026-04-24 20:41:14 +08:00
const token = await getToken ( )
2026-05-08 14:04:51 +08:00
this . socket = io ( ` http://127.0.0.1: ${ actualPort } /group-chat ` , {
2026-04-24 20:41:14 +08:00
auth : {
token : token || undefined ,
2026-05-20 11:13:15 +02:00
userId : this.agentId ,
2026-04-24 20:41:14 +08:00
name : this.name ,
2026-05-20 11:13:15 +02:00
description : this.description ,
source : 'agent' ,
agentSocketSecret : GROUP_CHAT_AGENT_SOCKET_SECRET ,
2026-04-24 20:41:14 +08:00
} ,
transports : [ 'websocket' ] ,
reconnection : true ,
reconnectionAttempts : Infinity ,
reconnectionDelay : 1000 ,
reconnectionDelayMax : 30000 ,
2026-05-14 09:03:57 +08:00
randomizationFactor : 0.5 ,
timeout : 30000 ,
2026-04-24 20:41:14 +08:00
} )
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 ( )
2026-05-22 10:20:39 +08:00
this . bridgeContextCache . clear ( )
2026-04-24 20:41:14 +08:00
}
}
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 )
}
} )
} )
}
2026-05-19 16:09:59 +08:00
sendMessage ( roomId : string , content : string , messageId? : string , extra? : Record < string , unknown > ) : Promise < string > {
2026-04-24 20:41:14 +08:00
this . ensureConnected ( )
return new Promise ( ( resolve , reject ) = > {
2026-05-19 16:09:59 +08:00
this . socket ! . emit ( 'message' , { roomId , content , id : messageId , . . . extra } , ( res : { id? : string ; error? : string } ) = > {
2026-04-24 20:41:14 +08:00
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 } )
}
2026-05-21 19:40:52 +08:00
emitContextStatus ( roomId : string , status : 'compressing' | 'replying' | 'ready' , extra? : Record < string , unknown > ) : void {
2026-04-24 20:41:14 +08:00
this . ensureConnected ( )
2026-05-21 19:40:52 +08:00
this . socket ! . emit ( 'context_status' , { roomId , agentName : this.name , status , . . . extra } )
2026-04-24 20:41:14 +08:00
}
2026-05-19 16:09:59 +08:00
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 } )
}
2026-04-24 20:41:14 +08:00
getJoinedRooms ( ) : string [ ] {
return Array . from ( this . joinedRooms )
}
2026-05-22 10:20:39 +08:00
private finiteToken ( value : unknown ) : number | undefined {
return typeof value === 'number' && Number . isFinite ( value ) && value >= 0
? Math . floor ( value )
: undefined
}
private cacheBridgeContext ( sessionId : string , data : Record < string , unknown > | AgentBridgeContextEstimate , instructions? : string ) : void {
const fixedContextTokens = this . finiteToken ( data . fixed_context_tokens )
if ( fixedContextTokens == null ) return
this . bridgeContextCache . set ( sessionId , {
fixedContextTokens ,
instructions ,
systemPromptTokens : this.finiteToken ( data . system_prompt_tokens ) ,
toolTokens : this.finiteToken ( data . tool_tokens ) ,
systemPromptChars : this.finiteToken ( data . system_prompt_chars ) ,
toolCount : this.finiteToken ( data . tool_count ) ,
toolNames : Array.isArray ( data . tool_names ) ? data . tool_names . map ( String ) : undefined ,
profile : typeof data . profile === 'string' ? data.profile : undefined ,
model : typeof data . model === 'string' ? data.model : undefined ,
provider : typeof data . provider === 'string' ? data.provider : undefined ,
} )
}
private estimateHistoryMessageTokens ( history : GroupEstimateMessage [ ] ) : number {
return estimateGroupHistoryMessageTokens ( history )
}
private estimateWithCachedBridgeContext ( sessionId : string , history : GroupEstimateMessage [ ] , instructions? : string ) : number | undefined {
const cache = this . bridgeContextCache . get ( sessionId )
if ( ! cache ) return undefined
if ( cache . instructions !== instructions ) return undefined
return groupContextTokensWithFixedOverhead ( cache . fixedContextTokens , history )
}
private async estimateGroupContextTokens (
roomId : string ,
sessionId : string ,
bridge : AgentBridgeClient ,
history : GroupEstimateMessage [ ] ,
instructions : string | undefined ,
phase : string ,
) : Promise < number | undefined > {
const cachedTokens = this . estimateWithCachedBridgeContext ( sessionId , history , instructions )
if ( cachedTokens != null ) {
logger . info ( {
roomId ,
agentName : this.name ,
profile : this.profile ,
sessionId ,
messages : history.length ,
fixedContextTokens : this.bridgeContextCache.get ( sessionId ) ? . fixedContextTokens ,
messageTokens : cachedTokens - ( this . bridgeContextCache . get ( sessionId ) ? . fixedContextTokens || 0 ) ,
fullContextTokens : cachedTokens ,
phase ,
source : 'cache' ,
} , '[GroupChat] full context estimate' )
return cachedTokens
}
const estimate = await bridge . contextEstimate (
sessionId ,
history ,
instructions ,
this . profile ,
)
this . cacheBridgeContext ( sessionId , estimate , instructions )
const totalTokens = Number ( estimate . token_count || 0 )
logger . info ( {
roomId ,
agentName : this.name ,
profile : this.profile ,
sessionId ,
messages : estimate.message_count ,
toolCount : estimate.tool_count ,
systemPromptChars : estimate.system_prompt_chars ,
fixedContextTokens : estimate.fixed_context_tokens ,
fullContextTokens : estimate.token_count ,
phase ,
source : 'bridge' ,
} , '[GroupChat] full context estimate' )
return Number . isFinite ( totalTokens ) && totalTokens > 0 ? Math . floor ( totalTokens ) : undefined
}
2026-04-24 20:41:14 +08:00
private ensureConnected ( ) : void {
if ( ! this . socket ? . connected ) {
throw new Error ( ` Agent " ${ this . name } " is not connected ` )
}
}
2026-05-20 11:13:15 +02:00
// ─── Hermes Agent Bridge Integration ───────────────────────
2026-04-24 20:41:14 +08:00
/**
* 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 ,
2026-05-19 16:09:59 +08:00
msg : MentionMessage ,
2026-05-21 19:40:52 +08:00
onStatus ? : ( status : 'compressing' | 'replying' | 'ready' , extra? : Record < string , unknown > ) = > void ,
2026-04-24 20:41:14 +08:00
) : Promise < void > {
logger . debug ( ` [AgentClients] ${ this . name } mentioned by ${ msg . senderName } : " ${ msg . content . slice ( 0 , 50 ) } " ` )
2026-05-21 09:05:17 +08:00
const runMessageId = groupMessageId ( roomId , this . profile , this . name )
let partIndex = 0
let streamMessageId = groupMessagePartId ( runMessageId , partIndex )
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
let streamStarted = false
2026-04-24 20:41:14 +08:00
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
2026-05-21 19:40:52 +08:00
const bridge = new AgentBridgeClient ( )
const sessionSeed = String ( this . storage ? . getRoom ? . ( roomId ) ? . sessionSeed || '0' )
const sessionId = groupBridgeSessionId ( roomId , this . profile , this . name , sessionSeed )
2026-04-24 20:41:14 +08:00
if ( this . contextEngine && this . storage ) {
try {
logger . debug ( ` [AgentClients] ${ this . name } : building context... ` )
// 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 ,
2026-05-19 16:09:59 +08:00
upstream : '' ,
apiKey : null ,
2026-04-24 20:41:14 +08:00
currentMessage : msg ,
compression ,
2026-04-29 16:26:24 +08:00
profile : this.profile ,
2026-05-22 10:20:39 +08:00
onProgress : ( event : { status : 'compressing' ; messageCount : number ; tokenCount : number } ) = > {
onStatus ? . ( 'compressing' , {
messageCount : event.messageCount ,
totalTokens : event.tokenCount ,
} )
} ,
2026-05-21 19:40:52 +08:00
contextTokenEstimator : async ( history : Array < { role : 'user' | 'assistant' ; content : string } > , estimateInstructions : string ) = > {
2026-05-22 10:20:39 +08:00
return this . estimateGroupContextTokens (
roomId ,
2026-05-21 19:40:52 +08:00
sessionId ,
2026-05-22 10:20:39 +08:00
bridge ,
2026-05-21 19:40:52 +08:00
history ,
estimateInstructions ,
2026-05-22 10:20:39 +08:00
'build' ,
2026-05-21 19:40:52 +08:00
)
} ,
2026-04-24 20:41:14 +08:00
} )
conversationHistory = ctx . conversationHistory
instructions = ctx . instructions
2026-05-21 19:40:52 +08:00
if ( typeof ctx . meta . contextTokenEstimate === 'number' && Number . isFinite ( ctx . meta . contextTokenEstimate ) ) {
this . storage . updateRoomTotalTokens ? . ( roomId , ctx . meta . contextTokenEstimate )
onStatus ? . ( 'replying' , { totalTokens : ctx.meta.contextTokenEstimate } )
}
2026-04-24 20:41:14 +08:00
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
}
}
2026-05-20 04:21:57 +02:00
// Keep routing explicit while removing only the mention tokens that
// selected this agent. This avoids making @all look like an
// instruction for the model to fan out another routing cycle.
const routedPrefix = isAllAgentsMentioned ( msg . content )
? ` 群聊系统:这条消息通过 @all 提及所有 agent,你是其中之一,请直接回复。 `
: ` 群聊系统:这条消息已经提及你( ${ this . name } ),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。 `
2026-05-19 16:09:59 +08:00
const rawInput = msg . input || msg . content
const input = isContentBlockArray ( rawInput )
? rawInput . map ( ( block ) = > {
if ( block . type !== 'text' ) return block
2026-05-20 04:21:57 +02:00
const text = stripMentionRoutingTokens ( String ( block . text || msg . content ) , this . name )
2026-05-19 16:09:59 +08:00
return { . . . block , text : ` ${ routedPrefix } \ n \ n原始消息: ${ text || msg . content } ` }
} )
2026-05-20 04:21:57 +02:00
: ` ${ routedPrefix } \ n \ n原始消息: ${ stripMentionRoutingTokens ( msg . content , this . name ) || msg . content } `
2026-05-24 12:52:14 +08:00
const runContext = [
` [Current Hermes profile: ${ this . profile } ] ` ,
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.' ,
] . join ( '\n' )
instructions = instructions ? ` ${ runContext } \ n ${ instructions } ` : runContext
2026-05-19 16:09:59 +08:00
const bridgeInput : AgentBridgeMessage = isContentBlockArray ( input )
? await convertContentBlocksForAgent ( input )
: input
const flushedAssistantParts = new Set < string > ( )
let lastChunk : AgentBridgeOutput | null = null
const started = await bridge . chat (
sessionId ,
bridgeInput ,
conversationHistory ,
instructions ,
this . profile ,
{
source : 'api_server' ,
2026-04-24 20:41:14 +08:00
} ,
2026-05-19 16:09:59 +08:00
)
this . emitMessageStreamStart ( roomId , streamMessageId )
2026-05-21 09:05:17 +08:00
streamStarted = true
2026-05-19 16:09:59 +08:00
for await ( const chunk of bridge . streamOutput ( started . run_id , { timeoutMs : 120000 } ) ) {
lastChunk = chunk
2026-05-22 10:20:39 +08:00
reasoningContent += await this . recordBridgeEvents ( roomId , sessionId , instructions , chunk , ( ) = > streamMessageId , async ( ) = > {
2026-05-19 16:09:59 +08:00
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 )
2026-05-21 09:05:17 +08:00
streamStarted = true
2026-05-19 16:09:59 +08:00
return toolBaseId
} )
if ( chunk . delta ) {
currentContent += chunk . delta
totalContent += chunk . delta
this . emitMessageStreamDelta ( roomId , streamMessageId , chunk . delta )
}
}
2026-04-24 20:41:14 +08:00
2026-05-19 16:09:59 +08:00
if ( lastChunk ? . status === 'error' ) {
logger . error ( ` [AgentClients] ${ this . name } : bridge response failed: ${ lastChunk . error || 'unknown error' } ` )
2026-05-21 09:05:17 +08:00
await this . sendAgentErrorMessage ( roomId , streamMessageId , lastChunk . error || 'Run failed' , msg , reasoningContent )
2026-05-19 16:09:59 +08:00
this . emitMessageStreamEnd ( roomId , streamMessageId )
2026-04-24 20:41:14 +08:00
this . stopTyping ( roomId )
2026-05-10 02:49:58 +08:00
onStatus ? . ( 'ready' )
2026-04-24 20:41:14 +08:00
return
}
2026-05-19 16:09:59 +08:00
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 ) {
2026-04-24 20:41:14 +08:00
this . stopTyping ( roomId )
2026-05-19 16:09:59 +08:00
await this . sendMessage ( roomId , currentContent , streamMessageId , {
role : 'assistant' ,
mentionDepth : nextMentionDepth ( msg ) ,
reasoning : reasoningContent || null ,
reasoning_content : reasoningContent || null ,
} )
this . emitMessageStreamEnd ( roomId , streamMessageId )
2026-05-21 19:40:52 +08:00
await this . refreshRoomFullContextEstimate ( roomId , sessionId , bridge , instructions )
2026-05-10 02:49:58 +08:00
onStatus ? . ( 'ready' )
2026-04-24 20:41:14 +08:00
return
}
2026-05-19 16:09:59 +08:00
logger . warn ( ` [AgentClients] ${ this . name } : bridge response completed without content ` )
this . emitMessageStreamEnd ( roomId , streamMessageId )
2026-05-10 02:49:58 +08:00
this . stopTyping ( roomId )
onStatus ? . ( 'ready' )
2026-04-24 20:41:14 +08:00
} catch ( err : any ) {
logger . error ( ` [AgentClients] ${ this . name } : error handling message: ${ err . message } ` )
2026-05-21 09:05:17 +08:00
try {
await this . sendAgentErrorMessage ( roomId , streamMessageId , err , msg , reasoningContent )
if ( streamStarted ) this . emitMessageStreamEnd ( roomId , streamMessageId )
} catch ( sendErr : any ) {
logger . warn ( ` [AgentClients] ${ this . name } : failed to send error message: ${ sendErr . message } ` )
}
2026-04-24 20:41:14 +08:00
this . stopTyping ( roomId )
onStatus ? . ( 'ready' )
}
}
2026-05-21 19:40:52 +08:00
private async refreshRoomFullContextEstimate (
roomId : string ,
sessionId : string ,
bridge : AgentBridgeClient ,
instructions? : string ,
) : Promise < void > {
if ( ! this . storage ? . getMessages ) return
try {
const history = this . buildRoomEstimateHistory ( roomId )
2026-05-22 10:20:39 +08:00
const cachedTokens = await this . estimateGroupContextTokens (
roomId ,
2026-05-21 19:40:52 +08:00
sessionId ,
2026-05-22 10:20:39 +08:00
bridge ,
2026-05-21 19:40:52 +08:00
history ,
instructions ,
2026-05-22 10:20:39 +08:00
'final' ,
2026-05-21 19:40:52 +08:00
)
2026-05-22 10:20:39 +08:00
if ( cachedTokens == null || cachedTokens <= 0 ) return
const rounded = Math . floor ( cachedTokens )
2026-05-21 19:40:52 +08:00
this . storage . updateRoomTotalTokens ? . ( roomId , rounded )
this . emitContextStatus ( roomId , 'replying' , { totalTokens : rounded } )
} catch ( err : any ) {
logger . warn ( ` [GroupChat] failed to refresh final context estimate room= ${ roomId } agent= ${ this . name } : ${ err . message } ` )
}
}
private buildRoomEstimateHistory ( roomId : string ) : Array < { role : 'user' | 'assistant' ; content : string } > {
const messages = this . storage ? . getMessages ? . ( roomId ) || [ ]
return messages . map ( ( message : any ) = > this . mapRoomMessageForEstimate ( message ) )
}
private mapRoomMessageForEstimate ( message : any ) : { role : 'user' | 'assistant' ; content : string } {
const senderName = String ( message ? . senderName || 'unknown' )
const role = String ( message ? . role || 'user' )
const isOwnAgent = message ? . senderId === this . socket ? . id || senderName === this . name
if ( role === 'tool' ) {
const label = message ? . tool_name ? ` Tool result: ${ message . tool_name } ` : 'Tool result'
return { role : 'user' , content : ` [ ${ senderName } ] [ ${ label } ] \ n ${ message ? . content || '' } ` }
}
if ( role === 'assistant' && Array . isArray ( message ? . tool_calls ) && message . tool_calls . length > 0 ) {
const toolsInfo = message . tool_calls . map ( ( toolCall : any ) = > {
const name = toolCall ? . function ? . name || 'unknown'
let args = String ( toolCall ? . function ? . arguments || '{}' )
if ( args . length > 4000 ) args = ` ${ args . slice ( 0 , 4000 ) } ... `
return ` [Calling tool: ${ name } with arguments: ${ args } ] `
} ) . join ( '\n' )
const content = String ( message ? . content || '' ) . trim ( )
return {
role : isOwnAgent ? 'assistant' : 'user' ,
content : content
? ` ${ this . formatAttributedContent ( senderName , content ) } \ n ${ this . formatAttributionPrefix ( senderName ) } ${ toolsInfo } `
: ` ${ this . formatAttributionPrefix ( senderName ) } ${ toolsInfo } ` ,
}
}
return {
role : isOwnAgent ? 'assistant' : 'user' ,
content : this.formatAttributedContent ( senderName , String ( message ? . content || '' ) ) ,
}
}
private formatAttributedContent ( senderName : string , content : string ) : string {
return ` ${ this . formatAttributionPrefix ( senderName ) } ${ this . stripMentions ( content ) } `
}
private formatAttributionPrefix ( senderName : string ) : string {
return ` [ ${ senderName } ]: `
}
private stripMentions ( content : string ) : string {
return String ( content || '' )
. replace ( /@([^\s@]+)/g , '' )
. replace ( /[ \t]{2,}/g , ' ' )
. replace ( /^\s+/ , '' )
}
2026-05-21 09:05:17 +08:00
private async sendAgentErrorMessage (
roomId : string ,
messageId : string ,
error : unknown ,
sourceMsg : MentionMessage ,
reasoningContent = '' ,
) : Promise < void > {
const detail = error instanceof Error ? error.message : String ( error || 'Run failed' )
const content = detail . startsWith ( 'Error:' ) ? detail : ` Error: ${ detail } `
await this . sendMessage ( roomId , content , messageId , {
role : 'assistant' ,
mentionDepth : nextMentionDepth ( sourceMsg ) ,
finish_reason : 'error' ,
reasoning : reasoningContent || null ,
reasoning_content : reasoningContent || null ,
} )
}
2026-05-19 16:09:59 +08:00
private async recordBridgeEvents (
roomId : string ,
2026-05-22 10:20:39 +08:00
sessionId : string ,
instructions : string | undefined ,
2026-05-19 16:09:59 +08:00
chunk : AgentBridgeOutput ,
getCurrentMessageId : ( ) = > string ,
beforeToolStarted : ( ) = > Promise < string > ,
) : Promise < string > {
let reasoning = ''
for ( const ev of chunk . events || [ ] ) {
const eventType = String ( ( ev as any ) ? . event || '' )
2026-05-22 10:20:39 +08:00
if ( eventType === 'bridge.context.ready' ) {
this . cacheBridgeContext ( sessionId , ev as Record < string , unknown > , instructions )
} else if ( eventType === 'tool.started' ) {
2026-05-19 16:09:59 +08:00
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 ,
} )
2026-05-29 09:02:38 +08:00
} else {
const text = groupBridgeReasoningDeltaFromEvent ( ev as Record < string , unknown > )
if ( text ) {
reasoning += text
this . emitMessageReasoningDelta ( roomId , getCurrentMessageId ( ) , text )
}
2026-05-19 16:09:59 +08:00
}
}
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
}
2026-04-24 20:41:14 +08:00
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
} )
}
}
2026-05-19 16:09:59 +08:00
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 )
}
2026-05-10 02:49:58 +08:00
2026-05-19 16:09:59 +08:00
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 )
2026-05-10 02:49:58 +08:00
}
2026-05-19 16:09:59 +08:00
function groupMessagePartId ( runMessageId : string , partIndex : number ) : string {
return ` ${ safeId ( runMessageId ) } _part_ ${ partIndex } `
2026-05-10 02:49:58 +08:00
}
2026-05-19 16:09:59 +08:00
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 }
2026-05-10 02:49:58 +08:00
}
}
2026-05-19 16:09:59 +08:00
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 ,
} )
2026-05-10 02:49:58 +08:00
}
2026-04-24 20:41:14 +08:00
// ─── 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 > ( )
2026-05-19 16:09:59 +08:00
private _mentionQueue = new Map < string , Array < { agent : AgentClient ; msg : MentionMessage } > > ( )
2026-04-24 20:41:14 +08:00
/**
* 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 )
2026-05-21 14:54:41 +08:00
try {
const result = await client . joinRoom ( roomId )
logger . info ( ` [AgentClients] ${ client . name } joined room: ${ roomId } ` )
return result
} catch ( err ) {
room . delete ( client . agentId )
if ( room . size === 0 ) this . rooms . delete ( roomId )
client . disconnect ( )
throw err
}
2026-04-24 20:41:14 +08:00
}
/**
* 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 ) ) )
}
2026-05-19 16:09:59 +08:00
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 )
}
2026-04-24 20:41:14 +08:00
/**
* 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 */ }
}
}
2026-05-15 15:52:16 +08:00
resetRoomContext ( roomId : string ) : void {
this . _mentionQueue . delete ( roomId )
2026-05-19 16:09:59 +08:00
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 )
}
2026-05-15 15:52:16 +08:00
if ( this . _contextEngine ) {
try { this . _contextEngine . invalidateRoom ( roomId ) } catch { /* ignore */ }
}
}
2026-04-24 20:41:14 +08:00
/**
* 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.
*/
2026-05-19 16:09:59 +08:00
async processMentions ( roomId : string , msg : MentionMessage ) : Promise < void > {
2026-04-24 20:41:14 +08:00
const agents = this . getAgents ( roomId )
2026-05-20 04:21:57 +02:00
const mentioned = resolveMentionTargets ( agents , msg . content , msg . senderId )
2026-04-24 20:41:14 +08:00
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 ,
2026-05-19 16:09:59 +08:00
msg : MentionMessage ,
2026-04-24 20:41:14 +08:00
) : 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 )
2026-05-21 19:40:52 +08:00
const onStatus = ( status : 'compressing' | 'replying' | 'ready' , extra? : Record < string , unknown > ) = > {
agent . emitContextStatus ( roomId , status , extra )
2026-04-24 20:41:14 +08:00
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 ]
2026-05-19 16:09:59 +08:00
await this . _processAgentMention ( roomId , last . agent , last . msg )
2026-04-24 20:41:14 +08:00
}
}
2026-05-19 16:09:59 +08:00
function nextMentionDepth ( msg : MentionMessage ) : number {
return Math . max ( 0 , msg . mentionDepth || 0 ) + 1
}