Closes the ground-truth loop. deploy/opencode-plugin/router-outcome.js hooks tool.execute.after, watches for test and build commands, and reports pass/fail to /outcome. opencode already runs your tests; this is what makes the result reach routing. Command detection is deliberately narrow -- pytest, npm test, cargo, go, ruff, mypy, tsc and friends. A failing `ls` says nothing about model quality, and a false signal is worse than none because it trains the router on noise. Verdict comes from exit status plus text signatures for tools that exit 0 while reporting failures, with "0 failed" and "no errors" guarded against. The router being unreachable never breaks a session. Attribution is the hard part, and two assumptions failed under test. The first fingerprint design keyed on the system prompt. One real opencode run produced TWO distinct keys, because it runs several agents with different prompts -- so that fingerprint identifies AGENTS, not sessions, and would have refused every single run forever. A permanent false positive dressed as safety. The second assumption was that opencode states its project root up front. Capturing a real request showed it does not. Directory is now derived from the file paths an agent touches across the whole conversation, counting every ancestor so the shared project root wins over any one subdirectory, and stripping trailing filenames so a file is never mistaken for a directory. A single mention is not enough; corroboration is required. When a report cannot be matched by directory and more than one conversation was active in the window, /outcome answers 409 and records nothing. Refusing beats guessing: a misattributed failure penalizes a model for work it never did, and this project has already recorded false failures twice from harness bugs that took measurement to catch. The window is 120 seconds, not 30 minutes. At 30 it swept in traffic from earlier in the same work session and refused a legitimate report -- observed directly, not theorised. Tests 232 -> 243. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTPER7K8fNyKiuqNvTCTa
109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
/**
|
|
* Report test outcomes back to the local LLM router.
|
|
*
|
|
* This closes the only loop the router cannot close by itself. Everything it
|
|
* records on its own is a proxy: structural checks know whether code *parses*,
|
|
* the local checker guesses whether prose *looks* right. Neither knows whether
|
|
* the answer did the job. opencode does — it runs your tests.
|
|
*
|
|
* Hooks `tool.execute.after`, watches for test/build commands, and POSTs the
|
|
* pass/fail to the router's /outcome endpoint. The router folds client
|
|
* outcomes into proficiency in BOTH directions (a client's "succeeded" means
|
|
* the work worked, unlike a parser's "ok" which only means it parsed), so this
|
|
* is what eventually makes routing discriminate on quality.
|
|
*
|
|
* Install:
|
|
* mkdir -p ~/.config/opencode/plugins
|
|
* cp router-outcome.js ~/.config/opencode/plugins/
|
|
*
|
|
* Or per-project, in .opencode/plugins/.
|
|
*
|
|
* PARALLEL SESSIONS: the report carries this session's directory, which the
|
|
* router matches against the working directory it sees in the conversation.
|
|
* That is exact even with several sessions running. If it cannot match, and
|
|
* more than one conversation has been routed recently, the router answers 409
|
|
* and records nothing rather than guessing — a misattributed failure would
|
|
* penalize a model for work it never did.
|
|
*/
|
|
|
|
const ROUTER = process.env.LLM_ROUTER_URL || "http://127.0.0.1:8080";
|
|
|
|
// 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 ?? "";
|
|
}
|
|
|
|
export const RouterOutcome = async ({ directory }) => {
|
|
return {
|
|
"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 }),
|
|
// 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-outcome] ambiguous session; outcome not recorded",
|
|
);
|
|
} else if (!res.ok && res.status !== 404) {
|
|
console.error(`[router-outcome] ${res.status} reporting outcome`);
|
|
}
|
|
} catch (err) {
|
|
// Swallowed on purpose: a reporting failure is not the user's problem.
|
|
console.error(`[router-outcome] could not reach ${ROUTER}: ${err.message}`);
|
|
}
|
|
},
|
|
};
|
|
};
|