Skip to main content

agentos_client/
error.rs

1//! Error taxonomy for the Agent OS client SDK.
2//!
3//! Mirrors `spec.md` §4 / ADR-001 §4. Preserves the TypeScript SDK distinction so callers can still
4//! discriminate path-guard violations from kernel errno failures. Public methods return
5//! [`anyhow::Result`]; the typed [`ClientError`] is carried as the `source` so callers can downcast.
6//!
7//! Durable session operations return typed client errors when the sidecar
8//! rejects an operation. ACP adapter JSON-RPC details are normalized by the
9//! sidecar and are not exposed as a second raw session API.
10
11use agentos_sidecar_client::{ProtocolCodecError, TransportError};
12
13/// Structured sidecar admission metadata kept behind one allocation so the
14/// public [`ClientError`] remains cheap to return through every SDK method.
15#[derive(Debug)]
16pub struct ResourceLimitDetails {
17    pub limit_name: Option<String>,
18    pub configured_limit: Option<u64>,
19    pub current_usage: Option<u64>,
20    pub requested: Option<u64>,
21    pub unit: Option<String>,
22    pub scope: Option<String>,
23    pub vm_id: Option<String>,
24    pub session_generation: Option<u64>,
25    pub capability_id: Option<u64>,
26    pub operation: Option<String>,
27    pub configuration_path: Option<String>,
28    pub retryable: Option<bool>,
29    pub errno: Option<String>,
30}
31
32/// Typed error taxonomy for the client SDK.
33#[derive(thiserror::Error, Debug)]
34pub enum ClientError {
35    /// A filesystem path was not absolute (did not start with `/`).
36    ///
37    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are
38    /// observable data (they surface in `BatchWriteResult.error` / `BatchReadResult.error`), not
39    /// logs, so the casing follows TS rather than the lowercase log convention.
40    #[error("Path must be absolute: {0}")]
41    PathNotAbsolute(String),
42
43    /// A filesystem path was not in posix-normalized form.
44    ///
45    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").
46    #[error("Path must be normalized: {0}")]
47    PathNotNormalized(String),
48
49    /// A write was attempted against a read-only path (for example `/proc`).
50    ///
51    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").
52    #[error("Path is read-only: {0}")]
53    PathReadOnly(String),
54
55    /// An SDK-spawned process with the given pid was not found.
56    ///
57    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are
58    /// observable data (surfaced to callers), not logs, so the casing follows TS rather than the
59    /// lowercase log convention.
60    #[error("Process not found: {0}")]
61    ProcessNotFound(u32),
62
63    /// A shell with the given synthetic `shell-N` id was not found.
64    #[error("shell not found: {0}")]
65    ShellNotFound(String),
66
67    /// An ACP session with the given id was not found.
68    #[error("session not found: {0}")]
69    SessionNotFound(String),
70
71    /// A kernel/sidecar operation failed. The errno `code` string (`ENOENT`, `EEXIST`, `ENOTDIR`,
72    /// `EACCES`, `EISDIR`, `ENOTEMPTY`, ...) is preserved verbatim for parity with the TypeScript
73    /// `KernelError`.
74    #[error("kernel error [{code}]: {message}")]
75    Kernel { code: String, message: String },
76
77    /// A sidecar policy/admission bound rejected an operation. Fields are
78    /// copied from the lockstep wire response and never parsed from text.
79    #[error("resource limit [{code}]: {message}")]
80    ResourceLimit {
81        code: String,
82        message: String,
83        details: Box<ResourceLimitDetails>,
84    },
85
86    /// A durable ACP/session operation was rejected by the sidecar. The stable
87    /// wire code remains separately inspectable, matching the TypeScript
88    /// client's `Error & { code?: string }` surface.
89    #[error("ACP operation [{code}]: {message}")]
90    AcpOperation { code: String, message: String },
91
92    /// A caller-supplied config value was rejected before the VM was created,
93    /// for example a host function key that cannot become a command name.
94    #[error("invalid config: {0}")]
95    InvalidConfig(String),
96
97    /// A cron schedule string could not be parsed/validated.
98    #[error("invalid schedule: {0}")]
99    InvalidSchedule(String),
100
101    /// A one-shot (ISO-8601) cron schedule resolved to a time in the past.
102    #[error("schedule is in the past: {0}")]
103    PastSchedule(String),
104
105    /// A framing/codec failure on the sidecar transport.
106    #[error("transport error: {0}")]
107    Transport(#[from] ProtocolCodecError),
108
109    /// A generic sidecar rejection or I/O failure with context.
110    #[error("sidecar error: {0}")]
111    Sidecar(String),
112}
113
114impl From<TransportError> for ClientError {
115    fn from(error: TransportError) -> Self {
116        match error {
117            TransportError::Protocol(error) => ClientError::Transport(error),
118            TransportError::Sidecar(message) => ClientError::Sidecar(message),
119        }
120    }
121}
122
123impl ClientError {
124    pub(crate) fn from_rejection(
125        rejection: agentos_sidecar_client::wire::RejectedResponse,
126    ) -> Self {
127        if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
128            || rejection.code == "ERR_AGENTOS_OVERLOADED"
129        {
130            return Self::ResourceLimit {
131                code: rejection.code,
132                message: rejection.message,
133                details: Box::new(ResourceLimitDetails {
134                    limit_name: rejection.limit_name,
135                    configured_limit: rejection.configured_limit,
136                    current_usage: rejection.current_usage,
137                    requested: rejection.requested,
138                    unit: rejection.unit,
139                    scope: rejection.scope,
140                    vm_id: rejection.vm_id,
141                    session_generation: rejection.session_generation,
142                    capability_id: rejection.capability_id,
143                    operation: rejection.operation,
144                    configuration_path: rejection.configuration_path,
145                    retryable: rejection.retryable,
146                    errno: rejection.errno,
147                }),
148            };
149        }
150        Self::Kernel {
151            code: rejection.code,
152            message: rejection.message,
153        }
154    }
155
156    /// Render this error the way the TypeScript `AgentOs` surfaces `err.message` into batch results
157    /// (`BatchWriteResult.error` / `BatchReadResult.error`).
158    ///
159    /// The general [`Display`](std::fmt::Display) impl carries a human/log-oriented prefix
160    /// (`kernel error [<code>]: ...`), but the batch surface is observable data that must match TS
161    /// byte-for-byte. For kernel failures TS reports `KernelError.message`, which is
162    /// `<code>: <message>` (and avoids doubling the code when the message already starts with it).
163    /// Path-guard variants already carry the exact TS strings via their `Display` impl.
164    pub fn batch_message(&self) -> String {
165        match self {
166            ClientError::Kernel { code, message } => {
167                if message.starts_with(&format!("{code}:")) {
168                    message.clone()
169                } else {
170                    format!("{code}: {message}")
171                }
172            }
173            ClientError::ResourceLimit { code, message, .. } => {
174                if message.starts_with(&format!("{code}:")) {
175                    message.clone()
176                } else {
177                    format!("{code}: {message}")
178                }
179            }
180            ClientError::AcpOperation { message, .. } => message.clone(),
181            ClientError::PathNotAbsolute(_)
182            | ClientError::PathNotNormalized(_)
183            | ClientError::PathReadOnly(_)
184            | ClientError::InvalidConfig(_)
185            | ClientError::ProcessNotFound(_)
186            | ClientError::ShellNotFound(_)
187            | ClientError::SessionNotFound(_)
188            | ClientError::InvalidSchedule(_)
189            | ClientError::PastSchedule(_)
190            | ClientError::Transport(_)
191            | ClientError::Sidecar(_) => self.to_string(),
192        }
193    }
194}
195
196/// Convenience alias for results carrying a typed [`ClientError`].
197pub type ClientResult<T> = std::result::Result<T, ClientError>;
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn structured_resource_limit_metadata_survives_rejection_conversion() {
205        let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
206            code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
207            message: String::from("handle command bytes exceeded"),
208            limit_name: Some(String::from("handleCommandBytes")),
209            configured_limit: Some(4096),
210            current_usage: Some(3072),
211            requested: Some(2048),
212            unit: Some(String::from("bytes")),
213            scope: Some(String::from("vm")),
214            vm_id: Some(String::from("vm-1")),
215            session_generation: Some(3),
216            capability_id: Some(11),
217            operation: Some(String::from("socket.write")),
218            configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
219            retryable: Some(true),
220            errno: Some(String::from("EAGAIN")),
221        });
222
223        match error {
224            ClientError::ResourceLimit { details, .. } => {
225                assert_eq!(details.configured_limit, Some(4096));
226                assert_eq!(details.current_usage, Some(3072));
227                assert_eq!(details.requested, Some(2048));
228                assert_eq!(
229                    details.configuration_path.as_deref(),
230                    Some("limits.reactor.maxHandleCommandBytes")
231                );
232                assert_eq!(details.retryable, Some(true));
233                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
234            }
235            other => panic!("expected resource limit, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn acp_operation_keeps_code_separate_from_message() {
241        let error = ClientError::AcpOperation {
242            code: String::from("session_busy"),
243            message: String::from("session is running"),
244        };
245        match &error {
246            ClientError::AcpOperation { code, message } => {
247                assert_eq!(code, "session_busy");
248                assert_eq!(message, "session is running");
249            }
250            other => panic!("expected ACP operation error, got {other:?}"),
251        }
252        assert_eq!(error.batch_message(), "session is running");
253    }
254}