use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use oxi_agent::tools::{MemoryBackend, MemoryItem, ToolError};
use crate::ports::{EmbeddingProvider, MemoryEntry, MemoryStore};
pub struct PortMemoryBackend {
store: Arc<dyn MemoryStore>,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
}
impl PortMemoryBackend {
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
Self {
store,
embeddings: None,
}
}
#[must_use]
pub fn with_embeddings(mut self, embeddings: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(embeddings);
self
}
#[must_use]
pub fn from_ports(
store: Arc<dyn MemoryStore>,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
) -> Self {
Self { store, embeddings }
}
}
impl std::fmt::Debug for PortMemoryBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PortMemoryBackend")
.field("store", &"<dyn MemoryStore>")
.field("has_embeddings", &self.embeddings.is_some())
.finish()
}
}
fn content_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn entry_to_item(e: MemoryEntry) -> MemoryItem {
MemoryItem {
id: e.id,
kind: e.kind,
content: content_to_string(&e.content),
subject: e.subject,
}
}
impl MemoryBackend for PortMemoryBackend {
fn put<'a>(
&'a self,
content: &'a str,
kind: &'a str,
subject: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
Box::pin(async move {
let id = uuid::Uuid::new_v4().to_string();
let embedding = if let Some(emb) = &self.embeddings {
Some(emb.embed(content).await.map_err(|e| e.to_string())?)
} else {
None
};
let entry = MemoryEntry {
id: id.clone(),
subject: subject.to_string(),
kind: kind.to_string(),
embedding,
content: serde_json::Value::String(content.to_string()),
created_at: chrono::Utc::now(),
};
self.store.put(entry).await.map_err(|e| e.to_string())?;
Ok(id)
})
}
fn search<'a>(
&'a self,
query: &'a str,
k: usize,
) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
Box::pin(async move {
let Some(emb) = &self.embeddings else {
return Err(
"Semantic search requires an EmbeddingProvider. Register one via \
OxiBuilder::with_embeddings() (or PortMemoryBackend::with_embeddings) \
to enable memory_recall."
.to_string(),
);
};
let vec = emb.embed(query).await.map_err(|e| e.to_string())?;
let entries = self
.store
.search(&vec, k)
.await
.map_err(|e| e.to_string())?;
Ok(entries.into_iter().map(entry_to_item).collect())
})
}
fn list<'a>(
&'a self,
subject: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
Box::pin(async move {
let entries = self.store.list(subject).await.map_err(|e| e.to_string())?;
Ok(entries.into_iter().map(entry_to_item).collect())
})
}
fn delete<'a>(
&'a self,
id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
Box::pin(async move { self.store.delete(id).await.map_err(|e| e.to_string()) })
}
fn memory_info(&self) -> Option<String> {
let mode = if self.embeddings.is_some() {
"semantic"
} else {
"store-only (no embeddings → search disabled)"
};
Some(format!("PortMemoryBackend ({mode})"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inmem::InMemoryMemoryStore;
fn backend() -> PortMemoryBackend {
PortMemoryBackend::new(Arc::new(InMemoryMemoryStore::new()))
}
#[tokio::test]
async fn put_list_delete_roundtrip() {
let b = backend();
let id = b.put("likes rust", "preference", "user-1").await.unwrap();
let listed = b.list("user-1").await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].content, "likes rust");
assert_eq!(listed[0].kind, "preference");
b.delete(&id).await.unwrap();
assert!(b.list("user-1").await.unwrap().is_empty());
}
#[tokio::test]
async fn search_without_embeddings_errors_clearly() {
let b = backend();
b.put("some fact", "fact", "s").await.unwrap();
let err = b.search("fact", 5).await.unwrap_err();
assert!(
err.contains("EmbeddingProvider"),
"expected guidance about embeddings, got: {err}"
);
}
#[test]
fn memory_info_reflects_embeddings_state() {
let b = backend();
assert!(b.memory_info().unwrap().contains("store-only"));
}
}