Skip to main content

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 run completed, but its native outcome cannot satisfy the canonical
66    /// agent-outcome contract (`AgentOutcome { text, stop_reason }`).
67    ///
68    /// A DETERMINISTIC refusal, and that is the whole reason it is its own variant: the channel
69    /// was sound ([`Self::Transport`] is wrong), the frames were well-formed ([`Self::Protocol`]
70    /// is wrong — and a protocol fault CAN be a transient peer flake, which this never is), and
71    /// the run did not fail ([`Self::Harness`] is wrong). The run's *configuration* produces an
72    /// outcome the seam excludes — e.g. a structured output where the contract's `text` demands a
73    /// String — so re-running it re-spends a whole agent run to hit the same wall. The worker
74    /// maps this variant to a TERMINAL activity failure; every other variant stays retryable.
75    #[error("agent-outcome contract refusal: {detail}")]
76    Contract {
77        /// Human-readable description naming the contract and what was found.
78        detail: String,
79    },
80}
81
82impl HarnessError {
83    /// Builds a [`Self::CapabilityNotSupported`] naming the unsupported primitive.
84    #[must_use]
85    pub fn capability_not_supported(primitive: impl Into<String>) -> Self {
86        Self::CapabilityNotSupported {
87            primitive: primitive.into(),
88        }
89    }
90
91    /// Builds a [`Self::StaleTarget`] with a detail message.
92    #[must_use]
93    pub fn stale_target(detail: impl Into<String>) -> Self {
94        Self::StaleTarget {
95            detail: detail.into(),
96        }
97    }
98
99    /// Builds a [`Self::Transport`] with a detail message.
100    #[must_use]
101    pub fn transport(detail: impl Into<String>) -> Self {
102        Self::Transport {
103            detail: detail.into(),
104        }
105    }
106
107    /// Builds a [`Self::Protocol`] with a detail message.
108    #[must_use]
109    pub fn protocol(detail: impl Into<String>) -> Self {
110        Self::Protocol {
111            detail: detail.into(),
112        }
113    }
114
115    /// Builds a [`Self::Harness`] with a detail message.
116    #[must_use]
117    pub fn harness(detail: impl Into<String>) -> Self {
118        Self::Harness {
119            detail: detail.into(),
120        }
121    }
122
123    /// Builds a [`Self::Contract`] with a detail message naming the canonical
124    /// agent-outcome contract and what was found instead.
125    #[must_use]
126    pub fn contract(detail: impl Into<String>) -> Self {
127        Self::Contract {
128            detail: detail.into(),
129        }
130    }
131
132    /// Whether this error is DETERMINISTIC — a property of how the run is
133    /// configured, so retrying re-spends a whole agent run to hit the same
134    /// wall — as opposed to potentially transient (a provider-overload burst,
135    /// a one-off malformed frame, a dropped pipe, a superseded attempt).
136    ///
137    /// This is THE retry-classification decision for the seam, made here in
138    /// the defining crate with an EXHAUSTIVE match — legal despite
139    /// `#[non_exhaustive]` — so adding a variant is a compile error at this
140    /// site and its classification is decided on purpose, never defaulted by
141    /// a caller's wildcard arm. The worker maps `true` to a terminal activity
142    /// failure and `false` to a retryable one.
143    ///
144    /// Per variant:
145    /// - [`Self::Contract`]: deterministic by definition — the run completed
146    ///   and its configured outcome shape cannot satisfy the agent-outcome
147    ///   contract; the next attempt is configured identically.
148    /// - [`Self::Transport`]: a broken channel can heal.
149    /// - [`Self::Protocol`]: a malformed frame CAN be a one-off peer flake
150    ///   (truncated stream, interleaved write), so it stays retryable even
151    ///   though some protocol faults are in fact permanent.
152    /// - [`Self::Harness`]: the run failed; overload and timeouts recur or
153    ///   do not — that judgement belongs to the retry policy.
154    /// - [`Self::CapabilityNotSupported`] / [`Self::StaleTarget`]: gating and
155    ///   staleness outcomes on the intervention path; when they surface from
156    ///   a result path at all they describe a racing world, not a fixed one.
157    #[must_use]
158    pub fn is_deterministic(&self) -> bool {
159        match self {
160            Self::Contract { .. } => true,
161            Self::CapabilityNotSupported { .. }
162            | Self::StaleTarget { .. }
163            | Self::Transport { .. }
164            | Self::Protocol { .. }
165            | Self::Harness { .. } => false,
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::HarnessError;
173
174    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
175
176    #[test]
177    fn harness_error_is_send_sync_static() {
178        assert_send_sync_static::<HarnessError>();
179    }
180
181    #[test]
182    fn capability_not_supported_names_the_primitive() {
183        let error = HarnessError::capability_not_supported("pause_resume");
184        assert_eq!(error.to_string(), "capability not supported: pause_resume");
185        assert!(matches!(error, HarnessError::CapabilityNotSupported { .. }));
186    }
187
188    #[test]
189    fn each_constructor_renders_its_class() {
190        assert_eq!(
191            HarnessError::stale_target("attempt 2 superseded").to_string(),
192            "stale target: attempt 2 superseded"
193        );
194        assert_eq!(
195            HarnessError::transport("broken pipe").to_string(),
196            "transport error: broken pipe"
197        );
198        assert_eq!(
199            HarnessError::protocol("no matching id").to_string(),
200            "protocol error: no matching id"
201        );
202        assert_eq!(
203            HarnessError::harness("exit code 1").to_string(),
204            "harness reported failure: exit code 1"
205        );
206        assert_eq!(
207            HarnessError::contract("output is a JSON object").to_string(),
208            "agent-outcome contract refusal: output is a JSON object"
209        );
210    }
211
212    /// The retry-classification decision, pinned in the crate that makes it:
213    /// exactly the contract refusal is deterministic; every class that can be
214    /// transient stays non-deterministic. (The match inside `is_deterministic`
215    /// is exhaustive, so a new variant fails compilation there — this test
216    /// pins the ANSWERS, the compiler pins the completeness.)
217    #[test]
218    fn only_the_contract_refusal_is_deterministic() {
219        assert!(HarnessError::contract("output is a JSON object").is_deterministic());
220        for transient in [
221            HarnessError::transport("broken pipe"),
222            HarnessError::protocol("invalid JSON frame"),
223            HarnessError::harness("run stopped without completing"),
224            HarnessError::stale_target("attempt 2 superseded"),
225            HarnessError::capability_not_supported("pause_resume"),
226        ] {
227            assert!(
228                !transient.is_deterministic(),
229                "{transient:?} can be transient and must not classify deterministic"
230            );
231        }
232    }
233}