UNPKG

@mariozechner/pi-agent

Version:

General-purpose agent with tool calling and session persistence

251 lines 8.64 kB
import { spawn } from "node:child_process"; import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { glob } from "glob"; // For GPT-OSS models via responses API export const toolsForResponses = [ { type: "function", name: "read", description: "Read contents of a file", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the file to read", }, }, required: ["path"], }, }, { type: "function", name: "list", description: "List contents of a directory", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the directory (default: current directory)", }, }, }, }, { type: "function", name: "bash", description: "Execute a command in Bash", parameters: { type: "object", properties: { command: { type: "string", description: "Command to execute", }, }, required: ["command"], }, }, { type: "function", name: "glob", description: "Find files matching a glob pattern", parameters: { type: "object", properties: { pattern: { type: "string", description: "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.json')", }, path: { type: "string", description: "Directory to search in (default: current directory)", }, }, required: ["pattern"], }, }, { type: "function", name: "rg", description: "Search using ripgrep.", parameters: { type: "object", properties: { args: { type: "string", description: 'Arguments to pass directly to ripgrep. Examples: "-l prompt" or "-i TODO" or "--type ts className" or "functionName src/". Never add quotes around the search pattern.', }, }, required: ["args"], }, }, ]; // For standard chat API (OpenAI format) export const toolsForChat = toolsForResponses.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters, }, })); // Helper to execute commands with abort support async function execWithAbort(command, signal) { return new Promise((resolve, reject) => { const child = spawn(command, { shell: true, signal, }); let stdout = ""; let stderr = ""; const MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB limit let outputTruncated = false; child.stdout?.on("data", (data) => { const chunk = data.toString(); if (stdout.length + chunk.length > MAX_OUTPUT_SIZE) { if (!outputTruncated) { stdout += "\n... [Output truncated - exceeded 1MB limit] ..."; outputTruncated = true; } } else { stdout += chunk; } }); child.stderr?.on("data", (data) => { const chunk = data.toString(); if (stderr.length + chunk.length > MAX_OUTPUT_SIZE) { if (!outputTruncated) { stderr += "\n... [Output truncated - exceeded 1MB limit] ..."; outputTruncated = true; } } else { stderr += chunk; } }); child.on("error", (error) => { reject(error); }); child.on("close", (code) => { if (signal?.aborted) { reject(new Error("Interrupted")); } else if (code !== 0 && code !== null) { // For some commands like ripgrep, exit code 1 is normal (no matches) if (code === 1 && command.includes("rg")) { resolve(""); // No matches for ripgrep } else if (stderr && !stdout) { reject(new Error(stderr)); } else { resolve(stdout || ""); } } else { resolve(stdout || stderr || ""); } }); // Kill the process if signal is aborted if (signal) { signal.addEventListener("abort", () => { child.kill("SIGTERM"); }, { once: true }); } }); } export async function executeTool(name, args, signal) { const parsed = JSON.parse(args); switch (name) { case "read": { const path = parsed.path; if (!path) return "Error: path parameter is required"; const file = resolve(path); if (!existsSync(file)) return `File not found: ${file}`; // Check file size before reading const stats = statSync(file); const MAX_FILE_SIZE = 1024 * 1024; // 1MB limit if (stats.size > MAX_FILE_SIZE) { // Read only the first 1MB const fd = openSync(file, "r"); const buffer = Buffer.alloc(MAX_FILE_SIZE); readSync(fd, buffer, 0, MAX_FILE_SIZE, 0); closeSync(fd); return buffer.toString("utf8") + "\n\n... [File truncated - exceeded 1MB limit] ..."; } const data = readFileSync(file, "utf8"); return data; } case "list": { const path = parsed.path || "."; const dir = resolve(path); if (!existsSync(dir)) return `Directory not found: ${dir}`; const entries = readdirSync(dir, { withFileTypes: true }); return entries.map((entry) => (entry.isDirectory() ? entry.name + "/" : entry.name)).join("\n"); } case "bash": { const command = parsed.command; if (!command) return "Error: command parameter is required"; try { const output = await execWithAbort(command, signal); return output || "Command executed successfully"; } catch (e) { if (e.message === "Interrupted") { throw e; // Re-throw interruption } throw new Error(`Command failed: ${e.message}`); } } case "glob": { const pattern = parsed.pattern; if (!pattern) return "Error: pattern parameter is required"; const searchPath = parsed.path || process.cwd(); try { const matches = await glob(pattern, { cwd: searchPath, dot: true, nodir: false, mark: true, // Add / to directories }); if (matches.length === 0) { return "No files found matching the pattern"; } // Sort by modification time (most recent first) if possible return matches.sort().join("\n"); } catch (e) { return `Glob error: ${e.message}`; } } case "rg": { const args = parsed.args; if (!args) return "Error: args parameter is required"; // Force ripgrep to never read from stdin by redirecting stdin from /dev/null const cmd = `rg ${args} < /dev/null`; try { const output = await execWithAbort(cmd, signal); return output.trim() || "No matches found"; } catch (e) { if (e.message === "Interrupted") { throw e; // Re-throw interruption } return `ripgrep error: ${e.message}`; } } default: return `Unknown tool: ${name}`; } } //# sourceMappingURL=tools.js.map