Skip to main content

mentra/runtime/
error.rs

1use crate::provider::{ProviderError, ProviderId};
2use crate::runtime::control::is_transient_provider_error;
3use thiserror::Error;
4
5/// Classifies a [`RuntimeError`] by its recoverability.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ErrorCategory {
8    /// Transient failure that may succeed on retry.
9    Retryable,
10    /// Permanent failure that cannot be retried.
11    Terminal,
12    /// Operation continued but state may be inconsistent.
13    Degraded,
14}
15
16/// Errors produced while configuring, running, or recovering Mentra agents.
17#[derive(Debug, Error)]
18pub enum RuntimeError {
19    #[error("{message}", message = provider_not_found_message(.0))]
20    ProviderNotFound(Option<ProviderId>),
21    #[error("provider '{0}' did not return any models")]
22    NoModelsAvailable(ProviderId),
23    #[error("failed to send provider request: {0}")]
24    FailedToSendRequest(#[source] ProviderError),
25    #[error("failed to list provider models: {0}")]
26    FailedToListModels(#[source] ProviderError),
27    #[error("failed to create a fresh provider session scope: {0}")]
28    FailedToCreateProviderSessionScope(#[source] ProviderError),
29    #[error(
30        "fresh provider session scope changed provider identity from '{expected}' to '{actual}'"
31    )]
32    ProviderSessionScopeIdentityMismatch {
33        expected: ProviderId,
34        actual: ProviderId,
35    },
36    #[error("failed to stream provider response: {0}")]
37    FailedToStreamResponse(#[source] ProviderError),
38    #[error("failed to compact history: {0}")]
39    FailedToCompactHistory(#[source] ProviderError),
40    #[error("failed to persist transcript: {0}")]
41    FailedToPersistTranscript(#[source] std::io::Error),
42    #[error("failed to serialize transcript: {0}")]
43    FailedToSerializeTranscript(#[source] serde_json::Error),
44    #[error("failed to load tasks: {0}")]
45    FailedToLoadTasks(#[source] std::io::Error),
46    #[error("failed to write tasks: {0}")]
47    FailedToWriteTasks(#[source] std::io::Error),
48    #[error("failed to serialize tasks: {0}")]
49    FailedToSerializeTasks(#[source] serde_json::Error),
50    #[error("failed to restore tasks: {0}")]
51    FailedToRestoreTasks(#[source] std::io::Error),
52    #[error("failed to load team state: {0}")]
53    FailedToLoadTeam(#[source] std::io::Error),
54    #[error("failed to write team state: {0}")]
55    FailedToWriteTeam(#[source] std::io::Error),
56    #[error("failed to serialize team state: {0}")]
57    FailedToSerializeTeam(#[source] serde_json::Error),
58    #[error("failed to deserialize team state: {0}")]
59    FailedToDeserializeTeam(#[source] serde_json::Error),
60    #[error("invalid task state: {0}")]
61    InvalidTask(String),
62    #[error("invalid team state: {0}")]
63    InvalidTeam(String),
64    #[error("operation denied: {0}")]
65    OperationDenied(String),
66    #[error("runtime store error: {0}")]
67    Store(String),
68    #[error("cannot branch: {0}")]
69    Branch(#[source] crate::transcript::BranchError),
70    #[error("lease unavailable: {0}")]
71    LeaseUnavailable(String),
72    #[error("operation cancelled")]
73    Cancelled,
74    #[error("deadline exceeded")]
75    DeadlineExceeded,
76    #[error("tool budget exceeded at {0} call(s)")]
77    ToolBudgetExceeded(usize),
78    #[error("model budget exceeded at {0} request(s)")]
79    ModelBudgetExceeded(usize),
80    #[error("max rounds exceeded at {0}")]
81    MaxRoundsExceeded(usize),
82    #[error("run completed without a final assistant message")]
83    EmptyAssistantResponse,
84    #[error("no resumable user turn is available")]
85    NoResumableTurn,
86    /// [`Agent::continue_interrupted`](crate::agent::Agent::continue_interrupted)
87    /// on an agent whose transcript does not end inside an interrupted turn.
88    #[error("no interrupted turn is available to continue")]
89    NoInterruptedTurn,
90    #[error("invalid tool input for '{name}' ({id}): {source}")]
91    InvalidToolUseInput {
92        id: String,
93        name: String,
94        #[source]
95        source: serde_json::Error,
96    },
97    #[error("malformed provider event: {0}")]
98    MalformedProviderEvent(String),
99    #[error(
100        "subagent template built from '{template_source}' cannot be spawned by '{receiver}': \
101         a template is only spawnable by the agent (on the same runtime) it was built from"
102    )]
103    SubagentTemplateMismatch {
104        template_source: String,
105        receiver: String,
106    },
107}
108
109impl RuntimeError {
110    /// Returns the [`ErrorCategory`] for this error, classifying it as
111    /// retryable, terminal, or degraded.
112    pub fn category(&self) -> ErrorCategory {
113        match self {
114            // Provider-backed errors: delegate to transient check.
115            Self::FailedToSendRequest(source)
116            | Self::FailedToStreamResponse(source)
117            | Self::FailedToCompactHistory(source) => {
118                if is_transient_provider_error(source) {
119                    ErrorCategory::Retryable
120                } else {
121                    ErrorCategory::Terminal
122                }
123            }
124
125            // Listing models is not retryable even if the provider error is transient.
126            Self::FailedToListModels(_) => ErrorCategory::Terminal,
127
128            // Configuration and logic errors are permanent.
129            Self::ProviderNotFound(_)
130            | Self::NoModelsAvailable(_)
131            | Self::FailedToCreateProviderSessionScope(_)
132            | Self::ProviderSessionScopeIdentityMismatch { .. }
133            | Self::OperationDenied(_)
134            | Self::Cancelled
135            | Self::DeadlineExceeded
136            | Self::ToolBudgetExceeded(_)
137            | Self::ModelBudgetExceeded(_)
138            | Self::MaxRoundsExceeded(_)
139            | Self::EmptyAssistantResponse
140            | Self::NoResumableTurn
141            | Self::NoInterruptedTurn
142            | Self::InvalidToolUseInput { .. }
143            | Self::MalformedProviderEvent(_)
144            | Self::InvalidTask(_)
145            | Self::InvalidTeam(_)
146            | Self::SubagentTemplateMismatch { .. }
147            // Naming an entry that is not there is a caller mistake; retrying
148            // with the same id gets the same answer.
149            | Self::Branch(_) => ErrorCategory::Terminal,
150
151            // Persistence and store errors: state may be inconsistent.
152            Self::FailedToPersistTranscript(_)
153            | Self::FailedToSerializeTranscript(_)
154            | Self::FailedToLoadTasks(_)
155            | Self::FailedToWriteTasks(_)
156            | Self::FailedToSerializeTasks(_)
157            | Self::FailedToRestoreTasks(_)
158            | Self::FailedToLoadTeam(_)
159            | Self::FailedToWriteTeam(_)
160            | Self::FailedToSerializeTeam(_)
161            | Self::FailedToDeserializeTeam(_)
162            | Self::Store(_)
163            | Self::LeaseUnavailable(_) => ErrorCategory::Degraded,
164        }
165    }
166}
167
168fn provider_not_found_message(provider: &Option<ProviderId>) -> String {
169    match provider {
170        Some(provider) => format!("provider '{provider}' is not registered"),
171        None => "no providers are registered".to_string(),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use std::error::Error;
178
179    use super::{ErrorCategory, RuntimeError};
180    use crate::provider::{ProviderError, ProviderId};
181
182    #[test]
183    fn display_mentions_missing_provider() {
184        let error = RuntimeError::ProviderNotFound(Some(ProviderId::new("custom")));
185
186        assert_eq!(error.to_string(), "provider 'custom' is not registered");
187    }
188
189    #[test]
190    fn display_mentions_missing_models() {
191        let error = RuntimeError::NoModelsAvailable(ProviderId::new("custom"));
192
193        assert_eq!(
194            error.to_string(),
195            "provider 'custom' did not return any models"
196        );
197    }
198
199    #[test]
200    fn source_is_exposed_for_wrapped_errors() {
201        let error = RuntimeError::FailedToSerializeTasks(
202            serde_json::from_str::<serde_json::Value>("{").expect_err("invalid json"),
203        );
204
205        assert!(error.source().is_some());
206    }
207
208    #[test]
209    fn empty_assistant_response_has_clear_display_text() {
210        assert_eq!(
211            RuntimeError::EmptyAssistantResponse.to_string(),
212            "run completed without a final assistant message"
213        );
214    }
215
216    #[test]
217    fn transient_provider_error_is_retryable() {
218        let error = RuntimeError::FailedToSendRequest(ProviderError::Retryable {
219            message: "rate limited".into(),
220            delay: None,
221        });
222
223        assert_eq!(error.category(), ErrorCategory::Retryable);
224    }
225
226    #[test]
227    fn permanent_provider_error_is_terminal() {
228        let error =
229            RuntimeError::FailedToSendRequest(ProviderError::InvalidRequest("bad body".into()));
230
231        assert_eq!(error.category(), ErrorCategory::Terminal);
232    }
233
234    #[test]
235    fn budget_exceeded_is_terminal() {
236        assert_eq!(
237            RuntimeError::ToolBudgetExceeded(100).category(),
238            ErrorCategory::Terminal,
239        );
240        assert_eq!(
241            RuntimeError::ModelBudgetExceeded(50).category(),
242            ErrorCategory::Terminal,
243        );
244    }
245
246    #[test]
247    fn display_names_both_parties_of_a_subagent_template_mismatch() {
248        let error = RuntimeError::SubagentTemplateMismatch {
249            template_source: "agent-a".to_string(),
250            receiver: "agent-b".to_string(),
251        };
252
253        let message = error.to_string();
254        assert!(message.contains("agent-a"));
255        assert!(message.contains("agent-b"));
256    }
257
258    #[test]
259    fn subagent_template_mismatch_is_terminal() {
260        assert_eq!(
261            RuntimeError::SubagentTemplateMismatch {
262                template_source: "agent-a".to_string(),
263                receiver: "agent-b".to_string(),
264            }
265            .category(),
266            ErrorCategory::Terminal,
267        );
268    }
269
270    #[test]
271    fn persistence_io_error_is_degraded() {
272        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "disk full");
273        let error = RuntimeError::FailedToPersistTranscript(io_err);
274
275        assert_eq!(error.category(), ErrorCategory::Degraded);
276    }
277}