@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/src/cli.mjs CHANGED
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Host CLI resolution chain (release-plan C6 / spec host-checkout-resolution).
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, junction-built by the relink anchor tool)
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 until the repo split retires them.
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 make the resolution layer provide it: node_modules/@deepseek-ai/dsh/lib/bin.js',
33
- ' (run the repo relink script to (re)build the junction tree from DSH_REPO,',
34
- ' then build the host checkout if lib/ is missing);',
35
- ' 3) or set repo in dsh-eval.config.mjs (legacy, retired at repo split).',
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 (EVAL-008).
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 one eval case's shape. Throws with a descriptive message on
44
- * the first violation found. Checks:
45
- *
46
- * - `id` is a non-empty string.
47
- * - `task` is a string.
48
- * - `mode` (if present) is `'real'` or `'mock'`.
49
- * - `disableRows` (if present) is an array of strings (loader row ids to
50
- * disable in this run's overlay). An EMPTY array is legal and means
51
- * "disable nothing, explicitly" — it overrides a `disableRows` default
52
- * from `dsh-eval.config.mjs`, which is how gate-interaction cases opt
53
- * back in inside a package that disables the gate row by default.
54
- * - `expect` is an array; every element has `describe` (string) and `check` (function).
55
- * - mock mode requires a `script` with `steps` array.
56
- *
57
- * @param {object} evalCase - the case to validate.
58
- * @param {string} file - the source file path (for error messages).
59
- */
60
- export function validateEvalCase(evalCase, file) {
61
- if (evalCase === null || typeof evalCase !== 'object') {
62
- throw new Error(`${file}: case must be an object`)
63
- }
64
- if (typeof evalCase.id !== 'string' || evalCase.id === '') {
65
- throw new Error(`${file}: case.id must be a non-empty string`)
66
- }
67
- if (typeof evalCase.task !== 'string') {
68
- throw new Error(`${file}: case '${evalCase.id}': task must be a string`)
69
- }
70
- if (evalCase.mode !== undefined && evalCase.mode !== 'real' && evalCase.mode !== 'mock') {
71
- throw new Error(`${file}: case '${evalCase.id}': mode must be 'real' or 'mock' (got '${evalCase.mode}')`)
72
- }
73
- if (evalCase.gates !== undefined) {
74
- throw new Error(`${file}: case '${evalCase.id}': the 'gates' field was removed declare disableRows: ['gates'] instead`)
75
- }
76
- if (evalCase.disableRows !== undefined) {
77
- if (!Array.isArray(evalCase.disableRows)
78
- || evalCase.disableRows.some(row => typeof row !== 'string' || row === '')) {
79
- throw new Error(`${file}: case '${evalCase.id}': disableRows must be a string[] of loader row ids (empty = explicit none, overriding config; got '${JSON.stringify(evalCase.disableRows)}')`)
80
- }
81
- }
82
- if (!Array.isArray(evalCase.expect)) {
83
- throw new Error(`${file}: case '${evalCase.id}': expect must be a Matcher[]`)
84
- }
85
- for (let i = 0; i < evalCase.expect.length; i += 1) {
86
- const matcher = evalCase.expect[i]
87
- if (typeof matcher?.describe !== 'string' || typeof matcher?.check !== 'function') {
88
- throw new Error(`${file}: case '${evalCase.id}': expect[${i}] must have { describe: string, check: function }`)
89
- }
90
- }
91
- if (evalCase.mode === 'mock') {
92
- if (evalCase.script === undefined || !Array.isArray(evalCase.script?.steps)) {
93
- throw new Error(`${file}: case '${evalCase.id}': mock mode requires script.steps`)
94
- }
95
- }
96
- }
97
-
98
- /**
99
- * Detect duplicate case ids across a flat case list. Throws on the first
100
- * duplicate found, naming both source files.
101
- * @param {{ id: string, __file: string }[]} cases - cases with `__file` attached.
102
- */
103
- export function detectDuplicateIds(cases) {
104
- const seen = new Map()
105
- for (const evalCase of cases) {
106
- const existing = seen.get(evalCase.id)
107
- if (existing !== undefined) {
108
- const locations = existing === evalCase.__file
109
- ? existing
110
- : `${existing} and ${evalCase.__file}`
111
- throw new Error(`duplicate case id '${evalCase.id}' in ${locations}`)
112
- }
113
- seen.set(evalCase.id, evalCase.__file)
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
+ }