feat: add bridge session commands (#743)
This commit is contained in:
@@ -131,7 +131,10 @@ export async function handleUpdate(ctx: any) {
|
||||
})
|
||||
restart.on('exit', (code, signal) => {
|
||||
updateInProgress = false
|
||||
console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`)
|
||||
const failed = (typeof code === 'number' && code !== 0) || Boolean(signal)
|
||||
if (failed) {
|
||||
console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`)
|
||||
}
|
||||
})
|
||||
restart.unref()
|
||||
}, 3000)
|
||||
|
||||
@@ -201,6 +201,14 @@ export function deleteSession(id: string): boolean {
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function clearSessionMessages(id: string): number {
|
||||
if (!isSqliteAvailable()) return 0
|
||||
const db = getDb()!
|
||||
const result = db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id)
|
||||
updateSessionStats(id)
|
||||
return Number(result.changes)
|
||||
}
|
||||
|
||||
export function renameSession(id: string, title: string): boolean {
|
||||
if (!isSqliteAvailable()) return false
|
||||
const db = getDb()!
|
||||
|
||||
@@ -18,6 +18,7 @@ import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle
|
||||
import { handleBridgeRun } from './handle-bridge-run'
|
||||
import { handleAbort } from './abort'
|
||||
import { getOrCreateSession } from './compression'
|
||||
import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command'
|
||||
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||
|
||||
export type { ContentBlock } from './types'
|
||||
@@ -70,6 +71,32 @@ export class ChatRunSocket {
|
||||
}) => {
|
||||
if (data.session_id) {
|
||||
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
||||
const source = resolveRunSource(data.source, data.session_id)
|
||||
const command = parseSessionCommand(data.input)
|
||||
if (command && source === 'cli') {
|
||||
try {
|
||||
await handleSessionCommand(data.session_id, command, {
|
||||
nsp: this.nsp,
|
||||
socket,
|
||||
sessionMap: this.sessionMap,
|
||||
bridge: this.bridge,
|
||||
gatewayManager: this.gatewayManager,
|
||||
profile: currentProfile(),
|
||||
model: data.model,
|
||||
instructions: data.instructions,
|
||||
runQueuedItem: this.runQueuedItem.bind(this),
|
||||
})
|
||||
} catch (err) {
|
||||
this.emitToSession(socket, data.session_id, 'session.command', {
|
||||
event: 'session.command',
|
||||
command: command.rawName,
|
||||
ok: false,
|
||||
action: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.isWorking) {
|
||||
state.queue.push({
|
||||
queue_id: data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -77,7 +104,7 @@ export class ChatRunSocket {
|
||||
model: data.model,
|
||||
instructions: data.instructions,
|
||||
profile: currentProfile(),
|
||||
source: resolveRunSource(data.source, data.session_id),
|
||||
source,
|
||||
})
|
||||
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
|
||||
event: 'run.queued',
|
||||
@@ -89,7 +116,7 @@ export class ChatRunSocket {
|
||||
}
|
||||
state.isWorking = true
|
||||
state.profile = currentProfile()
|
||||
state.source = resolveRunSource(data.source, data.session_id)
|
||||
state.source = source
|
||||
}
|
||||
try {
|
||||
await this.handleRun(socket, data, currentProfile())
|
||||
@@ -169,6 +196,7 @@ export class ChatRunSocket {
|
||||
skipUserMessage = false,
|
||||
) {
|
||||
const source = resolveRunSource(data.source, data.session_id)
|
||||
if (data.session_id && source === 'cli' && isSessionCommand(data.input)) return
|
||||
|
||||
if (source === 'cli') {
|
||||
let fullInstructions = data.instructions
|
||||
|
||||
@@ -48,7 +48,7 @@ export function handleMessage(messages: SessionMessage[], sid: string): any[] {
|
||||
let _messages = []
|
||||
try {
|
||||
_messages = messages
|
||||
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined)
|
||||
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'command') && m.content !== undefined)
|
||||
.map((m, idx, arr) => {
|
||||
const msg: any = {
|
||||
id: m.id,
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { Server, Socket } from 'socket.io'
|
||||
import { addMessage, clearSessionMessages, createSession, getSession, renameSession, updateSessionStats } from '../../../db/hermes/session-store'
|
||||
import { logger } from '../../logger'
|
||||
import type { AgentBridgeClient } from '../agent-bridge'
|
||||
import { flushBridgePendingToDb } from './bridge-message'
|
||||
import { buildDbHistory, forceCompressBridgeHistory, getOrCreateSession, replaceState } from './compression'
|
||||
import { handleAbort } from './abort'
|
||||
import { calcAndUpdateUsage } from './usage'
|
||||
import { countTokens } from '../../../lib/context-compressor'
|
||||
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||
|
||||
type CommandName =
|
||||
| 'usage'
|
||||
| 'status'
|
||||
| 'abort'
|
||||
| 'queue'
|
||||
| 'clear'
|
||||
| 'title'
|
||||
| 'compress'
|
||||
| 'steer'
|
||||
| 'destroy'
|
||||
|
||||
interface ParsedSessionCommand {
|
||||
name: CommandName
|
||||
rawName: string
|
||||
args: string
|
||||
}
|
||||
|
||||
interface SessionCommandContext {
|
||||
nsp: ReturnType<Server['of']>
|
||||
socket: Socket
|
||||
sessionMap: Map<string, SessionState>
|
||||
bridge: AgentBridgeClient
|
||||
gatewayManager: any
|
||||
profile: string
|
||||
model?: string
|
||||
instructions?: string
|
||||
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
|
||||
}
|
||||
|
||||
const COMMAND_ALIASES: Record<string, CommandName> = {
|
||||
usage: 'usage',
|
||||
status: 'status',
|
||||
abort: 'abort',
|
||||
queue: 'queue',
|
||||
clear: 'clear',
|
||||
title: 'title',
|
||||
compress: 'compress',
|
||||
steer: 'steer',
|
||||
destroy: 'destroy',
|
||||
destory: 'destroy',
|
||||
}
|
||||
|
||||
export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null {
|
||||
if (typeof input !== 'string') return null
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed.startsWith('/')) return null
|
||||
const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/)
|
||||
if (!match) return null
|
||||
const rawName = match[1].toLowerCase()
|
||||
const name = COMMAND_ALIASES[rawName]
|
||||
if (!name) return { name: 'status', rawName, args: match[2]?.trim() || '' }
|
||||
return { name, rawName, args: match[2]?.trim() || '' }
|
||||
}
|
||||
|
||||
export function isSessionCommand(input: string | ContentBlock[]): boolean {
|
||||
return parseSessionCommand(input) !== null
|
||||
}
|
||||
|
||||
export async function handleSessionCommand(
|
||||
sessionId: string,
|
||||
command: ParsedSessionCommand,
|
||||
ctx: SessionCommandContext,
|
||||
): Promise<void> {
|
||||
const state = getOrCreateSession(ctx.sessionMap, sessionId)
|
||||
ctx.socket.join(`session:${sessionId}`)
|
||||
ensureCommandSession(sessionId, ctx)
|
||||
persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`)
|
||||
|
||||
const emitCommand = (payload: Record<string, unknown>) => {
|
||||
const message = typeof payload.message === 'string' ? payload.message : ''
|
||||
if (message) persistCommandMessage(sessionId, state, message)
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, 'session.command', {
|
||||
event: 'session.command',
|
||||
session_id: sessionId,
|
||||
command: command.rawName,
|
||||
ok: true,
|
||||
...payload,
|
||||
})
|
||||
}
|
||||
|
||||
if (!COMMAND_ALIASES[command.rawName]) {
|
||||
emitCommand({
|
||||
ok: false,
|
||||
action: 'error',
|
||||
terminal: !state.isWorking,
|
||||
message: `Unknown bridge command: /${command.rawName}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch (command.name) {
|
||||
case 'usage': {
|
||||
const usage = await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||
})
|
||||
emitCommand({
|
||||
action: 'usage',
|
||||
terminal: !state.isWorking,
|
||||
message: `Usage: input ${usage.inputTokens}, output ${usage.outputTokens}, total ${usage.inputTokens + usage.outputTokens} tokens.`,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const row = getSession(sessionId)
|
||||
emitCommand({
|
||||
action: 'status',
|
||||
terminal: !state.isWorking,
|
||||
message: [
|
||||
`Status: ${state.isWorking ? 'running' : 'idle'}`,
|
||||
`source: ${state.source || row?.source || 'cli'}`,
|
||||
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
|
||||
`model: ${ctx.model || row?.model || '-'}`,
|
||||
`queue: ${state.queue.length}`,
|
||||
`run: ${state.runId || state.activeRunMarker || '-'}`,
|
||||
].join(', '),
|
||||
isWorking: state.isWorking,
|
||||
isAborting: Boolean(state.isAborting),
|
||||
queueLength: state.queue.length,
|
||||
source: state.source || row?.source || 'cli',
|
||||
profile: state.profile || ctx.profile || row?.profile || 'default',
|
||||
model: ctx.model || row?.model || null,
|
||||
runId: state.runId || state.activeRunMarker || null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'abort':
|
||||
await handleAbort(ctx.nsp, ctx.socket, sessionId, ctx.sessionMap, ctx.bridge, ctx.runQueuedItem)
|
||||
emitCommand({ action: 'abort', message: 'Abort requested.' })
|
||||
return
|
||||
|
||||
case 'queue': {
|
||||
if (!command.args) {
|
||||
emitCommand({ ok: false, action: 'queue', terminal: !state.isWorking, message: 'Usage: /queue <message>' })
|
||||
return
|
||||
}
|
||||
if (!state.isWorking) {
|
||||
emitCommand({ ok: false, action: 'queue', message: 'Session is idle. Send the message normally instead.' })
|
||||
return
|
||||
}
|
||||
state.queue.push({
|
||||
queue_id: `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
input: command.args,
|
||||
model: ctx.model,
|
||||
instructions: ctx.instructions,
|
||||
profile: ctx.profile,
|
||||
source: 'cli',
|
||||
})
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
|
||||
event: 'run.queued',
|
||||
session_id: sessionId,
|
||||
queue_length: state.queue.length,
|
||||
})
|
||||
emitCommand({
|
||||
action: 'queue',
|
||||
terminal: false,
|
||||
message: `Queued message. Queue length: ${state.queue.length}.`,
|
||||
queueLength: state.queue.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
if (command.args === '--history') {
|
||||
if (state.isWorking) {
|
||||
emitCommand({
|
||||
ok: false,
|
||||
action: 'clear',
|
||||
terminal: false,
|
||||
message: 'Cannot clear history while the bridge run is active. Abort or destroy it first.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const deleted = clearSessionMessages(sessionId)
|
||||
state.messages = []
|
||||
clearTransientRunState(state)
|
||||
await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||
})
|
||||
emitCommand({
|
||||
action: 'clear',
|
||||
clearHistory: true,
|
||||
message: `Cleared ${deleted} history messages from the database.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
emitCommand({
|
||||
action: 'clear',
|
||||
message: 'Cleared the current display. History in the database was not deleted.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'title': {
|
||||
if (!command.args) {
|
||||
emitCommand({ ok: false, action: 'title', terminal: !state.isWorking, message: 'Usage: /title <new title>' })
|
||||
return
|
||||
}
|
||||
const title = command.args.slice(0, 120)
|
||||
if (!getSession(sessionId)) {
|
||||
createSession({ id: sessionId, profile: ctx.profile, source: 'cli', model: ctx.model, title })
|
||||
}
|
||||
const updated = renameSession(sessionId, title)
|
||||
emitCommand({
|
||||
ok: updated,
|
||||
action: 'title',
|
||||
title,
|
||||
message: updated ? `Title updated: ${title}` : 'Session was not found in the database.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'compress': {
|
||||
if (state.isWorking) {
|
||||
emitCommand({ ok: false, action: 'compress', terminal: false, message: 'Compression can only run while the session is idle.' })
|
||||
return
|
||||
}
|
||||
clearTransientRunState(state)
|
||||
const emit = (event: string, payload: any) => emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||
try {
|
||||
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
const tokenEstimate = history.length > 0 ? countTokens(JSON.stringify(history)) : 0
|
||||
emit('compression.started', {
|
||||
event: 'compression.started',
|
||||
message_count: history.length,
|
||||
token_count: tokenEstimate,
|
||||
source: 'command',
|
||||
})
|
||||
const result = await forceCompressBridgeHistory(
|
||||
sessionId,
|
||||
ctx.profile,
|
||||
[],
|
||||
(profile: string) => ctx.gatewayManager.getUpstream(profile),
|
||||
(profile: string) => ctx.gatewayManager.getApiKey(profile),
|
||||
)
|
||||
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||
await calcAndUpdateUsage(sessionId, state, emit)
|
||||
emit('compression.completed', {
|
||||
event: 'compression.completed',
|
||||
compressed: result.compressed,
|
||||
llmCompressed: result.llmCompressed,
|
||||
totalMessages: result.beforeMessages,
|
||||
resultMessages: result.resultMessages,
|
||||
beforeTokens: result.beforeTokens,
|
||||
afterTokens: result.afterTokens,
|
||||
summaryTokens: result.summaryTokens,
|
||||
verbatimCount: result.verbatimCount,
|
||||
compressedStartIndex: result.compressedStartIndex,
|
||||
source: 'command',
|
||||
})
|
||||
emitCommand({
|
||||
action: 'compress',
|
||||
message: `Compression completed: ${result.beforeMessages} -> ${result.resultMessages} messages, ${result.beforeTokens} -> ${result.afterTokens} tokens.`,
|
||||
beforeMessages: result.beforeMessages,
|
||||
resultMessages: result.resultMessages,
|
||||
beforeTokens: result.beforeTokens,
|
||||
afterTokens: result.afterTokens,
|
||||
compressed: result.compressed,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(err, '[chat-run-socket] /compress failed for session %s', sessionId)
|
||||
emit('compression.completed', {
|
||||
event: 'compression.completed',
|
||||
compressed: false,
|
||||
totalMessages: 0,
|
||||
resultMessages: 0,
|
||||
beforeTokens: 0,
|
||||
afterTokens: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
source: 'command',
|
||||
})
|
||||
emitCommand({
|
||||
ok: false,
|
||||
action: 'compress',
|
||||
message: `Compression failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'steer': {
|
||||
if (!command.args) {
|
||||
emitCommand({ ok: false, action: 'steer', terminal: !state.isWorking, message: 'Usage: /steer <instruction>' })
|
||||
return
|
||||
}
|
||||
if (!state.isWorking) {
|
||||
emitCommand({ ok: false, action: 'steer', message: 'No active bridge run to steer.' })
|
||||
return
|
||||
}
|
||||
await ctx.bridge.steer(sessionId, command.args)
|
||||
emitCommand({ action: 'steer', terminal: false, message: 'Steer instruction sent.' })
|
||||
return
|
||||
}
|
||||
|
||||
case 'destroy': {
|
||||
const wasWorking = state.isWorking
|
||||
let bridgeReachable = true
|
||||
let bridgeError: string | null = null
|
||||
try {
|
||||
if (wasWorking) {
|
||||
flushBridgePendingToDb(state, sessionId)
|
||||
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
|
||||
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
|
||||
})
|
||||
}
|
||||
await ctx.bridge.destroy(sessionId).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)
|
||||
})
|
||||
} finally {
|
||||
updateSessionStats(sessionId)
|
||||
await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||
})
|
||||
state.isWorking = false
|
||||
state.isAborting = false
|
||||
state.profile = undefined
|
||||
state.abortController = undefined
|
||||
state.runId = undefined
|
||||
state.responseRun = undefined
|
||||
state.activeRunMarker = undefined
|
||||
state.events = []
|
||||
state.queue = []
|
||||
state.bridgePendingAssistantContent = undefined
|
||||
state.bridgePendingReasoningContent = undefined
|
||||
state.bridgeOutput = undefined
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
replaceState(ctx.sessionMap, sessionId, 'session.command', {
|
||||
event: 'session.command',
|
||||
action: 'destroy',
|
||||
})
|
||||
}
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
|
||||
event: 'run.queued',
|
||||
session_id: sessionId,
|
||||
queue_length: 0,
|
||||
})
|
||||
emitCommand({
|
||||
action: 'destroy',
|
||||
message: bridgeReachable
|
||||
? (wasWorking ? 'Destroyed bridge agent and stopped the active run.' : 'Destroyed bridge agent.')
|
||||
: `Bridge agent was not reachable; cleared local session state.${bridgeError ? ` (${bridgeError})` : ''}`,
|
||||
destroyed: true,
|
||||
bridgeReachable,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearTransientRunState(state: SessionState) {
|
||||
state.events = []
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
state.responseRun = undefined
|
||||
state.activeRunMarker = undefined
|
||||
state.runId = undefined
|
||||
state.abortController = undefined
|
||||
state.isAborting = false
|
||||
}
|
||||
|
||||
function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) {
|
||||
if (getSession(sessionId)) return
|
||||
createSession({
|
||||
id: sessionId,
|
||||
profile: ctx.profile,
|
||||
source: 'cli',
|
||||
model: ctx.model,
|
||||
title: 'Bridge command',
|
||||
})
|
||||
}
|
||||
|
||||
function persistCommandMessage(sessionId: string, state: SessionState, content: string) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const id = addMessage({
|
||||
session_id: sessionId,
|
||||
role: 'command',
|
||||
content,
|
||||
timestamp: now,
|
||||
})
|
||||
state.messages.push({
|
||||
id: id || `command_${now}_${state.messages.length}`,
|
||||
session_id: sessionId,
|
||||
role: 'command',
|
||||
content,
|
||||
timestamp: now,
|
||||
})
|
||||
updateSessionStats(sessionId)
|
||||
}
|
||||
|
||||
function emitToSession(nsp: ReturnType<Server['of']>, socket: Socket, sessionId: string, event: string, payload: any) {
|
||||
const tagged = { ...payload, session_id: sessionId }
|
||||
nsp.to(`session:${sessionId}`).emit(event, tagged)
|
||||
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
|
||||
socket.emit(event, tagged)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user