1use 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
41struct 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#[async_trait]
97pub trait MutationAuthorizer: Send + Sync {
98 async fn authorize_memory_mutation(&self, session_id: &str) -> io::Result<()>;
100}
101
102pub struct MemoryTool<A> {
104 store: SelectedMemoryStore,
105 authorizer: A,
106 searched: AtomicBool,
107}
108
109impl<A> MemoryTool<A>
110where
111 A: MutationAuthorizer,
112{
113 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}