UNPKG

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
/** * 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