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; async execute(request: BackendRequest): Promise { 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; 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": [], "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 => !!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 => !!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 }) ) : [], 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 => !!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 { 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 };