1use std::collections::HashMap;
12#[cfg(unix)]
13use std::fs::{File, OpenOptions};
14#[cfg(unix)]
15use std::io::Write;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TrajectoryStep {
23 pub step: usize,
25 pub thought: Option<String>,
27 pub action: Option<String>,
29 pub action_args: Option<String>,
31 pub observation: Option<String>,
33 pub response: Option<String>,
35 pub success: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Trajectory {
42 pub id: String,
44 pub session_id: String,
46 pub model: String,
48 pub timestamp: i64,
50 pub total_tokens: usize,
52 pub tool_calls: usize,
54 pub iterations: usize,
56 pub success: bool,
58 pub steps: Vec<TrajectoryStep>,
60 pub messages: Vec<TrajectoryMessage>,
62 pub metadata: HashMap<String, String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TrajectoryMessage {
69 pub role: String,
70 pub content: String,
71 pub tool_call_id: Option<String>,
72 pub tool_name: Option<String>,
73}
74
75impl Trajectory {
76 pub fn new(
77 id: impl Into<String>,
78 session_id: impl Into<String>,
79 model: impl Into<String>,
80 ) -> Self {
81 let timestamp = std::time::SystemTime::now()
82 .duration_since(std::time::UNIX_EPOCH)
83 .map(|d| d.as_secs() as i64)
84 .unwrap_or(0);
85
86 Self {
87 id: id.into(),
88 session_id: session_id.into(),
89 model: model.into(),
90 timestamp,
91 total_tokens: 0,
92 tool_calls: 0,
93 iterations: 0,
94 success: false,
95 steps: Vec::new(),
96 messages: Vec::new(),
97 metadata: HashMap::new(),
98 }
99 }
100
101 pub fn add_step(&mut self, mut step: TrajectoryStep) {
103 redact_step(&mut step);
104 if step.action.is_some() {
105 self.tool_calls += 1;
106 }
107 self.steps.push(step);
108 }
109
110 pub fn record_tool_step(
112 &mut self,
113 thought: Option<String>,
114 action: String,
115 action_args: String,
116 observation: String,
117 success: bool,
118 ) {
119 self.add_step(TrajectoryStep {
120 step: self.steps.len() + 1,
121 thought,
122 action: Some(action),
123 action_args: Some(action_args),
124 observation: Some(observation),
125 response: None,
126 success,
127 });
128 }
129
130 pub fn record_response(&mut self, response: String) {
132 let response_len = response.len();
133 self.add_message("assistant", &response, None, None);
135 self.total_tokens += response_len;
136
137 if let Some(step) = self.steps.last_mut() {
139 step.response = Some("[REDACTED]".to_string());
140 }
141 }
142
143 pub fn add_message(
145 &mut self,
146 role: &str,
147 content: &str,
148 tool_call_id: Option<&str>,
149 tool_name: Option<&str>,
150 ) {
151 self.messages.push(TrajectoryMessage {
152 role: role.to_string(),
153 content: redacted(content),
154 tool_call_id: tool_call_id.map(|s| s.to_string()),
155 tool_name: tool_name.map(|s| s.to_string()),
156 });
157 }
158
159 pub fn to_json(&self) -> anyhow::Result<String> {
161 let mut trajectory = self.clone();
162 trajectory
163 .metadata
164 .values_mut()
165 .for_each(|value| *value = "[REDACTED]".to_string());
166 trajectory.steps.iter_mut().for_each(redact_step);
167 trajectory.messages.iter_mut().for_each(|message| {
168 if !message.content.is_empty() {
169 message.content = "[REDACTED]".to_string();
170 }
171 });
172 Ok(serde_json::to_string_pretty(&trajectory)?)
173 }
174
175 pub fn save_to_file(&self, path: &Path) -> anyhow::Result<()> {
177 self.save(path)
178 }
179
180 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
182 save_private(path, self.to_json()?.as_bytes())?;
183 tracing::info!(
184 "Trajectory saved to {:?} ({} steps, {} tokens, {} msgs)",
185 path,
186 self.steps.len(),
187 self.total_tokens,
188 self.messages.len()
189 );
190 Ok(())
191 }
192}
193
194pub struct TrajectoryCollector {
198 pub trajectory: Trajectory,
199 current_step: Option<TrajectoryStep>,
200}
201
202impl TrajectoryCollector {
203 pub fn new(session_id: impl Into<String>, model: impl Into<String>) -> Self {
204 let id = uuid::Uuid::new_v4().to_string();
205 Self {
206 trajectory: Trajectory::new(id, session_id, model),
207 current_step: None,
208 }
209 }
210
211 pub fn start_step(&mut self, action: &str, action_args: &str) {
213 let step = TrajectoryStep {
214 step: self.trajectory.steps.len() + 1,
215 thought: None,
216 action: Some(action.to_string()),
217 action_args: Some(redacted(action_args)),
218 observation: None,
219 response: None,
220 success: false,
221 };
222 self.current_step = Some(step);
223 }
224
225 pub fn complete_step(&mut self, observation: &str, success: bool) {
227 if let Some(mut step) = self.current_step.take() {
228 step.observation = Some(redacted(observation));
229 step.success = success;
230 self.trajectory.add_step(step);
231 }
232 }
233
234 pub fn fail_step(&mut self, error: &str) {
236 if let Some(mut step) = self.current_step.take() {
237 step.observation = Some(redacted(error));
238 step.success = false;
239 self.trajectory.add_step(step);
240 }
241 }
242
243 pub fn set_thought(&mut self, thought: &str) {
245 if let Some(ref mut step) = self.current_step {
246 step.thought = Some(redacted(thought));
247 }
248 }
249
250 pub fn set_response(&mut self, response: &str) {
252 if let Some(ref mut step) = self.current_step {
253 step.response = Some(redacted(response));
254 }
255 }
256
257 pub fn record_message(
259 &mut self,
260 role: &str,
261 content: &str,
262 tool_call_id: Option<&str>,
263 tool_name: Option<&str>,
264 ) {
265 self.trajectory
266 .add_message(role, content, tool_call_id, tool_name);
267 self.trajectory.total_tokens += content.len();
268 }
269
270 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
272 self.trajectory.save(path)
273 }
274
275 pub fn finalize(&mut self, success: bool) -> String {
277 self.trajectory.success = success;
278 self.to_json().unwrap_or_default()
279 }
280
281 pub fn to_json(&self) -> anyhow::Result<String> {
283 self.trajectory.to_json()
284 }
285}
286
287fn redact_step(step: &mut TrajectoryStep) {
288 for value in [
289 &mut step.thought,
290 &mut step.action_args,
291 &mut step.observation,
292 &mut step.response,
293 ] {
294 if value.as_deref().is_some_and(|value| !value.is_empty()) {
295 *value = Some("[REDACTED]".to_string());
296 }
297 }
298}
299
300fn redacted(value: &str) -> String {
301 if value.is_empty() {
302 String::new()
303 } else {
304 "[REDACTED]".to_string()
305 }
306}
307
308#[cfg(unix)]
309fn save_private(path: &Path, contents: &[u8]) -> anyhow::Result<()> {
310 use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
311
312 let parent = path
313 .parent()
314 .filter(|parent| !parent.as_os_str().is_empty())
315 .ok_or_else(|| anyhow::anyhow!("trajectory path requires a private parent directory"))?;
316 match std::fs::symlink_metadata(parent) {
317 Ok(metadata) => validate_private_directory(&metadata)?,
318 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
319 std::fs::create_dir_all(parent)?;
320 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
321 validate_private_directory(&std::fs::symlink_metadata(parent)?)?;
322 }
323 Err(error) => return Err(error.into()),
324 }
325
326 let mut options = OpenOptions::new();
327 options
328 .create(true)
329 .read(true)
330 .write(true)
331 .mode(0o600)
332 .custom_flags(libc::O_NOFOLLOW);
333 let mut file = options.open(path)?;
334 let metadata = file.metadata()?;
335 if !metadata.is_file() || metadata.nlink() != 1 || metadata.mode() & 0o077 != 0 {
336 anyhow::bail!("trajectory file is not private");
337 }
338 file.set_len(0)?;
339 file.write_all(contents)?;
340 file.sync_all()?;
341 File::open(parent)?.sync_all()?;
342 Ok(())
343}
344
345#[cfg(unix)]
346fn validate_private_directory(metadata: &std::fs::Metadata) -> anyhow::Result<()> {
347 use std::os::unix::fs::MetadataExt;
348
349 if !metadata.is_dir() || metadata.file_type().is_symlink() || metadata.mode() & 0o077 != 0 {
350 anyhow::bail!("trajectory directory is not private");
351 }
352 Ok(())
353}
354
355#[cfg(not(unix))]
356fn save_private(_path: &Path, _contents: &[u8]) -> anyhow::Result<()> {
357 anyhow::bail!("private trajectory persistence is unavailable on this platform")
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_trajectory_new() {
366 let t = Trajectory::new("test-1", "session-1", "claude-sonnet");
367 assert_eq!(t.model, "claude-sonnet");
368 assert_eq!(t.tool_calls, 0);
369 assert!(!t.id.is_empty());
370 }
371
372 #[test]
373 fn test_add_step() {
374 let mut t = Trajectory::new("test-2", "session-1", "gpt-4");
375 t.add_step(TrajectoryStep {
376 step: 1,
377 thought: Some("I need to read the file".into()),
378 action: Some("read".into()),
379 action_args: Some(r#"{"path":"test"}"#.into()),
380 observation: Some("file content".into()),
381 response: None,
382 success: true,
383 });
384 assert_eq!(t.steps.len(), 1);
385 assert_eq!(t.tool_calls, 1);
386 }
387
388 #[test]
389 fn test_trajectory_collector() {
390 let mut collector = TrajectoryCollector::new("session-2", "claude-opus");
391
392 collector.start_step("shell", r#"{"command":"ls"}"#);
393 collector.set_thought("Check directory contents");
394 collector.complete_step("file1.txt\nfile2.txt", true);
395
396 assert_eq!(collector.trajectory.steps.len(), 1);
397 assert_eq!(collector.trajectory.tool_calls, 1);
398
399 collector.record_message("user", "list files", None, None);
400 collector.record_message("assistant", "done", None, None);
401
402 assert_eq!(collector.trajectory.messages.len(), 2);
403 }
404
405 #[test]
406 fn test_trajectory_json() {
407 let mut t = Trajectory::new("test-3", "session-3", "claude-sonnet");
408 t.add_step(TrajectoryStep {
409 step: 1,
410 thought: None,
411 action: Some("read".into()),
412 action_args: Some(r#"{"path":"/tmp/test"}"#.into()),
413 observation: Some("data".into()),
414 response: Some("Here's the data".into()),
415 success: true,
416 });
417 t.total_tokens = 150;
418 t.success = true;
419
420 let json = t.to_json().unwrap();
421 assert!(json.contains("\"action\": \"read\""));
422 assert!(json.contains("\"total_tokens\": 150"));
423 assert!(json.contains("\"success\": true"));
424
425 let parsed: Trajectory = serde_json::from_str(&json).unwrap();
427 assert_eq!(parsed.steps.len(), 1);
428 assert_eq!(parsed.tool_calls, 1);
429 }
430
431 #[test]
432 fn trajectory_json_redacts_all_freeform_text() {
433 let mut trajectory = Trajectory::new("test-4", "session-4", "model");
434 trajectory
435 .metadata
436 .insert("task".to_string(), "clipboard secret".to_string());
437 trajectory.add_step(TrajectoryStep {
438 step: 1,
439 thought: Some("credential thought".to_string()),
440 action: Some("praefectus".to_string()),
441 action_args: Some(r#"{"value":"typed secret"}"#.to_string()),
442 observation: Some("private selector".to_string()),
443 response: Some("repeated secret".to_string()),
444 success: false,
445 });
446 trajectory.add_message("user", "raw task secret", None, None);
447
448 let json = trajectory.to_json().expect("trajectory should serialize");
449 for secret in [
450 "clipboard secret",
451 "credential thought",
452 "typed secret",
453 "private selector",
454 "repeated secret",
455 "raw task secret",
456 ] {
457 assert!(!json.contains(secret));
458 }
459 assert!(json.contains("[REDACTED]"));
460 }
461
462 #[cfg(unix)]
463 #[test]
464 fn trajectory_save_creates_private_durable_storage() {
465 use std::os::unix::fs::PermissionsExt;
466
467 let root = tempfile::tempdir().expect("temporary directory should open");
468 let path = root.path().join("private").join("trajectory.json");
469 Trajectory::new("test-5", "session-5", "model")
470 .save(&path)
471 .expect("private trajectory should save");
472
473 assert_eq!(
474 std::fs::metadata(path.parent().expect("parent should exist"))
475 .expect("directory metadata should load")
476 .permissions()
477 .mode()
478 & 0o777,
479 0o700
480 );
481 assert_eq!(
482 std::fs::metadata(&path)
483 .expect("file metadata should load")
484 .permissions()
485 .mode()
486 & 0o777,
487 0o600
488 );
489 }
490
491 #[cfg(unix)]
492 #[test]
493 fn trajectory_save_rejects_public_or_linked_storage() {
494 use std::os::unix::fs::{symlink, PermissionsExt};
495
496 let root = tempfile::tempdir().expect("temporary directory should open");
497 let public = root.path().join("public");
498 std::fs::create_dir(&public).expect("public directory should create");
499 std::fs::set_permissions(&public, std::fs::Permissions::from_mode(0o755))
500 .expect("public permissions should set");
501 let trajectory = Trajectory::new("test-6", "session-6", "model");
502 assert!(trajectory.save(&public.join("trajectory.json")).is_err());
503
504 let private = root.path().join("private");
505 std::fs::create_dir(&private).expect("private directory should create");
506 std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))
507 .expect("private permissions should set");
508 let destination = private.join("destination.json");
509 std::fs::write(&destination, "existing").expect("destination should create");
510 let linked = private.join("trajectory.json");
511 symlink(&destination, &linked).expect("link should create");
512 assert!(trajectory.save(&linked).is_err());
513 assert_eq!(
514 std::fs::read_to_string(destination).expect("destination should remain readable"),
515 "existing"
516 );
517 }
518}