Skip to main content

oxicode_sdk/
port_memory_backend.rs

1//! SDK-provided [`MemoryBackend`] adapter bridging the SDK's
2//! [`MemoryStore`] + optional [`EmbeddingProvider`] ports into the agent's
3//! `memory_*` tools.
4//!
5//! This is the memory analog of [`crate::url_resolver::SdkUrlResolver`]: the SDK ships a
6//! standard adapter so a *pure*-`oxicode-sdk` consumer (one that does not write a
7//! bespoke `MemoryBackend`, as oxicode-cli does with its Mnemopi backend) can
8//! make the agent's memory tools functional simply by registering the ports.
9//!
10//! # Why this exists
11//!
12//! `oxicode-agent`'s [`MemoryBackend`] (the trait the `memory_*` tools call) and
13//! the SDK's [`MemoryStore`] port are *different* traits with different shapes
14//! — the port is the SDK consumer's storage contract; the backend is the
15//! agent tool contract. They do not map 1:1, so an adapter is required.
16//! Without it, `OxicodeBuilder::with_memory(store)` stores a port that the agent
17//! loop never reads (the agent uses `ToolContext.memory`, which stayed
18//! `None`). This adapter closes that gap.
19//!
20//! # Wiring
21//!
22//! Register the ports on the engine, then bridge them onto the agent:
23//!
24//! ```no_run
25//! use std::sync::Arc;
26//! use oxicode_sdk::{OxicodeBuilder, inmem::InMemoryMemoryStore};
27//!
28//! let oxicode = OxicodeBuilder::new()
29//!     .with_builtins()
30//!     .with_memory(Arc::new(InMemoryMemoryStore::new()))
31//!     .build();
32//! let agent = oxicode.agent(oxicode_agent::AgentConfig {
33//!     model_id: "anthropic/claude-sonnet-4-20250514".into(),
34//!     ..Default::default()
35//! })
36//! .with_port_memory() // bridges MemoryStore (+ EmbeddingProvider) → tools
37//! .build()
38//! .unwrap();
39//! ```
40//!
41//! Without an [`EmbeddingProvider`], `put` / `list` / `delete` work but
42//! semantic `search` returns an error (the port's search is vector-based).
43
44use 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
52/// Adapter that exposes an SDK [`MemoryStore`] (plus an optional
53/// [`EmbeddingProvider`]) as an [`oxicode_agent::tools::MemoryBackend`] so the
54/// agent's `memory_*` tools can read/write it.
55///
56/// Construct directly ([`Self::new`] / [`Self::from_ports`]) or bridge it
57/// onto an agent via [`crate::AgentBuilder::with_port_memory`].
58pub struct PortMemoryBackend {
59    store: Arc<dyn MemoryStore>,
60    embeddings: Option<Arc<dyn EmbeddingProvider>>,
61}
62
63impl PortMemoryBackend {
64    /// Wrap a [`MemoryStore`] with no embedding provider.
65    ///
66    /// `put` / `list` / `delete` work; semantic `search` is unavailable until
67    /// an [`EmbeddingProvider`] is attached via [`Self::with_embeddings`].
68    pub fn new(store: Arc<dyn MemoryStore>) -> Self {
69        Self {
70            store,
71            embeddings: None,
72        }
73    }
74
75    /// Attach an embedding provider, enabling semantic search.
76    #[must_use]
77    pub fn with_embeddings(mut self, embeddings: Arc<dyn EmbeddingProvider>) -> Self {
78        self.embeddings = Some(embeddings);
79        self
80    }
81
82    /// Build from both ports at once.
83    ///
84    /// Pass `None` for `embeddings` to disable semantic search (the port's
85    /// `search` is vector-based and cannot run without embeddings).
86    #[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
104/// Recover a `String` from a stored [`PortValue`](serde_json::Value).
105///
106/// Round-trips losslessly: [`PortMemoryBackend`] always stores content as
107/// [`serde_json::Value::String`], which this recovers directly. Other JSON
108/// variants (only possible if a foreign writer touched the store) serialize
109/// back to text.
110fn 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
117/// Map a stored [`MemoryEntry`] to the agent-tool [`MemoryItem`].
118fn 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}