Skip to main content

harness_loop/experience/
transcript.rs

1//! Capture every conversation turn into a [`Memory`] as it happens.
2//!
3//! Backend-agnostic: it writes `MemoryEntry`s, so it lands in *any* `Memory`
4//! (JSONL, SQLite recall, a CortexDB-backed brain, …). Pair with
5//! `harness-cortexdb` and turns flow into CortexDB; schedule
6//! [`CortexdbMemory::consolidate`](../../harness_cortexdb) periodically to
7//! distill them into the knowledge graph.
8//!
9//! The framework's [`Hook`] is synchronous but `Memory::write` is async, so the
10//! hook only *enqueues* onto a channel; a background task owns the `Memory` and
11//! drains it. This keeps the agent loop non-blocking.
12//!
13//! ```ignore
14//! let (recorder, rx) = TranscriptRecorder::new("sess-42");
15//! spawn_transcript_writer(rx, memory.clone());     // background async writer
16//! recorder.note_user(&user_input);                 // the user half-turn
17//! AgentLoop::new(model)
18//!     .with_hook(std::sync::Arc::new(recorder))    // assistant + tool turns
19//!     .run(task, &mut world).await?;
20//! ```
21
22use harness_core::{Event, Hook, HookOutcome, Memory, MemoryEntry, ModelOutput, World};
23use std::sync::Arc;
24use tokio::sync::mpsc;
25
26/// One captured turn, ready to persist. `role` is `user | assistant | tool`.
27#[derive(Debug, Clone)]
28pub struct CapturedTurn {
29    pub session: String,
30    pub role: String,
31    pub content: String,
32}
33
34/// A [`Hook`] that enqueues assistant + tool turns onto a channel. Construct with
35/// [`TranscriptRecorder::new`], which also returns the receiver to hand to
36/// [`spawn_transcript_writer`].
37pub struct TranscriptRecorder {
38    tx: mpsc::UnboundedSender<CapturedTurn>,
39    session: String,
40}
41
42impl TranscriptRecorder {
43    /// Create a recorder for `session`, returning it plus the receiver its
44    /// background writer drains.
45    pub fn new(session: impl Into<String>) -> (Self, mpsc::UnboundedReceiver<CapturedTurn>) {
46        let (tx, rx) = mpsc::unbounded_channel();
47        (
48            Self {
49                tx,
50                session: session.into(),
51            },
52            rx,
53        )
54    }
55
56    /// Record the user half-turn. The hook only sees the model's output and tool
57    /// results, so the app enqueues the user's message explicitly (once, before
58    /// or right after `run`).
59    pub fn note_user(&self, text: impl Into<String>) {
60        self.enqueue("user", text.into());
61    }
62
63    fn enqueue(&self, role: &str, content: String) {
64        if content.trim().is_empty() {
65            return;
66        }
67        let _ = self.tx.send(CapturedTurn {
68            session: self.session.clone(),
69            role: role.into(),
70            content,
71        });
72    }
73}
74
75/// Pull the assistant's text out of a model output — its `text`, or the
76/// reasoning channel when a thinking model left `text` empty.
77fn assistant_text(out: &ModelOutput) -> String {
78    out.text
79        .clone()
80        .filter(|t| !t.trim().is_empty())
81        .or_else(|| out.reasoning.clone())
82        .unwrap_or_default()
83}
84
85impl Hook for TranscriptRecorder {
86    fn name(&self) -> &str {
87        "transcript-recorder"
88    }
89    fn matches(&self, ev: &Event<'_>) -> bool {
90        matches!(ev, Event::PostModel { .. } | Event::PostToolUse { .. })
91    }
92    fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
93        match ev {
94            Event::PostModel { out } => self.enqueue("assistant", assistant_text(out)),
95            Event::PostToolUse { action, result } => {
96                let body = serde_json::to_string(&result.content).unwrap_or_default();
97                self.enqueue("tool", format!("[{}] {}", action.tool, body));
98            }
99            _ => {}
100        }
101        HookOutcome::Allow
102    }
103}
104
105/// Spawn the background writer: drains `rx` and persists each turn to `memory`.
106/// `role` and `session` ride along as tags (`role:…`, `session:…`), so a
107/// metadata-aware backend (e.g. CortexDB) can filter/aggregate on them.
108///
109/// Transcripts are the biggest PII surface — tool results and model output flow
110/// in verbatim. Redact at this boundary by handing in a redacting `memory`:
111/// `spawn_transcript_writer(rx, Arc::new(RedactingMemory::new(cortex)))`
112/// (see `harness_context::RedactingMemory`).
113pub fn spawn_transcript_writer(
114    mut rx: mpsc::UnboundedReceiver<CapturedTurn>,
115    memory: Arc<dyn Memory>,
116) -> tokio::task::JoinHandle<()> {
117    tokio::spawn(async move {
118        while let Some(turn) = rx.recv().await {
119            let entry = MemoryEntry::new(turn.content)
120                .with_source("transcript")
121                .with_tags([
122                    format!("role:{}", turn.role),
123                    format!("session:{}", turn.session),
124                ]);
125            if let Err(e) = memory.write(entry).await {
126                tracing::warn!(error = %e, "transcript write failed");
127            }
128        }
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use harness_core::{Action, MemoryError, ToolResult};
136    use std::sync::Mutex;
137
138    #[derive(Default)]
139    struct CapMem(Mutex<Vec<MemoryEntry>>);
140    #[async_trait::async_trait]
141    impl Memory for CapMem {
142        async fn recall(&self, _q: &str, _k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
143            Ok(vec![])
144        }
145        async fn write(&self, e: MemoryEntry) -> Result<(), MemoryError> {
146            self.0.lock().unwrap().push(e);
147            Ok(())
148        }
149    }
150
151    fn out(text: &str) -> ModelOutput {
152        ModelOutput {
153            text: Some(text.into()),
154            ..Default::default()
155        }
156    }
157
158    #[tokio::test]
159    async fn captures_user_assistant_and_tool_turns_into_memory() {
160        let (recorder, rx) = TranscriptRecorder::new("sess-1");
161        let mem = Arc::new(CapMem::default());
162        let handle = spawn_transcript_writer(rx, mem.clone());
163
164        // user (explicit), assistant (PostModel), tool (PostToolUse)
165        recorder.note_user("hello");
166        let mut world = harness_context::default_world(std::env::temp_dir());
167        let o = out("hi there");
168        recorder.fire(&Event::PostModel { out: &o }, &mut world);
169        let action = Action {
170            tool: "read_file".into(),
171            call_id: "c1".into(),
172            args: serde_json::json!({"path": "x"}),
173        };
174        let result = ToolResult {
175            ok: true,
176            content: serde_json::json!({"content": "data"}),
177            trace: None,
178        };
179        recorder.fire(
180            &Event::PostToolUse {
181                action: &action,
182                result: &result,
183            },
184            &mut world,
185        );
186
187        // Close the channel so the writer task finishes, then join.
188        drop(recorder);
189        handle.await.unwrap();
190
191        let stored = mem.0.lock().unwrap();
192        assert_eq!(stored.len(), 3, "user + assistant + tool");
193        let roles: Vec<&str> = stored
194            .iter()
195            .filter_map(|e| e.tags.iter().find(|t| t.starts_with("role:")))
196            .map(|s| s.as_str())
197            .collect();
198        assert!(roles.contains(&"role:user"));
199        assert!(roles.contains(&"role:assistant"));
200        assert!(roles.contains(&"role:tool"));
201        assert!(
202            stored
203                .iter()
204                .all(|e| e.tags.iter().any(|t| t == "session:sess-1"))
205        );
206    }
207}