a82926a22e
---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
315 lines
10 KiB
TypeScript
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 }; |