Skip to main content

harness_loop/experience/
transcript.rs

1//! Capture every conversation turn into a [`Memory`] as it happens.
2//!
3//! Turns are capped ([`DEFAULT_MAX_CHARS`]), consecutive verbatim repeats are
4//! dropped, and each entry carries a TTL ([`DEFAULT_TTL_DAYS`]). A transcript is
5//! a searchable copy of something the app already stores authoritatively, so it
6//! is allowed to age out — and a backend that ignores `expires_ms` will keep it
7//! forever, which is how a shared brain fills with `[list_dir]` echoes.
8//!
9//! Backend-agnostic: it writes `MemoryEntry`s, so it lands in *any* `Memory`
10//! (JSONL, SQLite recall, a CortexDB-backed brain, …). Pair with
11//! `harness-cortexdb` and turns flow into CortexDB; schedule
12//! [`CortexdbMemory::consolidate`](../../harness_cortexdb) periodically to
13//! distill them into the knowledge graph.
14//!
15//! The framework's [`Hook`] is synchronous but `Memory::write` is async, so the
16//! hook only *enqueues* onto a channel; a background task owns the `Memory` and
17//! drains it. This keeps the agent loop non-blocking.
18//!
19//! ```ignore
20//! let (recorder, rx) = TranscriptRecorder::new("sess-42");
21//! spawn_transcript_writer(rx, memory.clone());     // background async writer
22//! recorder.note_user(&user_input);                 // the user half-turn
23//! AgentLoop::new(model)
24//!     .with_hook(std::sync::Arc::new(recorder))    // assistant + tool turns
25//!     .run(task, &mut world).await?;
26//! ```
27
28use harness_core::{Event, Hook, HookOutcome, Memory, MemoryEntry, ModelOutput, World};
29use std::collections::hash_map::DefaultHasher;
30use std::hash::{Hash, Hasher};
31use std::sync::{Arc, Mutex};
32use tokio::sync::mpsc;
33
34/// One captured turn, ready to persist. `role` is `user | assistant | tool`.
35#[derive(Debug, Clone)]
36pub struct CapturedTurn {
37    pub session: String,
38    pub role: String,
39    pub content: String,
40}
41
42/// A [`Hook`] that enqueues assistant + tool turns onto a channel. Construct with
43/// [`TranscriptRecorder::new`], which also returns the receiver to hand to
44/// [`spawn_transcript_writer`].
45pub struct TranscriptRecorder {
46    tx: mpsc::UnboundedSender<CapturedTurn>,
47    session: String,
48    /// Longest content kept per turn; longer is truncated with a marker.
49    max_chars: usize,
50    /// Hash of the last enqueued turn, to drop verbatim repeats.
51    last: Mutex<Option<u64>>,
52}
53
54/// Default per-turn cap. A tool result is not a memory: a `read_file` of a
55/// 13 KB file is 13 KB of transcript, and recall never wanted the tail of it.
56pub const DEFAULT_MAX_CHARS: usize = 2_000;
57
58impl TranscriptRecorder {
59    /// Create a recorder for `session`, returning it plus the receiver its
60    /// background writer drains.
61    pub fn new(session: impl Into<String>) -> (Self, mpsc::UnboundedReceiver<CapturedTurn>) {
62        let (tx, rx) = mpsc::unbounded_channel();
63        (
64            Self {
65                tx,
66                session: session.into(),
67                max_chars: DEFAULT_MAX_CHARS,
68                last: Mutex::new(None),
69            },
70            rx,
71        )
72    }
73
74    /// Override the per-turn character cap. `0` disables truncation — only do
75    /// that for a backend you are happy to grow without bound.
76    pub fn with_max_chars(mut self, max_chars: usize) -> Self {
77        self.max_chars = max_chars;
78        self
79    }
80
81    /// Record the user half-turn. The hook only sees the model's output and tool
82    /// results, so the app enqueues the user's message explicitly (once, before
83    /// or right after `run`).
84    pub fn note_user(&self, text: impl Into<String>) {
85        self.enqueue("user", text.into());
86    }
87
88    fn enqueue(&self, role: &str, content: String) {
89        if content.trim().is_empty() {
90            return;
91        }
92        let content = truncate(content, self.max_chars);
93
94        // An agent that lists the same directory on every iteration deposits
95        // the same turn on every iteration. Verbatim repeats carry no new
96        // information and crowd out real memories in recall, so drop a turn
97        // identical to the one before it. Only consecutive repeats: the same
98        // command run again after something else happened is a real event.
99        let mut h = DefaultHasher::new();
100        role.hash(&mut h);
101        content.hash(&mut h);
102        let digest = h.finish();
103        {
104            let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner());
105            if *last == Some(digest) {
106                return;
107            }
108            *last = Some(digest);
109        }
110
111        let _ = self.tx.send(CapturedTurn {
112            session: self.session.clone(),
113            role: role.into(),
114            content,
115        });
116    }
117}
118
119/// Cut `s` to at most `max` characters, marking that it was cut. `max == 0`
120/// means no limit. Respects char boundaries, so it is safe on UTF-8.
121fn truncate(s: String, max: usize) -> String {
122    if max == 0 || s.chars().count() <= max {
123        return s;
124    }
125    let kept: String = s.chars().take(max).collect();
126    format!("{kept}… [{} chars truncated]", s.chars().count() - max)
127}
128
129/// Pull the assistant's text out of a model output — its `text`, or the
130/// reasoning channel when a thinking model left `text` empty.
131fn assistant_text(out: &ModelOutput) -> String {
132    out.text
133        .clone()
134        .filter(|t| !t.trim().is_empty())
135        .or_else(|| out.reasoning.clone())
136        .unwrap_or_default()
137}
138
139impl Hook for TranscriptRecorder {
140    fn name(&self) -> &str {
141        "transcript-recorder"
142    }
143    fn matches(&self, ev: &Event<'_>) -> bool {
144        matches!(ev, Event::PostModel { .. } | Event::PostToolUse { .. })
145    }
146    fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
147        match ev {
148            Event::PostModel { out } => self.enqueue("assistant", assistant_text(out)),
149            Event::PostToolUse { action, result } => {
150                let body = serde_json::to_string(&result.content).unwrap_or_default();
151                self.enqueue("tool", format!("[{}] {}", action.tool, body));
152            }
153            _ => {}
154        }
155        HookOutcome::Allow
156    }
157}
158
159/// Spawn the background writer: drains `rx` and persists each turn to `memory`.
160/// `role` and `session` ride along as tags (`role:…`, `session:…`), so a
161/// metadata-aware backend (e.g. CortexDB) can filter/aggregate on them.
162///
163/// Transcripts are the biggest PII surface — tool results and model output flow
164/// in verbatim. Redact at this boundary by handing in a redacting `memory`:
165/// `spawn_transcript_writer(rx, Arc::new(RedactingMemory::new(cortex)))`
166/// (see `harness_context::RedactingMemory`).
167pub fn spawn_transcript_writer(
168    rx: mpsc::UnboundedReceiver<CapturedTurn>,
169    memory: Arc<dyn Memory>,
170) -> tokio::task::JoinHandle<()> {
171    spawn_transcript_writer_with_ttl(rx, memory, Some(DEFAULT_TTL_DAYS))
172}
173
174/// Default retention for a captured turn. A transcript is a *searchable copy* —
175/// the authoritative one lives in the app's own store — so it should age out.
176/// Without this every turn of every session accumulated forever: one shared
177/// brain reached 70% raw tool-call echoes, six of which had ever been recalled.
178pub const DEFAULT_TTL_DAYS: u32 = 30;
179
180/// As [`spawn_transcript_writer`], with an explicit retention. `None` keeps
181/// turns forever — choose it only when something else prunes the backend.
182pub fn spawn_transcript_writer_with_ttl(
183    mut rx: mpsc::UnboundedReceiver<CapturedTurn>,
184    memory: Arc<dyn Memory>,
185    ttl_days: Option<u32>,
186) -> tokio::task::JoinHandle<()> {
187    tokio::spawn(async move {
188        while let Some(turn) = rx.recv().await {
189            let mut entry = MemoryEntry::new(turn.content)
190                .with_source("transcript")
191                .with_tags([
192                    format!("role:{}", turn.role),
193                    format!("session:{}", turn.session),
194                ]);
195            if let Some(days) = ttl_days {
196                entry = entry.with_ttl_days(days);
197            }
198            if let Err(e) = memory.write(entry).await {
199                tracing::warn!(error = %e, "transcript write failed");
200            }
201        }
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use harness_core::{Action, MemoryError, ToolResult};
209    use std::sync::Mutex;
210
211    #[test]
212    fn a_long_tool_result_is_truncated_with_a_marker() {
213        let (rec, mut rx) = TranscriptRecorder::new("s");
214        rec.note_user("x".repeat(DEFAULT_MAX_CHARS + 500));
215        let turn = rx.try_recv().expect("one turn");
216        assert!(turn.content.chars().count() < DEFAULT_MAX_CHARS + 100);
217        assert!(
218            turn.content.contains("500 chars truncated"),
219            "{}",
220            turn.content
221        );
222    }
223
224    #[test]
225    fn truncation_respects_char_boundaries() {
226        // Byte slicing would panic here; a Chinese transcript is the common case.
227        let (rec, mut rx) = TranscriptRecorder::new("s");
228        let rec = rec.with_max_chars(3);
229        rec.note_user("你好世界啊");
230        let turn = rx.try_recv().expect("one turn");
231        assert!(turn.content.starts_with("你好世"));
232        assert!(turn.content.contains("2 chars truncated"));
233    }
234
235    #[test]
236    fn a_verbatim_repeat_of_the_previous_turn_is_dropped() {
237        // The shared brain grew 49 identical `[list_dir]` memories from one
238        // agent that re-listed the same directory every iteration.
239        let (rec, mut rx) = TranscriptRecorder::new("s");
240        rec.note_user("[list_dir] {\"path\":\".\"}");
241        rec.note_user("[list_dir] {\"path\":\".\"}");
242        rec.note_user("[list_dir] {\"path\":\".\"}");
243        assert!(rx.try_recv().is_ok());
244        assert!(rx.try_recv().is_err(), "repeats must not be enqueued");
245    }
246
247    #[test]
248    fn a_repeat_after_something_else_is_kept() {
249        // Only *consecutive* repeats are noise. The same command run again
250        // after other work is a real event in the transcript.
251        let (rec, mut rx) = TranscriptRecorder::new("s");
252        rec.note_user("ls");
253        rec.note_user("cat x");
254        rec.note_user("ls");
255        assert_eq!(rx.try_recv().unwrap().content, "ls");
256        assert_eq!(rx.try_recv().unwrap().content, "cat x");
257        assert_eq!(rx.try_recv().unwrap().content, "ls");
258    }
259
260    #[test]
261    fn max_chars_zero_disables_truncation() {
262        let (rec, mut rx) = TranscriptRecorder::new("s");
263        let rec = rec.with_max_chars(0);
264        let long = "y".repeat(50_000);
265        rec.note_user(long.clone());
266        assert_eq!(rx.try_recv().unwrap().content, long);
267    }
268
269    #[tokio::test]
270    async fn written_turns_carry_a_ttl_by_default() {
271        let (tx, rx) = mpsc::unbounded_channel();
272        let mem = Arc::new(CapMem::default());
273        let h = spawn_transcript_writer(rx, mem.clone());
274        tx.send(CapturedTurn {
275            session: "s".into(),
276            role: "tool".into(),
277            content: "[ls] {}".into(),
278        })
279        .unwrap();
280        drop(tx);
281        h.await.unwrap();
282        let got = mem.0.lock().unwrap();
283        assert_eq!(got.len(), 1);
284        assert!(
285            got[0].expires_ms.is_some(),
286            "a transcript turn must expire on its own"
287        );
288    }
289
290    #[tokio::test]
291    async fn an_explicit_none_ttl_keeps_turns_forever() {
292        let (tx, rx) = mpsc::unbounded_channel();
293        let mem = Arc::new(CapMem::default());
294        let h = spawn_transcript_writer_with_ttl(rx, mem.clone(), None);
295        tx.send(CapturedTurn {
296            session: "s".into(),
297            role: "tool".into(),
298            content: "[ls] {}".into(),
299        })
300        .unwrap();
301        drop(tx);
302        h.await.unwrap();
303        assert!(mem.0.lock().unwrap()[0].expires_ms.is_none());
304    }
305
306    #[derive(Default)]
307    struct CapMem(Mutex<Vec<MemoryEntry>>);
308    #[async_trait::async_trait]
309    impl Memory for CapMem {
310        async fn recall(&self, _q: &str, _k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
311            Ok(vec![])
312        }
313        async fn write(&self, e: MemoryEntry) -> Result<(), MemoryError> {
314            self.0.lock().unwrap().push(e);
315            Ok(())
316        }
317    }
318
319    fn out(text: &str) -> ModelOutput {
320        ModelOutput {
321            text: Some(text.into()),
322            ..Default::default()
323        }
324    }
325
326    #[tokio::test]
327    async fn captures_user_assistant_and_tool_turns_into_memory() {
328        let (recorder, rx) = TranscriptRecorder::new("sess-1");
329        let mem = Arc::new(CapMem::default());
330        let handle = spawn_transcript_writer(rx, mem.clone());
331
332        // user (explicit), assistant (PostModel), tool (PostToolUse)
333        recorder.note_user("hello");
334        let mut world = harness_context::default_world(std::env::temp_dir());
335        let o = out("hi there");
336        recorder.fire(&Event::PostModel { out: &o }, &mut world);
337        let action = Action {
338            tool: "read_file".into(),
339            call_id: "c1".into(),
340            args: serde_json::json!({"path": "x"}),
341        };
342        let result = ToolResult {
343            ok: true,
344            content: serde_json::json!({"content": "data"}),
345            trace: None,
346        };
347        recorder.fire(
348            &Event::PostToolUse {
349                action: &action,
350                result: &result,
351            },
352            &mut world,
353        );
354
355        // Close the channel so the writer task finishes, then join.
356        drop(recorder);
357        handle.await.unwrap();
358
359        let stored = mem.0.lock().unwrap();
360        assert_eq!(stored.len(), 3, "user + assistant + tool");
361        let roles: Vec<&str> = stored
362            .iter()
363            .filter_map(|e| e.tags.iter().find(|t| t.starts_with("role:")))
364            .map(|s| s.as_str())
365            .collect();
366        assert!(roles.contains(&"role:user"));
367        assert!(roles.contains(&"role:assistant"));
368        assert!(roles.contains(&"role:tool"));
369        assert!(
370            stored
371                .iter()
372                .all(|e| e.tags.iter().any(|t| t == "session:sess-1"))
373        );
374    }
375}
376
377#[cfg(test)]
378mod export_surface {
379    /// The two call sites in superleo reach these three through
380    /// `harness_loop::experience::…`; a re-export that stops being public is a
381    /// downstream break, not a local one, so pin the path here.
382    #[test]
383    fn superleo_facing_exports_resolve() {
384        let _: u32 = crate::experience::DEFAULT_TTL_DAYS;
385        let _ = crate::experience::spawn_transcript_writer_with_ttl;
386        let (_rec, _rx) = crate::experience::TranscriptRecorder::new("s");
387        let _: usize = crate::experience::DEFAULT_MAX_CHARS;
388    }
389}