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 invited: number
} }
export interface AgentAddResult {
profile: string
ok: boolean
agent?: RoomAgent
code?: string
error?: string
reason?: string
}
export interface ChatMessage { export interface ChatMessage {
id: string id: string
roomId: string roomId: string
@@ -133,7 +142,7 @@ export async function createRoom(data: {
inviteCode: string inviteCode: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[] agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number } 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', { return request('/api/hermes/group-chat/rooms', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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`, { return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -64,12 +64,36 @@ function generateCode(): string {
return code 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 }) { async function handleCreateRoom(name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }) {
try { try {
store.setUserInfo(userName, description) store.setUserInfo(userName, description)
const res = await store.createNewRoom(name, inviteCode, undefined, compression) const res = await store.createNewRoom(name, inviteCode, undefined, compression)
showCreateModal.value = false 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) await store.joinRoom(res.room.id)
} catch { } catch {
message.error(t('common.saveFailed')) message.error(t('common.saveFailed'))
@@ -105,7 +129,9 @@ async function confirmCloneRoom() {
cloneRoomName.value = '' cloneRoomName.value = ''
cloneInviteCode.value = '' cloneInviteCode.value = ''
await store.joinRoom(res.room.id) 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 { } catch {
message.error(t('common.saveFailed')) message.error(t('common.saveFailed'))
} }
@@ -170,7 +196,7 @@ async function confirmAddAgent() {
if (err.message?.includes('already')) { if (err.message?.includes('already')) {
message.warning(t('groupChat.agentAlreadyInRoom')) message.warning(t('groupChat.agentAlreadyInRoom'))
} else { } 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', selectProfile: 'Wahlen Sie ein Profil',
agentAdded: 'Agent hinzugefugt', agentAdded: 'Agent hinzugefugt',
agentAlreadyInRoom: 'Agent ist bereits in diesem Raum', agentAlreadyInRoom: 'Agent ist bereits in diesem Raum',
agentAddFailedCount: '{count} Agent(en) wurden nicht hinzugefugt: {details}',
noAgents: 'Keine Agenten in diesem Raum', noAgents: 'Keine Agenten in diesem Raum',
members: 'Mitglieder', members: 'Mitglieder',
roomCreated: 'Raum erstellt', roomCreated: 'Raum erstellt',
+1
View File
@@ -1053,6 +1053,7 @@ export default {
selectProfile: 'Select a profile', selectProfile: 'Select a profile',
agentAdded: 'Agent added', agentAdded: 'Agent added',
agentAlreadyInRoom: 'Agent already in this room', agentAlreadyInRoom: 'Agent already in this room',
agentAddFailedCount: '{count} agent(s) were not added: {details}',
noAgents: 'No agents in this room', noAgents: 'No agents in this room',
members: 'members', members: 'members',
roomCreated: 'Room created', roomCreated: 'Room created',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job ejecutado',
selectProfile: 'Seleccione un perfil', selectProfile: 'Seleccione un perfil',
agentAdded: 'Agente agregado', agentAdded: 'Agente agregado',
agentAlreadyInRoom: 'El agente ya esta en esta sala', agentAlreadyInRoom: 'El agente ya esta en esta sala',
agentAddFailedCount: 'No se agregaron {count} agente(s): {details}',
noAgents: 'No hay agentes en esta sala', noAgents: 'No hay agentes en esta sala',
members: 'Miembros', members: 'Miembros',
roomCreated: 'Sala creada', roomCreated: 'Sala creada',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job declenche',
selectProfile: 'Selectionnez un profil', selectProfile: 'Selectionnez un profil',
agentAdded: 'Agent ajoute', agentAdded: 'Agent ajoute',
agentAlreadyInRoom: "L'agent est deja dans ce salon", agentAlreadyInRoom: "L'agent est deja dans ce salon",
agentAddFailedCount: "{count} agent(s) n'ont pas ete ajoutes : {details}",
noAgents: 'Aucun agent dans ce salon', noAgents: 'Aucun agent dans ce salon',
members: 'Membres', members: 'Membres',
roomCreated: 'Salon cree', roomCreated: 'Salon cree',
+1
View File
@@ -1104,6 +1104,7 @@ export default {
selectProfile: 'プロファイルを選択', selectProfile: 'プロファイルを選択',
agentAdded: 'エージェントが追加されました', agentAdded: 'エージェントが追加されました',
agentAlreadyInRoom: 'このエージェントは既にルームにいます', agentAlreadyInRoom: 'このエージェントは既にルームにいます',
agentAddFailedCount: '{count} 件のエージェントを追加できませんでした: {details}',
noAgents: 'このルームにエージェントはいません', noAgents: 'このルームにエージェントはいません',
members: 'メンバー', members: 'メンバー',
roomCreated: 'ルームが作成されました', roomCreated: 'ルームが作成されました',
+1
View File
@@ -1104,6 +1104,7 @@ export default {
selectProfile: '프로필 선택', selectProfile: '프로필 선택',
agentAdded: '에이전트가 추가되었습니다', agentAdded: '에이전트가 추가되었습니다',
agentAlreadyInRoom: '해당 에이전트가 이미 방에 있습니다', agentAlreadyInRoom: '해당 에이전트가 이미 방에 있습니다',
agentAddFailedCount: '{count}개의 에이전트를 추가하지 못했습니다: {details}',
noAgents: '이 방에 에이전트가 없습니다', noAgents: '이 방에 에이전트가 없습니다',
members: '멤버', members: '멤버',
roomCreated: '방이 생성되었습니다', roomCreated: '방이 생성되었습니다',
+1
View File
@@ -1104,6 +1104,7 @@ jobTriggered: 'Job acionado',
selectProfile: 'Selecione um perfil', selectProfile: 'Selecione um perfil',
agentAdded: 'Agente adicionado', agentAdded: 'Agente adicionado',
agentAlreadyInRoom: 'O agente ja esta nesta sala', agentAlreadyInRoom: 'O agente ja esta nesta sala',
agentAddFailedCount: '{count} agente(s) nao foram adicionados: {details}',
noAgents: 'Nenhum agente nesta sala', noAgents: 'Nenhum agente nesta sala',
members: 'Membros', members: 'Membros',
roomCreated: 'Sala criada', roomCreated: 'Sala criada',
@@ -1056,6 +1056,7 @@ export default {
selectProfile: '選擇一個設定檔', selectProfile: '選擇一個設定檔',
agentAdded: '智慧代理已新增', agentAdded: '智慧代理已新增',
agentAlreadyInRoom: '該智慧代理已在房間中', agentAlreadyInRoom: '該智慧代理已在房間中',
agentAddFailedCount: '{count} 個智慧代理未新增:{details}',
noAgents: '目前房間無智慧代理', noAgents: '目前房間無智慧代理',
members: '成員', members: '成員',
roomCreated: '房間已建立', roomCreated: '房間已建立',
+1
View File
@@ -1055,6 +1055,7 @@ export default {
selectProfile: '选择一个配置', selectProfile: '选择一个配置',
agentAdded: '智能体已添加', agentAdded: '智能体已添加',
agentAlreadyInRoom: '该智能体已在房间中', agentAlreadyInRoom: '该智能体已在房间中',
agentAddFailedCount: '{count} 个智能体未添加:{details}',
noAgents: '当前房间暂无智能体', noAgents: '当前房间暂无智能体',
members: '成员', members: '成员',
roomCreated: '房间已创建', roomCreated: '房间已创建',
+72 -48
View File
@@ -27,6 +27,47 @@ function generateInviteCode(): string {
return code 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 // Create room
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) { if (!chatServer) {
@@ -57,29 +98,26 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
const storage = chatServer.getStorage() const storage = chatServer.getStorage()
storage.saveRoom(roomId, name, inviteCode, compression) storage.saveRoom(roomId, name, inviteCode, compression)
// Save agents to DB and auto-connect via Socket.IO
const addedAgents = [] const addedAgents = []
const agentResults = []
for (const a of agents || []) { 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 { try {
const client = await chatServer.agentClients.createAgent({ const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
agentId: agent.agentId, profile: a.profile,
profile: agent.profile, name: a.name || a.profile,
name: agent.name, description: a.description || '',
description: agent.description, invited: a.invited,
invited: agent.invited,
}) })
await chatServer.agentClients.addAgentToRoom(roomId, client) addedAgents.push(agent)
agentResults.push({ profile: a.profile, ok: true, agent })
} catch (err: any) { } 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) 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. // 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 addedAgents = []
const agentResults = []
for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) { 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 { try {
const client = await chatServer.agentClients.createAgent({ const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
agentId: agent.agentId, profile: sourceAgent.profile,
profile: agent.profile, name: sourceAgent.name,
name: agent.name, description: sourceAgent.description,
description: agent.description, invited: sourceAgent.invited,
invited: agent.invited,
}) })
await chatServer.agentClients.addAgentToRoom(roomId, client) addedAgents.push(agent)
agentResults.push({ profile: sourceAgent.profile, ok: true, agent })
} catch (err: any) { } 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) const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents } ctx.body = { room, agents: addedAgents, agentResults }
}) })
// Get room detail and messages // Get room detail and messages
@@ -236,24 +265,19 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx)
return 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 { try {
const client = await chatServer.agentClients.createAgent({ const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, {
agentId: agent.agentId, profile,
profile: agent.profile, name: name || profile,
name: agent.name, description: description || '',
description: agent.description, invited,
invited: agent.invited,
}) })
await chatServer.agentClients.addAgentToRoom(ctx.params.roomId, client) ctx.body = { agent }
} catch (err: any) { } 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 // List agents in room
@@ -711,9 +711,16 @@ export class AgentClients {
} }
room.set(client.agentId, client) room.set(client.agentId, client)
const result = await client.joinRoom(roomId) try {
logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`) const result = await client.joinRoom(roomId)
return result 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 () => { 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 agentsBefore = [{ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 }]
const storage = { const storage = {