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 generated tokens
75    /// accumulate, but the context-shaped figures are already cumulative: an
76    /// agent re-sends the whole conversation each turn and reports it, mostly
77    /// as cache reads. Summing those across turns counts the same conversation
78    /// once per turn, and the error grows with the 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    /// | `cost_usd`, `premium_requests`, `duration_ms`, `api_duration_ms` | summed |
86    /// | `context_tokens`, `cache_read_tokens`, `cache_write_tokens` | latest |
87    /// | `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    pub fn accumulate(&mut self, turn: &Usage) {
93        fn add(total: &mut Option<u64>, turn: Option<u64>) {
94            if let Some(value) = turn {
95                *total = Some(total.unwrap_or(0) + value);
96            }
97        }
98        fn latest<T: Copy>(total: &mut Option<T>, turn: Option<T>) {
99            if turn.is_some() {
100                *total = turn;
101            }
102        }
103
104        add(&mut self.input_tokens, turn.input_tokens);
105        add(&mut self.output_tokens, turn.output_tokens);
106        add(&mut self.reasoning_tokens, turn.reasoning_tokens);
107        add(&mut self.premium_requests, turn.premium_requests);
108        add(&mut self.duration_ms, turn.duration_ms);
109        add(&mut self.api_duration_ms, turn.api_duration_ms);
110        if let Some(cost) = turn.cost_usd {
111            self.cost_usd = Some(self.cost_usd.unwrap_or(0.0) + cost);
112        }
113
114        latest(&mut self.context_tokens, turn.context_tokens);
115        latest(&mut self.cache_read_tokens, turn.cache_read_tokens);
116        latest(&mut self.cache_write_tokens, turn.cache_write_tokens);
117        latest(&mut self.context_window, turn.context_window);
118        latest(&mut self.max_output_tokens, turn.max_output_tokens);
119        latest(&mut self.ai_credits_nano, turn.ai_credits_nano);
120    }
121
122    /// Share of the context window in use, from 0.0 to 1.0.
123    ///
124    /// `None` unless the agent reported both the tokens and the window, which
125    /// today means Claude. Returns the ratio rather than a formatted string or
126    /// a bar, so a host renders it however it likes.
127    #[must_use]
128    pub fn context_used(&self) -> Option<f64> {
129        let (used, window) = (self.context_tokens?, self.context_window?);
130        if window == 0 {
131            return None;
132        }
133        #[expect(
134            clippy::cast_precision_loss,
135            reason = "token counts are far below the f64 integer limit"
136        )]
137        Some(used as f64 / window as f64)
138    }
139}
140
141/// A quota signal the agent emitted mid-run.
142///
143/// Surfaced rather than acted on: this crate reports what the provider said and
144/// leaves backing off to the caller. See `docs/operating-limits.md`.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[non_exhaustive]
147pub struct RateLimit {
148    /// The provider's status word, e.g. `allowed`, `rejected`.
149    pub status: String,
150    /// Which window this refers to, e.g. `five_hour`.
151    pub window: Option<String>,
152    /// Unix epoch seconds at which the window resets.
153    pub resets_at: Option<i64>,
154    /// The provider's status for overage beyond the plan, e.g. `rejected`.
155    pub overage_status: Option<String>,
156    /// Whether the run was already drawing on overage rather than the plan.
157    pub is_using_overage: Option<bool>,
158}
159
160impl RateLimit {
161    /// Whether this signal means the request was actually refused, as opposed
162    /// to an informational "still allowed" heartbeat.
163    #[must_use]
164    pub fn is_blocking(&self) -> bool {
165        !self.status.eq_ignore_ascii_case("allowed")
166    }
167}
168
169/// Why the agent stopped.
170#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172#[non_exhaustive]
173pub enum Stop {
174    /// Completed normally.
175    #[default]
176    Completed,
177    /// The agent reported an error result.
178    Error,
179    /// The agent stopped for a reason it named but this crate does not model.
180    Other(String),
181}
182
183/// The result of one completed run.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[non_exhaustive]
186pub struct Outcome {
187    /// Which agent produced it.
188    pub agent: Agent,
189    /// The native session id, when the run had or produced one. This is the
190    /// handle a later turn resumes with.
191    pub session: Option<String>,
192    /// The assistant's final text.
193    pub text: String,
194    /// Token and cost accounting.
195    pub usage: Usage,
196    /// Why it stopped.
197    pub stop: Stop,
198    /// The last quota signal seen, if any.
199    pub rate_limit: Option<RateLimit>,
200    /// The process exit code.
201    pub exit_code: i32,
202    /// Raw stderr, kept for diagnostics and capped at
203    /// [`crate::MAX_CAPTURE`].
204    pub stderr: String,
205    /// How many output lines could not be parsed, with the first as a sample.
206    ///
207    /// Agents interleave banners with their JSON, so a non-zero count is not
208    /// automatically a fault. It becomes one when paired with an empty [`Self::text`],
209    /// which is what a vendor changing its output shape looks like from here.
210    /// See [`Outcome::looks_like_a_format_change`].
211    pub unparsed: usize,
212    /// The first line that failed to parse.
213    pub first_unparsed: Option<String>,
214    /// The answer parsed against the schema given to [`crate::Request::schema`].
215    ///
216    /// `None` when no schema was asked for, or when the agent's answer did not
217    /// parse as JSON. Never a re-interpretation of prose: this is only set from
218    /// a value the agent produced under a schema.
219    pub structured: Option<serde_json::Value>,
220}
221
222impl Outcome {
223    /// Whether the run finished cleanly: a zero exit and no error result.
224    #[must_use]
225    pub fn is_ok(&self) -> bool {
226        self.exit_code == 0 && self.stop == Stop::Completed
227    }
228
229    /// Whether this run looks like the agent changed its output format.
230    ///
231    /// The signature is a process that exited successfully while every line it
232    /// printed was unreadable: the CLI is healthy and this crate's parser is
233    /// not. Worth logging loudly, because the alternative symptom is a
234    /// successful run that mysteriously returns nothing.
235    #[must_use]
236    pub fn looks_like_a_format_change(&self) -> bool {
237        self.exit_code == 0 && self.unparsed > 0 && self.text.trim().is_empty()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    /// The trap `accumulate` exists to avoid. An agent re-sends the whole
246    /// conversation each turn and reports it, mostly as cache reads, so summing
247    /// the context figures counts the same conversation once per turn.
248    ///
249    /// Numbers from two real Codex turns on one thread.
250    #[test]
251    fn a_session_total_does_not_count_the_conversation_twice() {
252        let turn1 = Usage {
253            input_tokens: Some(2_286),
254            output_tokens: Some(5),
255            cache_read_tokens: Some(13_056),
256            context_tokens: Some(15_342),
257            ..Usage::default()
258        };
259        let turn2 = Usage {
260            input_tokens: Some(2_543),
261            output_tokens: Some(11),
262            cache_read_tokens: Some(28_160),
263            context_tokens: Some(30_703),
264            ..Usage::default()
265        };
266
267        let mut session = Usage::default();
268        session.accumulate(&turn1);
269        session.accumulate(&turn2);
270
271        // New work each turn, so these add up.
272        assert_eq!(session.input_tokens, Some(4_829));
273        assert_eq!(session.output_tokens, Some(16));
274        // The conversation is one conversation. Summing would claim 46,045.
275        assert_eq!(
276            session.context_tokens,
277            Some(30_703),
278            "context is already cumulative and must not be summed"
279        );
280        assert_eq!(session.cache_read_tokens, Some(28_160));
281    }
282
283    #[test]
284    fn cost_and_duration_accumulate() {
285        let mut session = Usage::default();
286        for _ in 0..3 {
287            session.accumulate(&Usage {
288                cost_usd: Some(0.5),
289                duration_ms: Some(1_000),
290                premium_requests: Some(1),
291                ..Usage::default()
292            });
293        }
294        assert!((session.cost_usd.expect("cost") - 1.5).abs() < 1e-9);
295        assert_eq!(session.duration_ms, Some(3_000));
296        assert_eq!(session.premium_requests, Some(3));
297    }
298
299    /// A field the agent stopped reporting must keep its last known value
300    /// rather than being wiped by a turn that said nothing.
301    #[test]
302    fn a_silent_turn_does_not_erase_what_is_known() {
303        let mut session = Usage {
304            context_window: Some(200_000),
305            cost_usd: Some(1.0),
306            ..Usage::default()
307        };
308        session.accumulate(&Usage::default());
309        assert_eq!(session.context_window, Some(200_000));
310        assert_eq!(session.cost_usd, Some(1.0));
311    }
312
313    #[test]
314    fn context_used_is_a_ratio_and_absent_without_both_halves() {
315        let full = Usage {
316            context_tokens: Some(27_645),
317            context_window: Some(200_000),
318            ..Usage::default()
319        };
320        let share = full.context_used().expect("both halves present");
321        assert!((share - 0.138_225).abs() < 1e-6, "{share}");
322
323        // Codex reports tokens but no window, so a share is not knowable.
324        let partial = Usage {
325            context_tokens: Some(30_703),
326            ..Usage::default()
327        };
328        assert_eq!(partial.context_used(), None);
329    }
330}