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    /// 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}