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
+2 -13
View File
@@ -1,7 +1,6 @@
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import * as hermesCli from '../services/hermes/hermes-cli'
import { getGatewayManagerInstance } from '../services/gateway-bootstrap'
declare const __APP_VERSION__: string
@@ -69,21 +68,11 @@ export function startVersionCheck(): void {
export async function healthCheck(ctx: any) {
const raw = await hermesCli.getVersion()
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
let gatewayOk = false
try {
const mgr = getGatewayManagerInstance()
const upstream = mgr?.getUpstream()
if (!upstream) {
throw new Error('GatewayManager not initialized')
}
const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) })
gatewayOk = res.ok
} catch { }
ctx.body = {
status: gatewayOk ? 'ok' : 'error',
status: 'ok',
platform: 'hermes-agent',
version: hermesVersion,
gateway: gatewayOk ? 'running' : 'stopped',
gateway: 'running',
webui_version: LOCAL_VERSION,
webui_latest: cachedLatestVersion,
webui_update_available: Boolean(LOCAL_VERSION && cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION),
@@ -1,6 +1,7 @@
import { readFile } from 'fs/promises'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileName } from '../../services/hermes/hermes-profile'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
import { restartGateway } from '../../services/hermes/hermes-cli'
import { saveEnvValue } from '../../services/config-helpers'
import { logger } from '../../services/logger'
import { safeFileStore } from '../../services/safe-file-store'
@@ -78,6 +79,15 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>): Re
return target
}
async function destroyBridgeProfile(profile: string): Promise<void> {
try {
const result = await new AgentBridgeClient().destroyProfile(profile)
logger.info('[config] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', profile, result.destroyed)
} catch (err) {
logger.warn(err, '[config] failed to destroy bridge sessions after gateway restart profile=%s', profile)
}
}
async function readEnvPlatforms(): Promise<Record<string, any>> {
try {
const raw = await readFile(envPath(), 'utf-8')
@@ -127,7 +137,7 @@ export async function getConfig(ctx: any) {
}
export async function updateConfig(ctx: any) {
const { section, values } = ctx.request.body as { section: string; values: Record<string, any> }
const { section, values, restart } = ctx.request.body as { section: string; values: Record<string, any>; restart?: boolean }
if (!section || !values) {
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
}
@@ -142,17 +152,19 @@ export async function updateConfig(ctx: any) {
},
})
// 使用 GatewayManager 重启平台网关
if (PLATFORM_SECTIONS.has(section)) {
const mgr = getGatewayManagerInstance()
if (mgr) {
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
// Platform adapters still run through Hermes gateway; restart it so channel
// config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions.
if (restart !== false && PLATFORM_SECTIONS.has(section)) {
const activeProfile = getActiveProfileName()
try {
const restartResult = await restartGateway()
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%s', section, activeProfile, restartResult)
await destroyBridgeProfile(activeProfile)
} catch (err) {
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
}
@@ -208,16 +220,18 @@ export async function updateCredentials(ctx: any) {
},
})
// 使用 GatewayManager 重启平台网关
const mgr = getGatewayManagerInstance()
if (mgr) {
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
// Platform adapters still run through Hermes gateway; restart it so channel
// credentials are applied, then refresh bridge sessions.
const activeProfile = getActiveProfileName()
try {
const restartResult = await restartGateway()
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%s', platform, activeProfile, restartResult)
await destroyBridgeProfile(activeProfile)
} catch (err) {
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
ctx.body = { success: true }
@@ -250,7 +250,14 @@ export async function readRun(ctx: Context) {
}
// Prevent path traversal
if (jobId.includes('..') || fileName.includes('..') || jobId.includes('/') || fileName.includes('/')) {
if (
jobId.includes('..')
|| fileName.includes('..')
|| jobId.includes('/')
|| fileName.includes('/')
|| jobId.includes('\\')
|| fileName.includes('\\')
) {
ctx.status = 400
ctx.body = { error: 'Invalid path' }
return
@@ -1,33 +0,0 @@
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
export async function list(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const gateways = await mgr.listAll()
ctx.body = { gateways }
}
export async function start(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
const status = await mgr.start(ctx.params.name)
ctx.body = { success: true, gateway: status }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function stop(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
await mgr.stop(ctx.params.name)
ctx.body = { success: true }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function health(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const status = await mgr.detectStatus(ctx.params.name)
ctx.body = { gateway: status }
}
+260 -85
View File
@@ -1,135 +1,310 @@
import type { Context } from 'koa'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { execFile } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { getHermesBin } from '../../services/hermes/hermes-path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
function getUpstream(profile: string): string {
const mgr = getGatewayManagerInstance()
if (!mgr) {
throw new Error('GatewayManager not initialized')
}
return mgr.getUpstream(profile)
}
const execFileAsync = promisify(execFile)
const TIMEOUT_MS = 60_000
function getApiKey(profile: string): string | null {
const mgr = getGatewayManagerInstance()
return mgr?.getApiKey(profile) ?? null
}
type JobRecord = Record<string, any>
function resolveProfile(ctx: Context): string {
// Use header/query from request first, then fall back to authoritative source
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
return requestedProfile || getActiveProfileName()
}
if (requestedProfile) {
return requestedProfile
}
function resolveProfileDir(profile: string): string {
return getProfileDir(profile || 'default')
}
// Fallback: read from authoritative source (active_profile file)
try {
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
return getActiveProfileName()
} catch {
return 'default'
function getJobsPath(profile: string): string {
return join(resolveProfileDir(profile), 'cron', 'jobs.json')
}
function normalizeJob(job: JobRecord): JobRecord {
const id = job.job_id || job.id
const skills = Array.isArray(job.skills)
? job.skills
: (job.skill ? [job.skill] : [])
return {
...job,
id,
job_id: id,
skills,
skill: job.skill ?? skills[0] ?? null,
model: job.model ?? null,
provider: job.provider ?? null,
base_url: job.base_url ?? null,
script: job.script ?? null,
schedule_display: job.schedule_display ?? job.schedule?.display ?? job.schedule?.expr ?? '',
repeat: job.repeat ?? { times: null, completed: 0 },
enabled: job.enabled ?? true,
state: job.state ?? ((job.enabled ?? true) ? 'scheduled' : 'paused'),
paused_at: job.paused_at ?? null,
paused_reason: job.paused_reason ?? null,
created_at: job.created_at ?? '',
next_run_at: job.next_run_at ?? null,
last_run_at: job.last_run_at ?? null,
last_status: job.last_status ?? null,
last_error: job.last_error ?? null,
deliver: job.deliver ?? 'local',
origin: job.origin ?? null,
last_delivery_error: job.last_delivery_error ?? null,
}
}
function buildHeaders(profile: string): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const apiKey = getApiKey(profile)
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
return headers
function readJobs(profile: string, includeDisabled = true): JobRecord[] {
const jobsPath = getJobsPath(profile)
if (!existsSync(jobsPath)) return []
const parsed = JSON.parse(readFileSync(jobsPath, 'utf-8'))
const rawJobs = Array.isArray(parsed) ? parsed : parsed?.jobs
const jobs = Array.isArray(rawJobs) ? rawJobs.map(normalizeJob) : []
if (includeDisabled) return jobs
return jobs.filter((job) => job.enabled !== false)
}
const TIMEOUT_MS = 30_000
function findJob(profile: string, jobId: string): JobRecord | null {
return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null
}
async function readUpstreamError(res: Response): Promise<unknown> {
const contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
return await res.json()
} catch {
// Fall through to a stable error shape below.
function boolQuery(value: unknown, defaultValue: boolean): boolean {
if (value == null) return defaultValue
const text = String(value).toLowerCase()
return text === '1' || text === 'true' || text === 'yes'
}
function getBody(ctx: Context): Record<string, any> {
return (ctx.request.body && typeof ctx.request.body === 'object')
? ctx.request.body as Record<string, any>
: {}
}
function getRepeatValue(repeat: unknown): number | null {
if (repeat == null || repeat === '') return null
if (typeof repeat === 'number' && Number.isFinite(repeat)) return repeat
if (typeof repeat === 'object') {
const times = (repeat as any).times
if (typeof times === 'number' && Number.isFinite(times)) return times
if (typeof times === 'string' && times.trim()) {
const parsed = Number(times)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
const text = await res.text().catch(() => '')
return { error: { message: text || `Upstream error: ${res.status} ${res.statusText}` } }
const parsed = Number(repeat)
return Number.isFinite(parsed) ? parsed : null
}
async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise<void> {
const profile = resolveProfile(ctx)
let upstream: string
try {
upstream = getUpstream(profile)
} catch (e: any) {
ctx.status = 503
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } }
return
function hasRepeatField(body: Record<string, any>): boolean {
return Object.prototype.hasOwnProperty.call(body, 'repeat')
}
function getSkills(body: Record<string, any>): string[] | null {
if (Array.isArray(body.skills)) {
return body.skills.map((skill) => String(skill || '').trim()).filter(Boolean)
}
const params = new URLSearchParams(ctx.search || '')
params.delete('token')
const search = params.toString()
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
if (typeof body.skill === 'string') {
const skill = body.skill.trim()
return skill ? [skill] : []
}
return null
}
const headers = buildHeaders(profile)
const body = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'
? JSON.stringify(ctx.request.body || {})
: undefined
let res: Response
async function runHermesCron(profile: string, args: string[]): Promise<void> {
const profileDir = resolveProfileDir(profile)
try {
res = await fetch(url, {
method: method || ctx.req.method,
headers,
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
await execFileAsync(getHermesBin(), args, {
cwd: process.cwd(),
env: { ...process.env, HERMES_HOME: profileDir },
timeout: TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true,
})
} catch (e: any) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${e.message}` } }
return
} catch (error: any) {
const stderr = String(error?.stderr || '').trim()
const stdout = String(error?.stdout || '').trim()
throw new Error(stderr || stdout || error?.message || 'Hermes cron command failed')
}
}
if (!res.ok) {
ctx.status = res.status
ctx.set('Content-Type', 'application/json')
ctx.body = await readUpstreamError(res)
return
}
function sendJobNotFound(ctx: Context): void {
ctx.status = 404
ctx.body = { error: { message: 'Job not found' } }
}
ctx.status = res.status
ctx.set('Content-Type', res.headers.get('content-type') || 'application/json')
ctx.body = await res.json()
function sendCommandError(ctx: Context, error: any): void {
ctx.status = 500
ctx.body = { error: { message: error?.message || 'Hermes cron command failed' } }
}
function findCreatedJob(beforeJobs: JobRecord[], afterJobs: JobRecord[]): JobRecord | null {
const beforeIds = new Set(beforeJobs.map((job) => job.job_id || job.id))
const created = afterJobs.find((job) => !beforeIds.has(job.job_id || job.id))
if (created) return created
return [...afterJobs].sort((a, b) => {
const aTime = Date.parse(a.created_at || '') || 0
const bTime = Date.parse(b.created_at || '') || 0
return bTime - aTime
})[0] ?? null
}
export async function list(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
const profile = resolveProfile(ctx)
const includeDisabled = boolQuery(ctx.query.include_disabled, false)
ctx.body = { jobs: readJobs(profile, includeDisabled) }
}
export async function get(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
}
export async function create(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
const profile = resolveProfile(ctx)
const body = getBody(ctx)
const schedule = String(body.schedule || body.schedule_display || '').trim()
const prompt = String(body.prompt || '').trim()
if (!schedule) {
ctx.status = 400
ctx.body = { error: { message: 'Schedule is required' } }
return
}
const beforeJobs = readJobs(profile, true)
const args = ['cron', 'create']
const name = String(body.name || '').trim()
if (name) args.push('--name', name)
if (body.deliver != null && String(body.deliver).trim()) args.push('--deliver', String(body.deliver).trim())
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
for (const skill of skills || []) args.push('--skill', skill)
if (body.script != null && String(body.script).trim()) args.push('--script', String(body.script).trim())
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
args.push(schedule)
if (prompt) args.push(prompt)
try {
await runHermesCron(profile, args)
const job = findCreatedJob(beforeJobs, readJobs(profile, true))
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function update(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
const body = getBody(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
const args = ['cron', 'edit', ctx.params.id]
if (body.schedule != null || body.schedule_display != null) {
args.push('--schedule', String(body.schedule ?? body.schedule_display))
}
if (body.prompt != null) args.push('--prompt', String(body.prompt))
if (body.name != null) args.push('--name', String(body.name))
if (body.deliver != null) args.push('--deliver', String(body.deliver))
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
if (skills) {
if (skills.length === 0) {
args.push('--clear-skills')
} else {
for (const skill of skills) args.push('--skill', skill)
}
}
if (body.script != null) args.push('--script', String(body.script))
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
if (body.no_agent === false) args.push('--agent')
try {
await runHermesCron(profile, args)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function remove(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'remove', ctx.params.id])
ctx.body = { ok: true }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function pause(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/pause`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'pause', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function resume(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/resume`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'resume', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function run(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/run`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'run', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
import { resolve, normalize } from 'path'
import { homedir } from 'os'
import * as kanbanCli from '../../services/hermes/hermes-kanban'
import { isPathWithin } from '../../services/hermes/hermes-path'
import {
searchSessionSummariesWithProfile,
getSessionDetailFromDbWithProfile,
@@ -596,7 +597,7 @@ export async function readArtifact(ctx: Context) {
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
const resolved = resolve(normalize(filePath))
if (!resolved.startsWith(kanbanDir)) {
if (!isPathWithin(resolved, kanbanDir)) {
ctx.status = 403
ctx.body = { error: 'Path must be within kanban workspaces' }
return
@@ -0,0 +1,223 @@
import type { Context } from 'koa'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, extname, isAbsolute, join, resolve } from 'path'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { config } from '../../config'
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
const XAI_VIDEO_MODEL = 'grok-imagine-video'
const MAX_IMAGE_BYTES = 25 * 1024 * 1024
const DEFAULT_POLL_INTERVAL_MS = 5000
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
type AuthJson = {
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
}
function readJsonFile(path: string): any {
try {
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return null
}
}
function resolveXaiToken(): { token: string; source: string } | null {
const envToken = String(process.env.XAI_API_KEY || '').trim()
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
const auth = readJsonFile(getActiveAuthPath()) as AuthJson | null
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
const pool = auth?.credential_pool?.['xai-oauth']
if (Array.isArray(pool)) {
const poolToken = String(pool.find(entry => entry?.access_token)?.access_token || '').trim()
if (poolToken) return { token: poolToken, source: 'xai-oauth' }
}
return null
}
function mimeFromPath(path: string): string | null {
const ext = extname(path).toLowerCase()
if (ext === '.png') return 'image/png'
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.webp') return 'image/webp'
return null
}
function mimeFromMagic(buffer: Buffer): string | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'
return null
}
function imagePathToDataUri(imagePath: string): string {
const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath)
const image = readFileSync(resolvedPath)
if (image.length > MAX_IMAGE_BYTES) {
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
err.status = 413
throw err
}
const mime = mimeFromMagic(image) || mimeFromPath(resolvedPath)
if (!mime) {
const err: any = new Error('unsupported image type; use png, jpeg, or webp')
err.status = 400
throw err
}
return `data:${mime};base64,${image.toString('base64')}`
}
function normalizeImageInput(body: any): string {
const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : ''
if (imageUrl) return imageUrl
const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : ''
if (imageBase64) {
if (imageBase64.startsWith('data:image/')) return imageBase64
const mime = typeof body.mime_type === 'string' ? body.mime_type.trim() : ''
if (!mime.startsWith('image/')) {
const err: any = new Error('mime_type is required when image_base64 is not a data URI')
err.status = 400
throw err
}
return `data:${mime};base64,${imageBase64}`
}
const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : ''
if (!imagePath) {
const err: any = new Error('image_path, image_url, or image_base64 is required')
err.status = 400
throw err
}
if (!existsSync(isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath))) {
const err: any = new Error('image_path does not exist')
err.status = 404
throw err
}
return imagePathToDataUri(imagePath)
}
function normalizeDuration(value: unknown): number {
const duration = Number(value || 8)
if (!Number.isFinite(duration) || duration < 1 || duration > 15) {
const err: any = new Error('duration must be between 1 and 15 seconds')
err.status = 400
throw err
}
return duration
}
export function defaultMediaOutputPath(requestId: string, now = new Date()): string {
const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `video_${now.getTime()}`
return join(config.appHome, 'media', `${safeRequestId}.mp4`)
}
async function requestXaiJson(url: string, token: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...(init.headers || {}),
},
})
const text = await res.text()
let data: any = null
try { data = text ? JSON.parse(text) : null } catch {}
if (!res.ok) {
const detail = data?.error?.message || data?.error || text || res.statusText
const err: any = new Error(`xAI request failed: ${res.status} ${detail}`)
err.status = res.status === 401 || res.status === 403 ? 502 : 502
throw err
}
return data
}
async function downloadVideo(url: string, outputPath: string): Promise<void> {
const res = await fetch(url)
if (!res.ok) throw new Error(`failed to download generated video: ${res.status} ${res.statusText}`)
const arrayBuffer = await res.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, buffer)
}
export async function grokImageToVideo(ctx: Context) {
const tokenInfo = resolveXaiToken()
if (!tokenInfo) {
ctx.status = 401
ctx.body = {
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
code: 'missing_xai_token',
}
return
}
const body = ctx.request.body as any
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
if (!prompt) {
ctx.status = 400
ctx.body = { error: 'prompt is required', code: 'missing_prompt' }
return
}
try {
const image = normalizeImageInput(body)
const duration = normalizeDuration(body.duration)
const rawTimeoutMs = Number(body.timeout_ms || DEFAULT_TIMEOUT_MS)
const timeoutMs = Number.isFinite(rawTimeoutMs)
? Math.max(10000, Math.min(rawTimeoutMs, 30 * 60 * 1000))
: DEFAULT_TIMEOUT_MS
const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : ''
const started = await requestXaiJson(XAI_VIDEO_GENERATIONS_URL, tokenInfo.token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: XAI_VIDEO_MODEL,
prompt,
image: { url: image },
duration,
}),
})
const requestId = String(started?.request_id || '').trim()
if (!requestId) throw new Error('xAI response missing request_id')
const deadline = Date.now() + timeoutMs
let latest: any = null
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS))
latest = await requestXaiJson(`${XAI_VIDEO_STATUS_URL}/${encodeURIComponent(requestId)}`, tokenInfo.token)
if (latest?.status === 'done') {
const videoUrl = String(latest?.video?.url || '').trim()
const outputPath = requestedOutputPath || defaultMediaOutputPath(requestId)
if (videoUrl) await downloadVideo(videoUrl, outputPath)
ctx.body = {
request_id: requestId,
status: latest.status,
video_url: videoUrl,
output_path: outputPath,
token_source: tokenInfo.source,
}
return
}
if (latest?.status === 'expired' || latest?.status === 'failed' || latest?.status === 'error') {
ctx.status = 502
ctx.body = { request_id: requestId, status: latest.status, error: latest?.error || 'xAI video generation failed' }
return
}
}
ctx.status = 504
ctx.body = { request_id: requestId, status: latest?.status || 'pending', error: 'Timed out waiting for xAI video generation' }
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || String(err) }
}
}
@@ -1,7 +1,8 @@
import { readFile } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { join } from 'path'
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
@@ -118,6 +119,73 @@ function resolveVisibleDefault(defaultModel: string, defaultProvider: string, gr
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
}
function profileEnvPath(profile: string): string {
return join(getProfileDir(profile), '.env')
}
function profileAuthPath(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
function envReader(envContent: string) {
const envHasValue = (key: string): boolean => {
if (!key) return false
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
}
const envGetValue = (key: string): string => {
if (!key) return ''
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return match?.[1]?.trim() || ''
}
return { envHasValue, envGetValue }
}
function providerKeyForCustom(name: string): string {
return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
}
function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
const byProvider = new Map<string, AvailableGroup>()
for (const group of groups) {
const existing = byProvider.get(group.provider)
if (!existing) {
byProvider.set(group.provider, {
...group,
models: [...new Set(group.models)],
available_models: [...new Set(group.available_models || group.models)],
model_meta: group.model_meta ? { ...group.model_meta } : undefined,
})
continue
}
existing.models = [...new Set([...existing.models, ...group.models])]
existing.available_models = [...new Set([...(existing.available_models || existing.models), ...(group.available_models || group.models)])]
existing.api_key = existing.api_key || group.api_key
existing.base_url = existing.base_url || group.base_url
existing.builtin = existing.builtin || group.builtin
existing.model_meta = { ...(existing.model_meta || {}), ...(group.model_meta || {}) }
if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta
}
return [...byProvider.values()]
}
type ProviderFetchCache = Map<string, Promise<string[]>>
function cachedProviderModels(
cache: ProviderFetchCache,
baseUrl: string,
apiKey: string,
freeOnly = false,
): Promise<string[]> {
const key = `${baseUrl.replace(/\/+$/, '')}\n${apiKey}\n${freeOnly ? 'free' : 'all'}`
let pending = cache.get(key)
if (!pending) {
pending = fetchProviderModels(baseUrl, apiKey, freeOnly)
cache.set(key, pending)
}
return pending
}
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
@@ -125,8 +193,244 @@ async function isCopilotAuthorized(envContent: string): Promise<boolean> {
return !!(await resolveCopilotOAuthToken(envContent))
}
async function buildAvailableForProfile(
profile: string,
fetchCache: ProviderFetchCache,
appConfig: Awaited<ReturnType<typeof readAppConfig>>,
): Promise<{
profile: string
default: string
default_provider: string
groups: AvailableGroup[]
}> {
const config = await readConfigYamlForProfile(profile)
const modelSection = config.model
let currentDefault = ''
let currentDefaultProvider = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
currentDefaultProvider = String(modelSection.provider || '').trim()
if (currentDefaultProvider === 'custom' && currentDefault) {
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
const match = cps.find(
(cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '')
&& cp.model === currentDefault,
)
if (match) currentDefaultProvider = providerKeyForCustom(String(match.name || ''))
}
} else if (typeof modelSection === 'string') {
currentDefault = modelSection.trim()
}
let envContent = ''
try { envContent = await readFile(profileEnvPath(profile), 'utf-8') } catch {}
const { envHasValue, envGetValue } = envReader(envContent)
const isOAuthAuthorized = (providerKey: string): boolean => {
try {
const authPath = profileAuthPath(profile)
if (!existsSync(authPath)) return false
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
const provider = auth.providers?.[providerKey]
const pool = auth.credential_pool?.[providerKey]
return !!(
provider?.tokens?.access_token ||
provider?.access_token ||
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
)
} catch { return false }
}
let copilotLiveModels: CopilotModelMeta[] | null = null
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
if (copilotLiveModels !== null) return copilotLiveModels
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
catch { copilotLiveModels = [] }
return copilotLiveModels
}
const groups: AvailableGroup[] = []
const seenProviders = new Set<string>()
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
if (seenProviders.has(provider)) return
seenProviders.add(provider)
const availableModels = [...new Set(models)]
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
}
const copilotEnabled = appConfig.copilotEnabled === true
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
currentDefault = ''
currentDefaultProvider = ''
}
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
if (!envMapping.api_key_env) {
if (providerKey === 'copilot') {
if (!copilotEnabled) continue
if (!(await isCopilotAuthorized(envContent))) continue
} else if (!isOAuthAuthorized(providerKey)) {
continue
}
}
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
const label = preset?.label || providerKey.replace(/^custom:/, '')
let baseUrl = preset?.base_url || ''
if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) {
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
}
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
let modelMeta: Record<string, ModelMeta> | undefined
if (providerKey === 'copilot') {
const live = await getCopilotLive()
if (live.length > 0) {
modelsList = live.map((m) => m.id)
modelMeta = {}
for (const m of live) {
if (m.preview || m.disabled) {
modelMeta[m.id] = {
...(m.preview ? { preview: true } : {}),
...(m.disabled ? { disabled: true } : {}),
}
}
}
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
}
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
if (envMapping.api_key_env) {
const apiKey = envGetValue(envMapping.api_key_env)
if (apiKey) {
const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter')
if (fetched.length > 0) modelsList = fetched
}
}
}
if (modelsList.length > 0) {
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
}
}
const customProviders = Array.isArray(config.custom_providers)
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
: []
const customFetches = await Promise.allSettled(
customProviders.map(async cp => {
if (!cp.base_url) return null
const providerKey = providerKeyForCustom(cp.name)
const baseUrl = cp.base_url.replace(/\/+$/, '')
let models = [cp.model].filter(Boolean)
if (cp.api_key) {
const fetched = await cachedProviderModels(fetchCache, baseUrl, cp.api_key)
if (fetched.length > 0) models = [...new Set([...models, ...fetched])]
}
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
}),
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value?.models.length) {
const { providerKey, label, base_url, models, api_key } = result.value
addGroup(providerKey, label, base_url, models, api_key)
}
}
if (groups.length === 0) {
const fallback = buildModelGroups(config)
for (const group of fallback.groups) {
const models = group.models.map(model => model.id)
if (models.length) addGroup(group.provider, group.provider, '', models, '')
}
currentDefault = currentDefault || fallback.default
}
for (const g of groups) {
g.models = Array.from(new Set(g.models))
g.available_models = Array.from(new Set(g.available_models || g.models))
}
return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups }
}
export async function getAvailable(ctx: any) {
try {
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: ''
if (!requestedProfile) {
const appConfig = await readAppConfig()
const modelAliases = normalizeAliases(appConfig.modelAliases)
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
const fetchCache: ProviderFetchCache = new Map()
const profileResults = await Promise.all(
listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
)
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
const activeProfile = getActiveProfileName()
const defaultProfile = profileResults.find(result => result.profile === activeProfile && (result.default || result.default_provider))
|| profileResults.find(result => result.default && result.default_provider)
|| profileResults.find(result => result.default)
const visibleDefault = resolveVisibleDefault(
defaultProfile?.default || '',
defaultProfile?.default_provider || '',
visibleGroups,
)
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
provider: p.value,
label: p.label,
base_url: p.base_url,
models: p.models,
api_key: '',
}))
ctx.body = {
default: visibleDefault.defaultModel,
default_provider: visibleDefault.defaultProvider,
groups: visibleGroups,
allProviders: applyModelAliases(allProvidersBase, modelAliases),
model_aliases: modelAliases,
model_visibility: modelVisibility,
profiles: profileResults.map(result => ({
profile: result.profile,
default: result.default,
default_provider: result.default_provider,
groups: applyModelVisibility(applyModelAliases(result.groups, modelAliases), modelVisibility),
})),
}
return
}
const appConfigForProfile = await readAppConfig()
const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases)
const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility)
const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile)
const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile)
const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile)
const visibleProfileDefault = resolveVisibleDefault(profileResult.default, profileResult.default_provider, visibleProfileGroups)
ctx.body = {
default: visibleProfileDefault.defaultModel,
default_provider: visibleProfileDefault.defaultProvider,
groups: visibleProfileGroups,
allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => ({
provider: p.value,
label: p.label,
base_url: p.base_url,
models: p.models,
api_key: '',
})), modelAliasesForProfile),
model_aliases: modelAliasesForProfile,
model_visibility: modelVisibilityForProfile,
profiles: [{
profile: profileResult.profile,
default: profileResult.default,
default_provider: profileResult.default_provider,
groups: visibleProfileGroups,
}],
}
return
const config = await readConfigYaml()
const modelSection = config.model
let currentDefault = ''
@@ -239,16 +543,16 @@ export async function getAvailable(ctx: any) {
const live = await getCopilotLive()
if (live.length > 0) {
modelsList = live.map((m) => m.id)
modelMeta = {}
const nextModelMeta: Record<string, ModelMeta> = {}
for (const m of live) {
if (m.preview || m.disabled) {
modelMeta[m.id] = {
nextModelMeta[m.id] = {
...(m.preview ? { preview: true } : {}),
...(m.disabled ? { disabled: true } : {}),
}
}
}
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined
}
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
// OpenRouter and local CLIProxyAPI expose dynamic OpenAI-compatible /models catalogs.
@@ -286,8 +590,9 @@ export async function getAvailable(ctx: any) {
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value) {
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = result.value as any
const value = (result as { value?: any }).value
if (value) {
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = value
addGroup(providerKey, label, base_url, models, cpApiKey, cpBuiltin)
}
}
@@ -4,7 +4,7 @@ import { basename, join } from 'path'
import { tmpdir } from 'os'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { SessionDeleter } from '../../services/hermes/session-deleter'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
@@ -42,7 +42,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
name: 'default',
active: activeProfileName === 'default',
model: '—',
gateway: 'stopped',
alias: '',
}]
const profilesDir = join(base, 'profiles')
@@ -56,7 +55,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
name,
active: name === activeProfileName,
model: '—',
gateway: 'stopped',
alias: '',
})
}
@@ -186,12 +184,6 @@ export async function create(ctx: any) {
}
}
const mgr = getGatewayManagerInstance()
if (mgr) {
try { await mgr.start(name) } catch (err: any) {
logger.error(err, 'Failed to start gateway for profile "%s"', name)
}
}
ctx.body = {
success: true,
message: output.trim(),
@@ -223,8 +215,12 @@ export async function remove(ctx: any) {
return
}
try {
const mgr = getGatewayManagerInstance()
if (mgr) { try { await mgr.stop(name) } catch { } }
try {
const result = await new AgentBridgeClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name)
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
@@ -296,10 +292,6 @@ export async function switchProfile(ctx: any) {
return
}
// Update GatewayManager to match the authoritative source
const mgr = getGatewayManagerInstance()
if (mgr) { mgr.setActiveProfile(name) }
// Destroy all bridge sessions so they get recreated with the new profile config
try {
const { AgentBridgeClient } = await import('../../services/hermes/agent-bridge')
@@ -1,18 +1,19 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import {
listSessions as localListSessions,
searchSessions as localSearchSessions,
getSession as localGetSession,
getSessionDetail as localGetSessionDetail,
deleteSession as localDeleteSession,
renameSession as localRenameSession,
} from '../../db/hermes/session-store'
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getGroupChatServer } from '../../routes/hermes/group-chat'
import { logger } from '../../services/logger'
import type { ConversationSummary } from '../../services/hermes/conversations'
@@ -31,6 +32,43 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
return filterPendingDeletedSessions(items)
}
interface HermesDeleteResult {
attempted: boolean
deleted: boolean
profile?: string
error?: string
}
function hasProfileOnDisk(profile: string): boolean {
return listProfileNamesFromDisk().includes(profile || 'default')
}
async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise<HermesDeleteResult> {
const targetProfile = profile || 'default'
if (!hasProfileOnDisk(targetProfile)) {
return { attempted: false, deleted: false, profile: targetProfile }
}
try {
const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile)
if (!hermesSession) {
return { attempted: false, deleted: false, profile: targetProfile }
}
const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile)
return {
attempted: true,
deleted,
profile: targetProfile,
error: deleted ? undefined : 'Failed to delete Hermes session',
}
} catch (err: any) {
const message = err?.message || 'Failed to inspect Hermes session'
logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped')
return { attempted: true, deleted: false, profile: targetProfile, error: message }
}
}
export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
@@ -98,11 +136,19 @@ export async function getConversationMessages(ctx: any) {
export async function list(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName()
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: undefined
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = localListSessions(profile, source, effectiveLimit)
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source === 'api_server' || s.source === 'cli')) }
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
sessions: filterPendingDeletedSessions(allSessions.filter(s =>
(s.source === 'api_server' || s.source === 'cli') &&
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
)),
}
}
/**
@@ -171,14 +217,17 @@ export async function getHermesSession(ctx: any) {
export async function remove(ctx: any) {
const sessionId = ctx.params.id
const ok = localDeleteSession(sessionId)
if (!ok) {
const existing = localGetSession(sessionId)
const hermesProfile = existing?.profile || getActiveProfileName()
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
const localDeleted = existing ? localDeleteSession(sessionId) : true
if (!localDeleted) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
deleteUsage(sessionId)
ctx.body = { ok: true }
ctx.body = { ok: true, deleted: Boolean(existing), hermes }
}
export async function batchRemove(ctx: any) {
@@ -199,10 +248,22 @@ export async function batchRemove(ctx: any) {
const results = {
deleted: 0,
failed: 0,
errors: [] as Array<{ id: string; error: string }>
hermesDeleted: 0,
hermesFailed: 0,
errors: [] as Array<{ id: string; error: string }>,
hermesErrors: [] as Array<{ id: string; profile?: string; error: string }>
}
for (const id of validIds) {
const existing = localGetSession(id)
const hermes = await deleteHermesSessionIfPresent(id, existing?.profile)
if (hermes.deleted) {
results.hermesDeleted++
} else if (hermes.attempted && hermes.error) {
results.hermesFailed++
results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error })
}
const ok = localDeleteSession(id)
if (ok) {
deleteUsage(id)
@@ -292,7 +353,9 @@ export async function setModel(ctx: any) {
export async function contextLength(ctx: any) {
const profile = (ctx.query.profile as string) || undefined
ctx.body = { context_length: getModelContextLength(profile) }
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
}
export async function usageStats(ctx: any) {
@@ -365,7 +428,7 @@ export async function listWorkspaceFolders(ctx: any) {
// Security: prevent path traversal
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
if (!fullPath.startsWith(resolve(WORKSPACE_BASE))) {
if (!isPathWithin(fullPath, WORKSPACE_BASE)) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
@@ -437,10 +500,9 @@ export async function exportSession(ctx: any) {
}
async function compressSession(session: any) {
const mgr = getGatewayManagerInstance()
const profile = getActiveProfileName()
const upstream = mgr ? mgr.getUpstream(profile).replace(/\/$/, '') : ''
const apiKey = mgr ? mgr.getApiKey(profile) || undefined : undefined
const profile = session.profile || getActiveProfileName()
const upstream = ''
const apiKey = undefined
const messages = (session.messages || []).map((m: any) => ({
role: m.role,
content: m.content || '',
@@ -450,7 +512,11 @@ async function compressSession(session: any) {
reasoning_content: m.reasoning,
}))
return exportCompressor.compress(messages, upstream, apiKey, session.id, profile)
return exportCompressor.compress(messages, upstream, apiKey, session.id, {
profile,
model: session.model,
provider: session.provider,
})
}
function serializeAsText(title: string | null, messages: any[]): string {
@@ -6,6 +6,7 @@ import {
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
} from '../../services/config-helpers'
import { pinSkill } from '../../services/hermes/hermes-cli'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
@@ -301,7 +302,7 @@ export async function readFile_(ctx: any) {
realPath = filePath.slice(5)
}
const fullPath = resolve(join(hd, 'skills', realPath))
if (!fullPath.startsWith(join(hd, 'skills'))) {
if (!isPathWithin(fullPath, join(hd, 'skills'))) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
@@ -13,9 +13,11 @@ const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
const XAI_REDIRECT_HOST = '127.0.0.1'
const XAI_CALLBACK_BIND_HOST = process.env.HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST?.trim() || XAI_REDIRECT_HOST
const XAI_REDIRECT_PORT = 56121
const XAI_REDIRECT_PATH = '/callback'
const POLL_MAX_DURATION = 15 * 60 * 1000
const XAI_DEFAULT_MODEL = 'grok-4.3'
interface XaiSession {
id: string
@@ -41,6 +43,18 @@ interface AuthJson {
const sessions = new Map<string, XaiSession>()
export function applyXaiOAuthDefaultModel(config: Record<string, any>): Record<string, any> {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
const currentDefault = String(config.model.default || '').trim()
config.model.provider = 'xai-oauth'
config.model.default = currentDefault.toLowerCase().startsWith('grok-')
? currentDefault
: XAI_DEFAULT_MODEL
delete config.model.base_url
delete config.model.api_key
return config
}
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => {
@@ -181,14 +195,7 @@ async function saveTokens(session: XaiSession, tokenData: any) {
}]
saveAuthJson(authPath, auth)
await updateConfigYaml((config) => {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
config.model.provider = 'xai-oauth'
config.model.default = config.model.default || 'grok-4.3'
delete config.model.base_url
delete config.model.api_key
return config
})
await updateConfigYaml(applyXaiOAuthDefaultModel)
}
async function exchangeCode(session: XaiSession, code: string) {
@@ -257,7 +264,7 @@ function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_POR
reject(err)
}
})
server.listen(preferredPort, XAI_REDIRECT_HOST, () => {
server.listen(preferredPort, XAI_CALLBACK_BIND_HOST, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : preferredPort
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
@@ -65,6 +65,7 @@ function runNpm(args: string[], options: { timeout?: number } = {}) {
timeout: options.timeout,
stdio: ['pipe', 'pipe', 'pipe'],
env: getCurrentNodeEnv(),
windowsHide: true,
}).trim()
}
@@ -1,3 +1,4 @@
import { join } from 'path'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import type {
ConversationDetail,
@@ -62,7 +63,7 @@ interface ConversationSessionRow {
}
function conversationDbPath(): string {
return `${getActiveProfileDir()}/state.db`
return join(getActiveProfileDir(), 'state.db')
}
function normalizeNumber(value: unknown, fallback = 0): number {
+9
View File
@@ -118,6 +118,7 @@ export const GC_ROOMS_SCHEMA: Record<string, string> = {
maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000',
tailMessageCount: 'INTEGER NOT NULL DEFAULT 10',
totalTokens: 'INTEGER NOT NULL DEFAULT 0',
sessionSeed: "TEXT NOT NULL DEFAULT '0'",
}
export const GC_MESSAGES_TABLE = 'gc_messages'
@@ -129,6 +130,14 @@ export const GC_MESSAGES_SCHEMA: Record<string, string> = {
senderName: 'TEXT NOT NULL',
content: 'TEXT NOT NULL',
timestamp: 'INTEGER NOT NULL',
role: "TEXT NOT NULL DEFAULT 'user'",
tool_call_id: 'TEXT',
tool_calls: 'TEXT',
tool_name: 'TEXT',
finish_reason: 'TEXT',
reasoning: 'TEXT',
reasoning_details: 'TEXT',
reasoning_content: 'TEXT',
}
export const GC_ROOM_AGENTS_TABLE = 'gc_room_agents'
@@ -219,9 +219,10 @@ export function renameSession(id: string, title: string): boolean {
return result.changes > 0
}
export function listSessions(profile: string, source?: string, limit = 2000): HermesSessionRow[] {
export function listSessions(profile?: string, source?: string, limit = 2000): HermesSessionRow[] {
if (!isSqliteAvailable()) return []
const db = getDb()!
const profileFilter = profile?.trim()
// Use a subquery to generate preview from first user message if not set
const sql = `
@@ -239,13 +240,17 @@ export function listSessions(profile: string, source?: string, limit = 2000): He
''
) AS preview
FROM ${SESSIONS_TABLE} s
WHERE s.profile = ?
WHERE 1 = 1
${profileFilter ? 'AND s.profile = ?' : ''}
${source ? 'AND s.source = ?' : ''}
ORDER BY s.last_active DESC
LIMIT ?
`
const params: any[] = [profile]
const params: any[] = []
if (profileFilter) {
params.push(profileFilter)
}
if (source) {
params.push(source)
}
+7 -6
View File
@@ -1,4 +1,5 @@
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
import { join } from 'path'
import type { LocalUsageStats } from './usage-store'
const SQLITE_AVAILABLE = (() => {
@@ -66,7 +67,7 @@ interface HermesSessionInternalRow extends HermesSessionRow {
}
function sessionDbPath(): string {
return `${getActiveProfileDir()}/state.db`
return join(getActiveProfileDir(), 'state.db')
}
function normalizeNumber(value: unknown, fallback = 0): number {
@@ -643,7 +644,7 @@ export async function getSessionDetailFromDb(sessionId: string): Promise<HermesS
export async function getSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
const idx = loadAllSessions(db)
@@ -670,7 +671,7 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
const idx = loadAllSessions(db)
@@ -702,7 +703,7 @@ export async function findLatestExactSessionIdWithProfile(
if (!trimmed) return null
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
const loweredQuery = trimmed.toLowerCase()
const likePattern = buildLikePattern(loweredQuery)
@@ -1212,7 +1213,7 @@ export async function listSessionSummaries(source?: string, limit = 2000, profil
}
const { DatabaseSync } = await import('node:sqlite')
const dbPath = profile ? `${getProfileDir(profile)}/state.db` : sessionDbPath()
const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath()
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
@@ -1259,7 +1260,7 @@ export async function searchSessionSummariesWithProfile(
if (!trimmed) return []
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
const normalized = sanitizeFtsQuery(trimmed)
const prefixQuery = toPrefixQuery(normalized)
+23 -7
View File
@@ -10,7 +10,6 @@ import { readFileSync } from 'fs'
import { config } from './config'
import { getToken, requireAuth } from './services/auth'
import { initLoginLimiter } from './services/login-limiter'
import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap'
import { bindShutdown } from './services/shutdown'
import { setupTerminalWebSocket } from './routes/hermes/terminal'
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
@@ -21,6 +20,8 @@ import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
import { logger } from './services/logger'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
@@ -88,14 +89,30 @@ export async function bootstrap() {
const authToken = await getToken()
await initLoginLimiter()
try {
const skillInjector = new HermesSkillInjector()
const injectionResult = await skillInjector.injectMissingSkills()
if (injectionResult.injected.length > 0) {
console.log('[bootstrap] bundled skills injected:', injectionResult.injected.join(', '))
}
if (injectionResult.updated.length > 0) {
console.log('[bootstrap] bundled skills updated:', injectionResult.updated.join(', '))
}
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled skills')
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
}
// Debug: log environment variable
console.log('[bootstrap] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN =', process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN)
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
const app = new Koa()
await initGatewayManager()
console.log('[bootstrap] gateway manager initialized')
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
@@ -151,10 +168,9 @@ export async function bootstrap() {
// Group chat Socket.IO (must be after server is created)
const groupChatServer = new GroupChatServer(servers)
setGroupChatServer(groupChatServer)
groupChatServer.setGatewayManager(getGatewayManagerInstance())
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
setChatRunServer(chatRunServer)
chatRunServer.init()
@@ -13,6 +13,7 @@ import {
type ChatMessage,
type CompressionConfig,
type CompressedResult,
type SummarizerOptions,
DEFAULT_COMPRESSION_CONFIG,
countTokens,
serializeForSummary,
@@ -35,7 +36,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
@@ -57,7 +58,7 @@ export class ExportCompressor {
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, meta, profile,
messages, snapshot, upstream, apiKey, meta, summarizer,
)
}
@@ -65,7 +66,7 @@ export class ExportCompressor {
'[export-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, meta, profile)
return this.fullCompress(messages, upstream, apiKey, meta, summarizer)
}
private async incrementalCompress(
@@ -74,7 +75,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const newMessages = messages.slice(lastMessageIndex + 1)
@@ -86,7 +87,7 @@ export class ExportCompressor {
const history = buildConversationHistory(newMessages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
@@ -112,7 +113,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
if (messages.length === 0) {
return { messages: [], meta }
@@ -125,7 +126,7 @@ export class ExportCompressor {
const history = buildConversationHistory(messages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] full-llm failed: %s', err.message)
@@ -14,7 +14,9 @@
*/
import { encodingForModel, getEncoding } from 'js-tiktoken'
import { randomUUID } from 'crypto'
import { logger } from '../../services/logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../../services/hermes/agent-bridge'
import {
getCompressionSnapshot,
saveCompressionSnapshot,
@@ -70,6 +72,12 @@ export interface CompressedResult {
}
}
export interface SummarizerOptions {
profile?: string
model?: string | null
provider?: string | null
}
// ─── Token counting ─────────────────────────────────────
let _encoder: ReturnType<typeof getEncoding> | null = null
@@ -372,8 +380,14 @@ export async function callSummarizer(
history: Array<{ role: string; content: string }>,
timeoutMs: number,
previousSummary?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<string> {
void upstream
void apiKey
const options: SummarizerOptions = typeof summarizer === 'string'
? { profile: summarizer }
: summarizer || {}
const profile = options.profile || 'default'
const convHistory: Array<{ role: string; content: string }> = [...history]
if (previousSummary) {
@@ -383,60 +397,38 @@ export async function callSummarizer(
)
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const bridge = new AgentBridgeClient({ timeoutMs: timeoutMs + 15_000 })
const sessionId = `compress_${Date.now().toString(36)}_${randomUUID().replace(/-/g, '').slice(0, 12)}`
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers,
body: JSON.stringify({
input: prompt,
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: prompt,
conversation_history: convHistory,
stream: true,
store: false,
}),
signal: AbortSignal.timeout(timeoutMs),
})
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(timeoutMs / 1000),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
}, { timeoutMs: timeoutMs + 15_000 })
if (!res.ok) {
throw new Error(`Summarization response failed: ${res.status}`)
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
const payload = result.result as any
const output = String(
payload?.final_response ||
result.output ||
'',
).trim()
if (!output) throw new Error('Empty summarization response')
return output
} finally {
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
if (!res.body) {
throw new Error('Summarization response stream missing')
}
let output = ''
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
if (eventType === 'response.output_text.delta' && parsed.delta) {
output += parsed.delta
continue
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!output && finalText) output = finalText
if (!output || output.trim() === '') {
throw new Error('Empty summarization response')
}
return output.trim()
}
if (eventType === 'response.failed') {
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
}
}
throw new Error('Summarization response stream ended without a terminal event')
}
// ─── Main Compressor ────────────────────────────────────
@@ -465,7 +457,7 @@ export class ChatContextCompressor {
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
@@ -489,7 +481,7 @@ export class ChatContextCompressor {
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), profile,
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), summarizer,
)
} else {
// No snapshot → full compress (compress all messages)
@@ -497,7 +489,7 @@ export class ChatContextCompressor {
'[context-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), profile)
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), summarizer)
}
}
@@ -508,7 +500,7 @@ export class ChatContextCompressor {
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const total = messages.length
@@ -550,7 +542,7 @@ export class ChatContextCompressor {
const history = buildConversationHistory(toCompress)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[context-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] incremental-llm failed: %s — keeping new messages verbatim', err.message)
@@ -599,7 +591,7 @@ export class ChatContextCompressor {
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
const cleaned = pruneOldToolResults(messages, this.config.tailMessageCount)
@@ -625,7 +617,7 @@ export class ChatContextCompressor {
let summary: string | null = null
try {
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[context-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] full-llm failed: %s', err.message)
@@ -1,9 +0,0 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/gateways'
export const gatewayRoutes = new Router()
gatewayRoutes.get('/api/hermes/gateways', ctrl.list)
gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start)
gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop)
gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health)
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/media'
export const mediaRoutes = new Router()
mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo)
@@ -1,8 +1,13 @@
import type { Context } from 'koa'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { updateUsage } from '../../db/hermes/usage-store'
function getGatewayManager() { return getGatewayManagerInstance() }
let gatewayManager: any = null
export function setGatewayManagerForTest(manager: any): void {
gatewayManager = manager
}
function getGatewayManager() { return gatewayManager }
// --- run_id → session_id mapping (in-memory, ephemeral) ---
+2 -2
View File
@@ -21,7 +21,6 @@ import { codexAuthRoutes } from './hermes/codex-auth'
import { nousAuthRoutes } from './hermes/nous-auth'
import { copilotAuthRoutes } from './hermes/copilot-auth'
import { xaiAuthRoutes } from './hermes/xai-auth'
import { gatewayRoutes } from './hermes/gateways'
import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download'
@@ -29,6 +28,7 @@ import { jobRoutes } from './hermes/jobs'
import { cronHistoryRoutes } from './hermes/cron-history'
import { kanbanRoutes } from './hermes/kanban'
import { ttsRoutes } from './hermes/tts'
import { mediaRoutes } from './hermes/media'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
@@ -64,7 +64,6 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(nousAuthRoutes.routes())
app.use(copilotAuthRoutes.routes())
app.use(xaiAuthRoutes.routes())
app.use(gatewayRoutes.routes())
app.use(weixinRoutes.routes())
app.use(groupChatRoutes.routes()) // Must be before proxy
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
@@ -72,6 +71,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(jobRoutes.routes()) // Must be before proxy
app.use(cronHistoryRoutes.routes()) // Must be before proxy
app.use(kanbanRoutes.routes()) // Must be before proxy
app.use(mediaRoutes.routes()) // Must be before proxy
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)
+19 -1
View File
@@ -2,7 +2,7 @@ import { readFile, chmod } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath, getProfileDir } from './hermes/hermes-profile'
import { logger } from './logger'
import { safeFileStore } from './safe-file-store'
@@ -76,6 +76,10 @@ export async function readConfigYaml(): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPath())
}
export async function readConfigYamlForProfile(profile: string): Promise<Record<string, any>> {
return safeFileStore.readYaml(join(getProfileDir(profile), 'config.yaml'))
}
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
await safeFileStore.writeYaml(configPath(), config, { backup: true })
}
@@ -86,6 +90,20 @@ export async function updateConfigYaml<T = void>(
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
}
export function stripLegacyApiServerGatewayConfig(config: Record<string, any>): { config: Record<string, any>; changed: boolean } {
if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) {
return { config, changed: false }
}
if (config.platforms.api_server !== undefined) {
delete config.platforms.api_server
if (Object.keys(config.platforms).length === 0) delete config.platforms
return { config, changed: true }
}
return { config, changed: false }
}
// --- .env helpers ---
function assertValidEnvKey(key: string): void {
@@ -1,15 +0,0 @@
let gatewayManager: any = null
export function getGatewayManagerInstance(): any {
return gatewayManager
}
export async function initGatewayManager(): Promise<void> {
const { GatewayManager } = await import('./hermes/gateway-manager')
const { getActiveProfileName } = await import('./hermes/hermes-profile')
const activeProfile = getActiveProfileName()
gatewayManager = new GatewayManager(activeProfile)
await gatewayManager.detectAllOnStartup()
await gatewayManager.startAll()
}
@@ -1,13 +1,23 @@
import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net'
import { tmpdir } from 'os'
import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
function resolveDefaultAgentBridgeEndpoint(): string {
if (process.env.VITEST) {
return process.platform === 'win32'
? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}`
: `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}`
}
return process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
}
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint()
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
function envPositiveInt(name: string): number | undefined {
@@ -26,6 +36,7 @@ export interface AgentBridgeOptions {
export interface AgentBridgeRequestOptions {
timeoutMs?: number
serialize?: boolean
}
export interface AgentBridgeChatOptions {
@@ -33,6 +44,9 @@ export interface AgentBridgeChatOptions {
storage_message?: AgentBridgeMessage
model?: string
provider?: string
source?: string
wait?: boolean
timeout?: number
}
export type AgentBridgeMessage =
@@ -298,6 +312,10 @@ export class AgentBridgeClient {
}
}
if (!options.serialize) {
return run()
}
const next = this.lock.then(run, run)
this.lock = next.catch(() => undefined)
return next
@@ -325,6 +343,9 @@ export class AgentBridgeClient {
...(profile ? { profile } : {}),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
...(options.source ? { source: options.source } : {}),
...(options.wait ? { wait: true } : {}),
...(options.timeout ? { timeout: options.timeout } : {}),
...(options.force_compress ? { force_compress: true } : {}),
})
}
@@ -383,12 +404,22 @@ export class AgentBridgeClient {
return this.request<AgentBridgeRunResult>({ action: 'get_result', run_id: runId }, options)
}
interrupt(sessionId: string, message?: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'interrupt', session_id: sessionId, message })
interrupt(sessionId: string, message?: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'interrupt',
session_id: sessionId,
message,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'steer', session_id: sessionId, text })
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
session_id: sessionId,
text,
...(profile ? { profile } : {}),
})
}
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
@@ -407,15 +438,27 @@ export class AgentBridgeClient {
}
destroyAll(): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_all' })
return this.request({ action: 'destroy_all' }, { serialize: true })
}
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'get_history', session_id: sessionId })
destroyProfile(profile: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_profile', profile }, { serialize: true })
}
destroy(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy', session_id: sessionId })
getHistory(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'get_history',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
list(): Promise<AgentBridgeResponse> {
@@ -423,7 +466,7 @@ export class AgentBridgeClient {
}
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' })
return this.request({ action: 'shutdown' }, { serialize: true })
}
}
@@ -11,13 +11,16 @@ from __future__ import annotations
import argparse
import copy
import hashlib
import importlib.util
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
@@ -174,6 +177,11 @@ def _base_hermes_home() -> Path:
return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME))
def _worker_profile() -> str | None:
raw = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE", "").strip()
return raw or None
def _profile_home(profile: str | None) -> Path:
base = _base_hermes_home()
if not profile or profile == "default":
@@ -319,8 +327,20 @@ def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None:
os.environ[key] = value
def _set_worker_profile_env(profile: str | None) -> None:
profile_home = _profile_home(profile)
os.environ["HERMES_HOME"] = str(profile_home)
os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default"
values = _read_dotenv(profile_home / ".env")
for key, value in values.items():
os.environ[key] = value
@contextmanager
def _profile_env(profile: str | None):
if _worker_profile():
yield
return
original = _apply_profile_env(profile)
env_snapshot = _apply_profile_dotenv(profile)
try:
@@ -832,6 +852,7 @@ class AgentPool:
storage_message: Any | None,
conversation_history: list[dict[str, Any]] | None,
profile: str | None,
source: str | None = None,
) -> bool:
persist_message = storage_message if storage_message is not None else message
user_content = str(persist_message) if not isinstance(persist_message, dict) else str(persist_message.get("content", persist_message))
@@ -848,7 +869,7 @@ class AgentPool:
if hasattr(db, "create_session"):
db.create_session(
session_id=session.session_id,
source=_bridge_platform(),
source=source or _bridge_platform(),
model=session.config.get("model"),
)
@@ -958,6 +979,7 @@ class AgentPool:
force_compress: bool = False,
model: str | None = None,
provider: str | None = None,
source: str | None = None,
) -> RunRecord:
session = self.get_or_create(session_id, profile=profile, model=model, provider=provider)
with session.lock:
@@ -973,14 +995,14 @@ class AgentPool:
thread = threading.Thread(
target=self._run_chat,
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress),
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress, source),
daemon=True,
name=f"hermes-bridge-run-{run_id[:8]}",
)
thread.start()
return record
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False, source: str | None = None) -> None:
with self._run_lock:
with _profile_env(profile):
def stream_callback(delta: str) -> None:
@@ -1004,7 +1026,7 @@ class AgentPool:
os.environ["HERMES_EXEC_ASK"] = "1"
except Exception:
previous_approval_callback = None
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
self._prepersist_user_message(session, message, storage_message, conversation_history, profile, source)
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
if force_compress:
compress = getattr(session.agent, "_compress_context", None)
@@ -1265,7 +1287,13 @@ class BridgeServer:
raise ValueError("action is required")
if action == "ping":
return {"pong": True, "time": time.time(), "agent_root": str(_agent_root())}
return {
"pong": True,
"time": time.time(),
"agent_root": str(_agent_root()),
"profile": _worker_profile() or "default",
"hermes_home": str(_hermes_home()),
}
if action == "chat":
session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex
@@ -1276,6 +1304,7 @@ class BridgeServer:
profile = req.get("profile")
model = req.get("model")
provider = req.get("provider")
source = req.get("source")
record = self.pool.start_chat(
session_id,
message,
@@ -1286,6 +1315,7 @@ class BridgeServer:
bool(req.get("force_compress")),
model,
provider,
source,
)
if req.get("wait"):
timeout = float(req.get("timeout", 0) or 0)
@@ -1355,50 +1385,13 @@ class BridgeServer:
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
if self.endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(self.endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(self.endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {self.endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {self.endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
_write_json_response(conn, resp)
def _gc_idle_sessions(self) -> None:
"""Destroy sessions idle longer than IDLE_TIMEOUT_SECONDS."""
@@ -1458,16 +1451,530 @@ class BridgeServer:
pass
class WorkerProcess:
STARTUP_TIMEOUT_SECONDS = 120
REQUEST_TIMEOUT_SECONDS = 120
def __init__(self, profile: str, endpoint: str, agent_root: str | None, hermes_home: str | None) -> None:
self.profile = profile or "default"
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self.process: subprocess.Popen[str] | None = None
self.last_used_at = time.time()
self._lock = threading.RLock()
@property
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def start(self) -> None:
with self._lock:
if self.running:
return
args = [
sys.executable,
str(Path(__file__).resolve()),
"--endpoint",
self.endpoint,
"--worker-profile",
self.profile,
]
if self.agent_root:
args.extend(["--agent-root", self.agent_root])
if self.hermes_home:
args.extend(["--hermes-home", self.hermes_home])
env = {
**os.environ,
"HERMES_AGENT_BRIDGE_ENDPOINT": self.endpoint,
"HERMES_AGENT_BRIDGE_WORKER_PROFILE": self.profile,
}
self.process = subprocess.Popen(
args,
env=env,
cwd=os.getcwd(),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
self._pipe_stderr()
self._wait_ready()
def _pipe_stderr(self) -> None:
proc = self.process
if proc is None or proc.stderr is None:
return
def run() -> None:
assert proc.stderr is not None
for line in proc.stderr:
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
threading.Thread(target=run, daemon=True, name=f"hermes-bridge-worker-stderr-{self.profile}").start()
def _wait_ready(self) -> None:
proc = self.process
if proc is None or proc.stdout is None:
raise RuntimeError(f"profile worker {self.profile} did not start")
lines: queue.Queue[str | None] = queue.Queue()
ready_event = threading.Event()
def read_stdout() -> None:
assert proc.stdout is not None
try:
for line in proc.stdout:
if ready_event.is_set():
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
else:
lines.put(line)
finally:
lines.put(None)
threading.Thread(target=read_stdout, daemon=True, name=f"hermes-bridge-worker-stdout-{self.profile}").start()
deadline = time.time() + self.STARTUP_TIMEOUT_SECONDS
while time.time() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"profile worker {self.profile} exited before ready")
try:
line = lines.get(timeout=0.1)
except queue.Empty:
continue
if line is None:
time.sleep(0.05)
continue
text = line.strip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
try:
data = json.loads(text)
if data.get("event") == "ready":
ready_event.set()
return
except Exception:
pass
self.stop()
raise RuntimeError(f"profile worker {self.profile} did not become ready within {self.STARTUP_TIMEOUT_SECONDS}s")
def stop(self) -> None:
with self._lock:
proc = self.process
self.process = None
if proc is None:
return
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def request(self, req: dict[str, Any]) -> dict[str, Any]:
self.start()
self.last_used_at = time.time()
return _send_bridge_request(self.endpoint, req, self.REQUEST_TIMEOUT_SECONDS)
def _worker_endpoint(profile: str) -> str:
safe = hashlib.sha256(profile.encode("utf-8")).hexdigest()[:16]
if os.name == "nt":
port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780"))
return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}"
root = Path(tempfile.gettempdir()) / "hermes-agent-bridge-workers"
return f"ipc://{root / f'{safe}.sock'}"
def _connect_bridge_socket(endpoint: str, timeout: float) -> socket.socket:
if endpoint.startswith("ipc://"):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(endpoint.removeprefix("ipc://"))
return sock
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((parsed.hostname or "127.0.0.1", int(parsed.port or 0)))
return sock
def _send_bridge_request(endpoint: str, req: dict[str, Any], timeout: float) -> dict[str, Any]:
sock = _connect_bridge_socket(endpoint, timeout)
try:
sock.sendall((json.dumps(req, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
chunks: list[bytes] = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("worker closed without a response")
resp = json.loads(line.decode("utf-8"))
if not resp.get("ok"):
raise RuntimeError(str(resp.get("error") or "worker request failed"))
return resp
finally:
try:
sock.close()
except OSError:
pass
def _make_listen_socket(endpoint: str) -> socket.socket:
if endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
def _read_json_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
def _write_json_response(conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
class BridgeBroker:
IDLE_TIMEOUT_SECONDS = 30 * 60
GC_INTERVAL_SECONDS = 60
def __init__(self, endpoint: str, agent_root: str | None = None, hermes_home: str | None = None) -> None:
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self._workers: dict[str, WorkerProcess] = {}
self._run_profile: dict[str, str] = {}
self._session_profile: dict[str, str] = {}
self._approval_profile: dict[str, str] = {}
self._compression_profile: dict[str, str] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._last_gc = time.time()
def _normalize_profile(self, value: Any) -> str:
profile = str(value or "").strip()
return profile or "default"
def _worker_for_profile(self, profile: str) -> WorkerProcess:
profile = self._normalize_profile(profile)
with self._lock:
worker = self._workers.get(profile)
if worker is None:
worker = WorkerProcess(profile, _worker_endpoint(profile), self.agent_root, self.hermes_home)
self._workers[profile] = worker
return worker
def _profile_for_run(self, run_id: str) -> str:
with self._lock:
profile = self._run_profile.get(run_id)
if not profile:
raise KeyError(f"unknown run: {run_id}")
return profile
def _profile_for_session(self, session_id: str, fallback_profile: Any = None) -> str:
with self._lock:
profile = self._session_profile.get(session_id)
if not profile:
fallback = self._normalize_profile(fallback_profile)
if fallback_profile is not None and fallback:
return fallback
raise KeyError(f"unknown session: {session_id}")
return profile
def _record_response_routes(self, profile: str, resp: dict[str, Any]) -> None:
run_id = str(resp.get("run_id") or "")
session_id = str(resp.get("session_id") or "")
with self._lock:
if run_id:
self._run_profile[run_id] = profile
if session_id:
self._session_profile[session_id] = profile
for event in resp.get("events") or []:
if not isinstance(event, dict):
continue
approval_id = str(event.get("approval_id") or "")
if approval_id:
self._approval_profile[approval_id] = profile
request_id = str(event.get("request_id") or "")
if event.get("event") == "bridge.compression.requested" and request_id:
self._compression_profile[request_id] = profile
if event.get("event") in {"bridge.compression.completed", "bridge.compression.failed"} and request_id:
self._compression_profile.pop(request_id, None)
def _forward(self, profile: str, req: dict[str, Any]) -> dict[str, Any]:
worker = self._worker_for_profile(profile)
forwarded = dict(req)
forwarded["profile"] = profile
resp = worker.request(forwarded)
self._record_response_routes(profile, resp)
return resp
def handle(self, req: dict[str, Any]) -> dict[str, Any]:
action = str(req.get("action") or "").strip()
if not action:
raise ValueError("action is required")
if action == "ping":
with self._lock:
workers = {profile: worker.running for profile, worker in self._workers.items()}
return {"pong": True, "time": time.time(), "mode": "broker", "workers": workers}
if action == "worker_ping":
profile = self._normalize_profile(req.get("profile"))
resp = self._forward(profile, {"action": "ping"})
resp["worker_profile"] = profile
return resp
if action == "chat":
profile = self._normalize_profile(req.get("profile"))
return self._forward(profile, req)
if action in {"get_result", "get_output"}:
profile = self._profile_for_run(str(req.get("run_id") or ""))
return self._forward(profile, req)
if action in {"interrupt", "steer", "get_history", "destroy"}:
session_id = str(req.get("session_id") or "")
profile = self._profile_for_session(session_id, req.get("profile"))
resp = self._forward(profile, req)
if action == "destroy":
with self._lock:
self._session_profile.pop(session_id, None)
return resp
if action == "approval_respond":
approval_id = str(req.get("approval_id") or "").strip()
if not approval_id:
raise ValueError("approval_id is required")
with self._lock:
profile = self._approval_profile.get(approval_id)
if not profile:
raise KeyError(f"unknown approval request: {approval_id}")
return self._forward(profile, req)
if action == "compression_respond":
request_id = str(req.get("request_id") or "").strip()
if not request_id:
raise ValueError("request_id is required")
with self._lock:
profile = self._compression_profile.get(request_id)
if not profile:
raise KeyError(f"unknown compression request: {request_id}")
return self._forward(profile, req)
if action == "destroy_all":
with self._lock:
workers = list(self._workers.values())
self._run_profile.clear()
self._session_profile.clear()
self._approval_profile.clear()
self._compression_profile.clear()
destroyed = 0
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
resp = worker.request({"action": "destroy_all"})
destroyed += int(resp.get("destroyed") or 0)
except Exception:
pass
return {"destroyed": destroyed}
if action == "destroy_profile":
profile = self._normalize_profile(req.get("profile"))
with self._lock:
worker = self._workers.get(profile)
self._run_profile = {key: value for key, value in self._run_profile.items() if value != profile}
self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile}
self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile}
self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile}
if worker is None or not worker.running:
if worker is not None:
worker.stop()
return {"profile": profile, "destroyed": 0}
try:
resp = worker.request({"action": "destroy_all"})
return {"profile": profile, "destroyed": int(resp.get("destroyed") or 0)}
except Exception:
return {"profile": profile, "destroyed": 0}
if action == "list":
sessions: list[Any] = []
with self._lock:
workers = list(self._workers.items())
for profile, worker in workers:
if not worker.running:
continue
try:
resp = worker.request({"action": "list"})
for session in resp.get("sessions") or []:
if isinstance(session, dict):
session.setdefault("profile", profile)
sessions.append(session)
except Exception:
pass
return {"sessions": sessions}
if action == "shutdown":
self._stop.set()
with self._lock:
workers = list(self._workers.values())
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
worker.request({"action": "shutdown"})
except Exception:
worker.stop()
return {"status": "shutting_down"}
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
_write_json_response(conn, resp)
def _gc_idle_workers(self) -> None:
now = time.time()
if now - self._last_gc < self.GC_INTERVAL_SECONDS:
return
self._last_gc = now
with self._lock:
idle = [
profile for profile, worker in self._workers.items()
if worker.running and now - worker.last_used_at > self.IDLE_TIMEOUT_SECONDS
]
for profile in idle:
with self._lock:
worker = self._workers.pop(profile, None)
if worker:
worker.stop()
def serve_forever(self) -> None:
server = self._make_server_socket()
server.listen(64)
server.settimeout(0.2)
print(json.dumps({"event": "ready", "endpoint": self.endpoint, "mode": "broker"}), flush=True)
while not self._stop.is_set():
conn: socket.socket | None = None
try:
try:
conn, _addr = server.accept()
except socket.timeout:
self._gc_idle_workers()
continue
try:
req = self._read_request(conn)
data = self.handle(req)
resp = {"ok": True, **_jsonable(data)}
except Exception as exc:
resp = {
"ok": False,
"error": str(exc),
"error_type": exc.__class__.__name__,
}
self._write_response(conn, resp)
except KeyboardInterrupt:
break
except Exception as exc:
print(f"[hermes-bridge-broker] server loop error: {exc}", file=sys.stderr, flush=True)
finally:
if conn is not None:
try:
conn.close()
except OSError:
pass
with self._lock:
workers = list(self._workers.values())
self._workers.clear()
for worker in workers:
worker.stop()
server.close()
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Hermes AIAgent in-process bridge")
parser.add_argument("--endpoint", default=os.environ.get("HERMES_AGENT_BRIDGE_ENDPOINT", DEFAULT_ENDPOINT))
parser.add_argument("--agent-root", default=os.environ.get("HERMES_AGENT_ROOT", DEFAULT_AGENT_ROOT))
parser.add_argument("--hermes-home", default=os.environ.get("HERMES_HOME", DEFAULT_HERMES_HOME))
parser.add_argument("--worker-profile", default=os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE"))
args = parser.parse_args(argv)
_set_path_env(args.agent_root, args.hermes_home)
_ensure_agent_imports()
BridgeServer(args.endpoint).serve_forever()
if args.worker_profile:
_set_worker_profile_env(str(args.worker_profile or "default"))
BridgeServer(args.endpoint).serve_forever()
else:
BridgeBroker(args.endpoint, args.agent_root, args.hermes_home).serve_forever()
return 0
@@ -127,7 +127,7 @@ export class ContextEngine {
// Under threshold — return summary + new messages directly
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -155,7 +155,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (after incremental compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -163,7 +163,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
return { conversationHistory: history, instructions, meta }
}
@@ -177,7 +177,7 @@ export class ContextEngine {
// Under threshold — pass all messages verbatim
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.logHistory('Path B (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -209,7 +209,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, tail, input.agentSocketId)
const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName)
this.logHistory('Path B (after full compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -217,7 +217,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.trimToBudget(history, 0, config.maxHistoryTokens)
meta.verbatimCount = history.length
return { conversationHistory: history, instructions, meta }
@@ -265,6 +265,7 @@ export class ContextEngine {
summary: string,
messages: StoredMessage[],
agentSocketId: string,
agentName: string,
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
@@ -275,7 +276,7 @@ export class ContextEngine {
)
}
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId)))
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName)))
return history
}
@@ -314,11 +315,51 @@ export class ContextEngine {
private mapToHistory(
msg: StoredMessage,
agentSocketId: string,
agentName: string,
): { role: 'user' | 'assistant'; content: string } {
if (msg.senderId === agentSocketId) {
return { role: 'assistant', content: msg.content }
const senderName = msg.senderName || 'unknown'
const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName
if (msg.role === 'tool') {
const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result'
return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` }
}
return { role: 'user', content: `[${msg.senderName}]: ${msg.content}` }
if (msg.role === 'assistant' && msg.tool_calls?.length) {
const toolsInfo = msg.tool_calls.map(tc => {
const name = tc.function?.name || 'unknown'
let args = tc.function?.arguments || '{}'
if (args.length > 4000) args = `${args.slice(0, 4000)}...`
return `[Calling tool: ${name} with arguments: ${args}]`
}).join('\n')
const content = msg.content?.trim()
return {
role: isOwnAgent ? 'assistant' : 'user',
content: content
? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`
: `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`,
}
}
return {
role: isOwnAgent ? 'assistant' : 'user',
content: this.formatAttributedContent(senderName, msg.content || ''),
}
}
private formatAttributedContent(senderName: string, content: string): string {
return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}`
}
private formatAttributionPrefix(senderName: string, _content?: string): string {
return `[${senderName}]: `
}
private stripMentions(content: string): string {
return String(content || '')
.replace(/@([^\s@]+)/g, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/^\s+/, '')
}
private trimToBudget(
@@ -6,10 +6,11 @@ import {
} from './prompt'
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge'
/**
* Calls Hermes /v1/responses to produce LLM-generated summaries.
* The context engine owns history assembly; Responses storage/chaining is not used.
* Calls the local bridge to produce LLM-generated summaries.
* The context engine owns history assembly; gateway storage/chaining is not used.
*/
export class GatewaySummarizer implements GatewayCaller {
private timeoutMs: number
@@ -19,8 +20,8 @@ export class GatewaySummarizer implements GatewayCaller {
}
async summarize(
upstream: string,
apiKey: string | null,
_upstream: string,
_apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
roomId: string,
@@ -29,7 +30,7 @@ export class GatewaySummarizer implements GatewayCaller {
): Promise<{ summary: string; sessionId: string }> {
const history: Array<{ role: string; content: string }> = messages.map(m => ({
role: 'user',
content: `[${m.senderName}]: ${m.content}`,
content: summarizeMessageForPrompt(m),
}))
if (previousSummary) {
@@ -43,132 +44,67 @@ export class GatewaySummarizer implements GatewayCaller {
? buildIncrementalUpdatePrompt()
: buildFullSummaryPrompt()
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
input: userPrompt,
const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 })
const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 160)
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: userPrompt,
instructions: systemPrompt || buildSummarizationSystemPrompt(),
conversation_history: history,
stream: true,
store: false,
}),
signal: AbortSignal.timeout(this.timeoutMs),
})
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(this.timeoutMs / 1000),
}, { timeoutMs: this.timeoutMs + 15_000 })
if (!res.ok) {
throw new Error(`Summarization response failed: ${res.status}`)
}
if (!res.body) {
throw new Error('Summarization response stream missing')
}
let output = ''
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
if (eventType === 'response.output_text.delta' && parsed.delta) {
output += parsed.delta
continue
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!output && finalText) output = finalText
const payload = result.result as any
const output = String(payload?.final_response || result.output || '').trim()
if (!output) throw new Error('Empty summarization response')
const usage = response.usage || {}
const usage = payload?.usage || payload?.response?.usage
if (usage) {
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
model: payload?.model || payload?.response?.model || '',
profile,
})
logger.debug(`[GatewaySummarizer] Recorded response usage for compression room ${roomId} (profile=${profile}): input=${usage.input_tokens ?? 0}, output=${usage.output_tokens ?? 0}`)
if (!output || output.trim() === '') {
throw new Error('Empty summarization response')
}
return { summary: output.trim(), sessionId: '' }
}
if (eventType === 'response.failed') {
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
}
logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`)
return { summary: output, sessionId }
} finally {
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
throw new Error('Summarization response stream ended without a terminal event')
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
function summarizeMessageForPrompt(message: StoredMessage): string {
if (message.role === 'tool') {
const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result'
return `[${label}]\n${message.content || ''}`
}
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
if (message.role === 'assistant' && message.tool_calls?.length) {
const toolsInfo = message.tool_calls.map(tc => {
const name = tc.function?.name || 'tool'
const args = tc.function?.arguments || '{}'
return `${name}(${args})`
}).join(', ')
const content = message.content?.trim()
return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]`
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
return `[${message.senderName}]: ${message.content}`
}
@@ -52,15 +52,23 @@ export function buildAgentInstructions(params: AgentInstructionsParams): string
${memberSection}
规则:
- 有人用 @${params.agentName} 提及你时才需要回复,重点回应提及你的人
- 禁止@自己
- 当你收到群聊任务时,说明系统已经判断你需要回复;请直接回应当前消息,不要因为消息里同时提及其他成员而拒绝回复或输出空回复
- 重点回应提及你的人
- 回答简洁、对群聊有帮助。
- 不要假装是人类,需要时明确表明自己是 AI。
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文
- 回复最新一条提及你的消息
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
- 不要假装是人类,需要时明确表明自己是 AI。
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
- 历史消息里的"[发送者]: ..."只是系统添加的归属标记,用来帮助你理解谁说了这句话;不要在你的回复中复述或模仿这种方括号前缀
- 回复时使用自然语言即可;如果需要点名某人,只使用 @名字,不要输出"[${params.agentName}]:"这类格式
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文
- 回复最新一条提及你的消息。
- 群聊系统支持 agent 之间通过 @名字 接力:当你在回复中写出 @某个成员,系统会把消息路由给对应成员。
- 如果用户明确要求你叫、让、请某个 agent 执行任务,不要自己代办,不要说你无法指挥其他 agent;请直接用 @名字 转交任务,并简短说明你已转交。
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方,并把需要对方执行的任务写清楚。
- 不要主动 @ 任何人,除非最新消息明确要求你转交、邀请、询问某个具体成员。
- 如果只是回答提问,直接回答,不要在结尾 @ 其他成员继续接力。
- 不要为了活跃气氛、征求补充、让别人也看看而 @ 其他 agent 或用户。
- 只有在确实需要对方执行动作、提供信息、确认决策时,才可以 @名字。
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
return getSystemPrompt(basePrompt)
}
@@ -8,6 +8,11 @@ export interface StoredMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null
tool_name?: string | null
finish_reason?: string | null
}
// ─── Compression Config ────────────────────────────────────
@@ -1,11 +1,12 @@
import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises'
import { resolve, normalize, isAbsolute, basename } from 'path'
import { resolve, normalize, isAbsolute, basename, join } from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync } from 'fs'
import YAML from 'js-yaml'
import { config } from '../../config'
import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
import { isPathWithin, relativePathFromBase } from './hermes-path'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
@@ -90,11 +91,7 @@ export function validatePath(filePath: string): string {
* Check if a path is inside the upload directory.
*/
export function isInUploadDir(filePath: string): boolean {
const normalized = normalize(resolve(filePath))
const uploadNormalized = normalize(resolve(config.uploadDir))
return normalized.startsWith(uploadNormalized + '/')
|| normalized.startsWith(uploadNormalized + '\\')
|| normalized === uploadNormalized
return isPathWithin(filePath, config.uploadDir)
}
/**
@@ -120,7 +117,7 @@ export function resolveHermesPath(relativePath: string): string {
throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' })
}
const resolved = resolve(homeDir, normalized)
if (!resolved.startsWith(homeDir)) {
if (!isPathWithin(resolved, homeDir)) {
throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' })
}
return resolved
@@ -160,9 +157,7 @@ export class LocalFileProvider implements FileProvider {
try {
const fullPath = resolve(p, entry.name)
const s = await fsStat(fullPath)
const relPath = fullPath.startsWith(homeDir)
? fullPath.slice(homeDir.length + 1)
: entry.name
const relPath = relativePathFromBase(fullPath, homeDir) ?? entry.name
results.push({
name: entry.name,
path: relPath,
@@ -181,9 +176,7 @@ export class LocalFileProvider implements FileProvider {
const p = validatePath(filePath)
const homeDir = getActiveProfileDir()
const s = await fsStat(p)
const relPath = p.startsWith(homeDir)
? p.slice(homeDir.length + 1)
: basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return {
name: basename(p),
path: relPath || basename(p),
@@ -291,7 +284,7 @@ export class DockerFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -309,7 +302,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', this.containerName, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -321,9 +314,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -338,9 +331,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -354,7 +347,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -364,7 +357,7 @@ export class DockerFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -374,7 +367,7 @@ export class DockerFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -385,7 +378,7 @@ export class DockerFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -395,7 +388,7 @@ export class DockerFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -406,7 +399,7 @@ export class DockerFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -451,7 +444,7 @@ export class SSHFileProvider implements FileProvider {
// Pass a single quoted command string to prevent shell injection on remote
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `cat ${this.shellEscape(p)}`,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -469,7 +462,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `test -f ${this.shellEscape(p)}`,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -481,9 +474,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -498,9 +491,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -514,7 +507,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `cat > ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -524,7 +517,7 @@ export class SSHFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -534,7 +527,7 @@ export class SSHFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -545,7 +538,7 @@ export class SSHFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -555,7 +548,7 @@ export class SSHFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -566,7 +559,7 @@ export class SSHFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -590,7 +583,7 @@ export class SingularityFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -608,7 +601,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -620,9 +613,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -637,9 +630,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -653,7 +646,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -663,7 +656,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -673,7 +666,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -684,7 +677,7 @@ export class SingularityFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -694,7 +687,7 @@ export class SingularityFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -705,7 +698,7 @@ export class SingularityFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -720,7 +713,7 @@ export class SingularityFileProvider implements FileProvider {
*/
export function getTerminalConfig(): TerminalConfig {
try {
const configPath = `${getActiveProfileDir()}/config.yaml`
const configPath = join(getActiveProfileDir(), 'config.yaml')
if (!existsSync(configPath)) return { backend: 'local' }
const raw = readFileSync(configPath, 'utf-8')
const doc = YAML.load(raw, { json: true }) as any
@@ -777,7 +770,7 @@ async function resolveDockerContainer(cfg: TerminalConfig): Promise<string> {
try {
const { stdout } = await execFileAsync('docker', [
'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest',
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
const id = stdout.trim()
if (id) return id
} catch { }
@@ -0,0 +1,125 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
import { logger } from '../logger'
import { safeFileStore } from '../safe-file-store'
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
const execFileAsync = promisify(execFile)
function resolveHermesBin(): string {
return process.env.HERMES_BIN?.trim() || 'hermes'
}
function isDockerRuntime(): boolean {
return existsSync('/.dockerenv')
}
function isTermuxRuntime(): boolean {
const prefix = process.env.PREFIX || ''
return !!process.env.TERMUX_VERSION ||
prefix.includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
}
export function gatewayStatusLooksRunning(output: string): boolean {
const text = output.toLowerCase()
if (text.includes('gateway is not running') || text.includes('not running')) return false
return text.includes('gateway is running') || text.includes('running')
}
export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
const text = output.toLowerCase()
return text.includes('runtime lock is already held')
|| text.includes('gateway runtime lock is already held')
|| text.includes('already held by another instance')
}
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
try {
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
timeout: 10000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
return gatewayStatusLooksRunning(`${stdout}\n${stderr}`)
} catch (err: any) {
const output = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`
if (gatewayStatusLooksRuntimeLocked(output)) {
logger.info({ profileDir }, 'Hermes gateway status reported runtime lock held; treating gateway as already running')
return true
}
if (output.trim()) {
logger.warn({ err, profileDir }, 'Hermes gateway status failed; treating as not running')
}
return false
}
}
async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
if (isDockerRuntime() || isTermuxRuntime()) {
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
return
}
try {
await execFileAsync(hermesBin, ['gateway', 'start'], {
timeout: 30000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
logger.info('[gateway-autostart] gateway started via Hermes CLI service profile=%s home=%s', profile, profileDir)
} catch (err) {
logger.warn(err, '[gateway-autostart] Hermes CLI gateway start failed; falling back to background run profile=%s home=%s', profile, profileDir)
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via fallback background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
}
}
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
const configPath = join(profileDir, 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
}, { backup: true })
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before gateway startup: %s', profileDir)
}
}
export async function ensureProfileGatewaysRunning(): Promise<void> {
const hermesBin = resolveHermesBin()
const profiles = listProfileNamesFromDisk()
for (const profile of profiles) {
const profileDir = getProfileDir(profile)
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
if (running) {
logger.info('[gateway-autostart] gateway already running profile=%s home=%s', profile, profileDir)
continue
}
await clearApiServerForProfile(profileDir)
await startGatewayForProfile(hermesBin, profile, profileDir)
}
}
@@ -14,13 +14,13 @@
* - 否 → 标记为 stopped
*
* detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写
* config.yaml。端口修正发生在启动前的 resolvePort 阶段。
* config.yaml。
*
* 端口分配流程(resolvePort,启动前调用):
* ① 读取配置端口
* ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口
* ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口
* ④ 从 8642 起递增查找空闲端口,并写入 config.yaml
* ④ 从 8642 起递增查找空闲端口,仅返回本次运行使用的端口,不再回写 config.yaml
*
* 启动模式:
* - 所有平台统一使用 `hermes gateway run --replace`
@@ -36,7 +36,6 @@ import { createServer } from 'net'
import yaml from 'js-yaml'
import { logger } from '../logger'
import { detectHermesHome, getHermesBin } from './hermes-path'
import { safeFileStore } from '../safe-file-store'
const execFileAsync = promisify(execFile)
@@ -334,53 +333,6 @@ export class GatewayManager {
})
}
// ============================
// 配置写入
// ============================
/**
* 将端口和主机写入 profile 的 config.yaml
* 写入完整结构:
* platforms:
* api_server:
* enabled: true
* key: ''
* cors_origins: '*'
* extra:
* port: <port>
* host: <host>
* 同时清理旧的顶层 port/host(避免 Hermes 读取错误)
*/
private async writeProfilePort(name: string, port: number, host: string): Promise<void> {
const configPath = join(this.profileDir(name), 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (cfg) => {
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {}
cfg.platforms.api_server.enabled = true
cfg.platforms.api_server.key = ''
cfg.platforms.api_server.cors_origins = '*'
cfg.platforms.api_server.extra.port = port
cfg.platforms.api_server.extra.host = host
// 清理旧的顶层 port/hostHermes 只从 extra 读取
if (cfg.platforms.api_server.port !== undefined) {
delete cfg.platforms.api_server.port
}
if (cfg.platforms.api_server.host !== undefined) {
delete cfg.platforms.api_server.host
}
return cfg
})
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
} catch (err) {
logger.error(err, 'Failed to write config for profile "%s"', name)
}
}
// ============================
// 端口分配
// ============================
@@ -392,7 +344,6 @@ export class GatewayManager {
* 1. 当前 profile 已经健康运行 → 直接使用运行端口
* 2. 未运行 → 从 8642 开始找空闲端口
* 3. 检查已管理 profile / 本轮已分配端口 / 系统 TCP 占用
* 4. 先写入 config.yaml,再启动 gateway
*/
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
const { port: configuredPort, host } = this.readProfilePort(name)
@@ -437,8 +388,6 @@ export class GatewayManager {
} else {
logger.debug('Assigning port %d for profile "%s"', port, name)
}
await this.writeProfilePort(name, port, host)
this.allocatedPorts.add(port)
return { port, host }
}
@@ -0,0 +1,22 @@
import { spawn } from 'child_process'
import { getActiveProfileDir } from './hermes-profile'
export function startGatewayRunManaged(
hermesBin: string,
opts: { profileDir?: string } = {},
): { pid: number | null; reused: boolean } {
const profileDir = opts.profileDir || getActiveProfileDir()
const child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
child.unref()
const pid = child.pid ?? null
return { pid, reused: false }
}
@@ -1,8 +1,10 @@
import { io, Socket } from 'socket.io-client'
import { getToken } from '../../../services/auth'
import type { GatewayManager } from '../gateway-manager'
import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
// ─── Types ────────────────────────────────────────────────────
@@ -22,6 +24,15 @@ interface MessageData {
timestamp: number
}
type MentionMessage = {
content: string
senderName: string
senderId: string
timestamp: number
input?: string | ContentBlock[]
mentionDepth?: number
}
interface MemberData {
id: string
name: string
@@ -55,9 +66,10 @@ class AgentClient {
private joinedRooms = new Set<string>()
private handlers: AgentEventHandler
private _reconnecting = false
private gatewayManager: GatewayManager | null = null
private contextEngine: any = null
private storage: any = null
private pendingToolCallIds = new Map<string, string[]>()
private pendingToolBaseIds = new Map<string, string>()
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
@@ -75,10 +87,6 @@ class AgentClient {
return this.socket?.id
}
setGatewayManager(manager: GatewayManager): void {
this.gatewayManager = manager
}
setContextEngine(engine: any): void {
this.contextEngine = engine
}
@@ -146,10 +154,10 @@ class AgentClient {
})
}
sendMessage(roomId: string, content: string): Promise<string> {
sendMessage(roomId: string, content: string, messageId?: string, extra?: Record<string, unknown>): Promise<string> {
this.ensureConnected()
return new Promise((resolve, reject) => {
this.socket!.emit('message', { roomId, content }, (res: { id?: string; error?: string }) => {
this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => {
if (res.error) {
reject(new Error(res.error))
} else {
@@ -174,6 +182,52 @@ class AgentClient {
this.socket!.emit('context_status', { roomId, agentName: this.name, status })
}
emitApprovalRequested(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload })
}
emitApprovalResolved(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload })
}
async interrupt(roomId: string): Promise<void> {
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile)
this.stopTyping(roomId)
this.emitContextStatus(roomId, 'ready')
}
emitMessageStreamStart(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_start', {
roomId,
id: messageId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
timestamp: Date.now(),
})
}
emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta })
}
emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta })
}
emitMessageStreamEnd(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_end', { roomId, id: messageId })
}
getJoinedRooms(): string[] {
return Array.from(this.joinedRooms)
}
@@ -193,23 +247,10 @@ class AgentClient {
*/
async replyToMention(
roomId: string,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
): Promise<void> {
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
if (!this.gatewayManager) {
logger.debug(`[AgentClients] ${this.name}: gatewayManager is null, skipping`)
return
}
const upstream = this.gatewayManager.getUpstream(this.profile)
const apiKey = this.gatewayManager.getApiKey(this.profile)
logger.debug(`[AgentClients] ${this.name}: upstream=${upstream}, profile=${this.profile}`)
if (!upstream) {
logger.error(`[AgentClients] ${this.name}: no gateway upstream for profile "${this.profile}"`)
return
}
try {
// Notify room that agent is typing
this.startTyping(roomId)
@@ -244,8 +285,8 @@ class AgentClient {
roomName: roomId,
memberNames,
members,
upstream,
apiKey,
upstream: '',
apiKey: null,
currentMessage: msg,
compression,
profile: this.profile,
@@ -261,86 +302,101 @@ class AgentClient {
}
}
// Strip @mention from input — agent already knows it was mentioned
const input = msg.content.replace(new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'), '').trim() || msg.content
const responseRes = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
// Keep the original mentions visible and add an explicit routing note.
// When a user mentions multiple agents, stripping only this agent's
// name can make the remaining input look like it was meant for
// someone else.
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
const rawInput = msg.input || msg.content
const input = isContentBlockArray(rawInput)
? rawInput.map((block) => {
if (block.type !== 'text') return block
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
const bridge = new AgentBridgeClient()
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
const flushedAssistantParts = new Set<string>()
let lastChunk: AgentBridgeOutput | null = null
const started = await bridge.chat(
sessionId,
bridgeInput,
conversationHistory,
instructions,
this.profile,
{
source: 'api_server',
},
body: JSON.stringify({
input,
...(conversationHistory.length > 0 ? { conversation_history: conversationHistory } : {}),
...(instructions ? { instructions } : {}),
stream: true,
store: false,
}),
signal: AbortSignal.timeout(120000),
})
)
if (!responseRes.ok) {
const text = await responseRes.text().catch(() => '')
logger.error(`[AgentClients] ${this.name}: gateway response failed (${responseRes.status}): ${text}`)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (!responseRes.body) {
logger.error(`[AgentClients] ${this.name}: gateway response stream missing`)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
let fullContent = ''
for await (const frame of readSseFrames(responseRes.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
logger.debug(`[AgentClients] ${this.name}: event=${eventType}`)
if (eventType === 'response.output_text.delta' && parsed.delta) {
fullContent += parsed.delta
continue
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!fullContent && finalText) fullContent = finalText
const usage = response.usage || {}
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
profile: this.profile,
})
logger.debug(`[AgentClients] ${this.name}: response completed, content length=${fullContent.length}`)
if (fullContent) {
this.stopTyping(roomId)
this.sendMessage(roomId, fullContent)
this.emitMessageStreamStart(roomId, streamMessageId)
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
lastChunk = chunk
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
const toolBaseId = streamMessageId
if (currentContent.trim()) {
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
flushedAssistantParts.add(streamMessageId)
currentContent = ''
}
onStatus?.('ready')
return
}
if (eventType === 'response.failed') {
logger.error(`[AgentClients] ${this.name}: response failed`)
this.stopTyping(roomId)
onStatus?.('ready')
return
this.emitMessageStreamEnd(roomId, toolBaseId)
partIndex += 1
streamMessageId = groupMessagePartId(runMessageId, partIndex)
this.emitMessageStreamStart(roomId, streamMessageId)
return toolBaseId
})
if (chunk.delta) {
currentContent += chunk.delta
totalContent += chunk.delta
this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta)
}
}
logger.warn(`[AgentClients] ${this.name}: response stream ended without terminal event`)
if (lastChunk?.status === 'error') {
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (!totalContent) {
currentContent = extractBridgeFinalText(lastChunk)
totalContent = currentContent
}
recordBridgeUsage(roomId, this.profile, lastChunk?.result)
logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`)
if (currentContent) {
this.stopTyping(roomId)
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
this.emitMessageStreamEnd(roomId, streamMessageId)
onStatus?.('ready')
return
}
logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
} catch (err: any) {
@@ -350,6 +406,132 @@ class AgentClient {
}
}
private async recordBridgeEvents(
roomId: string,
chunk: AgentBridgeOutput,
getCurrentMessageId: () => string,
beforeToolStarted: () => Promise<string>,
): Promise<string> {
let reasoning = ''
for (const ev of chunk.events || []) {
const eventType = String((ev as any)?.event || '')
if (eventType === 'tool.started') {
const toolBaseId = await beforeToolStarted()
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
} else if (eventType === 'tool.completed') {
this.recordToolCompleted(roomId, ev as Record<string, unknown>)
} else if (eventType === 'approval.requested') {
this.emitApprovalRequested(roomId, {
event: 'approval.requested',
approval_id: (ev as any).approval_id,
command: (ev as any).command,
description: (ev as any).description,
choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined,
allow_permanent: (ev as any).allow_permanent,
})
} else if (eventType === 'approval.resolved') {
this.emitApprovalResolved(roomId, {
event: 'approval.resolved',
approval_id: (ev as any).approval_id,
choice: (ev as any).choice,
})
} else if (eventType === 'reasoning.delta' || eventType === 'thinking.delta') {
const text = String((ev as any)?.text || '')
reasoning += text
this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text)
}
}
return reasoning
}
private recordToolStarted(roomId: string, ev: Record<string, unknown>, runMessageId: string): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName))
this.trackPendingToolCall(roomId, toolName, toolCallId)
this.pendingToolBaseIds.set(toolCallId, runMessageId)
const timestamp = Date.now()
const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {}
const args = normalizeToolArgs(rawArgs)
const toolCall = {
id: toolCallId,
type: 'function',
function: {
name: toolName,
arguments: JSON.stringify(args),
},
}
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolcall_${safeId(toolCallId)}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: '',
timestamp,
role: 'assistant',
tool_calls: [toolCall],
finish_reason: 'tool_calls',
}
this.sendMessage(roomId, '', msg.id, {
role: 'assistant',
tool_calls: msg.tool_calls,
finish_reason: 'tool_calls',
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`))
}
private recordToolCompleted(roomId: string, ev: Record<string, unknown>): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const rawId = String(ev.tool_call_id || '').trim()
const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName))
const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0)
this.pendingToolBaseIds.delete(toolCallId)
const output = bridgeToolOutput(ev)
const timestamp = Date.now()
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: output,
timestamp,
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
}
this.sendMessage(roomId, output, msg.id, {
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`))
}
private pendingToolKey(roomId: string, toolName: string): string {
return `${roomId}::${toolName || 'tool'}`
}
private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key) || []
list.push(toolCallId)
this.pendingToolCallIds.set(key, list)
}
private takePendingToolCall(roomId: string, toolName: string): string | undefined {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key)
if (!list?.length) return undefined
const id = list.shift()
if (list.length) this.pendingToolCallIds.set(key, list)
else this.pendingToolCallIds.delete(key)
return id
}
private nextToolIndex(roomId: string, toolName: string): number {
const key = this.pendingToolKey(roomId, toolName)
return (this.pendingToolCallIds.get(key)?.length || 0) + 1
}
private bindEvents(): void {
const s = this.socket!
@@ -387,77 +569,79 @@ class AgentClient {
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
}
function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string {
const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120)
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
function groupMessageId(roomId: string, profile: string, name: string): string {
const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160)
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
function groupMessagePartId(runMessageId: string, partIndex: number): string {
return `${safeId(runMessageId)}_part_${partIndex}`
}
function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string {
const raw = String(rawToolCallId || '').trim()
if (raw) return raw
return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}`
}
function safeId(value: string): string {
return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
}
function bridgeToolOutput(ev: Record<string, unknown>): string {
const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? ''
return typeof value === 'string' ? value : JSON.stringify(value ?? '')
}
function normalizeToolArgs(value: unknown): Record<string, unknown> {
if (!value) return {}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : { value }
} catch {
return { value }
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
}
function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string {
const result = chunk?.result as any
const output = result?.final_response || chunk?.output || ''
return typeof output === 'string' ? output.trim() : ''
}
function recordBridgeUsage(roomId: string, profile: string, result: unknown): void {
const payload = result as any
const usage = payload?.usage || payload?.response?.usage
if (!usage) return
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: payload?.model || payload?.response?.model || '',
profile,
})
}
// ─── AgentClients (roomId -> agents) ──────────────────────────
export class AgentClients {
private rooms = new Map<string, Map<string, AgentClient>>()
private _gatewayManager: GatewayManager | null = null
private _contextEngine: any = null
private _storage: any = null
// Per-room processing lock + mention queue
private _processingRooms = new Set<string>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: { content: string; senderName: string; senderId: string; timestamp: number } }>>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: MentionMessage }>>()
/**
* Create an agent client and connect it to the server.
@@ -468,7 +652,6 @@ export class AgentClients {
await client.connect(port)
// Auto-apply stored references (fixes propagation for agents created after set*)
if (this._gatewayManager) client.setGatewayManager(this._gatewayManager)
if (this._contextEngine) client.setContextEngine(this._contextEngine)
if (this._storage) client.setStorage(this._storage)
@@ -557,6 +740,13 @@ export class AgentClients {
return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content)))
}
async interruptAgent(roomId: string, agentName: string): Promise<void> {
const agent = this.getAgents(roomId).find(a => a.name === agentName)
if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`)
this._mentionQueue.delete(`${roomId}:${agent.name}`)
await agent.interrupt(roomId)
}
/**
* Disconnect all agents in a room.
*/
@@ -576,7 +766,12 @@ export class AgentClients {
resetRoomContext(roomId: string): void {
this._mentionQueue.delete(roomId)
this._processingRooms.delete(roomId)
for (const key of Array.from(this._mentionQueue.keys())) {
if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key)
}
for (const key of Array.from(this._processingRooms)) {
if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key)
}
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
@@ -593,16 +788,6 @@ export class AgentClients {
logger.info('[AgentClients] All agents disconnected')
}
/**
* Set gateway manager for all existing and future agents.
*/
setGatewayManager(manager: GatewayManager): void {
this._gatewayManager = manager
this.rooms.forEach((room) => {
room.forEach((client) => client.setGatewayManager(manager))
})
}
/**
* Set context engine for all existing and future agents.
*/
@@ -628,13 +813,14 @@ export class AgentClients {
* Server-side: parse @mentions and forward to matching agents directly.
* If the room is already processing (compressing/replying), queue the mention.
*/
async processMentions(roomId: string, msg: { content: string; senderName: string; senderId: string; timestamp: number }): Promise<void> {
if (!this._gatewayManager) return
const content = msg.content.toLowerCase()
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
const agents = this.getAgents(roomId)
const senderName = msg.senderName.toLowerCase()
const mentioned = agents.filter(a => content.includes(`@${a.name.toLowerCase()}`))
const mentioned = agents.filter(a => (
a.name.toLowerCase() !== senderName &&
isAgentMentioned(msg.content, a.name)
))
if (mentioned.length === 0) return
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
@@ -652,7 +838,7 @@ export class AgentClients {
private async _processAgentMention(
roomId: string,
agent: AgentClient,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
): Promise<void> {
const agentKey = `${roomId}:${agent.name}`
if (this._processingRooms.has(agentKey)) {
@@ -693,9 +879,16 @@ export class AgentClients {
// Process the last queued mention only (most recent, discards stale intermediate ones)
const last = queue[queue.length - 1]
this._processingRooms.add(agentKey)
this._processAgentMention(roomId, last.agent, last.msg).catch((err) => {
logger.error(`[AgentClients] error processing queued mention: ${err.message}`)
})
await this._processAgentMention(roomId, last.agent, last.msg)
}
}
function nextMentionDepth(msg: MentionMessage): number {
return Math.max(0, msg.mentionDepth || 0) + 1
}
function isAgentMentioned(content: string, agentName: string): boolean {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
return pattern.test(content)
}
@@ -6,6 +6,8 @@ import { getDb } from '../../../db'
import { AgentClients } from './agent-clients'
import { ContextEngine } from '../context-engine/compressor'
import { SessionDeleter } from '../session-deleter'
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
import { AgentBridgeClient } from '../agent-bridge'
// ─── Types ────────────────────────────────────────────────────
@@ -16,6 +18,43 @@ interface ChatMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
mentionDepth?: number
}
function contentToStorageString(content: unknown): string {
if (typeof content === 'string') return content
return JSON.stringify(content ?? '')
}
function contentToText(content: unknown): string {
if (typeof content === 'string') {
const trimmed = content.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return contentToText(JSON.parse(trimmed))
} catch {
return content
}
}
return content
}
if (Array.isArray(content)) {
return content.map((block: any) => {
if (block?.type === 'text') return block.text || ''
if (block?.type === 'image') return `[Image: ${block.name || block.path || ''}]`
if (block?.type === 'file') return `[File: ${block.name || block.path || ''}]`
return ''
}).filter(Boolean).join('\n')
}
return content == null ? '' : String(content)
}
interface RoomAgent {
@@ -64,6 +103,64 @@ export interface PendingSessionDeleteDrainResult {
failed: Array<{ sessionId: string; error: string }>
}
function parseJsonArray(value: unknown): any[] | null {
if (value == null || value === '') return null
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function normalizeMessageRole(role: unknown): string {
const value = String(role || '').trim()
return ['user', 'assistant', 'tool', 'command'].includes(value) ? value : 'user'
}
function normalizeMentionDepth(depth: unknown): number {
const value = Number(depth)
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
function groupRunOrder(id: string): { baseId: string; phase: number } {
const value = String(id || '')
const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/)
if (partMatch) {
const part = Number(partMatch[2] || 0)
const kind = partMatch[3] || 'assistant'
const offset = kind === 'toolcall' ? 1 : kind === 'toolresult' ? 2 : 0
return { baseId: partMatch[1], phase: part * 3 + offset }
}
const toolIdx = value.indexOf('_toolcall_')
if (toolIdx >= 0) return { baseId: value.slice(0, toolIdx), phase: 0 }
const resultIdx = value.indexOf('_toolresult_')
if (resultIdx >= 0) return { baseId: value.slice(0, resultIdx), phase: 1 }
return { baseId: value, phase: 2 }
}
function sortGroupMessages<T extends { id: string; timestamp: number }>(messages: T[]): T[] {
const baseMinTimestamp = new Map<string, number>()
for (const msg of messages) {
const { baseId } = groupRunOrder(msg.id)
const existing = baseMinTimestamp.get(baseId)
if (existing == null || msg.timestamp < existing) baseMinTimestamp.set(baseId, msg.timestamp)
}
return [...messages].sort((a, b) => {
const ao = groupRunOrder(a.id)
const bo = groupRunOrder(b.id)
const at = baseMinTimestamp.get(ao.baseId) ?? a.timestamp
const bt = baseMinTimestamp.get(bo.baseId) ?? b.timestamp
if (at !== bt) return at - bt
if (ao.baseId !== bo.baseId) return ao.baseId.localeCompare(bo.baseId)
if (ao.phase !== bo.phase) return ao.phase - bo.phase
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return a.id.localeCompare(b.id)
})
}
class ChatStorage {
private db() { return getDb() }
@@ -175,16 +272,16 @@ class ChatStorage {
// ─── Rooms ────────────────────────────────────────────────
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE id = ?').get(roomId) as any
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any
}
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE inviteCode = ?').get(code) as any
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any
}
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms ORDER BY id').all() || []) as any[]
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[]
}
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
@@ -212,25 +309,132 @@ class ChatStorage {
this.db()?.prepare('UPDATE gc_rooms SET totalTokens = ? WHERE id = ?').run(tokens, roomId)
}
rotateRoomSessionSeed(roomId: string): string {
const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
this.db()?.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?').run(seed, roomId)
return seed
}
estimateTokens(text: string): number {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / 4)
}
private contentToUsageText(content: unknown): string {
if (typeof content === 'string') return content
if (!content) return ''
if (Array.isArray(content)) {
return content.map((block: any) => {
if (typeof block?.text === 'string') return block.text
if (typeof block?.type === 'string') return `[${block.type}]`
return String(block || '')
}).join('\n')
}
return String(content)
}
private estimateUsageTokensFromMessages(messages: ChatMessage[]): { inputTokens: number; outputTokens: number } {
const inputTokens = messages
.filter(m => (m.role || 'user') === 'user')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)), 0)
const outputTokens = messages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0)
return { inputTokens, outputTokens }
}
private estimateRoomTotalTokens(roomId: string, messages: ChatMessage[]): number {
const snapshot = this.getContextSnapshot(roomId)
if (snapshot && messages.length) {
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
const newMessages = snapshotIdx >= 0
? messages.slice(snapshotIdx + 1)
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
const newUsage = this.estimateUsageTokensFromMessages(newMessages)
return countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens + newUsage.outputTokens
}
const usage = this.estimateUsageTokensFromMessages(messages)
return usage.inputTokens + usage.outputTokens
}
// ─── Messages ─────────────────────────────────────────────
getMessages(roomId: string, limit = 500): ChatMessage[] {
const rows = (this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
).all(roomId, limit) || []) as any[]
return rows.reverse()
return sortGroupMessages(rows.map(row => ({
...row,
tool_calls: parseJsonArray(row.tool_calls),
})))
}
getMessage(messageId: string): ChatMessage | null {
const row = this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?'
).get(messageId) as any
if (!row) return null
return {
...row,
tool_calls: parseJsonArray(row.tool_calls),
}
}
addMessage(msg: ChatMessage): void {
this.upsertMessage(msg)
}
upsertMessage(msg: ChatMessage): void {
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
this.db()?.prepare(
'INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
).run(msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp)
`INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ` ON CONFLICT(id) DO UPDATE SET
roomId = excluded.roomId,
senderId = excluded.senderId,
senderName = excluded.senderName,
content = excluded.content,
timestamp = excluded.timestamp,
role = excluded.role,
tool_call_id = excluded.tool_call_id,
tool_calls = excluded.tool_calls,
tool_name = excluded.tool_name,
finish_reason = excluded.finish_reason,
reasoning = excluded.reasoning,
reasoning_details = excluded.reasoning_details,
reasoning_content = excluded.reasoning_content`
).run(
msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp,
msg.role || 'user',
msg.tool_call_id ?? null,
toolCallsJson,
msg.tool_name ?? null,
msg.finish_reason ?? null,
msg.reasoning ?? null,
msg.reasoning_details ?? null,
msg.reasoning_content ?? null,
)
}
saveMessageAndRefreshRoom(msg: ChatMessage, options: { preserveExistingTimestamp?: boolean } = {}): { message: ChatMessage; totalTokens: number } {
const db = this.db()
if (!db) return { message: msg, totalTokens: 0 }
db.exec('BEGIN IMMEDIATE')
try {
const existing = this.getMessage(msg.id)
const message = existing && options.preserveExistingTimestamp ? { ...msg, timestamp: existing.timestamp } : msg
this.upsertMessage(message)
this.pruneMessages(msg.roomId)
const messages = this.getMessages(msg.roomId)
const totalTokens = this.estimateRoomTotalTokens(msg.roomId, messages)
this.updateRoomTotalTokens(msg.roomId, totalTokens)
db.exec('COMMIT')
return { message, totalTokens }
} catch (err) {
try { db.exec('ROLLBACK') } catch { /* ignore */ }
throw err
}
}
clearRoomContext(roomId: string): void {
@@ -238,7 +442,7 @@ class ChatStorage {
if (!db) return
db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId)
db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0 WHERE id = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId)
}
pruneMessages(roomId: string, keep = 500): void {
@@ -419,13 +623,6 @@ export class GroupChatServer {
/** roomId -> (agentName -> { agentName, status }) */
private contextStatusState = new Map<string, Map<string, { agentName: string; status: string }>>()
setGatewayManager(manager: any): void {
this.agentClients.setGatewayManager(manager)
if (this._contextEngine && manager) {
this._contextEngine.setUpstream(manager.getUpstream(''), manager.getApiKey(''))
}
}
constructor(httpServers: HttpServer | HttpServer[]) {
this.storage = new ChatStorage()
this.storage.init()
@@ -569,10 +766,18 @@ export class GroupChatServer {
logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`)
socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack))
socket.on('message', (data: { roomId?: string; content: string }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message', (data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message_stream_start', (data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }) => this.handleMessageStreamStart(socket, data))
socket.on('message_stream_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageStreamDelta(socket, data))
socket.on('message_reasoning_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageReasoningDelta(socket, data))
socket.on('message_stream_end', (data: { roomId?: string; id?: string }) => this.handleMessageStreamEnd(socket, data))
socket.on('typing', (data: { roomId?: string }) => this.handleTyping(socket, data))
socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data))
socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data))
socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack))
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data))
socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data))
socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack))
socket.on('disconnect', () => this.handleDisconnect(socket))
}
@@ -581,14 +786,18 @@ export class GroupChatServer {
private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId) || socketId
const userInfo = this.userInfoMap.get(userId) || { name: `User-${userId.slice(0, 6)}`, description: '' }
const userName = data.name || userInfo.name
const description = data.description || userInfo.description
const roomId = data.roomId || 'general'
const existingMember = this.storage.getMemberByUserId(roomId, userId)
const userInfo = this.userInfoMap.get(userId) || {
name: existingMember?.name || `User-${userId.slice(0, 6)}`,
description: existingMember?.description || '',
}
const userName = data.name || existingMember?.name || userInfo.name
const description = data.description || existingMember?.description || userInfo.description
// Update stored user info
this.userInfoMap.set(userId, { name: userName, description })
const roomId = data.roomId || 'general'
let room = this.rooms.get(roomId)
if (!room) {
room = new ChatRoom(roomId)
@@ -628,7 +837,7 @@ export class GroupChatServer {
logger.debug(`[GroupChat] ${userName} (user=${userId}) joined room: ${roomId}`)
}
private handleMessage(socket: Socket, data: { roomId?: string; content: string }, ack?: (res: any) => void): void {
private handleMessage(socket: Socket, data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (res: any) => void): void {
const socketId = socket.id
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
@@ -643,37 +852,105 @@ export class GroupChatServer {
const userName = member?.name || `User-${socketId.slice(0, 6)}`
const msg: ChatMessage = {
id: this.generateId(),
id: this.normalizeClientMessageId(data.id) || this.generateId(),
roomId,
senderId: userId,
senderName: userName,
content: data.content,
timestamp: Date.now(),
content: contentToStorageString(data.content),
timestamp: this.normalizeMessageTimestamp(data.timestamp, data.role),
role: normalizeMessageRole(data.role),
tool_call_id: data.tool_call_id ?? null,
tool_calls: Array.isArray(data.tool_calls) ? data.tool_calls : null,
tool_name: data.tool_name ?? null,
finish_reason: data.finish_reason ?? null,
reasoning: data.reasoning ?? null,
reasoning_details: data.reasoning_details ?? null,
reasoning_content: data.reasoning_content ?? null,
}
this.storage.addMessage(msg)
this.storage.pruneMessages(roomId)
const saved = this.storage.saveMessageAndRefreshRoom(msg)
const savedMsg = saved.message
const totalTokens = saved.totalTokens
// Recalculate total tokens for the room
const messages = this.storage.getMessages(roomId)
const totalTokens = this.storage.estimateTokens(messages.map(m => m.content + m.senderName).join(''))
this.storage.updateRoomTotalTokens(roomId, totalTokens)
this.nsp.to(roomId).emit('message', msg)
this.nsp.to(roomId).emit('message', savedMsg)
this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens })
ack?.({ id: msg.id })
ack?.({ id: savedMsg.id })
// Server-side @mention routing — parse mentions and invoke agents directly
this.agentClients.processMentions(roomId, {
content: msg.content,
senderName: msg.senderName,
senderId: msg.senderId,
timestamp: msg.timestamp,
}).catch((err) => {
logger.error(`[GroupChat] processMentions error: ${err.message}`)
const mentionDepth = normalizeMentionDepth(data.mentionDepth)
const shouldRouteMentions =
savedMsg.role === 'user' ||
(savedMsg.role === 'assistant' && mentionDepth < 2)
if (shouldRouteMentions) {
// Server-side @mention routing — parse user mentions and invoke agents directly.
this.agentClients.processMentions(roomId, {
content: contentToText(savedMsg.content),
input: Array.isArray(data.content) ? data.content : undefined,
senderName: savedMsg.senderName,
senderId: savedMsg.senderId,
timestamp: savedMsg.timestamp,
mentionDepth,
}).catch((err) => {
logger.error(`[GroupChat] processMentions error: ${err.message}`)
})
}
}
private handleMessageStreamStart(socket: Socket, data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
const member = room.getOnlineMemberBySocketId(socket.id)
this.nsp.to(roomId).emit('message_stream_start', {
id,
roomId,
senderId: data.senderId || member?.userId || socket.id,
senderName: data.senderName || member?.name || `User-${socket.id.slice(0, 6)}`,
content: '',
timestamp: data.timestamp || Date.now(),
role: 'assistant',
finish_reason: 'streaming',
})
}
private handleMessageStreamDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_stream_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageReasoningDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_reasoning_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageStreamEnd(socket: Socket, data: { roomId?: string; id?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
this.nsp.to(roomId).emit('message_stream_end', { roomId, id })
}
private handleTyping(socket: Socket, data: { roomId?: string }): void {
const roomId = data.roomId || 'general'
const userId = this.socketUserMap.get(socket.id) || socket.id
@@ -749,6 +1026,75 @@ export class GroupChatServer {
})
}
private async handleInterruptAgent(socket: Socket, data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
const agentName = data.agentName
if (!roomId || !agentName) {
ack?.({ error: 'roomId and agentName are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
await this.agentClients.interruptAgent(roomId, agentName)
this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' })
ack?.({ ok: true })
} catch (err: any) {
logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`)
ack?.({ error: err.message || 'interrupt failed' })
}
}
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.requested', {
event: 'approval.requested',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
command: data.command || '',
description: data.description || '',
choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'],
allow_permanent: Boolean(data.allow_permanent),
})
}
private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.resolved', {
event: 'approval.resolved',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
choice: data.choice || '',
})
}
private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
if (!roomId || !data.approval_id) {
ack?.({ error: 'roomId and approval_id are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny')
ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) })
} catch (err: any) {
logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`)
ack?.({ error: err.message || 'approval response failed' })
}
}
private handleDisconnect(socket: Socket): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId)
@@ -804,4 +1150,19 @@ export class GroupChatServer {
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
private normalizeClientMessageId(id?: string): string | null {
const cleaned = String(id || '').trim()
if (!cleaned || cleaned.length > 160) return null
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : null
}
private normalizeMessageTimestamp(timestamp?: unknown, role?: unknown): number {
const normalizedRole = normalizeMessageRole(role)
if (normalizedRole !== 'user') {
const value = Number(timestamp)
if (Number.isFinite(value) && value > 0) return value
}
return Date.now()
}
}
+215 -14
View File
@@ -1,12 +1,20 @@
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { logger } from '../logger'
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { isGatewayRunningForProfile } from './gateway-autostart'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
const isTermux = !!process.env.TERMUX_VERSION ||
(process.env.PREFIX || '').includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
/**
* Hermes CLI
@@ -18,6 +26,156 @@ function resolveHermesBin(): string {
const HERMES_BIN = resolveHermesBin()
async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await isGatewayRunningForProfile(HERMES_BIN, profileDir)) return true
await new Promise(resolve => setTimeout(resolve, 500))
}
return false
}
async function stopGatewayForActiveProfile(): Promise<void> {
try {
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
} catch (err) {
logger.warn(err, 'hermes gateway stop before restart failed; continuing with run --replace')
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err: any) {
return err?.code === 'EPERM'
}
}
function readJsonPid(path: string): number | null {
if (!existsSync(path)) return null
try {
const data = JSON.parse(readFileSync(path, 'utf-8'))
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch {
return null
}
}
function readGatewayLockPid(profileDir: string): number | null {
return readJsonPid(join(profileDir, 'gateway.lock'))
}
function readGatewayStatePid(profileDir: string): number | null {
const pid = readJsonPid(join(profileDir, 'gateway.pid'))
if (pid) return pid
const statePath = join(profileDir, 'gateway_state.json')
if (!existsSync(statePath)) return null
try {
const data = JSON.parse(readFileSync(statePath, 'utf-8'))
const state = data?.gateway_state
const statePid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return statePid && Number.isFinite(statePid) && statePid > 0 && (state === 'running' || state === 'starting')
? statePid
: null
} catch {
return null
}
}
async function killWindowsPid(pid: number): Promise<void> {
if (!pid || process.platform !== 'win32') return
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
timeout: 5000,
windowsHide: true,
})
} catch (err) {
logger.warn(err, 'Failed to taskkill gateway PID %d; falling back to process.kill', pid)
try { process.kill(pid) } catch {}
}
}
function cleanupStaleGatewayLock(profileDir: string, allowMalformedDelete = false): boolean {
const lockPath = join(profileDir, 'gateway.lock')
if (!existsSync(lockPath)) return true
try {
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'))
const pid = Number(lockData?.pid)
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return false
unlinkSync(lockPath)
return true
} catch {
if (!allowMalformedDelete) return false
try {
unlinkSync(lockPath)
return true
} catch {
return false
}
}
}
async function waitForGatewayLockReleased(profileDir: string, timeoutMs = 15000, allowMalformedDelete = false): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (cleanupStaleGatewayLock(profileDir, allowMalformedDelete)) return true
await sleep(500)
}
return cleanupStaleGatewayLock(profileDir, allowMalformedDelete)
}
async function forceReleaseWindowsGatewayLock(profileDir: string): Promise<void> {
if (process.platform !== 'win32') return
const pids = new Set<number>()
const lockPid = readGatewayLockPid(profileDir)
const statePid = readGatewayStatePid(profileDir)
if (lockPid) pids.add(lockPid)
if (statePid) pids.add(statePid)
for (const pid of pids) {
if (isProcessAlive(pid)) {
logger.warn('Gateway lock is still held by PID %d; force killing Windows process tree', pid)
await killWindowsPid(pid)
}
}
}
async function waitForGatewayLockReleasedAfterStop(profileDir: string, timeoutMs = 15000): Promise<boolean> {
if (await waitForGatewayLockReleased(profileDir, timeoutMs)) return true
await forceReleaseWindowsGatewayLock(profileDir)
return waitForGatewayLockReleased(profileDir, 5000, true)
}
function activeGatewayExecOpts() {
return {
...execOpts,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
}
}
async function clearLegacyApiServerGatewayConfig(): Promise<void> {
try {
await updateConfigYaml((config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
})
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before restart')
}
}
export interface HermesSession {
id: string
source: string
@@ -210,6 +368,26 @@ export async function deleteSession(id: string): Promise<boolean> {
}
}
/**
* Delete a session from a specific Hermes profile.
*/
export async function deleteSessionForProfile(id: string, profile: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
env: {
...process.env,
HERMES_HOME: getProfileDir(profile),
},
})
return true
} catch (err: any) {
logger.error({ err, sessionId: id, profile }, 'Hermes CLI: profile session delete failed')
return false
}
}
/**
* Rename a session title via Hermes CLI
*/
@@ -255,7 +433,7 @@ export async function startGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -269,22 +447,49 @@ export async function startGatewayBackground(): Promise<number | null> {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
})
child.unref()
return child.pid ?? null
}
/**
* Restart Hermes gateway (stop then start)
* Restart Hermes gateway through Hermes CLI, falling back to detached
* `gateway run` when the environment does not support `gateway restart`.
*/
export async function restartGateway(): Promise<string> {
try {
await stopGateway()
} catch (err) {
// Ignore stop errors, gateway might not be running
await clearLegacyApiServerGatewayConfig()
const profileDir = getActiveProfileDir()
if (isDocker || isTermux || process.platform === 'win32') {
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway stopped but runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run replace triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced'
}
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error('Hermes gateway restart completed but gateway did not report running within timeout')
return stdout || stderr
} catch (err: any) {
logger.warn(err, 'hermes gateway restart failed; falling back to gateway run')
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway restart failed and runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run fallback triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run started (PID: ${result.pid})` : 'Gateway run started'
}
const result = await startGateway()
return result
}
/**
@@ -293,7 +498,7 @@ export async function restartGateway(): Promise<string> {
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -363,7 +568,6 @@ export interface HermesProfile {
name: string
active: boolean
model: string
gateway: string
alias: string
}
@@ -372,7 +576,6 @@ export interface HermesProfileDetail {
path: string
model: string
provider: string
gateway: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
@@ -403,7 +606,6 @@ export async function listProfiles(): Promise<HermesProfile[]> {
name: match[2],
active: !!match[1],
model: match[3],
gateway: match[4],
alias: match[5].trim() === '—' ? '' : match[5].trim(),
})
}
@@ -443,7 +645,6 @@ export async function getProfile(name: string): Promise<HermesProfileDetail> {
path: result.path || '',
model,
provider: providerMatch ? providerMatch[1] : '',
gateway: result.gateway || '',
skills: parseInt(result.skills || '0', 10),
hasEnv: result['.env'] === 'exists',
hasSoulMd: result['soul.md'] === 'exists',
@@ -7,7 +7,7 @@
* - 用户自定义: HERMES_HOME
*/
import { basename, dirname, resolve, join } from 'path'
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
import { homedir } from 'os'
/**
@@ -62,3 +62,20 @@ export function getHermesBin(customBin?: string): string {
if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim()
return 'hermes'
}
function comparablePath(path: string): string {
return process.platform === 'win32' ? path.toLowerCase() : path
}
export function isPathWithin(targetPath: string, basePath: string): boolean {
const base = resolve(basePath)
const target = resolve(targetPath)
const rel = relative(comparablePath(base), comparablePath(target))
return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel))
}
export function relativePathFromBase(targetPath: string, basePath: string): string | null {
if (!isPathWithin(targetPath, basePath)) return null
const rel = relative(resolve(basePath), resolve(targetPath))
return rel.replace(/\\/g, '/')
}
@@ -1,5 +1,5 @@
import { join } from 'path'
import { readFileSync, existsSync } from 'fs'
import { readFileSync, existsSync, readdirSync } from 'fs'
import { detectHermesRootHome } from './hermes-path'
export function getHermesBaseDir(): string {
@@ -69,3 +69,21 @@ export function getProfileDir(name: string): string {
const dir = join(hermesBase, 'profiles', name)
return existsSync(dir) ? dir : hermesBase
}
export function listProfileNamesFromDisk(): string[] {
const hermesBase = getHermesBaseDir()
const names = new Set<string>(['default'])
const profilesDir = join(hermesBase, 'profiles')
try {
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.trim()) {
names.add(entry.name)
}
}
} catch {}
return [...names].sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return a.localeCompare(b)
})
}
@@ -10,6 +10,12 @@ const HERMES_BASE = detectHermesHome()
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
const DEFAULT_CONTEXT_LENGTH = 200_000
export interface ModelContextLengthOptions {
profile?: string
model?: string | null
provider?: string | null
}
interface ModelLimit {
context?: number
output?: number
@@ -351,15 +357,19 @@ function lookupContextFromDatabase(modelName: string, provider: string | null):
}
}
export function getModelContextLength(profile?: string): number {
export function getModelContextLength(input?: string | ModelContextLengthOptions): number {
const options: ModelContextLengthOptions = typeof input === 'string'
? { profile: input }
: input || {}
const profile = options.profile
const profileDir = getProfileDir(profile)
const config = loadConfig(profileDir)
if (!config) return DEFAULT_CONTEXT_LENGTH
const model = getDefaultModel(config)
const model = String(options.model || '').trim() || getDefaultModel(config)
if (!model) return DEFAULT_CONTEXT_LENGTH
const provider = getDefaultProvider(config)
const provider = String(options.provider || '').trim() || getDefaultProvider(config)
// 0. Database model_context table (highest priority)
const dbCtx = lookupContextFromDatabase(model, provider)
@@ -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<{
@@ -0,0 +1,97 @@
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import { getActiveProfileDir } from './hermes-profile'
import { logger } from '../logger'
export interface SkillInjectionResult {
sourceDir: string
targetDir: string
injected: string[]
updated: string[]
skipped: string[]
}
export class HermesSkillInjector {
constructor(
private readonly sourceDir = HermesSkillInjector.resolveSourceDir(),
private readonly targetDir = join(getActiveProfileDir(), 'skills'),
) {}
static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string {
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
if (override) return resolve(override)
const candidates = [
// Production bundle: dist/server/index.js with dist/skills copied by build.
resolve(baseDir, '../skills'),
// Development/test: packages/server/src/services/hermes -> packages/skills.
resolve(baseDir, '../../../../skills'),
// Running from repository root without bundling.
resolve(process.cwd(), 'packages/skills'),
]
return candidates.find(candidate => existsSync(candidate)) || candidates[0]
}
async injectMissingSkills(): Promise<SkillInjectionResult> {
const result: SkillInjectionResult = {
sourceDir: this.sourceDir,
targetDir: this.targetDir,
injected: [],
updated: [],
skipped: [],
}
if (!await this.isDirectory(this.sourceDir)) {
logger.debug('[skill-injector] no bundled skills directory at %s', this.sourceDir)
return result
}
await mkdir(this.targetDir, { recursive: true })
const entries = await readdir(this.sourceDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const sourceSkillDir = join(this.sourceDir, entry.name)
const targetSkillDir = join(this.targetDir, entry.name)
const existed = existsSync(targetSkillDir)
if (existsSync(targetSkillDir)) {
await rm(targetSkillDir, { recursive: true, force: true })
}
await this.copyDir(sourceSkillDir, targetSkillDir)
if (existed) result.updated.push(entry.name)
else result.injected.push(entry.name)
}
if (result.injected.length > 0 || result.updated.length > 0) {
logger.info({
injected: result.injected,
updated: result.updated,
targetDir: this.targetDir,
}, '[skill-injector] synced bundled skills')
}
return result
}
private async isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
private async copyDir(sourceDir: string, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const entries = await readdir(sourceDir, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = join(sourceDir, entry.name)
const targetPath = join(targetDir, entry.name)
if (entry.isDirectory()) {
await this.copyDir(sourcePath, targetPath)
} else if (entry.isFile()) {
await copyFile(sourcePath, targetPath)
}
}
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import pino from 'pino'
import { resolve } from 'path'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { mkdirSync, statSync, truncateSync, openSync, readSync, closeSync, writeFileSync } from 'fs'
import { config } from '../config'
const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB
const CHECK_INTERVAL = 60_000 // Check every minute
const logDir = resolve(config.appHome, 'logs')
const logDir = process.env.VITEST
? resolve(tmpdir(), 'hermes-web-ui-test-logs', String(process.pid))
: resolve(config.appHome, 'logs')
mkdirSync(logDir, { recursive: true })
const logFile = resolve(logDir, 'server.log')
-43
View File
@@ -1,31 +1,5 @@
import { logger } from './logger'
import { closeDb } from '../db'
import { getGatewayManagerInstance } from './gateway-bootstrap'
function shouldStopGatewaysOnShutdown(signal: string): boolean {
// nodemon may use SIGTERM on Windows restarts, so dev mode opts out via env.
// Production keeps stopping owned gateways by default.
const override = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN?.trim()
console.log(`[shutdown] Signal: ${signal}, HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN: ${override}`)
// Explicit '0' or 'false' means dev mode: never stop gateways
if (override === '0' || override === 'false') {
console.log('[shutdown] Dev mode detected: NOT stopping gateways')
return false
}
// Explicit '1' or 'true' means always stop gateways
if (override === '1' || override === 'true') {
console.log('[shutdown] Explicit gateway shutdown enabled: stopping gateways')
return true
}
// Default behavior: only stop gateways on explicit termination, not on reload
const shouldStop = signal !== 'SIGUSR2'
console.log(`[shutdown] Default behavior: ${shouldStop ? 'STOPPING' : 'NOT stopping'} gateways (signal: ${signal})`)
return shouldStop
}
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
let isShuttingDown = false
@@ -39,25 +13,8 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
logger.info('Shutting down (%s)...', signal)
console.log(`[shutdown] Received signal: ${signal}`)
console.log(`[shutdown] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN = ${process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN}`)
console.log(`[shutdown] shouldStopGatewaysOnShutdown = ${shouldStopGatewaysOnShutdown(signal)}`)
try {
if (shouldStopGatewaysOnShutdown(signal)) {
// Stop gateway processes owned by this Web UI instance first.
try {
const gatewayManager = getGatewayManagerInstance()
if (gatewayManager) {
await gatewayManager.stopAll()
logger.info('All gateways stopped')
}
} catch (err) {
logger.warn(err, 'Failed to stop gateways (non-fatal)')
}
} else {
logger.info('Skipping gateway shutdown for %s', signal)
}
if (agentBridgeManager) {
try {
await agentBridgeManager.stop()