1use std::sync::atomic::Ordering;
3
4use super::server::{CepComputedStats, CrpMode, LeanCtxServer, ToolCallRecord};
5use super::startup::auto_consolidate_knowledge;
6use super::{ctx_compress, ctx_share};
7
8pub(crate) const BACKGROUND_POLL_MODE: &str = "background";
11
12#[must_use]
17pub(crate) fn counts_in_session(tool: &str, mode: Option<&str>) -> bool {
18 !(tool == "ctx_shell" && mode == Some(BACKGROUND_POLL_MODE))
19}
20
21fn fold_value_snapshot(
24 session: &mut lean_ctx::core::session::SessionState,
25) -> lean_ctx::core::value::snapshot::ValueSnapshot {
26 let security = lean_ctx::core::security_events::drain_pending();
27 session.stats.security.merge(&security);
28 lean_ctx::core::value::snapshot::ValueSnapshot::from_session(session)
29}
30
31fn publish_value_snapshot(snapshot: lean_ctx::core::value::snapshot::ValueSnapshot) {
33 if !lean_ctx::core::value::display_enabled() {
34 return;
35 }
36 drop(tokio::task::spawn_blocking(move || {
37 lean_ctx::core::value::snapshot::write_throttled(&snapshot);
38 lean_ctx::core::value::milestones::maybe_notify();
39 }));
40}
41
42fn ocla_metric_points(
48 context: &lean_ctx::core::ocla::OclaRequestContext,
49 tool: &str,
50 original: usize,
51 saved: usize,
52 duration_ms: u64,
53) -> Vec<lean_ctx::core::ocla::MetricPoint> {
54 let mut dimensions = std::collections::BTreeMap::new();
55 dimensions.insert("tool".to_string(), tool.to_string());
56 let original = metric_milli(original as u64);
57 let saved = metric_milli(saved as u64);
58 let duration = metric_milli(duration_ms);
59 vec![
60 lean_ctx::core::ocla::MetricPoint {
61 context: context.clone(),
62 name: "mcp.tool.original_tokens".to_string(),
63 value_milli: original,
64 dimensions: dimensions.clone(),
65 },
66 lean_ctx::core::ocla::MetricPoint {
67 context: context.clone(),
68 name: "mcp.tool.saved_tokens".to_string(),
69 value_milli: saved,
70 dimensions: dimensions.clone(),
71 },
72 lean_ctx::core::ocla::MetricPoint {
73 context: context.clone(),
74 name: "mcp.tool.duration_ms".to_string(),
75 value_milli: duration,
76 dimensions,
77 },
78 ]
79}
80
81fn metric_milli(value: u64) -> i64 {
82 i64::try_from(value.saturating_mul(1_000)).unwrap_or(i64::MAX)
83}
84
85impl LeanCtxServer {
86 pub async fn record_call(
88 &self,
89 tool: &str,
90 original: usize,
91 saved: usize,
92 mode: Option<String>,
93 ) {
94 self.record_call_with_timing(tool, original, saved, mode, 0)
95 .await;
96 }
97
98 pub async fn record_call_with_path(
102 &self,
103 tool: &str,
104 original: usize,
105 saved: usize,
106 mode: Option<String>,
107 path: Option<&str>,
108 duration_ms: u64,
109 ) {
110 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, path)
111 .await;
112 }
113
114 pub async fn record_call_with_timing(
116 &self,
117 tool: &str,
118 original: usize,
119 saved: usize,
120 mode: Option<String>,
121 duration_ms: u64,
122 ) {
123 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
124 .await;
125 }
126
127 async fn record_call_with_timing_inner(
128 &self,
129 tool: &str,
130 original: usize,
131 saved: usize,
132 mode: Option<String>,
133 duration_ms: u64,
134 path: Option<&str>,
135 ) {
136 let presence = self.presence_agent_id.read().await.clone();
137 if let Some(agent_id) = presence.as_deref()
138 && let Err(error) =
139 lean_ctx::core::agents::AgentRegistry::heartbeat_persistent(agent_id)
140 {
141 tracing::warn!("lean-ctx: failed to update MCP agent heartbeat: {error}");
142 }
143 if let Some(worker) = self.agent_id.read().await.clone()
147 && presence.as_deref() != Some(worker.as_str())
148 && let Err(error) =
149 lean_ctx::core::agents::AgentRegistry::renew_worker_persistent(&worker)
150 {
151 tracing::debug!("lean-ctx: failed to renew agent {worker}: {error}");
152 }
153 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
154 let mut calls = self.tool_calls.write().await;
155 calls.push(ToolCallRecord {
156 tool: tool.to_string(),
157 original_tokens: original,
158 saved_tokens: saved,
159 mode: mode.clone(),
160 duration_ms,
161 timestamp: ts.clone(),
162 });
163
164 const MAX_TOOL_CALL_RECORDS: usize = 500;
165 if calls.len() > MAX_TOOL_CALL_RECORDS {
166 let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
167 calls.drain(..excess);
168 }
169
170 if duration_ms > 0 || original > 0 {
175 Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
176 }
177
178 lean_ctx::core::events::emit_tool_call(
179 tool,
180 original as u64,
181 saved as u64,
182 mode.clone(),
183 duration_ms,
184 path.map(ToString::to_string),
185 );
186
187 let output_tokens = original.saturating_sub(saved);
188 lean_ctx::core::metering::MeterStore::append_best_effort(
189 lean_ctx::core::metering::MeterEntry::new(
190 tool,
191 original as u64,
192 output_tokens as u64,
193 saved as u64,
194 ),
195 );
196 lean_ctx::core::stats::record_at_turn(
197 tool,
198 original,
199 output_tokens,
200 lean_ctx::core::context_overhead::observed_turns(),
201 );
202 if tool == "ctx_shell" {
209 lean_ctx::core::savings_ledger::record_tool_event(
210 tool,
211 original,
212 output_tokens,
213 None,
214 None,
215 );
216 }
217
218 {
221 let session_r = self.session.read().await;
222 let agent_id = self
223 .presence_agent_id
224 .read()
225 .await
226 .clone()
227 .unwrap_or_default();
228 let ctx = lean_ctx::core::ocla::OclaRequestContext {
229 request_id: format!("mcp-{}", self.call_count.load(Ordering::Relaxed)),
230 session_id: session_r.id.clone(),
231 agent_id,
232 content_ref: path.map_or_else(|| format!("tool:{tool}"), |p| format!("file:{p}")),
233 tenant_id: None,
234 trace_id: "tr-unit".into(),
235 task_id: None,
236 parent_task_id: None,
237 };
238 drop(session_r);
239 let observation = lean_ctx::core::ocla::Observation {
240 context: ctx.clone(),
241 name: format!("tool_call:{tool}"),
242 attributes: std::collections::BTreeMap::from([
243 ("original_tokens".into(), original.to_string()),
244 ("saved_tokens".into(), saved.to_string()),
245 ("duration_ms".into(), duration_ms.to_string()),
246 ]),
247 };
248 let hook = lean_ctx::core::ocla::OclaRegistry::global()
249 .observation_hook
250 .as_ref();
251 let _ = hook.observe(observation).await;
252
253 let metrics = ocla_metric_points(&ctx, tool, original, saved, duration_ms);
257 let _ = lean_ctx::core::ocla::OclaRegistry::global()
258 .metrics_exporter
259 .export_metrics(metrics)
260 .await;
261 }
262 let mut session = self.session.write().await;
263 if counts_in_session(tool, mode.as_deref()) {
264 session.record_tool_call(saved as u64, original as u64);
265 if tool == "ctx_shell" {
266 session.record_command();
267 }
268 }
269 let save_due = session.should_save();
270 let value_snapshot = fold_value_snapshot(&mut session);
271 drop(calls);
272 drop(session);
273 publish_value_snapshot(value_snapshot);
274
275 if save_due {
276 tokio::spawn(lean_ctx::core::session::SessionState::save_shared_logged(
277 self.session.instance(),
278 ));
279 }
280
281 self.write_mcp_live_stats().await;
282 }
283
284 pub async fn publish_value_snapshot(&self) {
288 let snapshot = {
289 let mut session = self.session.write().await;
290 fold_value_snapshot(&mut session)
291 };
292 publish_value_snapshot(snapshot);
293 }
294
295 pub fn increment_and_check(&self) -> bool {
297 let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
298 let interval = Self::checkpoint_interval_effective();
299 interval > 0 && count.is_multiple_of(interval)
300 }
301
302 pub async fn auto_checkpoint(&self) -> Option<String> {
304 let cache = self.cache.read().await;
305 if cache.get_all_entries().is_empty() {
306 return None;
307 }
308 let complexity = lean_ctx::core::adaptive::classify_from_context(&cache);
309 let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
310 drop(cache);
311
312 let session = self.session.read().await;
313 let session_summary = session.format_compact();
314 let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
315 let project_root = session.project_root.clone();
316 let summary_candidate = lean_ctx::core::session_summary::build_candidate(&session);
318 drop(session);
319
320 tokio::spawn(lean_ctx::core::session::SessionState::save_shared_logged(
321 self.session.instance(),
322 ));
323
324 if has_insights && let Some(ref root) = project_root {
325 let root = root.clone();
326 let own = self.session.instance();
328 lean_ctx::core::policy::runtime::spawn_for_project(root.clone(), move || {
329 lean_ctx::core::session::caller::scope(Some(own), || {
330 auto_consolidate_knowledge(&root);
331 });
332 });
333 }
334
335 if let Some(ref root) = project_root {
337 let root = root.clone();
338 lean_ctx::core::policy::runtime::spawn_for_project(root.clone(), move || {
339 let _ = lean_ctx::core::session_summary::maybe_record_periodic(
340 &root,
341 summary_candidate,
342 );
343 });
344 }
345
346 let multi_agent_block = self
347 .auto_multi_agent_checkpoint(project_root.as_ref())
348 .await;
349
350 self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
351 .await;
352
353 self.record_cep_snapshot().await;
354
355 if !lean_ctx::core::protocol::meta_visible() {
356 return None;
357 }
358
359 let doc_reminder = {
360 let session = self.session.read().await;
361 let calls = self.tool_calls.read().await;
362 Self::activity_nudge(&session, &calls)
363 };
364
365 Some(format!(
366 "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}{doc_reminder}",
367 complexity.instruction_suffix()
368 ))
369 }
370
371 async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
372 let Some(root) = project_root else {
373 return String::new();
374 };
375
376 let registry = lean_ctx::core::agents::AgentRegistry::load_or_create();
377 let active = registry.list_active(Some(root));
378 if active.len() <= 1 {
379 return String::new();
380 }
381
382 let agent_id = self.agent_id.read().await;
383 let my_id = match agent_id.as_deref() {
384 Some(id) => id.to_string(),
385 None => return String::new(),
386 };
387 drop(agent_id);
388
389 let cache = self.cache.read().await;
390 let entries = cache.get_all_entries();
391 if !entries.is_empty() {
392 let mut by_access: Vec<_> = entries.iter().collect();
393 by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count()));
394 let top_paths: Vec<&str> = by_access
395 .iter()
396 .take(5)
397 .map(|(key, _)| key.as_str())
398 .collect();
399 let paths_csv = top_paths.join(",");
400
401 let _ = ctx_share::handle(
402 "push",
403 Some(&my_id),
404 None,
405 Some(&paths_csv),
406 None,
407 &cache,
408 root,
409 );
410 }
411 drop(cache);
412
413 let pending_count = registry
414 .scratchpad
415 .iter()
416 .filter(|e| {
417 !e.read_by.contains(&my_id)
418 && e.from_agent != my_id
419 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(my_id.as_str()))
420 && e.project_root
421 .as_deref()
422 .is_some_and(|own| lean_ctx::core::pathutil::same_project(own, &root))
423 })
424 .count();
425
426 let shared_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
428 .unwrap_or_default()
429 .join("agents")
430 .join("shared")
431 .join(lean_ctx::core::project_hash::hash_project_root(root));
432 let shared_count = if shared_dir.exists() {
433 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
434 } else {
435 0
436 };
437
438 let agent_names: Vec<String> = active
439 .iter()
440 .map(|a| {
441 let role = a.role.as_deref().unwrap_or(&a.agent_type);
442 format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
443 })
444 .collect();
445
446 format!(
447 "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
448 agent_names.join(", "),
449 pending_count,
450 shared_count,
451 )
452 }
453
454 pub fn append_tool_call_log(
456 tool: &str,
457 duration_ms: u64,
458 original: usize,
459 saved: usize,
460 mode: Option<&str>,
461 timestamp: &str,
462 ) {
463 const MAX_LOG_LINES: usize = 50;
464 if let Ok(dir) = lean_ctx::core::paths::state_dir() {
465 let log_path = dir.join("tool-calls.log");
466 let mode_str = mode.unwrap_or("-");
467 let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
468 let line = format!(
469 "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
470 );
471
472 let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
473 .unwrap_or_default()
474 .lines()
475 .map(std::string::ToString::to_string)
476 .collect();
477
478 lines.push(line.trim_end().to_string());
479 if lines.len() > MAX_LOG_LINES {
480 lines.drain(0..lines.len() - MAX_LOG_LINES);
481 }
482
483 let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
484 }
485 }
486
487 fn compute_cep_stats(
488 calls: &[ToolCallRecord],
489 stats: &lean_ctx::core::cache::CacheStats,
490 complexity: &lean_ctx::core::adaptive::TaskComplexity,
491 ) -> CepComputedStats {
492 let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
493 let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
494 let total_compressed = total_original.saturating_sub(total_saved);
495 let compression_rate = if total_original > 0 {
496 total_saved as f64 / total_original as f64
497 } else {
498 0.0
499 };
500
501 let modes_used: std::collections::HashSet<&str> =
502 calls.iter().filter_map(|c| c.mode.as_deref()).collect();
503 let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
504 let cache_util = stats.hit_rate() / 100.0;
505 let output_efficiency = 1.0 - lean_ctx::core::output_echo::current_avg_ratio();
508 let cep_score = cache_util * 0.25
509 + mode_diversity * 0.15
510 + compression_rate * 0.45
511 + output_efficiency * 0.15;
512
513 let mut mode_counts: std::collections::HashMap<String, u64> =
514 std::collections::HashMap::new();
515 for call in calls {
516 if let Some(ref mode) = call.mode {
517 *mode_counts.entry(mode.clone()).or_insert(0) += 1;
518 }
519 }
520
521 CepComputedStats {
522 cep_score: (cep_score * 100.0).round() as u32,
523 cache_util: (cache_util * 100.0).round() as u32,
524 mode_diversity: (mode_diversity * 100.0).round() as u32,
525 compression_rate: (compression_rate * 100.0).round() as u32,
526 total_original,
527 total_compressed,
528 total_saved,
529 mode_counts,
530 complexity: format!("{complexity:?}"),
531 cache_hits: stats.cache_hits(),
532 total_reads: stats.total_reads(),
533 tool_call_count: calls.len() as u64,
534 }
535 }
536
537 async fn write_mcp_live_stats(&self) {
538 let count = self.call_count.load(Ordering::Relaxed);
539 if count > 1 && !count.is_multiple_of(5) {
540 return;
541 }
542
543 let cache = self.cache.read().await;
544 let calls = self.tool_calls.read().await;
545 let stats = cache.get_stats();
546 let complexity = lean_ctx::core::adaptive::classify_from_context(&cache);
547 let dedup = crate::tools::ctx_read::dedup_hook::summary();
548
549 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
550 let started_at = calls
551 .first()
552 .map(|c| c.timestamp.clone())
553 .unwrap_or_default();
554
555 drop(cache);
556 drop(calls);
557
558 lean_ctx::core::stats::record_cep_session(
564 cs.cep_score,
565 cs.cache_hits,
566 cs.total_reads,
567 cs.total_original,
568 cs.total_compressed,
569 &cs.mode_counts,
570 cs.tool_call_count,
571 &cs.complexity,
572 );
573
574 let reuse = lean_ctx::core::cache::reuse_snapshot();
579 let provider_reuse = lean_ctx::core::proxy_cache_attribution::snapshot();
580
581 let source_snapshot = lean_ctx::core::auto_mode_resolver::source_counts();
582 let compressed_cache_hits = source_snapshot
583 .iter()
584 .find(|(k, _)| *k == "compressed_cache_hit")
585 .map_or(0, |(_, v)| *v);
586 let full_delivery_degraded = source_snapshot
587 .iter()
588 .find(|(k, _)| *k == "full_delivery_degraded")
589 .map_or(0, |(_, v)| *v);
590
591 let live = serde_json::json!({
592 "cep_score": cs.cep_score,
593 "cache_utilization": cs.cache_util,
594 "mode_diversity": cs.mode_diversity,
595 "compression_rate": cs.compression_rate,
596 "task_complexity": cs.complexity,
597 "files_cached": cs.total_reads,
598 "total_reads": cs.total_reads,
599 "cache_hits": cs.cache_hits,
600 "dedup_reads": dedup.total_reads,
601 "dedup_hits": dedup.dedup_hits,
602 "dedup_tokens_saved": dedup.tokens_saved,
603 "read_reuse_rate": reuse.read_reuse_rate(),
604 "disk_reuse_rate": reuse.disk_reuse_rate(),
605 "provider_reuse_rate": provider_reuse.provider_reuse_rate(),
606 "read_reuse": {
607 "reused_renderings": reuse.reused_renderings,
608 "eligible_ctx_reads": reuse.eligible_ctx_reads,
609 },
610 "disk_reuse": {
611 "content_cache_hits": reuse.disk_content_hits,
612 "eligible_search_reads": reuse.eligible_search_reads,
613 },
614 "provider_reuse": {
615 "warm_reuses": provider_reuse.warm_reuses,
616 "ttl_lapses": provider_reuse.ttl_lapses,
617 "prefix_changes": provider_reuse.prefix_changes,
618 "eligible_requests": provider_reuse.eligible_provider_requests(),
619 },
620 "reuse_outcomes": {
621 "cold": reuse.cold,
622 "disk_content_hit": reuse.disk_content_hits,
623 "render_cache_hit": reuse.render_cache_hits,
624 "unchanged_stub": reuse.unchanged_stubs,
625 "cross_file_ref": reuse.cross_file_refs,
626 "fresh_bypass": reuse.fresh_bypasses,
627 "stale": reuse.stale,
628 "variant_evicted": reuse.variant_evicted,
629 "policy_bypass": reuse.policy_bypasses,
630 },
631 "compressed_cache_hits": compressed_cache_hits,
632 "full_delivery_degraded": full_delivery_degraded,
633 "tokens_saved": cs.total_saved,
634 "tokens_original": cs.total_original,
635 "tool_calls": cs.tool_call_count,
636 "started_at": started_at,
637 "updated_at": chrono::Local::now().to_rfc3339(),
638 });
639
640 if let Ok(dir) = lean_ctx::core::paths::state_dir() {
641 let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
642 }
643 }
644
645 pub async fn record_cep_snapshot(&self) {
647 let cache = self.cache.read().await;
648 let calls = self.tool_calls.read().await;
649 let stats = cache.get_stats();
650 let complexity = lean_ctx::core::adaptive::classify_from_context(&cache);
651
652 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
653
654 drop(cache);
655 drop(calls);
656
657 lean_ctx::core::stats::record_cep_session(
658 cs.cep_score,
659 cs.cache_hits,
660 cs.total_reads,
661 cs.total_original,
662 cs.total_compressed,
663 &cs.mode_counts,
664 cs.tool_call_count,
665 &cs.complexity,
666 );
667 }
668
669 fn activity_nudge(
670 session: &lean_ctx::core::session::SessionState,
671 calls: &[ToolCallRecord],
672 ) -> &'static str {
673 let last_doc_ts = session
674 .progress
675 .last()
676 .map(|p| p.timestamp)
677 .or_else(|| session.decisions.last().map(|d| d.timestamp))
678 .or_else(|| session.findings.last().map(|f| f.timestamp));
679
680 if let Some(ts) = last_doc_ts {
681 let age = chrono::Utc::now() - ts;
682 if age.num_minutes() < 8 {
683 return "";
684 }
685 }
686
687 let (weighted_score, significant_tools, shell_heavy, edit_heavy) =
688 Self::compute_activity_score(calls, last_doc_ts);
689
690 if weighted_score < 20 || significant_tools < 5 {
691 if session.stats.total_tool_calls >= 30
692 && session.decisions.is_empty()
693 && session.progress.is_empty()
694 {
695 return "\n[CHECKPOINT: please document current progress via ctx_session(action=\"task\") or ctx_knowledge(action=\"remember\")]";
696 }
697 return "";
698 }
699
700 if shell_heavy {
701 "\n[CHECKPOINT: multiple shell commands executed — any test results or findings worth persisting via ctx_knowledge(action=\"remember\")?]"
702 } else if edit_heavy {
703 "\n[CHECKPOINT: several files modified — document the architecture decision or pattern via ctx_knowledge(action=\"remember\")?]"
704 } else {
705 "\n[CHECKPOINT: significant work detected — consider persisting decisions via ctx_knowledge(action=\"remember\")]"
706 }
707 }
708
709 fn compute_activity_score(
710 calls: &[ToolCallRecord],
711 last_doc_ts: Option<chrono::DateTime<chrono::Utc>>,
712 ) -> (u32, u32, bool, bool) {
713 let mut weighted_score: u32 = 0;
714 let mut significant_tools: u32 = 0;
715 let mut shell_count: u32 = 0;
716 let mut edit_count: u32 = 0;
717
718 let since_doc: Vec<&ToolCallRecord> = if let Some(ts) = last_doc_ts {
719 let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
720 calls.iter().filter(|c| c.timestamp > ts_str).collect()
721 } else {
722 calls.iter().collect()
723 };
724
725 for call in &since_doc {
726 let tool = call.tool.as_str();
727 let is_knowledge = tool == "ctx_knowledge" || tool == "ctx_session";
728 if is_knowledge {
729 weighted_score = 0;
730 significant_tools = 0;
731 shell_count = 0;
732 edit_count = 0;
733 continue;
734 }
735
736 let (weight, significant) = match tool {
737 "edit" | "write" | "str_replace" => {
738 edit_count += 1;
739 (4u32, true)
740 }
741 "ctx_shell" => {
742 shell_count += 1;
743 let is_test_or_build = call
744 .mode
745 .as_deref()
746 .is_some_and(|m| m.contains("test") || m.contains("build"));
747 if is_test_or_build {
748 (3, true)
749 } else {
750 (2, true)
751 }
752 }
753 "ctx_read" => {
754 let is_cache_hit = call.saved_tokens > 0
755 && call.original_tokens > 0
756 && call.saved_tokens == call.original_tokens;
757 if is_cache_hit { (0, false) } else { (1, false) }
758 }
759 _ => (1, false),
760 };
761
762 weighted_score = weighted_score.saturating_add(weight);
763 if significant {
764 significant_tools += 1;
765 }
766 }
767
768 let shell_heavy = shell_count >= 3 && shell_count > edit_count;
769 let edit_heavy = edit_count >= 3 && edit_count >= shell_count;
770
771 (weighted_score, significant_tools, shell_heavy, edit_heavy)
772 }
773}
774
775#[cfg(test)]
776mod activity_score_tests {
777 use super::*;
778
779 fn make_call(tool: &str, mode: Option<&str>) -> ToolCallRecord {
780 ToolCallRecord {
781 tool: tool.to_string(),
782 original_tokens: 100,
783 saved_tokens: 50,
784 mode: mode.map(String::from),
785 duration_ms: 10,
786 timestamp: "2026-01-01 12:00:00".to_string(),
787 }
788 }
789
790 fn make_cache_hit() -> ToolCallRecord {
791 ToolCallRecord {
792 tool: "ctx_read".to_string(),
793 original_tokens: 100,
794 saved_tokens: 100,
795 mode: Some("full".to_string()),
796 duration_ms: 1,
797 timestamp: "2026-01-01 12:00:00".to_string(),
798 }
799 }
800
801 #[test]
802 fn empty_calls_zero_score() {
803 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&[], None);
804 assert_eq!(score, 0);
805 assert_eq!(sig, 0);
806 }
807
808 #[test]
809 fn edits_have_highest_weight() {
810 let calls = vec![
811 make_call("edit", None),
812 make_call("edit", None),
813 make_call("edit", None),
814 ];
815 let (score, sig, _, edit_heavy) = LeanCtxServer::compute_activity_score(&calls, None);
816 assert_eq!(score, 12);
817 assert_eq!(sig, 3);
818 assert!(edit_heavy);
819 }
820
821 #[test]
822 fn shell_test_build_weight_three() {
823 let calls = vec![
824 make_call("ctx_shell", Some("test")),
825 make_call("ctx_shell", Some("build")),
826 make_call("ctx_shell", Some("test")),
827 ];
828 let (score, sig, shell_heavy, _) = LeanCtxServer::compute_activity_score(&calls, None);
829 assert_eq!(score, 9);
830 assert_eq!(sig, 3);
831 assert!(shell_heavy);
832 }
833
834 #[test]
835 fn cache_hits_zero_weight() {
836 let calls = vec![make_cache_hit(), make_cache_hit(), make_cache_hit()];
837 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
838 assert_eq!(score, 0);
839 assert_eq!(sig, 0);
840 }
841
842 #[test]
843 fn knowledge_call_resets_score() {
844 let calls = vec![
845 make_call("edit", None),
846 make_call("edit", None),
847 make_call("ctx_knowledge", None),
848 make_call("ctx_read", None),
849 ];
850 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
851 assert_eq!(score, 1);
852 assert_eq!(sig, 0);
853 }
854
855 #[test]
856 fn mixed_workflow_scoring() {
857 let calls = vec![
858 make_call("ctx_read", None),
859 make_call("ctx_read", None),
860 make_call("edit", None),
861 make_call("edit", None),
862 make_call("ctx_shell", Some("test output")),
863 make_call("ctx_shell", None),
864 ];
865 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
866 assert_eq!(score, 2 + 4 + 4 + 3 + 2);
867 assert_eq!(sig, 4);
868 }
869
870 #[test]
871 fn ocla_metrics_are_payload_free_and_saturating() {
872 let context = lean_ctx::core::ocla::OclaRequestContext {
873 request_id: "mcp-1".into(),
874 session_id: "session-1".into(),
875 agent_id: "agent-1".into(),
876 content_ref: "tool:ctx_read".into(),
877 tenant_id: None,
878 trace_id: "tr-unit".into(),
879 task_id: None,
880 parent_task_id: None,
881 };
882 let points = super::ocla_metric_points(&context, "ctx_read", 750, 125, 42);
883 assert_eq!(points.len(), 3);
884 assert_eq!(points[0].name, "mcp.tool.original_tokens");
885 assert_eq!(points[0].value_milli, 750_000);
886 assert_eq!(points[1].name, "mcp.tool.saved_tokens");
887 assert_eq!(points[1].value_milli, 125_000);
888 assert_eq!(points[2].name, "mcp.tool.duration_ms");
889 assert_eq!(points[2].value_milli, 42_000);
890 assert_eq!(points[0].dimensions["tool"], "ctx_read");
891 assert_eq!(points[0].context, context);
892 assert!(!points[0].dimensions.contains_key("path"));
893
894 let saturated = super::ocla_metric_points(
895 &points[0].context,
896 "ctx_shell",
897 usize::MAX,
898 usize::MAX,
899 u64::MAX,
900 );
901 assert!(saturated.iter().all(|point| point.value_milli == i64::MAX));
902 }
903
904 #[test]
907 fn background_job_polls_stay_out_of_the_session_stats() {
908 assert!(!counts_in_session("ctx_shell", Some(BACKGROUND_POLL_MODE)));
909 assert!(counts_in_session("ctx_shell", None));
910 assert!(counts_in_session("ctx_shell", Some("full")));
911 assert!(counts_in_session("ctx_read", Some(BACKGROUND_POLL_MODE)));
912 }
913
914 #[tokio::test]
917 async fn mcp_metrics_project_observation_but_leave_outcome_to_lifecycle() {
918 use std::sync::Arc;
919 use std::sync::atomic::{AtomicUsize, Ordering};
920
921 use lean_ctx::core::ocla::registry::{OclaRegistry, with_test_registry};
922 use lean_ctx::core::ocla::traits::{ObservationHook, OclaService, OutcomeTracker};
923 use lean_ctx::core::ocla::types::{
924 Observation, OclaCapability, OclaCapabilityKind, OclaResult, Outcome,
925 };
926
927 struct SpyObservation(Arc<AtomicUsize>);
928 struct SpyOutcome(Arc<AtomicUsize>);
929 impl OclaService for SpyObservation {
930 fn capability(&self) -> OclaCapability {
931 OclaCapability::available(OclaCapabilityKind::ObservationHook)
932 }
933 }
934 impl OclaService for SpyOutcome {
935 fn capability(&self) -> OclaCapability {
936 OclaCapability::available(OclaCapabilityKind::OutcomeTracker)
937 }
938 }
939 #[async_trait::async_trait]
940 impl ObservationHook for SpyObservation {
941 async fn observe(&self, _observation: Observation) -> OclaResult<()> {
942 self.0.fetch_add(1, Ordering::Relaxed);
943 Ok(())
944 }
945 }
946 impl OutcomeTracker for SpyOutcome {
947 fn record_outcome(&self, _outcome: Outcome) -> OclaResult<()> {
948 self.0.fetch_add(1, Ordering::Relaxed);
949 Ok(())
950 }
951 }
952
953 let _dir = lean_ctx::core::data_dir::isolated_data_dir();
954 let observation_calls = Arc::new(AtomicUsize::new(0));
955 let outcome_calls = Arc::new(AtomicUsize::new(0));
956 let mut registry = OclaRegistry::with_builtins();
957 registry.observation_hook = Arc::new(SpyObservation(observation_calls.clone()));
958 registry.outcome_tracker = Arc::new(SpyOutcome(outcome_calls.clone()));
959 let _guard = with_test_registry(registry);
960 let server = crate::tools::LeanCtxServer::new();
961
962 server
963 .record_call_with_timing("ctx_read", 100, 40, Some("full".into()), 3)
964 .await;
965
966 assert_eq!(observation_calls.load(Ordering::Relaxed), 1);
967 assert_eq!(outcome_calls.load(Ordering::Relaxed), 0);
968 }
969}