@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.
- package/README.md +140 -0
- package/dist/bin.d.ts +13 -0
- package/dist/bin.js +28 -0
- package/dist/bin.js.map +1 -0
- package/dist/deterministicChecks.d.ts +39 -7
- package/dist/deterministicChecks.js +120 -11
- package/dist/deterministicChecks.js.map +1 -1
- package/dist/evalOutput.d.ts +17 -4
- package/dist/evalOutput.js +40 -5
- package/dist/evalOutput.js.map +1 -1
- package/dist/evalRunner.d.ts +68 -15
- package/dist/evalRunner.js +309 -55
- package/dist/evalRunner.js.map +1 -1
- package/dist/evalSuite.d.ts +32 -11
- package/dist/evalSuite.js +487 -42
- package/dist/evalSuite.js.map +1 -1
- package/dist/evalTypes.d.ts +187 -15
- package/dist/index.d.ts +5 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/pipelineCli.d.ts +102 -0
- package/dist/pipelineCli.js +348 -0
- package/dist/pipelineCli.js.map +1 -0
- package/dist/reporters/drive.d.ts +14 -0
- package/dist/reporters/drive.js +49 -0
- package/dist/reporters/drive.js.map +1 -0
- package/dist/reporters/registry.d.ts +11 -0
- package/dist/reporters/registry.js +26 -0
- package/dist/reporters/registry.js.map +1 -0
- package/dist/reporters/reporterTypes.d.ts +36 -0
- package/dist/reporters/reporterTypes.js +2 -0
- package/dist/reporters/reporterTypes.js.map +1 -0
- package/dist/reporters/textReporter.d.ts +10 -0
- package/dist/reporters/textReporter.js +50 -0
- package/dist/reporters/textReporter.js.map +1 -0
- package/dist/toolChecks.d.ts +21 -0
- package/dist/toolChecks.js +38 -0
- package/dist/toolChecks.js.map +1 -0
- 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)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
54
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
* -
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* -
|
|
62
|
-
* -
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|