Skip to main content

agent_abstraction/
outcome.rs

1//! What a finished run produced.
2
3use serde::{Deserialize, Serialize};
4
5use crate::agent::Agent;
6
7/// Token and cost accounting for a run.
8///
9/// Every field is optional because the three agents report different subsets:
10/// Claude reports full token counts and a dollar cost, Codex reports tokens,
11/// Copilot reports premium requests and no tokens at all. An absent field means
12/// "this agent did not say", never zero.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct Usage {
16    /// Input tokens that were **not** served from cache.
17    ///
18    /// Normalized, because the vendors disagree on what "input" counts. Claude
19    /// reports the uncached remainder and Codex reports the whole prompt with
20    /// the cached part included, so this field is derived on the Codex side by
21    /// subtracting. Reading it as the same quantity on both was the point.
22    pub input_tokens: Option<u64>,
23    /// Generated tokens.
24    pub output_tokens: Option<u64>,
25    /// Input tokens served from the prompt cache.
26    pub cache_read_tokens: Option<u64>,
27    /// Input tokens written into the prompt cache.
28    pub cache_write_tokens: Option<u64>,
29    /// Every input token the turn was charged for, cached or not.
30    ///
31    /// The size of the conversation as the model saw it, which makes this the
32    /// context tracker: compare it to [`Usage::context_window`]. It is already
33    /// a running total, since the cached portion *is* the prior conversation,
34    /// so **summing it across turns double counts**. See
35    /// [`Usage::accumulate`].
36    pub context_tokens: Option<u64>,
37    /// The selected model's context window, where the agent reports one.
38    ///
39    /// Claude alone does. Without it a host can still show tokens used, just
40    /// not a share of the limit.
41    pub context_window: Option<u64>,
42    /// The most tokens the model may generate in one reply.
43    pub max_output_tokens: Option<u64>,
44    /// Output tokens spent on reasoning rather than the visible answer, where
45    /// the agent separates them. Codex alone does.
46    pub reasoning_tokens: Option<u64>,
47    /// Cost in USD, when the agent priced the run itself. Never inferred from a
48    /// local price table, because a guessed cost is worse than no cost.
49    pub cost_usd: Option<f64>,
50    /// Copilot's premium-request count, its legacy billing unit.
51    pub premium_requests: Option<u64>,
52    /// Copilot's AI-credit spend for the session, in nano units, which is the
53    /// unit that replaced premium requests. Divide by 1e9 for credits.
54    ///
55    /// Session-scoped and cumulative within a session, verified by running
56    /// Copilot repeatedly: it restarts each run rather than accruing across
57    /// them. Not an account balance.
58    pub ai_credits_nano: Option<u64>,
59    /// Wall-clock time the run took, in milliseconds.
60    pub duration_ms: Option<u64>,
61    /// Time spent waiting on the provider, in milliseconds.
62    pub api_duration_ms: Option<u64>,
63}
64
65impl Usage {
66    /// Whether the agent reported anything at all.
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        *self == Usage::default()
70    }
71
72    /// Fold one turn's usage into a session running total.
73    ///
74    /// Provided because the obvious loop is wrong. Cost and tokens accumulate,
75    /// but `context_tokens` is already cumulative: an agent re-sends the whole
76    /// conversation each turn and reports its size. Summing that across turns
77    /// counts the same conversation once per turn, and the error grows with the
78    /// session.
79    ///
80    /// So additive fields add, and context-shaped fields take the newer value:
81    ///
82    /// | field | behaviour |
83    /// |---|---|
84    /// | `output_tokens`, `reasoning_tokens`, `input_tokens` | summed |
85    /// | `cache_read_tokens`, `cache_write_tokens` | summed |
86    /// | `cost_usd`, `premium_requests`, `duration_ms`, `api_duration_ms` | summed |
87    /// | `context_tokens`, `context_window`, `max_output_tokens` | latest |
88    /// | `ai_credits_nano` | latest, being a session total already |
89    ///
90    /// `input_tokens` sums because it is the uncached remainder, which is new
91    /// work each turn.
92    ///
93    /// The cache figures sum for the same reason, which this once got wrong by
94    /// filing them with the context. They are not the conversation's size: a
95    /// turn's terminal record already sums the cache reads of every call in
96    /// that turn, so 100000 and 102000 arrive as 202000, and every one of those
97    /// reads is billed. Taking the latest reported one turn's cache traffic as
98    /// the whole session's, which understates a long session badly and leaves a
99    /// host's token total unable to explain its own cost figure.
100    pub fn accumulate(&mut self, turn: &Usage) {
101        fn add(total: &mut Option<u64>, turn: Option<u64>) {
102            if let Some(value) = turn {
103                *total = Some(total.unwrap_or(0) + value);
104            }
105        }
106        fn latest<T: Copy>(total: &mut Option<T>, turn: Option<T>) {
107            if turn.is_some() {
108                *total = turn;
109            }
110        }
111
112        add(&mut self.input_tokens, turn.input_tokens);
113        add(&mut self.output_tokens, turn.output_tokens);
114        add(&mut self.cache_read_tokens, turn.cache_read_tokens);
115        add(&mut self.cache_write_tokens, turn.cache_write_tokens);
116        add(&mut self.reasoning_tokens, turn.reasoning_tokens);
117        add(&mut self.premium_requests, turn.premium_requests);
118        add(&mut self.duration_ms, turn.duration_ms);
119        add(&mut self.api_duration_ms, turn.api_duration_ms);
120        if let Some(cost) = turn.cost_usd {
121            self.cost_usd = Some(self.cost_usd.unwrap_or(0.0) + cost);
122        }
123
124        latest(&mut self.context_tokens, turn.context_tokens);
125        latest(&mut self.context_window, turn.context_window);
126        latest(&mut self.max_output_tokens, turn.max_output_tokens);
127        latest(&mut self.ai_credits_nano, turn.ai_credits_nano);
128    }
129
130    /// Share of the context window in use, from 0.0 to 1.0.
131    ///
132    /// `None` unless the agent reported both the tokens and the window, which
133    /// today means Claude. Returns the ratio rather than a formatted string or
134    /// a bar, so a host renders it however it likes.
135    #[must_use]
136    pub fn context_used(&self) -> Option<f64> {
137        let (used, window) = (self.context_tokens?, self.context_window?);
138        if window == 0 {
139            return None;
140        }
141        #[expect(
142            clippy::cast_precision_loss,
143            reason = "token counts are far below the f64 integer limit"
144        )]
145        Some(used as f64 / window as f64)
146    }
147}
148
149/// A quota signal the agent emitted mid-run.
150///
151/// Surfaced rather than acted on: this crate reports what the provider said and
152/// leaves backing off to the caller. See `docs/operating-limits.md`.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[non_exhaustive]
155pub struct RateLimit {
156    /// The provider's status word, e.g. `allowed`, `rejected`.
157    pub status: String,
158    /// Which window this refers to, e.g. `five_hour`.
159    pub window: Option<String>,
160    /// Unix epoch seconds at which the window resets.
161    pub resets_at: Option<i64>,
162    /// The provider's status for overage beyond the plan, e.g. `rejected`.
163    pub overage_status: Option<String>,
164    /// Whether the run was already drawing on overage rather than the plan.
165    pub is_using_overage: Option<bool>,
166}
167
168impl RateLimit {
169    /// Whether this signal means the request was actually refused, as opposed
170    /// to an informational "still allowed" heartbeat.
171    #[must_use]
172    pub fn is_blocking(&self) -> bool {
173        !self.status.eq_ignore_ascii_case("allowed")
174    }
175}
176
177/// Why the agent stopped.
178#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum Stop {
182    /// Completed normally.
183    #[default]
184    Completed,
185    /// The agent reported an error result.
186    Error,
187    /// The agent stopped for a reason it named but this crate does not model.
188    Other(String),
189}
190
191/// The result of one completed run.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193#[non_exhaustive]
194pub struct Outcome {
195    /// Which agent produced it.
196    pub agent: Agent,
197    /// The native session id, when the run had or produced one. This is the
198    /// handle a later turn resumes with.
199    pub session: Option<String>,
200    /// The assistant's final text.
201    pub text: String,
202    /// Token and cost accounting.
203    pub usage: Usage,
204    /// Why it stopped.
205    pub stop: Stop,
206    /// The last quota signal seen, if any.
207    pub rate_limit: Option<RateLimit>,
208    /// The process exit code.
209    pub exit_code: i32,
210    /// Raw stderr, kept for diagnostics and capped at
211    /// [`crate::MAX_CAPTURE`].
212    pub stderr: String,
213    /// How many output lines could not be parsed, with the first as a sample.
214    ///
215    /// Agents interleave banners with their JSON, so a non-zero count is not
216    /// automatically a fault. It becomes one when paired with an empty [`Self::text`],
217    /// which is what a vendor changing its output shape looks like from here.
218    /// See [`Outcome::looks_like_a_format_change`].
219    pub unparsed: usize,
220    /// The first line that failed to parse.
221    pub first_unparsed: Option<String>,
222    /// The answer parsed against the schema given to [`crate::Request::schema`].
223    ///
224    /// `None` when no schema was asked for, or when the agent's answer did not
225    /// parse as JSON. Never a re-interpretation of prose: this is only set from
226    /// a value the agent produced under a schema.
227    pub structured: Option<serde_json::Value>,
228}
229
230impl Outcome {
231    /// Whether the run finished cleanly: a zero exit and no error result.
232    #[must_use]
233    pub fn is_ok(&self) -> bool {
234        self.exit_code == 0 && self.stop == Stop::Completed
235    }
236
237    /// Whether this run looks like the agent changed its output format.
238    ///
239    /// The signature is a process that exited successfully while every line it
240    /// printed was unreadable: the CLI is healthy and this crate's parser is
241    /// not. Worth logging loudly, because the alternative symptom is a
242    /// successful run that mysteriously returns nothing.
243    #[must_use]
244    pub fn looks_like_a_format_change(&self) -> bool {
245        self.exit_code == 0 && self.unparsed > 0 && self.text.trim().is_empty()
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    /// The trap `accumulate` exists to avoid, and the one it once fell into
254    /// next door. `context_tokens` is the conversation's size as the agent last
255    /// saw it, so summing it counts the same conversation once per turn. The
256    /// cache figures look alike and are not: each turn's is what that turn's
257    /// calls actually read, and every read is billed.
258    ///
259    /// Numbers from two real Codex turns on one thread.
260    #[test]
261    fn a_session_total_does_not_count_the_conversation_twice() {
262        let turn1 = Usage {
263            input_tokens: Some(2_286),
264            output_tokens: Some(5),
265            cache_read_tokens: Some(13_056),
266            context_tokens: Some(15_342),
267            ..Usage::default()
268        };
269        let turn2 = Usage {
270            input_tokens: Some(2_543),
271            output_tokens: Some(11),
272            cache_read_tokens: Some(28_160),
273            context_tokens: Some(30_703),
274            ..Usage::default()
275        };
276
277        let mut session = Usage::default();
278        session.accumulate(&turn1);
279        session.accumulate(&turn2);
280
281        // New work each turn, so these add up.
282        assert_eq!(session.input_tokens, Some(4_829));
283        assert_eq!(session.output_tokens, Some(16));
284        // The conversation is one conversation. Summing would claim 46,045.
285        assert_eq!(
286            session.context_tokens,
287            Some(30_703),
288            "context is already cumulative and must not be summed"
289        );
290        // Billed traffic, not a size: the session read 41,216 tokens out of
291        // cache across the two turns and was charged for all of them. Taking
292        // the latest would report the second turn's 28,160 as the whole
293        // session's, and no token total built on it could explain its cost.
294        assert_eq!(
295            session.cache_read_tokens,
296            Some(41_216),
297            "cache reads are billed per call and accumulate"
298        );
299    }
300
301    #[test]
302    fn cost_and_duration_accumulate() {
303        let mut session = Usage::default();
304        for _ in 0..3 {
305            session.accumulate(&Usage {
306                cost_usd: Some(0.5),
307                duration_ms: Some(1_000),
308                premium_requests: Some(1),
309                ..Usage::default()
310            });
311        }
312        assert!((session.cost_usd.expect("cost") - 1.5).abs() < 1e-9);
313        assert_eq!(session.duration_ms, Some(3_000));
314        assert_eq!(session.premium_requests, Some(3));
315    }
316
317    /// A field the agent stopped reporting must keep its last known value
318    /// rather than being wiped by a turn that said nothing.
319    #[test]
320    fn a_silent_turn_does_not_erase_what_is_known() {
321        let mut session = Usage {
322            context_window: Some(200_000),
323            cost_usd: Some(1.0),
324            ..Usage::default()
325        };
326        session.accumulate(&Usage::default());
327        assert_eq!(session.context_window, Some(200_000));
328        assert_eq!(session.cost_usd, Some(1.0));
329    }
330
331    #[test]
332    fn context_used_is_a_ratio_and_absent_without_both_halves() {
333        let full = Usage {
334            context_tokens: Some(27_645),
335            context_window: Some(200_000),
336            ..Usage::default()
337        };
338        let share = full.context_used().expect("both halves present");
339        assert!((share - 0.138_225).abs() < 1e-6, "{share}");
340
341        // Codex reports tokens but no window, so a share is not knowable.
342        let partial = Usage {
343            context_tokens: Some(30_703),
344            ..Usage::default()
345        };
346        assert_eq!(partial.context_used(), None);
347    }
348}