1pub mod document;
23mod events;
24mod gc;
25pub mod live;
26mod outcome;
27mod pages;
28#[cfg(test)]
29mod pages_tests;
30pub mod transcript;
31#[cfg(test)]
32mod transcript_tests;
33pub use events::{
34 Answer, Budgets, Delivery, DeliveryKind, DeliveryState, End, Event, Header, Kind, MessageEvent,
35 Send,
36};
37#[cfg(test)]
38mod recorder_tests;
39mod summary;
40
41pub use gc::{Collection, Usage, collect, usage};
42pub use summary::{Summary, tokens_text};
43
44use std::{
45 fmt,
46 fs::File,
47 io::{BufRead, BufReader, Write},
48 path::{Path, PathBuf},
49};
50
51use rig_core::message::{AssistantContent, Message, ToolResultContent, UserContent};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54#[cfg(test)]
55use serde_json::json;
56
57use crate::{platform, store::Request};
58
59pub const DIRECTORY: &str = "agent-sessions";
61const VERSION: u32 = 1;
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct SessionRef {
70 pub producer: crate::types::ProducerId,
71 pub state: crate::types::StateId,
72 pub run_id: crate::types::RunId,
73 pub request_id: crate::types::RequestId,
74 pub session_id: crate::types::SessionId,
75}
76
77impl fmt::Display for SessionRef {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 write!(
80 formatter,
81 "{}/{}/{}/{}/{}",
82 self.producer, self.state, self.run_id, self.request_id, self.session_id
83 )
84 }
85}
86
87impl SessionRef {
88 pub fn parse(text: &str) -> Option<Self> {
90 let mut parts = text.rsplitn(5, '/');
91 let (session_id, request_id, run_id, state, producer) = (
92 parts.next()?,
93 parts.next()?,
94 parts.next()?,
95 parts.next()?,
96 parts.next()?,
97 );
98 let reference = Self {
99 producer: producer.parse().ok()?,
100 state: state.parse().ok()?,
101 run_id: run_id.parse().ok()?,
102 request_id: request_id.parse().ok()?,
103 session_id: session_id.parse().ok()?,
104 };
105 reference.valid().then_some(reference)
106 }
107
108 pub fn valid(&self) -> bool {
111 let printable = |value: &str| {
112 (1..=crate::types::MAX_ID_BYTES).contains(&value.len())
113 && !value.chars().any(char::is_control)
114 };
115 [
116 self.state.as_str(),
117 self.run_id.as_str(),
118 self.request_id.as_str(),
119 self.session_id.as_str(),
120 ]
121 .into_iter()
122 .all(|id| printable(id) && valid_id(id))
123 }
124}
125
126fn valid_id(id: &str) -> bool {
128 id.parse::<crate::types::SessionId>().is_ok()
129}
130
131pub fn directory(state: &Path) -> PathBuf {
133 state.join(DIRECTORY)
134}
135
136pub fn path(state: &Path, id: &str) -> Result<PathBuf, String> {
138 let id: crate::types::SessionId = id.parse()?;
139 Ok(directory(state).join(format!("{id}.jsonl")))
140}
141
142#[derive(Debug, Clone)]
145pub struct Saving {
146 pub state: PathBuf,
147 pub state_id: crate::types::StateId,
148 pub producer: crate::types::ProducerId,
149}
150
151fn warn(message: &str) {
152 let _ = writeln!(std::io::stderr().lock(), "{message}");
153}
154
155pub struct Recorder {
158 id: crate::types::SessionId,
159 state: Recording,
160 publisher: crate::changes::Publisher,
161}
162
163enum Recording {
166 Off,
167 Pending(Box<Pending>),
168 Review { file: File, reference: SessionRef },
169 FollowUp { file: File, send: usize },
170 Failed,
171 SavedFailure(SessionRef),
172}
173struct Pending {
174 path: PathBuf,
175 reference: SessionRef,
176 eval_id: Option<crate::types::EvalId>,
177 target: Option<crate::types::ArtifactName>,
178}
179
180impl Recorder {
181 pub fn off(id: &crate::types::SessionId) -> Self {
183 Self {
184 id: id.clone(),
185 state: Recording::Off,
186 publisher: crate::changes::Publisher::default(),
187 }
188 }
189
190 pub fn new(saving: Option<&Saving>, request: &Request, id: &crate::types::SessionId) -> Self {
192 let Some(saving) = saving else {
193 return Self::off(id);
194 };
195 let path = match path(&saving.state, id) {
196 Ok(path) => path,
197 Err(error) => {
198 warn(&error);
199 return Self::off(id);
200 }
201 };
202 Self {
203 id: id.clone(),
204 state: Recording::Pending(Box::new(Pending {
205 path,
206 reference: SessionRef {
207 producer: saving.producer.clone(),
208 state: saving.state_id.clone(),
209 run_id: request.run_id.clone(),
210 request_id: request.id.clone(),
211 session_id: id.clone(),
212 },
213 eval_id: Some(request.eval_id.clone()),
214 target: Some(request.target.clone()),
215 })),
216 publisher: crate::changes::Publisher::new(&saving.state),
217 }
218 }
219
220 #[cfg(test)]
222 pub(crate) fn test(state: &Path, id: &str) -> Self {
223 let id: crate::types::SessionId = id.parse().unwrap();
224 Self {
225 state: Recording::Pending(Box::new(Pending {
226 path: path(state, &id).unwrap(),
227 reference: SessionRef {
228 producer: "tester@host".parse().unwrap(),
229 state: "state".parse().unwrap(),
230 run_id: "run".parse().unwrap(),
231 request_id: "request".parse().unwrap(),
232 session_id: id.clone(),
233 },
234 eval_id: None,
235 target: None,
236 })),
237 ..Self::off(&id)
238 }
239 }
240
241 pub(crate) fn append(
243 state: &Path,
244 path: &Path,
245 id: &crate::types::SessionId,
246 send: usize,
247 ) -> Result<Self, String> {
248 let file = platform::open_no_follow(File::options().append(true), path)
249 .map_err(|error| format!("Cannot open Agent session {id}: {error}"))?;
250 Ok(Self {
251 id: id.clone(),
252 state: Recording::FollowUp { file, send },
253 publisher: crate::changes::Publisher::new(state),
254 })
255 }
256
257 pub fn id(&self) -> &crate::types::SessionId {
258 &self.id
259 }
260
261 pub fn reference(&self) -> Option<&SessionRef> {
263 match &self.state {
264 Recording::Review { reference, .. } | Recording::SavedFailure(reference) => {
265 Some(reference)
266 }
267 Recording::Off
268 | Recording::Pending(_)
269 | Recording::FollowUp { .. }
270 | Recording::Failed => None,
271 }
272 }
273
274 pub fn start(&mut self, mut details: Header) {
276 let state = std::mem::replace(&mut self.state, Recording::Off);
277 let pending = match state {
278 Recording::Pending(pending) => pending,
279 other => {
280 self.state = other;
281 return;
282 }
283 };
284 let reference = pending.reference;
285 details.version = Some(VERSION);
286 details.producer = Some(reference.producer.clone());
287 details.state = Some(reference.state.clone());
288 details.run_id = Some(reference.run_id.clone());
289 details.request_id = Some(reference.request_id.clone());
290 details.session_id = Some(reference.session_id.clone());
291 details.eval_id = pending.eval_id;
292 details.target = pending.target;
293 match create(&pending.path) {
294 Ok(file) => self.start_file(file, reference, details),
295 Err(error) => self.fail(&error),
296 }
297 }
298
299 fn start_file(&mut self, mut file: File, reference: SessionRef, details: Header) {
301 match write_event(&mut file, None, Kind::Review(Box::new(details))) {
302 Ok(()) => {
303 self.state = Recording::Review { file, reference };
304 self.notify();
305 }
306 Err(error) => self.fail(&error.to_string()),
307 }
308 }
309
310 fn active(&self) -> bool {
311 matches!(
312 self.state,
313 Recording::Review { .. } | Recording::FollowUp { .. }
314 )
315 }
316
317 pub(crate) fn message(&mut self, turn: usize, message: &Message, repair: bool) {
319 if !self.active() {
320 return;
321 }
322 self.event(Kind::Message(MessageEvent {
323 turn,
324 message: message.clone(),
325 repair,
326 is_error: Vec::new(),
327 question: None,
328 }));
329 }
330
331 pub(crate) fn tool_results(&mut self, turn: usize, message: &Message, failed: &[bool]) {
333 if !self.active() {
334 return;
335 }
336 self.event(Kind::Message(MessageEvent {
337 turn,
338 message: message.clone(),
339 repair: false,
340 is_error: failed.to_vec(),
341 question: None,
342 }));
343 }
344
345 pub fn event(&mut self, kind: Kind) {
348 let result = match &mut self.state {
349 Recording::Review { file, .. } => write_event(file, None, kind),
350 Recording::FollowUp { file, send } => write_event(file, Some(*send), kind),
351 Recording::Off
352 | Recording::Pending(_)
353 | Recording::Failed
354 | Recording::SavedFailure(_) => return,
355 };
356 match result {
357 Ok(()) => self.notify(),
358 Err(error) => self.fail(&error.to_string()),
359 }
360 }
361
362 fn notify(&self) {
363 self.publisher
364 .notify(crate::changes::Change::SessionInvalidated(self.id.clone()));
365 }
366
367 fn fail(&mut self, error: &str) {
368 self.state = match std::mem::replace(&mut self.state, Recording::Failed) {
369 Recording::Review { reference, .. } | Recording::SavedFailure(reference) => {
370 Recording::SavedFailure(reference)
371 }
372 Recording::Off
373 | Recording::Pending(_)
374 | Recording::FollowUp { .. }
375 | Recording::Failed => Recording::Failed,
376 };
377 warn(&format!(
378 "Cannot save Agent session {}: {error}; the review goes on without saving it.",
379 self.id
380 ));
381 }
382}
383
384fn write_event(file: &mut File, send: Option<usize>, kind: Kind) -> std::io::Result<()> {
386 let event = Event {
387 kind,
388 send,
389 at: Some(crate::broker::now()),
390 };
391 let mut line = serde_json::to_vec(&event).expect("session events are JSON");
392 line.push(b'\n');
393 file.write_all(&line).and_then(|()| file.flush())
394}
395
396fn create(path: &Path) -> Result<File, String> {
398 let directory = path.parent().expect("a session file has a directory");
399 platform::create_private_dir_all(directory).map_err(|e| e.to_string())?;
400 if !platform::is_private_dir(directory).map_err(|e| e.to_string())? {
401 return Err(format!(
402 "{} must have owner-only permissions.",
403 crate::platform::path_text(directory)
404 ));
405 }
406 let file = platform::open_no_follow(
407 platform::private_options().append(true).create_new(true),
408 path,
409 )
410 .map_err(|e| e.to_string())?;
411 platform::restrict_file(&file).map_err(|e| e.to_string())?;
412 Ok(file)
413}
414
415#[derive(Debug)]
417pub struct Conversation {
418 pub path: PathBuf,
419 pub events: Vec<Event>,
420 pub wire_events: Vec<Value>,
422}
423
424impl Conversation {
425 pub fn load(path: &Path) -> Result<Option<Self>, String> {
428 let file = match platform::open_no_follow(File::options().read(true), path) {
429 Ok(file) => file,
430 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
431 Err(error) => {
432 return Err(format!(
433 "Cannot read {}: {error}",
434 crate::platform::path_text(path)
435 ));
436 }
437 };
438 if !file.metadata().map_err(|e| e.to_string())?.is_file() {
439 return Err(format!(
440 "{} is not a regular file.",
441 crate::platform::path_text(path)
442 ));
443 }
444 let lines: Vec<String> = BufReader::new(file)
445 .lines()
446 .collect::<Result<_, _>>()
447 .map_err(|e| format!("Cannot read {}: {e}", crate::platform::path_text(path)))?;
448 let count = lines.len();
449 let mut events = Vec::new();
450 let mut wire_events = Vec::new();
451 for (index, line) in lines.into_iter().enumerate() {
452 match serde_json::from_str::<Value>(&line) {
453 Ok(value) => {
454 let event: Event = serde_json::from_value(value.clone()).map_err(|error| {
455 format!(
456 "{} line {} is not a session event: {error}",
457 crate::platform::path_text(path),
458 index + 1
459 )
460 })?;
461 wire_events.push(value);
462 events.push(event);
463 }
464 Err(error) if error.is_eof() && index + 1 == count => {}
465 Err(_) => {
466 return Err(format!(
467 "{} line {} is not a session event.",
468 crate::platform::path_text(path),
469 index + 1
470 ));
471 }
472 }
473 }
474 if events
475 .first()
476 .is_none_or(|event| !matches!(event.kind, Kind::Review(_)))
477 {
478 return Err(format!(
479 "{} has no review event.",
480 crate::platform::path_text(path)
481 ));
482 }
483 Ok(Some(Self {
484 path: path.into(),
485 events,
486 wire_events,
487 }))
488 }
489
490 pub fn header(&self) -> &Header {
492 let Kind::Review(header) = &self.events[0].kind else {
493 unreachable!("load requires a review header")
494 };
495 header
496 }
497
498 pub fn sends(&self) -> usize {
500 self.events
501 .iter()
502 .filter(|event| matches!(event.kind, Kind::Send(_)))
503 .count()
504 }
505
506 pub fn history(&self) -> Result<Vec<Message>, String> {
510 let mut messages = self
511 .events
512 .iter()
513 .filter_map(|event| match &event.kind {
514 Kind::Message(event) => Some(event.message.clone()),
515 _ => None,
516 })
517 .collect::<Vec<_>>();
518 let Some(last) = messages
519 .iter()
520 .rposition(|message| matches!(message, Message::Assistant { .. }))
521 else {
522 return Ok(messages);
523 };
524 let answered: Vec<_> = messages[last + 1..]
525 .iter()
526 .flat_map(|message| match message {
527 Message::User { content } => content.as_slice(),
528 _ => &[],
529 })
530 .filter_map(|part| match part {
531 UserContent::ToolResult(result) => Some(result.call.wire()),
532 _ => None,
533 })
534 .collect();
535 let Message::Assistant { content, .. } = &messages[last] else {
536 unreachable!("the last answer")
537 };
538 let missing: Vec<_> = content
539 .iter()
540 .filter_map(|part| match part {
541 AssistantContent::ToolCall(call) if !answered.contains(&call.id.wire()) => {
542 Some(UserContent::ToolResult(call.result(vec![
543 ToolResultContent::text(
544 "Not run: the review stopped before this tool call completed.",
545 ),
546 ])))
547 }
548 _ => None,
549 })
550 .collect();
551 if missing.is_empty() {
552 return Ok(messages);
553 }
554 let ran = last + 1 < messages.len();
555 match messages.last_mut() {
556 Some(Message::User { content }) if ran => content.extend(missing),
557 _ => messages.push(Message::User { content: missing }),
558 }
559 Ok(messages)
560 }
561}
562
563pub async fn lock(state: &Path, id: &str) -> Result<File, String> {
565 let path = path(state, id)?.with_extension("lock");
566 let file = platform::open_no_follow(
567 platform::private_options()
568 .read(true)
569 .write(true)
570 .create(true)
571 .truncate(false),
572 &path,
573 )
574 .map_err(|e| format!("Cannot lock Agent session {id}: {e}"))?;
575 loop {
576 match file.try_lock() {
577 Ok(()) => return Ok(file),
578 Err(std::fs::TryLockError::WouldBlock) => {
579 tokio::time::sleep(platform::FILE_LOCK_RETRY_INTERVAL).await;
580 }
581 Err(std::fs::TryLockError::Error(error)) => {
582 return Err(format!("Cannot lock Agent session {id}: {error}"));
583 }
584 }
585 }
586}
587
588#[cfg(test)]
589pub(crate) mod live_tests;
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use std::time::Duration;
594
595 fn reference() -> SessionRef {
596 SessionRef {
597 producer: "alice@laptop".parse().unwrap(),
598 state: "6f1c0a52-8d1e-4c43-9a55-31f6a7f2d0e4".parse().unwrap(),
599 run_id: "run-Hq2b9X".parse().unwrap(),
600 request_id: "run-Hq2b9X-3".parse().unwrap(),
601 session_id: "0e8c7c2e-2a49-4b8e-9f7a-5d7a0c3b1f20".parse().unwrap(),
602 }
603 }
604
605 #[test]
606 fn producer_identity_validates_at_the_session_wire_boundary() {
607 let valid = serde_json::to_value(reference()).unwrap();
608 for producer in [
609 String::new(),
610 "bad\nproducer".into(),
611 "x".repeat(201),
612 "é".repeat(101),
613 ] {
614 let mut wire = valid.clone();
615 wire["producer"] = json!(producer);
616 assert!(serde_json::from_value::<SessionRef>(wire).is_err());
617 }
618 for producer in [
619 "name/with/slash@host".to_owned(),
620 "x".repeat(200),
621 "é".repeat(100),
622 ] {
623 let mut wire = valid.clone();
624 wire["producer"] = json!(producer);
625 let reference: SessionRef = serde_json::from_value(wire.clone()).unwrap();
626 assert_eq!(serde_json::to_value(reference).unwrap(), wire);
627 }
628 }
629
630 #[test]
631 fn references_round_trip_through_their_text_form() {
632 let reference = reference();
633 let text = reference.to_string();
634 assert_eq!(
635 text,
636 "alice@laptop/6f1c0a52-8d1e-4c43-9a55-31f6a7f2d0e4/run-Hq2b9X/run-Hq2b9X-3/0e8c7c2e-2a49-4b8e-9f7a-5d7a0c3b1f20"
637 );
638 assert_eq!(SessionRef::parse(&text), Some(reference));
639 let odd = SessionRef {
641 producer: "a/b@host".parse().unwrap(),
642 ..self::reference()
643 };
644 assert_eq!(SessionRef::parse(&odd.to_string()), Some(odd));
645 for text in [
646 "run-Hq2b9X-3",
647 "0e8c7c2e-2a49-4b8e-9f7a-5d7a0c3b1f20",
648 "a/b/c/d",
649 "alice@laptop/state/run/../session",
650 "alice@laptop/state/run/request/",
651 "alice@laptop/st ate/run/request/session",
652 ] {
653 assert_eq!(SessionRef::parse(text), None, "{text}");
654 }
655 assert!(path(Path::new("/state"), "..").is_err());
656 assert!(path(Path::new("/state"), "a/b").is_err());
657 }
658
659 #[test]
660 fn calls_a_stopped_review_never_answered_are_answered_on_replay() {
661 use rig_core::message::{ToolCall, ToolFunction, ToolName};
662 let call = |id: &str| {
663 ToolCall::from_wire(
664 id,
665 ToolFunction::new(ToolName::new("read").unwrap(), json!({})),
666 )
667 };
668 let answer = Message::Assistant {
669 id: None,
670 content: vec![
671 AssistantContent::ToolCall(call("c1")),
672 AssistantContent::ToolCall(call("c2")),
673 ],
674 };
675 let ran = Message::tool_results(vec![
676 call("c1").result(vec![ToolResultContent::text("ran")]),
677 ]);
678 let replay = |messages: &[&Message]| {
679 let events: Vec<Value> = std::iter::once(json!({"kind":"review"}))
680 .chain(
681 messages
682 .iter()
683 .map(|message| json!({"kind":"message","message":message})),
684 )
685 .collect();
686 Conversation {
687 path: "session.jsonl".into(),
688 wire_events: Vec::new(),
689 events: events
690 .into_iter()
691 .map(|event| serde_json::from_value(event).unwrap())
692 .collect(),
693 }
694 .history()
695 .unwrap()
696 };
697 let results = |message: &Message| match message {
698 Message::User { content } => content
699 .iter()
700 .map(|part| match part {
701 UserContent::ToolResult(result) => {
702 (result.call.wire().into_owned(), json!(result.content))
703 }
704 _ => panic!("only tool results"),
705 })
706 .collect::<Vec<_>>(),
707 _ => panic!("a user message"),
708 };
709 let not_run = json!([
710 {"type":"text","text":"Not run: the review stopped before this tool call completed."},
711 ]);
712 let history = replay(&[&answer, &ran]);
714 assert_eq!(history.len(), 2);
715 assert_eq!(
716 results(&history[1]),
717 [
718 ("c1".to_owned(), json!([{"type":"text","text":"ran"}])),
719 ("c2".to_owned(), not_run.clone())
720 ]
721 );
722 let history = replay(&[&answer]);
724 assert_eq!(history.len(), 2);
725 assert_eq!(
726 results(&history[1]),
727 [
728 ("c1".to_owned(), not_run.clone()),
729 ("c2".to_owned(), not_run)
730 ]
731 );
732 }
733
734 #[test]
735 fn a_session_that_cannot_be_written_is_skipped() {
736 let root = crate::test_os::tempdir();
737 let state = root.path().join("state");
739 std::fs::create_dir(&state).unwrap();
740 std::fs::write(directory(&state), "not a directory").unwrap();
741 let reference = reference();
742 let mut recorder = Recorder {
743 state: Recording::Pending(Box::new(Pending {
744 path: path(&state, &reference.session_id).unwrap(),
745 reference: reference.clone(),
746 eval_id: None,
747 target: None,
748 })),
749 ..Recorder::off(&reference.session_id)
750 };
751 recorder.start(Header {
752 backend: Some(crate::config::Backend::Openai),
753 ..Header::default()
754 });
755 recorder.message(1, &Message::user("still recorded nowhere"), false);
756 recorder.event(Kind::End(End::default()));
757 assert!(recorder.reference().is_none());
758 assert!(std::fs::metadata(directory(&state)).unwrap().is_file());
759 }
760 #[test]
761 fn jsonl_edges_keep_wire_events_and_reject_invalid_complete_records() {
762 let root = crate::test_os::tempdir();
763 let file = root.path().join("legacy.jsonl");
764 let header = r#"{"kind":"review","version":1,"sessionId":"legacy.session","runId":"run-old","requestId":"run-old-1","backend":"openai","model":"fixture","reasoning":null,"parameters":{"vendor":true},"budgets":{"timeoutMs":1000,"maxToolCalls":null,"maxTokens":null},"tools":[],"unknownSavedField":7,"at":"2026-01-01T00:00:00Z"}"#;
765 let message = r#"{"kind":"message","turn":1,"message":{"role":"user","content":[{"type":"text","text":"hello"}]},"at":"2026-01-01T00:00:01Z"}"#;
766 std::fs::write(&file, format!("{header}\n{message}\n{{\"kind\":")).unwrap();
767 let conversation = Conversation::load(&file).unwrap().unwrap();
768 assert_eq!(conversation.events.len(), 2);
769 assert_eq!(
770 conversation.header().session_id.as_ref().unwrap().as_str(),
771 "legacy.session"
772 );
773 assert_eq!(
774 conversation.header().budgets.as_ref().unwrap().timeout_ms,
775 Some(Duration::from_millis(1000))
776 );
777 assert_eq!(
778 conversation.wire_events[0],
779 serde_json::from_str::<Value>(header).unwrap()
780 );
781 assert_eq!(
782 conversation.wire_events[1],
783 serde_json::from_str::<Value>(message).unwrap()
784 );
785 assert_eq!(conversation.history().unwrap().len(), 1);
786 for invalid in [
787 r#"{"kind":"message","message":17}"#,
788 r#"{"kind":"attempt","turn":"bad","attempt":1,"usage":{}}"#,
789 r#"{"kind":"unknown"}"#,
790 ] {
791 std::fs::write(&file, format!("{header}\n{invalid}\n")).unwrap();
792 assert!(Conversation::load(&file).is_err(), "{invalid}");
793 }
794 std::fs::write(&file, header.replace("legacy.session", "../escape")).unwrap();
795 assert!(Conversation::load(&file).is_err());
796 }
797}