mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 05:20:15 +00:00
Fix group chat agent connection failures (#900)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1104,6 +1104,7 @@ export default {
|
||||
selectProfile: 'プロファイルを選択',
|
||||
agentAdded: 'エージェントが追加されました',
|
||||
agentAlreadyInRoom: 'このエージェントは既にルームにいます',
|
||||
agentAddFailedCount: '{count} 件のエージェントを追加できませんでした: {details}',
|
||||
noAgents: 'このルームにエージェントはいません',
|
||||
members: 'メンバー',
|
||||
roomCreated: 'ルームが作成されました',
|
||||
|
||||
@@ -1104,6 +1104,7 @@ export default {
|
||||
selectProfile: '프로필 선택',
|
||||
agentAdded: '에이전트가 추가되었습니다',
|
||||
agentAlreadyInRoom: '해당 에이전트가 이미 방에 있습니다',
|
||||
agentAddFailedCount: '{count}개의 에이전트를 추가하지 못했습니다: {details}',
|
||||
noAgents: '이 방에 에이전트가 없습니다',
|
||||
members: '멤버',
|
||||
roomCreated: '방이 생성되었습니다',
|
||||
|
||||
@@ -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: '房間已建立',
|
||||
|
||||
@@ -1055,6 +1055,7 @@ export default {
|
||||
selectProfile: '选择一个配置',
|
||||
agentAdded: '智能体已添加',
|
||||
agentAlreadyInRoom: '该智能体已在房间中',
|
||||
agentAddFailedCount: '{count} 个智能体未添加:{details}',
|
||||
noAgents: '当前房间暂无智能体',
|
||||
members: '成员',
|
||||
roomCreated: '房间已创建',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user