Files
memind/tool-gateway.mjs
T

306 lines
9.7 KiB
JavaScript

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,
};
}