@catheadowl/dsh-eval 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/trace.mjs CHANGED
@@ -1,218 +1,293 @@
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
- import { readdirSync, readFileSync } from 'node:fs'
12
- import { join } from 'node:path'
13
-
14
- /** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
15
- const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
16
-
17
- /**
18
- * Parse one uncompressed JSONL session artifact.
19
- * @param {string} text - the artifact's full text (header line first).
20
- * @returns {{ header: object, events: object[] }} header plus event records in log order.
21
- */
22
- export function parseSessionLog(text) {
23
- const lines = text.split('\n').filter(line => line.trim() !== '')
24
- if (lines.length === 0) throw new Error('empty session log')
25
- const header = JSON.parse(lines[0])
26
- if (header.type !== 'session') throw new Error('first line is not a session header')
27
- const events = []
28
- for (const line of lines.slice(1)) {
29
- let record
30
- try {
31
- record = JSON.parse(line)
32
- } catch {
33
- continue // torn or partial tail line: keep the decodable prefix
34
- }
35
- if (record === null || typeof record !== 'object') continue
36
- if (CHUNK_ROW_TYPES.has(record.type)) continue
37
- events.push(record)
38
- }
39
- return { header, events }
40
- }
41
-
42
- /** Concatenate the text blocks of one assembled assistant message. */
43
- function messageText(message) {
44
- const content = message?.content
45
- if (!Array.isArray(content)) return ''
46
- return content
47
- .filter(block => block?.type === 'text' && typeof block.text === 'string')
48
- .map(block => block.text)
49
- .join('')
50
- }
51
-
52
- /**
53
- * Extract the visible text of one tool-result message. Real messages are
54
- * user-role with a single wrapping `tool-result` block whose `content` holds
55
- * the actual blocks (see `createToolResultMessage` in
56
- * `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
57
- * tolerated for hand-built fixtures.
58
- */
59
- function toolResultText(message) {
60
- const content = message?.content
61
- if (!Array.isArray(content)) return ''
62
- const wrapper = content.find(block => block?.type === 'tool-result')
63
- const inner = wrapper !== undefined ? wrapper.content : content
64
- if (!Array.isArray(inner)) return ''
65
- return inner
66
- .filter(block => block?.type === 'text' && typeof block.text === 'string')
67
- .map(block => block.text)
68
- .join('')
69
- }
70
-
71
- /**
72
- * Extract the `isError` flag from a tool-result message's wrapper block.
73
- * Returns `undefined` when the flag is absent (treated as success by
74
- * matchers — see `toolResultSucceeded`).
75
- */
76
- function toolResultIsError(message) {
77
- const content = message?.content
78
- if (!Array.isArray(content)) return undefined
79
- const wrapper = content.find(block => block?.type === 'tool-result')
80
- return wrapper?.isError
81
- }
82
-
83
- /** Best-effort parse of a tool call's raw JSON arguments string. */
84
- function parseArguments(raw) {
85
- try {
86
- return JSON.parse(raw)
87
- } catch {
88
- return undefined
89
- }
90
- }
91
-
92
- /**
93
- * Build one assertable trace from parsed session logs. Child sessions surface
94
- * only through the parent's tool events, so the MAIN log (no `origin:
95
- * 'subagent'` header) owns the tool/final-text projections; all logs stay
96
- * available under `sessions`.
97
- * @param {{ header: object, events: object[] }[]} logs - parsed session logs.
98
- * @returns {EvalTrace}
99
- */
100
- export function buildTrace(logs) {
101
- const mains = logs.filter(log => log.header.origin !== 'subagent')
102
- const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
103
- const events = main?.events ?? []
104
-
105
- const toolCalls = []
106
- const toolResults = []
107
- const assistantTexts = []
108
- const userMessages = []
109
- const requestHeaders = []
110
- for (const event of events) {
111
- if (event.type === 'request/header') {
112
- // The assembled model request header: system prompt + mounted tool
113
- // schemas. What the model is told it can do and how — the "did my
114
- // plugin's section inject?" projection.
115
- requestHeaders.push({
116
- seq: event.seq,
117
- reason: event.data?.reason,
118
- system: event.data?.header?.system ?? '',
119
- toolNames: Array.isArray(event.data?.header?.tools)
120
- ? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
121
- : [],
122
- })
123
- } else if (event.type === 'tool/call') {
124
- toolCalls.push({
125
- seq: event.seq,
126
- turn: event.data.turn,
127
- step: event.data.step,
128
- callId: event.data.callId,
129
- name: event.data.name,
130
- arguments: event.data.arguments,
131
- parsedArguments: parseArguments(event.data.arguments),
132
- })
133
- } else if (event.type === 'tool/result') {
134
- toolResults.push({
135
- seq: event.seq,
136
- turn: event.data.turn,
137
- step: event.data.step,
138
- callId: event.data.message?.source?.callId,
139
- text: toolResultText(event.data.message),
140
- error: event.data.error,
141
- isError: toolResultIsError(event.data.message),
142
- })
143
- } else if (event.type === 'assistant/message') {
144
- const text = messageText(event.data.message)
145
- if (text !== '') assistantTexts.push(text)
146
- } else if (event.type === 'user/message') {
147
- // The user-role model-visible surface: the task prompt (kind 'user'),
148
- // plugin steering, or injected context. `source` tells them apart —
149
- // steer has no dedicated event type (the legacy `steering/message` was
150
- // migrated to `user/message`), so the matcher side filters by `source`.
151
- const text = messageText(event.data)
152
- if (text !== '') {
153
- userMessages.push({
154
- seq: event.seq,
155
- source: event.data?.source,
156
- text,
157
- })
158
- }
159
- }
160
- }
161
-
162
- return {
163
- sessions: logs,
164
- sessionId: main?.header.id,
165
- toolCalls,
166
- toolResults,
167
- assistantTexts,
168
- userMessages,
169
- requestHeaders,
170
- finalText: assistantTexts.at(-1) ?? '',
171
- }
172
- }
173
-
174
- /** Recursively collect files named `name` under `dir`. */
175
- function collectFiles(dir, name, out = []) {
176
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
177
- const path = join(dir, entry.name)
178
- if (entry.isDirectory()) collectFiles(path, name, out)
179
- else if (entry.name === name) out.push(path)
180
- }
181
- return out
182
- }
183
-
184
- /**
185
- * Load every session log under a persistence root and build one trace.
186
- * @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
187
- * @returns {EvalTrace | undefined} the trace, or `undefined` when no log materialized.
188
- */
189
- export function loadTraceDir(sessionsRoot) {
190
- let files
191
- try {
192
- files = collectFiles(sessionsRoot, 'session.jsonl')
193
- } catch {
194
- return undefined
195
- }
196
- if (files.length === 0) return undefined
197
- const logs = files
198
- .map(file => readFileSync(file, 'utf8'))
199
- .map(parseSessionLog)
200
- return buildTrace(logs)
201
- }
202
-
203
- /**
204
- * @typedef {object} EvalTrace
205
- * @property {{ header: object, events: object[] }[]} sessions - every parsed log.
206
- * @property {string | undefined} sessionId - the main session's id.
207
- * @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
208
- * @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
209
- * @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
210
- * @property {{ seq: number, source: object, text: string }[]} userMessages
211
- * - non-empty `user/message` events (task prompt, plugin steer, injected
212
- * context) with their verbatim `source` (`kind` + plugin-specific fields),
213
- * in log order. Steer has no dedicated event type; matchers filter by
214
- * `source`.
215
- * @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
216
- * - projected `request/header` events (assembled system prompt + mounted tools).
217
- * @property {string} finalText - the last assembled assistant text ('' when none).
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
+
11
+ import { readdirSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ /** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
15
+ const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
16
+
17
+ /**
18
+ * Parse one uncompressed JSONL session artifact.
19
+ * @param {string} text - the artifact's full text (header line first).
20
+ * @returns {{ header: object, events: object[] }} header plus event records in log order.
21
+ */
22
+ export function parseSessionLog(text) {
23
+ const lines = text.split('\n').filter(line => line.trim() !== '')
24
+ if (lines.length === 0) throw new Error('empty session log')
25
+ const header = JSON.parse(lines[0])
26
+ if (header.type !== 'session') throw new Error('first line is not a session header')
27
+ const events = []
28
+ for (const line of lines.slice(1)) {
29
+ let record
30
+ try {
31
+ record = JSON.parse(line)
32
+ } catch {
33
+ continue // torn or partial tail line: keep the decodable prefix
34
+ }
35
+ if (record === null || typeof record !== 'object') continue
36
+ if (CHUNK_ROW_TYPES.has(record.type)) continue
37
+ events.push(record)
38
+ }
39
+ return { header, events }
40
+ }
41
+
42
+ /** Concatenate the text blocks of one assembled assistant message. */
43
+ function messageText(message) {
44
+ const content = message?.content
45
+ if (!Array.isArray(content)) return ''
46
+ return content
47
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
48
+ .map(block => block.text)
49
+ .join('')
50
+ }
51
+
52
+ /**
53
+ * Extract the visible text of one tool-result message. Real messages are
54
+ * user-role with a single wrapping `tool-result` block whose `content` holds
55
+ * the actual blocks (see `createToolResultMessage` in
56
+ * `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
57
+ * tolerated for hand-built fixtures.
58
+ */
59
+ function toolResultText(message) {
60
+ const content = message?.content
61
+ if (!Array.isArray(content)) return ''
62
+ const wrapper = content.find(block => block?.type === 'tool-result')
63
+ const inner = wrapper !== undefined ? wrapper.content : content
64
+ if (!Array.isArray(inner)) return ''
65
+ return inner
66
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
67
+ .map(block => block.text)
68
+ .join('')
69
+ }
70
+
71
+ /**
72
+ * Extract the `isError` flag from a tool-result message's wrapper block.
73
+ * Returns `undefined` when the flag is absent (treated as success by
74
+ * matchers — see `toolResultSucceeded`).
75
+ */
76
+ function toolResultIsError(message) {
77
+ const content = message?.content
78
+ if (!Array.isArray(content)) return undefined
79
+ const wrapper = content.find(block => block?.type === 'tool-result')
80
+ return wrapper?.isError
81
+ }
82
+
83
+ /** Best-effort parse of a tool call's raw JSON arguments string. */
84
+ function parseArguments(raw) {
85
+ try {
86
+ return JSON.parse(raw)
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Project one subagent child log into an assertable record. The durable
94
+ * identity (`label` / `mode` / `provider`) comes from the FIRST
95
+ * `subagent/descriptor` event whose payload carries the descriptor version
96
+ * this projection supports (3) — mirroring `foldSubagentDescriptor` in
97
+ * `deepseek-harness/packages/subagent/subagent/src/descriptor.ts`, where the
98
+ * establishing provider appends exactly one authoritative descriptor and
99
+ * later events cannot rewrite it. Completion is the child's own last
100
+ * assistant text — a child that produced none may have been dispatched but
101
+ * never ran to an answer (turn/end reasons are not consulted).
102
+ */
103
+ function projectChild(log) {
104
+ let label
105
+ let mode
106
+ let provider
107
+ for (const event of log.events) {
108
+ if (event.type !== 'subagent/descriptor') continue
109
+ const data = event.data
110
+ if (data === null || typeof data !== 'object') continue
111
+ if (label !== undefined || mode !== undefined || provider !== undefined) break
112
+ if (data.version !== 3) continue
113
+ if (typeof data.label === 'string') label = data.label
114
+ if (typeof data.mode === 'string') mode = data.mode
115
+ if (typeof data.provider === 'string') provider = data.provider
116
+ }
117
+ const assistantTexts = log.events
118
+ .filter(event => event.type === 'assistant/message')
119
+ .map(event => messageText(event.data.message))
120
+ .filter(text => text !== '')
121
+ return {
122
+ sessionId: log.header.id,
123
+ parentSession: log.header.parentSession,
124
+ delegationDepth: log.header.delegationDepth,
125
+ label,
126
+ mode,
127
+ provider,
128
+ assistantTexts,
129
+ finalText: assistantTexts.at(-1) ?? '',
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Build one assertable trace from parsed session logs. Child sessions surface
135
+ * only through the parent's tool events, so the MAIN log (no `origin:
136
+ * 'subagent'` header) owns the tool/final-text projections; subagent children
137
+ * project separately under `subagentChildren`; all logs stay available under
138
+ * `sessions`. A log carrying `parentSession` without `origin: 'subagent'`
139
+ * (non-subagent fork/resume shape) counts as a child record here but remains
140
+ * a main candidate too — the host-side ownership check walks the agent chain,
141
+ * which the log alone cannot reproduce.
142
+ * @param {{ header: object, events: object[] }[]} logs - parsed session logs.
143
+ * @returns {EvalTrace}
144
+ */
145
+ export function buildTrace(logs) {
146
+ const mains = logs.filter(log => log.header.origin !== 'subagent')
147
+ const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
148
+ const events = main?.events ?? []
149
+ const subagentChildren = logs
150
+ .filter(log => log.header.origin === 'subagent' || log.header.parentSession !== undefined)
151
+ .map(projectChild)
152
+
153
+ const toolCalls = []
154
+ const toolResults = []
155
+ const assistantEntries = []
156
+ const userMessages = []
157
+ const requestHeaders = []
158
+ for (const event of events) {
159
+ if (event.type === 'request/header') {
160
+ // The assembled model request header: system prompt + mounted tool
161
+ // schemas. What the model is told it can do and how — the "did my
162
+ // plugin's section inject?" projection.
163
+ requestHeaders.push({
164
+ seq: event.seq,
165
+ reason: event.data?.reason,
166
+ system: event.data?.header?.system ?? '',
167
+ toolNames: Array.isArray(event.data?.header?.tools)
168
+ ? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
169
+ : [],
170
+ })
171
+ } else if (event.type === 'tool/call') {
172
+ toolCalls.push({
173
+ seq: event.seq,
174
+ turn: event.data.turn,
175
+ step: event.data.step,
176
+ callId: event.data.callId,
177
+ name: event.data.name,
178
+ arguments: event.data.arguments,
179
+ parsedArguments: parseArguments(event.data.arguments),
180
+ })
181
+ } else if (event.type === 'tool/result') {
182
+ toolResults.push({
183
+ seq: event.seq,
184
+ turn: event.data.turn,
185
+ step: event.data.step,
186
+ callId: event.data.message?.source?.callId,
187
+ text: toolResultText(event.data.message),
188
+ error: event.data.error,
189
+ isError: toolResultIsError(event.data.message),
190
+ })
191
+ } else if (event.type === 'assistant/message') {
192
+ const text = messageText(event.data.message)
193
+ if (text !== '') assistantEntries.push({ seq: event.seq, text })
194
+ } else if (event.type === 'user/message') {
195
+ // The user-role model-visible surface: the task prompt (kind 'user'),
196
+ // plugin steering, or injected context. `source` tells them apart —
197
+ // steer has no dedicated event type (the legacy `steering/message` was
198
+ // migrated to `user/message`), so the matcher side filters by `source`.
199
+ const text = messageText(event.data)
200
+ if (text !== '') {
201
+ userMessages.push({
202
+ seq: event.seq,
203
+ source: event.data?.source,
204
+ text,
205
+ })
206
+ }
207
+ }
208
+ }
209
+
210
+ const assistantTexts = assistantEntries.map(entry => entry.text)
211
+ // The answer to the task, as opposed to the last message: once a
212
+ // plugin-sourced injection (kind 'plugin' a turn-close gate splice, an
213
+ // infra complaint) enters the conversation, every assistant message after
214
+ // it responds to the injection, not to the task. The answer is therefore
215
+ // the last assistant text BEFORE the first plugin injection; without one
216
+ // it degenerates to finalText (the task was the reviewer's last business).
217
+ const firstInjectionSeq = userMessages.find(
218
+ message => message.source?.kind === 'plugin',
219
+ )?.seq
220
+ const answerEntries = firstInjectionSeq === undefined
221
+ ? assistantEntries
222
+ : assistantEntries.filter(entry => entry.seq < firstInjectionSeq)
223
+ const answerText = answerEntries.at(-1)?.text ?? ''
224
+
225
+ return {
226
+ sessions: logs,
227
+ sessionId: main?.header.id,
228
+ toolCalls,
229
+ toolResults,
230
+ assistantTexts,
231
+ answerText,
232
+ userMessages,
233
+ requestHeaders,
234
+ subagentChildren,
235
+ finalText: assistantTexts.at(-1) ?? '',
236
+ }
237
+ }
238
+
239
+ /** Recursively collect files named `name` under `dir`. */
240
+ function collectFiles(dir, name, out = []) {
241
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
242
+ const path = join(dir, entry.name)
243
+ if (entry.isDirectory()) collectFiles(path, name, out)
244
+ else if (entry.name === name) out.push(path)
245
+ }
246
+ return out
247
+ }
248
+
249
+ /**
250
+ * Load every session log under a persistence root and build one trace.
251
+ * @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
252
+ * @returns {EvalTrace | undefined} the trace, or `undefined` when no log materialized.
253
+ */
254
+ export function loadTraceDir(sessionsRoot) {
255
+ let files
256
+ try {
257
+ files = collectFiles(sessionsRoot, 'session.jsonl')
258
+ } catch {
259
+ return undefined
260
+ }
261
+ if (files.length === 0) return undefined
262
+ const logs = files
263
+ .map(file => readFileSync(file, 'utf8'))
264
+ .map(parseSessionLog)
265
+ return buildTrace(logs)
266
+ }
267
+
268
+ /**
269
+ * @typedef {object} EvalTrace
270
+ * @property {{ header: object, events: object[] }[]} sessions - every parsed log.
271
+ * @property {string | undefined} sessionId - the main session's id.
272
+ * @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
273
+ * @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
274
+ * @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
275
+ * @property {string} answerText - the last assistant text BEFORE the first
276
+ * plugin-sourced user message (gate splice / injected complaint); equals
277
+ * finalText when no plugin injection intervened ('' when none at all).
278
+ * The "answer to the task", as opposed to the possibly-hijacked last message.
279
+ * @property {{ seq: number, source: object, text: string }[]} userMessages
280
+ * - non-empty `user/message` events (task prompt, plugin steer, injected
281
+ * context) with their verbatim `source` (`kind` + plugin-specific fields),
282
+ * in log order. Steer has no dedicated event type; matchers filter by
283
+ * `source`.
284
+ * @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
285
+ * - projected `request/header` events (assembled system prompt + mounted tools).
286
+ * @property {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, label: string | undefined, mode: string | undefined, provider: string | undefined, assistantTexts: string[], finalText: string }[]} subagentChildren
287
+ * - one record per subagent child log (`origin: 'subagent'` header, or a
288
+ * header carrying `parentSession`). Identity comes from the first
289
+ * version-3 `subagent/descriptor` event; `finalText` is the child's own
290
+ * last assistant text ('' when it produced none — dispatched but not
291
+ * answered).
292
+ * @property {string} finalText - the last assembled assistant text ('' when none).
293
+ */