1use 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
10fn 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
64const 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 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 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 }