Skip to main content

apollo/
trajectory.rs

1//! Trajectory export for RL training data generation.
2//!
3//! Captures full ReAct steps (thought → action → observation → response)
4//! and serializes to JSON for fine-tuning or evaluation.
5//!
6//! Port from hermes-rs `trajectory.rs`.
7//!
8//! pontytail: JSON file output only. Parquet/Arrow can be added when
9//! large-scale training pipelines need direct export.
10
11use 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/// A single step in a trajectory (one tool call + result)
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TrajectoryStep {
23    /// Step index
24    pub step: usize,
25    /// Agent reasoning/thought before the action
26    pub thought: Option<String>,
27    /// Tool name or action taken
28    pub action: Option<String>,
29    /// Arguments passed to the tool
30    pub action_args: Option<String>,
31    /// Direct observation/result from the action
32    pub observation: Option<String>,
33    /// Final response if this was the terminal step
34    pub response: Option<String>,
35    /// Whether the step completed successfully
36    pub success: bool,
37}
38
39/// A complete trajectory (conversation) ready for training.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Trajectory {
42    /// Unique identifier
43    pub id: String,
44    /// Session ID this trajectory came from
45    pub session_id: String,
46    /// Model used for generation
47    pub model: String,
48    /// Unix timestamp
49    pub timestamp: i64,
50    /// Total tokens consumed
51    pub total_tokens: usize,
52    /// Number of tool calls made
53    pub tool_calls: usize,
54    /// Number of iterations (LLM rounds)
55    pub iterations: usize,
56    /// Whether the overall task was successful
57    pub success: bool,
58    /// The individual ReAct steps
59    pub steps: Vec<TrajectoryStep>,
60    /// Raw messages for full fidelity
61    pub messages: Vec<TrajectoryMessage>,
62    /// Arbitrary metadata
63    pub metadata: HashMap<String, String>,
64}
65
66/// A single message in the conversation for training data.
67#[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    /// Add a ReAct step to the trajectory.
102    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    /// Record a ReAct tool step directly (used by the agent loop).
111    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    /// Record the final text response for the current step (used by loop_runner).
131    pub fn record_response(&mut self, response: String) {
132        let response_len = response.len();
133        // Record as a message
134        self.add_message("assistant", &response, None, None);
135        self.total_tokens += response_len;
136
137        // Also update the last step's response if applicable
138        if let Some(step) = self.steps.last_mut() {
139            step.response = Some("[REDACTED]".to_string());
140        }
141    }
142
143    /// Add a raw message to the trajectory.
144    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    /// Serialize to pretty JSON.
160    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    /// Save to a JSON file (alias for save). Creates parent directories.
176    pub fn save_to_file(&self, path: &Path) -> anyhow::Result<()> {
177        self.save(path)
178    }
179
180    /// Save to a JSON file. Creates parent directories.
181    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
194/// TrajectoryCollector — collects trajectory data during an agent run.
195///
196/// Attach to AgentRunner to capture all steps incrementally.
197pub 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    /// Start tracking a new tool call step.
212    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    /// Complete the current step with an observation.
226    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    /// Mark the current step as failed.
235    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    /// Attach a thought to the current step.
244    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    /// Attach a final response to the current step.
251    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    /// Record a message to the trajectory.
258    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    /// Finish and save to file.
271    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
272        self.trajectory.save(path)
273    }
274
275    /// Mark the overall trajectory as successful and return JSON.
276    pub fn finalize(&mut self, success: bool) -> String {
277        self.trajectory.success = success;
278        self.to_json().unwrap_or_default()
279    }
280
281    /// Serialize the trajectory to JSON.
282    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        // Round-trip
426        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}