@catheadowl/dsh-eval 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.i18n.yaml +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/host-wiring.md +1 -1
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +13 -1
- package/docs/review.md +12 -9
- package/package.json +7 -1
- package/src/adapters/dsh/review.mjs +70 -17
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +21 -1
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
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
|
+
|
|
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
|
+
*/
|