aion_integrations/error.rs
1//! The harness-neutral error taxonomy for the integration seam.
2//!
3//! [`HarnessError`] is the single error type every [`crate::AgentHarness`] /
4//! [`crate::AgentSession`] method returns. It is **harness-neutral**: no variant names a
5//! concrete harness, and only the transport/protocol variants reference the notion of a wire
6//! at all (as generic descriptions, never a specific protocol type). An adapter maps its own
7//! failures onto these variants; callers above the adapter branch on the variant alone.
8
9/// The neutral error taxonomy for the harness-integration seam.
10///
11/// Every arm is harness-neutral. [`Self::CapabilityNotSupported`] is the first-class outcome an
12/// observability-only harness returns from [`crate::AgentSession::intervene`] for any command —
13/// it is a legitimate, gated rejection, not an internal failure.
14#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum HarnessError {
17 /// The requested intervention primitive is not in the harness's advertised capability set.
18 ///
19 /// This is the first-class rejection an observability-only harness (empty capability set)
20 /// returns for *every* command, and the rejection any harness returns for a primitive it did
21 /// not advertise. It is a normal, expected outcome of capability gating — not a fault.
22 #[error("capability not supported: {primitive}")]
23 CapabilityNotSupported {
24 /// A neutral label naming the unsupported primitive (e.g. `"pause_resume"`).
25 primitive: String,
26 },
27 /// The command targets a stale or unknown activity attempt and is a no-op.
28 ///
29 /// A command addressed to a superseded attempt (a later attempt is now live) or to a session
30 /// that has already reached its terminal result is dropped without effect.
31 #[error("stale target: {detail}")]
32 StaleTarget {
33 /// Human-readable detail describing why the target is stale.
34 detail: String,
35 },
36 /// The underlying transport failed (spawn/connect failure, broken pipe, EOF, I/O error).
37 ///
38 /// Neutral: it describes *that* the transport failed and carries the detail, never *which*
39 /// transport. An adapter maps its own I/O failures here.
40 #[error("transport error: {detail}")]
41 Transport {
42 /// Human-readable description of the transport failure.
43 detail: String,
44 },
45 /// A message was received that violates the wire protocol contract.
46 ///
47 /// Malformed framing, an undecodable envelope, a response that correlates to no outstanding
48 /// request, or a terminal result delivered on the wrong message kind. This signals a bug in
49 /// the peer or the adapter, distinct from an ordinary transport outage.
50 #[error("protocol error: {detail}")]
51 Protocol {
52 /// Human-readable description of the protocol violation.
53 detail: String,
54 },
55 /// The harness reported an application-level failure while running the agent.
56 ///
57 /// The agent ran but ended in failure (a non-success exit, an error result, a rejected run).
58 /// Distinct from [`Self::Transport`] (the channel broke) and [`Self::Protocol`] (a malformed
59 /// message): here the channel and framing were sound and the harness *reported* failure.
60 #[error("harness reported failure: {detail}")]
61 Harness {
62 /// Human-readable description of the reported failure.
63 detail: String,
64 },
65 /// The harness cannot be launched as CONFIGURED, so no run was started.
66 ///
67 /// A DETERMINISTIC refusal, and its own variant for the same reason
68 /// [`Self::Contract`] is: the channel never opened, so [`Self::Transport`] is wrong
69 /// and would tell an operator a story about a flaky pipe; no frame was exchanged, so
70 /// [`Self::Protocol`] is wrong; and nothing ran, so [`Self::Harness`] is wrong. What
71 /// is wrong is a value somebody WROTE — an environment pass-through entry that is a
72 /// `KEY=VALUE` pair instead of a name, a declaration that omits the variable the
73 /// program is looked up on. The next attempt reads the same document and meets the
74 /// same wall, so retrying spends a worker's attempt budget to learn nothing. The
75 /// worker maps this variant to a TERMINAL activity failure.
76 #[error("harness configuration refusal: {detail}")]
77 Configuration {
78 /// Human-readable description naming the setting and what is wrong with it.
79 detail: String,
80 },
81 /// The run completed, but its native outcome cannot satisfy the canonical
82 /// agent-outcome contract (`AgentOutcome { text, stop_reason }`).
83 ///
84 /// A DETERMINISTIC refusal, and that is the whole reason it is its own variant: the channel
85 /// was sound ([`Self::Transport`] is wrong), the frames were well-formed ([`Self::Protocol`]
86 /// is wrong — and a protocol fault CAN be a transient peer flake, which this never is), and
87 /// the run did not fail ([`Self::Harness`] is wrong). The run's *configuration* produces an
88 /// outcome the seam excludes — e.g. a structured output where the contract's `text` demands a
89 /// String — so re-running it re-spends a whole agent run to hit the same wall. The worker
90 /// maps this variant to a TERMINAL activity failure; every other variant stays retryable.
91 #[error("agent-outcome contract refusal: {detail}")]
92 Contract {
93 /// Human-readable description naming the contract and what was found.
94 detail: String,
95 },
96}
97
98impl HarnessError {
99 /// Builds a [`Self::CapabilityNotSupported`] naming the unsupported primitive.
100 #[must_use]
101 pub fn capability_not_supported(primitive: impl Into<String>) -> Self {
102 Self::CapabilityNotSupported {
103 primitive: primitive.into(),
104 }
105 }
106
107 /// Builds a [`Self::StaleTarget`] with a detail message.
108 #[must_use]
109 pub fn stale_target(detail: impl Into<String>) -> Self {
110 Self::StaleTarget {
111 detail: detail.into(),
112 }
113 }
114
115 /// Builds a [`Self::Transport`] with a detail message.
116 #[must_use]
117 pub fn transport(detail: impl Into<String>) -> Self {
118 Self::Transport {
119 detail: detail.into(),
120 }
121 }
122
123 /// Builds a [`Self::Protocol`] with a detail message.
124 #[must_use]
125 pub fn protocol(detail: impl Into<String>) -> Self {
126 Self::Protocol {
127 detail: detail.into(),
128 }
129 }
130
131 /// Builds a [`Self::Harness`] with a detail message.
132 #[must_use]
133 pub fn harness(detail: impl Into<String>) -> Self {
134 Self::Harness {
135 detail: detail.into(),
136 }
137 }
138
139 /// Builds a [`Self::Contract`] with a detail message naming the canonical
140 /// agent-outcome contract and what was found instead.
141 #[must_use]
142 pub fn contract(detail: impl Into<String>) -> Self {
143 Self::Contract {
144 detail: detail.into(),
145 }
146 }
147
148 /// Builds a [`Self::Configuration`] with a detail message naming the setting that
149 /// makes the harness unlaunchable.
150 #[must_use]
151 pub fn configuration(detail: impl Into<String>) -> Self {
152 Self::Configuration {
153 detail: detail.into(),
154 }
155 }
156
157 /// Whether this error is DETERMINISTIC — a property of how the run is
158 /// configured, so retrying re-spends a whole agent run to hit the same
159 /// wall — as opposed to potentially transient (a provider-overload burst,
160 /// a one-off malformed frame, a dropped pipe, a superseded attempt).
161 ///
162 /// This is THE retry-classification decision for the seam, made here in
163 /// the defining crate with an EXHAUSTIVE match — legal despite
164 /// `#[non_exhaustive]` — so adding a variant is a compile error at this
165 /// site and its classification is decided on purpose, never defaulted by
166 /// a caller's wildcard arm. The worker maps `true` to a terminal activity
167 /// failure and `false` to a retryable one.
168 ///
169 /// Per variant:
170 /// - [`Self::Contract`]: deterministic by definition — the run completed
171 /// and its configured outcome shape cannot satisfy the agent-outcome
172 /// contract; the next attempt is configured identically.
173 /// - [`Self::Configuration`]: deterministic by definition — the launch was
174 /// refused by a value in the document, and the next attempt reads the
175 /// same document. A refusal that presented as a transient transport
176 /// failure would tell the operator the wrong story AND spend the whole
177 /// attempt budget confirming it.
178 /// - [`Self::Transport`]: a broken channel can heal.
179 /// - [`Self::Protocol`]: a malformed frame CAN be a one-off peer flake
180 /// (truncated stream, interleaved write), so it stays retryable even
181 /// though some protocol faults are in fact permanent.
182 /// - [`Self::Harness`]: the run failed; overload and timeouts recur or
183 /// do not — that judgement belongs to the retry policy.
184 /// - [`Self::CapabilityNotSupported`] / [`Self::StaleTarget`]: gating and
185 /// staleness outcomes on the intervention path; when they surface from
186 /// a result path at all they describe a racing world, not a fixed one.
187 #[must_use]
188 pub fn is_deterministic(&self) -> bool {
189 match self {
190 Self::Contract { .. } | Self::Configuration { .. } => true,
191 Self::CapabilityNotSupported { .. }
192 | Self::StaleTarget { .. }
193 | Self::Transport { .. }
194 | Self::Protocol { .. }
195 | Self::Harness { .. } => false,
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::HarnessError;
203
204 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
205
206 #[test]
207 fn harness_error_is_send_sync_static() {
208 assert_send_sync_static::<HarnessError>();
209 }
210
211 #[test]
212 fn capability_not_supported_names_the_primitive() {
213 let error = HarnessError::capability_not_supported("pause_resume");
214 assert_eq!(error.to_string(), "capability not supported: pause_resume");
215 assert!(matches!(error, HarnessError::CapabilityNotSupported { .. }));
216 }
217
218 #[test]
219 fn each_constructor_renders_its_class() {
220 assert_eq!(
221 HarnessError::stale_target("attempt 2 superseded").to_string(),
222 "stale target: attempt 2 superseded"
223 );
224 assert_eq!(
225 HarnessError::transport("broken pipe").to_string(),
226 "transport error: broken pipe"
227 );
228 assert_eq!(
229 HarnessError::protocol("no matching id").to_string(),
230 "protocol error: no matching id"
231 );
232 assert_eq!(
233 HarnessError::harness("exit code 1").to_string(),
234 "harness reported failure: exit code 1"
235 );
236 assert_eq!(
237 HarnessError::contract("output is a JSON object").to_string(),
238 "agent-outcome contract refusal: output is a JSON object"
239 );
240 }
241
242 /// The retry-classification decision, pinned in the crate that makes it:
243 /// exactly the contract refusal is deterministic; every class that can be
244 /// transient stays non-deterministic. (The match inside `is_deterministic`
245 /// is exhaustive, so a new variant fails compilation there — this test
246 /// pins the ANSWERS, the compiler pins the completeness.)
247 #[test]
248 fn only_the_contract_refusal_is_deterministic() {
249 assert!(HarnessError::contract("output is a JSON object").is_deterministic());
250 for transient in [
251 HarnessError::transport("broken pipe"),
252 HarnessError::protocol("invalid JSON frame"),
253 HarnessError::harness("run stopped without completing"),
254 HarnessError::stale_target("attempt 2 superseded"),
255 HarnessError::capability_not_supported("pause_resume"),
256 ] {
257 assert!(
258 !transient.is_deterministic(),
259 "{transient:?} can be transient and must not classify deterministic"
260 );
261 }
262 }
263}