Skip to main content

mobius/
lib.rs

1//! A small, modular Rust framework for one linear agent session.
2//!
3//! Applications compose an [`agent::Agent`] from explicit model, sandbox, checkpoint, and
4//! middleware adapters. Frontends remain separate: they submit [`protocol::Op`] values and
5//! render the frontend-neutral [`protocol::Event`] stream.
6
7use std::future::Future;
8use std::pin::Pin;
9
10pub mod agent;
11pub mod backend;
12pub mod middleware;
13pub mod protocol;
14
15/// A boxed asynchronous operation used by runtime-pluggable interfaces.
16pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
17
18/// A model-provider failure with retry metadata preserved for callers.
19#[derive(Debug, thiserror::Error)]
20#[error("{message}")]
21pub struct ProviderError {
22    message: String,
23    status: Option<u16>,
24    retryable: bool,
25    retry_after: Option<String>,
26    kind: ProviderErrorKind,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ProviderErrorKind {
31    Other,
32    StreamInterrupted,
33}
34
35impl ProviderError {
36    /// Creates a non-retryable provider failure without an HTTP response.
37    #[must_use]
38    pub fn new(message: impl Into<String>) -> Self {
39        Self {
40            message: message.into(),
41            status: None,
42            retryable: false,
43            retry_after: None,
44            kind: ProviderErrorKind::Other,
45        }
46    }
47
48    /// Creates a retryable provider failure without an HTTP response.
49    #[must_use]
50    pub fn retryable(message: impl Into<String>) -> Self {
51        Self {
52            retryable: true,
53            ..Self::new(message)
54        }
55    }
56
57    /// Creates a retryable response-stream interruption without exposing transport details.
58    #[must_use]
59    pub fn stream_interrupted(retry_after: Option<String>) -> Self {
60        Self {
61            message: "model response stream was interrupted".into(),
62            status: None,
63            retryable: true,
64            retry_after,
65            kind: ProviderErrorKind::StreamInterrupted,
66        }
67    }
68
69    pub(crate) fn http(
70        message: impl Into<String>,
71        status: u16,
72        retry_after: Option<String>,
73    ) -> Self {
74        Self {
75            message: message.into(),
76            status: Some(status),
77            retryable: status == 408 || status == 429 || (500..=599).contains(&status),
78            retry_after,
79            kind: ProviderErrorKind::Other,
80        }
81    }
82
83    /// Returns the provider's HTTP status code, when one was received.
84    #[must_use]
85    pub fn status(&self) -> Option<u16> {
86        self.status
87    }
88
89    /// Reports whether retrying the operation is normally safe.
90    #[must_use]
91    pub fn is_retryable(&self) -> bool {
92        self.retryable
93    }
94
95    /// Reports whether a response ended before its completion record arrived.
96    #[must_use]
97    pub fn is_stream_interrupted(&self) -> bool {
98        self.kind == ProviderErrorKind::StreamInterrupted
99    }
100
101    /// Returns the provider's raw `Retry-After` header value.
102    #[must_use]
103    pub fn retry_after(&self) -> Option<&str> {
104        self.retry_after.as_deref()
105    }
106}
107
108impl From<String> for ProviderError {
109    fn from(message: String) -> Self {
110        Self::new(message)
111    }
112}
113
114impl From<&str> for ProviderError {
115    fn from(message: &str) -> Self {
116        Self::new(message)
117    }
118}
119
120/// Errors returned by möbius modules.
121#[derive(Debug, thiserror::Error)]
122pub enum Error {
123    #[error("configuration error: {0}")]
124    Config(String),
125    #[error("duplicate registration: {0}")]
126    Duplicate(String),
127    #[error("unknown registration: {0}")]
128    Unknown(String),
129    #[error("provider error: {0}")]
130    Provider(#[from] ProviderError),
131    #[error("authentication error: {0}")]
132    Auth(String),
133    #[error("sandbox rejected path: {0}")]
134    Sandbox(String),
135    #[error("tool error: {0}")]
136    Tool(String),
137    #[error("checkpoint error: {0}")]
138    Checkpoint(String),
139    #[error("agent busy: {0}")]
140    Busy(String),
141    #[error("agent stopped: {0}")]
142    Stopped(String),
143    #[error("{primary}; rollback failed: {rollback}")]
144    Rollback {
145        primary: Box<Error>,
146        rollback: Box<Error>,
147    },
148    #[error(transparent)]
149    Io(#[from] std::io::Error),
150    #[error(transparent)]
151    Http(#[from] reqwest::Error),
152    #[error(transparent)]
153    Json(#[from] serde_json::Error),
154    #[error("checkpoint storage error")]
155    Sqlite(
156        #[source]
157        #[from]
158        rusqlite::Error,
159    ),
160}
161
162/// Result type shared by möbius modules.
163pub type Result<T> = std::result::Result<T, Error>;
164
165pub(crate) fn preview_json(value: &serde_json::Value) -> String {
166    let value = value.to_string();
167    if value.len() <= 10_000 {
168        return value;
169    }
170    format!("{}…", truncate_utf8(&value, 10_000))
171}
172
173pub(crate) fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
174    let mut end = value.len().min(max_bytes);
175    while !value.is_char_boundary(end) {
176        end -= 1;
177    }
178    &value[..end]
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn sqlite_errors_do_not_expose_engine_messages() {
187        let error = Error::from(rusqlite::Error::InvalidQuery);
188
189        assert_eq!(error.to_string(), "checkpoint storage error");
190    }
191}