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 /// An interactive transport accepted a control request locally but never
54 /// acknowledged it. The agent process may still be alive, but the caller
55 /// must stop that run before retrying the input on a resumed session.
56 #[error(
57 "`{bin}` did not acknowledge interactive input within {} s; its transport may be stalled",
58 timeout.as_secs()
59 )]
60 ControlTimeout {
61 /// The agent binary whose transport stopped responding.
62 bin: String,
63 /// How long the delivery receipt was allowed to take.
64 timeout: Duration,
65 },
66
67 /// The agent ran to completion but exited non-zero.
68 #[error("`{bin}` exited with status {code}: {stderr}")]
69 Failed {
70 /// The binary that failed.
71 bin: String,
72 /// Its exit code, or `-1` when it died to a signal.
73 code: i32,
74 /// Its stderr, trimmed, for the message.
75 stderr: String,
76 },
77
78 /// The provider refused the request for quota reasons: a usage limit, a
79 /// rate limit, or an exhausted budget.
80 ///
81 /// This is deliberately its own variant and this crate never retries it
82 /// automatically. Backing off is the caller's decision and burying a
83 /// retry loop in here would turn a limit the provider set into something
84 /// the library quietly works around. See `docs/operating-limits.md`.
85 #[error("`{bin}` was rate limited or hit a usage limit: {message}")]
86 RateLimited {
87 /// The binary that was limited.
88 bin: String,
89 /// The provider's own wording, passed through unedited.
90 message: String,
91 },
92
93 /// The request asked an agent for something it cannot do headlessly:
94 /// forking on Codex, a named session on Copilot, an event stream on an
95 /// agent that only prints text.
96 ///
97 /// Always an error, never a silent downgrade: a caller that asked to fork
98 /// and got a linear resume would corrupt the conversation it meant to
99 /// branch.
100 #[error("{agent} does not support {what}")]
101 Unsupported {
102 /// The agent that was asked.
103 agent: Agent,
104 /// The capability it lacks.
105 what: &'static str,
106 },
107
108 /// A named session already belongs to a different agent. Sessions cannot
109 /// migrate: the stored handle is only meaningful to the CLI that minted it.
110 #[error("session `{name}` belongs to {bound}, cannot resume it on {requested}")]
111 SessionConflict {
112 /// The caller's session name.
113 name: String,
114 /// The agent that created the session.
115 bound: Agent,
116 /// The agent the caller tried to use.
117 requested: Agent,
118 },
119
120 /// Another run currently owns the same named session.
121 ///
122 /// The lease is cross-process and ends when that run settles or its process
123 /// dies. Retrying later is safe; running both would fork the provider's
124 /// conversation and leave the store pointing at whichever finished last.
125 #[error("session `{name}` for project `{project}` already has a run in progress")]
126 SessionBusy {
127 /// The caller-owned session name.
128 name: String,
129 /// The project namespace containing it.
130 project: String,
131 },
132
133 /// The session store could not be read or written.
134 #[error("session store I/O failed at {path}: {source}")]
135 Store {
136 /// The file or directory involved.
137 path: String,
138 /// The underlying OS error.
139 #[source]
140 source: std::io::Error,
141 },
142
143 /// The agent produced output this crate could not interpret: a missing
144 /// session id under a format that promises one, or unparseable JSON where
145 /// the contract requires it.
146 #[error("could not parse {agent} output: {detail}")]
147 Parse {
148 /// The agent whose output was unreadable.
149 agent: Agent,
150 /// What specifically was wrong.
151 detail: String,
152 },
153
154 /// The agent has no usable credentials.
155 ///
156 /// Its own category because the remedy is a specific human action rather
157 /// than anything about the request, and because it is easy to reach by
158 /// accident: [`crate::EnvPolicy::Minimal`] withholds the environment by
159 /// default, so a credential this crate does not know to pass through
160 /// presents as a login failure rather than a configuration one.
161 #[error("`{bin}` is not authenticated: {message}. To fix: {hint}")]
162 NotAuthenticated {
163 /// The agent that refused.
164 agent: Agent,
165 /// The binary that refused.
166 bin: String,
167 /// The provider's own wording, unedited.
168 message: String,
169 /// The command that resolves it.
170 hint: &'static str,
171 },
172
173 /// The agent ran, exited cleanly, and reported that the turn itself failed.
174 ///
175 /// Its own variant because the process succeeding says nothing about the
176 /// turn succeeding. An unknown model, a schema the provider rejects or an
177 /// upstream outage all arrive this way: exit code 0, with the failure
178 /// described in the output. Reporting that as `Ok` hands back an
179 /// [`crate::Outcome`] whose `text` is an error message, which a caller
180 /// checking only `Result::is_ok` will render as the answer.
181 ///
182 /// The same reasoning already applied to [`Error::NotAuthenticated`] and
183 /// [`Error::RateLimited`], which are also reported with a zero exit; this
184 /// covers everything else in that family.
185 #[error("{agent} reported a failed turn{}: {message}", status.map(|s| format!(" (status {s})")).unwrap_or_default())]
186 AgentError {
187 /// The agent that failed.
188 agent: Agent,
189 /// The binary that ran.
190 bin: String,
191 /// The provider's status code, where the agent reported one. A 404 is
192 /// typically an unknown model, a 400 a rejected request.
193 status: Option<u16>,
194 /// The agent's own description, unedited.
195 message: String,
196 },
197
198 /// The CLI rejected an argument this crate passed it.
199 ///
200 /// Almost always a version mismatch: the flag was verified against the
201 /// release named in [`crate::Agent::verified_version`] and the installed
202 /// one differs. Separated from [`Error::Failed`] because the remedy is
203 /// different: nothing about the request is wrong, the wrapper and the CLI
204 /// disagree. Run [`crate::Probe`] to confirm.
205 #[error(
206 "`{bin}` rejected an argument, which usually means its version differs from the one these flags were verified against: {detail}"
207 )]
208 FlagRejected {
209 /// The binary that refused.
210 bin: String,
211 /// Its own complaint, unedited.
212 detail: String,
213 },
214
215 /// The run was stopped by [`crate::Run::cancel`] or by dropping its handle.
216 ///
217 /// Not a fault: the caller asked for this. Distinguished from
218 /// [`Error::Interrupted`], which means the driver died unexpectedly, and
219 /// from [`Error::Timeout`], which is a deadline rather than a request.
220 #[error("the run of `{bin}` was cancelled")]
221 Cancelled {
222 /// The binary that was stopped.
223 bin: String,
224 },
225
226 /// A prompt, system prompt or raw argument too large for the command line,
227 /// on an agent with no way to deliver it off the argv.
228 ///
229 /// Returned rather than letting the OS reject the spawn with a bare
230 /// `E2BIG`, which says nothing about which input was the problem.
231 #[error(
232 "{what} is {size} bytes, over the {limit} byte command-line budget for {agent}, \
233 and it has no way to take it off the command line"
234 )]
235 CommandLineTooLarge {
236 /// The agent the request targeted.
237 agent: Agent,
238 /// Which input overflowed.
239 what: &'static str,
240 /// Its size in bytes.
241 size: usize,
242 /// The budget it exceeded.
243 limit: usize,
244 },
245
246 /// [`crate::stream`] was called outside a Tokio runtime.
247 ///
248 /// Spawning the driver task needs a runtime context. Reporting this rather
249 /// than letting `tokio::spawn` panic keeps the fallible signature honest.
250 #[error("no Tokio runtime is running; call this from within one")]
251 NoRuntime,
252
253 /// The task driving the run panicked or was cancelled, so there is no
254 /// outcome to report.
255 ///
256 /// Distinct from [`Error::Spawn`] on purpose: the process started fine, and
257 /// reporting this as a spawn failure would name the wrong cause. It is also
258 /// why this is not squeezed into an [`std::io::Error`], which a dropped
259 /// runtime task is not.
260 #[error("the run of `{bin}` was interrupted: {detail}")]
261 Interrupted {
262 /// The binary that was running.
263 bin: String,
264 /// Whether the task panicked or was cancelled.
265 detail: String,
266 },
267}
268
269impl Error {
270 /// Whether retrying this exact request later could plausibly succeed.
271 ///
272 /// True for quota and timeout failures, and for a named session whose prior
273 /// run has not settled yet. False for a missing binary, an unsupported
274 /// capability, or an agent mismatch, which need the caller to change
275 /// something first. This classifies; it does not retry. A caller must stop
276 /// the old run before retrying [`Error::ControlTimeout`].
277 #[must_use]
278 pub fn is_transient(&self) -> bool {
279 matches!(
280 self,
281 Error::RateLimited { .. }
282 | Error::Timeout { .. }
283 | Error::ControlTimeout { .. }
284 | Error::SessionBusy { .. }
285 )
286 }
287
288 /// Whether this run was stopped because the caller asked, rather than
289 /// because anything went wrong. A UI should not show it as a failure.
290 #[must_use]
291 pub fn is_cancelled(&self) -> bool {
292 matches!(self, Error::Cancelled { .. })
293 }
294
295 /// Whether this failed because the agent has no usable credentials.
296 ///
297 /// Worth branching on in a UI: unlike most failures, the user can fix it,
298 /// and [`Error::NotAuthenticated`] carries the command that does.
299 #[must_use]
300 pub fn is_auth_failure(&self) -> bool {
301 matches!(self, Error::NotAuthenticated { .. })
302 }
303}