overmind-mcp
Version:
Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour
317 lines • 15.1 kB
JavaScript
/**
* HermesGatewayRunner — HTTP-based runner for the Hermes API Server.
*
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ ARCHITECTURE (v3.5 — Gateway Integration) ║
* ║ ║
* ║ Replaces the subprocess spawn in HermesRunner with HTTP+SSE calls ║
* ║ to the Hermes API Server (gateway/platforms/api_server.py). ║
* ║ ║
* ║ BENEFITS over HermesRunner (spawn): ║
* ║ - No Python startup per call (~5-10s saved) ║
* ║ - SSE streaming output in real-time ║
* ║ - Native session management via X-Hermes-Session-Id header ║
* ║ - Proper abort via fetch AbortController ║
* ║ - Model/provider swap via config, not CLI flags ║
* ║ ║
* ║ ENDPOINTS USED: ║
* ║ POST /v1/chat/completions → main chat (streaming SSE or JSON) ║
* ║ GET /health → connectivity check ║
* ║ ║
* ║ FALLBACK: ║
* ║ If the API server is not running, falls back to HermesRunner ║
* ║ (subprocess spawn) for backward compatibility. ║
* ╚══════════════════════════════════════════════════════════════════════╝
*/
import { HermesGatewayManager } from './HermesGatewayManager.js';
import { registerLiveAgent, appendLiveOutput, setLiveStatus, unregisterLiveAgent, } from '../lib/agent_lifecycle.js';
import { registerProcess } from '../lib/processRegistry.js';
import { withSpan } from '../lib/telemetry.js';
import { rootLogger } from '../lib/logger.js';
import { saveSessionId, getLastSessionId } from '../lib/sessions.js';
import { getWorkspaceDir } from '../lib/config.js';
import { HermesProfileManager } from './HermesProfileManager.js';
import fs from 'fs';
import path from 'path';
const logger = rootLogger.child({ module: 'HermesGatewayRunner' });
/** Pseudo-PID for gateway runs (no real OS process to track) */
let gatewayPidCounter = 70000;
/** In-memory session map for gateway runs: agentName → sessionId */
const gatewaySessions = new Map();
/**
* ═══════════════════════════════════════════════════════════════════════
* SOUL.md CACHE (Fix #1 — Critical)
*
* The Hermes API Server does NOT auto-inject SOUL.md for
* /v1/chat/completions calls. We must read it ourselves and inject it
* as the system message in the request body.
*
* Cache avoids hitting the filesystem on every call. TTL = 5 min.
* ═══════════════════════════════════════════════════════════════════════
*/
const SOUL_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const soulCache = new Map();
/** Max concurrent gateway runs (Fix #2 — rate limit). */
const MAX_CONCURRENT_GW_RUNS = 20;
let activeGwRuns = 0;
async function loadSoulMd(profile) {
if (!profile || profile === 'default')
return null;
// Check cache
const cached = soulCache.get(profile);
if (cached && Date.now() - cached.loadedAt < SOUL_CACHE_TTL_MS) {
return cached.content;
}
try {
const profilePath = await HermesProfileManager.getProfilePath(profile);
if (!profilePath)
return null;
const soulPath = path.join(profilePath, 'SOUL.md');
if (!fs.existsSync(soulPath))
return null;
const content = fs.readFileSync(soulPath, 'utf-8');
soulCache.set(profile, { content, loadedAt: Date.now() });
logger.debug({ profile, size: content.length }, '[GatewayRunner] SOUL.md loaded and cached.');
return content;
}
catch (err) {
logger.warn({ profile, err }, '[GatewayRunner] Failed to read SOUL.md — running without system prompt.');
return null;
}
}
/**
* Runner that talks to the Hermes API Server via HTTP+SSE.
*
* Falls back to HermesRunner (subprocess spawn) if the API server is not
* available — ensuring zero downtime during the migration.
*/
export class HermesGatewayRunner {
manager;
constructor() {
this.manager = HermesGatewayManager.getInstance();
}
/**
* Run a prompt against the Hermes API Server.
*
* If the server is not reachable, returns an error with transport='fallback-spawn'
* so the caller can decide to use the old HermesRunner.
*/
async runAgent(options) {
const { agentName } = options;
let { sessionId } = options;
// ─── Resolve session ──────────────────────────────────────────────────
if (options.autoResume && agentName && !sessionId) {
// Try in-memory gateway sessions first
const gwSession = gatewaySessions.get(agentName);
if (gwSession) {
sessionId = gwSession;
}
else {
// Fall back to persisted sessions (from spawn mode)
const lastId = await getLastSessionId(agentName, getWorkspaceDir(), 'hermes');
if (lastId) {
sessionId = lastId;
logger.info({ sessionId }, '[GatewayRunner] Auto-resume from persisted sessions.');
}
}
}
return withSpan('hermes.gateway.runAgent', async (span) => {
span.setAttribute('agentName', agentName || '');
span.setAttribute('transport', 'gateway-http');
// ─── Ensure gateway is ready ──────────────────────────────────────
const gw = await this.manager.ensureReady();
if (!gw) {
return {
result: '',
error: 'GATEWAY_NOT_READY',
transport: 'fallback-spawn',
};
}
// ─── Register a pseudo-agent for lifecycle tracking ───────────────
const pseudoPid = ++gatewayPidCounter;
const abortController = new AbortController();
registerLiveAgent({
pid: pseudoPid,
runner: 'hermes-gateway',
agentName: agentName || 'anonymous',
sessionId: sessionId || '',
abortController,
cleanupFn: async () => {
abortController.abort();
},
childRef: null, // No child process — HTTP-based
});
// Also register in processRegistry for cross-system visibility
void registerProcess(pseudoPid, {
agentName: agentName || '',
runner: 'hermes-gateway',
configPath: getWorkspaceDir(),
});
try {
const result = await this.executeChat(gw, options, sessionId, pseudoPid, abortController.signal);
// Save session linkage
if (result.sessionId && agentName) {
gatewaySessions.set(agentName, result.sessionId);
await saveSessionId(agentName, result.sessionId, getWorkspaceDir(), 'hermes');
}
setLiveStatus(pseudoPid, result.error ? 'failed' : 'done', result.error ? 1 : 0);
return result;
}
catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
logger.error({ error: errMsg, agentName }, '[GatewayRunner] Run failed.');
setLiveStatus(pseudoPid, 'failed', 1);
return {
result: '',
error: `GATEWAY_ERROR: ${errMsg}`,
transport: 'gateway-http',
};
}
finally {
unregisterLiveAgent(pseudoPid);
}
}, { agentName: agentName || '', transport: 'gateway-http' });
}
/**
* Execute a chat completion against the API server.
*
* Uses streaming SSE to get real-time output, assembling the final
* text from delta chunks.
*/
async executeChat(gw, options, sessionId, pseudoPid, externalSignal) {
const { prompt, agentName, silent } = options;
// ─── Fix #2: Concurrency guard (rate limit protection) ──────────────
if (activeGwRuns >= MAX_CONCURRENT_GW_RUNS) {
return {
result: '',
error: 'GATEWAY_BUSY: too many concurrent runs, retry later',
transport: 'gateway-http',
};
}
// ─── Fix #1: Load SOUL.md for system prompt injection ───────────────
const profile = options.profile || agentName;
const soulContent = await loadSoulMd(profile);
// Build request headers
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${gw.apiKey}`,
};
// Session management via header
if (sessionId) {
headers['X-Hermes-Session-Id'] = sessionId;
}
// Profile selection via header (maps to -p <profile>)
if (profile && profile !== 'default') {
headers['X-Hermes-Profile'] = profile;
}
// Build request body (OpenAI chat completions format)
const messages = [];
// ─── Fix #1: Inject SOUL.md as system message ──────────────────────
if (soulContent) {
messages.push({ role: 'system', content: soulContent });
}
messages.push({ role: 'user', content: prompt });
const body = {
model: options.model || 'hermes-agent',
messages,
stream: true, // Always stream — we assemble the final text
};
// Combine abort signals
const combinedSignal = externalSignal;
logger.info({ url: gw.url, agentName, sessionId: sessionId?.slice(0, 20), profile, soulInjected: !!soulContent }, '[GatewayRunner] POST /v1/chat/completions (streaming)');
// ─── Fix #2: Track active runs for rate-limit protection ────────────
activeGwRuns++;
let response;
try {
response = await fetch(`${gw.url}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: combinedSignal,
});
}
finally {
activeGwRuns--;
}
if (!response.ok) {
const errText = await response.text().catch(() => '(no body)');
return {
result: '',
error: `HTTP_${response.status}: ${errText.slice(0, 500)}`,
transport: 'gateway-http',
};
}
// ─── Parse SSE stream ────────────────────────────────────────────────
const fullText = await this.parseSSEStream(response, pseudoPid, silent, agentName);
// Extract session ID from response headers
const responseSessionId = response.headers.get('x-hermes-session-id') || undefined;
return {
result: fullText.trim(),
sessionId: responseSessionId || sessionId,
rawOutput: fullText,
model: options.model,
transport: 'gateway-http',
};
}
/**
* Parse the SSE stream from /v1/chat/completions.
*
* Format (OpenAI-compatible):
* data: {"choices":[{"delta":{"content":"hello"}}]}
* data: {"choices":[{"delta":{"content":" world"}}]}
* data: [DONE]
*
* Assembles full text, streams chunks to agent_lifecycle in real-time.
*/
async parseSSEStream(response, pseudoPid, silent, agentName) {
let fullText = '';
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body for SSE stream');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
// Process complete SSE events (separated by \n\n)
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep incomplete chunk in buffer
for (const event of events) {
const line = event.trim();
if (!line.startsWith('data: '))
continue;
const data = line.slice(6); // Remove "data: " prefix
// End of stream
if (data === '[DONE]')
continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) {
const chunk = delta.content;
fullText += chunk;
// Stream to agent_lifecycle for real-time output
appendLiveOutput(pseudoPid, chunk);
// Also write to stderr for live monitoring (matching HermesRunner pattern)
if (!silent && agentName) {
process.stderr.write(`[HermesGW:${agentName}] ${chunk}`);
}
}
}
catch {
// Non-JSON line (e.g., comments) — skip
}
}
}
}
finally {
reader.releaseLock?.();
}
return fullText;
}
}
//# sourceMappingURL=HermesGatewayRunner.js.map