feat(ci): v0.9.0 — Distribution & Expansion milestone complete
---ci---
project: ci
phase: 6
milestone: v0.9
status: complete
artifacts:
tags: [v0.9.0]
decisions:
- id: D-047
decision: v0.9 theme = Distribution & Expansion
rationale: npm publish + OpenAI/Anthropic backends + agent flesh + parallel execution
confidence: 0.92
- id: D-049
decision: Feature milestone — patch tags v0.8.1-v0.8.6 then v0.9.0
rationale: OpenAI backend, agent flesh, npm publish all feat
confidence: 0.95
- id: D-059
decision: Rename OllamaBaseBackend to LLMBaseBackend + thin OllamaBaseBackend subclass
rationale: 15 of 17 methods backend-agnostic
confidence: 0.92
- id: D-060
decision: OpenAI/Anthropic backends use native fetch() not SDK packages
rationale: No dependency bloat; fetch native in Node 18+
confidence: 0.85
- id: D-066
decision: Concurrency limiter internal (no p-limit dependency)
rationale: 15 lines; avoids dependency for trivial feature
confidence: 0.90
- id: D-067
decision: Promise.allSettled for review agents at orchestrator lines 373-400
rationale: Current sequential loop replaced with parallel execution
confidence: 0.88
requirements:
covered: [PUBLISH-01, PUBLISH-02, PUBLISH-03, PUBLISH-04, OPENAI-01, OPENAI-02, OPENAI-03, OPENAI-04, OPENAI-05, FLESH-01, FLESH-02, FLESH-03, FLESH-04, FLESH-05, ANTHROPIC-01, ANTHROPIC-02, FLESH-06, FLESH-07, NPM-01, NPM-02, PARALLEL-01, PARALLEL-02, PARALLEL-03, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
6 phases, 28 tasks, 4077 net lines added, 57 test suites, 527 tests, zero stub agents
This commit is contained in:
+7
-356
@@ -1,335 +1,11 @@
|
||||
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, ToolDefinition } from "./tool-registry.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 50;
|
||||
|
||||
const PERSONA_TOOL_MAP: Record<string, string> = {
|
||||
read: "readFile",
|
||||
write: "writeFile",
|
||||
edit: "editFile",
|
||||
bash: "runBash",
|
||||
glob: "glob",
|
||||
grep: "grep",
|
||||
};
|
||||
|
||||
export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
abstract readonly name: string;
|
||||
readonly type: BackendType = "llm";
|
||||
|
||||
protected config: LLMBackendConfig;
|
||||
protected projectPath: string;
|
||||
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
|
||||
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
|
||||
import { LLMBackendConfig } from "./types.js";
|
||||
import { ModelProfile } from "../types/config.js";
|
||||
import { ToolRegistry } from "./tool-registry.js";
|
||||
|
||||
export abstract class OllamaBaseBackend extends LLMBaseBackend {
|
||||
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 allowedTools = this.parsePersonaTools(personaContent);
|
||||
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
|
||||
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
|
||||
|
||||
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.callModelWithTools(messages, model, filteredDefinitions);
|
||||
|
||||
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 parsePersonaTools(personaContent: string): string[] | null {
|
||||
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return null;
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
|
||||
if (!toolsMatch) {
|
||||
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
|
||||
if (inlineMatch) {
|
||||
return inlineMatch[1]
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => PERSONA_TOOL_MAP[t] || t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolsBlock = toolsMatch[1];
|
||||
const toolNames: string[] = [];
|
||||
const lineRegex = /^\s+(\w+):/gm;
|
||||
let lineMatch;
|
||||
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
|
||||
const personaToolName = lineMatch[1];
|
||||
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
|
||||
}
|
||||
|
||||
return toolNames.length > 0 ? toolNames : null;
|
||||
}
|
||||
|
||||
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
|
||||
if (!allowedTools) return definitions;
|
||||
const allowedSet = new Set(allowedTools);
|
||||
return definitions.filter((def) => allowedSet.has(def.name));
|
||||
}
|
||||
|
||||
protected async callModelWithTools(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolDefinitions: ToolDefinition[]
|
||||
): Promise<OllamaChatResponse> {
|
||||
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
|
||||
}
|
||||
|
||||
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
|
||||
return definitions.map((def) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
|
||||
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
|
||||
}
|
||||
|
||||
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 CIAgent ${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",
|
||||
commit_hash: String(e.commit_hash || ""),
|
||||
}));
|
||||
super(config || { base_url: "http://localhost:11434", model_profile: "balanced" });
|
||||
}
|
||||
|
||||
protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string {
|
||||
@@ -359,29 +35,4 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
export { ChatMessage as OllamaMessage, ChatCompletionResponse as OllamaChatResponse };
|
||||
Reference in New Issue
Block a user