Skip to main content

spar/
spend.rs

1//! What a run spent.
2//!
3//! A run makes many agent calls at scheduled effort, and the terminal report
4//! said nothing about them: no count per agent, no wall time, no retries or
5//! hand-overs. `max_rounds`, `absorb_new_issues`, `max_followups` and the
6//! effort schedule are all documented in terms of what they cost, and nothing
7//! in the output let anybody see whether changing one did what they hoped.
8//!
9//! Ambient on purpose. Threading a counter through every call site would put
10//! accounting in the signature of everything that asks a model a question, and
11//! the one thing this must not do is change what the loop decides.
12
13use std::sync::Mutex;
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18/// One call to one agent.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Spent {
21    pub agent: String,
22    /// The kind of call, as the effort schedule names it: `triage`,
23    /// `review_1`, `respond`, and the rest.
24    pub kind: String,
25    /// What it was asked for, empty when the CLI's own default was used.
26    #[serde(default, skip_serializing_if = "String::is_empty")]
27    pub effort: String,
28    /// The issue or pull request this call was about, when it was about one.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub subject: Option<i64>,
31    pub seconds: f64,
32    /// The same agent, asked again after an unusable answer.
33    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34    pub retry: bool,
35    /// A stand in answering for an agent that could not.
36    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37    pub fallback: bool,
38    /// Where the CLI's event stream reports it. Blank where it does not, which
39    /// is most of them.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tokens: Option<u64>,
42    /// Whether the call produced a usable answer.
43    pub ok: bool,
44}
45
46static SPENT: Mutex<Vec<Spent>> = Mutex::new(Vec::new());
47
48/// Record one call, and say so as it happens.
49pub fn record(call: Spent) {
50    crate::logdim!(
51        "{} {} call{}{} took {:.0}s{}",
52        call.agent,
53        call.kind,
54        match call.effort.as_str() {
55            "" => String::new(),
56            effort => format!(" at {effort}"),
57        },
58        match (call.retry, call.fallback) {
59            (true, _) => " (retry)",
60            (_, true) => " (standing in)",
61            _ => "",
62        },
63        call.seconds,
64        match call.tokens {
65            Some(tokens) => format!(", {tokens} tokens"),
66            None => String::new(),
67        }
68    );
69    SPENT
70        .lock()
71        .unwrap_or_else(std::sync::PoisonError::into_inner)
72        .push(call);
73}
74
75/// Everything recorded so far.
76pub fn taken() -> Vec<Spent> {
77    SPENT
78        .lock()
79        .unwrap_or_else(std::sync::PoisonError::into_inner)
80        .clone()
81}
82
83#[cfg(test)]
84pub fn forget() {
85    SPENT
86        .lock()
87        .unwrap_or_else(std::sync::PoisonError::into_inner)
88        .clear();
89}
90
91/// One line per agent, and one for the run, or nothing when nothing was asked.
92pub fn summary(calls: &[Spent]) -> Option<String> {
93    if calls.is_empty() {
94        return None;
95    }
96    let mut agents: Vec<&str> = calls.iter().map(|c| c.agent.as_str()).collect();
97    agents.sort_unstable();
98    agents.dedup();
99
100    let mut lines = Vec::new();
101    for agent in agents {
102        let theirs: Vec<&Spent> = calls.iter().filter(|c| c.agent == agent).collect();
103        let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
104        let mut line = format!(
105            "  {agent}: {} call(s), {}",
106            theirs.len(),
107            clock(Duration::from_secs_f64(seconds))
108        );
109        let retries = theirs.iter().filter(|c| c.retry).count();
110        let stood_in = theirs.iter().filter(|c| c.fallback).count();
111        let failed = theirs.iter().filter(|c| !c.ok).count();
112        for (count, what) in [
113            (retries, "retry"),
114            (stood_in, "standing in"),
115            (failed, "failed"),
116        ] {
117            if count > 0 {
118                line.push_str(&format!(", {count} {what}"));
119            }
120        }
121        let tokens: u64 = theirs.iter().filter_map(|c| c.tokens).sum();
122        if tokens > 0 {
123            line.push_str(&format!(", {tokens} tokens"));
124        }
125        lines.push(line);
126    }
127    let total: f64 = calls.iter().map(|c| c.seconds).sum();
128    Some(format!(
129        "spent: {} agent call(s), {} of model time\n{}",
130        calls.len(),
131        clock(Duration::from_secs_f64(total)),
132        lines.join("\n")
133    ))
134}
135
136/// What one issue cost, for the line under its result.
137pub fn for_subject(calls: &[Spent], subject: i64) -> Option<String> {
138    let theirs: Vec<&Spent> = calls
139        .iter()
140        .filter(|c| c.subject == Some(subject))
141        .collect();
142    if theirs.is_empty() {
143        return None;
144    }
145    let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
146    Some(format!(
147        "{} call(s), {}",
148        theirs.len(),
149        clock(Duration::from_secs_f64(seconds))
150    ))
151}
152
153/// Wall time as somebody reads it, rather than as a float.
154fn clock(taken: Duration) -> String {
155    let seconds = taken.as_secs();
156    if seconds < 90 {
157        return format!("{seconds}s");
158    }
159    let minutes = seconds / 60;
160    if minutes < 90 {
161        return format!("{minutes}m{:02}s", seconds % 60);
162    }
163    format!("{}h{:02}m", minutes / 60, minutes % 60)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn call(agent: &str, kind: &str, seconds: f64) -> Spent {
171        Spent {
172            agent: agent.into(),
173            kind: kind.into(),
174            effort: "high".into(),
175            subject: Some(42),
176            seconds,
177            retry: false,
178            fallback: false,
179            tokens: None,
180            ok: true,
181        }
182    }
183
184    /// Tuning was blind: the documented reason `absorb_new_issues` is off is
185    /// that it "multiplies what a run costs", and nothing showed the multiplier.
186    #[test]
187    fn the_summary_says_what_each_agent_was_asked_and_how_long_it_took() {
188        let mut calls = vec![
189            call("claude", "triage", 30.0),
190            call("codex", "triage", 45.0),
191            call("claude", "implement", 120.0),
192        ];
193        calls[2].retry = true;
194        calls[2].tokens = Some(12_000);
195
196        let text = summary(&calls).expect("something was spent");
197        assert!(text.contains("3 agent call(s)"), "{text}");
198        assert!(text.contains("claude: 2 call(s)"), "{text}");
199        assert!(text.contains("codex: 1 call(s)"), "{text}");
200        assert!(text.contains("1 retry"), "{text}");
201        assert!(text.contains("12000 tokens"), "{text}");
202        assert_eq!(None, summary(&[]), "a run that asked nothing says nothing");
203    }
204
205    #[test]
206    fn one_issue_is_accounted_for_on_its_own() {
207        let mut other = call("codex", "review_1", 60.0);
208        other.subject = Some(43);
209        let calls = vec![call("claude", "implement", 30.0), other];
210        assert_eq!(Some("1 call(s), 30s".to_string()), for_subject(&calls, 42));
211        assert_eq!(None, for_subject(&calls, 99));
212    }
213
214    /// Seconds are what the clock gives and minutes are what a person reads.
215    #[test]
216    fn wall_time_reads_as_time() {
217        assert_eq!("45s", clock(Duration::from_secs(45)));
218        assert_eq!("2m30s", clock(Duration::from_secs(150)));
219        assert_eq!("2h05m", clock(Duration::from_secs(7_500)));
220    }
221}