@crewhaus/eval-runner 0.1.3 → 0.1.5
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/dist/aggregate.d.ts +15 -0
- package/dist/aggregate.js +35 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +216 -0
- package/dist/run-sample.d.ts +19 -0
- package/dist/run-sample.js +187 -0
- package/dist/semaphore.d.ts +15 -0
- package/dist/semaphore.js +39 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/dist/wire-once.d.ts +42 -0
- package/dist/wire-once.js +150 -0
- package/package.json +37 -34
- package/src/aggregate.ts +0 -48
- package/src/errors.ts +0 -7
- package/src/index.default-invoker.test.ts +0 -172
- package/src/index.interrupt.test.ts +0 -99
- package/src/index.judge.test.ts +0 -167
- package/src/index.test.ts +0 -254
- package/src/index.ts +0 -264
- package/src/run-sample.eventlog.test.ts +0 -124
- package/src/run-sample.test.ts +0 -273
- package/src/run-sample.ts +0 -232
- package/src/semaphore.test.ts +0 -58
- package/src/semaphore.ts +0 -35
- package/src/tenant-roots.test.ts +0 -30
- package/src/types.ts +0 -93
- package/src/wire-once.test.ts +0 -445
- package/src/wire-once.ts +0 -204
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SampleResult } from "./types";
|
|
2
|
+
export declare function quantile(sorted: ReadonlyArray<number>, q: number): number;
|
|
3
|
+
export declare function aggregate(samples: ReadonlyArray<SampleResult>): {
|
|
4
|
+
passRate: number;
|
|
5
|
+
meanScore: number;
|
|
6
|
+
p50Turns: number;
|
|
7
|
+
p95Turns: number;
|
|
8
|
+
p50LatencyMs: number;
|
|
9
|
+
p95LatencyMs: number;
|
|
10
|
+
totalTokens: {
|
|
11
|
+
input: number;
|
|
12
|
+
output: number;
|
|
13
|
+
};
|
|
14
|
+
errorCount: number;
|
|
15
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function quantile(sorted, q) {
|
|
2
|
+
if (sorted.length === 0)
|
|
3
|
+
return 0;
|
|
4
|
+
if (sorted.length === 1)
|
|
5
|
+
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)
|
|
10
|
+
return sorted[lo] ?? 0;
|
|
11
|
+
const fract = idx - lo;
|
|
12
|
+
return (sorted[lo] ?? 0) * (1 - fract) + (sorted[hi] ?? 0) * fract;
|
|
13
|
+
}
|
|
14
|
+
export function aggregate(samples) {
|
|
15
|
+
const ok = samples.filter((s) => s.error === undefined);
|
|
16
|
+
const total = samples.length;
|
|
17
|
+
const passed = ok.filter((s) => s.grades.overall.passed).length;
|
|
18
|
+
const meanScore = ok.length === 0 ? 0 : ok.reduce((sum, s) => sum + s.grades.overall.score, 0) / ok.length;
|
|
19
|
+
const turnsSorted = ok.map((s) => s.turns).sort((a, b) => a - b);
|
|
20
|
+
const latSorted = ok.map((s) => s.latencyMs).sort((a, b) => a - b);
|
|
21
|
+
const totalTokens = ok.reduce((acc, s) => ({
|
|
22
|
+
input: acc.input + s.tokens.input,
|
|
23
|
+
output: acc.output + s.tokens.output,
|
|
24
|
+
}), { input: 0, output: 0 });
|
|
25
|
+
return {
|
|
26
|
+
passRate: total === 0 ? 0 : passed / total,
|
|
27
|
+
meanScore,
|
|
28
|
+
p50Turns: quantile(turnsSorted, 0.5),
|
|
29
|
+
p95Turns: quantile(turnsSorted, 0.95),
|
|
30
|
+
p50LatencyMs: quantile(latSorted, 0.5),
|
|
31
|
+
p95LatencyMs: quantile(latSorted, 0.95),
|
|
32
|
+
totalTokens,
|
|
33
|
+
errorCount: samples.length - ok.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Sample } from "@crewhaus/eval-dataset";
|
|
2
|
+
import type { CompiledGrader } from "@crewhaus/eval-grader";
|
|
3
|
+
import type { IrV0 } from "@crewhaus/ir";
|
|
4
|
+
import { aggregate } from "./aggregate";
|
|
5
|
+
import { RunnerError } from "./errors";
|
|
6
|
+
import { runSample } from "./run-sample";
|
|
7
|
+
import { Semaphore } from "./semaphore";
|
|
8
|
+
import type { AgentInvoker, EvalRunSummary, GraderEntry, RunEvalOptions, SampleResult } from "./types";
|
|
9
|
+
import { type SharedAgentDeps, wireRunOnce } from "./wire-once";
|
|
10
|
+
export type { AgentInvoker, EvalRunSummary, GraderEntry, RunEvalOptions, SampleResult };
|
|
11
|
+
export type { SharedAgentDeps };
|
|
12
|
+
export { wireRunOnce };
|
|
13
|
+
export { Semaphore };
|
|
14
|
+
export { aggregate };
|
|
15
|
+
export { RunnerError };
|
|
16
|
+
export { runSample };
|
|
17
|
+
export type RunEvalArgs = {
|
|
18
|
+
/** Lowered agent IR (target: cli). Caller is responsible for narrowing. */
|
|
19
|
+
readonly ir: IrV0;
|
|
20
|
+
readonly dataset: {
|
|
21
|
+
name: string;
|
|
22
|
+
samples: AsyncIterable<Sample>;
|
|
23
|
+
};
|
|
24
|
+
readonly compiledGraders: ReadonlyArray<CompiledGrader>;
|
|
25
|
+
readonly opts?: RunEvalOptions;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the eval output directory. When an eval runs inside a tenant scope
|
|
29
|
+
* (e.g. a cloud/managed eval), the tenant's rebased `evalRoot` is used so one
|
|
30
|
+
* tenant's eval artifacts never share a directory with another's; the global
|
|
31
|
+
* default is only used outside any tenant scope (#150). An explicit
|
|
32
|
+
* `optsOutDir` always wins for trusted callers.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveEvalOutDir(runId: string, optsOutDir?: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Execute an evaluation. Returns a summary; per-sample artifacts and
|
|
37
|
+
* the summary itself are also persisted under `outDir`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runEval(args: RunEvalArgs): Promise<EvalRunSummary>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
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 { createJudgeGrader, loadRubric } from "@crewhaus/eval-judge";
|
|
27
|
+
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
28
|
+
import { currentTenantContext } from "@crewhaus/tenancy";
|
|
29
|
+
import { aggregate } from "./aggregate";
|
|
30
|
+
import { RunnerError } from "./errors";
|
|
31
|
+
import { runSample } from "./run-sample";
|
|
32
|
+
import { Semaphore } from "./semaphore";
|
|
33
|
+
import { wireRunOnce } from "./wire-once";
|
|
34
|
+
export { wireRunOnce };
|
|
35
|
+
export { Semaphore };
|
|
36
|
+
export { aggregate };
|
|
37
|
+
export { RunnerError };
|
|
38
|
+
export { runSample };
|
|
39
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the eval output directory. When an eval runs inside a tenant scope
|
|
42
|
+
* (e.g. a cloud/managed eval), the tenant's rebased `evalRoot` is used so one
|
|
43
|
+
* tenant's eval artifacts never share a directory with another's; the global
|
|
44
|
+
* default is only used outside any tenant scope (#150). An explicit
|
|
45
|
+
* `optsOutDir` always wins for trusted callers.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveEvalOutDir(runId, optsOutDir) {
|
|
48
|
+
if (optsOutDir !== undefined)
|
|
49
|
+
return optsOutDir;
|
|
50
|
+
const tenant = currentTenantContext()?.tenant;
|
|
51
|
+
if (tenant !== undefined)
|
|
52
|
+
return join(tenant.evalRoot, runId);
|
|
53
|
+
return join(".crewhaus", "evals", runId);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Execute an evaluation. Returns a summary; per-sample artifacts and
|
|
57
|
+
* the summary itself are also persisted under `outDir`.
|
|
58
|
+
*/
|
|
59
|
+
export async function runEval(args) {
|
|
60
|
+
const { ir, dataset, compiledGraders } = args;
|
|
61
|
+
const opts = args.opts ?? {};
|
|
62
|
+
const runId = opts.runId ?? generateRunId();
|
|
63
|
+
const outDir = resolveEvalOutDir(runId, opts.outDir);
|
|
64
|
+
mkdirSync(outDir, { recursive: true });
|
|
65
|
+
// Resolve graders. Replace any `llm_judge` placeholder with a real judge
|
|
66
|
+
// grader bound to the runner's judgeModel (or the per-grader override).
|
|
67
|
+
const graders = compiledGraders.map((g) => {
|
|
68
|
+
if (g.judgeSpec) {
|
|
69
|
+
const rubric = loadRubric(g.judgeSpec.rubric);
|
|
70
|
+
const model = g.judgeSpec.model ?? opts.judgeModel;
|
|
71
|
+
const grader = createJudgeGrader(rubric, model !== undefined ? { model } : {});
|
|
72
|
+
return { name: g.name, grader };
|
|
73
|
+
}
|
|
74
|
+
return { name: g.name, grader: g.grader };
|
|
75
|
+
});
|
|
76
|
+
// The default invoker calls runChatLoop with the per-sample fresh runContext.
|
|
77
|
+
const invoker = opts.invoker ?? (await defaultInvoker(ir, opts));
|
|
78
|
+
const startedAt = new Date().toISOString();
|
|
79
|
+
const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
|
|
80
|
+
const sem = new Semaphore(concurrency);
|
|
81
|
+
// Persist run-level config snapshot up front so SIGINT mid-run still leaves
|
|
82
|
+
// a usable directory.
|
|
83
|
+
const specHash = await hashSpec(ir);
|
|
84
|
+
await Bun.write(join(outDir, "run.json"), JSON.stringify({
|
|
85
|
+
runId,
|
|
86
|
+
startedAt,
|
|
87
|
+
specHash,
|
|
88
|
+
datasetName: dataset.name,
|
|
89
|
+
graderNames: graders.map((g) => g.name),
|
|
90
|
+
model: ir.agent.model,
|
|
91
|
+
...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
|
|
92
|
+
concurrency,
|
|
93
|
+
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
94
|
+
}, null, 2));
|
|
95
|
+
// Materialize the dataset into a list so we can run samples concurrently.
|
|
96
|
+
// For very large datasets the streaming loaders still avoid loading the
|
|
97
|
+
// whole file into memory — but eventually all sample objects sit in RAM.
|
|
98
|
+
const samples = [];
|
|
99
|
+
for await (const s of dataset.samples)
|
|
100
|
+
samples.push(s);
|
|
101
|
+
if (samples.length === 0) {
|
|
102
|
+
throw new RunnerError(`dataset "${dataset.name}" yielded zero samples`);
|
|
103
|
+
}
|
|
104
|
+
let interrupted = false;
|
|
105
|
+
const abortHandler = () => {
|
|
106
|
+
interrupted = true;
|
|
107
|
+
};
|
|
108
|
+
process.once("SIGINT", abortHandler);
|
|
109
|
+
const settled = await Promise.allSettled(samples.map(async (sample) => {
|
|
110
|
+
const release = await sem.acquire();
|
|
111
|
+
// Check *after* acquiring the slot: every callback's synchronous prefix
|
|
112
|
+
// runs during `.map()` (before any SIGINT can fire), so a pre-acquire
|
|
113
|
+
// check would never observe a mid-run interrupt. Samples still queued on
|
|
114
|
+
// the semaphore when SIGINT arrives are skipped here as their turn comes.
|
|
115
|
+
if (interrupted) {
|
|
116
|
+
release();
|
|
117
|
+
throw new RunnerError(`run interrupted before sample "${sample.id}"`);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return await runSample({
|
|
121
|
+
sample,
|
|
122
|
+
invoker,
|
|
123
|
+
graders,
|
|
124
|
+
outDir,
|
|
125
|
+
model: ir.agent.model,
|
|
126
|
+
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
release();
|
|
131
|
+
}
|
|
132
|
+
}));
|
|
133
|
+
process.removeListener("SIGINT", abortHandler);
|
|
134
|
+
const results = settled.map((r, i) => {
|
|
135
|
+
if (r.status === "fulfilled")
|
|
136
|
+
return r.value;
|
|
137
|
+
const sample = samples[i];
|
|
138
|
+
return {
|
|
139
|
+
sampleId: sample?.id ?? `unknown-${i}`,
|
|
140
|
+
sessionId: "(unset)",
|
|
141
|
+
startedAt,
|
|
142
|
+
endedAt: new Date().toISOString(),
|
|
143
|
+
latencyMs: 0,
|
|
144
|
+
turns: 0,
|
|
145
|
+
tokens: { input: 0, output: 0 },
|
|
146
|
+
model: ir.agent.model,
|
|
147
|
+
agentOutput: "",
|
|
148
|
+
grades: {
|
|
149
|
+
overall: { passed: false, score: 0, rationale: "sample failed entirely" },
|
|
150
|
+
perGrader: [],
|
|
151
|
+
},
|
|
152
|
+
error: r.reason instanceof Error ? r.reason.message : String(r.reason),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const endedAt = new Date().toISOString();
|
|
156
|
+
const aggregates = aggregate(results);
|
|
157
|
+
const summary = {
|
|
158
|
+
runId,
|
|
159
|
+
startedAt,
|
|
160
|
+
endedAt,
|
|
161
|
+
samples: results,
|
|
162
|
+
aggregates,
|
|
163
|
+
config: {
|
|
164
|
+
specHash,
|
|
165
|
+
datasetName: dataset.name,
|
|
166
|
+
graderNames: graders.map((g) => g.name),
|
|
167
|
+
model: ir.agent.model,
|
|
168
|
+
...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
|
|
169
|
+
concurrency,
|
|
170
|
+
...(opts.seed !== undefined ? { seed: opts.seed } : {}),
|
|
171
|
+
},
|
|
172
|
+
outDir,
|
|
173
|
+
};
|
|
174
|
+
await Bun.write(join(outDir, "results.json"), JSON.stringify(summary, null, 2));
|
|
175
|
+
return summary;
|
|
176
|
+
}
|
|
177
|
+
async function defaultInvoker(ir, opts) {
|
|
178
|
+
const wired = await wireRunOnce(ir, opts.cwd !== undefined ? { cwd: opts.cwd } : {});
|
|
179
|
+
return async (req) => {
|
|
180
|
+
const agentOutput = await runChatLoop({
|
|
181
|
+
model: wired.model,
|
|
182
|
+
instructions: wired.instructions,
|
|
183
|
+
tools: [...wired.tools],
|
|
184
|
+
hooks: wired.hooks,
|
|
185
|
+
skills: wired.skills,
|
|
186
|
+
slashCommands: wired.slashCommands,
|
|
187
|
+
...(wired.subAgents !== undefined && wired.spawnSubAgent !== undefined
|
|
188
|
+
? { subAgents: wired.subAgents, spawnSubAgent: wired.spawnSubAgent }
|
|
189
|
+
: {}),
|
|
190
|
+
permissionRules: wired.permissionRules,
|
|
191
|
+
permissionMode: "auto",
|
|
192
|
+
sessionName: `${wired.sessionName}_${req.sample.id}`,
|
|
193
|
+
sessionTarget: wired.sessionTarget,
|
|
194
|
+
runContext: req.runContext,
|
|
195
|
+
sessionRootDir: req.sessionRootDir,
|
|
196
|
+
singleTurn: true,
|
|
197
|
+
seedMessages: [{ role: "user", content: req.sample.input }],
|
|
198
|
+
});
|
|
199
|
+
return { agentOutput };
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function generateRunId() {
|
|
203
|
+
const bytes = new Uint8Array(8);
|
|
204
|
+
crypto.getRandomValues(bytes);
|
|
205
|
+
return `run_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
206
|
+
}
|
|
207
|
+
async function hashSpec(ir) {
|
|
208
|
+
const text = JSON.stringify({
|
|
209
|
+
name: ir.name,
|
|
210
|
+
target: ir.target,
|
|
211
|
+
model: ir.agent.model,
|
|
212
|
+
instructions: ir.agent.instructions,
|
|
213
|
+
tools: ir.tools,
|
|
214
|
+
});
|
|
215
|
+
return Bun.hash(text).toString(16);
|
|
216
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Sample } from "@crewhaus/eval-dataset";
|
|
2
|
+
import { type CompiledGrader, type Grader } from "@crewhaus/eval-grader";
|
|
3
|
+
import type { AgentInvoker, GraderEntry, SampleResult } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* Per-sample logic. Mints a fresh runContext (and therefore a fresh
|
|
6
|
+
* TraceEventBus + sessionId), subscribes to the bus, calls the invoker,
|
|
7
|
+
* and persists `transcript.jsonl`, `events.jsonl`, `grades.json`,
|
|
8
|
+
* `meta.json` to the per-sample directory.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runSample(args: {
|
|
11
|
+
sample: Sample;
|
|
12
|
+
invoker: AgentInvoker;
|
|
13
|
+
graders: ReadonlyArray<GraderEntry>;
|
|
14
|
+
outDir: string;
|
|
15
|
+
model: string;
|
|
16
|
+
seed?: number;
|
|
17
|
+
}): Promise<SampleResult>;
|
|
18
|
+
/** Helper: produce one combined grader from an array of CompiledGrader. */
|
|
19
|
+
export declare function combineGraderEntries(graders: ReadonlyArray<CompiledGrader>): Grader;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { combineCompiledGraders, } from "@crewhaus/eval-grader";
|
|
4
|
+
import { openEventLog } from "@crewhaus/event-log";
|
|
5
|
+
import { createRunContext } from "@crewhaus/run-context";
|
|
6
|
+
import { RunnerError } from "./errors";
|
|
7
|
+
/**
|
|
8
|
+
* Per-sample logic. Mints a fresh runContext (and therefore a fresh
|
|
9
|
+
* TraceEventBus + sessionId), subscribes to the bus, calls the invoker,
|
|
10
|
+
* and persists `transcript.jsonl`, `events.jsonl`, `grades.json`,
|
|
11
|
+
* `meta.json` to the per-sample directory.
|
|
12
|
+
*/
|
|
13
|
+
export async function runSample(args) {
|
|
14
|
+
const { sample, invoker, graders, outDir, model } = args;
|
|
15
|
+
const sampleDir = join(outDir, sanitize(sample.id));
|
|
16
|
+
mkdirSync(sampleDir, { recursive: true });
|
|
17
|
+
const runContext = createRunContext();
|
|
18
|
+
const events = [];
|
|
19
|
+
const unsubscribe = runContext.eventBus.subscribe((e) => {
|
|
20
|
+
events.push(e);
|
|
21
|
+
});
|
|
22
|
+
const startedAt = new Date().toISOString();
|
|
23
|
+
const t0 = performance.now();
|
|
24
|
+
let agentOutput = "";
|
|
25
|
+
let transcript = [];
|
|
26
|
+
let invokerEvents;
|
|
27
|
+
let error;
|
|
28
|
+
try {
|
|
29
|
+
const result = await invoker({
|
|
30
|
+
sample,
|
|
31
|
+
runContext,
|
|
32
|
+
sessionRootDir: sampleDir,
|
|
33
|
+
...(args.seed !== undefined ? { seed: args.seed } : {}),
|
|
34
|
+
});
|
|
35
|
+
agentOutput = result.agentOutput;
|
|
36
|
+
transcript = result.transcript ? [...result.transcript] : [];
|
|
37
|
+
invokerEvents = result.events;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
error = err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
unsubscribe();
|
|
44
|
+
}
|
|
45
|
+
const endedAt = new Date().toISOString();
|
|
46
|
+
const latencyMs = Math.round(performance.now() - t0);
|
|
47
|
+
// If the invoker didn't supply a transcript, attempt to read it from disk
|
|
48
|
+
// (this is the path the production runChatLoop wrapper takes).
|
|
49
|
+
if (transcript.length === 0 && error === undefined) {
|
|
50
|
+
transcript = await readTranscript(sampleDir, runContext.sessionId);
|
|
51
|
+
}
|
|
52
|
+
const finalEvents = invokerEvents ? [...invokerEvents] : events;
|
|
53
|
+
// Rename auto-named event-log file → transcript.jsonl for stable artifact
|
|
54
|
+
// names. (Skip if the invoker didn't actually write one, e.g. test stubs.)
|
|
55
|
+
const autoPath = join(sampleDir, `${runContext.sessionId}.jsonl`);
|
|
56
|
+
const transcriptPath = join(sampleDir, "transcript.jsonl");
|
|
57
|
+
if (await Bun.file(autoPath).exists()) {
|
|
58
|
+
renameSync(autoPath, transcriptPath);
|
|
59
|
+
}
|
|
60
|
+
else if (transcript.length > 0) {
|
|
61
|
+
// Stub-supplied transcript — write it ourselves.
|
|
62
|
+
await Bun.write(transcriptPath, transcript.map((e) => JSON.stringify(e)).join("\n") + (transcript.length > 0 ? "\n" : ""));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await Bun.write(transcriptPath, "");
|
|
66
|
+
}
|
|
67
|
+
// Persist captured trace events.
|
|
68
|
+
await Bun.write(join(sampleDir, "events.jsonl"), finalEvents.map((e) => JSON.stringify(e)).join("\n") + (finalEvents.length > 0 ? "\n" : ""));
|
|
69
|
+
const turns = transcript.filter((e) => e.kind === "assistant_message").length;
|
|
70
|
+
const toolCalls = extractToolCalls(finalEvents);
|
|
71
|
+
const tokens = sumTokens(finalEvents);
|
|
72
|
+
// Apply graders.
|
|
73
|
+
const runResult = {
|
|
74
|
+
agentOutput,
|
|
75
|
+
events: finalEvents,
|
|
76
|
+
transcript,
|
|
77
|
+
toolCalls,
|
|
78
|
+
turns,
|
|
79
|
+
latencyMs,
|
|
80
|
+
};
|
|
81
|
+
const perGrader = [];
|
|
82
|
+
for (const g of graders) {
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await g.grader(sample, runResult);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
result = {
|
|
89
|
+
passed: false,
|
|
90
|
+
score: 0,
|
|
91
|
+
rationale: `grader threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
perGrader.push({ name: g.name, ...result });
|
|
95
|
+
}
|
|
96
|
+
// Overall = AND of all graders, score = mean.
|
|
97
|
+
const overall = {
|
|
98
|
+
passed: error === undefined && perGrader.every((g) => g.passed),
|
|
99
|
+
score: perGrader.length === 0 ? 0 : perGrader.reduce((s, g) => s + g.score, 0) / perGrader.length,
|
|
100
|
+
rationale: error !== undefined
|
|
101
|
+
? `agent invocation error: ${error}`
|
|
102
|
+
: perGrader.map((g) => `[${g.name}: ${g.passed ? "✓" : "✗"}] ${g.rationale}`).join(" & "),
|
|
103
|
+
};
|
|
104
|
+
const sampleResult = {
|
|
105
|
+
sampleId: sample.id,
|
|
106
|
+
sessionId: runContext.sessionId,
|
|
107
|
+
startedAt,
|
|
108
|
+
endedAt,
|
|
109
|
+
latencyMs,
|
|
110
|
+
turns,
|
|
111
|
+
tokens,
|
|
112
|
+
model,
|
|
113
|
+
agentOutput,
|
|
114
|
+
grades: { overall, perGrader },
|
|
115
|
+
...(error !== undefined ? { error } : {}),
|
|
116
|
+
};
|
|
117
|
+
await Bun.write(join(sampleDir, "grades.json"), JSON.stringify(sampleResult.grades, null, 2));
|
|
118
|
+
await Bun.write(join(sampleDir, "meta.json"), JSON.stringify({
|
|
119
|
+
sampleId: sample.id,
|
|
120
|
+
sessionId: runContext.sessionId,
|
|
121
|
+
startedAt,
|
|
122
|
+
endedAt,
|
|
123
|
+
latencyMs,
|
|
124
|
+
turns,
|
|
125
|
+
tokens,
|
|
126
|
+
model,
|
|
127
|
+
...(error !== undefined ? { error } : {}),
|
|
128
|
+
...(args.seed !== undefined ? { seed: args.seed } : {}),
|
|
129
|
+
}, null, 2));
|
|
130
|
+
return sampleResult;
|
|
131
|
+
}
|
|
132
|
+
/** Helper: produce one combined grader from an array of CompiledGrader. */
|
|
133
|
+
export function combineGraderEntries(graders) {
|
|
134
|
+
return combineCompiledGraders(graders);
|
|
135
|
+
}
|
|
136
|
+
/** Strip path separators from sample IDs to keep the on-disk layout flat. */
|
|
137
|
+
function sanitize(id) {
|
|
138
|
+
return id.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
139
|
+
}
|
|
140
|
+
async function readTranscript(dir, sessionId) {
|
|
141
|
+
const out = [];
|
|
142
|
+
try {
|
|
143
|
+
const log = await openEventLog(sessionId, { rootDir: dir });
|
|
144
|
+
for await (const e of log.read())
|
|
145
|
+
out.push(e);
|
|
146
|
+
await log.close();
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Tolerate missing log (test stubs may skip persistence).
|
|
150
|
+
if (!(err instanceof Error && /invalid sessionId/.test(err.message))) {
|
|
151
|
+
throw new RunnerError(`failed to read transcript for ${sessionId}`, err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function extractToolCalls(events) {
|
|
157
|
+
const calls = [];
|
|
158
|
+
const startByUseId = new Map();
|
|
159
|
+
for (const ev of events) {
|
|
160
|
+
if (ev.kind === "tool_call_start") {
|
|
161
|
+
startByUseId.set(ev.toolUseId, { toolName: ev.toolName, ts: Date.parse(ev.timestamp) });
|
|
162
|
+
}
|
|
163
|
+
else if (ev.kind === "tool_call_end") {
|
|
164
|
+
const start = startByUseId.get(ev.toolUseId);
|
|
165
|
+
calls.push({
|
|
166
|
+
toolName: ev.toolName,
|
|
167
|
+
toolUseId: ev.toolUseId,
|
|
168
|
+
isError: ev.isError,
|
|
169
|
+
ts: start?.ts ?? Date.parse(ev.timestamp),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
calls.sort((a, b) => a.ts - b.ts);
|
|
174
|
+
return calls.map(({ ts: _ts, ...rest }) => rest);
|
|
175
|
+
}
|
|
176
|
+
function sumTokens(events) {
|
|
177
|
+
let input = 0;
|
|
178
|
+
let output = 0;
|
|
179
|
+
for (const ev of events) {
|
|
180
|
+
if (ev.kind === "model_response") {
|
|
181
|
+
const e = ev;
|
|
182
|
+
input += e.usage.input;
|
|
183
|
+
output += e.usage.output;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { input, output };
|
|
187
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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 declare class Semaphore {
|
|
7
|
+
private readonly capacity;
|
|
8
|
+
private inflight;
|
|
9
|
+
private readonly waitQueue;
|
|
10
|
+
constructor(capacity: number);
|
|
11
|
+
acquire(): Promise<() => void>;
|
|
12
|
+
private release;
|
|
13
|
+
get pending(): number;
|
|
14
|
+
get active(): number;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
capacity;
|
|
8
|
+
inflight = 0;
|
|
9
|
+
waitQueue = [];
|
|
10
|
+
constructor(capacity) {
|
|
11
|
+
this.capacity = capacity;
|
|
12
|
+
if (capacity < 1)
|
|
13
|
+
throw new Error(`Semaphore capacity must be ≥ 1 (got ${capacity})`);
|
|
14
|
+
}
|
|
15
|
+
async acquire() {
|
|
16
|
+
if (this.inflight < this.capacity) {
|
|
17
|
+
this.inflight += 1;
|
|
18
|
+
return () => this.release();
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
this.waitQueue.push(() => {
|
|
22
|
+
this.inflight += 1;
|
|
23
|
+
resolve(() => this.release());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
release() {
|
|
28
|
+
this.inflight -= 1;
|
|
29
|
+
const next = this.waitQueue.shift();
|
|
30
|
+
if (next)
|
|
31
|
+
next();
|
|
32
|
+
}
|
|
33
|
+
get pending() {
|
|
34
|
+
return this.waitQueue.length;
|
|
35
|
+
}
|
|
36
|
+
get active() {
|
|
37
|
+
return this.inflight;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
* The agent-invocation contract. The runner provides a per-sample
|
|
8
|
+
* `RunContext` (fresh `TraceEventBus`, fresh `sessionId`) and the
|
|
9
|
+
* destination directory for the event log. The invoker is responsible
|
|
10
|
+
* for actually running the chat loop and returning the assistant text.
|
|
11
|
+
*
|
|
12
|
+
* In production this is a thin wrapper around `runChatLoop`. In tests it's
|
|
13
|
+
* a deterministic stub that returns a canned answer per sample.
|
|
14
|
+
*/
|
|
15
|
+
export type AgentInvoker = (req: AgentInvokeRequest) => Promise<AgentInvokeResult>;
|
|
16
|
+
export type AgentInvokeRequest = {
|
|
17
|
+
readonly sample: Sample;
|
|
18
|
+
readonly runContext: RunContext;
|
|
19
|
+
/** Where the runtime should write the per-sample event-log JSONL. */
|
|
20
|
+
readonly sessionRootDir: string;
|
|
21
|
+
readonly seed?: number;
|
|
22
|
+
};
|
|
23
|
+
export type AgentInvokeResult = {
|
|
24
|
+
/** Final assistant text returned by the chat loop. */
|
|
25
|
+
readonly agentOutput: string;
|
|
26
|
+
/**
|
|
27
|
+
* If the invoker has out-of-band knowledge (e.g. a stub), it can supply
|
|
28
|
+
* the captured event-log entries here. The default invoker reads them
|
|
29
|
+
* from disk after `runChatLoop` returns.
|
|
30
|
+
*/
|
|
31
|
+
readonly transcript?: ReadonlyArray<TranscriptEvent>;
|
|
32
|
+
/** Same as transcript — invoker can short-circuit the bus subscription. */
|
|
33
|
+
readonly events?: ReadonlyArray<TraceEvent>;
|
|
34
|
+
};
|
|
35
|
+
export type SampleResult = {
|
|
36
|
+
readonly sampleId: string;
|
|
37
|
+
readonly sessionId: string;
|
|
38
|
+
readonly startedAt: string;
|
|
39
|
+
readonly endedAt: string;
|
|
40
|
+
readonly latencyMs: number;
|
|
41
|
+
readonly turns: number;
|
|
42
|
+
readonly tokens: {
|
|
43
|
+
input: number;
|
|
44
|
+
output: number;
|
|
45
|
+
};
|
|
46
|
+
readonly model: string;
|
|
47
|
+
readonly agentOutput: string;
|
|
48
|
+
readonly grades: {
|
|
49
|
+
overall: GradeResult;
|
|
50
|
+
perGrader: Array<{
|
|
51
|
+
name: string;
|
|
52
|
+
} & GradeResult>;
|
|
53
|
+
};
|
|
54
|
+
readonly error?: string;
|
|
55
|
+
};
|
|
56
|
+
export type EvalRunSummary = {
|
|
57
|
+
readonly runId: string;
|
|
58
|
+
readonly startedAt: string;
|
|
59
|
+
readonly endedAt: string;
|
|
60
|
+
readonly samples: ReadonlyArray<SampleResult>;
|
|
61
|
+
readonly aggregates: {
|
|
62
|
+
readonly passRate: number;
|
|
63
|
+
readonly meanScore: number;
|
|
64
|
+
readonly p50Turns: number;
|
|
65
|
+
readonly p95Turns: number;
|
|
66
|
+
readonly p50LatencyMs: number;
|
|
67
|
+
readonly p95LatencyMs: number;
|
|
68
|
+
readonly totalTokens: {
|
|
69
|
+
input: number;
|
|
70
|
+
output: number;
|
|
71
|
+
};
|
|
72
|
+
readonly errorCount: number;
|
|
73
|
+
};
|
|
74
|
+
readonly config: {
|
|
75
|
+
readonly specHash: string;
|
|
76
|
+
readonly datasetName: string;
|
|
77
|
+
readonly graderNames: ReadonlyArray<string>;
|
|
78
|
+
readonly model: string;
|
|
79
|
+
readonly judgeModel?: string;
|
|
80
|
+
readonly concurrency: number;
|
|
81
|
+
readonly seed?: number;
|
|
82
|
+
};
|
|
83
|
+
readonly outDir: string;
|
|
84
|
+
};
|
|
85
|
+
export type RunEvalOptions = {
|
|
86
|
+
readonly runId?: string;
|
|
87
|
+
readonly concurrency?: number;
|
|
88
|
+
readonly seed?: number;
|
|
89
|
+
readonly outDir?: string;
|
|
90
|
+
readonly judgeModel?: string;
|
|
91
|
+
readonly invoker?: AgentInvoker;
|
|
92
|
+
readonly cwd?: string;
|
|
93
|
+
};
|
|
94
|
+
export type GraderEntry = {
|
|
95
|
+
readonly name: string;
|
|
96
|
+
readonly grader: Grader;
|
|
97
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|