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:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
@@ -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<{