Skip to main content

looprs_core/
observation.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::types::ToolId;
6
7const OUTPUT_PREVIEW_LEN: usize = 500;
8
9/// A captured observation from tool usage in a session
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Observation {
12    pub tool_name: String,
13    pub input: Value,
14    pub output: String,
15    pub tool_use_id: Option<ToolId>,
16    pub timestamp: u64,
17    pub session_id: String,
18    pub context: Option<String>,
19}
20
21impl Observation {
22    pub fn new(
23        tool_name: String,
24        input: Value,
25        output: String,
26        tool_use_id: Option<ToolId>,
27        session_id: String,
28    ) -> Self {
29        let timestamp = SystemTime::now()
30            .duration_since(UNIX_EPOCH)
31            .map(|d| d.as_secs())
32            .unwrap_or(0);
33
34        Observation {
35            tool_name,
36            input,
37            output,
38            tool_use_id,
39            timestamp,
40            session_id,
41            context: None,
42        }
43    }
44
45    pub fn with_context(mut self, context: String) -> Self {
46        self.context = Some(context);
47        self
48    }
49
50    pub fn to_description(&self) -> String {
51        let input_str = serde_json::to_string_pretty(&self.input).unwrap_or_default();
52        let output_preview = if self.output.len() > OUTPUT_PREVIEW_LEN {
53            format!("{}...", &self.output[..OUTPUT_PREVIEW_LEN])
54        } else {
55            self.output.clone()
56        };
57
58        let time_str = match SystemTime::UNIX_EPOCH
59            .checked_add(std::time::Duration::from_secs(self.timestamp))
60        {
61            Some(t) => format!("{t:?}"),
62            None => format!("{} (unix timestamp)", self.timestamp),
63        };
64
65        format!(
66            "**Tool:** {}\n{}**Time:** {}\n\n**Input:**\n```\n{}\n```\n\n**Output:**\n```\n{}\n```{}",
67            self.tool_name,
68            self.tool_use_id
69                .as_ref()
70                .map(|id| format!("**Tool Use ID:** {id}\n"))
71                .unwrap_or_default(),
72            time_str,
73            input_str,
74            output_preview,
75            if let Some(ctx) = &self.context {
76                format!("\n\n**Context:** {ctx}")
77            } else {
78                String::new()
79            }
80        )
81    }
82
83    pub fn to_title(&self) -> String {
84        if let Some(ctx) = &self.context {
85            format!("Observation: {}", ctx.chars().take(60).collect::<String>())
86        } else {
87            format!("Observation: {}", self.tool_name)
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn observation_creation() {
98        let obs = Observation::new(
99            "bash".to_string(),
100            serde_json::json!({"command": "cargo test"}),
101            "test result: ok".to_string(),
102            Some(ToolId::new("tool_123")),
103            "sess-123".to_string(),
104        );
105
106        assert_eq!(obs.tool_name, "bash");
107        assert_eq!(
108            obs.tool_use_id.map(|v| v.as_str().to_string()),
109            Some("tool_123".to_string())
110        );
111        assert_eq!(obs.session_id, "sess-123");
112        assert!(obs.context.is_none());
113    }
114
115    #[test]
116    fn observation_with_context() {
117        let obs = Observation::new(
118            "bash".to_string(),
119            serde_json::json!({}),
120            "output".to_string(),
121            None,
122            "sess-123".to_string(),
123        )
124        .with_context("Testing changes".to_string());
125
126        assert_eq!(obs.context, Some("Testing changes".to_string()));
127    }
128
129    #[test]
130    fn observation_description() {
131        let obs = Observation::new(
132            "bash".to_string(),
133            serde_json::json!({"command": "test"}),
134            "success".to_string(),
135            Some(ToolId::new("tool_7")),
136            "sess-123".to_string(),
137        )
138        .with_context("Test execution".to_string());
139
140        let desc = obs.to_description();
141        assert!(desc.contains("bash"));
142        assert!(desc.contains("tool_7"));
143        assert!(desc.contains("success"));
144        assert!(desc.contains("Test execution"));
145    }
146
147    #[test]
148    fn title_with_context() {
149        let obs = Observation::new(
150            "bash".to_string(),
151            serde_json::json!({}),
152            "output".to_string(),
153            None,
154            "sess-123".to_string(),
155        )
156        .with_context("Fixed parser edge case".to_string());
157
158        let title = obs.to_title();
159        assert!(title.contains("Fixed parser edge case"));
160        assert!(title.starts_with("Observation:"));
161    }
162}