940b85bfae
Add IntelligenceBackend abstraction with two categories: - LLMBackend (OllamaLocal, OllamaCloud): CI runs tool loop, provides tools, constructs prompts - AgentBackend (Opencode): agent runs own tool loop, CI serializes request Refactor all 18 agents from hardcoded stubs to persona loaders that delegate to the active backend or fail honestly when no backend is available. Refactor OrchestratorAgent.executeStage() from monolithic switch to agent delegation via STAGE_AGENT_MAP for intelligent stages (research, plan, execute, verify), with mechanical stages (specify, clarify, complete) staying inline. Wire CLI commands with --backend flag and auto-detection (opencode → ollama-local → ollama-cloud). Harden rollback/ship with real git operations. No command returns fake success.
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
|
|
export interface ToolDefinition {
|
|
name: string;
|
|
description: string;
|
|
parameters: {
|
|
type: "object";
|
|
properties: Record<string, { type: string; description: string }>;
|
|
required: string[];
|
|
};
|
|
}
|
|
|
|
export interface ToolCall {
|
|
name: string;
|
|
arguments: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ToolResult {
|
|
name: string;
|
|
content: string;
|
|
isError?: boolean;
|
|
}
|
|
|
|
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
{
|
|
name: "readFile",
|
|
description: "Read the contents of a file at the given path",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "Absolute file path to read" },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
},
|
|
{
|
|
name: "writeFile",
|
|
description: "Write content to a file, creating it if it doesn't exist",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "Absolute file path to write" },
|
|
content: { type: "string", description: "Content to write to the file" },
|
|
},
|
|
required: ["path", "content"],
|
|
},
|
|
},
|
|
{
|
|
name: "editFile",
|
|
description: "Replace an exact string in a file with a new string",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "Absolute file path to edit" },
|
|
old: { type: "string", description: "Exact string to find in the file" },
|
|
new: { type: "string", description: "String to replace it with" },
|
|
},
|
|
required: ["path", "old", "new"],
|
|
},
|
|
},
|
|
{
|
|
name: "runBash",
|
|
description: "Execute a bash command and return stdout/stderr",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
command: { type: "string", description: "Bash command to execute" },
|
|
cwd: { type: "string", description: "Working directory for the command" },
|
|
timeout: { type: "number", description: "Timeout in milliseconds (default 30000)" },
|
|
},
|
|
required: ["command"],
|
|
},
|
|
},
|
|
{
|
|
name: "glob",
|
|
description: "Find files matching a glob pattern recursively",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
|
|
cwd: { type: "string", description: "Directory to search in" },
|
|
},
|
|
required: ["pattern"],
|
|
},
|
|
},
|
|
{
|
|
name: "grep",
|
|
description: "Search file contents using a regular expression",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
pattern: { type: "string", description: "Regex pattern to search for" },
|
|
include: { type: "string", description: "File pattern to include (e.g. *.ts)" },
|
|
cwd: { type: "string", description: "Directory to search in" },
|
|
},
|
|
required: ["pattern"],
|
|
},
|
|
},
|
|
];
|
|
|
|
export class ToolRegistry {
|
|
private projectPath: string;
|
|
private maxFileSize: number;
|
|
|
|
constructor(projectPath: string, maxFileSize: number = 1024 * 1024) {
|
|
this.projectPath = projectPath;
|
|
this.maxFileSize = maxFileSize;
|
|
}
|
|
|
|
execute(call: ToolCall): ToolResult {
|
|
try {
|
|
switch (call.name) {
|
|
case "readFile":
|
|
return this.readFile(call.arguments);
|
|
case "writeFile":
|
|
return this.writeFile(call.arguments);
|
|
case "editFile":
|
|
return this.editFile(call.arguments);
|
|
case "runBash":
|
|
return this.runBash(call.arguments);
|
|
case "glob":
|
|
return this.glob(call.arguments);
|
|
case "grep":
|
|
return this.grep(call.arguments);
|
|
default:
|
|
return { name: call.name, content: `Unknown tool: ${call.name}`, isError: true };
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
name: call.name,
|
|
content: `Tool error: ${err instanceof Error ? err.message : String(err)}`,
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
getDefinitions(): ToolDefinition[] {
|
|
return TOOL_DEFINITIONS;
|
|
}
|
|
|
|
getOpenAIToolSchema(): Array<Record<string, unknown>> {
|
|
return TOOL_DEFINITIONS.map((def) => ({
|
|
type: "function",
|
|
function: {
|
|
name: def.name,
|
|
description: def.description,
|
|
parameters: def.parameters,
|
|
},
|
|
}));
|
|
}
|
|
|
|
private readFile(args: Record<string, unknown>): ToolResult {
|
|
const filePath = String(args.path);
|
|
if (!fs.existsSync(filePath)) {
|
|
return { name: "readFile", content: `File not found: ${filePath}`, isError: true };
|
|
}
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.size > this.maxFileSize) {
|
|
return { name: "readFile", content: `File too large: ${filePath} (${stat.size} bytes)`, isError: true };
|
|
}
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
return { name: "readFile", content };
|
|
} catch (err) {
|
|
return { name: "readFile", content: `Read error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
}
|
|
}
|
|
|
|
private writeFile(args: Record<string, unknown>): ToolResult {
|
|
const filePath = String(args.path);
|
|
const content = String(args.content);
|
|
try {
|
|
const dir = path.dirname(filePath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
return { name: "writeFile", content: `Written: ${filePath}` };
|
|
} catch (err) {
|
|
return { name: "writeFile", content: `Write error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
}
|
|
}
|
|
|
|
private editFile(args: Record<string, unknown>): ToolResult {
|
|
const filePath = String(args.path);
|
|
const oldStr = String(args.old);
|
|
const newStr = String(args.new);
|
|
if (!fs.existsSync(filePath)) {
|
|
return { name: "editFile", content: `File not found: ${filePath}`, isError: true };
|
|
}
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
if (!content.includes(oldStr)) {
|
|
return { name: "editFile", content: `String not found in ${filePath}`, isError: true };
|
|
}
|
|
const updated = content.replace(oldStr, newStr);
|
|
fs.writeFileSync(filePath, updated, "utf-8");
|
|
return { name: "editFile", content: `Edited: ${filePath}` };
|
|
} catch (err) {
|
|
return { name: "editFile", content: `Edit error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
}
|
|
}
|
|
|
|
private runBash(args: Record<string, unknown>): ToolResult {
|
|
const command = String(args.command);
|
|
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
|
const timeout = args.timeout ? Number(args.timeout) : 30000;
|
|
try {
|
|
const stdout = execSync(command, {
|
|
cwd,
|
|
timeout,
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return { name: "runBash", content: stdout || "(no output)" };
|
|
} catch (err: unknown) {
|
|
const execErr = err as { stderr?: string; stdout?: string; status?: number };
|
|
const output = [`Exit code: ${execErr.status || 1}`, `stdout: ${execErr.stdout || ""}`, `stderr: ${execErr.stderr || ""}`].join("\n");
|
|
return { name: "runBash", content: output, isError: true };
|
|
}
|
|
}
|
|
|
|
private glob(args: Record<string, unknown>): ToolResult {
|
|
const pattern = String(args.pattern);
|
|
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
|
const matches = this.globRecursive(cwd, pattern);
|
|
return { name: "glob", content: JSON.stringify(matches.slice(0, 200)) };
|
|
}
|
|
|
|
private grep(args: Record<string, unknown>): ToolResult {
|
|
const pattern = String(args.pattern);
|
|
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
|
const include = args.include ? String(args.include) : undefined;
|
|
const matches = this.grepRecursive(cwd, pattern, include);
|
|
return { name: "grep", content: JSON.stringify(matches.slice(0, 100)) };
|
|
}
|
|
|
|
private globRecursive(dir: string, pattern: string): string[] {
|
|
const results: string[] = [];
|
|
const regex = this.globToRegex(pattern);
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...this.globRecursive(fullPath, pattern));
|
|
} else if (regex.test(entry.name) || regex.test(path.relative(this.projectPath, fullPath))) {
|
|
results.push(path.relative(this.projectPath, fullPath));
|
|
}
|
|
}
|
|
} catch {}
|
|
return results.sort();
|
|
}
|
|
|
|
private globToRegex(pattern: string): RegExp {
|
|
const escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*\*/g, "{{GLOBSTAR}}")
|
|
.replace(/\*/g, "[^/]*")
|
|
.replace(/{{GLOBSTAR}}/g, ".*")
|
|
.replace(/\?/g, "[^/]");
|
|
return new RegExp(`^${escaped}$`);
|
|
}
|
|
|
|
private grepRecursive(dir: string, patternStr: string, include?: string): Array<{ file: string; line: number; content: string }> {
|
|
const results: Array<{ file: string; line: number; content: string }> = [];
|
|
const regex = new RegExp(patternStr);
|
|
const includeRegex = include ? this.globToRegex(include) : null;
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...this.grepRecursive(fullPath, patternStr, include));
|
|
} else if (includeRegex ? includeRegex.test(entry.name) : true) {
|
|
try {
|
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
const lines = content.split("\n");
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (regex.test(lines[i])) {
|
|
results.push({
|
|
file: path.relative(this.projectPath, fullPath),
|
|
line: i + 1,
|
|
content: lines[i].trim(),
|
|
});
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
return results;
|
|
}
|
|
} |