Skip to main content

supercode_harness/
error.rs

1use thiserror::Error;
2
3/// Result alias used throughout the crate.
4pub type Result<T> = std::result::Result<T, Error>;
5
6/// Errors that can arise while configuring or running an [`crate::Agent`].
7///
8/// `#[non_exhaustive]` so new variants can be added without a breaking release;
9/// match with a `_` arm.
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum Error {
13    /// A native session interchange operation failed.
14    #[error(transparent)]
15    Interchange(#[from] supercode_interchange::InterchangeError),
16
17    /// No API key was provided and none could be found in the environment.
18    #[error("missing API key: set it on the Config or via the {0} environment variable")]
19    MissingApiKey(String),
20
21    /// The HTTP transport failed. The underlying error is kept as an opaque
22    /// source rather than exposing the `reqwest` type, so a transport-library
23    /// bump is not a breaking change for this crate's public API.
24    #[error("http transport error: {0}")]
25    Http(#[source] Box<dyn std::error::Error + Send + Sync>),
26
27    /// The provider returned a non-success status.
28    #[error("provider returned status {status}: {body}")]
29    Provider {
30        /// HTTP status code.
31        status: u16,
32        /// Raw response body (truncated upstream if large).
33        body: String,
34    },
35
36    /// A response body could not be parsed.
37    #[error("failed to decode provider response: {0}")]
38    Decode(#[from] serde_json::Error),
39
40    /// A persisted session artifact failed its own framing/schema contract.
41    #[error("invalid session artifact: {0}")]
42    InvalidSession(String),
43
44    /// A versioned SDK/runtime operation failed. Runtime adapters retain this
45    /// typed value so outer SDK surfaces preserve its stable error name.
46    #[error(transparent)]
47    Sdk(#[from] crate::sdk::SdkError),
48
49    /// The model asked for a tool that isn't registered.
50    ///
51    /// Constructed by the agent loop's `run_tool` dispatch and fed back to the
52    /// model as this variant's `Display` rendering, so it is load-bearing on
53    /// the real tool-call path, not just a documented-but-unused variant.
54    #[error("model requested unknown tool: {0}")]
55    UnknownTool(String),
56
57    /// A tool's input arguments were not valid for its schema.
58    ///
59    /// Constructed both by built-in tools' argument parsing (`parse_args`) and
60    /// by the agent loop's `run_tool` when the model's raw argument JSON fails
61    /// to parse; either way its `Display` rendering is what the model sees.
62    #[error("invalid arguments for tool `{tool}`: {message}")]
63    InvalidArguments {
64        /// The tool that was called.
65        tool: String,
66        /// What was wrong with the arguments.
67        message: String,
68    },
69
70    /// A tool failed while executing.
71    #[error("tool `{tool}` failed: {message}")]
72    Tool {
73        /// The tool that failed.
74        tool: String,
75        /// Failure detail.
76        message: String,
77    },
78
79    /// The agent loop exceeded its configured iteration budget.
80    #[error("agent exceeded the maximum of {0} reasoning/tool iterations without finishing")]
81    MaxIterations(usize),
82
83    /// BP-7 (catalog §4a "Turn/budget caps"): `core.max_budget_usd` was
84    /// already exhausted before this `send` could issue a request. Raised
85    /// at the top of `crate::Agent::run_loop`; a budget reached MID-loop
86    /// instead ends that loop cleanly with a `spend_budget` finish marker,
87    /// exactly like the output-token cap.
88    #[error("spend budget exhausted: ${spent_usd:.4} of ${budget_usd:.4} used")]
89    BudgetExhausted {
90        /// Dollars this agent has spent so far.
91        spent_usd: f64,
92        /// The configured cap.
93        budget_usd: f64,
94    },
95
96    /// BP-7: `core.max_budget_usd` was armed against a model this build
97    /// cannot price. Refused at construction rather than accepted and
98    /// silently ignored — see [`crate::Config::max_budget_usd`].
99    #[error(
100        "core.max_budget_usd is set but model `{model}` has no known price;          set core.price_input_per_mtok and core.price_output_per_mtok"
101    )]
102    UnpriceableBudget {
103        /// The model with no resolvable price.
104        model: String,
105    },
106
107    /// An I/O operation failed.
108    #[error("io error: {0}")]
109    Io(#[from] std::io::Error),
110
111    /// PARITY-18 D4 — a live request would exceed the target model's
112    /// context window even after [`crate::tokens::context_guard`]'s safety
113    /// margin and completion reserve are applied. Raised by
114    /// `crate::Agent::run_loop`'s per-send guard, which runs before EVERY
115    /// request this agent issues (not only the first) once
116    /// [`crate::Agent::set_context_limit`] has armed it — so an
117    /// over-context request is refused at any point in a session, not just
118    /// at the CLI's one-shot preflight.
119    ///
120    /// PARITY-18 v3 — `projected_tokens` is [`crate::tokens::context_guard`]'s
121    /// margin-adjusted estimate of messages+tools ONLY; it does NOT include
122    /// the completion reserve, so the refusal condition is actually
123    /// `projected_tokens + reserve_tokens > context_limit`, not
124    /// `projected_tokens > context_limit` — printing the bare comparison
125    /// (v2's wording) was arithmetically false as written (e.g. "projected
126    /// 193,064 > limit 200,000" reads as passing when the refusal is only
127    /// true once the reserve is added). `reserve_tokens` is carried on the
128    /// error so the `Display` impl states the true inequality.
129    #[error(
130        "cannot reduce below context limit: projected {projected_tokens} tokens + {reserve_tokens} reserve exceeds model {model} limit {context_limit}"
131    )]
132    ContextLimitExceeded {
133        /// Margin-adjusted projected token count for the request that was
134        /// about to be sent (messages + tools only; excludes the completion
135        /// reserve — see `reserve_tokens`).
136        projected_tokens: u64,
137        /// The completion-token reserve
138        /// ([`crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS`]) added to
139        /// `projected_tokens` to derive the true refusal condition:
140        /// `projected_tokens + reserve_tokens > context_limit`.
141        reserve_tokens: u64,
142        /// The target model's context-window size.
143        context_limit: u64,
144        /// The model slug this limit was resolved for.
145        model: String,
146    },
147
148    /// P5-3 (§2 module 9 `subagents`, §5.3-style resource bound): a
149    /// `spawn_subagent` call was refused because it would exceed
150    /// `capabilities.subagents.max_depth` — the fail-closed depth cap that
151    /// keeps a parent-spawning-children-spawning-children chain from
152    /// growing unbounded. Named so the model (and a test) can tell this
153    /// apart from every other tool-error shape.
154    #[error(
155        "subagent spawn refused: depth {attempted_depth} would exceed \
156         capabilities.subagents.max_depth={max_depth}"
157    )]
158    SubagentDepthExceeded {
159        /// The configured cap.
160        max_depth: usize,
161        /// The depth the new child would have been spawned at.
162        attempted_depth: usize,
163    },
164
165    /// P5-3 (§2 module 9, §5.3-style resource bound): a `spawn_subagent`
166    /// call was refused because `capabilities.subagents.max_concurrent`
167    /// subagents are already in flight ANYWHERE in this spawn tree (the
168    /// concurrency gauge is shared root-to-leaf) — the fail-closed
169    /// fork-bomb guard.
170    #[error(
171        "subagent spawn refused: {max_concurrent} subagent(s) already running \
172         (capabilities.subagents.max_concurrent={max_concurrent})"
173    )]
174    SubagentConcurrencyExceeded {
175        /// The configured cap.
176        max_concurrent: usize,
177    },
178
179    /// P5-3 (§2.2 C6): a `background: true` spawn was refused because no
180    /// `capabilities.subagents.background_prompts` auto-policy
181    /// (`"auto_policy"` or `"parent"`) is configured — a detached child
182    /// cannot prompt interactively, so this is enforced fail-closed at
183    /// spawn time, defensively re-checking what
184    /// `crate::configfile::validate_modules`'s C6 resolver rule already
185    /// requires at config-resolve time (belt-and-suspenders for a `Config`
186    /// hand-built via [`crate::ConfigBuilder`] that bypassed the resolver).
187    #[error(
188        "subagent spawn refused: background=true requires \
189         capabilities.subagents.background_prompts = \"auto_policy\" or \"parent\" (§2.2 C6) \
190         — none is configured"
191    )]
192    SubagentBackgroundPolicyMissing,
193
194    /// P5-3: `spawn_subagent`'s `agent_type` named an agent definition not
195    /// present in `capabilities.subagents.agents`.
196    #[error("unknown subagent agent_type `{0}` — not defined in capabilities.subagents.agents")]
197    SubagentDefinitionNotFound(String),
198
199    /// P5-3: `subagent_status` (or an internal join) named a subagent id
200    /// this agent never spawned (or one already reaped).
201    #[error("unknown subagent id `{0}`")]
202    SubagentNotFound(String),
203
204    /// P5-6 (§2 module 4 `tools.background`, resource bound): a
205    /// `background_exec` call was refused because
206    /// `capabilities.tools_background.max_concurrent` background jobs are
207    /// already running for this agent — fail-closed, mirroring
208    /// [`Error::SubagentConcurrencyExceeded`]'s cap treatment (§2 module
209    /// 9).
210    #[error(
211        "background exec refused: {max_concurrent} background job(s) already running \
212         (capabilities.tools_background.max_concurrent={max_concurrent})"
213    )]
214    BackgroundJobConcurrencyExceeded {
215        /// The configured cap.
216        max_concurrent: usize,
217    },
218
219    /// P5-6: `background_status`/`background_kill` named a job id this
220    /// agent never spawned (or one already reaped after finishing).
221    #[error("unknown background job id `{0}`")]
222    BackgroundJobNotFound(String),
223
224    /// A reversible reduction invariant or sidecar pointer check failed.
225    #[error(transparent)]
226    Reduction(#[from] supercode_reduce::ReductionError),
227
228    /// Catch-all for everything else.
229    #[error("{0}")]
230    Other(String),
231}
232
233impl Error {
234    /// Convenience constructor for a tool failure.
235    pub fn tool(tool: impl Into<String>, message: impl Into<String>) -> Self {
236        Error::Tool {
237            tool: tool.into(),
238            message: message.into(),
239        }
240    }
241}
242
243// Kept as a manual conversion (not `#[from]`) so the `reqwest` type stays out
244// of the public API surface — see `Error::Http`.
245impl From<reqwest::Error> for Error {
246    fn from(e: reqwest::Error) -> Self {
247        Error::Http(Box::new(e))
248    }
249}