@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/cli.mjs
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host CLI resolution chain
|
|
2
|
+
* Host CLI resolution chain.
|
|
3
3
|
*
|
|
4
4
|
* Locating the compiled dsh CLI follows the same two-layer model as package
|
|
5
5
|
* imports: committed files carry no real host-checkout path — the machine's
|
|
6
|
-
* resolution layer (node_modules
|
|
7
|
-
* absorbs it. Precedence, first hit wins:
|
|
6
|
+
* resolution layer (node_modules) absorbs it. Precedence, first hit wins:
|
|
8
7
|
*
|
|
9
8
|
* 1. explicit `--repo <dir>` flag — the documented escape hatch;
|
|
10
9
|
* 2. resolution layer — `node_modules/@deepseek-ai/dsh/lib/bin.js`
|
|
11
10
|
* (the CLI package's own bin target) reachable upward from startDir;
|
|
12
11
|
* 3. config `repo` key (legacy) — kept working for existing checked-in
|
|
13
|
-
* `dsh-eval.config.mjs` files
|
|
12
|
+
* `dsh-eval.config.mjs` files.
|
|
14
13
|
*
|
|
15
14
|
* Every miss fails loud with a fingerprint and placeholder-only guidance —
|
|
16
15
|
* no machine-specific example paths, no silent fallback to guessing.
|
|
@@ -29,10 +28,11 @@ const PACKAGED_CLI_PATH = join('node_modules', '@deepseek-ai', 'dsh', 'lib', 'bi
|
|
|
29
28
|
export const NO_CLI_GUIDANCE = [
|
|
30
29
|
'no dsh CLI found. In order:',
|
|
31
30
|
" 1) pass --repo <host-checkout> explicitly;",
|
|
32
|
-
' 2) or
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
31
|
+
' 2) or provide the CLI through the resolution layer:',
|
|
32
|
+
' node_modules/@deepseek-ai/dsh/lib/bin.js — install the @deepseek-ai/dsh',
|
|
33
|
+
' package (or link a built deepseek-harness checkout into node_modules',
|
|
34
|
+
' and build it so lib/bin.js exists);',
|
|
35
|
+
' 3) or set repo in dsh-eval.config.mjs (legacy).',
|
|
36
36
|
].join('\n')
|
|
37
37
|
|
|
38
38
|
/** Validate a repo-style candidate: return the CLI path or undefined. */
|
package/src/config.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared `dsh-eval.config.mjs` discovery and loading
|
|
2
|
+
* Shared `dsh-eval.config.mjs` discovery and loading.
|
|
3
3
|
*
|
|
4
4
|
* Both CLIs (`dsh-eval`, `dsh-review`) repeat `--profile/--repo` wiring in
|
|
5
5
|
* every consumer's package scripts. A per-package config file removes that
|
package/src/discovery.mjs
CHANGED
|
@@ -1,115 +1,190 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared case / experiment discovery and validation for the eval CLIs.
|
|
3
|
-
* Both `dsh-eval` and `dsh-review` scan directories recursively; this
|
|
4
|
-
* module ensures they share identical skip rules and validation.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readdirSync, statSync } from 'node:fs'
|
|
8
|
-
import { join, resolve } from 'node:path'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Directories never to descend into during discovery. `.runs` holds
|
|
12
|
-
* prior-run artifacts that would be re-discovered as cases; `node_modules`
|
|
13
|
-
* would pull in dependencies that happen to match the suffix.
|
|
14
|
-
*/
|
|
15
|
-
const SKIP_DIRS = new Set(['.runs', 'node_modules'])
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Recursively collect files matching `suffix` under `path`.
|
|
19
|
-
* Always skips `.runs` and `node_modules`.
|
|
20
|
-
* @param {string} path - a file or directory path.
|
|
21
|
-
* @param {string} suffix - the filename suffix to match (e.g. '.eval.mjs').
|
|
22
|
-
* @param {string[]} [out] - accumulator (internal).
|
|
23
|
-
* @returns {string[]} list of discovered files (unsorted; callers sort if needed).
|
|
24
|
-
*/
|
|
25
|
-
export function discoverFiles(path, suffix, out = []) {
|
|
26
|
-
const absolute = resolve(path)
|
|
27
|
-
if (statSync(absolute).isFile()) {
|
|
28
|
-
if (absolute.endsWith(suffix)) out.push(absolute)
|
|
29
|
-
return out
|
|
30
|
-
}
|
|
31
|
-
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
|
32
|
-
const full = join(absolute, entry.name)
|
|
33
|
-
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
34
|
-
discoverFiles(full, suffix, out)
|
|
35
|
-
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
36
|
-
out.push(full)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Validate
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared case / experiment discovery and validation for the eval CLIs.
|
|
3
|
+
* Both `dsh-eval` and `dsh-review` scan directories recursively; this
|
|
4
|
+
* module ensures they share identical skip rules and validation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
8
|
+
import { join, resolve } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Directories never to descend into during discovery. `.runs` holds
|
|
12
|
+
* prior-run artifacts that would be re-discovered as cases; `node_modules`
|
|
13
|
+
* would pull in dependencies that happen to match the suffix.
|
|
14
|
+
*/
|
|
15
|
+
const SKIP_DIRS = new Set(['.runs', 'node_modules'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Recursively collect files matching `suffix` under `path`.
|
|
19
|
+
* Always skips `.runs` and `node_modules`.
|
|
20
|
+
* @param {string} path - a file or directory path.
|
|
21
|
+
* @param {string} suffix - the filename suffix to match (e.g. '.eval.mjs').
|
|
22
|
+
* @param {string[]} [out] - accumulator (internal).
|
|
23
|
+
* @returns {string[]} list of discovered files (unsorted; callers sort if needed).
|
|
24
|
+
*/
|
|
25
|
+
export function discoverFiles(path, suffix, out = []) {
|
|
26
|
+
const absolute = resolve(path)
|
|
27
|
+
if (statSync(absolute).isFile()) {
|
|
28
|
+
if (absolute.endsWith(suffix)) out.push(absolute)
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
|
32
|
+
const full = join(absolute, entry.name)
|
|
33
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
34
|
+
discoverFiles(full, suffix, out)
|
|
35
|
+
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
36
|
+
out.push(full)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate a `disableRows` declaration (case-level or ad-hoc): an array of
|
|
44
|
+
* non-empty loader row id strings. An EMPTY array is legal and means
|
|
45
|
+
* "disable nothing, explicitly" — it overrides a `disableRows` default
|
|
46
|
+
* from `dsh-eval.config.mjs`. Throws with `label` context. Shared by
|
|
47
|
+
* `validateEvalCase` (load time) and `runEvalCase` (execution time) so both
|
|
48
|
+
* report the identical message.
|
|
49
|
+
* @param {unknown} disableRows - the value to validate.
|
|
50
|
+
* @param {string} label - error-message context (e.g. `case '<id>'`).
|
|
51
|
+
*/
|
|
52
|
+
export function validateDisableRows(disableRows, label) {
|
|
53
|
+
if (!Array.isArray(disableRows)
|
|
54
|
+
|| disableRows.some(row => typeof row !== 'string' || row === '')) {
|
|
55
|
+
throw new Error(`${label}: disableRows must be a string[] of loader row ids (empty = explicit none, overriding config; got '${JSON.stringify(disableRows)}')`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate a `followups` declaration (case-level): an array of non-empty
|
|
61
|
+
* strings, one per additional driven turn. Throws with `label` context.
|
|
62
|
+
* @param {unknown} followups - the value to validate.
|
|
63
|
+
* @param {string} label - error-message context (e.g. `case '<id>'`).
|
|
64
|
+
*/
|
|
65
|
+
export function validateFollowups(followups, label) {
|
|
66
|
+
if (!Array.isArray(followups)
|
|
67
|
+
|| followups.length === 0
|
|
68
|
+
|| followups.some(text => typeof text !== 'string' || text === '')) {
|
|
69
|
+
throw new Error(`${label}: followups must be a non-empty string[] of followup turn texts (got '${JSON.stringify(followups)}')`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate one eval case's shape. Throws with a descriptive message on
|
|
75
|
+
* the first violation found. Checks:
|
|
76
|
+
*
|
|
77
|
+
* - `id` is a non-empty string.
|
|
78
|
+
* - `task` is a string.
|
|
79
|
+
* - `mode` (if present) is `'real'` or `'mock'`.
|
|
80
|
+
* - `disableRows` (if present) is an array of strings (loader row ids to
|
|
81
|
+
* disable in this run's overlay). An EMPTY array is legal and means
|
|
82
|
+
* "disable nothing, explicitly" — it overrides a `disableRows` default
|
|
83
|
+
* from `dsh-eval.config.mjs`, which is how gate-interaction cases opt
|
|
84
|
+
* back in inside a package that disables the gate row by default.
|
|
85
|
+
* - `rowConfig` (if present) maps loader row ids to config objects whose
|
|
86
|
+
* leaf values are scalars or arrays of scalars (see `validateRowConfig`).
|
|
87
|
+
* The overlay REPLACES the row's whole config — restate any keys the row
|
|
88
|
+
* needs, not just the ones being changed.
|
|
89
|
+
* - `followups` (if present) is a non-empty array of followup turn texts
|
|
90
|
+
* (cross-turn driving; see `validateFollowups`), with optional positive
|
|
91
|
+
* finite `settleTimeoutMs`.
|
|
92
|
+
* - `expect` is an array; every element has `describe` (string) and `check` (function).
|
|
93
|
+
* - mock mode requires a `script` with `steps` array.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} evalCase - the case to validate.
|
|
96
|
+
* @param {string} file - the source file path (for error messages).
|
|
97
|
+
*/
|
|
98
|
+
export function validateEvalCase(evalCase, file) {
|
|
99
|
+
if (evalCase === null || typeof evalCase !== 'object') {
|
|
100
|
+
throw new Error(`${file}: case must be an object`)
|
|
101
|
+
}
|
|
102
|
+
if (typeof evalCase.id !== 'string' || evalCase.id === '') {
|
|
103
|
+
throw new Error(`${file}: case.id must be a non-empty string`)
|
|
104
|
+
}
|
|
105
|
+
if (typeof evalCase.task !== 'string') {
|
|
106
|
+
throw new Error(`${file}: case '${evalCase.id}': task must be a string`)
|
|
107
|
+
}
|
|
108
|
+
if (evalCase.mode !== undefined && evalCase.mode !== 'real' && evalCase.mode !== 'mock') {
|
|
109
|
+
throw new Error(`${file}: case '${evalCase.id}': mode must be 'real' or 'mock' (got '${evalCase.mode}')`)
|
|
110
|
+
}
|
|
111
|
+
if (evalCase.gates !== undefined) {
|
|
112
|
+
throw new Error(`${file}: case '${evalCase.id}': the 'gates' field was removed — declare disableRows: ['gates'] instead`)
|
|
113
|
+
}
|
|
114
|
+
if (evalCase.disableRows !== undefined) {
|
|
115
|
+
validateDisableRows(evalCase.disableRows, `${file}: case '${evalCase.id}'`)
|
|
116
|
+
}
|
|
117
|
+
if (evalCase.rowConfig !== undefined) {
|
|
118
|
+
validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
|
|
119
|
+
}
|
|
120
|
+
if (evalCase.followups !== undefined) {
|
|
121
|
+
validateFollowups(evalCase.followups, `${file}: case '${evalCase.id}'`)
|
|
122
|
+
if (evalCase.settleTimeoutMs !== undefined
|
|
123
|
+
&& (typeof evalCase.settleTimeoutMs !== 'number' || !Number.isFinite(evalCase.settleTimeoutMs) || evalCase.settleTimeoutMs <= 0)) {
|
|
124
|
+
throw new Error(`${file}: case '${evalCase.id}': settleTimeoutMs must be a positive finite number (got '${JSON.stringify(evalCase.settleTimeoutMs)}')`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(evalCase.expect)) {
|
|
128
|
+
throw new Error(`${file}: case '${evalCase.id}': expect must be a Matcher[]`)
|
|
129
|
+
}
|
|
130
|
+
for (let i = 0; i < evalCase.expect.length; i += 1) {
|
|
131
|
+
const matcher = evalCase.expect[i]
|
|
132
|
+
if (typeof matcher?.describe !== 'string' || typeof matcher?.check !== 'function') {
|
|
133
|
+
throw new Error(`${file}: case '${evalCase.id}': expect[${i}] must have { describe: string, check: function }`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (evalCase.mode === 'mock') {
|
|
137
|
+
if (evalCase.script === undefined || !Array.isArray(evalCase.script?.steps)) {
|
|
138
|
+
throw new Error(`${file}: case '${evalCase.id}': mock mode requires script.steps`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate a `rowConfig` mapping (case-level or ad-hoc): keys are loader row
|
|
145
|
+
* ids, values are config objects whose leaf values must be scalars (string /
|
|
146
|
+
* number / boolean) or arrays of scalars. Nested objects are rejected — the
|
|
147
|
+
* overlay emitter only handles flat config keys. Throws with `label` context.
|
|
148
|
+
* @param {unknown} rowConfig - the value to validate.
|
|
149
|
+
* @param {string} label - error-message context (e.g. `case '<id>'`).
|
|
150
|
+
*/
|
|
151
|
+
export function validateRowConfig(rowConfig, label) {
|
|
152
|
+
if (rowConfig === null || typeof rowConfig !== 'object' || Array.isArray(rowConfig)) {
|
|
153
|
+
throw new Error(`${label}: rowConfig must be an object mapping row ids to config objects (got '${JSON.stringify(rowConfig)}')`)
|
|
154
|
+
}
|
|
155
|
+
for (const [rowId, config] of Object.entries(rowConfig)) {
|
|
156
|
+
if (rowId === '') throw new Error(`${label}: rowConfig row id must be a non-empty string`)
|
|
157
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
158
|
+
throw new Error(`${label}: rowConfig['${rowId}'] must be a config object (got '${JSON.stringify(config)}')`)
|
|
159
|
+
}
|
|
160
|
+
for (const [key, value] of Object.entries(config)) {
|
|
161
|
+
if (key === '') throw new Error(`${label}: rowConfig['${rowId}'] has an empty config key`)
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
if (value.some(item => item === null || typeof item === 'object')) {
|
|
164
|
+
throw new Error(`${label}: rowConfig['${rowId}']['${key}'] must be an array of scalars`)
|
|
165
|
+
}
|
|
166
|
+
} else if (value === null || typeof value === 'object') {
|
|
167
|
+
throw new Error(`${label}: rowConfig['${rowId}']['${key}'] must be a scalar or scalar array (nested objects are not supported)`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Detect duplicate case ids across a flat case list. Throws on the first
|
|
175
|
+
* duplicate found, naming both source files.
|
|
176
|
+
* @param {{ id: string, __file: string }[]} cases - cases with `__file` attached.
|
|
177
|
+
*/
|
|
178
|
+
export function detectDuplicateIds(cases) {
|
|
179
|
+
const seen = new Map()
|
|
180
|
+
for (const evalCase of cases) {
|
|
181
|
+
const existing = seen.get(evalCase.id)
|
|
182
|
+
if (existing !== undefined) {
|
|
183
|
+
const locations = existing === evalCase.__file
|
|
184
|
+
? existing
|
|
185
|
+
: `${existing} and ${evalCase.__file}`
|
|
186
|
+
throw new Error(`duplicate case id '${evalCase.id}' in ${locations}`)
|
|
187
|
+
}
|
|
188
|
+
seen.set(evalCase.id, evalCase.__file)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eval multi-turn driver, loaded as a Cordis plugin through the eval
|
|
3
|
+
* overlay when a case declares `followups`. It REPLACES the one-shot
|
|
4
|
+
* `headless-runner` row (the overlay disables that row and inserts this one):
|
|
5
|
+
* the headless runner exits at the main agent's first idle, which aborts every
|
|
6
|
+
* in-process background subagent at process teardown (measured: a turn-close
|
|
7
|
+
* fixer child dies between publication and its first model call). Keeping the
|
|
8
|
+
* process alive across turns is what makes fire-and-forget children observable
|
|
9
|
+
* at all — the survival window is the driver's lifetime.
|
|
10
|
+
*
|
|
11
|
+
* Plan (env `DSH_EVAL_DRIVER_PLAN`, a JSON file written by the runner):
|
|
12
|
+
* `{ followups: string[], settleTimeoutMs?: number }`. The FIRST turn's task
|
|
13
|
+
* still arrives as the CLI positional; each followup is one additional user
|
|
14
|
+
* message. Before each followup the driver waits for background subagent
|
|
15
|
+
* children to settle (tracked via the global `session/event` feed: a session
|
|
16
|
+
* whose header marks it a subagent is pending from its `turn/start` until its
|
|
17
|
+
* `turn/end`), so a case can assert "the fixer child finished, THEN the next
|
|
18
|
+
* turn re-scanned".
|
|
19
|
+
*
|
|
20
|
+
* Output contract mirrors the headless runner's stdout/exit semantics: last
|
|
21
|
+
* non-empty assistant text of the main session on stdout, exit 0 iff the LAST
|
|
22
|
+
* turn ended `completed`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from 'node:fs'
|
|
26
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
27
|
+
|
|
28
|
+
/** Environment variable pointing at this run's driver plan JSON. */
|
|
29
|
+
const PLAN_ENV = 'DSH_EVAL_DRIVER_PLAN'
|
|
30
|
+
|
|
31
|
+
/** Default bound on waiting for background children to settle. */
|
|
32
|
+
const DEFAULT_SETTLE_TIMEOUT_MS = 60_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Quiescence grace after the pending set drains: a turn-close dispatch races
|
|
36
|
+
* the driver's poll loop, so an empty set is only trusted after this many ms
|
|
37
|
+
* pass without a new subagent `turn/start`.
|
|
38
|
+
*/
|
|
39
|
+
const SETTLE_GRACE_MS = 250
|
|
40
|
+
|
|
41
|
+
/** Poll interval while waiting on pending children. */
|
|
42
|
+
const SETTLE_POLL_MS = 25
|
|
43
|
+
|
|
44
|
+
/** One user-role followup message. */
|
|
45
|
+
function followupMessage(text) {
|
|
46
|
+
return createUserMessage({
|
|
47
|
+
content: [{ type: 'text', text }],
|
|
48
|
+
source: { kind: 'user' },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a session (from the event feed) is a subagent child log. */
|
|
53
|
+
function isSubagentSession(session) {
|
|
54
|
+
const header = session?.header
|
|
55
|
+
if (header === null || typeof header !== 'object') return false
|
|
56
|
+
return header.origin === 'subagent' || header.parentSession !== undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Aggregate the last assistant text and turn outcome over the whole log. */
|
|
60
|
+
function summarize(agent) {
|
|
61
|
+
let text = ''
|
|
62
|
+
let reason
|
|
63
|
+
for (const event of agent.session.events) {
|
|
64
|
+
if (event.type === 'assistant/message') {
|
|
65
|
+
const joined = event.data.message.content
|
|
66
|
+
.filter(block => block.type === 'text')
|
|
67
|
+
.map(block => block.text)
|
|
68
|
+
.join('')
|
|
69
|
+
if (joined !== '') text = joined
|
|
70
|
+
}
|
|
71
|
+
if (event.type === 'turn/end') reason = event.data.reason
|
|
72
|
+
}
|
|
73
|
+
return { text, reason }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run the plan: task turn, then per followup — wait for background subagent
|
|
78
|
+
* children to settle, submit the followup, wait for idle.
|
|
79
|
+
*/
|
|
80
|
+
async function run(ctx, plan, io) {
|
|
81
|
+
// Pending background children: subagent sessions between their turn/start
|
|
82
|
+
// and turn/end. Registered BEFORE awaiting the loader so a plugin that
|
|
83
|
+
// dispatches during mount cannot slip a turn/start past the counter; the
|
|
84
|
+
// listener is global — child sessions publish their own events on the same
|
|
85
|
+
// feed the persistence layer listens on.
|
|
86
|
+
const pending = new Set()
|
|
87
|
+
ctx.on('session/event', (session, event) => {
|
|
88
|
+
if (!isSubagentSession(session)) return
|
|
89
|
+
if (event.type === 'turn/start') pending.add(session.header.id)
|
|
90
|
+
if (event.type === 'turn/end') pending.delete(session.header.id)
|
|
91
|
+
})
|
|
92
|
+
await ctx.get('loader')?.await()
|
|
93
|
+
const agents = ctx.get('agents')
|
|
94
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
95
|
+
const sessions = ctx.get('sessions')
|
|
96
|
+
if (agents === undefined || defaultModel === undefined || sessions === undefined) {
|
|
97
|
+
throw new Error('eval-multi-turn-driver: agents/sessions services are unavailable')
|
|
98
|
+
}
|
|
99
|
+
const settleTimeoutMs = plan.settleTimeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS
|
|
100
|
+
const waitForSettle = async () => {
|
|
101
|
+
const deadline = Date.now() + settleTimeoutMs
|
|
102
|
+
for (;;) {
|
|
103
|
+
if (pending.size === 0) {
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, SETTLE_GRACE_MS))
|
|
105
|
+
if (pending.size === 0) return
|
|
106
|
+
}
|
|
107
|
+
if (Date.now() > deadline) {
|
|
108
|
+
throw new Error(`eval-multi-turn-driver: background subagents did not settle within ${settleTimeoutMs}ms (${[...pending].join(', ')})`)
|
|
109
|
+
}
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, SETTLE_POLL_MS))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selection = defaultModel.currentSelection()
|
|
115
|
+
const { agent } = await agents.create({
|
|
116
|
+
sessionId: `session-${crypto.randomUUID()}`,
|
|
117
|
+
meta: { cwd: process.cwd() },
|
|
118
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
119
|
+
})
|
|
120
|
+
await agent.whenIdle()
|
|
121
|
+
const stop = plan.task ?? ''
|
|
122
|
+
if (stop === '') throw new Error('eval-multi-turn-driver: plan.task must be the case task text')
|
|
123
|
+
agent.followup(followupMessage(stop))
|
|
124
|
+
await agent.whenIdle()
|
|
125
|
+
for (const followup of plan.followups) {
|
|
126
|
+
await waitForSettle()
|
|
127
|
+
agent.followup(followupMessage(followup))
|
|
128
|
+
await agent.whenIdle()
|
|
129
|
+
}
|
|
130
|
+
// The LAST turn's close also dispatches (a defer fixer fires on every
|
|
131
|
+
// failed stop): wait once more so close-dispatched children are not
|
|
132
|
+
// silently aborted at exit — "dispatched ⇒ observable outcome" holds for
|
|
133
|
+
// every turn, not just the ones a followup follows.
|
|
134
|
+
await waitForSettle()
|
|
135
|
+
await sessions.flush(agent.session)
|
|
136
|
+
const outcome = summarize(agent)
|
|
137
|
+
io.stdout.write(outcome.text + '\n')
|
|
138
|
+
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const name = 'eval-multi-turn-driver'
|
|
142
|
+
|
|
143
|
+
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
144
|
+
|
|
145
|
+
/** Mount the multi-turn driver from the run plan. */
|
|
146
|
+
export function apply(ctx) {
|
|
147
|
+
// Read through the global service store: appExit is an optional launcher
|
|
148
|
+
// host value, never an injected dependency.
|
|
149
|
+
const exit = ctx.get('appExit')
|
|
150
|
+
if (exit === undefined) {
|
|
151
|
+
throw new Error('eval-multi-turn-driver: the launcher must provide ctx.appExit before the tree mounts')
|
|
152
|
+
}
|
|
153
|
+
const planPath = process.env[PLAN_ENV]
|
|
154
|
+
if (planPath === undefined) {
|
|
155
|
+
throw new Error(`eval-multi-turn-driver: ${PLAN_ENV} must point at the run's driver plan JSON`)
|
|
156
|
+
}
|
|
157
|
+
const plan = JSON.parse(readFileSync(planPath, 'utf8'))
|
|
158
|
+
const io = { stdout: process.stdout, stderr: process.stderr, exit }
|
|
159
|
+
void run(ctx, plan, io).catch(error => {
|
|
160
|
+
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
161
|
+
exit(1)
|
|
162
|
+
})
|
|
163
|
+
}
|