Skip to main content

oxi_agent/tools/
memory_reflect.rs

1//! `memory_reflect` tool — persist a session summary to memory.
2
3use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
7
8/// Tool that stores a session reflection/summary to the configured
9/// [`MemoryBackend`].
10///
11/// When a `summary` is supplied it is persisted as a `summary` memory scoped
12/// to the current session. Without one the tool returns a placeholder —
13/// automatic LLM summarisation is a future enhancement.
14///
15/// Requires `ctx.memory` to be set; otherwise returns an error.
16pub struct MemoryReflectTool;
17
18#[async_trait]
19impl AgentTool for MemoryReflectTool {
20    fn name(&self) -> &str {
21        "memory_reflect"
22    }
23
24    fn label(&self) -> &str {
25        "Memory Reflect"
26    }
27
28    fn description(&self) -> &str {
29        "Save a session summary or reflection to long-term memory. \
30         A non-empty `summary` is required."
31    }
32
33    fn essential(&self) -> bool {
34        false
35    }
36
37    fn parameters_schema(&self) -> Value {
38        json!({
39            "type": "object",
40            "properties": {
41                "summary": {
42                    "type": "string",
43                    "description": "Session summary to persist to long-term memory."
44                }
45            },
46            "required": ["summary"]
47        })
48    }
49
50    async fn execute(
51        &self,
52        _tool_call_id: &str,
53        params: Value,
54        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
55        ctx: &ToolContext,
56    ) -> Result<AgentToolResult, ToolError> {
57        let backend = ctx.memory.as_ref().ok_or("Memory not configured")?;
58
59        let subject = ctx.session_id.as_deref().unwrap_or("default");
60
61        let summary = params
62            .get("summary")
63            .and_then(|v| v.as_str())
64            .ok_or("missing required parameter: summary")?;
65        if summary.trim().is_empty() {
66            return Err("summary must not be empty".into());
67        }
68        backend.put(summary, "summary", subject).await?;
69        Ok(AgentToolResult::success(format!(
70            "Reflected session summary to memory (subject: {}).",
71            subject
72        )))
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::tools::MemoryBackend;
80    use parking_lot::Mutex;
81    use std::future::Future;
82    use std::pin::Pin;
83    use std::sync::Arc;
84
85    /// Records every `put` call; the remaining trait methods are stubbed.
86    #[derive(Debug)]
87    struct MockMemory {
88        puts: Mutex<Vec<(String, String, String)>>,
89    }
90
91    impl MockMemory {
92        fn new() -> Self {
93            Self {
94                puts: Mutex::new(vec![]),
95            }
96        }
97    }
98
99    impl MemoryBackend for MockMemory {
100        fn put<'a>(
101            &'a self,
102            content: &'a str,
103            kind: &'a str,
104            subject: &'a str,
105        ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
106            self.puts
107                .lock()
108                .push((content.into(), kind.into(), subject.into()));
109            Box::pin(async move { Ok("mem-1".to_string()) })
110        }
111
112        fn search<'a>(
113            &'a self,
114            _query: &'a str,
115            _k: usize,
116        ) -> Pin<
117            Box<dyn Future<Output = Result<Vec<crate::tools::MemoryItem>, ToolError>> + Send + 'a>,
118        > {
119            Box::pin(async move { Ok(vec![]) })
120        }
121
122        fn list<'a>(
123            &'a self,
124            _subject: &'a str,
125        ) -> Pin<
126            Box<dyn Future<Output = Result<Vec<crate::tools::MemoryItem>, ToolError>> + Send + 'a>,
127        > {
128            Box::pin(async move { Ok(vec![]) })
129        }
130
131        fn delete<'a>(
132            &'a self,
133            _id: &'a str,
134        ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
135            Box::pin(async move { Ok(()) })
136        }
137    }
138
139    #[tokio::test]
140    async fn reflect_with_summary_persists() {
141        let mock = Arc::new(MockMemory::new());
142        let ctx = ToolContext::default()
143            .with_session("s1")
144            .with_memory(mock.clone());
145        let result = MemoryReflectTool
146            .execute("c1", json!({"summary": "did X"}), None, &ctx)
147            .await
148            .unwrap();
149        assert!(result.success);
150        assert!(result.output.contains("Reflected session summary"));
151        let puts = mock.puts.lock();
152        assert_eq!(puts.len(), 1);
153        assert_eq!(puts[0].0, "did X");
154        assert_eq!(puts[0].1, "summary");
155        assert_eq!(puts[0].2, "s1");
156    }
157
158    #[tokio::test]
159    async fn reflect_without_summary_errors() {
160        let mock = Arc::new(MockMemory::new());
161        let ctx = ToolContext::default().with_memory(mock.clone());
162        let err = MemoryReflectTool
163            .execute("c1", json!({}), None, &ctx)
164            .await
165            .unwrap_err();
166        assert!(err.contains("summary"));
167        assert!(mock.puts.lock().is_empty());
168    }
169
170    #[tokio::test]
171    async fn reflect_rejects_empty_summary() {
172        let mock = Arc::new(MockMemory::new());
173        let ctx = ToolContext::default().with_memory(mock.clone());
174        let err = MemoryReflectTool
175            .execute("c1", json!({"summary": "   "}), None, &ctx)
176            .await
177            .unwrap_err();
178        assert!(err.contains("empty"));
179        assert!(mock.puts.lock().is_empty());
180    }
181
182    #[tokio::test]
183    async fn reflect_errors_when_memory_not_configured() {
184        let ctx = ToolContext::default();
185        let err = MemoryReflectTool
186            .execute("c1", json!({"summary": "x"}), None, &ctx)
187            .await
188            .unwrap_err();
189        assert_eq!(err, "Memory not configured");
190    }
191}