Skip to main content

harness_loop/
replay.rs

1//! Session record + replay (DESIGN.md §15 v0.2+).
2//!
3//! Two halves:
4//! - [`SessionRecorder`] is a [`Hook`] that captures every lifecycle event
5//!   to a JSONL file. Wire it via `AgentLoop::with_hook` and you get a
6//!   complete trace of what the agent did.
7//! - [`read_session`] + [`replay_as_mock`] reconstruct a deterministic
8//!   `MockModel` from a recorded log so you can replay the run offline,
9//!   verify changes, or debug failures without rerunning against a real LLM.
10
11use harness_core::{
12    Action, CompactionStage, Event, Hook, HookOutcome, ModelOutput, ToolResult, World,
13};
14use serde::{Deserialize, Serialize};
15use std::fs::OpenOptions;
16use std::io::Write;
17use std::path::Path;
18use std::sync::Mutex;
19
20/// One event in the recorded session. Owned (no borrows) so it round-trips
21/// through serde without lifetime gymnastics.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum SessionEvent {
26    Start {
27        ts_ms: i64,
28        source: String,
29    },
30    PreModel {
31        ts_ms: i64,
32        history_len: usize,
33        tools_count: usize,
34    },
35    PostModel {
36        ts_ms: i64,
37        output: ModelOutput,
38    },
39    PreTool {
40        ts_ms: i64,
41        action: Action,
42    },
43    PostTool {
44        ts_ms: i64,
45        call_id: String,
46        result: ToolResult,
47    },
48    /// When the call really ran ([`Event::ToolTimed`]). For parallel calls the
49    /// `PreTool` / `PostTool` stamps are both taken after the work is done;
50    /// this is the interval to draw. Absent from logs recorded before 0.0.66.
51    ToolTimed {
52        ts_ms: i64,
53        call_id: String,
54        started_ms: i64,
55        finished_ms: i64,
56    },
57    Sensor {
58        ts_ms: i64,
59        id: String,
60        signals: usize,
61    },
62    PreCompact {
63        ts_ms: i64,
64        stage: CompactionStage,
65    },
66    PostCompact {
67        ts_ms: i64,
68        stage: CompactionStage,
69        /// Estimated context tokens before and after the stage ran. `serde`
70        /// defaults keep logs recorded before these existed readable.
71        #[serde(default)]
72        before: u32,
73        #[serde(default)]
74        after: u32,
75    },
76    Heartbeat {
77        ts_ms: i64,
78        iter: u32,
79    },
80    /// Budget ratio crossed a high-water threshold. Currently the loop only
81    /// fires this once, at the moment the iteration budget is exhausted and
82    /// the forced final-synthesis pass is about to run.
83    BudgetWarning {
84        ts_ms: i64,
85        ratio: f32,
86    },
87    End {
88        ts_ms: i64,
89    },
90}
91
92/// Hook that serialises every relevant lifecycle event into a JSONL file.
93///
94/// Failures (locked mutex, I/O errors) are logged via `tracing::warn` but
95/// never panic — recording is a best-effort observability layer, not a
96/// correctness path.
97pub struct SessionRecorder {
98    file: Mutex<std::fs::File>,
99}
100
101impl SessionRecorder {
102    /// Open the file for append (creating it if needed).
103    pub fn new(path: &Path) -> std::io::Result<Self> {
104        if let Some(parent) = path.parent() {
105            std::fs::create_dir_all(parent)?;
106        }
107        let f = OpenOptions::new().create(true).append(true).open(path)?;
108        Ok(Self {
109            file: Mutex::new(f),
110        })
111    }
112
113    fn write(&self, ev: &SessionEvent) {
114        let Ok(mut f) = self.file.lock() else {
115            return;
116        };
117        match serde_json::to_string(ev) {
118            Ok(s) => {
119                if let Err(e) = writeln!(f, "{s}") {
120                    tracing::warn!(error=%e, "session recorder write failed");
121                }
122            }
123            Err(e) => tracing::warn!(error=%e, "session recorder serialize failed"),
124        }
125    }
126}
127
128impl Hook for SessionRecorder {
129    fn name(&self) -> &str {
130        "session-recorder"
131    }
132    fn matches(&self, _ev: &Event<'_>) -> bool {
133        true
134    }
135
136    fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
137        let ts = world.clock.now_ms();
138        let session_ev = match ev {
139            Event::SessionStart { source } => Some(SessionEvent::Start {
140                ts_ms: ts,
141                source: format!("{source:?}"),
142            }),
143            Event::PreModel { ctx } => Some(SessionEvent::PreModel {
144                ts_ms: ts,
145                history_len: ctx.history.len(),
146                tools_count: ctx.tools.len(),
147            }),
148            Event::PostModel { out } => Some(SessionEvent::PostModel {
149                ts_ms: ts,
150                output: (*out).clone(),
151            }),
152            Event::PreToolUse { action } => Some(SessionEvent::PreTool {
153                ts_ms: ts,
154                action: (*action).clone(),
155            }),
156            Event::PostToolUse { action, result } => Some(SessionEvent::PostTool {
157                ts_ms: ts,
158                call_id: action.call_id.clone(),
159                result: (*result).clone(),
160            }),
161            Event::ToolTimed {
162                action,
163                started_ms,
164                finished_ms,
165            } => Some(SessionEvent::ToolTimed {
166                ts_ms: ts,
167                call_id: action.call_id.clone(),
168                started_ms: *started_ms,
169                finished_ms: *finished_ms,
170            }),
171            Event::PostSensor { sensor, signals } => Some(SessionEvent::Sensor {
172                ts_ms: ts,
173                id: (*sensor).clone(),
174                signals: signals.len(),
175            }),
176            Event::PreCompact { stage } => Some(SessionEvent::PreCompact {
177                ts_ms: ts,
178                stage: *stage,
179            }),
180            Event::PostCompact {
181                stage,
182                before,
183                after,
184            } => Some(SessionEvent::PostCompact {
185                ts_ms: ts,
186                stage: *stage,
187                before: *before,
188                after: *after,
189            }),
190            Event::Heartbeat { iter } => Some(SessionEvent::Heartbeat {
191                ts_ms: ts,
192                iter: *iter,
193            }),
194            Event::BudgetWarning { ratio } => Some(SessionEvent::BudgetWarning {
195                ts_ms: ts,
196                ratio: *ratio,
197            }),
198            Event::SessionEnd => Some(SessionEvent::End { ts_ms: ts }),
199            _ => None,
200        };
201        if let Some(e) = session_ev {
202            self.write(&e);
203        }
204        HookOutcome::Allow
205    }
206}
207
208/// Read a recorded JSONL session log back into memory.
209///
210/// Tolerates malformed lines (logged, skipped) so a partially-corrupted log
211/// still yields usable replay material.
212pub fn read_session(path: &Path) -> std::io::Result<Vec<SessionEvent>> {
213    let content = std::fs::read_to_string(path)?;
214    let mut events = Vec::new();
215    for (i, line) in content.lines().enumerate() {
216        let line = line.trim();
217        if line.is_empty() {
218            continue;
219        }
220        match serde_json::from_str(line) {
221            Ok(e) => events.push(e),
222            Err(err) => tracing::warn!(line=i+1, error=%err, "session log line skipped"),
223        }
224    }
225    Ok(events)
226}
227
228/// Build a [`harness_models::MockModel`] that returns each recorded
229/// `PostModel` output in order. Pair with a fresh `AgentLoop` to replay the
230/// run.
231pub fn replay_as_mock(events: &[SessionEvent]) -> harness_models::MockModel {
232    use harness_models::{MockModel, MockResponse};
233    let mut m = MockModel::new().with_name("replay");
234    for e in events {
235        if let SessionEvent::PostModel { output, .. } = e {
236            m = m.script(MockResponse {
237                text: output.text.clone(),
238                tool_calls: output.tool_calls.clone(),
239                stop_reason: output.stop_reason,
240                input_tokens: output.usage.input_tokens,
241                output_tokens: output.usage.output_tokens,
242                reasoning: output.reasoning.clone(),
243                // Carried so a replay of an image-producing run reproduces
244                // the images too — otherwise "deterministic replay" would
245                // quietly mean "replay of the text only".
246                images: output.images.clone(),
247            });
248        }
249    }
250    m
251}
252
253/// Backwards-compatible alias.
254pub fn replay_as_mock_via_events(events: &[SessionEvent]) -> harness_models::MockModel {
255    replay_as_mock(events)
256}
257
258/// Stats from a single session — handy summary for the `harness trace` CLI.
259#[derive(Debug, Clone, Default)]
260pub struct SessionStats {
261    pub events: usize,
262    pub model_calls: usize,
263    pub tool_calls: usize,
264    pub iters: u32,
265    pub input_tokens: u32,
266    pub output_tokens: u32,
267    pub stages_run: usize,
268    pub duration_ms: i64,
269}
270
271impl SessionStats {
272    pub fn from(events: &[SessionEvent]) -> Self {
273        let mut s = Self {
274            events: events.len(),
275            ..Default::default()
276        };
277        let mut first_ts: Option<i64> = None;
278        let mut last_ts: Option<i64> = None;
279        for e in events {
280            let ts = match e {
281                SessionEvent::Start { ts_ms, .. }
282                | SessionEvent::PreModel { ts_ms, .. }
283                | SessionEvent::PostModel { ts_ms, .. }
284                | SessionEvent::PreTool { ts_ms, .. }
285                | SessionEvent::PostTool { ts_ms, .. }
286                | SessionEvent::ToolTimed { ts_ms, .. }
287                | SessionEvent::Sensor { ts_ms, .. }
288                | SessionEvent::PreCompact { ts_ms, .. }
289                | SessionEvent::PostCompact { ts_ms, .. }
290                | SessionEvent::Heartbeat { ts_ms, .. }
291                | SessionEvent::BudgetWarning { ts_ms, .. }
292                | SessionEvent::End { ts_ms } => *ts_ms,
293            };
294            if first_ts.is_none() {
295                first_ts = Some(ts);
296            }
297            last_ts = Some(ts);
298
299            match e {
300                SessionEvent::PostModel { output, .. } => {
301                    s.model_calls += 1;
302                    s.input_tokens += output.usage.input_tokens;
303                    s.output_tokens += output.usage.output_tokens;
304                }
305                SessionEvent::PreTool { .. } => s.tool_calls += 1,
306                SessionEvent::PostCompact { .. } => s.stages_run += 1,
307                SessionEvent::Heartbeat { iter, .. } => s.iters = s.iters.max(*iter + 1),
308                _ => {}
309            }
310        }
311        s.duration_ms = match (first_ts, last_ts) {
312            (Some(a), Some(b)) => b - a,
313            _ => 0,
314        };
315        s
316    }
317}
318
319/// Multi-line, content-rich version of [`format_event_short`].
320///
321/// Surfaces what `format_event_short` hides: model text, full tool args,
322/// tool result preview, and failure reasons. Used by `harness trace --verbose`
323/// and by [`LiveProgressHook`] so operators can actually see what their agent
324/// is doing instead of guessing from `ok=false`.
325pub fn format_event_verbose(e: &SessionEvent) -> String {
326    match e {
327        SessionEvent::Start { source, .. } => format!("session start ({source})"),
328        SessionEvent::Heartbeat { iter, .. } => format!("iter {iter}"),
329        SessionEvent::PreModel {
330            history_len,
331            tools_count,
332            ..
333        } => format!("→ model (history={history_len}, tools={tools_count})"),
334        SessionEvent::PostModel { output, .. } => {
335            let mut out = format!(
336                "← model: {} tool_call(s) [{}/{} tok, stop={:?}]",
337                output.tool_calls.len(),
338                output.usage.input_tokens,
339                output.usage.output_tokens,
340                output.stop_reason,
341            );
342            if let Some(text) = output.text.as_deref().filter(|s| !s.is_empty()) {
343                out.push_str("\n  text: ");
344                out.push_str(&truncate(text, 400));
345            }
346            if let Some(reasoning) = output.reasoning.as_deref().filter(|s| !s.is_empty()) {
347                out.push_str("\n  reasoning: ");
348                out.push_str(&truncate(reasoning, 200));
349            }
350            out
351        }
352        SessionEvent::PreTool { action, .. } => {
353            let args = action.args.to_string();
354            format!("  → tool {} args={}", action.tool, truncate(&args, 240))
355        }
356        SessionEvent::PostTool {
357            call_id, result, ..
358        } => {
359            let preview = preview_tool_result(result);
360            format!(
361                "  ← tool {} ok={} {}",
362                call_id,
363                result.ok,
364                if preview.is_empty() {
365                    String::new()
366                } else {
367                    format!("\n      {preview}")
368                }
369            )
370        }
371        SessionEvent::ToolTimed {
372            call_id,
373            started_ms,
374            finished_ms,
375            ..
376        } => format!("  ⏱ tool {call_id} ran {}ms", finished_ms - started_ms),
377        SessionEvent::Sensor { id, signals, .. } => {
378            format!("  ⚑ sensor {id}: {signals} signal(s)")
379        }
380        SessionEvent::PreCompact { stage, .. } => format!("  ⇩ pre-compact {stage:?}"),
381        SessionEvent::PostCompact { stage, .. } => format!("  ⇧ post-compact {stage:?}"),
382        SessionEvent::BudgetWarning { ratio, .. } => {
383            if *ratio >= 1.0 {
384                "≫ budget exhausted — forcing tool-less final-synthesis pass".into()
385            } else {
386                format!("≫ budget warning (used {:.0}%)", ratio * 100.0)
387            }
388        }
389        SessionEvent::End { .. } => "session end".into(),
390    }
391}
392
393/// Pull the most actionable text out of a [`ToolResult`] for human display.
394///
395/// For failures, prefer `errors`/`hint`/`message` keys if the tool returned a
396/// structured JSON payload (the multi-engine search tool in `investor-bot`
397/// follows this convention). Falls back to a truncated JSON dump.
398fn preview_tool_result(r: &ToolResult) -> String {
399    let v = &r.content;
400    if !r.ok {
401        // Try the common error-shape conventions first.
402        if let Some(errors) = v.get("errors").and_then(|x| x.as_array()) {
403            let joined: Vec<String> = errors
404                .iter()
405                .filter_map(|e| e.as_str().map(String::from))
406                .collect();
407            if !joined.is_empty() {
408                let hint = v
409                    .get("hint")
410                    .and_then(|x| x.as_str())
411                    .map(|h| format!(" | hint: {h}"))
412                    .unwrap_or_default();
413                return format!("errors=[{}]{hint}", truncate(&joined.join("; "), 240));
414            }
415        }
416        if let Some(msg) = v.get("message").and_then(|x| x.as_str()) {
417            return format!("message={}", truncate(msg, 240));
418        }
419        if let Some(err) = v.get("error").and_then(|x| x.as_str()) {
420            return format!("error={}", truncate(err, 240));
421        }
422    }
423    // Generic preview: serialize, trim, truncate.
424    let s = v.to_string();
425    if s == "null" || s == "{}" {
426        String::new()
427    } else {
428        format!("preview={}", truncate(&s, 240))
429    }
430}
431
432fn truncate(s: &str, max: usize) -> String {
433    // Char-wise truncation so we don't bisect a multibyte sequence.
434    let chars: Vec<char> = s.chars().collect();
435    if chars.len() <= max {
436        s.replace('\n', " ⏎ ")
437    } else {
438        let head: String = chars[..max].iter().collect();
439        format!(
440            "{}… ({} chars total)",
441            head.replace('\n', " ⏎ "),
442            chars.len()
443        )
444    }
445}
446
447/// Tiny helper used by the CLI: convert a single event to a single line of
448/// pretty-printed text (does NOT include the timestamp prefix).
449pub fn format_event_short(e: &SessionEvent) -> String {
450    match e {
451        SessionEvent::Start { source, .. } => format!("session start ({source})"),
452        SessionEvent::Heartbeat { iter, .. } => format!("iter {iter}"),
453        SessionEvent::PreModel {
454            history_len,
455            tools_count,
456            ..
457        } => {
458            format!("→ model (history={history_len}, tools={tools_count})")
459        }
460        SessionEvent::PostModel { output, .. } => {
461            let calls = output.tool_calls.len();
462            let txt = output
463                .text
464                .as_deref()
465                .unwrap_or("")
466                .chars()
467                .take(60)
468                .collect::<String>();
469            if calls > 0 {
470                format!(
471                    "← model: {} tool_call(s) [{}/{} tok]",
472                    calls, output.usage.input_tokens, output.usage.output_tokens
473                )
474            } else {
475                format!(
476                    "← model: {:?} [{}/{} tok]",
477                    txt, output.usage.input_tokens, output.usage.output_tokens
478                )
479            }
480        }
481        SessionEvent::PreTool { action, .. } => {
482            format!("  → tool {} args={}", action.tool, action.args)
483        }
484        SessionEvent::PostTool {
485            call_id, result, ..
486        } => {
487            format!("  ← tool {} ok={}", call_id, result.ok)
488        }
489        SessionEvent::ToolTimed {
490            call_id,
491            started_ms,
492            finished_ms,
493            ..
494        } => format!("  ⏱ tool {call_id} ran {}ms", finished_ms - started_ms),
495        SessionEvent::Sensor { id, signals, .. } => format!("  ⚑ sensor {id}: {signals} signal(s)"),
496        SessionEvent::PreCompact { stage, .. } => format!("  ⇩ pre-compact {stage:?}"),
497        SessionEvent::PostCompact { stage, .. } => format!("  ⇧ post-compact {stage:?}"),
498        SessionEvent::BudgetWarning { ratio, .. } => {
499            format!("≫ budget warning (used {:.0}%)", ratio * 100.0)
500        }
501        SessionEvent::End { .. } => "session end".into(),
502    }
503}
504
505/// `Hook` that prints a verbose progress trace to stderr in real time.
506///
507/// Pair with `AgentLoop::with_hook(Arc::new(LiveProgressHook::default()))` to
508/// see model calls, tool calls, and tool results as they happen — instead of
509/// staring at a silent terminal for 60 seconds and then post-mortem'ing a JSONL
510/// file. Independent of `SessionRecorder`; both can be installed together.
511///
512/// Output is structured to be greppable: `[iter=N]` prefix on every line, and
513/// each line is one event. Writes go to stderr, so stdout stays clean for
514/// the final answer.
515#[derive(Default)]
516pub struct LiveProgressHook {
517    iter: std::sync::atomic::AtomicU32,
518}
519
520impl LiveProgressHook {
521    pub fn new() -> Self {
522        Self::default()
523    }
524}
525
526impl Hook for LiveProgressHook {
527    fn name(&self) -> &str {
528        "live-progress"
529    }
530    fn matches(&self, _ev: &Event<'_>) -> bool {
531        true
532    }
533    fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
534        let ts = world.clock.now_ms();
535        let iter = self.iter.load(std::sync::atomic::Ordering::Relaxed);
536        if let Event::Custom {
537            name: "model_progress",
538            data,
539        } = ev
540        {
541            eprintln!(
542                "[iter={iter}] … generating {:.0}s, {} chars so far ({:.0} chars/s)",
543                data["elapsed_s"].as_f64().unwrap_or(0.0),
544                data["chars"].as_u64().unwrap_or(0),
545                data["chars_per_s"].as_f64().unwrap_or(0.0),
546            );
547            return HookOutcome::Allow;
548        }
549        // Reuse the recorder's projection + the verbose formatter so the
550        // live output is the same format you'd see post-mortem.
551        let session_ev = match ev {
552            Event::SessionStart { source } => Some(SessionEvent::Start {
553                ts_ms: ts,
554                source: format!("{source:?}"),
555            }),
556            Event::PreModel { ctx } => Some(SessionEvent::PreModel {
557                ts_ms: ts,
558                history_len: ctx.history.len(),
559                tools_count: ctx.tools.len(),
560            }),
561            Event::PostModel { out } => Some(SessionEvent::PostModel {
562                ts_ms: ts,
563                output: (*out).clone(),
564            }),
565            Event::PreToolUse { action } => Some(SessionEvent::PreTool {
566                ts_ms: ts,
567                action: (*action).clone(),
568            }),
569            Event::PostToolUse { action, result } => Some(SessionEvent::PostTool {
570                ts_ms: ts,
571                call_id: action.call_id.clone(),
572                result: (*result).clone(),
573            }),
574            Event::Heartbeat { iter: i } => {
575                self.iter.store(*i, std::sync::atomic::Ordering::Relaxed);
576                Some(SessionEvent::Heartbeat {
577                    ts_ms: ts,
578                    iter: *i,
579                })
580            }
581            Event::PreCompact { stage } => Some(SessionEvent::PreCompact {
582                ts_ms: ts,
583                stage: *stage,
584            }),
585            Event::PostCompact {
586                stage,
587                before,
588                after,
589            } => Some(SessionEvent::PostCompact {
590                ts_ms: ts,
591                stage: *stage,
592                before: *before,
593                after: *after,
594            }),
595            Event::BudgetWarning { ratio } => Some(SessionEvent::BudgetWarning {
596                ts_ms: ts,
597                ratio: *ratio,
598            }),
599            Event::SessionEnd => Some(SessionEvent::End { ts_ms: ts }),
600            _ => None,
601        };
602        if let Some(e) = session_ev {
603            for line in format_event_verbose(&e).lines() {
604                eprintln!("[iter={iter}] {line}");
605            }
606        }
607        HookOutcome::Allow
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn sample_log() -> Vec<SessionEvent> {
616        vec![
617            SessionEvent::Start {
618                ts_ms: 0,
619                source: "Startup".into(),
620            },
621            SessionEvent::Heartbeat { ts_ms: 1, iter: 0 },
622            SessionEvent::PreModel {
623                ts_ms: 2,
624                history_len: 1,
625                tools_count: 3,
626            },
627            SessionEvent::PostModel {
628                ts_ms: 100,
629                output: ModelOutput {
630                    text: Some("hi".into()),
631                    ..Default::default()
632                },
633            },
634            SessionEvent::End { ts_ms: 110 },
635        ]
636    }
637
638    #[test]
639    fn stats_compute_correctly() {
640        let s = SessionStats::from(&sample_log());
641        assert_eq!(s.events, 5);
642        assert_eq!(s.model_calls, 1);
643        assert_eq!(s.iters, 1);
644        assert_eq!(s.duration_ms, 110);
645    }
646
647    #[test]
648    fn round_trip_via_serde() {
649        let original = sample_log();
650        let json: Vec<String> = original
651            .iter()
652            .map(|e| serde_json::to_string(e).unwrap())
653            .collect();
654        let parsed: Vec<SessionEvent> = json
655            .iter()
656            .map(|s| serde_json::from_str::<SessionEvent>(s).unwrap())
657            .collect();
658        assert_eq!(parsed.len(), original.len());
659        assert!(
660            matches!(parsed[3], SessionEvent::PostModel { ref output, .. } if output.text.as_deref() == Some("hi"))
661        );
662    }
663}