Skip to main content

agent_engine/tools/
memory_context.rs

1//! `memory_context` control tool (task A4, spec §7.2).
2//!
3//! Scope: only `disable`, `status`, and `recall_once` commit directly — they
4//! are always locally safe/revocable (spec §7.2 rules). `enable` requires
5//! deterministic host-owned proof of user intent (`ExplicitCommand` from the
6//! `/memory` frontend command, task A5) that a model tool call cannot supply
7//! through JSON parameters alone, so it fails with the typed
8//! [`MemoryContextError::RequiresHostConfirmation`] refusal. `index_history`
9//! returns the host-computed D1 disclosure preview, but a model call remains
10//! only a proposal and cannot confirm or begin import.
11//!
12//! Boundary discipline: raw JSON arguments are parsed into the typed
13//! [`MemoryContextRequest`] here at the boundary — malformed values, unknown
14//! actions, schema-foreign properties, and action-inapplicable parameters all
15//! fail closed with static messages that never echo raw model input.
16
17use 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
28/// Control the continuous-memory context of the current session (spec §7.2).
29pub struct MemoryContextTool;
30
31/// The complete, closed set of schema properties (spec §7.2,
32/// `additionalProperties: false`). Enforced manually because provider-side
33/// schema validation is advisory, never trusted.
34const SCHEMA_PROPERTIES: [&str; 4] = ["action", "mode", "capture_tools", "expires_minutes"];
35
36/// Typed, parse-at-the-boundary form of one tool invocation. Only data that
37/// survived validation exists past this point — no raw JSON leaks through.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum MemoryContextRequest {
40    /// Proposal for a durable session lease — always refused (host-only).
41    Enable,
42    /// Revoke this session's memory context (always locally allowed).
43    Disable,
44    /// Metadata-only status snapshot (never spawns a provider).
45    Status,
46    /// Grant a one-shot recall lease, optionally with a bounded expiry.
47    RecallOnce { expires_minutes: Option<u32> },
48    /// Proposal to index prior history — always refused (host-only:
49    /// requires separate disclosure preview and consent, spec §7.2).
50    IndexHistory,
51}
52
53impl MemoryContextRequest {
54    /// Static action name for the typed response — never the raw input.
55    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
66/// Parse raw model JSON into a [`MemoryContextRequest`]. Every failure is a
67/// static, content-free message: raw model input is never echoed back.
68fn 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    // additionalProperties: false — schema-foreign keys fail closed.
74    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    // Validate every known property's type/range regardless of action.
84    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    // Reject parameters inapplicable to the requested action — a fail-closed
120    // strictness on top of the schema's flat property list.
121    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` accepts the full parameter surface (mode/capture_tools/
145        // expires_minutes) per the schema; it is refused later regardless.
146        "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        // Static message only — the raw action value is never echoed.
167        _ => Err("unknown 'action': not a value from the schema enum".to_string()),
168    }
169}
170
171/// Static mode label for the bounded response summary.
172fn 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
182/// Render the bounded JSON status summary (mode, project digest placeholder,
183/// expiry). `None` means no capability is wired: memory is deterministically
184/// `Off` — `Off` requires no infrastructure (task A4 scope).
185fn 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        // Placeholder until task A5 wires the host-computed project digest.
213        "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        // Exact schema from spec §7.2.
239        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(&params).map_err(RuntimeError::Tool)?;
270        let capability: Option<&MemoryContextCapability> = ctx.capabilities.memory_context.as_ref();
271
272        match request {
273            // `status` is deterministic even with no capability wired: memory
274            // is `Off`, and `Off` requires no infrastructure.
275            MemoryContextRequest::Status => Ok(render_summary(
276                request.action_name(),
277                capability.map(MemoryContextCapability::status).as_ref(),
278            )),
279            // `disable` is always locally allowed and idempotent; with no
280            // capability there is nothing to revoke — already `Off`.
281            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            // A model may request the host-owned preview, but the request is
294            // only a proposal: this branch never receives a confirmation proof
295            // and cannot begin import.
296            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            // Durable enable needs deterministic host-owned proof
305            // (ExplicitCommand from /memory, task A5). A tool call cannot carry
306            // it — typed refusal, no lease installed, nothing mutated.
307            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    /// Tool context with the capability wired; also returns the capability
346    /// so tests can observe the shared session state.
347    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    // ── forged enable / index_history ───────────────────────────────────────
365
366    #[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    // ── status ──────────────────────────────────────────────────────────────
413
414    #[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    // ── disable ─────────────────────────────────────────────────────────────
444
445    #[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    // ── recall_once ─────────────────────────────────────────────────────────
502
503    #[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        // A second grant while one is pending fails typed and leaves the
525        // original pending lease in place.
526        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    // ── fail-closed parsing ─────────────────────────────────────────────────
568
569    #[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}