Skip to main content

machi_types/
error.rs

1//! Structured errors and stable error codes.
2//!
3//! Control planes must branch on [`ErrorCode`] / [`RetryClass`], never on
4//! substring matching of [`Display`](std::fmt::Display) output.
5
6use std::fmt;
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10
11/// Machine-stable error code for control-plane handling.
12///
13/// Codes use dotted `domain.reason` strings via [`ErrorCode::as_str`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum ErrorCode {
18    // --- types ---
19    /// Invalid or empty identifier.
20    TypesInvalidId,
21    /// Message or payload failed validation.
22    TypesValidation,
23    /// Serialization failure.
24    TypesSerde,
25
26    // --- tool ---
27    /// Tool not found in registry.
28    ToolNotFound,
29    /// Tool arguments failed schema/parse.
30    ToolInvalidArgs,
31    /// Tool execution failed.
32    ToolExecution,
33    /// Tool timed out.
34    ToolTimeout,
35    /// Tool cancelled.
36    ToolCancelled,
37    /// Tool denied by policy/capability.
38    ToolDenied,
39    /// Tool call rejected by approval gate.
40    ToolApprovalDenied,
41    /// Tool stream ended without a terminal item (protocol violation).
42    ToolStreamProtocol,
43    /// Tool rate limited by upstream service.
44    ToolRateLimited,
45    /// Tool concurrency limit exceeded.
46    ToolConcurrencyLimit,
47    /// Tool network failure.
48    ToolNetwork,
49    /// Tool upstream service unavailable.
50    ToolServiceUnavailable,
51
52    // --- llm ---
53    /// LLM transport or provider failure.
54    LlmProvider,
55    /// LLM request cancelled.
56    LlmCancelled,
57    /// LLM response invalid.
58    LlmInvalidResponse,
59    /// LLM authentication / authorization failure.
60    LlmAuth,
61    /// LLM rate limited.
62    LlmRateLimit,
63    /// Stream/sample idle timeout between chunks.
64    LlmIdleTimeout,
65    /// Provider returned an empty completion (no text / tool calls).
66    LlmEmptyResponse,
67    /// Output truncated (max tokens / length limit).
68    LlmTruncated,
69
70    // --- agent ---
71    /// Agent definition invalid.
72    AgentInvalidDefinition,
73    /// Agent build failure.
74    AgentBuild,
75    /// Agent type / definition not found for resolution.
76    AgentNotFound,
77
78    // --- runtime / turn ---
79    /// Turn hit max steps.
80    RuntimeMaxSteps,
81    /// Turn cancelled.
82    RuntimeCancelled,
83    /// Runtime gate rejected the outcome.
84    RuntimeGate,
85    /// Structured output failed schema validation after retries.
86    RuntimeStructuredOutput,
87    /// Turn deadline exceeded.
88    RuntimeDeadline,
89    /// Identical tool calls repeated past the stationarity hard stop.
90    RuntimeStationarity,
91
92    // --- host ---
93    /// Host spawn failed.
94    HostSpawn,
95    /// Agent budget exhausted.
96    HostBudget,
97    /// Nested spawn depth exceeded.
98    HostDepth,
99    /// Concurrent nested agent cap exceeded.
100    HostConcurrency,
101    /// Host capability unsupported.
102    HostUnsupported,
103    /// Host cancelled.
104    HostCancelled,
105    /// Isolation backend failure.
106    HostIsolation,
107
108    // --- workflow ---
109    /// Workflow script compile/runtime failure.
110    WorkflowScript,
111    /// Journal divergence on resume.
112    WorkflowDivergence,
113    /// Journal I/O or integrity failure.
114    WorkflowJournal,
115    /// Workflow agent budget exceeded.
116    WorkflowBudget,
117    /// Workflow cancelled.
118    WorkflowCancelled,
119    /// Workflow validation / probe failure.
120    WorkflowValidate,
121
122    // --- state / memory ---
123    /// Conversation state invariant violated (e.g. dangling tool call).
124    StateInvariant,
125    /// Persistence backend I/O failure.
126    StatePersistence,
127
128    // --- compaction ---
129    /// Compaction strategy failed.
130    CompactionFailed,
131    /// Context still exceeds limits after compaction.
132    CompactionOverflow,
133
134    /// Generic internal failure.
135    Internal,
136}
137
138impl ErrorCode {
139    /// Stable `snake_case` dotted code string.
140    #[must_use]
141    pub const fn as_str(self) -> &'static str {
142        match self {
143            Self::TypesInvalidId => "types.invalid_id",
144            Self::TypesValidation => "types.validation",
145            Self::TypesSerde => "types.serde",
146            Self::ToolNotFound => "tool.not_found",
147            Self::ToolInvalidArgs => "tool.invalid_args",
148            Self::ToolExecution => "tool.execution",
149            Self::ToolTimeout => "tool.timeout",
150            Self::ToolCancelled => "tool.cancelled",
151            Self::ToolDenied => "tool.denied",
152            Self::ToolApprovalDenied => "tool.approval_denied",
153            Self::ToolStreamProtocol => "tool.stream_protocol",
154            Self::ToolRateLimited => "tool.rate_limited",
155            Self::ToolConcurrencyLimit => "tool.concurrency_limit",
156            Self::ToolNetwork => "tool.network",
157            Self::ToolServiceUnavailable => "tool.service_unavailable",
158            Self::LlmProvider => "llm.provider",
159            Self::LlmCancelled => "llm.cancelled",
160            Self::LlmInvalidResponse => "llm.invalid_response",
161            Self::LlmAuth => "llm.auth",
162            Self::LlmRateLimit => "llm.rate_limit",
163            Self::LlmIdleTimeout => "llm.idle_timeout",
164            Self::LlmEmptyResponse => "llm.empty_response",
165            Self::LlmTruncated => "llm.truncated",
166            Self::AgentInvalidDefinition => "agent.invalid_definition",
167            Self::AgentBuild => "agent.build",
168            Self::AgentNotFound => "agent.not_found",
169            Self::RuntimeMaxSteps => "runtime.max_steps",
170            Self::RuntimeCancelled => "runtime.cancelled",
171            Self::RuntimeGate => "runtime.gate",
172            Self::RuntimeStructuredOutput => "runtime.structured_output",
173            Self::RuntimeDeadline => "runtime.deadline",
174            Self::RuntimeStationarity => "runtime.stationarity",
175            Self::HostSpawn => "host.spawn",
176            Self::HostBudget => "host.budget",
177            Self::HostDepth => "host.depth",
178            Self::HostConcurrency => "host.concurrency",
179            Self::HostUnsupported => "host.unsupported",
180            Self::HostCancelled => "host.cancelled",
181            Self::HostIsolation => "host.isolation",
182            Self::WorkflowScript => "workflow.script",
183            Self::WorkflowDivergence => "workflow.divergence",
184            Self::WorkflowJournal => "workflow.journal",
185            Self::WorkflowBudget => "workflow.budget",
186            Self::WorkflowCancelled => "workflow.cancelled",
187            Self::WorkflowValidate => "workflow.validate",
188            Self::StateInvariant => "state.invariant",
189            Self::StatePersistence => "state.persistence",
190            Self::CompactionFailed => "compaction.failed",
191            Self::CompactionOverflow => "compaction.overflow",
192            Self::Internal => "internal",
193        }
194    }
195
196    /// Domain prefix (`types`, `tool`, `llm`, …).
197    #[must_use]
198    pub const fn domain(self) -> &'static str {
199        match self {
200            Self::TypesInvalidId | Self::TypesValidation | Self::TypesSerde => "types",
201            Self::ToolNotFound
202            | Self::ToolInvalidArgs
203            | Self::ToolExecution
204            | Self::ToolTimeout
205            | Self::ToolCancelled
206            | Self::ToolDenied
207            | Self::ToolApprovalDenied
208            | Self::ToolStreamProtocol
209            | Self::ToolRateLimited
210            | Self::ToolConcurrencyLimit
211            | Self::ToolNetwork
212            | Self::ToolServiceUnavailable => "tool",
213            Self::LlmProvider
214            | Self::LlmCancelled
215            | Self::LlmInvalidResponse
216            | Self::LlmAuth
217            | Self::LlmRateLimit
218            | Self::LlmIdleTimeout
219            | Self::LlmEmptyResponse
220            | Self::LlmTruncated => "llm",
221            Self::AgentInvalidDefinition | Self::AgentBuild | Self::AgentNotFound => "agent",
222            Self::RuntimeMaxSteps
223            | Self::RuntimeCancelled
224            | Self::RuntimeGate
225            | Self::RuntimeStructuredOutput
226            | Self::RuntimeDeadline
227            | Self::RuntimeStationarity => "runtime",
228            Self::HostSpawn
229            | Self::HostBudget
230            | Self::HostDepth
231            | Self::HostConcurrency
232            | Self::HostUnsupported
233            | Self::HostCancelled
234            | Self::HostIsolation => "host",
235            Self::WorkflowScript
236            | Self::WorkflowDivergence
237            | Self::WorkflowJournal
238            | Self::WorkflowBudget
239            | Self::WorkflowCancelled
240            | Self::WorkflowValidate => "workflow",
241            Self::StateInvariant | Self::StatePersistence => "state",
242            Self::CompactionFailed | Self::CompactionOverflow => "compaction",
243            Self::Internal => "internal",
244        }
245    }
246
247    /// Default retry classification for this code.
248    ///
249    /// Kernel paths set an explicit [`RetryClass`] when they know more; this
250    /// is the baseline hosts may consult.
251    #[must_use]
252    pub const fn default_retry(self) -> RetryClass {
253        match self {
254            Self::LlmRateLimit | Self::LlmProvider | Self::LlmEmptyResponse => RetryClass::Backoff,
255            Self::LlmAuth => RetryClass::AuthRefresh,
256            Self::ToolTimeout => RetryClass::Immediate,
257            Self::ToolCancelled
258            | Self::LlmCancelled
259            | Self::LlmIdleTimeout
260            | Self::LlmTruncated
261            | Self::RuntimeCancelled
262            | Self::HostCancelled
263            | Self::WorkflowCancelled
264            | Self::ToolDenied
265            | Self::ToolApprovalDenied
266            | Self::ToolNotFound
267            | Self::ToolInvalidArgs
268            | Self::ToolStreamProtocol
269            | Self::TypesInvalidId
270            | Self::TypesValidation
271            | Self::TypesSerde
272            | Self::AgentInvalidDefinition
273            | Self::AgentBuild
274            | Self::AgentNotFound
275            | Self::RuntimeMaxSteps
276            | Self::RuntimeGate
277            | Self::RuntimeStructuredOutput
278            | Self::RuntimeDeadline
279            | Self::RuntimeStationarity
280            | Self::HostBudget
281            | Self::HostDepth
282            | Self::HostConcurrency
283            | Self::HostUnsupported
284            | Self::WorkflowDivergence
285            | Self::WorkflowBudget
286            | Self::WorkflowValidate
287            | Self::StateInvariant
288            | Self::CompactionOverflow
289            | Self::Internal => RetryClass::Never,
290            Self::ToolExecution
291            | Self::ToolRateLimited
292            | Self::ToolConcurrencyLimit
293            | Self::ToolNetwork
294            | Self::ToolServiceUnavailable
295            | Self::LlmInvalidResponse
296            | Self::HostSpawn
297            | Self::HostIsolation
298            | Self::WorkflowScript
299            | Self::WorkflowJournal
300            | Self::StatePersistence
301            | Self::CompactionFailed => RetryClass::Never,
302        }
303    }
304}
305
306impl fmt::Display for ErrorCode {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        f.write_str(self.as_str())
309    }
310}
311
312/// Whether an automatic retry may be appropriate.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
314#[serde(rename_all = "snake_case")]
315#[non_exhaustive]
316pub enum RetryClass {
317    /// Do not retry.
318    #[default]
319    Never,
320    /// Safe to retry immediately.
321    Immediate,
322    /// Retry with backoff.
323    Backoff,
324    /// Refresh credentials then retry.
325    AuthRefresh,
326}
327
328/// Kernel error with stable code, message, and optional source.
329#[derive(Debug, Clone, thiserror::Error)]
330pub struct MachiError {
331    code: ErrorCode,
332    message: String,
333    retry: RetryClass,
334    #[source]
335    source: Option<Arc<dyn std::error::Error + Send + Sync>>,
336}
337
338impl MachiError {
339    /// Create an error with code and message.
340    ///
341    /// Retry class defaults to [`ErrorCode::default_retry`].
342    #[must_use]
343    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
344        Self {
345            code,
346            message: message.into(),
347            retry: code.default_retry(),
348            source: None,
349        }
350    }
351
352    /// Attach retry classification (overrides default).
353    #[must_use]
354    pub const fn with_retry(mut self, retry: RetryClass) -> Self {
355        self.retry = retry;
356        self
357    }
358
359    /// Attach a source error.
360    #[must_use]
361    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
362        self.source = Some(Arc::new(source));
363        self
364    }
365
366    /// Stable code.
367    #[must_use]
368    pub const fn code(&self) -> ErrorCode {
369        self.code
370    }
371
372    /// Retry class.
373    #[must_use]
374    pub const fn retry_class(&self) -> RetryClass {
375        self.retry
376    }
377
378    /// Human-readable message.
379    #[must_use]
380    pub fn message(&self) -> &str {
381        &self.message
382    }
383
384    /// Convenience: cancelled-style runtime error.
385    #[must_use]
386    pub fn cancelled(message: impl Into<String>) -> Self {
387        Self::new(ErrorCode::RuntimeCancelled, message)
388    }
389}
390
391impl fmt::Display for MachiError {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        write!(f, "{}: {}", self.code, self.message)
394    }
395}
396
397/// Result alias using [`MachiError`].
398pub type Result<T> = std::result::Result<T, MachiError>;
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn display_includes_code() {
406        let err = MachiError::new(ErrorCode::ToolTimeout, "exceeded 5s");
407        assert!(err.to_string().contains("tool.timeout"), "{err}");
408        assert_eq!(err.retry_class(), RetryClass::Immediate);
409    }
410
411    #[test]
412    fn rate_limit_defaults_to_backoff() {
413        let err = MachiError::new(ErrorCode::LlmRateLimit, "429");
414        assert_eq!(err.retry_class(), RetryClass::Backoff);
415        assert_eq!(err.code().domain(), "llm");
416    }
417
418    #[test]
419    fn all_codes_have_domain_prefix_in_as_str() {
420        let codes = [
421            ErrorCode::TypesInvalidId,
422            ErrorCode::ToolApprovalDenied,
423            ErrorCode::ToolStreamProtocol,
424            ErrorCode::LlmAuth,
425            ErrorCode::LlmRateLimit,
426            ErrorCode::AgentNotFound,
427            ErrorCode::RuntimeStructuredOutput,
428            ErrorCode::RuntimeDeadline,
429            ErrorCode::HostIsolation,
430            ErrorCode::WorkflowValidate,
431            ErrorCode::StateInvariant,
432            ErrorCode::StatePersistence,
433            ErrorCode::CompactionFailed,
434            ErrorCode::CompactionOverflow,
435            ErrorCode::Internal,
436        ];
437        for code in codes {
438            let s = code.as_str();
439            assert!(
440                s.starts_with(code.domain()) || code == ErrorCode::Internal,
441                "code {s} should start with domain {}",
442                code.domain()
443            );
444        }
445    }
446}
447
448include!("error_code_matrix.rs");