import { spawn as nodeSpawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import fs from 'node:fs/promises'; import path from 'node:path'; const CODE_EXECUTORS = new Set(['aider', 'openhands']); const DEFAULT_STDIO_LIMIT = 64 * 1024; function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function positiveInteger(value, fallback) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return fallback; return Math.floor(n); } function normalizeExecutor(value, fallback = 'aider') { const normalized = String(value ?? fallback).trim().toLowerCase(); if (CODE_EXECUTORS.has(normalized)) return normalized; return fallback; } function csvSet(value) { return new Set( String(value ?? '') .split(',') .map((item) => item.trim().toLowerCase()) .filter(Boolean), ); } function appendLimited(current, chunk, limit) { const next = `${current}${Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '')}`; if (next.length <= limit) return next; return next.slice(next.length - limit); } export function extractToolInstruction(userMessage) { const content = userMessage?.content; if (typeof content === 'string') return content.trim(); if (!Array.isArray(content)) { return String(userMessage?.text ?? userMessage?.value ?? '').trim(); } return content .map((item) => { if (typeof item === 'string') return item; if (item?.type === 'text') return item.text ?? ''; return ''; }) .map((item) => String(item ?? '').trim()) .filter(Boolean) .join('\n') .trim(); } function runValidation(userMessage) { const metadata = userMessage?.metadata ?? {}; const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; const validation = runMetadata.validation ?? metadata.toolGatewayValidation; return validation && typeof validation === 'object' && !Array.isArray(validation) ? validation : null; } function validationFilePaths(userMessage) { const validation = runValidation(userMessage); if (!validation) return []; const candidates = [ validation.expectedFile ?? validation.expectedPath, ...(Array.isArray(validation.expectedFiles) ? validation.expectedFiles : []), ]; return candidates .map((item) => { if (typeof item === 'string') return item.trim(); if (!item || typeof item !== 'object' || Array.isArray(item)) return ''; return String(item.path ?? item.file ?? item.relativePath ?? '').trim(); }) .filter(Boolean); } export function resolveAiderReceiptPath(userMessage, requestId) { const expected = `.memind/agent-runs/${String(requestId ?? '').trim()}.json`; return validationFilePaths(userMessage).find((item) => item === expected) ?? null; } export async function prepareAiderReceiptFile(cwd, relativePath) { if (!cwd || !relativePath) return null; const root = path.resolve(String(cwd)); const target = path.resolve(root, relativePath); if (target !== root && !target.startsWith(`${root}${path.sep}`)) { throw new Error(`Aider receipt path escapes working directory: ${relativePath}`); } await fs.mkdir(path.dirname(target), { recursive: true }); try { await fs.access(target); } catch { await fs.writeFile( target, `${JSON.stringify({ status: 'pending', executor: 'aider' }, null, 2)}\n`, { flag: 'wx' }, ); } return target; } async function resolveAiderContextFiles(userMessage, cwd) { const metadata = userMessage?.metadata ?? {}; const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; const candidates = Array.isArray(runMetadata.aiderContextFiles) ? runMetadata.aiderContextFiles : []; if (!cwd || candidates.length === 0) return []; const root = path.resolve(String(cwd)); const resolved = []; for (const relativePath of candidates.slice(0, 40)) { const target = path.resolve(root, String(relativePath ?? '')); if (target === root || !target.startsWith(`${root}${path.sep}`)) continue; try { const stat = await fs.stat(target); if (stat.isFile()) resolved.push(target); } catch { // Review context is best-effort; delivery validation remains authoritative. } } return resolved; } export function hardenAiderLaunchPlan(plan, receiptPath = null, contextFiles = []) { const args = [...(plan?.args ?? [])]; for (const flag of ['--no-git', '--no-auto-commits', '--no-dirty-commits']) { if (!args.includes(flag)) args.push(flag); } for (const contextFile of contextFiles) { if (!args.includes(contextFile)) args.push('--file', contextFile); } if (receiptPath && !args.includes(receiptPath)) { args.push('--file', receiptPath); } return { ...plan, args }; } export function createToolGateway({ llmProviderService, env = process.env, spawnImpl = nodeSpawn, } = {}) { const enabled = envFlag(env.MEMIND_TOOL_GATEWAY_ENABLED, false); const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false); const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider'); const openhandsTaskTypes = csvSet( env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex', ); const stdioLimit = positiveInteger(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT); function getStatus() { return { enabled, dryRun, protocol: 'agent-run-v1', executors: ['aider', 'openhands'], defaultExecutor, openhandsTaskTypes: [...openhandsTaskTypes], }; } function selectExecutor({ userMessage, taskType } = {}) { const metadata = userMessage?.metadata ?? {}; const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; const requested = normalizeExecutor(runMetadata.executor, ''); if (requested) return requested; const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase(); if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands'; return defaultExecutor; } async function executeJob({ runId, requestId, userId, userMessage, taskType, cwd, timeoutMs = 15 * 60 * 1000, } = {}) { if (!enabled) { throw new Error('Tool Gateway is disabled'); } if (!llmProviderService?.getExecutorLaunchPlan) { throw new Error('Tool Gateway missing llm provider service'); } const instruction = extractToolInstruction(userMessage); if (!instruction) { throw new Error('Tool Gateway job missing instruction'); } const executor = selectExecutor({ userMessage, taskType }); const receiptPath = executor === 'aider' ? resolveAiderReceiptPath(userMessage, requestId) : null; const executorInstruction = receiptPath ? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.` : instruction; let plan = await llmProviderService.getExecutorLaunchPlan(executor, { cwd, mode: 'headless', instruction: executorInstruction, purpose: 'default', includeSecret: true, }); if (!plan?.ok) { throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`); } if (executor === 'aider') { const preparedReceipt = dryRun ? (receiptPath ? path.resolve(String(cwd), receiptPath) : null) : await prepareAiderReceiptFile(cwd, receiptPath); const contextFiles = await resolveAiderContextFiles(userMessage, cwd); plan = hardenAiderLaunchPlan(plan, preparedReceipt, contextFiles); } if (dryRun) { return { ok: true, dryRun: true, executor, protocol: 'agent-run-v1', cwd: plan.cwd, command: plan.command, args: plan.args ?? [], runId, requestId, userId, }; } return await new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const child = spawnImpl(plan.command, plan.args ?? [], { cwd: plan.cwd, env: { ...process.env, ...(plan.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], }); const cleanup = () => { if (timer) clearTimeout(timer); }; const timer = setTimeout(() => { child.kill('SIGTERM'); const err = new Error(`Tool Gateway job timed out after ${timeoutMs}ms`); err.code = 'TOOL_GATEWAY_TIMEOUT'; reject(err); }, timeoutMs); if (child.stdout instanceof EventEmitter) { child.stdout.on('data', (chunk) => { stdout = appendLimited(stdout, chunk, stdioLimit); }); } if (child.stderr instanceof EventEmitter) { child.stderr.on('data', (chunk) => { stderr = appendLimited(stderr, chunk, stdioLimit); }); } child.on('error', (err) => { cleanup(); reject(err); }); child.on('exit', (code, signal) => { cleanup(); if (code === 0) { resolve({ ok: true, executor, protocol: 'agent-run-v1', cwd: plan.cwd, command: plan.command, args: plan.args ?? [], exitCode: code, signal: signal ?? null, stdout, stderr, }); return; } const err = new Error(`Tool Gateway executor ${executor} exited with ${signal ?? code}`); err.code = 'TOOL_GATEWAY_EXECUTOR_FAILED'; err.executor = executor; err.exitCode = code; err.signal = signal ?? null; err.stdout = stdout; err.stderr = stderr; reject(err); }); }); } return { getStatus, selectExecutor, executeJob, }; }