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