@crewhaus/eval-runner 0.1.0

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 ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@crewhaus/eval-runner",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Eval runner — per-sample isolation, concurrency, grading, persistence",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/agent-context-isolation": "0.0.0",
16
+ "@crewhaus/compiler": "0.0.0",
17
+ "@crewhaus/errors": "0.0.0",
18
+ "@crewhaus/eval-dataset": "0.0.0",
19
+ "@crewhaus/eval-grader": "0.0.0",
20
+ "@crewhaus/eval-judge": "0.0.0",
21
+ "@crewhaus/event-log": "0.0.0",
22
+ "@crewhaus/hooks-engine": "0.0.0",
23
+ "@crewhaus/ir": "0.0.0",
24
+ "@crewhaus/logging": "0.0.0",
25
+ "@crewhaus/mcp-host": "0.0.0",
26
+ "@crewhaus/permission-engine": "0.0.0",
27
+ "@crewhaus/run-context": "0.0.0",
28
+ "@crewhaus/runtime-core": "0.0.0",
29
+ "@crewhaus/skills-registry": "0.0.0",
30
+ "@crewhaus/slash-commands": "0.0.0",
31
+ "@crewhaus/spec": "0.0.0",
32
+ "@crewhaus/sub-agent-spawner": "0.0.0",
33
+ "@crewhaus/tool-bash": "0.0.0",
34
+ "@crewhaus/tool-catalog": "0.0.0",
35
+ "@crewhaus/tool-fetch": "0.0.0",
36
+ "@crewhaus/tool-fs": "0.0.0",
37
+ "@crewhaus/tool-image": "0.0.0",
38
+ "@crewhaus/tool-mcp": "0.0.0",
39
+ "@crewhaus/tool-task": "0.0.0",
40
+ "@crewhaus/tool-todo": "0.0.0",
41
+ "@crewhaus/tool-web": "0.0.0",
42
+ "@crewhaus/trace-event-bus": "0.0.0",
43
+ "zod": "^3.23.8"
44
+ },
45
+ "license": "Apache-2.0",
46
+ "author": {
47
+ "name": "Max Meier",
48
+ "email": "max@studiomax.io",
49
+ "url": "https://studiomax.io"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/crewhaus/factory.git",
54
+ "directory": "packages/eval-runner"
55
+ },
56
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/eval-runner#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/crewhaus/factory/issues"
59
+ },
60
+ "publishConfig": {
61
+ "access": "restricted"
62
+ },
63
+ "files": [
64
+ "src",
65
+ "README.md",
66
+ "LICENSE",
67
+ "NOTICE"
68
+ ]
69
+ }
@@ -0,0 +1,48 @@
1
+ import type { SampleResult } from "./types";
2
+
3
+ export function quantile(sorted: ReadonlyArray<number>, q: number): number {
4
+ if (sorted.length === 0) return 0;
5
+ if (sorted.length === 1) return sorted[0] ?? 0;
6
+ const idx = q * (sorted.length - 1);
7
+ const lo = Math.floor(idx);
8
+ const hi = Math.ceil(idx);
9
+ if (lo === hi) return sorted[lo] ?? 0;
10
+ const fract = idx - lo;
11
+ return (sorted[lo] ?? 0) * (1 - fract) + (sorted[hi] ?? 0) * fract;
12
+ }
13
+
14
+ export function aggregate(samples: ReadonlyArray<SampleResult>): {
15
+ passRate: number;
16
+ meanScore: number;
17
+ p50Turns: number;
18
+ p95Turns: number;
19
+ p50LatencyMs: number;
20
+ p95LatencyMs: number;
21
+ totalTokens: { input: number; output: number };
22
+ errorCount: number;
23
+ } {
24
+ const ok = samples.filter((s) => s.error === undefined);
25
+ const total = samples.length;
26
+ const passed = ok.filter((s) => s.grades.overall.passed).length;
27
+ const meanScore =
28
+ ok.length === 0 ? 0 : ok.reduce((sum, s) => sum + s.grades.overall.score, 0) / ok.length;
29
+ const turnsSorted = ok.map((s) => s.turns).sort((a, b) => a - b);
30
+ const latSorted = ok.map((s) => s.latencyMs).sort((a, b) => a - b);
31
+ const totalTokens = ok.reduce(
32
+ (acc, s) => ({
33
+ input: acc.input + s.tokens.input,
34
+ output: acc.output + s.tokens.output,
35
+ }),
36
+ { input: 0, output: 0 },
37
+ );
38
+ return {
39
+ passRate: total === 0 ? 0 : passed / total,
40
+ meanScore,
41
+ p50Turns: quantile(turnsSorted, 0.5),
42
+ p95Turns: quantile(turnsSorted, 0.95),
43
+ p50LatencyMs: quantile(latSorted, 0.5),
44
+ p95LatencyMs: quantile(latSorted, 0.95),
45
+ totalTokens,
46
+ errorCount: samples.length - ok.length,
47
+ };
48
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { RuntimeError } from "@crewhaus/errors";
2
+
3
+ export class RunnerError extends RuntimeError {
4
+ constructor(message: string, cause?: unknown) {
5
+ super(`eval-runner: ${message}`, cause);
6
+ }
7
+ }
@@ -0,0 +1,254 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { lower } from "@crewhaus/compiler";
6
+ import type { Sample } from "@crewhaus/eval-dataset";
7
+ import { parseGradersConfig } from "@crewhaus/eval-grader";
8
+ import type { IrNode, IrV0 } from "@crewhaus/ir";
9
+ import { parseSpec } from "@crewhaus/spec";
10
+
11
+ function narrowToAgent(ir: IrNode): IrV0 {
12
+ if (ir.target !== "cli") throw new Error(`test fixture must be target:cli, got ${ir.target}`);
13
+ return ir;
14
+ }
15
+ import { aggregate, quantile } from "./aggregate";
16
+ import { type AgentInvoker, runEval } from "./index";
17
+ import { Semaphore } from "./semaphore";
18
+
19
+ const TMP_ROOTS: string[] = [];
20
+ function newTempRoot(): string {
21
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-eval-runner-"));
22
+ TMP_ROOTS.push(dir);
23
+ return dir;
24
+ }
25
+ afterAll(() => {
26
+ for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ const HELLO_SPEC = `name: hello-test
30
+ target: cli
31
+ agent:
32
+ model: claude-opus-4-7
33
+ instructions: |
34
+ You are a helpful, concise assistant.
35
+ `;
36
+
37
+ async function* yieldSamples(samples: Sample[]): AsyncIterable<Sample> {
38
+ for (const s of samples) yield s;
39
+ }
40
+
41
+ const FIXED_GRADERS = `
42
+ graders:
43
+ - name: math
44
+ type: exact_match
45
+ `;
46
+
47
+ describe("Semaphore", () => {
48
+ test("respects capacity", async () => {
49
+ const sem = new Semaphore(2);
50
+ let active = 0;
51
+ let maxActive = 0;
52
+ const tasks = Array.from({ length: 10 }).map(async () => {
53
+ const release = await sem.acquire();
54
+ active += 1;
55
+ maxActive = Math.max(maxActive, active);
56
+ await new Promise((r) => setTimeout(r, 5));
57
+ active -= 1;
58
+ release();
59
+ });
60
+ await Promise.all(tasks);
61
+ expect(maxActive).toBeLessThanOrEqual(2);
62
+ });
63
+
64
+ test("rejects capacity < 1", () => {
65
+ expect(() => new Semaphore(0)).toThrow();
66
+ });
67
+ });
68
+
69
+ describe("aggregate", () => {
70
+ test("quantile handles edge cases", () => {
71
+ expect(quantile([], 0.5)).toBe(0);
72
+ expect(quantile([42], 0.5)).toBe(42);
73
+ expect(quantile([1, 2, 3, 4, 5], 0.5)).toBe(3);
74
+ expect(quantile([1, 2, 3, 4, 5], 0.95)).toBeCloseTo(4.8);
75
+ });
76
+
77
+ test("computes pass rate, mean score, percentiles", () => {
78
+ const samples = [
79
+ mockSample("s1", true, 1, 100, 1),
80
+ mockSample("s2", true, 0.5, 200, 2),
81
+ mockSample("s3", false, 0, 300, 3),
82
+ ];
83
+ const agg = aggregate(samples);
84
+ expect(agg.passRate).toBeCloseTo(2 / 3);
85
+ expect(agg.meanScore).toBeCloseTo(0.5);
86
+ expect(agg.p50LatencyMs).toBe(200);
87
+ expect(agg.errorCount).toBe(0);
88
+ });
89
+ });
90
+
91
+ describe("runEval — T3 5-sample fixture", () => {
92
+ test("runs 5 samples, persists artifacts, computes aggregates", async () => {
93
+ const outDir = newTempRoot();
94
+ const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
95
+ const samples: Sample[] = [
96
+ { id: "q1", input: "What is 2+2?", expected_output: "4" },
97
+ { id: "q2", input: "What is 5-3?", expected_output: "2" },
98
+ { id: "q3", input: "What is 6/2?", expected_output: "3" },
99
+ { id: "q4", input: "What is 3*3?", expected_output: "9" },
100
+ { id: "q5", input: "What is 10-7?", expected_output: "3" },
101
+ ];
102
+
103
+ // Stub invoker: returns the expected_output verbatim. exact_match grader passes.
104
+ const invoker: AgentInvoker = async ({ sample }) => ({
105
+ agentOutput: sample.expected_output ?? "",
106
+ transcript: [],
107
+ events: [],
108
+ });
109
+
110
+ const { compiled } = parseGradersConfig(FIXED_GRADERS);
111
+ const summary = await runEval({
112
+ ir,
113
+ dataset: { name: "fixture5", samples: yieldSamples(samples) },
114
+ compiledGraders: compiled,
115
+ opts: { invoker, outDir, concurrency: 2 },
116
+ });
117
+
118
+ expect(summary.samples).toHaveLength(5);
119
+ expect(summary.aggregates.passRate).toBe(1);
120
+ expect(summary.aggregates.meanScore).toBe(1);
121
+
122
+ // Per-sample artifacts
123
+ for (const id of ["q1", "q2", "q3", "q4", "q5"]) {
124
+ expect(existsSync(join(outDir, id, "transcript.jsonl"))).toBe(true);
125
+ expect(existsSync(join(outDir, id, "events.jsonl"))).toBe(true);
126
+ expect(existsSync(join(outDir, id, "grades.json"))).toBe(true);
127
+ expect(existsSync(join(outDir, id, "meta.json"))).toBe(true);
128
+ }
129
+ // Run-level artifacts
130
+ expect(existsSync(join(outDir, "run.json"))).toBe(true);
131
+ expect(existsSync(join(outDir, "results.json"))).toBe(true);
132
+ const results = JSON.parse(readFileSync(join(outDir, "results.json"), "utf-8"));
133
+ expect(results.samples).toHaveLength(5);
134
+ expect(results.aggregates.passRate).toBe(1);
135
+ });
136
+
137
+ test("captures grader failures correctly", async () => {
138
+ const outDir = newTempRoot();
139
+ const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
140
+ const samples: Sample[] = [
141
+ { id: "ok", input: "x", expected_output: "y" },
142
+ { id: "wrong", input: "x", expected_output: "y" },
143
+ ];
144
+ const invoker: AgentInvoker = async ({ sample }) => ({
145
+ agentOutput: sample.id === "ok" ? "y" : "z",
146
+ transcript: [],
147
+ events: [],
148
+ });
149
+ const { compiled } = parseGradersConfig(FIXED_GRADERS);
150
+ const summary = await runEval({
151
+ ir,
152
+ dataset: { name: "mixed", samples: yieldSamples(samples) },
153
+ compiledGraders: compiled,
154
+ opts: { invoker, outDir },
155
+ });
156
+ expect(summary.aggregates.passRate).toBe(0.5);
157
+ expect(summary.samples.find((s) => s.sampleId === "wrong")?.grades.overall.passed).toBe(false);
158
+ });
159
+
160
+ test("invoker errors do not kill the rest of the run", async () => {
161
+ const outDir = newTempRoot();
162
+ const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
163
+ const samples: Sample[] = [
164
+ { id: "ok", input: "x", expected_output: "y" },
165
+ { id: "boom", input: "x", expected_output: "y" },
166
+ ];
167
+ const invoker: AgentInvoker = async ({ sample }) => {
168
+ if (sample.id === "boom") throw new Error("invoker exploded");
169
+ return { agentOutput: sample.expected_output ?? "", transcript: [], events: [] };
170
+ };
171
+ const { compiled } = parseGradersConfig(FIXED_GRADERS);
172
+ const summary = await runEval({
173
+ ir,
174
+ dataset: { name: "withErr", samples: yieldSamples(samples) },
175
+ compiledGraders: compiled,
176
+ opts: { invoker, outDir },
177
+ });
178
+ expect(summary.samples).toHaveLength(2);
179
+ expect(summary.aggregates.errorCount).toBe(1);
180
+ const boom = summary.samples.find((s) => s.sampleId === "boom");
181
+ expect(boom?.error).toBe("invoker exploded");
182
+ expect(boom?.grades.overall.passed).toBe(false);
183
+ // The good sample still completed and was scored.
184
+ expect(summary.samples.find((s) => s.sampleId === "ok")?.grades.overall.passed).toBe(true);
185
+ });
186
+
187
+ test("rejects empty dataset", async () => {
188
+ const outDir = newTempRoot();
189
+ const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
190
+ const invoker: AgentInvoker = async () => ({
191
+ agentOutput: "",
192
+ transcript: [],
193
+ events: [],
194
+ });
195
+ const { compiled } = parseGradersConfig(FIXED_GRADERS);
196
+ await expect(
197
+ runEval({
198
+ ir,
199
+ dataset: { name: "empty", samples: yieldSamples([]) },
200
+ compiledGraders: compiled,
201
+ opts: { invoker, outDir },
202
+ }),
203
+ ).rejects.toThrow(/yielded zero samples/);
204
+ });
205
+ });
206
+
207
+ describe("runEval — T7 200-sample concurrency-8 SLO", () => {
208
+ test("completes 200 samples at concurrency 8 well under 60s", async () => {
209
+ const outDir = newTempRoot();
210
+ const ir = narrowToAgent(lower(parseSpec(HELLO_SPEC)));
211
+ const samples: Sample[] = Array.from({ length: 200 }).map((_, i) => ({
212
+ id: `s${i.toString().padStart(3, "0")}`,
213
+ input: `q${i}`,
214
+ expected_output: `a${i}`,
215
+ }));
216
+ const invoker: AgentInvoker = async ({ sample }) => {
217
+ // Tiny delay to mimic real-world async work without burning CPU.
218
+ await new Promise((r) => setTimeout(r, 1));
219
+ return { agentOutput: sample.expected_output ?? "", transcript: [], events: [] };
220
+ };
221
+ const { compiled } = parseGradersConfig(FIXED_GRADERS);
222
+
223
+ const t0 = performance.now();
224
+ const summary = await runEval({
225
+ ir,
226
+ dataset: { name: "load200", samples: yieldSamples(samples) },
227
+ compiledGraders: compiled,
228
+ opts: { invoker, outDir, concurrency: 8 },
229
+ });
230
+ const elapsedMs = performance.now() - t0;
231
+
232
+ expect(summary.samples).toHaveLength(200);
233
+ expect(summary.aggregates.passRate).toBe(1);
234
+ expect(elapsedMs).toBeLessThan(60_000);
235
+ }, 90_000);
236
+ });
237
+
238
+ function mockSample(id: string, passed: boolean, score: number, latencyMs: number, turns: number) {
239
+ return {
240
+ sampleId: id,
241
+ sessionId: `sess_${id.padEnd(16, "0")}`,
242
+ startedAt: "2026-01-01T00:00:00Z",
243
+ endedAt: "2026-01-01T00:00:01Z",
244
+ latencyMs,
245
+ turns,
246
+ tokens: { input: 10, output: 20 },
247
+ model: "claude-opus-4-7",
248
+ agentOutput: "x",
249
+ grades: {
250
+ overall: { passed, score, rationale: "" },
251
+ perGrader: [],
252
+ },
253
+ };
254
+ }
package/src/index.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Catalog R-eval `eval-runner` — run an agent against a dataset.
3
+ *
4
+ * Per-sample isolation: fresh `RunContext` (and therefore fresh
5
+ * `TraceEventBus` + `sessionId`) so concurrent samples don't see each
6
+ * other's events. Concurrency is enforced by a tiny in-package semaphore
7
+ * (default 4). Per-sample artifacts persist to
8
+ * `.crewhaus/evals/<runId>/<sampleId>/{transcript.jsonl, events.jsonl,
9
+ * grades.json, meta.json}`. Run-level: `run.json` (config snapshot)
10
+ * + `results.json` (aggregates).
11
+ *
12
+ * `--seed` is honored only for providers that surface temperature
13
+ * reproducibility (Anthropic does not). Document divergence; do not
14
+ * promise byte-identical reruns.
15
+ *
16
+ * `permissionMode` is forced to `"auto"` so `alwaysAsk` rules auto-deny
17
+ * rather than blocking on stdin in a non-interactive eval run.
18
+ *
19
+ * MCP servers are shared across samples (read-mostly assumption). An
20
+ * `isolateMcpPerSample` escape hatch is reserved for future use.
21
+ *
22
+ * Reference: build-roadmap.md §16.
23
+ */
24
+ import { mkdirSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import type { Sample } from "@crewhaus/eval-dataset";
27
+ import type { CompiledGrader, Grader } from "@crewhaus/eval-grader";
28
+ import { createJudgeGrader, loadRubric } from "@crewhaus/eval-judge";
29
+ import type { IrV0 } from "@crewhaus/ir";
30
+ import { runChatLoop } from "@crewhaus/runtime-core";
31
+ import { aggregate } from "./aggregate";
32
+ import { RunnerError } from "./errors";
33
+ import { runSample } from "./run-sample";
34
+ import { Semaphore } from "./semaphore";
35
+ import type {
36
+ AgentInvoker,
37
+ EvalRunSummary,
38
+ GraderEntry,
39
+ RunEvalOptions,
40
+ SampleResult,
41
+ } from "./types";
42
+ import { type SharedAgentDeps, wireRunOnce } from "./wire-once";
43
+
44
+ export type { AgentInvoker, EvalRunSummary, GraderEntry, RunEvalOptions, SampleResult };
45
+ export type { SharedAgentDeps };
46
+ export { wireRunOnce };
47
+ export { Semaphore };
48
+ export { aggregate };
49
+ export { RunnerError };
50
+ export { runSample };
51
+
52
+ const DEFAULT_CONCURRENCY = 4;
53
+
54
+ export type RunEvalArgs = {
55
+ /** Lowered agent IR (target: cli). Caller is responsible for narrowing. */
56
+ readonly ir: IrV0;
57
+ readonly dataset: { name: string; samples: AsyncIterable<Sample> };
58
+ readonly compiledGraders: ReadonlyArray<CompiledGrader>;
59
+ readonly opts?: RunEvalOptions;
60
+ };
61
+
62
+ /**
63
+ * Execute an evaluation. Returns a summary; per-sample artifacts and
64
+ * the summary itself are also persisted under `outDir`.
65
+ */
66
+ export async function runEval(args: RunEvalArgs): Promise<EvalRunSummary> {
67
+ const { ir, dataset, compiledGraders } = args;
68
+ const opts = args.opts ?? {};
69
+
70
+ const runId = opts.runId ?? generateRunId();
71
+ const outDir = opts.outDir ?? join(".crewhaus", "evals", runId);
72
+ mkdirSync(outDir, { recursive: true });
73
+
74
+ // Resolve graders. Replace any `llm_judge` placeholder with a real judge
75
+ // grader bound to the runner's judgeModel (or the per-grader override).
76
+ const graders: GraderEntry[] = compiledGraders.map((g) => {
77
+ if (g.judgeSpec) {
78
+ const rubric = loadRubric(g.judgeSpec.rubric);
79
+ const model = g.judgeSpec.model ?? opts.judgeModel;
80
+ const grader = createJudgeGrader(rubric, model !== undefined ? { model } : {});
81
+ return { name: g.name, grader };
82
+ }
83
+ return { name: g.name, grader: g.grader };
84
+ });
85
+
86
+ // The default invoker calls runChatLoop with the per-sample fresh runContext.
87
+ const invoker = opts.invoker ?? (await defaultInvoker(ir, opts));
88
+
89
+ const startedAt = new Date().toISOString();
90
+ const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
91
+ const sem = new Semaphore(concurrency);
92
+
93
+ // Persist run-level config snapshot up front so SIGINT mid-run still leaves
94
+ // a usable directory.
95
+ const specHash = await hashSpec(ir);
96
+ await Bun.write(
97
+ join(outDir, "run.json"),
98
+ JSON.stringify(
99
+ {
100
+ runId,
101
+ startedAt,
102
+ specHash,
103
+ datasetName: dataset.name,
104
+ graderNames: graders.map((g) => g.name),
105
+ model: ir.agent.model,
106
+ ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
107
+ concurrency,
108
+ ...(opts.seed !== undefined ? { seed: opts.seed } : {}),
109
+ },
110
+ null,
111
+ 2,
112
+ ),
113
+ );
114
+
115
+ // Materialize the dataset into a list so we can run samples concurrently.
116
+ // For very large datasets the streaming loaders still avoid loading the
117
+ // whole file into memory — but eventually all sample objects sit in RAM.
118
+ const samples: Sample[] = [];
119
+ for await (const s of dataset.samples) samples.push(s);
120
+ if (samples.length === 0) {
121
+ throw new RunnerError(`dataset "${dataset.name}" yielded zero samples`);
122
+ }
123
+
124
+ let interrupted = false;
125
+ const abortHandler = () => {
126
+ interrupted = true;
127
+ };
128
+ process.once("SIGINT", abortHandler);
129
+
130
+ const settled = await Promise.allSettled(
131
+ samples.map(async (sample) => {
132
+ if (interrupted) {
133
+ throw new RunnerError(`run interrupted before sample "${sample.id}"`);
134
+ }
135
+ const release = await sem.acquire();
136
+ try {
137
+ return await runSample({
138
+ sample,
139
+ invoker,
140
+ graders,
141
+ outDir,
142
+ model: ir.agent.model,
143
+ ...(opts.seed !== undefined ? { seed: opts.seed } : {}),
144
+ });
145
+ } finally {
146
+ release();
147
+ }
148
+ }),
149
+ );
150
+
151
+ process.removeListener("SIGINT", abortHandler);
152
+
153
+ const results: SampleResult[] = settled.map((r, i) => {
154
+ if (r.status === "fulfilled") return r.value;
155
+ const sample = samples[i];
156
+ return {
157
+ sampleId: sample?.id ?? `unknown-${i}`,
158
+ sessionId: "(unset)",
159
+ startedAt,
160
+ endedAt: new Date().toISOString(),
161
+ latencyMs: 0,
162
+ turns: 0,
163
+ tokens: { input: 0, output: 0 },
164
+ model: ir.agent.model,
165
+ agentOutput: "",
166
+ grades: {
167
+ overall: { passed: false, score: 0, rationale: "sample failed entirely" },
168
+ perGrader: [],
169
+ },
170
+ error: r.reason instanceof Error ? r.reason.message : String(r.reason),
171
+ };
172
+ });
173
+
174
+ const endedAt = new Date().toISOString();
175
+ const aggregates = aggregate(results);
176
+
177
+ const summary: EvalRunSummary = {
178
+ runId,
179
+ startedAt,
180
+ endedAt,
181
+ samples: results,
182
+ aggregates,
183
+ config: {
184
+ specHash,
185
+ datasetName: dataset.name,
186
+ graderNames: graders.map((g) => g.name),
187
+ model: ir.agent.model,
188
+ ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
189
+ concurrency,
190
+ ...(opts.seed !== undefined ? { seed: opts.seed } : {}),
191
+ },
192
+ outDir,
193
+ };
194
+
195
+ await Bun.write(join(outDir, "results.json"), JSON.stringify(summary, null, 2));
196
+
197
+ return summary;
198
+ }
199
+
200
+ async function defaultInvoker(ir: IrV0, opts: RunEvalOptions): Promise<AgentInvoker> {
201
+ const wired: SharedAgentDeps = await wireRunOnce(
202
+ ir,
203
+ opts.cwd !== undefined ? { cwd: opts.cwd } : {},
204
+ );
205
+ return async (req) => {
206
+ const agentOutput = await runChatLoop({
207
+ model: wired.model,
208
+ instructions: wired.instructions,
209
+ tools: [...wired.tools],
210
+ hooks: wired.hooks,
211
+ skills: wired.skills,
212
+ slashCommands: wired.slashCommands,
213
+ ...(wired.subAgents !== undefined && wired.spawnSubAgent !== undefined
214
+ ? { subAgents: wired.subAgents, spawnSubAgent: wired.spawnSubAgent }
215
+ : {}),
216
+ permissionRules: wired.permissionRules,
217
+ permissionMode: "auto",
218
+ sessionName: `${wired.sessionName}_${req.sample.id}`,
219
+ sessionTarget: wired.sessionTarget,
220
+ runContext: req.runContext,
221
+ sessionRootDir: req.sessionRootDir,
222
+ singleTurn: true,
223
+ seedMessages: [{ role: "user", content: req.sample.input }],
224
+ });
225
+ return { agentOutput };
226
+ };
227
+ }
228
+
229
+ function generateRunId(): string {
230
+ const bytes = new Uint8Array(8);
231
+ crypto.getRandomValues(bytes);
232
+ return `run_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
233
+ }
234
+
235
+ async function hashSpec(ir: IrV0): Promise<string> {
236
+ const text = JSON.stringify({
237
+ name: ir.name,
238
+ target: ir.target,
239
+ model: ir.agent.model,
240
+ instructions: ir.agent.instructions,
241
+ tools: ir.tools,
242
+ });
243
+ return Bun.hash(text).toString(16);
244
+ }
@@ -0,0 +1,232 @@
1
+ import { mkdirSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Sample } from "@crewhaus/eval-dataset";
4
+ import {
5
+ type CompiledGrader,
6
+ type GradeResult,
7
+ type Grader,
8
+ type RunResult,
9
+ type ToolCall,
10
+ combineCompiledGraders,
11
+ } from "@crewhaus/eval-grader";
12
+ import { type Event as TranscriptEvent, openEventLog } from "@crewhaus/event-log";
13
+ import { createRunContext } from "@crewhaus/run-context";
14
+ import type { ModelResponseEvent, TraceEvent } from "@crewhaus/trace-event-bus";
15
+ import { RunnerError } from "./errors";
16
+ import type { AgentInvoker, GraderEntry, SampleResult } from "./types";
17
+
18
+ /**
19
+ * Per-sample logic. Mints a fresh runContext (and therefore a fresh
20
+ * TraceEventBus + sessionId), subscribes to the bus, calls the invoker,
21
+ * and persists `transcript.jsonl`, `events.jsonl`, `grades.json`,
22
+ * `meta.json` to the per-sample directory.
23
+ */
24
+ export async function runSample(args: {
25
+ sample: Sample;
26
+ invoker: AgentInvoker;
27
+ graders: ReadonlyArray<GraderEntry>;
28
+ outDir: string; // .crewhaus/evals/<runId>
29
+ model: string;
30
+ seed?: number;
31
+ }): Promise<SampleResult> {
32
+ const { sample, invoker, graders, outDir, model } = args;
33
+ const sampleDir = join(outDir, sanitize(sample.id));
34
+ mkdirSync(sampleDir, { recursive: true });
35
+
36
+ const runContext = createRunContext();
37
+ const events: TraceEvent[] = [];
38
+ const unsubscribe = runContext.eventBus.subscribe((e) => {
39
+ events.push(e);
40
+ });
41
+
42
+ const startedAt = new Date().toISOString();
43
+ const t0 = performance.now();
44
+
45
+ let agentOutput = "";
46
+ let transcript: TranscriptEvent[] = [];
47
+ let invokerEvents: ReadonlyArray<TraceEvent> | undefined;
48
+ let error: string | undefined;
49
+ try {
50
+ const result = await invoker({
51
+ sample,
52
+ runContext,
53
+ sessionRootDir: sampleDir,
54
+ ...(args.seed !== undefined ? { seed: args.seed } : {}),
55
+ });
56
+ agentOutput = result.agentOutput;
57
+ transcript = result.transcript ? [...result.transcript] : [];
58
+ invokerEvents = result.events;
59
+ } catch (err) {
60
+ error = err instanceof Error ? err.message : String(err);
61
+ } finally {
62
+ unsubscribe();
63
+ }
64
+
65
+ const endedAt = new Date().toISOString();
66
+ const latencyMs = Math.round(performance.now() - t0);
67
+
68
+ // If the invoker didn't supply a transcript, attempt to read it from disk
69
+ // (this is the path the production runChatLoop wrapper takes).
70
+ if (transcript.length === 0 && error === undefined) {
71
+ transcript = await readTranscript(sampleDir, runContext.sessionId);
72
+ }
73
+
74
+ const finalEvents = invokerEvents ? [...invokerEvents] : events;
75
+
76
+ // Rename auto-named event-log file → transcript.jsonl for stable artifact
77
+ // names. (Skip if the invoker didn't actually write one, e.g. test stubs.)
78
+ const autoPath = join(sampleDir, `${runContext.sessionId}.jsonl`);
79
+ const transcriptPath = join(sampleDir, "transcript.jsonl");
80
+ if (await Bun.file(autoPath).exists()) {
81
+ renameSync(autoPath, transcriptPath);
82
+ } else if (transcript.length > 0) {
83
+ // Stub-supplied transcript — write it ourselves.
84
+ await Bun.write(
85
+ transcriptPath,
86
+ transcript.map((e) => JSON.stringify(e)).join("\n") + (transcript.length > 0 ? "\n" : ""),
87
+ );
88
+ } else {
89
+ await Bun.write(transcriptPath, "");
90
+ }
91
+
92
+ // Persist captured trace events.
93
+ await Bun.write(
94
+ join(sampleDir, "events.jsonl"),
95
+ finalEvents.map((e) => JSON.stringify(e)).join("\n") + (finalEvents.length > 0 ? "\n" : ""),
96
+ );
97
+
98
+ const turns = transcript.filter((e) => e.kind === "assistant_message").length;
99
+ const toolCalls = extractToolCalls(finalEvents);
100
+ const tokens = sumTokens(finalEvents);
101
+
102
+ // Apply graders.
103
+ const runResult: RunResult = {
104
+ agentOutput,
105
+ events: finalEvents,
106
+ transcript,
107
+ toolCalls,
108
+ turns,
109
+ latencyMs,
110
+ };
111
+
112
+ const perGrader: Array<{ name: string } & GradeResult> = [];
113
+ for (const g of graders) {
114
+ let result: GradeResult;
115
+ try {
116
+ result = await g.grader(sample, runResult);
117
+ } catch (err) {
118
+ result = {
119
+ passed: false,
120
+ score: 0,
121
+ rationale: `grader threw: ${err instanceof Error ? err.message : String(err)}`,
122
+ };
123
+ }
124
+ perGrader.push({ name: g.name, ...result });
125
+ }
126
+
127
+ // Overall = AND of all graders, score = mean.
128
+ const overall: GradeResult = {
129
+ passed: error === undefined && perGrader.every((g) => g.passed),
130
+ score:
131
+ perGrader.length === 0 ? 0 : perGrader.reduce((s, g) => s + g.score, 0) / perGrader.length,
132
+ rationale:
133
+ error !== undefined
134
+ ? `agent invocation error: ${error}`
135
+ : perGrader.map((g) => `[${g.name}: ${g.passed ? "✓" : "✗"}] ${g.rationale}`).join(" & "),
136
+ };
137
+
138
+ const sampleResult: SampleResult = {
139
+ sampleId: sample.id,
140
+ sessionId: runContext.sessionId,
141
+ startedAt,
142
+ endedAt,
143
+ latencyMs,
144
+ turns,
145
+ tokens,
146
+ model,
147
+ agentOutput,
148
+ grades: { overall, perGrader },
149
+ ...(error !== undefined ? { error } : {}),
150
+ };
151
+
152
+ await Bun.write(join(sampleDir, "grades.json"), JSON.stringify(sampleResult.grades, null, 2));
153
+ await Bun.write(
154
+ join(sampleDir, "meta.json"),
155
+ JSON.stringify(
156
+ {
157
+ sampleId: sample.id,
158
+ sessionId: runContext.sessionId,
159
+ startedAt,
160
+ endedAt,
161
+ latencyMs,
162
+ turns,
163
+ tokens,
164
+ model,
165
+ ...(error !== undefined ? { error } : {}),
166
+ ...(args.seed !== undefined ? { seed: args.seed } : {}),
167
+ },
168
+ null,
169
+ 2,
170
+ ),
171
+ );
172
+
173
+ return sampleResult;
174
+ }
175
+
176
+ /** Helper: produce one combined grader from an array of CompiledGrader. */
177
+ export function combineGraderEntries(graders: ReadonlyArray<CompiledGrader>): Grader {
178
+ return combineCompiledGraders(graders);
179
+ }
180
+
181
+ /** Strip path separators from sample IDs to keep the on-disk layout flat. */
182
+ function sanitize(id: string): string {
183
+ return id.replace(/[^A-Za-z0-9_.-]/g, "_");
184
+ }
185
+
186
+ async function readTranscript(dir: string, sessionId: string): Promise<TranscriptEvent[]> {
187
+ const out: TranscriptEvent[] = [];
188
+ try {
189
+ const log = await openEventLog(sessionId, { rootDir: dir });
190
+ for await (const e of log.read()) out.push(e);
191
+ await log.close();
192
+ } catch (err) {
193
+ // Tolerate missing log (test stubs may skip persistence).
194
+ if (!(err instanceof Error && /invalid sessionId/.test(err.message))) {
195
+ throw new RunnerError(`failed to read transcript for ${sessionId}`, err);
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ function extractToolCalls(events: ReadonlyArray<TraceEvent>): ToolCall[] {
202
+ const calls: Array<ToolCall & { ts: number }> = [];
203
+ const startByUseId = new Map<string, { toolName: string; ts: number }>();
204
+ for (const ev of events) {
205
+ if (ev.kind === "tool_call_start") {
206
+ startByUseId.set(ev.toolUseId, { toolName: ev.toolName, ts: Date.parse(ev.timestamp) });
207
+ } else if (ev.kind === "tool_call_end") {
208
+ const start = startByUseId.get(ev.toolUseId);
209
+ calls.push({
210
+ toolName: ev.toolName,
211
+ toolUseId: ev.toolUseId,
212
+ isError: ev.isError,
213
+ ts: start?.ts ?? Date.parse(ev.timestamp),
214
+ });
215
+ }
216
+ }
217
+ calls.sort((a, b) => a.ts - b.ts);
218
+ return calls.map(({ ts: _ts, ...rest }) => rest);
219
+ }
220
+
221
+ function sumTokens(events: ReadonlyArray<TraceEvent>): { input: number; output: number } {
222
+ let input = 0;
223
+ let output = 0;
224
+ for (const ev of events) {
225
+ if (ev.kind === "model_response") {
226
+ const e = ev as ModelResponseEvent;
227
+ input += e.usage.input;
228
+ output += e.usage.output;
229
+ }
230
+ }
231
+ return { input, output };
232
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Trivial async semaphore — no `p-limit` dep. Acquire returns a release fn;
3
+ * callers MUST call it (use try/finally). When the semaphore would otherwise
4
+ * be over-capacity, acquirers queue FIFO.
5
+ */
6
+ export class Semaphore {
7
+ private inflight = 0;
8
+ private readonly waitQueue: Array<() => void> = [];
9
+ constructor(private readonly capacity: number) {
10
+ if (capacity < 1) throw new Error(`Semaphore capacity must be ≥ 1 (got ${capacity})`);
11
+ }
12
+ async acquire(): Promise<() => void> {
13
+ if (this.inflight < this.capacity) {
14
+ this.inflight += 1;
15
+ return () => this.release();
16
+ }
17
+ return new Promise<() => void>((resolve) => {
18
+ this.waitQueue.push(() => {
19
+ this.inflight += 1;
20
+ resolve(() => this.release());
21
+ });
22
+ });
23
+ }
24
+ private release(): void {
25
+ this.inflight -= 1;
26
+ const next = this.waitQueue.shift();
27
+ if (next) next();
28
+ }
29
+ get pending(): number {
30
+ return this.waitQueue.length;
31
+ }
32
+ get active(): number {
33
+ return this.inflight;
34
+ }
35
+ }
package/src/types.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Sample } from "@crewhaus/eval-dataset";
2
+ import type { GradeResult, Grader } from "@crewhaus/eval-grader";
3
+ import type { Event as TranscriptEvent } from "@crewhaus/event-log";
4
+ import type { RunContext } from "@crewhaus/run-context";
5
+ import type { TraceEvent } from "@crewhaus/trace-event-bus";
6
+
7
+ /**
8
+ * The agent-invocation contract. The runner provides a per-sample
9
+ * `RunContext` (fresh `TraceEventBus`, fresh `sessionId`) and the
10
+ * destination directory for the event log. The invoker is responsible
11
+ * for actually running the chat loop and returning the assistant text.
12
+ *
13
+ * In production this is a thin wrapper around `runChatLoop`. In tests it's
14
+ * a deterministic stub that returns a canned answer per sample.
15
+ */
16
+ export type AgentInvoker = (req: AgentInvokeRequest) => Promise<AgentInvokeResult>;
17
+
18
+ export type AgentInvokeRequest = {
19
+ readonly sample: Sample;
20
+ readonly runContext: RunContext;
21
+ /** Where the runtime should write the per-sample event-log JSONL. */
22
+ readonly sessionRootDir: string;
23
+ readonly seed?: number;
24
+ };
25
+
26
+ export type AgentInvokeResult = {
27
+ /** Final assistant text returned by the chat loop. */
28
+ readonly agentOutput: string;
29
+ /**
30
+ * If the invoker has out-of-band knowledge (e.g. a stub), it can supply
31
+ * the captured event-log entries here. The default invoker reads them
32
+ * from disk after `runChatLoop` returns.
33
+ */
34
+ readonly transcript?: ReadonlyArray<TranscriptEvent>;
35
+ /** Same as transcript — invoker can short-circuit the bus subscription. */
36
+ readonly events?: ReadonlyArray<TraceEvent>;
37
+ };
38
+
39
+ export type SampleResult = {
40
+ readonly sampleId: string;
41
+ readonly sessionId: string;
42
+ readonly startedAt: string;
43
+ readonly endedAt: string;
44
+ readonly latencyMs: number;
45
+ readonly turns: number;
46
+ readonly tokens: { input: number; output: number };
47
+ readonly model: string;
48
+ readonly agentOutput: string;
49
+ readonly grades: { overall: GradeResult; perGrader: Array<{ name: string } & GradeResult> };
50
+ readonly error?: string;
51
+ };
52
+
53
+ export type EvalRunSummary = {
54
+ readonly runId: string;
55
+ readonly startedAt: string;
56
+ readonly endedAt: string;
57
+ readonly samples: ReadonlyArray<SampleResult>;
58
+ readonly aggregates: {
59
+ readonly passRate: number;
60
+ readonly meanScore: number;
61
+ readonly p50Turns: number;
62
+ readonly p95Turns: number;
63
+ readonly p50LatencyMs: number;
64
+ readonly p95LatencyMs: number;
65
+ readonly totalTokens: { input: number; output: number };
66
+ readonly errorCount: number;
67
+ };
68
+ readonly config: {
69
+ readonly specHash: string;
70
+ readonly datasetName: string;
71
+ readonly graderNames: ReadonlyArray<string>;
72
+ readonly model: string;
73
+ readonly judgeModel?: string;
74
+ readonly concurrency: number;
75
+ readonly seed?: number;
76
+ };
77
+ readonly outDir: string;
78
+ };
79
+
80
+ export type RunEvalOptions = {
81
+ readonly runId?: string;
82
+ readonly concurrency?: number;
83
+ readonly seed?: number;
84
+ readonly outDir?: string;
85
+ readonly judgeModel?: string;
86
+ readonly invoker?: AgentInvoker;
87
+ readonly cwd?: string;
88
+ };
89
+
90
+ export type GraderEntry = {
91
+ readonly name: string;
92
+ readonly grader: Grader;
93
+ };
@@ -0,0 +1,204 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Build the agent stack ONCE per eval run, then share across samples.
5
+ *
6
+ * This factors the runRun logic from apps/cli/src/index.ts so the CLI's
7
+ * `crewhaus run` and the eval runner's `crewhaus eval` use the same
8
+ * tool/hook/skill/MCP/sub-agent wiring. Single source of truth for the
9
+ * "what is the full agent stack from an IR" question.
10
+ *
11
+ * The MCP host (if any) is shared across all eval samples — re-spinning
12
+ * stdio MCP servers per sample for 200 samples would burn ~30s in process
13
+ * startup and exceed the T7 SLO. The trade-off is documented: eval-runner
14
+ * assumes the agent's MCP usage is read-mostly. An `isolateMcpPerSample`
15
+ * escape hatch is reserved for a future where this matters.
16
+ */
17
+ import type { SubAgentDefinition } from "@crewhaus/agent-context-isolation";
18
+ import { type HookDef, loadHooks } from "@crewhaus/hooks-engine";
19
+ import type { IrV0 } from "@crewhaus/ir";
20
+ import { createLogger } from "@crewhaus/logging";
21
+ import { McpHost } from "@crewhaus/mcp-host";
22
+ import {
23
+ BUILTIN_DEFAULT_RULES,
24
+ PermissionConfigError,
25
+ type RuleSet,
26
+ parsePermissionsConfig,
27
+ tagRules,
28
+ } from "@crewhaus/permission-engine";
29
+ import { type SkillRef, createSkillTool, discoverSkills } from "@crewhaus/skills-registry";
30
+ import { type SlashCommand, loadCommands } from "@crewhaus/slash-commands";
31
+ import { spawnSubAgent } from "@crewhaus/sub-agent-spawner";
32
+ import { type RegisteredTool, ToolCatalog } from "@crewhaus/tool-catalog";
33
+ import { registerMcpServer } from "@crewhaus/tool-mcp";
34
+ import { createTaskTool } from "@crewhaus/tool-task";
35
+ import { RunnerError } from "./errors";
36
+
37
+ type SpawnSubAgentFn = typeof spawnSubAgent;
38
+
39
+ export type SharedAgentDeps = {
40
+ readonly tools: ReadonlyArray<RegisteredTool>;
41
+ readonly hooks: ReadonlyArray<HookDef>;
42
+ readonly skills: ReadonlyArray<SkillRef>;
43
+ readonly slashCommands: ReadonlyMap<string, SlashCommand>;
44
+ readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
45
+ readonly spawnSubAgent?: SpawnSubAgentFn;
46
+ readonly permissionRules: RuleSet;
47
+ readonly mcpHost?: McpHost;
48
+ readonly model: string;
49
+ readonly instructions: string;
50
+ readonly sessionName: string;
51
+ readonly sessionTarget: string;
52
+ };
53
+
54
+ const logger = createLogger({ bindings: { module: "eval-runner.wire" } });
55
+
56
+ export async function wireRunOnce(ir: IrV0, opts: { cwd?: string } = {}): Promise<SharedAgentDeps> {
57
+ const cwd = opts.cwd ?? process.cwd();
58
+
59
+ // Tools.
60
+ let tools: RegisteredTool[] = [];
61
+ if (ir.tools.length > 0) {
62
+ await applyToolConfigs(ir.tools, ir.toolConfigs);
63
+ const toolMap = await loadToolMap();
64
+ tools = ir.tools.map((name) => {
65
+ const tool = toolMap[name];
66
+ if (!tool) {
67
+ const known = Object.keys(toolMap).sort().join(", ");
68
+ throw new RunnerError(`unknown tool "${name}" — known tools: ${known}`);
69
+ }
70
+ return tool;
71
+ });
72
+ }
73
+
74
+ // MCP servers (shared across samples).
75
+ let mcpHost: McpHost | undefined;
76
+ if (Object.keys(ir.mcp_servers).length > 0) {
77
+ const host = new McpHost({ logger });
78
+ mcpHost = host;
79
+ for (const [name, cfg] of Object.entries(ir.mcp_servers)) host.addServer(name, cfg);
80
+ const tempCatalog = new ToolCatalog();
81
+ for (const t of tools) tempCatalog.register(t);
82
+ await Promise.all(
83
+ Object.keys(ir.mcp_servers).map((name) => registerMcpServer(host, name, tempCatalog)),
84
+ );
85
+ tools = tempCatalog.list().slice();
86
+ }
87
+
88
+ // Permission rules.
89
+ const permissionRules = buildRuleSet(ir.permissions.rules, cwd);
90
+
91
+ // Hooks / skills / slash-commands.
92
+ const [hooks, skills, slashCommands] = await Promise.all([
93
+ loadHooks({ cwd }),
94
+ discoverSkills({ cwd }),
95
+ loadCommands({ cwd }),
96
+ ]);
97
+ if (skills.length > 0) tools.push(createSkillTool(skills));
98
+
99
+ // Sub-agents.
100
+ let subAgents: ReadonlyMap<string, SubAgentDefinition> | undefined;
101
+ if (ir.subAgents.length > 0) {
102
+ subAgents = new Map(
103
+ ir.subAgents.map((d) => [
104
+ d.name,
105
+ {
106
+ name: d.name,
107
+ description: d.description,
108
+ instructions: d.instructions,
109
+ tools: d.tools,
110
+ ...(d.model !== undefined ? { model: d.model } : {}),
111
+ permissions: d.permissions,
112
+ inherit_bypass: d.inheritBypass,
113
+ } satisfies SubAgentDefinition,
114
+ ]),
115
+ );
116
+ tools.push(createTaskTool({ subAgents }));
117
+ }
118
+
119
+ return {
120
+ tools,
121
+ hooks,
122
+ skills,
123
+ slashCommands,
124
+ permissionRules,
125
+ model: ir.agent.model,
126
+ instructions: ir.agent.instructions,
127
+ sessionName: ir.name,
128
+ sessionTarget: ir.target,
129
+ ...(subAgents !== undefined ? { subAgents, spawnSubAgent } : {}),
130
+ ...(mcpHost !== undefined ? { mcpHost } : {}),
131
+ };
132
+ }
133
+
134
+ async function loadToolMap(): Promise<Record<string, RegisteredTool>> {
135
+ const [fs, bash, todo, web, image, fetchPkg] = await Promise.all([
136
+ import("@crewhaus/tool-fs"),
137
+ import("@crewhaus/tool-bash"),
138
+ import("@crewhaus/tool-todo"),
139
+ import("@crewhaus/tool-web"),
140
+ import("@crewhaus/tool-image"),
141
+ import("@crewhaus/tool-fetch"),
142
+ ]);
143
+ return {
144
+ read: fs.read,
145
+ write: fs.write,
146
+ edit: fs.edit,
147
+ glob: fs.glob,
148
+ grep: fs.grep,
149
+ bash: bash.bash,
150
+ todoWrite: todo.todoWrite,
151
+ webFetch: web.webFetch,
152
+ webSearch: web.webSearch,
153
+ readImage: image.readImage,
154
+ fetch: fetchPkg.fetch,
155
+ };
156
+ }
157
+
158
+ async function applyToolConfigs(
159
+ toolNames: readonly string[],
160
+ toolConfigs: Readonly<Record<string, unknown>>,
161
+ ): Promise<void> {
162
+ const used = new Set(toolNames);
163
+ if (used.has("fetch") && toolConfigs["fetch"] !== undefined) {
164
+ const { registerFetchConfig } = await import("@crewhaus/tool-fetch");
165
+ registerFetchConfig(toolConfigs["fetch"] as Parameters<typeof registerFetchConfig>[0]);
166
+ }
167
+ if (used.has("webFetch") && toolConfigs["webFetch"] !== undefined) {
168
+ const { registerWebFetchConfig } = await import("@crewhaus/tool-web");
169
+ registerWebFetchConfig(toolConfigs["webFetch"] as Parameters<typeof registerWebFetchConfig>[0]);
170
+ }
171
+ }
172
+
173
+ function buildRuleSet(
174
+ yamlRules: ReadonlyArray<{ type: "alwaysAllow" | "alwaysDeny" | "alwaysAsk"; pattern: string }>,
175
+ cwd: string,
176
+ ): RuleSet {
177
+ let settings: RuleSet["settings"] = [];
178
+ const settingsPath = join(cwd, ".crewhaus", "settings.json");
179
+ if (existsSync(settingsPath)) {
180
+ let raw: unknown;
181
+ try {
182
+ raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
183
+ } catch (err) {
184
+ throw new RunnerError(`failed to parse ${settingsPath}: ${(err as Error).message}`, err);
185
+ }
186
+ const root = (raw as { permissions?: unknown }).permissions;
187
+ if (root !== undefined) {
188
+ try {
189
+ const parsed = parsePermissionsConfig(root, "settings");
190
+ settings = tagRules(parsed.rules, "settings");
191
+ } catch (err) {
192
+ if (err instanceof PermissionConfigError) throw new RunnerError(err.message, err);
193
+ throw err;
194
+ }
195
+ }
196
+ }
197
+ return {
198
+ flag: [],
199
+ settings,
200
+ yaml: tagRules(yamlRules, "yaml"),
201
+ hooks: [],
202
+ builtin: BUILTIN_DEFAULT_RULES,
203
+ };
204
+ }