1use std::fmt::Write as _;
5use std::sync::Arc;
6
7use parking_lot::RwLock;
8use zeph_memory::embedding_store::SearchFilter;
9use zeph_memory::semantic::SemanticMemory;
10use zeph_memory::types::ConversationId;
11use zeph_tools::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
12use zeph_tools::registry::{InvocationHint, ToolDef};
13use zeph_tools::{CheckpointActionResult, CheckpointListResult};
14
15use zeph_sanitizer::ContentTrustLevel;
16use zeph_sanitizer::memory_validation::MemoryWriteValidator;
17
18pub type MemoryConsentTrustSlot = Arc<RwLock<u8>>;
44
45struct ConsentGate {
47 trust_slot: MemoryConsentTrustSlot,
48 confirm_threshold: ContentTrustLevel,
49}
50
51#[must_use]
72pub fn parse_consent_trust_level(s: &str) -> ContentTrustLevel {
73 ContentTrustLevel::from_str_opt(s).unwrap_or_else(|| {
74 tracing::warn!(
75 value = s,
76 "invalid memory.consent_gate trust-tier value, falling back to external_untrusted"
77 );
78 ContentTrustLevel::ExternalUntrusted
79 })
80}
81
82#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
83struct MemorySearchParams {
84 query: String,
86 #[serde(default = "default_limit")]
88 limit: u32,
89}
90
91fn default_limit() -> u32 {
92 5
93}
94
95#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
96struct MemorySaveParams {
97 content: String,
99 #[serde(default = "default_role")]
101 role: String,
102}
103
104fn default_role() -> String {
105 "assistant".into()
106}
107
108pub struct MemoryToolExecutor {
110 memory: Arc<SemanticMemory>,
111 conversation_id: ConversationId,
112 validator: MemoryWriteValidator,
113 ephemeral: bool,
115 consent_gate: Option<ConsentGate>,
118 audit_logger: Option<Arc<zeph_tools::AuditLogger>>,
121 audit_all: bool,
128}
129
130impl MemoryToolExecutor {
131 #[must_use]
133 pub fn new(memory: Arc<SemanticMemory>, conversation_id: ConversationId) -> Self {
134 Self {
135 memory,
136 conversation_id,
137 validator: MemoryWriteValidator::new(
138 zeph_sanitizer::memory_validation::MemoryWriteValidationConfig::default(),
139 ),
140 ephemeral: false,
141 consent_gate: None,
142 audit_logger: None,
143 audit_all: true,
144 }
145 }
146
147 #[must_use]
149 pub fn with_validator(
150 memory: Arc<SemanticMemory>,
151 conversation_id: ConversationId,
152 validator: MemoryWriteValidator,
153 ) -> Self {
154 Self {
155 memory,
156 conversation_id,
157 validator,
158 ephemeral: false,
159 consent_gate: None,
160 audit_logger: None,
161 audit_all: true,
162 }
163 }
164
165 #[must_use]
170 pub fn ephemeral(mut self) -> Self {
171 self.ephemeral = true;
172 self
173 }
174
175 #[must_use]
183 pub fn with_consent_gate(
184 mut self,
185 trust_slot: MemoryConsentTrustSlot,
186 confirm_threshold: ContentTrustLevel,
187 ) -> Self {
188 self.consent_gate = Some(ConsentGate {
189 trust_slot,
190 confirm_threshold,
191 });
192 self
193 }
194
195 #[must_use]
198 pub fn with_audit(mut self, logger: Arc<zeph_tools::AuditLogger>) -> Self {
199 self.audit_logger = Some(logger);
200 self
201 }
202
203 #[must_use]
208 pub fn with_audit_all(mut self, audit_all: bool) -> Self {
209 self.audit_all = audit_all;
210 self
211 }
212
213 fn current_trust_level(&self) -> ContentTrustLevel {
233 self.consent_gate
234 .as_ref()
235 .map_or(ContentTrustLevel::Trusted, |gate| {
236 ContentTrustLevel::from_ordinal(*gate.trust_slot.read())
237 })
238 }
239
240 async fn do_memory_save(
245 &self,
246 params: &MemorySaveParams,
247 ) -> Result<Option<ToolOutput>, ToolError> {
248 if params.content.is_empty() {
249 return Err(ToolError::InvalidParams {
250 message: "content must not be empty".to_owned(),
251 });
252 }
253 if params.content.len() > 4096 {
254 return Err(ToolError::InvalidParams {
255 message: "content exceeds maximum length of 4096 characters".to_owned(),
256 });
257 }
258
259 if let Err(e) = self.validator.validate_memory_save(¶ms.content) {
261 return Err(ToolError::InvalidParams {
262 message: format!("memory write rejected: {e}"),
263 });
264 }
265
266 let role = params.role.as_str();
267 let trust_level = self.current_trust_level();
268
269 let message_id_opt = self
273 .memory
274 .remember_with_provenance(
275 self.conversation_id,
276 role,
277 ¶ms.content,
278 None,
279 None,
280 Some(trust_level.as_str()),
281 )
282 .await
283 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
284
285 if self.audit_all
286 && let Some(logger) = &self.audit_logger
287 {
288 let preview: String = params.content.chars().take(120).collect();
289 let entry = zeph_tools::AuditEntry::memory_write(
290 "memory_save",
291 format!("save: {preview}"),
292 None,
293 Some(trust_level.as_str()),
294 );
295 logger.log(&entry).await;
296 }
297
298 let summary = match message_id_opt {
299 Some(message_id) => {
300 if self.ephemeral {
301 format!(
302 "Saved to session memory (message_id: {message_id}, conversation: {}). Ephemeral — not available after session ends.",
303 self.conversation_id
304 )
305 } else {
306 format!(
307 "Saved to memory (message_id: {message_id}, conversation: {}). Content will be available for future recall.",
308 self.conversation_id
309 )
310 }
311 }
312 None => "Memory admission rejected: message did not meet quality threshold.".to_owned(),
313 };
314
315 Ok(Some(ToolOutput {
316 tool_name: zeph_common::ToolName::new("memory_save"),
317 summary,
318 blocks_executed: 1,
319 filter_stats: None,
320 diff: None,
321 streamed: false,
322 terminal_id: None,
323 locations: None,
324 raw_response: None,
325 claim_source: Some(zeph_tools::ClaimSource::Memory),
326 ..Default::default()
327 }))
328 }
329}
330
331impl ToolExecutor for MemoryToolExecutor {
332 fn tool_definitions(&self) -> Vec<ToolDef> {
333 vec![
334 ToolDef {
335 id: "memory_search".into(),
336 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(),
337 schema: schemars::schema_for!(MemorySearchParams),
338 invocation: InvocationHint::ToolCall,
339 output_schema: None,
340 server_id: None,
341 },
342 ToolDef {
343 id: "memory_save".into(),
344 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(),
345 schema: schemars::schema_for!(MemorySaveParams),
346 invocation: InvocationHint::ToolCall,
347 output_schema: None,
348 server_id: None,
349 },
350 ]
351 }
352
353 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
354 Ok(None)
355 }
356
357 #[allow(clippy::too_many_lines)] async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
359 match call.tool_id.as_str() {
360 "memory_search" => {
361 let params: MemorySearchParams = deserialize_params(&call.params)?;
362 let limit = params.limit.clamp(1, 20) as usize;
363
364 let filter = Some(SearchFilter {
365 conversation_id: Some(self.conversation_id),
366 role: None,
367 category: None,
368 });
369
370 let recalled = self
371 .memory
372 .recall(¶ms.query, limit, filter)
373 .await
374 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
375
376 let key_facts = self
377 .memory
378 .search_key_facts(¶ms.query, limit, Some(self.conversation_id))
379 .await
380 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
381
382 let summaries = self
383 .memory
384 .search_session_summaries(¶ms.query, limit, Some(self.conversation_id))
385 .await
386 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
387
388 let mut output = String::new();
389
390 let _ = writeln!(output, "## Recalled Messages ({} results)", recalled.len());
391 for r in &recalled {
392 let role = match r.message.role {
393 zeph_llm::provider::Role::Assistant => "assistant",
394 zeph_llm::provider::Role::System => "system",
395 zeph_llm::provider::Role::User | _ => "user",
396 };
397 let content = r.message.content.trim();
398 let _ = writeln!(output, "[score: {:.2}] {role}: {content}", r.score);
399 }
400
401 let _ = writeln!(output);
402 let _ = writeln!(output, "## Key Facts ({} results)", key_facts.len());
403 for fact in &key_facts {
404 let _ = writeln!(output, "- {fact}");
405 }
406
407 let _ = writeln!(output);
408 let _ = writeln!(output, "## Session Summaries ({} results)", summaries.len());
409 for s in &summaries {
410 let _ = writeln!(
411 output,
412 "[conv #{}, score: {:.2}] {}",
413 s.conversation_id, s.score, s.summary_text
414 );
415 }
416
417 Ok(Some(ToolOutput {
418 tool_name: zeph_common::ToolName::new("memory_search"),
419 summary: output,
420 blocks_executed: 1,
421 filter_stats: None,
422 diff: None,
423 streamed: false,
424 terminal_id: None,
425 locations: None,
426 raw_response: None,
427 claim_source: Some(zeph_tools::ClaimSource::Memory),
428 ..Default::default()
429 }))
430 }
431 "memory_save" => {
432 let params: MemorySaveParams = deserialize_params(&call.params)?;
433
434 if let Some(gate) = &self.consent_gate
441 && self.current_trust_level() >= gate.confirm_threshold
442 {
443 let preview: String = params.content.chars().take(80).collect();
444 let ellipsis = if params.content.chars().count() > 80 {
445 "…"
446 } else {
447 ""
448 };
449 let trust = self.current_trust_level();
450 return Err(ToolError::ConfirmationRequired {
451 command: format!(
452 "Save to memory: {preview}{ellipsis} [source: {}]",
453 trust.as_str()
454 ),
455 });
456 }
457
458 self.do_memory_save(¶ms).await
459 }
460 _ => Ok(None),
461 }
462 }
463
464 fn requires_confirmation(&self, call: &ToolCall) -> bool {
465 if call.tool_id.as_str() != "memory_save" {
466 return false;
467 }
468 let Some(gate) = &self.consent_gate else {
469 return false;
470 };
471 self.current_trust_level() >= gate.confirm_threshold
472 }
473
474 async fn execute_tool_call_confirmed(
479 &self,
480 call: &ToolCall,
481 ) -> Result<Option<ToolOutput>, ToolError> {
482 if call.tool_id.as_str() == "memory_save" {
483 let params: MemorySaveParams = deserialize_params(&call.params)?;
484 return self.do_memory_save(¶ms).await;
485 }
486 self.execute_tool_call(call).await
487 }
488
489 fn checkpoint_undo(&self, _n: usize) -> CheckpointActionResult {
490 CheckpointActionResult::unsupported()
491 }
492
493 fn checkpoint_redo(&self) -> CheckpointActionResult {
494 CheckpointActionResult::unsupported()
495 }
496
497 fn checkpoint_list(&self) -> CheckpointListResult {
498 CheckpointListResult::default()
499 }
500
501 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
502 false
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use zeph_llm::any::AnyProvider;
510 use zeph_llm::mock::MockProvider;
511 use zeph_memory::semantic::SemanticMemory;
512
513 async fn make_memory() -> SemanticMemory {
514 SemanticMemory::with_sqlite_backend(
515 ":memory:",
516 AnyProvider::Mock(MockProvider::default()),
517 "test-model",
518 0.7,
519 0.3,
520 )
521 .await
522 .unwrap()
523 }
524
525 fn make_executor(memory: SemanticMemory) -> MemoryToolExecutor {
526 MemoryToolExecutor::new(Arc::new(memory), ConversationId(1))
527 }
528
529 #[tokio::test]
530 async fn tool_definitions_returns_two_tools() {
531 let memory = make_memory().await;
532 let executor = make_executor(memory);
533 let defs = executor.tool_definitions();
534 assert_eq!(defs.len(), 2);
535 assert_eq!(defs[0].id.as_ref(), "memory_search");
536 assert_eq!(defs[1].id.as_ref(), "memory_save");
537 }
538
539 #[tokio::test]
540 async fn execute_always_returns_none() {
541 let memory = make_memory().await;
542 let executor = make_executor(memory);
543 let result = executor.execute("any response").await.unwrap();
544 assert!(result.is_none());
545 }
546
547 #[tokio::test]
548 async fn execute_tool_call_unknown_returns_none() {
549 let memory = make_memory().await;
550 let executor = make_executor(memory);
551 let call = ToolCall {
552 tool_id: zeph_common::ToolName::new("unknown_tool"),
553 params: serde_json::Map::new(),
554 caller_id: None,
555 context: None,
556
557 tool_call_id: String::new(),
558 skill_name: None,
559 };
560 let result = executor.execute_tool_call(&call).await.unwrap();
561 assert!(result.is_none());
562 }
563
564 #[tokio::test]
565 async fn memory_search_returns_output() {
566 let memory = make_memory().await;
567 let executor = make_executor(memory);
568 let mut params = serde_json::Map::new();
569 params.insert(
570 "query".into(),
571 serde_json::Value::String("test query".into()),
572 );
573 let call = ToolCall {
574 tool_id: zeph_common::ToolName::new("memory_search"),
575 params,
576 caller_id: None,
577 context: None,
578
579 tool_call_id: String::new(),
580 skill_name: None,
581 };
582 let result = executor.execute_tool_call(&call).await.unwrap();
583 assert!(result.is_some());
584 let output = result.unwrap();
585 assert_eq!(output.tool_name, "memory_search");
586 assert!(output.summary.contains("Recalled Messages"));
587 assert!(output.summary.contains("Key Facts"));
588 assert!(output.summary.contains("Session Summaries"));
589 }
590
591 #[tokio::test]
592 async fn memory_save_stores_and_returns_confirmation() {
593 let memory = make_memory().await;
594 let sqlite = memory.sqlite().clone();
595 let cid = sqlite.create_conversation().await.unwrap();
597 let executor = MemoryToolExecutor::new(Arc::new(memory), cid);
598
599 let mut params = serde_json::Map::new();
600 params.insert(
601 "content".into(),
602 serde_json::Value::String("User prefers dark mode".into()),
603 );
604 let call = ToolCall {
605 tool_id: zeph_common::ToolName::new("memory_save"),
606 params,
607 caller_id: None,
608 context: None,
609
610 tool_call_id: String::new(),
611 skill_name: None,
612 };
613 let result = executor.execute_tool_call(&call).await.unwrap();
614 assert!(result.is_some());
615 let output = result.unwrap();
616 assert!(output.summary.contains("Saved to memory"));
617 assert!(output.summary.contains("message_id:"));
618 }
619
620 #[tokio::test]
621 async fn memory_save_empty_content_returns_error() {
622 let memory = make_memory().await;
623 let executor = make_executor(memory);
624 let mut params = serde_json::Map::new();
625 params.insert("content".into(), serde_json::Value::String(String::new()));
626 let call = ToolCall {
627 tool_id: zeph_common::ToolName::new("memory_save"),
628 params,
629 caller_id: None,
630 context: None,
631
632 tool_call_id: String::new(),
633 skill_name: None,
634 };
635 let result = executor.execute_tool_call(&call).await;
636 assert!(result.is_err());
637 }
638
639 #[tokio::test]
640 async fn memory_save_oversized_content_returns_error() {
641 let memory = make_memory().await;
642 let executor = make_executor(memory);
643 let mut params = serde_json::Map::new();
644 params.insert(
645 "content".into(),
646 serde_json::Value::String("x".repeat(4097)),
647 );
648 let call = ToolCall {
649 tool_id: zeph_common::ToolName::new("memory_save"),
650 params,
651 caller_id: None,
652 context: None,
653
654 tool_call_id: String::new(),
655 skill_name: None,
656 };
657 let result = executor.execute_tool_call(&call).await;
658 assert!(result.is_err());
659 }
660
661 #[tokio::test]
662 async fn memory_save_ephemeral_returns_session_only_message() {
663 let memory = make_memory().await;
664 let sqlite = memory.sqlite().clone();
665 let cid = sqlite.create_conversation().await.unwrap();
666 let executor = MemoryToolExecutor::new(Arc::new(memory), cid).ephemeral();
667
668 let mut params = serde_json::Map::new();
669 params.insert(
670 "content".into(),
671 serde_json::Value::String("temp fact".into()),
672 );
673 let call = ToolCall {
674 tool_id: zeph_common::ToolName::new("memory_save"),
675 params,
676 caller_id: None,
677 context: None,
678 tool_call_id: String::new(),
679 skill_name: None,
680 };
681 let output = executor.execute_tool_call(&call).await.unwrap().unwrap();
682 assert!(
683 output.summary.contains("Ephemeral"),
684 "bare-mode save must mention ephemeral semantics; got: {}",
685 output.summary
686 );
687 assert!(
688 !output.summary.contains("available for future recall"),
689 "bare-mode save must not claim cross-session persistence; got: {}",
690 output.summary
691 );
692 }
693
694 fn memory_save_call(content: &str) -> ToolCall {
695 let mut params = serde_json::Map::new();
696 params.insert("content".into(), serde_json::Value::String(content.into()));
697 ToolCall {
698 tool_id: zeph_common::ToolName::new("memory_save"),
699 params,
700 caller_id: None,
701 context: None,
702 tool_call_id: String::new(),
703 skill_name: None,
704 }
705 }
706
707 #[tokio::test]
710 async fn memory_save_without_consent_gate_never_requires_confirmation() {
711 let memory = make_memory().await;
712 let sqlite = memory.sqlite().clone();
713 let cid = sqlite.create_conversation().await.unwrap();
714 let executor = MemoryToolExecutor::new(Arc::new(memory), cid);
716 let call = memory_save_call("a fact");
717 let result = executor.execute_tool_call(&call).await;
718 assert!(result.is_ok(), "expected no confirmation gate: {result:?}");
719 }
720
721 #[tokio::test]
722 async fn memory_save_requires_confirmation_when_turn_trust_at_or_above_threshold() {
723 let memory = make_memory().await;
724 let sqlite = memory.sqlite().clone();
725 let cid = sqlite.create_conversation().await.unwrap();
726 let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
727 let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
728 Arc::clone(&trust_slot),
729 ContentTrustLevel::ExternalUntrusted,
730 );
731
732 *trust_slot.write() = ContentTrustLevel::ExternalUntrusted as u8;
734
735 let call = memory_save_call("derived from untrusted web content");
736 let result = executor.execute_tool_call(&call).await;
737 assert!(
738 matches!(result, Err(ToolError::ConfirmationRequired { .. })),
739 "expected ConfirmationRequired, got: {result:?}"
740 );
741 assert!(executor.requires_confirmation(&call));
742 }
743
744 #[tokio::test]
745 async fn memory_save_below_confirm_threshold_does_not_require_confirmation() {
746 let memory = make_memory().await;
747 let sqlite = memory.sqlite().clone();
748 let cid = sqlite.create_conversation().await.unwrap();
749 let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
750 let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
751 Arc::clone(&trust_slot),
752 ContentTrustLevel::ExternalUntrusted,
753 );
754
755 *trust_slot.write() = ContentTrustLevel::LocalUntrusted as u8;
757
758 let call = memory_save_call("derived from a local shell command");
759 let result = executor.execute_tool_call(&call).await;
760 assert!(result.is_ok(), "expected no confirmation gate: {result:?}");
761 }
762
763 #[tokio::test]
764 async fn execute_tool_call_confirmed_bypasses_consent_gate_and_saves() {
765 let memory = make_memory().await;
766 let sqlite = memory.sqlite().clone();
767 let cid = sqlite.create_conversation().await.unwrap();
768 let trust_slot: MemoryConsentTrustSlot = Arc::new(RwLock::new(0u8));
769 let executor = MemoryToolExecutor::new(Arc::new(memory), cid).with_consent_gate(
770 Arc::clone(&trust_slot),
771 ContentTrustLevel::ExternalUntrusted,
772 );
773 *trust_slot.write() = ContentTrustLevel::ExternalUntrusted as u8;
774
775 let call = memory_save_call("approved after confirmation");
776 assert!(matches!(
778 executor.execute_tool_call(&call).await,
779 Err(ToolError::ConfirmationRequired { .. })
780 ));
781 let result = executor.execute_tool_call_confirmed(&call).await;
783 assert!(result.is_ok(), "confirmed save should succeed: {result:?}");
784 let output = result.unwrap().unwrap();
785 assert!(output.summary.contains("Saved to memory"));
786 }
787
788 async fn make_file_logger(log_path: &std::path::Path) -> Arc<zeph_tools::AuditLogger> {
791 let audit_config = zeph_tools::AuditConfig {
792 enabled: true,
793 destination: zeph_tools::AuditDestination::File(log_path.to_path_buf()),
794 tool_risk_summary: false,
795 };
796 Arc::new(
797 zeph_tools::AuditLogger::from_config(&audit_config, false)
798 .await
799 .unwrap(),
800 )
801 }
802
803 #[tokio::test]
804 async fn memory_save_audited_when_audit_all_true() {
805 let memory = make_memory().await;
806 let sqlite = memory.sqlite().clone();
807 let cid = sqlite.create_conversation().await.unwrap();
808 let dir = tempfile::tempdir().unwrap();
809 let log_path = dir.path().join("audit.jsonl");
810 let logger = make_file_logger(&log_path).await;
811
812 let executor = MemoryToolExecutor::new(Arc::new(memory), cid)
813 .with_audit(logger)
814 .with_audit_all(true);
815
816 let call = memory_save_call("audited fact");
817 executor.execute_tool_call(&call).await.unwrap();
818
819 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
820 assert!(
821 content.contains("memory_save"),
822 "audit_all=true must record the interactive memory_save write, got: {content}"
823 );
824 }
825
826 #[tokio::test]
827 async fn memory_save_not_audited_when_audit_all_false() {
828 let memory = make_memory().await;
829 let sqlite = memory.sqlite().clone();
830 let cid = sqlite.create_conversation().await.unwrap();
831 let dir = tempfile::tempdir().unwrap();
832 let log_path = dir.path().join("audit.jsonl");
833 let logger = make_file_logger(&log_path).await;
834
835 let executor = MemoryToolExecutor::new(Arc::new(memory), cid)
836 .with_audit(logger)
837 .with_audit_all(false);
838
839 let call = memory_save_call("unaudited fact");
840 executor.execute_tool_call(&call).await.unwrap();
841
842 let content = tokio::fs::read_to_string(&log_path)
845 .await
846 .unwrap_or_default();
847 assert!(
848 content.is_empty(),
849 "audit_all=false must suppress the interactive memory_save audit entry, got: {content}"
850 );
851 }
852
853 #[tokio::test]
856 async fn memory_search_description_mentions_user_provided_facts() {
857 let memory = make_memory().await;
858 let executor = make_executor(memory);
859 let defs = executor.tool_definitions();
860 let memory_search = defs
861 .iter()
862 .find(|d| d.id.as_ref() == "memory_search")
863 .unwrap();
864 assert!(
865 memory_search
866 .description
867 .contains("user provided during this or previous conversations"),
868 "memory_search description must contain disambiguation phrase; got: {}",
869 memory_search.description
870 );
871 }
872}