@crewhaus/eval-runner 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +34 -38
- package/src/index.default-invoker.test.ts +172 -0
- package/src/index.interrupt.test.ts +99 -0
- package/src/index.judge.test.ts +167 -0
- package/src/index.ts +22 -2
- package/src/run-sample.eventlog.test.ts +124 -0
- package/src/run-sample.test.ts +273 -0
- package/src/semaphore.test.ts +58 -0
- package/src/tenant-roots.test.ts +30 -0
- package/src/wire-once.test.ts +445 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/eval-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Eval runner — per-sample isolation, concurrency, grading, persistence",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,41 +12,42 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/agent-context-isolation": "0.
|
|
16
|
-
"@crewhaus/compiler": "0.
|
|
17
|
-
"@crewhaus/errors": "0.
|
|
18
|
-
"@crewhaus/eval-dataset": "0.
|
|
19
|
-
"@crewhaus/eval-grader": "0.
|
|
20
|
-
"@crewhaus/eval-judge": "0.
|
|
21
|
-
"@crewhaus/event-log": "0.
|
|
22
|
-
"@crewhaus/hooks-engine": "0.
|
|
23
|
-
"@crewhaus/ir": "0.
|
|
24
|
-
"@crewhaus/logging": "0.
|
|
25
|
-
"@crewhaus/mcp-host": "0.
|
|
26
|
-
"@crewhaus/permission-engine": "0.
|
|
27
|
-
"@crewhaus/run-context": "0.
|
|
28
|
-
"@crewhaus/runtime-core": "0.
|
|
29
|
-
"@crewhaus/skills-registry": "0.
|
|
30
|
-
"@crewhaus/slash-commands": "0.
|
|
31
|
-
"@crewhaus/spec": "0.
|
|
32
|
-
"@crewhaus/sub-agent-spawner": "0.
|
|
33
|
-
"@crewhaus/
|
|
34
|
-
"@crewhaus/tool-
|
|
35
|
-
"@crewhaus/tool-
|
|
36
|
-
"@crewhaus/tool-
|
|
37
|
-
"@crewhaus/tool-
|
|
38
|
-
"@crewhaus/tool-
|
|
39
|
-
"@crewhaus/tool-
|
|
40
|
-
"@crewhaus/tool-
|
|
41
|
-
"@crewhaus/tool-
|
|
42
|
-
"@crewhaus/
|
|
15
|
+
"@crewhaus/agent-context-isolation": "0.1.2",
|
|
16
|
+
"@crewhaus/compiler": "0.1.2",
|
|
17
|
+
"@crewhaus/errors": "0.1.2",
|
|
18
|
+
"@crewhaus/eval-dataset": "0.1.2",
|
|
19
|
+
"@crewhaus/eval-grader": "0.1.2",
|
|
20
|
+
"@crewhaus/eval-judge": "0.1.2",
|
|
21
|
+
"@crewhaus/event-log": "0.1.2",
|
|
22
|
+
"@crewhaus/hooks-engine": "0.1.2",
|
|
23
|
+
"@crewhaus/ir": "0.1.2",
|
|
24
|
+
"@crewhaus/logging": "0.1.2",
|
|
25
|
+
"@crewhaus/mcp-host": "0.1.2",
|
|
26
|
+
"@crewhaus/permission-engine": "0.1.2",
|
|
27
|
+
"@crewhaus/run-context": "0.1.2",
|
|
28
|
+
"@crewhaus/runtime-core": "0.1.2",
|
|
29
|
+
"@crewhaus/skills-registry": "0.1.2",
|
|
30
|
+
"@crewhaus/slash-commands": "0.1.2",
|
|
31
|
+
"@crewhaus/spec": "0.1.2",
|
|
32
|
+
"@crewhaus/sub-agent-spawner": "0.1.2",
|
|
33
|
+
"@crewhaus/tenancy": "0.1.2",
|
|
34
|
+
"@crewhaus/tool-bash": "0.1.2",
|
|
35
|
+
"@crewhaus/tool-catalog": "0.1.2",
|
|
36
|
+
"@crewhaus/tool-fetch": "0.1.2",
|
|
37
|
+
"@crewhaus/tool-fs": "0.1.2",
|
|
38
|
+
"@crewhaus/tool-image": "0.1.2",
|
|
39
|
+
"@crewhaus/tool-mcp": "0.1.2",
|
|
40
|
+
"@crewhaus/tool-task": "0.1.2",
|
|
41
|
+
"@crewhaus/tool-todo": "0.1.2",
|
|
42
|
+
"@crewhaus/tool-web": "0.1.2",
|
|
43
|
+
"@crewhaus/trace-event-bus": "0.1.2",
|
|
43
44
|
"zod": "^3.23.8"
|
|
44
45
|
},
|
|
45
46
|
"license": "Apache-2.0",
|
|
46
47
|
"author": {
|
|
47
48
|
"name": "Max Meier",
|
|
48
|
-
"email": "max@
|
|
49
|
-
"url": "https://
|
|
49
|
+
"email": "max@crewhaus.ai",
|
|
50
|
+
"url": "https://crewhaus.ai"
|
|
50
51
|
},
|
|
51
52
|
"repository": {
|
|
52
53
|
"type": "git",
|
|
@@ -58,12 +59,7 @@
|
|
|
58
59
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
59
60
|
},
|
|
60
61
|
"publishConfig": {
|
|
61
|
-
"access": "
|
|
62
|
+
"access": "public"
|
|
62
63
|
},
|
|
63
|
-
"files": [
|
|
64
|
-
"src",
|
|
65
|
-
"README.md",
|
|
66
|
-
"LICENSE",
|
|
67
|
-
"NOTICE"
|
|
68
|
-
]
|
|
64
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
69
65
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated test for `runEval`'s built-in `defaultInvoker` — the production
|
|
3
|
+
* path taken when the caller supplies no `opts.invoker`. It wires the agent
|
|
4
|
+
* stack once via `wireRunOnce`, then drives `runChatLoop` per sample.
|
|
5
|
+
*
|
|
6
|
+
* We stub both `./wire-once` and `@crewhaus/runtime-core` so nothing real is
|
|
7
|
+
* spun up (no tools, no MCP, no model call). The stub `runChatLoop` records
|
|
8
|
+
* the options it was handed so we can assert the per-sample session name, the
|
|
9
|
+
* forced `permissionMode: "auto"`, `singleTurn`, and the seeded user message.
|
|
10
|
+
* `mock.module` is process-global, so this lives in its own file.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { lower } from "@crewhaus/compiler";
|
|
17
|
+
import type { Sample } from "@crewhaus/eval-dataset";
|
|
18
|
+
import { parseGradersConfig } from "@crewhaus/eval-grader";
|
|
19
|
+
import type { IrNode, IrV0 } from "@crewhaus/ir";
|
|
20
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
21
|
+
|
|
22
|
+
type ChatLoopCall = Record<string, unknown>;
|
|
23
|
+
const chatLoopCalls: ChatLoopCall[] = [];
|
|
24
|
+
const wireCalls: Array<{ cwd?: string }> = [];
|
|
25
|
+
|
|
26
|
+
// Capture real modules so `afterAll` can restore them — `mock.module` is
|
|
27
|
+
// process-global and does not auto-restore across test files. Each capture is
|
|
28
|
+
// a plain-object SNAPSHOT (`{ ...ns }`): an ESM namespace is a live view that
|
|
29
|
+
// resolves to the stubs once mock.module patches the module, so restoring
|
|
30
|
+
// from the namespace itself would silently reinstall the stubs.
|
|
31
|
+
const realWireOnce = { ...(await import("./wire-once")) };
|
|
32
|
+
const realRuntimeCore = { ...(await import("@crewhaus/runtime-core")) };
|
|
33
|
+
|
|
34
|
+
// Toggle whether the wired deps include sub-agents (drives the optional
|
|
35
|
+
// subAgents/spawnSubAgent spread in defaultInvoker).
|
|
36
|
+
let includeSubAgents = false;
|
|
37
|
+
|
|
38
|
+
function baseDeps() {
|
|
39
|
+
return {
|
|
40
|
+
tools: [],
|
|
41
|
+
hooks: [],
|
|
42
|
+
skills: [],
|
|
43
|
+
slashCommands: new Map(),
|
|
44
|
+
permissionRules: { flag: [], settings: [], yaml: [], hooks: [], builtin: [] },
|
|
45
|
+
model: "claude-wired",
|
|
46
|
+
instructions: "wired instructions",
|
|
47
|
+
sessionName: "wired-session",
|
|
48
|
+
sessionTarget: "cli",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
mock.module("./wire-once", () => ({
|
|
53
|
+
wireRunOnce: async (_ir: unknown, opts: { cwd?: string } = {}) => {
|
|
54
|
+
wireCalls.push(opts);
|
|
55
|
+
if (includeSubAgents) {
|
|
56
|
+
return {
|
|
57
|
+
...baseDeps(),
|
|
58
|
+
subAgents: new Map([["helper", { name: "helper" }]]),
|
|
59
|
+
spawnSubAgent: () => undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return baseDeps();
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
mock.module("@crewhaus/runtime-core", () => ({
|
|
67
|
+
...realRuntimeCore,
|
|
68
|
+
runChatLoop: async (opts: ChatLoopCall) => {
|
|
69
|
+
chatLoopCalls.push(opts);
|
|
70
|
+
return `answer for ${(opts["seedMessages"] as Array<{ content: string }>)[0]?.content}`;
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const { runEval } = await import("./index");
|
|
75
|
+
|
|
76
|
+
function narrowToAgent(ir: IrNode): IrV0 {
|
|
77
|
+
if (ir.target !== "cli") throw new Error(`expected target:cli, got ${ir.target}`);
|
|
78
|
+
return ir;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SPEC = `name: default-invoker-test
|
|
82
|
+
target: cli
|
|
83
|
+
agent:
|
|
84
|
+
model: claude-opus-4-7
|
|
85
|
+
instructions: spec instructions
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
|
|
89
|
+
for (const s of samples) yield s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TMP_ROOTS: string[] = [];
|
|
93
|
+
function newTempRoot(): string {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-definvoker-"));
|
|
95
|
+
TMP_ROOTS.push(dir);
|
|
96
|
+
return dir;
|
|
97
|
+
}
|
|
98
|
+
afterAll(() => {
|
|
99
|
+
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
mock.module("./wire-once", () => realWireOnce);
|
|
101
|
+
mock.module("@crewhaus/runtime-core", () => realRuntimeCore);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("runEval — defaultInvoker (no caller invoker)", () => {
|
|
105
|
+
test("wires once, drives runChatLoop per sample (no sub-agents)", async () => {
|
|
106
|
+
const outDir = newTempRoot();
|
|
107
|
+
chatLoopCalls.length = 0;
|
|
108
|
+
wireCalls.length = 0;
|
|
109
|
+
includeSubAgents = false;
|
|
110
|
+
|
|
111
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
112
|
+
const samples: Sample[] = [
|
|
113
|
+
{ id: "a", input: "first", expected_output: "answer for first" },
|
|
114
|
+
{ id: "b", input: "second", expected_output: "answer for second" },
|
|
115
|
+
];
|
|
116
|
+
const { compiled } = parseGradersConfig("graders:\n - name: m\n type: exact_match\n");
|
|
117
|
+
|
|
118
|
+
const summary = await runEval({
|
|
119
|
+
ir,
|
|
120
|
+
dataset: { name: "def", samples: yieldSamples(samples) },
|
|
121
|
+
compiledGraders: compiled,
|
|
122
|
+
// No invoker → defaultInvoker path. cwd threads into wireRunOnce.
|
|
123
|
+
opts: { outDir, cwd: "/tmp/some-cwd" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// wireRunOnce called exactly once, with the cwd.
|
|
127
|
+
expect(wireCalls).toHaveLength(1);
|
|
128
|
+
expect(wireCalls[0]).toEqual({ cwd: "/tmp/some-cwd" });
|
|
129
|
+
|
|
130
|
+
// runChatLoop invoked per sample with the wired stack + forced options.
|
|
131
|
+
expect(chatLoopCalls).toHaveLength(2);
|
|
132
|
+
const call = chatLoopCalls.find(
|
|
133
|
+
(c) => (c["seedMessages"] as Array<{ content: string }>)[0]?.content === "first",
|
|
134
|
+
);
|
|
135
|
+
expect(call?.["permissionMode"]).toBe("auto");
|
|
136
|
+
expect(call?.["singleTurn"]).toBe(true);
|
|
137
|
+
expect(call?.["model"]).toBe("claude-wired");
|
|
138
|
+
expect(call?.["instructions"]).toBe("wired instructions");
|
|
139
|
+
expect(call?.["sessionName"]).toBe("wired-session_a");
|
|
140
|
+
expect(call?.["subAgents"]).toBeUndefined();
|
|
141
|
+
expect(call?.["spawnSubAgent"]).toBeUndefined();
|
|
142
|
+
|
|
143
|
+
// The agentOutput flowed back through to grading (exact_match passes).
|
|
144
|
+
expect(summary.aggregates.passRate).toBe(1);
|
|
145
|
+
expect(summary.samples.find((s) => s.sampleId === "a")?.agentOutput).toBe("answer for first");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("threads sub-agents through to runChatLoop when wired", async () => {
|
|
149
|
+
const outDir = newTempRoot();
|
|
150
|
+
chatLoopCalls.length = 0;
|
|
151
|
+
wireCalls.length = 0;
|
|
152
|
+
includeSubAgents = true;
|
|
153
|
+
|
|
154
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
155
|
+
const samples: Sample[] = [{ id: "a", input: "q", expected_output: "answer for q" }];
|
|
156
|
+
const { compiled } = parseGradersConfig("graders:\n - name: m\n type: exact_match\n");
|
|
157
|
+
|
|
158
|
+
await runEval({
|
|
159
|
+
ir,
|
|
160
|
+
dataset: { name: "def-sub", samples: yieldSamples(samples) },
|
|
161
|
+
compiledGraders: compiled,
|
|
162
|
+
// No cwd → wireRunOnce receives `{}` (the cwd-absent spread branch).
|
|
163
|
+
opts: { outDir },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(wireCalls[0]).toEqual({});
|
|
167
|
+
expect(chatLoopCalls).toHaveLength(1);
|
|
168
|
+
const call = chatLoopCalls[0];
|
|
169
|
+
expect(call?.["subAgents"]).toBeInstanceOf(Map);
|
|
170
|
+
expect(typeof call?.["spawnSubAgent"]).toBe("function");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated test for the SIGINT mid-run interrupt path in `runEval`.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own file because it emits a process-level `SIGINT`: keeping it
|
|
5
|
+
* apart from the rest of the suite ensures the emitted signal only interacts
|
|
6
|
+
* with the handler `runEval` itself registers (and removes) for this run.
|
|
7
|
+
*
|
|
8
|
+
* The interrupt check sits *after* `sem.acquire()`: every sample callback's
|
|
9
|
+
* synchronous prefix runs during `samples.map(...)`, before any SIGINT can
|
|
10
|
+
* fire, so a pre-acquire check would never observe a mid-run interrupt. With
|
|
11
|
+
* `concurrency: 1`, sample s0 holds the only slot while the rest queue; firing
|
|
12
|
+
* SIGINT inside s0's invoker means each later sample is skipped as its turn
|
|
13
|
+
* comes — exercising both the interrupt throw and the rejected→SampleResult
|
|
14
|
+
* mapping.
|
|
15
|
+
*/
|
|
16
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
17
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { lower } from "@crewhaus/compiler";
|
|
21
|
+
import type { Sample } from "@crewhaus/eval-dataset";
|
|
22
|
+
import { parseGradersConfig } from "@crewhaus/eval-grader";
|
|
23
|
+
import type { IrNode, IrV0 } from "@crewhaus/ir";
|
|
24
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
25
|
+
import { type AgentInvoker, runEval } from "./index";
|
|
26
|
+
|
|
27
|
+
function narrowToAgent(ir: IrNode): IrV0 {
|
|
28
|
+
if (ir.target !== "cli") throw new Error(`expected target:cli, got ${ir.target}`);
|
|
29
|
+
return ir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SPEC = `name: interrupt-test
|
|
33
|
+
target: cli
|
|
34
|
+
agent:
|
|
35
|
+
model: claude-opus-4-7
|
|
36
|
+
instructions: hi
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
|
|
40
|
+
for (const s of samples) yield s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TMP_ROOTS: string[] = [];
|
|
44
|
+
function newTempRoot(): string {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-interrupt-"));
|
|
46
|
+
TMP_ROOTS.push(dir);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("runEval — SIGINT mid-run interrupt", () => {
|
|
54
|
+
test("interrupts still-queued samples and maps them to failed results", async () => {
|
|
55
|
+
const outDir = newTempRoot();
|
|
56
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
57
|
+
const samples: Sample[] = Array.from({ length: 4 }).map((_, i) => ({
|
|
58
|
+
id: `s${i}`,
|
|
59
|
+
input: "x",
|
|
60
|
+
expected_output: "y",
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
let count = 0;
|
|
64
|
+
const invoker: AgentInvoker = async ({ sample }) => {
|
|
65
|
+
count += 1;
|
|
66
|
+
// Fire SIGINT while the first sample holds the single concurrency slot;
|
|
67
|
+
// the rest are queued and will observe `interrupted` as they dequeue.
|
|
68
|
+
if (count === 1) process.emit("SIGINT" as NodeJS.Signals);
|
|
69
|
+
return { agentOutput: sample.expected_output ?? "", events: [] };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const { compiled } = parseGradersConfig("graders:\n - name: m\n type: exact_match\n");
|
|
73
|
+
const summary = await runEval({
|
|
74
|
+
ir,
|
|
75
|
+
dataset: { name: "interrupt", samples: yieldSamples(samples) },
|
|
76
|
+
compiledGraders: compiled,
|
|
77
|
+
opts: { invoker, outDir, concurrency: 1 },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// s0 completed; s1..s3 were interrupted before running.
|
|
81
|
+
expect(summary.samples).toHaveLength(4);
|
|
82
|
+
expect(summary.samples.find((s) => s.sampleId === "s0")?.grades.overall.passed).toBe(true);
|
|
83
|
+
const interrupted = summary.samples.filter((s) => s.error?.includes("interrupted"));
|
|
84
|
+
expect(interrupted.length).toBe(3);
|
|
85
|
+
expect(summary.aggregates.errorCount).toBe(3);
|
|
86
|
+
|
|
87
|
+
// The mapped failure carries the canonical placeholder fields.
|
|
88
|
+
const s3 = summary.samples.find((s) => s.sampleId === "s3");
|
|
89
|
+
expect(s3?.sessionId).toBe("(unset)");
|
|
90
|
+
expect(s3?.latencyMs).toBe(0);
|
|
91
|
+
expect(s3?.turns).toBe(0);
|
|
92
|
+
expect(s3?.tokens).toEqual({ input: 0, output: 0 });
|
|
93
|
+
expect(s3?.grades.overall.rationale).toBe("sample failed entirely");
|
|
94
|
+
|
|
95
|
+
// results.json was still persisted despite the interrupt.
|
|
96
|
+
const results = JSON.parse(readFileSync(join(outDir, "results.json"), "utf-8"));
|
|
97
|
+
expect(results.aggregates.errorCount).toBe(3);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated test for the `llm_judge` grader-resolution branch in `runEval`.
|
|
3
|
+
*
|
|
4
|
+
* The runner replaces any compiled grader carrying a `judgeSpec` with a real
|
|
5
|
+
* judge grader bound to the run's `judgeModel` (or the per-grader override).
|
|
6
|
+
* We stub `@crewhaus/eval-judge` so no LLM/network is touched: `loadRubric`
|
|
7
|
+
* echoes its input and `createJudgeGrader` returns a deterministic grader that
|
|
8
|
+
* records the model it was bound to. `mock.module` is process-global, so this
|
|
9
|
+
* lives in its own file (Bun gives each test file a fresh module graph).
|
|
10
|
+
*/
|
|
11
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { lower } from "@crewhaus/compiler";
|
|
16
|
+
import type { Sample } from "@crewhaus/eval-dataset";
|
|
17
|
+
import type { CompiledGrader, GradeResult } from "@crewhaus/eval-grader";
|
|
18
|
+
import type { IrNode, IrV0 } from "@crewhaus/ir";
|
|
19
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
20
|
+
|
|
21
|
+
const boundModels: Array<string | undefined> = [];
|
|
22
|
+
const loadedRubrics: unknown[] = [];
|
|
23
|
+
|
|
24
|
+
// Capture the real module so `afterAll` can restore it — `mock.module` is
|
|
25
|
+
// process-global and does not auto-restore across test files. The capture is
|
|
26
|
+
// a plain-object SNAPSHOT (`{ ...ns }`): an ESM namespace is a live view that
|
|
27
|
+
// resolves to the stubs once mock.module patches the module, so restoring
|
|
28
|
+
// from the namespace itself would silently reinstall the stubs.
|
|
29
|
+
const realEvalJudge = { ...(await import("@crewhaus/eval-judge")) };
|
|
30
|
+
|
|
31
|
+
mock.module("@crewhaus/eval-judge", () => ({
|
|
32
|
+
...realEvalJudge,
|
|
33
|
+
loadRubric: (input: unknown) => {
|
|
34
|
+
loadedRubrics.push(input);
|
|
35
|
+
return { criteria: [{ name: "quality", weight: 1, description: "is it good" }] };
|
|
36
|
+
},
|
|
37
|
+
createJudgeGrader: (_rubric: unknown, opts: { model?: string } = {}) => {
|
|
38
|
+
boundModels.push(opts.model);
|
|
39
|
+
// Deterministic grader: always passes, no network.
|
|
40
|
+
return async (): Promise<GradeResult> => ({
|
|
41
|
+
passed: true,
|
|
42
|
+
score: 1,
|
|
43
|
+
rationale: `judged with ${opts.model ?? "(default)"}`,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const { runEval } = await import("./index");
|
|
49
|
+
|
|
50
|
+
function narrowToAgent(ir: IrNode): IrV0 {
|
|
51
|
+
if (ir.target !== "cli") throw new Error(`expected target:cli, got ${ir.target}`);
|
|
52
|
+
return ir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SPEC = `name: judge-test
|
|
56
|
+
target: cli
|
|
57
|
+
agent:
|
|
58
|
+
model: claude-opus-4-7
|
|
59
|
+
instructions: hi
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
|
|
63
|
+
for (const s of samples) yield s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TMP_ROOTS: string[] = [];
|
|
67
|
+
function newTempRoot(): string {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-judge-"));
|
|
69
|
+
TMP_ROOTS.push(dir);
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
afterAll(() => {
|
|
73
|
+
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
mock.module("@crewhaus/eval-judge", () => realEvalJudge);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// A compiled grader with a judgeSpec but no per-grader model → uses opts.judgeModel.
|
|
78
|
+
function judgeGraderNoModel(name: string): CompiledGrader {
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
grader: async () => {
|
|
82
|
+
throw new Error("placeholder must be replaced");
|
|
83
|
+
},
|
|
84
|
+
weight: 1,
|
|
85
|
+
judgeSpec: { rubric: { criteria: [{ name: "q", weight: 1, description: "d" }] } } as never,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A compiled grader whose judgeSpec carries its own model override.
|
|
90
|
+
function judgeGraderWithModel(name: string, model: string): CompiledGrader {
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
grader: async () => {
|
|
94
|
+
throw new Error("placeholder must be replaced");
|
|
95
|
+
},
|
|
96
|
+
weight: 1,
|
|
97
|
+
judgeSpec: {
|
|
98
|
+
rubric: { criteria: [{ name: "q", weight: 1, description: "d" }] },
|
|
99
|
+
model,
|
|
100
|
+
} as never,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("runEval — llm_judge resolution", () => {
|
|
105
|
+
test("binds judge grader to opts.judgeModel when no per-grader override", async () => {
|
|
106
|
+
const outDir = newTempRoot();
|
|
107
|
+
boundModels.length = 0;
|
|
108
|
+
loadedRubrics.length = 0;
|
|
109
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
110
|
+
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
111
|
+
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
112
|
+
|
|
113
|
+
const summary = await runEval({
|
|
114
|
+
ir,
|
|
115
|
+
dataset: { name: "judged", samples: yieldSamples(samples) },
|
|
116
|
+
compiledGraders: [judgeGraderNoModel("rubricA")],
|
|
117
|
+
opts: { invoker, outDir, judgeModel: "claude-judge-x" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(loadedRubrics).toHaveLength(1);
|
|
121
|
+
expect(boundModels).toEqual(["claude-judge-x"]);
|
|
122
|
+
expect(summary.config.graderNames).toEqual(["rubricA"]);
|
|
123
|
+
expect(summary.config.judgeModel).toBe("claude-judge-x");
|
|
124
|
+
expect(summary.aggregates.passRate).toBe(1);
|
|
125
|
+
|
|
126
|
+
// judgeModel surfaced in the persisted run.json snapshot.
|
|
127
|
+
const runJson = JSON.parse(readFileSync(join(outDir, "run.json"), "utf-8"));
|
|
128
|
+
expect(runJson.judgeModel).toBe("claude-judge-x");
|
|
129
|
+
expect(runJson.graderNames).toEqual(["rubricA"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("per-grader judgeSpec.model overrides the run judgeModel", async () => {
|
|
133
|
+
const outDir = newTempRoot();
|
|
134
|
+
boundModels.length = 0;
|
|
135
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
136
|
+
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
137
|
+
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
138
|
+
|
|
139
|
+
await runEval({
|
|
140
|
+
ir,
|
|
141
|
+
dataset: { name: "judged2", samples: yieldSamples(samples) },
|
|
142
|
+
compiledGraders: [judgeGraderWithModel("rubricB", "claude-override")],
|
|
143
|
+
// No opts.judgeModel → the per-grader override must win.
|
|
144
|
+
opts: { invoker, outDir },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(boundModels).toEqual(["claude-override"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a judge grader with neither override nor judgeModel binds the default", async () => {
|
|
151
|
+
const outDir = newTempRoot();
|
|
152
|
+
boundModels.length = 0;
|
|
153
|
+
const ir = narrowToAgent(lower(parseSpec(SPEC)));
|
|
154
|
+
const samples: Sample[] = [{ id: "q1", input: "hi", expected_output: "y" }];
|
|
155
|
+
const invoker = async () => ({ agentOutput: "anything", events: [] });
|
|
156
|
+
|
|
157
|
+
await runEval({
|
|
158
|
+
ir,
|
|
159
|
+
dataset: { name: "judged3", samples: yieldSamples(samples) },
|
|
160
|
+
compiledGraders: [judgeGraderNoModel("rubricC")],
|
|
161
|
+
opts: { invoker, outDir },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// model is undefined → createJudgeGrader called with `{}` (no model key).
|
|
165
|
+
expect(boundModels).toEqual([undefined]);
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { CompiledGrader, Grader } from "@crewhaus/eval-grader";
|
|
|
28
28
|
import { createJudgeGrader, loadRubric } from "@crewhaus/eval-judge";
|
|
29
29
|
import type { IrV0 } from "@crewhaus/ir";
|
|
30
30
|
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
31
|
+
import { currentTenantContext } from "@crewhaus/tenancy";
|
|
31
32
|
import { aggregate } from "./aggregate";
|
|
32
33
|
import { RunnerError } from "./errors";
|
|
33
34
|
import { runSample } from "./run-sample";
|
|
@@ -59,6 +60,20 @@ export type RunEvalArgs = {
|
|
|
59
60
|
readonly opts?: RunEvalOptions;
|
|
60
61
|
};
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the eval output directory. When an eval runs inside a tenant scope
|
|
65
|
+
* (e.g. a cloud/managed eval), the tenant's rebased `evalRoot` is used so one
|
|
66
|
+
* tenant's eval artifacts never share a directory with another's; the global
|
|
67
|
+
* default is only used outside any tenant scope (#150). An explicit
|
|
68
|
+
* `optsOutDir` always wins for trusted callers.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveEvalOutDir(runId: string, optsOutDir?: string): string {
|
|
71
|
+
if (optsOutDir !== undefined) return optsOutDir;
|
|
72
|
+
const tenant = currentTenantContext()?.tenant;
|
|
73
|
+
if (tenant !== undefined) return join(tenant.evalRoot, runId);
|
|
74
|
+
return join(".crewhaus", "evals", runId);
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
/**
|
|
63
78
|
* Execute an evaluation. Returns a summary; per-sample artifacts and
|
|
64
79
|
* the summary itself are also persisted under `outDir`.
|
|
@@ -68,7 +83,7 @@ export async function runEval(args: RunEvalArgs): Promise<EvalRunSummary> {
|
|
|
68
83
|
const opts = args.opts ?? {};
|
|
69
84
|
|
|
70
85
|
const runId = opts.runId ?? generateRunId();
|
|
71
|
-
const outDir = opts.outDir
|
|
86
|
+
const outDir = resolveEvalOutDir(runId, opts.outDir);
|
|
72
87
|
mkdirSync(outDir, { recursive: true });
|
|
73
88
|
|
|
74
89
|
// Resolve graders. Replace any `llm_judge` placeholder with a real judge
|
|
@@ -129,10 +144,15 @@ export async function runEval(args: RunEvalArgs): Promise<EvalRunSummary> {
|
|
|
129
144
|
|
|
130
145
|
const settled = await Promise.allSettled(
|
|
131
146
|
samples.map(async (sample) => {
|
|
147
|
+
const release = await sem.acquire();
|
|
148
|
+
// Check *after* acquiring the slot: every callback's synchronous prefix
|
|
149
|
+
// runs during `.map()` (before any SIGINT can fire), so a pre-acquire
|
|
150
|
+
// check would never observe a mid-run interrupt. Samples still queued on
|
|
151
|
+
// the semaphore when SIGINT arrives are skipped here as their turn comes.
|
|
132
152
|
if (interrupted) {
|
|
153
|
+
release();
|
|
133
154
|
throw new RunnerError(`run interrupted before sample "${sample.id}"`);
|
|
134
155
|
}
|
|
135
|
-
const release = await sem.acquire();
|
|
136
156
|
try {
|
|
137
157
|
return await runSample({
|
|
138
158
|
sample,
|