Files
Hermes-ui/packages/server/src/services/hermes/run-chat/session-command.ts
T

658 lines
22 KiB
TypeScript
Raw Normal View History

2026-05-15 12:04:03 +08:00
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, estimateSnapshotAwareHistoryUsage, forceCompressBridgeHistory, getOrCreateSession, replaceState } from './compression'
2026-05-15 12:04:03 +08:00
import { handleAbort } from './abort'
import { calcAndUpdateUsage, contextTokensWithCachedOverhead, updateMessageContextTokenUsage } from './usage'
2026-05-23 20:14:10 +08:00
import { contentBlocksToString } from './content-blocks'
2026-05-15 12:04:03 +08:00
import type { ContentBlock, QueuedRun, SessionState } from './types'
type CommandName =
| 'usage'
| 'status'
| 'abort'
| 'queue'
| 'plan'
| 'goal'
| 'subgoal'
2026-05-15 12:04:03 +08:00
| '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
profile: string
model?: string
provider?: string
model_groups?: Array<{ provider: string; models: string[] }>
2026-05-15 12:04:03 +08:00
instructions?: string
queueId?: string
2026-05-15 12:04:03 +08:00
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
}
const COMMAND_ALIASES: Record<string, CommandName> = {
usage: 'usage',
status: 'status',
abort: 'abort',
queue: 'queue',
plan: 'plan',
goal: 'goal',
subgoal: 'subgoal',
2026-05-15 12:04:03 +08:00
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)
if (command.name !== 'plan') {
persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`)
}
2026-05-15 12:04:03 +08:00
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)
const bridgeStatus = await getBridgeSessionStatus(ctx, sessionId)
const bridgeRunning = bridgeStatus?.running === true
const isWorking = state.isWorking || bridgeRunning
const runId = state.runId || state.activeRunMarker || bridgeStatus?.currentRunId || null
2026-05-15 12:04:03 +08:00
emitCommand({
action: 'status',
terminal: !isWorking,
2026-05-15 12:04:03 +08:00
message: [
`Status: ${isWorking ? 'running' : 'idle'}`,
2026-05-15 12:04:03 +08:00
`source: ${state.source || row?.source || 'cli'}`,
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
`model: ${ctx.model || row?.model || '-'}`,
`queue: ${state.queue.length}`,
`run: ${runId || '-'}`,
bridgeStatus ? `bridge: ${bridgeRunning ? 'running' : 'idle'}` : null,
].filter(Boolean).join(', '),
isWorking,
2026-05-15 12:04:03 +08:00
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,
bridgeStatus,
2026-05-15 12:04:03 +08:00
})
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
}
2026-05-23 20:14:10 +08:00
const queueId = `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
2026-05-15 12:04:03 +08:00
state.queue.push({
2026-05-23 20:14:10 +08:00
queue_id: queueId,
2026-05-15 12:04:03 +08:00
input: command.args,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
2026-05-15 12:04:03 +08:00
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
2026-05-23 19:51:12 +08:00
originSocketId: ctx.socket.id,
2026-05-15 12:04:03 +08:00
})
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: serializeVisibleQueuedMessages(state.queue),
2026-05-15 12:04:03 +08:00
})
emitCommand({
action: 'queue',
terminal: false,
message: `Queued message. Queue length: ${state.queue.length}.`,
queueLength: state.queue.length,
})
return
}
case 'plan': {
const bridgeCommand = `plan${command.args ? ` ${command.args}` : ''}`
let result
try {
result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile)
} catch (err) {
emitCommand({
ok: false,
action: 'plan',
terminal: !state.isWorking,
message: `Plan command failed: ${err instanceof Error ? err.message : String(err)}`,
})
return
}
if (!result.handled || !result.message) {
emitCommand({
ok: false,
action: 'plan',
terminal: !state.isWorking,
message: result.message || 'Plan command is not available.',
})
return
}
const queueId = ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
const displayCommand = `/${bridgeCommand}`
const next: QueuedRun = {
queue_id: queueId,
input: result.message,
displayInput: displayCommand,
displayRole: 'command',
storageMessage: displayCommand,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
originSocketId: ctx.socket.id,
}
if (state.isWorking) {
state.queue.push(next)
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
return
}
emitCommand({
action: 'plan',
terminal: false,
started: true,
})
ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile)
return
}
case 'goal':
case 'subgoal': {
const isGoalSet = command.name === 'goal'
&& Boolean(command.args)
&& !['status', 'pause', 'resume', 'clear', 'stop', 'done'].includes(command.args.toLowerCase())
if (state.isWorking && isGoalSet) {
emitCommand({
ok: false,
action: 'goal',
terminal: false,
message: 'Agent is running. Use /goal status, /goal pause, or /goal clear mid-run, or /abort before setting a new goal.',
})
return
}
const bridgeCommand = `${command.name}${command.args ? ` ${command.args}` : ''}`
let result
try {
result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile)
} catch (err) {
emitCommand({
ok: false,
action: command.name,
terminal: !state.isWorking,
message: `Goal command failed: ${err instanceof Error ? err.message : String(err)}`,
})
return
}
if (result.clear_goal_continuations) {
const removed = removeGoalContinuationRuns(state)
if (removed > 0) emitQueuedState(ctx, sessionId, state)
}
const kickoffPrompt = typeof result.kickoff_prompt === 'string' ? result.kickoff_prompt.trim() : ''
const bridgeStatus = result.action === 'goal_status' || result.action === 'status'
? await getBridgeSessionStatus(ctx, sessionId)
: null
const message = formatGoalStatusMessage(String(result.message || ''), bridgeStatus)
const resultAction = String(result.action || command.name)
const action = (command.name === 'goal' || command.name === 'subgoal') && resultAction === 'clear'
? `${command.name}_clear`
: resultAction
emitCommand({
action,
terminal: !state.isWorking && !kickoffPrompt,
started: Boolean(kickoffPrompt),
message,
type: result.type || 'goal',
maxTurns: result.max_turns,
bridgeStatus,
})
if (!kickoffPrompt) return
const next: QueuedRun = {
queue_id: ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: kickoffPrompt,
displayInput: null,
storageMessage: kickoffPrompt,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
originSocketId: ctx.socket.id,
}
if (state.isWorking) {
state.queue.push(next)
emitQueuedState(ctx, sessionId, state)
return
}
ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile)
return
}
2026-05-15 12:04:03 +08:00
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 usageEstimate = estimateSnapshotAwareHistoryUsage(sessionId, history)
const beforeContextTokens = contextTokensWithCachedOverhead(state, usageEstimate.tokenCount)
2026-05-15 12:04:03 +08:00
emit('compression.started', {
event: 'compression.started',
message_count: usageEstimate.messageCount,
token_count: beforeContextTokens,
2026-05-15 12:04:03 +08:00
source: 'command',
})
const result = await forceCompressBridgeHistory(
sessionId,
ctx.profile,
[],
)
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
const usage = await calcAndUpdateUsage(sessionId, state, emit)
const afterContextTokens = contextTokensWithCachedOverhead(state, result.afterTokens)
2026-05-15 12:04:03 +08:00
emit('compression.completed', {
event: 'compression.completed',
compressed: result.compressed,
llmCompressed: result.llmCompressed,
totalMessages: result.beforeMessages,
resultMessages: result.resultMessages,
beforeTokens: beforeContextTokens,
2026-05-15 12:04:03 +08:00
afterTokens: result.afterTokens,
summaryTokens: result.summaryTokens,
verbatimCount: result.verbatimCount,
compressedStartIndex: result.compressedStartIndex,
contextTokens: afterContextTokens,
2026-05-15 12:04:03 +08:00
source: 'command',
})
updateMessageContextTokenUsage(sessionId, state, emit, result.afterTokens, usage)
2026-05-15 12:04:03 +08:00
emitCommand({
action: 'compress',
message: `Compression completed: ${result.beforeMessages} -> ${result.resultMessages} messages, ${beforeContextTokens} -> ${afterContextTokens} tokens.`,
2026-05-15 12:04:03 +08:00
beforeMessages: result.beforeMessages,
resultMessages: result.resultMessages,
beforeTokens: beforeContextTokens,
afterTokens: afterContextTokens,
messageBeforeTokens: result.beforeTokens,
messageAfterTokens: result.afterTokens,
2026-05-15 12:04:03 +08:00
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', state.profile).catch((err) => {
2026-05-15 12:04:03 +08:00
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
})
}
await ctx.bridge.destroy(sessionId, state.profile).catch((err) => {
2026-05-15 12:04:03 +08:00
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.bridgePendingToolCallMarkup = undefined
2026-05-15 12:04:03 +08:00
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.bridgePendingToolCallMarkup = undefined
2026-05-15 12:04:03 +08:00
state.bridgeCompressionResults = undefined
state.responseRun = undefined
state.activeRunMarker = undefined
state.runId = undefined
state.abortController = undefined
state.isAborting = false
}
function removeGoalContinuationRuns(state: SessionState): number {
const before = state.queue.length
state.queue = state.queue.filter(item => !item.goalContinuation)
return before - state.queue.length
}
function emitQueuedState(ctx: SessionCommandContext, sessionId: string, state: SessionState) {
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
}
function serializeVisibleQueuedMessages(queue: QueuedRun[]) {
return queue.filter(item => item.displayInput !== null).map(item => ({
id: item.queue_id,
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
content: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
}))
}
type BridgeSessionStatus = {
exists: boolean
running: boolean
currentRunId: string | null
messageCount: number
}
async function getBridgeSessionStatus(ctx: SessionCommandContext, sessionId: string): Promise<BridgeSessionStatus | null> {
try {
const raw = await ctx.bridge.status(sessionId, ctx.profile) as Record<string, unknown>
return {
exists: raw.exists === true,
running: raw.running === true,
currentRunId: typeof raw.current_run_id === 'string' && raw.current_run_id.trim()
? raw.current_run_id
: null,
messageCount: typeof raw.message_count === 'number' && Number.isFinite(raw.message_count)
? raw.message_count
: 0,
}
} catch (err) {
logger.debug({ err, sessionId }, '[chat-run-socket] bridge status lookup failed')
return null
}
}
function formatGoalStatusMessage(message: string, bridgeStatus: BridgeSessionStatus | null): string {
if (!bridgeStatus) return message
const lines = [message]
if (bridgeStatus.running) {
const progress = parseGoalTurnProgress(message)
lines.push(progress
? `Current turn: ${Math.min(progress.used + 1, progress.max)}/${progress.max} running (completed turns: ${progress.used}/${progress.max}; count updates after the judge).`
: 'Current turn: running (turn count updates after the judge).')
}
lines.push(`Run: ${bridgeStatus.running ? 'running' : 'idle'}${bridgeStatus.currentRunId ? ` (${bridgeStatus.currentRunId})` : ''}`)
return lines.filter(Boolean).join('\n')
}
function parseGoalTurnProgress(message: string): { used: number; max: number } | null {
const match = message.match(/\b(\d+)\s*\/\s*(\d+)\s+turns\b/i)
if (!match) return null
const used = Number(match[1])
const max = Number(match[2])
if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return null
return { used, max }
}
2026-05-15 12:04:03 +08:00
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)
}
}