306 lines
9.7 KiB
JavaScript
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,
|
|
};
|
|
}
|