From 3612a767354e13ec43950651abb8f14bddb32fb6 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 21 May 2026 09:05:17 +0800 Subject: [PATCH] show chat run errors as agent messages (#887) --- packages/client/src/api/hermes/chat.ts | 35 +++++++++- packages/client/src/api/hermes/group-chat.ts | 2 +- .../components/hermes/chat/MessageItem.vue | 22 ++++++ .../hermes/group-chat/GroupMessageItem.vue | 35 ++++++++++ packages/client/src/stores/hermes/chat.ts | 69 +++++++++---------- .../hermes/group-chat/agent-clients.ts | 40 +++++++++-- 6 files changed, 157 insertions(+), 46 deletions(-) diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 3026be4..8bf7823 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -467,6 +467,18 @@ export function disconnectChatRun(): void { } } +function removeSocketListener(socket: Socket, event: string, handler: (...args: any[]) => void): void { + const candidate = socket as Socket & { + off?: (event: string, handler: (...args: any[]) => void) => Socket + removeListener?: (event: string, handler: (...args: any[]) => void) => Socket + } + if (typeof candidate.off === 'function') { + candidate.off(event, handler) + return + } + candidate.removeListener?.(event, handler) +} + /** * Start a chat run via Socket.IO and stream events back. * Returns an AbortController-compatible handle for cancellation. @@ -500,6 +512,23 @@ export function startRunViaSocket( let closed = false const socket = connectChatRun() + const handleSocketError = (err: Error) => { + if (closed) return + closed = true + sessionEventHandlers.delete(sid) + onError(err) + } + socket.once('connect_error', handleSocketError) + const handleSocketDisconnect = (reason: string) => { + if (closed || reason === 'io client disconnect') return + handleSocketError(new Error(`Socket disconnected: ${reason}`)) + } + socket.once('disconnect', handleSocketDisconnect) + + const removeTerminalSocketListeners = () => { + removeSocketListener(socket, 'connect_error', handleSocketError) + removeSocketListener(socket, 'disconnect', handleSocketDisconnect) + } if (sessionEventHandlers.has(sid)) { socket.emit('run', body) @@ -548,6 +577,7 @@ export function startRunViaSocket( onEvent(evt) if ((evt as any).queue_remaining > 0) return closed = true + removeTerminalSocketListeners() onDone() }, onRunFailed: (evt: RunEvent) => { @@ -555,7 +585,8 @@ export function startRunViaSocket( onEvent(evt) if ((evt as any).queue_remaining > 0) return closed = true - onError(new Error(evt.error || 'Run failed')) + removeTerminalSocketListeners() + onDone() }, onCompressionStarted: (evt: RunEvent) => { if (closed) return @@ -574,6 +605,7 @@ export function startRunViaSocket( onEvent(evt) if ((evt as any).queue_length > 0) return closed = true + removeTerminalSocketListeners() onDone() }, onUsageUpdated: (evt: RunEvent) => { @@ -585,6 +617,7 @@ export function startRunViaSocket( onEvent(evt) if ((evt as any).terminal === false) return closed = true + removeTerminalSocketListeners() sessionEventHandlers.delete(sid) onDone() }, diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index d8a77d4..75fa15a 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -34,7 +34,7 @@ export interface ChatMessage { tool_call_id?: string | null tool_calls?: any[] | null tool_name?: string | null - finish_reason?: string | null + finish_reason?: 'streaming' | 'tool_calls' | 'error' | string | null reasoning?: string | null reasoning_details?: string | null reasoning_content?: string | null diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index 4b6878a..685891d 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -34,6 +34,7 @@ const { t } = useI18n(); const toast = useMessage(); const isSystem = computed(() => props.message.role === "system"); +const isAgentError = computed(() => props.message.role === "assistant" && props.message.systemType === "error"); const effectiveHeadingIdPrefix = computed(() => props.headingIdPrefix || `msg-${props.message.id}`); const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command"); @@ -790,6 +791,7 @@ onBeforeUnmount(() => { class="message-bubble" :class="{ system: isSystem, + 'agent-error': isAgentError, command: isCommandMessage, 'command-error': isCommandError, 'speech-playing': isPlayingThisMessage && !isPausedThisMessage, @@ -1043,6 +1045,12 @@ onBeforeUnmount(() => { background-color: $msg-assistant-bg; border-radius: 10px; } + + .message-bubble.agent-error { + color: $error; + background-color: rgba(var(--error-rgb), 0.06); + border: 1px solid rgba(var(--error-rgb), 0.2); + } } &.tool { @@ -1120,6 +1128,20 @@ onBeforeUnmount(() => { background-color: rgba(var(--warning-rgb), 0.06); } + &.agent-error { + color: $error; + background-color: rgba(var(--error-rgb), 0.06); + border: 1px solid rgba(var(--error-rgb), 0.2); + + :deep(.markdown-body), + :deep(.markdown-body p), + :deep(.markdown-body li), + :deep(.markdown-body strong), + :deep(.markdown-body code) { + color: $error; + } + } + &.speech-playing { box-shadow: 0 0 0 2px #ff6b6b, diff --git a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue index 82cab2a..6922899 100644 --- a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue +++ b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue @@ -41,6 +41,12 @@ const isAgent = computed(() => { return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName) }) +const isAgentError = computed(() => { + if (props.message.role !== 'assistant') return false + if (props.message.finish_reason === 'error') return true + return /^Error:\s*/i.test(props.message.content || '') +}) + const isSelf = computed(() => { return !!props.currentUserId && props.message.senderId === props.currentUserId }) @@ -443,6 +449,7 @@ onBeforeUnmount(() => { class="msg-content" :class="{ 'agent-content': isAgent, + 'agent-error': isAgentError, 'speech-playing': isPlayingThisMessage && !isPausedThisMessage, }" > @@ -548,6 +555,20 @@ onBeforeUnmount(() => { background-color: rgba(var(--accent-primary-rgb), 0.06); } + &.agent .msg-content.agent-error { + color: $error; + background-color: rgba(var(--error-rgb), 0.06); + border: 1px solid rgba(var(--error-rgb), 0.2); + + :deep(.markdown-body), + :deep(.markdown-body p), + :deep(.markdown-body li), + :deep(.markdown-body strong), + :deep(.markdown-body code) { + color: $error; + } + } + &.self .msg-content { background-color: rgba(var(--accent-primary-rgb), 0.1); } @@ -834,6 +855,20 @@ onBeforeUnmount(() => { animation: rainbow-glow 4s linear infinite; } + &.agent-error { + color: $error; + background-color: rgba(var(--error-rgb), 0.06); + border: 1px solid rgba(var(--error-rgb), 0.2); + + :deep(.markdown-body), + :deep(.markdown-body p), + :deep(.markdown-body li), + :deep(.markdown-body strong), + :deep(.markdown-body code) { + color: $error; + } + } + :deep(.mention-highlight) { color: #409eff; font-weight: 600; diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 14571a1..f865977 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -567,6 +567,10 @@ export const useChatStore = defineStore('chat', () => { setPendingApproval({ ...e, session_id: sessionId } as RunEvent) } else if (e.event === 'approval.resolved') { clearPendingApproval({ ...e, session_id: sessionId } as RunEvent) + } else if (e.event === 'run.failed') { + addAgentErrorMessage(sessionId, e.error) + serverWorking.value.delete(sessionId) + queueLengths.value.delete(sessionId) } else if (e.event === 'tool.started') { const msgs = getSessionMsgs(sessionId) const toolCallId = e.tool_call_id as string | undefined @@ -692,6 +696,29 @@ export const useChatStore = defineStore('chat', () => { } } + function addAgentErrorMessage(sessionId: string, error?: string | null) { + const content = error ? `Error: ${error}` : 'Run failed' + const msgs = getSessionMsgs(sessionId) + const last = msgs[msgs.length - 1] + if (last?.isStreaming) { + updateMessage(sessionId, last.id, { + role: 'assistant', + content, + isStreaming: false, + systemType: 'error', + }) + return + } + if (last?.role === 'assistant' && last.systemType === 'error' && last.content === content) return + addMessage(sessionId, { + id: uid(), + role: 'assistant', + content, + timestamp: Date.now(), + systemType: 'error', + }) + } + function handleSessionCommandEvent(evt: RunEvent) { const sid = evt.session_id if (!sid) return @@ -1319,22 +1346,8 @@ export const useChatStore = defineStore('chat', () => { } case 'run.failed': { + addAgentErrorMessage(sid, evt.error) const msgs = getSessionMsgs(sid) - const lastErr = msgs[msgs.length - 1] - if (lastErr?.isStreaming) { - updateMessage(sid, lastErr.id, { - isStreaming: false, - content: evt.error ? `Error: ${evt.error}` : 'Run failed', - role: 'system', - }) - } else { - addMessage(sid, { - id: uid(), - role: 'system', - content: evt.error ? `Error: ${evt.error}` : 'Run failed', - timestamp: Date.now(), - }) - } msgs.forEach((m, i) => { if (m.role === 'tool' && m.toolStatus === 'running') { msgs[i] = { ...m, toolStatus: 'error' } @@ -1371,20 +1384,14 @@ export const useChatStore = defineStore('chat', () => { // onError (err) => { console.warn('Socket.IO run stream error:', err.message) + addAgentErrorMessage(sid, err.message) const msgs = getSessionMsgs(sid) - const last = msgs[msgs.length - 1] - if (last?.isStreaming) { - updateMessage(sid, last.id, { isStreaming: false }) - } msgs.forEach((m, i) => { if (m.role === 'tool' && m.toolStatus === 'running') { - msgs[i] = { ...m, toolStatus: 'done' } + msgs[i] = { ...m, toolStatus: 'error' } } }) cleanup() - if (sid === activeSessionId.value) { - void refreshActiveSession() - } }, undefined, ) @@ -1756,22 +1763,8 @@ export const useChatStore = defineStore('chat', () => { } else { queueLengths.value.delete(sid) } + addAgentErrorMessage(sid, evt.error) const msgs = getSessionMsgs(sid) - const lastErr = msgs[msgs.length - 1] - if (lastErr?.isStreaming) { - updateMessage(sid, lastErr.id, { - isStreaming: false, - content: evt.error ? `Error: ${evt.error}` : 'Run failed', - role: 'system', - }) - } else { - addMessage(sid, { - id: uid(), - role: 'system', - content: evt.error ? `Error: ${evt.error}` : 'Run failed', - timestamp: Date.now(), - }) - } msgs.forEach((m, i) => { if (m.role === 'tool' && m.toolStatus === 'running') { msgs[i] = { ...m, toolStatus: 'error' } diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index 1b5f336..d1e1e67 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -264,6 +264,13 @@ class AgentClient { onStatus?: (status: 'compressing' | 'replying' | 'ready') => void, ): Promise { logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`) + 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 try { // Notify room that agent is typing this.startTyping(roomId) @@ -335,12 +342,6 @@ class AgentClient { const bridge = new AgentBridgeClient() const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) - const runMessageId = groupMessageId(roomId, this.profile, this.name) - let partIndex = 0 - let streamMessageId = groupMessagePartId(runMessageId, partIndex) - let currentContent = '' - let totalContent = '' - let reasoningContent = '' const flushedAssistantParts = new Set() let lastChunk: AgentBridgeOutput | null = null const started = await bridge.chat( @@ -355,6 +356,7 @@ class AgentClient { ) this.emitMessageStreamStart(roomId, streamMessageId) + streamStarted = true for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) { lastChunk = chunk reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => { @@ -373,6 +375,7 @@ class AgentClient { partIndex += 1 streamMessageId = groupMessagePartId(runMessageId, partIndex) this.emitMessageStreamStart(roomId, streamMessageId) + streamStarted = true return toolBaseId }) if (chunk.delta) { @@ -384,6 +387,7 @@ class AgentClient { if (lastChunk?.status === 'error') { logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`) + await this.sendAgentErrorMessage(roomId, streamMessageId, lastChunk.error || 'Run failed', msg, reasoningContent) this.emitMessageStreamEnd(roomId, streamMessageId) this.stopTyping(roomId) onStatus?.('ready') @@ -414,11 +418,35 @@ class AgentClient { onStatus?.('ready') } catch (err: any) { logger.error(`[AgentClients] ${this.name}: error handling message: ${err.message}`) + 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}`) + } this.stopTyping(roomId) onStatus?.('ready') } } + private async sendAgentErrorMessage( + roomId: string, + messageId: string, + error: unknown, + sourceMsg: MentionMessage, + reasoningContent = '', + ): Promise { + 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, + }) + } + private async recordBridgeEvents( roomId: string, chunk: AgentBridgeOutput,