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}