Skip to main content

tact_memory/
tool.rs

1//! Explicit agent access to the global memory store.
2
3use super::{MemoryAccess, MemoryKey, MemoryRecord, MemoryStore, SelectedMemoryStore};
4use nanocodex::{
5    Tool,
6    tools::contract::{
7        ToolContext, ToolDefinition, ToolInput, ToolOutput, ToolResult, async_trait,
8    },
9};
10use serde::{Deserialize, Deserializer, Serialize};
11use serde_json::{Value, json};
12use std::{
13    io,
14    sync::atomic::{AtomicBool, Ordering},
15};
16use zeroize::Zeroizing;
17
18const DEFAULT_SCAN_LIMIT: usize = 5;
19
20#[derive(Deserialize)]
21#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
22enum MemoryOperation {
23    Scan {
24        query: String,
25        #[serde(default)]
26        limit: Option<usize>,
27    },
28    Read {
29        keys: Vec<MemoryKey>,
30    },
31    Put {
32        content: MemoryContent,
33        #[serde(default)]
34        replace: Option<MemoryKey>,
35    },
36    Delete {
37        key: MemoryKey,
38    },
39}
40
41/// Zeroizes Tact's typed copy even when object deserialization later rejects the call.
42///
43/// Nanocodex owns the raw tool arguments and conversation records outside this wrapper; those
44/// dependency-owned copies do not provide a zeroization guarantee.
45struct MemoryContent(Zeroizing<String>);
46
47impl<'de> Deserialize<'de> for MemoryContent {
48    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49    where
50        D: Deserializer<'de>,
51    {
52        String::deserialize(deserializer)
53            .map(Zeroizing::new)
54            .map(Self)
55    }
56}
57
58#[derive(Serialize)]
59struct ScanOutput {
60    operation: &'static str,
61    backend: MemoryAccess,
62    abstained: bool,
63    candidates: Vec<ToolCandidate>,
64}
65
66#[derive(Serialize)]
67struct ToolCandidate {
68    key: MemoryKey,
69    preview: String,
70    score: f64,
71}
72
73#[derive(Serialize)]
74struct ReadOutput {
75    operation: &'static str,
76    backend: MemoryAccess,
77    memories: Vec<MemoryRecord>,
78}
79
80#[derive(Serialize)]
81struct PutOutput {
82    operation: &'static str,
83    backend: MemoryAccess,
84    memory: MemoryRecord,
85    replaced: bool,
86}
87
88#[derive(Serialize)]
89struct DeleteOutput {
90    operation: &'static str,
91    backend: MemoryAccess,
92    key: MemoryKey,
93}
94
95/// Authorizes mutations for one agent session.
96#[async_trait]
97pub trait MutationAuthorizer: Send + Sync {
98    /// Returns success only when `session_id` may mutate memory.
99    async fn authorize_memory_mutation(&self, session_id: &str) -> io::Result<()>;
100}
101
102/// Nanocodex tool exposing bounded memory operations.
103pub struct MemoryTool<A> {
104    store: SelectedMemoryStore,
105    authorizer: A,
106    searched: AtomicBool,
107}
108
109impl<A> MemoryTool<A>
110where
111    A: MutationAuthorizer,
112{
113    /// Creates a tool over `store` with the supplied mutation authorizer.
114    pub const fn new(store: SelectedMemoryStore, authorizer: A) -> Self {
115        Self {
116            store,
117            authorizer,
118            searched: AtomicBool::new(false),
119        }
120    }
121
122    async fn scan(&self, query: String, limit: Option<usize>) -> ToolResult {
123        if query.trim().is_empty() {
124            return Err(io::Error::other("memory scan query is empty").into());
125        }
126        let limit = limit.unwrap_or(DEFAULT_SCAN_LIMIT);
127        if !(1..=DEFAULT_SCAN_LIMIT).contains(&limit) {
128            return Err(io::Error::other("memory scan limit must be between 1 and 5").into());
129        }
130        let backend = self.store.access().await?;
131        let scan = self.store.scan(&query, limit).await?;
132        self.searched.store(true, Ordering::Release);
133        json_output(&ScanOutput {
134            operation: "scan",
135            backend,
136            abstained: scan.abstained,
137            candidates: scan
138                .candidates
139                .into_iter()
140                .map(|candidate| ToolCandidate {
141                    key: candidate.key,
142                    preview: candidate.preview,
143                    score: candidate.score,
144                })
145                .collect(),
146        })
147    }
148
149    async fn read(&self, keys: Vec<MemoryKey>) -> ToolResult {
150        if keys.is_empty() {
151            return Err(io::Error::other("memory read requires at least one key").into());
152        }
153        let backend = self.store.access().await?;
154        let memories = self.store.read(&[], &keys).await?;
155        json_output(&ReadOutput {
156            operation: "read",
157            backend,
158            memories,
159        })
160    }
161
162    async fn put(
163        &self,
164        session_id: &str,
165        content: MemoryContent,
166        replace: Option<MemoryKey>,
167    ) -> ToolResult {
168        let content = content.0;
169        self.authorizer
170            .authorize_memory_mutation(session_id)
171            .await?;
172        if !self.searched.swap(false, Ordering::AcqRel) {
173            return Err(io::Error::other("scan memory before storing a conclusion").into());
174        }
175        let backend = self.store.access().await?;
176        let replaced = replace.is_some();
177        let memory = self.store.put(content.as_str(), replace).await?;
178        json_output(&PutOutput {
179            operation: "put",
180            backend,
181            memory,
182            replaced,
183        })
184    }
185
186    async fn delete(&self, session_id: &str, key: MemoryKey) -> ToolResult {
187        self.authorizer
188            .authorize_memory_mutation(session_id)
189            .await?;
190        let backend = self.store.access().await?;
191        self.store.delete(key.clone()).await?;
192        json_output(&DeleteOutput {
193            operation: "delete",
194            backend,
195            key,
196        })
197    }
198}
199
200#[async_trait]
201impl<A> Tool for MemoryTool<A>
202where
203    A: MutationAuthorizer + 'static,
204{
205    fn definition(&self) -> ToolDefinition {
206        ToolDefinition::function(
207            "memory",
208            "Explicitly searches, reads, stores, replaces, or deletes bounded memories in exactly one configured local or remote backend. Pass keys returned by scan unchanged when reading, for example {\"operation\":\"read\",\"keys\":[{\"id\":7,\"version\":1,\"namespace\":\"alice\"}]}. Remote reads include the authenticated and team namespaces. Put and delete are root-agent-only; remote mutation also requires a writer credential.",
209            memory_input_schema(),
210        )
211        .with_output_schema(memory_output_schema())
212    }
213
214    async fn execute(&self, input: ToolInput, context: ToolContext<'_>) -> ToolResult {
215        match input.decode_json::<MemoryOperation>()? {
216            MemoryOperation::Scan { query, limit } => self.scan(query, limit).await,
217            MemoryOperation::Read { keys } => self.read(keys).await,
218            MemoryOperation::Put { content, replace } => {
219                self.put(context.session_id(), content, replace).await
220            }
221            MemoryOperation::Delete { key } => self.delete(context.session_id(), key).await,
222        }
223    }
224}
225
226fn json_output(value: &impl Serialize) -> ToolResult {
227    Ok(ToolOutput::from_json(serde_json::to_value(value)?, true))
228}
229
230fn memory_input_schema() -> Value {
231    json!({
232        "oneOf": [
233            {
234                "type": "object",
235                "properties": {
236                    "operation": { "type": "string", "const": "scan" },
237                    "query": { "type": "string", "minLength": 1, "maxLength": 512 },
238                    "limit": { "type": "integer", "minimum": 1, "maximum": 5, "default": 5 }
239                },
240                "required": ["operation", "query"],
241                "additionalProperties": false
242            },
243            {
244                "type": "object",
245                "properties": {
246                    "operation": { "type": "string", "const": "read" },
247                    "keys": {
248                        "type": "array",
249                        "items": memory_key_schema(),
250                        "minItems": 1,
251                        "description": "Exact candidate keys returned by scan. Preserve each id, version, and namespace unchanged."
252                    }
253                },
254                "required": ["operation", "keys"],
255                "additionalProperties": false
256            },
257            {
258                "type": "object",
259                "properties": {
260                    "operation": { "type": "string", "const": "put" },
261                    "content": { "type": "string", "minLength": 1, "maxLength": 1024 },
262                    "replace": memory_key_schema()
263                },
264                "required": ["operation", "content"],
265                "additionalProperties": false
266            },
267            {
268                "type": "object",
269                "properties": {
270                    "operation": { "type": "string", "const": "delete" },
271                    "key": memory_key_schema()
272                },
273                "required": ["operation", "key"],
274                "additionalProperties": false
275            }
276        ]
277    })
278}
279
280fn memory_output_schema() -> Value {
281    let record = memory_record_schema();
282    let backend = memory_backend_schema();
283    json!({
284        "oneOf": [
285            {
286                "type": "object",
287                "properties": {
288                    "operation": { "type": "string", "const": "scan" },
289                    "backend": backend.clone(),
290                    "abstained": { "type": "boolean" },
291                    "candidates": {
292                        "type": "array",
293                        "maxItems": 5,
294                        "items": {
295                            "type": "object",
296                            "properties": {
297                                "key": memory_key_schema(),
298                                "preview": { "type": "string", "maxLength": 64 },
299                                "score": { "type": "number" }
300                            },
301                            "required": ["key", "preview", "score"],
302                            "additionalProperties": false
303                        }
304                    }
305                },
306                "required": ["operation", "backend", "abstained", "candidates"],
307                "additionalProperties": false
308            },
309            {
310                "type": "object",
311                "properties": {
312                    "operation": { "type": "string", "const": "read" },
313                    "backend": backend.clone(),
314                    "memories": { "type": "array", "items": record.clone() }
315                },
316                "required": ["operation", "backend", "memories"],
317                "additionalProperties": false
318            },
319            {
320                "type": "object",
321                "properties": {
322                    "operation": { "type": "string", "const": "put" },
323                    "backend": backend.clone(),
324                    "memory": record,
325                    "replaced": { "type": "boolean" }
326                },
327                "required": ["operation", "backend", "memory", "replaced"],
328                "additionalProperties": false
329            },
330            {
331                "type": "object",
332                "properties": {
333                    "operation": { "type": "string", "const": "delete" },
334                    "backend": backend,
335                    "key": memory_key_schema()
336                },
337                "required": ["operation", "backend", "key"],
338                "additionalProperties": false
339            }
340        ]
341    })
342}
343
344fn memory_backend_schema() -> Value {
345    json!({
346        "type": "object",
347        "properties": {
348            "source": { "type": "string", "enum": ["local", "remote"] },
349            "namespace": { "type": ["string", "null"] },
350            "role": { "type": ["string", "null"], "enum": ["reader", "writer", null] }
351        },
352        "required": ["source", "namespace", "role"],
353        "additionalProperties": false
354    })
355}
356
357fn memory_key_schema() -> Value {
358    json!({
359        "type": "object",
360        "description": "An exact memory key returned by scan, read, list, or put. Preserve every field unchanged.",
361        "properties": {
362            "id": { "type": "integer", "minimum": 1 },
363            "version": { "type": "integer", "minimum": 1 }
364            ,"namespace": { "type": "string", "minLength": 1 }
365        },
366        "required": ["id", "version"],
367        "additionalProperties": false
368    })
369}
370
371fn memory_record_schema() -> Value {
372    json!({
373        "type": "object",
374        "properties": {
375            "key": memory_key_schema(),
376            "content": { "type": "string" },
377            "created_at_ms": { "type": "integer" },
378            "updated_at_ms": { "type": "integer" },
379            "last_scanned_at_ms": { "type": ["integer", "null"] },
380            "scan_count": { "type": "integer", "minimum": 0 },
381            "last_used_at_ms": { "type": ["integer", "null"] },
382            "use_count": { "type": "integer", "minimum": 0 },
383            "probation_until_ms": { "type": ["integer", "null"] }
384        },
385        "required": [
386            "key", "content", "created_at_ms", "updated_at_ms", "last_scanned_at_ms",
387            "scan_count", "last_used_at_ms", "use_count", "probation_until_ms"
388        ],
389        "additionalProperties": false
390    })
391}
392
393#[cfg(test)]
394mod tests {
395    use super::{MemoryOperation, MemoryTool, MutationAuthorizer};
396    use crate::{MemoryStore, SelectedMemoryStore};
397    use nanocodex::{
398        Tool,
399        tools::contract::{DEFAULT_TOOL_OUTPUT_TOKENS, ToolContext, ToolInput, async_trait},
400    };
401    use serde_json::{json, value::to_raw_value};
402    use std::io;
403    use tempfile::tempdir;
404
405    struct TestAuthorizer;
406
407    #[async_trait]
408    impl MutationAuthorizer for TestAuthorizer {
409        async fn authorize_memory_mutation(&self, session_id: &str) -> io::Result<()> {
410            if session_id == "root" {
411                return Ok(());
412            }
413            Err(io::Error::other(
414                "memory mutation is only available to root agents",
415            ))
416        }
417    }
418
419    fn input(value: serde_json::Value) -> ToolInput {
420        ToolInput::Function(to_raw_value(&value).unwrap())
421    }
422
423    fn context(session_id: &str) -> ToolContext<'_> {
424        ToolContext::new(
425            "test-model",
426            session_id,
427            "test-call",
428            &[],
429            DEFAULT_TOOL_OUTPUT_TOKENS,
430        )
431    }
432
433    #[test]
434    fn definition_has_one_closed_tagged_surface() {
435        let tool = MemoryTool::new(
436            SelectedMemoryStore::local(tempdir().unwrap().path().join("memory.sqlite3")),
437            TestAuthorizer,
438        );
439        let definition = tool.definition();
440        let schema = definition.parameters().unwrap().as_value();
441        let output_schema = definition.output_schema().unwrap().as_value();
442
443        assert_eq!(definition.name(), "memory");
444        assert_eq!(schema["oneOf"].as_array().unwrap().len(), 4);
445        assert!(
446            schema["oneOf"]
447                .as_array()
448                .unwrap()
449                .iter()
450                .all(|operation| { operation["additionalProperties"] == json!(false) })
451        );
452        let scan_output = output_schema["oneOf"]
453            .as_array()
454            .unwrap()
455            .iter()
456            .find(|operation| operation["properties"]["operation"]["const"] == json!("scan"))
457            .expect("scan output should be exposed");
458        let candidate = &scan_output["properties"]["candidates"]["items"];
459        assert_eq!(candidate["properties"]["preview"]["maxLength"], json!(64));
460    }
461
462    #[test]
463    fn exact_operations_share_the_memory_key_shape() {
464        let tool = MemoryTool::new(
465            SelectedMemoryStore::local(tempdir().unwrap().path().join("memory.sqlite3")),
466            TestAuthorizer,
467        );
468        let definition = tool.definition();
469        let operations = definition.parameters().unwrap().as_value()["oneOf"]
470            .as_array()
471            .unwrap();
472        let operation = |name| {
473            operations
474                .iter()
475                .find(|operation| operation["properties"]["operation"]["const"] == json!(name))
476                .unwrap()
477        };
478        let read = operation("read");
479        let delete = operation("delete");
480        let delete_output = definition.output_schema().unwrap().as_value()["oneOf"]
481            .as_array()
482            .unwrap()
483            .iter()
484            .find(|operation| operation["properties"]["operation"]["const"] == json!("delete"))
485            .unwrap();
486
487        assert_eq!(read["required"], json!(["operation", "keys"]));
488        assert!(read["properties"].get("ids").is_none());
489        assert!(
490            read["properties"]["keys"]["description"]
491                .as_str()
492                .unwrap()
493                .contains("scan")
494        );
495        assert_eq!(delete["required"], json!(["operation", "key"]));
496        assert!(delete["properties"].get("id").is_none());
497        assert_eq!(
498            delete_output["required"],
499            json!(["operation", "backend", "key"])
500        );
501        assert!(
502            definition.description().contains(
503                r#"{"operation":"read","keys":[{"id":7,"version":1,"namespace":"alice"}]}"#
504            )
505        );
506
507        assert!(
508            serde_json::from_value::<MemoryOperation>(json!({"operation": "read", "ids": [1]}))
509                .is_err()
510        );
511        assert!(
512            serde_json::from_value::<MemoryOperation>(json!({
513                "operation": "delete",
514                "key": {"id": 1, "version": 2}
515            }))
516            .is_ok()
517        );
518    }
519
520    #[tokio::test]
521    async fn reads_and_deletes_keys_returned_by_the_store() {
522        let directory = tempdir().unwrap();
523        let store = SelectedMemoryStore::local(directory.path().join("memory.sqlite3"));
524        let tool = MemoryTool::new(store.clone(), TestAuthorizer);
525        tool.execute(
526            input(json!({"operation": "scan", "query": "key contract"})),
527            context("root"),
528        )
529        .await
530        .unwrap();
531        tool.execute(
532            input(json!({"operation": "put", "content": "Use one exact memory key shape."})),
533            context("root"),
534        )
535        .await
536        .unwrap();
537        let key = store.list().await.unwrap().remove(0).key;
538
539        assert!(
540            tool.execute(
541                input(json!({"operation": "read", "keys": [key.clone()]})),
542                context("root"),
543            )
544            .await
545            .unwrap()
546            .success
547        );
548        assert!(
549            tool.execute(
550                input(json!({"operation": "delete", "key": key})),
551                context("root"),
552            )
553            .await
554            .unwrap()
555            .success
556        );
557        assert!(store.list().await.unwrap().is_empty());
558    }
559
560    #[test]
561    fn serde_rejects_caller_supplied_authority() {
562        let input = json!({
563            "operation": "delete",
564            "key": {"id": 1, "version": 1},
565            "is_root": true
566        });
567        assert!(serde_json::from_value::<MemoryOperation>(input).is_err());
568    }
569
570    #[tokio::test]
571    async fn root_put_requires_a_fresh_scan() {
572        let directory = tempdir().unwrap();
573        let store = SelectedMemoryStore::local(directory.path().join("memory.sqlite3"));
574        let tool = MemoryTool::new(store.clone(), TestAuthorizer);
575        let put = || {
576            input(json!({
577                "operation": "put",
578                "content": "The durable test preference is concise output."
579            }))
580        };
581
582        let Err(error) = tool.execute(put(), context("root")).await else {
583            panic!("put without a scan unexpectedly succeeded");
584        };
585        assert_eq!(error.to_string(), "scan memory before storing a conclusion");
586        let Err(error) = tool
587            .execute(
588                input(json!({
589                    "operation": "scan",
590                    "query": "durable preference",
591                    "limit": 0
592                })),
593                context("root"),
594            )
595            .await
596        else {
597            panic!("zero-result scan unexpectedly succeeded");
598        };
599        assert_eq!(
600            error.to_string(),
601            "memory scan limit must be between 1 and 5"
602        );
603        let Err(error) = tool.execute(put(), context("root")).await else {
604            panic!("invalid scan armed a put");
605        };
606        assert_eq!(error.to_string(), "scan memory before storing a conclusion");
607        assert!(
608            tool.execute(
609                input(json!({ "operation": "scan", "query": "durable preference" })),
610                context("root"),
611            )
612            .await
613            .unwrap()
614            .success
615        );
616        assert!(tool.execute(put(), context("root")).await.unwrap().success);
617        assert_eq!(store.list().await.unwrap().len(), 1);
618        let Err(error) = tool.execute(put(), context("root")).await else {
619            panic!("put reused an earlier scan");
620        };
621        assert_eq!(error.to_string(), "scan memory before storing a conclusion");
622    }
623
624    #[tokio::test]
625    async fn selected_store_rejects_secret_content_before_storage() {
626        let directory = tempdir().unwrap();
627        let store = SelectedMemoryStore::local(directory.path().join("memory.sqlite3"));
628        let tool = MemoryTool::new(store.clone(), TestAuthorizer);
629        tool.execute(
630            input(json!({ "operation": "scan", "query": "credentials" })),
631            context("root"),
632        )
633        .await
634        .unwrap();
635
636        let Err(error) = tool
637            .execute(
638                input(json!({ "operation": "put", "content": "password=hunter2" })),
639                context("root"),
640            )
641            .await
642        else {
643            panic!("secret-bearing memory unexpectedly reached storage");
644        };
645
646        assert_eq!(
647            error.to_string(),
648            "memory content was rejected as a likely secret"
649        );
650        assert!(store.list().await.unwrap().is_empty());
651    }
652}