edgecrab_types/
trajectory.rs1use serde::{Deserialize, Serialize};
7
8use crate::Message;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Trajectory {
12 pub session_id: String,
13 pub model: String,
14 pub timestamp: String,
15 pub messages: Vec<Message>,
16 pub metadata: TrajectoryMetadata,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TrajectoryMetadata {
21 pub task_id: Option<String>,
22 pub total_tokens: u64,
23 pub total_cost: f64,
24 pub api_calls: u32,
25 pub tools_used: Vec<String>,
26 pub completed: bool,
27 pub duration_seconds: f64,
28}
29
30pub fn extract_reasoning(content: &str) -> (String, Option<String>) {
35 let re = regex::Regex::new(r"(?s)<think>(.*?)</think>").expect("valid regex");
36 let reasoning = re.captures(content).map(|c| c[1].trim().to_string());
37 let cleaned = re.replace_all(content, "").trim().to_string();
38 (cleaned, reasoning)
39}
40
41pub fn has_content_after_think(content: &str) -> bool {
43 let re = regex::Regex::new(r"(?s)<think>.*?</think>").expect("valid regex");
44 let after = re.replace_all(content, "").trim().to_string();
45 !after.is_empty()
46}
47
48pub fn convert_scratchpad_to_think(content: &str) -> String {
52 content
53 .replace("<REASONING_SCRATCHPAD>", "<think>")
54 .replace("</REASONING_SCRATCHPAD>", "</think>")
55}
56
57pub fn has_incomplete_scratchpad(content: &str) -> bool {
59 content.contains("<REASONING_SCRATCHPAD>") && !content.contains("</REASONING_SCRATCHPAD>")
60}
61
62pub fn save_trajectory(path: &std::path::Path, trajectory: &Trajectory) -> std::io::Result<()> {
64 use std::io::Write;
65 let json = serde_json::to_string(trajectory)
66 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
67 let mut file = std::fs::OpenOptions::new()
68 .create(true)
69 .append(true)
70 .open(path)?;
71 writeln!(file, "{json}")?;
72 Ok(())
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn extract_reasoning_with_think_tags() {
81 let content = "<think>Let me figure this out...</think>The answer is 42.";
82 let (cleaned, reasoning) = extract_reasoning(content);
83 assert_eq!(cleaned, "The answer is 42.");
84 assert_eq!(reasoning.as_deref(), Some("Let me figure this out..."));
85 }
86
87 #[test]
88 fn extract_reasoning_no_tags() {
89 let content = "Just a normal response.";
90 let (cleaned, reasoning) = extract_reasoning(content);
91 assert_eq!(cleaned, "Just a normal response.");
92 assert!(reasoning.is_none());
93 }
94
95 #[test]
96 fn has_content_after_think_true() {
97 assert!(has_content_after_think(
98 "<think>thinking...</think>Real content here."
99 ));
100 }
101
102 #[test]
103 fn has_content_after_think_false() {
104 assert!(!has_content_after_think("<think>only thinking</think>"));
105 }
106
107 #[test]
108 fn convert_scratchpad() {
109 let input = "<REASONING_SCRATCHPAD>stuff</REASONING_SCRATCHPAD>";
110 assert_eq!(convert_scratchpad_to_think(input), "<think>stuff</think>");
111 }
112
113 #[test]
114 fn incomplete_scratchpad() {
115 assert!(has_incomplete_scratchpad(
116 "<REASONING_SCRATCHPAD>partial reasoning"
117 ));
118 assert!(!has_incomplete_scratchpad(
119 "<REASONING_SCRATCHPAD>complete</REASONING_SCRATCHPAD>"
120 ));
121 }
122
123 #[test]
124 fn trajectory_roundtrip() {
125 let traj = Trajectory {
126 session_id: "s1".into(),
127 model: "test-model".into(),
128 timestamp: "2026-03-28T00:00:00Z".into(),
129 messages: vec![Message::user("hello"), Message::assistant("hi")],
130 metadata: TrajectoryMetadata {
131 task_id: Some("t1".into()),
132 total_tokens: 100,
133 total_cost: 0.001,
134 api_calls: 1,
135 tools_used: vec!["read_file".into()],
136 completed: true,
137 duration_seconds: 2.5,
138 },
139 };
140 let json = serde_json::to_string(&traj).expect("serialize");
141 let deser: Trajectory = serde_json::from_str(&json).expect("deserialize");
142 assert_eq!(deser.session_id, "s1");
143 assert_eq!(deser.messages.len(), 2);
144 }
145}