@catheadowl/dsh-eval 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/dsh-eval.mjs +335 -0
- package/bin/dsh-review.mjs +154 -0
- package/docs/README.md +15 -0
- package/docs/disablerows.md +25 -0
- package/docs/host-wiring.md +71 -0
- package/docs/intent-cases.md +39 -0
- package/docs/known-issues.md +15 -0
- package/docs/matchers.md +35 -0
- package/docs/report.md +27 -0
- package/docs/review.md +77 -0
- package/package.json +31 -0
- package/src/adapters/dsh/index.mjs +7 -0
- package/src/adapters/dsh/review.mjs +192 -0
- package/src/assertions.mjs +389 -0
- package/src/cli.mjs +104 -0
- package/src/config.mjs +119 -0
- package/src/discovery.mjs +115 -0
- package/src/experiment/review.mjs +118 -0
- package/src/index.mjs +49 -0
- package/src/mock/mock-adapter.mjs +73 -0
- package/src/mock/script.mjs +49 -0
- package/src/report.mjs +142 -0
- package/src/review-report.mjs +101 -0
- package/src/runner.mjs +373 -0
- package/src/tool-validation.mjs +77 -0
- package/src/trace.mjs +218 -0
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,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
|
+
*
|
|
7
|
+
* - always: `session-persistence-jsonl` re-rooted to the run dir, plaintext
|
|
8
|
+
* one-event-per-line layout (config override is whole-replace, so every
|
|
9
|
+
* field the backend needs is restated);
|
|
10
|
+
* - optional case persona: `system-prompt` persona override;
|
|
11
|
+
* - optional `disableRows: ['<row-id>', ...]` case declaration: the listed
|
|
12
|
+
* loader rows are disabled, so e.g. a turn-close blocking gate plugin
|
|
13
|
+
* cannot splice feedback steps past the script's terminal step (the
|
|
14
|
+
* disableRows × turn-close gate boundary contract);
|
|
15
|
+
* - mock mode: `agent-default-model` re-pointed at the `eval-mock` provider
|
|
16
|
+
* plus an insert mounting the scripted adapter plugin by `file://` URL
|
|
17
|
+
* (relative plugin names resolve against the PROFILE dir, not the overlay
|
|
18
|
+
* file, so an absolute URL is the portable reference).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process'
|
|
22
|
+
import {
|
|
23
|
+
chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync,
|
|
24
|
+
} from 'node:fs'
|
|
25
|
+
import { tmpdir, homedir } from 'node:os'
|
|
26
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
28
|
+
import { loadTraceDir } from './trace.mjs'
|
|
29
|
+
|
|
30
|
+
/** This framework's root directory (the eval package dir). */
|
|
31
|
+
const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
32
|
+
|
|
33
|
+
/** The scripted mock adapter plugin, referenced from generated overlays. */
|
|
34
|
+
const MOCK_ADAPTER_PATH = join(FRAMEWORK_ROOT, 'src', 'mock', 'mock-adapter.mjs')
|
|
35
|
+
|
|
36
|
+
/** The profile-local module-fallback directory, rebuilt fresh by boot and never staged. */
|
|
37
|
+
const MODULE_FALLBACK_DIR = '.dsh-module-fallback'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stage the profile store into a temporary DSH_HOME. The BOOTED profile's
|
|
41
|
+
* directory is COPIED (sans its own `node_modules` and `.dsh-module-fallback`):
|
|
42
|
+
* `prepareProfile` unconditionally rewrites the profile's root cordis.yml on
|
|
43
|
+
* every boot, and copying keeps that write inside the temporary home instead
|
|
44
|
+
* of leaking through a junction into the real store. Only the profile's own
|
|
45
|
+
* `node_modules` stays junctioned — out-of-tree plugin resolution needs it,
|
|
46
|
+
* and a task boot never writes it. The module-fallback directories (the
|
|
47
|
+
* store-level shared `profiles/node_modules` and the profile-local
|
|
48
|
+
* `.dsh-module-fallback`) are deliberately NOT staged: `healProfilesModuleFallback`
|
|
49
|
+
* rebuilds both fresh in the temporary home on every boot, and `.dsh-module-fallback`
|
|
50
|
+
* in particular is full of junctions that a naive recursive copy would follow
|
|
51
|
+
* into the real store. The copy is junction-aware — links are recreated as
|
|
52
|
+
* links, never descended (see `copyProfileEntry`). An absent profile copies
|
|
53
|
+
* nothing: boot initializes shipped templates inside the temporary home.
|
|
54
|
+
* @param {string} realHome - the real Harness home holding `profiles/`.
|
|
55
|
+
* @param {string} tmpHome - the temporary home (created up to `profiles/`).
|
|
56
|
+
* @param {string} profileName - the profile this run boots.
|
|
57
|
+
* @returns {string[]} every created junction path (unlink before rmSync).
|
|
58
|
+
*/
|
|
59
|
+
export function stageProfileStore(realHome, tmpHome, profileName) {
|
|
60
|
+
const junctions = []
|
|
61
|
+
const tmpProfiles = join(tmpHome, 'profiles')
|
|
62
|
+
mkdirSync(tmpProfiles, { recursive: true })
|
|
63
|
+
const realProfiles = join(realHome, 'profiles')
|
|
64
|
+
if (!existsSync(realProfiles)) return junctions
|
|
65
|
+
const realProfileDir = join(realProfiles, profileName)
|
|
66
|
+
if (!existsSync(join(realProfileDir, 'package.json'))) return junctions
|
|
67
|
+
const tmpProfileDir = join(tmpProfiles, profileName)
|
|
68
|
+
mkdirSync(tmpProfileDir, { recursive: true })
|
|
69
|
+
for (const entry of readdirSync(realProfileDir, { withFileTypes: true })) {
|
|
70
|
+
if (entry.name === 'node_modules' || entry.name === MODULE_FALLBACK_DIR) continue
|
|
71
|
+
copyProfileEntry(join(realProfileDir, entry.name), join(tmpProfileDir, entry.name), junctions)
|
|
72
|
+
}
|
|
73
|
+
const profileModules = join(realProfileDir, 'node_modules')
|
|
74
|
+
if (existsSync(profileModules)) {
|
|
75
|
+
const target = join(tmpProfileDir, 'node_modules')
|
|
76
|
+
symlinkSync(profileModules, target, 'junction')
|
|
77
|
+
junctions.push(target)
|
|
78
|
+
}
|
|
79
|
+
return junctions
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Copy one profile entry (file, directory, or link) into the staged profile.
|
|
84
|
+
* A link is recreated as a link (`'junction'` on Windows) instead of being
|
|
85
|
+
* followed: Node's `cpSync` dereferences Windows junctions in recursive mode
|
|
86
|
+
* (no cycle guard), so a junction-bearing tree would recurse into the real
|
|
87
|
+
* store and overflow the native stack. Recreated junctions are appended to
|
|
88
|
+
* `junctions` so callers can unlink them before `rmSync` (which would
|
|
89
|
+
* otherwise descend through them into the real store).
|
|
90
|
+
*/
|
|
91
|
+
function copyProfileEntry(from, to, junctions) {
|
|
92
|
+
const stat = lstatSync(from)
|
|
93
|
+
if (stat.isSymbolicLink()) {
|
|
94
|
+
const target = readlinkSync(from)
|
|
95
|
+
symlinkSync(target, to, 'junction')
|
|
96
|
+
junctions.push(to)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (stat.isDirectory()) {
|
|
100
|
+
mkdirSync(to, { recursive: true })
|
|
101
|
+
for (const child of readdirSync(from)) {
|
|
102
|
+
copyProfileEntry(join(from, child), join(to, child), junctions)
|
|
103
|
+
}
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
copyFileSync(from, to)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** JSON double-quoted strings are valid YAML scalars — enough for this emitter. */
|
|
110
|
+
function yamlScalar(value) {
|
|
111
|
+
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
|
112
|
+
return JSON.stringify(String(value))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Serialize the per-run overlay patch list to YAML.
|
|
117
|
+
* @param {object} parts - overlay ingredients (see runEvalCase).
|
|
118
|
+
* @returns {string} the overlay file text.
|
|
119
|
+
*/
|
|
120
|
+
export function buildOverlayYaml(parts) {
|
|
121
|
+
const lines = []
|
|
122
|
+
lines.push('- id: session-persistence-jsonl')
|
|
123
|
+
lines.push(' config:')
|
|
124
|
+
lines.push(` root: ${yamlScalar(parts.sessionsRoot)}`)
|
|
125
|
+
lines.push(' packChunks: false')
|
|
126
|
+
lines.push(' compression: none')
|
|
127
|
+
if (parts.persona !== undefined) {
|
|
128
|
+
lines.push('- id: system-prompt')
|
|
129
|
+
lines.push(' config:')
|
|
130
|
+
lines.push(` persona: ${yamlScalar(parts.persona)}`)
|
|
131
|
+
}
|
|
132
|
+
for (const rowId of parts.disableRows ?? []) {
|
|
133
|
+
// Per-row disable uses the same cross-layer overlay mechanism as
|
|
134
|
+
// `session-title-llm`: an `- id: <row> / disabled: true` patch targets
|
|
135
|
+
// the row the plugin bundle itself inserts.
|
|
136
|
+
lines.push(`- id: ${yamlScalar(rowId)}`)
|
|
137
|
+
lines.push(' disabled: true')
|
|
138
|
+
}
|
|
139
|
+
if (parts.mock) {
|
|
140
|
+
lines.push('- id: agent-default-model')
|
|
141
|
+
lines.push(' config:')
|
|
142
|
+
lines.push(' provider: eval-mock')
|
|
143
|
+
lines.push(' model: eval-mock')
|
|
144
|
+
// The title generator also calls the default provider and would consume
|
|
145
|
+
// script steps; deterministic runs own every model call themselves.
|
|
146
|
+
lines.push('- id: session-title-llm')
|
|
147
|
+
lines.push(' disabled: true')
|
|
148
|
+
lines.push('- insert:')
|
|
149
|
+
lines.push(' - id: eval-mock-llm')
|
|
150
|
+
lines.push(` name: ${yamlScalar(pathToFileURL(MOCK_ADAPTER_PATH).href)}`)
|
|
151
|
+
}
|
|
152
|
+
return `${lines.join('\n')}\n`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run one eval case end to end.
|
|
157
|
+
*
|
|
158
|
+
* Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
|
|
159
|
+
* script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
|
|
160
|
+
* prepare?: (workspace: string) => void | Promise<void>,
|
|
161
|
+
* inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
|
|
162
|
+
* timeoutMs?: number }`
|
|
163
|
+
*
|
|
164
|
+
* @param {object} evalCase - the case under test.
|
|
165
|
+
* @param {object} options
|
|
166
|
+
* @param {string} options.profile - the dsh profile booting the run (plugin installed there).
|
|
167
|
+
* @param {string} [options.dshRepoDir] - the deepseek-harness checkout (legacy CLI
|
|
168
|
+
* location; ignored when cliPath is given).
|
|
169
|
+
* @param {string} [options.cliPath] - explicit compiled CLI entry (C6 chain result;
|
|
170
|
+
* takes precedence over dshRepoDir).
|
|
171
|
+
* @param {'real' | 'mock'} [options.mode] - force a mode over the case's own.
|
|
172
|
+
* @param {string} [options.artifactsDir] - copy stdout/stderr/trace/session logs here (created).
|
|
173
|
+
* @returns {Promise<EvalRunResult>}
|
|
174
|
+
*/
|
|
175
|
+
export async function runEvalCase(evalCase, options) {
|
|
176
|
+
const mode = options.mode ?? evalCase.mode ?? 'real'
|
|
177
|
+
const binPath = options.cliPath !== undefined
|
|
178
|
+
? resolve(options.cliPath)
|
|
179
|
+
: join(resolve(options.dshRepoDir), 'apps', 'cli', 'lib', 'bin.js')
|
|
180
|
+
const timeoutMs = evalCase.timeoutMs ?? 180_000
|
|
181
|
+
|
|
182
|
+
const runDir = mkdtempSync(join(tmpdir(), 'dsh-eval-'))
|
|
183
|
+
// Everything past this point is wrapped in try/finally so the temp dir
|
|
184
|
+
// (and any junctions) are cleaned up even when `prepare`, profile
|
|
185
|
+
// staging, or spawn throw. Previously only the normal exit path
|
|
186
|
+
// cleaned up — a `prepare` failure leaked the entire runDir.
|
|
187
|
+
try {
|
|
188
|
+
const dshHome = join(runDir, 'dsh-home')
|
|
189
|
+
const workspace = join(runDir, 'workspace')
|
|
190
|
+
const sessionsRoot = join(runDir, 'sessions')
|
|
191
|
+
mkdirSync(workspace, { recursive: true })
|
|
192
|
+
await evalCase.prepare?.(workspace)
|
|
193
|
+
|
|
194
|
+
// Profiles resolve under $DSH_HOME/profiles, and eval overwrites DSH_HOME
|
|
195
|
+
// for session/settings isolation: stage the profile store (see
|
|
196
|
+
// stageProfileStore — the booted profile is copied, so boot's unconditional
|
|
197
|
+
// cordis.yml rewrite stays inside the temporary home; only the profile's
|
|
198
|
+
// read-only node_modules stays linked, and the shared fallback is rebuilt
|
|
199
|
+
// by boot inside the temporary home). The managed credential
|
|
200
|
+
// document is copied in because `dsh-credentials-local` resolves it per
|
|
201
|
+
// request. Falls back to the default `~/.dsh` when the ambient environment
|
|
202
|
+
// sets no home of its own.
|
|
203
|
+
const realHome = (process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
204
|
+
mkdirSync(dshHome, { recursive: true })
|
|
205
|
+
stageProfileStore(realHome, dshHome, options.profile)
|
|
206
|
+
const realCredentials = join(realHome, '.credentials.yaml')
|
|
207
|
+
if (existsSync(realCredentials)) {
|
|
208
|
+
const credentialsCopy = join(dshHome, '.credentials.yaml')
|
|
209
|
+
copyFileSync(realCredentials, credentialsCopy)
|
|
210
|
+
try {
|
|
211
|
+
// Best-effort owner-only on POSIX (the harness's own e2e uses 0o600);
|
|
212
|
+
// a no-op beyond the read-only bit on Windows.
|
|
213
|
+
chmodSync(credentialsCopy, 0o600)
|
|
214
|
+
} catch { /* permission tightening is best-effort */ }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const env = {
|
|
218
|
+
...process.env,
|
|
219
|
+
DSH_HOME: dshHome,
|
|
220
|
+
DSH_TELEMETRY_DISABLED: '1',
|
|
221
|
+
}
|
|
222
|
+
if (mode === 'mock') {
|
|
223
|
+
if (evalCase.script === undefined) {
|
|
224
|
+
throw new Error(`case '${evalCase.id}': mock mode requires a script`)
|
|
225
|
+
}
|
|
226
|
+
const scriptPath = join(runDir, 'mock-script.json')
|
|
227
|
+
writeFileSync(scriptPath, JSON.stringify(evalCase.script))
|
|
228
|
+
env.DSH_EVAL_MOCK_SCRIPT = scriptPath
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const overlayPath = join(runDir, 'eval-overlay.yml')
|
|
232
|
+
if (evalCase.disableRows !== undefined
|
|
233
|
+
&& (!Array.isArray(evalCase.disableRows)
|
|
234
|
+
|| evalCase.disableRows.some(row => typeof row !== 'string' || row === ''))) {
|
|
235
|
+
throw new Error(`case '${evalCase.id}': disableRows must be a string[] of loader row ids`)
|
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-run tool boundary validation for blind review.
|
|
3
|
+
*
|
|
4
|
+
* After a review run completes, this module inspects the session trace's
|
|
5
|
+
* `request/header` events to verify that no unexpected tools were mounted
|
|
6
|
+
* in the reviewer's session. This is the detection half of the
|
|
7
|
+
* sterile-profile strategy: the profile prevents plugin tools from being
|
|
8
|
+
* installed, the overlay disables every host tool row, and this check
|
|
9
|
+
* makes any residual drift (a bundle leaking tools through a patch, a host
|
|
10
|
+
* regression, a misconfigured profile) an explicit adapter failure.
|
|
11
|
+
*
|
|
12
|
+
* The module is intentionally pure: it takes an already-parsed trace and
|
|
13
|
+
* returns a plain result object. File I/O (reading the session log,
|
|
14
|
+
* writing evidence) stays in the calling adapter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Collect every distinct tool name mounted across all `request/header`
|
|
19
|
+
* events in a trace.
|
|
20
|
+
* @param {import('./trace.mjs').EvalTrace} trace
|
|
21
|
+
* @returns {string[]} sorted tool names.
|
|
22
|
+
*/
|
|
23
|
+
function collectMountedToolNames(trace) {
|
|
24
|
+
const names = new Set()
|
|
25
|
+
for (const header of trace.requestHeaders ?? []) {
|
|
26
|
+
for (const name of header.toolNames ?? []) {
|
|
27
|
+
names.add(name)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...names].sort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check a review trace for tool leakage.
|
|
35
|
+
*
|
|
36
|
+
* Every tool name appearing in any `request/header` event is compared
|
|
37
|
+
* against the allowed set. An empty allowed set (the review default)
|
|
38
|
+
* means the reviewer must see no tools at all.
|
|
39
|
+
*
|
|
40
|
+
* @param {import('./trace.mjs').EvalTrace | undefined} trace - the parsed trace; `undefined` skips validation.
|
|
41
|
+
* @param {{ allowedTools?: Set<string> }} [options]
|
|
42
|
+
* @returns {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function validateToolBoundary(trace, options = {}) {
|
|
45
|
+
const allowed = options.allowedTools ?? new Set()
|
|
46
|
+
const allowedNames = [...allowed].sort()
|
|
47
|
+
if (trace === undefined || trace === null) {
|
|
48
|
+
return { ok: true, unexpected: [], actual: [], allowed: allowedNames }
|
|
49
|
+
}
|
|
50
|
+
const actual = collectMountedToolNames(trace)
|
|
51
|
+
const unexpected = actual.filter(name => !allowed.has(name))
|
|
52
|
+
return {
|
|
53
|
+
ok: unexpected.length === 0,
|
|
54
|
+
unexpected,
|
|
55
|
+
actual,
|
|
56
|
+
allowed: allowedNames,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render a diagnostic evidence document for a tool boundary failure.
|
|
62
|
+
* Suitable for writing to `.runs/<id>/tool-boundary-evidence.json`.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ ok: boolean, unexpected: string[], actual: string[], allowed: string[] }} validation
|
|
65
|
+
* @param {{ runDir: string, profile: string }} context
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function renderToolBoundaryEvidence(validation, context) {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
status: 'tool-boundary-violation',
|
|
71
|
+
runDir: context.runDir,
|
|
72
|
+
profile: context.profile,
|
|
73
|
+
unexpectedTools: validation.unexpected,
|
|
74
|
+
actualMounted: validation.actual,
|
|
75
|
+
allowedTools: validation.allowed,
|
|
76
|
+
}, null, 2) + '\n'
|
|
77
|
+
}
|
package/src/trace.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-trace parsing for dsh agent eval. The evidence source is the JSONL
|
|
3
|
+
* session artifact written by `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
4
|
+
* (configured `compression: none`, `packChunks: false` by the eval overlay):
|
|
5
|
+
* one `type: 'session'` header line, then one JSON record per `SessionEvent`.
|
|
6
|
+
* Event shapes follow `deepseek-harness/packages/core/session/src/types.ts`
|
|
7
|
+
* (`SessionEventMap`); packed `*-chunks` storage rows are tolerated and
|
|
8
|
+
* skipped — they only carry `assistant/chunk` deltas eval never asserts on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
|
|
15
|
+
const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse one uncompressed JSONL session artifact.
|
|
19
|
+
* @param {string} text - the artifact's full text (header line first).
|
|
20
|
+
* @returns {{ header: object, events: object[] }} header plus event records in log order.
|
|
21
|
+
*/
|
|
22
|
+
export function parseSessionLog(text) {
|
|
23
|
+
const lines = text.split('\n').filter(line => line.trim() !== '')
|
|
24
|
+
if (lines.length === 0) throw new Error('empty session log')
|
|
25
|
+
const header = JSON.parse(lines[0])
|
|
26
|
+
if (header.type !== 'session') throw new Error('first line is not a session header')
|
|
27
|
+
const events = []
|
|
28
|
+
for (const line of lines.slice(1)) {
|
|
29
|
+
let record
|
|
30
|
+
try {
|
|
31
|
+
record = JSON.parse(line)
|
|
32
|
+
} catch {
|
|
33
|
+
continue // torn or partial tail line: keep the decodable prefix
|
|
34
|
+
}
|
|
35
|
+
if (record === null || typeof record !== 'object') continue
|
|
36
|
+
if (CHUNK_ROW_TYPES.has(record.type)) continue
|
|
37
|
+
events.push(record)
|
|
38
|
+
}
|
|
39
|
+
return { header, events }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Concatenate the text blocks of one assembled assistant message. */
|
|
43
|
+
function messageText(message) {
|
|
44
|
+
const content = message?.content
|
|
45
|
+
if (!Array.isArray(content)) return ''
|
|
46
|
+
return content
|
|
47
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
48
|
+
.map(block => block.text)
|
|
49
|
+
.join('')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract the visible text of one tool-result message. Real messages are
|
|
54
|
+
* user-role with a single wrapping `tool-result` block whose `content` holds
|
|
55
|
+
* the actual blocks (see `createToolResultMessage` in
|
|
56
|
+
* `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
|
|
57
|
+
* tolerated for hand-built fixtures.
|
|
58
|
+
*/
|
|
59
|
+
function toolResultText(message) {
|
|
60
|
+
const content = message?.content
|
|
61
|
+
if (!Array.isArray(content)) return ''
|
|
62
|
+
const wrapper = content.find(block => block?.type === 'tool-result')
|
|
63
|
+
const inner = wrapper !== undefined ? wrapper.content : content
|
|
64
|
+
if (!Array.isArray(inner)) return ''
|
|
65
|
+
return inner
|
|
66
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
67
|
+
.map(block => block.text)
|
|
68
|
+
.join('')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the `isError` flag from a tool-result message's wrapper block.
|
|
73
|
+
* Returns `undefined` when the flag is absent (treated as success by
|
|
74
|
+
* matchers — see `toolResultSucceeded`).
|
|
75
|
+
*/
|
|
76
|
+
function toolResultIsError(message) {
|
|
77
|
+
const content = message?.content
|
|
78
|
+
if (!Array.isArray(content)) return undefined
|
|
79
|
+
const wrapper = content.find(block => block?.type === 'tool-result')
|
|
80
|
+
return wrapper?.isError
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Best-effort parse of a tool call's raw JSON arguments string. */
|
|
84
|
+
function parseArguments(raw) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(raw)
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build one assertable trace from parsed session logs. Child sessions surface
|
|
94
|
+
* only through the parent's tool events, so the MAIN log (no `origin:
|
|
95
|
+
* 'subagent'` header) owns the tool/final-text projections; all logs stay
|
|
96
|
+
* available under `sessions`.
|
|
97
|
+
* @param {{ header: object, events: object[] }[]} logs - parsed session logs.
|
|
98
|
+
* @returns {EvalTrace}
|
|
99
|
+
*/
|
|
100
|
+
export function buildTrace(logs) {
|
|
101
|
+
const mains = logs.filter(log => log.header.origin !== 'subagent')
|
|
102
|
+
const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
|
|
103
|
+
const events = main?.events ?? []
|
|
104
|
+
|
|
105
|
+
const toolCalls = []
|
|
106
|
+
const toolResults = []
|
|
107
|
+
const assistantTexts = []
|
|
108
|
+
const userMessages = []
|
|
109
|
+
const requestHeaders = []
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
if (event.type === 'request/header') {
|
|
112
|
+
// The assembled model request header: system prompt + mounted tool
|
|
113
|
+
// schemas. What the model is told it can do and how — the "did my
|
|
114
|
+
// plugin's section inject?" projection.
|
|
115
|
+
requestHeaders.push({
|
|
116
|
+
seq: event.seq,
|
|
117
|
+
reason: event.data?.reason,
|
|
118
|
+
system: event.data?.header?.system ?? '',
|
|
119
|
+
toolNames: Array.isArray(event.data?.header?.tools)
|
|
120
|
+
? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
|
|
121
|
+
: [],
|
|
122
|
+
})
|
|
123
|
+
} else if (event.type === 'tool/call') {
|
|
124
|
+
toolCalls.push({
|
|
125
|
+
seq: event.seq,
|
|
126
|
+
turn: event.data.turn,
|
|
127
|
+
step: event.data.step,
|
|
128
|
+
callId: event.data.callId,
|
|
129
|
+
name: event.data.name,
|
|
130
|
+
arguments: event.data.arguments,
|
|
131
|
+
parsedArguments: parseArguments(event.data.arguments),
|
|
132
|
+
})
|
|
133
|
+
} else if (event.type === 'tool/result') {
|
|
134
|
+
toolResults.push({
|
|
135
|
+
seq: event.seq,
|
|
136
|
+
turn: event.data.turn,
|
|
137
|
+
step: event.data.step,
|
|
138
|
+
callId: event.data.message?.source?.callId,
|
|
139
|
+
text: toolResultText(event.data.message),
|
|
140
|
+
error: event.data.error,
|
|
141
|
+
isError: toolResultIsError(event.data.message),
|
|
142
|
+
})
|
|
143
|
+
} else if (event.type === 'assistant/message') {
|
|
144
|
+
const text = messageText(event.data.message)
|
|
145
|
+
if (text !== '') assistantTexts.push(text)
|
|
146
|
+
} else if (event.type === 'user/message') {
|
|
147
|
+
// The user-role model-visible surface: the task prompt (kind 'user'),
|
|
148
|
+
// plugin steering, or injected context. `source` tells them apart —
|
|
149
|
+
// steer has no dedicated event type (the legacy `steering/message` was
|
|
150
|
+
// migrated to `user/message`), so the matcher side filters by `source`.
|
|
151
|
+
const text = messageText(event.data)
|
|
152
|
+
if (text !== '') {
|
|
153
|
+
userMessages.push({
|
|
154
|
+
seq: event.seq,
|
|
155
|
+
source: event.data?.source,
|
|
156
|
+
text,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
sessions: logs,
|
|
164
|
+
sessionId: main?.header.id,
|
|
165
|
+
toolCalls,
|
|
166
|
+
toolResults,
|
|
167
|
+
assistantTexts,
|
|
168
|
+
userMessages,
|
|
169
|
+
requestHeaders,
|
|
170
|
+
finalText: assistantTexts.at(-1) ?? '',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Recursively collect files named `name` under `dir`. */
|
|
175
|
+
function collectFiles(dir, name, out = []) {
|
|
176
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
177
|
+
const path = join(dir, entry.name)
|
|
178
|
+
if (entry.isDirectory()) collectFiles(path, name, out)
|
|
179
|
+
else if (entry.name === name) out.push(path)
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load every session log under a persistence root and build one trace.
|
|
186
|
+
* @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
|
|
187
|
+
* @returns {EvalTrace | undefined} the trace, or `undefined` when no log materialized.
|
|
188
|
+
*/
|
|
189
|
+
export function loadTraceDir(sessionsRoot) {
|
|
190
|
+
let files
|
|
191
|
+
try {
|
|
192
|
+
files = collectFiles(sessionsRoot, 'session.jsonl')
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
if (files.length === 0) return undefined
|
|
197
|
+
const logs = files
|
|
198
|
+
.map(file => readFileSync(file, 'utf8'))
|
|
199
|
+
.map(parseSessionLog)
|
|
200
|
+
return buildTrace(logs)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @typedef {object} EvalTrace
|
|
205
|
+
* @property {{ header: object, events: object[] }[]} sessions - every parsed log.
|
|
206
|
+
* @property {string | undefined} sessionId - the main session's id.
|
|
207
|
+
* @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
|
|
208
|
+
* @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
|
|
209
|
+
* @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
|
|
210
|
+
* @property {{ seq: number, source: object, text: string }[]} userMessages
|
|
211
|
+
* - non-empty `user/message` events (task prompt, plugin steer, injected
|
|
212
|
+
* context) with their verbatim `source` (`kind` + plugin-specific fields),
|
|
213
|
+
* in log order. Steer has no dedicated event type; matchers filter by
|
|
214
|
+
* `source`.
|
|
215
|
+
* @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
|
|
216
|
+
* - projected `request/header` events (assembled system prompt + mounted tools).
|
|
217
|
+
* @property {string} finalText - the last assembled assistant text ('' when none).
|
|
218
|
+
*/
|