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