Something went wrong. Try again.
Parametric learning in your code harness.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340// Runs a machine on opencode through its local server and SDK. Each run gets// a fresh opencode session rooted in the run's sandbox, with the machine's// instructions as the system prompt and opencode's built-in tools switched// off. The machine's own tools are served to opencode over MCP from this// process (see ../mcp.ts), so they run with the run's sandbox like they do// under other harnesses. The session is deleted afterwards so prompts do not// accumulate in opencode's own storage.//// Models use opencode's "provider/model" ids, e.g.// "openrouter/thinkingmachines/inkling:free". Runs use a custom opencode// agent whose prompt replaces opencode's coding prompt, and the server runs// with a private config home so the user's opencode config stays out.//// @opencode-ai/sdk is an optional peer dependency, and the SDK starts the// `opencode` binary, which must be on PATH.
import { spawn } from "node:child_process";import { randomBytes } from "node:crypto";import { rmSync } from "node:fs";import { mkdtemp } from "node:fs/promises";import os from "node:os";import path from "node:path";import type { Harness, HarnessRequest } from "../harness.ts";import type { HarnessResult, Message } from "../types.ts";import { serveToolsOverMcp } from "../mcp.ts";import { withTranscript } from "./transcript.ts";
type Result<T> = { data?: T; error?: unknown };
type OpencodePart = | { type: "text"; text: string; synthetic?: boolean } | { type: "tool"; callID: string; tool: string; state: { status: string; input?: unknown; output?: string; error?: string } } | { type: "step-finish"; reason?: string } | { type: string };
type OpencodeMessage = { info: { role?: string; error?: { name?: string; data?: { message?: string } }; tokens?: { input?: number; output?: number }; finish?: string; }; parts: OpencodePart[];};
/** The subset of the opencode SDK client this harness uses. */export type OpencodeClient = { session: { create(options: { body?: { title?: string }; query?: { directory?: string } }): Promise<Result<{ id: string }>>; prompt(options: { path: { id: string }; query?: { directory?: string }; body: { model: { providerID: string; modelID: string }; agent?: string; system?: string; tools?: Record<string, boolean>; parts: Array<{ type: "text"; text: string }>; }; signal?: AbortSignal; }): Promise<Result<OpencodeMessage>>; delete(options: { path: { id: string }; query?: { directory?: string } }): Promise<Result<unknown>>; messages(options: { path: { id: string }; query?: { directory?: string } }): Promise<Result<OpencodeMessage[]>>; }; mcp: { add(options: { query?: { directory?: string }; body: { name: string; config: { type: "remote"; url: string; headers?: Record<string, string>; oauth?: false } }; }): Promise<Result<unknown>>; disconnect(options: { path: { name: string }; query?: { directory?: string } }): Promise<Result<unknown>>; };};
export type OpencodeHarnessOptions = { /** Use an existing client (tests, or a server you started yourself). */ client?: OpencodeClient; /** Keep opencode sessions after each run instead of deleting them. */ keepSessions?: boolean; /** Extra opencode config merged over the defaults, e.g. a custom `provider`. */ config?: Record<string, unknown>;};
export function opencodeHarness(options: OpencodeHarnessOptions = {}): Harness { let started: Promise<{ client: OpencodeClient; close(): void }> | undefined; const connect = () => { if (options.client) return Promise.resolve({ client: options.client, close() {} }); started ??= startServer(options.config); return started; };
return { name: "opencode", async run(request: HarnessRequest): Promise<HarnessResult> { const { client } = await connect(); const model = splitModel(request.model); const query = request.sandbox.workdir ? { directory: request.sandbox.workdir } : undefined; const toolServer = request.tools.length > 0 ? await attachTools(client, request, query) : undefined; let session: { id: string } | undefined; try { session = unwrap(await client.session.create({ body: { title: "machine run" }, query }), "create a session"); const reply = unwrap( await client.session.prompt({ path: { id: session.id }, query, body: { model, agent: MACHINE_AGENT, system: request.instructions, // Only this run's machine tools; opencode's built-in tools stay off. tools: { "*": false, ...(toolServer ? { [`${toolServer.name}_*`]: true } : {}) }, parts: [{ type: "text", text: withTranscript(request.history, request.input) }], }, signal: request.signal, }), "prompt the model", ); if (reply.info.error) { const { name, data } = reply.info.error; throw new Error(`opencode model error${name ? ` (${name})` : ""}: ${data?.message ?? "unknown error"}`); } // A turn with tool calls spans several assistant messages; the prompt // reply is only the last. Read the session back for the whole turn. const messages = unwrap(await client.session.messages({ path: { id: session.id }, query }), "read the session"); const steps = messages.filter((m) => m.info.role === "assistant"); return toResult(request, steps.length > 0 ? steps : [reply], toolServer?.name); } finally { if (session && !options.keepSessions) await client.session.delete({ path: { id: session.id }, query }).catch(() => {}); await toolServer?.detach(); } }, async close() { if (!started) return; const server = await started.catch(() => undefined); started = undefined; server?.close(); }, };}
/** * Serve the run's tools over MCP from this process and register the server * with opencode under a run-specific name. opencode exposes them as * "<name>_<tool>". */async function attachTools(client: OpencodeClient, request: HarnessRequest, query?: { directory: string }) { const server = await serveToolsOverMcp(request.tools, { sandbox: request.sandbox, signal: request.signal }); const name = `machine${randomBytes(4).toString("hex")}`; try { unwrap( await client.mcp.add({ query, body: { name, config: { type: "remote", url: server.url, headers: { authorization: `Bearer ${server.token}` }, oauth: false } }, }), "register the machine's tools", ); } catch (error) { await server.close(); throw error; } return { name, async detach() { await client.mcp.disconnect({ path: { name }, query }).catch(() => {}); await server.close(); }, };}
function toResult(request: HarnessRequest, steps: OpencodeMessage[], toolPrefix?: string): HarnessResult { const trajectory: Message[] = [{ role: "user", content: request.input }]; const texts: string[] = []; let stopReason = steps.at(-1)?.info.finish; let inputTokens = 0; let outputTokens = 0; for (const step of steps) { inputTokens += step.info.tokens?.input ?? 0; outputTokens += step.info.tokens?.output ?? 0; } for (const part of steps.flatMap((m) => m.parts)) { if (part.type === "text") { const { text, synthetic } = part as { text: string; synthetic?: boolean }; if (synthetic || !text) continue; texts.push(text); trajectory.push({ role: "assistant", content: text }); } else if (part.type === "tool") { const { callID, tool: rawName, state } = part as Extract<OpencodePart, { type: "tool" }>; const tool = toolPrefix && rawName.startsWith(`${toolPrefix}_`) ? rawName.slice(toolPrefix.length + 1) : rawName; trajectory.push({ role: "assistant", content: "", name: tool, toolCallId: callID, toolInput: state.input }); const output = state.status === "error" ? `error: ${state.error ?? ""}` : (state.output ?? ""); trajectory.push({ role: "tool", content: output, name: tool, toolCallId: callID }); } else if (part.type === "step-finish") { stopReason = (part as { reason?: string }).reason ?? stopReason; } } return { trajectory, output: texts.at(-1) ?? "", model: request.model, usage: { inputTokens, outputTokens }, stopReason, };}
/** "openrouter/thinkingmachines/inkling:free" → provider "openrouter", model "thinkingmachines/inkling:free". */export function splitModel(model: string): { providerID: string; modelID: string } { const slash = model.indexOf("/"); if (slash <= 0 || slash === model.length - 1) { throw new Error(`opencode models are "provider/model", e.g. "openrouter/thinkingmachines/inkling:free"; got "${model}".`); } return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };}
function unwrap<T>(result: Result<T>, what: string): T { if (result.error !== undefined || result.data === undefined) { throw new Error(`opencode could not ${what}: ${JSON.stringify(result.error ?? "no data").slice(0, 500)}`); } return result.data;}
/** * The opencode agent every run uses. Its prompt replaces opencode's default * coding-agent system prompt (about 9k characters about codebases, git, and * tests), so the machine's instructions are the only real instructions the * model sees. opencode still appends a short environment block (model id, * working directory, platform, date) before the machine's instructions. */export const MACHINE_AGENT = "machine";const MACHINE_AGENT_PROMPT = "Follow the instructions that come after this, which describe your task.";
/** Server config: the machine agent, nothing shared, and every tool off unless a run enables its own. */export function serverConfig(extra: Record<string, unknown> = {}) { return { ...extra, tools: { "*": false }, share: "disabled", autoupdate: false, agent: { [MACHINE_AGENT]: { mode: "primary", prompt: MACHINE_AGENT_PROMPT, tools: { "*": false } } }, };}
/** * Environment for `opencode serve`. A private, empty config home keeps the * user's global opencode config and instruction files (e.g. * ~/.config/opencode/AGENTS.md) out of machine runs, and the flags turn off * project config, Claude Code instruction files and skills, plugins, sharing, * updates, and LSP downloads. Provider keys still come from the environment. */export function serverEnvironment( configHome: string, extraConfig: Record<string, unknown> = {}, base: NodeJS.ProcessEnv = process.env,): NodeJS.ProcessEnv { return { ...base, XDG_CONFIG_HOME: configHome, OPENCODE_CONFIG_CONTENT: JSON.stringify(serverConfig(extraConfig)), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_DISABLE_CLAUDE_CODE: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", OPENCODE_PURE: "1", OPENCODE_DISABLE_SHARE: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_LSP_DOWNLOAD: "1", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", };}
async function startServer(extraConfig?: Record<string, unknown>): Promise<{ client: OpencodeClient; close(): void }> { let sdk: { createOpencodeClient(config: { baseUrl: string }): unknown }; try { // A variable specifier keeps the SDK optional: it is resolved only at run time. const specifier = "@opencode-ai/sdk"; sdk = await import(specifier); } catch (error) { throw new Error(`The opencode harness needs @opencode-ai/sdk installed (bun add @opencode-ai/sdk): ${error instanceof Error ? error.message : error}`); } const server = await spawnServer(extraConfig); return { client: sdk.createOpencodeClient({ baseUrl: server.url }) as OpencodeClient, close: server.close };}
/** * Start `opencode serve` on a free local port. We spawn it ourselves rather * than through the SDK helper so we hold the process: close() escalates from * SIGTERM to SIGKILL, and the server is killed if this process exits first. */async function spawnServer(extraConfig?: Record<string, unknown>, timeoutMs = 15_000): Promise<{ url: string; close(): void }> { const configHome = await mkdtemp(path.join(os.tmpdir(), "machine-opencode-")); const proc = spawn("opencode", ["serve", "--hostname=127.0.0.1", "--port=0"], { env: serverEnvironment(configHome, extraConfig), stdio: ["ignore", "pipe", "pipe"], }); const removeConfigHome = () => rmSync(configHome, { recursive: true, force: true }); proc.once("exit", removeConfigHome); const kill = () => { if (proc.exitCode !== null || proc.signalCode !== null) return; proc.kill("SIGTERM"); const force = setTimeout(() => proc.kill("SIGKILL"), 2_000); force.unref(); proc.once("exit", () => clearTimeout(force)); }; const onExit = () => { proc.kill("SIGKILL"); removeConfigHome(); }; process.once("exit", onExit);
const url = await new Promise<string>((resolve, reject) => { let output = ""; const timer = setTimeout(() => fail(new Error(`opencode server did not start within ${timeoutMs}ms: ${output.slice(-500)}`)), timeoutMs); const fail = (error: Error) => { clearTimeout(timer); kill(); reject(error); }; const read = (chunk: Buffer) => { output += chunk.toString(); const match = output.match(/opencode server listening on (https?:\/\/\S+)/); if (match) { clearTimeout(timer); resolve(match[1]); } }; proc.stdout.on("data", read); proc.stderr.on("data", (chunk: Buffer) => (output += chunk.toString())); proc.once("error", (error) => fail(new Error(`Could not start opencode (is it on PATH?): ${error.message}`))); proc.once("exit", (code) => fail(new Error(`opencode server exited with code ${code}: ${output.slice(-500)}`))); });
return { url, close() { process.off("exit", onExit); kill(); // Remove now: this process may exit before the server's exit event fires. removeConfigHome(); }, };}