opencode 1.18 treats every export of a plugin module as a plugin function. router-link.js exported a Map (), so the loader rejected the entire module: chat.headers and tool.execute.after never ran. The 29 node tests passed because they imported the module directly, bypassing the loader. - Move parentCache from a named export to a property on RouterLink - Add loader-contract test: every module export must be a function - Update test imports to use RouterLink.parentCache Evidence: opencode.log at every start since 2026-09-26T08:02Z shows "Plugin export is not a function" for router-link.js.
225 lines
9.0 KiB
JavaScript
225 lines
9.0 KiB
JavaScript
/**
|
|
* Stamps requests to the local LLM router with conversation identity, and
|
|
* reports test outcomes back to it.
|
|
*
|
|
* This replaces `router-outcome.js`, which only reported outcomes. A router
|
|
* that knows *which conversation* and *which agent* a request belongs to can
|
|
* attribute quality per-session and distinguish agent-specific behavior from
|
|
* general model quality. That is the loop this plugin closes on the way in.
|
|
*
|
|
* Two hooks:
|
|
*
|
|
* chat.headers — for requests routed to the llm-router provider, set
|
|
* X-Router-Conversation (the session id),
|
|
* X-Router-Agent (the agent, slugged to the
|
|
* router's charset), and X-Router-Parent
|
|
* (the conversation's parent session, when known).
|
|
* Only the llm-router provider gets these: another
|
|
* provider would not understand them and they would
|
|
* just leak identity.
|
|
*
|
|
* tool.execute.after — the old router-outcome logic, carried over
|
|
* unchanged: watch test/build commands, POST
|
|
* pass/fail to the router's /outcome endpoint so
|
|
* feedback.py folds it into proficiency. It now also
|
|
* carries conversation_id so the router can
|
|
* attribute the outcome to the right session.
|
|
*
|
|
* Failure policy: no hook may throw or stall a request more than the 250ms
|
|
* parent lookup. The router being down must never break the session — a
|
|
* reporting/stamping failure is not the user's problem.
|
|
*
|
|
* Install:
|
|
* command cp -f deploy/opencode-plugin/router-link.js ~/.config/opencode/plugins/
|
|
* rm ~/.config/opencode/plugins/router-outcome.js
|
|
*
|
|
* (`command cp` because `cp` is aliased to -i in zsh and silently does
|
|
* nothing on an existing file; and the old file MUST go — both hook
|
|
* tool.execute.after, so an outcome would be posted twice and counted twice
|
|
* by feedback.py.)
|
|
*
|
|
* Or per-project, in .opencode/plugins/.
|
|
*/
|
|
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
|
|
const ROUTER = process.env.LLM_ROUTER_URL || "http://127.0.0.1:8080";
|
|
const PROVIDER_ID = "llm-router";
|
|
|
|
// The deterministic path that the `chat.headers` hook must stay well under.
|
|
const PARENT_TIMEOUT_MS = 250;
|
|
const PARENT_CACHE_MAX = 1000;
|
|
const TIMEOUT_SENTINEL = Symbol("parentOf timeout");
|
|
|
|
// Commands whose exit status is a real verdict on the work. Deliberately
|
|
// narrow: a failing `ls` says nothing about model quality, and a false signal
|
|
// is worse than no signal — it trains the router on noise.
|
|
const TEST_COMMAND = new RegExp(
|
|
[
|
|
"\\bpytest\\b",
|
|
"\\bunittest\\b",
|
|
"\\bnpm\\s+(run\\s+)?test\\b",
|
|
"\\bpnpm\\s+(run\\s+)?test\\b",
|
|
"\\byarn\\s+test\\b",
|
|
"\\bvitest\\b",
|
|
"\\bjest\\b",
|
|
"\\bcargo\\s+(test|check|build)\\b",
|
|
"\\bgo\\s+(test|build|vet)\\b",
|
|
"\\bmake\\s+(test|check)\\b",
|
|
"\\bmvn\\s+test\\b",
|
|
"\\bgradle\\s+test\\b",
|
|
"\\btsc\\b",
|
|
"\\bruff\\b",
|
|
"\\bmypy\\b",
|
|
"\\beslint\\b",
|
|
].join("|"),
|
|
);
|
|
|
|
// Failure signatures, for tools that exit 0 while reporting failures.
|
|
const FAILURE_TEXT =
|
|
/\b(\d+\s+failed|FAILED|FAIL\b|Traceback \(most recent call last\)|error(s)?:|panic:|AssertionError|✗|✖)/;
|
|
|
|
function looksFailed(output) {
|
|
const exit = output?.exitCode ?? output?.exit_code;
|
|
if (typeof exit === "number" && exit !== 0) return true;
|
|
const text = `${output?.stdout ?? ""}\n${output?.stderr ?? ""}\n${
|
|
typeof output?.output === "string" ? output.output : ""
|
|
}`;
|
|
// "0 failed" and "no errors" must not trip the failure regex.
|
|
if (/\b0 failed\b|\bno errors?\b/i.test(text)) return false;
|
|
return FAILURE_TEXT.test(text);
|
|
}
|
|
|
|
function commandOf(input) {
|
|
const args = input?.args ?? input?.arguments ?? {};
|
|
return args.command ?? args.cmd ?? args.script ?? "";
|
|
}
|
|
|
|
// Cache of successful parent lookups: session id -> parentID (or null for root
|
|
// sessions when the SDK returns no parentID). We cache successes so repeated
|
|
// requests in the same session do not keep hitting the SDK. We cache nothing on
|
|
// timeout or throw -- a transient miss should not be sticky.
|
|
// Eviction: when the Map reaches PARENT_CACHE_MAX, the oldest-inserted entry
|
|
// is evicted first (Map preserves insertion order, so the first key is oldest).
|
|
const parentCache = new Map();
|
|
|
|
function cacheSet(sessionID, parentID) {
|
|
if (!parentCache.has(sessionID) && parentCache.size >= PARENT_CACHE_MAX) {
|
|
const firstKey = parentCache.keys().next().value;
|
|
parentCache.delete(firstKey);
|
|
}
|
|
parentCache.set(sessionID, parentID);
|
|
}
|
|
|
|
// Resolve a session's parent id, racing the SDK call against a timeout so a
|
|
// slow session store can never stall request stamping. Returns undefined on
|
|
// timeout or catch; never throws. Returns null for root sessions (no parent).
|
|
async function parentOf(client, sessionID) {
|
|
if (parentCache.has(sessionID)) return parentCache.get(sessionID);
|
|
try {
|
|
const result = await Promise.race([
|
|
client.session.get({ path: { id: sessionID } }),
|
|
sleep(PARENT_TIMEOUT_MS).then(() => TIMEOUT_SENTINEL),
|
|
]);
|
|
if (result === TIMEOUT_SENTINEL) return undefined;
|
|
const parentID = result?.data?.parentID ?? null;
|
|
cacheSet(sessionID, parentID);
|
|
return parentID;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Whether a request is bound for the local router. opencode identifies the
|
|
// provider both on the model (`model.providerID`) and on the provider context
|
|
// (`provider.info.id`); if neither is populated at runtime the provider's
|
|
// base URL is a usable fallback, compared by ORIGIN: opencode.json sets
|
|
// options.baseURL to something like http://127.0.0.1:8080/v1, which never
|
|
// string-equals the bare router URL. A malformed URL on either side (the
|
|
// provider's, or a bad LLM_ROUTER_URL) fails safe: no headers.
|
|
function isRouterRequest(input, providerUrl) {
|
|
if (input?.model?.providerID === PROVIDER_ID) return true;
|
|
if (input?.provider?.info?.id === PROVIDER_ID) return true;
|
|
if (!providerUrl) return false;
|
|
try {
|
|
return new URL(providerUrl).origin === new URL(ROUTER).origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The router validates X-Router-Agent against ^[A-Za-z0-9._:-]{1,64}$ and
|
|
// treats a failing value as absent, so a live name like "Sisyphus -
|
|
// ultraworker" would silently lose attribution. Slug to the legal charset:
|
|
// lowercase; runs of characters outside [a-z0-9._:-] become one '-'; repeated
|
|
// '-' collapse; leading/trailing '-' trimmed; cut to 64. A name already in
|
|
// the charset (e.g. "oh-my-claudecode:executor") passes through unchanged.
|
|
function slugAgent(name) {
|
|
return String(name)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._:-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64);
|
|
}
|
|
|
|
export const RouterLink = async ({ client, directory }) => {
|
|
return {
|
|
"chat.headers": async (input, output) => {
|
|
const providerUrl =
|
|
input?.provider?.info?.options?.baseURL ??
|
|
input?.provider?.info?.options?.url ??
|
|
input?.provider?.info?.api?.url;
|
|
if (!isRouterRequest(input, providerUrl)) return;
|
|
|
|
output.headers["X-Router-Conversation"] = input.sessionID;
|
|
const agent = slugAgent(input.agent ?? "");
|
|
if (agent) output.headers["X-Router-Agent"] = agent;
|
|
|
|
const parentID = await parentOf(client, input.sessionID);
|
|
if (parentID) output.headers["X-Router-Parent"] = parentID;
|
|
},
|
|
|
|
"tool.execute.after": async (input, output) => {
|
|
// Only shell-ish tools carry a command whose exit status is a verdict.
|
|
const command = commandOf(input);
|
|
if (!command || !TEST_COMMAND.test(command)) return;
|
|
|
|
const ok = !looksFailed(output);
|
|
const detail = `${command.slice(0, 120)}${ok ? " — passed" : " — failed"}`;
|
|
|
|
try {
|
|
const res = await fetch(`${ROUTER}/outcome`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
ok,
|
|
detail,
|
|
source: directory,
|
|
conversation_id: input.sessionID,
|
|
}),
|
|
// The router being down must never break the user's session.
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
if (res.status === 409) {
|
|
// Several sessions active and the directory did not match one the
|
|
// router had seen. Dropping the sample is the correct outcome.
|
|
console.error(
|
|
"[router-link] ambiguous session; outcome not recorded",
|
|
);
|
|
} else if (!res.ok && res.status !== 404) {
|
|
console.error(`[router-link] ${res.status} reporting outcome`);
|
|
}
|
|
} catch (err) {
|
|
// Swallowed on purpose: a reporting failure is not the user's problem.
|
|
console.error(`[router-link] could not reach ${ROUTER}: ${err.message}`);
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
// Attach parentCache for test introspection. opencode's plugin loader
|
|
// rejects any non-function export (see loader-contract test), so the map
|
|
// lives as a property on the factory function rather than a named export.
|
|
RouterLink.parentCache = parentCache;
|