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//!
7//! # Embedded composition
8//!
9//! The caller owns every runtime dependency. Include exactly one message-handling middleware,
10//! give new sessions a non-empty [`protocol::SessionContext::bot_id`], and keep draining events
11//! while commands are active.
12//!
13//! ```rust,no_run
14//! use std::path::Path;
15//! use std::sync::Arc;
16//!
17//! use mobius::Result;
18//! use mobius::agent::{Agent, AgentConfig, create_agent};
19//! use mobius::backend::checkpoint::{CheckpointStore, sqlite::SqliteCheckpoint};
20//! use mobius::backend::model::{Model, ModelRouter, openai::OpenAi};
21//! use mobius::backend::sandbox::{ApprovalPolicy, Sandbox, local::LocalSandbox};
22//! use mobius::middleware::{Middleware, MiddlewareStack};
23//! use mobius::middleware::{messages::Messages, tools::Tools};
24//! use mobius::protocol::SessionContext;
25//!
26//! async fn build_agent(
27//!     workspace: &Path,
28//!     api_key: String,
29//!     model_id: &str,
30//! ) -> Result<Agent> {
31//!     let model: Arc<dyn Model> = Arc::new(OpenAi::new(
32//!         api_key,
33//!         "https://api.openai.com/v1",
34//!         model_id,
35//!     )?);
36//!     let models = Arc::new(ModelRouter::new("default", model));
37//!     let sandbox = Arc::new(Sandbox::new(
38//!         Arc::new(LocalSandbox::new(workspace)?),
39//!         ApprovalPolicy::Ask,
40//!     ));
41//!     let checkpoints: Arc<dyn CheckpointStore> =
42//!         Arc::new(SqliteCheckpoint::new(workspace.join("mobius.sqlite3"))?);
43//!     let middleware: Vec<Arc<dyn Middleware>> = vec![
44//!         Arc::new(Messages::default()),
45//!         Arc::new(Tools::coding()),
46//!     ];
47//!
48//!     create_agent(
49//!         AgentConfig::new(
50//!             models,
51//!             sandbox,
52//!             checkpoints,
53//!             MiddlewareStack::new(middleware)?,
54//!             "You are a concise coding agent.",
55//!         )
56//!         .session_context(SessionContext {
57//!             bot_id: "embedded".into(),
58//!             ..SessionContext::default()
59//!         }),
60//!     )
61//!     .await
62//! }
63//! ```
64//!
65//! A custom provider implements [`backend::model::Model`] and must return normalized output.
66//! [`backend::model::ModelEventSink`] is synchronous and fallible; propagate its error rather
67//! than silently losing a streamed event. This example also uses `serde_json`.
68//!
69//! ```rust,no_run
70//! use serde_json::json;
71//!
72//! use mobius::{BoxFuture, Result};
73//! use mobius::backend::model::{Model, ModelEventSink, ModelOutput, ModelRequest};
74//! use mobius::protocol::{ModelEvent, ModelInfo, TokenUsage};
75//!
76//! struct EchoModel;
77//!
78//! impl Model for EchoModel {
79//!     fn info(&self) -> ModelInfo {
80//!         ModelInfo {
81//!             model: "echo".into(),
82//!             reasoning_effort: None,
83//!         }
84//!     }
85//!
86//!     fn respond<'a>(
87//!         &'a self,
88//!         _request: ModelRequest<'a>,
89//!         events: ModelEventSink,
90//!     ) -> BoxFuture<'a, Result<ModelOutput>> {
91//!         Box::pin(async move {
92//!             events(ModelEvent::TextDelta("done".into()))?;
93//!             ModelOutput::from_output(
94//!                 vec![json!({
95//!                     "type": "message",
96//!                     "role": "assistant",
97//!                     "content": [{"type": "output_text", "text": "done"}]
98//!                 })],
99//!                 true,
100//!                 TokenUsage::default(),
101//!             )
102//!         })
103//!     }
104//! }
105//! ```
106//!
107//! A capability implements [`middleware::Middleware`] and joins the declaration-ordered
108//! [`middleware::MiddlewareStack`]. Static prompt sections are composed once at agent creation.
109//!
110//! ```rust,no_run
111//! use std::sync::Arc;
112//!
113//! use mobius::Result;
114//! use mobius::middleware::{Middleware, MiddlewareStack, PromptSection, RuntimeContext};
115//! use mobius::middleware::messages::Messages;
116//!
117//! struct Policy;
118//!
119//! impl Middleware for Policy {
120//!     fn name(&self) -> &'static str {
121//!         "policy"
122//!     }
123//!
124//!     fn prompt_section(&self, _runtime: &RuntimeContext) -> Result<Option<PromptSection>> {
125//!         Ok(Some(PromptSection::new("Follow the repository policy.")))
126//!     }
127//! }
128//!
129//! fn middleware_stack() -> Result<MiddlewareStack> {
130//!     MiddlewareStack::new(vec![Arc::new(Messages::default()), Arc::new(Policy)])
131//! }
132//! ```
133//!
134//! # Runtime contracts
135//!
136//! - [`Error`] and [`ProviderError`] preserve actionable failure classes and retry metadata;
137//!   callers should not infer policy by matching display strings.
138//! - [`agent::create_agent`] validates composition and unwinds started middleware on startup
139//!   failure. [`agent::AgentSender`] documents bounded submission and sender-drop shutdown;
140//!   drain [`agent::AgentEvents::recv`] until the stream closes.
141//! - [`backend::checkpoint::CheckpointStore::save_with_events`] is the atomic logical boundary
142//!   for checkpoint, transcript, execution, and event state. Backend contracts specify durability
143//!   and which optional history operations are supported.
144//! - [`backend::sandbox::Sandbox`] owns approval and background-process cleanup around an
145//!   injected [`backend::sandbox::SandboxBackend`]. Backends must keep cancellation cleanup for
146//!   resources they launch; the default authorized path fails closed.
147//! - In `mobius-gateway`, signal shutdown through `GatewayServer::serve_until` and await it;
148//!   dropping the serving future does not perform graceful shutdown.
149
150use std::future::Future;
151use std::pin::Pin;
152
153pub mod agent;
154pub mod backend;
155pub mod middleware;
156pub mod protocol;
157
158/// A boxed asynchronous operation used by runtime-pluggable interfaces.
159pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
160
161/// A model-provider failure with retry metadata preserved for callers.
162#[derive(Debug, thiserror::Error)]
163#[error("{message}")]
164pub struct ProviderError {
165    message: String,
166    status: Option<u16>,
167    retryable: bool,
168    retry_after: Option<String>,
169    kind: ProviderErrorKind,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum ProviderErrorKind {
174    Other,
175    StreamInterrupted,
176}
177
178impl ProviderError {
179    /// Creates a non-retryable provider failure without an HTTP response.
180    #[must_use]
181    pub fn new(message: impl Into<String>) -> Self {
182        Self {
183            message: message.into(),
184            status: None,
185            retryable: false,
186            retry_after: None,
187            kind: ProviderErrorKind::Other,
188        }
189    }
190
191    /// Creates a retryable provider failure without an HTTP response.
192    #[must_use]
193    pub fn retryable(message: impl Into<String>) -> Self {
194        Self {
195            retryable: true,
196            ..Self::new(message)
197        }
198    }
199
200    /// Creates a retryable response-stream interruption without exposing transport details.
201    #[must_use]
202    pub fn stream_interrupted(retry_after: Option<String>) -> Self {
203        Self {
204            message: "model response stream was interrupted".into(),
205            status: None,
206            retryable: true,
207            retry_after,
208            kind: ProviderErrorKind::StreamInterrupted,
209        }
210    }
211
212    pub(crate) fn http(
213        message: impl Into<String>,
214        status: u16,
215        retry_after: Option<String>,
216    ) -> Self {
217        Self {
218            message: message.into(),
219            status: Some(status),
220            retryable: status == 408 || status == 429 || (500..=599).contains(&status),
221            retry_after,
222            kind: ProviderErrorKind::Other,
223        }
224    }
225
226    /// Returns the provider's HTTP status code, when one was received.
227    #[must_use]
228    pub fn status(&self) -> Option<u16> {
229        self.status
230    }
231
232    /// Reports whether the failure is classified as retryable.
233    ///
234    /// This does not prove that the original request was unprocessed. Before
235    /// replaying, account for partial output and possible remote side effects.
236    #[must_use]
237    pub fn is_retryable(&self) -> bool {
238        self.retryable
239    }
240
241    /// Reports whether a response ended before its completion record arrived.
242    #[must_use]
243    pub fn is_stream_interrupted(&self) -> bool {
244        self.kind == ProviderErrorKind::StreamInterrupted
245    }
246
247    /// Returns the provider's raw `Retry-After` header value.
248    #[must_use]
249    pub fn retry_after(&self) -> Option<&str> {
250        self.retry_after.as_deref()
251    }
252}
253
254impl From<String> for ProviderError {
255    fn from(message: String) -> Self {
256        Self::new(message)
257    }
258}
259
260impl From<&str> for ProviderError {
261    fn from(message: &str) -> Self {
262        Self::new(message)
263    }
264}
265
266/// Errors returned by möbius modules.
267#[derive(Debug, thiserror::Error)]
268pub enum Error {
269    #[error("configuration error: {0}")]
270    Config(String),
271    #[error("duplicate registration: {0}")]
272    Duplicate(String),
273    #[error("unknown registration: {0}")]
274    Unknown(String),
275    #[error("provider error: {0}")]
276    Provider(#[from] ProviderError),
277    #[error("authentication error: {0}")]
278    Auth(String),
279    #[error("sandbox rejected path: {0}")]
280    Sandbox(String),
281    #[error("tool error: {0}")]
282    Tool(String),
283    #[error("checkpoint error: {0}")]
284    Checkpoint(String),
285    #[error("agent busy: {0}")]
286    Busy(String),
287    #[error("agent stopped: {0}")]
288    Stopped(String),
289    #[error("{primary}; rollback failed: {rollback}")]
290    Rollback {
291        primary: Box<Error>,
292        rollback: Box<Error>,
293    },
294    #[error(transparent)]
295    Io(#[from] std::io::Error),
296    #[error(transparent)]
297    Http(#[from] reqwest::Error),
298    #[error(transparent)]
299    Json(#[from] serde_json::Error),
300    #[error("checkpoint storage error")]
301    Sqlite(
302        #[source]
303        #[from]
304        rusqlite::Error,
305    ),
306}
307
308/// Result type shared by möbius modules.
309pub type Result<T> = std::result::Result<T, Error>;
310
311pub(crate) fn preview_json(value: &serde_json::Value) -> String {
312    let value = value.to_string();
313    if value.len() <= 10_000 {
314        return value;
315    }
316    format!("{}…", truncate_utf8(&value, 10_000))
317}
318
319pub(crate) fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
320    &value[..value.floor_char_boundary(max_bytes)]
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn sqlite_errors_do_not_expose_engine_messages() {
329        let error = Error::from(rusqlite::Error::InvalidQuery);
330
331        assert_eq!(error.to_string(), "checkpoint storage error");
332    }
333}