Skip to main content

nanocodex_agent/
error.rs

1use std::sync::Arc;
2#[cfg(feature = "openai")]
3use std::{io, path::PathBuf};
4
5#[cfg(feature = "openai")]
6use nanocodex_oai_api::ResponseError;
7#[cfg(feature = "openai")]
8pub use nanocodex_oai_api::transport::ResponsesError;
9
10/// Recovery action attached by a higher-layer execution policy.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum ExecutionPolicyDisposition {
13    /// The same live policy owner may safely retry the operation.
14    Retry,
15    /// This policy owner must stop and be rebuilt from authoritative state.
16    Reopen,
17    /// The operation cannot be retried automatically.
18    Fatal,
19}
20
21/// Error returned by the Nanocodex library boundary.
22#[derive(Debug, thiserror::Error)]
23pub enum NanocodexError {
24    /// Caller input or two configured policies are incompatible.
25    #[error("invalid task request: {0}")]
26    InvalidRequest(String),
27
28    /// The configured workspace could not be resolved.
29    #[cfg(feature = "openai")]
30    #[error("failed to resolve task workspace {path}: {source}")]
31    ResolveWorkspace {
32        /// Workspace path supplied by the caller.
33        path: PathBuf,
34        /// Underlying filesystem failure.
35        #[source]
36        source: io::Error,
37    },
38
39    /// The resolved workspace exists but is not a directory.
40    #[cfg(feature = "openai")]
41    #[error("task workspace is not a directory: {path}")]
42    WorkspaceNotDirectory {
43        /// Resolved workspace path.
44        path: PathBuf,
45    },
46
47    /// The resolved workspace cannot be represented as UTF-8.
48    #[cfg(feature = "openai")]
49    #[error("task workspace path is not valid UTF-8: {path}")]
50    WorkspaceNotUtf8 {
51        /// Resolved workspace path.
52        path: PathBuf,
53    },
54
55    /// A follow-on prompt attempted to change an owned session's workspace.
56    #[cfg(feature = "openai")]
57    #[error("an active agent session cannot change workspace from {current} to {requested}")]
58    WorkspaceChanged {
59        /// Workspace already owned by the session.
60        current: String,
61        /// Conflicting workspace requested by the caller.
62        requested: String,
63    },
64
65    /// A completed provider response violated an agent-loop invariant.
66    #[cfg(feature = "openai")]
67    #[error("malformed Responses API event: {detail}")]
68    MalformedResponse {
69        /// Stable invariant failure description.
70        detail: &'static str,
71    },
72
73    /// A service returned an output for the wrong kind of attempt.
74    #[cfg(feature = "openai")]
75    #[error("invalid Responses attempt state: {detail}")]
76    InvalidAttemptState {
77        /// Stable invalid-state description.
78        detail: &'static str,
79    },
80
81    /// The immutable request prefix could not be serialized for fingerprinting.
82    #[cfg(feature = "openai")]
83    #[error("failed to fingerprint the immutable prompt prefix: {0}")]
84    SerializePromptPrefix(#[source] serde_json::Error),
85
86    /// The private driver stopped before accepting a command.
87    #[error("the agent stopped before accepting the command")]
88    AgentStopped,
89
90    /// An agent with an attached execution policy stopped and must be rebuilt
91    /// from that policy's authoritative state before accepting more work.
92    #[error("the execution-policy-owned agent stopped and must be reopened")]
93    ExecutionPolicyOwnerStopped,
94
95    /// The private driver stopped after accepting a turn but before delivering its result.
96    #[error("the agent stopped before the turn completed")]
97    TurnStopped,
98
99    /// Shared cleanup failure returned to every caller of an idempotent
100    /// shutdown.
101    #[error(transparent)]
102    Shutdown(Arc<Self>),
103
104    /// Steering targeted a queued or terminal turn.
105    #[error("the targeted turn is queued, completed, or otherwise not active for steering")]
106    TurnNotSteerable,
107
108    /// The active turn cannot accept more queued steering input.
109    #[error("the active turn's steering queue is full")]
110    SteerQueueFull,
111
112    /// Cancellation targeted an already terminal turn.
113    #[error("the targeted turn has already completed or been cancelled")]
114    TurnNotCancellable,
115
116    /// The targeted turn was cancelled after its resources stopped.
117    #[error("the turn was cancelled")]
118    TurnCancelled,
119
120    /// A fork was requested before any safe committed boundary existed.
121    #[cfg(feature = "openai")]
122    #[error("the agent has no safe conversation boundary to fork")]
123    ForkBeforeCompletedTurn,
124
125    /// A historical result came from a different conversation lineage.
126    #[cfg(feature = "openai")]
127    #[error("the completed turn belongs to a different conversation lineage")]
128    CheckpointLineageMismatch,
129
130    /// A serialized session snapshot failed structural or policy validation.
131    #[cfg(feature = "openai")]
132    #[error("invalid session snapshot: {0}")]
133    InvalidSessionSnapshot(String),
134
135    /// A higher-layer execution policy or its host store failed.
136    #[cfg(feature = "openai")]
137    #[error("{layer} execution policy failed: {source}")]
138    ExecutionPolicy {
139        /// Human-readable layer identity.
140        layer: &'static str,
141        /// Action the lifecycle must preserve while handling the failure.
142        disposition: ExecutionPolicyDisposition,
143        /// Original extension error.
144        #[source]
145        source: Arc<dyn std::error::Error + Send + Sync>,
146    },
147
148    /// An identified prompt was submitted without an execution policy.
149    #[cfg(feature = "openai")]
150    #[error("identified prompt submission requires a configured execution policy")]
151    ExecutionPolicyNotConfigured,
152
153    /// An attached execution policy violated the agent integration contract.
154    #[cfg(feature = "openai")]
155    #[error("invalid execution policy state: {0}")]
156    InvalidExecutionPolicy(String),
157
158    /// An execution policy relied on a fail-closed default for a capability
159    /// that must explicitly acknowledge durable authority.
160    #[cfg(feature = "openai")]
161    #[error("execution policy does not implement required capability `{capability}`")]
162    ExecutionPolicyCapabilityUnsupported {
163        /// Missing policy capability.
164        capability: &'static str,
165    },
166
167    /// A context-inheriting branch was requested from an execution-policy-owned session.
168    #[cfg(feature = "openai")]
169    #[error(
170        "cannot {operation} from an agent with an attached execution policy; build the branch with its own execution policy"
171    )]
172    ExecutionPolicyBranchUnsupported {
173        /// Requested child operation.
174        operation: &'static str,
175    },
176
177    /// A typed execution boundary could not be encoded or decoded.
178    #[cfg(feature = "openai")]
179    #[error("execution policy payload is invalid: {0}")]
180    ExecutionPayload(#[source] serde_json::Error),
181
182    /// A policy-replayed result does not retain an in-process fork checkpoint.
183    #[cfg(feature = "openai")]
184    #[error("a policy-replayed result cannot be used as an in-process fork checkpoint")]
185    ReplayedCheckpointUnavailable,
186
187    /// The selected backend does not implement one lifecycle capability.
188    #[error("the selected agent backend does not support {capability}")]
189    UnsupportedCapability {
190        /// Stable capability name.
191        capability: &'static str,
192    },
193
194    /// A concrete backend violated the common lifecycle contract.
195    #[error("agent backend violated the lifecycle contract: {detail}")]
196    BackendContract {
197        /// Stable invariant that the backend violated.
198        detail: &'static str,
199    },
200
201    /// A concrete lifecycle backend reported a transport or protocol failure.
202    #[error("{backend} backend failed: {source}")]
203    Backend {
204        /// Stable backend identity.
205        backend: &'static str,
206        /// Original backend error.
207        #[source]
208        source: Arc<dyn std::error::Error + Send + Sync>,
209    },
210
211    /// A previously failed identified operation was replayed from its durable terminal record.
212    #[cfg(feature = "openai")]
213    #[error("durable operation previously failed: {0}")]
214    ReplayedExecutionFailed(String),
215
216    /// Agent construction was attempted outside an active Tokio runtime.
217    #[cfg(feature = "openai")]
218    #[error("building an agent requires an active Tokio runtime")]
219    TokioRuntimeUnavailable,
220
221    /// Codex-compatible rollout recording could not be initialized.
222    #[cfg(feature = "openai")]
223    #[error("failed to initialize a Codex rollout under {codex_home}: {source}")]
224    InitializeRollout {
225        /// Codex state directory selected by the caller.
226        codex_home: PathBuf,
227        /// Underlying filesystem failure.
228        #[source]
229        source: io::Error,
230    },
231
232    /// A committed rollout could not be durably persisted.
233    #[cfg(feature = "openai")]
234    #[error("failed to persist Codex rollout at {path}: {source}")]
235    PersistRollout {
236        /// Rollout file that could not be written.
237        path: PathBuf,
238        /// Underlying filesystem failure.
239        #[source]
240        source: io::Error,
241    },
242
243    /// Contractual agent event serialization failed.
244    #[error(transparent)]
245    Event(#[from] nanocodex_oai_api::events::EventError),
246
247    /// A complete Responses operation failed.
248    #[cfg(feature = "openai")]
249    #[error(transparent)]
250    Response(#[from] ResponseError),
251
252    /// The configured tool registry or runtime could not be built.
253    #[cfg(feature = "openai")]
254    #[error("failed to build tools for an agent driver: {0}")]
255    Tools(#[from] nanocodex_tools::ToolsBuildError),
256}
257
258impl NanocodexError {
259    /// Wraps an error returned by a concrete lifecycle backend.
260    #[doc(hidden)]
261    pub fn backend<E>(backend: &'static str, source: E) -> Self
262    where
263        E: std::error::Error + Send + Sync + 'static,
264    {
265        Self::Backend {
266            backend,
267            source: Arc::new(source),
268        }
269    }
270
271    /// Wraps an error returned by a higher-layer execution policy.
272    #[cfg(feature = "openai")]
273    #[doc(hidden)]
274    pub fn execution_policy<E>(layer: &'static str, source: E) -> Self
275    where
276        E: std::error::Error + Send + Sync + 'static,
277    {
278        Self::ExecutionPolicy {
279            layer,
280            disposition: ExecutionPolicyDisposition::Fatal,
281            source: Arc::new(source),
282        }
283    }
284
285    /// Wraps an execution-policy error with its required recovery action.
286    #[cfg(feature = "openai")]
287    #[doc(hidden)]
288    pub fn execution_policy_with_disposition<E>(
289        layer: &'static str,
290        disposition: ExecutionPolicyDisposition,
291        source: E,
292    ) -> Self
293    where
294        E: std::error::Error + Send + Sync + 'static,
295    {
296        Self::ExecutionPolicy {
297            layer,
298            disposition,
299            source: Arc::new(source),
300        }
301    }
302
303    /// Returns the recovery action supplied by an execution policy.
304    #[cfg(feature = "openai")]
305    #[must_use]
306    pub fn execution_policy_disposition(&self) -> Option<ExecutionPolicyDisposition> {
307        match self {
308            Self::ExecutionPolicy { disposition, .. } => Some(*disposition),
309            Self::ExecutionPolicyOwnerStopped => Some(ExecutionPolicyDisposition::Reopen),
310            Self::Shutdown(source) => source.execution_policy_disposition(),
311            _ => None,
312        }
313    }
314
315    /// Returns the underlying Responses transport/API error, including when a
316    /// caller-provided Tower middleware boxed the standard service error.
317    #[cfg(feature = "openai")]
318    #[must_use]
319    pub fn responses_error(&self) -> Option<&ResponsesError> {
320        match self {
321            Self::Response(error) => error.responses_error(),
322            Self::Shutdown(error) => error.responses_error(),
323            _ => None,
324        }
325    }
326}
327
328/// Result type returned by the owned agent lifecycle.
329pub type Result<T> = std::result::Result<T, NanocodexError>;
330
331#[cfg(all(test, feature = "openai"))]
332mod tests {
333    use super::{NanocodexError, ResponsesError};
334    use nanocodex_oai_api::{ResponseError, tower::ResponsesServiceError};
335
336    #[test]
337    fn response_error_is_the_single_provider_failure_boundary() {
338        let service = NanocodexError::Response(ResponseError::from(ResponsesServiceError::from(
339            ResponsesError::UnexpectedEnd,
340        )));
341        assert!(matches!(
342            service.responses_error(),
343            Some(ResponsesError::UnexpectedEnd)
344        ));
345
346        let service = ResponsesServiceError::from(ResponsesError::UnexpectedEnd);
347        let error =
348            NanocodexError::Response(ResponseError::from(Box::new(service) as tower::BoxError));
349        assert!(matches!(
350            error.responses_error(),
351            Some(ResponsesError::UnexpectedEnd)
352        ));
353        assert_eq!(
354            error.to_string(),
355            "Responses WebSocket closed without a close frame"
356        );
357    }
358}