@catheadowl/dsh-eval 0.1.0 → 0.2.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.
- package/CHANGELOG.md +57 -0
- package/README.i18n.yaml +6 -0
- package/README.md +59 -55
- package/README.zh.md +129 -0
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +6 -2
- package/docs/cross-turn.md +66 -0
- package/docs/disablerows.md +1 -1
- package/docs/experimental.md +35 -0
- package/docs/host-wiring.md +3 -3
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +28 -2
- package/docs/review.md +13 -10
- package/docs/rowconfig.md +39 -0
- package/docs/runner-api.md +42 -0
- package/package.json +19 -3
- package/src/adapters/dsh/review.mjs +210 -192
- package/src/assertions.mjs +499 -389
- package/src/cli.mjs +8 -8
- package/src/config.mjs +1 -1
- package/src/discovery.mjs +190 -115
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +39 -0
- package/src/index.mjs +38 -49
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +109 -0
- package/src/report.mjs +1 -1
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +236 -373
- package/src/sandbox.mjs +262 -0
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
- package/src/adapters/dsh/index.mjs +0 -7
package/src/runner.mjs
CHANGED
|
@@ -1,373 +1,236 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The eval run driver. One eval case = one `dsh --profile <p> --patch
|
|
3
|
-
* <generated-overlay> "<task>"` headless run in an isolated `DSH_HOME`, so
|
|
4
|
-
* the session JSONL trace lands alone under a per-run persistence root and
|
|
5
|
-
* needs no teardown of shared state. The overlay is generated per run
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - optional
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
writeFileSync(overlayPath, buildOverlayYaml({
|
|
238
|
-
sessionsRoot,
|
|
239
|
-
persona: evalCase.persona,
|
|
240
|
-
disableRows: evalCase.disableRows,
|
|
241
|
-
mock: mode === 'mock',
|
|
242
|
-
}))
|
|
243
|
-
|
|
244
|
-
const cliArgs = [
|
|
245
|
-
binPath,
|
|
246
|
-
'--profile', options.profile,
|
|
247
|
-
'--patch', overlayPath,
|
|
248
|
-
evalCase.task,
|
|
249
|
-
]
|
|
250
|
-
const child = spawn(process.execPath, cliArgs, { cwd: workspace, env })
|
|
251
|
-
|
|
252
|
-
let stdout = ''
|
|
253
|
-
let stderr = ''
|
|
254
|
-
child.stdout.on('data', chunk => { stdout += chunk })
|
|
255
|
-
child.stderr.on('data', chunk => { stderr += chunk })
|
|
256
|
-
|
|
257
|
-
let timedOut = false
|
|
258
|
-
const timer = setTimeout(() => {
|
|
259
|
-
timedOut = true
|
|
260
|
-
child.kill('SIGTERM')
|
|
261
|
-
}, timeoutMs)
|
|
262
|
-
|
|
263
|
-
const exitCode = await new Promise(resolveExit => {
|
|
264
|
-
child.on('error', error => { stderr += `\ndsh-eval: failed to spawn dsh CLI: ${error.message}\n`; resolveExit(127) })
|
|
265
|
-
child.on('exit', code => resolveExit(code ?? 1))
|
|
266
|
-
})
|
|
267
|
-
clearTimeout(timer)
|
|
268
|
-
|
|
269
|
-
const trace = loadTraceDir(sessionsRoot)
|
|
270
|
-
const sessionLogs = collectSessionLogTexts(sessionsRoot)
|
|
271
|
-
|
|
272
|
-
// Workspace assertions live HERE, before the run dir cleanup: a case's
|
|
273
|
-
// `inspect(workspace, { trace })` may throw; the failure text rides the
|
|
274
|
-
// result instead of leaking past cleanup.
|
|
275
|
-
let inspectError
|
|
276
|
-
if (typeof evalCase.inspect === 'function') {
|
|
277
|
-
try {
|
|
278
|
-
await evalCase.inspect(workspace, { trace })
|
|
279
|
-
} catch (error) {
|
|
280
|
-
inspectError = error instanceof Error ? error.message : String(error)
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (options.artifactsDir !== undefined) {
|
|
285
|
-
mkdirSync(options.artifactsDir, { recursive: true })
|
|
286
|
-
writeFileSync(join(options.artifactsDir, 'stdout.txt'), stdout)
|
|
287
|
-
writeFileSync(join(options.artifactsDir, 'stderr.txt'), stderr)
|
|
288
|
-
writeFileSync(join(options.artifactsDir, 'trace.json'), JSON.stringify({
|
|
289
|
-
caseId: evalCase.id,
|
|
290
|
-
mode,
|
|
291
|
-
task: evalCase.task,
|
|
292
|
-
exitCode,
|
|
293
|
-
timedOut,
|
|
294
|
-
trace,
|
|
295
|
-
}, undefined, 2))
|
|
296
|
-
try {
|
|
297
|
-
cpSync(sessionsRoot, join(options.artifactsDir, 'sessions'), { recursive: true })
|
|
298
|
-
} catch { /* no session materialized — nothing to copy */ }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
caseId: evalCase.id, mode, task: evalCase.task, exitCode, timedOut,
|
|
303
|
-
stdout, stderr, trace, sessionLogs, inspectError, runDir,
|
|
304
|
-
}
|
|
305
|
-
} finally {
|
|
306
|
-
if (process.env.DSH_EVAL_KEEP_TMP !== '1') {
|
|
307
|
-
// Drop every junction first so cleanup can never descend into the
|
|
308
|
-
// real profile store. Junctions may not exist when the error
|
|
309
|
-
// happened before stageProfileStore ran — readdirSync catches that.
|
|
310
|
-
try {
|
|
311
|
-
const profileJunctions = []
|
|
312
|
-
const walk = (dir) => {
|
|
313
|
-
let entries
|
|
314
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
315
|
-
for (const entry of entries) {
|
|
316
|
-
const full = join(dir, entry.name)
|
|
317
|
-
if (entry.isSymbolicLink()) profileJunctions.push(full)
|
|
318
|
-
else if (entry.isDirectory()) walk(full)
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
walk(join(runDir, 'dsh-home'))
|
|
322
|
-
for (const junction of profileJunctions) {
|
|
323
|
-
try { unlinkSync(junction) } catch { /* junction absent — nothing to drop */ }
|
|
324
|
-
}
|
|
325
|
-
} catch { /* dsh-home not created yet */ }
|
|
326
|
-
rmSync(runDir, { recursive: true, force: true })
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** Read every session artifact under the root as text (best-effort, pre-cleanup). */
|
|
332
|
-
function collectSessionLogTexts(sessionsRoot) {
|
|
333
|
-
const texts = []
|
|
334
|
-
const walk = (dir) => {
|
|
335
|
-
let entries
|
|
336
|
-
try {
|
|
337
|
-
entries = readdirSync(dir, { withFileTypes: true })
|
|
338
|
-
} catch {
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
for (const entry of entries) {
|
|
342
|
-
const path = join(dir, entry.name)
|
|
343
|
-
if (entry.isDirectory()) walk(path)
|
|
344
|
-
else if (entry.name === 'session.jsonl') texts.push(readFileSync(path, 'utf8'))
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
walk(sessionsRoot)
|
|
348
|
-
return texts
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* @typedef {object} EvalRunResult
|
|
353
|
-
* @property {string} caseId
|
|
354
|
-
* @property {'real' | 'mock'} mode
|
|
355
|
-
* @property {string} task
|
|
356
|
-
* @property {number} exitCode - the headless CLI's exit code (0 = turn completed).
|
|
357
|
-
* @property {boolean} timedOut
|
|
358
|
-
* @property {string} stdout - printed final assistant text (plus any startup chatter).
|
|
359
|
-
* @property {string} stderr
|
|
360
|
-
* @property {import('./trace.mjs').EvalTrace | undefined} trace
|
|
361
|
-
* @property {string[]} sessionLogs - raw session artifact texts, pre-cleanup.
|
|
362
|
-
* @property {string | undefined} inspectError - the case's `inspect` failure text, when it threw.
|
|
363
|
-
* @property {string} runDir - removed unless DSH_EVAL_KEEP_TMP=1.
|
|
364
|
-
*/
|
|
365
|
-
|
|
366
|
-
/** Re-exported so bin can resolve the framework without guessing paths. */
|
|
367
|
-
export { FRAMEWORK_ROOT }
|
|
368
|
-
|
|
369
|
-
/** Whether a candidate dsh repo dir looks like one (the CLI artifact exists). */
|
|
370
|
-
export function looksLikeDshRepo(dir) {
|
|
371
|
-
if (!isAbsolute(dir)) return false
|
|
372
|
-
return existsSync(join(dir, 'apps', 'cli', 'lib', 'bin.js'))
|
|
373
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The eval run driver. One eval case = one `dsh --profile <p> --patch
|
|
3
|
+
* <generated-overlay> "<task>"` headless run in an isolated `DSH_HOME`, so
|
|
4
|
+
* the session JSONL trace lands alone under a per-run persistence root and
|
|
5
|
+
* needs no teardown of shared state. The overlay is generated per run
|
|
6
|
+
* (see overlay.mjs):
|
|
7
|
+
*
|
|
8
|
+
* - always: `session-persistence-jsonl` re-rooted to the run dir, plaintext
|
|
9
|
+
* one-event-per-line layout (config override is whole-replace, so every
|
|
10
|
+
* field the backend needs is restated);
|
|
11
|
+
* - optional case persona: `system-prompt` persona override;
|
|
12
|
+
* - optional `disableRows: ['<row-id>', ...]` case declaration: the listed
|
|
13
|
+
* loader rows are disabled, so e.g. a turn-close blocking gate plugin
|
|
14
|
+
* cannot splice feedback steps past the script's terminal step (the
|
|
15
|
+
* disableRows × turn-close gate boundary contract);
|
|
16
|
+
* - optional `rowConfig: { '<row-id>': { key: value } }` case declaration:
|
|
17
|
+
* per-row config overrides in this run's overlay. The overlay REPLACES
|
|
18
|
+
* the row's whole config (cordis patch semantics), so restate any keys
|
|
19
|
+
* the row still needs — the arm-style A/B use case disables one
|
|
20
|
+
* provider via `disabledProviders` while restating the row's other
|
|
21
|
+
* config keys explicitly;
|
|
22
|
+
* - mock mode: `agent-default-model` re-pointed at the `eval-mock` provider
|
|
23
|
+
* plus an insert mounting the scripted adapter plugin by `file://` URL
|
|
24
|
+
* (relative plugin names resolve against the PROFILE dir, not the overlay
|
|
25
|
+
* file, so an absolute URL is the portable reference).
|
|
26
|
+
*
|
|
27
|
+
* Isolation mechanics (home staging, junction-safe teardown, spawn/timeout)
|
|
28
|
+
* live in sandbox.mjs; overlay serialization lives in overlay.mjs. This
|
|
29
|
+
* module is the orchestration only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, cpSync, readdirSync, readFileSync } from 'node:fs'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
import { loadTraceDir } from './trace.mjs'
|
|
37
|
+
import { validateRowConfig, validateDisableRows, validateFollowups } from './discovery.mjs'
|
|
38
|
+
import { CLI_RELATIVE_PATH } from './cli.mjs'
|
|
39
|
+
import { buildOverlayYaml } from './overlay.mjs'
|
|
40
|
+
import { resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh } from './sandbox.mjs'
|
|
41
|
+
|
|
42
|
+
/** This framework's root directory (the eval package dir). */
|
|
43
|
+
const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run one eval case end to end.
|
|
47
|
+
*
|
|
48
|
+
* Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
|
|
49
|
+
* script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
|
|
50
|
+
* rowConfig?: Record<string, Record<string, unknown>>,
|
|
51
|
+
* followups?: string[], settleTimeoutMs?: number,
|
|
52
|
+
* prepare?: (workspace: string) => void | Promise<void>,
|
|
53
|
+
* inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
|
|
54
|
+
* timeoutMs?: number }`
|
|
55
|
+
*
|
|
56
|
+
* `followups` opts into cross-turn driving: the overlay swaps the one-shot
|
|
57
|
+
* `headless-runner` row for the eval multi-turn driver, which — before each
|
|
58
|
+
* followup — waits for background subagent children to settle (bounded by
|
|
59
|
+
* `settleTimeoutMs`), keeping the process alive so fire-and-forget children
|
|
60
|
+
* (turn-close defer fixers) can run to completion between turns.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} evalCase - the case under test.
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {string} options.profile - the dsh profile booting the run (plugin installed there).
|
|
65
|
+
* @param {string} [options.dshRepoDir] - the deepseek-harness checkout (legacy CLI
|
|
66
|
+
* location; ignored when cliPath is given).
|
|
67
|
+
* @deprecated options.dshRepoDir — pass the C6 chain result via cliPath
|
|
68
|
+
* instead; this legacy option is removed in the next minor release.
|
|
69
|
+
* @param {string} [options.cliPath] - explicit compiled CLI entry (C6 chain result;
|
|
70
|
+
* takes precedence over dshRepoDir).
|
|
71
|
+
* @param {'real' | 'mock'} [options.mode] - force a mode over the case's own.
|
|
72
|
+
* @param {string} [options.artifactsDir] - copy stdout/stderr/trace/session logs here (created).
|
|
73
|
+
* @returns {Promise<EvalRunResult>}
|
|
74
|
+
*/
|
|
75
|
+
export async function runEvalCase(evalCase, options) {
|
|
76
|
+
const mode = options.mode ?? evalCase.mode ?? 'real'
|
|
77
|
+
const binPath = options.cliPath !== undefined
|
|
78
|
+
? resolve(options.cliPath)
|
|
79
|
+
: join(resolve(options.dshRepoDir), ...CLI_RELATIVE_PATH.split(/[\\/]/))
|
|
80
|
+
const timeoutMs = evalCase.timeoutMs ?? 180_000
|
|
81
|
+
|
|
82
|
+
const runDir = mkdtempSync(join(tmpdir(), 'dsh-eval-'))
|
|
83
|
+
// Everything past this point is wrapped in try/finally so the temp dir
|
|
84
|
+
// (and any junctions) are cleaned up even when `prepare`, profile
|
|
85
|
+
// staging, or spawn throw. Previously only the normal exit path
|
|
86
|
+
// cleaned up — a `prepare` failure leaked the entire runDir.
|
|
87
|
+
try {
|
|
88
|
+
const dshHome = join(runDir, 'dsh-home')
|
|
89
|
+
const workspace = join(runDir, 'workspace')
|
|
90
|
+
const sessionsRoot = join(runDir, 'sessions')
|
|
91
|
+
mkdirSync(workspace, { recursive: true })
|
|
92
|
+
await evalCase.prepare?.(workspace)
|
|
93
|
+
|
|
94
|
+
// Profiles resolve under $DSH_HOME/profiles, and eval overwrites DSH_HOME
|
|
95
|
+
// for session/settings isolation (staging + teardown mechanics in
|
|
96
|
+
// sandbox.mjs — the booted profile is copied so boot's unconditional
|
|
97
|
+
// cordis.yml rewrite stays inside the temporary home; only the profile's
|
|
98
|
+
// read-only node_modules stays linked, and the shared fallback is rebuilt
|
|
99
|
+
// by boot inside the temporary home).
|
|
100
|
+
const realHome = resolveRealDshHome()
|
|
101
|
+
stageSandboxHome(realHome, dshHome, options.profile)
|
|
102
|
+
|
|
103
|
+
const env = {
|
|
104
|
+
...process.env,
|
|
105
|
+
DSH_HOME: dshHome,
|
|
106
|
+
DSH_TELEMETRY_DISABLED: '1',
|
|
107
|
+
}
|
|
108
|
+
if (mode === 'mock') {
|
|
109
|
+
if (evalCase.script === undefined) {
|
|
110
|
+
throw new Error(`case '${evalCase.id}': mock mode requires a script`)
|
|
111
|
+
}
|
|
112
|
+
const scriptPath = join(runDir, 'mock-script.json')
|
|
113
|
+
writeFileSync(scriptPath, JSON.stringify(evalCase.script))
|
|
114
|
+
env.DSH_EVAL_MOCK_SCRIPT = scriptPath
|
|
115
|
+
}
|
|
116
|
+
if (evalCase.followups !== undefined) {
|
|
117
|
+
const planPath = join(runDir, 'driver-plan.json')
|
|
118
|
+
writeFileSync(planPath, JSON.stringify({
|
|
119
|
+
task: evalCase.task,
|
|
120
|
+
followups: evalCase.followups,
|
|
121
|
+
...(evalCase.settleTimeoutMs === undefined ? {} : { settleTimeoutMs: evalCase.settleTimeoutMs }),
|
|
122
|
+
}))
|
|
123
|
+
env.DSH_EVAL_DRIVER_PLAN = planPath
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (evalCase.disableRows !== undefined) {
|
|
127
|
+
validateDisableRows(evalCase.disableRows, `case '${evalCase.id}'`)
|
|
128
|
+
}
|
|
129
|
+
if (evalCase.rowConfig !== undefined) {
|
|
130
|
+
validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
|
|
131
|
+
}
|
|
132
|
+
if (evalCase.followups !== undefined) {
|
|
133
|
+
validateFollowups(evalCase.followups, `case '${evalCase.id}'`)
|
|
134
|
+
}
|
|
135
|
+
const overlayPath = join(runDir, 'eval-overlay.yml')
|
|
136
|
+
writeFileSync(overlayPath, buildOverlayYaml({
|
|
137
|
+
sessionsRoot,
|
|
138
|
+
persona: evalCase.persona,
|
|
139
|
+
disableRows: evalCase.disableRows,
|
|
140
|
+
rowConfig: evalCase.rowConfig,
|
|
141
|
+
mock: mode === 'mock',
|
|
142
|
+
...(evalCase.followups === undefined ? {} : { followups: evalCase.followups }),
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
|
|
146
|
+
cli: binPath,
|
|
147
|
+
cliArgs: ['--profile', options.profile, '--patch', overlayPath, evalCase.task],
|
|
148
|
+
cwd: workspace,
|
|
149
|
+
env,
|
|
150
|
+
timeoutMs,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const trace = loadTraceDir(sessionsRoot)
|
|
154
|
+
const sessionLogs = collectSessionLogTexts(sessionsRoot)
|
|
155
|
+
|
|
156
|
+
// Workspace assertions live HERE, before the run dir cleanup: a case's
|
|
157
|
+
// `inspect(workspace, { trace })` may throw; the failure text rides the
|
|
158
|
+
// result instead of leaking past cleanup.
|
|
159
|
+
let inspectError
|
|
160
|
+
if (typeof evalCase.inspect === 'function') {
|
|
161
|
+
try {
|
|
162
|
+
await evalCase.inspect(workspace, { trace })
|
|
163
|
+
} catch (error) {
|
|
164
|
+
inspectError = error instanceof Error ? error.message : String(error)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (options.artifactsDir !== undefined) {
|
|
169
|
+
mkdirSync(options.artifactsDir, { recursive: true })
|
|
170
|
+
writeFileSync(join(options.artifactsDir, 'stdout.txt'), stdout)
|
|
171
|
+
writeFileSync(join(options.artifactsDir, 'stderr.txt'), stderr)
|
|
172
|
+
writeFileSync(join(options.artifactsDir, 'trace.json'), JSON.stringify({
|
|
173
|
+
caseId: evalCase.id,
|
|
174
|
+
mode,
|
|
175
|
+
task: evalCase.task,
|
|
176
|
+
exitCode,
|
|
177
|
+
timedOut,
|
|
178
|
+
trace,
|
|
179
|
+
}, undefined, 2))
|
|
180
|
+
try {
|
|
181
|
+
cpSync(sessionsRoot, join(options.artifactsDir, 'sessions'), { recursive: true })
|
|
182
|
+
} catch { /* no session materialized — nothing to copy */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
caseId: evalCase.id, mode, task: evalCase.task, exitCode, timedOut,
|
|
187
|
+
stdout, stderr, trace, sessionLogs, inspectError, runDir,
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
teardownSandbox(runDir, { keep: process.env.DSH_EVAL_KEEP_TMP === '1' })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read every session artifact under the root as text (best-effort, pre-cleanup). */
|
|
195
|
+
function collectSessionLogTexts(sessionsRoot) {
|
|
196
|
+
const texts = []
|
|
197
|
+
const walk = (dir) => {
|
|
198
|
+
let entries
|
|
199
|
+
try {
|
|
200
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
201
|
+
} catch {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const path = join(dir, entry.name)
|
|
206
|
+
if (entry.isDirectory()) walk(path)
|
|
207
|
+
else if (entry.name === 'session.jsonl') texts.push(readFileSync(path, 'utf8'))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
walk(sessionsRoot)
|
|
211
|
+
return texts
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @typedef {object} EvalRunResult
|
|
216
|
+
* @property {string} caseId
|
|
217
|
+
* @property {'real' | 'mock'} mode
|
|
218
|
+
* @property {string} task
|
|
219
|
+
* @property {number} exitCode - the headless CLI's exit code (0 = turn completed).
|
|
220
|
+
* @property {boolean} timedOut
|
|
221
|
+
* @property {string} stdout - printed final assistant text (plus any startup chatter).
|
|
222
|
+
* @property {string} stderr
|
|
223
|
+
* @property {import('./trace.mjs').EvalTrace | undefined} trace
|
|
224
|
+
* @property {string[]} sessionLogs - raw session artifact texts, pre-cleanup.
|
|
225
|
+
* @property {string | undefined} inspectError - the case's `inspect` failure text, when it threw.
|
|
226
|
+
* @property {string} runDir - removed unless DSH_EVAL_KEEP_TMP=1.
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/** Re-exported so bin can resolve the framework without guessing paths. */
|
|
230
|
+
export { FRAMEWORK_ROOT }
|
|
231
|
+
|
|
232
|
+
/** Whether a candidate dsh repo dir looks like one (the CLI artifact exists). */
|
|
233
|
+
export function looksLikeDshRepo(dir) {
|
|
234
|
+
if (!isAbsolute(dir)) return false
|
|
235
|
+
return existsSync(join(dir, ...CLI_RELATIVE_PATH.split(/[\\/]/)))
|
|
236
|
+
}
|