Fix bridge history, profile models, and Windows gateway handling (#845)
* feat: support profile-aware group chat bridge flows * feat: route cron jobs through hermes cli * Fix group chat routing and isolate bridge tests * Add Grok image-to-video media skill * Default Grok videos to media directory * Fix bridge profile fallback and cron repeat clearing * Refine bridge chat and gateway platform handling * Filter bridge tool-call text deltas * Preserve structured bridge chat history * Prepare beta release build artifacts * Fix Windows run profile resolution * Fix Windows path compatibility checks * Fix profile-scoped model page display * Hide Windows subprocess windows for jobs and updates * Hide Windows file backend subprocess windows * Avoid Windows gateway restart lock conflicts * Treat Windows gateway lock as running on startup * Force release Windows gateway lock on restart * Tighten Windows gateway lock cleanup * Update chat e2e source expectation * Bump package version to 0.5.30 --------- Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -60,7 +60,7 @@ export async function handleAbort(
|
||||
|
||||
if (state.source === 'cli') {
|
||||
try {
|
||||
await bridge.interrupt(sessionId, 'Aborted by user')
|
||||
await bridge.interrupt(sessionId, 'Aborted by user', state.profile)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface BridgeDeltaFilterState {
|
||||
bridgePendingToolCallMarkup?: string
|
||||
}
|
||||
|
||||
const TOOL_CALL_MARKER = '[Calling tool:'
|
||||
const MAX_PENDING_TOOL_MARKUP_LENGTH = 100_000
|
||||
|
||||
function findToolMarkupEnd(text: string, start: number): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (ch === '\\') {
|
||||
escaped = true
|
||||
} else if (ch === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (ch === '[') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (ch === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function trailingMarkerPrefixLength(text: string): number {
|
||||
const max = Math.min(text.length, TOOL_CALL_MARKER.length - 1)
|
||||
for (let len = max; len > 0; len -= 1) {
|
||||
if (TOOL_CALL_MARKER.startsWith(text.slice(text.length - len))) return len
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function filterBridgeToolCallMarkupDelta(
|
||||
state: BridgeDeltaFilterState,
|
||||
delta: string,
|
||||
): string {
|
||||
if (!delta) return ''
|
||||
|
||||
const text = `${state.bridgePendingToolCallMarkup || ''}${delta}`
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
|
||||
let out = ''
|
||||
let idx = 0
|
||||
while (idx < text.length) {
|
||||
const markerIdx = text.indexOf(TOOL_CALL_MARKER, idx)
|
||||
if (markerIdx < 0) {
|
||||
const rest = text.slice(idx)
|
||||
const pendingPrefixLength = trailingMarkerPrefixLength(rest)
|
||||
if (pendingPrefixLength > 0) {
|
||||
out += rest.slice(0, rest.length - pendingPrefixLength)
|
||||
state.bridgePendingToolCallMarkup = rest.slice(rest.length - pendingPrefixLength)
|
||||
} else {
|
||||
out += rest
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
out += text.slice(idx, markerIdx)
|
||||
const end = findToolMarkupEnd(text, markerIdx)
|
||||
if (end < 0) {
|
||||
state.bridgePendingToolCallMarkup = text.slice(markerIdx)
|
||||
if (state.bridgePendingToolCallMarkup.length > MAX_PENDING_TOOL_MARKUP_LENGTH) {
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
idx = end
|
||||
if (text[idx] === '\r' && text[idx + 1] === '\n') {
|
||||
idx += 2
|
||||
} else if (text[idx] === '\n') {
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import {
|
||||
getSessionDetail,
|
||||
getSession,
|
||||
} from '../../../db/hermes/session-store'
|
||||
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
|
||||
import { ChatContextCompressor, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
@@ -96,12 +97,17 @@ export async function buildCompressedHistory(
|
||||
apiKey: string | undefined,
|
||||
emit: (event: string, payload: any) => void,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
modelContext: { model?: string | null; provider?: string | null } = {},
|
||||
): Promise<ChatMessage[]> {
|
||||
try {
|
||||
let history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
if (history.length === 0) return []
|
||||
|
||||
const contextLength = getModelContextLength(profile)
|
||||
const contextLength = getModelContextLength({
|
||||
profile,
|
||||
model: modelContext.model,
|
||||
provider: modelContext.provider,
|
||||
})
|
||||
const triggerTokens = Math.floor(contextLength / 2)
|
||||
const cState = getOrCreateSession(sessionMap, sessionId)
|
||||
const assembledTokens = await calcAndUpdateUsage(sessionId, cState, emit)
|
||||
@@ -118,13 +124,13 @@ export async function buildCompressedHistory(
|
||||
...newMessages,
|
||||
] as ChatMessage[]
|
||||
} else {
|
||||
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
|
||||
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
|
||||
}
|
||||
} else if (history.length > 4) {
|
||||
if (totalTokens <= triggerTokens && history.length <= 150) {
|
||||
logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', sessionId, history.length, totalTokens)
|
||||
} else {
|
||||
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
|
||||
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +151,7 @@ export async function compressHistory(
|
||||
totalTokens: number,
|
||||
emit: (event: string, payload: any) => void,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
modelContext: { model?: string | null; provider?: string | null } = {},
|
||||
): Promise<ChatMessage[]> {
|
||||
const msgCount = newMessagesOnly ? newMessagesOnly.length : history.length
|
||||
pushState(sessionMap, sessionId, 'compression.started', {
|
||||
@@ -155,7 +162,12 @@ export async function compressHistory(
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId)
|
||||
const session = getSession(sessionId)
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
|
||||
profile: session?.profile,
|
||||
model: modelContext.model || session?.model,
|
||||
provider: modelContext.provider || session?.provider,
|
||||
})
|
||||
const afterTokens = await calcAndUpdateUsage(sessionId, cState, emit)
|
||||
const compressedMeta = {
|
||||
event: 'compression.completed' as const,
|
||||
@@ -211,8 +223,6 @@ export async function forceCompressBridgeHistory(
|
||||
sessionId: string,
|
||||
profile: string,
|
||||
_messages: ChatMessage[],
|
||||
getUpstream: (profile: string) => string,
|
||||
getApiKey: (profile: string) => string | undefined,
|
||||
): Promise<BridgeCompressionResult> {
|
||||
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
|
||||
@@ -231,8 +241,9 @@ export async function forceCompressBridgeHistory(
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = getUpstream(profile).replace(/\/$/, '')
|
||||
const apiKey = getApiKey(profile) || undefined
|
||||
const upstream = ''
|
||||
const apiKey = undefined
|
||||
const session = getSession(sessionId)
|
||||
const beforeUsage = estimateSnapshotAwareHistoryUsage(sessionId, history)
|
||||
const totalTokens = beforeUsage.tokenCount
|
||||
bridgeLogger.info({
|
||||
@@ -245,7 +256,11 @@ export async function forceCompressBridgeHistory(
|
||||
snapshotAware: true,
|
||||
}, '[chat-run-socket] bridge forced compression started')
|
||||
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, profile)
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
|
||||
profile: session?.profile || profile,
|
||||
model: session?.model,
|
||||
provider: session?.provider,
|
||||
})
|
||||
const compressedMessages = result.messages.map(m => {
|
||||
const msg: any = { role: m.role, content: m.content }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ContentBlock } from './types'
|
||||
|
||||
type ResponseContentPart = { type: string; text?: string; image_url?: string }
|
||||
type AgentContentPart = { type: string; text?: string; image_url?: { url: string } }
|
||||
|
||||
/**
|
||||
* Convert ContentBlock[] to string for display/storage
|
||||
*/
|
||||
@@ -29,22 +32,16 @@ export function isContentBlockArray(input: any): input is ContentBlock[] {
|
||||
/**
|
||||
* Convert ContentBlock[] to multimodal format for /v1/responses API.
|
||||
*/
|
||||
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: string }>> {
|
||||
const parts: Array<{ type: string; text?: string; image_url?: string }> = []
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
|
||||
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<ResponseContentPart[]> {
|
||||
const parts: ResponseContentPart[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
parts.push({ type: 'input_text', text: block.text })
|
||||
} else if (block.type === 'image') {
|
||||
try {
|
||||
const buf = await fs.readFile(block.path)
|
||||
const ext = path.extname(block.path).toLowerCase().replace('.', '')
|
||||
const mime = ext === 'jpg' ? 'jpeg' : ext || 'png'
|
||||
const base64 = buf.toString('base64')
|
||||
parts.push({ type: 'input_image', image_url: `data:image/${mime};base64,${base64}` })
|
||||
} catch {
|
||||
const dataUri = await imageBlockToDataUri(block)
|
||||
if (dataUri) {
|
||||
parts.push({ type: 'input_image', image_url: dataUri })
|
||||
} else {
|
||||
parts.push({ type: 'input_text', text: `[Image: ${block.path}]` })
|
||||
}
|
||||
} else if (block.type === 'file') {
|
||||
@@ -59,15 +56,42 @@ export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Arra
|
||||
* Convert ContentBlock[] to the normalized multimodal shape Hermes agent
|
||||
* receives after /v1/responses input normalization.
|
||||
*/
|
||||
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: { url: string } }>> {
|
||||
const responseParts = await convertContentBlocks(blocks)
|
||||
return responseParts.map((part) => {
|
||||
if (part.type === 'input_text') {
|
||||
return { type: 'text', text: part.text || '' }
|
||||
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<AgentContentPart[]> {
|
||||
const parts: AgentContentPart[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
parts.push({ type: 'text', text: block.text || '' })
|
||||
} else if (block.type === 'image') {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: `[Attached image: ${block.name || block.path}]\nLocal image path for tools: ${block.path}`,
|
||||
})
|
||||
const dataUri = await imageBlockToDataUri(block)
|
||||
if (dataUri) {
|
||||
parts.push({ type: 'image_url', image_url: { url: dataUri } })
|
||||
}
|
||||
} else if (block.type === 'file') {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: `[Attached file: ${block.name || block.path}]\nLocal file path for tools: ${block.path}`,
|
||||
})
|
||||
}
|
||||
if (part.type === 'input_image') {
|
||||
return { type: 'image_url', image_url: { url: part.image_url || '' } }
|
||||
}
|
||||
return { type: 'text', text: part.text || '' }
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
async function imageBlockToDataUri(block: Extract<ContentBlock, { type: 'image' }>): Promise<string | null> {
|
||||
try {
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
const buf = await fs.readFile(block.path)
|
||||
const ext = path.extname(block.path).toLowerCase().replace('.', '')
|
||||
const mimeFromExt = ext === 'jpg' ? 'jpeg' : ext || 'png'
|
||||
const mime = block.media_type?.startsWith('image/')
|
||||
? block.media_type.slice('image/'.length)
|
||||
: mimeFromExt
|
||||
return `data:image/${mime};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,8 @@ import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
|
||||
import type { ContentBlock, SessionState, ChatRunSource } from './types'
|
||||
|
||||
export function resolveRunSource(source?: string, sessionId?: string): ChatRunSource {
|
||||
const normalized = String(source || '').trim()
|
||||
if (normalized === 'cli') return 'cli'
|
||||
if (normalized === 'api_server') return 'api_server'
|
||||
if (sessionId) {
|
||||
const existing = getSession(sessionId)
|
||||
if (existing?.source === 'cli') return 'cli'
|
||||
}
|
||||
return 'api_server'
|
||||
export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource {
|
||||
return 'cli'
|
||||
}
|
||||
|
||||
export async function loadSessionStateFromDb(sid: string, _sessionMap: Map<string, SessionState>): Promise<SessionState> {
|
||||
@@ -78,7 +71,6 @@ export async function handleApiRun(
|
||||
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; instructions?: string; source?: string },
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
skipUserMessage = false,
|
||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||
) {
|
||||
@@ -96,8 +88,8 @@ export async function handleApiRun(
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = gatewayManager.getUpstream(profile).replace(/\/$/, '')
|
||||
const apiKey = gatewayManager.getApiKey(profile) || undefined
|
||||
const upstream = ''
|
||||
const apiKey = undefined
|
||||
|
||||
const runMarker = session_id
|
||||
? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -179,7 +171,11 @@ export async function handleApiRun(
|
||||
if (model) body.model = model
|
||||
body.instructions = fullInstructions
|
||||
if (session_id) {
|
||||
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap)
|
||||
const sessionRow = getSession(session_id)
|
||||
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap, {
|
||||
model: sessionRow?.model || model,
|
||||
provider: sessionRow?.provider || provider,
|
||||
})
|
||||
if (compressed.length > 0) {
|
||||
body.conversation_history = compressed
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getSession, createSession, addMessage, updateSession, updateSessionStat
|
||||
import { updateUsage } from '../../../db/hermes/usage-store'
|
||||
import { logger, bridgeLogger } from '../../logger'
|
||||
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
|
||||
import { readConfigYaml } from '../../config-helpers'
|
||||
import { contentBlocksToString, convertContentBlocksForAgent, extractTextForPreview, isContentBlockArray } from './content-blocks'
|
||||
import { buildCompressedHistory } from './compression'
|
||||
import { pushState, replaceState } from './compression'
|
||||
@@ -24,43 +23,19 @@ import {
|
||||
import { forceCompressBridgeHistory } from './compression'
|
||||
import { summarizeToolArguments } from './response-utils'
|
||||
import { buildDbHistory } from './compression'
|
||||
import { convertHistoryFormat } from './message-format'
|
||||
import type { ContentBlock, SessionState } from './types'
|
||||
import type { ChatMessage } from '../../../lib/context-compressor'
|
||||
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
|
||||
import { filterBridgeToolCallMarkupDelta } from './bridge-delta'
|
||||
|
||||
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
|
||||
|
||||
type RunModelGroup = { provider: string; models: string[] }
|
||||
|
||||
async function resolveDefaultModelConfig(): Promise<{ model: string; provider: string }> {
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
const modelConfig = config?.model
|
||||
const model = typeof modelConfig === 'string'
|
||||
? modelConfig.trim()
|
||||
: String(modelConfig?.default || '').trim()
|
||||
const provider = typeof modelConfig === 'object'
|
||||
? String(modelConfig?.provider || '').trim()
|
||||
: ''
|
||||
return { model, provider }
|
||||
} catch {
|
||||
return { model: '', provider: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
|
||||
if (!groups?.length || !provider || !model) return false
|
||||
const group = groups.find(item => item.provider === provider)
|
||||
return Array.isArray(group?.models) && group.models.includes(model)
|
||||
}
|
||||
|
||||
export async function handleBridgeRun(
|
||||
nsp: ReturnType<Server['of']>,
|
||||
socket: Socket,
|
||||
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string },
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
bridge: AgentBridgeClient,
|
||||
_skipUserMessage = false,
|
||||
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
|
||||
@@ -78,14 +53,14 @@ export async function handleBridgeRun(
|
||||
const sessionRow = getSession(session_id)
|
||||
const sessionModel = sessionRow?.model || ''
|
||||
const sessionProvider = sessionRow?.provider || ''
|
||||
const hasGroups = Array.isArray(data.model_groups) && data.model_groups.length > 0
|
||||
const sessionModelAvailable = hasGroups && hasModelInGroups(data.model_groups, sessionProvider, sessionModel)
|
||||
const shouldUseDefault = !sessionModel || !sessionProvider || !sessionModelAvailable
|
||||
const defaultModelConfig = shouldUseDefault
|
||||
? await resolveDefaultModelConfig()
|
||||
: { model: '', provider: '' }
|
||||
const resolvedModel = shouldUseDefault ? defaultModelConfig.model : sessionModel
|
||||
const resolvedProvider = shouldUseDefault ? defaultModelConfig.provider : sessionProvider
|
||||
const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({
|
||||
profile,
|
||||
sessionModel,
|
||||
sessionProvider,
|
||||
requestedModel: data.model,
|
||||
requestedProvider: data.provider,
|
||||
modelGroups: data.model_groups,
|
||||
})
|
||||
if (sessionRow) {
|
||||
const updates: { model?: string; provider?: string } = {}
|
||||
if (resolvedModel && sessionRow.model !== resolvedModel) updates.model = resolvedModel
|
||||
@@ -117,6 +92,7 @@ export async function handleBridgeRun(
|
||||
state.bridgeOutput = ''
|
||||
state.bridgePendingAssistantContent = ''
|
||||
state.bridgePendingReasoningContent = ''
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
state.bridgeToolCounter = 0
|
||||
state.bridgePendingTools = []
|
||||
state.responseRun = undefined
|
||||
@@ -154,12 +130,13 @@ export async function handleBridgeRun(
|
||||
|
||||
const history = await buildCompressedHistory(
|
||||
session_id, profile,
|
||||
gatewayManager.getUpstream(profile).replace(/\/$/, ''),
|
||||
gatewayManager.getApiKey(profile) || undefined,
|
||||
'',
|
||||
undefined,
|
||||
emit,
|
||||
sessionMap,
|
||||
{ model: resolvedModel, provider: resolvedProvider },
|
||||
)
|
||||
const bridgeHistory = history.length > 0 ? convertHistoryFormat(history) : history
|
||||
const bridgeHistory = history
|
||||
|
||||
try {
|
||||
const bridgeInput = isContentBlockArray(input)
|
||||
@@ -207,7 +184,7 @@ export async function handleBridgeRun(
|
||||
})
|
||||
|
||||
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)
|
||||
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, bridge, dequeueNextQueuedRun)
|
||||
if (chunk.done) break
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -220,6 +197,7 @@ export async function handleBridgeRun(
|
||||
state.runId = undefined
|
||||
state.activeRunMarker = undefined
|
||||
state.events = []
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
flushBridgePendingToDb(state, session_id)
|
||||
updateSessionStats(session_id)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -244,7 +222,6 @@ async function applyBridgeChunkAsync(
|
||||
emit: (event: string, payload: any) => void,
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
bridge: AgentBridgeClient,
|
||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||
): Promise<void> {
|
||||
@@ -357,8 +334,6 @@ async function applyBridgeChunkAsync(
|
||||
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
|
||||
@@ -421,30 +396,33 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
|
||||
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),
|
||||
const delta = filterBridgeToolCallMarkupDelta(state, chunk.delta)
|
||||
if (delta) {
|
||||
state.bridgeOutput = (state.bridgeOutput || '') + delta
|
||||
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta
|
||||
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
|
||||
if (last?.role === 'assistant' && last.finish_reason == null) {
|
||||
last.content += delta
|
||||
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'assistant',
|
||||
content: 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,
|
||||
output: state.bridgeOutput,
|
||||
})
|
||||
}
|
||||
emit('message.delta', {
|
||||
event: 'message.delta',
|
||||
run_id: chunk.run_id,
|
||||
delta: chunk.delta,
|
||||
output: state.bridgeOutput,
|
||||
})
|
||||
}
|
||||
|
||||
if (!chunk.done) return
|
||||
@@ -459,6 +437,7 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
|
||||
flushBridgePendingToDb(state, sessionId)
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
updateSessionStats(sessionId)
|
||||
await delay(BRIDGE_USAGE_FLUSH_DELAY_MS)
|
||||
const usage = await calcAndUpdateUsage(sessionId, state, emit)
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Server, Socket } from 'socket.io'
|
||||
import { logger } from '../../logger'
|
||||
import { getSystemPrompt } from '../../../lib/llm-prompt'
|
||||
import { getSession } from '../../../db/hermes/session-store'
|
||||
import { getActiveProfileName } from '../hermes-profile'
|
||||
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../hermes-profile'
|
||||
import { AgentBridgeClient } from '../agent-bridge'
|
||||
import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle-api-run'
|
||||
import { handleBridgeRun } from './handle-bridge-run'
|
||||
@@ -25,14 +25,12 @@ export type { ContentBlock } from './types'
|
||||
|
||||
export class ChatRunSocket {
|
||||
private nsp: ReturnType<Server['of']>
|
||||
private gatewayManager: any
|
||||
private bridge = new AgentBridgeClient()
|
||||
/** sessionId → session state (messages, working status, events, run tracking) */
|
||||
private sessionMap = new Map<string, SessionState>()
|
||||
|
||||
constructor(io: Server, gatewayManager: any) {
|
||||
constructor(io: Server) {
|
||||
this.nsp = io.of('/chat-run')
|
||||
this.gatewayManager = gatewayManager
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -60,6 +58,17 @@ export class ChatRunSocket {
|
||||
private onConnection(socket: Socket) {
|
||||
const socketProfile = (socket.handshake.query?.profile as string) || 'default'
|
||||
const currentProfile = () => getActiveProfileName() || socketProfile || 'default'
|
||||
const profileExists = (profile: string) => {
|
||||
if (!profile || profile === 'default') return true
|
||||
return listProfileNamesFromDisk().includes(profile)
|
||||
}
|
||||
const resolveRunProfile = (sessionId?: string, requested?: string) => {
|
||||
const requestedProfile = typeof requested === 'string' ? requested.trim() : ''
|
||||
if (requestedProfile && profileExists(requestedProfile)) return requestedProfile
|
||||
if (!sessionId) return currentProfile()
|
||||
const storedProfile = getSession(sessionId)?.profile || ''
|
||||
return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
|
||||
}
|
||||
|
||||
socket.on('run', async (data: {
|
||||
input: string | ContentBlock[]
|
||||
@@ -70,7 +79,9 @@ export class ChatRunSocket {
|
||||
model_groups?: Array<{ provider: string; models: string[] }>
|
||||
queue_id?: string
|
||||
source?: string
|
||||
profile?: string
|
||||
}) => {
|
||||
const runProfile = resolveRunProfile(data.session_id, data.profile)
|
||||
if (data.session_id) {
|
||||
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
||||
const source = resolveRunSource(data.source, data.session_id)
|
||||
@@ -82,8 +93,7 @@ export class ChatRunSocket {
|
||||
socket,
|
||||
sessionMap: this.sessionMap,
|
||||
bridge: this.bridge,
|
||||
gatewayManager: this.gatewayManager,
|
||||
profile: currentProfile(),
|
||||
profile: runProfile,
|
||||
model: data.model,
|
||||
instructions: data.instructions,
|
||||
runQueuedItem: this.runQueuedItem.bind(this),
|
||||
@@ -107,7 +117,7 @@ export class ChatRunSocket {
|
||||
provider: data.provider,
|
||||
model_groups: data.model_groups,
|
||||
instructions: data.instructions,
|
||||
profile: currentProfile(),
|
||||
profile: runProfile,
|
||||
source,
|
||||
})
|
||||
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
|
||||
@@ -119,11 +129,11 @@ export class ChatRunSocket {
|
||||
return
|
||||
}
|
||||
state.isWorking = true
|
||||
state.profile = currentProfile()
|
||||
state.profile = runProfile
|
||||
state.source = source
|
||||
}
|
||||
try {
|
||||
await this.handleRun(socket, data, currentProfile())
|
||||
await this.handleRun(socket, data, runProfile)
|
||||
} catch (err) {
|
||||
if (data.session_id) {
|
||||
const state = this.sessionMap.get(data.session_id)
|
||||
@@ -224,7 +234,7 @@ export class ChatRunSocket {
|
||||
|
||||
await handleBridgeRun(
|
||||
this.nsp, socket, { ...data, instructions: fullInstructions }, profile,
|
||||
this.sessionMap, this.gatewayManager, this.bridge,
|
||||
this.sessionMap, this.bridge,
|
||||
skipUserMessage,
|
||||
loadSessionStateFromDb,
|
||||
this.dequeueNextQueuedRun.bind(this),
|
||||
@@ -234,7 +244,7 @@ export class ChatRunSocket {
|
||||
|
||||
await handleApiRun(
|
||||
this.nsp, socket, data, profile,
|
||||
this.sessionMap, this.gatewayManager,
|
||||
this.sessionMap,
|
||||
skipUserMessage,
|
||||
this.dequeueNextQueuedRun.bind(this),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readConfigYamlForProfile } from '../../config-helpers'
|
||||
|
||||
export type RunModelGroup = { provider: string; models: string[] }
|
||||
|
||||
async function resolveDefaultModelConfig(profile: string): Promise<{ model: string; provider: string }> {
|
||||
try {
|
||||
const config = await readConfigYamlForProfile(profile)
|
||||
const modelConfig = config?.model
|
||||
const model = typeof modelConfig === 'string'
|
||||
? modelConfig.trim()
|
||||
: String(modelConfig?.default || '').trim()
|
||||
const provider = typeof modelConfig === 'object'
|
||||
? String(modelConfig?.provider || '').trim()
|
||||
: ''
|
||||
return { model, provider }
|
||||
} catch {
|
||||
return { model: '', provider: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
|
||||
if (!groups?.length || !provider || !model) return false
|
||||
const group = groups.find(item => item.provider === provider)
|
||||
return Array.isArray(group?.models) && group.models.includes(model)
|
||||
}
|
||||
|
||||
export async function resolveBridgeRunModelConfig(options: {
|
||||
profile: string
|
||||
sessionModel?: string | null
|
||||
sessionProvider?: string | null
|
||||
requestedModel?: string | null
|
||||
requestedProvider?: string | null
|
||||
modelGroups?: RunModelGroup[]
|
||||
}): Promise<{ model: string; provider: string }> {
|
||||
const sessionModel = String(options.sessionModel || '').trim()
|
||||
const sessionProvider = String(options.sessionProvider || '').trim()
|
||||
const requestedModel = String(options.requestedModel || '').trim()
|
||||
const requestedProvider = String(options.requestedProvider || '').trim()
|
||||
const candidateModel = sessionModel || requestedModel
|
||||
const candidateProvider = sessionProvider || requestedProvider
|
||||
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
|
||||
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
|
||||
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
|
||||
return shouldUseDefault
|
||||
? resolveDefaultModelConfig(options.profile)
|
||||
: { model: candidateModel, provider: candidateProvider }
|
||||
}
|
||||
@@ -30,7 +30,6 @@ interface SessionCommandContext {
|
||||
socket: Socket
|
||||
sessionMap: Map<string, SessionState>
|
||||
bridge: AgentBridgeClient
|
||||
gatewayManager: any
|
||||
profile: string
|
||||
model?: string
|
||||
instructions?: string
|
||||
@@ -243,8 +242,6 @@ export async function handleSessionCommand(
|
||||
sessionId,
|
||||
ctx.profile,
|
||||
[],
|
||||
(profile: string) => ctx.gatewayManager.getUpstream(profile),
|
||||
(profile: string) => ctx.gatewayManager.getApiKey(profile),
|
||||
)
|
||||
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||
await calcAndUpdateUsage(sessionId, state, emit)
|
||||
@@ -312,11 +309,11 @@ export async function handleSessionCommand(
|
||||
try {
|
||||
if (wasWorking) {
|
||||
flushBridgePendingToDb(state, sessionId)
|
||||
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
|
||||
await ctx.bridge.interrupt(sessionId, 'Destroyed by user', state.profile).catch((err) => {
|
||||
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
|
||||
})
|
||||
}
|
||||
await ctx.bridge.destroy(sessionId).catch((err) => {
|
||||
await ctx.bridge.destroy(sessionId, state.profile).catch((err) => {
|
||||
bridgeReachable = false
|
||||
bridgeError = err instanceof Error ? err.message : String(err)
|
||||
logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId)
|
||||
@@ -337,6 +334,7 @@ export async function handleSessionCommand(
|
||||
state.queue = []
|
||||
state.bridgePendingAssistantContent = undefined
|
||||
state.bridgePendingReasoningContent = undefined
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
state.bridgeOutput = undefined
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
@@ -366,6 +364,7 @@ export async function handleSessionCommand(
|
||||
function clearTransientRunState(state: SessionState) {
|
||||
state.events = []
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
state.responseRun = undefined
|
||||
state.activeRunMarker = undefined
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SessionState {
|
||||
source?: ChatRunSource
|
||||
bridgePendingAssistantContent?: string
|
||||
bridgePendingReasoningContent?: string
|
||||
bridgePendingToolCallMarkup?: string
|
||||
bridgeOutput?: string
|
||||
bridgeToolCounter?: number
|
||||
bridgePendingTools?: Array<{
|
||||
|
||||
Reference in New Issue
Block a user