@catheadowl/dsh-eval 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/dsh-eval.mjs +335 -0
- package/bin/dsh-review.mjs +154 -0
- package/docs/README.md +15 -0
- package/docs/disablerows.md +25 -0
- package/docs/host-wiring.md +71 -0
- package/docs/intent-cases.md +39 -0
- package/docs/known-issues.md +15 -0
- package/docs/matchers.md +35 -0
- package/docs/report.md +27 -0
- package/docs/review.md +77 -0
- package/package.json +31 -0
- package/src/adapters/dsh/index.mjs +7 -0
- package/src/adapters/dsh/review.mjs +192 -0
- package/src/assertions.mjs +389 -0
- package/src/cli.mjs +104 -0
- package/src/config.mjs +119 -0
- package/src/discovery.mjs +115 -0
- package/src/experiment/review.mjs +118 -0
- package/src/index.mjs +49 -0
- package/src/mock/mock-adapter.mjs +73 -0
- package/src/mock/script.mjs +49 -0
- package/src/report.mjs +142 -0
- package/src/review-report.mjs +101 -0
- package/src/runner.mjs +373 -0
- package/src/tool-validation.mjs +77 -0
- package/src/trace.mjs +218 -0
|
@@ -0,0 +1,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 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
|
+
}
|
|
@@ -0,0 +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 }
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @catheadowl/dsh-eval public surface — what plugin eval cases import:
|
|
3
|
+
* matchers for the `expect` list and mock-script step builders. The runner
|
|
4
|
+
* and trace parser are bin/runner internals, also exported for ad-hoc use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
toolCalled,
|
|
9
|
+
toolNotCalled,
|
|
10
|
+
firstTool,
|
|
11
|
+
toolSequence,
|
|
12
|
+
toolCallArgs,
|
|
13
|
+
toolResultFor,
|
|
14
|
+
toolResultIsError,
|
|
15
|
+
toolResultSucceeded,
|
|
16
|
+
toolResultTextIncludes,
|
|
17
|
+
finalTextIncludes,
|
|
18
|
+
finalTextMatches,
|
|
19
|
+
assistantTextIncludes,
|
|
20
|
+
systemPromptIncludes,
|
|
21
|
+
toolMounted,
|
|
22
|
+
userMessageTextIncludes,
|
|
23
|
+
userMessageTextExcludes,
|
|
24
|
+
} from './assertions.mjs'
|
|
25
|
+
|
|
26
|
+
export { toolCallStep, textStep } from './mock/script.mjs'
|
|
27
|
+
|
|
28
|
+
export { runEvalCase, buildOverlayYaml, looksLikeDshRepo, stageProfileStore, FRAMEWORK_ROOT } from './runner.mjs'
|
|
29
|
+
|
|
30
|
+
export { parseSessionLog, buildTrace, loadTraceDir } from './trace.mjs'
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
defineReviewExperiment,
|
|
34
|
+
executeReviewExperiment,
|
|
35
|
+
materializeReviewExperiment,
|
|
36
|
+
renderObservationSections,
|
|
37
|
+
OBSERVATIONS_PLACEHOLDER,
|
|
38
|
+
} from './experiment/review.mjs'
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
createDshHeadlessReviewExecutor,
|
|
42
|
+
resolveDshCli,
|
|
43
|
+
runDshReviewExperiment,
|
|
44
|
+
} from './adapters/dsh/review.mjs'
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
validateToolBoundary,
|
|
48
|
+
renderToolBoundaryEvidence,
|
|
49
|
+
} from './tool-validation.mjs'
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable run reports for the behavior CLI (EVAL-007).
|
|
3
|
+
*
|
|
4
|
+
* One report covers ONE `dsh-eval run` invocation: the selection summary,
|
|
5
|
+
* per-case outcomes, and the invocation's environment anchors (profile /
|
|
6
|
+
* repo / mode filter). Reports exist so multi-plugin consumers and CI can
|
|
7
|
+
* aggregate results without scraping human output; the human `text` format
|
|
8
|
+
* is unchanged and remains the default.
|
|
9
|
+
*
|
|
10
|
+
* Case statuses:
|
|
11
|
+
* - `pass` — ran, exit 0, every matcher satisfied, inspect clean.
|
|
12
|
+
* - `fail` — ran but a failure was recorded (non-zero exit, matcher
|
|
13
|
+
* failure, timeout, inspect error), or the runner threw, or the case
|
|
14
|
+
* file failed to load / carried a duplicate id.
|
|
15
|
+
* - `skip` — not run (mode filter or missing credential); `skipReason`
|
|
16
|
+
* says why, so `--fail-on-skip` audits stay explainable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build one per-case record. All fields are JSON-stable scalars/arrays.
|
|
21
|
+
* @param {object} parts
|
|
22
|
+
* @param {string} parts.id - case id (a case-file load failure uses the file path).
|
|
23
|
+
* @param {string} parts.file - absolute path of the case file.
|
|
24
|
+
* @param {'real' | 'mock'} [parts.mode] - the mode the case ran (or would have run) in;
|
|
25
|
+
* absent for file-level failures that never resolved to a case.
|
|
26
|
+
* @param {'pass' | 'fail' | 'skip'} parts.status
|
|
27
|
+
* @param {string[]} [parts.failures] - human-readable failure lines (fail only).
|
|
28
|
+
* @param {string} [parts.skipReason] - why the case was skipped (skip only).
|
|
29
|
+
* @param {number} [parts.exitCode] - the headless CLI's exit code, when it ran.
|
|
30
|
+
* @param {boolean} [parts.timedOut]
|
|
31
|
+
* @param {number} [parts.durationMs] - wall time of the run, when it ran.
|
|
32
|
+
* @param {string} [parts.artifactsDir] - where post-mortem artifacts landed, when written.
|
|
33
|
+
*/
|
|
34
|
+
export function createCaseRecord(parts) {
|
|
35
|
+
const record = { id: parts.id, file: parts.file }
|
|
36
|
+
if (parts.mode !== undefined) record.mode = parts.mode
|
|
37
|
+
record.status = parts.status
|
|
38
|
+
if (parts.failures !== undefined) record.failures = parts.failures
|
|
39
|
+
if (parts.skipReason !== undefined) record.skipReason = parts.skipReason
|
|
40
|
+
if (parts.exitCode !== undefined) record.exitCode = parts.exitCode
|
|
41
|
+
if (parts.timedOut !== undefined) record.timedOut = parts.timedOut
|
|
42
|
+
if (parts.durationMs !== undefined) record.durationMs = parts.durationMs
|
|
43
|
+
if (parts.artifactsDir !== undefined) record.artifactsDir = parts.artifactsDir
|
|
44
|
+
return record
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Aggregate case records into the selection summary. Counts are derived,
|
|
49
|
+
* never accumulated alongside, so the two can never disagree.
|
|
50
|
+
* @param {object[]} records - case records from one invocation.
|
|
51
|
+
*/
|
|
52
|
+
export function summarizeRecords(records) {
|
|
53
|
+
return {
|
|
54
|
+
selected: records.length,
|
|
55
|
+
passed: records.filter(r => r.status === 'pass').length,
|
|
56
|
+
failed: records.filter(r => r.status === 'fail').length,
|
|
57
|
+
skipped: records.filter(r => r.status === 'skip').length,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the full invocation report object.
|
|
63
|
+
* @param {object} parts
|
|
64
|
+
* @param {string} parts.profile - the dsh profile that ran the cases.
|
|
65
|
+
* @param {string} parts.repo - the dsh host location that ran the cases
|
|
66
|
+
* (checkout dir, or the packaged CLI path when resolved via node_modules).
|
|
67
|
+
* @param {'flag' | 'node_modules' | 'config'} [parts.cliSource] - which C6
|
|
68
|
+
* chain segment supplied the CLI (C6: host-checkout-resolution).
|
|
69
|
+
* @param {'real' | 'mock' | 'all'} parts.modeFilter - the `--mode` selection.
|
|
70
|
+
* @param {object[]} parts.records - per-case records, in execution order.
|
|
71
|
+
* @param {string} parts.startedAt - ISO timestamp of the invocation start.
|
|
72
|
+
* @param {string} parts.finishedAt - ISO timestamp of the invocation end.
|
|
73
|
+
* @param {boolean} parts.failOnSkip - whether skip-only selections were fatal.
|
|
74
|
+
*/
|
|
75
|
+
export function buildRunReport(parts) {
|
|
76
|
+
return {
|
|
77
|
+
tool: 'dsh-eval',
|
|
78
|
+
profile: parts.profile,
|
|
79
|
+
repo: parts.repo,
|
|
80
|
+
...(parts.cliSource !== undefined ? { cliSource: parts.cliSource } : {}),
|
|
81
|
+
mode: parts.modeFilter,
|
|
82
|
+
failOnSkip: parts.failOnSkip,
|
|
83
|
+
startedAt: parts.startedAt,
|
|
84
|
+
finishedAt: parts.finishedAt,
|
|
85
|
+
summary: summarizeRecords(parts.records),
|
|
86
|
+
results: parts.records,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The exit code the CLI should use for a finished invocation.
|
|
92
|
+
* @param {object[]} records - case records from one invocation.
|
|
93
|
+
* @param {boolean} failOnSkip - `--fail-on-skip`: an all-skipped non-empty
|
|
94
|
+
* selection is a failure ("never ran but reported success").
|
|
95
|
+
*/
|
|
96
|
+
export function reportExitCode(records, failOnSkip) {
|
|
97
|
+
const summary = summarizeRecords(records)
|
|
98
|
+
if (summary.failed > 0) return 1
|
|
99
|
+
if (failOnSkip && summary.selected > 0 && summary.passed + summary.failed === 0) return 1
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Determinism diagnostic for failed MOCK runs (generic — no plugin named here
|
|
105
|
+
* is special to the framework).
|
|
106
|
+
*
|
|
107
|
+
* A mock run is deterministic only while the script owns every model call.
|
|
108
|
+
* Any non-host plugin injecting user-visible input after the script's
|
|
109
|
+
* terminal step (steer, turn-close gate feedback, ...) drives extra model
|
|
110
|
+
* calls: the script exhausts early and `finalText*` no longer means "the
|
|
111
|
+
* script's last step". When such injections are visible in the trace, name
|
|
112
|
+
* them so the failure explains itself instead of every consumer rediscovering
|
|
113
|
+
* the mechanism by reading raw traces.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} parts
|
|
116
|
+
* @param {import('./trace.mjs').EvalTrace} [parts.trace] - the failed run's trace.
|
|
117
|
+
* @param {string[]} parts.failures - the recorded failure lines (heuristic:
|
|
118
|
+
* the hint only fires when a terminal-text failure is among them).
|
|
119
|
+
* @returns {string | undefined} the hint line, or undefined when no non-host
|
|
120
|
+
* plugin injection is visible.
|
|
121
|
+
*/
|
|
122
|
+
export function mockDeterminismHint(parts) {
|
|
123
|
+
const { trace, failures } = parts
|
|
124
|
+
if (trace === undefined) return undefined
|
|
125
|
+
if (!failures.some(f => f.includes('final text'))) return undefined
|
|
126
|
+
const injectors = new Set()
|
|
127
|
+
for (const message of trace.userMessages ?? []) {
|
|
128
|
+
const source = message.source
|
|
129
|
+
// Host injections (@deepseek-ai/* runtime context, skill catalogs) ride
|
|
130
|
+
// along every request and never drive extra script steps; only non-host
|
|
131
|
+
// plugin messages can splice between/after script steps.
|
|
132
|
+
if (source?.kind === 'plugin' && typeof source.plugin === 'string'
|
|
133
|
+
&& !source.plugin.startsWith('@deepseek-ai/')) {
|
|
134
|
+
injectors.add(source.plugin)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (injectors.size === 0) return undefined
|
|
138
|
+
const names = [...injectors].map(name => `'${name}'`).join(', ')
|
|
139
|
+
return `mock determinism broken: user messages injected by non-host plugin(s) ${names} drove model calls the script does not own`
|
|
140
|
+
+ ` — if this case does not need that plugin, declare disableRows: [${names}];`
|
|
141
|
+
+ ` otherwise account for the interaction in the script or assert with assistantTextIncludes`
|
|
142
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-graded review report renderer (product-review P4).
|
|
3
|
+
*
|
|
4
|
+
* A review experiment's value lives in the HUMAN judgment applied after the
|
|
5
|
+
* runs: which reviewer flags are already-accepted trade-offs (intentional
|
|
6
|
+
* design), which are new red flags, and what to do next. Without a stable
|
|
7
|
+
* output shape that judgment stays ephemeral — "asking a model ad hoc" —
|
|
8
|
+
* and cannot be archived, compared across rounds, or audited by a third
|
|
9
|
+
* party.
|
|
10
|
+
*
|
|
11
|
+
* This renderer fills every machine-knowable field (experiment, adapter,
|
|
12
|
+
* profile, run count, observations fingerprint, rubric identity, each
|
|
13
|
+
* reviewer's verbatim answer) and leaves three explicit human sections as
|
|
14
|
+
* checklists. It deliberately does NOT score or summarize the answers:
|
|
15
|
+
* compressing fresh-model output into an automatic verdict would fake the
|
|
16
|
+
* very judgment this layer exists to preserve (product-review risk R3).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
/** Short hex fingerprint of the materialized observations (byte-stable). */
|
|
22
|
+
export function observationsFingerprint(observations) {
|
|
23
|
+
return createHash('sha256').update(observations, 'utf8').digest('hex').slice(0, 16)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the review report markdown.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} parts
|
|
30
|
+
* @param {object} parts.experiment - the frozen definition (id, summary, rubric).
|
|
31
|
+
* @param {object} [parts.result] - executeReviewExperiment output; absent for a
|
|
32
|
+
* dry run (no reviewer was invoked).
|
|
33
|
+
* @param {string} [parts.observations] - the materialized observations text,
|
|
34
|
+
* when available outside `result` (dry run).
|
|
35
|
+
* @param {string} [parts.adapter] - adapter label (e.g. 'dsh-headless'); absent
|
|
36
|
+
* for a dry run.
|
|
37
|
+
* @param {string} [parts.profile] - the profile the reviewers ran under.
|
|
38
|
+
* @returns {string} markdown report.
|
|
39
|
+
*/
|
|
40
|
+
export function renderReviewReport(parts) {
|
|
41
|
+
const { experiment } = parts
|
|
42
|
+
const result = parts.result
|
|
43
|
+
const dry = result === undefined
|
|
44
|
+
const observations = parts.observations ?? result?.observations ?? ''
|
|
45
|
+
const rubric = experiment.rubric instanceof URL
|
|
46
|
+
? experiment.rubric.href
|
|
47
|
+
: String(experiment.rubric)
|
|
48
|
+
const lines = []
|
|
49
|
+
lines.push(`# Review report — ${experiment.id}`)
|
|
50
|
+
lines.push('')
|
|
51
|
+
if (experiment.summary) lines.push(`> ${experiment.summary}`)
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push(`- experiment: \`${experiment.id}\``)
|
|
54
|
+
lines.push(`- adapter: ${parts.adapter ?? 'none (dry run)'}`)
|
|
55
|
+
if (parts.profile !== undefined) lines.push(`- profile: \`${parts.profile}\``)
|
|
56
|
+
lines.push(`- runs: ${dry ? '0 (dry run — observations materialized only)' : result.runs}`)
|
|
57
|
+
lines.push(`- observations: \`observations.md\` (sha256:${observationsFingerprint(observations)})`)
|
|
58
|
+
lines.push(`- rubric: ${rubric.includes('\n') ? '(inline string — see experiment definition)' : `\`${rubric}\``}`)
|
|
59
|
+
lines.push('')
|
|
60
|
+
|
|
61
|
+
lines.push('## Reviewer conclusions')
|
|
62
|
+
lines.push('')
|
|
63
|
+
if (dry) {
|
|
64
|
+
lines.push('_Dry run: no reviewer was invoked. Verify the materialized observations look right, then run for real._')
|
|
65
|
+
lines.push('')
|
|
66
|
+
} else {
|
|
67
|
+
for (const attempt of result.attempts) {
|
|
68
|
+
if (attempt.ok) {
|
|
69
|
+
lines.push(`### run ${attempt.index} — ok`)
|
|
70
|
+
lines.push('')
|
|
71
|
+
lines.push('```text', attempt.result.stdout.trimEnd(), '```')
|
|
72
|
+
} else {
|
|
73
|
+
lines.push(`### run ${attempt.index} — FAIL`)
|
|
74
|
+
lines.push('')
|
|
75
|
+
lines.push('```text', String(attempt.error).trimEnd(), '```')
|
|
76
|
+
}
|
|
77
|
+
lines.push('')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push('## Intentional design hits (human judgment)')
|
|
82
|
+
lines.push('')
|
|
83
|
+
lines.push('Reviewer flags that match an already-accepted trade-off. Cite the run and quote the flag.')
|
|
84
|
+
lines.push('')
|
|
85
|
+
lines.push('- [ ] ')
|
|
86
|
+
lines.push('')
|
|
87
|
+
lines.push('## New red flags (human judgment)')
|
|
88
|
+
lines.push('')
|
|
89
|
+
lines.push('Flags NOT covered by the rubric or the intentional-design list. These are the actual findings.')
|
|
90
|
+
lines.push('')
|
|
91
|
+
lines.push('- [ ] ')
|
|
92
|
+
lines.push('')
|
|
93
|
+
lines.push('## Next step (pick one)')
|
|
94
|
+
lines.push('')
|
|
95
|
+
lines.push('- [ ] change plugin output')
|
|
96
|
+
lines.push('- [ ] change rubric')
|
|
97
|
+
lines.push('- [ ] add / adjust a behavior case')
|
|
98
|
+
lines.push('- [ ] no action (record why)')
|
|
99
|
+
lines.push('')
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|