176 lines
6 KiB
TypeScript
176 lines
6 KiB
TypeScript
|
|
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
||
|
|
import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker";
|
||
|
|
|
||
|
|
import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Configuration for a headless OpenCode server (`opencode serve` / `opencode web`).
|
||
|
|
* Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server.
|
||
|
|
*/
|
||
|
|
export type OpenCodeConfig = {
|
||
|
|
serverUrl: string;
|
||
|
|
/** Basic-auth password; empty = no Authorization header. */
|
||
|
|
serverPassword: string;
|
||
|
|
/** Basic-auth username (OpenCode default: opencode). */
|
||
|
|
serverUsername: string;
|
||
|
|
/** Repo root to scan for .agents/skills/ and to describe in the prompt. */
|
||
|
|
repoRoot: string;
|
||
|
|
/** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */
|
||
|
|
providerId: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type OpenCodeSessionOptions = {
|
||
|
|
config: OpenCodeConfig;
|
||
|
|
resolvedModel: ResolvedModel;
|
||
|
|
roleId: string;
|
||
|
|
briefId?: string;
|
||
|
|
briefTitle?: string;
|
||
|
|
workflowPrompt?: string;
|
||
|
|
briefAcceptance?: string;
|
||
|
|
briefFiles?: string[];
|
||
|
|
maxSteps?: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
type SessionCreateResponse = { id: string; title?: string };
|
||
|
|
type MessageResponse = {
|
||
|
|
info?: { role?: string; error?: { name?: string; message?: string } };
|
||
|
|
parts?: Array<{ type?: string; text?: string; tool?: string }>;
|
||
|
|
};
|
||
|
|
|
||
|
|
function basicAuthHeader(username: string, password: string): string {
|
||
|
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||
|
|
return `Basic ${token}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripProviderPrefix(modelId: string, providerId: string): string {
|
||
|
|
const prefix = `${providerId}/`;
|
||
|
|
return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create an OpenCode session that drives a remote `opencode serve` over HTTP.
|
||
|
|
* Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin,
|
||
|
|
* no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade.
|
||
|
|
*/
|
||
|
|
export async function createOpenCodeSession(
|
||
|
|
context: WorkerJobContext,
|
||
|
|
options: OpenCodeSessionOptions,
|
||
|
|
): Promise<WorkerSession> {
|
||
|
|
const {
|
||
|
|
config,
|
||
|
|
resolvedModel,
|
||
|
|
roleId,
|
||
|
|
briefId,
|
||
|
|
briefTitle,
|
||
|
|
workflowPrompt,
|
||
|
|
briefAcceptance,
|
||
|
|
briefFiles,
|
||
|
|
} = options;
|
||
|
|
const apiBase = config.serverUrl.replace(/\/$/, "");
|
||
|
|
const headers: Record<string, string> = {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
Accept: "application/json",
|
||
|
|
};
|
||
|
|
if (config.serverPassword) {
|
||
|
|
headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword);
|
||
|
|
}
|
||
|
|
|
||
|
|
let skillContext = "";
|
||
|
|
try {
|
||
|
|
const skills = await loadAgentSkills(config.repoRoot);
|
||
|
|
skillContext = buildSkillFullContext(skills);
|
||
|
|
} catch (e) {
|
||
|
|
console.warn("[opencode] skill loading failed, continuing without skills:", e);
|
||
|
|
}
|
||
|
|
|
||
|
|
const systemParts: string[] = [
|
||
|
|
`You are an AI agent executing a brief for convoy "${context.convoyId}".`,
|
||
|
|
`Role: ${roleId}`,
|
||
|
|
briefId ? `Brief ID: ${briefId}` : "",
|
||
|
|
briefTitle ? `Brief: ${briefTitle}` : "",
|
||
|
|
`Working directory: ${config.repoRoot}`,
|
||
|
|
workflowPrompt ? `\n## Task\n${workflowPrompt}` : "",
|
||
|
|
briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "",
|
||
|
|
briefFiles?.length
|
||
|
|
? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}`
|
||
|
|
: "",
|
||
|
|
skillContext || "",
|
||
|
|
"\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.",
|
||
|
|
].filter(Boolean);
|
||
|
|
const systemPrompt = systemParts.join("\n");
|
||
|
|
|
||
|
|
const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId);
|
||
|
|
|
||
|
|
async function createSession(title: string): Promise<string> {
|
||
|
|
const res = await fetch(`${apiBase}/session`, {
|
||
|
|
method: "POST",
|
||
|
|
headers,
|
||
|
|
body: JSON.stringify({ title }),
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`);
|
||
|
|
}
|
||
|
|
const data = (await res.json()) as SessionCreateResponse;
|
||
|
|
if (!data.id) throw new Error("OpenCode session create returned no id");
|
||
|
|
return data.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function sendPrompt(sessionId: string, prompt: string): Promise<{
|
||
|
|
summary: string;
|
||
|
|
toolCalls: number;
|
||
|
|
}> {
|
||
|
|
const res = await fetch(`${apiBase}/session/${sessionId}/message`, {
|
||
|
|
method: "POST",
|
||
|
|
headers,
|
||
|
|
body: JSON.stringify({
|
||
|
|
model: { providerID: config.providerId, modelID: modelId },
|
||
|
|
system: systemPrompt,
|
||
|
|
parts: [{ type: "text", text: prompt }],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`);
|
||
|
|
}
|
||
|
|
const data = (await res.json()) as MessageResponse;
|
||
|
|
if (data.info?.error) {
|
||
|
|
throw new Error(
|
||
|
|
`OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const textParts = (data.parts ?? [])
|
||
|
|
.filter((p) => p.type === "text" && p.text)
|
||
|
|
.map((p) => p.text as string);
|
||
|
|
const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length;
|
||
|
|
return {
|
||
|
|
summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.",
|
||
|
|
toolCalls,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
context,
|
||
|
|
async run(prompt: string) {
|
||
|
|
console.info(
|
||
|
|
`[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`,
|
||
|
|
);
|
||
|
|
try {
|
||
|
|
const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`;
|
||
|
|
const sessionId = await createSession(title);
|
||
|
|
console.info(`[opencode] session started id=${sessionId}`);
|
||
|
|
const result = await sendPrompt(sessionId, prompt);
|
||
|
|
console.info(
|
||
|
|
`[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`,
|
||
|
|
);
|
||
|
|
return result;
|
||
|
|
} catch (e) {
|
||
|
|
const msg = e instanceof Error ? e.message : String(e);
|
||
|
|
console.error(`[opencode] session failed:`, msg);
|
||
|
|
return {
|
||
|
|
summary: `OpenCode session failed: ${msg}`,
|
||
|
|
toolCalls: 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|