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