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