Skip to main content

oxi_agent/tools/
memory_reflect.rs

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