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