Fix group chat agent connection failures (#900)

This commit is contained in:
ekko
2026-05-21 14:54:41 +08:00
committed by GitHub
parent 013b4abcbf
commit b2ec321990
14 changed files with 220 additions and 56 deletions
+11 -2
View File
@@ -23,6 +23,15 @@ export interface RoomAgent {
invited: number
}
export interface AgentAddResult {
profile: string
ok: boolean
agent?: RoomAgent
code?: string
error?: string
reason?: string
}
export interface ChatMessage {
id: string
roomId: string
@@ -133,7 +142,7 @@ export async function createRoom(data: {
inviteCode: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
}): Promise<{ room: RoomInfo; agents: RoomAgent[] }> {
}): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request('/api/hermes/group-chat/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -141,7 +150,7 @@ export async function createRoom(data: {
})
}
export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[] }> {
export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -64,12 +64,36 @@ function generateCode(): string {
return code
}
function formatAgentFailures(results?: Array<{ ok: boolean; profile: string; error?: string; reason?: string }>): string | null {
const failed = results?.filter(result => !result.ok) || []
if (failed.length === 0) return null
const details = failed.map(result => result.reason || result.error || result.profile).join('; ')
return t('groupChat.agentAddFailedCount', { count: failed.length, details })
}
function extractApiErrorMessage(err: any): string {
const raw = err?.message || ''
const jsonStart = raw.indexOf('{')
if (jsonStart >= 0) {
try {
const parsed = JSON.parse(raw.slice(jsonStart))
if (parsed?.code === 'PROFILE_AGENT_CONNECT_FAILED' && parsed?.error) {
return parsed.reason ? `${parsed.error}: ${parsed.reason}` : parsed.error
}
if (parsed?.error) return parsed.error
} catch { /* ignore */ }
}
return raw || t('common.saveFailed')
}
async function handleCreateRoom(name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }) {
try {
store.setUserInfo(userName, description)
const res = await store.createNewRoom(name, inviteCode, undefined, compression)
showCreateModal.value = false
message.success(t('groupChat.roomCreated'))
const failureMessage = formatAgentFailures(res.agentResults)
if (failureMessage) message.warning(failureMessage)
else message.success(t('groupChat.roomCreated'))
await store.joinRoom(res.room.id)
} catch {
message.error(t('common.saveFailed'))
@@ -105,7 +129,9 @@ async function confirmCloneRoom() {
cloneRoomName.value = ''
cloneInviteCode.value = ''
await store.joinRoom(res.room.id)
message.success(t('groupChat.roomCloned'))
const failureMessage = formatAgentFailures(res.agentResults)
if (failureMessage) message.warning(failureMessage)
else message.success(t('groupChat.roomCloned'))
} catch {
message.error(t('common.saveFailed'))
}
@@ -170,7 +196,7 @@ async function confirmAddAgent() {
if (err.message?.includes('already')) {
message.warning(t('groupChat.agentAlreadyInRoom'))
} else {
message.error(t('common.saveFailed'))
message.error(extractApiErrorMessage(err))
}
}
}
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job ausgelost',
selectProfile: 'Wahlen Sie ein Profil',
agentAdded: 'Agent hinzugefugt',
agentAlreadyInRoom: 'Agent ist bereits in diesem Raum',
agentAddFailedCount: '{count} Agent(en) wurden nicht hinzugefugt: {details}',
noAgents: 'Keine Agenten in diesem Raum',
members: 'Mitglieder',
roomCreated: 'Raum erstellt',
+1
View File
@@ -1053,6 +1053,7 @@ export default {
selectProfile: 'Select a profile',
agentAdded: 'Agent added',
agentAlreadyInRoom: 'Agent already in this room',
agentAddFailedCount: '{count} agent(s) were not added: {details}',
noAgents: 'No agents in this room',
members: 'members',
roomCreated: 'Room created',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job ejecutado',
selectProfile: 'Seleccione un perfil',
agentAdded: 'Agente agregado',
agentAlreadyInRoom: 'El agente ya esta en esta sala',
agentAddFailedCount: 'No se agregaron {count} agente(s): {details}',
noAgents: 'No hay agentes en esta sala',
members: 'Miembros',
roomCreated: 'Sala creada',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job declenche',
selectProfile: 'Selectionnez un profil',
agentAdded: 'Agent ajoute',
agentAlreadyInRoom: "L'agent est deja dans ce salon",
agentAddFailedCount: "{count} agent(s) n'ont pas ete ajoutes : {details}",
noAgents: 'Aucun agent dans ce salon',
members: 'Membres',
roomCreated: 'Salon cree',
+1
View File
@@ -1104,6 +1104,7 @@ export default {
selectProfile: 'プロファイルを選択',
agentAdded: 'エージェントが追加されました',
agentAlreadyInRoom: 'このエージェントは既にルームにいます',
agentAddFailedCount: '{count} 件のエージェントを追加できませんでした: {details}',
noAgents: 'このルームにエージェントはいません',
members: 'メンバー',
roomCreated: 'ルームが作成されました',
+1
View File
@@ -1104,6 +1104,7 @@ export default {
selectProfile: '프로필 선택',
agentAdded: '에이전트가 추가되었습니다',
agentAlreadyInRoom: '해당 에이전트가 이미 방에 있습니다',
agentAddFailedCount: '{count}개의 에이전트를 추가하지 못했습니다: {details}',
noAgents: '이 방에 에이전트가 없습니다',
members: '멤버',
roomCreated: '방이 생성되었습니다',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job acionado',
selectProfile: 'Selecione um perfil',
agentAdded: 'Agente adicionado',
agentAlreadyInRoom: 'O agente ja esta nesta sala',
agentAddFailedCount: '{count} agente(s) nao foram adicionados: {details}',
noAgents: 'Nenhum agente nesta sala',
members: 'Membros',
roomCreated: 'Sala criada',
@@ -1056,6 +1056,7 @@ export default {
selectProfile: '選擇一個設定檔',
agentAdded: '智慧代理已新增',
agentAlreadyInRoom: '該智慧代理已在房間中',
agentAddFailedCount: '{count} 個智慧代理未新增:{details}',
noAgents: '目前房間無智慧代理',
members: '成員',
roomCreated: '房間已建立',
+1
View File
@@ -1055,6 +1055,7 @@ export default {
selectProfile: '选择一个配置',
agentAdded: '智能体已添加',
agentAlreadyInRoom: '该智能体已在房间中',
agentAddFailedCount: '{count} 个智能体未添加:{details}',
noAgents: '当前房间暂无智能体',
members: '成员',
roomCreated: '房间已创建',
+72 -48
View File
@@ -27,6 +27,47 @@ function generateInviteCode(): string {
return code
}
type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number }
function sanitizeAgentConnectReason(reason?: string): string {
return (reason || 'agent runtime connection failed')
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
.replace(/(api[_-]?key|token|secret|password)=([^\s]+)/gi, '$1=[REDACTED]')
.split('\n')[0]
.slice(0, 240)
}
function agentConnectFailureBody(profile: string, err: any) {
return {
code: 'PROFILE_AGENT_CONNECT_FAILED',
error: `Failed to connect agent "${profile}" to room`,
profile,
reason: sanitizeAgentConnectReason(err?.message),
}
}
async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) {
const profile = input.profile
const name = input.name || profile
const description = input.description || ''
const invited = input.invited ? 1 : 0
const client = await server.agentClients.createAgent({
agentId,
profile,
name,
description,
invited,
})
try {
await server.agentClients.addAgentToRoom(roomId, client)
return server.getStorage().addRoomAgent(roomId, agentId, profile, name, description, invited)
} catch (err) {
server.agentClients.removeAgentFromRoom(roomId, client.agentId)
throw err
}
}
// Create room
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
@@ -57,29 +98,26 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
const storage = chatServer.getStorage()
storage.saveRoom(roomId, name, inviteCode, compression)
// Save agents to DB and auto-connect via Socket.IO
const addedAgents = []
const agentResults = []
for (const a of agents || []) {
const agentId = generateId()
const agent = storage.addRoomAgent(roomId, agentId, a.profile, a.name || a.profile, a.description || '', a.invited ? 1 : 0)
addedAgents.push(agent)
try {
const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile,
name: agent.name,
description: agent.description,
invited: agent.invited,
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: a.profile,
name: a.name || a.profile,
description: a.description || '',
invited: a.invited,
})
await chatServer.agentClients.addAgentToRoom(roomId, client)
addedAgents.push(agent)
agentResults.push({ profile: a.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${err.message}`)
console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents }
ctx.body = { room, agents: addedAgents, agentResults }
})
// Clone room roles/config without copying the conversation context.
@@ -108,34 +146,25 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) =
})
const addedAgents = []
const agentResults = []
for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) {
const agentId = generateId()
const agent = storage.addRoomAgent(
roomId,
agentId,
sourceAgent.profile,
sourceAgent.name,
sourceAgent.description,
sourceAgent.invited,
)
addedAgents.push(agent)
try {
const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile,
name: agent.name,
description: agent.description,
invited: agent.invited,
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: sourceAgent.profile,
name: sourceAgent.name,
description: sourceAgent.description,
invited: sourceAgent.invited,
})
await chatServer.agentClients.addAgentToRoom(roomId, client)
addedAgents.push(agent)
agentResults.push({ profile: sourceAgent.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect cloned agent ${agent.profile} to room ${roomId}: ${err.message}`)
console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents }
ctx.body = { room, agents: addedAgents, agentResults }
})
// Get room detail and messages
@@ -236,24 +265,19 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx)
return
}
const agentId = generateId()
const agent = chatServer.getStorage().addRoomAgent(ctx.params.roomId, agentId, profile, name || profile, description || '', invited ? 1 : 0)
// Auto-connect agent via Socket.IO
try {
const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile,
name: agent.name,
description: agent.description,
invited: agent.invited,
const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, {
profile,
name: name || profile,
description: description || '',
invited,
})
await chatServer.agentClients.addAgentToRoom(ctx.params.roomId, client)
ctx.body = { agent }
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${err.message}`)
console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`)
ctx.status = 502
ctx.body = agentConnectFailureBody(profile, err)
}
ctx.body = { agent }
})
// List agents in room
@@ -711,9 +711,16 @@ export class AgentClients {
}
room.set(client.agentId, client)
const result = await client.joinRoom(roomId)
logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`)
return result
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
}
}
/**
@@ -101,6 +101,95 @@ describe('Group Chat member/agent identity sync', () => {
}))
})
it('does not persist an agent when the runtime client cannot connect', async () => {
const addRoomAgent = vi.fn()
const chatServer = {
getStorage: () => ({
getRoomAgents: vi.fn(() => []),
addRoomAgent,
}),
agentClients: {
createAgent: vi.fn(async () => {
throw new Error('Connection timeout')
}),
addAgentToRoom: vi.fn(),
removeAgentFromRoom: vi.fn(),
},
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx: any = {
params: { roomId: 'room-1' },
request: { body: { profile: 'default', name: 'Worker' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(ctx.status).toBe(502)
expect(ctx.body).toMatchObject({
code: 'PROFILE_AGENT_CONNECT_FAILED',
profile: 'default',
reason: 'Connection timeout',
})
expect(addRoomAgent).not.toHaveBeenCalled()
})
it('does not persist an agent and disconnects runtime state when room join fails', async () => {
const addRoomAgent = vi.fn()
const runtimeClient = { agentId: 'agent-stable-1' }
const chatServer = {
getStorage: () => ({
getRoomAgents: vi.fn(() => []),
addRoomAgent,
}),
agentClients: {
createAgent: vi.fn(async () => runtimeClient),
addAgentToRoom: vi.fn(async () => {
throw new Error('join failed')
}),
removeAgentFromRoom: vi.fn(),
},
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx: any = {
params: { roomId: 'room-1' },
request: { body: { profile: 'default', name: 'Worker' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(ctx.status).toBe(502)
expect(ctx.body).toMatchObject({
code: 'PROFILE_AGENT_CONNECT_FAILED',
profile: 'default',
reason: 'join failed',
})
expect(addRoomAgent).not.toHaveBeenCalled()
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
})
it('rolls back AgentClients room state when joining a room fails', async () => {
const clients = new AgentClients()
const runtimeClient = {
agentId: 'agent-stable-1',
name: 'Worker',
joinRoom: vi.fn(async () => {
throw new Error('join failed')
}),
disconnect: vi.fn(),
}
await expect(clients.addAgentToRoom('room-1', runtimeClient as any)).rejects.toThrow('join failed')
expect(runtimeClient.disconnect).toHaveBeenCalled()
expect(clients.getAgents('room-1')).toEqual([])
})
it('removes the runtime agent by persisted agentId and returns synchronized room state', async () => {
const agentsBefore = [{ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 }]
const storage = {