1use std::collections::HashMap;
20use std::sync::Mutex;
21
22use crate::backend::storage::filestore::FileStore;
23
24use super::archive::collapse_preview;
25use super::session_io::OUTPUT_FILE;
26use super::zone_naming::{agent_current_zone, is_valid_definition_id};
27
28const CHUNK: i64 = 1024 * 1024;
30const MAX_SCAN: i64 = 4 * CHUNK;
34const MAX_LINE: usize = 2 * 1024 * 1024;
36const MEMO_CAP: usize = 512;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum HistoryRead {
42 NoTranscript,
44 Transcript(AgentHistory),
46 Failed,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct AgentHistory {
52 pub last_user_message: String,
55 pub last_activity_ms: i64,
57}
58
59fn history_store() -> Option<std::sync::Arc<FileStore>> {
62 #[cfg(test)]
63 {
64 if let Some(s) = TEST_STORE.with(|t| t.borrow().clone()) {
65 return Some(s);
66 }
67 }
68 super::global_store::global_transcript_store().cloned()
69}
70
71#[cfg(test)]
72thread_local! {
73 static TEST_STORE: std::cell::RefCell<Option<std::sync::Arc<FileStore>>> =
74 const { std::cell::RefCell::new(None) };
75}
76
77#[cfg(test)]
79pub(crate) fn set_test_store(store: Option<std::sync::Arc<FileStore>>) {
80 TEST_STORE.with(|t| *t.borrow_mut() = store);
81}
82
83pub async fn read_histories(definition_ids: Vec<String>) -> HashMap<String, HistoryRead> {
86 let Some(store) = history_store() else {
87 return HashMap::new();
88 };
89 let mut ids = definition_ids;
90 ids.sort();
91 ids.dedup();
92 tokio::task::spawn_blocking(move || {
93 ids.into_iter()
94 .map(|id| {
95 let r = read_agent_history(&store, &id);
96 (id, r)
97 })
98 .collect()
99 })
100 .await
101 .unwrap_or_default()
102}
103
104type MemoKey = (usize, String);
105type MemoVal = (i64, i64, AgentHistory);
106
107fn memo() -> &'static Mutex<HashMap<MemoKey, MemoVal>> {
108 static MEMO: std::sync::OnceLock<Mutex<HashMap<MemoKey, MemoVal>>> = std::sync::OnceLock::new();
109 MEMO.get_or_init(|| Mutex::new(HashMap::new()))
110}
111
112pub fn read_agent_history(store: &FileStore, definition_id: &str) -> HistoryRead {
115 if !is_valid_definition_id(definition_id) {
116 return HistoryRead::NoTranscript;
117 }
118 let zone = match newest_zone_with_output(store, definition_id) {
119 Ok(Some(z)) => z,
120 Ok(None) => return HistoryRead::NoTranscript,
121 Err(e) => {
122 tracing::warn!(error = %e, definition_id, "agent history: transcript lookup failed");
123 return HistoryRead::Failed;
124 }
125 };
126 let (zone, size, modts) = zone;
127 let key = (store as *const FileStore as usize, zone.clone());
128 if let Some((s, m, h)) = memo().lock().unwrap().get(&key) {
129 if *s == size && *m == modts {
130 return HistoryRead::Transcript(h.clone());
131 }
132 }
133 let last_user_message = match scan_tail_for_prompt(store, &zone, size) {
134 Ok(m) => m,
135 Err(e) => {
136 tracing::warn!(error = %e, zone = %zone, "agent history: transcript tail read failed");
137 return HistoryRead::Failed;
138 }
139 };
140 let history = AgentHistory { last_user_message, last_activity_ms: modts };
141 let mut memo = memo().lock().unwrap();
142 if memo.len() >= MEMO_CAP {
143 memo.clear();
144 }
145 memo.insert(key, (size, modts, history.clone()));
146 HistoryRead::Transcript(history)
147}
148
149fn newest_zone_with_output(
152 store: &FileStore,
153 definition_id: &str,
154) -> Result<Option<(String, i64, i64)>, String> {
155 let current = agent_current_zone(definition_id);
156 if let Some(f) = store.stat(¤t, OUTPUT_FILE).map_err(|e| e.to_string())? {
157 if f.size > 0 {
158 return Ok(Some((current, f.size, f.modts)));
159 }
160 }
161 let prefix = format!("agent:{definition_id}:archive:");
162 let mut archives: Vec<(u64, String)> = store
163 .zone_ids_with_prefix(&prefix)
164 .map_err(|e| e.to_string())?
165 .into_iter()
166 .filter_map(|z| z.strip_prefix(&prefix).and_then(|t| t.parse::<u64>().ok()).map(|ts| (ts, z)))
167 .collect();
168 archives.sort_by(|a, b| b.0.cmp(&a.0));
169 for (_, zone) in archives {
170 if let Some(f) = store.stat(&zone, OUTPUT_FILE).map_err(|e| e.to_string())? {
171 if f.size > 0 {
172 return Ok(Some((zone, f.size, f.modts)));
173 }
174 }
175 }
176 Ok(None)
177}
178
179fn scan_tail_for_prompt(store: &FileStore, zone: &str, size: i64) -> Result<String, String> {
182 let floor = (size - MAX_SCAN).max(0);
183 let mut end = size;
184 let mut carry: Vec<u8> = Vec::new();
187 while end > floor {
188 let start = (end - CHUNK).max(floor);
189 let (_, mut buf) = store
190 .read_at(zone, OUTPUT_FILE, start, end - start)
191 .map_err(|e| e.to_string())?;
192 buf.extend_from_slice(&carry);
193 carry.clear();
194 let body_from = if start > 0 {
197 match buf.iter().position(|b| *b == b'\n') {
198 Some(i) => {
199 if i <= MAX_LINE {
200 carry = buf[..i].to_vec();
201 }
202 i + 1
203 }
204 None => {
205 if buf.len() <= MAX_LINE {
206 carry = std::mem::take(&mut buf);
207 }
208 buf.len()
209 }
210 }
211 } else {
212 0
213 };
214 for line in buf[body_from.min(buf.len())..].split(|b| *b == b'\n').rev() {
215 if let Some(msg) = user_prompt_in_line(line) {
216 return Ok(collapse_preview(&msg));
217 }
218 }
219 end = start;
220 }
221 Ok(String::new())
222}
223
224fn user_prompt_in_line(line: &[u8]) -> Option<String> {
227 if !contains(line, br#""type":"user""#) {
229 return None;
230 }
231 let v: serde_json::Value = serde_json::from_slice(line).ok()?;
232 if v.get("type")?.as_str()? != "user" {
233 return None;
234 }
235 if v.get("tool_use_result").is_some() {
236 return None;
237 }
238 if v.get("parent_tool_use_id").map_or(false, |p| !p.is_null()) {
239 return None;
240 }
241 let content = v.get("message")?.get("content")?;
242 let text = match content {
243 serde_json::Value::String(s) => s.clone(),
244 serde_json::Value::Array(blocks) => {
245 if blocks.iter().any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result")) {
246 return None;
247 }
248 blocks
249 .iter()
250 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
251 .collect::<Vec<_>>()
252 .join("\n")
253 }
254 _ => return None,
255 };
256 let text = text.trim();
257 if text.is_empty() || is_scaffolding(text) {
258 return None;
259 }
260 Some(text.to_string())
261}
262
263fn is_scaffolding(text: &str) -> bool {
265 const PREFIXES: &[&str] = &[
266 "# Session Context",
267 "[JEKT:",
268 "[BROADCAST:",
269 "[AgentMux]",
270 "<system-reminder>",
271 "<command-name>",
272 "<command-message>",
273 "<local-command",
274 "Caveat:",
275 "[Request interrupted",
276 ];
277 PREFIXES.iter().any(|p| text.starts_with(p))
278}
279
280fn contains(haystack: &[u8], needle: &[u8]) -> bool {
281 haystack.windows(needle.len()).any(|w| w == needle)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::backend::storage::filestore::{FileMeta, FileOpts};
288 use std::collections::HashMap as Map;
289
290 fn put(fs: &FileStore, zone: &str, body: &[u8]) {
291 let meta: FileMeta = Map::new();
292 fs.make_file(zone, OUTPUT_FILE, meta, FileOpts::default()).unwrap();
293 fs.write_file(zone, OUTPUT_FILE, body).unwrap();
294 }
295
296 fn user(text: &str) -> String {
297 serde_json::json!({"type":"user","message":{"role":"user","content":text}}).to_string()
298 }
299
300 fn delta() -> String {
301 r#"{"type":"stream_event","event":{"type":"content_block_delta"}}"#.to_string()
302 }
303
304 fn read(fs: &FileStore, id: &str) -> HistoryRead {
305 read_agent_history(fs, id)
306 }
307
308 #[test]
309 fn no_zone_is_no_transcript_and_a_bad_id_is_too() {
310 let fs = FileStore::open_in_memory().unwrap();
311 assert_eq!(read(&fs, "agent-1"), HistoryRead::NoTranscript);
312 assert_eq!(read(&fs, "../evil"), HistoryRead::NoTranscript);
313 }
314
315 #[test]
316 fn finds_the_newest_real_prompt_and_skips_tool_results_and_scaffolding() {
317 let fs = FileStore::open_in_memory().unwrap();
318 let body = [
319 user("# Session Context\nboilerplate"),
320 user("first real question"),
321 delta(),
322 user("fix the login race"),
323 r#"{"type":"user","tool_use_result":{},"message":{"role":"user","content":[{"type":"tool_result","content":"x"}]}}"#.to_string(),
324 r#"{"type":"user","parent_tool_use_id":"toolu_1","message":{"role":"user","content":"sub-agent prompt"}}"#.to_string(),
325 user("[JEKT:FROM=someone TIER=coord]\nbody"),
326 delta(),
327 ]
328 .join("\n");
329 put(&fs, "agent:agent-1:current", body.as_bytes());
330 match read(&fs, "agent-1") {
331 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "fix the login race"),
332 other => panic!("expected a transcript, got {other:?}"),
333 }
334 }
335
336 #[test]
337 fn reads_a_prompt_given_as_text_blocks() {
338 let fs = FileStore::open_in_memory().unwrap();
339 let body = serde_json::json!({"type":"user","message":{"role":"user","content":[
340 {"type":"text","text":"line one"},{"type":"text","text":"line two"}]}})
341 .to_string();
342 put(&fs, "agent:agent-2:current", body.as_bytes());
343 match read(&fs, "agent-2") {
344 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "line one line two"),
345 other => panic!("expected a transcript, got {other:?}"),
346 }
347 }
348
349 #[test]
350 fn a_transcript_with_no_prompt_is_still_a_transcript() {
351 let fs = FileStore::open_in_memory().unwrap();
352 put(&fs, "agent:agent-3:current", delta().as_bytes());
353 match read(&fs, "agent-3") {
354 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, ""),
355 other => panic!("expected a transcript, got {other:?}"),
356 }
357 }
358
359 #[test]
360 fn an_archived_only_agent_reads_its_newest_archive() {
361 let fs = FileStore::open_in_memory().unwrap();
362 put(&fs, "agent:agent-4:archive:1000", user("old question").as_bytes());
363 put(&fs, "agent:agent-4:archive:2000", user("newer question").as_bytes());
364 match read(&fs, "agent-4") {
365 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "newer question"),
366 other => panic!("expected a transcript, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn an_empty_current_zone_falls_through_to_the_archive() {
372 let fs = FileStore::open_in_memory().unwrap();
373 put(&fs, "agent:agent-5:current", b"");
374 put(&fs, "agent:agent-5:archive:5", user("before the archive").as_bytes());
375 match read(&fs, "agent-5") {
376 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "before the archive"),
377 other => panic!("expected a transcript, got {other:?}"),
378 }
379 }
380
381 #[test]
385 fn only_the_tail_is_read_and_a_prompt_beyond_the_cap_is_not_found() {
386 let fs = FileStore::open_in_memory_with_cap(64 * 1024 * 1024).unwrap();
387 let filler = format!("{}\n", delta());
388 let mut body = Vec::new();
389 body.extend_from_slice(user("buried far back").as_bytes());
390 body.push(b'\n');
391 while (body.len() as i64) < MAX_SCAN + 3 * CHUNK {
392 body.extend_from_slice(filler.as_bytes());
393 }
394 put(&fs, "agent:agent-6:current", &body);
395 match read(&fs, "agent-6") {
396 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, ""),
397 other => panic!("expected a transcript, got {other:?}"),
398 }
399 let mut body2 = body.clone();
401 body2.extend_from_slice(user("asked just now").as_bytes());
402 put(&fs, "agent:agent-7:current", &body2);
403 match read(&fs, "agent-7") {
404 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "asked just now"),
405 other => panic!("expected a transcript, got {other:?}"),
406 }
407 }
408
409 fn filler_of(n: usize) -> Vec<u8> {
411 let head = br#"{"type":"stream_event","p":""#;
412 let tail = b"\"}\n";
413 let mut v = head.to_vec();
414 v.extend(std::iter::repeat(b'x').take(n - head.len() - tail.len()));
415 v.extend_from_slice(tail);
416 v
417 }
418
419 #[test]
421 fn a_prompt_straddling_a_chunk_boundary_is_found() {
422 let fs = FileStore::open_in_memory_with_cap(64 * 1024 * 1024).unwrap();
423 let prompt = user("split across two chunks");
424 let mut body = Vec::new();
425 body.extend(filler_of(CHUNK as usize));
426 body.extend_from_slice(prompt.as_bytes());
427 body.push(b'\n');
428 let after = CHUNK as usize - 20;
431 body.extend(filler_of(after));
432 put(&fs, "agent:agent-8:current", &body);
433 match read(&fs, "agent-8") {
434 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "split across two chunks"),
435 other => panic!("expected a transcript, got {other:?}"),
436 }
437 }
438
439 #[test]
443 #[ignore]
444 fn history_real_store_timing() {
445 let Ok(path) = std::env::var("AGENTMUX_HISTORY_REAL_STORE") else { return };
446 let fs = FileStore::open_read_only(std::path::Path::new(&path)).unwrap();
447 let ids: Vec<String> = fs
448 .zone_ids_with_prefix("agent:")
449 .unwrap()
450 .into_iter()
451 .filter_map(|z| z.strip_suffix(":current").and_then(|z| z.strip_prefix("agent:")).map(String::from))
452 .collect();
453 let t = std::time::Instant::now();
454 let (mut found, mut empty, mut none, mut failed) = (0, 0, 0, 0);
455 for id in &ids {
456 match read_agent_history(&fs, id) {
457 HistoryRead::Transcript(h) if h.last_user_message.is_empty() => empty += 1,
458 HistoryRead::Transcript(_) => found += 1,
459 HistoryRead::NoTranscript => none += 1,
460 HistoryRead::Failed => failed += 1,
461 }
462 }
463 println!("{} agents: {found} with a prompt, {empty} without, {none} no transcript, {failed} failed, cold in {:?}", ids.len(), t.elapsed());
464 let t = std::time::Instant::now();
465 for id in &ids {
466 let _ = read_agent_history(&fs, id);
467 }
468 println!("warm in {:?}", t.elapsed());
469 }
470
471 #[test]
472 fn a_changed_transcript_is_read_again() {
473 let fs = FileStore::open_in_memory().unwrap();
474 put(&fs, "agent:agent-9:current", user("one").as_bytes());
475 let first = read(&fs, "agent-9");
476 fs.append_data("agent:agent-9:current", OUTPUT_FILE, format!("\n{}", user("two")).as_bytes())
477 .unwrap();
478 let second = read(&fs, "agent-9");
479 assert_ne!(first, second);
480 match second {
481 HistoryRead::Transcript(h) => assert_eq!(h.last_user_message, "two"),
482 other => panic!("expected a transcript, got {other:?}"),
483 }
484 }
485}