Skip to main content

cortex_mcp/tools/
memory_outcome.rs

1//! `cortex_memory_outcome` MCP tool handler.
2//!
3//! Records a helpful / not-helpful outcome verdict for an active memory used
4//! in a session. Mirrors the `cortex memory outcome record` CLI path
5//! (`crates/cortex-cli/src/cmd/memory.rs` `outcome_record` fn) using the
6//! same [`MemoryRepo`] surface.
7//!
8//! Gate: [`GateId::SessionWrite`].
9//! Tier: supervised — executes and logs on every call.
10
11use std::sync::{Arc, Mutex};
12
13use chrono::Utc;
14use cortex_core::{MemoryId, OutcomeMemoryRelation};
15use cortex_store::repo::{MemoryRepo, MemorySessionUse, OutcomeMemoryRelationRecord};
16use cortex_store::Pool;
17use serde_json::{json, Value};
18
19use crate::tool_handler::{GateId, ToolError, ToolHandler};
20
21/// Stable invariant emitted when the memory is absent or not active.
22const MEMORY_NOT_FOUND_INVARIANT: &str = "memory.outcome.memory_not_found";
23
24/// MCP tool: `cortex_memory_outcome`.
25///
26/// Schema:
27/// ```text
28/// cortex_memory_outcome(
29///   memory_id:  string,
30///   session_id: string,
31///   result:     "helpful" | "not-helpful",
32///   note?:      string,
33/// ) -> { recorded: bool }
34/// ```
35#[derive(Debug)]
36pub struct CortexMemoryOutcomeTool {
37    pool: Arc<Mutex<Pool>>,
38}
39
40impl CortexMemoryOutcomeTool {
41    /// Construct the tool over a shared, mutex-guarded store connection.
42    #[must_use]
43    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
44        Self { pool }
45    }
46}
47
48impl ToolHandler for CortexMemoryOutcomeTool {
49    fn name(&self) -> &'static str {
50        "cortex_memory_outcome"
51    }
52
53    fn gate_set(&self) -> &'static [GateId] {
54        &[GateId::SessionWrite]
55    }
56
57    fn call(&self, params: Value) -> Result<Value, ToolError> {
58        let memory_id_str = params["memory_id"]
59            .as_str()
60            .filter(|s| !s.is_empty())
61            .ok_or_else(|| ToolError::InvalidParams("memory_id is required".into()))?;
62
63        let session_id = params["session_id"]
64            .as_str()
65            .filter(|s| !s.trim().is_empty())
66            .ok_or_else(|| ToolError::InvalidParams("session_id is required".into()))?;
67
68        let result_str = params["result"]
69            .as_str()
70            .ok_or_else(|| ToolError::InvalidParams("result is required".into()))?;
71
72        let note = params["note"].as_str().map(ToOwned::to_owned);
73
74        let memory_id: MemoryId = memory_id_str.parse().map_err(|err| {
75            ToolError::InvalidParams(format!("memory_id `{memory_id_str}` is invalid: {err}"))
76        })?;
77
78        let (relation, result_name) = match result_str {
79            "helpful" => (OutcomeMemoryRelation::Used, "helpful"),
80            "not-helpful" => (OutcomeMemoryRelation::Rejected, "not-helpful"),
81            other => {
82                return Err(ToolError::InvalidParams(format!(
83                    "result must be `helpful` or `not-helpful`, got `{other}`"
84                )));
85            }
86        };
87
88        tracing::info!(
89            "cortex_memory_outcome via MCP: memory_id={} result={}",
90            memory_id,
91            result_name
92        );
93
94        let pool = self
95            .pool
96            .lock()
97            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
98        let repo = MemoryRepo::new(&pool);
99
100        // Validate that the memory exists and is active.
101        let memory = match repo.get_by_id(&memory_id) {
102            Ok(Some(m)) if m.status == "active" => m,
103            Ok(Some(_)) => {
104                return Err(ToolError::InvalidParams(format!(
105                    "{MEMORY_NOT_FOUND_INVARIANT}: memory {memory_id} exists but is not active"
106                )));
107            }
108            Ok(None) => {
109                return Err(ToolError::InvalidParams(format!(
110                    "{MEMORY_NOT_FOUND_INVARIANT}: memory {memory_id} not found"
111                )));
112            }
113            Err(err) => {
114                return Err(ToolError::Internal(format!(
115                    "failed to look up memory {memory_id}: {err}"
116                )));
117            }
118        };
119
120        let now = Utc::now();
121
122        let session_use = MemorySessionUse {
123            memory_id: memory.id,
124            session_id: session_id.to_owned(),
125            first_used_at: now,
126            last_used_at: now,
127            use_count: 1,
128        };
129        repo.record_session_use(&session_use)
130            .map_err(|err| ToolError::Internal(format!("failed to record session use: {err}")))?;
131
132        let outcome_ref = format!("outcome:{session_id}:{memory_id}:{result_name}");
133
134        // evidence_ref must be None for non-validation relations (Used/Rejected);
135        // the policy gate rejects non-None evidence_ref on non-Validated writes
136        // (memory.outcome.utility_to_truth_promotion_unauthorized). Embed the
137        // operator note in outcome_ref instead so it appears in audit records.
138        let outcome_ref_with_note = match note {
139            Some(ref n) if !n.is_empty() => format!("{outcome_ref}:note:{n}"),
140            _ => outcome_ref.clone(),
141        };
142
143        let relation_record = OutcomeMemoryRelationRecord {
144            outcome_ref: outcome_ref_with_note,
145            memory_id,
146            relation,
147            recorded_at: now,
148            source_event_id: None,
149            validation_scope: None,
150            validating_principal_id: None,
151            evidence_ref: None,
152        };
153
154        repo.record_outcome_relation(&relation_record, None)
155            .map_err(|err| {
156                ToolError::Internal(format!("failed to record outcome relation: {err}"))
157            })?;
158
159        Ok(json!({ "recorded": true }))
160    }
161}