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}