@catheadowl/dsh-eval 0.2.0 → 0.3.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/CHANGELOG.md +129 -0
- package/README.i18n.yaml +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +344 -335
- package/bin/dsh-review.mjs +179 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/experimental.md +4 -3
- package/docs/host-wiring.md +27 -2
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +34 -1
- package/docs/review.md +15 -10
- package/docs/runner-api.md +1 -2
- package/package.json +8 -2
- package/src/adapters/dsh/review.mjs +99 -52
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +166 -0
- package/src/experiment/review.mjs +118 -118
- package/src/experimental.mjs +6 -1
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +87 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/report.mjs +5 -0
- package/src/review-report.mjs +45 -1
- package/src/runner.mjs +35 -30
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +85 -77
- package/src/trace.mjs +590 -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/experimental.mjs
CHANGED
|
@@ -16,7 +16,12 @@ export { stageProfileStore } from './sandbox.mjs'
|
|
|
16
16
|
export { buildOverlayYaml, overlayDisableRows } from './overlay.mjs'
|
|
17
17
|
|
|
18
18
|
// --- session-trace primitives ---
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
parseSessionLog,
|
|
21
|
+
buildTrace,
|
|
22
|
+
collectSessionTrace,
|
|
23
|
+
KNOWN_SESSION_FORMAT_VERSIONS,
|
|
24
|
+
} from './trace.mjs'
|
|
20
25
|
|
|
21
26
|
// --- review experiment execution layer ---
|
|
22
27
|
export {
|
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,87 @@
|
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
+
// Carried explicitly instead of inherited: this plugin's `LlmAdapter` base
|
|
44
|
+
// resolves from THIS package's peer instance, which can lag the host
|
|
45
|
+
// runtime that drives it (host 0.1.5-rc.2 grew `prepareCall` while the
|
|
46
|
+
// local peer was 0.0.1-rc.1 — the inherited face was missing and every mock
|
|
47
|
+
// run died at `registration.adapter.prepareCall is not a function`). The
|
|
48
|
+
// shape is the host base-class default: model metadata plus a dispatch
|
|
49
|
+
// entry bound to this same adapter generation.
|
|
50
|
+
async prepareCall(provider, model, signal) {
|
|
51
|
+
return {
|
|
52
|
+
model: await this.resolveModel(provider, model, signal),
|
|
53
|
+
stream: options => this.stream(options),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async * stream(_options) {
|
|
58
|
+
const step = this.steps[this.cursor]
|
|
59
|
+
this.cursor += 1
|
|
60
|
+
if (step === undefined) {
|
|
61
|
+
// Script exhausted: finish the loop loudly-but-gracefully so the run
|
|
62
|
+
// still produces a trace a case can assert on.
|
|
63
|
+
const text = `eval-mock: script exhausted after ${this.cursor - 1} step(s)`
|
|
64
|
+
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
65
|
+
yield { type: 'text-delta', index: 0, text }
|
|
66
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
|
67
|
+
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
|
68
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
for (const chunk of step) yield chunk
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const name = 'eval-mock-llm'
|
|
76
|
+
|
|
77
|
+
export const inject = ['llm']
|
|
78
|
+
|
|
79
|
+
/** Register the `eval-mock` adapter from the scripted run. */
|
|
80
|
+
export function apply(ctx) {
|
|
81
|
+
const scriptPath = process.env[SCRIPT_ENV]
|
|
82
|
+
if (scriptPath === undefined) {
|
|
83
|
+
throw new Error(`eval-mock-llm: ${SCRIPT_ENV} must point at the run's mock script JSON`)
|
|
84
|
+
}
|
|
85
|
+
const script = JSON.parse(readFileSync(scriptPath, 'utf8'))
|
|
86
|
+
ctx.llm.registerAdapter(['eval-mock'], new EvalMockAdapter(script))
|
|
87
|
+
}
|
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/report.mjs
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* @param {boolean} [parts.timedOut]
|
|
31
31
|
* @param {number} [parts.durationMs] - wall time of the run, when it ran.
|
|
32
32
|
* @param {string} [parts.artifactsDir] - where post-mortem artifacts landed, when written.
|
|
33
|
+
* @param {object} [parts.census] - the run trace's projection census
|
|
34
|
+
* (`trace.census`), carried on both pass and fail records so a green case
|
|
35
|
+
* whose evidence surface degraded is still inspectable after the fact.
|
|
36
|
+
* Absent when no trace materialized.
|
|
33
37
|
*/
|
|
34
38
|
export function createCaseRecord(parts) {
|
|
35
39
|
const record = { id: parts.id, file: parts.file }
|
|
@@ -41,6 +45,7 @@ export function createCaseRecord(parts) {
|
|
|
41
45
|
if (parts.timedOut !== undefined) record.timedOut = parts.timedOut
|
|
42
46
|
if (parts.durationMs !== undefined) record.durationMs = parts.durationMs
|
|
43
47
|
if (parts.artifactsDir !== undefined) record.artifactsDir = parts.artifactsDir
|
|
48
|
+
if (parts.census !== undefined) record.census = parts.census
|
|
44
49
|
return record
|
|
45
50
|
}
|
|
46
51
|
|
package/src/review-report.mjs
CHANGED
|
@@ -23,6 +23,29 @@ export function observationsFingerprint(observations) {
|
|
|
23
23
|
return createHash('sha256').update(observations, 'utf8').digest('hex').slice(0, 16)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* One-line statement of whether the tool boundary check RAN on the review
|
|
28
|
+
* runs. A run without a session artifact reports `status: 'not-executed'`;
|
|
29
|
+
* that must show up in the report header, because a review whose tool face was
|
|
30
|
+
* never verified is weaker evidence than one whose was (EVAL-021). Executors
|
|
31
|
+
* that do not validate at all (custom executors, dry runs) say so instead of
|
|
32
|
+
* implying a pass.
|
|
33
|
+
*/
|
|
34
|
+
function toolBoundarySummary(attempts) {
|
|
35
|
+
const skipped = attempts.filter(attempt => attempt.result?.toolValidation?.status === 'not-executed')
|
|
36
|
+
if (skipped.length > 0) {
|
|
37
|
+
return `NOT EXECUTED on run(s) ${skipped.map(attempt => attempt.index).join(', ')}`
|
|
38
|
+
+ ' — no session artifact; the reviewer tool face was not verified (see the per-run notes)'
|
|
39
|
+
}
|
|
40
|
+
const checked = attempts.filter(attempt => attempt.result?.toolValidation?.status === 'checked')
|
|
41
|
+
if (attempts.length > 0 && checked.length === attempts.length) return 'checked on every run'
|
|
42
|
+
if (checked.length > 0) {
|
|
43
|
+
return `checked on ${checked.length} of ${attempts.length} run(s); the rest reported none`
|
|
44
|
+
+ ' (failed before validation, or a non-verifying executor)'
|
|
45
|
+
}
|
|
46
|
+
return 'not reported by this executor'
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/**
|
|
27
50
|
* Render the review report markdown.
|
|
28
51
|
*
|
|
@@ -54,6 +77,7 @@ export function renderReviewReport(parts) {
|
|
|
54
77
|
lines.push(`- adapter: ${parts.adapter ?? 'none (dry run)'}`)
|
|
55
78
|
if (parts.profile !== undefined) lines.push(`- profile: \`${parts.profile}\``)
|
|
56
79
|
lines.push(`- runs: ${dry ? '0 (dry run — observations materialized only)' : result.runs}`)
|
|
80
|
+
if (!dry) lines.push(`- tool boundary: ${toolBoundarySummary(result.attempts)}`)
|
|
57
81
|
lines.push(`- observations: \`observations.md\` (sha256:${observationsFingerprint(observations)})`)
|
|
58
82
|
lines.push(`- rubric: ${rubric.includes('\n') ? '(inline string — see experiment definition)' : `\`${rubric}\``}`)
|
|
59
83
|
lines.push('')
|
|
@@ -68,7 +92,27 @@ export function renderReviewReport(parts) {
|
|
|
68
92
|
if (attempt.ok) {
|
|
69
93
|
lines.push(`### run ${attempt.index} — ok`)
|
|
70
94
|
lines.push('')
|
|
71
|
-
|
|
95
|
+
// The answer, not the raw final message: adapters derive an
|
|
96
|
+
// answer that survives tail interactions (gate splices); stdout
|
|
97
|
+
// is the fallback for executors without trace-derived answers.
|
|
98
|
+
lines.push('```text', (attempt.result?.answer ?? attempt.result?.stdout ?? '').trimEnd(), '```')
|
|
99
|
+
// Traceability: the grader can always recover the full conversation
|
|
100
|
+
// from the persisted per-run artifacts. The divergence note matches
|
|
101
|
+
// the bin's write predicate exactly (both fields defined and
|
|
102
|
+
// differing) — a stdout-less executor writes no run-N.stdout.txt,
|
|
103
|
+
// so it must not be cited either.
|
|
104
|
+
const diverged = attempt.result?.answer !== undefined
|
|
105
|
+
&& attempt.result?.stdout !== undefined
|
|
106
|
+
&& attempt.result.answer !== attempt.result.stdout
|
|
107
|
+
lines.push('')
|
|
108
|
+
lines.push(`- transcript: \`run-${attempt.index}.stderr.txt\`${diverged ? ` (answer differs from the final message — see \`run-${attempt.index}.stdout.txt\`)` : ''}`)
|
|
109
|
+
if (attempt.result?.toolValidation?.status === 'not-executed') {
|
|
110
|
+
// The unverified-boundary fact rides the run it applies to, next to
|
|
111
|
+
// the answer it qualifies: this answer is stdout's final message
|
|
112
|
+
// (there was no trace to derive it from) and no request header was
|
|
113
|
+
// ever inspected for tool leakage.
|
|
114
|
+
lines.push(`- **tool-boundary check NOT EXECUTED**: ${attempt.result.traceGap ?? 'no session trace materialized'} — the answer above is stdout's final message, not a trace-derived answer.`)
|
|
115
|
+
}
|
|
72
116
|
} else {
|
|
73
117
|
lines.push(`### run ${attempt.index} — FAIL`)
|
|
74
118
|
lines.push('')
|