feat(backends): multi-backend intelligence layer — LLM + Agent backends, persona-loading agents, honest CLI commands

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.
This commit is contained in:
CI
2026-05-29 15:58:34 +00:00
parent ddf04792c7
commit 940b85bfae
33 changed files with 1828 additions and 100 deletions
+55
View File
@@ -0,0 +1,55 @@
import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
import { OpencodeBackend } from "./opencode.js";
import { OllamaLocalBackend } from "./ollama-local.js";
import { OllamaCloudBackend } from "./ollama-cloud.js";
const AUTO_DETECT_ORDER: Array<"opencode" | "ollama-local" | "ollama-cloud"> = [
"opencode",
"ollama-local",
"ollama-cloud",
];
export function createBackend(
name: string,
config: BackendConfigSection
): IntelligenceBackend {
switch (name) {
case "opencode":
return new OpencodeBackend(config.agent_backends.opencode);
case "ollama-local":
return new OllamaLocalBackend(config.llm_backends["ollama-local"]);
case "ollama-cloud":
return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]);
default:
throw new BackendUnavailableError(name);
}
}
export async function resolveBackend(
config: BackendConfigSection
): Promise<IntelligenceBackend> {
if (config.provider !== "auto") {
const backend = createBackend(config.provider, config);
if (!(await backend.isAvailable())) {
throw new BackendUnavailableError(config.provider);
}
return backend;
}
for (const name of AUTO_DETECT_ORDER) {
try {
const backend = createBackend(name, config);
if (await backend.isAvailable()) {
return backend;
}
} catch {}
}
throw new BackendUnavailableError("auto");
}
export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js";
export { OpencodeBackend } from "./opencode.js";
export { OllamaLocalBackend } from "./ollama-local.js";
export { OllamaCloudBackend } from "./ollama-cloud.js";
+317
View File
@@ -0,0 +1,317 @@
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
LLMBackendConfig,
TokenUsage,
Artifact,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
export abstract class OllamaBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
this.projectPath = process.cwd();
}
abstract isAvailable(): Promise<boolean>;
async execute(request: BackendRequest): Promise<BackendResult> {
const startTime = Date.now();
try {
const personaContent = this.loadPersona(request.persona);
const workflowContent = this.loadWorkflow(request.workflow);
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const messages: OllamaMessage[] = [];
messages.push({
role: "system",
content: this.buildSystemPrompt(personaContent, workflowContent, request),
});
messages.push({
role: "user",
content: request.task,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
let round = 0;
const allArtifacts: Artifact[] = [];
const allDecisions: Decision[] = [];
const allEscalations: Escalation[] = [];
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModel(messages, model, toolRegistry);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
const assistantContent = response.choices?.[0]?.message?.content || "";
const toolCalls = response.choices?.[0]?.message?.tool_calls;
messages.push({
role: "assistant",
content: assistantContent,
tool_calls: toolCalls,
});
if (!toolCalls || toolCalls.length === 0) {
return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
total_tokens: totalInputTokens + totalOutputTokens,
estimated_cost_usd: 0,
});
}
for (const toolCall of toolCalls) {
const call: ToolCall = {
name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
const result = toolRegistry.execute(call);
messages.push({
role: "tool",
name: call.name,
content: result.content,
});
if (call.name === "writeFile" && !result.isError) {
allArtifacts.push({
path: String(call.arguments.path),
content: String(call.arguments.content),
operation: "create",
});
}
}
}
const finalContent = messages
.filter((m) => m.role === "assistant" && m.content)
.map((m) => m.content)
.join("\n");
return this.parseFinalResponse(
`Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`,
allArtifacts,
allDecisions,
allEscalations,
{ input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 }
);
} catch (err) {
return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
protected abstract callModel(
messages: OllamaMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse>;
protected abstract resolveModel(): string;
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
const parts = [persona];
if (workflow) {
parts.push("", "## Workflow Instructions", workflow);
}
parts.push(
"",
"## Execution Context",
`Autonomy level: ${request.autonomy}`,
`Project path: ${request.context.project_path}`,
`Phase: ${request.context.phase}`,
`Stage: ${request.context.stage}`,
"",
"## Output Format",
"When you have completed your task, output a JSON object with this structure:",
"```json",
'{',
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "learnship_equivalent": "", "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
);
return parts.join("\n");
}
protected loadPersona(persona: AgentName): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return `You are the CI ${persona} agent. Execute the requested task thoroughly and autonomously.`;
}
protected loadWorkflow(workflow: string): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return "";
}
protected parseFinalResponse(
content: string,
artifacts: Artifact[],
decisions: Decision[],
escalations: Escalation[],
usage: TokenUsage
): BackendResult {
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || content,
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
usage,
};
} catch {}
}
return {
success: true,
output: content,
artifacts,
decisions,
escalations,
usage,
};
}
private parseArtifacts(raw: unknown[]): Artifact[] {
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as Artifact["operation"]) || "create",
}));
}
private parseDecisions(raw: unknown[]): Decision[] {
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as Decision["category"]) || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
learnship_equivalent: String(d.learnship_equivalent || ""),
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
}
private parseEscalations(raw: unknown[]): Escalation[] {
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as Escalation["type"]) || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
audit_file: String(e.audit_file || ""),
}));
}
protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string {
if (availableModels.length === 0) return "llama3.1";
const sorted = [...availableModels].sort((a, b) => a.length - b.length);
switch (profile) {
case "speed":
return sorted[0];
case "quality":
return sorted[sorted.length - 1];
case "balanced":
default:
return sorted[Math.floor(sorted.length / 2)] || sorted[0];
}
}
protected async fetchAvailableModels(): Promise<string[]> {
try {
const response = await fetch(`${this.config.base_url}/api/tags`);
if (!response.ok) return [];
const data = await response.json() as { models?: Array<{ name: string }> };
return (data.models || []).map((m) => m.name);
} catch {
return [];
}
}
}
interface OllamaMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
}
interface OllamaChatResponse {
choices?: Array<{
message: {
content: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export { OllamaMessage, OllamaChatResponse };
+139
View File
@@ -0,0 +1,139 @@
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js";
import { OllamaCloudConfig, emptyBackendResult } from "./types.js";
import { ToolRegistry } from "./tool-registry.js";
const MAX_RETRIES = 3;
const BASE_BACKOFF_MS = 1000;
export class OllamaCloudBackend extends OllamaBaseBackend {
readonly name = "ollama-cloud";
private cloudConfig: OllamaCloudConfig;
private apiKey: string | null;
constructor(config?: OllamaCloudConfig) {
super(config);
this.cloudConfig = config || {
base_url: "",
api_key_env: "OLLAMA_CLOUD_API_KEY",
model_profile: "quality",
timeout_ms: 60000,
};
this.apiKey = this.resolveApiKey();
}
async isAvailable(): Promise<boolean> {
if (!this.cloudConfig.base_url) return false;
if (!this.apiKey) return false;
try {
const response = await fetch(`${this.cloudConfig.base_url}/v1/models`, {
headers: this.getAuthHeaders(),
signal: AbortSignal.timeout(10000),
});
return response.ok;
} catch {
return false;
}
}
protected resolveModel(): string {
if (this.cloudConfig.model) return this.cloudConfig.model;
return "llama3.1:70b";
}
protected async callModel(
messages: OllamaMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse> {
if (!this.apiKey) {
throw new Error(`API key not found. Set ${this.cloudConfig.api_key_env} environment variable.`);
}
const url = `${this.cloudConfig.base_url}/v1/chat/completions`;
const body: Record<string, unknown> = {
model,
messages: messages.map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name) msg.name = m.name;
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: toolRegistry.getOpenAIToolSchema(),
stream: false,
};
return this.callWithRetry(url, body);
}
private async callWithRetry(
url: string,
body: Record<string, unknown>,
attempt: number = 0
): Promise<OllamaChatResponse> {
const timeout = this.cloudConfig.timeout_ms || 60000;
try {
const response = await fetch(url, {
method: "POST",
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (response.status === 429 && attempt < MAX_RETRIES) {
const retryAfter = response.headers.get("Retry-After");
const delay = retryAfter
? parseInt(retryAfter) * 1000
: BASE_BACKOFF_MS * Math.pow(2, attempt);
await this.sleep(delay);
return this.callWithRetry(url, body, attempt + 1);
}
if (response.status === 401 || response.status === 403) {
throw new Error(`Authentication failed. Check ${this.cloudConfig.api_key_env} environment variable.`);
}
if (response.status === 402) {
throw new Error("Quota exceeded. Check your Ollama Cloud billing status.");
}
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`Ollama Cloud API error (${response.status}): ${errorText}`);
}
return (await response.json()) as OllamaChatResponse;
} catch (err) {
if (err instanceof TypeError && err.message.includes("fetch")) {
if (attempt < MAX_RETRIES) {
await this.sleep(BASE_BACKOFF_MS * Math.pow(2, attempt));
return this.callWithRetry(url, body, attempt + 1);
}
}
throw err;
}
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
}
return headers;
}
private resolveApiKey(): string | null {
return process.env[this.cloudConfig.api_key_env] || null;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
+81
View File
@@ -0,0 +1,81 @@
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js";
import { OllamaLocalConfig } from "./types.js";
import { ToolRegistry } from "./tool-registry.js";
export class OllamaLocalBackend extends OllamaBaseBackend {
readonly name = "ollama-local";
private localConfig: OllamaLocalConfig;
constructor(config?: OllamaLocalConfig) {
super(config);
this.localConfig = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.localConfig.base_url}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
protected resolveModel(): string {
if (this.localConfig.model) return this.localConfig.model;
const models = this.fetchAvailableModelsSync();
return this.modelProfileToModel(this.localConfig.model_profile, models);
}
protected async callModel(
messages: OllamaMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse> {
const url = `${this.localConfig.base_url}/v1/chat/completions`;
const body: Record<string, unknown> = {
model,
messages: messages.map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name) msg.name = m.name;
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: toolRegistry.getOpenAIToolSchema(),
stream: false,
};
const timeout = this.localConfig.timeout_ms || 10000;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`Ollama local API error (${response.status}): ${errorText}`);
}
return (await response.json()) as OllamaChatResponse;
}
private fetchAvailableModelsSync(): string[] {
try {
const { execSync } = require("node:child_process");
const result = execSync(`curl -s ${this.localConfig.base_url}/api/tags`, {
encoding: "utf-8",
timeout: 5000,
});
const data = JSON.parse(result) as { models?: Array<{ name: string }> };
return (data.models || []).map((m) => m.name);
} catch {
return [];
}
}
}
+183
View File
@@ -0,0 +1,183 @@
import { execSync, spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
OpencodeBackendConfig,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
export class OpencodeBackend implements IntelligenceBackend {
readonly name = "opencode";
readonly type: BackendType = "agent";
private config: OpencodeBackendConfig;
constructor(config?: OpencodeBackendConfig) {
this.config = config || { enabled: true };
}
async isAvailable(): Promise<boolean> {
const executable = this.config.executable || "opencode";
try {
const result = execSync(`${executable} --version`, {
encoding: "utf-8",
timeout: 5000,
stdio: "pipe",
});
return !!result;
} catch {
return false;
}
}
async execute(request: BackendRequest): Promise<BackendResult> {
const executable = this.config.executable || "opencode";
const startTime = Date.now();
try {
const serializedRequest = this.serializeRequest(request);
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${Date.now()}.json`
);
fs.writeFileSync(tempFile, serializedRequest, "utf-8");
const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`;
const contextEnv = {
...process.env,
CI_BACKEND_REQUEST: tempFile,
CI_PROJECT_PATH: request.context.project_path,
CI_PHASE: String(request.context.phase),
CI_STAGE: request.context.stage,
CI_AUTONOMY: request.autonomy,
};
const result = execSync(command, {
cwd: request.context.project_path,
encoding: "utf-8",
timeout: 600000,
env: contextEnv,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 10 * 1024 * 1024,
});
try {
fs.unlinkSync(tempFile);
} catch {}
return this.parseResult(result, Date.now() - startTime);
} catch (err) {
const execErr = err as { stderr?: string; status?: number };
try {
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${startTime}.json`
);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
} catch {}
if (execErr.stderr) {
return emptyBackendResult(
`opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}`
);
}
return emptyBackendResult(
`opencode backend error: ${err instanceof Error ? err.message : String(err)}`
);
}
}
private serializeRequest(request: BackendRequest): string {
return JSON.stringify({
persona: request.persona,
workflow: request.workflow,
task: request.task,
context: {
project_path: request.context.project_path,
phase: request.context.phase,
stage: request.context.stage,
specification: request.context.specification,
config_path: request.context.config_path,
},
autonomy: request.autonomy,
}, null, 2);
}
private parseResult(output: string, durationMs: number): BackendResult {
const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || output,
artifacts: Array.isArray(parsed.artifacts)
? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record<string, unknown>) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as "create" | "update" | "delete") || "create",
}))
: [],
decisions: Array.isArray(parsed.decisions)
? parsed.decisions.filter((d: unknown) => !!d).map((d: Record<string, unknown>) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
learnship_equivalent: String(d.learnship_equivalent || ""),
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}))
: [],
escalations: Array.isArray(parsed.escalations)
? parsed.escalations.filter((e: unknown) => !!e).map((e: Record<string, unknown>) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending",
audit_file: String(e.audit_file || ""),
}))
: [],
usage: parsed.usage || {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
} catch {}
}
return {
success: true,
output,
artifacts: [],
decisions: [],
escalations: [],
usage: {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
}
}
+299
View File
@@ -0,0 +1,299 @@
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;
}
}
+137
View File
@@ -0,0 +1,137 @@
import { AgentName, AutonomyLevel, ModelProfile } from "../types/config.js";
import { AgentContext } from "../agents/base.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
export type BackendType = "llm" | "agent";
export interface BackendRequest {
persona: AgentName;
workflow: string;
task: string;
context: AgentContext;
autonomy: AutonomyLevel;
}
export interface Artifact {
path: string;
content: string;
operation: "create" | "update" | "delete";
}
export interface TokenUsage {
input_tokens: number;
output_tokens: number;
total_tokens: number;
estimated_cost_usd: number;
}
export interface BackendResult {
success: boolean;
output: string;
artifacts: Artifact[];
decisions: Decision[];
escalations: Escalation[];
usage: TokenUsage;
error?: string;
}
export interface IntelligenceBackend {
readonly name: string;
readonly type: BackendType;
isAvailable(): Promise<boolean>;
execute(request: BackendRequest): Promise<BackendResult>;
}
export interface LLMBackendConfig {
base_url: string;
model_profile: ModelProfile;
model?: string;
timeout_ms?: number;
}
export interface OllamaLocalConfig extends LLMBackendConfig {
base_url: string;
model_profile: ModelProfile;
model?: string;
timeout_ms?: number;
}
export interface OllamaCloudConfig extends LLMBackendConfig {
base_url: string;
api_key_env: string;
model_profile: ModelProfile;
model?: string;
timeout_ms?: number;
}
export interface OpencodeBackendConfig {
enabled: boolean;
executable?: string;
}
export interface BackendConfigSection {
provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud";
fallback?: "opencode" | "ollama-local" | "ollama-cloud";
agent_backends: {
opencode?: OpencodeBackendConfig;
};
llm_backends: {
"ollama-local"?: OllamaLocalConfig;
"ollama-cloud"?: OllamaCloudConfig;
};
}
export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
provider: "auto",
agent_backends: {
opencode: { enabled: true },
},
llm_backends: {
"ollama-local": {
base_url: "http://localhost:11434",
model_profile: "balanced",
},
"ollama-cloud": {
base_url: "",
api_key_env: "OLLAMA_CLOUD_API_KEY",
model_profile: "quality",
timeout_ms: 60000,
},
},
};
export class BackendUnavailableError extends Error {
readonly backendName: string;
readonly agentName?: string;
constructor(backendName: string, agentName?: string) {
const agentMsg = agentName ? ` (agent: ${agentName})` : "";
super(
`Intelligence backend "${backendName}" is not available${agentMsg}. ` +
`Configure one of:\n` +
` 1. Install opencode: npm i -g opencode\n` +
` 2. Run Ollama locally: ollama serve\n` +
` 3. Set OLLAMA_CLOUD_API_KEY for remote inference`
);
this.name = "BackendUnavailableError";
this.backendName = backendName;
this.agentName = agentName;
}
}
export function emptyTokenUsage(): TokenUsage {
return { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 };
}
export function emptyBackendResult(error?: string): BackendResult {
return {
success: false,
output: "",
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
error,
};
}