Skip to main content

lean_ctx_server/tools/registered/
ctx_session.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5use std::time::Duration;
6
7use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_str};
8use lean_ctx::tool_defs::tool_def;
9
10/// #1570 P1: `compact` records a directive (summary in `value`); the proxy
11/// replaces the pre-cut span on every request until `restore` clears it —
12/// lossless by construction since the client resends full history each turn.
13fn handle_compact_action(
14    action: &str,
15    value: Option<&str>,
16    args: &Map<String, Value>,
17) -> ToolOutput {
18    let text = if action == "restore" {
19        if lean_ctx::core::compact_directive::clear() {
20            "Compaction cleared — the next request carries the full original history again."
21                .to_string()
22        } else {
23            "No active compaction to restore.".to_string()
24        }
25    } else {
26        let keep = args
27            .get("keep_recent_turns")
28            .and_then(serde_json::Value::as_u64)
29            .map_or(6, |n| n as usize)
30            .max(4);
31        match value {
32            None | Some("") => "compact requires value = the summary that replaces the \
33                 compacted span. Write it exhaustive-but-lean: every file path, decision, \
34                 constraint and finding that keeps context integrity; quote short user \
35                 requests verbatim; drop dead-end exploration."
36                .to_string(),
37            Some(summary) => {
38                match lean_ctx::core::compact_directive::create(keep, summary.to_string()) {
39                    Ok(()) => format!(
40                        "Compaction armed (keeps last {keep} user turns verbatim). It applies \
41                         to your NEXT request onward, only outside the provider-cached \
42                         prefix, at tool-pair-safe boundaries. Undo any time: \
43                         ctx_session action=\"restore\"."
44                    ),
45                    Err(reason) => reason,
46                }
47            }
48        }
49    };
50    ToolOutput {
51        text,
52        original_tokens: 0,
53        saved_tokens: 0,
54        mode: Some(action.to_string()),
55        path: None,
56        changed: false,
57        shell_outcome: None,
58        content_blocks: None,
59    }
60}
61
62pub struct CtxSessionTool;
63
64/// Session management is useful, but it must never make the entire MCP server
65/// wait behind a long-lived writer. Explicitly retryable operations fail fast.
66const SESSION_LOCK_BUDGET: Duration = Duration::from_millis(250);
67
68fn session_matches_snapshot(
69    live: &lean_ctx::core::session::SessionState,
70    snapshot: &lean_ctx::core::session::SessionState,
71) -> bool {
72    live.id == snapshot.id
73        && live.version == snapshot.version
74        && live.project_root == snapshot.project_root
75        && live.shell_cwd == snapshot.shell_cwd
76        && live.extra_roots == snapshot.extra_roots
77}
78
79impl McpTool for CtxSessionTool {
80    fn name(&self) -> &'static str {
81        "ctx_session"
82    }
83
84    fn tool_def(&self) -> Tool {
85        tool_def(
86            "ctx_session",
87            "Session memory. save at session end, load at start, status = snapshot;\n\
88             task|finding|decision record progress (value=text).\n\
89             ANTIPATTERN: permanent project knowledge → ctx_knowledge.",
90            json!({
91                "type": "object",
92                "properties": {
93                    "action": {
94                        "type": "string",
95                        "description": "status|handoff|load|save|task|finding|decision|compact|restore|list|… (invalid action lists all)"
96                    },
97                    "value": { "type": "string" },
98                    "tag": { "type": "string", "enum": ["note", "dead_end"], "description": "finding: dead_end = tried and abandoned" },
99                    "keep_recent_turns": { "type": "integer", "description": "compact: trailing user turns kept verbatim (default 6)" },
100                    "session_id": { "type": "string", "description": "Omit for your own session" }
101                },
102                "required": ["action"]
103            }),
104        )
105    }
106
107    fn handle(
108        &self,
109        args: &Map<String, Value>,
110        ctx: &ToolContext,
111    ) -> Result<ToolOutput, ErrorData> {
112        let action = get_str(args, "action")
113            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
114        let value = get_str(args, "value");
115        let sid = get_str(args, "session_id");
116        let format = get_str(args, "format");
117        let path = get_str(args, "path");
118        let write = get_bool(args, "write").unwrap_or(false);
119        let privacy = get_str(args, "privacy");
120        let terse = get_bool(args, "terse");
121        let dead_end = match get_str(args, "tag").as_deref() {
122            None | Some("note") => false,
123            Some("dead_end") => true,
124            Some(_) => {
125                return Err(ErrorData::invalid_params(
126                    "tag must be note or dead_end",
127                    None,
128                ));
129            }
130        };
131
132        // Restore is content-free. Compact summaries are admitted against a
133        // detached, trusted session scope before the directive reaches disk.
134        if action == "restore" {
135            return Ok(handle_compact_action(&action, value.as_deref(), args));
136        }
137        if action == "compact" {
138            let Some(summary) = value.as_deref().filter(|summary| !summary.is_empty()) else {
139                return Ok(handle_compact_action(&action, value.as_deref(), args));
140            };
141            let session_handle = ctx
142                .session
143                .as_ref()
144                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
145            let Some(snapshot) = crate::server::bounded_lock::read_for(
146                session_handle,
147                "ctx_session compact admission snapshot",
148                SESSION_LOCK_BUDGET,
149            )
150            .map(|session| session.clone()) else {
151                return Err(ErrorData::internal_error(
152                    "session is busy; retry the operation",
153                    None,
154                ));
155            };
156            let admitted =
157                lean_ctx::core::session::admission::admit_storable_session_text(&snapshot, summary)
158                    .map_err(|_| {
159                        ErrorData::invalid_params(
160                            "compact summary withheld by current policy",
161                            None,
162                        )
163                    })?;
164            return Ok(handle_compact_action(&action, Some(&admitted), args));
165        }
166
167        let tool_calls_handle = ctx
168            .tool_calls
169            .as_ref()
170            .ok_or_else(|| ErrorData::internal_error("tool_calls not available", None))?;
171        let call_durations: Vec<(String, u64)> = crate::server::bounded_lock::read_for(
172            tool_calls_handle,
173            "ctx_session tool-call snapshot",
174            SESSION_LOCK_BUDGET,
175        )
176        .map_or_else(Vec::new, |tc| {
177            tc.iter().map(|c| (c.tool.clone(), c.duration_ms)).collect()
178        });
179        let agent_id = ctx
180            .agent_id
181            .as_ref()
182            .and_then(|agent_id| agent_id.try_read().ok().and_then(|id| id.clone()));
183
184        let result = if let Some(result) = crate::tools::ctx_session::handle_without_session(
185            &action,
186            value.as_deref(),
187            sid.as_deref(),
188        ) {
189            result
190        } else {
191            let session_handle = ctx
192                .session
193                .as_ref()
194                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
195
196            if action == "save" {
197                let Some(snapshot) = crate::server::bounded_lock::read_for(
198                    session_handle,
199                    "ctx_session save snapshot",
200                    SESSION_LOCK_BUDGET,
201                )
202                .map(|session| session.clone()) else {
203                    return Err(ErrorData::internal_error(
204                        "session is busy; retry the operation",
205                        None,
206                    ));
207                };
208                let result =
209                    match lean_ctx::core::session::SessionState::save_shared_sync(session_handle) {
210                        Err(error) => format!("Save failed: {error}"),
211                        Ok(receipt) if receipt.current => {
212                            format!("Session {} saved (v{}).", receipt.id, receipt.version)
213                        }
214                        Ok(receipt) => format!(
215                            "Session {} snapshot v{} saved; newer session changes remain pending.",
216                            receipt.id, receipt.version
217                        ),
218                    };
219                lean_ctx::core::session::admission::admit_tool_value(&snapshot, &result)
220                    .unwrap_or_else(|_| {
221                        "ERROR: session save result withheld by current policy.".into()
222                    })
223            } else if action == "reset" {
224                let Some(snapshot) = crate::server::bounded_lock::read_for(
225                    session_handle,
226                    "ctx_session reset snapshot",
227                    SESSION_LOCK_BUDGET,
228                )
229                .map(|session| session.clone()) else {
230                    return Err(ErrorData::internal_error(
231                        "session is busy; retry the operation",
232                        None,
233                    ));
234                };
235                let output =
236                    match lean_ctx::core::session::SessionState::save_shared_sync(session_handle) {
237                        Err(error) => {
238                            format!("Reset refused: current session was not saved: {error}")
239                        }
240                        Ok(receipt)
241                            if !receipt.current
242                                || receipt.id != snapshot.id
243                                || receipt.version != snapshot.version =>
244                        {
245                            "Reset refused: session changed while it was being saved; retry.".into()
246                        }
247                        Ok(_) => {
248                            let Some(mut live) = crate::server::bounded_lock::write_for(
249                                session_handle,
250                                "ctx_session reset commit",
251                                SESSION_LOCK_BUDGET,
252                            ) else {
253                                return Err(ErrorData::internal_error(
254                                    "session is busy; retry the operation",
255                                    None,
256                                ));
257                            };
258                            if !session_matches_snapshot(&live, &snapshot)
259                                || !std::sync::Arc::ptr_eq(&live.save_gate, &snapshot.save_gate)
260                            {
261                                return Err(ErrorData::internal_error(
262                                    "session changed during reset; retry the operation",
263                                    None,
264                                ));
265                            }
266                            crate::tools::ctx_session::reset_after_save(&mut live)
267                        }
268                    };
269                lean_ctx::core::session::admission::admit_tool_value(&snapshot, &output)
270                    .unwrap_or_else(|_| "ERROR: reset result withheld by current policy.".into())
271            } else if action == "load" {
272                let Some(snapshot) = crate::server::bounded_lock::read_for(
273                    session_handle,
274                    "ctx_session load snapshot",
275                    SESSION_LOCK_BUDGET,
276                )
277                .map(|session| session.clone()) else {
278                    return Err(ErrorData::internal_error(
279                        "session is busy; retry the operation",
280                        None,
281                    ));
282                };
283                let (candidate, output) =
284                    crate::tools::ctx_session::prepare_load(&snapshot, sid.as_deref()).map_err(
285                        |_| {
286                            ErrorData::invalid_params(
287                                "session load denied by current project scope or content policy",
288                                None,
289                            )
290                        },
291                    )?;
292                let Some(mut live) = crate::server::bounded_lock::write_for(
293                    session_handle,
294                    "ctx_session load commit",
295                    SESSION_LOCK_BUDGET,
296                ) else {
297                    return Err(ErrorData::internal_error(
298                        "session is busy; retry the operation",
299                        None,
300                    ));
301                };
302                if !session_matches_snapshot(&live, &snapshot) {
303                    return Err(ErrorData::internal_error(
304                        "session changed during load; retry the operation",
305                        None,
306                    ));
307                }
308                *live = candidate;
309                output
310            } else if matches!(action.as_str(), "export" | "resume" | "snapshot") {
311                let Some(snapshot) = crate::server::bounded_lock::read_for(
312                    session_handle,
313                    "ctx_session snapshot",
314                    SESSION_LOCK_BUDGET,
315                )
316                .map(|session| session.clone()) else {
317                    return Err(ErrorData::internal_error(
318                        "session is busy; retry the operation",
319                        None,
320                    ));
321                };
322                crate::tools::ctx_session::handle_snapshot_read_action(
323                    &snapshot,
324                    &action,
325                    value.as_deref(),
326                    crate::tools::ctx_session::SessionToolOptions {
327                        format: format.as_deref(),
328                        path: path.as_deref(),
329                        write,
330                        privacy: privacy.as_deref(),
331                        terse,
332                        agent_id: agent_id.as_deref(),
333                    },
334                )
335                .expect("snapshot action is explicitly handled")
336            } else if matches!(action.as_str(), "status" | "show" | "handoff") {
337                let Some(snapshot) = crate::server::bounded_lock::read_for(
338                    session_handle,
339                    "ctx_session read",
340                    SESSION_LOCK_BUDGET,
341                )
342                .map(|session| session.clone()) else {
343                    return Err(ErrorData::internal_error(
344                        "session is busy; retry the operation",
345                        None,
346                    ));
347                };
348                crate::tools::ctx_session::handle_read_only(&snapshot, &action)
349                    .expect("read-only action is explicitly handled")
350            } else if matches!(action.as_str(), "task" | "finding" | "decision" | "list") {
351                let Some(snapshot) = crate::server::bounded_lock::read_for(
352                    session_handle,
353                    "ctx_session admitted action snapshot",
354                    SESSION_LOCK_BUDGET,
355                )
356                .map(|session| session.clone()) else {
357                    return Err(ErrorData::internal_error(
358                        "session is busy; retry the operation",
359                        None,
360                    ));
361                };
362                let prepared = crate::tools::ctx_session::prepare_admitted_action(
363                    &snapshot,
364                    &call_durations,
365                    &action,
366                    value.as_deref(),
367                    sid.as_deref(),
368                    crate::tools::ctx_session::SessionToolOptions {
369                        format: format.as_deref(),
370                        path: path.as_deref(),
371                        write,
372                        privacy: privacy.as_deref(),
373                        terse,
374                        agent_id: agent_id.as_deref(),
375                    },
376                )
377                .map_err(|_| {
378                    ErrorData::invalid_params(
379                        "session action withheld by current project or content policy",
380                        None,
381                    )
382                })?;
383                if matches!(action.as_str(), "task" | "finding" | "decision") {
384                    let candidate = prepared.candidate;
385                    let output = prepared.output;
386                    let Some(mut live) = crate::server::bounded_lock::write_for(
387                        session_handle,
388                        "ctx_session admitted action commit",
389                        SESSION_LOCK_BUDGET,
390                    ) else {
391                        return Err(ErrorData::internal_error(
392                            "session is busy; retry the operation",
393                            None,
394                        ));
395                    };
396                    if !session_matches_snapshot(&live, &snapshot) {
397                        return Err(ErrorData::internal_error(
398                            "session changed during content admission; retry the operation",
399                            None,
400                        ));
401                    }
402                    if !prepared.store_admission.is_current() {
403                        return Err(ErrorData::internal_error(
404                            "session policy changed during content admission; retry the operation",
405                            None,
406                        ));
407                    }
408                    *live = candidate.clone();
409                    drop(live);
410                    // The finding as the session keeps it (admitted, redacted)
411                    // becomes one of the session episode's dead ends.
412                    if dead_end
413                        && action == "finding"
414                        && let Some(finding) = candidate.findings.last()
415                    {
416                        lean_ctx_hive::capture::episode::note_dead_end(
417                            &candidate.id,
418                            &finding.summary,
419                        );
420                    }
421                    lean_ctx::core::session::admission::admit_tool_value(&candidate, &output)
422                        .unwrap_or_else(|_| "[lean-ctx gateway: session content withheld]".into())
423                } else {
424                    if !prepared.store_admission.is_current() {
425                        return Err(ErrorData::internal_error(
426                            "session policy changed during content admission; retry the operation",
427                            None,
428                        ));
429                    }
430                    prepared.output
431                }
432            } else {
433                let Some(mut session) = crate::server::bounded_lock::write_for(
434                    session_handle,
435                    "ctx_session write",
436                    SESSION_LOCK_BUDGET,
437                ) else {
438                    return Err(ErrorData::internal_error(
439                        "session is busy; retry the operation",
440                        None,
441                    ));
442                };
443                crate::tools::ctx_session::handle(
444                    &mut session,
445                    &call_durations,
446                    &action,
447                    value.as_deref(),
448                    sid.as_deref(),
449                    crate::tools::ctx_session::SessionToolOptions {
450                        format: format.as_deref(),
451                        path: path.as_deref(),
452                        write,
453                        privacy: privacy.as_deref(),
454                        terse,
455                        agent_id: agent_id.as_deref(),
456                    },
457                )
458            }
459        };
460
461        Ok(ToolOutput {
462            text: result,
463            original_tokens: 0,
464            saved_tokens: 0,
465            mode: Some(action),
466            path: None,
467            changed: false,
468            shell_outcome: None,
469            content_blocks: None,
470        })
471    }
472
473    // No action declares `publishes_every_source` (the trait default applies).
474    // `status` and `show` print a counters line and `Root: <project>`: that
475    // is session-derived context, which is neither published into the call's
476    // source capture nor marks it incomplete, so their reply cannot claim a
477    // complete source set. A session reply is therefore never registered in
478    // the delivered-lineage register. The delivery calls stay
479    // (`SessionState::format_compact_delivered`, `finding_served`): a finding
480    // bound to a source that no longer admits is left out of the reply.
481}