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:
@@ -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/host,Hermes 只从 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user