oxicode_sdk/
port_memory_backend.rs1use std::future::Future;
45use std::pin::Pin;
46use std::sync::Arc;
47
48use oxicode_agent::tools::{MemoryBackend, MemoryItem, ToolError};
49
50use crate::ports::{EmbeddingProvider, MemoryEntry, MemoryStore};
51
52pub struct PortMemoryBackend {
59 store: Arc<dyn MemoryStore>,
60 embeddings: Option<Arc<dyn EmbeddingProvider>>,
61}
62
63impl PortMemoryBackend {
64 pub fn new(store: Arc<dyn MemoryStore>) -> Self {
69 Self {
70 store,
71 embeddings: None,
72 }
73 }
74
75 #[must_use]
77 pub fn with_embeddings(mut self, embeddings: Arc<dyn EmbeddingProvider>) -> Self {
78 self.embeddings = Some(embeddings);
79 self
80 }
81
82 #[must_use]
87 pub fn from_ports(
88 store: Arc<dyn MemoryStore>,
89 embeddings: Option<Arc<dyn EmbeddingProvider>>,
90 ) -> Self {
91 Self { store, embeddings }
92 }
93}
94
95impl std::fmt::Debug for PortMemoryBackend {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("PortMemoryBackend")
98 .field("store", &"<dyn MemoryStore>")
99 .field("has_embeddings", &self.embeddings.is_some())
100 .finish()
101 }
102}
103
104fn content_to_string(value: &serde_json::Value) -> String {
111 match value {
112 serde_json::Value::String(s) => s.clone(),
113 other => other.to_string(),
114 }
115}
116
117fn entry_to_item(e: MemoryEntry) -> MemoryItem {
119 MemoryItem {
120 id: e.id,
121 kind: e.kind,
122 content: content_to_string(&e.content),
123 subject: e.subject,
124 }
125}
126
127impl MemoryBackend for PortMemoryBackend {
128 fn put<'a>(
129 &'a self,
130 content: &'a str,
131 kind: &'a str,
132 subject: &'a str,
133 ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
134 Box::pin(async move {
135 let id = uuid::Uuid::new_v4().to_string();
136 let embedding = if let Some(emb) = &self.embeddings {
137 Some(emb.embed(content).await.map_err(|e| e.to_string())?)
138 } else {
139 None
140 };
141 let entry = MemoryEntry {
142 id: id.clone(),
143 subject: subject.to_string(),
144 kind: kind.to_string(),
145 embedding,
146 content: serde_json::Value::String(content.to_string()),
147 created_at: chrono::Utc::now(),
148 };
149 self.store.put(entry).await.map_err(|e| e.to_string())?;
150 Ok(id)
151 })
152 }
153
154 fn search<'a>(
155 &'a self,
156 query: &'a str,
157 k: usize,
158 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
159 Box::pin(async move {
160 let Some(emb) = &self.embeddings else {
161 return Err(
162 "Semantic search requires an EmbeddingProvider. Register one via \
163 OxicodeBuilder::with_embeddings() (or PortMemoryBackend::with_embeddings) \
164 to enable memory_recall."
165 .to_string(),
166 );
167 };
168 let vec = emb.embed(query).await.map_err(|e| e.to_string())?;
169 let entries = self
170 .store
171 .search(&vec, k)
172 .await
173 .map_err(|e| e.to_string())?;
174 Ok(entries.into_iter().map(entry_to_item).collect())
175 })
176 }
177
178 fn list<'a>(
179 &'a self,
180 subject: &'a str,
181 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
182 Box::pin(async move {
183 let entries = self.store.list(subject).await.map_err(|e| e.to_string())?;
184 Ok(entries.into_iter().map(entry_to_item).collect())
185 })
186 }
187
188 fn delete<'a>(
189 &'a self,
190 id: &'a str,
191 ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
192 Box::pin(async move { self.store.delete(id).await.map_err(|e| e.to_string()) })
193 }
194
195 fn memory_info(&self) -> Option<String> {
196 let mode = if self.embeddings.is_some() {
197 "semantic"
198 } else {
199 "store-only (no embeddings → search disabled)"
200 };
201 Some(format!("PortMemoryBackend ({mode})"))
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::inmem::InMemoryMemoryStore;
209
210 fn backend() -> PortMemoryBackend {
211 PortMemoryBackend::new(Arc::new(InMemoryMemoryStore::new()))
212 }
213
214 #[tokio::test]
215 async fn put_list_delete_roundtrip() {
216 let b = backend();
217 let id = b.put("likes rust", "preference", "user-1").await.unwrap();
218 let listed = b.list("user-1").await.unwrap();
219 assert_eq!(listed.len(), 1);
220 assert_eq!(listed[0].content, "likes rust");
221 assert_eq!(listed[0].kind, "preference");
222 b.delete(&id).await.unwrap();
223 assert!(b.list("user-1").await.unwrap().is_empty());
224 }
225
226 #[tokio::test]
227 async fn search_without_embeddings_errors_clearly() {
228 let b = backend();
229 b.put("some fact", "fact", "s").await.unwrap();
230 let err = b.search("fact", 5).await.unwrap_err();
231 assert!(
232 err.contains("EmbeddingProvider"),
233 "expected guidance about embeddings, got: {err}"
234 );
235 }
236
237 #[test]
238 fn memory_info_reflects_embeddings_state() {
239 let b = backend();
240 assert!(b.memory_info().unwrap().contains("store-only"));
241 }
242}