@catheadowl/dsh-eval 0.2.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 +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/host-wiring.md +1 -1
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +13 -1
- package/docs/review.md +12 -9
- package/package.json +7 -1
- package/src/adapters/dsh/review.mjs +70 -17
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +21 -1
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model-independent review experiment primitives.
|
|
3
|
-
*
|
|
4
|
-
* A review experiment separates four concerns:
|
|
5
|
-
* - frozen inputs owned by the plugin;
|
|
6
|
-
* - live observation/projection of those inputs;
|
|
7
|
-
* - the blind prompt shown to an independent reviewer;
|
|
8
|
-
* - the hidden human rubric used after the run.
|
|
9
|
-
*
|
|
10
|
-
* Nothing in this module knows how a model is invoked. Callers provide an
|
|
11
|
-
* executor (dsh headless is one adapter) when they want to run the assembled
|
|
12
|
-
* task.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const OBSERVATIONS_PLACEHOLDER = '{{EVAL_OBSERVATIONS}}'
|
|
16
|
-
|
|
17
|
-
/** Define and validate a human-graded review experiment. */
|
|
18
|
-
export function defineReviewExperiment(definition) {
|
|
19
|
-
if (definition === null || typeof definition !== 'object') {
|
|
20
|
-
throw new TypeError('review experiment must be an object')
|
|
21
|
-
}
|
|
22
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(definition.id ?? '')) {
|
|
23
|
-
throw new TypeError('review experiment id must be a non-empty path-safe string')
|
|
24
|
-
}
|
|
25
|
-
if (typeof definition.prompt !== 'string' || definition.prompt.split(OBSERVATIONS_PLACEHOLDER).length !== 2) {
|
|
26
|
-
throw new TypeError(`review experiment '${definition.id}': prompt must contain exactly one ${OBSERVATIONS_PLACEHOLDER}`)
|
|
27
|
-
}
|
|
28
|
-
if (typeof definition.observe !== 'function') {
|
|
29
|
-
throw new TypeError(`review experiment '${definition.id}': observe must be a function`)
|
|
30
|
-
}
|
|
31
|
-
if (!(typeof definition.rubric === 'string' || definition.rubric instanceof URL)) {
|
|
32
|
-
throw new TypeError(`review experiment '${definition.id}': rubric must identify the hidden grading standard`)
|
|
33
|
-
}
|
|
34
|
-
const defaultRuns = definition.defaultRuns ?? 3
|
|
35
|
-
if (!Number.isInteger(defaultRuns) || defaultRuns < 1) {
|
|
36
|
-
throw new TypeError(`review experiment '${definition.id}': defaultRuns must be a positive integer`)
|
|
37
|
-
}
|
|
38
|
-
return Object.freeze({
|
|
39
|
-
...definition,
|
|
40
|
-
kind: 'review',
|
|
41
|
-
defaultRuns,
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Render the standard observation document consumed by a blind reviewer. */
|
|
46
|
-
export function renderObservationSections(sections) {
|
|
47
|
-
if (!Array.isArray(sections) || sections.length === 0) {
|
|
48
|
-
throw new TypeError('review observations must contain at least one section')
|
|
49
|
-
}
|
|
50
|
-
const lines = []
|
|
51
|
-
for (const section of sections) {
|
|
52
|
-
if (typeof section?.heading !== 'string' || section.heading.length === 0) {
|
|
53
|
-
throw new TypeError('every observation section needs a heading')
|
|
54
|
-
}
|
|
55
|
-
lines.push(`## ${section.heading}`)
|
|
56
|
-
if (section.introduction) lines.push(String(section.introduction), '')
|
|
57
|
-
for (const entry of section.entries ?? []) {
|
|
58
|
-
if (typeof entry?.heading !== 'string' || entry.heading.length === 0) {
|
|
59
|
-
throw new TypeError(`section '${section.heading}' contains an entry without a heading`)
|
|
60
|
-
}
|
|
61
|
-
lines.push(`### ${entry.heading}`)
|
|
62
|
-
for (const paragraph of entry.paragraphs ?? []) lines.push(String(paragraph))
|
|
63
|
-
if (entry.call !== undefined) lines.push(`Call: ${JSON.stringify(entry.call)}`)
|
|
64
|
-
if (entry.json !== undefined) {
|
|
65
|
-
lines.push('```json', JSON.stringify(entry.json, null, 2), '```')
|
|
66
|
-
}
|
|
67
|
-
lines.push('')
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return lines.join('\n').trimEnd()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Materialize live observations and assemble the blind reviewer task. */
|
|
74
|
-
export async function materializeReviewExperiment(experiment) {
|
|
75
|
-
const sections = await experiment.observe()
|
|
76
|
-
const observations = renderObservationSections(sections)
|
|
77
|
-
return {
|
|
78
|
-
experimentId: experiment.id,
|
|
79
|
-
observations,
|
|
80
|
-
task: experiment.prompt.replace(OBSERVATIONS_PLACEHOLDER, observations),
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Execute the same materialized task through N fresh executor calls.
|
|
86
|
-
* The executor is intentionally generic: `(task, context) => result`.
|
|
87
|
-
*/
|
|
88
|
-
export async function executeReviewExperiment(experiment, executor, options = {}) {
|
|
89
|
-
if (typeof executor !== 'function') throw new TypeError('review executor must be a function')
|
|
90
|
-
const runs = options.runs ?? experiment.defaultRuns
|
|
91
|
-
if (!Number.isInteger(runs) || runs < 1) throw new TypeError('runs must be a positive integer')
|
|
92
|
-
|
|
93
|
-
// Observe once. Every reviewer sees byte-identical evidence, so variance is
|
|
94
|
-
// attributable to interpretation rather than fixture/projection drift.
|
|
95
|
-
const materialized = await materializeReviewExperiment(experiment)
|
|
96
|
-
const attempts = []
|
|
97
|
-
for (let index = 1; index <= runs; index += 1) {
|
|
98
|
-
try {
|
|
99
|
-
const result = await executor(materialized.task, {
|
|
100
|
-
experiment,
|
|
101
|
-
experimentId: experiment.id,
|
|
102
|
-
index,
|
|
103
|
-
runs,
|
|
104
|
-
})
|
|
105
|
-
attempts.push({ index, ok: true, result })
|
|
106
|
-
} catch (error) {
|
|
107
|
-
attempts.push({
|
|
108
|
-
index,
|
|
109
|
-
ok: false,
|
|
110
|
-
error: error instanceof Error ? error.message : String(error),
|
|
111
|
-
result: error?.result,
|
|
112
|
-
})
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return { ...materialized, runs, attempts }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export { OBSERVATIONS_PLACEHOLDER }
|
|
1
|
+
/**
|
|
2
|
+
* Model-independent review experiment primitives.
|
|
3
|
+
*
|
|
4
|
+
* A review experiment separates four concerns:
|
|
5
|
+
* - frozen inputs owned by the plugin;
|
|
6
|
+
* - live observation/projection of those inputs;
|
|
7
|
+
* - the blind prompt shown to an independent reviewer;
|
|
8
|
+
* - the hidden human rubric used after the run.
|
|
9
|
+
*
|
|
10
|
+
* Nothing in this module knows how a model is invoked. Callers provide an
|
|
11
|
+
* executor (dsh headless is one adapter) when they want to run the assembled
|
|
12
|
+
* task.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const OBSERVATIONS_PLACEHOLDER = '{{EVAL_OBSERVATIONS}}'
|
|
16
|
+
|
|
17
|
+
/** Define and validate a human-graded review experiment. */
|
|
18
|
+
export function defineReviewExperiment(definition) {
|
|
19
|
+
if (definition === null || typeof definition !== 'object') {
|
|
20
|
+
throw new TypeError('review experiment must be an object')
|
|
21
|
+
}
|
|
22
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(definition.id ?? '')) {
|
|
23
|
+
throw new TypeError('review experiment id must be a non-empty path-safe string')
|
|
24
|
+
}
|
|
25
|
+
if (typeof definition.prompt !== 'string' || definition.prompt.split(OBSERVATIONS_PLACEHOLDER).length !== 2) {
|
|
26
|
+
throw new TypeError(`review experiment '${definition.id}': prompt must contain exactly one ${OBSERVATIONS_PLACEHOLDER}`)
|
|
27
|
+
}
|
|
28
|
+
if (typeof definition.observe !== 'function') {
|
|
29
|
+
throw new TypeError(`review experiment '${definition.id}': observe must be a function`)
|
|
30
|
+
}
|
|
31
|
+
if (!(typeof definition.rubric === 'string' || definition.rubric instanceof URL)) {
|
|
32
|
+
throw new TypeError(`review experiment '${definition.id}': rubric must identify the hidden grading standard`)
|
|
33
|
+
}
|
|
34
|
+
const defaultRuns = definition.defaultRuns ?? 3
|
|
35
|
+
if (!Number.isInteger(defaultRuns) || defaultRuns < 1) {
|
|
36
|
+
throw new TypeError(`review experiment '${definition.id}': defaultRuns must be a positive integer`)
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
...definition,
|
|
40
|
+
kind: 'review',
|
|
41
|
+
defaultRuns,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Render the standard observation document consumed by a blind reviewer. */
|
|
46
|
+
export function renderObservationSections(sections) {
|
|
47
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
48
|
+
throw new TypeError('review observations must contain at least one section')
|
|
49
|
+
}
|
|
50
|
+
const lines = []
|
|
51
|
+
for (const section of sections) {
|
|
52
|
+
if (typeof section?.heading !== 'string' || section.heading.length === 0) {
|
|
53
|
+
throw new TypeError('every observation section needs a heading')
|
|
54
|
+
}
|
|
55
|
+
lines.push(`## ${section.heading}`)
|
|
56
|
+
if (section.introduction) lines.push(String(section.introduction), '')
|
|
57
|
+
for (const entry of section.entries ?? []) {
|
|
58
|
+
if (typeof entry?.heading !== 'string' || entry.heading.length === 0) {
|
|
59
|
+
throw new TypeError(`section '${section.heading}' contains an entry without a heading`)
|
|
60
|
+
}
|
|
61
|
+
lines.push(`### ${entry.heading}`)
|
|
62
|
+
for (const paragraph of entry.paragraphs ?? []) lines.push(String(paragraph))
|
|
63
|
+
if (entry.call !== undefined) lines.push(`Call: ${JSON.stringify(entry.call)}`)
|
|
64
|
+
if (entry.json !== undefined) {
|
|
65
|
+
lines.push('```json', JSON.stringify(entry.json, null, 2), '```')
|
|
66
|
+
}
|
|
67
|
+
lines.push('')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n').trimEnd()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Materialize live observations and assemble the blind reviewer task. */
|
|
74
|
+
export async function materializeReviewExperiment(experiment) {
|
|
75
|
+
const sections = await experiment.observe()
|
|
76
|
+
const observations = renderObservationSections(sections)
|
|
77
|
+
return {
|
|
78
|
+
experimentId: experiment.id,
|
|
79
|
+
observations,
|
|
80
|
+
task: experiment.prompt.replace(OBSERVATIONS_PLACEHOLDER, observations),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Execute the same materialized task through N fresh executor calls.
|
|
86
|
+
* The executor is intentionally generic: `(task, context) => result`.
|
|
87
|
+
*/
|
|
88
|
+
export async function executeReviewExperiment(experiment, executor, options = {}) {
|
|
89
|
+
if (typeof executor !== 'function') throw new TypeError('review executor must be a function')
|
|
90
|
+
const runs = options.runs ?? experiment.defaultRuns
|
|
91
|
+
if (!Number.isInteger(runs) || runs < 1) throw new TypeError('runs must be a positive integer')
|
|
92
|
+
|
|
93
|
+
// Observe once. Every reviewer sees byte-identical evidence, so variance is
|
|
94
|
+
// attributable to interpretation rather than fixture/projection drift.
|
|
95
|
+
const materialized = await materializeReviewExperiment(experiment)
|
|
96
|
+
const attempts = []
|
|
97
|
+
for (let index = 1; index <= runs; index += 1) {
|
|
98
|
+
try {
|
|
99
|
+
const result = await executor(materialized.task, {
|
|
100
|
+
experiment,
|
|
101
|
+
experimentId: experiment.id,
|
|
102
|
+
index,
|
|
103
|
+
runs,
|
|
104
|
+
})
|
|
105
|
+
attempts.push({ index, ok: true, result })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
attempts.push({
|
|
108
|
+
index,
|
|
109
|
+
ok: false,
|
|
110
|
+
error: error instanceof Error ? error.message : String(error),
|
|
111
|
+
result: error?.result,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ...materialized, runs, attempts }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { OBSERVATIONS_PLACEHOLDER }
|
package/src/index.mjs
CHANGED
|
@@ -25,6 +25,10 @@ export {
|
|
|
25
25
|
toolMounted,
|
|
26
26
|
userMessageTextIncludes,
|
|
27
27
|
userMessageTextExcludes,
|
|
28
|
+
subagentDispatched,
|
|
29
|
+
subagentCompleted,
|
|
30
|
+
subagentDispatchCount,
|
|
31
|
+
subagentCompletedCount,
|
|
28
32
|
} from './assertions.mjs'
|
|
29
33
|
|
|
30
34
|
export { toolCallStep, textStep } from './mock/script.mjs'
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The eval mock-LLM adapter, loaded as a Cordis plugin through the eval
|
|
3
|
-
* overlay (`name: file://...` insert). It registers the keyless `eval-mock`
|
|
4
|
-
* provider and replays a scripted StreamChunk sequence: one script step per
|
|
5
|
-
* model call, so a case controls exactly which tool calls and final text the
|
|
6
|
-
* "model" produces. Pattern precedent:
|
|
7
|
-
* deepseek-harness/examples/headless-agent/tests/fixtures/cli-mock-llm.ts.
|
|
8
|
-
*
|
|
9
|
-
* Deterministic layer purpose: verify the eval runner/trace/assertion
|
|
10
|
-
* pipeline without an API key, and drive the plugin's tool execution path
|
|
11
|
-
* (a scripted tool call runs through the real tool pipeline).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { readFileSync } from 'node:fs'
|
|
15
|
-
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
16
|
-
|
|
17
|
-
/** Environment variable pointing at this run's mock script JSON. */
|
|
18
|
-
const SCRIPT_ENV = 'DSH_EVAL_MOCK_SCRIPT'
|
|
19
|
-
|
|
20
|
-
/** Adapter that yields one scripted chunk list per stream() call. */
|
|
21
|
-
class EvalMockAdapter extends LlmAdapter {
|
|
22
|
-
constructor(script) {
|
|
23
|
-
super()
|
|
24
|
-
this.steps = script.steps ?? []
|
|
25
|
-
this.cursor = 0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async resolveModel(provider, model) {
|
|
29
|
-
return {
|
|
30
|
-
provider,
|
|
31
|
-
id: model,
|
|
32
|
-
name: model,
|
|
33
|
-
reasoning: {
|
|
34
|
-
efforts: [
|
|
35
|
-
{ id: 'off', name: 'Off' },
|
|
36
|
-
{ id: 'high', name: 'High' },
|
|
37
|
-
],
|
|
38
|
-
defaultEffort: 'off',
|
|
39
|
-
},
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async * stream(_options) {
|
|
44
|
-
const step = this.steps[this.cursor]
|
|
45
|
-
this.cursor += 1
|
|
46
|
-
if (step === undefined) {
|
|
47
|
-
// Script exhausted: finish the loop loudly-but-gracefully so the run
|
|
48
|
-
// still produces a trace a case can assert on.
|
|
49
|
-
const text = `eval-mock: script exhausted after ${this.cursor - 1} step(s)`
|
|
50
|
-
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
51
|
-
yield { type: 'text-delta', index: 0, text }
|
|
52
|
-
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
|
53
|
-
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
|
54
|
-
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
for (const chunk of step) yield chunk
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export const name = 'eval-mock-llm'
|
|
62
|
-
|
|
63
|
-
export const inject = ['llm']
|
|
64
|
-
|
|
65
|
-
/** Register the `eval-mock` adapter from the scripted run. */
|
|
66
|
-
export function apply(ctx) {
|
|
67
|
-
const scriptPath = process.env[SCRIPT_ENV]
|
|
68
|
-
if (scriptPath === undefined) {
|
|
69
|
-
throw new Error(`eval-mock-llm: ${SCRIPT_ENV} must point at the run's mock script JSON`)
|
|
70
|
-
}
|
|
71
|
-
const script = JSON.parse(readFileSync(scriptPath, 'utf8'))
|
|
72
|
-
ctx.llm.registerAdapter(['eval-mock'], new EvalMockAdapter(script))
|
|
73
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The eval mock-LLM adapter, loaded as a Cordis plugin through the eval
|
|
3
|
+
* overlay (`name: file://...` insert). It registers the keyless `eval-mock`
|
|
4
|
+
* provider and replays a scripted StreamChunk sequence: one script step per
|
|
5
|
+
* model call, so a case controls exactly which tool calls and final text the
|
|
6
|
+
* "model" produces. Pattern precedent:
|
|
7
|
+
* deepseek-harness/examples/headless-agent/tests/fixtures/cli-mock-llm.ts.
|
|
8
|
+
*
|
|
9
|
+
* Deterministic layer purpose: verify the eval runner/trace/assertion
|
|
10
|
+
* pipeline without an API key, and drive the plugin's tool execution path
|
|
11
|
+
* (a scripted tool call runs through the real tool pipeline).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
16
|
+
|
|
17
|
+
/** Environment variable pointing at this run's mock script JSON. */
|
|
18
|
+
const SCRIPT_ENV = 'DSH_EVAL_MOCK_SCRIPT'
|
|
19
|
+
|
|
20
|
+
/** Adapter that yields one scripted chunk list per stream() call. */
|
|
21
|
+
class EvalMockAdapter extends LlmAdapter {
|
|
22
|
+
constructor(script) {
|
|
23
|
+
super()
|
|
24
|
+
this.steps = script.steps ?? []
|
|
25
|
+
this.cursor = 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async resolveModel(provider, model) {
|
|
29
|
+
return {
|
|
30
|
+
provider,
|
|
31
|
+
id: model,
|
|
32
|
+
name: model,
|
|
33
|
+
reasoning: {
|
|
34
|
+
efforts: [
|
|
35
|
+
{ id: 'off', name: 'Off' },
|
|
36
|
+
{ id: 'high', name: 'High' },
|
|
37
|
+
],
|
|
38
|
+
defaultEffort: 'off',
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async * stream(_options) {
|
|
44
|
+
const step = this.steps[this.cursor]
|
|
45
|
+
this.cursor += 1
|
|
46
|
+
if (step === undefined) {
|
|
47
|
+
// Script exhausted: finish the loop loudly-but-gracefully so the run
|
|
48
|
+
// still produces a trace a case can assert on.
|
|
49
|
+
const text = `eval-mock: script exhausted after ${this.cursor - 1} step(s)`
|
|
50
|
+
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
51
|
+
yield { type: 'text-delta', index: 0, text }
|
|
52
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
|
53
|
+
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
|
54
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const chunk of step) yield chunk
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const name = 'eval-mock-llm'
|
|
62
|
+
|
|
63
|
+
export const inject = ['llm']
|
|
64
|
+
|
|
65
|
+
/** Register the `eval-mock` adapter from the scripted run. */
|
|
66
|
+
export function apply(ctx) {
|
|
67
|
+
const scriptPath = process.env[SCRIPT_ENV]
|
|
68
|
+
if (scriptPath === undefined) {
|
|
69
|
+
throw new Error(`eval-mock-llm: ${SCRIPT_ENV} must point at the run's mock script JSON`)
|
|
70
|
+
}
|
|
71
|
+
const script = JSON.parse(readFileSync(scriptPath, 'utf8'))
|
|
72
|
+
ctx.llm.registerAdapter(['eval-mock'], new EvalMockAdapter(script))
|
|
73
|
+
}
|
package/src/mock/script.mjs
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Script builders for the eval mock-LLM layer. A mock script is
|
|
3
|
-
* `{ steps: ChunkStep[] }` where each step is the exact StreamChunk list one
|
|
4
|
-
* model call yields (its `finish` chunk included); the adapter consumes one
|
|
5
|
-
* step per stream() call. Cases build steps with these helpers instead of
|
|
6
|
-
* hand-writing chunk JSON.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
let autoId = 0
|
|
10
|
-
|
|
11
|
-
/** Next deterministic call id for generated steps. */
|
|
12
|
-
function nextCallId() {
|
|
13
|
-
autoId += 1
|
|
14
|
-
return `eval-mock-call-${autoId}`
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* One model call that requests a single tool invocation, then finishes with
|
|
19
|
-
* `kind: 'tool-calls'` so the loop executes the tool and calls back.
|
|
20
|
-
* @param {string} name - the model-facing tool name to call.
|
|
21
|
-
* @param {object} [args] - the tool arguments (serialized as the model's raw JSON).
|
|
22
|
-
* @param {{ id?: string }} [options] - override the generated call id.
|
|
23
|
-
*/
|
|
24
|
-
export function toolCallStep(name, args = {}, options = {}) {
|
|
25
|
-
const id = options.id ?? nextCallId()
|
|
26
|
-
const json = JSON.stringify(args)
|
|
27
|
-
return [
|
|
28
|
-
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
29
|
-
{ type: 'tool-call-delta', index: 0, id, name, argumentsDelta: json },
|
|
30
|
-
{ type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: json } },
|
|
31
|
-
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
|
|
32
|
-
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* One model call that answers plain text and stops the loop
|
|
38
|
-
* (`kind: 'stop'`).
|
|
39
|
-
* @param {string} text - the assistant reply.
|
|
40
|
-
*/
|
|
41
|
-
export function textStep(text) {
|
|
42
|
-
return [
|
|
43
|
-
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
44
|
-
{ type: 'text-delta', index: 0, text },
|
|
45
|
-
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
|
46
|
-
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
|
|
47
|
-
{ type: 'finish', reason: { kind: 'stop' } },
|
|
48
|
-
]
|
|
49
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Script builders for the eval mock-LLM layer. A mock script is
|
|
3
|
+
* `{ steps: ChunkStep[] }` where each step is the exact StreamChunk list one
|
|
4
|
+
* model call yields (its `finish` chunk included); the adapter consumes one
|
|
5
|
+
* step per stream() call. Cases build steps with these helpers instead of
|
|
6
|
+
* hand-writing chunk JSON.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let autoId = 0
|
|
10
|
+
|
|
11
|
+
/** Next deterministic call id for generated steps. */
|
|
12
|
+
function nextCallId() {
|
|
13
|
+
autoId += 1
|
|
14
|
+
return `eval-mock-call-${autoId}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One model call that requests a single tool invocation, then finishes with
|
|
19
|
+
* `kind: 'tool-calls'` so the loop executes the tool and calls back.
|
|
20
|
+
* @param {string} name - the model-facing tool name to call.
|
|
21
|
+
* @param {object} [args] - the tool arguments (serialized as the model's raw JSON).
|
|
22
|
+
* @param {{ id?: string }} [options] - override the generated call id.
|
|
23
|
+
*/
|
|
24
|
+
export function toolCallStep(name, args = {}, options = {}) {
|
|
25
|
+
const id = options.id ?? nextCallId()
|
|
26
|
+
const json = JSON.stringify(args)
|
|
27
|
+
return [
|
|
28
|
+
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
29
|
+
{ type: 'tool-call-delta', index: 0, id, name, argumentsDelta: json },
|
|
30
|
+
{ type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: json } },
|
|
31
|
+
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
|
|
32
|
+
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One model call that answers plain text and stops the loop
|
|
38
|
+
* (`kind: 'stop'`).
|
|
39
|
+
* @param {string} text - the assistant reply.
|
|
40
|
+
*/
|
|
41
|
+
export function textStep(text) {
|
|
42
|
+
return [
|
|
43
|
+
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
44
|
+
{ type: 'text-delta', index: 0, text },
|
|
45
|
+
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
|
46
|
+
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } },
|
|
47
|
+
{ type: 'finish', reason: { kind: 'stop' } },
|
|
48
|
+
]
|
|
49
|
+
}
|
package/src/overlay.mjs
CHANGED
|
@@ -19,6 +19,9 @@ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
19
19
|
/** The scripted mock adapter plugin, referenced from generated overlays. */
|
|
20
20
|
const MOCK_ADAPTER_PATH = join(FRAMEWORK_ROOT, 'src', 'mock', 'mock-adapter.mjs')
|
|
21
21
|
|
|
22
|
+
/** The multi-turn driver plugin, mounted when a case declares `followups`. */
|
|
23
|
+
const MULTI_TURN_DRIVER_PATH = join(FRAMEWORK_ROOT, 'src', 'driver', 'multi-turn-driver.mjs')
|
|
24
|
+
|
|
22
25
|
/** JSON double-quoted strings are valid YAML scalars — enough for this emitter. */
|
|
23
26
|
function yamlScalar(value) {
|
|
24
27
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
|
@@ -92,5 +95,15 @@ export function buildOverlayYaml(parts) {
|
|
|
92
95
|
lines.push(' - id: eval-mock-llm')
|
|
93
96
|
lines.push(` name: ${yamlScalar(pathToFileURL(MOCK_ADAPTER_PATH).href)}`)
|
|
94
97
|
}
|
|
98
|
+
if (parts.followups !== undefined) {
|
|
99
|
+
// Cross-turn driving replaces the one-shot headless runner: it exits at
|
|
100
|
+
// the FIRST idle and aborts every in-process background subagent at
|
|
101
|
+
// teardown, so fire-and-forget children need the driver's longer lifetime.
|
|
102
|
+
lines.push('- id: headless-runner')
|
|
103
|
+
lines.push(' disabled: true')
|
|
104
|
+
lines.push('- insert:')
|
|
105
|
+
lines.push(' - id: eval-multi-turn-driver')
|
|
106
|
+
lines.push(` name: ${yamlScalar(pathToFileURL(MULTI_TURN_DRIVER_PATH).href)}`)
|
|
107
|
+
}
|
|
95
108
|
return `${lines.join('\n')}\n`
|
|
96
109
|
}
|
package/src/review-report.mjs
CHANGED
|
@@ -68,7 +68,20 @@ export function renderReviewReport(parts) {
|
|
|
68
68
|
if (attempt.ok) {
|
|
69
69
|
lines.push(`### run ${attempt.index} — ok`)
|
|
70
70
|
lines.push('')
|
|
71
|
-
|
|
71
|
+
// The answer, not the raw final message: adapters derive an
|
|
72
|
+
// answer that survives tail interactions (gate splices); stdout
|
|
73
|
+
// is the fallback for executors without trace-derived answers.
|
|
74
|
+
lines.push('```text', (attempt.result?.answer ?? attempt.result?.stdout ?? '').trimEnd(), '```')
|
|
75
|
+
// Traceability: the grader can always recover the full conversation
|
|
76
|
+
// from the persisted per-run artifacts. The divergence note matches
|
|
77
|
+
// the bin's write predicate exactly (both fields defined and
|
|
78
|
+
// differing) — a stdout-less executor writes no run-N.stdout.txt,
|
|
79
|
+
// so it must not be cited either.
|
|
80
|
+
const diverged = attempt.result?.answer !== undefined
|
|
81
|
+
&& attempt.result?.stdout !== undefined
|
|
82
|
+
&& attempt.result.answer !== attempt.result.stdout
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push(`- transcript: \`run-${attempt.index}.stderr.txt\`${diverged ? ` (answer differs from the final message — see \`run-${attempt.index}.stdout.txt\`)` : ''}`)
|
|
72
85
|
} else {
|
|
73
86
|
lines.push(`### run ${attempt.index} — FAIL`)
|
|
74
87
|
lines.push('')
|
package/src/runner.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { tmpdir } from 'node:os'
|
|
|
34
34
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
35
35
|
import { fileURLToPath } from 'node:url'
|
|
36
36
|
import { loadTraceDir } from './trace.mjs'
|
|
37
|
-
import { validateRowConfig, validateDisableRows } from './discovery.mjs'
|
|
37
|
+
import { validateRowConfig, validateDisableRows, validateFollowups } from './discovery.mjs'
|
|
38
38
|
import { CLI_RELATIVE_PATH } from './cli.mjs'
|
|
39
39
|
import { buildOverlayYaml } from './overlay.mjs'
|
|
40
40
|
import { resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh } from './sandbox.mjs'
|
|
@@ -48,10 +48,17 @@ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
48
48
|
* Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
|
|
49
49
|
* script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
|
|
50
50
|
* rowConfig?: Record<string, Record<string, unknown>>,
|
|
51
|
+
* followups?: string[], settleTimeoutMs?: number,
|
|
51
52
|
* prepare?: (workspace: string) => void | Promise<void>,
|
|
52
53
|
* inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
|
|
53
54
|
* timeoutMs?: number }`
|
|
54
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
|
+
*
|
|
55
62
|
* @param {object} evalCase - the case under test.
|
|
56
63
|
* @param {object} options
|
|
57
64
|
* @param {string} options.profile - the dsh profile booting the run (plugin installed there).
|
|
@@ -106,6 +113,15 @@ export async function runEvalCase(evalCase, options) {
|
|
|
106
113
|
writeFileSync(scriptPath, JSON.stringify(evalCase.script))
|
|
107
114
|
env.DSH_EVAL_MOCK_SCRIPT = scriptPath
|
|
108
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
|
+
}
|
|
109
125
|
|
|
110
126
|
if (evalCase.disableRows !== undefined) {
|
|
111
127
|
validateDisableRows(evalCase.disableRows, `case '${evalCase.id}'`)
|
|
@@ -113,6 +129,9 @@ export async function runEvalCase(evalCase, options) {
|
|
|
113
129
|
if (evalCase.rowConfig !== undefined) {
|
|
114
130
|
validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
|
|
115
131
|
}
|
|
132
|
+
if (evalCase.followups !== undefined) {
|
|
133
|
+
validateFollowups(evalCase.followups, `case '${evalCase.id}'`)
|
|
134
|
+
}
|
|
116
135
|
const overlayPath = join(runDir, 'eval-overlay.yml')
|
|
117
136
|
writeFileSync(overlayPath, buildOverlayYaml({
|
|
118
137
|
sessionsRoot,
|
|
@@ -120,6 +139,7 @@ export async function runEvalCase(evalCase, options) {
|
|
|
120
139
|
disableRows: evalCase.disableRows,
|
|
121
140
|
rowConfig: evalCase.rowConfig,
|
|
122
141
|
mock: mode === 'mock',
|
|
142
|
+
...(evalCase.followups === undefined ? {} : { followups: evalCase.followups }),
|
|
123
143
|
}))
|
|
124
144
|
|
|
125
145
|
const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
|