Files
ci/src/backends/ollama-base.ts
T
Jon Chery a82926a22e fix(P01): quick wins — remove dead refs, unused imports, fix postinstall, version bump
---ci---
project: ci
phase: 1
milestone: v0.5
status: complete
decisions:
  - id: D-020
    decision: Phase 1 Quick Wins complete
    rationale: All 5 FIX requirements (FIX-01 through FIX-05) verified and passing
    confidence: 0.95
    alternatives: []
requirements:
  covered: [FIX-01, FIX-02, FIX-03, FIX-04, FIX-05]
---/ci---

Phase 1 (Quick Wins) summary:
- A1/FIX-01: Marked .planning/ refs as (legacy)/(removed) in docs
- A2/FIX-02: Removed unused execSync import from ollama-base.ts
- A3/FIX-03: Replaced postinstall with explicit install-opencode, removed scripts/ from files
- A4/FIX-04: Verified opencode.json is clean (no learnship entry)
- A5/FIX-05: Version bump to 0.5.0
2026-05-29 16:39:26 +00:00

315 lines
10 KiB
TypeScript

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": [], "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 })
)
: [],
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 };