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 run was stopped by [`crate::Run::cancel`] or by dropping its handle.
128 ///
129 /// Not a fault: the caller asked for this. Distinguished from
130 /// [`Error::Interrupted`], which means the driver died unexpectedly, and
131 /// from [`Error::Timeout`], which is a deadline rather than a request.
132 #[error("the run of `{bin}` was cancelled")]
133 Cancelled {
134 /// The binary that was stopped.
135 bin: String,
136 },
137
138 /// A prompt, system prompt or raw argument too large for the command line,
139 /// on an agent with no way to deliver it off the argv.
140 ///
141 /// Returned rather than letting the OS reject the spawn with a bare
142 /// `E2BIG`, which says nothing about which input was the problem.
143 #[error(
144 "{what} is {size} bytes, over the {limit} byte command-line budget for {agent}, \
145 and it has no way to take it off the command line"
146 )]
147 CommandLineTooLarge {
148 /// The agent the request targeted.
149 agent: Agent,
150 /// Which input overflowed.
151 what: &'static str,
152 /// Its size in bytes.
153 size: usize,
154 /// The budget it exceeded.
155 limit: usize,
156 },
157
158 /// [`crate::stream`] was called outside a Tokio runtime.
159 ///
160 /// Spawning the driver task needs a runtime context. Reporting this rather
161 /// than letting `tokio::spawn` panic keeps the fallible signature honest.
162 #[error("no Tokio runtime is running; call this from within one")]
163 NoRuntime,
164
165 /// The task driving the run panicked or was cancelled, so there is no
166 /// outcome to report.
167 ///
168 /// Distinct from [`Error::Spawn`] on purpose: the process started fine, and
169 /// reporting this as a spawn failure would name the wrong cause. It is also
170 /// why this is not squeezed into an [`std::io::Error`], which a dropped
171 /// runtime task is not.
172 #[error("the run of `{bin}` was interrupted: {detail}")]
173 Interrupted {
174 /// The binary that was running.
175 bin: String,
176 /// Whether the task panicked or was cancelled.
177 detail: String,
178 },
179}
180
181impl Error {
182 /// Whether retrying this exact request later could plausibly succeed.
183 ///
184 /// True for quota and timeout failures; false for a missing binary, an
185 /// unsupported capability, or a session conflict, which need the caller to
186 /// change something first. This classifies; it does not retry.
187 #[must_use]
188 pub fn is_transient(&self) -> bool {
189 matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
190 }
191
192 /// Whether this run was stopped because the caller asked, rather than
193 /// because anything went wrong. A UI should not show it as a failure.
194 #[must_use]
195 pub fn is_cancelled(&self) -> bool {
196 matches!(self, Error::Cancelled { .. })
197 }
198}