Submatter/apps/worker/src/session/opencode-session.ts
Randall Stillwell 9572eac3eb
Some checks are pending
CI / Lint, type-check, test (push) Waiting to run
Deploy homelab (CT 116) / Deploy to CT 116 (push) Waiting to run
feat(worker): OpenCode harness for Submatter Phase B
Add WORKER_SESSION_TYPE=opencode with a headless OpenCode session adapter,
compose profile, and OmniRoute plugin config. Prefer OpenCode over the
unused OpenHands agent-server path; fix missing yaml runtime dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 12:47:56 -05:00

175 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,
};
}
},
};
}