agcodex_core/
error.rs

1use reqwest::StatusCode;
2use serde_json;
3use std::io;
4use std::time::Duration;
5use thiserror::Error;
6use tokio::task::JoinError;
7use uuid::Uuid;
8
9pub type Result<T> = std::result::Result<T, CodexErr>;
10
11#[derive(Error, Debug)]
12pub enum SandboxErr {
13    /// Error from sandbox execution
14    #[error("sandbox denied exec error, exit code: {0}, stdout: {1}, stderr: {2}")]
15    Denied(i32, String, String),
16
17    /// Error from linux seccomp filter setup
18    #[cfg(target_os = "linux")]
19    #[error("seccomp setup error")]
20    SeccompInstall(#[from] seccompiler::Error),
21
22    /// Error from linux seccomp backend
23    #[cfg(target_os = "linux")]
24    #[error("seccomp backend error")]
25    SeccompBackend(#[from] seccompiler::BackendError),
26
27    /// Command timed out
28    #[error("command timed out")]
29    Timeout,
30
31    /// Command was killed by a signal
32    #[error("command was killed by a signal")]
33    Signal(i32),
34
35    /// Error from linux landlock
36    #[error("Landlock was not able to fully enforce all sandbox rules")]
37    LandlockRestrict,
38}
39
40#[derive(Error, Debug)]
41pub enum CodexErr {
42    /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP
43    /// handshake has succeeded but **before** it finished emitting `response.completed`.
44    ///
45    /// The Session loop treats this as a transient error and will automatically retry the turn.
46    ///
47    /// Optionally includes the requested delay before retrying the turn.
48    #[error("stream disconnected before completion: {0}")]
49    Stream(String, Option<Duration>),
50
51    #[error("no conversation with id: {0}")]
52    ConversationNotFound(Uuid),
53
54    #[error("session configured event was not the first event in the stream")]
55    SessionConfiguredNotFirstEvent,
56
57    /// Returned by run_command_stream when the spawned child process timed out (10s).
58    #[error("timeout waiting for child process to exit")]
59    Timeout,
60
61    /// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes
62    /// could not be captured). Analogous to the previous `CodexError::Spawn` variant.
63    #[error("spawn failed: child stdout/stderr not captured")]
64    Spawn,
65
66    /// Returned by run_command_stream when the user pressed Ctrl‑C (SIGINT). Session uses this to
67    /// surface a polite FunctionCallOutput back to the model instead of crashing the CLI.
68    #[error("interrupted (Ctrl-C)")]
69    Interrupted,
70
71    /// Unexpected HTTP status code.
72    #[error("unexpected status {0}: {1}")]
73    UnexpectedStatus(StatusCode, String),
74
75    #[error("{0}")]
76    UsageLimitReached(UsageLimitReachedError),
77
78    #[error(
79        "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing."
80    )]
81    UsageNotIncluded,
82
83    #[error("We're currently experiencing high demand, which may cause temporary errors.")]
84    InternalServerError,
85
86    /// Retry limit exceeded.
87    #[error("exceeded retry limit, last status: {0}")]
88    RetryLimit(StatusCode),
89
90    /// Agent loop died unexpectedly
91    #[error("internal error; agent loop died unexpectedly")]
92    InternalAgentDied,
93
94    /// Sandbox error
95    #[error("sandbox error: {0}")]
96    Sandbox(#[from] SandboxErr),
97
98    #[error("agcodex-linux-sandbox was required but not provided")]
99    LandlockSandboxExecutableNotProvided,
100
101    // -----------------------------------------------------------------
102    // Automatic conversions for common external error types
103    // -----------------------------------------------------------------
104    #[error(transparent)]
105    Io(#[from] io::Error),
106
107    #[error(transparent)]
108    Reqwest(#[from] reqwest::Error),
109
110    #[error(transparent)]
111    Json(#[from] serde_json::Error),
112
113    #[cfg(target_os = "linux")]
114    #[error(transparent)]
115    LandlockRuleset(#[from] landlock::RulesetError),
116
117    #[cfg(target_os = "linux")]
118    #[error(transparent)]
119    LandlockPathFd(#[from] landlock::PathFdError),
120
121    #[error(transparent)]
122    TokioJoin(#[from] JoinError),
123
124    #[error("{0}")]
125    EnvVar(EnvVarError),
126
127    // MCP-related errors
128    #[error("MCP server error: {0}")]
129    McpServer(String),
130
131    #[error("MCP client start failed for server {server}: {error}")]
132    McpClientStart { server: String, error: String },
133
134    #[error("MCP tool not found: {0}")]
135    McpToolNotFound(String),
136
137    // Configuration errors
138    #[error("invalid configuration: {0}")]
139    InvalidConfig(String),
140
141    #[error("invalid working directory: {0}")]
142    InvalidWorkingDirectory(String),
143
144    // Mode restriction errors
145    #[error("operation not allowed in current mode: {0}")]
146    ModeRestriction(String),
147
148    // Undo/Redo system errors
149    #[error("no branch point available for creating branch")]
150    NoBranchPointAvailable,
151
152    #[error("no current state available for creating checkpoint")]
153    NoCurrentStateForCheckpoint,
154
155    #[error("snapshot {0} not found")]
156    SnapshotNotFound(Uuid),
157
158    #[error("branch {0} not found")]
159    BranchNotFound(Uuid),
160
161    #[error("undo stack is empty")]
162    UndoStackEmpty,
163
164    #[error("redo stack is empty")]
165    RedoStackEmpty,
166
167    #[error("memory limit exceeded: {current} > {limit} bytes")]
168    MemoryLimitExceeded { current: usize, limit: usize },
169
170    // General errors for migration from anyhow
171    #[error("{0}")]
172    General(String),
173}
174
175#[derive(Debug)]
176pub struct UsageLimitReachedError {
177    pub plan_type: Option<String>,
178}
179
180impl std::fmt::Display for UsageLimitReachedError {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        if let Some(plan_type) = &self.plan_type
183            && plan_type == "plus"
184        {
185            write!(
186                f,
187                "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
188            )?;
189        } else {
190            write!(
191                f,
192                "You've hit your usage limit. Limits reset every 5h and every week."
193            )?;
194        }
195        Ok(())
196    }
197}
198
199#[derive(Debug)]
200pub struct EnvVarError {
201    /// Name of the environment variable that is missing.
202    pub var: String,
203
204    /// Optional instructions to help the user get a valid value for the
205    /// variable and set it.
206    pub instructions: Option<String>,
207}
208
209impl std::fmt::Display for EnvVarError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "Missing environment variable: `{}`.", self.var)?;
212        if let Some(instructions) = &self.instructions {
213            write!(f, " {instructions}")?;
214        }
215        Ok(())
216    }
217}
218
219impl CodexErr {
220    /// Minimal shim so that existing `e.downcast_ref::<CodexErr>()` checks continue to compile
221    /// after replacing `anyhow::Error` in the return signature. This mirrors the behavior of
222    /// `anyhow::Error::downcast_ref` but works directly on our concrete enum.
223    pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
224        (self as &dyn std::any::Any).downcast_ref::<T>()
225    }
226}
227
228pub fn get_error_message_ui(e: &CodexErr) -> String {
229    match e {
230        CodexErr::Sandbox(SandboxErr::Denied(_, _, stderr)) => stderr.to_string(),
231        _ => e.to_string(),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn usage_limit_reached_error_formats_plus_plan() {
241        let err = UsageLimitReachedError {
242            plan_type: Some("plus".to_string()),
243        };
244        assert_eq!(
245            err.to_string(),
246            "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
247        );
248    }
249
250    #[test]
251    fn usage_limit_reached_error_formats_default_when_none() {
252        let err = UsageLimitReachedError { plan_type: None };
253        assert_eq!(
254            err.to_string(),
255            "You've hit your usage limit. Limits reset every 5h and every week."
256        );
257    }
258
259    #[test]
260    fn usage_limit_reached_error_formats_default_for_other_plans() {
261        let err = UsageLimitReachedError {
262            plan_type: Some("pro".to_string()),
263        };
264        assert_eq!(
265            err.to_string(),
266            "You've hit your usage limit. Limits reset every 5h and every week."
267        );
268    }
269}