@dungle-scrubs/skillval 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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/cli.js +1009 -0
- package/dist/cli.js.map +1 -0
- package/package.json +70 -0
- package/schemas/config.schema.json +23 -0
- package/schemas/skillval.schema.json +131 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { existsSync, readFileSync as readFileSync3 } from "fs";
|
|
8
|
+
import { homedir as homedir2 } from "os";
|
|
9
|
+
import { resolve } from "path";
|
|
10
|
+
import { Check as checkSchema, Errors as schemaErrors } from "typebox/value";
|
|
11
|
+
import { parse as parseYaml } from "yaml";
|
|
12
|
+
|
|
13
|
+
// src/config-contract.ts
|
|
14
|
+
import Type from "typebox";
|
|
15
|
+
|
|
16
|
+
// src/executors/codex.ts
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import { mkdirSync, readFileSync as readFileSync2, symlinkSync } from "fs";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import { join as join2 } from "path";
|
|
21
|
+
|
|
22
|
+
// src/utils.ts
|
|
23
|
+
import { createHash } from "crypto";
|
|
24
|
+
import { readdirSync, readFileSync } from "fs";
|
|
25
|
+
import { join, relative } from "path";
|
|
26
|
+
function sha256(input) {
|
|
27
|
+
return createHash("sha256").update(input).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
30
|
+
function walkFiles(root) {
|
|
31
|
+
const files = [];
|
|
32
|
+
function visit(directory) {
|
|
33
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
34
|
+
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
35
|
+
const path = join(directory, entry.name);
|
|
36
|
+
if (entry.isDirectory()) visit(path);
|
|
37
|
+
else if (entry.isFile()) files.push(path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
visit(root);
|
|
41
|
+
return files.sort();
|
|
42
|
+
}
|
|
43
|
+
function skillContentHash(skillDirectory) {
|
|
44
|
+
const parts = walkFiles(skillDirectory).map(
|
|
45
|
+
(file) => `${relative(skillDirectory, file)}
|
|
46
|
+
${readFileSync(file, "utf8")}`
|
|
47
|
+
);
|
|
48
|
+
return sha256(parts.join("\0"));
|
|
49
|
+
}
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/executors/codex.ts
|
|
55
|
+
var TRIAL_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
56
|
+
var CodexExecutor = class {
|
|
57
|
+
metadata;
|
|
58
|
+
#realHome;
|
|
59
|
+
constructor(realHome = homedir()) {
|
|
60
|
+
this.#realHome = realHome;
|
|
61
|
+
this.metadata = detectCodex(realHome);
|
|
62
|
+
}
|
|
63
|
+
runTrial(request) {
|
|
64
|
+
if (request.arm === "skill") seedSkill(request);
|
|
65
|
+
const sandbox = request.evalCase.mode === "generation" ? "workspace-write" : "read-only";
|
|
66
|
+
const environment = request.arm === "baseline" ? {
|
|
67
|
+
...process.env,
|
|
68
|
+
CODEX_HOME: join2(this.#realHome, ".codex"),
|
|
69
|
+
HOME: request.home
|
|
70
|
+
} : { ...process.env };
|
|
71
|
+
const result = spawnSync(
|
|
72
|
+
"codex",
|
|
73
|
+
[
|
|
74
|
+
"exec",
|
|
75
|
+
"--json",
|
|
76
|
+
"--skip-git-repo-check",
|
|
77
|
+
"--ephemeral",
|
|
78
|
+
"-s",
|
|
79
|
+
sandbox,
|
|
80
|
+
"-C",
|
|
81
|
+
request.workspace,
|
|
82
|
+
request.evalCase.prompt
|
|
83
|
+
],
|
|
84
|
+
{
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
env: environment,
|
|
87
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
88
|
+
timeout: TRIAL_TIMEOUT_MS
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
if (result.status !== 0) {
|
|
92
|
+
throw new Error(`codex exec exited ${result.status}: ${result.stderr?.slice(-500)}`);
|
|
93
|
+
}
|
|
94
|
+
return parseCodexTrace(result.stdout, request.skillName);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
function seedSkill(request) {
|
|
98
|
+
const skillsRoot = join2(request.workspace, ".agents/skills");
|
|
99
|
+
mkdirSync(skillsRoot, { recursive: true });
|
|
100
|
+
symlinkSync(request.skillDirectory, join2(skillsRoot, request.skillName));
|
|
101
|
+
}
|
|
102
|
+
function detectCodex(realHome = homedir()) {
|
|
103
|
+
const version = spawnSync("codex", ["--version"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
104
|
+
if (version === "") throw new Error("codex CLI not found on PATH");
|
|
105
|
+
const model = readFileSync2(join2(realHome, ".codex/config.toml"), "utf8").match(
|
|
106
|
+
/^model\s*=\s*"([^"]+)"/m
|
|
107
|
+
)?.[1] ?? "default";
|
|
108
|
+
return { model, name: "codex", version };
|
|
109
|
+
}
|
|
110
|
+
function parseCodexTrace(stdout, skillName) {
|
|
111
|
+
let completed = false;
|
|
112
|
+
let invoked = false;
|
|
113
|
+
const texts = [];
|
|
114
|
+
let usage;
|
|
115
|
+
for (const line of stdout.split("\n")) {
|
|
116
|
+
if (line.trim() === "") continue;
|
|
117
|
+
let event;
|
|
118
|
+
try {
|
|
119
|
+
event = JSON.parse(line);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!isRecord(event)) continue;
|
|
124
|
+
const item = isRecord(event.item) ? event.item : void 0;
|
|
125
|
+
if (item?.type === "command_execution" && typeof item.command === "string" && item.command.includes(`${skillName}/SKILL.md`)) {
|
|
126
|
+
invoked = true;
|
|
127
|
+
}
|
|
128
|
+
if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") {
|
|
129
|
+
texts.push(item.text);
|
|
130
|
+
}
|
|
131
|
+
if (event.type === "turn.completed") {
|
|
132
|
+
completed = true;
|
|
133
|
+
usage = event.usage;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { agentText: texts.join("\n"), completed, invoked, usage };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/executors/index.ts
|
|
140
|
+
var executorFactories = {
|
|
141
|
+
codex: () => new CodexExecutor()
|
|
142
|
+
};
|
|
143
|
+
var EXECUTOR_NAMES = Object.keys(executorFactories);
|
|
144
|
+
function createExecutor(name) {
|
|
145
|
+
return executorFactories[name]();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/config-contract.ts
|
|
149
|
+
var configFileSchema = Type.ReadonlyObject(
|
|
150
|
+
Type.Object({
|
|
151
|
+
// Names come from the executor registry so configuration cannot advertise a missing adapter.
|
|
152
|
+
executor: Type.Enum(EXECUTOR_NAMES, {
|
|
153
|
+
description: "Trial executor."
|
|
154
|
+
}),
|
|
155
|
+
roots: Type.Readonly(
|
|
156
|
+
Type.Array(Type.String({ minLength: 1, pattern: String.raw`\S` }), {
|
|
157
|
+
description: "Directories whose immediate children are agent skill directories."
|
|
158
|
+
})
|
|
159
|
+
)
|
|
160
|
+
}),
|
|
161
|
+
{
|
|
162
|
+
$id: "https://raw.githubusercontent.com/dungle-scrubs/skillval/main/schemas/config.schema.json",
|
|
163
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
title: "skillval configuration"
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// src/config.ts
|
|
170
|
+
var ConfigError = class extends Error {
|
|
171
|
+
code;
|
|
172
|
+
constructor(message, code = "CONFIG_ERROR") {
|
|
173
|
+
super(message);
|
|
174
|
+
this.code = code;
|
|
175
|
+
this.name = "ConfigError";
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
function resolveConfigPath(options = {}) {
|
|
179
|
+
const environment = options.environment ?? process.env;
|
|
180
|
+
const home = options.home ?? homedir2();
|
|
181
|
+
const selected = options.cliPath ?? environment.SKILLVAL_CONFIG ?? (environment.XDG_CONFIG_HOME ? resolve(environment.XDG_CONFIG_HOME, "skillval/config.yml") : resolve(home, ".config/skillval/config.yml"));
|
|
182
|
+
return resolve(expandHome(selected, home));
|
|
183
|
+
}
|
|
184
|
+
function expandRoot(root, home = homedir2()) {
|
|
185
|
+
return resolve(expandHome(root, home));
|
|
186
|
+
}
|
|
187
|
+
function loadConfig(path, home = homedir2()) {
|
|
188
|
+
if (!existsSync(path)) {
|
|
189
|
+
throw new ConfigError(`config file not found: ${path}`, "CONFIG_NOT_FOUND");
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = parseYaml(readFileSync3(path, "utf8"));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
196
|
+
throw new ConfigError(`invalid YAML in ${path}: ${detail}`, "CONFIG_YAML_INVALID");
|
|
197
|
+
}
|
|
198
|
+
if (!checkSchema(configFileSchema, parsed)) {
|
|
199
|
+
const [firstError] = schemaErrors(configFileSchema, parsed);
|
|
200
|
+
const location = firstError?.instancePath.replaceAll("/", ".").replace(/^\./, "");
|
|
201
|
+
const subject = location === void 0 || location === "" ? path : `${path} ${location}`;
|
|
202
|
+
throw new ConfigError(`${subject} ${firstError?.message ?? "is invalid"}`);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
executor: parsed.executor,
|
|
206
|
+
roots: parsed.roots.map((root) => expandRoot(root, home))
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function resolveStateDirectory(environment = process.env, home = homedir2()) {
|
|
210
|
+
return environment.XDG_STATE_HOME ? resolve(environment.XDG_STATE_HOME, "skillval") : resolve(home, ".local/state/skillval");
|
|
211
|
+
}
|
|
212
|
+
function expandHome(value, home) {
|
|
213
|
+
const withTilde = value === "~" ? home : value.startsWith("~/") ? `${home}${value.slice(1)}` : value;
|
|
214
|
+
return withTilde.replaceAll(String.raw`\${HOME}`, home).replaceAll("$HOME", home);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/discovery.ts
|
|
218
|
+
import { existsSync as existsSync3, readdirSync as readdirSync3 } from "fs";
|
|
219
|
+
import { join as join6 } from "path";
|
|
220
|
+
|
|
221
|
+
// src/case-file.ts
|
|
222
|
+
import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
|
|
223
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
224
|
+
import { parse as parseYaml2 } from "yaml";
|
|
225
|
+
|
|
226
|
+
// src/case-contract.ts
|
|
227
|
+
import Type2 from "typebox";
|
|
228
|
+
import { Check as checkSchema2, Errors as schemaErrors2 } from "typebox/value";
|
|
229
|
+
|
|
230
|
+
// src/graders.ts
|
|
231
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
232
|
+
import { existsSync as existsSync2, writeFileSync } from "fs";
|
|
233
|
+
import { createRequire } from "module";
|
|
234
|
+
import { dirname, join as join3 } from "path";
|
|
235
|
+
var packageRequire = createRequire(import.meta.url);
|
|
236
|
+
var graders = {
|
|
237
|
+
tsc: {
|
|
238
|
+
modes: ["generation"],
|
|
239
|
+
run: gradeTsc
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
var GRADER_NAMES = Object.keys(graders);
|
|
243
|
+
function graderSupportsMode(name, mode) {
|
|
244
|
+
const modes = graders[name].modes;
|
|
245
|
+
return modes.includes(mode);
|
|
246
|
+
}
|
|
247
|
+
function runGraders(evalCase, workspace) {
|
|
248
|
+
return (evalCase.assert?.graders ?? []).map((name) => graders[name].run(workspace));
|
|
249
|
+
}
|
|
250
|
+
function gradeTsc(workspace) {
|
|
251
|
+
if (!existsSync2(join3(workspace, "package.json"))) {
|
|
252
|
+
writeFileSync(join3(workspace, "package.json"), '{ "type": "module" }\n');
|
|
253
|
+
}
|
|
254
|
+
const nodeTypesDirectory = dirname(packageRequire.resolve("@types/node/package.json"));
|
|
255
|
+
writeFileSync(
|
|
256
|
+
join3(workspace, "tsconfig.json"),
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
compilerOptions: {
|
|
259
|
+
lib: ["es2023"],
|
|
260
|
+
module: "esnext",
|
|
261
|
+
moduleResolution: "bundler",
|
|
262
|
+
noEmit: true,
|
|
263
|
+
noUncheckedIndexedAccess: true,
|
|
264
|
+
strict: true,
|
|
265
|
+
target: "es2023",
|
|
266
|
+
typeRoots: [dirname(nodeTypesDirectory)],
|
|
267
|
+
types: ["node"]
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
const typescriptBinary = packageRequire.resolve("typescript/bin/tsc");
|
|
272
|
+
const result = spawnSync2(typescriptBinary, ["-p", workspace], {
|
|
273
|
+
encoding: "utf8",
|
|
274
|
+
timeout: 12e4
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
detail: result.status === 0 ? "compiles strict" : (result.stdout ?? "").slice(0, 500),
|
|
278
|
+
name: "tsc",
|
|
279
|
+
pass: result.status === 0
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/case-contract.ts
|
|
284
|
+
var classificationSchema = Type2.Enum(["capability", "preference"]);
|
|
285
|
+
var nonEmptyStringSchema = Type2.String({ minLength: 1, pattern: String.raw`\S` });
|
|
286
|
+
var stringArraySchema = Type2.Readonly(Type2.Array(Type2.String()));
|
|
287
|
+
var armSchema = Type2.Enum(["baseline", "skill"]);
|
|
288
|
+
var fixtureSchema = Type2.ReadonlyObject(
|
|
289
|
+
Type2.Object({
|
|
290
|
+
path: Type2.Optional(
|
|
291
|
+
Type2.String({
|
|
292
|
+
description: "Directory relative to skillval.yml, copied into the workspace.",
|
|
293
|
+
minLength: 1,
|
|
294
|
+
pattern: String.raw`\S`
|
|
295
|
+
})
|
|
296
|
+
),
|
|
297
|
+
setup: Type2.Optional(
|
|
298
|
+
Type2.Readonly(
|
|
299
|
+
Type2.Array(nonEmptyStringSchema, {
|
|
300
|
+
description: "Shell commands run sequentially inside the workspace after the copy."
|
|
301
|
+
})
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
}),
|
|
305
|
+
{ additionalProperties: false, minProperties: 1 }
|
|
306
|
+
);
|
|
307
|
+
var caseAssertSchema = Type2.ReadonlyObject(
|
|
308
|
+
Type2.Object({
|
|
309
|
+
graders: Type2.Optional(
|
|
310
|
+
Type2.Readonly(Type2.Array(Type2.Enum(GRADER_NAMES), { uniqueItems: true }))
|
|
311
|
+
),
|
|
312
|
+
must_match: Type2.Optional(stringArraySchema),
|
|
313
|
+
must_not_match: Type2.Optional(stringArraySchema)
|
|
314
|
+
}),
|
|
315
|
+
{ additionalProperties: false }
|
|
316
|
+
);
|
|
317
|
+
var evalCaseSchema = Type2.ReadonlyObject(
|
|
318
|
+
Type2.Object({
|
|
319
|
+
arms: Type2.Optional(
|
|
320
|
+
Type2.Readonly(
|
|
321
|
+
Type2.Array(armSchema, {
|
|
322
|
+
uniqueItems: true
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
),
|
|
326
|
+
assert: Type2.Optional(caseAssertSchema),
|
|
327
|
+
fixture: Type2.Optional(fixtureSchema),
|
|
328
|
+
id: nonEmptyStringSchema,
|
|
329
|
+
mode: Type2.Enum(["generation", "trigger"]),
|
|
330
|
+
prompt: nonEmptyStringSchema,
|
|
331
|
+
rule: Type2.Optional(Type2.String()),
|
|
332
|
+
should_trigger: Type2.Optional(Type2.Boolean()),
|
|
333
|
+
trials: Type2.Optional(Type2.Integer({ maximum: 5, minimum: 1 })),
|
|
334
|
+
type: Type2.Optional(classificationSchema)
|
|
335
|
+
}),
|
|
336
|
+
{ additionalProperties: false }
|
|
337
|
+
);
|
|
338
|
+
var skillEvalsSchema = Type2.ReadonlyObject(
|
|
339
|
+
Type2.Object({
|
|
340
|
+
cases: Type2.Readonly(Type2.Array(evalCaseSchema)),
|
|
341
|
+
class: classificationSchema,
|
|
342
|
+
fixture: Type2.Optional(fixtureSchema),
|
|
343
|
+
skill: nonEmptyStringSchema
|
|
344
|
+
}),
|
|
345
|
+
{
|
|
346
|
+
$id: "https://raw.githubusercontent.com/dungle-scrubs/skillval/main/schemas/skillval.schema.json",
|
|
347
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
348
|
+
additionalProperties: false,
|
|
349
|
+
title: "skillval case file"
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
var CaseContractError = class extends Error {
|
|
353
|
+
code;
|
|
354
|
+
constructor(message, code = "CASE_FILE_INVALID") {
|
|
355
|
+
super(message);
|
|
356
|
+
this.code = code;
|
|
357
|
+
this.name = "CaseContractError";
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
function parseCaseValue(value, path, expectedSkill) {
|
|
361
|
+
if (!checkSchema2(skillEvalsSchema, value)) {
|
|
362
|
+
const [firstError] = schemaErrors2(skillEvalsSchema, value);
|
|
363
|
+
const location = firstError?.instancePath.replaceAll("/", ".").replace(/^\./, "");
|
|
364
|
+
if (location?.endsWith(".trials")) {
|
|
365
|
+
const match = /cases\.(\d+)\.trials$/.exec(location);
|
|
366
|
+
const caseIndex = match?.[1] ?? "unknown";
|
|
367
|
+
throw new CaseContractError(
|
|
368
|
+
`${path} case at index ${caseIndex} trials must be an integer from 1 through 5`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const subject = location === void 0 || location === "" ? path : `${path} ${location}`;
|
|
372
|
+
throw new CaseContractError(`${subject} ${firstError?.message ?? "is invalid"}`);
|
|
373
|
+
}
|
|
374
|
+
if (expectedSkill !== void 0 && value.skill !== expectedSkill) {
|
|
375
|
+
throw new CaseContractError(
|
|
376
|
+
`${path} declares skill "${value.skill}", expected "${expectedSkill}"`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const ids = /* @__PURE__ */ new Set();
|
|
380
|
+
for (const evalCase of value.cases) {
|
|
381
|
+
if (ids.has(evalCase.id)) {
|
|
382
|
+
throw new CaseContractError(`${path} case id "${evalCase.id}" is duplicated`);
|
|
383
|
+
}
|
|
384
|
+
ids.add(evalCase.id);
|
|
385
|
+
validatePatterns(evalCase, path);
|
|
386
|
+
validateGraders(evalCase, path);
|
|
387
|
+
}
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
function validateGraders(evalCase, path) {
|
|
391
|
+
for (const grader of evalCase.assert?.graders ?? []) {
|
|
392
|
+
if (!graderSupportsMode(grader, evalCase.mode)) {
|
|
393
|
+
throw new CaseContractError(
|
|
394
|
+
`${path} case "${evalCase.id}" grader "${grader}" does not support ${evalCase.mode} mode`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function validatePatterns(evalCase, path) {
|
|
400
|
+
const fields = ["must_match", "must_not_match"];
|
|
401
|
+
for (const field of fields) {
|
|
402
|
+
for (const pattern of evalCase.assert?.[field] ?? []) {
|
|
403
|
+
try {
|
|
404
|
+
new RegExp(pattern, "m");
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
407
|
+
throw new CaseContractError(
|
|
408
|
+
`${path} case "${evalCase.id}" has invalid ${field} regex "${pattern}": ${detail}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/fixture.ts
|
|
416
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
417
|
+
import { createHash as createHash2 } from "crypto";
|
|
418
|
+
import { cpSync, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
|
|
419
|
+
import { basename, join as join4 } from "path";
|
|
420
|
+
var SETUP_COMMAND_TIMEOUT_MS = 6e4;
|
|
421
|
+
var FixtureSetupError = class extends Error {
|
|
422
|
+
results;
|
|
423
|
+
constructor(message, results) {
|
|
424
|
+
super(message);
|
|
425
|
+
this.name = "FixtureSetupError";
|
|
426
|
+
this.results = results;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
function selectFixture(caseFixture, suiteFixture) {
|
|
430
|
+
return caseFixture ?? suiteFixture;
|
|
431
|
+
}
|
|
432
|
+
function resolveFixture(fixture, baseDirectory) {
|
|
433
|
+
if (fixture === void 0) return void 0;
|
|
434
|
+
const directory = fixture.path === void 0 ? void 0 : join4(baseDirectory, fixture.path);
|
|
435
|
+
const setup = fixture.setup ?? [];
|
|
436
|
+
return { directory, hash: fixtureIdentityHash(directory, setup), setup };
|
|
437
|
+
}
|
|
438
|
+
function walkFixtureEntries(directory) {
|
|
439
|
+
const entries = [];
|
|
440
|
+
function visit(current, prefix) {
|
|
441
|
+
const dirents = [...readdirSync2(current, { withFileTypes: true })].sort(
|
|
442
|
+
(left, right) => left.name.localeCompare(right.name)
|
|
443
|
+
);
|
|
444
|
+
for (const dirent of dirents) {
|
|
445
|
+
if (SKIPPED_DIRECTORIES.has(dirent.name)) continue;
|
|
446
|
+
const absolutePath = join4(current, dirent.name);
|
|
447
|
+
const path = prefix === "" ? dirent.name : `${prefix}/${dirent.name}`;
|
|
448
|
+
if (dirent.isSymbolicLink()) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`contains unsupported symlink "${path}"; create links with setup commands instead`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (dirent.isDirectory()) {
|
|
454
|
+
entries.push({ absolutePath, executable: false, path, type: "directory" });
|
|
455
|
+
visit(absolutePath, path);
|
|
456
|
+
} else if (dirent.isFile()) {
|
|
457
|
+
const executable = (statSync(absolutePath).mode & 73) !== 0;
|
|
458
|
+
entries.push({ absolutePath, executable, path, type: "file" });
|
|
459
|
+
} else {
|
|
460
|
+
throw new Error(`contains unsupported special file "${path}"`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
visit(directory, "");
|
|
465
|
+
return entries;
|
|
466
|
+
}
|
|
467
|
+
function fixtureIdentityHash(directory, setup) {
|
|
468
|
+
const hash = createHash2("sha256");
|
|
469
|
+
if (directory !== void 0) {
|
|
470
|
+
for (const entry of walkFixtureEntries(directory)) {
|
|
471
|
+
if (entry.type === "directory") {
|
|
472
|
+
hash.update(`D\0${entry.path}\0`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const content = readFileSync4(entry.absolutePath);
|
|
476
|
+
hash.update(`F\0${entry.path}\0${entry.executable ? "x" : "-"}\0${content.byteLength}\0`);
|
|
477
|
+
hash.update(content);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
hash.update(`S\0${JSON.stringify(setup)}`);
|
|
481
|
+
return hash.digest("hex");
|
|
482
|
+
}
|
|
483
|
+
function applyFixture(fixture, workspace, home) {
|
|
484
|
+
if (fixture.directory !== void 0) {
|
|
485
|
+
cpSync(fixture.directory, workspace, {
|
|
486
|
+
filter: (source) => !SKIPPED_DIRECTORIES.has(basename(source)),
|
|
487
|
+
recursive: true,
|
|
488
|
+
verbatimSymlinks: true
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const results = [];
|
|
492
|
+
for (const command of fixture.setup) {
|
|
493
|
+
const outcome = spawnSync3(command, {
|
|
494
|
+
cwd: workspace,
|
|
495
|
+
encoding: "utf8",
|
|
496
|
+
env: { HOME: home, PATH: process.env.PATH ?? "" },
|
|
497
|
+
killSignal: "SIGKILL",
|
|
498
|
+
shell: true,
|
|
499
|
+
timeout: SETUP_COMMAND_TIMEOUT_MS
|
|
500
|
+
});
|
|
501
|
+
results.push({
|
|
502
|
+
command,
|
|
503
|
+
exitCode: outcome.status,
|
|
504
|
+
signal: outcome.signal ?? null,
|
|
505
|
+
stderr: outcome.stderr ?? "",
|
|
506
|
+
stdout: outcome.stdout ?? ""
|
|
507
|
+
});
|
|
508
|
+
if (outcome.error !== void 0 || outcome.status !== 0) {
|
|
509
|
+
const timedOut = outcome.error?.code === "ETIMEDOUT";
|
|
510
|
+
const reason = timedOut ? `timed out after ${SETUP_COMMAND_TIMEOUT_MS / 1e3}s` : outcome.error?.message ?? (outcome.signal !== null ? `terminated by ${outcome.signal}` : `exit code ${outcome.status}`);
|
|
511
|
+
throw new FixtureSetupError(`fixture setup failed: "${command}" (${reason})`, results);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return results;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/case-file.ts
|
|
518
|
+
var CaseFileError = class extends CaseContractError {
|
|
519
|
+
constructor(message, code = "CASE_FILE_INVALID") {
|
|
520
|
+
super(message, code);
|
|
521
|
+
this.name = "CaseFileError";
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
function readCaseFile(path, expectedSkill) {
|
|
525
|
+
const evals = parseCaseFile(readFileSync5(path, "utf8"), path, expectedSkill);
|
|
526
|
+
const baseDirectory = dirname2(path);
|
|
527
|
+
assertFixtureDirectory(evals.fixture, `${path} suite fixture`, baseDirectory);
|
|
528
|
+
for (const evalCase of evals.cases) {
|
|
529
|
+
assertFixtureDirectory(
|
|
530
|
+
evalCase.fixture,
|
|
531
|
+
`${path} case "${evalCase.id}" fixture`,
|
|
532
|
+
baseDirectory
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return evals;
|
|
536
|
+
}
|
|
537
|
+
function assertFixtureDirectory(fixture, subject, baseDirectory) {
|
|
538
|
+
if (fixture?.path === void 0) return;
|
|
539
|
+
const directory = join5(baseDirectory, fixture.path);
|
|
540
|
+
const stats = statSync2(directory, { throwIfNoEntry: false });
|
|
541
|
+
if (stats === void 0) {
|
|
542
|
+
throw new CaseFileError(
|
|
543
|
+
`${subject} path "${fixture.path}" does not exist`,
|
|
544
|
+
"CASE_FIXTURE_INVALID"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (!stats.isDirectory()) {
|
|
548
|
+
throw new CaseFileError(
|
|
549
|
+
`${subject} path "${fixture.path}" is not a directory`,
|
|
550
|
+
"CASE_FIXTURE_INVALID"
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
walkFixtureEntries(directory);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
557
|
+
throw new CaseFileError(`${subject} path "${fixture.path}" ${detail}`, "CASE_FIXTURE_INVALID");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function parseCaseFile(source, path = "skillval.yml", expectedSkill) {
|
|
561
|
+
let parsed;
|
|
562
|
+
try {
|
|
563
|
+
parsed = parseYaml2(source);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
566
|
+
throw new CaseFileError(`${path} YAML is invalid: ${detail}`, "CASE_FILE_YAML_INVALID");
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
return parseCaseValue(parsed, path, expectedSkill);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error instanceof CaseContractError) {
|
|
572
|
+
throw new CaseFileError(error.message, error.code);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/discovery.ts
|
|
579
|
+
function discoverSkills(roots) {
|
|
580
|
+
const missingRoots = [];
|
|
581
|
+
const skills = [];
|
|
582
|
+
for (const root of roots) {
|
|
583
|
+
if (!existsSync3(root)) {
|
|
584
|
+
missingRoots.push(root);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const entries = readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
588
|
+
for (const entry of entries) {
|
|
589
|
+
const skillDirectory = join6(root, entry.name);
|
|
590
|
+
if (!existsSync3(join6(skillDirectory, "SKILL.md"))) continue;
|
|
591
|
+
skills.push(describeSkill(entry.name, root, skillDirectory));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { missingRoots, skills };
|
|
595
|
+
}
|
|
596
|
+
function discoveryReport(discovery) {
|
|
597
|
+
return {
|
|
598
|
+
missingRoots: discovery.missingRoots,
|
|
599
|
+
skills: discovery.skills.map((skill) => {
|
|
600
|
+
if (skill.status !== "ready") return skill;
|
|
601
|
+
return {
|
|
602
|
+
caseCount: skill.caseCount,
|
|
603
|
+
class: skill.class,
|
|
604
|
+
hasSkillval: skill.hasSkillval,
|
|
605
|
+
name: skill.name,
|
|
606
|
+
root: skill.root,
|
|
607
|
+
skillDirectory: skill.skillDirectory,
|
|
608
|
+
status: skill.status,
|
|
609
|
+
validationError: skill.validationError
|
|
610
|
+
};
|
|
611
|
+
})
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function selectSkills(discovery, requestedNames) {
|
|
615
|
+
const byName = /* @__PURE__ */ new Map();
|
|
616
|
+
for (const skill of discovery.skills) {
|
|
617
|
+
if (!byName.has(skill.name)) byName.set(skill.name, skill);
|
|
618
|
+
}
|
|
619
|
+
if (requestedNames.length === 0) {
|
|
620
|
+
return [...byName.values()].filter(
|
|
621
|
+
(skill) => skill.status === "ready"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
return requestedNames.map((name) => {
|
|
625
|
+
const skill = byName.get(name);
|
|
626
|
+
if (skill === void 0) {
|
|
627
|
+
throw new Error(`skill "${name}" not found under configured roots`);
|
|
628
|
+
}
|
|
629
|
+
if (skill.status === "missing") {
|
|
630
|
+
throw new Error(`skill "${name}" has no skillval.yml`);
|
|
631
|
+
}
|
|
632
|
+
if (skill.status === "invalid") {
|
|
633
|
+
throw new Error(skill.validationError);
|
|
634
|
+
}
|
|
635
|
+
return skill;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
function describeSkill(name, root, skillDirectory) {
|
|
639
|
+
const caseFilePath = join6(skillDirectory, "skillval.yml");
|
|
640
|
+
if (!existsSync3(caseFilePath)) {
|
|
641
|
+
return {
|
|
642
|
+
caseCount: 0,
|
|
643
|
+
class: void 0,
|
|
644
|
+
hasSkillval: false,
|
|
645
|
+
name,
|
|
646
|
+
root,
|
|
647
|
+
skillDirectory,
|
|
648
|
+
status: "missing",
|
|
649
|
+
validationError: void 0
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
let evals;
|
|
653
|
+
try {
|
|
654
|
+
evals = readCaseFile(caseFilePath, name);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return {
|
|
657
|
+
caseCount: 0,
|
|
658
|
+
class: "invalid",
|
|
659
|
+
hasSkillval: true,
|
|
660
|
+
name,
|
|
661
|
+
root,
|
|
662
|
+
skillDirectory,
|
|
663
|
+
status: "invalid",
|
|
664
|
+
validationError: error instanceof Error ? error.message : String(error)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
caseCount: evals.cases.length,
|
|
669
|
+
class: evals.class,
|
|
670
|
+
evals,
|
|
671
|
+
hasSkillval: true,
|
|
672
|
+
name,
|
|
673
|
+
root,
|
|
674
|
+
skillDirectory,
|
|
675
|
+
status: "ready",
|
|
676
|
+
validationError: void 0
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/runner.ts
|
|
681
|
+
import { mkdirSync as mkdirSync3, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
682
|
+
import { tmpdir } from "os";
|
|
683
|
+
import { join as join8 } from "path";
|
|
684
|
+
|
|
685
|
+
// src/cache.ts
|
|
686
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
|
|
687
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
688
|
+
var RUNNER_VERSION = 6;
|
|
689
|
+
var ArmCache = class {
|
|
690
|
+
#stateDirectory;
|
|
691
|
+
constructor(stateDirectory = resolveStateDirectory()) {
|
|
692
|
+
this.#stateDirectory = stateDirectory;
|
|
693
|
+
}
|
|
694
|
+
lookup(identity) {
|
|
695
|
+
const path = this.#path(identity);
|
|
696
|
+
if (!existsSync4(path)) return void 0;
|
|
697
|
+
return { ...JSON.parse(readFileSync6(path, "utf8")), cached: true };
|
|
698
|
+
}
|
|
699
|
+
store(identity, result) {
|
|
700
|
+
const path = this.#path(identity);
|
|
701
|
+
mkdirSync2(dirname3(path), { recursive: true });
|
|
702
|
+
writeFileSync2(path, JSON.stringify(result));
|
|
703
|
+
}
|
|
704
|
+
#path(identity) {
|
|
705
|
+
const parts = [
|
|
706
|
+
String(RUNNER_VERSION),
|
|
707
|
+
identity.skillHash,
|
|
708
|
+
JSON.stringify(identity.evalCase),
|
|
709
|
+
identity.arm,
|
|
710
|
+
identity.executor.name,
|
|
711
|
+
identity.executor.version,
|
|
712
|
+
identity.executor.model
|
|
713
|
+
];
|
|
714
|
+
if (identity.fixtureHash !== void 0) parts.push(identity.fixtureHash);
|
|
715
|
+
const key = sha256(parts.join("\0"));
|
|
716
|
+
return join7(this.#stateDirectory, "cache", `${key}.json`);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// src/grade.ts
|
|
721
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
722
|
+
import { relative as relative2 } from "path";
|
|
723
|
+
var INJECTED_FILES = /* @__PURE__ */ new Set(["package.json", "tsconfig.json"]);
|
|
724
|
+
function gradeTrial(evalCase, arm, trace, workspace) {
|
|
725
|
+
const checks = [];
|
|
726
|
+
checks.push({
|
|
727
|
+
detail: trace.completed ? "turn.completed seen" : "no turn.completed in trace",
|
|
728
|
+
name: "trace",
|
|
729
|
+
pass: trace.completed
|
|
730
|
+
});
|
|
731
|
+
if (evalCase.should_trigger !== void 0 && arm === "skill") {
|
|
732
|
+
checks.push({
|
|
733
|
+
detail: `invoked=${trace.invoked}, expected=${evalCase.should_trigger}`,
|
|
734
|
+
name: "trigger",
|
|
735
|
+
pass: trace.invoked === evalCase.should_trigger
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
const gradedText = evalCase.mode === "generation" ? walkFiles(workspace).filter((file) => !INJECTED_FILES.has(relative2(workspace, file))).map((file) => `=== ${relative2(workspace, file)} ===
|
|
739
|
+
${readFileSync7(file, "utf8")}`).join("\n") : trace.agentText;
|
|
740
|
+
for (const pattern of evalCase.assert?.must_match ?? []) {
|
|
741
|
+
const pass = new RegExp(pattern, "m").test(gradedText);
|
|
742
|
+
checks.push({
|
|
743
|
+
detail: pass ? pattern : `${pattern} | got: ${gradedText.slice(0, 400)}`,
|
|
744
|
+
name: "must_match",
|
|
745
|
+
pass
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
for (const pattern of evalCase.assert?.must_not_match ?? []) {
|
|
749
|
+
checks.push({
|
|
750
|
+
detail: pattern,
|
|
751
|
+
name: "must_not_match",
|
|
752
|
+
pass: !new RegExp(pattern, "m").test(gradedText)
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
checks.push(...runGraders(evalCase, workspace));
|
|
756
|
+
return checks;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/vote.ts
|
|
760
|
+
function clampedTrialCount(configured) {
|
|
761
|
+
return Math.min(5, Math.max(1, configured ?? 1));
|
|
762
|
+
}
|
|
763
|
+
function hasMajority(trials) {
|
|
764
|
+
const passes = trials.filter((trial) => trial.pass).length;
|
|
765
|
+
return passes * 2 > trials.length;
|
|
766
|
+
}
|
|
767
|
+
function shouldEscalate(trials) {
|
|
768
|
+
return new Set(trials.map((trial) => trial.pass)).size > 1 && trials.length < 5;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/runner.ts
|
|
772
|
+
function runEvaluation(config, options, log) {
|
|
773
|
+
const discovery = discoverSkills(config.roots);
|
|
774
|
+
const selectedSkills = selectSkills(discovery, options.requestedSkills);
|
|
775
|
+
const executor = createExecutor(config.executor);
|
|
776
|
+
const stateDirectory = resolveStateDirectory();
|
|
777
|
+
const cache = new ArmCache(stateDirectory);
|
|
778
|
+
const skillInputs = selectedSkills.map((skill) => ({
|
|
779
|
+
contentHash: skillContentHash(skill.skillDirectory),
|
|
780
|
+
skill
|
|
781
|
+
}));
|
|
782
|
+
const runHash = participatingSkillsHash(
|
|
783
|
+
skillInputs.map(({ contentHash, skill }) => ({ contentHash, name: skill.name }))
|
|
784
|
+
);
|
|
785
|
+
const skillReports = {};
|
|
786
|
+
let failures = 0;
|
|
787
|
+
let noops = 0;
|
|
788
|
+
for (const { contentHash, skill } of skillInputs) {
|
|
789
|
+
const evals = skill.evals;
|
|
790
|
+
log(`${skill.name} (${evals.class}, ${contentHash.slice(0, 12)}):`);
|
|
791
|
+
const cases = evals.cases.filter((evalCase) => options.caseFilter === void 0 || evalCase.id === options.caseFilter).map(
|
|
792
|
+
(evalCase) => runCase(
|
|
793
|
+
{
|
|
794
|
+
cache,
|
|
795
|
+
executor,
|
|
796
|
+
skill,
|
|
797
|
+
skillHash: contentHash,
|
|
798
|
+
skipBaseline: options.skipBaseline,
|
|
799
|
+
useCache: options.useCache
|
|
800
|
+
},
|
|
801
|
+
evalCase,
|
|
802
|
+
log
|
|
803
|
+
)
|
|
804
|
+
);
|
|
805
|
+
skillReports[skill.name] = { cases, class: evals.class, contentHash };
|
|
806
|
+
failures += cases.filter((result) => !result.pass).length;
|
|
807
|
+
noops += cases.filter((result) => result.noop).length;
|
|
808
|
+
}
|
|
809
|
+
const report = {
|
|
810
|
+
executor: executor.metadata,
|
|
811
|
+
runHash,
|
|
812
|
+
skills: skillReports
|
|
813
|
+
};
|
|
814
|
+
const reportDirectory = join8(stateDirectory, "reports");
|
|
815
|
+
const reportPath = join8(reportDirectory, `${runHash}.json`);
|
|
816
|
+
mkdirSync3(reportDirectory, { recursive: true });
|
|
817
|
+
writeFileSync3(reportPath, JSON.stringify(report, null, 2));
|
|
818
|
+
return { failures, noops, report, reportPath };
|
|
819
|
+
}
|
|
820
|
+
function participatingSkillsHash(skills) {
|
|
821
|
+
return sha256(
|
|
822
|
+
[...skills].sort((left, right) => left.name.localeCompare(right.name)).map(({ contentHash, name }) => `${name}\0${contentHash}`).join("\0")
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
function runCase(context, evalCase, log) {
|
|
826
|
+
const arms = (evalCase.arms ?? ["skill"]).filter(
|
|
827
|
+
(arm) => arm === "skill" || !context.skipBaseline
|
|
828
|
+
);
|
|
829
|
+
const fixture = resolveFixture(
|
|
830
|
+
selectFixture(evalCase.fixture, context.skill.evals.fixture),
|
|
831
|
+
context.skill.skillDirectory
|
|
832
|
+
);
|
|
833
|
+
const results = arms.map((arm) => {
|
|
834
|
+
log(` ${evalCase.id} [${arm}] ...`);
|
|
835
|
+
const result = runArm(
|
|
836
|
+
{
|
|
837
|
+
cache: context.cache,
|
|
838
|
+
evalCase,
|
|
839
|
+
executor: context.executor,
|
|
840
|
+
fixture,
|
|
841
|
+
skill: context.skill,
|
|
842
|
+
skillHash: context.skillHash,
|
|
843
|
+
useCache: context.useCache
|
|
844
|
+
},
|
|
845
|
+
arm
|
|
846
|
+
);
|
|
847
|
+
log(
|
|
848
|
+
` ${evalCase.id} [${arm}] ${result.pass ? "pass" : "FAIL"}${result.cached ? " (cached)" : ""}`
|
|
849
|
+
);
|
|
850
|
+
return result;
|
|
851
|
+
});
|
|
852
|
+
return {
|
|
853
|
+
arms: results,
|
|
854
|
+
id: evalCase.id,
|
|
855
|
+
noop: results.find((result) => result.arm === "baseline")?.pass === true,
|
|
856
|
+
pass: results.find((result) => result.arm === "skill")?.pass === true,
|
|
857
|
+
rule: evalCase.rule
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function runArm(context, arm) {
|
|
861
|
+
const identity = {
|
|
862
|
+
arm,
|
|
863
|
+
evalCase: context.evalCase,
|
|
864
|
+
executor: context.executor.metadata,
|
|
865
|
+
fixtureHash: context.fixture?.hash,
|
|
866
|
+
skillHash: context.skillHash
|
|
867
|
+
};
|
|
868
|
+
if (context.useCache) {
|
|
869
|
+
const hit = context.cache.lookup(identity);
|
|
870
|
+
if (hit !== void 0) return hit;
|
|
871
|
+
}
|
|
872
|
+
const trials = [];
|
|
873
|
+
const wanted = clampedTrialCount(context.evalCase.trials);
|
|
874
|
+
for (let index = 0; index < wanted; index += 1) {
|
|
875
|
+
trials.push(runTrial(context, arm));
|
|
876
|
+
}
|
|
877
|
+
while (shouldEscalate(trials)) trials.push(runTrial(context, arm));
|
|
878
|
+
const result = {
|
|
879
|
+
arm,
|
|
880
|
+
cached: false,
|
|
881
|
+
pass: hasMajority(trials),
|
|
882
|
+
trials
|
|
883
|
+
};
|
|
884
|
+
context.cache.store(identity, result);
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
function runTrial(context, arm) {
|
|
888
|
+
const workspace = mkdtempSync(join8(tmpdir(), `skillval-${context.evalCase.id}-`));
|
|
889
|
+
const trialHome = mkdtempSync(join8(tmpdir(), "skillval-home-"));
|
|
890
|
+
try {
|
|
891
|
+
const fixtureSetup = context.fixture === void 0 ? void 0 : applyFixture(context.fixture, workspace, trialHome);
|
|
892
|
+
const trace = context.executor.runTrial({
|
|
893
|
+
arm,
|
|
894
|
+
evalCase: context.evalCase,
|
|
895
|
+
home: trialHome,
|
|
896
|
+
skillDirectory: context.skill.skillDirectory,
|
|
897
|
+
skillName: context.skill.name,
|
|
898
|
+
workspace
|
|
899
|
+
});
|
|
900
|
+
const checks = gradeTrial(context.evalCase, arm, trace, workspace);
|
|
901
|
+
return {
|
|
902
|
+
checks,
|
|
903
|
+
fixtureSetup,
|
|
904
|
+
pass: checks.every((check) => check.pass),
|
|
905
|
+
usage: trace.usage
|
|
906
|
+
};
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (error instanceof FixtureSetupError) {
|
|
909
|
+
return {
|
|
910
|
+
checks: [{ detail: error.message, name: "fixture-setup", pass: false }],
|
|
911
|
+
fixtureSetup: error.results,
|
|
912
|
+
pass: false,
|
|
913
|
+
usage: void 0
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
917
|
+
return {
|
|
918
|
+
checks: [{ detail, name: "run", pass: false }],
|
|
919
|
+
pass: false,
|
|
920
|
+
usage: void 0
|
|
921
|
+
};
|
|
922
|
+
} finally {
|
|
923
|
+
rmSync(workspace, { force: true, recursive: true });
|
|
924
|
+
rmSync(trialHome, { force: true, recursive: true });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/cli.ts
|
|
929
|
+
var program = new Command();
|
|
930
|
+
program.name("skillval").description("Evaluate agent skills with deterministic graders").configureHelp({ showGlobalOptions: true }).version("0.1.0").option("--config <path>", "read configuration from this path");
|
|
931
|
+
program.command("run").description(
|
|
932
|
+
"Run selected cases and return a report whose exit status fails when any skill arm fails"
|
|
933
|
+
).argument("[skill...]", "skill names; omit to run every skill with skillval.yml").option("--case <id>", "run only the case with this id").option("--no-cache", "ignore cached arm results").option("--skip-baseline", "do not run baseline arms").option("--json", "return the complete report as JSON").action((skills, options, command) => {
|
|
934
|
+
const globalOptions = command.optsWithGlobals();
|
|
935
|
+
const configPath = resolveConfigPath({ cliPath: globalOptions.config });
|
|
936
|
+
const config = loadConfig(configPath);
|
|
937
|
+
const outcome = runEvaluation(
|
|
938
|
+
config,
|
|
939
|
+
{
|
|
940
|
+
caseFilter: options.case,
|
|
941
|
+
requestedSkills: skills,
|
|
942
|
+
skipBaseline: options.skipBaseline === true,
|
|
943
|
+
useCache: options.cache
|
|
944
|
+
},
|
|
945
|
+
options.json === true ? () => void 0 : (message) => console.log(message)
|
|
946
|
+
);
|
|
947
|
+
if (options.json === true) {
|
|
948
|
+
console.log(JSON.stringify(outcome.report, null, 2));
|
|
949
|
+
} else {
|
|
950
|
+
console.log(`report: ${outcome.reportPath}`);
|
|
951
|
+
if (outcome.noops > 0) {
|
|
952
|
+
console.log(`no-op alert: ${outcome.noops} case(s) pass at baseline - prune candidates`);
|
|
953
|
+
}
|
|
954
|
+
console.log(
|
|
955
|
+
outcome.failures === 0 ? "all cases passed" : `${outcome.failures} case(s) FAILED`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
process.exitCode = outcome.failures > 0 ? 1 : 0;
|
|
959
|
+
});
|
|
960
|
+
program.command("list").description(
|
|
961
|
+
"Return discovered skills, evaluation metadata, and configured roots that are missing"
|
|
962
|
+
).option("--json", "return the complete discovery result as JSON").action((options, command) => {
|
|
963
|
+
const globalOptions = command.optsWithGlobals();
|
|
964
|
+
const configPath = resolveConfigPath({ cliPath: globalOptions.config });
|
|
965
|
+
const config = loadConfig(configPath);
|
|
966
|
+
const discovery = discoverSkills(config.roots);
|
|
967
|
+
if (options.json === true) {
|
|
968
|
+
console.log(JSON.stringify(discoveryReport(discovery), null, 2));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
printSkillTable(discovery.skills);
|
|
972
|
+
for (const skill of discovery.skills) {
|
|
973
|
+
if (skill.status === "invalid") console.log(`invalid skill: ${skill.validationError}`);
|
|
974
|
+
}
|
|
975
|
+
for (const root of discovery.missingRoots) console.log(`missing root: ${root}`);
|
|
976
|
+
});
|
|
977
|
+
async function main() {
|
|
978
|
+
try {
|
|
979
|
+
await program.parseAsync(process.argv);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
982
|
+
process.exitCode = 1;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function printSkillTable(skills) {
|
|
986
|
+
const rows = [
|
|
987
|
+
["NAME", "ROOT", "CLASS", "CASES", "HAS-SKILLVAL.YML"],
|
|
988
|
+
...skills.map((skill) => [
|
|
989
|
+
skill.name,
|
|
990
|
+
skill.root,
|
|
991
|
+
skill.class ?? "-",
|
|
992
|
+
String(skill.caseCount),
|
|
993
|
+
skill.hasSkillval ? "true" : "false"
|
|
994
|
+
])
|
|
995
|
+
];
|
|
996
|
+
const widths = rows[0]?.map(
|
|
997
|
+
(_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))
|
|
998
|
+
);
|
|
999
|
+
for (const row of rows) {
|
|
1000
|
+
console.log(
|
|
1001
|
+
row.map((cell, column) => cell.padEnd(widths?.[column] ?? cell.length)).join(" ").trimEnd()
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
await main();
|
|
1006
|
+
export {
|
|
1007
|
+
main
|
|
1008
|
+
};
|
|
1009
|
+
//# sourceMappingURL=cli.js.map
|