1use super::{Tool, ToolContext};
18use crate::runtime::memory_context::{
19 DurableStatus, MemoryContextCapability, MemoryContextError, MemoryContextMode,
20 MemoryContextStatus, OneShotStatus,
21};
22use crate::runtime::memory_history::{
23 propose_history_import, CanonicalHistoryMetadataIo, HistoryImportHostState,
24};
25use crate::{Result, RuntimeError};
26use serde_json::{json, Value};
27
28pub struct MemoryContextTool;
30
31const SCHEMA_PROPERTIES: [&str; 4] = ["action", "mode", "capture_tools", "expires_minutes"];
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum MemoryContextRequest {
40 Enable,
42 Disable,
44 Status,
46 RecallOnce { expires_minutes: Option<u32> },
48 IndexHistory,
51}
52
53impl MemoryContextRequest {
54 fn action_name(self) -> &'static str {
56 match self {
57 MemoryContextRequest::Enable => "enable",
58 MemoryContextRequest::Disable => "disable",
59 MemoryContextRequest::Status => "status",
60 MemoryContextRequest::RecallOnce { .. } => "recall_once",
61 MemoryContextRequest::IndexHistory => "index_history",
62 }
63 }
64}
65
66fn parse_request(params: &Value) -> std::result::Result<MemoryContextRequest, String> {
69 let object = params
70 .as_object()
71 .ok_or_else(|| "memory_context parameters must be a JSON object".to_string())?;
72
73 if object
75 .keys()
76 .any(|key| !SCHEMA_PROPERTIES.contains(&key.as_str()))
77 {
78 return Err(
79 "memory_context rejects unknown parameters (additionalProperties is false)".to_string(),
80 );
81 }
82
83 let mode_supplied = match object.get("mode") {
85 None => false,
86 Some(Value::String(mode))
87 if matches!(
88 mode.as_str(),
89 "recall_each_prompt" | "capture_only" | "capture_and_recall"
90 ) =>
91 {
92 true
93 }
94 Some(_) => return Err("malformed 'mode': not a value from the schema enum".to_string()),
95 };
96 let capture_tools_supplied = match object.get("capture_tools") {
97 None => false,
98 Some(Value::Bool(_)) => true,
99 Some(_) => return Err("malformed 'capture_tools': expected a boolean".to_string()),
100 };
101 let expires_minutes = match object.get("expires_minutes") {
102 None => None,
103 Some(value) => {
104 let minutes = value
105 .as_u64()
106 .filter(|minutes| (1..=1440).contains(minutes))
107 .ok_or_else(|| {
108 "malformed 'expires_minutes': expected an integer in 1..=1440".to_string()
109 })?;
110 Some(u32::try_from(minutes).expect("bounded by 1440"))
111 }
112 };
113
114 let action = object
115 .get("action")
116 .and_then(Value::as_str)
117 .ok_or_else(|| "missing or malformed 'action'".to_string())?;
118
119 let reject_inapplicable = |action_name: &'static str| -> std::result::Result<(), String> {
122 if mode_supplied {
123 return Err(format!(
124 "'mode' is not applicable to action '{action_name}'"
125 ));
126 }
127 if capture_tools_supplied {
128 return Err(format!(
129 "'capture_tools' is not applicable to action '{action_name}'"
130 ));
131 }
132 Ok(())
133 };
134 let reject_expiry = |action_name: &'static str| -> std::result::Result<(), String> {
135 if expires_minutes.is_some() {
136 return Err(format!(
137 "'expires_minutes' is not applicable to action '{action_name}'"
138 ));
139 }
140 Ok(())
141 };
142
143 match action {
144 "enable" => Ok(MemoryContextRequest::Enable),
147 "disable" => {
148 reject_inapplicable("disable")?;
149 reject_expiry("disable")?;
150 Ok(MemoryContextRequest::Disable)
151 }
152 "status" => {
153 reject_inapplicable("status")?;
154 reject_expiry("status")?;
155 Ok(MemoryContextRequest::Status)
156 }
157 "recall_once" => {
158 reject_inapplicable("recall_once")?;
159 Ok(MemoryContextRequest::RecallOnce { expires_minutes })
160 }
161 "index_history" => {
162 reject_inapplicable("index_history")?;
163 reject_expiry("index_history")?;
164 Ok(MemoryContextRequest::IndexHistory)
165 }
166 _ => Err("unknown 'action': not a value from the schema enum".to_string()),
168 }
169}
170
171fn mode_label(mode: MemoryContextMode) -> &'static str {
173 match mode {
174 MemoryContextMode::Off => "off",
175 MemoryContextMode::RecallOnce => "recall_once",
176 MemoryContextMode::RecallEachPrompt => "recall_each_prompt",
177 MemoryContextMode::CaptureOnly => "capture_only",
178 MemoryContextMode::CaptureAndRecall => "capture_and_recall",
179 }
180}
181
182fn render_summary(action: &'static str, status: Option<&MemoryContextStatus>) -> String {
186 let (mode, one_shot, expires_at) = match status {
187 None => ("off", "idle", Value::Null),
188 Some(status) => {
189 let (mode, expires_at) = match &status.durable {
190 DurableStatus::Off => ("off", Value::Null),
191 DurableStatus::Active {
192 mode, expires_at, ..
193 } => (
194 mode_label(*mode),
195 expires_at
196 .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
197 .map_or(Value::Null, |since| json!(since.as_secs())),
198 ),
199 };
200 let one_shot = match &status.one_shot {
201 OneShotStatus::Idle => "idle",
202 OneShotStatus::Pending { .. } => "pending",
203 OneShotStatus::Consumed { .. } => "consumed",
204 };
205 (mode, one_shot, expires_at)
206 }
207 };
208 json!({
209 "action": action,
210 "mode": mode,
211 "one_shot_recall": one_shot,
212 "project_digest": Value::Null,
214 "expires_at": expires_at,
215 })
216 .to_string()
217}
218
219fn typed_failure(error: MemoryContextError) -> RuntimeError {
220 RuntimeError::Tool(format!("memory_context denied: {error}"))
221}
222
223#[async_trait::async_trait]
224impl Tool for MemoryContextTool {
225 fn origin(&self) -> crate::tools::ToolOrigin {
226 crate::tools::ToolOrigin::Builtin
227 }
228
229 fn name(&self) -> &str {
230 "memory_context"
231 }
232
233 fn description(&self) -> &str {
234 "Control the continuous-memory context of this session. 'status', 'disable', and 'recall_once' commit locally safe actions. 'enable' requires the deterministic /memory command. 'index_history' returns a host-computed metadata preview only; explicit frontend confirmation is still required and no import begins from a model tool call."
235 }
236
237 fn parameters(&self) -> Value {
238 json!({
240 "type": "object",
241 "additionalProperties": false,
242 "properties": {
243 "action": {
244 "type": "string",
245 "enum": ["enable", "disable", "status", "recall_once", "index_history"],
246 "description": "Memory-context action. index_history previews metadata only; import still requires explicit frontend confirmation."
247 },
248 "mode": {
249 "type": "string",
250 "enum": ["recall_each_prompt", "capture_only", "capture_and_recall"],
251 "description": "Requested durable mode (enable proposals only)."
252 },
253 "capture_tools": {
254 "type": "boolean",
255 "description": "Whether tool activity is eligible for capture (enable proposals only)."
256 },
257 "expires_minutes": {
258 "type": "integer",
259 "minimum": 1,
260 "maximum": 1440,
261 "description": "Bounded lease expiry in minutes."
262 }
263 },
264 "required": ["action"]
265 })
266 }
267
268 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
269 let request = parse_request(¶ms).map_err(RuntimeError::Tool)?;
270 let capability: Option<&MemoryContextCapability> = ctx.capabilities.memory_context.as_ref();
271
272 match request {
273 MemoryContextRequest::Status => Ok(render_summary(
276 request.action_name(),
277 capability.map(MemoryContextCapability::status).as_ref(),
278 )),
279 MemoryContextRequest::Disable => Ok(render_summary(
282 request.action_name(),
283 capability.map(MemoryContextCapability::disable).as_ref(),
284 )),
285 MemoryContextRequest::RecallOnce { expires_minutes } => {
286 let capability = capability
287 .ok_or_else(|| typed_failure(MemoryContextError::CapabilityUnavailable))?;
288 let status = capability
289 .recall_once(expires_minutes)
290 .map_err(typed_failure)?;
291 Ok(render_summary(request.action_name(), Some(&status)))
292 }
293 MemoryContextRequest::IndexHistory => {
297 let host = HistoryImportHostState::from_current_host()
298 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
299 let mut io = CanonicalHistoryMetadataIo::new();
300 let preview = propose_history_import(&host, &mut io)
301 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
302 Ok(preview.render())
303 }
304 MemoryContextRequest::Enable => {
308 if capability.is_none() {
309 return Err(typed_failure(MemoryContextError::CapabilityUnavailable));
310 }
311 Err(typed_failure(MemoryContextError::RequiresHostConfirmation))
312 }
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::runtime::memory_context::{
321 CapturePolicy, ContextProviderId, MemoryContextLease, MemoryLeaseId, ProjectId,
322 RecallPolicy, RequestId, SessionId, SessionMemoryState, UserIntentProof,
323 };
324 use crate::tools::test_helpers::create_tool_context;
325 use std::sync::{Arc, Mutex};
326 use std::time::SystemTime;
327
328 fn test_state() -> Arc<Mutex<SessionMemoryState>> {
329 Arc::new(Mutex::new(SessionMemoryState::new(
330 SessionId::parse("sess-tool").expect("valid session id"),
331 )))
332 }
333
334 fn capability_over(state: Arc<Mutex<SessionMemoryState>>) -> MemoryContextCapability {
335 MemoryContextCapability::new(
336 state,
337 ProjectId::parse("proj-tool").expect("valid project id"),
338 ContextProviderId::parse("axel-memory").expect("valid provider id"),
339 UserIntentProof::ExplicitCommand {
340 command_id: RequestId::parse("cmd-test").expect("valid request id"),
341 },
342 )
343 }
344
345 fn wired_context() -> (ToolContext, MemoryContextCapability) {
348 let capability = capability_over(test_state());
349 let mut ctx = create_tool_context();
350 ctx.capabilities.memory_context = Some(capability.clone());
351 (ctx, capability)
352 }
353
354 fn assert_fully_off(capability: &MemoryContextCapability) {
355 let status = capability.status();
356 assert_eq!(status.durable, DurableStatus::Off);
357 assert_eq!(status.one_shot, OneShotStatus::Idle);
358 }
359
360 fn parsed(output: &str) -> Value {
361 serde_json::from_str(output).expect("tool output is valid JSON")
362 }
363
364 #[tokio::test]
367 async fn forged_enable_is_denied_and_installs_no_lease() {
368 let (ctx, capability) = wired_context();
369
370 let error = MemoryContextTool
371 .execute(
372 json!({"action": "enable", "mode": "capture_and_recall", "capture_tools": true, "expires_minutes": 60}),
373 ctx,
374 )
375 .await
376 .unwrap_err()
377 .to_string();
378
379 assert!(error.contains("host confirmation"), "{error}");
380 assert_fully_off(&capability);
381 }
382
383 #[tokio::test]
384 async fn model_index_history_returns_preview_but_cannot_confirm() {
385 let (ctx, capability) = wired_context();
386
387 let preview = MemoryContextTool
388 .execute(json!({"action": "index_history"}), ctx)
389 .await
390 .expect("model proposal receives metadata-only preview");
391
392 assert!(preview.contains("History import preview"), "{preview}");
393 assert!(
394 preview.contains("explicit confirmation required: true"),
395 "{preview}"
396 );
397 assert!(preview.contains("no import has started"), "{preview}");
398 assert_fully_off(&capability);
399 }
400
401 #[tokio::test]
402 async fn enable_without_capability_fails_typed_unavailable() {
403 let error = MemoryContextTool
404 .execute(json!({"action": "enable"}), create_tool_context())
405 .await
406 .unwrap_err()
407 .to_string();
408
409 assert!(error.contains("unavailable in this context"), "{error}");
410 }
411
412 #[tokio::test]
415 async fn status_without_capability_returns_off_without_erroring() {
416 let output = MemoryContextTool
417 .execute(json!({"action": "status"}), create_tool_context())
418 .await
419 .expect("status must never error: Off requires no infrastructure");
420
421 let output = parsed(&output);
422 assert_eq!(output["action"], "status");
423 assert_eq!(output["mode"], "off");
424 assert_eq!(output["one_shot_recall"], "idle");
425 assert_eq!(output["project_digest"], Value::Null);
426 assert_eq!(output["expires_at"], Value::Null);
427 }
428
429 #[tokio::test]
430 async fn status_with_capability_reports_current_state() {
431 let (ctx, _capability) = wired_context();
432
433 let output = MemoryContextTool
434 .execute(json!({"action": "status"}), ctx)
435 .await
436 .unwrap();
437
438 let output = parsed(&output);
439 assert_eq!(output["mode"], "off");
440 assert_eq!(output["one_shot_recall"], "idle");
441 }
442
443 #[tokio::test]
446 async fn disable_without_capability_is_an_idempotent_off_noop() {
447 for _ in 0..2 {
448 let output = MemoryContextTool
449 .execute(json!({"action": "disable"}), create_tool_context())
450 .await
451 .expect("disable is always locally allowed");
452 let output = parsed(&output);
453 assert_eq!(output["action"], "disable");
454 assert_eq!(output["mode"], "off");
455 assert_eq!(output["one_shot_recall"], "idle");
456 }
457 }
458
459 #[tokio::test]
460 async fn disable_revokes_an_active_lease_and_stays_idempotent() {
461 let state = test_state();
462 let lease = MemoryContextLease::grant(
463 MemoryLeaseId::parse("lease-durable").unwrap(),
464 SessionId::parse("sess-tool").unwrap(),
465 ProjectId::parse("proj-tool").unwrap(),
466 ContextProviderId::parse("axel-memory").unwrap(),
467 MemoryContextMode::CaptureAndRecall,
468 CapturePolicy::default(),
469 RecallPolicy::default(),
470 UserIntentProof::ExplicitCommand {
471 command_id: RequestId::parse("cmd-host").unwrap(),
472 },
473 SystemTime::now(),
474 None,
475 )
476 .unwrap();
477 state.lock().unwrap().install(lease).unwrap();
478
479 let capability = capability_over(state);
480 assert_eq!(
481 capability.status().durable,
482 DurableStatus::Active {
483 mode: MemoryContextMode::CaptureAndRecall,
484 lease_id: MemoryLeaseId::parse("lease-durable").unwrap(),
485 expires_at: None,
486 }
487 );
488
489 for _ in 0..2 {
490 let mut ctx = create_tool_context();
491 ctx.capabilities.memory_context = Some(capability.clone());
492 let output = MemoryContextTool
493 .execute(json!({"action": "disable"}), ctx)
494 .await
495 .unwrap();
496 assert_eq!(parsed(&output)["mode"], "off");
497 assert_eq!(capability.status().durable, DurableStatus::Off);
498 }
499 }
500
501 #[tokio::test]
504 async fn recall_once_installs_exactly_one_pending_one_shot() {
505 let (ctx, capability) = wired_context();
506
507 let output = MemoryContextTool
508 .execute(json!({"action": "recall_once"}), ctx)
509 .await
510 .unwrap();
511
512 let output = parsed(&output);
513 assert_eq!(output["action"], "recall_once");
514 assert_eq!(
515 output["mode"], "off",
516 "one-shot never occupies the durable slot"
517 );
518 assert_eq!(output["one_shot_recall"], "pending");
519 assert!(matches!(
520 capability.status().one_shot,
521 OneShotStatus::Pending { .. }
522 ));
523
524 let mut ctx = create_tool_context();
527 ctx.capabilities.memory_context = Some(capability.clone());
528 let error = MemoryContextTool
529 .execute(json!({"action": "recall_once"}), ctx)
530 .await
531 .unwrap_err()
532 .to_string();
533 assert!(error.contains("already pending"), "{error}");
534 assert!(matches!(
535 capability.status().one_shot,
536 OneShotStatus::Pending { .. }
537 ));
538 }
539
540 #[tokio::test]
541 async fn recall_once_accepts_a_bounded_expiry() {
542 let (ctx, capability) = wired_context();
543
544 let output = MemoryContextTool
545 .execute(json!({"action": "recall_once", "expires_minutes": 5}), ctx)
546 .await
547 .unwrap();
548
549 assert_eq!(parsed(&output)["one_shot_recall"], "pending");
550 assert!(matches!(
551 capability.status().one_shot,
552 OneShotStatus::Pending { .. }
553 ));
554 }
555
556 #[tokio::test]
557 async fn recall_once_without_capability_fails_typed() {
558 let error = MemoryContextTool
559 .execute(json!({"action": "recall_once"}), create_tool_context())
560 .await
561 .unwrap_err()
562 .to_string();
563
564 assert!(error.contains("unavailable in this context"), "{error}");
565 }
566
567 #[tokio::test]
570 async fn unknown_action_fails_closed_without_echoing_input() {
571 let (ctx, capability) = wired_context();
572
573 let error = MemoryContextTool
574 .execute(json!({"action": "hijack_everything"}), ctx)
575 .await
576 .unwrap_err()
577 .to_string();
578
579 assert!(error.contains("unknown 'action'"), "{error}");
580 assert!(
581 !error.contains("hijack_everything"),
582 "raw model input must never be echoed back: {error}"
583 );
584 assert_fully_off(&capability);
585 }
586
587 #[tokio::test]
588 async fn malformed_action_and_missing_action_fail_closed() {
589 for params in [
590 json!({}),
591 json!({"action": 5}),
592 json!({"action": null}),
593 json!([]),
594 ] {
595 let (ctx, capability) = wired_context();
596 let error = MemoryContextTool
597 .execute(params, ctx)
598 .await
599 .unwrap_err()
600 .to_string();
601 assert!(
602 error.contains("'action'") || error.contains("JSON object"),
603 "{error}"
604 );
605 assert_fully_off(&capability);
606 }
607 }
608
609 #[tokio::test]
610 async fn extra_properties_fail_closed() {
611 let (ctx, capability) = wired_context();
612
613 let error = MemoryContextTool
614 .execute(json!({"action": "status", "surprise_grant": true}), ctx)
615 .await
616 .unwrap_err()
617 .to_string();
618
619 assert!(error.contains("unknown parameters"), "{error}");
620 assert!(
621 !error.contains("surprise_grant"),
622 "raw model input must never be echoed back: {error}"
623 );
624 assert_fully_off(&capability);
625 }
626
627 #[tokio::test]
628 async fn inapplicable_and_out_of_range_parameters_fail_closed() {
629 let cases = [
630 json!({"action": "disable", "mode": "capture_only"}),
631 json!({"action": "status", "expires_minutes": 5}),
632 json!({"action": "recall_once", "capture_tools": true}),
633 json!({"action": "recall_once", "mode": "recall_each_prompt"}),
634 json!({"action": "recall_once", "expires_minutes": 0}),
635 json!({"action": "recall_once", "expires_minutes": 1441}),
636 json!({"action": "recall_once", "expires_minutes": "5"}),
637 json!({"action": "recall_once", "expires_minutes": 5.5}),
638 json!({"action": "enable", "mode": "off"}),
639 json!({"action": "enable", "capture_tools": "yes"}),
640 ];
641 for params in cases {
642 let (ctx, capability) = wired_context();
643 MemoryContextTool
644 .execute(params.clone(), ctx)
645 .await
646 .expect_err(&format!("must fail closed: {params}"));
647 assert_fully_off(&capability);
648 }
649 }
650}