Skip to main content

horus/
lib.rs

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