Skip to main content

lean_ctx_server/tools/
server_metrics.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
8/// The `mode` a `ctx_shell` status or cancel request reports: looking after a
9/// running background job, not a new command.
10pub(crate) const BACKGROUND_POLL_MODE: &str = "background";
11
12/// Whether a call counts in the session's tool-call and command stats (the
13/// value snapshot behind status lines and the cockpit). A background-job poll
14/// does not: a host that watches a job polls it every few seconds, and counted
15/// polls drowned the real commands and diluted the share of output kept out.
16#[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
21/// Moves the security events recorded since the last call into the session's
22/// counters and captures the value snapshot the display channels read.
23fn 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
31/// Writes the snapshot off the async workers (throttled, atomic).
32fn 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
42/// Build payload-free OCLA metrics for one completed MCP tool call.
43///
44/// Values use milli-units and saturate at the signed metric contract's upper
45/// bound. Tool dimensions are bounded metadata; request payloads and paths
46/// never enter the exporter.
47fn 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    /// Records a tool call's token savings without timing information.
87    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    /// Records a tool call like `record_call`, but includes an optional file
99    /// path for observability and the measured handler duration (#1020 — the
100    /// duration is what makes the row land in `tool-calls.log`).
101    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    /// Records a tool call's token savings, duration, and emits events and stats.
115    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        // The agent `ctx_agent register` made in this session keeps its
144        // worker lease while the session calls tools; one that expired is
145        // renewed (and `read`/`wait` say so once).
146        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        // #1020: persist whenever we have a duration OR real metrics. The old
171        // `duration_ms > 0` gate dropped every read/search row (they record with
172        // measured metrics but were previously logged via a separate zero-filled
173        // path), so `tool-calls.log` only ever showed `orig=0 saved=0 mode=-`.
174        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        // MCP shell savings are measured (raw vs compressed output), so they are
203        // ledger-grade (GL #479 D2). Reads are ledgered by the ctx_read /
204        // ctx_multi_read callers (#685, decoupled from the heatmap) and ctx_search
205        // records itself — only shell is recorded here, exactly once. `actual_tokens`
206        // is the *sent* output; a prior duplicate block passed `saved` and so both
207        // double-counted shell events and stored the wrong saving (#685).
208        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        // OCLA ObservationHook: project every MCP tool call as a structured
219        // observation for the canonical observation capability.
220        {
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            // OCLA MetricsExporter projection: one bounded, local batch per
254            // tool call. This is observational only and cannot affect stats,
255            // billing, or external export destinations.
256            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    /// Folds security events recorded outside `record_call` (e.g. injection
285    /// flags found after the handler) into the session and republishes the
286    /// value snapshot, so the status line never lags a blocked or flagged call.
287    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    /// Increments the call counter and returns true if a checkpoint is due.
296    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    /// Generates a compressed context checkpoint with session state and multi-agent sync.
303    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        // Snapshot the session under the lock; persist the summary off the hot path.
317        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            // This server's own session, never the project's latest (I07/S03).
327            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        // Periodically record a recallable AI session summary (#292), off-thread.
336        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        // Only this project's shares (`ctx_share` partitions by project).
427        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    /// Appends a tool call entry to the rotating `tool-calls.log` file.
455    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        // Output efficiency (#501): 1 - avg echo ratio. An agent that keeps
506        // re-quoting delivered content burns the input savings on output.
507        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        // Persist CEP on the live-stats cadence (first call + every 5th) so even
559        // short sessions register `sessions`/`total_cache_hits` instead of only
560        // recording on an `auto_checkpoint` that a brief workload may never reach.
561        // `record_cep_session` is delta-based and PID-guarded, so the extra call
562        // that coincides with a checkpoint is a no-op for the totals (#361).
563        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        // Cache layers can both participate in one read (for example a
575        // session-cache rendering later collapsed by content dedup).  Never
576        // aggregate their independent counters into a synthetic hit rate.
577        // `reuse_snapshot` records one terminal outcome per read instead.
578        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    /// Persists a CEP (Cognitive Efficiency Protocol) score snapshot for analytics.
646    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    /// A host watching a background job polls it every few seconds; those
905    /// polls once made a session read "896 commands" for a handful of real ones.
906    #[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    /// An MCP tool call is projected as an OCLA observation; its outcome is
915    /// left to the tool lifecycle.
916    #[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}