Skip to main content

lean_ctx/tools/
server_metrics.rs

1use std::sync::atomic::Ordering;
2
3use super::server::{CepComputedStats, CrpMode, LeanCtxServer, ToolCallRecord};
4use super::startup::auto_consolidate_knowledge;
5use super::{ctx_compress, ctx_share};
6
7impl LeanCtxServer {
8    /// Records a tool call's token savings without timing information.
9    pub async fn record_call(
10        &self,
11        tool: &str,
12        original: usize,
13        saved: usize,
14        mode: Option<String>,
15    ) {
16        self.record_call_with_timing(tool, original, saved, mode, 0)
17            .await;
18    }
19
20    /// Records a tool call like `record_call`, but includes an optional file
21    /// path for observability and the measured handler duration (#1020 — the
22    /// duration is what makes the row land in `tool-calls.log`).
23    pub async fn record_call_with_path(
24        &self,
25        tool: &str,
26        original: usize,
27        saved: usize,
28        mode: Option<String>,
29        path: Option<&str>,
30        duration_ms: u64,
31    ) {
32        self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, path)
33            .await;
34    }
35
36    /// Records a tool call's token savings, duration, and emits events and stats.
37    pub async fn record_call_with_timing(
38        &self,
39        tool: &str,
40        original: usize,
41        saved: usize,
42        mode: Option<String>,
43        duration_ms: u64,
44    ) {
45        self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
46            .await;
47    }
48
49    async fn record_call_with_timing_inner(
50        &self,
51        tool: &str,
52        original: usize,
53        saved: usize,
54        mode: Option<String>,
55        duration_ms: u64,
56        path: Option<&str>,
57    ) {
58        if let Some(agent_id) = self.presence_agent_id.read().await.clone()
59            && let Err(error) = crate::core::agents::AgentRegistry::heartbeat_persistent(&agent_id)
60        {
61            tracing::warn!("lean-ctx: failed to update MCP agent heartbeat: {error}");
62        }
63        let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
64        let mut calls = self.tool_calls.write().await;
65        calls.push(ToolCallRecord {
66            tool: tool.to_string(),
67            original_tokens: original,
68            saved_tokens: saved,
69            mode: mode.clone(),
70            duration_ms,
71            timestamp: ts.clone(),
72        });
73
74        const MAX_TOOL_CALL_RECORDS: usize = 500;
75        if calls.len() > MAX_TOOL_CALL_RECORDS {
76            let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
77            calls.drain(..excess);
78        }
79
80        // #1020: persist whenever we have a duration OR real metrics. The old
81        // `duration_ms > 0` gate dropped every read/search row (they record with
82        // measured metrics but were previously logged via a separate zero-filled
83        // path), so `tool-calls.log` only ever showed `orig=0 saved=0 mode=-`.
84        if duration_ms > 0 || original > 0 {
85            Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
86        }
87
88        crate::core::events::emit_tool_call(
89            tool,
90            original as u64,
91            saved as u64,
92            mode.clone(),
93            duration_ms,
94            path.map(ToString::to_string),
95        );
96
97        let output_tokens = original.saturating_sub(saved);
98        crate::core::stats::record(tool, original, output_tokens);
99        // MCP shell savings are measured (raw vs compressed output), so they are
100        // ledger-grade (GL #479 D2). Reads are ledgered by the ctx_read /
101        // ctx_multi_read callers (#685, decoupled from the heatmap) and ctx_search
102        // records itself — only shell is recorded here, exactly once. `actual_tokens`
103        // is the *sent* output; a prior duplicate block passed `saved` and so both
104        // double-counted shell events and stored the wrong saving (#685).
105        if tool == "ctx_shell" {
106            crate::core::savings_ledger::record_tool_event(tool, original, output_tokens);
107        }
108
109        let mut session = self.session.write().await;
110        session.record_tool_call(saved as u64, original as u64);
111        if tool == "ctx_shell" {
112            session.record_command();
113        }
114        let pending_save = if session.should_save() {
115            session.prepare_save().ok()
116        } else {
117            None
118        };
119        drop(calls);
120        drop(session);
121
122        if let Some(prepared) = pending_save {
123            tokio::task::spawn_blocking(move || {
124                let _ = prepared.write_to_disk();
125            });
126        }
127
128        self.write_mcp_live_stats().await;
129    }
130
131    /// Increments the call counter and returns true if a checkpoint is due.
132    pub fn increment_and_check(&self) -> bool {
133        let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
134        let interval = Self::checkpoint_interval_effective();
135        interval > 0 && count.is_multiple_of(interval)
136    }
137
138    /// Generates a compressed context checkpoint with session state and multi-agent sync.
139    pub async fn auto_checkpoint(&self) -> Option<String> {
140        let cache = self.cache.read().await;
141        if cache.get_all_entries().is_empty() {
142            return None;
143        }
144        let complexity = crate::core::adaptive::classify_from_context(&cache);
145        let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
146        drop(cache);
147
148        let mut session = self.session.write().await;
149        let _ = session.save();
150        let session_summary = session.format_compact();
151        let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
152        let project_root = session.project_root.clone();
153        // Snapshot the session under the lock; persist the summary off the hot path.
154        let summary_candidate = crate::core::session_summary::build_candidate(&session);
155        drop(session);
156
157        if has_insights && let Some(ref root) = project_root {
158            let root = root.clone();
159            std::thread::spawn(move || {
160                auto_consolidate_knowledge(&root);
161            });
162        }
163
164        // Periodically record a recallable AI session summary (#292), off-thread.
165        if let Some(ref root) = project_root {
166            let root = root.clone();
167            std::thread::spawn(move || {
168                let _ =
169                    crate::core::session_summary::maybe_record_periodic(&root, summary_candidate);
170            });
171        }
172
173        let multi_agent_block = self
174            .auto_multi_agent_checkpoint(project_root.as_ref())
175            .await;
176
177        self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
178            .await;
179
180        self.record_cep_snapshot().await;
181
182        if !crate::core::protocol::meta_visible() {
183            return None;
184        }
185
186        let doc_reminder = {
187            let session = self.session.read().await;
188            let calls = self.tool_calls.read().await;
189            Self::activity_nudge(&session, &calls)
190        };
191
192        Some(format!(
193            "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}{doc_reminder}",
194            complexity.instruction_suffix()
195        ))
196    }
197
198    async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
199        let Some(root) = project_root else {
200            return String::new();
201        };
202
203        let registry = crate::core::agents::AgentRegistry::load_or_create();
204        let active = registry.list_active(Some(root));
205        if active.len() <= 1 {
206            return String::new();
207        }
208
209        let agent_id = self.agent_id.read().await;
210        let my_id = match agent_id.as_deref() {
211            Some(id) => id.to_string(),
212            None => return String::new(),
213        };
214        drop(agent_id);
215
216        let cache = self.cache.read().await;
217        let entries = cache.get_all_entries();
218        if !entries.is_empty() {
219            let mut by_access: Vec<_> = entries.iter().collect();
220            by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count()));
221            let top_paths: Vec<&str> = by_access
222                .iter()
223                .take(5)
224                .map(|(key, _)| key.as_str())
225                .collect();
226            let paths_csv = top_paths.join(",");
227
228            let _ = ctx_share::handle(
229                "push",
230                Some(&my_id),
231                None,
232                Some(&paths_csv),
233                None,
234                &cache,
235                root,
236            );
237        }
238        drop(cache);
239
240        let pending_count = registry
241            .scratchpad
242            .iter()
243            .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
244            .count();
245
246        let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
247            .unwrap_or_default()
248            .join("agents")
249            .join("shared");
250        let shared_count = if shared_dir.exists() {
251            std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
252        } else {
253            0
254        };
255
256        let agent_names: Vec<String> = active
257            .iter()
258            .map(|a| {
259                let role = a.role.as_deref().unwrap_or(&a.agent_type);
260                format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
261            })
262            .collect();
263
264        format!(
265            "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
266            agent_names.join(", "),
267            pending_count,
268            shared_count,
269        )
270    }
271
272    /// Appends a tool call entry to the rotating `tool-calls.log` file.
273    pub fn append_tool_call_log(
274        tool: &str,
275        duration_ms: u64,
276        original: usize,
277        saved: usize,
278        mode: Option<&str>,
279        timestamp: &str,
280    ) {
281        const MAX_LOG_LINES: usize = 50;
282        if let Ok(dir) = crate::core::paths::state_dir() {
283            let log_path = dir.join("tool-calls.log");
284            let mode_str = mode.unwrap_or("-");
285            let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
286            let line = format!(
287                "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
288            );
289
290            let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
291                .unwrap_or_default()
292                .lines()
293                .map(std::string::ToString::to_string)
294                .collect();
295
296            lines.push(line.trim_end().to_string());
297            if lines.len() > MAX_LOG_LINES {
298                lines.drain(0..lines.len() - MAX_LOG_LINES);
299            }
300
301            let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
302        }
303    }
304
305    fn compute_cep_stats(
306        calls: &[ToolCallRecord],
307        stats: &crate::core::cache::CacheStats,
308        complexity: &crate::core::adaptive::TaskComplexity,
309    ) -> CepComputedStats {
310        let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
311        let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
312        let total_compressed = total_original.saturating_sub(total_saved);
313        let compression_rate = if total_original > 0 {
314            total_saved as f64 / total_original as f64
315        } else {
316            0.0
317        };
318
319        let modes_used: std::collections::HashSet<&str> =
320            calls.iter().filter_map(|c| c.mode.as_deref()).collect();
321        let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
322        let cache_util = stats.hit_rate() / 100.0;
323        // Output efficiency (#501): 1 - avg echo ratio. An agent that keeps
324        // re-quoting delivered content burns the input savings on output.
325        let output_efficiency = 1.0 - crate::core::output_echo::current_avg_ratio();
326        let cep_score = cache_util * 0.25
327            + mode_diversity * 0.15
328            + compression_rate * 0.45
329            + output_efficiency * 0.15;
330
331        let mut mode_counts: std::collections::HashMap<String, u64> =
332            std::collections::HashMap::new();
333        for call in calls {
334            if let Some(ref mode) = call.mode {
335                *mode_counts.entry(mode.clone()).or_insert(0) += 1;
336            }
337        }
338
339        CepComputedStats {
340            cep_score: (cep_score * 100.0).round() as u32,
341            cache_util: (cache_util * 100.0).round() as u32,
342            mode_diversity: (mode_diversity * 100.0).round() as u32,
343            compression_rate: (compression_rate * 100.0).round() as u32,
344            total_original,
345            total_compressed,
346            total_saved,
347            mode_counts,
348            complexity: format!("{complexity:?}"),
349            cache_hits: stats.cache_hits(),
350            total_reads: stats.total_reads(),
351            tool_call_count: calls.len() as u64,
352        }
353    }
354
355    async fn write_mcp_live_stats(&self) {
356        let count = self.call_count.load(Ordering::Relaxed);
357        if count > 1 && !count.is_multiple_of(5) {
358            return;
359        }
360
361        let cache = self.cache.read().await;
362        let calls = self.tool_calls.read().await;
363        let stats = cache.get_stats();
364        let complexity = crate::core::adaptive::classify_from_context(&cache);
365
366        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
367        let started_at = calls
368            .first()
369            .map(|c| c.timestamp.clone())
370            .unwrap_or_default();
371
372        drop(cache);
373        drop(calls);
374
375        // Persist CEP on the live-stats cadence (first call + every 5th) so even
376        // short sessions register `sessions`/`total_cache_hits` instead of only
377        // recording on an `auto_checkpoint` that a brief workload may never reach.
378        // `record_cep_session` is delta-based and PID-guarded, so the extra call
379        // that coincides with a checkpoint is a no-op for the totals (#361).
380        crate::core::stats::record_cep_session(
381            cs.cep_score,
382            cs.cache_hits,
383            cs.total_reads,
384            cs.total_original,
385            cs.total_compressed,
386            &cs.mode_counts,
387            cs.tool_call_count,
388            &cs.complexity,
389        );
390
391        let live = serde_json::json!({
392            "cep_score": cs.cep_score,
393            "cache_utilization": cs.cache_util,
394            "mode_diversity": cs.mode_diversity,
395            "compression_rate": cs.compression_rate,
396            "task_complexity": cs.complexity,
397            "files_cached": cs.total_reads,
398            "total_reads": cs.total_reads,
399            "cache_hits": cs.cache_hits,
400            "tokens_saved": cs.total_saved,
401            "tokens_original": cs.total_original,
402            "tool_calls": cs.tool_call_count,
403            "started_at": started_at,
404            "updated_at": chrono::Local::now().to_rfc3339(),
405        });
406
407        if let Ok(dir) = crate::core::paths::state_dir() {
408            let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
409        }
410    }
411
412    /// Persists a CEP (Cognitive Efficiency Protocol) score snapshot for analytics.
413    pub async fn record_cep_snapshot(&self) {
414        let cache = self.cache.read().await;
415        let calls = self.tool_calls.read().await;
416        let stats = cache.get_stats();
417        let complexity = crate::core::adaptive::classify_from_context(&cache);
418
419        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
420
421        drop(cache);
422        drop(calls);
423
424        crate::core::stats::record_cep_session(
425            cs.cep_score,
426            cs.cache_hits,
427            cs.total_reads,
428            cs.total_original,
429            cs.total_compressed,
430            &cs.mode_counts,
431            cs.tool_call_count,
432            &cs.complexity,
433        );
434    }
435
436    fn activity_nudge(
437        session: &crate::core::session::SessionState,
438        calls: &[ToolCallRecord],
439    ) -> &'static str {
440        let last_doc_ts = session
441            .progress
442            .last()
443            .map(|p| p.timestamp)
444            .or_else(|| session.decisions.last().map(|d| d.timestamp))
445            .or_else(|| session.findings.last().map(|f| f.timestamp));
446
447        if let Some(ts) = last_doc_ts {
448            let age = chrono::Utc::now() - ts;
449            if age.num_minutes() < 8 {
450                return "";
451            }
452        }
453
454        let (weighted_score, significant_tools, shell_heavy, edit_heavy) =
455            Self::compute_activity_score(calls, last_doc_ts);
456
457        if weighted_score < 20 || significant_tools < 5 {
458            if session.stats.total_tool_calls >= 30
459                && session.decisions.is_empty()
460                && session.progress.is_empty()
461            {
462                return "\n[CHECKPOINT: please document current progress via ctx_session(action=\"task\") or ctx_knowledge(action=\"remember\")]";
463            }
464            return "";
465        }
466
467        if shell_heavy {
468            "\n[CHECKPOINT: multiple shell commands executed — any test results or findings worth persisting via ctx_knowledge(action=\"remember\")?]"
469        } else if edit_heavy {
470            "\n[CHECKPOINT: several files modified — document the architecture decision or pattern via ctx_knowledge(action=\"remember\")?]"
471        } else {
472            "\n[CHECKPOINT: significant work detected — consider persisting decisions via ctx_knowledge(action=\"remember\")]"
473        }
474    }
475
476    fn compute_activity_score(
477        calls: &[ToolCallRecord],
478        last_doc_ts: Option<chrono::DateTime<chrono::Utc>>,
479    ) -> (u32, u32, bool, bool) {
480        let mut weighted_score: u32 = 0;
481        let mut significant_tools: u32 = 0;
482        let mut shell_count: u32 = 0;
483        let mut edit_count: u32 = 0;
484
485        let since_doc: Vec<&ToolCallRecord> = if let Some(ts) = last_doc_ts {
486            let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
487            calls.iter().filter(|c| c.timestamp > ts_str).collect()
488        } else {
489            calls.iter().collect()
490        };
491
492        for call in &since_doc {
493            let tool = call.tool.as_str();
494            let is_knowledge = tool == "ctx_knowledge" || tool == "ctx_session";
495            if is_knowledge {
496                weighted_score = 0;
497                significant_tools = 0;
498                shell_count = 0;
499                edit_count = 0;
500                continue;
501            }
502
503            let (weight, significant) = match tool {
504                "edit" | "write" | "str_replace" => {
505                    edit_count += 1;
506                    (4u32, true)
507                }
508                "ctx_shell" => {
509                    shell_count += 1;
510                    let is_test_or_build = call
511                        .mode
512                        .as_deref()
513                        .is_some_and(|m| m.contains("test") || m.contains("build"));
514                    if is_test_or_build {
515                        (3, true)
516                    } else {
517                        (2, true)
518                    }
519                }
520                "ctx_read" => {
521                    let is_cache_hit = call.saved_tokens > 0
522                        && call.original_tokens > 0
523                        && call.saved_tokens == call.original_tokens;
524                    if is_cache_hit { (0, false) } else { (1, false) }
525                }
526                _ => (1, false),
527            };
528
529            weighted_score = weighted_score.saturating_add(weight);
530            if significant {
531                significant_tools += 1;
532            }
533        }
534
535        let shell_heavy = shell_count >= 3 && shell_count > edit_count;
536        let edit_heavy = edit_count >= 3 && edit_count >= shell_count;
537
538        (weighted_score, significant_tools, shell_heavy, edit_heavy)
539    }
540}
541
542#[cfg(test)]
543mod activity_score_tests {
544    use super::*;
545
546    fn make_call(tool: &str, mode: Option<&str>) -> ToolCallRecord {
547        ToolCallRecord {
548            tool: tool.to_string(),
549            original_tokens: 100,
550            saved_tokens: 50,
551            mode: mode.map(String::from),
552            duration_ms: 10,
553            timestamp: "2026-01-01 12:00:00".to_string(),
554        }
555    }
556
557    fn make_cache_hit() -> ToolCallRecord {
558        ToolCallRecord {
559            tool: "ctx_read".to_string(),
560            original_tokens: 100,
561            saved_tokens: 100,
562            mode: Some("full".to_string()),
563            duration_ms: 1,
564            timestamp: "2026-01-01 12:00:00".to_string(),
565        }
566    }
567
568    #[test]
569    fn empty_calls_zero_score() {
570        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&[], None);
571        assert_eq!(score, 0);
572        assert_eq!(sig, 0);
573    }
574
575    #[test]
576    fn edits_have_highest_weight() {
577        let calls = vec![
578            make_call("edit", None),
579            make_call("edit", None),
580            make_call("edit", None),
581        ];
582        let (score, sig, _, edit_heavy) = LeanCtxServer::compute_activity_score(&calls, None);
583        assert_eq!(score, 12);
584        assert_eq!(sig, 3);
585        assert!(edit_heavy);
586    }
587
588    #[test]
589    fn shell_test_build_weight_three() {
590        let calls = vec![
591            make_call("ctx_shell", Some("test")),
592            make_call("ctx_shell", Some("build")),
593            make_call("ctx_shell", Some("test")),
594        ];
595        let (score, sig, shell_heavy, _) = LeanCtxServer::compute_activity_score(&calls, None);
596        assert_eq!(score, 9);
597        assert_eq!(sig, 3);
598        assert!(shell_heavy);
599    }
600
601    #[test]
602    fn cache_hits_zero_weight() {
603        let calls = vec![make_cache_hit(), make_cache_hit(), make_cache_hit()];
604        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
605        assert_eq!(score, 0);
606        assert_eq!(sig, 0);
607    }
608
609    #[test]
610    fn knowledge_call_resets_score() {
611        let calls = vec![
612            make_call("edit", None),
613            make_call("edit", None),
614            make_call("ctx_knowledge", None),
615            make_call("ctx_read", None),
616        ];
617        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
618        assert_eq!(score, 1);
619        assert_eq!(sig, 0);
620    }
621
622    #[test]
623    fn mixed_workflow_scoring() {
624        let calls = vec![
625            make_call("ctx_read", None),
626            make_call("ctx_read", None),
627            make_call("edit", None),
628            make_call("edit", None),
629            make_call("ctx_shell", Some("test output")),
630            make_call("ctx_shell", None),
631        ];
632        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
633        assert_eq!(score, 2 + 4 + 4 + 3 + 2);
634        assert_eq!(sig, 4);
635    }
636}