Skip to main content

zeph_core/
memory_tools.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write as _;
5use std::sync::Arc;
6
7use zeph_memory::embedding_store::SearchFilter;
8use zeph_memory::semantic::SemanticMemory;
9use zeph_memory::types::ConversationId;
10use zeph_tools::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
11use zeph_tools::registry::{InvocationHint, ToolDef};
12
13use zeph_sanitizer::memory_validation::MemoryWriteValidator;
14
15#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
16struct MemorySearchParams {
17    /// Natural language query to search memory for relevant past messages and facts.
18    query: String,
19    /// Maximum number of results to return (default: 5, max: 20).
20    #[serde(default = "default_limit")]
21    limit: u32,
22}
23
24fn default_limit() -> u32 {
25    5
26}
27
28#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
29struct MemorySaveParams {
30    /// The content to save to long-term memory. Should be a concise, self-contained fact or note.
31    content: String,
32    /// Role label for the saved message (default: "assistant").
33    #[serde(default = "default_role")]
34    role: String,
35}
36
37fn default_role() -> String {
38    "assistant".into()
39}
40
41/// Executes `memory_search` and `memory_save` tool calls on behalf of the agent.
42pub struct MemoryToolExecutor {
43    memory: Arc<SemanticMemory>,
44    conversation_id: ConversationId,
45    validator: MemoryWriteValidator,
46    /// When `true` the backing store is in-memory (bare mode) and saves do not persist across sessions.
47    ephemeral: bool,
48}
49
50impl MemoryToolExecutor {
51    /// Create with default validator and persistent (non-ephemeral) semantics.
52    #[must_use]
53    pub fn new(memory: Arc<SemanticMemory>, conversation_id: ConversationId) -> Self {
54        Self {
55            memory,
56            conversation_id,
57            validator: MemoryWriteValidator::new(
58                zeph_sanitizer::memory_validation::MemoryWriteValidationConfig::default(),
59            ),
60            ephemeral: false,
61        }
62    }
63
64    /// Create with a custom validator (used when security config is loaded).
65    #[must_use]
66    pub fn with_validator(
67        memory: Arc<SemanticMemory>,
68        conversation_id: ConversationId,
69        validator: MemoryWriteValidator,
70    ) -> Self {
71        Self {
72            memory,
73            conversation_id,
74            validator,
75            ephemeral: false,
76        }
77    }
78
79    /// Mark this executor as ephemeral (bare mode).
80    ///
81    /// When set, `memory_save` reports that the content is session-only and will not be
82    /// available after the session ends.
83    #[must_use]
84    pub fn ephemeral(mut self) -> Self {
85        self.ephemeral = true;
86        self
87    }
88}
89
90impl ToolExecutor for MemoryToolExecutor {
91    fn tool_definitions(&self) -> Vec<ToolDef> {
92        vec![
93            ToolDef {
94                id: "memory_search".into(),
95                description: "Search long-term memory for relevant past messages, facts, and session summaries. Use to recall facts, preferences, or information the user provided during this or previous conversations.\n\nParameters: query (string, required) - natural language search query; limit (integer, optional) - max results 1-20 (default: 5)\nReturns: ranked list of memory entries with similarity scores and timestamps\nErrors: Execution on database failure\nExample: {\"query\": \"user preference for output format\", \"limit\": 5}".into(),
96                schema: schemars::schema_for!(MemorySearchParams),
97                invocation: InvocationHint::ToolCall,
98                output_schema: None,
99                server_id: None,
100            },
101            ToolDef {
102                id: "memory_save".into(),
103                description: "Save a fact or note to long-term memory for cross-session recall. Use sparingly for key decisions, user preferences, or critical context worth remembering across sessions.\n\nParameters: content (string, required) - concise, self-contained fact or note; role (string, optional) - message role label (default: \"assistant\")\nReturns: confirmation with saved entry ID\nErrors: Execution on database failure; InvalidParams if content is empty\nExample: {\"content\": \"User prefers JSON output over YAML\", \"role\": \"assistant\"}".into(),
104                schema: schemars::schema_for!(MemorySaveParams),
105                invocation: InvocationHint::ToolCall,
106                output_schema: None,
107                server_id: None,
108            },
109        ]
110    }
111
112    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
113        Ok(None)
114    }
115
116    #[allow(clippy::too_many_lines)] // two tools with validation, search, and multi-source aggregation
117    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
118        match call.tool_id.as_str() {
119            "memory_search" => {
120                let params: MemorySearchParams = deserialize_params(&call.params)?;
121                let limit = params.limit.clamp(1, 20) as usize;
122
123                let filter = Some(SearchFilter {
124                    conversation_id: Some(self.conversation_id),
125                    role: None,
126                    category: None,
127                });
128
129                let recalled = self
130                    .memory
131                    .recall(&params.query, limit, filter)
132                    .await
133                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
134
135                let key_facts = self
136                    .memory
137                    .search_key_facts(&params.query, limit, Some(self.conversation_id))
138                    .await
139                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
140
141                let summaries = self
142                    .memory
143                    .search_session_summaries(&params.query, limit, Some(self.conversation_id))
144                    .await
145                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
146
147                let mut output = String::new();
148
149                let _ = writeln!(output, "## Recalled Messages ({} results)", recalled.len());
150                for r in &recalled {
151                    let role = match r.message.role {
152                        zeph_llm::provider::Role::Assistant => "assistant",
153                        zeph_llm::provider::Role::System => "system",
154                        zeph_llm::provider::Role::User | _ => "user",
155                    };
156                    let content = r.message.content.trim();
157                    let _ = writeln!(output, "[score: {:.2}] {role}: {content}", r.score);
158                }
159
160                let _ = writeln!(output);
161                let _ = writeln!(output, "## Key Facts ({} results)", key_facts.len());
162                for fact in &key_facts {
163                    let _ = writeln!(output, "- {fact}");
164                }
165
166                let _ = writeln!(output);
167                let _ = writeln!(output, "## Session Summaries ({} results)", summaries.len());
168                for s in &summaries {
169                    let _ = writeln!(
170                        output,
171                        "[conv #{}, score: {:.2}] {}",
172                        s.conversation_id, s.score, s.summary_text
173                    );
174                }
175
176                Ok(Some(ToolOutput {
177                    tool_name: zeph_common::ToolName::new("memory_search"),
178                    summary: output,
179                    blocks_executed: 1,
180                    filter_stats: None,
181                    diff: None,
182                    streamed: false,
183                    terminal_id: None,
184                    locations: None,
185                    raw_response: None,
186                    claim_source: Some(zeph_tools::ClaimSource::Memory),
187                    ..Default::default()
188                }))
189            }
190            "memory_save" => {
191                let params: MemorySaveParams = deserialize_params(&call.params)?;
192
193                if params.content.is_empty() {
194                    return Err(ToolError::InvalidParams {
195                        message: "content must not be empty".to_owned(),
196                    });
197                }
198                if params.content.len() > 4096 {
199                    return Err(ToolError::InvalidParams {
200                        message: "content exceeds maximum length of 4096 characters".to_owned(),
201                    });
202                }
203
204                // Schema validation: check content before writing to memory.
205                if let Err(e) = self.validator.validate_memory_save(&params.content) {
206                    return Err(ToolError::InvalidParams {
207                        message: format!("memory write rejected: {e}"),
208                    });
209                }
210
211                let role = params.role.as_str();
212
213                // Explicit user-directed saves bypass goal-conditioned scoring (goal_text = None).
214                let message_id_opt = self
215                    .memory
216                    .remember(self.conversation_id, role, &params.content, None)
217                    .await
218                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
219
220                let summary = match message_id_opt {
221                    Some(message_id) => {
222                        if self.ephemeral {
223                            format!(
224                                "Saved to session memory (message_id: {message_id}, conversation: {}). Ephemeral — not available after session ends.",
225                                self.conversation_id
226                            )
227                        } else {
228                            format!(
229                                "Saved to memory (message_id: {message_id}, conversation: {}). Content will be available for future recall.",
230                                self.conversation_id
231                            )
232                        }
233                    }
234                    None => "Memory admission rejected: message did not meet quality threshold."
235                        .to_owned(),
236                };
237
238                Ok(Some(ToolOutput {
239                    tool_name: zeph_common::ToolName::new("memory_save"),
240                    summary,
241                    blocks_executed: 1,
242                    filter_stats: None,
243                    diff: None,
244                    streamed: false,
245                    terminal_id: None,
246                    locations: None,
247                    raw_response: None,
248                    claim_source: Some(zeph_tools::ClaimSource::Memory),
249                    ..Default::default()
250                }))
251            }
252            _ => Ok(None),
253        }
254    }
255
256    zeph_tools::tool_executor_no_inner_defaults!();
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use zeph_llm::any::AnyProvider;
263    use zeph_llm::mock::MockProvider;
264    use zeph_memory::semantic::SemanticMemory;
265
266    async fn make_memory() -> SemanticMemory {
267        SemanticMemory::with_sqlite_backend(
268            ":memory:",
269            AnyProvider::Mock(MockProvider::default()),
270            "test-model",
271            0.7,
272            0.3,
273        )
274        .await
275        .unwrap()
276    }
277
278    fn make_executor(memory: SemanticMemory) -> MemoryToolExecutor {
279        MemoryToolExecutor::new(Arc::new(memory), ConversationId(1))
280    }
281
282    #[tokio::test]
283    async fn tool_definitions_returns_two_tools() {
284        let memory = make_memory().await;
285        let executor = make_executor(memory);
286        let defs = executor.tool_definitions();
287        assert_eq!(defs.len(), 2);
288        assert_eq!(defs[0].id.as_ref(), "memory_search");
289        assert_eq!(defs[1].id.as_ref(), "memory_save");
290    }
291
292    #[tokio::test]
293    async fn execute_always_returns_none() {
294        let memory = make_memory().await;
295        let executor = make_executor(memory);
296        let result = executor.execute("any response").await.unwrap();
297        assert!(result.is_none());
298    }
299
300    #[tokio::test]
301    async fn execute_tool_call_unknown_returns_none() {
302        let memory = make_memory().await;
303        let executor = make_executor(memory);
304        let call = ToolCall {
305            tool_id: zeph_common::ToolName::new("unknown_tool"),
306            params: serde_json::Map::new(),
307            caller_id: None,
308            context: None,
309
310            tool_call_id: String::new(),
311            skill_name: None,
312        };
313        let result = executor.execute_tool_call(&call).await.unwrap();
314        assert!(result.is_none());
315    }
316
317    #[tokio::test]
318    async fn memory_search_returns_output() {
319        let memory = make_memory().await;
320        let executor = make_executor(memory);
321        let mut params = serde_json::Map::new();
322        params.insert(
323            "query".into(),
324            serde_json::Value::String("test query".into()),
325        );
326        let call = ToolCall {
327            tool_id: zeph_common::ToolName::new("memory_search"),
328            params,
329            caller_id: None,
330            context: None,
331
332            tool_call_id: String::new(),
333            skill_name: None,
334        };
335        let result = executor.execute_tool_call(&call).await.unwrap();
336        assert!(result.is_some());
337        let output = result.unwrap();
338        assert_eq!(output.tool_name, "memory_search");
339        assert!(output.summary.contains("Recalled Messages"));
340        assert!(output.summary.contains("Key Facts"));
341        assert!(output.summary.contains("Session Summaries"));
342    }
343
344    #[tokio::test]
345    async fn memory_save_stores_and_returns_confirmation() {
346        let memory = make_memory().await;
347        let sqlite = memory.sqlite().clone();
348        // Create conversation first
349        let cid = sqlite.create_conversation().await.unwrap();
350        let executor = MemoryToolExecutor::new(Arc::new(memory), cid);
351
352        let mut params = serde_json::Map::new();
353        params.insert(
354            "content".into(),
355            serde_json::Value::String("User prefers dark mode".into()),
356        );
357        let call = ToolCall {
358            tool_id: zeph_common::ToolName::new("memory_save"),
359            params,
360            caller_id: None,
361            context: None,
362
363            tool_call_id: String::new(),
364            skill_name: None,
365        };
366        let result = executor.execute_tool_call(&call).await.unwrap();
367        assert!(result.is_some());
368        let output = result.unwrap();
369        assert!(output.summary.contains("Saved to memory"));
370        assert!(output.summary.contains("message_id:"));
371    }
372
373    #[tokio::test]
374    async fn memory_save_empty_content_returns_error() {
375        let memory = make_memory().await;
376        let executor = make_executor(memory);
377        let mut params = serde_json::Map::new();
378        params.insert("content".into(), serde_json::Value::String(String::new()));
379        let call = ToolCall {
380            tool_id: zeph_common::ToolName::new("memory_save"),
381            params,
382            caller_id: None,
383            context: None,
384
385            tool_call_id: String::new(),
386            skill_name: None,
387        };
388        let result = executor.execute_tool_call(&call).await;
389        assert!(result.is_err());
390    }
391
392    #[tokio::test]
393    async fn memory_save_oversized_content_returns_error() {
394        let memory = make_memory().await;
395        let executor = make_executor(memory);
396        let mut params = serde_json::Map::new();
397        params.insert(
398            "content".into(),
399            serde_json::Value::String("x".repeat(4097)),
400        );
401        let call = ToolCall {
402            tool_id: zeph_common::ToolName::new("memory_save"),
403            params,
404            caller_id: None,
405            context: None,
406
407            tool_call_id: String::new(),
408            skill_name: None,
409        };
410        let result = executor.execute_tool_call(&call).await;
411        assert!(result.is_err());
412    }
413
414    #[tokio::test]
415    async fn memory_save_ephemeral_returns_session_only_message() {
416        let memory = make_memory().await;
417        let sqlite = memory.sqlite().clone();
418        let cid = sqlite.create_conversation().await.unwrap();
419        let executor = MemoryToolExecutor::new(Arc::new(memory), cid).ephemeral();
420
421        let mut params = serde_json::Map::new();
422        params.insert(
423            "content".into(),
424            serde_json::Value::String("temp fact".into()),
425        );
426        let call = ToolCall {
427            tool_id: zeph_common::ToolName::new("memory_save"),
428            params,
429            caller_id: None,
430            context: None,
431            tool_call_id: String::new(),
432            skill_name: None,
433        };
434        let output = executor.execute_tool_call(&call).await.unwrap().unwrap();
435        assert!(
436            output.summary.contains("Ephemeral"),
437            "bare-mode save must mention ephemeral semantics; got: {}",
438            output.summary
439        );
440        assert!(
441            !output.summary.contains("available for future recall"),
442            "bare-mode save must not claim cross-session persistence; got: {}",
443            output.summary
444        );
445    }
446
447    /// `memory_search` description must mention user-provided facts so the model
448    /// prefers it over `search_code` for recalling information from conversation (#2475).
449    #[tokio::test]
450    async fn memory_search_description_mentions_user_provided_facts() {
451        let memory = make_memory().await;
452        let executor = make_executor(memory);
453        let defs = executor.tool_definitions();
454        let memory_search = defs
455            .iter()
456            .find(|d| d.id.as_ref() == "memory_search")
457            .unwrap();
458        assert!(
459            memory_search
460                .description
461                .contains("user provided during this or previous conversations"),
462            "memory_search description must contain disambiguation phrase; got: {}",
463            memory_search.description
464        );
465    }
466}