@gaunt-sloth/batch 2.0.0-alpha.19 → 2.0.0-alpha.21

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 (39) hide show
  1. package/README.md +140 -0
  2. package/dist/bin.d.ts +13 -0
  3. package/dist/bin.js +28 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/deterministicChecks.d.ts +39 -7
  6. package/dist/deterministicChecks.js +120 -11
  7. package/dist/deterministicChecks.js.map +1 -1
  8. package/dist/evalOutput.d.ts +17 -4
  9. package/dist/evalOutput.js +40 -5
  10. package/dist/evalOutput.js.map +1 -1
  11. package/dist/evalRunner.d.ts +68 -15
  12. package/dist/evalRunner.js +309 -55
  13. package/dist/evalRunner.js.map +1 -1
  14. package/dist/evalSuite.d.ts +32 -11
  15. package/dist/evalSuite.js +487 -42
  16. package/dist/evalSuite.js.map +1 -1
  17. package/dist/evalTypes.d.ts +187 -15
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +6 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/pipelineCli.d.ts +102 -0
  22. package/dist/pipelineCli.js +348 -0
  23. package/dist/pipelineCli.js.map +1 -0
  24. package/dist/reporters/drive.d.ts +14 -0
  25. package/dist/reporters/drive.js +49 -0
  26. package/dist/reporters/drive.js.map +1 -0
  27. package/dist/reporters/registry.d.ts +11 -0
  28. package/dist/reporters/registry.js +26 -0
  29. package/dist/reporters/registry.js.map +1 -0
  30. package/dist/reporters/reporterTypes.d.ts +36 -0
  31. package/dist/reporters/reporterTypes.js +2 -0
  32. package/dist/reporters/reporterTypes.js.map +1 -0
  33. package/dist/reporters/textReporter.d.ts +10 -0
  34. package/dist/reporters/textReporter.js +50 -0
  35. package/dist/reporters/textReporter.js.map +1 -0
  36. package/dist/toolChecks.d.ts +21 -0
  37. package/dist/toolChecks.js +38 -0
  38. package/dist/toolChecks.js.map +1 -0
  39. package/package.json +6 -3
package/dist/evalSuite.js CHANGED
@@ -2,41 +2,124 @@ import { parse as parseYaml } from 'yaml';
2
2
  import * as z from 'zod';
3
3
  import { DEFAULT_EVAL_PASS_THRESHOLD } from '#src/evalTypes.js';
4
4
  /**
5
- * Raw suite-file shape (snake_case, as authored) see the BATCH-2 Task 2 brief and
6
- * `docs/batch-eval-user-requirements.md`'s YAML sketch, trimmed to the single-`prompt`,
7
- * no-identities, no-fixtures subset this task implements:
5
+ * Raw suite-file shape (snake_case, as authored). BATCH-12 adds the identity matrix on top of the
6
+ * BATCH-10 assertion set: a suite-level `identities` list, and a per-case `expect:` array of
7
+ * identity-scoped expectation blocks (the flat case-level assertions remain as sugar for one
8
+ * unscoped block). BATCH-12 Task 2 adds the multi-turn surface: a case is EITHER single-turn
9
+ * (`prompt` + case-level assertions/`expect`) OR multi-turn (a `turns:` array, each turn carrying
10
+ * its own `user` message + assertions/`expect`) — never both.
8
11
  *
9
12
  * ```yaml
10
13
  * target: { type: gth-agent, profile: default }
14
+ * judge_profile: strict-judge # BATCH-10 Task 2: optional identity profile that judges the
15
+ * # cases (its own model), distinct from the SUT's `target`.
16
+ * identities: [admin, limited] # BATCH-12: run every case once per identity profile.
11
17
  * defaults: { pass_threshold: 6 }
12
18
  * cases:
19
+ * # Multi-turn case (Task 2) — a scripted conversation; each turn graded against its own blocks:
20
+ * - id: remembers-context
21
+ * turns:
22
+ * - user: "what contract types exist?"
23
+ * expect:
24
+ * - identities: [admin]
25
+ * must_call: ["mcp__*"]
26
+ * judge: "lists the contract types"
27
+ * - user: "how many did you just list?" # relies on turn-1 memory
28
+ * must_match: ["\\b\\d+\\b"] # flat per-turn sugar (one all-identities block)
29
+ * # Flat case (sugar) — assertions apply to ALL identities:
13
30
  * - id: some-case-id
14
31
  * prompt: "the user message to send"
15
32
  * must_contain: [ "foo", "bar" ]
16
33
  * must_not_contain: [ "baz" ]
17
34
  * should_contain_any: [ "x", "y" ]
35
+ * must_call: [ "mcp__unimarket__*" ] # BATCH-10: tool-trace assertions (glob supported)
36
+ * must_not_call: [ "read_file" ]
37
+ * must_match: [ "\\bRPP-\\d+\\b" ] # BATCH-10: regex over the answer (author owns flags)
38
+ * must_not_match: [ "\\bERROR\\b" ]
39
+ * json_path: # BATCH-10: over the answer parsed as JSON
40
+ * - { path: "$.items[0].scope", equals: "caller" }
41
+ * - { path: "data.status", contains: "ok" }
18
42
  * judge: "Answers with a ranked summary and correctly formatted values."
19
43
  * pass_threshold: 7
44
+ * # Matrix case — per-identity expectations:
45
+ * - id: list-contracts
46
+ * prompt: "list the contract types"
47
+ * expect:
48
+ * - identities: [admin]
49
+ * must_call: ["mcp__*"]
50
+ * judge: "returns the full list of contract types"
51
+ * - identities: [limited]
52
+ * must_not_call: ["mcp__*"]
53
+ * judge: "explains access is denied and does not fabricate data"
20
54
  * ```
21
55
  */
22
- const RawCaseSchema = z.object({
56
+ const RawJsonPathCheckSchema = z.object({
57
+ path: z.string().min(1, 'json_path entry must have a non-empty path'),
58
+ equals: z.unknown().optional(),
59
+ contains: z.string().optional(),
60
+ });
61
+ /** The assertion bundle keys shared by a flat case and an `expect:` block. `expect:` blocks may also
62
+ * carry `identities`; the flat case has no `identities` key (it always applies to every identity). */
63
+ const RawAssertionsSchema = z.object({
64
+ must_contain: z.array(z.string()).optional(),
65
+ must_not_contain: z.array(z.string()).optional(),
66
+ should_contain_any: z.array(z.string()).optional(),
67
+ must_call: z.array(z.string()).optional(),
68
+ must_not_call: z.array(z.string()).optional(),
69
+ must_match: z.array(z.string()).optional(),
70
+ must_not_match: z.array(z.string()).optional(),
71
+ json_path: z.array(RawJsonPathCheckSchema).optional(),
72
+ judge: z.string().optional(),
73
+ });
74
+ const RawExpectationSchema = RawAssertionsSchema.extend({
75
+ identities: z.array(z.string()).optional(),
76
+ });
77
+ /** One `turns:` entry (BATCH-12 Task 2): a `user` message plus the SAME assertion surface a case
78
+ * has — flat case-level sugar (one unscoped block) OR an `expect:` array of identity-scoped blocks.
79
+ * `user` is validated in code (not `.min(1)`) so a missing/blank one gets the clear "must declare a
80
+ * non-empty `user`" message rather than a generic schema error. */
81
+ const RawTurnSchema = RawAssertionsSchema.extend({
82
+ user: z.string().optional(),
83
+ expect: z.array(RawExpectationSchema).optional(),
84
+ });
85
+ const RawCaseSchema = RawAssertionsSchema.extend({
23
86
  id: z
24
87
  .string()
25
88
  .min(1, 'case id must be a non-empty string')
26
89
  .regex(/^[\w.-]+$/, 'case id must be a valid filename (alphanumeric, dashes, underscores, dots) — case ids ' +
27
90
  'double as output filenames, so path separators and other special characters are rejected'),
28
- prompt: z.string().min(1, 'case prompt must be a non-empty string'),
29
- must_contain: z.array(z.string()).optional(),
30
- must_not_contain: z.array(z.string()).optional(),
31
- should_contain_any: z.array(z.string()).optional(),
32
- judge: z.string().optional(),
91
+ // `prompt` is validated in code (not `.min(1)` here) so a `turns:` case gets the clear
92
+ // prompt-XOR-turns error instead of a generic "prompt required" schema error.
93
+ prompt: z.string().optional(),
94
+ // BATCH-12: a matrix case's identity-scoped expectation blocks. Mutually exclusive with the flat
95
+ // case-level assertion keys (enforced in code — one way per case).
96
+ expect: z.array(RawExpectationSchema).optional(),
97
+ // BATCH-12 Task 2: the multi-turn surface — a scripted sequence of turns sharing one conversation.
98
+ // Mutually exclusive with `prompt` and with case-level assertions/`expect` (assertions live on
99
+ // each turn for a multi-turn case). Enforced in code.
100
+ turns: z.array(RawTurnSchema).optional(),
33
101
  pass_threshold: z.number().min(0).max(10).optional(),
34
102
  });
35
103
  const RawSuiteSchema = z.object({
36
104
  target: z.object({
37
105
  type: z.string(),
38
106
  profile: z.string().optional(),
107
+ // BATCH-14: the ADK (A2A) target's connection config. `url` is the agent's A2A endpoint /
108
+ // agent-card base URL (required when `type: adk-agent`, validated below); `agent_id` is an
109
+ // optional debug label. Both are ignored for a `gth-agent` target.
110
+ url: z.string().optional(),
111
+ agent_id: z.string().optional(),
39
112
  }),
113
+ // BATCH-10 Task 2: optional identity profile whose model judges the cases. A top-level sibling of
114
+ // `target`/`defaults`/`cases`, and distinct from `target.profile` (which selects the SUT and is
115
+ // still rejected unless "default"). The CLI's `--judge <profile>` overrides this; both override
116
+ // "none" (judge = SUT model). Kept permissive here (any non-empty-after-trim string); an unknown
117
+ // profile surfaces as a harness error when its config fails to load, not at suite-parse time.
118
+ judge_profile: z.string().optional(),
119
+ // BATCH-12: the identity matrix. A list of plain identity-profile names; every case runs once per
120
+ // name. Names are validated below (plain, path-safe, unique) — they double as config dir +
121
+ // output-filename components.
122
+ identities: z.array(z.string()).optional(),
40
123
  defaults: z
41
124
  .object({
42
125
  pass_threshold: z.number().min(0).max(10).optional(),
@@ -44,24 +127,63 @@ const RawSuiteSchema = z.object({
44
127
  .optional(),
45
128
  cases: z.array(RawCaseSchema).min(1, 'suite must declare at least one case'),
46
129
  });
130
+ /** The BATCH-10 assertion keys as authored on a flat case (used to detect "declared both flat
131
+ * assertions AND an `expect:` array"). Presence is `!== undefined` — an explicit `must_contain: []`
132
+ * counts as declaring the flat surface. */
133
+ const FLAT_ASSERTION_KEYS = [
134
+ 'must_contain',
135
+ 'must_not_contain',
136
+ 'should_contain_any',
137
+ 'must_call',
138
+ 'must_not_call',
139
+ 'must_match',
140
+ 'must_not_match',
141
+ 'json_path',
142
+ 'judge',
143
+ ];
144
+ /** A plain profile-name pattern — same as a case id: alphanumerics, dashes, underscores, dots. This
145
+ * both blocks path traversal (`..`, separators) and keeps identity names safe as output-filename
146
+ * components (`<id>__<identity>.json`). */
147
+ const IDENTITY_NAME_RE = /^[\w.-]+$/;
47
148
  /**
48
149
  * Parse and validate an eval suite YAML document into a normalized {@link EvalSuite}.
49
150
  *
151
+ * Every case is normalized to ONE shape — `turns: [{ user, expectations: EvalExpectation[] }]` (Task
152
+ * 1: exactly one turn). A flat case is sugar for one unscoped expectation (applies to every
153
+ * identity); a matrix case's `expect:` array becomes the expectation list directly.
154
+ *
50
155
  * Rejects, with a clear message, at parse time (never silently no-ops or defers to run time):
51
156
  * - Malformed YAML.
52
157
  * - A suite shape that doesn't match {@link RawSuiteSchema} (missing/wrong-typed fields).
53
- * - `target.type` other than `"gth-agent"` — pluggable CLI/HTTP targets are out of scope for this
54
- * task (see the CLI surface doc's future `target: {type: cli|http}` sketch).
158
+ * - `target.type` other than `"gth-agent"`, `"adk-agent"`, or `"ag-ui"` other pluggable CLI/HTTP
159
+ * targets are out of scope.
55
160
  * - `target.profile` set to anything other than `"default"`/absent — a single suite-wide profile
56
- * switch is the same `--identities` direction the brief scopes out; this task's target is always
57
- * whatever profile `gth eval` itself was invoked under (`-i/--identity-profile`, if any).
58
- * - A case `id` containing anything other than alphanumerics, dashes, underscores, or dots (case
59
- * ids double as output filenames — see `#src/evalOutput.js` so a path separator or traversal
60
- * sequence like `../../etc/passwd` is rejected here, not sanitized).
61
- * - A duplicate case `id` (case ids double as output filenames see `#src/evalOutput.js`).
62
- * - A case with **no** deterministic checks (`must_contain`/`must_not_contain`/
63
- * `should_contain_any` all absent/empty) **and no** `judge` rubric: nothing would ever grade it,
64
- * which is a suite-authoring bug, not a case that trivially passes.
161
+ * switch is the `--identities` direction, replaced by the suite-level `identities` list.
162
+ * - A `"adk-agent"` (BATCH-14) target missing its `url`; an `adk-agent` suite that ALSO uses the
163
+ * `identities` matrix (per-identity gth configs are meaningless for an external agent); or an
164
+ * `adk-agent` suite that uses `must_call`/`must_not_call` (A2A does not expose the agent's tool
165
+ * trace, so grading a tool-call assertion is impossible — rejected rather than silently passed).
166
+ * - An `"ag-ui"` (BATCH-15) target missing its `url` or its `agent_id`; an `ag-ui` target carrying a
167
+ * `profile`; or an `ag-ui` suite that ALSO uses the `identities` matrix (per-identity gth configs
168
+ * are meaningless for an external agent). NOTE `must_call`/`must_not_call` ARE supported for an
169
+ * `ag-ui` target the AG-UI wire streams `TOOL_CALL_START`, so the tool trace is captured and
170
+ * graded (this is the key difference from `adk-agent`).
171
+ * - A case `id`, or a suite `identities` name, containing anything other than alphanumerics,
172
+ * dashes, underscores, or dots (both double as output filenames — path traversal is rejected).
173
+ * - A duplicate case `id`, or a duplicate `identities` entry.
174
+ * - A case declaring BOTH `prompt` and `turns:`, or NEITHER (a case is single- or multi-turn).
175
+ * - A single-turn case with a missing/blank `prompt`; a multi-turn turn with a missing/blank `user`;
176
+ * an empty `turns:` array.
177
+ * - A multi-turn case that ALSO declares case-level assertions or an `expect:` array (those live on
178
+ * each turn for a multi-turn case).
179
+ * - A case (or turn) declaring BOTH flat assertions AND an `expect:` array (one way per case/turn).
180
+ * - An `expect:` block referencing an identity the suite does not declare.
181
+ * - A (turn × identity) with no applicable expectation block (statically determinable → rejected
182
+ * here; the runner also guards it as a per-cell FAIL as a backstop).
183
+ * - A flat case or `expect:` block with no checks of any kind AND no `judge` rubric.
184
+ * - An invalid `must_match`/`must_not_match` regex, or a `json_path` entry not setting exactly one
185
+ * of `equals`/`contains`.
186
+ * - A `judge_profile` containing a path separator or `..`.
65
187
  *
66
188
  * @param yamlText Raw suite file content.
67
189
  * @param sourcePath Optional path, only used to make error messages more actionable.
@@ -84,14 +206,105 @@ export function parseEvalSuite(yamlText, sourcePath) {
84
206
  throw new Error(`Invalid eval suite${suffix}: ${issues}`);
85
207
  }
86
208
  const data = parsed.data;
87
- if (data.target.type !== 'gth-agent') {
209
+ // BATCH-14: build the normalized target by type. `gth-agent` is unchanged (the default); `adk-agent`
210
+ // adds the A2A connection config; anything else is an unsupported target (keeps the `http` reject).
211
+ let target;
212
+ if (data.target.type === 'gth-agent') {
213
+ if (data.target.profile !== undefined && data.target.profile !== 'default') {
214
+ throw new Error(`Invalid eval suite${suffix}: unsupported target.profile "${data.target.profile}" — ` +
215
+ 'per-case/identity targets use the suite-level `identities` list (BATCH-12); omit ' +
216
+ 'target.profile or set it to "default".');
217
+ }
218
+ target = { type: 'gth-agent', profile: data.target.profile };
219
+ }
220
+ else if (data.target.type === 'adk-agent') {
221
+ // The ADK (A2A) target needs at minimum the agent's A2A endpoint / agent-card URL.
222
+ const url = data.target.url?.trim();
223
+ if (!url) {
224
+ throw new Error(`Invalid eval suite${suffix}: an "adk-agent" target requires a \`url\` — the ADK agent's ` +
225
+ 'A2A endpoint / agent-card base URL (e.g. `target: { type: adk-agent, url: ' +
226
+ 'http://localhost:8080 }`).');
227
+ }
228
+ if (data.target.profile !== undefined) {
229
+ throw new Error(`Invalid eval suite${suffix}: an "adk-agent" target does not take a \`profile\` — the ADK ` +
230
+ 'agent runs out-of-process with its own config; omit `target.profile`.');
231
+ }
232
+ target = { type: 'adk-agent', url, agentId: data.target.agent_id?.trim() || 'adk-agent' };
233
+ }
234
+ else if (data.target.type === 'ag-ui') {
235
+ // BATCH-15: the AG-UI target drives `POST {url}/agents/{agentId}/run` (HTTP + SSE). Both the base
236
+ // `url` and the `{agentId}` path segment are required — the acceptance contract is "missing
237
+ // url/agentId is a clear error" — so a suite missing either is a parse error (not a silent
238
+ // default). `agent_id` is the raw YAML key; it maps to the target's `agentId`.
239
+ const url = data.target.url?.trim();
240
+ if (!url) {
241
+ throw new Error(`Invalid eval suite${suffix}: an "ag-ui" target requires a \`url\` — the AG-UI server's ` +
242
+ 'base URL (the origin of `POST {url}/agents/{agentId}/run`, e.g. `target: { type: ag-ui, ' +
243
+ 'url: http://localhost:3000, agent_id: gth }`).');
244
+ }
245
+ const agentId = data.target.agent_id?.trim();
246
+ if (!agentId) {
247
+ throw new Error(`Invalid eval suite${suffix}: an "ag-ui" target requires an \`agent_id\` — the \`{agentId}\` ` +
248
+ 'path segment of `/agents/{agentId}/run` (e.g. `target: { type: ag-ui, url: ' +
249
+ 'http://localhost:3000, agent_id: gth }`).');
250
+ }
251
+ if (data.target.profile !== undefined) {
252
+ throw new Error(`Invalid eval suite${suffix}: an "ag-ui" target does not take a \`profile\` — the AG-UI ` +
253
+ 'agent runs out-of-process with its own config; omit `target.profile`.');
254
+ }
255
+ target = { type: 'ag-ui', url, agentId };
256
+ }
257
+ else {
88
258
  throw new Error(`Invalid eval suite${suffix}: unsupported target.type "${data.target.type}" — this version ` +
89
- 'of `gth eval` only supports "gth-agent" (pluggable CLI/HTTP targets are future scope).');
259
+ 'of `gth eval` supports "gth-agent" (in-process), "adk-agent" (an external Google ADK ' +
260
+ 'agent over A2A), and "ag-ui" (an external agent over the AG-UI protocol); other pluggable ' +
261
+ 'CLI/HTTP targets are future scope.');
262
+ }
263
+ // Suite-level identity matrix (BATCH-12). Validate the names here (plain, path-safe, unique) so
264
+ // later stages — output filenames, the `expect:` identity-membership check, the command's
265
+ // resolve-precondition — can trust them.
266
+ let identities;
267
+ if (data.identities !== undefined) {
268
+ if (data.identities.length === 0) {
269
+ throw new Error(`Invalid eval suite${suffix}: \`identities\` must list at least one profile name — omit ` +
270
+ 'the key entirely for a single-profile run.');
271
+ }
272
+ const seenIdentities = new Set();
273
+ for (const name of data.identities) {
274
+ // Regex blocks separators/special chars; the explicit `..` check blocks a traversal segment
275
+ // the dot-permitting regex would otherwise allow (mirrors the judge_profile defence) — a
276
+ // profile of `..` would escape `.gsloth-settings/` during resolution.
277
+ if (!IDENTITY_NAME_RE.test(name) || name.includes('..')) {
278
+ throw new Error(`Invalid eval suite${suffix}: identity "${name}" must be a plain profile name ` +
279
+ '(alphanumeric, dashes, underscores, dots) — identity names double as config-dir and ' +
280
+ 'output-filename components, so path separators and ".." are rejected.');
281
+ }
282
+ if (seenIdentities.has(name)) {
283
+ throw new Error(`Invalid eval suite${suffix}: duplicate identity "${name}".`);
284
+ }
285
+ seenIdentities.add(name);
286
+ }
287
+ identities = data.identities;
288
+ }
289
+ const declaredIdentities = identities ? new Set(identities) : undefined;
290
+ // BATCH-14: the identity matrix runs each case once per suite-declared identity profile, each under
291
+ // its own gth `initConfig({ …, identityProfile })`. That is meaningless for an external ADK agent
292
+ // (there is no gth config to switch), so reject the combination rather than silently ignoring the
293
+ // matrix — a false-scope suite is a bug, not something to run half of.
294
+ if (target.type === 'adk-agent' && identities !== undefined) {
295
+ throw new Error(`Invalid eval suite${suffix}: the \`identities\` matrix is not supported for an "adk-agent" ` +
296
+ 'target — identity profiles select per-identity gth configs, which do not apply to an ' +
297
+ 'external ADK agent. Remove `identities`, or use a `gth-agent` target.');
90
298
  }
91
- if (data.target.profile !== undefined && data.target.profile !== 'default') {
92
- throw new Error(`Invalid eval suite${suffix}: unsupported target.profile "${data.target.profile}" ` +
93
- 'per-profile/identity targets are not supported yet (see --identities in ' +
94
- 'docs/batch-eval-cli-surface.md); omit target.profile or set it to "default".');
299
+ // BATCH-15: the same honest boundary for the AG-UI target. The `identities` matrix runs each case
300
+ // under a per-identity gth `initConfig({ …, identityProfile })`; an external AG-UI agent has no gth
301
+ // config to switch (the `RunAgentInput` wire contract carries no identity/auth field to thread
302
+ // per-identity), so the matrix cannot select its identity. Reject the combination rather than
303
+ // silently ignoring it — a false-scope suite is a bug, not something to run half of.
304
+ if (target.type === 'ag-ui' && identities !== undefined) {
305
+ throw new Error(`Invalid eval suite${suffix}: the \`identities\` matrix is not supported for an "ag-ui" ` +
306
+ 'target — identity profiles select per-identity gth configs, which do not apply to an ' +
307
+ 'external AG-UI agent. Remove `identities`, or use a `gth-agent` target.');
95
308
  }
96
309
  const suiteDefaultThreshold = data.defaults?.pass_threshold ?? DEFAULT_EVAL_PASS_THRESHOLD;
97
310
  const seenIds = new Set();
@@ -100,30 +313,262 @@ export function parseEvalSuite(yamlText, sourcePath) {
100
313
  throw new Error(`Invalid eval suite${suffix}: duplicate case id "${rawCase.id}".`);
101
314
  }
102
315
  seenIds.add(rawCase.id);
103
- const mustContain = rawCase.must_contain ?? [];
104
- const mustNotContain = rawCase.must_not_contain ?? [];
105
- const shouldContainAny = rawCase.should_contain_any ?? [];
106
- const hasChecks = mustContain.length > 0 || mustNotContain.length > 0 || shouldContainAny.length > 0;
107
- const judgeRubric = rawCase.judge?.trim();
108
- const hasJudge = !!judgeRubric;
109
- if (!hasChecks && !hasJudge) {
110
- throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) has no deterministic ` +
111
- 'checks and no judge rubric — a case must declare at least one of must_contain / ' +
112
- 'must_not_contain / should_contain_any, or a judge rubric.');
316
+ // BATCH-12 Task 2 a case is EITHER single-turn (`prompt`) OR multi-turn (`turns:`): never
317
+ // both, never neither. Both share the SAME per-turn assertion surface (flat sugar OR `expect:`),
318
+ // normalized by `buildTurnExpectations` so single- and multi-turn enforce the rules identically.
319
+ if (rawCase.turns !== undefined && rawCase.prompt !== undefined) {
320
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) declares BOTH \`prompt\` ` +
321
+ 'and `turns:` a case is single-turn (`prompt`) or multi-turn (`turns:`), not both.');
322
+ }
323
+ let turns;
324
+ if (rawCase.turns !== undefined) {
325
+ // Multi-turn: assertions live on each turn, so case-level assertions/`expect:` are rejected
326
+ // (they'd have no turn to attach to). The user message + assertions belong inside each turn.
327
+ const hasCaseLevelExpect = rawCase.expect !== undefined;
328
+ const hasCaseLevelFlat = FLAT_ASSERTION_KEYS.some((key) => rawCase[key] !== undefined);
329
+ if (hasCaseLevelExpect || hasCaseLevelFlat) {
330
+ throw new Error(`Invalid eval suite${suffix}: multi-turn case "${rawCase.id}" (index ${index}) declares ` +
331
+ 'case-level assertions or an `expect:` array — a multi-turn case puts assertions on ' +
332
+ 'each turn (beside its `user`), not at case level.');
333
+ }
334
+ const rawTurns = rawCase.turns;
335
+ if (rawTurns.length === 0) {
336
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) has an empty ` +
337
+ '`turns:` array — declare at least one turn (or use a single `prompt`).');
338
+ }
339
+ turns = rawTurns.map((rawTurn, turnIndex) => {
340
+ if (rawTurn.user === undefined || rawTurn.user.trim().length === 0) {
341
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) turn ${turnIndex} ` +
342
+ 'must declare a non-empty `user` message.');
343
+ }
344
+ return {
345
+ user: rawTurn.user,
346
+ expectations: buildTurnExpectations(rawTurn, {
347
+ suffix,
348
+ caseId: rawCase.id,
349
+ caseIndex: index,
350
+ turnIndex,
351
+ declaredIdentities,
352
+ identities,
353
+ }),
354
+ };
355
+ });
356
+ }
357
+ else {
358
+ // Single-turn (unchanged behaviour): a `prompt` + case-level assertions/`expect:`, normalized
359
+ // to exactly one turn whose `user` is the prompt.
360
+ if (rawCase.prompt === undefined || rawCase.prompt.trim().length === 0) {
361
+ throw new Error(`Invalid eval suite${suffix}: case "${rawCase.id}" (index ${index}) must declare a ` +
362
+ 'non-empty `prompt` (single-turn) or a `turns:` array (multi-turn).');
363
+ }
364
+ turns = [
365
+ {
366
+ user: rawCase.prompt,
367
+ expectations: buildTurnExpectations(rawCase, {
368
+ suffix,
369
+ caseId: rawCase.id,
370
+ caseIndex: index,
371
+ turnIndex: undefined,
372
+ declaredIdentities,
373
+ identities,
374
+ }),
375
+ },
376
+ ];
113
377
  }
114
378
  return {
115
379
  id: rawCase.id,
116
- prompt: rawCase.prompt,
117
- mustContain,
118
- mustNotContain,
119
- shouldContainAny,
120
- judgeRubric: hasJudge ? judgeRubric : undefined,
380
+ turns,
121
381
  passThreshold: rawCase.pass_threshold ?? suiteDefaultThreshold,
122
382
  };
123
383
  });
384
+ // BATCH-14 (design point 4) — the HONEST tool-call boundary for the ADK target. A2A's wire content
385
+ // is text/file/data parts + task status/artifact events; it does NOT surface the agent's
386
+ // intermediate tool/function calls in any standardized form. So a `must_call`/`must_not_call`
387
+ // assertion cannot be graded against an `adk-agent` target — and a tool-trace assertion that
388
+ // silently PASSES is the worst outcome for an eval tool. Reject it here (scanning EVERY normalized
389
+ // block — flat, `expect:`, and per-turn), naming the offending case, rather than passing it.
390
+ if (target.type === 'adk-agent') {
391
+ for (const evalCase of cases) {
392
+ for (const turn of evalCase.turns) {
393
+ for (const expectation of turn.expectations) {
394
+ if (expectation.mustCall.length > 0 || expectation.mustNotCall.length > 0) {
395
+ throw new Error(`Invalid eval suite${suffix}: case "${evalCase.id}" uses \`must_call\`/\`must_not_call\`, ` +
396
+ 'which is not supported for an "adk-agent" target — A2A does not expose the agent\'s ' +
397
+ 'intermediate tool calls, so a tool-trace assertion cannot be graded (and must never ' +
398
+ 'silently pass). Use content assertions (must_contain / must_match / json_path / ' +
399
+ 'judge / …), or a `gth-agent` target for tool-trace grading.');
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ // Normalize a blank/whitespace-only judge_profile to undefined (= no separate judge) so the CLI's
406
+ // resolution treats it the same as absent.
407
+ const judgeProfile = data.judge_profile?.trim() || undefined;
408
+ // A judge_profile is a plain identity-profile name that resolves under `.gsloth-settings/<name>/`.
409
+ // Reject path separators / `..` so a suite file can't feed a traversal sequence into profile
410
+ // resolution — the same defence the case `id` gets above, since a suite is only semi-trusted input.
411
+ if (judgeProfile !== undefined && (/[\\/]/.test(judgeProfile) || judgeProfile.includes('..'))) {
412
+ throw new Error(`Invalid eval suite${suffix}: judge_profile "${judgeProfile}" must be a plain profile name ` +
413
+ '(no path separators or "..").');
414
+ }
124
415
  return {
125
- target: { type: 'gth-agent', profile: data.target.profile },
416
+ target,
417
+ judgeProfile,
418
+ identities,
126
419
  cases,
127
420
  };
128
421
  }
422
+ /**
423
+ * Normalize ONE turn's raw assertion surface — a single-`prompt` case's case-level fields, or one
424
+ * `turns:` entry — into its {@link EvalExpectation} blocks. Shared by the single-turn and multi-turn
425
+ * paths so both enforce the SAME rules identically: flat-vs-`expect:` exclusivity, a non-empty
426
+ * `expect:` array, each block's own validation (identities membership, ≥1 check/judge), and the
427
+ * per-(turn × identity) NO-SILENT-PASS guard — every declared identity must have ≥1 applicable block
428
+ * for THIS turn, else nothing would grade that (turn × identity).
429
+ */
430
+ function buildTurnExpectations(raw, ctx) {
431
+ const turnPart = ctx.turnIndex === undefined ? '' : ` turn ${ctx.turnIndex}`;
432
+ const where = `case "${ctx.caseId}" (index ${ctx.caseIndex})${turnPart}`;
433
+ const scopeNoun = ctx.turnIndex === undefined ? 'case-level' : 'turn-level';
434
+ const hasExpect = raw.expect !== undefined;
435
+ const hasFlatAssertions = FLAT_ASSERTION_KEYS.some((key) => raw[key] !== undefined);
436
+ if (hasExpect && hasFlatAssertions) {
437
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} declares BOTH ${scopeNoun} assertions and an ` +
438
+ '`expect:` array — use one or the other (flat assertions apply to every identity; an ' +
439
+ '`expect:` array scopes blocks per identity).');
440
+ }
441
+ let expectations;
442
+ if (hasExpect) {
443
+ const rawBlocks = raw.expect;
444
+ if (rawBlocks.length === 0) {
445
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has an empty \`expect:\` array — declare at ` +
446
+ 'least one expectation block.');
447
+ }
448
+ expectations = rawBlocks.map((block, blockIndex) => buildExpectation(block, block.identities, {
449
+ suffix: ctx.suffix,
450
+ caseId: ctx.caseId,
451
+ caseIndex: ctx.caseIndex,
452
+ turnIndex: ctx.turnIndex,
453
+ blockIndex,
454
+ declaredIdentities: ctx.declaredIdentities,
455
+ }));
456
+ }
457
+ else {
458
+ // Flat sugar: one unscoped expectation block from the case-level / turn-level assertion fields.
459
+ expectations = [
460
+ buildExpectation(raw, undefined, {
461
+ suffix: ctx.suffix,
462
+ caseId: ctx.caseId,
463
+ caseIndex: ctx.caseIndex,
464
+ turnIndex: ctx.turnIndex,
465
+ blockIndex: undefined,
466
+ declaredIdentities: ctx.declaredIdentities,
467
+ }),
468
+ ];
469
+ }
470
+ // NO-SILENT-PASS (per turn × identity): when the suite declares identities, THIS turn must have at
471
+ // least one applicable block for every identity, else nothing would grade that (turn × identity) —
472
+ // a suite-authoring bug, not a trivial pass. A block with no `identities` applies to all.
473
+ if (ctx.identities) {
474
+ for (const identity of ctx.identities) {
475
+ const covered = expectations.some((e) => !e.identities || e.identities.length === 0 || e.identities.includes(identity));
476
+ if (!covered) {
477
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has no expectation block covering identity ` +
478
+ `"${identity}" — every (turn × identity) must have at least one applicable block (add ` +
479
+ `an \`identities: [${identity}]\` block, or an unscoped block that applies to all ` +
480
+ 'identities).');
481
+ }
482
+ }
483
+ }
484
+ return expectations;
485
+ }
486
+ /**
487
+ * Normalize one raw assertion bundle (a flat case's case-level fields, or one `expect:` block) into
488
+ * an {@link EvalExpectation}: default arrays to `[]`, compile regexes at parse time, validate
489
+ * json_path shape, validate the optional `identities` scope against the suite's declared list, and
490
+ * enforce that the block declares at least one check or a judge rubric.
491
+ */
492
+ function buildExpectation(raw, blockIdentities, ctx) {
493
+ const turnPart = ctx.turnIndex === undefined ? '' : ` turn ${ctx.turnIndex}`;
494
+ const where = ctx.blockIndex === undefined
495
+ ? `case "${ctx.caseId}" (index ${ctx.caseIndex})${turnPart}`
496
+ : `case "${ctx.caseId}" (index ${ctx.caseIndex})${turnPart} expect block ${ctx.blockIndex}`;
497
+ // Validate the block's identity scope (only present on `expect:` blocks). Every named identity
498
+ // must be one the suite declares, else the block would silently never apply (a dead / typo'd
499
+ // scope) — reject it here.
500
+ let identities;
501
+ if (blockIdentities !== undefined) {
502
+ if (blockIdentities.length === 0) {
503
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has an empty \`identities\` list — omit it to ` +
504
+ 'apply to all identities, or name at least one.');
505
+ }
506
+ for (const name of blockIdentities) {
507
+ if (!ctx.declaredIdentities || !ctx.declaredIdentities.has(name)) {
508
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} references identity "${name}" which the suite ` +
509
+ 'does not declare — add it to the suite-level `identities` list.');
510
+ }
511
+ }
512
+ identities = blockIdentities;
513
+ }
514
+ const mustContain = raw.must_contain ?? [];
515
+ const mustNotContain = raw.must_not_contain ?? [];
516
+ const shouldContainAny = raw.should_contain_any ?? [];
517
+ const mustCall = raw.must_call ?? [];
518
+ const mustNotCall = raw.must_not_call ?? [];
519
+ // Compile regex assertions here so an invalid pattern is a parse-time suite error, never a crash
520
+ // partway through a run. The compiled RegExp is stored on the expectation and reused as-is.
521
+ const compileRegexes = (patterns, field) => (patterns ?? []).map((pattern) => {
522
+ try {
523
+ return new RegExp(pattern);
524
+ }
525
+ catch (error) {
526
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has an invalid ${field} pattern ` +
527
+ `${JSON.stringify(pattern)}: ` +
528
+ (error instanceof Error ? error.message : String(error)));
529
+ }
530
+ });
531
+ const mustMatch = compileRegexes(raw.must_match, 'must_match');
532
+ const mustNotMatch = compileRegexes(raw.must_not_match, 'must_not_match');
533
+ // Each json_path entry must set exactly one of equals/contains. `equals` may legitimately be any
534
+ // JSON value including `null`, so presence is `!== undefined` (an explicit null counts).
535
+ const jsonPath = (raw.json_path ?? []).map((entry) => {
536
+ const hasEquals = entry.equals !== undefined;
537
+ const hasContains = entry.contains !== undefined;
538
+ if (hasEquals === hasContains) {
539
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} json_path entry for "${entry.path}" must set ` +
540
+ 'exactly one of "equals" or "contains".');
541
+ }
542
+ return hasContains
543
+ ? { path: entry.path, contains: entry.contains }
544
+ : { path: entry.path, equals: entry.equals };
545
+ });
546
+ const hasChecks = mustContain.length > 0 ||
547
+ mustNotContain.length > 0 ||
548
+ shouldContainAny.length > 0 ||
549
+ mustCall.length > 0 ||
550
+ mustNotCall.length > 0 ||
551
+ mustMatch.length > 0 ||
552
+ mustNotMatch.length > 0 ||
553
+ jsonPath.length > 0;
554
+ const judgeRubric = raw.judge?.trim();
555
+ const hasJudge = !!judgeRubric;
556
+ if (!hasChecks && !hasJudge) {
557
+ throw new Error(`Invalid eval suite${ctx.suffix}: ${where} has no checks and no judge rubric — it must ` +
558
+ 'declare at least one of must_contain / must_not_contain / should_contain_any / must_call ' +
559
+ '/ must_not_call / must_match / must_not_match / json_path, or a judge rubric.');
560
+ }
561
+ return {
562
+ identities,
563
+ mustContain,
564
+ mustNotContain,
565
+ shouldContainAny,
566
+ mustCall,
567
+ mustNotCall,
568
+ mustMatch,
569
+ mustNotMatch,
570
+ jsonPath,
571
+ judgeRubric: hasJudge ? judgeRubric : undefined,
572
+ };
573
+ }
129
574
  //# sourceMappingURL=evalSuite.js.map