agent-skill-evals 0.1.0 → 0.1.1

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.
Files changed (40) hide show
  1. package/README.md +14 -145
  2. package/dist/agent/index.d.mts +2 -3
  3. package/dist/agent/index.mjs +1 -1
  4. package/dist/agent-B15R7Xlr.mjs +2105 -0
  5. package/dist/agent-B15R7Xlr.mjs.map +1 -0
  6. package/dist/assertions/index.d.mts +6 -0
  7. package/dist/assertions/index.d.mts.map +1 -1
  8. package/dist/assertions/index.mjs +164 -449
  9. package/dist/assertions/index.mjs.map +1 -1
  10. package/dist/catalog-9Wh-AfRv.mjs +409 -0
  11. package/dist/catalog-9Wh-AfRv.mjs.map +1 -0
  12. package/dist/cli/init.d.mts +22 -0
  13. package/dist/cli/init.d.mts.map +1 -0
  14. package/dist/cli/init.mjs +369 -0
  15. package/dist/cli/init.mjs.map +1 -0
  16. package/dist/index-D9lGL0Qg.d.mts +381 -0
  17. package/dist/index-D9lGL0Qg.d.mts.map +1 -0
  18. package/dist/presets-DwfDWckA.mjs +80 -0
  19. package/dist/presets-DwfDWckA.mjs.map +1 -0
  20. package/dist/test-generator/index.d.mts +945 -0
  21. package/dist/test-generator/index.d.mts.map +1 -0
  22. package/dist/test-generator/index.mjs +2 -0
  23. package/dist/test-pack-Cu1WOssl.mjs +215 -0
  24. package/dist/test-pack-Cu1WOssl.mjs.map +1 -0
  25. package/package.json +16 -9
  26. package/schema/test-pack.schema.json +871 -0
  27. package/dist/agent-CM7fIL_C.mjs +0 -1525
  28. package/dist/agent-CM7fIL_C.mjs.map +0 -1
  29. package/dist/assertion-entries-CfmNt-fp.d.mts +0 -9
  30. package/dist/assertion-entries-CfmNt-fp.d.mts.map +0 -1
  31. package/dist/index-4l7TCFny.d.mts +0 -90
  32. package/dist/index-4l7TCFny.d.mts.map +0 -1
  33. package/dist/internal-services-5-mRgNls.mjs +0 -226
  34. package/dist/internal-services-5-mRgNls.mjs.map +0 -1
  35. package/dist/internal-services-DbsekQ_K.d.mts +0 -76
  36. package/dist/internal-services-DbsekQ_K.d.mts.map +0 -1
  37. package/dist/skill-checks/index.d.mts +0 -113
  38. package/dist/skill-checks/index.d.mts.map +0 -1
  39. package/dist/skill-checks/index.mjs +0 -408
  40. package/dist/skill-checks/index.mjs.map +0 -1
@@ -1,66 +1,74 @@
1
- import { c as parseRuntimeTestFields, n as FileSystem, r as NodeServicesLive, t as Environment } from "../internal-services-5-mRgNls.mjs";
2
- import { a as writeEvidenceToEffect, c as getRuntimeCheck, i as evidenceFromSnapshot, n as makeWorldHandle, o as decodeEvidenceSnapshotEither, r as EvidenceCollector, s as RuntimeCheckCatalogLive } from "../agent-CM7fIL_C.mjs";
1
+ import { a as EvidenceCollector, c as decodeEvidenceSnapshot, i as makeWorldHandle, l as parseRuntimeTestFields, n as assertMockServicesHealthy, o as evidenceFromSnapshot, r as stopMockServices, s as writeEvidenceTo } from "../agent-B15R7Xlr.mjs";
2
+ import { n as RUNTIME_CHECKS_BY_TYPE } from "../catalog-9Wh-AfRv.mjs";
3
3
  import { join } from "node:path";
4
- import * as Either from "effect/Either";
5
- import * as Effect from "effect/Effect";
6
- import * as Schema from "effect/Schema";
4
+ import { readFile } from "node:fs/promises";
7
5
  //#region src/assertions/_shared.ts
8
- function loadMetadataEffect(context) {
9
- return Effect.gen(function* () {
10
- const direct = context.providerResponse?.metadata;
11
- if (direct && typeof direct === "object" && "worldPath" in direct) return direct;
12
- const runDir = (yield* (yield* Environment).env).AGENT_SKILL_EVALS_RUN_DIR;
13
- if (runDir) {
14
- const parsed = yield* (yield* FileSystem).readText(join(runDir, "agent-skill-evals-meta.json")).pipe(Effect.map((buf) => {
15
- try {
16
- return JSON.parse(buf);
17
- } catch {
18
- return null;
19
- }
20
- }), Effect.catchAll(() => Effect.succeed(null)));
21
- if (parsed) return parsed;
22
- }
6
+ async function loadMetadata(context) {
7
+ const direct = context.providerResponse?.metadata;
8
+ if (direct && typeof direct === "object" && "worldPath" in direct) return direct;
9
+ const runDir = process.env.AGENT_SKILL_EVALS_RUN_DIR;
10
+ if (runDir) try {
11
+ const buf = await readFile(join(runDir, "agent-skill-evals-meta.json"), "utf8");
12
+ return JSON.parse(buf);
13
+ } catch {
23
14
  return null;
24
- });
15
+ }
16
+ return null;
25
17
  }
26
- function loadEvidenceEffect(meta) {
27
- return Effect.gen(function* () {
28
- const buf = yield* (yield* FileSystem).readText(meta.evidencePath).pipe(Effect.mapError((err) => /* @__PURE__ */ new Error(`evidence: failed to read ${meta.evidencePath}: ${err instanceof Error ? err.message : String(err)}`)));
29
- const decoded = decodeEvidenceSnapshotEither(yield* Effect.try({
30
- try: () => JSON.parse(buf),
31
- catch: (err) => /* @__PURE__ */ new Error(`evidence: invalid JSON in ${meta.evidencePath}: ${err instanceof Error ? err.message : String(err)}`)
32
- }));
33
- if (Either.isLeft(decoded)) return yield* Effect.fail(/* @__PURE__ */ new Error(`evidence: invalid agent-skill-evals.evidence.v1 payload: ${decoded.left.message}`));
34
- const snapshot = decoded.right;
35
- return EvidenceCollector.fromSnapshot(snapshot);
36
- });
18
+ async function loadEvidence(meta) {
19
+ let buf;
20
+ try {
21
+ buf = await readFile(meta.evidencePath, "utf8");
22
+ } catch (err) {
23
+ throw new Error(`evidence: failed to read ${meta.evidencePath}: ${err instanceof Error ? err.message : String(err)}`);
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(buf);
28
+ } catch (err) {
29
+ throw new Error(`evidence: invalid JSON in ${meta.evidencePath}: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ const decoded = decodeEvidenceSnapshot(parsed);
32
+ if (!decoded.ok) throw new Error(`evidence: ${decoded.error.message}`);
33
+ return EvidenceCollector.fromSnapshot(decoded.value);
37
34
  }
38
35
  function loadWorld(meta, evidenceCollector) {
39
- return makeWorldHandle(meta.worldPath, (event) => evidenceCollector.addCommand(event));
36
+ const world = makeWorldHandle(meta.worldPath, (event) => evidenceCollector.addCommand(event));
37
+ if (!meta.mockEnv) return world;
38
+ return {
39
+ ...world,
40
+ exec(command, args, opts = {}) {
41
+ return world.exec(command, args, {
42
+ ...opts,
43
+ env: {
44
+ ...meta.mockEnv,
45
+ ...opts.env ?? {}
46
+ }
47
+ });
48
+ }
49
+ };
40
50
  }
41
- function runEntriesEffect(entries, world, evidenceCollector, mode) {
42
- return Effect.gen(function* () {
43
- const results = [];
44
- for (const entry of entries) {
45
- const plugin = yield* getRuntimeCheck(entry.type);
46
- if (!plugin) {
47
- results.push({
48
- pass: false,
49
- score: 0,
50
- reason: `unknown effect type: ${entry.type}`
51
- });
52
- continue;
53
- }
54
- const r = yield* plugin.verify({
55
- assertion: entry.args,
56
- world,
57
- evidence: evidenceFromSnapshot(evidenceCollector.toSnapshot()),
58
- mode
51
+ async function runEntries(entries, world, evidenceCollector, mode) {
52
+ const results = [];
53
+ for (const entry of entries) {
54
+ const plugin = RUNTIME_CHECKS_BY_TYPE.get(entry.type);
55
+ if (!plugin) {
56
+ results.push({
57
+ pass: false,
58
+ score: 0,
59
+ reason: `unknown effect type: ${entry.type}`
59
60
  });
60
- results.push(r);
61
+ continue;
61
62
  }
62
- return results;
63
- });
63
+ const r = await plugin.verify({
64
+ assertion: entry.args,
65
+ world,
66
+ evidence: evidenceFromSnapshot(evidenceCollector.toSnapshot()),
67
+ mode
68
+ });
69
+ results.push(r);
70
+ }
71
+ return results;
64
72
  }
65
73
  function aggregate(results, emptyReason, options = {}) {
66
74
  if (results.length === 0) {
@@ -86,47 +94,82 @@ function aggregate(results, emptyReason, options = {}) {
86
94
  }
87
95
  //#endregion
88
96
  //#region src/assertions/skill-test.ts
89
- async function skillTest(_output, context) {
90
- return Effect.runPromise(skillTestEffect(_output, context).pipe(Effect.provide(RuntimeCheckCatalogLive), Effect.provide(NodeServicesLive)));
97
+ function withEvidencePointer(result, meta) {
98
+ if (result.pass) return result;
99
+ return {
100
+ ...result,
101
+ reason: `${result.reason}\nEvidence: ${meta.evidencePath}\nWorld: ${meta.worldPath}`
102
+ };
91
103
  }
92
- function skillTestEffect(_output, context) {
93
- return Effect.gen(function* () {
94
- const meta = yield* loadMetadataEffect(context);
95
- if (!meta) return {
104
+ async function skillTest(_output, context) {
105
+ const meta = await loadMetadata(context);
106
+ if (!meta) return {
107
+ pass: false,
108
+ score: 0,
109
+ reason: "skill.test: provider metadata missing"
110
+ };
111
+ const preconditionResults = meta.preconditionResults ?? [];
112
+ if (!meta.preconditionsPassed) {
113
+ const result = withEvidencePointer(aggregate(preconditionResults, "skill.test: preconditions failed"), meta);
114
+ await stopMockServices(meta.runDir);
115
+ return result;
116
+ }
117
+ const vars = context.vars ?? context.test?.vars ?? {};
118
+ let evidenceCollector;
119
+ try {
120
+ evidenceCollector = await loadEvidence(meta);
121
+ } catch (err) {
122
+ await stopMockServices(meta.runDir);
123
+ return {
96
124
  pass: false,
97
125
  score: 0,
98
- reason: "skill.test: provider metadata missing"
126
+ reason: err instanceof Error ? err.message : String(err)
99
127
  };
100
- const preconditionResults = meta.preconditionResults ?? [];
101
- if (!meta.preconditionsPassed) return aggregate(preconditionResults, "skill.test: preconditions failed");
102
- const vars = context.vars ?? context.test?.vars ?? {};
103
- const loadedEvidence = yield* Effect.either(loadEvidenceEffect(meta));
104
- if (Either.isLeft(loadedEvidence)) {
105
- const err = loadedEvidence.left;
106
- return {
107
- pass: false,
108
- score: 0,
109
- reason: err instanceof Error ? err.message : String(err)
110
- };
111
- }
112
- const evidenceCollector = loadedEvidence.right;
113
- const world = loadWorld(meta, evidenceCollector);
114
- const parsed = parseRuntimeTestFields(vars);
115
- const parseResults = parsed.errors.filter((error) => error.field !== "preconditions").map((error) => ({
128
+ }
129
+ try {
130
+ assertMockServicesHealthy(meta.runDir);
131
+ } catch (err) {
132
+ await stopMockServices(meta.runDir);
133
+ return withEvidencePointer({
116
134
  pass: false,
117
135
  score: 0,
118
- reason: `runtime test field ${error.index === void 0 ? error.field : `${error.field}[${error.index}]`}: ${error.reason}`
119
- }));
120
- const shouldResults = yield* runEntriesEffect(parsed.should, world, evidenceCollector, "should");
121
- const shouldNotResults = yield* runEntriesEffect(parsed.should_not, world, evidenceCollector, "should_not");
122
- yield* writeEvidenceToEffect(evidenceCollector, meta.runDir).pipe(Effect.orDie);
123
- return aggregate([
124
- ...preconditionResults,
125
- ...parseResults,
126
- ...shouldResults,
127
- ...shouldNotResults
128
- ], "skill.test: no Runtime Test Fields checks declared", { emptyPass: false });
129
- });
136
+ reason: `Mock Service failure: ${err instanceof Error ? err.message : String(err)}`
137
+ }, meta);
138
+ }
139
+ const world = loadWorld(meta, evidenceCollector);
140
+ const warningResults = evidenceCollector.toSnapshot().warnings?.map((warning) => ({
141
+ pass: false,
142
+ score: 0,
143
+ reason: `evidence warning: ${warning}`
144
+ })) ?? [];
145
+ const parsed = parseRuntimeTestFields(vars);
146
+ const parseResults = parsed.errors.filter((error) => error.field !== "preconditions").map((error) => ({
147
+ pass: false,
148
+ score: 0,
149
+ reason: `runtime test field ${error.index === void 0 ? error.field : `${error.field}[${error.index}]`}: ${error.reason}`
150
+ }));
151
+ const expectationResults = await runEntries(parsed.expect, world, evidenceCollector, "should");
152
+ let writeError;
153
+ try {
154
+ await writeEvidenceTo(evidenceCollector, meta.runDir);
155
+ } catch (err) {
156
+ writeError = err instanceof Error ? err.message : String(err);
157
+ }
158
+ const graded = withEvidencePointer(aggregate([
159
+ ...preconditionResults,
160
+ ...warningResults,
161
+ ...parseResults,
162
+ ...expectationResults
163
+ ], "skill.test: no Runtime Test Fields checks declared", { emptyPass: false }), meta);
164
+ if (writeError !== void 0) {
165
+ await stopMockServices(meta.runDir);
166
+ return {
167
+ ...graded,
168
+ reason: `${graded.reason}\n(note: failed to update evidence.json: ${writeError})`
169
+ };
170
+ }
171
+ await stopMockServices(meta.runDir);
172
+ return graded;
130
173
  }
131
174
  //#endregion
132
175
  //#region src/assertions/skill-budget.ts
@@ -136,20 +179,20 @@ const budgetFields = [
136
179
  ["completion", "maxCompletionTokens"],
137
180
  ["cached", "maxCachedTokens"]
138
181
  ];
139
- function configObject$1(value) {
182
+ function configObject(value) {
140
183
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
141
184
  }
142
- function numberSetting$1(config, key) {
185
+ function numberSetting(config, key) {
143
186
  const value = config[key];
144
187
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
145
188
  }
146
189
  function budgetSettings(context) {
147
- const config = configObject$1(context.config?.agentSkillEvals);
190
+ const config = configObject(context.config?.agentSkillEvals);
148
191
  return {
149
- maxTotalTokens: numberSetting$1(config, "maxTotalTokens"),
150
- maxPromptTokens: numberSetting$1(config, "maxPromptTokens"),
151
- maxCompletionTokens: numberSetting$1(config, "maxCompletionTokens"),
152
- maxCachedTokens: numberSetting$1(config, "maxCachedTokens")
192
+ maxTotalTokens: numberSetting(config, "maxTotalTokens"),
193
+ maxPromptTokens: numberSetting(config, "maxPromptTokens"),
194
+ maxCompletionTokens: numberSetting(config, "maxCompletionTokens"),
195
+ maxCachedTokens: numberSetting(config, "maxCachedTokens")
153
196
  };
154
197
  }
155
198
  function tokenUsage(context) {
@@ -193,379 +236,51 @@ async function skillBudget(_output, context) {
193
236
  };
194
237
  }
195
238
  //#endregion
196
- //#region src/skill-checks/assertions-static/_shared.ts
197
- function getStaticMeta(context) {
198
- const m = context.providerResponse?.metadata;
199
- if (m && typeof m === "object" && ("skill" in m || "tests" in m)) return m;
200
- return null;
201
- }
202
- function pass(reason, components) {
203
- return {
204
- pass: true,
205
- score: 1,
206
- reason,
207
- componentResults: components
208
- };
209
- }
210
- function fail(reason, components) {
211
- return {
212
- pass: false,
213
- score: 0,
214
- reason,
215
- componentResults: components
216
- };
217
- }
218
- //#endregion
219
- //#region src/skill-checks/assertions-static/settings.ts
220
- const DEFAULT_SETTINGS = {
221
- maxSkillLines: 200,
222
- destructiveEffects: ["file.changes_outside_scope", "tool.called"],
223
- requireTokenBudget: false,
224
- riskyEffects: ["file.changes_outside_scope", "tool.called"]
239
+ //#region src/assertions/promptfoo.ts
240
+ const routes = {
241
+ "skill.budget": skillBudget,
242
+ "skill.test": skillTest
225
243
  };
226
- const StringArraySchema = Schema.Array(Schema.String);
227
- function configObject(value) {
228
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
229
- }
230
- function numberSetting(config, key, fallback) {
231
- return Schema.is(Schema.Number)(config[key]) ? config[key] : fallback;
232
- }
233
- function stringArraySetting(config, key, fallback) {
234
- return Schema.is(StringArraySchema)(config[key]) ? config[key] : fallback;
235
- }
236
- function booleanSetting(config, key, fallback) {
237
- return typeof config[key] === "boolean" ? config[key] : fallback;
238
- }
239
- function skillCheckSettings(context) {
240
- const config = configObject(context.config?.agentSkillEvals);
241
- return {
242
- maxSkillLines: numberSetting(config, "maxSkillLines", DEFAULT_SETTINGS.maxSkillLines),
243
- destructiveEffects: stringArraySetting(config, "destructiveEffects", DEFAULT_SETTINGS.destructiveEffects),
244
- requireTokenBudget: booleanSetting(config, "requireTokenBudget", DEFAULT_SETTINGS.requireTokenBudget),
245
- riskyEffects: stringArraySetting(config, "riskyEffects", DEFAULT_SETTINGS.riskyEffects)
246
- };
247
- }
248
- //#endregion
249
- //#region src/skill-checks/assertions-static/context-economy.ts
250
- /**
251
- * SPEC §7.2 — context economy. Hard: referenced files must exist (already
252
- * surfaced via missingFiles). Warnings: SKILL.md size, reference depth,
253
- * missing TOC in long reference files.
254
- *
255
- * Implemented as warnings-only: this assertion always passes, but emits a
256
- * `score < 1` reason describing how SKILL.md compares to thresholds.
257
- */
258
- async function contextEconomy(_output, context) {
259
- const meta = getStaticMeta(context);
260
- if (!meta) return fail("context-economy: provider metadata missing");
261
- const skill = meta.skill;
262
- if (!skill) return fail("context-economy: skill not parsed");
263
- const { maxSkillLines: maxLines } = skillCheckSettings(context);
264
- const components = [];
265
- components.push({
266
- pass: skill.totalLines <= maxLines,
267
- score: skill.totalLines <= maxLines ? 1 : .5,
268
- reason: `SKILL.md ${skill.totalLines} line(s) (limit ${maxLines})`
269
- });
270
- components.push({
271
- pass: skill.missingReferences.length === 0,
272
- score: skill.missingReferences.length === 0 ? 1 : 0,
273
- reason: skill.missingReferences.length === 0 ? `${skill.references.length} reference(s) all resolved` : `missing references: ${skill.missingReferences.slice(0, 3).join(", ")}`
274
- });
275
- const failed = components.filter((c) => !c.pass && c.reason.startsWith("missing references"));
276
- if (failed.length === 0) {
277
- const oversize = components.find((c) => c.score < 1);
278
- return oversize ? {
279
- pass: true,
280
- score: .5,
281
- reason: oversize.reason,
282
- componentResults: components
283
- } : pass(`context-economy: ok`, components);
244
+ const knownMetrics = Object.keys(routes);
245
+ const availableMetrics = [...knownMetrics].sort().join(", ");
246
+ function sameConfig(a, b) {
247
+ if (a === void 0 && b === void 0) return true;
248
+ try {
249
+ return JSON.stringify(a) === JSON.stringify(b);
250
+ } catch {
251
+ return false;
284
252
  }
285
- return fail(failed.map((f) => `✗ ${f.reason}`).join("; "), components);
286
- }
287
- //#endregion
288
- //#region src/skill-checks/assertions-static/executable-helper.ts
289
- /**
290
- * SPEC §7.4 — verifier scripts referenced by tests must exist and be
291
- * runnable. Generated tests must not reference missing fixtures.
292
- */
293
- async function executableHelper(_output, context) {
294
- const meta = getStaticMeta(context);
295
- if (!meta) return fail("executable-helper: provider metadata missing");
296
- const tests = meta.tests;
297
- if (!tests) return fail("executable-helper: tests not parsed");
298
- const components = [];
299
- components.push({
300
- pass: tests.missingVerifierScripts.length === 0,
301
- score: tests.missingVerifierScripts.length === 0 ? 1 : 0,
302
- reason: tests.missingVerifierScripts.length === 0 ? `${tests.verifierScripts.length} verifier script(s) all present` : `missing verifier scripts: ${tests.missingVerifierScripts.slice(0, 3).join(", ")}`
303
- });
304
- components.push({
305
- pass: tests.nonExecutableVerifierScripts.length === 0,
306
- score: tests.nonExecutableVerifierScripts.length === 0 ? 1 : 0,
307
- reason: tests.nonExecutableVerifierScripts.length === 0 ? `${tests.verifierScripts.length} verifier script(s) executable` : `non-executable verifier scripts: ${tests.nonExecutableVerifierScripts.slice(0, 3).join(", ")}`
308
- });
309
- components.push({
310
- pass: tests.missingFixturePaths.length === 0,
311
- score: tests.missingFixturePaths.length === 0 ? 1 : 0,
312
- reason: tests.missingFixturePaths.length === 0 ? `${tests.fixturePaths.length} fixture(s) all present` : `missing fixtures: ${tests.missingFixturePaths.slice(0, 3).join(", ")}`
313
- });
314
- const failed = components.filter((c) => !c.pass);
315
- if (failed.length === 0) return pass("executable-helper: ok", components);
316
- return fail(failed.map((f) => `✗ ${f.reason}`).join("; "), components);
317
- }
318
- //#endregion
319
- //#region src/skill-checks/assertions-static/instruction-calibration.ts
320
- /**
321
- * SPEC §7.3 — for skills whose test pack uses destructive effects, the
322
- * SKILL.md must contain confirmation/clarification language and at least
323
- * one negative test must declare forbidden effects.
324
- *
325
- * `assert.config.agentSkillEvals.destructiveEffects` overrides the default list.
326
- */
327
- const CONFIRMATION_RE = /\b(confirm|ask first|do not.*without|before.*push|require.*approval|do not.*destructive)\b/i;
328
- const PLAN_BEFORE_ACT_RE = /\b(plan first|plan before|read.*before.*write|validate.*before|dry.run)\b/i;
329
- async function instructionCalibration(_output, context) {
330
- const meta = getStaticMeta(context);
331
- if (!meta) return fail("skill.instructions: provider metadata missing");
332
- const skill = meta.skill;
333
- const tests = meta.tests;
334
- if (!skill) return fail("skill.instructions: skill not parsed");
335
- const destructive = new Set(skillCheckSettings(context).destructiveEffects);
336
- if (!(tests ? tests.tests.some((t) => t.effectTypes.some((e) => destructive.has(e))) : false)) return pass("skill.instructions: no destructive effects in test pack");
337
- const components = [];
338
- const hasConfirm = CONFIRMATION_RE.test(skill.body) || PLAN_BEFORE_ACT_RE.test(skill.body);
339
- components.push({
340
- pass: hasConfirm,
341
- score: hasConfirm ? 1 : 0,
342
- reason: hasConfirm ? "SKILL.md describes confirmation / plan-before-act" : "SKILL.md uses destructive effects but lacks confirmation / plan-before-act language"
343
- });
344
- const declaresForbidden = tests ? tests.tests.some((t) => Array.isArray(t.vars.should_not) && t.vars.should_not.length > 0) : false;
345
- components.push({
346
- pass: declaresForbidden,
347
- score: declaresForbidden ? 1 : 0,
348
- reason: declaresForbidden ? "test pack declares forbidden effects (should_not)" : "no should_not declared in any test, despite destructive effects"
349
- });
350
- const failed = components.filter((c) => !c.pass);
351
- if (failed.length === 0) return pass("skill.instructions: ok", components);
352
- return fail(failed.map((f) => `✗ ${f.reason}`).join("; "), components);
353
- }
354
- //#endregion
355
- //#region src/skill-checks/assertions-static/negative-coverage.ts
356
- /**
357
- * SPEC §7.6 — risky skills (those whose test pack uses any risky effect)
358
- * must include at least one negative test.
359
- *
360
- * `assert.config.agentSkillEvals.riskyEffects` overrides the default risky-effect list.
361
- */
362
- async function negativeCoverage(_output, context) {
363
- const meta = getStaticMeta(context);
364
- if (!meta) return fail("skill.tests: provider metadata missing");
365
- const tests = meta.tests;
366
- if (!tests) return fail("skill.tests: tests not parsed");
367
- const risky = new Set(skillCheckSettings(context).riskyEffects);
368
- if (!tests.tests.some((t) => t.effectTypes.some((e) => risky.has(e)))) return pass("skill.tests: no risky effects, negative test not required");
369
- const negatives = tests.tests.filter((t) => t.isNegative);
370
- if (negatives.length === 0) return fail(`skill.tests: skill uses risky effects (${[...risky].join(", ")}) but no negative test (kind: negative) is declared`);
371
- return pass(`skill.tests: ${negatives.length} negative test(s)`);
372
- }
373
- //#endregion
374
- //#region src/skill-checks/assertions-static/routing-metadata.ts
375
- const GENERIC_PHRASES = [
376
- /\bhelp(s|ing)?\b/i,
377
- /\bgithub workflows?\b/i,
378
- /\bvarious\b/i,
379
- /\bany kind of\b/i
380
- ];
381
- const WHEN_TO_USE_RE = /\b(use when|use this|when (?:the )?(?:user|you))\b/i;
382
- const WHEN_NOT_RE = /\bdo not use|do not invoke|don'?t use|not for|avoid using\b/i;
383
- /**
384
- * SPEC §7.1 — routing metadata hard checks.
385
- */
386
- async function routingMetadata(_output, context) {
387
- const meta = getStaticMeta(context);
388
- if (!meta) return fail("routing-metadata: provider metadata missing");
389
- const skill = meta.skill;
390
- if (!skill) return fail("routing-metadata: skill not parsed (vars.skillPath missing?)");
391
- const fm = skill.frontmatter;
392
- const components = [];
393
- const name = typeof fm.name === "string" ? fm.name.trim() : "";
394
- components.push({
395
- pass: name.length > 0,
396
- score: name.length > 0 ? 1 : 0,
397
- reason: name ? `name: ${name}` : "missing `name` frontmatter"
398
- });
399
- const desc = typeof fm.description === "string" ? fm.description.trim() : "";
400
- components.push({
401
- pass: desc.length > 0,
402
- score: desc.length > 0 ? 1 : 0,
403
- reason: desc ? `description present (${desc.length} chars)` : "missing `description` frontmatter"
404
- });
405
- const sayWhen = WHEN_TO_USE_RE.test(desc);
406
- components.push({
407
- pass: sayWhen,
408
- score: sayWhen ? 1 : 0,
409
- reason: sayWhen ? "description says when to use" : "description does not say when to use (e.g. 'Use when …')"
410
- });
411
- const sayWhenNot = WHEN_NOT_RE.test(desc);
412
- components.push({
413
- pass: sayWhenNot,
414
- score: sayWhenNot ? 1 : 0,
415
- reason: sayWhenNot ? "description says when not to use" : "description does not say when not to use (e.g. 'Do not use for …')"
416
- });
417
- const generic = GENERIC_PHRASES.some((re) => re.test(desc)) && desc.length < 80;
418
- components.push({
419
- pass: !generic,
420
- score: generic ? 0 : 1,
421
- reason: generic ? "description is too generic" : "description is specific enough"
422
- });
423
- const failed = components.filter((c) => !c.pass);
424
- if (failed.length === 0) return pass(`routing-metadata: ${components.length}/${components.length} checks ok`, components);
425
- return fail(failed.map((f) => `✗ ${f.reason}`).join("; "), components);
426
253
  }
427
- //#endregion
428
- //#region src/skill-checks/assertions-static/scenario-validity.ts
429
254
  /**
430
- * SPEC §7.6 every runtime test must have prompt + fixture (or fixtureless)
431
- * + at least one of should/should_not. Unsupported effect types fail
432
- * static validation.
255
+ * Promptfoo hands a javascript assertion its `config` but not its `metric`,
256
+ * so a lone `metric:` declaration used to require a duplicate `config.metric`.
257
+ * Recover the metric from the test's assert list instead: the entry whose
258
+ * config matches the one Promptfoo passed is the assertion being graded.
433
259
  */
434
- async function scenarioValidity(_output, context) {
435
- const meta = getStaticMeta(context);
436
- if (!meta) return fail("skill.tests: provider metadata missing");
437
- const tests = meta.tests;
438
- if (!tests) return fail("skill.tests: tests not parsed (vars.testsGlob missing?)");
439
- const settings = skillCheckSettings(context);
440
- const components = [];
441
- if (tests.parseErrors.length > 0) components.push({
442
- pass: false,
443
- score: 0,
444
- reason: `parse errors: ${tests.parseErrors.map((e) => `${e.filePath}: ${e.error}`).join("; ")}`
445
- });
446
- if (tests.matchedFiles.length === 0) components.push({
447
- pass: false,
448
- score: 0,
449
- reason: "testsGlob matched no test files"
450
- });
451
- for (const t of tests.tests) {
452
- const issues = [];
453
- for (const error of t.entryErrors) {
454
- const at = error.index === void 0 ? error.field : `${error.field}[${error.index}]`;
455
- issues.push(`${at}: ${error.reason}`);
456
- }
457
- if (typeof t.vars.prompt !== "string" || t.vars.prompt.length === 0) issues.push("missing vars.prompt");
458
- if (!t.hasFixture) issues.push("missing vars.fixture (or vars.fixtureless: true)");
459
- if (!(Array.isArray(t.vars.should) && t.vars.should.length > 0 || Array.isArray(t.vars.should_not) && t.vars.should_not.length > 0)) issues.push("no should / should_not");
460
- if (settings.requireTokenBudget && !t.hasTokenBudget) issues.push("missing skill.budget assertion");
461
- components.push({
462
- pass: issues.length === 0,
463
- score: issues.length === 0 ? 1 : 0,
464
- reason: `${t.description ?? "(no description)"}: ${issues.length === 0 ? "ok" : issues.join(", ")}`
465
- });
466
- }
467
- if (meta.unresolvedEffectTypes.length > 0) components.push({
468
- pass: false,
469
- score: 0,
470
- reason: `unsupported effect types: ${meta.unresolvedEffectTypes.join(", ")}`
471
- });
472
- if (meta.missingFiles.length > 0) components.push({
473
- pass: false,
474
- score: 0,
475
- reason: `missing referenced files: ${meta.missingFiles.slice(0, 5).join(", ")}`
476
- });
477
- const failed = components.filter((c) => !c.pass);
478
- if (failed.length === 0) return pass(`skill.tests: ${tests.tests.length} test(s) ok`, components);
479
- return fail(failed.map((f) => `✗ ${f.reason}`).join("; "), components);
480
- }
481
- //#endregion
482
- //#region src/skill-checks/assertions-static/promptfoo.ts
483
- const metricChecks = {
484
- "skill.activation": [routingMetadata],
485
- "skill.budgets": [scenarioValidity],
486
- "skill.context": [contextEconomy],
487
- "skill.instructions": [instructionCalibration],
488
- "skill.tests": [scenarioValidity, negativeCoverage],
489
- "skill.verifiers": [executableHelper]
490
- };
491
- metricChecks["skill.checks"] = [
492
- routingMetadata,
493
- contextEconomy,
494
- instructionCalibration,
495
- scenarioValidity,
496
- negativeCoverage,
497
- executableHelper
498
- ];
499
- const availableMetrics$1 = Object.keys(metricChecks).sort().join(", ");
500
- function metricFrom$1(context) {
501
- return [
502
- context.assertion?.metric,
503
- context.assert?.metric,
504
- context.config?.metric,
505
- context.metric
506
- ].find((metric) => typeof metric === "string");
507
- }
508
- function runChecksEffect(output, context, checks) {
509
- return Effect.gen(function* () {
510
- const results = yield* Effect.forEach(checks, (check) => Effect.promise(() => check(output, context)), { concurrency: "unbounded" });
511
- const failed = results.filter((result) => !result.pass);
512
- const soft = results.filter((result) => result.pass && result.score < 1);
513
- return {
514
- pass: failed.length === 0,
515
- score: failed.length === 0 ? Math.min(...results.map((result) => result.score)) : 0,
516
- reason: failed.length === 0 ? soft.length > 0 ? soft.map((result) => result.reason).join("; ") : `skill checks: ${results.length} check(s) passed` : failed.map((result) => result.reason).join("; "),
517
- componentResults: results
518
- };
519
- });
520
- }
521
- async function agentSkillEvalsStaticAssertions(output, context) {
522
- return Effect.runPromise(agentSkillEvalsStaticAssertionsEffect(output, context));
523
- }
524
- function agentSkillEvalsStaticAssertionsEffect(output, context) {
525
- return Effect.gen(function* () {
526
- const metric = metricFrom$1(context);
527
- const checks = metric ? metricChecks[metric] : void 0;
528
- if (!checks) return {
529
- pass: false,
530
- score: 0,
531
- reason: `agent-skill-evals skill checks: unknown metric "${metric ?? "missing"}". Available metrics: ${availableMetrics$1}`
532
- };
533
- return yield* runChecksEffect(output, context, checks);
534
- });
260
+ function metricFromTestAsserts(context) {
261
+ const asserts = context.test?.assert;
262
+ if (!Array.isArray(asserts)) return void 0;
263
+ const candidates = asserts.filter((entry) => typeof entry === "object" && entry !== null && typeof entry.metric === "string" && knownMetrics.includes(entry.metric));
264
+ if (candidates.length === 1) return candidates[0].metric;
265
+ const matched = candidates.filter((entry) => sameConfig(entry.config, context.config));
266
+ if (matched.length === 1) return matched[0].metric;
535
267
  }
536
- //#endregion
537
- //#region src/assertions/promptfoo.ts
538
- const routes = {
539
- "skill.budget": skillBudget,
540
- "skill.test": skillTest
541
- };
542
- const staticMetrics = [
543
- "skill.checks",
544
- "skill.activation",
545
- "skill.budgets",
546
- "skill.context",
547
- "skill.instructions",
548
- "skill.tests",
549
- "skill.verifiers"
550
- ];
551
- const availableMetrics = [...Object.keys(routes), ...staticMetrics].sort().join(", ");
552
268
  function metricFrom(context) {
553
269
  return [
554
270
  context.assertion?.metric,
555
271
  context.assert?.metric,
556
272
  context.config?.metric,
557
273
  context.metric
558
- ].find((metric) => typeof metric === "string");
274
+ ].find((metric) => typeof metric === "string") ?? metricFromTestAsserts(context);
559
275
  }
560
276
  async function agentSkillEvalsAssertions(output, context) {
561
277
  const metric = metricFrom(context);
562
278
  const assertion = metric ? routes[metric] : void 0;
563
279
  if (assertion) return assertion(output, context);
564
- if (metric && staticMetrics.includes(metric)) return agentSkillEvalsStaticAssertions(output, context);
565
280
  return {
566
281
  pass: false,
567
282
  score: 0,
568
- reason: `agent-skill-evals assertions: unknown metric "${metric ?? "missing"}". Available metrics: ${availableMetrics}`
283
+ reason: `agent-skill-evals assertions: unknown metric "${metric ?? "missing"}". Available metrics: ${availableMetrics}. Declare the metric on the assertion (metric: skill.test). If one test case uses several agent-skill-evals assertions with identical config blocks, disambiguate with config.metric.`
569
284
  };
570
285
  }
571
286
  //#endregion