Skip to main content

whycodes_session/
run_stats.rs

1//! Per-run tallies behind `/stats`: model requests and tool calls.
2//!
3//! Counters only move at points the turn loop already visits (a finished
4//! stream, a finished tool batch), so they add no work to a turn. Like
5//! [`crate::SessionCost`] they live in memory: a resumed session counts from
6//! zero, while prompts, edits and todos are read from what is stored.
7
8use std::collections::BTreeMap;
9
10use whycodes_core::todo::{TodoItem, TodoStatus};
11use whycodes_core::types::{ToolCall, ToolResult, Usage};
12
13use crate::session::Session;
14
15/// Requests and tokens for one `provider/model`.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct ModelTally {
18    pub requests: u64,
19    pub input_tokens: u64,
20    pub output_tokens: u64,
21}
22
23/// Calls and failures for one tool name.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct ToolTally {
26    pub calls: u64,
27    pub errors: u64,
28}
29
30/// What this run asked of models and tools.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RunStats {
33    pub models: BTreeMap<String, ModelTally>,
34    pub tools: BTreeMap<String, ToolTally>,
35}
36
37impl RunStats {
38    /// One finished model request; `usage` may be empty (silent provider).
39    pub fn record_request(&mut self, provider: &str, model: &str, usage: &Usage) {
40        let key = format!("{provider}/{model}");
41        let tally = self.models.entry(key).or_default();
42        tally.requests += 1;
43        tally.input_tokens += usage.input_tokens;
44        tally.output_tokens += usage.output_tokens;
45    }
46
47    /// One tool batch; a call fails when its result is marked as an error.
48    pub fn record_tools(&mut self, calls: &[ToolCall], results: &[ToolResult]) {
49        for call in calls {
50            let failed = results
51                .iter()
52                .any(|r| r.tool_call_id == call.id && r.is_error);
53            let tally = self.tools.entry(call.name.clone()).or_default();
54            tally.calls += 1;
55            tally.errors += u64::from(failed);
56        }
57    }
58
59    pub fn requests(&self) -> u64 {
60        self.models.values().map(|t| t.requests).sum()
61    }
62
63    pub fn tool_calls(&self) -> u64 {
64        self.tools.values().map(|t| t.calls).sum()
65    }
66
67    pub fn tool_errors(&self) -> u64 {
68        self.tools.values().map(|t| t.errors).sum()
69    }
70}
71
72/// Subagent launches are calls to this tool.
73const TASK_TOOL: &str = "task";
74
75/// Plain-text `/stats` body for the TUI and the line REPL.
76pub fn stats_report(
77    session: &Session,
78    todos: &[TodoItem],
79    now: chrono::DateTime<chrono::Utc>,
80) -> String {
81    let run = &session.run_stats;
82    let mut lines = vec![
83        "Session stats".to_string(),
84        format!(
85            "  duration:  {} since the session started",
86            duration_label(now - session.created_at)
87        ),
88        format!("  prompts:   {}", session.prompt_count()),
89        format!("  requests:  {} this run", run.requests()),
90    ];
91    for (model, t) in &run.models {
92        lines.push(format!(
93            "    {model}: {} · {} in / {} out",
94            plural(t.requests, "request"),
95            compact(t.input_tokens),
96            compact(t.output_tokens)
97        ));
98    }
99    lines.push(format!(
100        "  tools:     {} · {} failed this run",
101        plural(run.tool_calls(), "call"),
102        run.tool_errors()
103    ));
104    let mut by_use: Vec<(&String, &ToolTally)> = run.tools.iter().collect();
105    by_use.sort_by_key(|a| std::cmp::Reverse(a.1.calls));
106    if !by_use.is_empty() {
107        let parts: Vec<String> = by_use
108            .iter()
109            .map(|(name, t)| match t.errors {
110                0 => format!("{name} {}", t.calls),
111                e => format!("{name} {} ({e} failed)", t.calls),
112            })
113            .collect();
114        lines.push(format!("    {}", parts.join(" · ")));
115    }
116    let tasks = run.tools.get(TASK_TOOL).map_or(0, |t| t.calls);
117    lines.push(format!("  subagents: {tasks} launched this run"));
118    lines.push(todo_line(todos));
119    lines.push(format!("  edits:     {}", session.edit_stats.label()));
120    let u = &session.usage;
121    lines.push(format!(
122        "  tokens:    {} in / {} out",
123        compact(u.input_tokens),
124        compact(u.output_tokens)
125    ));
126    lines.join("\n")
127}
128
129fn todo_line(todos: &[TodoItem]) -> String {
130    let count = |s: TodoStatus| todos.iter().filter(|t| t.status == s).count();
131    format!(
132        "  todos:     {}/{} done · {} in progress · {} pending · {} cancelled",
133        count(TodoStatus::Completed),
134        todos.len(),
135        count(TodoStatus::InProgress),
136        count(TodoStatus::Pending),
137        count(TodoStatus::Cancelled)
138    )
139}
140
141fn plural(n: u64, noun: &str) -> String {
142    match n {
143        1 => format!("1 {noun}"),
144        n => format!("{n} {noun}s"),
145    }
146}
147
148/// `1.2k`, `3.4M`; exact below a thousand.
149fn compact(n: u64) -> String {
150    match n {
151        n if n >= 1_000_000 => format!("{:.1}M", n as f64 / 1_000_000.0),
152        n if n >= 1_000 => format!("{:.1}k", n as f64 / 1_000.0),
153        n => n.to_string(),
154    }
155}
156
157fn duration_label(d: chrono::TimeDelta) -> String {
158    let mins = d.num_minutes().max(0);
159    match (mins / 60, mins % 60) {
160        (0, m) => format!("{m}m"),
161        (h, m) => format!("{h}h {m}m"),
162    }
163}
164
165#[cfg(test)]
166#[path = "run_stats_tests.rs"]
167mod tests;