Skip to main content

eval_magic/pipeline/
fill_transcripts.rs

1//! Stage 2 — `fill-transcripts`.
2//!
3//! Walks the iteration's `eval-*`
4//! directories and, for each `(eval, condition)` `run.json`, populates
5//! `tool_invocations` from the persisted transcript (Claude Code subagent JSONL
6//! resolved by the task's `agent_description`, or Codex `codex-events.jsonl`).
7//! Records that already carry invocations are skipped unless `overwrite`.
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use serde::Deserialize;
14
15use crate::adapters::{adapter_for, find_by_description};
16use crate::core::{ConditionsRecord, DispatchMechanism, Harness, RunRecord, ToolInvocation};
17use crate::pipeline::error::PipelineError;
18use crate::pipeline::io::write_json;
19use crate::pipeline::slots::{run_key, run_slots};
20use crate::validation::{SchemaName, validate_against_schema};
21
22/// Tally of what fill-transcripts did across the iteration's runs.
23#[derive(Debug, Default, Clone, PartialEq, Eq)]
24pub struct FillTranscriptsResult {
25    pub filled: usize,
26    pub skipped: usize,
27    pub missing: usize,
28}
29
30/// The `dispatch.json` fields fill-transcripts reads back.
31#[derive(Debug, Deserialize)]
32struct DispatchEnvelope {
33    tasks: Option<Vec<DispatchRef>>,
34}
35
36#[derive(Debug, Deserialize)]
37struct DispatchRef {
38    eval_id: String,
39    condition: String,
40    #[serde(default)]
41    run_index: Option<u32>,
42    #[serde(default)]
43    agent_description: Option<String>,
44    #[serde(default)]
45    outputs_dir: Option<String>,
46}
47
48/// The canonical dispatch description for an `(eval, condition, run)` run.
49///
50/// The runner writes a unique `agent_description` per task into `dispatch.json`
51/// (namespaced with the iteration + run nonce); reading it back binds each run to
52/// the exact agent that produced it. Falls back to the
53/// `<eval_id>:<condition>[:r<k>]` reconstruction when `dispatch.json` is absent,
54/// malformed, or missing the task (hand-authored/operator runs).
55pub fn resolve_agent_description(
56    iteration_dir: &Path,
57    eval_id: &str,
58    condition: &str,
59    run_index: Option<u32>,
60) -> String {
61    let dispatch_path = iteration_dir.join("dispatch.json");
62    if let Ok(raw) = fs::read_to_string(&dispatch_path)
63        && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
64        && let Some(tasks) = env.tasks
65        && let Some(task) = tasks
66            .iter()
67            .find(|t| t.eval_id == eval_id && t.condition == condition && t.run_index == run_index)
68        && let Some(desc) = &task.agent_description
69    {
70        return desc.clone();
71    }
72    run_key(eval_id, condition, run_index)
73}
74
75/// Populate `tool_invocations` for every `run.json` under `iteration_dir`. See
76/// the module docs for the transcript sources and overwrite semantics.
77pub fn fill_transcripts(
78    iteration_dir: &Path,
79    harness: Harness,
80    mechanism: DispatchMechanism,
81    subagents_dir: Option<&Path>,
82    overwrite: bool,
83) -> Result<FillTranscriptsResult, PipelineError> {
84    let conditions_path = iteration_dir.join("conditions.json");
85    if !conditions_path.exists() {
86        return Err(PipelineError::Message(format!(
87            "missing: {}",
88            conditions_path.display()
89        )));
90    }
91    let conditions: ConditionsRecord =
92        serde_json::from_str(&fs::read_to_string(&conditions_path)?)?;
93    let condition_names: Vec<String> = conditions
94        .conditions
95        .iter()
96        .map(|c| c.name.clone())
97        .collect();
98
99    let outputs_by_key = outputs_dirs_by_key(iteration_dir);
100
101    let mut result = FillTranscriptsResult::default();
102    for entry in fs::read_dir(iteration_dir)? {
103        let entry = entry?;
104        let dir_name = entry.file_name().to_string_lossy().into_owned();
105        let Some(eval_id) = dir_name.strip_prefix("eval-") else {
106            continue;
107        };
108
109        for cond in &condition_names {
110            let cond_dir = iteration_dir.join(&dir_name).join(cond);
111            for slot in run_slots(&cond_dir) {
112                let run_path = slot.dir.join("run.json");
113                if !run_path.exists() {
114                    continue;
115                }
116
117                let source = run_path.to_string_lossy();
118                let mut run: RunRecord = validate_against_schema(
119                    SchemaName::RunRecord,
120                    &serde_json::from_str(&fs::read_to_string(&run_path)?)?,
121                    &source,
122                )?;
123
124                if !run.tool_invocations.is_empty() && !overwrite {
125                    result.skipped += 1;
126                    continue;
127                }
128
129                let outputs_dir = outputs_by_key
130                    .get(&run_key(eval_id, cond, slot.run_index))
131                    .cloned()
132                    .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
133
134                // Resolve the in-session description lazily — only the InSession
135                // branch needs it, so a Cli run skips the dispatch.json re-read.
136                let description = (mechanism == DispatchMechanism::InSession).then(|| {
137                    resolve_agent_description(iteration_dir, eval_id, cond, slot.run_index)
138                });
139                let Some(invocations) = invocations_for_run(
140                    harness,
141                    mechanism,
142                    subagents_dir,
143                    description.as_deref(),
144                    Path::new(&outputs_dir),
145                ) else {
146                    result.missing += 1;
147                    continue;
148                };
149
150                run.tool_invocations = invocations;
151                write_json(&run_path, &run)?;
152                result.filled += 1;
153            }
154        }
155    }
156
157    Ok(result)
158}
159
160/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `outputs_dir` from
161/// `dispatch.json`. Empty when the file is absent or malformed (callers fall
162/// back to convention).
163fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
164    let mut out = HashMap::new();
165    if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
166        && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
167    {
168        for t in env.tasks.unwrap_or_default() {
169            if let Some(dir) = t.outputs_dir {
170                out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
171            }
172        }
173    }
174    out
175}
176
177/// Parse the invocations for one run, keyed on the dispatch mechanism: a
178/// `Cli`-mechanism harness reads the events file its CLI wrote under
179/// `outputs_dir` (e.g. Codex's `codex-events.jsonl`, Claude Code hybrid's
180/// `claude-events.jsonl`); an `InSession` harness reads the subagent transcript
181/// matched by `description` (resolved by the caller).
182fn invocations_for_run(
183    harness: Harness,
184    mechanism: DispatchMechanism,
185    subagents_dir: Option<&Path>,
186    description: Option<&str>,
187    outputs_dir: &Path,
188) -> Option<Vec<ToolInvocation>> {
189    match mechanism {
190        DispatchMechanism::Cli => {
191            let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
192            if !events_path.exists() {
193                return None;
194            }
195            adapter_for(harness).parse_cli_events(&events_path).ok()
196        }
197        DispatchMechanism::InSession => {
198            let subagent =
199                find_by_description(subagents_dir.unwrap_or_else(|| Path::new("")), description?)?;
200            adapter_for(harness)
201                .parse_transcript(&subagent.jsonl_path)
202                .ok()
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use serde_json::{Value, json};
211    use std::path::PathBuf;
212    use tempfile::TempDir;
213
214    fn write_dispatch(iteration_dir: &Path, tasks: Value) {
215        fs::create_dir_all(iteration_dir).unwrap();
216        fs::write(
217            iteration_dir.join("dispatch.json"),
218            serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
219        )
220        .unwrap();
221    }
222
223    fn jsonl(lines: &[Value]) -> String {
224        let body = lines
225            .iter()
226            .map(|l| l.to_string())
227            .collect::<Vec<_>>()
228            .join("\n");
229        format!("{body}\n")
230    }
231
232    fn write_run_record(path: &Path, tool_invocations: Value) {
233        let record = json!({
234            "eval_id": "crash",
235            "condition": "with_skill",
236            "skill_path": "/skill/SKILL.md",
237            "prompt": "Fix it",
238            "files": [],
239            "final_message": "Done.",
240            "tool_invocations": tool_invocations,
241            "total_tokens": Value::Null,
242            "duration_ms": Value::Null,
243        });
244        fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
245    }
246
247    // --- resolveAgentDescription ---
248
249    #[test]
250    fn returns_the_namespaced_agent_description_from_dispatch() {
251        let root = TempDir::new().unwrap();
252        let dir = root.path().join("iter-canonical");
253        write_dispatch(
254            &dir,
255            json!([
256                {"eval_id": "crash", "condition": "with_skill", "agent_description": "crash:with_skill:i3-abc123"},
257                {"eval_id": "crash", "condition": "without_skill", "agent_description": "crash:without_skill:i3-abc123"}
258            ]),
259        );
260        assert_eq!(
261            resolve_agent_description(&dir, "crash", "with_skill", None),
262            "crash:with_skill:i3-abc123"
263        );
264        assert_eq!(
265            resolve_agent_description(&dir, "crash", "without_skill", None),
266            "crash:without_skill:i3-abc123"
267        );
268    }
269
270    #[test]
271    fn falls_back_to_legacy_reconstruction_when_dispatch_absent() {
272        let root = TempDir::new().unwrap();
273        let dir = root.path().join("iter-no-dispatch");
274        fs::create_dir_all(&dir).unwrap();
275        assert_eq!(
276            resolve_agent_description(&dir, "crash", "with_skill", None),
277            "crash:with_skill"
278        );
279    }
280
281    #[test]
282    fn falls_back_when_task_missing_from_dispatch() {
283        let root = TempDir::new().unwrap();
284        let dir = root.path().join("iter-partial");
285        write_dispatch(
286            &dir,
287            json!([{"eval_id": "other", "condition": "with_skill", "agent_description": "other:with_skill:i1-x"}]),
288        );
289        assert_eq!(
290            resolve_agent_description(&dir, "crash", "with_skill", None),
291            "crash:with_skill"
292        );
293    }
294
295    #[test]
296    fn falls_back_when_dispatch_malformed() {
297        let root = TempDir::new().unwrap();
298        let dir = root.path().join("iter-malformed");
299        fs::create_dir_all(&dir).unwrap();
300        fs::write(dir.join("dispatch.json"), "{ not valid json").unwrap();
301        assert_eq!(
302            resolve_agent_description(&dir, "crash", "with_skill", None),
303            "crash:with_skill"
304        );
305    }
306
307    // --- fillTranscripts ---
308
309    #[test]
310    fn fills_a_claude_hybrid_run_record_from_outputs_events() {
311        let root = TempDir::new().unwrap();
312        let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
313        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
314        let outputs_dir = cond_dir.join("outputs");
315        fs::create_dir_all(&outputs_dir).unwrap();
316        let run_path = cond_dir.join("run.json");
317        write_run_record(&run_path, json!([]));
318        fs::write(
319            iteration_dir.join("conditions.json"),
320            json!({
321                "mode": "new-skill",
322                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
323                "timestamp": "2026-06-07T00:00:00.000Z",
324                "harness": "claude-code",
325                "run_mode": "hybrid"
326            })
327            .to_string(),
328        )
329        .unwrap();
330        write_dispatch(
331            &iteration_dir,
332            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
333        );
334        // `claude -p` stream-json: assistant tool_use + user tool_result + result.
335        fs::write(
336            outputs_dir.join("claude-events.jsonl"),
337            jsonl(&[
338                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
339                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
340                json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 10, "usage": {"input_tokens": 1, "output_tokens": 1, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}),
341            ]),
342        )
343        .unwrap();
344
345        let result = fill_transcripts(
346            &iteration_dir,
347            Harness::ClaudeCode,
348            DispatchMechanism::Cli,
349            None,
350            false,
351        )
352        .unwrap();
353        assert_eq!(result.filled, 1);
354        assert_eq!(result.missing, 0);
355
356        let updated: RunRecord =
357            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
358        assert_eq!(
359            serde_json::to_value(&updated.tool_invocations).unwrap(),
360            json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
361        );
362    }
363
364    #[test]
365    fn fills_a_codex_run_record_from_outputs_events() {
366        let root = TempDir::new().unwrap();
367        let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
368        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
369        let outputs_dir = cond_dir.join("outputs");
370        fs::create_dir_all(&outputs_dir).unwrap();
371        let run_path = cond_dir.join("run.json");
372        write_run_record(&run_path, json!([]));
373        fs::write(
374            iteration_dir.join("conditions.json"),
375            json!({
376                "mode": "new-skill",
377                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
378                "timestamp": "2026-06-07T00:00:00.000Z",
379                "harness": "codex"
380            })
381            .to_string(),
382        )
383        .unwrap();
384        write_dispatch(
385            &iteration_dir,
386            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
387        );
388        fs::write(
389            outputs_dir.join("codex-events.jsonl"),
390            jsonl(&[
391                json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
392            ]),
393        )
394        .unwrap();
395
396        let result = fill_transcripts(
397            &iteration_dir,
398            Harness::Codex,
399            DispatchMechanism::Cli,
400            None,
401            false,
402        )
403        .unwrap();
404        assert_eq!(result.filled, 1);
405        assert_eq!(result.missing, 0);
406
407        let updated: RunRecord =
408            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
409        assert_eq!(
410            serde_json::to_value(&updated.tool_invocations).unwrap(),
411            json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
412        );
413    }
414
415    #[test]
416    fn fills_codex_run_records_in_nested_run_dirs() {
417        let root = TempDir::new().unwrap();
418        let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
419        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
420        fs::create_dir_all(&iteration_dir).unwrap();
421        fs::write(
422            iteration_dir.join("conditions.json"),
423            json!({
424                "mode": "new-skill",
425                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
426                "timestamp": "2026-06-07T00:00:00.000Z",
427                "harness": "codex"
428            })
429            .to_string(),
430        )
431        .unwrap();
432        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
433            let run_dir = cond_dir.join(format!("run-{k}"));
434            let outputs_dir = run_dir.join("outputs");
435            fs::create_dir_all(&outputs_dir).unwrap();
436            write_run_record(&run_dir.join("run.json"), json!([]));
437            fs::write(
438                outputs_dir.join("codex-events.jsonl"),
439                jsonl(&[
440                    json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
441                ]),
442            )
443            .unwrap();
444        }
445
446        let result = fill_transcripts(
447            &iteration_dir,
448            Harness::Codex,
449            DispatchMechanism::Cli,
450            None,
451            false,
452        )
453        .unwrap();
454        assert_eq!(result.filled, 2);
455        assert_eq!(result.missing, 0);
456
457        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
458            let updated: RunRecord = serde_json::from_str(
459                &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
460            )
461            .unwrap();
462            assert_eq!(
463                serde_json::to_value(&updated.tool_invocations).unwrap(),
464                json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
465                "wrong invocations for run-{k}"
466            );
467        }
468    }
469}