Update CLI chat session bridge (#697)
* feat: add CLI chat sessions with Python agent bridge Introduce a new CLI chat mode that connects Web UI directly to Hermes Agent's AIAgent via a Python bridge subprocess and Socket.IO, bypassing the API Server /v1/responses path. Supports streaming, slash commands (/new, /undo, /retry, /branch, /compress, /save, /title), interrupt, and steer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: update CLI chat session bridge * fix: extend agent bridge startup timeouts * docs: update bridge chat session design * feat: align bridge compression and provider registry * chore: bump version to 0.5.20 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# Agent Bridge
|
||||
|
||||
Optional backend-side bridge for talking to `~/.hermes/hermes-agent` by
|
||||
instantiating `run_agent.AIAgent` directly in a Python process.
|
||||
|
||||
This is intentionally separate from the current Web UI chat path.
|
||||
|
||||
## Python Service
|
||||
|
||||
```bash
|
||||
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
|
||||
Default endpoint:
|
||||
|
||||
```text
|
||||
ipc:///tmp/hermes-agent-bridge.sock
|
||||
```
|
||||
|
||||
On Windows, the default endpoint is TCP because Python may not support Unix
|
||||
domain sockets there:
|
||||
|
||||
```text
|
||||
tcp://127.0.0.1:18765
|
||||
```
|
||||
|
||||
Override with:
|
||||
|
||||
```bash
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT=tcp://127.0.0.1:8765 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
|
||||
The service discovers Hermes Agent in this order:
|
||||
|
||||
1. `--agent-root`
|
||||
2. `HERMES_AGENT_ROOT`
|
||||
3. the installed `hermes` command path
|
||||
4. current working directory and parent directories
|
||||
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
|
||||
|
||||
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
|
||||
|
||||
Default agent root:
|
||||
|
||||
```text
|
||||
~/.hermes/hermes-agent
|
||||
```
|
||||
|
||||
You can pass both paths explicitly:
|
||||
|
||||
```bash
|
||||
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
|
||||
--agent-root ~/.hermes/hermes-agent \
|
||||
--hermes-home ~/.hermes
|
||||
```
|
||||
|
||||
The socket transport uses Python and Node standard libraries. No ZMQ dependency
|
||||
is required.
|
||||
|
||||
## Backend Usage
|
||||
|
||||
```ts
|
||||
import { AgentBridgeClient } from './services/hermes/agent-bridge'
|
||||
|
||||
const bridge = new AgentBridgeClient()
|
||||
const run = await bridge.chat(sessionId, message)
|
||||
|
||||
for await (const chunk of bridge.streamOutput(run.run_id)) {
|
||||
if (chunk.delta) {
|
||||
// forward chunk.delta to Socket.IO/SSE/etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The external chat call only sends `session_id` and `message`. Provider, model,
|
||||
keys, tools, reasoning, and session DB are resolved by hermes-agent from the
|
||||
normal Hermes config and environment.
|
||||
|
||||
The bridge instantiates `AIAgent` with `platform="cli"` by default so behavior
|
||||
matches CLI chat. Override it only if a caller intentionally needs a distinct
|
||||
platform identity:
|
||||
|
||||
```bash
|
||||
HERMES_AGENT_BRIDGE_PLATFORM=agent-bridge python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
@@ -0,0 +1,330 @@
|
||||
import { setTimeout as delay } from 'timers/promises'
|
||||
import { createConnection, type Socket } from 'net'
|
||||
import { URL } from 'url'
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
: 'ipc:///tmp/hermes-agent-bridge.sock'
|
||||
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export type AgentBridgeStatus = 'running' | 'complete' | 'interrupted' | 'error'
|
||||
|
||||
export interface AgentBridgeOptions {
|
||||
endpoint?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface AgentBridgeRequestOptions {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type AgentBridgeMessage =
|
||||
| string
|
||||
| Array<Record<string, unknown>>
|
||||
|
||||
export interface AgentBridgeResponse {
|
||||
ok: true
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AgentBridgeChatStarted extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
}
|
||||
|
||||
export interface AgentBridgeOutput extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
delta: string
|
||||
cursor: number
|
||||
output: string
|
||||
done: boolean
|
||||
result?: unknown
|
||||
error?: string | null
|
||||
events: Array<Record<string, unknown>>
|
||||
event_cursor: number
|
||||
}
|
||||
|
||||
export interface AgentBridgeRunResult extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
output: string
|
||||
deltas: string[]
|
||||
events: unknown[]
|
||||
result?: unknown
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface AgentBridgeCommandResult extends AgentBridgeResponse {
|
||||
session_id: string
|
||||
command: string
|
||||
handled: boolean
|
||||
message?: string
|
||||
new_session_id?: string
|
||||
history?: unknown[]
|
||||
retry?: boolean
|
||||
retry_input?: AgentBridgeMessage
|
||||
title?: string
|
||||
}
|
||||
|
||||
export class AgentBridgeError extends Error {
|
||||
response?: unknown
|
||||
}
|
||||
|
||||
export class AgentBridgeClient {
|
||||
readonly endpoint: string
|
||||
readonly timeoutMs: number
|
||||
private lock: Promise<unknown> = Promise.resolve()
|
||||
|
||||
constructor(options: AgentBridgeOptions = {}) {
|
||||
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
|
||||
this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
return this
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private connectSocket(): Promise<Socket> {
|
||||
return new Promise((resolveConnect, rejectConnect) => {
|
||||
const endpoint = this.endpoint
|
||||
let socket: Socket
|
||||
if (endpoint.startsWith('ipc://')) {
|
||||
socket = createConnection(endpoint.slice('ipc://'.length))
|
||||
} else if (endpoint.startsWith('tcp://')) {
|
||||
const url = new URL(endpoint)
|
||||
socket = createConnection({
|
||||
host: url.hostname || '127.0.0.1',
|
||||
port: Number(url.port),
|
||||
})
|
||||
} else {
|
||||
rejectConnect(new Error(`unsupported agent bridge endpoint: ${endpoint}`))
|
||||
return
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
socket.off('connect', onConnect)
|
||||
socket.off('error', onError)
|
||||
}
|
||||
const onConnect = () => {
|
||||
cleanup()
|
||||
resolveConnect(socket)
|
||||
}
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectConnect(err)
|
||||
}
|
||||
socket.once('connect', onConnect)
|
||||
socket.once('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
private readResponse(socket: Socket, timeoutMs: number): Promise<string> {
|
||||
return new Promise((resolveRead, rejectRead) => {
|
||||
let buffer = ''
|
||||
const timeout = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectRead(new Error(`Agent bridge request timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
: null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
socket.off('error', onError)
|
||||
socket.off('end', onEnd)
|
||||
socket.off('close', onClose)
|
||||
}
|
||||
const finish = (line: string) => {
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolveRead(line)
|
||||
}
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const idx = buffer.indexOf('\n')
|
||||
if (idx >= 0) finish(buffer.slice(0, idx))
|
||||
}
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectRead(err)
|
||||
}
|
||||
const onEnd = () => {
|
||||
const line = buffer.trim()
|
||||
if (line) finish(line)
|
||||
}
|
||||
const onClose = () => {
|
||||
if (!buffer.trim()) {
|
||||
cleanup()
|
||||
rejectRead(new Error('Agent bridge socket closed without a response'))
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('data', onData)
|
||||
socket.once('error', onError)
|
||||
socket.once('end', onEnd)
|
||||
socket.once('close', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
async request<T extends AgentBridgeResponse = AgentBridgeResponse>(
|
||||
payload: Record<string, unknown>,
|
||||
options: AgentBridgeRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const run = async (): Promise<T> => {
|
||||
const timeoutMs = options.timeoutMs || this.timeoutMs
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
|
||||
if (!response.ok) {
|
||||
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
|
||||
error.response = response
|
||||
throw error
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
const next = this.lock.then(run, run)
|
||||
this.lock = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
ping(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'ping' })
|
||||
}
|
||||
|
||||
chat(
|
||||
sessionId: string,
|
||||
message: AgentBridgeMessage,
|
||||
conversationHistory?: unknown[],
|
||||
instructions?: string,
|
||||
profile?: string,
|
||||
): Promise<AgentBridgeChatStarted> {
|
||||
return this.request<AgentBridgeChatStarted>({
|
||||
action: 'chat',
|
||||
session_id: sessionId,
|
||||
message,
|
||||
...(conversationHistory ? { conversation_history: conversationHistory } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
command(sessionId: string, command: string): Promise<AgentBridgeCommandResult> {
|
||||
return this.request<AgentBridgeCommandResult>({
|
||||
action: 'command',
|
||||
session_id: sessionId,
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
|
||||
return this.request<AgentBridgeOutput>({
|
||||
action: 'get_output',
|
||||
run_id: runId,
|
||||
cursor,
|
||||
event_cursor: eventCursor,
|
||||
}, options)
|
||||
}
|
||||
|
||||
async *streamOutput(
|
||||
runId: string,
|
||||
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
|
||||
): AsyncGenerator<AgentBridgeOutput> {
|
||||
const intervalMs = options.intervalMs || 100
|
||||
let cursor = 0
|
||||
let eventCursor = 0
|
||||
for (;;) {
|
||||
const chunk = await this.getOutput(runId, cursor, eventCursor, options)
|
||||
cursor = chunk.cursor
|
||||
eventCursor = chunk.event_cursor
|
||||
if (chunk.delta || chunk.done || (chunk.events && chunk.events.length > 0)) yield chunk
|
||||
if (chunk.done) return
|
||||
await delay(intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
async chatStream(
|
||||
sessionId: string,
|
||||
message: AgentBridgeMessage,
|
||||
onDelta: (delta: string, chunk: AgentBridgeOutput) => void | Promise<void>,
|
||||
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
|
||||
): Promise<AgentBridgeOutput> {
|
||||
const started = await this.chat(sessionId, message)
|
||||
let last: AgentBridgeOutput | null = null
|
||||
for await (const chunk of this.streamOutput(started.run_id, options)) {
|
||||
last = chunk
|
||||
if (chunk.delta) await onDelta(chunk.delta, chunk)
|
||||
}
|
||||
if (!last) throw new Error(`Agent bridge run ${started.run_id} produced no output state`)
|
||||
return last
|
||||
}
|
||||
|
||||
getResult(runId: string, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeRunResult> {
|
||||
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 })
|
||||
}
|
||||
|
||||
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'steer', session_id: sessionId, text })
|
||||
}
|
||||
|
||||
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'approval_respond', approval_id: approvalId, choice })
|
||||
}
|
||||
|
||||
compressionRespond(
|
||||
requestId: string,
|
||||
payload: { messages?: unknown[]; system_message?: string; error?: string },
|
||||
): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'compression_respond',
|
||||
request_id: requestId,
|
||||
...payload,
|
||||
}, { timeoutMs: this.timeoutMs })
|
||||
}
|
||||
|
||||
destroyAll(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy_all' })
|
||||
}
|
||||
|
||||
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'get_history', session_id: sessionId })
|
||||
}
|
||||
|
||||
destroy(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy', session_id: sessionId })
|
||||
}
|
||||
|
||||
list(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'list' })
|
||||
}
|
||||
|
||||
shutdown(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'shutdown' })
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentBridgeClient
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export * from './client'
|
||||
export * from './manager'
|
||||
@@ -0,0 +1,360 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||
import { logger } from '../../logger'
|
||||
import { detectHermesHome, getHermesBin } from '../hermes-path'
|
||||
import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client'
|
||||
|
||||
const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000
|
||||
|
||||
export interface AgentBridgeManagerOptions {
|
||||
endpoint?: string
|
||||
python?: string
|
||||
agentRoot?: string
|
||||
hermesHome?: string
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
interface BridgeCommand {
|
||||
command: string
|
||||
argsPrefix: string[]
|
||||
agentRoot?: string
|
||||
hermesHome: string
|
||||
}
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function pathCandidates(agentRoot?: string): string[] {
|
||||
if (!agentRoot) return []
|
||||
return process.platform === 'win32'
|
||||
? [
|
||||
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
|
||||
]
|
||||
: [
|
||||
join(agentRoot, 'venv', 'bin', 'python3'),
|
||||
join(agentRoot, 'venv', 'bin', 'python'),
|
||||
join(agentRoot, '.venv', 'bin', 'python3'),
|
||||
join(agentRoot, '.venv', 'bin', 'python'),
|
||||
]
|
||||
}
|
||||
|
||||
function uvCandidates(agentRoot?: string): string[] {
|
||||
return [
|
||||
process.env.HERMES_AGENT_BRIDGE_UV,
|
||||
process.env.UV,
|
||||
...(process.platform === 'win32'
|
||||
? [
|
||||
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '',
|
||||
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '',
|
||||
]
|
||||
: [
|
||||
agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '',
|
||||
]),
|
||||
'uv',
|
||||
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||
}
|
||||
|
||||
function resolveExecutable(command: string): string | undefined {
|
||||
const trimmed = command.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
return existsSync(trimmed) ? resolve(trimmed) : undefined
|
||||
}
|
||||
try {
|
||||
const lookup = process.platform === 'win32'
|
||||
? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true })
|
||||
: execFileSync('which', [trimmed], { encoding: 'utf-8' })
|
||||
return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function agentRootFromHermesBin(): string | undefined {
|
||||
const hermesBin = resolveExecutable(getHermesBin())
|
||||
if (!hermesBin) return undefined
|
||||
|
||||
const binDir = dirname(hermesBin)
|
||||
const rootCandidates = [
|
||||
resolve(binDir, '..'),
|
||||
resolve(binDir, '..', '..'),
|
||||
resolve(binDir, '..', 'hermes-agent'),
|
||||
resolve(binDir, '..', '..', 'hermes-agent'),
|
||||
]
|
||||
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
if (root) return root
|
||||
|
||||
try {
|
||||
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
|
||||
const match = first.match(/^#!\s*(.+)$/)
|
||||
const python = match?.[1]?.trim().split(/\s+/)[0]
|
||||
if (python) {
|
||||
const pyDir = dirname(python)
|
||||
const shebangRootCandidates = [
|
||||
resolve(pyDir, '..', '..'),
|
||||
resolve(pyDir, '..', '..', 'hermes-agent'),
|
||||
]
|
||||
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hermesBinPython(): string | undefined {
|
||||
const hermesBin = resolveExecutable(getHermesBin())
|
||||
if (!hermesBin) return undefined
|
||||
try {
|
||||
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
|
||||
const match = first.match(/^#!\s*(.+)$/)
|
||||
const python = match?.[1]?.trim().split(/\s+/)[0]
|
||||
return python && existsSync(python) ? python : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function firstExistingExecutable(candidates: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) {
|
||||
const resolved = resolveExecutable(candidate)
|
||||
if (resolved) return resolved
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
|
||||
const candidates = [
|
||||
explicit,
|
||||
process.env.HERMES_AGENT_ROOT,
|
||||
join(hermesHome, 'hermes-agent'),
|
||||
agentRootFromHermesBin(),
|
||||
process.cwd(),
|
||||
join(process.cwd(), 'hermes-agent'),
|
||||
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
|
||||
function bridgeCommand(options: AgentBridgeManagerOptions): BridgeCommand {
|
||||
const hermesHome = options.hermesHome || detectHermesHome()
|
||||
const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome)
|
||||
const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
if (explicitPython) {
|
||||
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
|
||||
if (venvPython) {
|
||||
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const shebangPython = hermesBinPython()
|
||||
if (shebangPython && existsSync(shebangPython)) {
|
||||
return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const uv = firstExistingExecutable(uvCandidates(agentRoot))
|
||||
if (uv) {
|
||||
const prefix = ['run']
|
||||
if (agentRoot) prefix.push('--project', agentRoot)
|
||||
prefix.push('python')
|
||||
return { command: uv, argsPrefix: prefix, agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const fallback = firstExistingExecutable([
|
||||
process.env.PYTHON || '',
|
||||
...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']),
|
||||
]) || (process.platform === 'win32' ? 'python' : 'python3')
|
||||
return { command: fallback, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
function bridgeScriptPath(): string {
|
||||
const candidates = [
|
||||
// Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py
|
||||
resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'),
|
||||
// ts-node/dev source tree.
|
||||
resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
]
|
||||
const found = candidates.find(candidate => existsSync(candidate))
|
||||
if (!found) {
|
||||
throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export class AgentBridgeManager {
|
||||
readonly endpoint: string
|
||||
private readonly options: AgentBridgeManagerOptions
|
||||
private child: ChildProcess | null = null
|
||||
private starting: Promise<void> | null = null
|
||||
private ready = false
|
||||
|
||||
constructor(options: AgentBridgeManagerOptions = {}) {
|
||||
this.options = options
|
||||
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
return !!this.child && !this.child.killed && this.ready
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return
|
||||
if (this.starting) return this.starting
|
||||
this.starting = this.startProcess()
|
||||
try {
|
||||
await this.starting
|
||||
} finally {
|
||||
this.starting = null
|
||||
}
|
||||
}
|
||||
|
||||
private async startProcess(): Promise<void> {
|
||||
const script = bridgeScriptPath()
|
||||
const command = bridgeCommand(this.options)
|
||||
const args = [...command.argsPrefix, script, '--endpoint', this.endpoint]
|
||||
const agentRoot = command.agentRoot
|
||||
const hermesHome = command.hermesHome
|
||||
if (agentRoot) args.push('--agent-root', agentRoot)
|
||||
if (hermesHome) args.push('--hermes-home', hermesHome)
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: this.endpoint,
|
||||
HERMES_HOME: hermesHome,
|
||||
...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}),
|
||||
}
|
||||
|
||||
logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' '))
|
||||
const child = spawn(command.command, args, {
|
||||
env,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
this.child = child
|
||||
this.ready = false
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal)
|
||||
this.ready = false
|
||||
if (this.child === child) this.child = null
|
||||
})
|
||||
|
||||
child.stderr?.on('data', chunk => {
|
||||
const text = String(chunk).trim()
|
||||
if (text) logger.warn('[agent-bridge] %s', text)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolveReady, rejectReady) => {
|
||||
let buffered = ''
|
||||
const startupTimeoutMs = this.options.startupTimeoutMs
|
||||
?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS')
|
||||
?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`))
|
||||
}, startupTimeoutMs)
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off('exit', onExitBeforeReady)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
child.stdout?.off('data', onStdout)
|
||||
rejectReady(err)
|
||||
}
|
||||
|
||||
const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
cleanup()
|
||||
child.stdout?.off('data', onStdout)
|
||||
rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`))
|
||||
}
|
||||
|
||||
let readyResolved = false
|
||||
const onStdout = (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8')
|
||||
buffered += text
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = buffered.slice(0, newline).trim()
|
||||
buffered = buffered.slice(newline + 1)
|
||||
if (!line) continue
|
||||
logger.info('[agent-bridge] %s', line)
|
||||
if (!readyResolved) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed?.event === 'ready') {
|
||||
this.ready = true
|
||||
readyResolved = true
|
||||
cleanup()
|
||||
resolveReady()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.once('error', onError)
|
||||
child.once('exit', onExitBeforeReady)
|
||||
child.stdout?.on('data', onStdout)
|
||||
})
|
||||
|
||||
logger.info('[agent-bridge] ready at %s', this.endpoint)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) return
|
||||
this.ready = false
|
||||
this.child = null
|
||||
|
||||
await new Promise<void>((resolveStop) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL')
|
||||
resolveStop()
|
||||
}, 1500)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolveStop()
|
||||
})
|
||||
if (!child.killed) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: AgentBridgeManager | null = null
|
||||
|
||||
export function getAgentBridgeManager(): AgentBridgeManager {
|
||||
if (!singleton) singleton = new AgentBridgeManager()
|
||||
return singleton
|
||||
}
|
||||
|
||||
export async function startAgentBridgeManager(): Promise<AgentBridgeManager> {
|
||||
const manager = getAgentBridgeManager()
|
||||
await manager.start()
|
||||
return manager
|
||||
}
|
||||
Reference in New Issue
Block a user