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}