Skip to main content

agent_abstraction/
error.rs

1//! The one error type every fallible call in this crate returns.
2//!
3//! Each variant is a case a caller has to branch on differently. A GUI shows
4//! [`Error::NotInstalled`] as an install prompt, [`Error::RateLimited`] as
5//! "wait", and [`Error::Unsupported`] as a programming mistake. Failures that
6//! need no branch collapse into [`Error::Spawn`] / [`Error::Store`].
7
8use std::time::Duration;
9
10use crate::agent::Agent;
11
12/// Result alias for this crate.
13pub type Result<T> = std::result::Result<T, Error>;
14
15/// Everything that can go wrong driving an agent CLI.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum Error {
19    /// The agent's binary is not on `PATH`. Carries the install command so a UI
20    /// can offer it directly instead of making the user go find it.
21    #[error("`{bin}` not found on PATH; install it: {hint}")]
22    NotInstalled {
23        /// The agent whose binary is missing.
24        agent: Agent,
25        /// The binary name that was looked up.
26        bin: String,
27        /// The documented install command.
28        hint: &'static str,
29    },
30
31    /// The child process could not be started, or its stdio could not be read.
32    #[error("failed to spawn `{bin}`: {source}")]
33    Spawn {
34        /// The binary that failed to start.
35        bin: String,
36        /// The underlying OS error.
37        #[source]
38        source: std::io::Error,
39    },
40
41    /// The run exceeded its deadline and the child was killed. Any output
42    /// captured before the kill is preserved so a caller can still show it.
43    #[error("`{bin}` exceeded its {} s timeout and was killed", timeout.as_secs())]
44    Timeout {
45        /// The binary that overran.
46        bin: String,
47        /// The deadline that was hit.
48        timeout: Duration,
49        /// Whatever the agent had printed before it was killed.
50        partial: String,
51    },
52
53    /// The agent ran to completion but exited non-zero.
54    #[error("`{bin}` exited with status {code}: {stderr}")]
55    Failed {
56        /// The binary that failed.
57        bin: String,
58        /// Its exit code, or `-1` when it died to a signal.
59        code: i32,
60        /// Its stderr, trimmed, for the message.
61        stderr: String,
62    },
63
64    /// The provider refused the request for quota reasons: a usage limit, a
65    /// rate limit, or an exhausted budget.
66    ///
67    /// This is deliberately its own variant and this crate never retries it
68    /// automatically. Backing off is the caller's decision and burying a
69    /// retry loop in here would turn a limit the provider set into something
70    /// the library quietly works around. See `docs/operating-limits.md`.
71    #[error("`{bin}` was rate limited or hit a usage limit: {message}")]
72    RateLimited {
73        /// The binary that was limited.
74        bin: String,
75        /// The provider's own wording, passed through unedited.
76        message: String,
77    },
78
79    /// The request asked an agent for something it cannot do headlessly:
80    /// forking on Codex, a named session on Copilot, an event stream on an
81    /// agent that only prints text.
82    ///
83    /// Always an error, never a silent downgrade: a caller that asked to fork
84    /// and got a linear resume would corrupt the conversation it meant to
85    /// branch.
86    #[error("{agent} does not support {what}")]
87    Unsupported {
88        /// The agent that was asked.
89        agent: Agent,
90        /// The capability it lacks.
91        what: &'static str,
92    },
93
94    /// A named session already belongs to a different agent. Sessions cannot
95    /// migrate: the stored handle is only meaningful to the CLI that minted it.
96    #[error("session `{name}` belongs to {bound}, cannot resume it on {requested}")]
97    SessionConflict {
98        /// The caller's session name.
99        name: String,
100        /// The agent that created the session.
101        bound: Agent,
102        /// The agent the caller tried to use.
103        requested: Agent,
104    },
105
106    /// The session store could not be read or written.
107    #[error("session store I/O failed at {path}: {source}")]
108    Store {
109        /// The file or directory involved.
110        path: String,
111        /// The underlying OS error.
112        #[source]
113        source: std::io::Error,
114    },
115
116    /// The agent produced output this crate could not interpret: a missing
117    /// session id under a format that promises one, or unparseable JSON where
118    /// the contract requires it.
119    #[error("could not parse {agent} output: {detail}")]
120    Parse {
121        /// The agent whose output was unreadable.
122        agent: Agent,
123        /// What specifically was wrong.
124        detail: String,
125    },
126
127    /// The agent has no usable credentials.
128    ///
129    /// Its own category because the remedy is a specific human action rather
130    /// than anything about the request, and because it is easy to reach by
131    /// accident: [`crate::EnvPolicy::Minimal`] withholds the environment by
132    /// default, so a credential this crate does not know to pass through
133    /// presents as a login failure rather than a configuration one.
134    #[error("`{bin}` is not authenticated: {message}. To fix: {hint}")]
135    NotAuthenticated {
136        /// The agent that refused.
137        agent: Agent,
138        /// The binary that refused.
139        bin: String,
140        /// The provider's own wording, unedited.
141        message: String,
142        /// The command that resolves it.
143        hint: &'static str,
144    },
145
146    /// The CLI rejected an argument this crate passed it.
147    ///
148    /// Almost always a version mismatch: the flag was verified against the
149    /// release named in [`crate::Agent::verified_version`] and the installed
150    /// one differs. Separated from [`Error::Failed`] because the remedy is
151    /// different: nothing about the request is wrong, the wrapper and the CLI
152    /// disagree. Run [`crate::Probe`] to confirm.
153    #[error(
154        "`{bin}` rejected an argument, which usually means its version differs from the one these flags were verified against: {detail}"
155    )]
156    FlagRejected {
157        /// The binary that refused.
158        bin: String,
159        /// Its own complaint, unedited.
160        detail: String,
161    },
162
163    /// The run was stopped by [`crate::Run::cancel`] or by dropping its handle.
164    ///
165    /// Not a fault: the caller asked for this. Distinguished from
166    /// [`Error::Interrupted`], which means the driver died unexpectedly, and
167    /// from [`Error::Timeout`], which is a deadline rather than a request.
168    #[error("the run of `{bin}` was cancelled")]
169    Cancelled {
170        /// The binary that was stopped.
171        bin: String,
172    },
173
174    /// A prompt, system prompt or raw argument too large for the command line,
175    /// on an agent with no way to deliver it off the argv.
176    ///
177    /// Returned rather than letting the OS reject the spawn with a bare
178    /// `E2BIG`, which says nothing about which input was the problem.
179    #[error(
180        "{what} is {size} bytes, over the {limit} byte command-line budget for {agent}, \
181         and it has no way to take it off the command line"
182    )]
183    CommandLineTooLarge {
184        /// The agent the request targeted.
185        agent: Agent,
186        /// Which input overflowed.
187        what: &'static str,
188        /// Its size in bytes.
189        size: usize,
190        /// The budget it exceeded.
191        limit: usize,
192    },
193
194    /// [`crate::stream`] was called outside a Tokio runtime.
195    ///
196    /// Spawning the driver task needs a runtime context. Reporting this rather
197    /// than letting `tokio::spawn` panic keeps the fallible signature honest.
198    #[error("no Tokio runtime is running; call this from within one")]
199    NoRuntime,
200
201    /// The task driving the run panicked or was cancelled, so there is no
202    /// outcome to report.
203    ///
204    /// Distinct from [`Error::Spawn`] on purpose: the process started fine, and
205    /// reporting this as a spawn failure would name the wrong cause. It is also
206    /// why this is not squeezed into an [`std::io::Error`], which a dropped
207    /// runtime task is not.
208    #[error("the run of `{bin}` was interrupted: {detail}")]
209    Interrupted {
210        /// The binary that was running.
211        bin: String,
212        /// Whether the task panicked or was cancelled.
213        detail: String,
214    },
215}
216
217impl Error {
218    /// Whether retrying this exact request later could plausibly succeed.
219    ///
220    /// True for quota and timeout failures; false for a missing binary, an
221    /// unsupported capability, or a session conflict, which need the caller to
222    /// change something first. This classifies; it does not retry.
223    #[must_use]
224    pub fn is_transient(&self) -> bool {
225        matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
226    }
227
228    /// Whether this run was stopped because the caller asked, rather than
229    /// because anything went wrong. A UI should not show it as a failure.
230    #[must_use]
231    pub fn is_cancelled(&self) -> bool {
232        matches!(self, Error::Cancelled { .. })
233    }
234
235    /// Whether this failed because the agent has no usable credentials.
236    ///
237    /// Worth branching on in a UI: unlike most failures, the user can fix it,
238    /// and [`Error::NotAuthenticated`] carries the command that does.
239    #[must_use]
240    pub fn is_auth_failure(&self) -> bool {
241        matches!(self, Error::NotAuthenticated { .. })
242    }
243}