UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

618 lines (617 loc) 27.9 kB
/** * Research tools factory for AutoResearch system. * * These tools allow an AI agent to conduct autonomous experiments: * reading/writing code, running experiments, recording results, and * managing the research lifecycle (accept/revert/checkpoint). * * @module autoresearch/tools */ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tool } from "ai"; import { z } from "zod"; import { withTimeout } from "../utils/errorHandling.js"; import { logger } from "../utils/logger.js"; import { parseExperimentSummary } from "./summaryParser.js"; /** * Create research management tools bound to a research session. * * These tools follow the same factory pattern as `createTaskTools()` in * `src/lib/tasks/tools/taskTools.ts`. Dependencies are captured via closure, * eliminating the need for module-level singleton state. * * @param deps - The research dependencies to bind to * @returns Record of tool name to tool definition * * @example * ```typescript * const tools = createResearchTools({ config, stateStore, repoPolicy, runner, recorder }); * // tools.research_get_context, tools.research_read_file, etc. * ``` */ export function createResearchTools(deps) { const { config, stateStore, repoPolicy, runner, recorder } = deps; return { /** * Get current research context including state, config, and recent results. */ research_get_context: tool({ description: "Get the current research context including branch, commits, metrics, recent results, paths, phase, and run count.", inputSchema: z.object({}), execute: async () => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state found. Initialize first.", }; } // Get recent results (last 10) const allRecords = await recorder.readAll(); const recentResults = allRecords.slice(-10); return { success: true, branch: state.branch, acceptedCommit: state.acceptedCommit, bestMetric: state.bestMetric, recentResults, mutablePaths: config.mutablePaths, immutablePaths: config.immutablePaths, currentPhase: state.currentPhase, runCount: state.runCount, tag: state.tag, keepCount: state.keepCount, }; } catch (error) { logger.error("[researchTools] research_get_context failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Read a file from the repository if allowed by policy. */ research_read_file: tool({ description: "Read the contents of a file from the repository. Only readable if in mutablePaths, immutablePaths, or is the programPath.", inputSchema: z.object({ path: z.string().describe("Relative file path from repo root"), }), execute: async ({ path: filePath }) => { try { if (!repoPolicy.isReadAllowed(filePath)) { return { success: false, error: `Read not allowed for path: ${filePath}. Must be in mutablePaths, immutablePaths, or programPath.`, }; } const fullPath = path.join(config.repoPath, filePath); const content = readFileSync(fullPath, "utf-8"); return { success: true, path: filePath, content, }; } catch (error) { logger.error("[researchTools] research_read_file failed", { path: filePath, error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), path: filePath, }; } }, }), /** * Write a candidate file to the repository if allowed by policy. */ research_write_candidate: tool({ description: "Write content to a file in the repository. Only allowed for paths in mutablePaths.", inputSchema: z.object({ path: z.string().describe("Relative file path from repo root"), content: z.string().describe("Content to write to the file"), }), execute: async ({ path: filePath, content }) => { try { if (!repoPolicy.isWriteAllowed(filePath)) { return { success: false, error: `Write not allowed for path: ${filePath}. Must be in mutablePaths.`, }; } // Detect and fix literal escape sequences that LLMs sometimes produce. // If the content has literal \n but no real newlines (for files > ~10 lines), // the AI serialized newlines incorrectly. let sanitizedContent = content; const realNewlines = (content.match(/\n/g) || []).length; const literalBackslashN = (content.match(/\\n/g) || []).length; if (realNewlines < 5 && literalBackslashN > 20) { // Content looks like it has literal \n instead of real newlines sanitizedContent = content .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\\\/g, "\\"); logger.warn(`[researchTools] Detected literal escape sequences in write content for ${filePath}. ` + `Fixed ${literalBackslashN} literal \\n → real newlines.`); } const fullPath = path.join(config.repoPath, filePath); writeFileSync(fullPath, sanitizedContent, "utf-8"); return { success: true, path: filePath, bytesWritten: Buffer.byteLength(sanitizedContent, "utf-8"), }; } catch (error) { logger.error("[researchTools] research_write_candidate failed", { path: filePath, error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), path: filePath, }; } }, }), /** * Get git diff of mutable paths only. */ research_diff: tool({ description: "Get the git diff showing changes to mutablePaths only. Returns empty string if no changes.", inputSchema: z.object({}), execute: async () => { try { // Get diff for each mutable path const diffs = []; for (const mutablePath of config.mutablePaths) { try { const diff = execFileSync("git", ["diff", "--", mutablePath], { cwd: config.repoPath, encoding: "utf-8", }); if (diff.trim()) { diffs.push(diff); } } catch (err) { // Only suppress if path doesn't exist const fullPath = path.join(config.repoPath, mutablePath); if (!existsSync(fullPath)) { continue; // Path doesn't exist yet, skip } throw err; // Real git error, let outer handler catch it } } const combinedDiff = diffs.join("\n"); return { success: true, diff: combinedDiff, hasChanges: combinedDiff.length > 0, }; } catch (error) { logger.error("[researchTools] research_diff failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Commit staged changes as a candidate. */ research_commit_candidate: tool({ description: "Commit staged changes as a candidate experiment. Validates branch and paths, stages mutablePaths, creates commit, and updates state with candidateCommit.", inputSchema: z.object({ message: z.string().describe("Git commit message"), }), execute: async ({ message }) => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state found." }; } // Stage mutable paths first (so validateCommit checks the staged index) for (const mutablePath of config.mutablePaths) { try { execFileSync("git", ["add", "--", mutablePath], { cwd: config.repoPath, encoding: "utf-8", }); } catch (addErr) { // Only ignore if the path doesn't exist; rethrow real git errors const msg = addErr instanceof Error ? addErr.message : String(addErr); if (!msg.includes("did not match any files") && !msg.includes("pathspec")) { throw addErr; } } } // Validate commit (checks staged files against policy) const validation = await repoPolicy.validateCommit(state.branch); if (!validation.valid) { // Unstage on validation failure for (const mutablePath of config.mutablePaths) { try { execFileSync("git", ["restore", "--staged", "--", mutablePath], { cwd: config.repoPath, encoding: "utf-8", }); } catch { /* ignore */ } } return { success: false, error: `Commit validation failed: ${validation.violations.join(", ")}`, violations: validation.violations, }; } // Create commit (--no-verify skips pre-commit hooks which may fail in worktrees) execFileSync("git", ["commit", "--no-verify", "-m", message], { cwd: config.repoPath, encoding: "utf-8", }); // Get the new commit hash const candidateCommit = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: config.repoPath, encoding: "utf-8", }).trim(); // Update state — clear run-derived fields so next experiment starts fresh await stateStore.update({ candidateCommit, lastSummary: null, lastStatus: null, }); return { success: true, candidateCommit, message, }; } catch (error) { logger.error("[researchTools] research_commit_candidate failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Run the experiment. */ research_run_experiment: tool({ description: "Run the configured experiment command with timeout. Returns structured summary with metric, memory, and crash status.", inputSchema: z.object({ description: z.string().describe("Description of this experiment run"), }), execute: async ({ description }) => { try { logger.info("[researchTools] Starting experiment", { description }); const summary = await withTimeout(runner.run(), config.timeoutMs + 30_000, new Error("Experiment runner exceeded safety timeout")); // Increment run count and save lastSummary const state = await stateStore.load(); if (state) { await stateStore.update({ runCount: state.runCount + 1, lastSummary: summary, }); } return { success: true, description, summary, }; } catch (error) { logger.error("[researchTools] research_run_experiment failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Parse the experiment log file. */ research_parse_log: tool({ description: "Parse the run.log file to extract structured experiment summary (metric, memory, crash status, etc.)", inputSchema: z.object({}), execute: async () => { try { const logPath = path.join(config.repoPath, config.logPath); const logContent = readFileSync(logPath, "utf-8"); const summary = parseExperimentSummary(logContent, config.metric, config.memoryMetric); return { success: true, summary, }; } catch (error) { logger.error("[researchTools] research_parse_log failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Record an experiment result. */ research_record: tool({ description: "Record the result of an experiment to results.tsv and runs.jsonl. Status is computed deterministically from the experiment outcome.", inputSchema: z.object({ description: z.string().describe("Description of the experiment"), }), execute: async ({ description }) => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state found." }; } // Get the current summary from state (saved by research_run_experiment) const summary = state.lastSummary; if (!summary) { return { success: false, error: "No experiment summary found. Run research_run_experiment first.", }; } const metric = summary.metric ?? null; const memoryGb = summary.memoryValue ?? null; // Compute status deterministically let status; if (summary?.timedOut) { status = "timeout"; } else if (summary?.crashed || metric === null) { status = "crash"; } else if (state.bestMetric === null) { // First successful run - always keep status = "keep"; } else { // Compare metric against best const isImprovement = config.metric.direction === "lower" ? metric < state.bestMetric : metric > state.bestMetric; status = isImprovement ? "keep" : "discard"; } const commit = state.candidateCommit || state.acceptedCommit || "unknown"; const record = { commit, metric, memoryGb, status, description, timestamp: new Date().toISOString(), }; await recorder.appendTsv(record); await recorder.appendJsonl(record); // Update last status in state await stateStore.update({ lastStatus: status }); return { success: true, record, }; } catch (error) { logger.error("[researchTools] research_record failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Accept the candidate commit as the new baseline. */ research_accept: tool({ description: "Accept the candidate commit as the new best. Updates acceptedCommit to candidateCommit, updates bestMetric from latest metric, and increments keepCount.", inputSchema: z.object({}), execute: async () => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state found." }; } if (!state.candidateCommit) { return { success: false, error: "No candidate commit to accept." }; } // Get latest summary from state (saved by research_run_experiment) const summary = state.lastSummary; if (!summary || summary.metric === null) { return { success: false, error: "No valid experiment summary to accept. Run an experiment first.", }; } if (summary.crashed || summary.timedOut) { return { success: false, error: `Cannot accept a ${summary.crashed ? "crashed" : "timed-out"} experiment. Use research_revert instead.`, }; } // Require that the latest recorded status is "keep" (set by research_record) if (state.lastStatus !== "keep") { return { success: false, error: `Cannot accept: last recorded status is "${state.lastStatus}". Only "keep" experiments can be accepted.`, }; } let bestMetric = state.bestMetric; // Validate that this is actually an improvement if (state.bestMetric !== null) { const isImprovement = config.metric.direction === "lower" ? summary.metric < state.bestMetric : summary.metric > state.bestMetric; if (!isImprovement) { return { success: false, error: `Metric ${summary.metric} is not better than current best ${state.bestMetric} (direction: ${config.metric.direction}). Use research_revert instead.`, }; } } bestMetric = summary.metric; await stateStore.update({ acceptedCommit: state.candidateCommit, bestMetric, baselineMetric: state.baselineMetric ?? bestMetric, keepCount: state.keepCount + 1, candidateCommit: null, }); return { success: true, acceptedCommit: state.candidateCommit, bestMetric, keepCount: state.keepCount + 1, }; } catch (error) { logger.error("[researchTools] research_accept failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Revert to the accepted commit. */ research_revert: tool({ description: "Revert repository to the accepted commit (git reset --hard). Clears candidateCommit from state.", inputSchema: z.object({}), execute: async () => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state found." }; } if (!state.acceptedCommit) { return { success: false, error: "No accepted commit to revert to.", }; } execFileSync("git", ["reset", "--hard", state.acceptedCommit], { cwd: config.repoPath, encoding: "utf-8", }); await stateStore.update({ candidateCommit: null }); return { success: true, revertedTo: state.acceptedCommit, }; } catch (error) { logger.error("[researchTools] research_revert failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Inspect the last 50 lines of the run log for debugging. */ research_inspect_failure: tool({ description: "Inspect the last 50 lines of run.log to debug experiment failures.", inputSchema: z.object({}), execute: async () => { try { const logPath = path.join(config.repoPath, config.logPath); const logContent = readFileSync(logPath, "utf-8"); const lines = logContent.split("\n"); const lastLines = lines.slice(-50).join("\n"); return { success: true, tail: lastLines, totalLines: lines.length, }; } catch (error) { logger.error("[researchTools] research_inspect_failure failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), /** * Save the current state to disk. */ research_checkpoint: tool({ description: "Save the current research state to disk. Call periodically to ensure progress is not lost.", inputSchema: z.object({}), execute: async () => { try { const state = await stateStore.load(); if (!state) { return { success: false, error: "No research state to checkpoint.", }; } // Force save by re-saving current state await stateStore.save(state); return { success: true, checkpointedAt: new Date().toISOString(), phase: state.currentPhase, runCount: state.runCount, }; } catch (error) { logger.error("[researchTools] research_checkpoint failed", { error: String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), }; }