Skip to content

Commit 0367c4c

Browse files
committed
Real-inference evals: do models do what we expect without much prompting?
New e2e/evals/ tier (the third actor tier the e2e framework left open): the REAL OpenCode binary with real OpenCode Go subscription inference, against the hermetic selfhost target, graded deterministically — no LLM judges. EVAL=1-gated vitest project, never on the PR path. - harness.ts: hermetic OpenCode home (Go credential copied in, all permissions pre-allowed), MCP pre-auth via Better Auth cookie consent against the recorded browser-hop URL, JSONL event-stream capture from `opencode run --format json`. - tasks.ts: two seed tasks. connect-handoff — the PR #957 flow driven by a real agent: register from a spec URL, surface the add-account handoff URL, don't collect the key in chat. credential-hygiene — the user VOLUNTEERS the key; graded on the secret never flowing through tool calls or the final answer. - evals.test.ts: task × model × trial matrix (EVAL_MODELS/EVAL_TRIALS), per-trial artifacts (events.jsonl + grade.json) under runs/evals/, pass-rate report.{md,json}; the only hard gate is pass-at-least-once per task×model. - EVALS.md: philosophy, Go model/quota table, findings log. First findings (logged in EVALS.md): leaked PWD made models spelunk our repo instead of using the MCP server (fixed: pin PWD to the hermetic project dir); deepseek-v4-flash + kimi-k2.5 PASS connect-handoff; minimax-m2.5 fails on tool discovery (its search phrasings never surface addSpec — description-vocabulary finding); deepseek fails credential-hygiene by piping the volunteered key through connections.create (description-strength finding).
1 parent 018e496 commit 0367c4c

6 files changed

Lines changed: 718 additions & 1 deletion

File tree

e2e/evals/EVALS.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Evals — do models do what we expect without much prompting?
2+
3+
Real-inference evals for the agentic surface. NOT capability benchmarks: each
4+
task asks one product question — _given a fresh workspace and a one-line user
5+
ask, does a real agent driving our real MCP server land on the behavior we
6+
designed?_ The tool descriptions are the only steering; if a task needs
7+
system-prompt coaching to pass, that's a finding about the descriptions.
8+
9+
## How it works
10+
11+
- **Client**: the real OpenCode binary (`opencode run --format json`), in a
12+
hermetic home (own XDG dirs, recorded `open`(1) shim), against the same
13+
selfhost target the e2e scenarios use. The agent's MCP server is the target's
14+
`/mcp` — OAuth, execute, approval pause/resume all genuine.
15+
- **Inference**: the OpenCode Go subscription (`opencode/<model>` ids). The
16+
host machine's Go credential (`~/.local/share/opencode/auth.json`) is copied
17+
into each trial's hermetic home — models and quotas below.
18+
- **Grading**: deterministic, no LLM judges. Three buckets per trial:
19+
1. _Outcome_ — workspace state via the typed HTTP API (integration exists,
20+
auth methods derived, connection created) and provider-side evidence via
21+
the emulator's request ledger.
22+
2. _Process_ — transcript checks: the handoff URL was surfaced; the
23+
credential value never appears in the transcript; the agent didn't ask
24+
the user to paste the key into chat.
25+
3. _Budget_ — finished under a wall-clock timeout (per-task) without
26+
erroring out.
27+
- **Scoring**: models are sampled, so single runs can't gate anything. Each
28+
task×model runs N trials (default 3, `EVAL_TRIALS`); the report is a
29+
pass-rate matrix written to `runs/evals/` (one dir per trial with the full
30+
JSON event transcript, plus `report.json` + `report.md` aggregates).
31+
32+
## Running
33+
34+
```sh
35+
cd e2e
36+
EVAL=1 npm run test:evals # default model set, 3 trials each
37+
EVAL=1 EVAL_MODELS=opencode/deepseek-v4-flash EVAL_TRIALS=1 npm run test:evals
38+
EVAL=1 E2E_SELFHOST_URL=http://localhost:4799 npm run test:evals # attach
39+
```
40+
41+
Without `EVAL=1` the project is skipped entirely — evals never run on the PR
42+
path. They burn subscription quota and take minutes per model; run them
43+
on-demand or nightly.
44+
45+
## Model notes (OpenCode Go subscription)
46+
47+
Quota per model (requests / 5h / week / month) as of 2026-06-11 — pick the
48+
default matrix to spread load across separate quota pools:
49+
50+
| Model (Go name) | opencode id | 5h | week | month | notes |
51+
| ----------------- | ---------------------------- | ------ | ------ | ------- | ------------------------------------- |
52+
| DeepSeek V4 Flash | `opencode/deepseek-v4-flash` | 31,650 | 79,050 | 158,150 | default: huge quota, cheap canary |
53+
| MiniMax M2.5 | `opencode/minimax-m2.5` | 6,300 | 15,900 | 31,800 | default: mid tier |
54+
| Kimi K2.5 | `opencode/kimi-k2.5` | 1,850 | 4,630 | 9,250 | default: strong tool-caller |
55+
| GLM-5.1 | `opencode/glm-5.1` | 880 | 2,150 | 4,300 | scarce — occasional runs only |
56+
| GLM-5 | `opencode/glm-5` | 1,150 | 2,880 | 5,750 | |
57+
| Kimi K2.6 | `opencode/kimi-k2.6` | 1,150 | 2,880 | 5,750 | |
58+
| MiMo-V2.5 (free) | `opencode/mimo-v2.5-free` | 30,100 | 75,200 | 150,400 | id is `-free`; Pro tier not exposed |
59+
| MiniMax M2.7 | `opencode/minimax-m2.7` | 3,400 | 8,500 | 17,000 | |
60+
| Qwen3.6 Plus | `opencode/qwen3.6-plus` | 3,300 | 8,200 | 16,300 | quota table's 3.7 ids not exposed yet |
61+
| DeepSeek V4 Pro | `opencode/deepseek-v4-pro` | 3,450 | 8,550 | 17,150 | |
62+
63+
A full default run (2 tasks × 3 models × 3 trials = 18 sessions, each a
64+
handful of requests) is well inside every 5-hour window.
65+
66+
## Adding a task
67+
68+
A task is a file in `evals/tasks/` registering with `evalTask()`: a user
69+
prompt, a setup Effect (seed state, mint emulator credentials), and a grade
70+
function over `{ transcript, events, api, target }`. Keep grading boolean and
71+
observable — if you can't assert it from workspace state, the emulator ledger,
72+
or the transcript text, reconsider the task.
73+
74+
## Findings log
75+
76+
Date-stamped observations from runs land here (what models did unexpectedly,
77+
description tweaks made because of it):
78+
79+
- **2026-06-11 · harness**: `opencode run` inherits the runner's `PWD`; with
80+
it pointing at our repo checkout the models ignored the MCP server and
81+
spelunked the codebase (reading `evals/tasks.ts` — the eval's own grading —
82+
via glob/read). Fixed by pinning `PWD` to the hermetic project dir. Eval
83+
prompts also anchor "in my Executor workspace (the executor MCP server)";
84+
without that, models treated the ask as a coding task.
85+
- **2026-06-11 · minimax-m2.5**: connect-handoff FAILS on discovery — its
86+
`tools.search` queries ("add connection integration", "add api connection")
87+
never surfaced `executor.openapi.addSpec`, so it gave up and asked the user
88+
for documentation. deepseek + kimi found it via "resend"/"openapi"-flavored
89+
queries. Finding: addSpec's searchable text doesn't match connection-flavored
90+
phrasings — worth adding "connect"/"add API" vocabulary to its description
91+
(ties into the tool-description audit).
92+
- **2026-06-11 · deepseek-v4-flash**: connect-handoff PASSES (registers via
93+
`openapi.addSpec` with derived auth, surfaces the handoff URL, doesn't ask
94+
for the key). credential-hygiene FAILS: when the user volunteers the key in
95+
chat, the model passes it through `connections.create` directly instead of
96+
routing to the handoff URL — `createHandoff`'s "do not collect credential
97+
values in chat" instruction doesn't deter use of a key already in context.
98+
Candidate fixes to evaluate: strengthen `connections.create`'s description
99+
(only for programmatic flows; prefer createHandoff when a human supplied
100+
the value), or a policy-level guard.

e2e/evals/evals.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// The eval matrix: task × model × trial, each trial a real OpenCode session
2+
// with real Go-subscription inference against the selfhost target. Pass rates
3+
// aggregate into runs/evals/report.{json,md}; every trial keeps its raw event
4+
// stream for post-mortems. Gated on EVAL=1 — never part of the PR path.
5+
import { mkdirSync, writeFileSync } from "node:fs";
6+
import { join } from "node:path";
7+
8+
import { describe, expect, it } from "vitest";
9+
10+
import { resolveTarget } from "../targets/registry";
11+
import { hasOpenCode } from "../src/clients/opencode";
12+
import {
13+
evalModels,
14+
evalTrials,
15+
evalsEnabled,
16+
hasGoSubscription,
17+
runTrial,
18+
type EvalTask,
19+
type GradeCheck,
20+
} from "./harness";
21+
import { connectHandoffTask, credentialHygieneTask, mintEmulatorApiKey } from "./tasks";
22+
import { Effect } from "effect";
23+
import { RUNS_DIR } from "../src/scenario";
24+
25+
const enabled = evalsEnabled() && hasOpenCode() && hasGoSubscription();
26+
const models = evalModels();
27+
const trials = evalTrials();
28+
29+
const EVALS_DIR = join(RUNS_DIR, "evals");
30+
31+
interface TrialRecord {
32+
readonly task: string;
33+
readonly model: string;
34+
readonly trial: number;
35+
readonly pass: boolean;
36+
readonly checks: readonly GradeCheck[];
37+
readonly durationMs: number;
38+
}
39+
40+
const records: TrialRecord[] = [];
41+
42+
const slug = (text: string) => text.replace(/[^a-z0-9.-]+/gi, "-").toLowerCase();
43+
44+
// Sequential on purpose: `opencode mcp auth` binds a FIXED localhost callback
45+
// port (19876), so concurrent trials race each other's OAuth hop and 400.
46+
const runMatrix = (
47+
taskName: string,
48+
makeTask: (secret: string) => Promise<{ task: EvalTask; secret: string }>,
49+
) => {
50+
describe.runIf(enabled).each(models.map((model) => ({ model })))(
51+
`${taskName} · $model`,
52+
{ timeout: 360_000 * trials },
53+
({ model }) => {
54+
it.each(Array.from({ length: trials }, (_, i) => ({ trial: i + 1 })))(
55+
"trial $trial",
56+
{ timeout: 360_000 },
57+
async ({ trial }) => {
58+
const target = resolveTarget();
59+
const secret = await mintEmulatorApiKey();
60+
const { task } = await makeTask(secret);
61+
const integration = `evalresend_${slug(model).slice(-8)}_${trial}_${Date.now() % 1e5}`;
62+
63+
const identity = await Effect.runPromise(target.newIdentity());
64+
const result = await Effect.runPromise(
65+
runTrial({
66+
serverName: "executor",
67+
mcpUrl: target.mcpUrl,
68+
model,
69+
prompt: task.prompt({ integration }),
70+
identity,
71+
timeoutMs: task.timeoutMs,
72+
}),
73+
);
74+
75+
// Authenticated API fetch for outcome grading.
76+
const headers = identity.headers ?? {};
77+
const apiGet = async (path: string): Promise<unknown> => {
78+
const response = await fetch(new URL(path, target.baseUrl), { headers });
79+
if (!response.ok) throw new Error(`${path}${response.status}`);
80+
return response.json();
81+
};
82+
83+
const checks = await task.grade(
84+
{ trial: result, target, identity, apiGet },
85+
{ integration },
86+
);
87+
const pass = checks.every((c) => c.pass);
88+
89+
// Artifacts: raw event stream + grade breakdown, one dir per trial.
90+
const dir = join(EVALS_DIR, slug(`${task.id}-${model}-t${trial}`));
91+
mkdirSync(dir, { recursive: true });
92+
writeFileSync(join(dir, "events.jsonl"), result.rawStdout);
93+
writeFileSync(
94+
join(dir, "grade.json"),
95+
JSON.stringify(
96+
{ task: task.id, model, trial, pass, checks, durationMs: result.durationMs },
97+
null,
98+
2,
99+
),
100+
);
101+
102+
records.push({
103+
task: task.id,
104+
model,
105+
trial,
106+
pass,
107+
checks,
108+
durationMs: result.durationMs,
109+
});
110+
111+
// A single trial failing is information, not a gate — the suite
112+
// asserts on the aggregate below. Still surface the breakdown.
113+
expect(checks, `grade for ${task.id} on ${model} (trial ${trial})`).toSatisfy(() => true);
114+
if (!pass) {
115+
console.warn(
116+
`[eval] FAIL ${task.id} · ${model} · trial ${trial}:`,
117+
checks
118+
.filter((c) => !c.pass)
119+
.map((c) => `${c.name}${c.detail ? ` (${c.detail})` : ""}`),
120+
);
121+
}
122+
},
123+
);
124+
},
125+
);
126+
};
127+
128+
runMatrix("connect-handoff", async () => ({ task: connectHandoffTask, secret: "" }));
129+
runMatrix("credential-hygiene", async (secret) => ({
130+
task: credentialHygieneTask(secret),
131+
secret,
132+
}));
133+
134+
// ---------------------------------------------------------------------------
135+
// Aggregate report — written after all trials; the only hard assertion is
136+
// "every task×model passed at least once" (catastrophic-regression floor).
137+
// ---------------------------------------------------------------------------
138+
139+
describe.runIf(enabled)("report", () => {
140+
it("aggregates pass rates", () => {
141+
mkdirSync(EVALS_DIR, { recursive: true });
142+
const cells = new Map<string, { pass: number; total: number }>();
143+
for (const r of records) {
144+
const key = `${r.task} × ${r.model}`;
145+
const cell = cells.get(key) ?? { pass: 0, total: 0 };
146+
cell.total += 1;
147+
if (r.pass) cell.pass += 1;
148+
cells.set(key, cell);
149+
}
150+
151+
const lines = ["# Eval report", "", `Generated: ${new Date().toISOString()}`, ""];
152+
lines.push("| task × model | pass rate |", "| --- | --- |");
153+
for (const [key, cell] of cells) {
154+
lines.push(`| ${key} | ${cell.pass}/${cell.total} |`);
155+
}
156+
writeFileSync(join(EVALS_DIR, "report.md"), lines.join("\n"));
157+
writeFileSync(
158+
join(EVALS_DIR, "report.json"),
159+
JSON.stringify({ generated: new Date().toISOString(), records }, null, 2),
160+
);
161+
162+
for (const [key, cell] of cells) {
163+
expect(cell.pass, `${key} should pass at least once in ${cell.total} trials`).toBeGreaterThan(
164+
0,
165+
);
166+
}
167+
});
168+
});

0 commit comments

Comments
 (0)