Skip to main content

cortex_mcp/tools/
run.rs

1//! `cortex_run` MCP tool handler.
2//!
3//! Schema (ADR 0045 §4, `cortex_run` extension):
4//! ```text
5//! cortex_run(task: string, model?: string, context_domains?: [string])
6//!   → { task: string, model: string, response_text: string,
7//!       raw_hash: string, persisted: bool, session_indexed: bool,
8//!       context_memories_used: int }
9//! ```
10//!
11//! `session_indexed` is `true` when the AgentResponse event was successfully
12//! written to the JSONL ledger and the SQLite mirror in the same call.
13//! `persisted` is an alias for `session_indexed` kept for schema compatibility.
14//! `context_memories_used` is the count of active memory refs selected into the
15//! context pack that was injected into the LLM request for this task.
16//!
17//! # Safety justification
18//!
19//! This tool is **supervised-tier**: every invocation logs at `tracing::info!`
20//! before the adapter call, making the tool's activation visible in the
21//! operator's log stream before the LLM response is returned.
22//!
23//! The LLM response is **candidate evidence only**. The ADR 0037
24//! `LocalUnsigned` / `RemoteUnsigned` ceiling is enforced by the run pipeline
25//! (`cortex_runtime::run_configured`): the assembled `RunReport` carries
26//! `forbidden_uses` that prevent the raw adapter output from being promoted as
27//! trusted-run-history without an explicit ledger write.
28//!
29//! The MCP tool writes the AgentResponse event to the JSONL ledger and the
30//! SQLite mirror using the local-development unsigned path (same policy as
31//! `cortex run` default). When the composed ADR 0026 policy rejects or
32//! quarantines the run (e.g. `RemoteUnsigned` without operator break-glass),
33//! the write is skipped and `session_indexed` is `false`. The operator retains
34//! `cortex_session_commit` as the sole gate for promoting candidate memories
35//! to `active` (ADR 0047).
36//!
37//! ## ADR 0045 §3 — BreakGlass exclusion
38//!
39//! No `BreakGlassAuthorization` parameter exists on this tool. A `BreakGlass`
40//! composed outcome from the PolicyEngine is treated identically to `Reject`
41//! at the MCP transport boundary: the tool returns
42//! `ToolError::PolicyRejected` and no write occurs.
43
44use std::path::PathBuf;
45use std::sync::{Arc, Mutex};
46
47use async_trait::async_trait;
48use cortex_context::{ContextPackBuilder, ContextRefCandidate, ContextRefId, Sensitivity};
49use cortex_core::{
50    compose_policy_outcomes, AuthorityClass, ClaimCeiling, PolicyContribution, PolicyOutcome,
51    RuntimeMode,
52};
53use cortex_ledger::{
54    JsonlLog, APPEND_ATTESTATION_REQUIRED_RULE_ID, APPEND_EVENT_SOURCE_TIER_GATE_RULE_ID,
55    APPEND_RUNTIME_MODE_RULE_ID,
56};
57use cortex_llm::{
58    blake3_hex, LlmAdapter, LlmError, LlmRequest, LlmResponse, MaxSensitivity, OllamaHttpAdapter,
59};
60use cortex_retrieval::{
61    AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
62};
63use cortex_runtime::{run_configured, Run};
64use cortex_store::mirror::{self, MIRROR_APPEND_PARITY_INVARIANT_RULE_ID};
65use cortex_store::proof::verify_memory_proof_closure;
66use cortex_store::repo::{ContradictionRepo, MemoryRepo};
67use cortex_store::Pool;
68use tracing::warn;
69
70use crate::tool_handler::{GateId, ToolError, ToolHandler};
71
72/// `cortex_run` MCP tool handler.
73///
74/// Builds an auditable context pack from the live store, dispatches the task
75/// through the resolved LLM adapter, and persists the AgentResponse event to
76/// the JSONL ledger and SQLite mirror using the local-development unsigned
77/// path (ADR 0026 §2, same policy as `cortex run` default).
78///
79/// When the composed policy rejects or quarantines the run (e.g.
80/// `RemoteUnsigned` mode without operator break-glass), the ledger write is
81/// skipped and `session_indexed` is `false`.
82///
83/// `rusqlite::Connection` is `Send` but not `Sync`; the `Mutex` provides the
84/// `Sync` bound required by `ToolHandler` while keeping the connection
85/// single-threaded at the call site (the stdio loop is single-threaded).
86#[derive(Debug)]
87pub struct CortexRunTool {
88    /// SQLite connection guarded by a mutex so the struct satisfies `Sync`.
89    pub pool: Arc<Mutex<Pool>>,
90    /// Path to the JSONL event-log file written on each successful run.
91    pub event_log: PathBuf,
92}
93
94impl CortexRunTool {
95    /// Construct with a shared store pool and event-log path.
96    #[must_use]
97    pub fn new(pool: Arc<Mutex<Pool>>, event_log: PathBuf) -> Self {
98        Self { pool, event_log }
99    }
100}
101
102impl ToolHandler for CortexRunTool {
103    fn name(&self) -> &'static str {
104        "cortex_run"
105    }
106
107    fn gate_set(&self) -> &'static [GateId] {
108        &[GateId::SessionWrite]
109    }
110
111    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
112        // ── 1. Extract and validate parameters ────────────────────────────────
113        let task = params
114            .get("task")
115            .and_then(|v| v.as_str())
116            .ok_or_else(|| {
117                ToolError::InvalidParams(
118                    "required parameter `task` is missing or not a string".into(),
119                )
120            })?
121            .to_owned();
122
123        if task.trim().is_empty() {
124            return Err(ToolError::InvalidParams("`task` must not be empty".into()));
125        }
126
127        let model_override: Option<String> = params
128            .get("model")
129            .and_then(|v| v.as_str())
130            .map(|s| s.to_owned());
131
132        // Reject an explicitly supplied empty string before resolution.
133        if let Some(ref m) = model_override {
134            if m.trim().is_empty() {
135                return Err(ToolError::InvalidParams(
136                    "`model` must not be empty when supplied".into(),
137                ));
138            }
139        }
140
141        // ── 2. Resolve the LLM backend ────────────────────────────────────────
142        // Priority: explicit `model` override (ollama:<ref> prefix) →
143        // env vars / cortex.toml via `LlmBackend::resolve()` → offline default.
144        let backend = resolve_backend(model_override.as_deref());
145        let model_name = backend_model_name(&backend);
146
147        tracing::info!(
148            task = %task,
149            model = %model_name,
150            "cortex_run via MCP: dispatching task"
151        );
152
153        // ── 3. Build the context pack from the live store ─────────────────────
154        let pool_guard = self
155            .pool
156            .lock()
157            .map_err(|_| ToolError::Internal("pool lock poisoned".into()))?;
158
159        // ADR 0048 §3 domain-tag sensitivity gate: for remote Claude backends,
160        // query active memories before any bytes leave the machine. Mirrors the
161        // same gate in `cortex_cli::cmd::run`.
162        if let ResolvedBackend::Claude { max_sensitivity, .. } = &backend {
163            let configured_max = max_sensitivity
164                .parse::<MaxSensitivity>()
165                .unwrap_or(MaxSensitivity::Medium);
166            let repo = MemoryRepo::new(&pool_guard);
167            match repo.max_sensitivity_for_active_memories() {
168                Ok(memory_max_str) => {
169                    let gate = cortex_llm::SensitivityGateResult::evaluate(
170                        &memory_max_str,
171                        configured_max,
172                    );
173                    tracing::info!(
174                        max_memory_sensitivity = %gate.max_memory_sensitivity,
175                        configured_max = ?gate.configured_max,
176                        allowed = gate.allowed,
177                        "cortex_run MCP: remote prompt domain-tag sensitivity gate"
178                    );
179                    if !gate.allowed {
180                        return Err(ToolError::PolicyRejected(format!(
181                            "sensitivity_exceeds_remote_threshold: memories at {} exceed configured max_sensitivity {}; remote dispatch refused",
182                            gate.max_memory_sensitivity,
183                            max_sensitivity,
184                        )));
185                    }
186                }
187                Err(err) => {
188                    return Err(ToolError::Internal(format!(
189                        "sensitivity gate store query failed: {err}; refusing remote dispatch"
190                    )));
191                }
192            }
193        }
194
195        let pack = build_context_pack(&pool_guard, &task)?;
196        let context_memories_used = pack.selected_refs.len();
197
198        drop(pool_guard);
199
200        // ── 4. Build the adapter and call run_configured ──────────────────────
201        let runtime = tokio::runtime::Builder::new_current_thread()
202            .enable_all()
203            .build()
204            .map_err(|err| ToolError::Internal(format!("failed to create tokio runtime: {err}")))?;
205
206        let report = runtime.block_on(async {
207            match &backend {
208                ResolvedBackend::Offline => {
209                    let adapter = MpcOfflineAdapter;
210                    let mut run = Run::new(task.clone(), pack)
211                        .map_err(|err| ToolError::Internal(err.to_string()))?;
212                    run.model = model_name.clone();
213                    run_configured(run, &adapter)
214                        .await
215                        .map_err(|err| ToolError::Internal(err.to_string()))
216                }
217                ResolvedBackend::Claude {
218                    model,
219                    max_sensitivity,
220                } => {
221                    let sensitivity = max_sensitivity
222                        .parse::<MaxSensitivity>()
223                        .unwrap_or(MaxSensitivity::Medium);
224                    match cortex_llm::ClaudeHttpAdapter::new(model.clone(), Some(sensitivity)) {
225                        Ok(adapter) => {
226                            let mut run = Run::new(task.clone(), pack)
227                                .map_err(|err| ToolError::Internal(err.to_string()))?;
228                            run.model = model.clone();
229                            run.runtime_mode = RuntimeMode::RemoteUnsigned;
230                            run_configured(run, &adapter)
231                                .await
232                                .map_err(|err| ToolError::Internal(err.to_string()))
233                        }
234                        Err(err) => Err(ToolError::Internal(format!(
235                            "ClaudeHttpAdapter init failed: {err}"
236                        ))),
237                    }
238                }
239                ResolvedBackend::Ollama { endpoint, model } => {
240                    use cortex_llm::OllamaConfig;
241                    let config = OllamaConfig {
242                        endpoint_url: endpoint.clone(),
243                        model: model.clone(),
244                    };
245                    match OllamaHttpAdapter::new(config) {
246                        Ok(adapter) => {
247                            let mut run = Run::new(task.clone(), pack)
248                                .map_err(|err| ToolError::Internal(err.to_string()))?;
249                            run.model = model.clone();
250                            run_configured(run, &adapter)
251                                .await
252                                .map_err(|err| ToolError::Internal(err.to_string()))
253                        }
254                        Err(err) => {
255                            warn!(
256                                "cortex_run: invalid ollama config: {err}; falling back to offline"
257                            );
258                            let adapter = MpcOfflineAdapter;
259                            let mut run = Run::new(task.clone(), pack)
260                                .map_err(|err| ToolError::Internal(err.to_string()))?;
261                            run.model = model_name.clone();
262                            run_configured(run, &adapter)
263                                .await
264                                .map_err(|err| ToolError::Internal(err.to_string()))
265                        }
266                    }
267                }
268            }
269        })?;
270
271        // ── 5. Extract response text from the agent response event payload ─────
272        let response_text = report
273            .agent_response_event
274            .payload
275            .get("text")
276            .and_then(|v| v.as_str())
277            .unwrap_or("")
278            .to_owned();
279
280        tracing::info!(
281            task = %task,
282            model = %report.model,
283            response_len = response_text.len(),
284            raw_hash = %report.raw_hash,
285            "cortex_run via MCP: task='{}' model='{}' response_len={}",
286            task,
287            report.model,
288            response_text.len()
289        );
290
291        // ── 6. Persist the AgentResponse event to the JSONL ledger + SQLite ──
292        // Mirrors the local-development unsigned path from `cortex run` (ADR
293        // 0026 §2). RemoteUnsigned runs quarantine under this policy, so
294        // `session_indexed` is `false` for those — no error is returned to the
295        // caller because the LLM response itself succeeded.
296        let session_indexed =
297            persist_agent_response_event_mcp(&self.pool, &self.event_log, &report);
298
299        // ── 7. Return MCP schema ───────────────────────────────────────────────
300        Ok(serde_json::json!({
301            "task":                   task,
302            "model":                  report.model,
303            "response_text":          response_text,
304            "raw_hash":               report.raw_hash,
305            "persisted":              session_indexed,
306            "session_indexed":        session_indexed,
307            "context_memories_used":  context_memories_used,
308        }))
309    }
310}
311
312// ── Ledger persistence (local-development unsigned path) ─────────────────────
313
314/// Attempt to persist the AgentResponse event from a completed run to the
315/// JSONL ledger and SQLite mirror.
316///
317/// Uses the same local-development unsigned policy as `cortex run` default
318/// (three required contributors: event-source tier gate, attestation, and
319/// runtime mode). `RemoteUnsigned` runs receive a `Warn` or `Quarantine`
320/// from the runtime-mode contributor depending on the composed outcome and
321/// will not be written — `false` is returned without bubbling the error.
322///
323/// All I/O failures are logged at `warn` level; the function returns `false`
324/// rather than propagating so the LLM response is always delivered to the
325/// caller even when ledger I/O is unavailable.
326fn persist_agent_response_event_mcp(
327    pool: &Arc<Mutex<Pool>>,
328    event_log: &std::path::Path,
329    report: &cortex_runtime::RunReport,
330) -> bool {
331    let ledger_policy = mcp_local_development_ledger_policy();
332    let mirror_policy = mcp_mirror_parity_satisfied_policy();
333
334    // Check composed policy before attempting any I/O.
335    match ledger_policy.final_outcome {
336        PolicyOutcome::Allow | PolicyOutcome::Warn => {}
337        _ => {
338            tracing::info!(
339                raw_hash = %report.raw_hash,
340                runtime_mode = ?report.runtime_mode,
341                "cortex_run: ledger write skipped: policy outcome {:?} does not permit unsigned append",
342                ledger_policy.final_outcome,
343            );
344            return false;
345        }
346    }
347
348    let mut log = match JsonlLog::open(event_log) {
349        Ok(log) => log,
350        Err(err) => {
351            warn!(
352                event_log = %event_log.display(),
353                error = %err,
354                "cortex_run: failed to open JSONL event log for ledger write"
355            );
356            return false;
357        }
358    };
359
360    let mut pool_guard = match pool.lock() {
361        Ok(guard) => guard,
362        Err(_) => {
363            warn!("cortex_run: pool lock poisoned; skipping ledger write");
364            return false;
365        }
366    };
367
368    match mirror::append_event(
369        &mut log,
370        &mut pool_guard,
371        report.agent_response_event.clone(),
372        &ledger_policy,
373        &mirror_policy,
374    ) {
375        Ok(sealed) => {
376            tracing::info!(
377                event_hash = %sealed.event_hash,
378                raw_hash = %report.raw_hash,
379                "cortex_run: AgentResponse event written to JSONL ledger"
380            );
381            true
382        }
383        Err(err) => {
384            warn!(
385                error = %err,
386                raw_hash = %report.raw_hash,
387                "cortex_run: failed to persist AgentResponse event to ledger"
388            );
389            false
390        }
391    }
392}
393
394/// ADR 0026 policy decision for an unsigned `cortex_run` MCP agent-response
395/// append into the local-development ledger.
396///
397/// Agent responses are `EventSource::ChildAgent`, never `EventSource::User`,
398/// so the attestation contributor is `Allow` by construction. The runtime-mode
399/// contributor emits `Warn` (unsigned local-development ledger, ADR 0037 §2).
400fn mcp_local_development_ledger_policy() -> cortex_core::PolicyDecision {
401    compose_policy_outcomes(
402        vec![
403            PolicyContribution::new(
404                APPEND_EVENT_SOURCE_TIER_GATE_RULE_ID,
405                PolicyOutcome::Allow,
406                "cortex_run MCP: agent-response source tier gate satisfied",
407            )
408            .expect("static policy contribution is valid"),
409            PolicyContribution::new(
410                APPEND_ATTESTATION_REQUIRED_RULE_ID,
411                PolicyOutcome::Allow,
412                "cortex_run MCP: non-user agent-response does not require user attestation",
413            )
414            .expect("static policy contribution is valid"),
415            PolicyContribution::new(
416                APPEND_RUNTIME_MODE_RULE_ID,
417                PolicyOutcome::Warn,
418                "cortex_run MCP: unsigned local-development ledger row (ADR 0037 §2 DevOnly)",
419            )
420            .expect("static policy contribution is valid"),
421        ],
422        None,
423    )
424}
425
426/// ADR 0026 policy decision for the JSONL <-> SQLite parity invariant gate
427/// on a mirrored append from `cortex_run` MCP.
428fn mcp_mirror_parity_satisfied_policy() -> cortex_core::PolicyDecision {
429    compose_policy_outcomes(
430        vec![PolicyContribution::new(
431            MIRROR_APPEND_PARITY_INVARIANT_RULE_ID,
432            PolicyOutcome::Allow,
433            "cortex_run MCP: mirror parity preflight passes for empty-or-consistent ledger",
434        )
435        .expect("static policy contribution is valid")],
436        None,
437    )
438}
439
440// ── Internal resolved backend ─────────────────────────────────────────────────
441
442/// Backend resolved from model override or config/env.
443enum ResolvedBackend {
444    Offline,
445    Claude {
446        model: String,
447        max_sensitivity: String,
448    },
449    Ollama {
450        endpoint: String,
451        model: String,
452    },
453}
454
455fn resolve_backend(model_override: Option<&str>) -> ResolvedBackend {
456    if let Some(spec) = model_override {
457        if let Some(model) = spec.strip_prefix("ollama:") {
458            let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
459                .unwrap_or_else(|_| "http://localhost:11434".to_string());
460            return ResolvedBackend::Ollama {
461                endpoint,
462                model: model.to_string(),
463            };
464        }
465        if let Some(model) = spec.strip_prefix("claude:") {
466            return ResolvedBackend::Claude {
467                model: model.to_string(),
468                max_sensitivity: "medium".to_string(),
469            };
470        }
471    }
472
473    // Env / config file resolution (mirrors LlmBackend::resolve() in cortex-cli).
474    let env_backend = std::env::var("CORTEX_LLM_BACKEND")
475        .ok()
476        .filter(|s| !s.is_empty());
477    let env_model = std::env::var("CORTEX_LLM_MODEL")
478        .ok()
479        .filter(|s| !s.is_empty());
480    let env_endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
481        .ok()
482        .filter(|s| !s.is_empty());
483
484    let backend_str = env_backend.as_deref().unwrap_or("offline");
485
486    match backend_str {
487        "ollama" => {
488            let model = env_model.unwrap_or_default();
489            let endpoint = env_endpoint.unwrap_or_else(|| "http://localhost:11434".to_string());
490            ResolvedBackend::Ollama { endpoint, model }
491        }
492        "claude" => {
493            let model = env_model.unwrap_or_default();
494            let max_sensitivity = std::env::var("CORTEX_LLM_MAX_SENSITIVITY")
495                .unwrap_or_else(|_| "medium".to_string());
496            ResolvedBackend::Claude {
497                model,
498                max_sensitivity,
499            }
500        }
501        _ => ResolvedBackend::Offline,
502    }
503}
504
505fn backend_model_name(backend: &ResolvedBackend) -> String {
506    match backend {
507        ResolvedBackend::Offline => "offline".to_string(),
508        ResolvedBackend::Claude { model, .. } => model.clone(),
509        ResolvedBackend::Ollama { model, .. } => format!("ollama:{model}"),
510    }
511}
512
513// ── Context pack builder (no cortex-cli dependency) ──────────────────────────
514
515/// Build a context pack from the store for the given task.
516///
517/// Replicates the essential steps of `cortex_cli::cmd::context::build_pack`
518/// without the CLI `Exit` return type: any failure maps to a `ToolError`.
519fn build_context_pack(pool: &Pool, task: &str) -> Result<cortex_context::ContextPack, ToolError> {
520    let repo = MemoryRepo::new(pool);
521    let active = repo
522        .list_by_status("active")
523        .map_err(|err| ToolError::Internal(format!("failed to read active memories: {err}")))?;
524
525    let mut builder = ContextPackBuilder::new(task, 4096_usize);
526
527    for memory in &active {
528        let proof = verify_memory_proof_closure(pool, &memory.id).map_err(|err| {
529            ToolError::Internal(format!(
530                "failed to verify memory {} proof closure: {err}",
531                memory.id
532            ))
533        })?;
534        if proof.require_current_use_allowed().is_err() {
535            // Skip memories excluded from default context use.
536            continue;
537        }
538        builder = builder.select_ref(
539            ContextRefCandidate::new(
540                ContextRefId::Memory {
541                    memory_id: memory.id,
542                },
543                memory.claim.clone(),
544            )
545            .with_claim_metadata(
546                RuntimeMode::LocalUnsigned,
547                AuthorityClass::Derived,
548                proof.state().into(),
549                ClaimCeiling::LocalUnsigned,
550            )
551            .with_sensitivity(Sensitivity::Internal),
552        );
553    }
554
555    // Gate on open contradictions — replaces the CLI helper.
556    gate_open_contradictions(pool, &active)?;
557
558    builder
559        .build()
560        .map_err(|err| ToolError::Internal(format!("context pack build failed: {err}")))
561}
562
563/// Reject the context build if open contradictions exist among active memories.
564///
565/// Mirrors `cortex_cli::cmd::context::gate_open_contradictions_for_default_context`.
566fn gate_open_contradictions(
567    pool: &Pool,
568    memories: &[cortex_store::repo::MemoryRecord],
569) -> Result<(), ToolError> {
570    use cortex_retrieval::resolve_conflicts;
571    use std::collections::{BTreeMap, BTreeSet};
572
573    let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
574        memories.iter().map(|m| (m.id.to_string(), m)).collect();
575
576    let contradictions = ContradictionRepo::new(pool)
577        .list_open()
578        .map_err(|err| ToolError::Internal(format!("failed to read open contradictions: {err}")))?;
579
580    let mut affected_ids = BTreeSet::new();
581    let mut conflict_edges = BTreeMap::<String, BTreeSet<String>>::new();
582
583    for contradiction in contradictions {
584        let left_active = active_by_id.contains_key(&contradiction.left_ref);
585        let right_active = active_by_id.contains_key(&contradiction.right_ref);
586        if !left_active && !right_active {
587            continue;
588        }
589        if !(left_active && right_active) {
590            return Err(ToolError::PolicyRejected(format!(
591                "open contradiction {} references unavailable memory and cannot be resolved for default context-pack use",
592                contradiction.id
593            )));
594        }
595        affected_ids.insert(contradiction.left_ref.clone());
596        affected_ids.insert(contradiction.right_ref.clone());
597        conflict_edges
598            .entry(contradiction.left_ref.clone())
599            .or_default()
600            .insert(contradiction.right_ref.clone());
601        conflict_edges
602            .entry(contradiction.right_ref)
603            .or_default()
604            .insert(contradiction.left_ref);
605    }
606
607    if affected_ids.is_empty() {
608        return Ok(());
609    }
610
611    let inputs = affected_ids
612        .iter()
613        .filter_map(|id| active_by_id.get(id.as_str()).copied())
614        .map(|memory| {
615            ConflictingMemoryInput::new(
616                memory.id.to_string(),
617                Some(memory.id.to_string()),
618                memory.claim.clone(),
619                AuthorityProofHint {
620                    authority: authority_level(&memory.authority),
621                    proof: ProofClosureHint::FullChainVerified,
622                },
623            )
624            .with_conflicts(
625                conflict_edges
626                    .get(&memory.id.to_string())
627                    .map(|ids| ids.iter().cloned().collect())
628                    .unwrap_or_default(),
629            )
630        })
631        .collect::<Vec<_>>();
632
633    let output = resolve_conflicts(&inputs, &[]);
634    output.require_default_use_allowed().map_err(|err| {
635        ToolError::PolicyRejected(format!(
636            "open contradiction blocks default context-pack use: {err}"
637        ))
638    })
639}
640
641fn authority_level(authority: &str) -> AuthorityLevel {
642    match authority {
643        "user" | "operator" => AuthorityLevel::High,
644        "tool" | "system" => AuthorityLevel::Medium,
645        _ => AuthorityLevel::Low,
646    }
647}
648
649// ── Offline adapter (mirrors OfflineAdapter in cortex-cli run.rs) ─────────────
650
651#[derive(Debug)]
652struct MpcOfflineAdapter;
653
654#[async_trait]
655impl LlmAdapter for MpcOfflineAdapter {
656    fn adapter_id(&self) -> &'static str {
657        "mcp-offline"
658    }
659
660    async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
661        let text = format!("offline response for {}", req.model);
662        Ok(LlmResponse {
663            text: text.clone(),
664            parsed_json: None,
665            model: req.model,
666            usage: None,
667            raw_hash: blake3_hex(text.as_bytes()),
668        })
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn make_pool() -> Arc<Mutex<Pool>> {
677        let pool = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
678        cortex_store::migrate::apply_pending(&pool).expect("in-memory migrations");
679        Arc::new(Mutex::new(pool))
680    }
681
682    fn make_tool_with_log(event_log: PathBuf) -> CortexRunTool {
683        CortexRunTool::new(make_pool(), event_log)
684    }
685
686    fn make_tool() -> (CortexRunTool, tempfile::TempDir) {
687        let dir = tempfile::tempdir().expect("temp dir");
688        let log_path = dir.path().join("events.jsonl");
689        let tool = make_tool_with_log(log_path);
690        (tool, dir)
691    }
692
693    /// Tool name must match the ADR 0045 schema contract.
694    #[test]
695    fn tool_name_matches_schema_contract() {
696        let (tool, _dir) = make_tool();
697        assert_eq!(tool.name(), "cortex_run");
698    }
699
700    /// gate_set must declare SessionWrite.
701    #[test]
702    fn gate_set_declares_session_write() {
703        let (tool, _dir) = make_tool();
704        assert!(
705            tool.gate_set().contains(&GateId::SessionWrite),
706            "gate_set must include SessionWrite"
707        );
708    }
709
710    /// Missing `task` must return InvalidParams.
711    #[test]
712    fn missing_task_returns_invalid_params() {
713        let (tool, _dir) = make_tool();
714        let err = tool
715            .call(serde_json::json!({}))
716            .expect_err("must reject missing task");
717        assert!(
718            matches!(err, ToolError::InvalidParams(_)),
719            "expected InvalidParams, got: {err:?}"
720        );
721    }
722
723    /// Empty `task` must return InvalidParams.
724    #[test]
725    fn empty_task_returns_invalid_params() {
726        let (tool, _dir) = make_tool();
727        let err = tool
728            .call(serde_json::json!({ "task": "   " }))
729            .expect_err("must reject empty task");
730        assert!(
731            matches!(err, ToolError::InvalidParams(_)),
732            "expected InvalidParams, got: {err:?}"
733        );
734    }
735
736    /// Empty explicit `model` must return InvalidParams.
737    #[test]
738    fn empty_model_override_returns_invalid_params() {
739        let (tool, _dir) = make_tool();
740        let err = tool
741            .call(serde_json::json!({ "task": "hello", "model": "" }))
742            .expect_err("must reject empty model");
743        assert!(
744            matches!(err, ToolError::InvalidParams(_)),
745            "expected InvalidParams, got: {err:?}"
746        );
747    }
748
749    /// With a valid task and offline backend the tool must return the full
750    /// schema including `session_indexed` and write one event to the JSONL log.
751    #[test]
752    fn valid_task_writes_event_to_ledger() {
753        let dir = tempfile::tempdir().expect("temp dir");
754        let log_path = dir.path().join("events.jsonl");
755        let tool = make_tool_with_log(log_path.clone());
756
757        let result = tool
758            .call(serde_json::json!({ "task": "diagnose the system" }))
759            .expect("offline run must succeed");
760
761        assert_eq!(result["task"], "diagnose the system");
762        assert!(result["raw_hash"].as_str().is_some());
763        assert!(result["response_text"].as_str().is_some());
764        assert!(result["model"].as_str().is_some());
765
766        // `session_indexed` must be present and boolean.
767        let session_indexed = result["session_indexed"]
768            .as_bool()
769            .expect("session_indexed must be a boolean");
770        // `persisted` must mirror `session_indexed`.
771        assert_eq!(result["persisted"], result["session_indexed"]);
772
773        if session_indexed {
774            // Verify the JSONL log has exactly one event row.
775            let log =
776                cortex_ledger::JsonlLog::open(&log_path).expect("JSONL log opens after tool call");
777            assert_eq!(
778                log.len(),
779                1,
780                "one AgentResponse event must be in the JSONL log"
781            );
782        }
783    }
784
785    /// Empty store → `context_memories_used` must be 0.
786    #[test]
787    fn context_memories_used_is_zero_for_empty_store() {
788        let (tool, _dir) = make_tool();
789
790        let result = tool
791            .call(serde_json::json!({ "task": "summarise project status" }))
792            .expect("offline run must succeed");
793
794        let used = result["context_memories_used"]
795            .as_u64()
796            .expect("context_memories_used must be a non-negative integer");
797        assert_eq!(used, 0, "empty store produces no context memories");
798    }
799
800    /// Seed one active memory → `context_memories_used` must be ≥ 1 and the
801    /// LLM-visible request message must contain a non-empty `selected_refs`
802    /// array inside the serialised `context_pack`.
803    ///
804    /// This test verifies the core Cortex contract: the LLM request carries
805    /// real memory context, not a bare task string.
806    #[test]
807    fn context_memories_used_reflects_active_memory_count_and_pack_is_non_empty() {
808        let pool_inner = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
809        cortex_store::migrate::apply_pending(&pool_inner).expect("apply migrations");
810
811        // Insert a source event and one active memory using valid ULIDs so that
812        // verify_memory_proof_closure can resolve the lineage edge and allow
813        // the memory into the context pack.
814        let event_id = cortex_core::EventId::new().to_string();
815        let memory_id = cortex_core::MemoryId::new().to_string();
816        pool_inner
817            .execute(
818                "INSERT INTO events (
819                    id, schema_version, observed_at, recorded_at, source_json,
820                    event_type, trace_id, session_id, domain_tags_json, payload_json,
821                    payload_hash, prev_event_hash, event_hash
822                ) VALUES (
823                    ?1, 1, '2026-05-13T00:00:00Z', '2026-05-13T00:00:00Z',
824                    '{\"type\":\"tool\",\"name\":\"test\"}', 'cortex.event.tool_result.v1',
825                    NULL, NULL, '[]', '{\"fixture\":true}',
826                    'pp_test', NULL, 'eh_test'
827                );",
828                rusqlite::params![event_id],
829            )
830            .expect("insert source event fixture");
831        let source_events_json = serde_json::json!([event_id]).to_string();
832        pool_inner
833            .execute(
834                "INSERT INTO memories (
835                    id, memory_type, status, claim, source_episodes_json,
836                    source_events_json, domains_json, salience_json, confidence,
837                    authority, applies_when_json, does_not_apply_when_json,
838                    created_at, updated_at
839                ) VALUES (
840                    ?1, 'semantic', 'active',
841                    'Cortex injects memory context into every LLM request.',
842                    '[]', ?2, '[]',
843                    json_object('score', 0.9), 0.9, 'user',
844                    '[]', '[]',
845                    '2026-05-13T00:00:00Z', '2026-05-13T00:00:00Z'
846                );",
847                rusqlite::params![memory_id, source_events_json],
848            )
849            .expect("insert active memory fixture");
850
851        let dir = tempfile::tempdir().expect("temp dir");
852        let log_path = dir.path().join("events.jsonl");
853        let pool = Arc::new(Mutex::new(pool_inner));
854        let tool = CortexRunTool::new(Arc::clone(&pool), log_path);
855
856        let result = tool
857            .call(serde_json::json!({ "task": "summarise project status" }))
858            .expect("offline run with seeded memories must succeed");
859
860        let used = result["context_memories_used"]
861            .as_u64()
862            .expect("context_memories_used must be a non-negative integer");
863        assert!(
864            used >= 1,
865            "one active memory must produce context_memories_used >= 1; got {used}"
866        );
867    }
868}