1use 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 query: String,
19 #[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 content: String,
32 #[serde(default = "default_role")]
34 role: String,
35}
36
37fn default_role() -> String {
38 "assistant".into()
39}
40
41pub struct MemoryToolExecutor {
43 memory: Arc<SemanticMemory>,
44 conversation_id: ConversationId,
45 validator: MemoryWriteValidator,
46 ephemeral: bool,
48}
49
50impl MemoryToolExecutor {
51 #[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 #[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 #[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)] 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(¶ms.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(¶ms.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(¶ms.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 if let Err(e) = self.validator.validate_memory_save(¶ms.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 let message_id_opt = self
215 .memory
216 .remember(self.conversation_id, role, ¶ms.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 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 #[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}