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