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