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,656 lines • 71.4 kB
JavaScript
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ 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 { isAuditEnabled } from '../lib/audit/types.js';
import { captureBridgeSubject, propagateSubjectContext } from '../lib/audit/bridge_capture.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;
// ── Audit Trail : pseudonymise l'externalKey en data_subject_id ──────────────
if (isAuditEnabled()) {
const capture = captureBridgeSubject(externalKey, params.data.provider);
propagateSubjectContext(capture);
this.log.info('Audit: Bridge subject captured', {
provider: params.data.provider,
source: capture.source,
internal: capture.isInternal,
});
}
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