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