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 /// Non-cached input tokens.
17 pub input_tokens: Option<u64>,
18 /// Generated tokens.
19 pub output_tokens: Option<u64>,
20 /// Input tokens served from the prompt cache.
21 pub cache_read_tokens: Option<u64>,
22 /// Input tokens written into the prompt cache.
23 pub cache_write_tokens: Option<u64>,
24 /// Cost in USD, when the agent priced the run itself. Never inferred from a
25 /// local price table, because a guessed cost is worse than no cost.
26 pub cost_usd: Option<f64>,
27 /// Copilot's premium-request count, its only usage unit.
28 pub premium_requests: Option<u64>,
29}
30
31impl Usage {
32 /// Whether the agent reported anything at all.
33 #[must_use]
34 pub fn is_empty(&self) -> bool {
35 *self == Usage::default()
36 }
37}
38
39/// A quota signal the agent emitted mid-run.
40///
41/// Surfaced rather than acted on: this crate reports what the provider said and
42/// leaves backing off to the caller. See `docs/operating-limits.md`.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[non_exhaustive]
45pub struct RateLimit {
46 /// The provider's status word, e.g. `allowed`, `rejected`.
47 pub status: String,
48 /// Which window this refers to, e.g. `five_hour`.
49 pub window: Option<String>,
50 /// Unix epoch seconds at which the window resets.
51 pub resets_at: Option<i64>,
52}
53
54impl RateLimit {
55 /// Whether this signal means the request was actually refused, as opposed
56 /// to an informational "still allowed" heartbeat.
57 #[must_use]
58 pub fn is_blocking(&self) -> bool {
59 !self.status.eq_ignore_ascii_case("allowed")
60 }
61}
62
63/// Why the agent stopped.
64#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum Stop {
68 /// Completed normally.
69 #[default]
70 Completed,
71 /// The agent reported an error result.
72 Error,
73 /// The agent stopped for a reason it named but this crate does not model.
74 Other(String),
75}
76
77/// The result of one completed run.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[non_exhaustive]
80pub struct Outcome {
81 /// Which agent produced it.
82 pub agent: Agent,
83 /// The native session id, when the run had or produced one. This is the
84 /// handle a later turn resumes with.
85 pub session: Option<String>,
86 /// The assistant's final text.
87 pub text: String,
88 /// Token and cost accounting.
89 pub usage: Usage,
90 /// Why it stopped.
91 pub stop: Stop,
92 /// The last quota signal seen, if any.
93 pub rate_limit: Option<RateLimit>,
94 /// The process exit code.
95 pub exit_code: i32,
96 /// Raw stderr, kept for diagnostics and capped at
97 /// [`crate::MAX_CAPTURE`].
98 pub stderr: String,
99 /// How many output lines could not be parsed, with the first as a sample.
100 ///
101 /// Agents interleave banners with their JSON, so a non-zero count is not
102 /// automatically a fault. It becomes one when paired with an empty [`Self::text`],
103 /// which is what a vendor changing its output shape looks like from here.
104 /// See [`Outcome::looks_like_a_format_change`].
105 pub unparsed: usize,
106 /// The first line that failed to parse.
107 pub first_unparsed: Option<String>,
108}
109
110impl Outcome {
111 /// Whether the run finished cleanly: a zero exit and no error result.
112 #[must_use]
113 pub fn is_ok(&self) -> bool {
114 self.exit_code == 0 && self.stop == Stop::Completed
115 }
116
117 /// Whether this run looks like the agent changed its output format.
118 ///
119 /// The signature is a process that exited successfully while every line it
120 /// printed was unreadable: the CLI is healthy and this crate's parser is
121 /// not. Worth logging loudly, because the alternative symptom is a
122 /// successful run that mysteriously returns nothing.
123 #[must_use]
124 pub fn looks_like_a_format_change(&self) -> bool {
125 self.exit_code == 0 && self.unparsed > 0 && self.text.trim().is_empty()
126 }
127}