Skip to main content

anda_engine/
store.rs

1//! Object storage support for engine contexts.
2//!
3//! [`Store`] wraps an [`ObjectStore`] backend and applies namespace isolation for
4//! agent and tool contexts. Each context receives a namespace derived from its
5//! path, so shared storage backends can safely hold data for many agents and
6//! tools.
7//!
8//! The module also defines [`VectorSearchFeaturesDyn`] and [`VectorStore`] for
9//! integrations that expose vector retrieval alongside object storage.
10//!
11//! ## Examples
12//! Basic usage:
13//! ```rust,ignore
14//! let store = Store::new(Arc::new(InMemory::new()));
15//! store.store_put(&namespace, &path, PutMode::Create, data).await?;
16//! let (content, meta) = store.store_get(&namespace, &path).await?;
17//! ```
18
19use anda_core::{BoxError, BoxPinFut, ObjectMeta, Path, PutMode, PutResult, path_join};
20use futures::TryStreamExt;
21use object_store::PutOptions;
22use std::sync::Arc;
23
24pub use object_store::{ObjectStore, ObjectStoreExt, local::LocalFileSystem, memory::InMemory};
25
26/// Maximum object size accepted by store-backed context APIs.
27pub const MAX_STORE_OBJECT_SIZE: usize = 1024 * 1024 * 2; // 2 MB
28
29/// Object-safe vector search interface.
30pub trait VectorSearchFeaturesDyn: Send + Sync + 'static {
31    /// Finds the top `n` similar item identifiers for a query string.
32    fn top_n(
33        &self,
34        namespace: Path,
35        query: String,
36        n: usize,
37    ) -> BoxPinFut<Result<Vec<String>, BoxError>>;
38
39    /// Finds the top `n` similar internal IDs for a query string.
40    fn top_n_ids(
41        &self,
42        namespace: Path,
43        query: String,
44        n: usize,
45    ) -> BoxPinFut<Result<Vec<String>, BoxError>>;
46}
47
48/// Cloneable wrapper around a vector search implementation.
49#[derive(Clone)]
50pub struct VectorStore {
51    inner: Arc<dyn VectorSearchFeaturesDyn>,
52}
53
54impl VectorStore {
55    /// Creates a vector store from an implementation.
56    pub fn new(inner: Arc<dyn VectorSearchFeaturesDyn>) -> Self {
57        Self { inner }
58    }
59
60    /// Creates a placeholder vector store that returns `not implemented` errors.
61    pub fn not_implemented() -> Self {
62        Self {
63            inner: Arc::new(NotImplemented),
64        }
65    }
66}
67
68impl VectorSearchFeaturesDyn for VectorStore {
69    fn top_n(
70        &self,
71        namespace: Path,
72        query: String,
73        n: usize,
74    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
75        self.inner.top_n(namespace, query, n)
76    }
77
78    fn top_n_ids(
79        &self,
80        namespace: Path,
81        query: String,
82        n: usize,
83    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
84        self.inner.top_n_ids(namespace, query, n)
85    }
86}
87
88/// A placeholder for not implemented features.
89#[derive(Clone, Debug)]
90pub struct NotImplemented;
91
92impl VectorSearchFeaturesDyn for NotImplemented {
93    fn top_n(
94        &self,
95        _namespace: Path,
96        _query: String,
97        _n: usize,
98    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
99        Box::pin(futures::future::ready(Err("not implemented".into())))
100    }
101
102    fn top_n_ids(
103        &self,
104        _namespace: Path,
105        _query: String,
106        _n: usize,
107    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
108        Box::pin(futures::future::ready(Err("not implemented".into())))
109    }
110}
111
112/// Mock vector search implementation that returns empty result sets.
113#[derive(Clone, Debug)]
114pub struct MockImplemented;
115
116impl VectorSearchFeaturesDyn for MockImplemented {
117    fn top_n(
118        &self,
119        _namespace: Path,
120        _query: String,
121        _n: usize,
122    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
123        Box::pin(futures::future::ready(Ok(vec![])))
124    }
125
126    fn top_n_ids(
127        &self,
128        _namespace: Path,
129        _query: String,
130        _n: usize,
131    ) -> BoxPinFut<Result<Vec<String>, BoxError>> {
132        Box::pin(futures::future::ready(Ok(vec![])))
133    }
134}
135
136/// Namespace-aware object storage facade used by engine contexts.
137///
138/// In Anda Engine, the namespace is derived from an agent or tool context path,
139/// which isolates data for each registered component.
140///
141/// Any [`ObjectStore`] implementation can be used, including in-memory,
142/// filesystem, cloud, and IC-COSE-backed stores.
143///
144/// You can find various implementations of [`ObjectStore`] at:
145/// <https://github.com/apache/arrow-rs/tree/main/object_store>
146///
147/// Alternatively, you can use [IC-COSE](https://github.com/ldclabs/ic-cose)'s
148/// [`ObjectStore`] implementation, which stores data on the ICP blockchain.
149#[derive(Clone)]
150pub struct Store {
151    store: Arc<dyn ObjectStore>,
152}
153
154impl Store {
155    /// Creates a storage facade from an object-store backend.
156    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
157        Self { store }
158    }
159
160    /// Retrieves object bytes and metadata from a namespace-relative path.
161    pub async fn store_get(
162        &self,
163        namespace: &Path,
164        path: &Path,
165    ) -> Result<(bytes::Bytes, ObjectMeta), BoxError> {
166        let path = path_join(namespace, path);
167        let res = self.store.get_opts(&path, Default::default()).await?;
168        let meta = res.meta.clone();
169        let data = res.bytes().await?;
170        Ok((data, meta))
171    }
172
173    /// Lists objects with optional namespace-relative prefix and offset filters.
174    ///
175    /// # Arguments
176    /// * `prefix` - Optional path prefix to filter results
177    /// * `offset` - Optional path to start listing from (exclude)
178    pub async fn store_list(
179        &self,
180        namespace: &Path,
181        prefix: Option<&Path>,
182        offset: &Path,
183    ) -> Result<Vec<ObjectMeta>, BoxError> {
184        let prefix = prefix.map(|p| path_join(namespace, p));
185        let offset = path_join(namespace, offset);
186
187        let mut res = if offset.is_root() {
188            self.store.list(prefix.as_ref())
189        } else {
190            self.store.list_with_offset(prefix.as_ref(), &offset)
191        };
192        let mut metas = Vec::new();
193        while let Some(meta) = res.try_next().await? {
194            metas.push(meta)
195        }
196
197        Ok(metas)
198    }
199
200    /// Stores bytes at a namespace-relative path with the given write mode.
201    ///
202    /// # Arguments
203    /// * `path` - Target storage path
204    /// * `mode` - Write mode (Create, Overwrite, etc.)
205    /// * `val` - Data to store as bytes
206    pub async fn store_put(
207        &self,
208        namespace: &Path,
209        path: &Path,
210        mode: PutMode,
211        val: bytes::Bytes,
212    ) -> Result<PutResult, BoxError> {
213        let full_path = path_join(namespace, path);
214        // Reject oversized objects early. Some backends (e.g. IC-COSE on ICP) hard-cap object size
215        // near this limit, so enforcing it here yields a clear error instead of a backend failure.
216        if val.len() > MAX_STORE_OBJECT_SIZE {
217            return Err(format!(
218                "object size {} bytes exceeds the {} byte limit (path: {})",
219                val.len(),
220                MAX_STORE_OBJECT_SIZE,
221                full_path
222            )
223            .into());
224        }
225        let res = self
226            .store
227            .put_opts(
228                &full_path,
229                val.into(),
230                PutOptions {
231                    mode,
232                    ..Default::default()
233                },
234            )
235            .await?;
236        Ok(res)
237    }
238
239    /// Renames an object if the target path does not exist.
240    ///
241    /// # Arguments
242    /// * `from` - Source path
243    /// * `to` - Destination path
244    pub async fn store_rename_if_not_exists(
245        &self,
246        namespace: &Path,
247        from: &Path,
248        to: &Path,
249    ) -> Result<(), BoxError> {
250        let from = path_join(namespace, from);
251        let to = path_join(namespace, to);
252        self.store.rename_if_not_exists(&from, &to).await?;
253        Ok(())
254    }
255
256    /// Deletes an object at a namespace-relative path.
257    ///
258    /// # Arguments
259    /// * `path` - Path of the object to delete
260    pub async fn store_delete(&self, namespace: &Path, path: &Path) -> Result<(), BoxError> {
261        let path = path_join(namespace, path);
262        self.store.delete(&path).await?;
263        Ok(())
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use bytes::Bytes;
271
272    #[tokio::test(flavor = "current_thread")]
273    async fn vector_store_delegates_to_implemented_and_placeholder_backends() {
274        let implemented = VectorStore::new(Arc::new(MockImplemented));
275        assert!(
276            implemented
277                .top_n(Path::from("ns"), "query".to_string(), 3)
278                .await
279                .unwrap()
280                .is_empty()
281        );
282        assert!(
283            implemented
284                .top_n_ids(Path::from("ns"), "query".to_string(), 3)
285                .await
286                .unwrap()
287                .is_empty()
288        );
289
290        let missing = VectorStore::not_implemented();
291        assert!(
292            missing
293                .top_n(Path::from("ns"), "query".to_string(), 3)
294                .await
295                .unwrap_err()
296                .to_string()
297                .contains("not implemented")
298        );
299        assert!(
300            missing
301                .top_n_ids(Path::from("ns"), "query".to_string(), 3)
302                .await
303                .unwrap_err()
304                .to_string()
305                .contains("not implemented")
306        );
307    }
308
309    #[tokio::test(flavor = "current_thread")]
310    async fn store_applies_namespace_to_crud_list_and_rename_operations() {
311        let store = Store::new(Arc::new(InMemory::new()));
312        let namespace = Path::from("agent/root");
313        let docs = Path::from("docs");
314        let first = Path::from("docs/first.txt");
315        let second = Path::from("docs/second.txt");
316        let renamed = Path::from("docs/renamed.txt");
317
318        store
319            .store_put(
320                &namespace,
321                &first,
322                PutMode::Overwrite,
323                Bytes::from_static(b"first"),
324            )
325            .await
326            .unwrap();
327        store
328            .store_put(
329                &namespace,
330                &second,
331                PutMode::Overwrite,
332                Bytes::from_static(b"second"),
333            )
334            .await
335            .unwrap();
336
337        let (data, meta) = store.store_get(&namespace, &first).await.unwrap();
338        assert_eq!(data, Bytes::from_static(b"first"));
339        assert_eq!(meta.location, Path::from("agent/root/docs/first.txt"));
340
341        let listed = store
342            .store_list(&namespace, Some(&docs), &Path::default())
343            .await
344            .unwrap();
345        assert_eq!(listed.len(), 2);
346        assert!(
347            listed
348                .iter()
349                .all(|meta| meta.location.as_ref().starts_with("agent/root/docs/"))
350        );
351
352        let listed_after_offset = store
353            .store_list(&namespace, Some(&docs), &first)
354            .await
355            .unwrap();
356        assert_eq!(listed_after_offset.len(), 1);
357        assert_eq!(
358            listed_after_offset[0].location,
359            Path::from("agent/root/docs/second.txt")
360        );
361
362        store
363            .store_rename_if_not_exists(&namespace, &second, &renamed)
364            .await
365            .unwrap();
366        let (data, _) = store.store_get(&namespace, &renamed).await.unwrap();
367        assert_eq!(data, Bytes::from_static(b"second"));
368
369        store.store_delete(&namespace, &renamed).await.unwrap();
370        assert!(store.store_get(&namespace, &renamed).await.is_err());
371    }
372}