@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
package/src/trace.mjs
CHANGED
|
@@ -1,218 +1,590 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session-trace parsing for dsh agent eval. The evidence source is the JSONL
|
|
3
|
-
* session artifact written by `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
4
|
-
* (configured `compression: none`, `packChunks: false` by the eval overlay):
|
|
5
|
-
* one `type: 'session'` header line, then one JSON record per `SessionEvent`.
|
|
6
|
-
* Event shapes follow `deepseek-harness/packages/core/session/src/types.ts`
|
|
7
|
-
* (`SessionEventMap`); packed `*-chunks` storage rows are tolerated and
|
|
8
|
-
* skipped — they only carry `assistant/chunk` deltas eval never asserts on.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
* @
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Session-trace parsing for dsh agent eval. The evidence source is the JSONL
|
|
3
|
+
* session artifact written by `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
4
|
+
* (configured `compression: none`, `packChunks: false` by the eval overlay):
|
|
5
|
+
* one `type: 'session'` header line, then one JSON record per `SessionEvent`.
|
|
6
|
+
* Event shapes follow `deepseek-harness/packages/core/session/src/types.ts`
|
|
7
|
+
* (`SessionEventMap`); packed `*-chunks` storage rows are tolerated and
|
|
8
|
+
* skipped — they only carry `assistant/chunk` deltas eval never asserts on.
|
|
9
|
+
*
|
|
10
|
+
* Both seam directions carry an explicit boundary: an artifact whose header
|
|
11
|
+
* stamp is not a known generation is refused here (`parseSessionLog`), and a
|
|
12
|
+
* collection that finds no artifact yields a named diagnosis rather than an
|
|
13
|
+
* unexplained `undefined` (`collectSessionTrace`). See docs/host-wiring.md.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { basename, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
|
|
20
|
+
const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
|
21
|
+
|
|
22
|
+
/** Projection field name per projected event type. */
|
|
23
|
+
const PROJECTED_FIELD_BY_EVENT_TYPE = new Map([
|
|
24
|
+
['tool/call', 'toolCalls'],
|
|
25
|
+
['tool/result', 'toolResults'],
|
|
26
|
+
['assistant/message', 'assistantTexts'],
|
|
27
|
+
['user/message', 'userMessages'],
|
|
28
|
+
['request/header', 'requestHeaders'],
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session format generations this parser accepts. Mirror of the generation
|
|
33
|
+
* chain the vendored host ships codecs for
|
|
34
|
+
* (`session-format-catalog/src/generated.ts`: codecs v0–v3,
|
|
35
|
+
* `currentVersion: 3` = `SESSION_FORMAT_VERSION` in
|
|
36
|
+
* `core/session/src/types.ts`). The projection is written and verified
|
|
37
|
+
* against the current generation; older ones parse tolerantly. A stamp
|
|
38
|
+
* outside this set means the host moved to a generation whose payload this
|
|
39
|
+
* projection was never verified against — fail at the seam instead of
|
|
40
|
+
* projecting empty fields, and bump this set only together with the
|
|
41
|
+
* re-verification the docs' maintenance trigger describes.
|
|
42
|
+
*/
|
|
43
|
+
export const KNOWN_SESSION_FORMAT_VERSIONS = new Set([0, 1, 2, 3])
|
|
44
|
+
|
|
45
|
+
/** Known generations rendered for an error message: `v0, v1, v2, v3`. */
|
|
46
|
+
function knownGenerationsLabel() {
|
|
47
|
+
return [...KNOWN_SESSION_FORMAT_VERSIONS].sort((a, b) => a - b).map(version => `v${version}`).join(', ')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One header version stamp, rendered compactly for a diagnostic. */
|
|
51
|
+
function headerVersionLabel(version) {
|
|
52
|
+
if (typeof version === 'number') return `v${version}`
|
|
53
|
+
return `(${JSON.stringify(version ?? null)})`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse one uncompressed JSONL session artifact.
|
|
58
|
+
* @param {string} text - the artifact's full text (header line first).
|
|
59
|
+
* @returns {{ header: object, events: object[] }} header plus event records in log order.
|
|
60
|
+
*/
|
|
61
|
+
export function parseSessionLog(text) {
|
|
62
|
+
const lines = text.split('\n').filter(line => line.trim() !== '')
|
|
63
|
+
if (lines.length === 0) throw new Error('empty session log')
|
|
64
|
+
const header = JSON.parse(lines[0])
|
|
65
|
+
if (header.type !== 'session') throw new Error('first line is not a session header')
|
|
66
|
+
// Generation gate: the header stamp is the host's own declaration of the
|
|
67
|
+
// artifact's logical layout. An unknown one is a seam drift, not a parse
|
|
68
|
+
// detail — say so here rather than degrade every projection to empty.
|
|
69
|
+
if (!KNOWN_SESSION_FORMAT_VERSIONS.has(header.version)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`session header version ${headerVersionLabel(header.version)} is not a known generation`
|
|
72
|
+
+ ` (known: ${knownGenerationsLabel()}); the host session format may have changed generation`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
const events = []
|
|
76
|
+
for (const line of lines.slice(1)) {
|
|
77
|
+
let record
|
|
78
|
+
try {
|
|
79
|
+
record = JSON.parse(line)
|
|
80
|
+
} catch {
|
|
81
|
+
continue // torn or partial tail line: keep the decodable prefix
|
|
82
|
+
}
|
|
83
|
+
if (record === null || typeof record !== 'object') continue
|
|
84
|
+
if (CHUNK_ROW_TYPES.has(record.type)) continue
|
|
85
|
+
events.push(record)
|
|
86
|
+
}
|
|
87
|
+
return { header, events }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Concatenate the text blocks of one assembled assistant message. */
|
|
91
|
+
function messageText(message) {
|
|
92
|
+
const content = message?.content
|
|
93
|
+
if (!Array.isArray(content)) return ''
|
|
94
|
+
return content
|
|
95
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
96
|
+
.map(block => block.text)
|
|
97
|
+
.join('')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract the visible text of one tool-result message. Real messages are
|
|
102
|
+
* user-role with a single wrapping `tool-result` block whose `content` holds
|
|
103
|
+
* the actual blocks (see `createToolResultMessage` in
|
|
104
|
+
* `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
|
|
105
|
+
* tolerated for hand-built fixtures.
|
|
106
|
+
*/
|
|
107
|
+
function toolResultText(message) {
|
|
108
|
+
const content = message?.content
|
|
109
|
+
if (!Array.isArray(content)) return ''
|
|
110
|
+
const wrapper = content.find(block => block?.type === 'tool-result')
|
|
111
|
+
const inner = wrapper !== undefined ? wrapper.content : content
|
|
112
|
+
if (!Array.isArray(inner)) return ''
|
|
113
|
+
return inner
|
|
114
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
115
|
+
.map(block => block.text)
|
|
116
|
+
.join('')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract the `isError` flag from a tool-result message's wrapper block.
|
|
121
|
+
* Returns `undefined` when the flag is absent (treated as success by
|
|
122
|
+
* matchers — see `toolResultSucceeded`).
|
|
123
|
+
*/
|
|
124
|
+
function toolResultIsError(message) {
|
|
125
|
+
const content = message?.content
|
|
126
|
+
if (!Array.isArray(content)) return undefined
|
|
127
|
+
const wrapper = content.find(block => block?.type === 'tool-result')
|
|
128
|
+
return wrapper?.isError
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Best-effort parse of a tool call's raw JSON arguments string. */
|
|
132
|
+
function parseArguments(raw) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(raw)
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Fold one child log's descriptor events exactly once: the identity
|
|
142
|
+
* `projectChild` asserts on, plus the counts the census reports. Single source
|
|
143
|
+
* on purpose — `projectChild` and `censusForChild` must agree on which
|
|
144
|
+
* descriptor established the identity, and the "supported" predicate must match
|
|
145
|
+
* the fold (a log whose only descriptors are unsupported yields both an empty
|
|
146
|
+
* identity and `supportedDescriptors: 0`, which is the census signal).
|
|
147
|
+
* @param {{ events: object[] }} log - one parsed child log.
|
|
148
|
+
* @returns {{ label: string | undefined, mode: string | undefined, provider: string | undefined, descriptorEvents: number, supportedDescriptors: number }}
|
|
149
|
+
*/
|
|
150
|
+
function foldChildDescriptor(log) {
|
|
151
|
+
let label
|
|
152
|
+
let mode
|
|
153
|
+
let provider
|
|
154
|
+
let descriptorEvents = 0
|
|
155
|
+
let supportedDescriptors = 0
|
|
156
|
+
for (const event of log.events) {
|
|
157
|
+
if (event.type !== 'subagent/descriptor') continue
|
|
158
|
+
descriptorEvents += 1
|
|
159
|
+
const data = event.data
|
|
160
|
+
if (data === null || typeof data !== 'object') continue
|
|
161
|
+
if (data.version === 3) supportedDescriptors += 1
|
|
162
|
+
if (label !== undefined || mode !== undefined || provider !== undefined) continue
|
|
163
|
+
if (data.version !== 3) continue
|
|
164
|
+
if (typeof data.label === 'string') label = data.label
|
|
165
|
+
if (typeof data.mode === 'string') mode = data.mode
|
|
166
|
+
if (typeof data.provider === 'string') provider = data.provider
|
|
167
|
+
}
|
|
168
|
+
return { label, mode, provider, descriptorEvents, supportedDescriptors }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Project one subagent child log into an assertable record. The durable
|
|
173
|
+
* identity (`label` / `mode` / `provider`) comes from the FIRST
|
|
174
|
+
* `subagent/descriptor` event whose payload carries the descriptor version
|
|
175
|
+
* this projection supports (3) — mirroring `foldSubagentDescriptor` in
|
|
176
|
+
* `deepseek-harness/packages/subagent/subagent/src/descriptor.ts`, where the
|
|
177
|
+
* establishing provider appends exactly one authoritative descriptor and
|
|
178
|
+
* later events cannot rewrite it. Completion is the child's own last
|
|
179
|
+
* assistant text — a child that produced none may have been dispatched but
|
|
180
|
+
* never ran to an answer (turn/end reasons are not consulted).
|
|
181
|
+
*/
|
|
182
|
+
function projectChild(log) {
|
|
183
|
+
const { label, mode, provider } = foldChildDescriptor(log)
|
|
184
|
+
const assistantTexts = log.events
|
|
185
|
+
.filter(event => event.type === 'assistant/message')
|
|
186
|
+
.map(event => messageText(event.data.message))
|
|
187
|
+
.filter(text => text !== '')
|
|
188
|
+
return {
|
|
189
|
+
sessionId: log.header.id,
|
|
190
|
+
parentSession: log.header.parentSession,
|
|
191
|
+
delegationDepth: log.header.delegationDepth,
|
|
192
|
+
label,
|
|
193
|
+
mode,
|
|
194
|
+
provider,
|
|
195
|
+
assistantTexts,
|
|
196
|
+
finalText: assistantTexts.at(-1) ?? '',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Count events by type, plug-in event types included (the only live registry is
|
|
202
|
+
* `SessionEventMap`, so no closed list exists).
|
|
203
|
+
* @param {object[]} events - parsed event records.
|
|
204
|
+
* @returns {Record<string, number>} count per event type, insertion-ordered.
|
|
205
|
+
*/
|
|
206
|
+
function countEventTypes(events) {
|
|
207
|
+
// Null-prototype accumulator: a plug-in event type may name an
|
|
208
|
+
// `Object.prototype` member (`constructor`, `toString`, `__proto__`), and
|
|
209
|
+
// `counts['constructor'] ?? 0` would otherwise read the inherited function
|
|
210
|
+
// and string-concatenate, while `__proto__` would be swallowed by its setter.
|
|
211
|
+
const counts = Object.create(null)
|
|
212
|
+
for (const event of events) {
|
|
213
|
+
if (typeof event?.type !== 'string') continue
|
|
214
|
+
counts[event.type] = (counts[event.type] ?? 0) + 1
|
|
215
|
+
}
|
|
216
|
+
return counts
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Census for one candidate child log: whether its `subagent/descriptor`
|
|
221
|
+
* events exist, how many, and how many carry the supported descriptor
|
|
222
|
+
* version, plus the folded identity itself. `label` is the field the
|
|
223
|
+
* subagent-count matchers key on, so `label: undefined` with a non-zero
|
|
224
|
+
* `descriptorEvents` is exactly the shape whose `*Count(label, 0)` assertion
|
|
225
|
+
* is green only because there was nothing to match — the identity-loss
|
|
226
|
+
* degradation the census exists to make visible.
|
|
227
|
+
* @param {{ header: object, events: object[] }} log - one parsed child candidate.
|
|
228
|
+
* @returns {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, descriptorEvents: number, supportedDescriptors: number, label: string | undefined, mode: string | undefined, provider: string | undefined }}
|
|
229
|
+
*/
|
|
230
|
+
function censusForChild(log) {
|
|
231
|
+
return {
|
|
232
|
+
sessionId: log.header.id,
|
|
233
|
+
parentSession: log.header.parentSession,
|
|
234
|
+
delegationDepth: log.header.delegationDepth,
|
|
235
|
+
...foldChildDescriptor(log),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Projection census for one built trace (see {@link EvalTrace.census}).
|
|
241
|
+
* Numbers only: this reports what the raw logs contained against what the
|
|
242
|
+
* projections kept, and never decides whether the difference is a defect.
|
|
243
|
+
*
|
|
244
|
+
* Three signals, because one number cannot cover three shapes: length
|
|
245
|
+
* differences (`projectionSkipped.main`) see records the projection dropped,
|
|
246
|
+
* `projectionFieldGaps` sees records it kept while a field went missing
|
|
247
|
+
* (`tool/call` and friends project 1:1, so their count − length is structurally
|
|
248
|
+
* zero), and the subagent block sees the child-log data source, which no main-log
|
|
249
|
+
* count can reach.
|
|
250
|
+
*
|
|
251
|
+
* @param {object[]} events - the MAIN log's events (the projection input).
|
|
252
|
+
* @param {EvalTrace} trace - the built trace, read for projection lengths.
|
|
253
|
+
* @param {object[]} childLogs - candidate child logs entering `subagentChildren`.
|
|
254
|
+
* @param {Record<string, number>} projectionFieldGaps - gaps the projection loop
|
|
255
|
+
* recorded while reading fields, keyed by what was missing.
|
|
256
|
+
* @returns {object} the census record.
|
|
257
|
+
*/
|
|
258
|
+
function buildCensus(events, trace, childLogs, projectionFieldGaps) {
|
|
259
|
+
const eventTypeCounts = countEventTypes(events)
|
|
260
|
+
const projectionLengths = {
|
|
261
|
+
toolCalls: trace.toolCalls.length,
|
|
262
|
+
toolResults: trace.toolResults.length,
|
|
263
|
+
assistantTexts: trace.assistantTexts.length,
|
|
264
|
+
userMessages: trace.userMessages.length,
|
|
265
|
+
requestHeaders: trace.requestHeaders.length,
|
|
266
|
+
}
|
|
267
|
+
const projectionSkipped = { main: {}, children: {} }
|
|
268
|
+
for (const [type, field] of PROJECTED_FIELD_BY_EVENT_TYPE) {
|
|
269
|
+
const missing = (eventTypeCounts[type] ?? 0) - projectionLengths[field]
|
|
270
|
+
if (missing > 0) projectionSkipped.main[field] = missing
|
|
271
|
+
}
|
|
272
|
+
const children = childLogs.map(censusForChild)
|
|
273
|
+
const supportedDescriptors = children.reduce((total, child) => total + child.supportedDescriptors, 0)
|
|
274
|
+
// Two degradation shapes, different signals: `withoutIdentity` is a child
|
|
275
|
+
// that folded no identity at all; `withoutLabel` is one that folded some
|
|
276
|
+
// identity but no label — the only field the `subagent*Count` matchers can
|
|
277
|
+
// match on, so its zero-count assertions are the vacuous ones.
|
|
278
|
+
//
|
|
279
|
+
// The child set is the parentSession heuristic (any log whose header carries
|
|
280
|
+
// `parentSession`, which the host also writes for fork/resume/seed logs), so a
|
|
281
|
+
// non-subagent fork log shows up here as an identity-less child. The census
|
|
282
|
+
// reports the set it was given; it cannot re-derive the host's agent-chain
|
|
283
|
+
// ownership check from a log alone.
|
|
284
|
+
const withoutIdentity = children.filter(
|
|
285
|
+
child => child.label === undefined && child.mode === undefined && child.provider === undefined,
|
|
286
|
+
).length
|
|
287
|
+
const withoutLabel = children.filter(child => child.label === undefined).length
|
|
288
|
+
if (withoutIdentity > 0) projectionSkipped.children.withoutIdentity = withoutIdentity
|
|
289
|
+
if (withoutLabel > 0) projectionSkipped.children.withoutLabel = withoutLabel
|
|
290
|
+
return {
|
|
291
|
+
eventTypeCounts,
|
|
292
|
+
projectionLengths,
|
|
293
|
+
projectionSkipped,
|
|
294
|
+
projectionFieldGaps,
|
|
295
|
+
subagent: {
|
|
296
|
+
mainLogDescriptorEvents: eventTypeCounts['subagent/descriptor'] ?? 0,
|
|
297
|
+
supportedDescriptors,
|
|
298
|
+
children,
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Build one assertable trace from parsed session logs. Child sessions surface
|
|
305
|
+
* only through the parent's tool events, so the MAIN log (no `origin:
|
|
306
|
+
* 'subagent'` header) owns the tool/final-text projections; subagent children
|
|
307
|
+
* project separately under `subagentChildren`; all logs stay available under
|
|
308
|
+
* `sessions`. A log carrying `parentSession` without `origin: 'subagent'`
|
|
309
|
+
* (non-subagent fork/resume shape) counts as a child record here but remains
|
|
310
|
+
* a main candidate too — the host-side ownership check walks the agent chain,
|
|
311
|
+
* which the log alone cannot reproduce.
|
|
312
|
+
* @param {{ header: object, events: object[] }[]} logs - parsed session logs.
|
|
313
|
+
* @returns {EvalTrace}
|
|
314
|
+
*/
|
|
315
|
+
export function buildTrace(logs) {
|
|
316
|
+
const mains = logs.filter(log => log.header.origin !== 'subagent')
|
|
317
|
+
const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
|
|
318
|
+
const events = main?.events ?? []
|
|
319
|
+
const childLogs = logs
|
|
320
|
+
.filter(log => log.header.origin === 'subagent' || log.header.parentSession !== undefined)
|
|
321
|
+
const subagentChildren = childLogs.map(projectChild)
|
|
322
|
+
|
|
323
|
+
const toolCalls = []
|
|
324
|
+
const toolResults = []
|
|
325
|
+
const assistantEntries = []
|
|
326
|
+
const userMessages = []
|
|
327
|
+
const requestHeaders = []
|
|
328
|
+
const gaps = {}
|
|
329
|
+
const recordGap = key => { gaps[key] = (gaps[key] ?? 0) + 1 }
|
|
330
|
+
for (const event of events) {
|
|
331
|
+
if (event.type === 'request/header') {
|
|
332
|
+
// The assembled model request header: system prompt + mounted tool
|
|
333
|
+
// schemas. What the model is told it can do and how — the "did my
|
|
334
|
+
// plugin's section inject?" projection.
|
|
335
|
+
if (typeof event.data?.header?.system !== 'string') recordGap('headerWithoutSystem')
|
|
336
|
+
if (Array.isArray(event.data?.header?.tools)
|
|
337
|
+
&& !event.data.header.tools.some(tool => typeof tool?.name === 'string')) {
|
|
338
|
+
recordGap('headerWithoutToolNames')
|
|
339
|
+
}
|
|
340
|
+
requestHeaders.push({
|
|
341
|
+
seq: event.seq,
|
|
342
|
+
reason: event.data?.reason,
|
|
343
|
+
system: event.data?.header?.system ?? '',
|
|
344
|
+
toolNames: Array.isArray(event.data?.header?.tools)
|
|
345
|
+
? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
|
|
346
|
+
: [],
|
|
347
|
+
})
|
|
348
|
+
} else if (event.type === 'tool/call') {
|
|
349
|
+
if (typeof event.data?.name !== 'string') recordGap('toolCallWithoutName')
|
|
350
|
+
if (typeof event.data?.callId !== 'string') recordGap('toolCallWithoutCallId')
|
|
351
|
+
toolCalls.push({
|
|
352
|
+
seq: event.seq,
|
|
353
|
+
turn: event.data.turn,
|
|
354
|
+
step: event.data.step,
|
|
355
|
+
callId: event.data.callId,
|
|
356
|
+
name: event.data.name,
|
|
357
|
+
arguments: event.data.arguments,
|
|
358
|
+
parsedArguments: parseArguments(event.data.arguments),
|
|
359
|
+
})
|
|
360
|
+
} else if (event.type === 'tool/result') {
|
|
361
|
+
if (typeof event.data?.message?.source?.callId !== 'string') recordGap('toolResultWithoutCallId')
|
|
362
|
+
toolResults.push({
|
|
363
|
+
seq: event.seq,
|
|
364
|
+
turn: event.data.turn,
|
|
365
|
+
step: event.data.step,
|
|
366
|
+
callId: event.data.message?.source?.callId,
|
|
367
|
+
text: toolResultText(event.data.message),
|
|
368
|
+
error: event.data.error,
|
|
369
|
+
isError: toolResultIsError(event.data.message),
|
|
370
|
+
})
|
|
371
|
+
} else if (event.type === 'assistant/message') {
|
|
372
|
+
const text = messageText(event.data.message)
|
|
373
|
+
if (text !== '') assistantEntries.push({ seq: event.seq, text })
|
|
374
|
+
} else if (event.type === 'user/message') {
|
|
375
|
+
// The user-role model-visible surface: the task prompt (kind 'user'),
|
|
376
|
+
// plugin steering, or injected context. `source` tells them apart —
|
|
377
|
+
// steer has no dedicated event type (the legacy `steering/message` was
|
|
378
|
+
// migrated to `user/message`), so the matcher side filters by `source`.
|
|
379
|
+
const text = messageText(event.data)
|
|
380
|
+
if (text !== '') {
|
|
381
|
+
userMessages.push({
|
|
382
|
+
seq: event.seq,
|
|
383
|
+
source: event.data?.source,
|
|
384
|
+
text,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const assistantTexts = assistantEntries.map(entry => entry.text)
|
|
391
|
+
// The answer to the task, as opposed to the last message: once a
|
|
392
|
+
// plugin-sourced injection (kind 'plugin' — a turn-close gate splice, an
|
|
393
|
+
// infra complaint) enters the conversation, every assistant message after
|
|
394
|
+
// it responds to the injection, not to the task. The answer is therefore
|
|
395
|
+
// the last assistant text BEFORE the first plugin injection; without one
|
|
396
|
+
// it degenerates to finalText (the task was the reviewer's last business).
|
|
397
|
+
const firstInjectionSeq = userMessages.find(
|
|
398
|
+
message => message.source?.kind === 'plugin',
|
|
399
|
+
)?.seq
|
|
400
|
+
const answerEntries = firstInjectionSeq === undefined
|
|
401
|
+
? assistantEntries
|
|
402
|
+
: assistantEntries.filter(entry => entry.seq < firstInjectionSeq)
|
|
403
|
+
const answerText = answerEntries.at(-1)?.text ?? ''
|
|
404
|
+
|
|
405
|
+
const result = {
|
|
406
|
+
sessions: logs,
|
|
407
|
+
sessionId: main?.header.id,
|
|
408
|
+
toolCalls,
|
|
409
|
+
toolResults,
|
|
410
|
+
assistantTexts,
|
|
411
|
+
answerText,
|
|
412
|
+
userMessages,
|
|
413
|
+
requestHeaders,
|
|
414
|
+
subagentChildren,
|
|
415
|
+
finalText: assistantTexts.at(-1) ?? '',
|
|
416
|
+
census: undefined,
|
|
417
|
+
}
|
|
418
|
+
result.census = buildCensus(events, result, childLogs, gaps)
|
|
419
|
+
return result
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Session artifact basenames: format v0 keeps `session.jsonl`, every later
|
|
424
|
+
* generation carries a `vN` component (`session.v3.jsonl` — the host's
|
|
425
|
+
* `generationLogFilename`). Matching the v0 name alone finds no trace at all
|
|
426
|
+
* once the host bumps the format, which surfaces as "no session trace
|
|
427
|
+
* materialized" rather than as a parse error.
|
|
428
|
+
*/
|
|
429
|
+
const SESSION_LOG_FILENAME = /^session(?:\.v\d+)?\.jsonl$/u
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Whether one file basename is a session JSONL artifact of any format generation.
|
|
433
|
+
* @param {string} name - the file basename to test.
|
|
434
|
+
* @returns {boolean} true for `session.jsonl` and `session.vN.jsonl`.
|
|
435
|
+
*/
|
|
436
|
+
export function isSessionLogFilename(name) {
|
|
437
|
+
return SESSION_LOG_FILENAME.test(name)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Recursively list files under `dir`; an unreadable directory contributes nothing. */
|
|
441
|
+
function listFiles(dir, out = []) {
|
|
442
|
+
let entries
|
|
443
|
+
try {
|
|
444
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
445
|
+
} catch {
|
|
446
|
+
return out
|
|
447
|
+
}
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const path = join(dir, entry.name)
|
|
450
|
+
if (entry.isDirectory()) listFiles(path, out)
|
|
451
|
+
else out.push(path)
|
|
452
|
+
}
|
|
453
|
+
return out
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Every session artifact under `sessionsRoot` (any generation, see
|
|
458
|
+
* `isSessionLogFilename`) as absolute paths — the collection half of the
|
|
459
|
+
* seam, shared by the trace builder and by the raw-log capture the behavior
|
|
460
|
+
* runner does before cleanup.
|
|
461
|
+
* @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
|
|
462
|
+
* @returns {string[]} artifact paths, in directory order.
|
|
463
|
+
*/
|
|
464
|
+
export function listSessionLogFiles(sessionsRoot) {
|
|
465
|
+
return listFiles(sessionsRoot).filter(path => isSessionLogFilename(basename(path)))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Most candidate names one gap diagnostic lists before it truncates. */
|
|
469
|
+
const GAP_NAME_LIMIT = 10
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Why a collection produced no artifact, phrased for a failure message: the
|
|
473
|
+
* candidate names actually seen (a renamed artifact is the likeliest host
|
|
474
|
+
* drift) plus the generation suspicion. Never returns an empty string — an
|
|
475
|
+
* empty root is itself the fact to report.
|
|
476
|
+
*/
|
|
477
|
+
function traceGapMessage(sessionsRoot, files) {
|
|
478
|
+
const names = [...new Set(files.map(file => basename(file)))]
|
|
479
|
+
const lookalikes = names.filter(name => name.toLowerCase().startsWith('session'))
|
|
480
|
+
const pool = lookalikes.length > 0 ? lookalikes : names
|
|
481
|
+
const shown = pool.slice(0, GAP_NAME_LIMIT)
|
|
482
|
+
const rest = pool.length - shown.length
|
|
483
|
+
const scan = shown.length === 0
|
|
484
|
+
? 'the root holds no files (missing or empty)'
|
|
485
|
+
: `${lookalikes.length > 0 ? 'session-like file(s)' : 'file(s)'} under it: `
|
|
486
|
+
+ `${shown.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`
|
|
487
|
+
return 'no session trace materialized: no session artifact'
|
|
488
|
+
+ ` (session.jsonl / session.vN.jsonl) under '${sessionsRoot}' — ${scan}`
|
|
489
|
+
+ '; the host artifact naming may have changed generation'
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Collect one run's session trace and, when there is none, the seam
|
|
494
|
+
* diagnosis for it.
|
|
495
|
+
*
|
|
496
|
+
* The `gap` string exists so that "the host's artifact/session layout moved"
|
|
497
|
+
* surfaces as that sentence in the behavior runner's failure text and in the
|
|
498
|
+
* review adapter's accounting, instead of as a bare `undefined` the reader
|
|
499
|
+
* has to trace back through the parser (see docs/host-wiring.md).
|
|
500
|
+
*
|
|
501
|
+
* @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
|
|
502
|
+
* @returns {{ trace: EvalTrace | undefined, gap: string | undefined }} the
|
|
503
|
+
* trace, or `undefined` plus the reason no trace could be built.
|
|
504
|
+
*/
|
|
505
|
+
export function collectSessionTrace(sessionsRoot) {
|
|
506
|
+
const files = listFiles(sessionsRoot)
|
|
507
|
+
const artifacts = files.filter(file => isSessionLogFilename(basename(file)))
|
|
508
|
+
if (artifacts.length === 0) {
|
|
509
|
+
return { trace: undefined, gap: traceGapMessage(sessionsRoot, files) }
|
|
510
|
+
}
|
|
511
|
+
const logs = []
|
|
512
|
+
const broken = []
|
|
513
|
+
for (const artifact of artifacts) {
|
|
514
|
+
try {
|
|
515
|
+
logs.push(parseSessionLog(readFileSync(artifact, 'utf8')))
|
|
516
|
+
} catch (error) {
|
|
517
|
+
broken.push(`${basename(artifact)}: ${error instanceof Error ? error.message : String(error)}`)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (broken.length > 0) {
|
|
521
|
+
return {
|
|
522
|
+
trace: undefined,
|
|
523
|
+
gap: `session artifact(s) failed to parse — ${broken.join('; ')}`
|
|
524
|
+
+ '; the host session format may have changed generation',
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { trace: buildTrace(logs), gap: undefined }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @typedef {object} EvalTrace
|
|
532
|
+
* @property {{ header: object, events: object[] }[]} sessions - every parsed log.
|
|
533
|
+
* @property {string | undefined} sessionId - the main session's id.
|
|
534
|
+
* @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
|
|
535
|
+
* @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
|
|
536
|
+
* @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
|
|
537
|
+
* @property {string} answerText - the last assistant text BEFORE the first
|
|
538
|
+
* plugin-sourced user message (gate splice / injected complaint); equals
|
|
539
|
+
* finalText when no plugin injection intervened ('' when none at all).
|
|
540
|
+
* The "answer to the task", as opposed to the possibly-hijacked last message.
|
|
541
|
+
* @property {{ seq: number, source: object, text: string }[]} userMessages
|
|
542
|
+
* - non-empty `user/message` events (task prompt, plugin steer, injected
|
|
543
|
+
* context) with their verbatim `source` (`kind` + plugin-specific fields),
|
|
544
|
+
* in log order. Steer has no dedicated event type; matchers filter by
|
|
545
|
+
* `source`.
|
|
546
|
+
* @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
|
|
547
|
+
* - projected `request/header` events (assembled system prompt + mounted tools).
|
|
548
|
+
* @property {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, label: string | undefined, mode: string | undefined, provider: string | undefined, assistantTexts: string[], finalText: string }[]} subagentChildren
|
|
549
|
+
* - one record per subagent child log (`origin: 'subagent'` header, or a
|
|
550
|
+
* header carrying `parentSession`). Identity comes from the first
|
|
551
|
+
* version-3 `subagent/descriptor` event; `finalText` is the child's own
|
|
552
|
+
* last assistant text ('' when it produced none — dispatched but not
|
|
553
|
+
* answered).
|
|
554
|
+
* @property {string} finalText - the last assembled assistant text ('' when none).
|
|
555
|
+
* @property {object | undefined} census - what the raw logs contained against
|
|
556
|
+
* what the projections kept (numbers only, never a verdict). Two data
|
|
557
|
+
* sources: `eventTypeCounts` counts the MAIN log's events by type (any type,
|
|
558
|
+
* plug-in ones included), and `subagent` censuses the child logs that enter
|
|
559
|
+
* `subagentChildren` (their `subagent/descriptor` event counts and how many
|
|
560
|
+
* carry the supported `version === 3`). `projectionLengths` are the five
|
|
561
|
+
* main-log projections' lengths after empty-text drops; `projectionSkipped`
|
|
562
|
+
* records where count minus length is positive, per projection, plus the two
|
|
563
|
+
* child-identity counters. Undefined only on a hand-built trace; a nested log
|
|
564
|
+
* under `sessions` carries none because the parsers never add one.
|
|
565
|
+
* @property {Record<string, number>} census.projectionFieldGaps
|
|
566
|
+
* - events the projection kept while a field it reads went missing, keyed by
|
|
567
|
+
* what was missing (`toolCallWithoutName`, `toolCallWithoutCallId`,
|
|
568
|
+
* `toolResultWithoutCallId`, `headerWithoutSystem`, `headerWithoutToolNames`).
|
|
569
|
+
* This is the 1:1-projection signal: for `tool/call`, `tool/result` and
|
|
570
|
+
* `request/header`, count − length is structurally zero, so a moved field
|
|
571
|
+
* shows up only here. An absent `request/header.tools` array is NOT counted
|
|
572
|
+
* (it projects to the same empty list as an empty one).
|
|
573
|
+
* @property {{ mainLogDescriptorEvents: number, supportedDescriptors: number, children: object[] }} census.subagent
|
|
574
|
+
* - the child-log data source the main-log counts cannot reach.
|
|
575
|
+
* `mainLogDescriptorEvents` counts `subagent/descriptor` events in the MAIN
|
|
576
|
+
* log itself (the current host writes them into the child log, so this is
|
|
577
|
+
* usually 0); `supportedDescriptors` sums the per-child counts below, i.e.
|
|
578
|
+
* it counts DESCRIPTOR EVENTS, not child sessions — one child may fold its
|
|
579
|
+
* identity from a single descriptor while logging several.
|
|
580
|
+
* @property {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, descriptorEvents: number, supportedDescriptors: number, label: string | undefined, mode: string | undefined, provider: string | undefined }[]} census.subagent.children
|
|
581
|
+
* - the accepted child logs, each with the identity `projectChild` folded
|
|
582
|
+
* from them; `descriptorEvents === 0` means the log states no identity at
|
|
583
|
+
* all, and a non-zero count with `supportedDescriptors === 0` means every
|
|
584
|
+
* descriptor was outside the supported version.
|
|
585
|
+
* @property {Record<string, number>} census.projectionSkipped.children
|
|
586
|
+
* - `withoutIdentity` counts children that folded no identity field at all;
|
|
587
|
+
* `withoutLabel` counts children whose `label` is absent — the field the
|
|
588
|
+
* `subagent*Count` matchers match on, so those are the records whose
|
|
589
|
+
* zero-count assertions pass only because there was nothing to match.
|
|
590
|
+
*/
|