Skip to main content

nanocodex_agent/
error.rs

1use std::{io, path::PathBuf, sync::Arc};
2
3use nanocodex_oai_api::ResponseError;
4pub use nanocodex_oai_api::transport::ResponsesError;
5
6/// Error returned by the Nanocodex library boundary.
7#[derive(Debug, thiserror::Error)]
8pub enum NanocodexError {
9    /// Caller input or two configured policies are incompatible.
10    #[error("invalid task request: {0}")]
11    InvalidRequest(String),
12
13    /// The configured workspace could not be resolved.
14    #[error("failed to resolve task workspace {path}: {source}")]
15    ResolveWorkspace {
16        /// Workspace path supplied by the caller.
17        path: PathBuf,
18        /// Underlying filesystem failure.
19        #[source]
20        source: io::Error,
21    },
22
23    /// The resolved workspace exists but is not a directory.
24    #[error("task workspace is not a directory: {path}")]
25    WorkspaceNotDirectory {
26        /// Resolved workspace path.
27        path: PathBuf,
28    },
29
30    /// The resolved workspace cannot be represented as UTF-8.
31    #[error("task workspace path is not valid UTF-8: {path}")]
32    WorkspaceNotUtf8 {
33        /// Resolved workspace path.
34        path: PathBuf,
35    },
36
37    /// A follow-on prompt attempted to change an owned session's workspace.
38    #[error("an active agent session cannot change workspace from {current} to {requested}")]
39    WorkspaceChanged {
40        /// Workspace already owned by the session.
41        current: String,
42        /// Conflicting workspace requested by the caller.
43        requested: String,
44    },
45
46    /// A completed provider response violated an agent-loop invariant.
47    #[error("malformed Responses API event: {detail}")]
48    MalformedResponse {
49        /// Stable invariant failure description.
50        detail: &'static str,
51    },
52
53    /// A service returned an output for the wrong kind of attempt.
54    #[error("invalid Responses attempt state: {detail}")]
55    InvalidAttemptState {
56        /// Stable invalid-state description.
57        detail: &'static str,
58    },
59
60    /// The immutable request prefix could not be serialized for fingerprinting.
61    #[error("failed to fingerprint the immutable prompt prefix: {0}")]
62    SerializePromptPrefix(#[source] serde_json::Error),
63
64    /// The private driver stopped before accepting a command.
65    #[error("the agent stopped before accepting the command")]
66    AgentStopped,
67
68    /// The private driver stopped after accepting a turn but before delivering its result.
69    #[error("the agent stopped before the turn completed")]
70    TurnStopped,
71
72    /// Shared cleanup failure returned to every caller of an idempotent
73    /// shutdown.
74    #[error(transparent)]
75    Shutdown(Arc<Self>),
76
77    /// Steering targeted a queued or terminal turn.
78    #[error("the targeted turn is queued, completed, or otherwise not active for steering")]
79    TurnNotSteerable,
80
81    /// The active turn cannot accept more queued steering input.
82    #[error("the active turn's steering queue is full")]
83    SteerQueueFull,
84
85    /// Cancellation targeted an already terminal turn.
86    #[error("the targeted turn has already completed or been cancelled")]
87    TurnNotCancellable,
88
89    /// The targeted turn was cancelled after its resources stopped.
90    #[error("the turn was cancelled")]
91    TurnCancelled,
92
93    /// A fork was requested before any safe committed boundary existed.
94    #[error("the agent has no safe conversation boundary to fork")]
95    ForkBeforeCompletedTurn,
96
97    /// A historical result came from a different conversation lineage.
98    #[error("the completed turn belongs to a different conversation lineage")]
99    CheckpointLineageMismatch,
100
101    /// A serialized session snapshot failed structural or policy validation.
102    #[error("invalid session snapshot: {0}")]
103    InvalidSessionSnapshot(String),
104
105    /// Agent construction was attempted outside an active Tokio runtime.
106    #[error("building an agent requires an active Tokio runtime")]
107    TokioRuntimeUnavailable,
108
109    /// Codex-compatible rollout recording could not be initialized.
110    #[error("failed to initialize a Codex rollout under {codex_home}: {source}")]
111    InitializeRollout {
112        /// Codex state directory selected by the caller.
113        codex_home: PathBuf,
114        /// Underlying filesystem failure.
115        #[source]
116        source: io::Error,
117    },
118
119    /// A committed rollout could not be durably persisted.
120    #[error("failed to persist Codex rollout at {path}: {source}")]
121    PersistRollout {
122        /// Rollout file that could not be written.
123        path: PathBuf,
124        /// Underlying filesystem failure.
125        #[source]
126        source: io::Error,
127    },
128
129    /// Contractual agent event serialization failed.
130    #[error(transparent)]
131    Event(#[from] nanocodex_oai_api::events::EventError),
132
133    /// A complete Responses operation failed.
134    #[error(transparent)]
135    Response(#[from] ResponseError),
136
137    /// The configured tool registry or runtime could not be built.
138    #[cfg(not(target_family = "wasm"))]
139    #[error("failed to build tools for an agent driver: {0}")]
140    Tools(#[from] nanocodex_tools::ToolsBuildError),
141}
142
143impl NanocodexError {
144    /// Returns the underlying Responses transport/API error, including when a
145    /// caller-provided Tower middleware boxed the standard service error.
146    #[must_use]
147    pub fn responses_error(&self) -> Option<&ResponsesError> {
148        match self {
149            Self::Response(error) => error.responses_error(),
150            Self::Shutdown(error) => error.responses_error(),
151            _ => None,
152        }
153    }
154}
155
156/// Result type returned by the owned agent lifecycle.
157pub type Result<T> = std::result::Result<T, NanocodexError>;
158
159#[cfg(test)]
160mod tests {
161    use super::{NanocodexError, ResponsesError};
162    use nanocodex_oai_api::{ResponseError, tower::ResponsesServiceError};
163
164    #[test]
165    fn response_error_is_the_single_provider_failure_boundary() {
166        let service = NanocodexError::Response(ResponseError::from(ResponsesServiceError::from(
167            ResponsesError::UnexpectedEnd,
168        )));
169        assert!(matches!(
170            service.responses_error(),
171            Some(ResponsesError::UnexpectedEnd)
172        ));
173
174        let service = ResponsesServiceError::from(ResponsesError::UnexpectedEnd);
175        let error =
176            NanocodexError::Response(ResponseError::from(Box::new(service) as tower::BoxError));
177        assert!(matches!(
178            error.responses_error(),
179            Some(ResponsesError::UnexpectedEnd)
180        ));
181        assert_eq!(
182            error.to_string(),
183            "Responses WebSocket closed without a close frame"
184        );
185    }
186}