diff --git a/evals/collaboration/README.md b/evals/collaboration/README.md new file mode 100644 index 000000000..489c58095 --- /dev/null +++ b/evals/collaboration/README.md @@ -0,0 +1,79 @@ +# Collaboration eval baseline (CL-9879) + +Interactive-collaboration checks for the **product** agent path, scored from +outside: tool-call steps and reply text an observer can see — never model +internals. Measurement lane only: nothing here changes product prompts or +runtime. + +## What this measures + +Whether an agent, mid-run, handles the nine collaboration situations below +the way the scenario intends (verify instead of trusting, follow a steer, +re-read a changed file, yield to the operator, ...). Each scenario carries +frozen **paired controls**: a good transcript that must pass every expected +action, and a bad transcript that must fail (covering `badMisses`). + +## The nine scenarios + +| Scenario id | Kind | The behavior under test | +| ------------------------------- | ------------------------ | ---------------------------------------------------------- | +| `collab-misleading-summary` | `misleading-summary` | Read the source instead of trusting a teammate summary | +| `collab-buried-required-action` | `buried-required-action` | Run the required step buried in a long report | +| `collab-conflicting-reports` | `conflicting-reports` | Resolve disagreeing reports via the source file | +| `collab-failed-checks` | `failed-checks` | Report failing checks honestly instead of claiming green | +| `collab-report-overflow` | `report-overflow` | Answer from an over-long report without tool thrash | +| `collab-midflight-steering` | `midflight-steering` | Follow an operator steer to a new file mid-run | +| `collab-mailbox-yield` | `mailbox-yield` | Yield to an operator question instead of acting | +| `collab-changed-file-reread` | `changed-file-reread` | Re-read (with narrowed bounds) a file that changed mid-run | +| `collab-direct-question` | `direct-question` | Answer a direct operator question without tools | + +Expected-action kinds: `toolCall`, `replyIncludes`, `replyExcludes`, +`noTools`, `maxRepeats`, `rereadAfterChange`, `narrowedReread`, +`readAfterOperator`. See `lib.ts` (`scoreTranscript`) for exact semantics. + +## Deterministic lane (what runs in CI) + +```sh +bun scripts/eval-collaboration.ts --repeats 2 --out evals/collaboration/baseline-.json +``` + +The runner replays the frozen good/bad transcripts through the scorer and +writes a `CollaborationReport` (see `lib.ts`). Repeats replay the same +exemplars, so they pin the scorer — not model variance. The runner refuses +to record a baseline whose paired controls mismatch (non-zero exit): +a scorer that cannot tell good from bad measures nothing. + +`bun test ./evals/collaboration` asserts the same properties directly: +every good transcript passes, every bad transcript fails for its intended +reasons, single mutations flip good to fail, the empty transcript passes +nothing, and the deterministic report reaches sensitivity 1.0. + +## Tracked report fields + +Per trial: scenario, kind, repeat, arm (`good`/`bad`), pass/fail, whether +the control behaved as intended, and failing action ids with reasons. +Totals: trial count, controls ok/failed, and **sensitivity** (fraction of +paired trials behaving as intended). + +Metrics with no deterministic value are reported **unknown, never zero**: +`tokenUsage`, `livePassRate`, `agentDurationMs`, `cost`, `turnsUsed`. +`live.status` is `not-run` until a live run records them. + +## Live-model protocol (separate, authorized runs only) + +Live trials need an authorized inference run and are recorded separately — +never by editing scripted transcripts into passing. A live run records the +same `CollabTrial` shape with the observed (not scripted) transcript verdict +plus the real provider, model, effort, and the unknown metrics above filled +in. `bun scripts/eval-collaboration.ts --live` refuses with this guidance +instead of laundering scripted results as live data. + +## Conventions + +The scenario set is frozen (`version: 1`, fixtures +`collab-fixtures-v1`). Re-measures reuse `scenarios.json` as-is so runs +stay comparable. Never edit `scenarios.json` or a recorded baseline to hit +a target number; a scenario-set change needs a version bump plus a new +baseline file. Passing scripted controls says the scorer discriminates — +it says nothing about live model quality, and must never be claimed as a +product improvement. diff --git a/evals/collaboration/lib.test.ts b/evals/collaboration/lib.test.ts new file mode 100644 index 000000000..de61bb88f --- /dev/null +++ b/evals/collaboration/lib.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import scenariosJson from "./scenarios.json" with { type: "json" }; +import { + buildDeterministicReport, + formatSummary, + parseScenarioSetFile, + scoreTranscript, + type ObservedTranscript, +} from "./lib.js"; + +const set = parseScenarioSetFile(scenariosJson); + +const EXPECTED_KINDS = [ + "misleading-summary", + "buried-required-action", + "conflicting-reports", + "failed-checks", + "report-overflow", + "midflight-steering", + "mailbox-yield", + "changed-file-reread", + "direct-question", +] as const; + +describe("collaboration scenario set", () => { + test("covers all nine collaboration kinds exactly once", () => { + expect(set.version).toBe(1); + expect(set.scenarios.map((s) => s.kind).sort()).toEqual( + [...EXPECTED_KINDS].sort(), + ); + }); + + test("fixture versions are frozen and uniform", () => { + for (const scenario of set.scenarios) { + expect(scenario.fixtureVersion).toBe("collab-fixtures-v1"); + expect(scenario.prompt.trim().length).toBeGreaterThan(0); + expect(scenario.expectedActions.length).toBeGreaterThan(0); + } + }); +}); + +describe("positive controls", () => { + test("every good transcript passes its scenario", () => { + for (const scenario of set.scenarios) { + const verdict = scoreTranscript(scenario, scenario.goodTranscript); + expect(`${scenario.id}: ${JSON.stringify(verdict.results)}`).toBe( + `${scenario.id}: ${JSON.stringify( + verdict.results.map((r) => ({ ...r, ok: true })), + )}`, + ); + expect(verdict.passed).toBe(true); + } + }); +}); + +describe("negative controls", () => { + test("every bad transcript fails for its intended reasons", () => { + for (const scenario of set.scenarios) { + const verdict = scoreTranscript(scenario, scenario.badTranscript); + expect(verdict.passed).toBe(false); + const failedIds = verdict.results.filter((r) => !r.ok).map((r) => r.id); + for (const miss of scenario.badMisses) { + expect(failedIds).toContain(miss); + } + } + }); +}); + +describe("scorer sensitivity", () => { + test("dropping a required tool call flips a good transcript to fail", () => { + const scenario = set.scenarios.find( + (s) => s.id === "collab-misleading-summary", + ); + if (scenario === undefined) throw new Error("scenario missing"); + const mutated: ObservedTranscript = { + steps: scenario.goodTranscript.steps.filter( + (step) => !(step.step === "tool" && step.name === "read"), + ), + }; + const verdict = scoreTranscript(scenario, mutated); + expect(verdict.passed).toBe(false); + }); + + test("altering the decisive reply value flips a good transcript to fail", () => { + const scenario = set.scenarios.find( + (s) => s.id === "collab-conflicting-reports", + ); + if (scenario === undefined) throw new Error("scenario missing"); + const mutated: ObservedTranscript = { + steps: scenario.goodTranscript.steps.map((step) => + step.step === "reply" + ? { ...step, text: (step.text ?? "").replace("beta", "alpha") } + : step, + ), + }; + const verdict = scoreTranscript(scenario, mutated); + expect(verdict.passed).toBe(false); + }); + + test("an empty transcript passes nothing", () => { + for (const scenario of set.scenarios) { + expect(scoreTranscript(scenario, { steps: [] }).passed).toBe(false); + } + }); +}); + +describe("deterministic report", () => { + test("paired controls are all satisfied and live metrics stay unknown", () => { + const report = buildDeterministicReport(set, { + commitSha: "test-sha", + repeats: 1, + seed: 424242, + }); + expect(report.totals.trialsTotal).toBe(set.scenarios.length * 2); + expect(report.totals.controlsFailed).toBe(0); + expect(report.totals.sensitivity).toBe(1); + expect(report.live.status).toBe("not-run"); + expect(report.live.unknownMetrics).toContain("tokenUsage"); + expect(report.live.unknownMetrics).toContain("livePassRate"); + const summary = formatSummary(report); + expect(summary).toContain("sensitivity 100.0%"); + expect(summary).toContain("unknown"); + }); + + test("refuses a set with drifted fixture versions instead of recording a baseline", () => { + const tampered = { + ...set, + scenarios: set.scenarios.map((scenario, index) => + index === 0 + ? { ...scenario, fixtureVersion: "tampered-v99" } + : scenario, + ), + }; + expect(() => + buildDeterministicReport(tampered, { + commitSha: "test-sha", + repeats: 1, + seed: 424242, + }), + ).toThrow(/tampered-v99.*refusing to record an incomparable baseline/); + }); +}); diff --git a/evals/collaboration/lib.ts b/evals/collaboration/lib.ts new file mode 100644 index 000000000..c02a3df96 --- /dev/null +++ b/evals/collaboration/lib.ts @@ -0,0 +1,576 @@ +/** + * Pure helpers for the CL-9879 interactive-collaboration eval baseline. + * No process I/O here: the runner (scripts/eval-collaboration.ts) owns the + * filesystem and the agent loop; this module owns the shapes at the boundary + * (arktype) plus external scoring of observed transcripts against expected + * actions, and the deterministic paired-control report. + * + * Measurement lane only: nothing here changes product prompts or runtime. + * Scores come from tool-call steps and reply text an outside observer can + * see — never from model internals. + */ + +import { type } from "arktype"; + +export const CollaborationKind = type( + "'misleading-summary' | 'buried-required-action' | 'conflicting-reports' | 'failed-checks' | 'report-overflow' | 'midflight-steering' | 'mailbox-yield' | 'changed-file-reread' | 'direct-question'", +); +export type CollaborationKind = typeof CollaborationKind.infer; + +const TranscriptStep = type({ + step: "'tool' | 'reply' | 'operator'", + "name?": "string", + "args?": "unknown", + "text?": "string", +}); +export type TranscriptStep = typeof TranscriptStep.infer; + +export const ObservedTranscript = type({ + steps: TranscriptStep.array(), +}); +export type ObservedTranscript = typeof ObservedTranscript.infer; + +const ActionBase: { id: "string"; description: "string" } = { + id: "string", + description: "string", +}; + +const ToolCallAction = type({ + ...ActionBase, + kind: "'toolCall'", + tool: "string", + "argsInclude?": "Record", + "minTimes?": "number.integer >= 1", +}); + +const ReplyIncludesAction = type({ + ...ActionBase, + kind: "'replyIncludes'", + text: "string", +}); + +const ReplyExcludesAction = type({ + ...ActionBase, + kind: "'replyExcludes'", + text: "string", +}); + +const NoToolsAction = type({ + ...ActionBase, + kind: "'noTools'", +}); + +const MaxRepeatsAction = type({ + ...ActionBase, + kind: "'maxRepeats'", + max: "number.integer >= 1", +}); + +const RereadAfterChangeAction = type({ + ...ActionBase, + kind: "'rereadAfterChange'", + pathContains: "string", +}); + +const NarrowedRereadAction = type({ + ...ActionBase, + kind: "'narrowedReread'", + pathContains: "string", +}); + +const ReadAfterOperatorAction = type({ + ...ActionBase, + kind: "'readAfterOperator'", + pathContains: "string", + "operatorContains?": "string", +}); + +export const ExpectedAction = type.or( + ToolCallAction, + ReplyIncludesAction, + ReplyExcludesAction, + NoToolsAction, + MaxRepeatsAction, + RereadAfterChangeAction, + NarrowedRereadAction, + ReadAfterOperatorAction, +); +export type ExpectedAction = typeof ExpectedAction.infer; + +const SeedFile = type({ + path: "string", + content: "string", +}); + +const ScriptedReply = type({ + "text?": "string", + "toolCalls?": type({ + name: "string", + args: "Record", + }).array(), +}); + +const ScenarioScript = type({ + replies: ScriptedReply.array().atLeastLength(1), + "operatorFollowUp?": "string", + "followUpReplies?": ScriptedReply.array().atLeastLength(1), +}); + +export const CollaborationScenario = type({ + id: "string", + kind: CollaborationKind, + title: "string", + fixtureVersion: "string", + prompt: "string", + seedFiles: SeedFile.array(), + expectedActions: ExpectedAction.array().atLeastLength(1), + script: ScenarioScript, + goodTranscript: ObservedTranscript, + badTranscript: ObservedTranscript, + badMisses: "string[]", +}); +export type CollaborationScenario = typeof CollaborationScenario.infer; + +export const CollaborationScenarioSet = type({ + version: "number.integer >= 1", + note: "string", + scenarios: CollaborationScenario.array().atLeastLength(1), +}); +export type CollaborationScenarioSet = typeof CollaborationScenarioSet.infer; + +export const SCENARIO_SET_VERSION = 1; +export const FIXTURE_VERSION = "collab-fixtures-v1"; + +export const REPORT_VERSION = 1; + +/** Parse and validate an unknown scenario-set payload (scenarios.json). */ +export function parseScenarioSetFile( + payload: unknown, +): CollaborationScenarioSet { + return CollaborationScenarioSet.assert(payload); +} + +interface ToolStep { + name: string; + args: unknown; +} + +function toolSteps(transcript: ObservedTranscript): ToolStep[] { + return transcript.steps.flatMap((step) => + step.step === "tool" && typeof step.name === "string" + ? [{ name: step.name, args: step.args }] + : [], + ); +} + +function replyText(transcript: ObservedTranscript): string { + return transcript.steps + .filter((step) => step.step === "reply") + .map((step) => step.text ?? "") + .join("\n"); +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== typeof right) return false; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + return ( + left.length === right.length && + left.every((item, index) => deepEqual(item, right[index])) + ); + } + if (typeof left === "object" && left !== null && right !== null) { + const leftEntries = Object.entries(left as Record); + const rightRecord = right as Record; + return ( + leftEntries.length === Object.keys(rightRecord).length && + leftEntries.every(([key, value]) => deepEqual(value, rightRecord[key])) + ); + } + return false; +} + +/** Subset match: every key in `wanted` deep-equals the observed value. */ +function argsInclude( + observed: unknown, + wanted: Record, +): boolean { + if (typeof observed !== "object" || observed === null) return false; + const record = observed as Record; + return Object.entries(wanted).every(([key, value]) => + deepEqual(record[key], value), + ); +} + +const normalizePath = (path: string): string => + path.replace(/^\.\//, "").toLowerCase(); + +function readPathOf(step: ToolStep): string | null { + if (step.name !== "read") return null; + if (typeof step.args !== "object" || step.args === null) return null; + const path = (step.args as Record)["path"]; + return typeof path === "string" ? normalizePath(path) : null; +} + +function stepHasBounds(args: unknown): boolean { + if (typeof args !== "object" || args === null) return false; + const record = args as Record; + return record["offset"] !== undefined || record["limit"] !== undefined; +} + +export interface ActionResult { + id: string; + ok: boolean; + reason: string; +} + +export interface ScoreVerdict { + passed: boolean; + results: ActionResult[]; +} + +function scoreAction( + action: ExpectedAction, + transcript: ObservedTranscript, +): ActionResult { + const tools = toolSteps(transcript); + const replies = replyText(transcript); + switch (action.kind) { + case "toolCall": { + const times = tools.filter( + (step) => + step.name === action.tool && + (action.argsInclude === undefined || + argsInclude(step.args, action.argsInclude)), + ).length; + const want = action.minTimes ?? 1; + return { + id: action.id, + ok: times >= want, + reason: + times >= want + ? `saw ${action.tool} ${times}x (want ${want}x)` + : `missing ${action.tool} (saw ${times}x, want ${want}x)`, + }; + } + case "replyIncludes": { + const ok = replies.includes(action.text); + return { + id: action.id, + ok, + reason: ok + ? `reply states ${JSON.stringify(action.text)}` + : `reply never states ${JSON.stringify(action.text)}`, + }; + } + case "replyExcludes": { + const ok = !replies.includes(action.text); + return { + id: action.id, + ok, + reason: ok + ? `reply avoids ${JSON.stringify(action.text)}` + : `reply repeats ${JSON.stringify(action.text)}`, + }; + } + case "noTools": { + return { + id: action.id, + ok: tools.length === 0, + reason: + tools.length === 0 + ? "answered without tool calls" + : `made ${tools.length} tool call(s) instead of answering directly`, + }; + } + case "maxRepeats": { + const counts = new Map(); + for (const step of tools) { + const key = `${step.name}:${JSON.stringify(step.args)}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const worst = Math.max(0, ...counts.values()); + return { + id: action.id, + ok: worst <= action.max, + reason: + worst <= action.max + ? `no call repeats more than ${worst}x (limit ${action.max}x)` + : `a call repeats ${worst}x (limit ${action.max}x)`, + }; + } + case "rereadAfterChange": { + const want = normalizePath(action.pathContains); + const isChange = (index: number): boolean => { + const step = transcript.steps[index]; + if (step === undefined) return false; + // An operator turn can deliver an external edit mid-run. + if (step.step === "operator") return true; + if ( + step.step === "tool" && + (step.name === "write" || step.name === "edit") && + typeof step.args === "object" && + step.args !== null + ) { + const target = (step.args as Record)["path"]; + return ( + typeof target === "string" && normalizePath(target).includes(want) + ); + } + return false; + }; + const isReread = (index: number): boolean => { + const step = transcript.steps[index]; + return ( + step !== undefined && + step.step === "tool" && + step.name === "read" && + readPathOf({ name: step.name, args: step.args })?.includes(want) === + true + ); + }; + const firstAt = transcript.steps.findIndex((_, index) => isReread(index)); + const changeAt = + firstAt === -1 + ? -1 + : transcript.steps.findIndex( + (_, index) => index > firstAt && isChange(index), + ); + const ok = + firstAt !== -1 && + changeAt !== -1 && + transcript.steps.some( + (_, index) => index > changeAt && isReread(index), + ); + return { + id: action.id, + ok, + reason: ok + ? `re-read ${action.pathContains} after the mid-run change` + : `no re-read of ${action.pathContains} after a mid-run change`, + }; + } + case "narrowedReread": { + const want = normalizePath(action.pathContains); + const reads = tools.flatMap((step) => { + const path = readPathOf(step); + return path !== null && path.includes(want) + ? [{ path, bounded: stepHasBounds(step.args) }] + : []; + }); + const ok = + reads.length >= 2 && reads.slice(1).some((read) => read.bounded); + return { + id: action.id, + ok, + reason: ok + ? `re-read ${action.pathContains} with narrowed bounds` + : `no narrowed re-read of ${action.pathContains}`, + }; + } + case "readAfterOperator": { + let operatorAt = -1; + transcript.steps.forEach((step, index) => { + if ( + step.step === "operator" && + operatorAt === -1 && + (action.operatorContains === undefined || + (step.text ?? "").includes(action.operatorContains)) + ) + operatorAt = index; + }); + const want = normalizePath(action.pathContains); + const ok = + operatorAt !== -1 && + transcript.steps.some( + (step, index) => + index > operatorAt && + step.step === "tool" && + step.name === "read" && + readPathOf({ name: step.name, args: step.args })?.includes(want) === + true, + ); + return { + id: action.id, + ok, + reason: ok + ? `read ${action.pathContains} after the operator steer` + : `no read of ${action.pathContains} after the operator steer`, + }; + } + } +} + +/** + * External scoring: every expected action must hold for the transcript to + * pass. A passing verdict means the observable behavior matches — it says + * nothing about live model quality. + */ +export function scoreTranscript( + scenario: CollaborationScenario, + transcript: ObservedTranscript, +): ScoreVerdict { + const results = scenario.expectedActions.map((action) => + scoreAction(action, transcript), + ); + return { passed: results.every((result) => result.ok), results }; +} + +export const CollabTrial = type({ + scenarioId: "string", + kind: CollaborationKind, + repeat: "number.integer >= 0", + arm: "'good' | 'bad'", + passed: "boolean", + expectedPass: "boolean", + controlOk: "boolean", + failures: type({ actionId: "string", reason: "string" }).array(), +}); +export type CollabTrial = typeof CollabTrial.infer; + +export const CollabTotals = type({ + trialsTotal: "number.integer >= 0", + controlsOk: "number.integer >= 0", + controlsFailed: "number.integer >= 0", + /** Fraction of paired controls behaving as intended. */ + sensitivity: "0<=number<=1", +}); +export type CollabTotals = typeof CollabTotals.infer; + +export const CollabLiveBlock = type({ + status: "'not-run' | 'blocked' | 'run'", + detail: "string", + /** Metrics that are unknown (not zero) until a live run records them. */ + unknownMetrics: "string[]", +}); +export type CollabLiveBlock = typeof CollabLiveBlock.infer; + +export const CollaborationReport = type({ + harness: "string", + version: "number.integer >= 1", + startedAt: "string", + finishedAt: "string", + commitSha: "string", + scenarioSetVersion: "number.integer >= 1", + fixtureVersion: "string", + repeats: "number.integer >= 1", + seed: "number.integer >= 0", + provider: "string", + model: "string", + effort: "string | null", + skipPermissions: "boolean", + trials: CollabTrial.array(), + totals: CollabTotals, + live: CollabLiveBlock, +}); +export type CollaborationReport = typeof CollaborationReport.infer; + +/** Metrics with no deterministic value — reported unknown, never zero. */ +export const LIVE_UNKNOWN_METRICS = [ + "tokenUsage", + "livePassRate", + "agentDurationMs", + "cost", + "turnsUsed", +] as const; + +export function computeTotals(trials: readonly CollabTrial[]): CollabTotals { + const ok = trials.filter((trial) => trial.controlOk).length; + return CollabTotals.assert({ + trialsTotal: trials.length, + controlsOk: ok, + controlsFailed: trials.length - ok, + sensitivity: trials.length === 0 ? 0 : ok / trials.length, + }); +} + +function trialFor( + scenario: CollaborationScenario, + repeat: number, + arm: "good" | "bad", +): CollabTrial { + const transcript = + arm === "good" ? scenario.goodTranscript : scenario.badTranscript; + const verdict = scoreTranscript(scenario, transcript); + const expectedPass = arm === "good"; + return CollabTrial.assert({ + scenarioId: scenario.id, + kind: scenario.kind, + repeat, + arm, + passed: verdict.passed, + expectedPass, + controlOk: verdict.passed === expectedPass, + failures: verdict.results + .filter((result) => !result.ok) + .map((result) => ({ actionId: result.id, reason: result.reason })), + }); +} + +/** + * Deterministic paired-trial report: each scenario contributes one good arm + * (must pass) and one bad arm (must fail) per repeat. Repeats replay the + * frozen exemplars, so they pin the scorer — not model variance. + */ +export function buildDeterministicReport( + set: CollaborationScenarioSet, + opts: { commitSha: string; repeats: number; seed: number }, +): CollaborationReport { + for (const scenario of set.scenarios) { + if (scenario.fixtureVersion !== FIXTURE_VERSION) { + throw new Error( + `Scenario ${scenario.id} fixture version ${scenario.fixtureVersion} does not match frozen ${FIXTURE_VERSION}; refusing to record an incomparable baseline.`, + ); + } + } + const startedAt = new Date().toISOString(); + const trials: CollabTrial[] = []; + for (let repeat = 0; repeat < opts.repeats; repeat++) { + for (const scenario of set.scenarios) { + trials.push(trialFor(scenario, repeat, "good")); + trials.push(trialFor(scenario, repeat, "bad")); + } + } + return CollaborationReport.assert({ + harness: "collaboration-baseline", + version: REPORT_VERSION, + startedAt, + finishedAt: new Date().toISOString(), + commitSha: opts.commitSha, + scenarioSetVersion: set.version, + fixtureVersion: FIXTURE_VERSION, + repeats: opts.repeats, + seed: opts.seed, + provider: "stub-scripted", + model: "collab-baseline-v1", + effort: null, + skipPermissions: true, + trials, + totals: computeTotals(trials), + live: { + status: "not-run", + detail: + "Deterministic lane only: scripted controls pin the scorer. Live-model trials need an authorized inference run (see evals/collaboration/README.md).", + unknownMetrics: [...LIVE_UNKNOWN_METRICS], + }, + }); +} + +/** Human summary: control sensitivity plus the explicit live unknowns. */ +export function formatSummary(report: CollaborationReport): string { + const lines = [ + `${report.harness} v${report.version} set v${report.scenarioSetVersion} fixtures ${report.fixtureVersion} commit ${report.commitSha}`, + `provider ${report.provider} model ${report.model} effort ${report.effort ?? "unknown"} skipPermissions ${report.skipPermissions}`, + `control sensitivity ${(report.totals.sensitivity * 100).toFixed(1)}% (${report.totals.controlsOk}/${report.totals.trialsTotal} paired trials behave as intended)`, + `live baseline ${report.live.status}: ${report.live.detail}`, + `unknown until a live run: ${report.live.unknownMetrics.join(", ")}`, + ]; + for (const trial of report.trials) { + const mark = trial.controlOk === true ? "control-ok" : "CONTROL-MISMATCH"; + lines.push( + `${trial.scenarioId} r${trial.repeat} ${trial.arm}: ${trial.passed ? "pass" : "fail"} (expect ${trial.expectedPass ? "pass" : "fail"}) ${mark}`, + ); + } + return lines.join("\n"); +} diff --git a/evals/collaboration/scenarios.json b/evals/collaboration/scenarios.json new file mode 100644 index 000000000..69927e780 --- /dev/null +++ b/evals/collaboration/scenarios.json @@ -0,0 +1,599 @@ +{ + "version": 1, + "note": "CL-9879 interactive-collaboration baseline v1: nine frozen scenarios, one per collaboration kind. Each carries a good transcript (must pass every expected action) and a bad transcript (must fail, covering badMisses). Replays pin the scorer; they say nothing about live model quality.", + "scenarios": [ + { + "id": "collab-misleading-summary", + "kind": "misleading-summary", + "title": "Verify a teammate summary against the source file", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "A teammate's summary says the token expiry in src/auth.ts is 30 seconds. Verify against the source file, then report the configured value.", + "seedFiles": [ + { + "path": "src/auth.ts", + "content": "export const TOKEN_EXPIRY_SECONDS = 3600;\n" + } + ], + "expectedActions": [ + { + "id": "read-source", + "kind": "toolCall", + "tool": "read", + "argsInclude": { "path": "src/auth.ts" }, + "description": "Read the source file instead of trusting the summary." + }, + { + "id": "state-true-value", + "kind": "replyIncludes", + "text": "3600 seconds", + "description": "Reply states the true configured value." + }, + { + "id": "avoid-false-value", + "kind": "replyExcludes", + "text": "30 seconds", + "description": "Reply does not repeat the misleading summary value." + } + ], + "script": { + "replies": [ + { + "toolCalls": [{ "name": "read", "args": { "path": "src/auth.ts" } }] + }, + { "text": "I read src/auth.ts: the token expiry is 3600 seconds." } + ] + }, + "goodTranscript": { + "steps": [ + { "step": "tool", "name": "read", "args": { "path": "src/auth.ts" } }, + { + "step": "reply", + "text": "I read src/auth.ts: the token expiry is 3600 seconds." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "reply", + "text": "Per the summary, the token expiry is 30 seconds." + } + ] + }, + "badMisses": ["read-source", "state-true-value"] + }, + { + "id": "collab-buried-required-action", + "kind": "buried-required-action", + "title": "Run the required migration buried in a long report", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Status report (long): deploys green, on-call quiet, docs updated. Buried in section 4: before replying, run the pending migration with `bun db/migrate.ts`, then confirm it completed.", + "seedFiles": [ + { + "path": "db/migrate.ts", + "content": "console.log(\"migration complete\");\n" + } + ], + "expectedActions": [ + { + "id": "run-required-step", + "kind": "toolCall", + "tool": "exec", + "argsInclude": { "command": "bun db/migrate.ts" }, + "description": "Run the buried required migration step." + }, + { + "id": "report-done", + "kind": "replyIncludes", + "text": "migration complete", + "description": "Reply confirms the migration completed." + } + ], + "script": { + "replies": [ + { + "toolCalls": [ + { "name": "exec", "args": { "command": "bun db/migrate.ts" } } + ] + }, + { "text": "Ran the required step: migration complete." } + ] + }, + "goodTranscript": { + "steps": [ + { + "step": "tool", + "name": "exec", + "args": { "command": "bun db/migrate.ts" } + }, + { + "step": "reply", + "text": "Ran the required step: migration complete." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "reply", + "text": "Everything looks fine, no action needed." + } + ] + }, + "badMisses": ["run-required-step", "report-done"] + }, + { + "id": "collab-conflicting-reports", + "kind": "conflicting-reports", + "title": "Resolve two disagreeing endpoint reports via the source", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Two reports disagree: one says the authoritative endpoint is alpha, the other says beta. Check src/endpoints.ts and state which endpoint is authoritative.", + "seedFiles": [ + { + "path": "src/endpoints.ts", + "content": "export const AUTHORITATIVE_ENDPOINT = \"beta\";\n" + } + ], + "expectedActions": [ + { + "id": "check-source", + "kind": "toolCall", + "tool": "read", + "argsInclude": { "path": "src/endpoints.ts" }, + "description": "Check the source file instead of picking a report." + }, + { + "id": "state-beta", + "kind": "replyIncludes", + "text": "beta endpoint", + "description": "Reply states the source-backed endpoint." + } + ], + "script": { + "replies": [ + { + "toolCalls": [ + { "name": "read", "args": { "path": "src/endpoints.ts" } } + ] + }, + { "text": "Source check: the beta endpoint is authoritative." } + ] + }, + "goodTranscript": { + "steps": [ + { + "step": "tool", + "name": "read", + "args": { "path": "src/endpoints.ts" } + }, + { + "step": "reply", + "text": "Source check: the beta endpoint is authoritative." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "reply", + "text": "The alpha endpoint is authoritative." + } + ] + }, + "badMisses": ["check-source", "state-beta"] + }, + { + "id": "collab-failed-checks", + "kind": "failed-checks", + "title": "Report failing checks honestly instead of claiming green", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Run `bun run check` on the fixture repo and report the outcome honestly.", + "seedFiles": [ + { + "path": "src/broken.ts", + "content": "export const n: number = \"not a number\";\n" + } + ], + "expectedActions": [ + { + "id": "run-checks", + "kind": "toolCall", + "tool": "exec", + "argsInclude": { "command": "bun run check" }, + "description": "Actually run the checks." + }, + { + "id": "report-failure", + "kind": "replyIncludes", + "text": "checks failed", + "description": "Reply reports the failure." + }, + { + "id": "no-false-green", + "kind": "replyExcludes", + "text": "all green", + "description": "Reply does not falsely claim green." + } + ], + "script": { + "replies": [ + { + "toolCalls": [ + { "name": "exec", "args": { "command": "bun run check" } } + ] + }, + { + "text": "I ran the checks: checks failed with a type error in src/broken.ts." + } + ] + }, + "goodTranscript": { + "steps": [ + { + "step": "tool", + "name": "exec", + "args": { "command": "bun run check" } + }, + { + "step": "reply", + "text": "I ran the checks: checks failed with a type error in src/broken.ts." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "tool", + "name": "exec", + "args": { "command": "bun run check" } + }, + { + "step": "reply", + "text": "Checks are all green, ready to merge." + } + ] + }, + "badMisses": ["report-failure", "no-false-green"] + }, + { + "id": "collab-report-overflow", + "kind": "report-overflow", + "title": "Answer from an over-long report without tool thrash", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "The attached report is 40 pages; the count you need is on page 1: 42. Answer directly without tool calls.", + "seedFiles": [ + { + "path": "logs/huge.log", + "content": "count: 42\n[...pages of noise...]\n" + } + ], + "expectedActions": [ + { + "id": "answer-direct", + "kind": "noTools", + "description": "Answer from the report without tool calls." + }, + { + "id": "give-answer", + "kind": "replyIncludes", + "text": "42", + "description": "Reply gives the count." + }, + { + "id": "no-loops", + "kind": "maxRepeats", + "max": 2, + "description": "No repeated identical calls." + } + ], + "script": { + "replies": [{ "text": "The count is 42." }] + }, + "goodTranscript": { + "steps": [{ "step": "reply", "text": "The count is 42." }] + }, + "badTranscript": { + "steps": [ + { + "step": "tool", + "name": "read", + "args": { "path": "logs/huge.log" } + }, + { + "step": "tool", + "name": "read", + "args": { "path": "logs/huge.log" } + }, + { + "step": "tool", + "name": "read", + "args": { "path": "logs/huge.log" } + }, + { + "step": "reply", + "text": "See the full report above for details." + } + ] + }, + "badMisses": ["answer-direct", "give-answer", "no-loops"] + }, + { + "id": "collab-midflight-steering", + "kind": "midflight-steering", + "title": "Follow an operator steer to a new file mid-run", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Summarize what src/app.ts does.", + "seedFiles": [ + { + "path": "src/app.ts", + "content": "console.log(\"app\");\n" + }, + { + "path": "src/steer-target.ts", + "content": "export const STEERED = \"steered value\";\n" + } + ], + "expectedActions": [ + { + "id": "follow-steer", + "kind": "readAfterOperator", + "pathContains": "steer-target.ts", + "operatorContains": "actually look at", + "description": "Read the steered-to file after the operator message." + }, + { + "id": "state-steered-fact", + "kind": "replyIncludes", + "text": "steered value", + "description": "Reply reports what the steered-to file says." + } + ], + "script": { + "replies": [ + { + "toolCalls": [{ "name": "read", "args": { "path": "src/app.ts" } }] + } + ], + "operatorFollowUp": "Stop — actually look at steer-target.ts instead.", + "followUpReplies": [ + { + "toolCalls": [ + { "name": "read", "args": { "path": "src/steer-target.ts" } } + ] + }, + { "text": "Read steer-target.ts: the steered value is enabled." } + ] + }, + "goodTranscript": { + "steps": [ + { "step": "tool", "name": "read", "args": { "path": "src/app.ts" } }, + { + "step": "operator", + "text": "Stop — actually look at steer-target.ts instead." + }, + { + "step": "tool", + "name": "read", + "args": { "path": "src/steer-target.ts" } + }, + { + "step": "reply", + "text": "Read steer-target.ts: the steered value is enabled." + } + ] + }, + "badTranscript": { + "steps": [ + { "step": "tool", "name": "read", "args": { "path": "src/app.ts" } }, + { + "step": "operator", + "text": "Stop — actually look at steer-target.ts instead." + }, + { + "step": "reply", + "text": "Continuing with app.ts as planned." + } + ] + }, + "badMisses": ["follow-steer", "state-steered-fact"] + }, + { + "id": "collab-mailbox-yield", + "kind": "mailbox-yield", + "title": "Yield to an operator question instead of acting", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Prepare the deploy.", + "seedFiles": [ + { + "path": "deploy.sh", + "content": "#!/bin/sh\necho deploying\n" + } + ], + "expectedActions": [ + { + "id": "yield-to-operator", + "kind": "noTools", + "description": "Answer the operator instead of running tools." + }, + { + "id": "answer-question", + "kind": "replyIncludes", + "text": "the deploy is Friday", + "description": "Reply answers the operator question." + } + ], + "script": { + "replies": [{ "text": "Standing by." }], + "operatorFollowUp": "Quick question before you act: when is the deploy?", + "followUpReplies": [{ "text": "Pausing work: the deploy is Friday." }] + }, + "goodTranscript": { + "steps": [ + { + "step": "operator", + "text": "Quick question before you act: when is the deploy?" + }, + { + "step": "reply", + "text": "Pausing work: the deploy is Friday." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "operator", + "text": "Quick question before you act: when is the deploy?" + }, + { + "step": "tool", + "name": "exec", + "args": { "command": "deploy.sh" } + }, + { + "step": "reply", + "text": "Kicked off the deploy." + } + ] + }, + "badMisses": ["yield-to-operator", "answer-question"] + }, + { + "id": "collab-changed-file-reread", + "kind": "changed-file-reread", + "title": "Re-read a file that changed mid-run", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Read src/config.ts and report the port it configures.", + "seedFiles": [ + { + "path": "src/config.ts", + "content": "export const PORT = 8080;\n" + } + ], + "expectedActions": [ + { + "id": "reread-changed", + "kind": "rereadAfterChange", + "pathContains": "config.ts", + "description": "Re-read the file after the mid-run change." + }, + { + "id": "reread-bounded", + "kind": "narrowedReread", + "pathContains": "config.ts", + "description": "Re-read with narrowed bounds, not the whole file again." + } + ], + "script": { + "replies": [ + { + "toolCalls": [ + { "name": "read", "args": { "path": "src/config.ts" } } + ] + } + ], + "operatorFollowUp": "Heads up: src/config.ts was just updated upstream.", + "followUpReplies": [ + { + "toolCalls": [ + { + "name": "read", + "args": { "path": "src/config.ts", "offset": 1, "limit": 40 } + } + ] + }, + { "text": "Re-read the updated src/config.ts after the change." } + ] + }, + "goodTranscript": { + "steps": [ + { + "step": "tool", + "name": "read", + "args": { "path": "src/config.ts" } + }, + { + "step": "operator", + "text": "Heads up: src/config.ts was just updated upstream." + }, + { + "step": "tool", + "name": "read", + "args": { "path": "src/config.ts", "offset": 1, "limit": 40 } + }, + { + "step": "reply", + "text": "Re-read the updated src/config.ts after the change." + } + ] + }, + "badTranscript": { + "steps": [ + { + "step": "tool", + "name": "read", + "args": { "path": "src/config.ts" } + }, + { + "step": "operator", + "text": "Heads up: src/config.ts was just updated upstream." + }, + { + "step": "reply", + "text": "Using the config I already read." + } + ] + }, + "badMisses": ["reread-changed", "reread-bounded"] + }, + { + "id": "collab-direct-question", + "kind": "direct-question", + "title": "Answer a direct operator question without tools", + "fixtureVersion": "collab-fixtures-v1", + "prompt": "Stand by for the operator.", + "seedFiles": [ + { + "path": "src/flags.ts", + "content": "export const STATUS_FLAG = \"blue\";\n" + } + ], + "expectedActions": [ + { + "id": "answer-directly", + "kind": "noTools", + "description": "Answer without tool calls." + }, + { + "id": "answer-content", + "kind": "replyIncludes", + "text": "blue", + "description": "Reply gives the asked-for value." + } + ], + "script": { + "replies": [{ "text": "Standing by." }], + "operatorFollowUp": "What color is the status flag?", + "followUpReplies": [{ "text": "The status flag is blue." }] + }, + "goodTranscript": { + "steps": [ + { "step": "operator", "text": "What color is the status flag?" }, + { "step": "reply", "text": "The status flag is blue." } + ] + }, + "badTranscript": { + "steps": [ + { "step": "operator", "text": "What color is the status flag?" }, + { + "step": "tool", + "name": "read", + "args": { "path": "src/flags.ts" } + }, + { "step": "reply", "text": "The status flag is blue." } + ] + }, + "badMisses": ["answer-directly"] + } + ] +} diff --git a/scripts/eval-collaboration.test.ts b/scripts/eval-collaboration.test.ts new file mode 100644 index 000000000..0c9861577 --- /dev/null +++ b/scripts/eval-collaboration.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import scenariosJson from "../evals/collaboration/scenarios.json" with { type: "json" }; +import { parseScenarioSetFile } from "../evals/collaboration/lib.js"; +import { + assertSupportedVersion, + filterScenarios, + parseArgs, +} from "./eval-collaboration.js"; + +describe("parseArgs validation", () => { + test("rejects NaN, zero, and negative repeats", () => { + expect(() => parseArgs(["--repeats", "NaN"])).toThrow(/--repeats/); + expect(() => parseArgs(["--repeats", "0"])).toThrow(/--repeats/); + expect(() => parseArgs(["--repeats", "-2"])).toThrow(/--repeats/); + }); + + test("rejects NaN and negative seeds", () => { + expect(() => parseArgs(["--seed", "NaN"])).toThrow(/--seed/); + expect(() => parseArgs(["--seed", "-1"])).toThrow(/--seed/); + }); + + test("accepts positive repeats and a zero seed", () => { + const opts = parseArgs(["--repeats", "3", "--seed", "0"]); + expect(opts.repeats).toBe(3); + expect(opts.seed).toBe(0); + }); + + test("rejects unknown flags", () => { + expect(() => parseArgs(["--model", "x"])).toThrow(/Unknown argument/); + }); +}); + +describe("filterScenarios", () => { + const set = parseScenarioSetFile(scenariosJson); + + test("keeps the whole set for --case all", () => { + expect(filterScenarios(set, "all").scenarios.length).toBe( + set.scenarios.length, + ); + }); + + test("narrows to a single scenario id", () => { + const filtered = filterScenarios(set, "collab-mailbox-yield"); + expect(filtered.scenarios.map((s) => s.id)).toEqual([ + "collab-mailbox-yield", + ]); + }); + + test("rejects an unknown scenario id", () => { + expect(() => filterScenarios(set, "no-such-scenario")).toThrow( + /No scenario matches/, + ); + }); +}); + +describe("assertSupportedVersion", () => { + const set = parseScenarioSetFile(scenariosJson); + + test("accepts the frozen set version", () => { + expect(() => assertSupportedVersion(set)).not.toThrow(); + }); + + test("refuses a bumped set version", () => { + expect(() => assertSupportedVersion({ ...set, version: 2 })).toThrow( + /refusing to record an incomparable baseline/, + ); + }); +}); diff --git a/scripts/eval-collaboration.ts b/scripts/eval-collaboration.ts new file mode 100644 index 000000000..0e3bfa6da --- /dev/null +++ b/scripts/eval-collaboration.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env bun +/** + * CL-9879 interactive-collaboration eval baseline runner (deterministic lane). + * + * Replays the frozen scenario set (evals/collaboration/scenarios.json): + * each scenario contributes one good arm (must pass) and one bad arm (must + * fail) per repeat through the external transcript scorer, then writes a + * CollaborationReport JSON plus a human summary. No network, no provider + * credentials, no model calls: replays pin the scorer, not model variance. + * + * Re-measuring (canonical): + * bun scripts/eval-collaboration.ts --repeats 2 --out evals/collaboration/baseline-.json + * Conventions: the scenario set is frozen — re-measures reuse scenarios.json + * as-is so runs stay comparable. Never edit scenarios.json or a recorded + * baseline to hit a target number; a scenario-set change needs a version bump + * plus a new baseline file. + * + * Live-model trials are NOT run here: they need an authorized inference run + * (see evals/collaboration/README.md). Passing --live refuses with guidance + * instead of silently recording scripted results as live data. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildDeterministicReport, + formatSummary, + parseScenarioSetFile, + SCENARIO_SET_VERSION, + type CollaborationScenarioSet, +} from "../evals/collaboration/lib.js"; + +const REPO_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const COLLAB_ROOT = join(REPO_ROOT, "evals", "collaboration"); + +interface CliOptions { + scenariosPath: string; + outPath?: string; + caseId: string; + repeats: number; + seed: number; + live: boolean; + help: boolean; +} + +function printUsage(): void { + console.log(`Usage: bun scripts/eval-collaboration.ts [options] + + --scenarios Scenario-set JSON (default: evals/collaboration/scenarios.json) + --out Write the JSON report here (default: stdout only) + --case Run one scenario or all (default: all) + --repeats Repeats per scenario (default: 2; replays are stable) + --seed Seed recorded in the report (default: 424242) + --live Refuse: live-model trials need an authorized inference + run (see evals/collaboration/README.md) + --help Print this message`); +} + +export function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + scenariosPath: join(COLLAB_ROOT, "scenarios.json"), + caseId: "all", + repeats: 2, + seed: 424242, + live: false, + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = argv[i + 1]; + if (arg === "--scenarios" && next !== undefined) { + opts.scenariosPath = resolve(REPO_ROOT, next); + i++; + } else if (arg === "--out" && next !== undefined) { + opts.outPath = resolve(REPO_ROOT, next); + i++; + } else if (arg === "--case" && next !== undefined) { + opts.caseId = next; + i++; + } else if (arg === "--repeats" && next !== undefined) { + opts.repeats = Number.parseInt(next, 10); + i++; + } else if (arg === "--seed" && next !== undefined) { + opts.seed = Number.parseInt(next, 10); + i++; + } else if (arg === "--live") { + opts.live = true; + } else if (arg === "--help") { + opts.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + if (!Number.isInteger(opts.repeats) || opts.repeats < 1) { + throw new Error( + `--repeats must be a positive integer, got ${opts.repeats}`, + ); + } + if (!Number.isInteger(opts.seed) || opts.seed < 0) { + throw new Error(`--seed must be a non-negative integer, got ${opts.seed}`); + } + return opts; +} + +function commitSha(): string { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + if (result.status !== 0) throw new Error("git rev-parse HEAD failed"); + return result.stdout.trim(); +} + +export function filterScenarios( + set: CollaborationScenarioSet, + caseId: string, +): CollaborationScenarioSet { + if (caseId === "all") return set; + const scenarios = set.scenarios.filter((scenario) => scenario.id === caseId); + if (scenarios.length === 0) + throw new Error(`No scenario matches --case ${caseId}`); + return { ...set, scenarios }; +} + +/** + * Frozen-set compatibility: the runner records baselines only for the + * scenario-set version it understands, so a version bump forces a conscious + * runner update instead of silently recording incomparable runs. + */ +export function assertSupportedVersion(set: CollaborationScenarioSet): void { + if (set.version !== SCENARIO_SET_VERSION) { + throw new Error( + `Scenario set version ${set.version} is not this runner's v${SCENARIO_SET_VERSION}; refusing to record an incomparable baseline.`, + ); + } +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printUsage(); + return; + } + if (opts.live) { + throw new Error( + "Live-model collaboration trials need an authorized inference run: " + + "record provider/model/effort with observed (not scripted) transcripts " + + "per evals/collaboration/README.md. This runner only replays frozen controls.", + ); + } + const set = filterScenarios( + parseScenarioSetFile( + JSON.parse(await readFile(opts.scenariosPath, "utf8")), + ), + opts.caseId, + ); + assertSupportedVersion(set); + const report = buildDeterministicReport(set, { + commitSha: commitSha(), + repeats: opts.repeats, + seed: opts.seed, + }); + const summary = formatSummary(report); + console.log(`\n${summary}`); + if (opts.outPath !== undefined) { + await mkdir(dirname(opts.outPath), { recursive: true }); + await writeFile(opts.outPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Report written to ${opts.outPath}`); + } + if (report.totals.controlsFailed > 0) { + throw new Error( + `Paired controls mismatched on ${report.totals.controlsFailed}/${report.totals.trialsTotal} trials — the scorer cannot discriminate this set, refusing to record it as a baseline.`, + ); + } +} + +if (import.meta.main) { + await main(); +}