@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the agent stack ONCE per eval run, then share across samples.
|
|
3
|
+
*
|
|
4
|
+
* This factors the runRun logic from apps/cli/src/index.ts so the CLI's
|
|
5
|
+
* `crewhaus run` and the eval runner's `crewhaus eval` use the same
|
|
6
|
+
* tool/hook/skill/MCP/sub-agent wiring. Single source of truth for the
|
|
7
|
+
* "what is the full agent stack from an IR" question.
|
|
8
|
+
*
|
|
9
|
+
* The MCP host (if any) is shared across all eval samples — re-spinning
|
|
10
|
+
* stdio MCP servers per sample for 200 samples would burn ~30s in process
|
|
11
|
+
* startup and exceed the T7 SLO. The trade-off is documented: eval-runner
|
|
12
|
+
* assumes the agent's MCP usage is read-mostly. An `isolateMcpPerSample`
|
|
13
|
+
* escape hatch is reserved for a future where this matters.
|
|
14
|
+
*/
|
|
15
|
+
import type { SubAgentDefinition } from "@crewhaus/agent-context-isolation";
|
|
16
|
+
import { type HookDef } from "@crewhaus/hooks-engine";
|
|
17
|
+
import type { IrV0 } from "@crewhaus/ir";
|
|
18
|
+
import { McpHost } from "@crewhaus/mcp-host";
|
|
19
|
+
import { type RuleSet } from "@crewhaus/permission-engine";
|
|
20
|
+
import { type SkillRef } from "@crewhaus/skills-registry";
|
|
21
|
+
import { type SlashCommand } from "@crewhaus/slash-commands";
|
|
22
|
+
import { spawnSubAgent } from "@crewhaus/sub-agent-spawner";
|
|
23
|
+
import { type RegisteredTool } from "@crewhaus/tool-catalog";
|
|
24
|
+
type SpawnSubAgentFn = typeof spawnSubAgent;
|
|
25
|
+
export type SharedAgentDeps = {
|
|
26
|
+
readonly tools: ReadonlyArray<RegisteredTool>;
|
|
27
|
+
readonly hooks: ReadonlyArray<HookDef>;
|
|
28
|
+
readonly skills: ReadonlyArray<SkillRef>;
|
|
29
|
+
readonly slashCommands: ReadonlyMap<string, SlashCommand>;
|
|
30
|
+
readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
|
|
31
|
+
readonly spawnSubAgent?: SpawnSubAgentFn;
|
|
32
|
+
readonly permissionRules: RuleSet;
|
|
33
|
+
readonly mcpHost?: McpHost;
|
|
34
|
+
readonly model: string;
|
|
35
|
+
readonly instructions: string;
|
|
36
|
+
readonly sessionName: string;
|
|
37
|
+
readonly sessionTarget: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function wireRunOnce(ir: IrV0, opts?: {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
}): Promise<SharedAgentDeps>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadHooks } from "@crewhaus/hooks-engine";
|
|
4
|
+
import { createLogger } from "@crewhaus/logging";
|
|
5
|
+
import { McpHost } from "@crewhaus/mcp-host";
|
|
6
|
+
import { BUILTIN_DEFAULT_RULES, PermissionConfigError, parsePermissionsConfig, tagRules, } from "@crewhaus/permission-engine";
|
|
7
|
+
import { createSkillTool, discoverSkills } from "@crewhaus/skills-registry";
|
|
8
|
+
import { loadCommands } from "@crewhaus/slash-commands";
|
|
9
|
+
import { spawnSubAgent } from "@crewhaus/sub-agent-spawner";
|
|
10
|
+
import { ToolCatalog } from "@crewhaus/tool-catalog";
|
|
11
|
+
import { registerMcpServer } from "@crewhaus/tool-mcp";
|
|
12
|
+
import { createTaskTool } from "@crewhaus/tool-task";
|
|
13
|
+
import { RunnerError } from "./errors";
|
|
14
|
+
const logger = createLogger({ bindings: { module: "eval-runner.wire" } });
|
|
15
|
+
export async function wireRunOnce(ir, opts = {}) {
|
|
16
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
17
|
+
// Tools.
|
|
18
|
+
let tools = [];
|
|
19
|
+
if (ir.tools.length > 0) {
|
|
20
|
+
await applyToolConfigs(ir.tools, ir.toolConfigs);
|
|
21
|
+
const toolMap = await loadToolMap();
|
|
22
|
+
tools = ir.tools.map((name) => {
|
|
23
|
+
const tool = toolMap[name];
|
|
24
|
+
if (!tool) {
|
|
25
|
+
const known = Object.keys(toolMap).sort().join(", ");
|
|
26
|
+
throw new RunnerError(`unknown tool "${name}" — known tools: ${known}`);
|
|
27
|
+
}
|
|
28
|
+
return tool;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// MCP servers (shared across samples).
|
|
32
|
+
let mcpHost;
|
|
33
|
+
if (Object.keys(ir.mcp_servers).length > 0) {
|
|
34
|
+
const host = new McpHost({ logger });
|
|
35
|
+
mcpHost = host;
|
|
36
|
+
for (const [name, cfg] of Object.entries(ir.mcp_servers))
|
|
37
|
+
host.addServer(name, cfg);
|
|
38
|
+
const tempCatalog = new ToolCatalog();
|
|
39
|
+
for (const t of tools)
|
|
40
|
+
tempCatalog.register(t);
|
|
41
|
+
await Promise.all(Object.keys(ir.mcp_servers).map((name) => registerMcpServer(host, name, tempCatalog)));
|
|
42
|
+
tools = tempCatalog.list().slice();
|
|
43
|
+
}
|
|
44
|
+
// Permission rules.
|
|
45
|
+
const permissionRules = buildRuleSet(ir.permissions.rules, cwd);
|
|
46
|
+
// Hooks / skills / slash-commands.
|
|
47
|
+
const [hooks, skills, slashCommands] = await Promise.all([
|
|
48
|
+
loadHooks({ cwd }),
|
|
49
|
+
discoverSkills({ cwd }),
|
|
50
|
+
loadCommands({ cwd }),
|
|
51
|
+
]);
|
|
52
|
+
if (skills.length > 0)
|
|
53
|
+
tools.push(createSkillTool(skills));
|
|
54
|
+
// Sub-agents.
|
|
55
|
+
let subAgents;
|
|
56
|
+
if (ir.subAgents.length > 0) {
|
|
57
|
+
subAgents = new Map(ir.subAgents.map((d) => [
|
|
58
|
+
d.name,
|
|
59
|
+
{
|
|
60
|
+
name: d.name,
|
|
61
|
+
description: d.description,
|
|
62
|
+
instructions: d.instructions,
|
|
63
|
+
tools: d.tools,
|
|
64
|
+
...(d.model !== undefined ? { model: d.model } : {}),
|
|
65
|
+
permissions: d.permissions,
|
|
66
|
+
inherit_bypass: d.inheritBypass,
|
|
67
|
+
},
|
|
68
|
+
]));
|
|
69
|
+
tools.push(createTaskTool({ subAgents }));
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
tools,
|
|
73
|
+
hooks,
|
|
74
|
+
skills,
|
|
75
|
+
slashCommands,
|
|
76
|
+
permissionRules,
|
|
77
|
+
model: ir.agent.model,
|
|
78
|
+
instructions: ir.agent.instructions,
|
|
79
|
+
sessionName: ir.name,
|
|
80
|
+
sessionTarget: ir.target,
|
|
81
|
+
...(subAgents !== undefined ? { subAgents, spawnSubAgent } : {}),
|
|
82
|
+
...(mcpHost !== undefined ? { mcpHost } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function loadToolMap() {
|
|
86
|
+
const [fs, bash, todo, web, image, fetchPkg] = await Promise.all([
|
|
87
|
+
import("@crewhaus/tool-fs"),
|
|
88
|
+
import("@crewhaus/tool-bash"),
|
|
89
|
+
import("@crewhaus/tool-todo"),
|
|
90
|
+
import("@crewhaus/tool-web"),
|
|
91
|
+
import("@crewhaus/tool-image"),
|
|
92
|
+
import("@crewhaus/tool-fetch"),
|
|
93
|
+
]);
|
|
94
|
+
return {
|
|
95
|
+
read: fs.read,
|
|
96
|
+
write: fs.write,
|
|
97
|
+
edit: fs.edit,
|
|
98
|
+
glob: fs.glob,
|
|
99
|
+
grep: fs.grep,
|
|
100
|
+
bash: bash.bash,
|
|
101
|
+
todoWrite: todo.todoWrite,
|
|
102
|
+
webFetch: web.webFetch,
|
|
103
|
+
webSearch: web.webSearch,
|
|
104
|
+
readImage: image.readImage,
|
|
105
|
+
fetch: fetchPkg.fetch,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function applyToolConfigs(toolNames, toolConfigs) {
|
|
109
|
+
const used = new Set(toolNames);
|
|
110
|
+
if (used.has("fetch") && toolConfigs["fetch"] !== undefined) {
|
|
111
|
+
const { registerFetchConfig } = await import("@crewhaus/tool-fetch");
|
|
112
|
+
registerFetchConfig(toolConfigs["fetch"]);
|
|
113
|
+
}
|
|
114
|
+
if (used.has("webFetch") && toolConfigs["webFetch"] !== undefined) {
|
|
115
|
+
const { registerWebFetchConfig } = await import("@crewhaus/tool-web");
|
|
116
|
+
registerWebFetchConfig(toolConfigs["webFetch"]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function buildRuleSet(yamlRules, cwd) {
|
|
120
|
+
let settings = [];
|
|
121
|
+
const settingsPath = join(cwd, ".crewhaus", "settings.json");
|
|
122
|
+
if (existsSync(settingsPath)) {
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw new RunnerError(`failed to parse ${settingsPath}: ${err.message}`, err);
|
|
129
|
+
}
|
|
130
|
+
const root = raw.permissions;
|
|
131
|
+
if (root !== undefined) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = parsePermissionsConfig(root, "settings");
|
|
134
|
+
settings = tagRules(parsed.rules, "settings");
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err instanceof PermissionConfigError)
|
|
138
|
+
throw new RunnerError(err.message, err);
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
flag: [],
|
|
145
|
+
settings,
|
|
146
|
+
yaml: tagRules(yamlRules, "yaml"),
|
|
147
|
+
hooks: [],
|
|
148
|
+
builtin: BUILTIN_DEFAULT_RULES,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/package.json
CHANGED
|
@@ -1,46 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/eval-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Eval runner — per-sample isolation, concurrency, grading, persistence",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/agent-context-isolation": "0.1.
|
|
16
|
-
"@crewhaus/compiler": "0.1.
|
|
17
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
-
"@crewhaus/eval-dataset": "0.1.
|
|
19
|
-
"@crewhaus/eval-grader": "0.1.
|
|
20
|
-
"@crewhaus/eval-judge": "0.1.
|
|
21
|
-
"@crewhaus/event-log": "0.1.
|
|
22
|
-
"@crewhaus/hooks-engine": "0.1.
|
|
23
|
-
"@crewhaus/ir": "0.1.
|
|
24
|
-
"@crewhaus/logging": "0.1.
|
|
25
|
-
"@crewhaus/mcp-host": "0.1.
|
|
26
|
-
"@crewhaus/permission-engine": "0.1.
|
|
27
|
-
"@crewhaus/run-context": "0.1.
|
|
28
|
-
"@crewhaus/runtime-core": "0.1.
|
|
29
|
-
"@crewhaus/skills-registry": "0.1.
|
|
30
|
-
"@crewhaus/slash-commands": "0.1.
|
|
31
|
-
"@crewhaus/spec": "0.1.
|
|
32
|
-
"@crewhaus/sub-agent-spawner": "0.1.
|
|
33
|
-
"@crewhaus/tenancy": "0.1.
|
|
34
|
-
"@crewhaus/tool-bash": "0.1.
|
|
35
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
36
|
-
"@crewhaus/tool-fetch": "0.1.
|
|
37
|
-
"@crewhaus/tool-fs": "0.1.
|
|
38
|
-
"@crewhaus/tool-image": "0.1.
|
|
39
|
-
"@crewhaus/tool-mcp": "0.1.
|
|
40
|
-
"@crewhaus/tool-task": "0.1.
|
|
41
|
-
"@crewhaus/tool-todo": "0.1.
|
|
42
|
-
"@crewhaus/tool-web": "0.1.
|
|
43
|
-
"@crewhaus/trace-event-bus": "0.1.
|
|
18
|
+
"@crewhaus/agent-context-isolation": "0.1.5",
|
|
19
|
+
"@crewhaus/compiler": "0.1.5",
|
|
20
|
+
"@crewhaus/errors": "0.1.5",
|
|
21
|
+
"@crewhaus/eval-dataset": "0.1.5",
|
|
22
|
+
"@crewhaus/eval-grader": "0.1.5",
|
|
23
|
+
"@crewhaus/eval-judge": "0.1.5",
|
|
24
|
+
"@crewhaus/event-log": "0.1.5",
|
|
25
|
+
"@crewhaus/hooks-engine": "0.1.5",
|
|
26
|
+
"@crewhaus/ir": "0.1.5",
|
|
27
|
+
"@crewhaus/logging": "0.1.5",
|
|
28
|
+
"@crewhaus/mcp-host": "0.1.5",
|
|
29
|
+
"@crewhaus/permission-engine": "0.1.5",
|
|
30
|
+
"@crewhaus/run-context": "0.1.5",
|
|
31
|
+
"@crewhaus/runtime-core": "0.1.5",
|
|
32
|
+
"@crewhaus/skills-registry": "0.1.5",
|
|
33
|
+
"@crewhaus/slash-commands": "0.1.5",
|
|
34
|
+
"@crewhaus/spec": "0.1.5",
|
|
35
|
+
"@crewhaus/sub-agent-spawner": "0.1.5",
|
|
36
|
+
"@crewhaus/tenancy": "0.1.5",
|
|
37
|
+
"@crewhaus/tool-bash": "0.1.5",
|
|
38
|
+
"@crewhaus/tool-catalog": "0.1.5",
|
|
39
|
+
"@crewhaus/tool-fetch": "0.1.5",
|
|
40
|
+
"@crewhaus/tool-fs": "0.1.5",
|
|
41
|
+
"@crewhaus/tool-image": "0.1.5",
|
|
42
|
+
"@crewhaus/tool-mcp": "0.1.5",
|
|
43
|
+
"@crewhaus/tool-task": "0.1.5",
|
|
44
|
+
"@crewhaus/tool-todo": "0.1.5",
|
|
45
|
+
"@crewhaus/tool-web": "0.1.5",
|
|
46
|
+
"@crewhaus/trace-event-bus": "0.1.5",
|
|
44
47
|
"zod": "^3.23.8"
|
|
45
48
|
},
|
|
46
49
|
"license": "Apache-2.0",
|
|
@@ -61,5 +64,5 @@
|
|
|
61
64
|
"publishConfig": {
|
|
62
65
|
"access": "public"
|
|
63
66
|
},
|
|
64
|
-
"files": ["
|
|
67
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
65
68
|
}
|
package/src/aggregate.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
});
|