/** * CLI Bridge run handler — handles runs that use the agent bridge * to communicate with Hermes CLI agent. */ import type { Server, Socket } from 'socket.io' import { getSession, createSession, addMessage, updateSessionStats } from '../../../db/hermes/session-store' import { updateUsage } from '../../../db/hermes/usage-store' import { countTokens } from '../../../lib/context-compressor' import { logger, bridgeLogger } from '../../logger' import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge' import { contentBlocksToString, extractTextForPreview } from './content-blocks' import { buildCompressedHistory } from './compression' import { pushState, replaceState } from './compression' import { calcAndUpdateUsage } from './usage' import { flushBridgePendingToDb, ensureOpenBridgeAssistantMessage, syncBridgeReasoningToMessage, recordBridgeToolStarted, recordBridgeToolCompleted, } from './bridge-message' import { forceCompressBridgeHistory } from './compression' import { summarizeToolArguments } from './response-utils' import { buildDbHistory } from './compression' import type { ContentBlock, SessionState } from './types' import type { ChatMessage } from '../../../lib/context-compressor' const BRIDGE_USAGE_FLUSH_DELAY_MS = 200 export async function handleBridgeRun( nsp: ReturnType, socket: Socket, data: { input: string | ContentBlock[]; session_id?: string; model?: string; instructions?: string; source?: string }, profile: string, sessionMap: Map, gatewayManager: any, bridge: AgentBridgeClient, _skipUserMessage = false, loadSessionStateFromDbFn: (sid: string, sessionMap: Map) => Promise, dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, ) { const { input, session_id, model, instructions } = data if (!session_id) { socket.emit('run.failed', { event: 'run.failed', error: 'session_id is required for cli source' }) return } const runMarker = `cli_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` const now = Math.floor(Date.now() / 1000) let state = sessionMap.get(session_id) if (!state) { state = getSession(session_id) ? await loadSessionStateFromDbFn(session_id, sessionMap) : { messages: [], isWorking: false, events: [], queue: [] } sessionMap.set(session_id, state) } state.isWorking = true state.isAborting = false state.profile = profile state.source = 'cli' state.activeRunMarker = runMarker state.runId = undefined state.abortController = undefined state.bridgeOutput = '' state.bridgePendingAssistantContent = '' state.bridgePendingReasoningContent = '' state.bridgeToolCounter = 0 state.bridgePendingTools = [] state.responseRun = undefined const inputStr = contentBlocksToString(input) state.messages.push({ id: state.messages.length + 1, session_id, runMarker, role: 'user', content: inputStr, timestamp: now, }) if (!getSession(session_id)) { const previewText = extractTextForPreview(input) const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) createSession({ id: session_id, profile, source: 'cli', model, title: preview }) } addMessage({ session_id, role: 'user', content: inputStr, timestamp: now, }) socket.join(`session:${session_id}`) const emit = (event: string, payload: any) => { const tagged = { ...payload, session_id } nsp.to(`session:${session_id}`).emit(event, tagged) if (!nsp.adapter.rooms.get(`session:${session_id}`)?.size && socket.connected) { socket.emit(event, tagged) } } const history = await buildCompressedHistory( session_id, profile, gatewayManager.getUpstream(profile).replace(/\/$/, ''), gatewayManager.getApiKey(profile) || undefined, emit, sessionMap, ) try { logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id) bridgeLogger.info({ sessionId: session_id, profile, inputChars: inputStr.length, historyMessages: history.length, hasInstructions: Boolean(instructions), }, '[chat-run-socket] starting CLI bridge run') const started = await bridge.chat(session_id, input as AgentBridgeMessage, history, instructions, profile) state.runId = started.run_id bridgeLogger.info({ sessionId: session_id, runId: started.run_id, status: started.status, }, '[chat-run-socket] CLI bridge run started') pushState(sessionMap, session_id, 'run.started', { event: 'run.started', run_id: started.run_id, queue_length: state.queue.length || 0, }) emit('run.started', { event: 'run.started', run_id: started.run_id, queue_length: state.queue.length || 0, }) for await (const chunk of bridge.streamOutput(started.run_id)) { await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, gatewayManager, bridge, dequeueNextQueuedRun) if (chunk.done) break } } catch (err: any) { if (state.activeRunMarker !== runMarker) return if (!state.isWorking) return const queueLen = state.queue?.length ?? 0 state.isWorking = false state.isAborting = false state.profile = undefined state.runId = undefined state.activeRunMarker = undefined state.events = [] flushBridgePendingToDb(state, session_id) updateSessionStats(session_id) const message = err instanceof Error ? err.message : String(err) emit('run.failed', { event: 'run.failed', error: message, queue_remaining: queueLen }) const errUsage = await calcAndUpdateUsage(session_id, state, emit) updateUsage(session_id, { inputTokens: errUsage.inputTokens, outputTokens: errUsage.outputTokens, profile: state.profile, }) if (queueLen > 0) dequeueNextQueuedRun(socket, session_id) } } async function applyBridgeChunkAsync( nsp: ReturnType, socket: Socket, state: SessionState, sessionId: string, runMarker: string, chunk: AgentBridgeOutput, emit: (event: string, payload: any) => void, profile: string, sessionMap: Map, gatewayManager: any, bridge: AgentBridgeClient, dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, ): Promise { if (state.activeRunMarker !== runMarker) { bridgeLogger.info({ sessionId, runId: chunk.run_id, runMarker, activeRunMarker: state.activeRunMarker, }, '[chat-run-socket] ignoring stale CLI bridge chunk') return } state.runId = chunk.run_id for (const ev of chunk.events || []) { const evType = ev.event as string | undefined if (evType === 'tool.started') { flushBridgePendingToDb(state, sessionId, runMarker) const toolName = (ev.tool_name as string) || '' const args = ev.args as Record | undefined const tool = recordBridgeToolStarted(state, sessionId, runMarker, toolName, args, ev.tool_call_id) const payload = { event: 'tool.started', run_id: chunk.run_id, tool_call_id: tool.id, tool: toolName, name: toolName, arguments: tool.arguments, preview: ev.preview || summarizeToolArguments(tool.arguments), } pushState(sessionMap, sessionId, 'tool.started', payload) emit('tool.started', payload) } else if (evType === 'tool.completed') { const toolName = (ev.tool_name as string) || '' const completed = recordBridgeToolCompleted(state, sessionId, runMarker, toolName, ev) const payload = { event: 'tool.completed', run_id: chunk.run_id, tool_call_id: completed.id, tool: toolName, name: toolName, output: completed.output, duration: completed.duration ?? ev.duration, error: ev.is_error || undefined, } pushState(sessionMap, sessionId, 'tool.completed', payload) emit('tool.completed', payload) } else if (evType === 'turn.boundary') { flushBridgePendingToDb(state, sessionId, runMarker) } else if (evType === 'reasoning.delta' || evType === 'thinking.delta') { const text = String(ev.text || '') if (text) { state.bridgePendingReasoningContent = (state.bridgePendingReasoningContent || '') + text const message = ensureOpenBridgeAssistantMessage(state, sessionId, runMarker) message.reasoning = (message.reasoning || '') + text message.reasoning_content = (message.reasoning_content || '') + text } emit(evType, { event: evType, run_id: chunk.run_id, text, }) } else if (evType === 'reasoning.available') { emit('reasoning.available', { event: 'reasoning.available', run_id: chunk.run_id, }) } else if (evType === 'approval.requested') { const payload = { event: 'approval.requested', run_id: chunk.run_id, approval_id: ev.approval_id, command: ev.command, description: ev.description, choices: ev.choices, allow_permanent: ev.allow_permanent, timeout_ms: ev.timeout_ms, } replaceState(sessionMap, sessionId, 'approval.requested', payload) emit('approval.requested', payload) } else if (evType === 'approval.resolved') { const payload = { event: 'approval.resolved', run_id: chunk.run_id, approval_id: ev.approval_id, choice: ev.choice, } replaceState(sessionMap, sessionId, 'approval.resolved', payload) emit('approval.resolved', payload) } else if (evType === 'bridge.compression.requested') { const bridgeHistory = await buildDbHistory(sessionId, { excludeLastUser: true }) const tokenCount = bridgeHistory.length > 0 ? countTokens(JSON.stringify(bridgeHistory)) : ev.approx_tokens const payload = { event: 'compression.started', run_id: chunk.run_id, request_id: ev.request_id, message_count: bridgeHistory.length || ev.message_count, token_count: tokenCount, source: 'bridge', } replaceState(sessionMap, sessionId, 'compression.started', payload) emit('compression.started', payload) if (ev.request_id && Array.isArray(ev.messages)) { try { const compressed = await forceCompressBridgeHistory( sessionId, profile, ev.messages as ChatMessage[], (p: string) => gatewayManager.getUpstream(p), (p: string) => gatewayManager.getApiKey(p), ) state.bridgeCompressionResults = state.bridgeCompressionResults || {} state.bridgeCompressionResults[String(ev.request_id)] = compressed await bridge.compressionRespond(String(ev.request_id), { messages: compressed.messages }) } catch (err: any) { await bridge.compressionRespond(String(ev.request_id), { error: err?.message || String(err), }).catch(() => undefined) } } } else if (evType === 'bridge.compression.completed') { const compressionResult = ev.request_id ? state.bridgeCompressionResults?.[String(ev.request_id)] : undefined const payload = { event: 'compression.completed', run_id: chunk.run_id, request_id: ev.request_id, compressed: compressionResult?.compressed ?? ev.compressed !== false, llmCompressed: compressionResult?.llmCompressed, totalMessages: compressionResult?.beforeMessages ?? ev.message_count, resultMessages: compressionResult?.resultMessages ?? ev.result_messages, beforeTokens: compressionResult?.beforeTokens ?? ev.approx_tokens, afterTokens: compressionResult?.afterTokens, summaryTokens: compressionResult?.summaryTokens, verbatimCount: compressionResult?.verbatimCount, compressedStartIndex: compressionResult?.compressedStartIndex, source: 'bridge', } if (ev.request_id && state.bridgeCompressionResults) { delete state.bridgeCompressionResults[String(ev.request_id)] } replaceState(sessionMap, sessionId, 'compression.completed', payload) emit('compression.completed', payload) await calcAndUpdateUsage(sessionId, state, emit) } else if (evType === 'bridge.compression.failed') { const payload = { event: 'compression.completed', run_id: chunk.run_id, request_id: ev.request_id, compressed: false, totalMessages: ev.message_count, resultMessages: ev.message_count, beforeTokens: ev.approx_tokens, error: ev.error, source: 'bridge', } if (ev.request_id && state.bridgeCompressionResults) { delete state.bridgeCompressionResults[String(ev.request_id)] } replaceState(sessionMap, sessionId, 'compression.completed', payload) emit('compression.completed', payload) } else if (evType === 'status') { emit('agent.event', { event: 'agent.event', run_id: chunk.run_id, ...ev, }) } } if (chunk.delta) { state.bridgeOutput = (state.bridgeOutput || '') + chunk.delta state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + chunk.delta const last = [...state.messages].reverse().find(m => m.runMarker === runMarker) if (last?.role === 'assistant' && last.finish_reason == null) { last.content += chunk.delta syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent) } else { state.messages.push({ id: state.messages.length + 1, session_id: sessionId, runMarker, role: 'assistant', content: chunk.delta, reasoning: state.bridgePendingReasoningContent || null, reasoning_content: state.bridgePendingReasoningContent || null, timestamp: Math.floor(Date.now() / 1000), }) } emit('message.delta', { event: 'message.delta', run_id: chunk.run_id, delta: chunk.delta, output: state.bridgeOutput, }) } if (!chunk.done) return if (!state.isWorking) return if (state.isAborting) { bridgeLogger.info({ sessionId, runId: chunk.run_id, status: chunk.status, }, '[chat-run-socket][abort] suppressing CLI bridge terminal chunk during abort') return } flushBridgePendingToDb(state, sessionId) updateSessionStats(sessionId) await delay(BRIDGE_USAGE_FLUSH_DELAY_MS) const usage = await calcAndUpdateUsage(sessionId, state, emit) updateUsage(sessionId, { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, profile: state.profile, }) const nextQueuedRun = state.queue.length > 0 ? state.queue[0] : undefined state.isWorking = Boolean(nextQueuedRun) state.isAborting = false if (nextQueuedRun) { state.profile = nextQueuedRun.profile || profile state.source = nextQueuedRun.source } else { state.profile = undefined } state.runId = undefined state.activeRunMarker = undefined state.events = [] const eventName = chunk.status === 'error' ? 'run.failed' : 'run.completed' const payload = { event: eventName, run_id: chunk.run_id, output: chunk.output || state.bridgeOutput || '', result: chunk.result, error: chunk.error, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, queue_remaining: state.queue.length, } emit(eventName, payload) if (state.queue.length > 0) { dequeueNextQueuedRun(socket, sessionId) } } function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) }