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 and interactive Codex runs do. Without it a host can still show
40    /// tokens used, just 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. Returns
133    /// the ratio rather than a formatted string or a bar, so a host renders it
134    /// 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    ///
172    /// Every status the provider prefixes with `allowed` is a heartbeat, not a
173    /// refusal. This was an exact match on `allowed`, which made
174    /// `allowed_warning` a block: Claude emits that once an account passes a
175    /// utilization threshold, which is the *opposite* of being refused, and
176    /// the account keeps working for the rest of the window. Observed on
177    /// claude 2.1.212, seven-day window, at 77% of quota:
178    ///
179    /// ```json
180    /// {"status": "allowed_warning", "utilization": 0.77,
181    ///  "surpassedThreshold": 0.75, "rateLimitType": "seven_day"}
182    /// ```
183    ///
184    /// The consequence was that crossing 75% of a window turned every
185    /// finished, successful run into `Error::RateLimited` and discarded its
186    /// answer, for as long as the window stayed above the threshold.
187    ///
188    /// Callers who want to *show* the warning read `status` and `resets_at`,
189    /// which carry it. This one method answers only whether the request was
190    /// refused.
191    #[must_use]
192    pub fn is_blocking(&self) -> bool {
193        !self.status.to_ascii_lowercase().starts_with("allowed")
194    }
195}
196
197/// Why the agent stopped.
198#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200#[non_exhaustive]
201pub enum Stop {
202    /// Completed normally.
203    #[default]
204    Completed,
205    /// The agent reported an error result.
206    Error,
207    /// The agent stopped for a reason it named but this crate does not model.
208    Other(String),
209}
210
211/// The result of one completed run.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct Outcome {
215    /// Which agent produced it.
216    pub agent: Agent,
217    /// The native session id, when the run had or produced one. This is the
218    /// handle a later turn resumes with.
219    pub session: Option<String>,
220    /// The assistant's final text.
221    pub text: String,
222    /// Token and cost accounting.
223    pub usage: Usage,
224    /// Why it stopped.
225    pub stop: Stop,
226    /// The last quota signal seen, if any.
227    pub rate_limit: Option<RateLimit>,
228    /// The process exit code.
229    pub exit_code: i32,
230    /// Raw stderr, kept for diagnostics and capped at
231    /// [`crate::MAX_CAPTURE`].
232    pub stderr: String,
233    /// How many output lines could not be parsed, with the first as a sample.
234    ///
235    /// Agents interleave banners with their JSON, so a non-zero count is not
236    /// automatically a fault. It becomes one when paired with an empty [`Self::text`],
237    /// which is what a vendor changing its output shape looks like from here.
238    /// See [`Outcome::looks_like_a_format_change`].
239    pub unparsed: usize,
240    /// The first line that failed to parse.
241    pub first_unparsed: Option<String>,
242    /// The answer parsed against the schema given to [`crate::Request::schema`].
243    ///
244    /// `None` when no schema was asked for, or when the agent's answer did not
245    /// parse as JSON. Never a re-interpretation of prose: this is only set from
246    /// a value the agent produced under a schema.
247    pub structured: Option<serde_json::Value>,
248}
249
250impl Outcome {
251    /// Whether the run finished cleanly: a zero exit and no error result.
252    #[must_use]
253    pub fn is_ok(&self) -> bool {
254        self.exit_code == 0 && self.stop == Stop::Completed
255    }
256
257    /// Whether this run looks like the agent changed its output format.
258    ///
259    /// The signature is a process that exited successfully while every line it
260    /// printed was unreadable: the CLI is healthy and this crate's parser is
261    /// not. Worth logging loudly, because the alternative symptom is a
262    /// successful run that mysteriously returns nothing.
263    #[must_use]
264    pub fn looks_like_a_format_change(&self) -> bool {
265        self.exit_code == 0 && self.unparsed > 0 && self.text.trim().is_empty()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    /// Reported from the field, and the reason a healthy account stopped
274    /// working: a run that finished and answered was handed back as
275    /// `Error::RateLimited`, for every run, once the seven-day window passed
276    /// three quarters full.
277    ///
278    /// The record below is verbatim from claude 2.1.212 at the time. `status`
279    /// is `allowed_warning`, which is the provider saying the request went
280    /// through and the window is filling, and the old exact match on
281    /// `allowed` read every character of that as a refusal.
282    #[test]
283    fn a_warning_is_not_a_refusal() {
284        let warned = RateLimit {
285            status: "allowed_warning".into(),
286            window: Some("seven_day".into()),
287            resets_at: Some(1_785_765_600),
288            overage_status: None,
289            is_using_overage: Some(false),
290        };
291        assert!(
292            !warned.is_blocking(),
293            "a warning that the window is filling blocked a run that succeeded"
294        );
295
296        // The plain heartbeat, and the refusals, both still read correctly.
297        assert!(
298            !RateLimit {
299                status: "allowed".into(),
300                ..warned.clone()
301            }
302            .is_blocking()
303        );
304        for refusal in ["rejected", "blocked", "REJECTED"] {
305            assert!(
306                RateLimit {
307                    status: refusal.into(),
308                    ..warned.clone()
309                }
310                .is_blocking(),
311                "{refusal} must still be read as a refusal"
312            );
313        }
314    }
315
316    /// The trap `accumulate` exists to avoid, and the one it once fell into
317    /// next door. `context_tokens` is the conversation's size as the agent last
318    /// saw it, so summing it counts the same conversation once per turn. The
319    /// cache figures look alike and are not: each turn's is what that turn's
320    /// calls actually read, and every read is billed.
321    ///
322    /// Numbers from two real Codex turns on one thread.
323    #[test]
324    fn a_session_total_does_not_count_the_conversation_twice() {
325        let turn1 = Usage {
326            input_tokens: Some(2_286),
327            output_tokens: Some(5),
328            cache_read_tokens: Some(13_056),
329            context_tokens: Some(15_342),
330            ..Usage::default()
331        };
332        let turn2 = Usage {
333            input_tokens: Some(2_543),
334            output_tokens: Some(11),
335            cache_read_tokens: Some(28_160),
336            context_tokens: Some(30_703),
337            ..Usage::default()
338        };
339
340        let mut session = Usage::default();
341        session.accumulate(&turn1);
342        session.accumulate(&turn2);
343
344        // New work each turn, so these add up.
345        assert_eq!(session.input_tokens, Some(4_829));
346        assert_eq!(session.output_tokens, Some(16));
347        // The conversation is one conversation. Summing would claim 46,045.
348        assert_eq!(
349            session.context_tokens,
350            Some(30_703),
351            "context is already cumulative and must not be summed"
352        );
353        // Billed traffic, not a size: the session read 41,216 tokens out of
354        // cache across the two turns and was charged for all of them. Taking
355        // the latest would report the second turn's 28,160 as the whole
356        // session's, and no token total built on it could explain its cost.
357        assert_eq!(
358            session.cache_read_tokens,
359            Some(41_216),
360            "cache reads are billed per call and accumulate"
361        );
362    }
363
364    #[test]
365    fn cost_and_duration_accumulate() {
366        let mut session = Usage::default();
367        for _ in 0..3 {
368            session.accumulate(&Usage {
369                cost_usd: Some(0.5),
370                duration_ms: Some(1_000),
371                premium_requests: Some(1),
372                ..Usage::default()
373            });
374        }
375        assert!((session.cost_usd.expect("cost") - 1.5).abs() < 1e-9);
376        assert_eq!(session.duration_ms, Some(3_000));
377        assert_eq!(session.premium_requests, Some(3));
378    }
379
380    /// A field the agent stopped reporting must keep its last known value
381    /// rather than being wiped by a turn that said nothing.
382    #[test]
383    fn a_silent_turn_does_not_erase_what_is_known() {
384        let mut session = Usage {
385            context_window: Some(200_000),
386            cost_usd: Some(1.0),
387            ..Usage::default()
388        };
389        session.accumulate(&Usage::default());
390        assert_eq!(session.context_window, Some(200_000));
391        assert_eq!(session.cost_usd, Some(1.0));
392    }
393
394    #[test]
395    fn context_used_is_a_ratio_and_absent_without_both_halves() {
396        let full = Usage {
397            context_tokens: Some(27_645),
398            context_window: Some(200_000),
399            ..Usage::default()
400        };
401        let share = full.context_used().expect("both halves present");
402        assert!((share - 0.138_225).abs() < 1e-6, "{share}");
403
404        // Codex reports tokens but no window, so a share is not knowable.
405        let partial = Usage {
406            context_tokens: Some(30_703),
407            ..Usage::default()
408        };
409        assert_eq!(partial.context_used(), None);
410    }
411}