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}