harness_loop/experience/
transcript.rs1use harness_core::{Event, Hook, HookOutcome, Memory, MemoryEntry, ModelOutput, World};
23use std::sync::Arc;
24use tokio::sync::mpsc;
25
26#[derive(Debug, Clone)]
28pub struct CapturedTurn {
29 pub session: String,
30 pub role: String,
31 pub content: String,
32}
33
34pub struct TranscriptRecorder {
38 tx: mpsc::UnboundedSender<CapturedTurn>,
39 session: String,
40}
41
42impl TranscriptRecorder {
43 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 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
75fn 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
105pub 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 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 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}