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

1,644 lines • 70.8 kB
/**
 * ╔══════════════════════════════════════════════════════════════════════╗
 * ║   OVERMIND BRIDGE — OverBridgeServer (HTTP JSON-RPC 2.0 Entry Point)║
 * ║                                                                      ║
 * ║   Serveur HTTP qui expose l'API Overmind Bridge via JSON-RPC 2.0.    ║
 * ║   Permet à n'importe quel client (curl, Python, fetch...) de :       ║
 * ║     - Parler à un agent                                               ║
 * ║     - Faire parler des agents entre eux (A2A)                        ║
 * ║     - Suivre l'état live des agents (busy/idle/online)               ║
 * ║     - Consulter l'historique persistant des messages                 ║
 * ║                                                                      ║
 * ║   ARCHITECTURE                                                       ║
 * ║   ─────────────                                                      ║
 * ║   Client HTTP → POST /rpc → JSON-RPC dispatcher                      ║
 * ║                          → OverBridgeService (deja wrappe MCP)       ║
 * ║                          → AgentRegistry (etat live)                 ║
 * ║                          → MessageLog (persistence Postgres)         ║
 * ║                          → Overmind MCP :3099                        ║
 * ╚══════════════════════════════════════════════════════════════════════╝
 */
import http from 'node:http';
import crypto from 'node:crypto';
import { URL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import { z } from 'zod';
import { AgentRegistry } from './AgentRegistry.js';
import { MessageLog } from './MessageLog.js';
import { SessionStore } from './SessionStore.js';
import { DirectiveParser } from './DirectiveParser.js';
import { WebhookAdapter } from './WebhookAdapter.js';
import { sanitizeAndParse, looksLikeWindowsPathIssue } from './JsonSanitizer.js';
import { getOrCreateRequestId } from './RequestContext.js';
import { createBridgeLogger, validateAgentName } from './utils.js';
const JSON_RPC_ERRORS = {
    PARSE_ERROR: { code: -32700, message: 'Parse error' },
    INVALID_REQUEST: { code: -32600, message: 'Invalid Request' },
    METHOD_NOT_FOUND: { code: -32601, message: 'Method not found' },
    INVALID_PARAMS: { code: -32602, message: 'Invalid params' },
    INTERNAL_ERROR: { code: -32603, message: 'Internal error' },
    AGENT_BUSY: { code: -32001, message: 'Agent is busy' },
    AGENT_OFFLINE: { code: -32002, message: 'Agent is offline' },
};
// ─── Zod Schemas (validation des params par méthode) ─────────────────────
const RunAgentParams = z.object({
    agentName: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    sessionId: z.string().optional(),
    path: z.string().optional(),
    model: z.string().optional(),
    mode: z.string().optional(),
    silent: z.boolean().optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
    /** Clé externe pour SessionStore (phone, userId, etc.) — auto-résolution de sessionId */
    externalKey: z.string().optional(),
    /** Active le parsing de directives dans la réponse (default: true si enableDirectives) */
    parseDirectives: z.boolean().optional(),
});
const A2AParams = z.object({
    fromAgent: z.string().min(1),
    toAgent: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    model: z.string().optional(),
    path: z.string().optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
// ─── A2A Extended Schemas ──────────────────────────────────────────────────
/** agent.broadcast — Un agent → tous les agents (fan-out global) */
const BroadcastParams = z.object({
    fromAgent: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    /** Liste des agents cibles. Si absent → tous les agents online. */
    targets: z.array(z.string().min(1)).optional(),
    /** Si true, retourne dès qu'un agent répond (default: false = attend tous) */
    race: z.boolean().optional().default(false),
    /** Timeout par agent en ms (default: 60_000) */
    agentTimeoutMs: z.number().int().min(1000).max(3_600_000).optional().default(120_000),
    model: z.string().optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
/** agent.pipeline — Chaîne séquentielle A→B→C (output de l'un = input du suivant) */
const PipelineParams = z.object({
    /** Agent qui initie la chaîne (fromAgent) */
    initiator: z.string().min(1),
    runner: z.string().min(1),
    /** Prompt initial */
    prompt: z.string().min(1),
    /** Étapes de la pipeline — chaque step reçoit le output du précédent */
    steps: z
        .array(z.object({
        agentName: z.string().min(1),
        /** Prompt prefix optionnel (le output précédent est appended) */
        promptPrefix: z.string().optional(),
        model: z.string().optional(),
    }))
        .min(1),
    /** Si true, chaque step reçoit TOUS les outputs précédents (default: false = juste le dernier) */
    accumulateContext: z.boolean().optional().default(false),
    /** Timeout total en ms (default: 3_600_000 = 1h) */
    totalTimeoutMs: z.number().int().min(10_000).max(86_400_000).optional().default(3_600_000),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
/** agent.fanout — Un agent → plusieurs en parallèle + merge */
const FanoutParams = z.object({
    fromAgent: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    targets: z.array(z.string().min(1)).min(1),
    /** Stratégie de merge des résultats */
    mergeStrategy: z.enum(['concat', 'best', 'vote', 'first_success']).optional().default('concat'),
    /** Critère pour 'best' (default: le plus long) */
    bestCriterion: z.string().optional(),
    /** Timeout par agent en ms (default: 120_000) */
    agentTimeoutMs: z.number().int().min(1000).max(3_600_000).optional().default(120_000),
    model: z.string().optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
/** agent.delegate — Délégation async (fire-and-forget avec ID de tracking) */
const DelegateParams = z.object({
    fromAgent: z.string().min(1),
    toAgent: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    model: z.string().optional(),
    /** Si fourni, callback URL appelée quand l'agent termine (POST result) */
    callbackUrl: z.string().url().optional(),
    /** Si true, retourne immédiatement avec un taskId (default: true) */
    async: z.boolean().optional().default(true),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
/** agent.query — Query multi-agents, retourne toutes les réponses */
const QueryParams = z.object({
    fromAgent: z.string().min(1),
    runner: z.string().min(1),
    prompt: z.string().min(1),
    targets: z.array(z.string().min(1)).min(1),
    /** Timeout par agent en ms (default: 30_000) */
    agentTimeoutMs: z.number().int().min(1000).max(3_600_000).optional().default(30_000),
    model: z.string().optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
});
const AgentStatusParams = z.object({
    agentName: z.string().min(1),
    runner: z.string().optional(),
    action: z.enum(['status', 'stream', 'kill', 'wait']).default('status'),
    sinceTimestamp: z.number().optional(),
    timeoutMs: z.number().optional(),
});
const ListAgentsParams = z
    .object({
    status: z.enum(['online', 'offline', 'busy', 'idle']).optional(),
    runner: z.string().optional(),
})
    .default({});
const MessageHistoryParams = z.object({
    toAgent: z.string().optional(),
    fromAgent: z.string().nullable().optional(),
    status: z.enum(['pending', 'running', 'done', 'failed', 'timeout']).optional(),
    limit: z.number().int().min(1).max(500).default(50),
    offset: z.number().int().min(0).default(0),
    sinceHours: z.number().positive().optional(),
});
const MessageGetParams = z.object({
    id: z.string().uuid(),
});
const MessageReplayParams = z.object({
    id: z.string().uuid(),
});
// ─── OverBridgeServer ─────────────────────────────────────────────────────
export class OverBridgeServer {
    service;
    registry;
    log;
    messageLog;
    sessions;
    directiveParser;
    webhookAdapter;
    config;
    server;
    startTime = 0;
    pruneTimer;
    rateLimiter = new Map();
    constructor(service, config, logger) {
        this.service = service;
        this.config = config;
        this.log = logger ?? createBridgeLogger('overbridge-server');
        this.registry = new AgentRegistry(this.log);
        this.directiveParser = new DirectiveParser({ logger: this.log });
        this.webhookAdapter = new WebhookAdapter({ logger: this.log });
        if (config.enableMessageLog) {
            this.messageLog = new MessageLog(config.postgres, this.log);
        }
        if (config.enableSessionStore) {
            const defaultPath = path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.overmind', 'bridge', 'sessions.json');
            this.sessions = new SessionStore({
                persistPath: config.sessionStorePath ?? defaultPath,
                ttlMs: config.sessionTtlMs,
            }, this.log);
        }
    }
    // ─── Lifecycle ───────────────────────────────────────────────────────────
    /**
     * Démarre le serveur HTTP et initialise les dépendances (MessageLog, SessionStore, OverBridgeService).
     */
    async start() {
        this.startTime = Date.now();
        // 1) Init MessageLog (Postgres)
        if (this.messageLog) {
            await this.messageLog.init();
        }
        // 2) Init SessionStore
        if (this.sessions) {
            await this.sessions.init();
        }
        // 3) Connect OverBridgeService (MCP healthcheck)
        try {
            const status = await this.service.connect(this.config.healthCheckIntervalMs);
            this.log.info(`🔌 OverBridgeService connected: ${status.status}`);
        }
        catch (err) {
            this.log.warn(`⚠️  OverBridgeService connect failed: ${err.message}`);
            // On continue quand même, le serveur répondra avec erreurs si MCP down
        }
        // 4) Démarre le serveur HTTP
        this.server = http.createServer((req, res) => this.handleRequest(req, res));
        await new Promise((resolve) => {
            this.server.listen(this.config.port, this.config.host, () => resolve());
        });
        // 5) Planifie le prune périodique des agents offline (toutes les 6h)
        // Évite que la map d'états grossisse indéfiniment.
        this.pruneTimer = setInterval(() => {
            try {
                this.registry.prune(24 * 60 * 60 * 1000);
            }
            catch (err) {
                this.log.warn(`⚠️  Prune failed: ${err.message}`);
            }
        }, 6 * 60 * 60 * 1000);
        const addr = this.server.address();
        const port = typeof addr === 'object' && addr ? addr.port : this.config.port;
        const url = `http://${this.config.host}:${port}`;
        this.log.info(`🚀 OverBridgeServer listening on ${url}`);
        this.log.info(`   POST ${url}/rpc   (JSON-RPC 2.0)`);
        this.log.info(`   GET  ${url}/health`);
        if (this.config.enableWebhooks) {
            this.log.info(`   POST ${url}/webhook/:provider   (voipms, twilio, discord, generic)`);
        }
        if (this.sessions) {
            this.log.info(`   SessionStore: enabled (TTL ${(this.config.sessionTtlMs ?? 14_400_000) / 1000}s)`);
        }
        if (this.config.enableDirectives) {
            this.log.info(`   DirectiveParser: enabled (SESSION_ID, CONTEXT_UPDATE, BRIDGE_NEXT)`);
        }
        return { port, host: this.config.host, url };
    }
    /**
     * Ferme proprement le serveur HTTP et les dépendances.
     */
    async stop() {
        if (this.pruneTimer) {
            clearInterval(this.pruneTimer);
            this.pruneTimer = undefined;
        }
        if (this.server) {
            await new Promise((resolve) => this.server.close(() => resolve()));
            this.server = undefined;
            this.log.info('🛑 HTTP server closed');
        }
        this.service.disconnect();
        if (this.messageLog) {
            await this.messageLog.close();
        }
        if (this.sessions) {
            await this.sessions.close();
        }
    }
    /**
     * Expose le registry (pour tests / inspection).
     */
    get agentRegistry() {
        return this.registry;
    }
    /**
     * Expose le MessageLog (pour tests / inspection).
     */
    get messages() {
        return this.messageLog;
    }
    /**
     * Expose le SessionStore (pour tests / inspection).
     */
    get sessionStore() {
        return this.sessions;
    }
    // ─── HTTP Request Handler ───────────────────────────────────────────────
    async handleRequest(req, res) {
        const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
        const reqId = getOrCreateRequestId(req.headers);
        // Set reqId header in response
        res.setHeader('X-Request-Id', reqId);
        // CORS preflight
        const reqOrigin = req.headers.origin || undefined;
        if (req.method === 'OPTIONS') {
            this.writeCors(res, reqOrigin);
            res.writeHead(204);
            res.end();
            return;
        }
        this.writeCors(res, reqOrigin);
        try {
            // Health check simple
            if (req.method === 'GET' && url.pathname === '/health') {
                await this.handleHealth(res);
                return;
            }
            // JSON-RPC endpoint
            if (req.method === 'POST' && url.pathname === '/rpc') {
                // Rate limiting (sliding window par IP)
                const maxReqs = this.config.rateLimitMax ?? 100;
                if (maxReqs > 0) {
                    const ip = (req.socket.remoteAddress || 'unknown').replace(/^::ffff:/, '');
                    const now = Date.now();
                    let entry = this.rateLimiter.get(ip);
                    if (!entry || now - entry.windowStart > 60_000) {
                        entry = { count: 0, windowStart: now };
                        this.rateLimiter.set(ip, entry);
                    }
                    entry.count++;
                    if (entry.count > maxReqs) {
                        this.writeJson(res, 429, {
                            jsonrpc: '2.0',
                            id: null,
                            error: {
                                code: -32000,
                                message: 'Rate limit exceeded',
                                data: { reqId, limit: maxReqs },
                            },
                        });
                        return;
                    }
                }
                await this.handleRpc(req, res, reqId);
                return;
            }
            // Webhook endpoint (si activé) — /webhook/:provider
            if (this.config.enableWebhooks &&
                req.method === 'POST' &&
                url.pathname.startsWith('/webhook/')) {
                await this.handleWebhook(req, res, url, reqId);
                return;
            }
            // File serve — /f/:filename (statique, comme bt-sms)
            if (req.method === 'GET' && url.pathname.startsWith('/f/')) {
                await this.handleStaticFile(req, res, url);
                return;
            }
            // 404
            this.writeJson(res, 404, { error: 'Not found', path: url.pathname, reqId });
        }
        catch (err) {
            this.log.error(`💥 Unhandled error: ${err.message} (reqId=${reqId})`);
            this.writeJson(res, 500, { error: 'Internal server error', reqId });
        }
    }
    writeCors(res, reqOrigin) {
        const allowed = this.config.allowedOrigins ?? [
            'http://localhost:3000',
            'http://localhost:5173',
        ];
        const origin = reqOrigin || '';
        if (allowed.includes('*')) {
            res.setHeader('Access-Control-Allow-Origin', '*');
        }
        else if (allowed.includes(origin)) {
            res.setHeader('Access-Control-Allow-Origin', origin);
        }
        else {
            // Origin pas dans la allowlist — on ne set pas ACAO (navigateur bloquera)
        }
        res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
        res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    }
    writeJson(res, status, body) {
        res.writeHead(status, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(body));
    }
    // ─── /webhook/:provider Endpoint ─────────────────────────────────────────
    async handleWebhook(req, res, url, reqId) {
        // /webhook/:provider — extrait le provider du path
        const parts = url.pathname.split('/').filter(Boolean);
        const provider = (parts[1] ?? 'voipms');
        const raw = await this.readBody(req);
        let payload;
        try {
            payload = JSON.parse(raw);
        }
        catch {
            // Tente sanitizer (VoIP.ms envoie parfois du form-encoded mal formé)
            try {
                payload = sanitizeAndParse(raw);
            }
            catch {
                this.writeJson(res, 400, { error: 'Invalid JSON body', reqId });
                return;
            }
        }
        // Adapte via WebhookAdapter
        const adapter = new WebhookAdapter({ provider, logger: this.log });
        let normalized;
        try {
            normalized = adapter.adapt(payload);
        }
        catch (err) {
            this.writeJson(res, 400, { error: err.message, reqId });
            return;
        }
        // Auto-dispatch vers agent.run via internal call
        this.log.info(`[reqId=${reqId}] 📨 Webhook ${provider} from ${normalized.externalKey}`);
        // Cherche un agent par défaut (config-driven ou fallback premier agent)
        // Pour l'instant on retourne le normalized et on log ; les utilisateurs
        // peuvent soit :
        //   1. Définir une config par-provider pour auto-dispatch
        //   2. Appeler manuellement /rpc agent.run après réception
        this.writeJson(res, 200, {
            received: true,
            reqId,
            provider,
            externalKey: normalized.externalKey,
            promptPreview: normalized.prompt.slice(0, 200),
            mediaCount: normalized.mediaUrls.length,
            metadata: normalized.metadata,
        });
    }
    // ─── /f/:filename Static File Serve ──────────────────────────────────────
    async handleStaticFile(_req, res, url) {
        // Decode le filename (les clients peuvent l'URL-encoder) et bloque
        // tout tentative de path traversal. On résout en absolu et on
        // vérifie que le résultat reste sous staticDir.
        const rawFilename = url.pathname.slice(3); // strip /f/
        if (!rawFilename) {
            this.writeJson(res, 400, { error: 'Invalid filename' });
            return;
        }
        let filename;
        try {
            filename = decodeURIComponent(rawFilename);
        }
        catch {
            this.writeJson(res, 400, { error: 'Invalid filename encoding' });
            return;
        }
        const staticDir = path.resolve(process.env.BRIDGE_STATIC_DIR ?? './public');
        const filepath = path.resolve(staticDir, filename);
        const expectedPrefix = staticDir.endsWith(path.sep) ? staticDir : staticDir + path.sep;
        if (!filepath.startsWith(expectedPrefix) && filepath !== staticDir) {
            this.writeJson(res, 403, { error: 'Forbidden' });
            return;
        }
        if (!fs.existsSync(filepath)) {
            this.writeJson(res, 404, { error: 'Not found' });
            return;
        }
        const ext = path.extname(filepath).toLowerCase();
        const mimeMap = {
            '.html': 'text/html',
            '.pdf': 'application/pdf',
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.json': 'application/json',
            '.txt': 'text/plain',
            '.mp3': 'audio/mpeg',
            '.mp4': 'video/mp4',
            '.gif': 'image/gif',
            '.wav': 'audio/wav',
            '.xml': 'text/xml',
        };
        res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
        fs.createReadStream(filepath).pipe(res);
    }
    // ─── /health Endpoint ───────────────────────────────────────────────────
    async handleHealth(res) {
        const mcpHealth = await this.service.proxyAccess.healthCheck();
        const regStats = this.registry.stats();
        let msgStats = null;
        if (this.messageLog) {
            try {
                msgStats = await this.messageLog.stats();
            }
            catch {
                msgStats = null;
            }
        }
        const sessionStats = this.sessions ? this.sessions.stats() : null;
        this.writeJson(res, 200, {
            status: mcpHealth.status,
            uptime: Date.now() - this.startTime,
            mcp: mcpHealth,
            agents: regStats,
            messages: msgStats,
            sessions: sessionStats,
            features: {
                sessionStore: !!this.sessions,
                directives: !!this.config.enableDirectives,
                webhooks: !!this.config.enableWebhooks,
                sanitizeJson: !!this.config.sanitizeJson,
                messageLog: !!this.messageLog,
            },
            version: '1.1.0',
        });
    }
    // ─── /rpc Endpoint (JSON-RPC 2.0 Dispatcher) ────────────────────────────
    async handleRpc(req, res, reqId) {
        // Auth check — timing-safe comparison pour éviter les timing attacks
        if (this.config.authToken) {
            const auth = req.headers.authorization || '';
            const expected = `Bearer ${this.config.authToken}`;
            const a = Buffer.from(auth);
            const b = Buffer.from(expected);
            if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
                this.writeJson(res, 401, {
                    jsonrpc: '2.0',
                    id: null,
                    error: { code: -32000, message: 'Unauthorized', data: { reqId } },
                });
                return;
            }
        }
        // Body parsing
        const raw = await this.readBody(req);
        let parsed;
        try {
            // Try direct parse first
            parsed = JSON.parse(raw);
        }
        catch {
            // Si sanitizer activé et body ressemble à un Windows path, on tente la repair
            if (this.config.sanitizeJson && looksLikeWindowsPathIssue(raw)) {
                try {
                    parsed = sanitizeAndParse(raw);
                    this.log.warn(`[reqId=${reqId}] ⚠️  JSON body sanitized (Windows path issue)`);
                }
                catch {
                    this.respondError(res, null, JSON_RPC_ERRORS.PARSE_ERROR);
                    return;
                }
            }
            else {
                this.respondError(res, null, JSON_RPC_ERRORS.PARSE_ERROR);
                return;
            }
        }
        // Inject reqId into all calls (pour corrélation)
        const injectReqId = (reqs) => {
            const inject = (r) => {
                if (!r.params)
                    r.params = {};
                r.params.__reqId = reqId;
            };
            if (Array.isArray(reqs))
                reqs.forEach(inject);
            else
                inject(reqs);
        };
        injectReqId(parsed);
        // Batch ou single
        if (Array.isArray(parsed)) {
            if (parsed.length === 0) {
                this.respondError(res, null, JSON_RPC_ERRORS.INVALID_REQUEST);
                return;
            }
            const responses = await Promise.all(parsed.map((r) => this.dispatchRpc(r).catch((e) => this.buildErrorResponse(r.id ?? null, e))));
            this.writeJson(res, 200, responses);
        }
        else {
            const response = await this.dispatchRpc(parsed);
            this.writeJson(res, 200, response);
        }
    }
    /**
     * Dispatch une requête JSON-RPC vers la bonne méthode.
     */
    async dispatchRpc(req) {
        // Validation de base JSON-RPC 2.0
        if (req.jsonrpc !== '2.0' || !req.method) {
            return { jsonrpc: '2.0', id: req.id ?? null, error: JSON_RPC_ERRORS.INVALID_REQUEST };
        }
        try {
            switch (req.method) {
                case 'agent.run':
                    return this.methodAgentRun(req);
                case 'agent.a2a':
                    return this.methodAgentA2A(req);
                case 'agent.broadcast':
                    return this.methodAgentBroadcast(req);
                case 'agent.pipeline':
                    return this.methodAgentPipeline(req);
                case 'agent.fanout':
                    return this.methodAgentFanout(req);
                case 'agent.delegate':
                    return this.methodAgentDelegate(req);
                case 'agent.query':
                    return this.methodAgentQuery(req);
                case 'agent.status':
                    return this.methodAgentStatus(req);
                case 'agent.list':
                    return this.methodAgentList(req);
                case 'agent.kill':
                    return this.methodAgentKill(req);
                case 'message.history':
                    return this.methodMessageHistory(req);
                case 'message.get':
                    return this.methodMessageGet(req);
                case 'message.replay':
                    return this.methodMessageReplay(req);
                case 'message.stats':
                    return this.methodMessageStats(req);
                case 'session.get':
                    return this.methodSessionGet(req);
                case 'session.list':
                    return this.methodSessionList(req);
                case 'session.delete':
                    return this.methodSessionDelete(req);
                case 'session.stats':
                    return this.methodSessionStats(req);
                case 'webhook.sms':
                    return this.methodWebhookSms(req);
                case 'health.ping':
                    return { jsonrpc: '2.0', id: req.id ?? null, result: { pong: true, ts: Date.now() } };
                default:
                    return {
                        jsonrpc: '2.0',
                        id: req.id ?? null,
                        error: { ...JSON_RPC_ERRORS.METHOD_NOT_FOUND, data: { method: req.method } },
                    };
            }
        }
        catch (err) {
            this.log.error(`💥 ${req.method} failed: ${err.message}`);
            return this.buildErrorResponse(req.id ?? null, err);
        }
    }
    // ─── RPC Methods ─────────────────────────────────────────────────────────
    /**
     * agent.run — Lance un agent (from client externe OU from autre agent).
     * Avec support SessionStore (externalKey) et DirectiveParser.
     */
    async methodAgentRun(req) {
        const params = this.validateParams(RunAgentParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { agentName, runner, prompt, sessionId, path, model, mode, silent, metadata, externalKey, parseDirectives, } = params.data;
        const validatedAgentName = validateAgentName(agentName);
        const reqId = req.params?.__reqId;
        // Register
        this.registry.register(validatedAgentName, runner);
        // SessionStore resolution (si externalKey fourni)
        let effectiveSessionId = sessionId;
        if (this.sessions && externalKey) {
            const stored = this.sessions.get(externalKey, validatedAgentName);
            if (stored) {
                effectiveSessionId = stored.sessionId;
                this.log.info(`[reqId=${reqId}] 🔗 Session restored for ${externalKey} → ${effectiveSessionId}`);
            }
        }
        // Persist (pending)
        let messageId;
        if (this.messageLog) {
            messageId = await this.messageLog.create({
                fromAgent: metadata?.fromAgent ?? null,
                toAgent: validatedAgentName,
                runner: runner,
                prompt,
                sessionId: effectiveSessionId,
                metadata: {
                    ...(metadata ?? {}),
                    ...(reqId ? { reqId } : {}),
                    ...(externalKey ? { externalKey } : {}),
                },
            });
        }
        // Run avec mutex (1 run par agent à la fois)
        const result = await this.registry.withLock(validatedAgentName, async () => {
            this.registry.markBusy(validatedAgentName, effectiveSessionId);
            if (messageId && this.messageLog) {
                await this.messageLog.markRunning(messageId, effectiveSessionId);
            }
            try {
                const agentResult = await this.service.runAgent({
                    runner: runner,
                    prompt,
                    agentName: validatedAgentName,
                    sessionId: effectiveSessionId,
                    path,
                    model,
                    mode,
                    silent,
                });
                const rawResponseText = agentResult.content.map((c) => c.text).join('\n');
                // ─── Directive parsing ─────────────────────────────────────────
                let cleanText = rawResponseText;
                let directives;
                const shouldParseDirectives = parseDirectives ?? this.config.enableDirectives;
                if (shouldParseDirectives) {
                    directives = this.directiveParser.parse(rawResponseText);
                    cleanText = directives.cleanText;
                    for (const action of directives.actions) {
                        if (action.kind === 'session' && this.sessions && externalKey) {
                            this.sessions.updateSessionId(externalKey, validatedAgentName, action.sessionId, runner);
                            this.log.info(`[reqId=${reqId}] 🔗 SESSION_ID directive → ${action.sessionId}`);
                        }
                        else if (action.kind === 'context' && this.sessions && externalKey) {
                            this.sessions.updateContext(externalKey, validatedAgentName, action.patch);
                            this.log.info(`[reqId=${reqId}] 📝 CONTEXT_UPDATE directive → ${JSON.stringify(action.patch)}`);
                        }
                        else if (action.kind === 'hint') {
                            this.log.info(`[reqId=${reqId}] 💡 BRIDGE_HINT: ${action.text}`);
                        }
                    }
                }
                // Save session
                if (this.sessions && externalKey && agentResult.sessionId) {
                    this.sessions.set({
                        externalKey,
                        agentName: validatedAgentName,
                        runner: runner,
                        sessionId: agentResult.sessionId,
                        context: directives?.actions.find((a) => a.kind === 'context')
                            ? directives.actions.find((a) => a.kind === 'context').patch
                            : undefined,
                    });
                }
                if (messageId && this.messageLog) {
                    await this.messageLog.markDone(messageId, cleanText, agentResult.sessionId);
                }
                this.registry.markIdle(validatedAgentName, !agentResult.isError);
                if (!agentResult.isError)
                    this.registry.markOnline(validatedAgentName);
                // Return content nettoyé des directives (si parsing activé)
                const resultContent = shouldParseDirectives
                    ? [{ type: 'text', text: cleanText }]
                    : agentResult.content;
                return {
                    messageId,
                    sessionId: agentResult.sessionId,
                    content: resultContent,
                    isError: agentResult.isError,
                    directives: shouldParseDirectives ? directives?.actions.map((a) => a.kind) : undefined,
                };
            }
            catch (err) {
                const errorMsg = err.message;
                if (messageId && this.messageLog) {
                    if (errorMsg.includes('TIMEOUT') || errorMsg.includes('timeout')) {
                        await this.messageLog.markTimeout(messageId);
                    }
                    else {
                        await this.messageLog.markFailed(messageId, errorMsg);
                    }
                }
                this.registry.markIdle(validatedAgentName, false);
                throw err;
            }
        });
        return { jsonrpc: '2.0', id: req.id ?? null, result };
    }
    /**
     * agent.a2a — Agent A parle à Agent B (le hub orchestre).
     * B reçoit un prompt enrichi avec le contexte de A.
     */
    async methodAgentA2A(req) {
        const params = this.validateParams(A2AParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { fromAgent, toAgent, runner, prompt, model, path, metadata } = params.data;
        const validatedFrom = validateAgentName(fromAgent);
        const validatedTo = validateAgentName(toAgent);
        // Register les deux si pas vus
        this.registry.register(validatedFrom, runner);
        this.registry.register(validatedTo, runner);
        this.registry.incrementA2aSent(validatedFrom);
        this.registry.incrementA2aReceived(validatedTo);
        // Enrichit le prompt avec contexte A→B
        const enrichedPrompt = [
            `[A2A — Agent-to-Agent Message]`,
            `FROM: ${validatedFrom}`,
            `TO: ${validatedTo}`,
            `TIMESTAMP: ${new Date().toISOString()}`,
            `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
            prompt,
        ].join('\n');
        // Persist + run (délègue à agent.run)
        let messageId;
        if (this.messageLog) {
            messageId = await this.messageLog.create({
                fromAgent: validatedFrom,
                toAgent: validatedTo,
                runner: runner,
                prompt: enrichedPrompt,
                metadata: { ...(metadata ?? {}), a2a: true, from: validatedFrom, to: validatedTo },
            });
        }
        const result = await this.registry.withLock(validatedTo, async () => {
            this.registry.markBusy(validatedTo);
            if (messageId && this.messageLog) {
                await this.messageLog.markRunning(messageId);
            }
            try {
                const agentResult = await this.service.runAgent({
                    runner: runner,
                    prompt: enrichedPrompt,
                    agentName: validatedTo,
                    path,
                    model,
                });
                const responseText = agentResult.content.map((c) => c.text).join('\n');
                if (messageId && this.messageLog) {
                    await this.messageLog.markDone(messageId, responseText, agentResult.sessionId);
                }
                this.registry.markIdle(validatedTo, !agentResult.isError);
                if (!agentResult.isError)
                    this.registry.markOnline(validatedTo);
                return {
                    messageId,
                    from: validatedFrom,
                    to: validatedTo,
                    sessionId: agentResult.sessionId,
                    content: agentResult.content,
                    isError: agentResult.isError,
                };
            }
            catch (err) {
                const errorMsg = err.message;
                if (messageId && this.messageLog) {
                    if (errorMsg.includes('TIMEOUT') || errorMsg.includes('timeout')) {
                        await this.messageLog.markTimeout(messageId);
                    }
                    else {
                        await this.messageLog.markFailed(messageId, errorMsg);
                    }
                }
                this.registry.markIdle(validatedTo, false);
                throw err;
            }
        });
        return { jsonrpc: '2.0', id: req.id ?? null, result };
    }
    // ─── A2A Extended Methods ────────────────────────────────────────────────
    /**
     * agent.broadcast — Un agent → tous les agents (fan-out global).
     * Si race=true, retourne dès qu'un agent répond.
     * Si race=false (default), attend tous et retourne les résultats.
     */
    async methodAgentBroadcast(req) {
        const params = this.validateParams(BroadcastParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { fromAgent, runner, prompt, targets, race, model } = params.data;
        const validatedFrom = validateAgentName(fromAgent);
        const reqId = req.params?.__reqId;
        // Determine target list
        let targetAgents;
        if (targets && targets.length > 0) {
            targetAgents = targets.map(validateAgentName);
        }
        else {
            // All online agents (excluding fromAgent)
            const onlineAgents = this.registry
                .list({ status: 'online' })
                .map((a) => a.name)
                .filter((name) => name !== validatedFrom);
            if (onlineAgents.length === 0) {
                return {
                    jsonrpc: '2.0',
                    id: req.id ?? null,
                    error: { code: -32005, message: 'No online agents to broadcast to' },
                };
            }
            targetAgents = onlineAgents;
        }
        this.log.info(`[reqId=${reqId}] 📡 Broadcast from ${validatedFrom} → ${targetAgents.length} agents (race=${race})`);
        // Register + increment counters
        this.registry.register(validatedFrom, runner);
        this.registry.incrementA2aSent(validatedFrom);
        for (const t of targetAgents) {
            this.registry.register(t, runner);
            this.registry.incrementA2aReceived(t);
        }
        const enrichedPrompt = this.buildA2aPrompt(validatedFrom, 'BROADCAST', prompt);
        // Run agents
        const runOne = async (agentName) => {
            try {
                const result = await this.service.runAgent({
                    runner: runner,
                    prompt: enrichedPrompt,
                    agentName,
                    model,
                });
                return {
                    agentName,
                    success: !result.isError,
                    content: result.content,
                    sessionId: result.sessionId,
                };
            }
            catch (err) {
                return {
                    agentName,
                    success: false,
                    error: err.message,
                    content: [],
                };
            }
        };
        let results;
        if (race) {
            // Return first successful result
            const promises = targetAgents.map((t) => runOne(t));
            const first = await Promise.race(promises);
            results = [first];
        }
        else {
            results = await Promise.all(targetAgents.map((t) => runOne(t)));
        }
        const successCount = results.filter((r) => r.success).length;
        this.log.info(`[reqId=${reqId}] 📡 Broadcast done: ${successCount}/${results.length} success`);
        return {
            jsonrpc: '2.0',
            id: req.id ?? null,
            result: {
                from: validatedFrom,
                targets: targetAgents,
                total: results.length,
                successCount,
                race,
                results,
            },
        };
    }
    /**
     * agent.pipeline — Chaîne séquentielle A→B→C.
     * Le output de chaque step est injecté dans le prompt du suivant.
     */
    async methodAgentPipeline(req) {
        const params = this.validateParams(PipelineParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { initiator, runner, prompt, steps, accumulateContext, totalTimeoutMs } = params.data;
        const validatedInitiator = validateAgentName(initiator);
        const reqId = req.params?.__reqId;
        this.log.info(`[reqId=${reqId}] 🔗 Pipeline: ${validatedInitiator} → ${steps.map((s) => s.agentName).join(' → ')}`);
        const deadline = Date.now() + totalTimeoutMs;
        const outputs = [];
        let currentPrompt = prompt;
        for (let i = 0; i < steps.length; i++) {
            const step = steps[i];
            if (Date.now() > deadline) {
                this.log.warn(`[reqId=${reqId}] ⏱️ Pipeline timeout at step ${i}/${steps.length}`);
                break;
            }
            const validatedAgent = validateAgentName(step.agentName);
            this.registry.register(validatedAgent, runner);
            // Build prompt for this step
            const stepPrompt = (step.promptPrefix ? step.promptPrefix + '\n\n' : '') +
                `[Pipeline Step ${i + 1}/${steps.length} — Input from previous]\n${currentPrompt}`;
            try {
                const result = await this.service.runAgent({
                    runner: runner,
                    prompt: stepPrompt,
                    agentName: validatedAgent,
                    model: step.model,
                });
                const outputText = result.content.map((c) => c.text).join('\n');
                const success = !result.isError;
                outputs.push({ agentName: validatedAgent, output: outputText, success });
                if (!success) {
                    this.log.warn(`[reqId=${reqId}] ❌ Pipeline step ${i + 1} (${validatedAgent}) failed — stopping`);
                    break;
                }
                // Next step gets: just the last output, or all accumulated outputs
                if (accumulateContext) {
                    currentPrompt = outputs.map((o) => `[${o.agentName}]: ${o.output}`).join('\n\n---\n\n');
                }
                else {
                    currentPrompt = outputText;
                }
            }
            catch (err) {
                this.log.error(`[reqId=${reqId}] 💥 Pipeline step ${i + 1} (${validatedAgent}) error: ${err.message}`);
                outputs.push({
                    agentName: validatedAgent,
                    output: '',
                    success: false,
                });
                break;
            }
        }
        return {
            jsonrpc: '2.0',
            id: req.id ?? null,
            result: {
                initiator: validatedInitiator,
                totalSteps: steps.length,
                executedSteps: outputs.length,
                allSuccess: outputs.every((o) => o.success),
                outputs,
                finalOutput: outputs.length > 0 ? outputs[outputs.length - 1].output : '',
            },
        };
    }
    /**
     * agent.fanout — Un agent → plusieurs en parallèle + merge.
     * mergeStrategy: concat | best | vote | first_success
     */
    async methodAgentFanout(req) {
        const params = this.validateParams(FanoutParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { fromAgent, runner, prompt, targets, mergeStrategy, model } = params.data;
        const validatedFrom = validateAgentName(fromAgent);
        const reqId = req.params?.__reqId;
        const targetAgents = targets.map(validateAgentName);
        this.log.info(`[reqId=${reqId}] 🌐 Fanout from ${validatedFrom} → ${targetAgents.length} agents (merge=${mergeStrategy})`);
        const enrichedPrompt = this.buildA2aPrompt(validatedFrom, 'FANOUT', prompt);
        // Run all targets in parallel
        const results = await Promise.all(targetAgents.map(async (agentName) => {
            try {
                const result = await this.service.runAgent({
                    runner: runner,
                    prompt: enrichedPrompt,
                    agentName,
                    model,
                });
                return {
                    agentName,
                    success: !result.isError,
                    text: result.content.map((c) => c.text).join('\n'),
                    sessionId: result.sessionId,
                };
            }
            catch (err) {
                return {
                    agentName,
                    success: false,
                    text: err.message,
                    sessionId: undefined,
                };
            }
        }));
        // Merge results
        let merged;
        let winner;
        switch (mergeStrategy) {
            case 'first_success': {
                const first = results.find((r) => r.success);
                merged = first ? first.text : 'All agents failed';
                winner = first?.agentName;
                break;
            }
            case 'best': {
                // Default: longest response
                const sorted = [...results]
                    .filter((r) => r.success)
                    .sort((a, b) => b.text.length - a.text.length);
                merged = sorted.length > 0 ? sorted[0].text : 'All agents failed';
                winner = sorted[0]?.agentName;
                break;
            }
            case 'vote': {
                // Simple vote: count similar responses (by length bucket)
                const buckets = new Map();
                for (const r of results.filter((r) => r.success)) {
                    const bucket = Math.floor(r.text.length / 100) * 100;
                    if (!buckets.has(bucket))
                        buckets.set(bucket, []);
                    buckets.get(bucket).push({ agentName: r.agentName, text: r.text });
                }
                const sortedBuckets = [...buckets.entries()].sort((a, b) => b[1].length - a[1].length);
                merged = sortedBuckets.length > 0 ? sortedBuckets[0][1][0].text : 'No votes';
                winner = sortedBuckets[0]?.[1]?.[0]?.agentName;
                break;
            }
            case 'concat':
            default: {
                merged = results
                    .map((r) => `### ${r.agentName}${r.success ? '' : ' (FAILED)'}\n${r.text}`)
                    .join('\n\n---\n\n');
                break;
            }
        }
        return {
            jsonrpc: '2.0',
            id: req.id ?? null,
            result: {
                from: validatedFrom,
                targets: targetAgents,
                mergeStrategy,
                winner,
                totalResults: results.length,
                successCount: results.filter((r) => r.success).length,
                results,
                merged,
            },
        };
    }
    /**
     * agent.delegate — Délégation async (fire-and-forget avec taskId).
     * Si async=true (default), retourne immédiatement avec un taskId.
     * Si callbackUrl fourni, POST le résultat quand l'agent termine.
     */
    async methodAgentDelegate(req) {
        const params = this.validateParams(DelegateParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { fromAgent, toAgent, runner, prompt, model, callbackUrl, async: isAsync, metadata, } = params.data;
        const validatedFrom = validateAgentName(fromAgent);
        const validatedTo = validateAgentName(toAgent);
        const reqId = req.params?.__reqId;
        const taskId = crypto.randomUUID();
        this.log.info(`[reqId=${reqId}] 🤝 Delegate ${validatedFrom} → ${validatedTo} (taskId=${taskId}, async=${isAsync})`);
        const enrichedPrompt = this.buildA2aPrompt(validatedFrom, 'DELEGATE', prompt);
        // Persist
        let messageId;
        if (this.messageLog) {
            messageId = await this.messageLog.create({
                fromAgent: validatedFrom,
                toAgent: validatedTo,
                runner: runner,
                prompt: enrichedPrompt,
                metadata: { ...(metadata ?? {}), taskId, delegate: true },
            });
        }
        // Run async (fire-and-forget)
        const runAsync = async () => {
            try {
                const result = await this.service.runAgent({
                    runner: runner,
                    prompt: enrichedPrompt,
                    agentName: validatedTo,
                    model,
                });
                const responseText = result.content.map((c) => c.text).join('\n');
                if (messageId && this.messageLog) {
                    await this.messageLog.markDone(messageId, responseText, result.sessionId);
                }
                // Callback si URL fournie
                if (callbackUrl) {
                    try {
                        await fetch(callbackUrl, {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                taskId,
                                from: validatedFrom,
                                to: validatedTo,
                                success: !result.isError,
                                content: result.content,
                                sessionId: result.sessionId,
                            }),
                        });
                    }
                    catch (err) {
                        this.log.warn(`[reqId=${reqId}] ⚠️ Callback ${callbackUrl} failed: ${err.message}`);
                    }
                }
            }
            catch (err) {
                const errorMsg = err.message;
                if (messageId && this.messageLog) {
                    await this.messageLog.markFailed(messageId, errorMsg);
                }
                this.log.error(`[reqId=${reqId}] 💥 Delegate ${taskId} failed: ${errorMsg}`);
            }
        };
        if (isAsync) {
            // Fire-and-forget — return immediately with taskId
            void runAsync();
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                result: {
                    taskId,
                    from: validatedFrom,
                    to: validatedTo,
                    status: 'delegated',
                    message: `Task delegated to ${validatedTo}. Use agent.status to check progress.`,
                    messageId,
                },
            };
        }
        // Sync mode — wait for result
        try {
            await runAsync();
            // Read the result from MessageLog
            if (messageId && this.messageLog) {
                const msg = await this.messageLog.getById(messageId);
                return {
                    jsonrpc: '2.0',
                    id: req.id ?? null,
                    result: {
                        taskId,
                        from: validatedFrom,
                        to: validatedTo,
                        status: msg?.status ?? 'unknown',
                        content: msg?.response ? [{ type: 'text', text: msg.response }] : [],
                        messageId,
                    },
                };
            }
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                result: { taskId, status: 'done', messageId },
            };
        }
        catch (err) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32603, message: `Delegate failed: ${err.message}` },
            };
        }
    }
    /**
     * agent.query — Query multi-agents, retourne toutes les réponses rapidement.
     * Comme fanout mais optimisé pour le read-only (courte réponse rapide).
     */
    async methodAgentQuery(req) {
        const params = this.validateParams(QueryParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { fromAgent, runner, prompt, targets, agentTimeoutMs, model } = params.data;
        const validatedFrom = validateAgentName(fromAgent);
        const reqId = req.params?.__reqId;
        const targetAgents = targets.map(validateAgentName);
        this.log.info(`[reqId=${reqId}] ❓ Query from ${validatedFrom} → ${targetAgents.length} agents`);
        const enrichedPrompt = this.buildA2aPrompt(validatedFrom, 'QUERY', prompt);
        // Run all in parallel with timeout
        const results = await Promise.all(targetAgents.map(async (agentName) => {
            const controller = new AbortController();
            const timer = setTimeout(() => controller.abort(), agentTimeoutMs);
            try {
                const result = await this.service.runAgent({
                    runner: runner,
                    prompt: enrichedPrompt,
                    agentName,
                    model,
                });
                clearTimeout(timer);
                return {
                    agentName,
                    success: !result.isError,
                    text: result.content.map((c) => c.text).join('\n'),
                    responseTimeMs: 0, // Could track if needed
                };
            }
            catch (err) {
                clearTimeout(timer);
                return {
                    agentName,
                    success: false,
                    text: err.message,
                    responseTimeMs: agentTimeoutMs,
                };
            }
        }));
        return {
            jsonrpc: '2.0',
            id: req.id ?? null,
            result: {
                from: validatedFrom,
                totalQueried: targetAgents.length,
                successCount: results.filter((r) => r.success).length,
                results,
            },
        };
    }
    // ─── A2A Helpers ──────────────────────────────────────────────────────────
    /**
     * Construit un prompt A2A standardisé avec header.
     */
    buildA2aPrompt(fromAgent, kind, prompt) {
        return [
            `[A2A — ${kind}]`,
            `FROM: ${fromAgent}`,
            `TIMESTAMP: ${new Date().toISOString()}`,
            `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
            prompt,
        ].join('\n');
    }
    /**
     * agent.status — Status live d'un agent (busy/idle/online) via registry.
     * Possibilité de proxy vers Overmind MCP agent_control aussi.
     */
    async methodAgentStatus(req) {
        const params = this.validateParams(AgentStatusParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const { agentName, runner, action, sinceTimestamp, timeoutMs } = params.data;
        const validatedName = validateAgentName(agentName);
        // Status local du registry (instantané)
        const localState = this.registry.get(validatedName);
        // Si action demandée (status/stream/kill/wait), on proxy vers MCP
        if (action) {
            const result = await this.service.agentStatus({
                agentName: validatedName,
                action,
                runner: runner,
                sinceTimestamp,
                timeoutMs,
            });
            // Sync registry avec retour MCP
            if (action === 'status') {
                const mcpState = this.parseAgentControlStatus(result);
                if (mcpState === 'running') {
                    this.registry.markBusy(validatedName, result.sessionId);
                }
                else if (mcpState === 'done') {
                    this.registry.markIdle(validatedName, true);
                    this.registry.markOnline(validatedName);
                }
                else if (mcpState === 'failed') {
                    this.registry.markIdle(validatedName, false);
                }
                else if (mcpState === 'orphaned') {
                    this.registry.markOffline(validatedName);
                }
            }
            else if (action === 'kill') {
                this.registry.markOffline(validatedName);
            }
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                result: { local: localState, mcp: result },
            };
        }
        return { jsonrpc: '2.0', id: req.id ?? null, result: { local: localState } };
    }
    /**
     * agent.list — Liste tous les agents et leur état.
     */
    async methodAgentList(req) {
        const params = this.validateParams(ListAgentsParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const agents = this.registry.list({
            status: params.data.status,
            runner: params.data.runner,
        });
        const stats = this.registry.stats();
        return { jsonrpc: '2.0', id: req.id ?? null, result: { agents, stats } };
    }
    /**
     * agent.kill — Kill un agent en cours.
     */
    async methodAgentKill(req) {
        const params = this.validateParams(z.object({ agentName: z.string().min(1), runner: z.string().optional() }), req.params, req.id);
        if (!params.ok)
            return params.response;
        const result = await this.service.killAgent(validateAgentName(params.data.agentName), params.data.runner);
        this.registry.markOffline(params.data.agentName);
        return { jsonrpc: '2.0', id: req.id ?? null, result };
    }
    /**
     * message.history — Historique des messages persistés.
     */
    async methodMessageHistory(req) {
        if (!this.messageLog) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'MessageLog disabled' },
            };
        }
        const params = this.validateParams(MessageHistoryParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const messages = await this.messageLog.list(params.data);
        return { jsonrpc: '2.0', id: req.id ?? null, result: { messages, count: messages.length } };
    }
    /**
     * message.get — Récupère un message par ID.
     */
    async methodMessageGet(req) {
        if (!this.messageLog) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'MessageLog disabled' },
            };
        }
        const params = this.validateParams(MessageGetParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const message = await this.messageLog.getById(params.data.id);
        return { jsonrpc: '2.0', id: req.id ?? null, result: { message } };
    }
    /**
     * message.replay — Rejoue un message (re-run l'agent avec le même prompt).
     * Le nouveau run crée un NOUVEAU message, l'ancien reste en status 'pending' pour traçabilité.
     */
    async methodMessageReplay(req) {
        if (!this.messageLog) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'MessageLog disabled' },
            };
        }
        const params = this.validateParams(MessageReplayParams, req.params, req.id);
        if (!params.ok)
            return params.response;
        const original = await this.messageLog.getById(params.data.id);
        if (!original) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32004, message: 'Message not found' },
            };
        }
        // Relance via agent.run
        const replayReq = {
            jsonrpc: '2.0',
            id: req.id,
            method: 'agent.run',
            params: {
                agentName: original.toAgent,
                runner: original.runner,
                prompt: original.prompt,
                sessionId: original.sessionId ?? undefined,
                metadata: { ...(original.metadata ?? {}), replayOf: original.id },
            },
        };
        return this.methodAgentRun(replayReq);
    }
    /**
     * message.stats — Statistiques globales du log.
     */
    async methodMessageStats(req) {
        if (!this.messageLog) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'MessageLog disabled' },
            };
        }
        const stats = await this.messageLog.stats();
        return { jsonrpc: '2.0', id: req.id ?? null, result: stats };
    }
    // ─── SessionStore RPC Methods ───────────────────────────────────────────
    async methodSessionGet(req) {
        if (!this.sessions) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'SessionStore disabled' },
            };
        }
        const params = this.validateParams(z.object({ externalKey: z.string().min(1), agentName: z.string().min(1) }), req.params, req.id);
        if (!params.ok)
            return params.response;
        const entry = this.sessions.get(params.data.externalKey, params.data.agentName);
        return { jsonrpc: '2.0', id: req.id ?? null, result: { session: entry } };
    }
    async methodSessionList(_req) {
        if (!this.sessions) {
            return {
                jsonrpc: '2.0',
                id: _req.id ?? null,
                error: { code: -32003, message: 'SessionStore disabled' },
            };
        }
        const list = this.sessions.list();
        const stats = this.sessions.stats();
        return { jsonrpc: '2.0', id: _req.id ?? null, result: { sessions: list, stats } };
    }
    async methodSessionDelete(req) {
        if (!this.sessions) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                error: { code: -32003, message: 'SessionStore disabled' },
            };
        }
        const params = this.validateParams(z.object({ externalKey: z.string().min(1), agentName: z.string().min(1) }), req.params, req.id);
        if (!params.ok)
            return params.response;
        const deleted = this.sessions.delete(params.data.externalKey, params.data.agentName);
        return { jsonrpc: '2.0', id: req.id ?? null, result: { deleted } };
    }
    async methodSessionStats(_req) {
        if (!this.sessions) {
            return {
                jsonrpc: '2.0',
                id: _req.id ?? null,
                error: { code: -32003, message: 'SessionStore disabled' },
            };
        }
        return { jsonrpc: '2.0', id: _req.id ?? null, result: this.sessions.stats() };
    }
    // ─── Webhook RPC Method (programmatic, sans passer par HTTP) ────────────
    async methodWebhookSms(req) {
        const params = this.validateParams(z.object({
            provider: z.enum(['voipms', 'twilio', 'discord', 'generic']).default('voipms'),
            payload: z.record(z.string(), z.unknown()),
            externalKey: z.string().optional(),
            /** Si fourni, dispatch automatique vers agent.run après adaptation */
            autoDispatch: z
                .object({
                agentName: z.string().min(1),
                runner: z.string().min(1),
                model: z.string().optional(),
                mode: z.string().optional(),
            })
                .optional(),
        }), req.params, req.id);
        if (!params.ok)
            return params.response;
        const adapter = new WebhookAdapter({ provider: params.data.provider, logger: this.log });
        const normalized = adapter.adapt(params.data.payload);
        const externalKey = params.data.externalKey ?? normalized.externalKey;
        if (!params.data.autoDispatch) {
            return {
                jsonrpc: '2.0',
                id: req.id ?? null,
                result: {
                    externalKey,
                    prompt: normalized.prompt,
                    mediaUrls: normalized.mediaUrls,
                    metadata: normalized.metadata,
                },
            };
        }
        // Auto-dispatch vers agent.run
        const innerReq = {
            jsonrpc: '2.0',
            id: req.id,
            method: 'agent.run',
            params: {
                agentName: params.data.autoDispatch.agentName,
                runner: params.data.autoDispatch.runner,
                prompt: normalized.prompt,
                model: params.data.autoDispatch.model,
                mode: params.data.autoDispatch.mode,
                externalKey,
                metadata: { webhook: true, provider: params.data.provider, ...normalized.metadata },
            },
        };
        return this.methodAgentRun(innerReq);
    }
    // ─── Helpers ────────────────────────────────────────────────────────────
    /**
     * Valide les params d'une requête JSON-RPC via Zod.
     * Retourne soit { data } (succès) soit une JsonRpcResponse d'erreur (à retourner tel quel).
     */
    validateParams(schema, params, id) {
        const result = schema.safeParse(params ?? {});
        if (!result.success) {
            return {
                ok: false,
                response: {
                    jsonrpc: '2.0',
                    id: id ?? null,
                    error: {
                        ...JSON_RPC_ERRORS.INVALID_PARAMS,
                        data: result.error.issues,
                    },
                },
            };
        }
        return { ok: true, data: result.data };
    }
    respondError(res, id, error) {
        this.writeJson(res, 200, { jsonrpc: '2.0', id, error });
    }
    buildErrorResponse(id, err) {
        const error = err;
        return {
            jsonrpc: '2.0',
            id,
            error: {
                code: error.code ?? JSON_RPC_ERRORS.INTERNAL_ERROR.code,
                message: error.message ?? 'Internal error',
            },
        };
    }
    readBody(req, maxBytes) {
        const limit = maxBytes ?? this.parseBodyLimit(this.config.jsonBodyLimit);
        return new Promise((resolve, reject) => {
            const chunks = [];
            let total = 0;
            req.on('data', (chunk) => {
                total += chunk.length;
                if (total > limit) {
                    req.destroy();
                    reject(Object.assign(new Error('Request body too large'), { code: 'EBODYTOOLARGE' }));
                    return;
                }
                chunks.push(chunk);
            });
            req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
            req.on('error', reject);
        });
    }
    /**
     * Parse "10mb" / "1gb" en bytes.
     */
    parseBodyLimit(limit) {
        if (!limit)
            return 10 * 1024 * 1024; // 10mb default
        const m = limit.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i);
        if (!m)
            return 10 * 1024 * 1024;
        const num = Number(m[1]);
        const unit = (m[2] ?? 'b').toLowerCase();
        const mult = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
        return Math.floor(num * (mult[unit] ?? 1));
    }
    /**
     * Parse le retour de agent_control pour extraire le status de l'agent distant.
     */
    parseAgentControlStatus(result) {
        const text = result.content
            .map((c) => c.text)
            .join('\n')
            .toLowerCase();
        if (text.includes('running') || text.includes('status: running'))
            return 'running';
        if (text.includes('done') || text.includes('completed'))
            return 'done';
        if (text.includes('failed') || text.includes('error'))
            return 'failed';
        if (text.includes('orphaned') || text.includes('offline'))
            return 'orphaned';
        return 'unknown';
    }
}
// ─── Safe Stats wrapper inline (déclaré ici pour éviter d'augmenter MessageLog) ───
// (Voir handleHealth — try/catch sur this.messageLog.stats())
//# sourceMappingURL=OverBridgeServer.js.map