From 284effff8f73f9311bac109e3f2dac1dcd5af929 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Mon, 4 May 2026 17:38:49 +1200 Subject: [PATCH 1/3] chore:clippy and fmt setup --- .clippy.toml | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 15 ++++++++++++ rustfmt.toml | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 .clippy.toml create mode 100644 rustfmt.toml diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..b18b638 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,66 @@ +# loopctl clippy configuration +# Strict linting for the entire project. +# Run: cargo clippy --all-features -- -D warnings + +# Thresholds + +# Maximum cognitive complexity per function before warning. +cognitive-complexity-threshold = 30 + +# Maximum number of struct fields before suggesting a builder. +struct-field-name-threshold = 8 + +# Type complexity threshold (default is 250). +type-complexity-threshold = 250 + +# Maximum lines of code per function body before warning. +too-many-lines-threshold = 150 + +# Maximum number of arguments before warning. +too-many-arguments-threshold = 7 + +# MSRV from Cargo.toml. +msrv = "1.85.0" + +# Warn when passing types larger than this by value (bytes). +trivial-copy-size-limit = 32 + +# Warn on large stack frames. +stack-size-threshold = 512000 + +# Warn when a single-char binding name is used more than N times. +single-char-binding-names-threshold = 4 + +# Style enforcement + +disallowed-names = ["foo", "bar", "baz", "quux"] + +doc-valid-idents = [ + "GraphQL", "WebSocket", "REST", "gRPC", + "OpenAI", "Anthropic", "HuggingFace", + "OAuth", "JWT", "HMAC", "SHA", + "Linux", "macOS", "FreeBSD", + "AccessKit", "CoreFoundation", "Direct2D", + "Direct3D", "DirectWrite", "OpenSSL", + "MinGW", "NaN", "NaNs", +] + +# Lint behavior + +avoid-breaking-exported-api = false +check-private-items = true +allow-one-hash-in-raw-strings = true + +enum-variant-size-threshold = 200 +large-error-threshold = 128 +pass-by-value-size-limit = 256 +vec-box-size-threshold = 128 +unnecessary-box-size = 128 +literal-representation-threshold = 4 +verbose-bit-mask-threshold = 1 + +max-struct-bools = 3 +max-fn-params-bools = 2 +max-trait-bounds = 4 + +standard-macro-braces = [] diff --git a/Cargo.toml b/Cargo.toml index 4379eaa..03b66d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,18 @@ proptest = "1" [features] default = [] testing = [] + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unreachable = "deny" +arithmetic_side_effects = "deny" +indexing_slicing = "deny" +match_same_arms = "deny" +redundant_clone = "deny" +clone_on_ref_ptr = "deny" +let_underscore_must_use = "deny" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..fd92d47 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,41 @@ +# loopctl formatting rules +# Derived from the project's existing style (edition 2024, rustfmt 1.8.0). +# Run: cargo fmt --all -- --check + +# Line width +max_width = 100 + +# Indentation +hard_tabs = false +tab_spaces = 4 + +# Newlines +newline_style = "Unix" + +# Functions & calls +fn_params_layout = "Tall" +fn_call_width = 60 +attr_fn_like_width = 70 +struct_lit_width = 18 +struct_variant_width = 35 +array_width = 60 +chain_width = 60 +single_line_if_else_max_width = 50 +single_line_let_else_max_width = 50 + +# Heuristics & expressions +use_small_heuristics = "Default" +short_array_element_width_threshold = 10 +remove_nested_parens = true +match_arm_leading_pipes = "Never" +match_block_trailing_comma = false + +# Imports +reorder_imports = true +reorder_modules = true + +# Miscellaneous +merge_derives = true +use_field_init_shorthand = true +force_explicit_abi = true + From f41ecfcbc5e76dbb3a9cd590b1d8c96bd48ff7fc Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Mon, 4 May 2026 18:10:00 +1200 Subject: [PATCH 2/3] feat: api error, clippy and fmt --- Makefile | 7 +- src/api_error.rs | 1368 +++++++++++++++++++++++++++++++++++++++++++++ src/core/error.rs | 21 +- src/lib.rs | 6 +- src/message.rs | 31 +- 5 files changed, 1410 insertions(+), 23 deletions(-) create mode 100644 src/api_error.rs diff --git a/Makefile b/Makefile index 48dcf9c..de8b854 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: check test clippy fmt docs ci +.PHONY: check test clippy fmt docs ci lint -ci: check test clippy fmt docs +ci: fmt check clippy test docs check: cargo check --all-features @@ -14,5 +14,8 @@ clippy: fmt: cargo fmt --all -- --check +lint: + cargo fmt --all + docs: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features diff --git a/src/api_error.rs b/src/api_error.rs new file mode 100644 index 0000000..c0c4c08 --- /dev/null +++ b/src/api_error.rs @@ -0,0 +1,1368 @@ +//! API and infrastructure error types. +//! +//! This module provides the error hierarchy for LLM API interactions, +//! tool execution, configuration handling, and general infrastructure +//! operations. Every failure mode an agent can encounter is captured by +//! [`ApiError`], with a corresponding [`ErrorCode`] for programmatic +//! matching, logging, and metrics. +//! +//! # Error Categories +//! +//! Errors are organized into numeric ranges so that monitoring dashboards +//! and alert rules can filter by category without matching individual variants: +//! +//! | Category | Code Range | Variants | +//! |----------|------------|---------------------------------------------------------| +//! | API | 1000–1005 | Request, response, rate limit, timeout, stream, context | +//! | Auth | 1100–1101 | Invalid key, auth failed | +//! | HTTP | 1200–1202 | Connection, request, response | +//! | Tool | 1300–1304 | Not found, execution, permission, input, timeout | +//! | Config | 1400–1403 | File not found, parse, validation, missing | +//! | I/O | 1500–1502 | File not found, read, write | +//! | JSON | 1600 | Parse | +//! | Internal | 1700 | Internal error | +//! | Signal | 1999 | Interrupted | +//! +//! # Provided Types +//! +//! - **[`ApiError`]** — The main error enum with ergonomic constructors +//! like [`ApiError::api`], [`ApiError::tool`], and [`ApiError::config`]. +//! - **[`ErrorCode`]** — A machine-readable code derived from an [`ApiError`] +//! via [`ApiError::code`]. +//! - **[`Result`]** — A convenience alias for `std::result::Result`. +//! +//! # Error Flow +//! +//! ```text +//! [API / Tool / Config / I/O] +//! │ +//! ▼ +//! ApiError (enum) +//! │ +//! ├──→ code() → ErrorCode (u32) +//! ├──→ is_retryable() → bool +//! ├──→ is_auth_error() / is_config_error() / … +//! │ +//! ▼ +//! Result (= std::result::Result) +//! ``` +//! +//! # Quick Start +//! +//! ```rust +//! use loopctl::api_error::{ApiError, ErrorCode}; +//! +//! // Construct errors with the ergonomic helpers +//! let err = ApiError::api("request failed"); +//! assert_eq!(err.code(), ErrorCode::ApiRequestFailed); +//! assert!(err.is_retryable()); +//! +//! // Pattern-match on the error kind +//! match err { +//! ApiError::Api(msg) => println!("API failure: {msg}"), +//! _ => unreachable!(), +//! } +//! ``` + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +// ================================================== +// ErrorCode +// ================================================== + +/// Machine-readable error codes for programmatic error handling. +/// +/// Each [`ErrorCode`] variant maps to a stable numeric value (see the +/// module-level table) designed for logging pipelines, metrics dashboards, +/// and cross-language interoperability. Codes are derived automatically +/// from an [`ApiError`] via [`ApiError::code`] — you rarely construct +/// them directly. +/// +/// # Stability +/// +/// The numeric values are part of the public API and will not change +/// across semver-compatible releases. New codes may be appended within +/// existing category ranges. +/// +/// # Example +/// +/// ```rust +/// use loopctl::api_error::{ApiError, ErrorCode}; +/// +/// let err = ApiError::api_rate_limited(); +/// assert_eq!(err.code(), ErrorCode::ApiRateLimited); +/// assert_eq!(err.code() as u32, 1002); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ErrorCode { + // ================================================== + // API errors (1000-1005) + // ================================================== + /// A generic LLM API request failed. + /// + /// Returned when the API responds with an unexpected status or the + /// request could not be completed for an unspecified reason. + /// Maps to numeric code **1000**. + /// + /// This is the default code for [`ApiError::Api`] variants whose + /// message does not match a more specific pattern (timeout, rate + /// limit, stream, or context overflow). + /// + /// # When to use + /// + /// Use [`ApiError::api`] to construct errors that map to this code. + /// The framework's retry loop treats this code as retryable via + /// [`ApiError::is_retryable`]. + /// + ApiRequestFailed = 1000, + + /// The API response could not be parsed. + /// + /// Indicates the server returned data in an unexpected format — for + /// example a changed JSON schema or a truncated body. Maps to **1001**. + /// + /// This error is **not** retryable; the response format is unlikely + /// to change on subsequent attempts. Investigate version + /// compatibility or schema changes. + /// + ApiResponseInvalid = 1001, + + /// The API rate limit was exceeded. + /// + /// The caller should back off and retry after the period indicated by + /// the provider's headers. [`ApiError::is_retryable`] returns `true`. + /// Maps to **1002**. + /// + ApiRateLimited = 1002, + + /// The API request timed out. + /// + /// The request took longer than the configured timeout. Safe to retry + /// (possibly with a longer timeout). Maps to **1003**. + /// + ApiTimeout = 1003, + + /// An error occurred while reading the SSE stream. + /// + /// The connection was established but the stream was interrupted or + /// produced malformed events. Maps to **1004**. + /// + ApiStreamError = 1004, + + /// The request exceeded the model's context window. + /// + /// The combined prompt `max_tokens` exceeds the model's limit. The + /// agent should reduce the context (e.g. via summarisation or + /// truncation) before retrying. Maps to **1005**. + /// + ApiContextOverflow = 1005, + + // ================================================== + // Auth errors (1100-1101) + // ================================================== + /// The API key is invalid or missing. + /// + /// The key may be expired, revoked, or not yet set. Check + /// environment variables and configuration files. Maps to **1100**. + /// + AuthInvalidKey = 1100, + + /// Authentication failed for a non-key-related reason. + /// + /// Covers token-expiry, account-suspension, or other auth-layer + /// rejections that are not specifically about an invalid key. + /// Maps to **1101**. + /// + AuthFailed = 1101, + + // ================================================== + // HTTP errors (1200-1202) + // ================================================== + /// A low-level HTTP connection error. + /// + /// Typically a DNS failure, TCP timeout, or TLS handshake error. + /// Maps to **1200**. + /// + /// Constructed via [`ApiError::http`] or [`ApiError::from_hyper`]. + /// This code is considered retryable by [`ApiError::is_retryable`] + /// because connection issues are often transient. + /// + HttpConnectionError = 1200, + + /// An HTTP request construction or send error. + /// + /// The request was malformed or could not be serialised before + /// reaching the server. Maps to **1201**. + /// + /// This code is retryable — a resend may succeed if the root cause + /// was a transient serialisation glitch. + /// + HttpRequestError = 1201, + + /// An HTTP response error (non-success status). + /// + /// The server responded but with a status code outside the 2xx range. + /// Maps to **1202**. + /// + /// Use [`ApiError::http_with_status`] to embed the status code in + /// the message for downstream log aggregation. + /// + HttpResponseError = 1202, + + // ================================================== + // Tool errors (1300-1304) + // ================================================== + /// The requested tool does not exist. + /// + /// The agent asked for a tool name that is not registered with the + /// tool registry. Maps to **1300**. + /// + /// Constructed via [`ApiError::tool_not_found`]. Not retryable — + /// the tool must be registered before use. + /// + ToolNotFound = 1300, + + /// Tool execution failed. + /// + /// The tool was found and invoked but returned an error during + /// processing. Maps to **1301**. + /// + /// Constructed via [`ApiError::tool`] when the message does not + /// match a more specific tool-error pattern. + /// + ToolExecutionFailed = 1301, + + /// Permission denied for tool execution. + /// + /// The agent is not authorised to run this tool, or the tool's + /// sandbox policy rejected the operation. Maps to **1302**. + /// + /// Constructed via [`ApiError::tool_permission`]. Not retryable — + /// permissions must be updated before the operation can succeed. + /// + ToolPermissionDenied = 1302, + + /// Tool input validation failed. + /// + /// The arguments provided to the tool did not satisfy its schema. + /// Maps to **1303**. + /// + /// Constructed via [`ApiError::tool_input_invalid`]. Not retryable — + /// the caller must fix the arguments. + /// + ToolInputInvalid = 1303, + + /// Tool execution timed out. + /// + /// The tool ran longer than its configured time limit. Maps to **1304**. + /// + /// May be retryable if the timeout was caused by a transient slowdown, + /// but the caller should consider increasing the timeout first. + /// + ToolTimeout = 1304, + + // ================================================== + // Config errors (1400-1403) + // ================================================== + /// The configuration file could not be found. + /// + /// The path pointed to by the config setting does not exist or is + /// not readable. Maps to **1400**. + /// + ConfigFileNotFound = 1400, + + /// The configuration file could not be parsed. + /// + /// The file exists but contains invalid TOML / JSON / YAML. Maps + /// to **1401**. + /// + ConfigParseError = 1401, + + /// Configuration values failed semantic validation. + /// + /// The file parsed correctly but a value is out of range or + /// semantically invalid (e.g. negative timeout). Maps to **1402**. + /// + ConfigValidationError = 1402, + + /// A required configuration value is missing entirely. + /// + /// No value was provided and no default is available. Maps to **1403**. + /// + ConfigMissing = 1403, + + // ================================================== + // I/O errors (1500-1502) + // ================================================== + /// A required file was not found on disk. + /// + /// Similar to [`ConfigFileNotFound`] but for general I/O paths. + /// Maps to **1500**. + /// + /// [`ConfigFileNotFound`]: ErrorCode::ConfigFileNotFound + /// + IoFileNotFound = 1500, + + /// A file read operation failed. + /// + /// Permission denied, I/O error, or unexpected EOF. Maps to **1501**. + /// + IoReadError = 1501, + + /// A file write operation failed. + /// + /// Disk full, permission denied, or path invalid. Maps to **1502**. + /// + IoWriteError = 1502, + + // ================================================== + // JSON errors (1600) + // ================================================== + /// A JSON payload could not be parsed. + /// + /// The response body or request payload contained malformed JSON. + /// Maps to **1600**. + /// + JsonParseError = 1600, + + // ================================================== + // Internal (1700) + // ================================================== + /// An internal / unexpected error. + /// + /// Catch-all for bugs or unclassifiable failures. If you see this + /// code in production please file a bug report. Maps to **1700**. + /// + InternalError = 1700, + + // ================================================== + // Signal (1999) + // ================================================== + /// The operation was interrupted by a user signal (e.g. Ctrl-C). + /// + /// Not an error per se — the agent loop checks for this code to + /// perform graceful shutdown. Maps to **1999**. + /// + Interrupted = 1999, +} + +// ================================================== +// ApiError +// ================================================== + +/// Main error type for API and infrastructure operations. +/// +/// This enum covers all failure modes an agent might encounter when +/// interacting with LLM APIs, executing tools, or managing configuration +/// and I/O. Each variant stores a human-readable message (or a source +/// error via `#[from]`) and can be mapped to a stable [`ErrorCode`] via +/// [`ApiError::code`]. +/// +/// # Construction +/// +/// Prefer the ergonomic constructor methods over enum variants directly: +/// +/// ```rust +/// use loopctl::api_error::{ApiError, ErrorCode}; +/// // Instead of ApiError::Api("…".into()) +/// let err = ApiError::api("request failed"); +/// +/// // Contextual constructors embed structured messages +/// let err = ApiError::tool_not_found("Bash"); +/// let err = ApiError::config_not_found("/etc/loopctl.toml"); +/// ``` +/// +/// # Error Classification +/// +/// The [`ApiError::code`] method inspects the error message to select the +/// most specific [`ErrorCode`] available. For example, an [`ApiError::Api`] +/// variant whose message contains `"rate limit"` will map to +/// [`ErrorCode::ApiRateLimited`] rather than the generic +/// [`ErrorCode::ApiRequestFailed`]. +/// +/// # Retry Guidance +/// +/// Use [`ApiError::is_retryable`] to decide whether to retry an operation: +/// +/// ```rust,ignore +/// if error.is_retryable() { +/// tokio::time::sleep(backoff).await; +/// continue; +/// } +/// ``` +/// +/// # Variants +/// +/// Each variant maps to a category of [`ErrorCode`]. See the individual +/// variant documentation for details on when each is produced and how +/// the message text influences code selection. +#[derive(Debug, Error)] +pub enum ApiError { + /// An error returned by the LLM API provider. + /// + /// Covers request failures, rate limits, timeouts, streaming errors, + /// and context-overflow conditions. The message is inspected by + /// [`ApiError::code`] to select the most specific [`ErrorCode`]. + /// + #[error("API error: {0}")] + Api(String), + + /// An authentication or authorisation error. + /// + /// Invalid API keys, expired tokens, or account-level restrictions. + /// The message text determines whether [`ErrorCode::AuthInvalidKey`] + /// or [`ErrorCode::AuthFailed`] is returned by [`ApiError::code`]. + /// + #[error("Authentication error: {0}")] + Auth(String), + + /// An HTTP-level transport error. + /// + /// Connection failures, DNS errors, TLS handshake problems, or + /// non-success status codes. Created via [`ApiError::http`] or + /// [`ApiError::http_with_status`]. + /// + #[error("HTTP error: {0}")] + Http(String), + + /// A JSON serialisation / deserialisation error. + /// + /// Automatically converted from [`serde_json::Error`] via the + /// `#[from]` attribute. Maps to [`ErrorCode::JsonParseError`]. + /// + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// A general I/O error. + /// + /// Automatically converted from [`std::io::Error`] via the `#[from]` + /// attribute. Maps to [`ErrorCode::IoReadError`]. + /// + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// A tool execution error. + /// + /// Covers tool-not-found, execution failures, permission denials, + /// invalid inputs, and timeouts. Prefer the specific constructors + /// ([`ApiError::tool_not_found`], [`ApiError::tool_permission`], + /// [`ApiError::tool_input_invalid`]) for richer context. + /// + #[error("Tool error: {0}")] + Tool(String), + + /// A configuration error. + /// + /// File-not-found, parse failures, validation errors, or missing + /// required values. Prefer the specific constructors + /// ([`ApiError::config_not_found`], [`ApiError::config_validation`]) + /// for structured messages. + /// + #[error("Configuration error: {0}")] + Config(String), + + /// The operation was interrupted by a user signal. + /// + /// Typically triggered by SIGINT (Ctrl-C). The agent loop checks + /// for this variant to perform graceful shutdown. Maps to + /// [`ErrorCode::Interrupted`]. + /// + #[error("Interrupted")] + Interrupted, + + /// A catch-all error for cases that don't fit another variant. + /// + /// Maps to [`ErrorCode::InternalError`]. Prefer a more specific + /// variant or constructor when possible. + /// + #[error("Other: {0}")] + Other(String), +} + +impl ApiError { + // ================================================== + // Classifiers + // ================================================== + // + // These methods inspect the error variant (and often the message + // text) to produce a classification — either an [`ErrorCode`] or + // a boolean predicate. They are called by retry loops, logging + // middleware, and metrics collectors throughout the framework. + + /// Derive the machine-readable [`ErrorCode`] for this error. + /// + /// Inspects the error variant and, for message-based variants + /// ([`ApiError::Api`], [`ApiError::Tool`], [`ApiError::Config`]), + /// parses the message text to select the most specific code + /// available. + /// + /// # Classification Strategy + /// + /// ```text + /// ApiError::Api(msg) + /// ├─ "timeout" → ApiTimeout + /// ├─ "rate limit" / "429" → ApiRateLimited + /// ├─ "stream" → ApiStreamError + /// ├─ context keywords → ApiContextOverflow + /// └─ (default) → ApiRequestFailed + /// + /// ApiError::Auth(msg) + /// ├─ "invalid" → AuthInvalidKey + /// └─ (default) → AuthFailed + /// + /// ApiError::Tool(msg) + /// ├─ "not found" → ToolNotFound + /// ├─ "permission" / "denied"→ ToolPermissionDenied + /// ├─ "timeout" → ToolTimeout + /// ├─ "invalid" → ToolInputInvalid + /// └─ (default) → ToolExecutionFailed + /// + /// ApiError::Config(msg) + /// ├─ "not found" → ConfigFileNotFound + /// ├─ "validation" → ConfigValidationError + /// ├─ "missing" → ConfigMissing + /// └─ (default) → ConfigParseError + /// + /// ApiError::Http(_) → HttpConnectionError + /// ApiError::Json(_) → JsonParseError + /// ApiError::Io(_) → IoReadError + /// ApiError::Interrupted → Interrupted + /// ApiError::Other(_) → InternalError + /// ``` + /// + /// # When called + /// + /// Called by logging middleware, metrics collectors, and retry + /// logic throughout the framework. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api("rate limit exceeded (429)"); + /// assert_eq!(err.code(), ErrorCode::ApiRateLimited); + /// ``` + /// + #[must_use] + pub fn code(&self) -> ErrorCode { + match self { + Self::Api(msg) => { + let msg_lower = msg.to_lowercase(); + if msg_lower.contains("timeout") { + ErrorCode::ApiTimeout + } else if msg_lower.contains("rate limit") || msg_lower.contains("429") { + ErrorCode::ApiRateLimited + } else if msg_lower.contains("stream") { + ErrorCode::ApiStreamError + } else if Self::is_context_overflow_internal(msg) { + ErrorCode::ApiContextOverflow + } else { + ErrorCode::ApiRequestFailed + } + } + Self::Auth(msg) => { + if msg.to_lowercase().contains("invalid") { + ErrorCode::AuthInvalidKey + } else { + ErrorCode::AuthFailed + } + } + Self::Http(_) => ErrorCode::HttpConnectionError, + Self::Json(_) => ErrorCode::JsonParseError, + Self::Io(_) => ErrorCode::IoReadError, + Self::Tool(msg) => { + let msg_lower = msg.to_lowercase(); + if msg_lower.contains("not found") { + ErrorCode::ToolNotFound + } else if msg_lower.contains("permission") || msg_lower.contains("denied") { + ErrorCode::ToolPermissionDenied + } else if msg_lower.contains("timeout") { + ErrorCode::ToolTimeout + } else if msg_lower.contains("invalid") { + ErrorCode::ToolInputInvalid + } else { + ErrorCode::ToolExecutionFailed + } + } + Self::Config(msg) => { + let msg_lower = msg.to_lowercase(); + if msg_lower.contains("not found") { + ErrorCode::ConfigFileNotFound + } else if msg_lower.contains("validation") { + ErrorCode::ConfigValidationError + } else if msg_lower.contains("missing") { + ErrorCode::ConfigMissing + } else { + ErrorCode::ConfigParseError + } + } + Self::Interrupted => ErrorCode::Interrupted, + Self::Other(_) => ErrorCode::InternalError, + } + } + + /// Check whether this error indicates a context-window overflow. + /// + /// Returns `true` when the error message contains keywords that + /// strongly suggest the prompt exceeded the model's maximum context + /// length — e.g. `"context"`, `"too many tokens"`, or + /// `"context length"`. + /// + /// # When called + /// + /// The agent loop calls this after each failed API call to decide + /// whether to attempt context reduction (summarisation, truncation) + /// before retrying. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api("context length exceeded"); + /// assert!(err.is_context_overflow()); + /// + /// let err = ApiError::api("server error"); + /// assert!(!err.is_context_overflow()); + /// ``` + /// + #[must_use] + pub fn is_context_overflow(&self) -> bool { + match self { + Self::Api(msg) | Self::Other(msg) => Self::is_context_overflow_internal(msg), + _ => false, + } + } + + /// Internal helper that checks a message string for context-overflow + /// keywords. + /// + /// Not part of the public API — extracted so both [`Self::code`] + /// and [`Self::is_context_overflow`] share the same heuristic. + /// + /// The detection looks for any of the following case-insensitive + /// substrings within the message: + /// + /// - `"context"` — catches "context length exceeded" and similar + /// - `"too many tokens"` — OpenAI-style phrasing + /// - `"exceeds maximum"` — generic overflow wording + /// - `"max tokens"` — common provider phrasing + /// - `"context length"` — redundant with `"context"` but included + /// for explicit clarity + /// + fn is_context_overflow_internal(msg: &str) -> bool { + let msg_lower = msg.to_lowercase(); + msg_lower.contains("context") + || msg_lower.contains("too many tokens") + || msg_lower.contains("exceeds maximum") + || msg_lower.contains("max tokens") + || msg_lower.contains("context length") + } + + /// Check whether this error is safe to retry. + /// + /// Returns `true` for transient failures where a retry (possibly + /// with back-off) has a reasonable chance of succeeding: + /// [`ErrorCode::ApiRequestFailed`], [`ErrorCode::ApiRateLimited`], + /// [`ErrorCode::ApiTimeout`], [`ErrorCode::HttpConnectionError`], + /// and [`ErrorCode::HttpRequestError`]. + /// + /// # When called + /// + /// Retry loops in the API client layer and agent loop call this + /// before deciding whether to re-dispatch a request. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api_rate_limited(); + /// assert!(err.is_retryable()); + /// + /// let err = ApiError::auth("bad key"); + /// assert!(!err.is_retryable()); + /// ``` + /// + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self.code(), + ErrorCode::ApiRequestFailed + | ErrorCode::ApiRateLimited + | ErrorCode::ApiTimeout + | ErrorCode::HttpConnectionError + | ErrorCode::HttpRequestError + ) + } + + /// Check whether this is an authentication error ([`ApiError::Auth`]). + /// + /// Useful for deciding whether to prompt the user for a new API key + /// versus showing a generic error message. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::ApiError; + /// let error = ApiError::auth("bad key"); + /// if error.is_auth_error() { + /// eprintln!("Please check your API key."); + /// } + /// ``` + /// + #[must_use] + pub const fn is_auth_error(&self) -> bool { + matches!(self, Self::Auth(..)) + } + + /// Check whether this is a configuration error ([`ApiError::Config`]). + /// + /// Useful for directing the user to fix their config file rather + /// than retrying. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::ApiError; + /// let error = ApiError::config("bad config"); + /// if error.is_config_error() { + /// eprintln!("Configuration problem — check loopctl.toml."); + /// } + /// ``` + /// + #[must_use] + pub const fn is_config_error(&self) -> bool { + matches!(self, Self::Config(..)) + } + + /// Check whether this is an I/O error ([`ApiError::Io`]). + /// + /// Returns `true` for file-system failures such as missing files, + /// permission errors, or disk-full conditions. + /// + #[must_use] + pub const fn is_io_error(&self) -> bool { + matches!(self, Self::Io(..)) + } + + /// Check whether this is a tool execution error ([`ApiError::Tool`]). + /// + /// Returns `true` for any tool-related failure — not found, + /// execution error, permission denied, invalid input, or timeout. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool_not_found("Bash"); + /// assert!(err.is_tool_error()); + /// ``` + /// + #[must_use] + pub const fn is_tool_error(&self) -> bool { + matches!(self, Self::Tool(..)) + } + + // ================================================== + // Constructors + // ================================================== + // + // Convenience methods that create the appropriate [`ApiError`] + // variant with a pre-formatted message. The message formatting + // is significant because [`ApiError::code`] performs keyword + // matching on the text to select the most specific [`ErrorCode`]. + + /// Create a generic [`ApiError::Api`] variant. + /// + /// Use this for LLM API failures that don't warrant a more specific + /// constructor. The message will be inspected by [`ApiError::code`] + /// to select the best [`ErrorCode`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api("unexpected 502 from upstream"); + /// ``` + /// + pub fn api(msg: impl Into) -> Self { + Self::Api(msg.into()) + } + + /// Create a generic [`ApiError::Auth`] variant. + /// + /// The message is inspected by [`ApiError::code`]: if it contains + /// `"invalid"` the code will be [`ErrorCode::AuthInvalidKey`], + /// otherwise [`ErrorCode::AuthFailed`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::auth("token expired"); + /// assert_eq!(err.code(), ErrorCode::AuthFailed); + /// ``` + /// + /// + pub fn auth(msg: impl Into) -> Self { + Self::Auth(msg.into()) + } + + /// Create an [`ApiError::Auth`] variant with an "Invalid API key" prefix. + /// + /// The prefixed message ensures [`ApiError::code`] returns + /// [`ErrorCode::AuthInvalidKey`]. Use this when the provider + /// explicitly rejects a key. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::auth_invalid_key("key expired"); + /// assert_eq!(err.code(), ErrorCode::AuthInvalidKey); + /// ``` + /// + /// + pub fn auth_invalid_key(msg: impl Into) -> Self { + Self::Auth(format!("Invalid API key: {}", msg.into())) + } + + /// Create a generic [`ApiError::Http`] variant. + /// + /// Use for connection-level failures that are not tied to a specific + /// HTTP status code. For status-specific errors prefer + /// [`ApiError::http_with_status`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::http("DNS resolution failed"); + /// ``` + /// + /// + pub fn http(msg: impl Into) -> Self { + Self::Http(msg.into()) + } + + /// Create an [`ApiError::Http`] variant that includes the HTTP status code. + /// + /// Formats the message as `"HTTP {status}: {msg}"` so that downstream + /// log aggregation can extract the status code. Maps to + /// [`ErrorCode::HttpConnectionError`] via [`ApiError::code`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::http_with_status(503, "service unavailable"); + /// assert!(err.to_string().contains("HTTP 503")); + /// ``` + /// + /// + pub fn http_with_status(status: u16, msg: impl Into) -> Self { + Self::Http(format!("HTTP {status}: {}", msg.into())) + } + + /// Create a generic [`ApiError::Tool`] variant. + /// + /// The message will be inspected by [`ApiError::code`] to select + /// the most specific [`ErrorCode`] (not found, permission, timeout, + /// invalid input, or generic execution failure). + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool("execution failed"); + /// assert_eq!(err.code(), ErrorCode::ToolExecutionFailed); + /// ``` + /// + /// + pub fn tool(msg: impl Into) -> Self { + Self::Tool(msg.into()) + } + + /// Create a [`ApiError::Tool`] variant prefixed with the tool name. + /// + /// Formats the message as `"{tool}: {msg}"` so logs clearly indicate + /// which tool failed. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool_with_name("Read", "file not found"); + /// assert!(err.to_string().contains("Read")); + /// ``` + /// + /// + pub fn tool_with_name(tool: impl Into, msg: impl Into) -> Self { + Self::Tool(format!("{}: {}", tool.into(), msg.into())) + } + + /// Create a [`ApiError::Tool`] variant for a missing tool. + /// + /// Formats the message as `"Tool not found: {tool}"` so that + /// [`ApiError::code`] returns [`ErrorCode::ToolNotFound`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool_not_found("Bash"); + /// assert_eq!(err.code(), ErrorCode::ToolNotFound); + /// ``` + /// + /// + pub fn tool_not_found(tool: impl Into) -> Self { + Self::Tool(format!("Tool not found: {}", tool.into())) + } + + /// Create a [`ApiError::Tool`] variant for a permission denial. + /// + /// Formats the message as `"{tool} permission denied: {msg}"` so + /// that [`ApiError::code`] returns [`ErrorCode::ToolPermissionDenied`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool_permission("Write", "read-only filesystem"); + /// assert_eq!(err.code(), ErrorCode::ToolPermissionDenied); + /// ``` + /// + /// + pub fn tool_permission(tool: impl Into, msg: impl Into) -> Self { + Self::Tool(format!("{} permission denied: {}", tool.into(), msg.into())) + } + + /// Create a [`ApiError::Tool`] variant for invalid tool input. + /// + /// Formats the message as `"{tool} invalid input: {msg}"` so that + /// [`ApiError::code`] returns [`ErrorCode::ToolInputInvalid`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::tool_input_invalid("Read", "path contains null bytes"); + /// assert_eq!(err.code(), ErrorCode::ToolInputInvalid); + /// ``` + /// + /// + pub fn tool_input_invalid(tool: impl Into, msg: impl Into) -> Self { + Self::Tool(format!("{} invalid input: {}", tool.into(), msg.into())) + } + + /// Create a generic [`ApiError::Config`] variant. + /// + /// The message will be inspected by [`ApiError::code`] to select + /// the most specific [`ErrorCode`] (file not found, parse error, + /// validation error, missing, or generic parse fallback). + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::config("invalid TOML syntax at line 42"); + /// assert_eq!(err.code(), ErrorCode::ConfigParseError); + /// ``` + /// + /// + pub fn config(msg: impl Into) -> Self { + Self::Config(msg.into()) + } + + /// Create a [`ApiError::Config`] variant for a missing config file. + /// + /// Formats the message as `"Configuration file not found: {path}"` + /// so that [`ApiError::code`] returns [`ErrorCode::ConfigFileNotFound`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::config_not_found("/etc/loopctl.toml"); + /// assert_eq!(err.code(), ErrorCode::ConfigFileNotFound); + /// ``` + /// + /// + pub fn config_not_found(path: impl Into) -> Self { + Self::Config(format!("Configuration file not found: {}", path.into())) + } + + /// Create a [`ApiError::Config`] variant for a validation failure. + /// + /// Formats the message as `"Configuration validation failed: {msg}"` + /// so that [`ApiError::code`] returns [`ErrorCode::ConfigValidationError`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::config_validation("timeout must be positive"); + /// assert_eq!(err.code(), ErrorCode::ConfigValidationError); + /// ``` + /// + pub fn config_validation(msg: impl Into) -> Self { + Self::Config(format!("Configuration validation failed: {}", msg.into())) + } + + /// Create an [`ApiError::Api`] variant for a request timeout. + /// + /// Formats the message as `"Request timeout: {msg}"` so that + /// [`ApiError::code`] returns [`ErrorCode::ApiTimeout`]. The error + /// is considered retryable by [`ApiError::is_retryable`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api_timeout("no response after 30s"); + /// assert!(err.is_retryable()); + /// assert_eq!(err.code(), ErrorCode::ApiTimeout); + /// ``` + /// + pub fn api_timeout(msg: impl Into) -> Self { + Self::Api(format!("Request timeout: {}", msg.into())) + } + + /// Create an [`ApiError::Api`] variant for a rate-limit response. + /// + /// Returns a fixed message containing `"rate limit"` so that + /// [`ApiError::code`] returns [`ErrorCode::ApiRateLimited`] and + /// [`ApiError::is_retryable`] returns `true`. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api_rate_limited(); + /// assert!(err.is_retryable()); + /// assert_eq!(err.code(), ErrorCode::ApiRateLimited); + /// ``` + /// + #[must_use] + pub fn api_rate_limited() -> Self { + Self::Api("Rate limit exceeded, please retry after a moment".into()) + } + + /// Create an [`ApiError::Api`] variant for an SSE stream error. + /// + /// Formats the message as `"Stream error: {msg}"` so that + /// [`ApiError::code`] returns [`ErrorCode::ApiStreamError`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::api_stream("connection reset mid-stream"); + /// assert_eq!(err.code(), ErrorCode::ApiStreamError); + /// ``` + /// + pub fn api_stream(msg: impl Into) -> Self { + Self::Api(format!("Stream error: {}", msg.into())) + } + + /// Create a catch-all [`ApiError::Other`] variant. + /// + /// Use only when the error does not fit any other category. Maps to + /// [`ErrorCode::InternalError`] via [`ApiError::code`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let err = ApiError::other("something unexpected happened"); + /// assert_eq!(err.code(), ErrorCode::InternalError); + /// ``` + /// + pub fn other(msg: impl Into) -> Self { + Self::Other(msg.into()) + } + + /// Convert any [`std::error::Error`] into an [`ApiError::Http`] variant. + /// + /// Uses the source error's [`Display`](std::fmt::Display) output as + /// the message string. Useful for collapsing hyper / reqwest errors + /// into the unified [`ApiError`] type. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// let io_err = std::io::Error::new(std::io::ErrorKind::Other, "oops"); + /// let api_err = ApiError::from_hyper(io_err); + /// assert!(matches!(api_err, ApiError::Http(_))); + /// ``` + /// + pub fn from_hyper(e: E) -> Self { + Self::Http(e.to_string()) + } +} + +/// Result type alias for operations that can fail with [`ApiError`]. +/// +/// Used throughout the crate so that function signatures stay concise. +/// Equivalent to `std::result::Result`. +/// +/// # When to use +/// +/// Any function in the agent framework that can fail should return +/// `Result` rather than spelling out the full path. This keeps +/// signatures uniform and allows `?` propagation across the entire +/// call stack. +/// +/// # Example +/// +/// ```rust,ignore +/// use loopctl::api_error::{ApiError, ErrorCode}; +/// fn load_config() -> Result { +/// let text = std::fs::read_to_string("loopctl.toml") +/// .map_err(|e| ApiError::config_not_found("loopctl.toml"))?; +/// Ok(parse(&text)) +/// } +/// ``` +/// +/// The framework selects the appropriate variant based on runtime +/// conditions and the agent's current state. +pub type Result = std::result::Result; + +// ================================================== +// Tests +// ================================================== + +#[cfg(test)] +/// Unit tests for the [`ApiError`] enum and [`ErrorCode`] codes. +/// +/// These tests verify that: +/// +/// - Each ergonomic constructor produces the correct [`ErrorCode`] +/// via [`ApiError::code`]. +/// - Keyword-based classification in [`ApiError::code`] selects the +/// most specific code for each message pattern. +/// - Boolean predicates ([`ApiError::is_retryable`], +/// [`ApiError::is_auth_error`], etc.) match the expected variants. +/// - `From` conversions for [`serde_json::Error`] and [`std::io::Error`] +/// produce the correct variant and code. +/// - [`ErrorCode`] serialisation round-trips through JSON. +/// +mod tests { + use super::*; + + /// Verify that a generic [`ApiError::api`] message maps to + /// [`ErrorCode::ApiRequestFailed`] and the Display output + /// contains the expected text. + /// + #[test] + fn test_api_error_code() { + let error = ApiError::api("API failed"); + assert!(error.to_string().contains("API error")); + assert!(error.to_string().contains("API failed")); + assert_eq!(error.code(), ErrorCode::ApiRequestFailed); + } + + /// Verify that [`ApiError::auth`] with "Invalid" in the message + /// maps to [`ErrorCode::AuthInvalidKey`] and is recognised by + /// [`ApiError::is_auth_error`]. + /// + #[test] + fn test_auth_error() { + let error = ApiError::auth("Invalid key"); + assert!(error.to_string().contains("Authentication error")); + assert_eq!(error.code(), ErrorCode::AuthInvalidKey); + assert!(error.is_auth_error()); + } + + /// Verify [`ApiError::auth_invalid_key`] produces the correct error code. + /// + /// Asserts that the error carries [`ErrorCode::AuthInvalidKey`] and that + /// the prefix "invalid" in the message triggers the specialized variant + /// rather than the generic [`ErrorCode::AuthFailed`]. + /// + #[test] + fn test_auth_invalid_key() { + let error = ApiError::auth_invalid_key("expired"); + assert_eq!(error.code(), ErrorCode::AuthInvalidKey); + } + + /// Verify [`ApiError::auth`] without "invalid" maps to [`ErrorCode::AuthFailed`]. + /// + /// When the message does not contain the word "invalid", the classifier + /// should fall through to the generic [`ErrorCode::AuthFailed`] variant. + /// + #[test] + fn test_auth_failed_generic() { + let error = ApiError::auth("token expired"); + assert_eq!(error.code(), ErrorCode::AuthFailed); + } + + /// Verify [`ApiError::http`] maps to [`ErrorCode::HttpConnectionError`]. + /// + /// Asserts that the [`Display`](std::fmt::Display) output contains + /// "HTTP error" and that [`ApiError::code`] returns the correct variant. + /// + #[test] + fn test_http_error() { + let error = ApiError::http("Connection failed"); + assert!(error.to_string().contains("HTTP error")); + assert_eq!(error.code(), ErrorCode::HttpConnectionError); + } + + /// Verify [`ApiError::http_with_status`] embeds the status code. + /// + /// Checks that the [`Display`](std::fmt::Display) output includes both + /// the HTTP status code (e.g., "HTTP 500") and the original error message. + /// + #[test] + fn test_http_error_with_status() { + let error = ApiError::http_with_status(500, "Server error"); + assert!(error.to_string().contains("HTTP 500")); + assert!(error.to_string().contains("Server error")); + } + + #[test] + fn test_tool_error() { + let error = ApiError::tool("Execution failed"); + assert!(error.to_string().contains("Tool error")); + assert_eq!(error.code(), ErrorCode::ToolExecutionFailed); + assert!(error.is_tool_error()); + } + + #[test] + fn test_tool_not_found() { + let error = ApiError::tool_not_found("Bash"); + assert!(error.to_string().contains("Tool not found")); + assert_eq!(error.code(), ErrorCode::ToolNotFound); + } + + #[test] + fn test_tool_permission() { + let error = ApiError::tool_permission("Write", "no access"); + assert_eq!(error.code(), ErrorCode::ToolPermissionDenied); + } + + #[test] + fn test_tool_input_invalid() { + let error = ApiError::tool_input_invalid("Read", "bad path"); + assert_eq!(error.code(), ErrorCode::ToolInputInvalid); + } + + #[test] + fn test_config_error() { + let error = ApiError::config("Invalid config"); + assert!(error.to_string().contains("Configuration error")); + assert_eq!(error.code(), ErrorCode::ConfigParseError); + assert!(error.is_config_error()); + } + + #[test] + fn test_config_not_found() { + let error = ApiError::config_not_found("/etc/config.toml"); + assert_eq!(error.code(), ErrorCode::ConfigFileNotFound); + } + + #[test] + fn test_config_validation() { + let error = ApiError::config_validation("missing field"); + assert_eq!(error.code(), ErrorCode::ConfigValidationError); + } + + #[test] + fn test_context_overflow_detection() { + let error = ApiError::api("Context length exceeded"); + assert!(error.is_context_overflow()); + assert_eq!(error.code(), ErrorCode::ApiContextOverflow); + + let error = ApiError::api("max tokens reached"); + assert!(error.is_context_overflow()); + + let error = ApiError::api("too many tokens in request"); + assert!(error.is_context_overflow()); + + let error = ApiError::api("normal error"); + assert!(!error.is_context_overflow()); + } + + #[test] + fn test_retryable_errors() { + assert!(ApiError::api_timeout("timed out").is_retryable()); + assert!(ApiError::api_rate_limited().is_retryable()); + assert!(ApiError::http("connection failed").is_retryable()); + assert!(!ApiError::auth("invalid key").is_retryable()); + assert!(!ApiError::tool("not found").is_retryable()); + } + + #[test] + fn test_error_codes_numeric() { + assert_eq!(ErrorCode::ApiRequestFailed as u32, 1000); + assert_eq!(ErrorCode::ApiRateLimited as u32, 1002); + assert_eq!(ErrorCode::ApiTimeout as u32, 1003); + assert_eq!(ErrorCode::AuthFailed as u32, 1101); + assert_eq!(ErrorCode::ToolExecutionFailed as u32, 1301); + assert_eq!(ErrorCode::Interrupted as u32, 1999); + } + + #[test] + fn test_from_json_error() { + let json_result: std::result::Result = + serde_json::from_str("{invalid}"); + let error: ApiError = json_result.unwrap_err().into(); + assert!(matches!(error, ApiError::Json(_))); + assert_eq!(error.code(), ErrorCode::JsonParseError); + } + + #[test] + fn test_from_io_error() { + let error: ApiError = + std::io::Error::new(std::io::ErrorKind::NotFound, "file not found").into(); + assert!(matches!(error, ApiError::Io(_))); + assert_eq!(error.code(), ErrorCode::IoReadError); + assert!(error.is_io_error()); + } + + #[test] + fn test_result_type() { + fn returns_result() -> Result { + Ok("success".to_string()) + } + let result = returns_result(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + } + + #[test] + fn test_api_stream_error() { + let error = ApiError::api_stream("connection reset"); + assert_eq!(error.code(), ErrorCode::ApiStreamError); + } + + #[test] + fn test_tool_with_name() { + let error = ApiError::tool_with_name("Read", "file not found"); + assert!(error.to_string().contains("Read")); + assert!(error.to_string().contains("file not found")); + } + + #[test] + fn test_from_hyper() { + let error = ApiError::from_hyper(std::io::Error::new(std::io::ErrorKind::Other, "oops")); + assert!(matches!(error, ApiError::Http(_))); + } + + #[test] + fn test_other_error() { + let error = ApiError::other("something went wrong"); + assert!(error.to_string().contains("something went wrong")); + assert_eq!(error.code(), ErrorCode::InternalError); + } + + #[test] + fn test_error_code_serialization() { + let code = ErrorCode::ApiRequestFailed; + let json = serde_json::to_string(&code).unwrap(); + let back: ErrorCode = serde_json::from_str(&json).unwrap(); + assert_eq!(code, back); + } +} diff --git a/src/core/error.rs b/src/core/error.rs index 41d4d62..de4f441 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -1,6 +1,6 @@ //! Error types for the agent framework. //! -//! Every agent operation returns `Result`, and this +//! Every agent operation returns `Result`, and this //! module defines the single unified error enum that carries structured //! context for each failure mode. The variants are fine-grained enough //! for callers to match on and recover programmatically @@ -300,14 +300,19 @@ impl AgentError { pub fn tool_not_found(tool: impl Into, available: &[&str]) -> Self { let tool = tool.into(); let available_str = if available.is_empty() { - "none registered".to_string() + String::from("none registered") } else if available.len() <= 10 { available.join(", ") } else { + let count = available.len().saturating_sub(10); format!( - "{}... (and {} more)", - available[..10].join(", "), - available.len() - 10 + "{}... (and {count} more)", + available + .iter() + .take(10) + .copied() + .collect::>() + .join(", "), ) }; Self::ToolNotFound { @@ -334,7 +339,8 @@ impl AgentError { /// Returns `false` for all other variants (e.g. invalid input, /// cancel, configuration errors) where retrying is unlikely to /// help. - pub fn is_recoverable(&self) -> bool { + #[must_use] + pub const fn is_recoverable(&self) -> bool { matches!( self, Self::ToolExecution { .. } @@ -359,7 +365,8 @@ impl AgentError { /// let err = AgentError::Cancelled; /// assert!(err.is_cancelled()); /// ``` - pub fn is_cancelled(&self) -> bool { + #[must_use] + pub const fn is_cancelled(&self) -> bool { matches!(self, Self::Cancelled) } } diff --git a/src/lib.rs b/src/lib.rs index 3d67fd7..c7118d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ //! A trait-based framework for building agent loops with pluggable LLM //! clients, tools, and memory. //! -//! See the [`message`] module for the core conversation types and -//! the [`core`] module for foundational traits and error types. +//! See the [`message`] module for the core conversation types, +//! the [`core`] module for foundational traits and error types, +//! and the [`api_error`] module for API and infrastructure error types. +pub mod api_error; pub mod core; pub mod message; diff --git a/src/message.rs b/src/message.rs index ff631c0..f30b6a3 100644 --- a/src/message.rs +++ b/src/message.rs @@ -194,7 +194,8 @@ impl Message { /// MessagePart::tool_call("t1", "search", serde_json::json!({"q": "weather"})), /// ]); /// ``` - pub fn new(role: Role, parts: Vec) -> Self { + #[must_use] + pub const fn new(role: Role, parts: Vec) -> Self { Self { role, parts } } } @@ -254,7 +255,7 @@ impl fmt::Display for Message { /// /// # Serialization /// -/// Serialized as lowercase snake_case strings (`"user"`, `"assistant"`) +/// Serialized as lowercase `snake_case` strings (`"user"`, `"assistant"`) /// to match the API convention. /// /// # Example @@ -296,8 +297,8 @@ pub enum Role { impl fmt::Display for Role { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Role::User => write!(f, "user"), - Role::Assistant => write!(f, "assistant"), + Self::User => write!(f, "user"), + Self::Assistant => write!(f, "assistant"), } } } @@ -548,7 +549,8 @@ impl MessagePart { /// let part = MessagePart::text("hello"); /// assert!(part.is_text()); /// ``` - pub fn is_text(&self) -> bool { + #[must_use] + pub const fn is_text(&self) -> bool { matches!(self, Self::Text { .. }) } @@ -565,7 +567,8 @@ impl MessagePart { /// let part = MessagePart::tool_call("id", "tool", Value::Null); /// assert!(part.is_tool_call()); /// ``` - pub fn is_tool_call(&self) -> bool { + #[must_use] + pub const fn is_tool_call(&self) -> bool { matches!(self, Self::ToolCall { .. }) } @@ -580,7 +583,8 @@ impl MessagePart { /// let part = MessagePart::tool_result("id", "output", false); /// assert!(part.is_tool_result()); /// ``` - pub fn is_tool_result(&self) -> bool { + #[must_use] + pub const fn is_tool_result(&self) -> bool { matches!(self, Self::ToolResult { .. }) } @@ -604,6 +608,7 @@ impl MessagePart { /// let tool = MessagePart::tool_call("id", "tool", Value::Null); /// assert_eq!(tool.as_text(), None); /// ``` + #[must_use] pub fn as_text(&self) -> Option<&str> { match self { Self::Text { text } => Some(text), @@ -687,7 +692,7 @@ impl ImageSource { #[must_use] pub fn new_base64(media_type: impl Into, data: impl Into) -> Self { Self { - encoding: "base64".to_string(), + encoding: "base64".into(), media_type: media_type.into(), data: data.into(), } @@ -797,7 +802,7 @@ impl ToolResult { /// ]); /// ``` #[must_use] - pub fn from_multipart(parts: Vec) -> Self { + pub const fn from_multipart(parts: Vec) -> Self { Self::Multipart(parts) } @@ -812,7 +817,8 @@ impl ToolResult { /// let content = ToolResult::from_string("ok"); /// assert!(content.is_string()); /// ``` - pub fn is_string(&self) -> bool { + #[must_use] + pub const fn is_string(&self) -> bool { matches!(self, Self::Text(_)) } } @@ -871,7 +877,7 @@ impl From for ToolResult { /// ``` impl From<&str> for ToolResult { fn from(s: &str) -> Self { - Self::Text(s.to_string()) + Self::Text(s.to_owned()) } } @@ -1020,7 +1026,8 @@ impl ToolResultPart { /// use loopctl::message::{ImageSource, ToolResultPart}; /// let part = ToolResultPart::image(ImageSource::new_base64("image/png", "...")); /// ``` - pub fn image(source: ImageSource) -> Self { + #[must_use] + pub const fn image(source: ImageSource) -> Self { Self::Image { source } } } From 5420d63d1cfb8a010533bfa25e3b9ae5cf0d1029 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Mon, 4 May 2026 19:48:29 +1200 Subject: [PATCH 3/3] fix: api error - make all ErrorCode reachable via code() --- Cargo.toml | 1 + src/api_error.rs | 319 +++++++++++++++++++++++++++++++---------------- src/message.rs | 14 +-- 3 files changed, 213 insertions(+), 121 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 03b66d6..c862962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ path = "src/lib.rs" futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_repr = "0.1" thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } tracing = "0.1" diff --git a/src/api_error.rs b/src/api_error.rs index c0c4c08..47f874d 100644 --- a/src/api_error.rs +++ b/src/api_error.rs @@ -41,7 +41,8 @@ //! │ //! ├──→ code() → ErrorCode (u32) //! ├──→ is_retryable() → bool -//! ├──→ is_auth_error() / is_config_error() / … +//! ├──→ is_auth_error() / is_config_error() / is_context_overflow() +//! ├──→ is_io_error() / is_tool_error() //! │ //! ▼ //! Result (= std::result::Result) @@ -64,7 +65,7 @@ //! } //! ``` -use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use thiserror::Error; // ================================================== @@ -94,7 +95,8 @@ use thiserror::Error; /// assert_eq!(err.code(), ErrorCode::ApiRateLimited); /// assert_eq!(err.code() as u32, 1002); /// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)] +#[repr(u16)] pub enum ErrorCode { // ================================================== // API errors (1000-1005) @@ -114,7 +116,6 @@ pub enum ErrorCode { /// Use [`ApiError::api`] to construct errors that map to this code. /// The framework's retry loop treats this code as retryable via /// [`ApiError::is_retryable`]. - /// ApiRequestFailed = 1000, /// The API response could not be parsed. @@ -125,7 +126,6 @@ pub enum ErrorCode { /// This error is **not** retryable; the response format is unlikely /// to change on subsequent attempts. Investigate version /// compatibility or schema changes. - /// ApiResponseInvalid = 1001, /// The API rate limit was exceeded. @@ -133,21 +133,18 @@ pub enum ErrorCode { /// The caller should back off and retry after the period indicated by /// the provider's headers. [`ApiError::is_retryable`] returns `true`. /// Maps to **1002**. - /// ApiRateLimited = 1002, /// The API request timed out. /// /// The request took longer than the configured timeout. Safe to retry /// (possibly with a longer timeout). Maps to **1003**. - /// ApiTimeout = 1003, /// An error occurred while reading the SSE stream. /// /// The connection was established but the stream was interrupted or /// produced malformed events. Maps to **1004**. - /// ApiStreamError = 1004, /// The request exceeded the model's context window. @@ -155,7 +152,6 @@ pub enum ErrorCode { /// The combined prompt `max_tokens` exceeds the model's limit. The /// agent should reduce the context (e.g. via summarisation or /// truncation) before retrying. Maps to **1005**. - /// ApiContextOverflow = 1005, // ================================================== @@ -165,7 +161,6 @@ pub enum ErrorCode { /// /// The key may be expired, revoked, or not yet set. Check /// environment variables and configuration files. Maps to **1100**. - /// AuthInvalidKey = 1100, /// Authentication failed for a non-key-related reason. @@ -173,7 +168,6 @@ pub enum ErrorCode { /// Covers token-expiry, account-suspension, or other auth-layer /// rejections that are not specifically about an invalid key. /// Maps to **1101**. - /// AuthFailed = 1101, // ================================================== @@ -187,7 +181,6 @@ pub enum ErrorCode { /// Constructed via [`ApiError::http`] or [`ApiError::from_hyper`]. /// This code is considered retryable by [`ApiError::is_retryable`] /// because connection issues are often transient. - /// HttpConnectionError = 1200, /// An HTTP request construction or send error. @@ -197,7 +190,6 @@ pub enum ErrorCode { /// /// This code is retryable — a resend may succeed if the root cause /// was a transient serialisation glitch. - /// HttpRequestError = 1201, /// An HTTP response error (non-success status). @@ -207,7 +199,6 @@ pub enum ErrorCode { /// /// Use [`ApiError::http_with_status`] to embed the status code in /// the message for downstream log aggregation. - /// HttpResponseError = 1202, // ================================================== @@ -220,7 +211,6 @@ pub enum ErrorCode { /// /// Constructed via [`ApiError::tool_not_found`]. Not retryable — /// the tool must be registered before use. - /// ToolNotFound = 1300, /// Tool execution failed. @@ -230,7 +220,6 @@ pub enum ErrorCode { /// /// Constructed via [`ApiError::tool`] when the message does not /// match a more specific tool-error pattern. - /// ToolExecutionFailed = 1301, /// Permission denied for tool execution. @@ -240,7 +229,6 @@ pub enum ErrorCode { /// /// Constructed via [`ApiError::tool_permission`]. Not retryable — /// permissions must be updated before the operation can succeed. - /// ToolPermissionDenied = 1302, /// Tool input validation failed. @@ -250,7 +238,6 @@ pub enum ErrorCode { /// /// Constructed via [`ApiError::tool_input_invalid`]. Not retryable — /// the caller must fix the arguments. - /// ToolInputInvalid = 1303, /// Tool execution timed out. @@ -259,7 +246,6 @@ pub enum ErrorCode { /// /// May be retryable if the timeout was caused by a transient slowdown, /// but the caller should consider increasing the timeout first. - /// ToolTimeout = 1304, // ================================================== @@ -269,27 +255,23 @@ pub enum ErrorCode { /// /// The path pointed to by the config setting does not exist or is /// not readable. Maps to **1400**. - /// ConfigFileNotFound = 1400, /// The configuration file could not be parsed. /// /// The file exists but contains invalid TOML / JSON / YAML. Maps /// to **1401**. - /// ConfigParseError = 1401, /// Configuration values failed semantic validation. /// /// The file parsed correctly but a value is out of range or /// semantically invalid (e.g. negative timeout). Maps to **1402**. - /// ConfigValidationError = 1402, /// A required configuration value is missing entirely. /// /// No value was provided and no default is available. Maps to **1403**. - /// ConfigMissing = 1403, // ================================================== @@ -301,19 +283,16 @@ pub enum ErrorCode { /// Maps to **1500**. /// /// [`ConfigFileNotFound`]: ErrorCode::ConfigFileNotFound - /// IoFileNotFound = 1500, /// A file read operation failed. /// /// Permission denied, I/O error, or unexpected EOF. Maps to **1501**. - /// IoReadError = 1501, /// A file write operation failed. /// /// Disk full, permission denied, or path invalid. Maps to **1502**. - /// IoWriteError = 1502, // ================================================== @@ -323,7 +302,6 @@ pub enum ErrorCode { /// /// The response body or request payload contained malformed JSON. /// Maps to **1600**. - /// JsonParseError = 1600, // ================================================== @@ -333,7 +311,6 @@ pub enum ErrorCode { /// /// Catch-all for bugs or unclassifiable failures. If you see this /// code in production please file a bug report. Maps to **1700**. - /// InternalError = 1700, // ================================================== @@ -343,7 +320,6 @@ pub enum ErrorCode { /// /// Not an error per se — the agent loop checks for this code to /// perform graceful shutdown. Maps to **1999**. - /// Interrupted = 1999, } @@ -404,7 +380,6 @@ pub enum ApiError { /// Covers request failures, rate limits, timeouts, streaming errors, /// and context-overflow conditions. The message is inspected by /// [`ApiError::code`] to select the most specific [`ErrorCode`]. - /// #[error("API error: {0}")] Api(String), @@ -413,7 +388,6 @@ pub enum ApiError { /// Invalid API keys, expired tokens, or account-level restrictions. /// The message text determines whether [`ErrorCode::AuthInvalidKey`] /// or [`ErrorCode::AuthFailed`] is returned by [`ApiError::code`]. - /// #[error("Authentication error: {0}")] Auth(String), @@ -422,7 +396,6 @@ pub enum ApiError { /// Connection failures, DNS errors, TLS handshake problems, or /// non-success status codes. Created via [`ApiError::http`] or /// [`ApiError::http_with_status`]. - /// #[error("HTTP error: {0}")] Http(String), @@ -430,15 +403,24 @@ pub enum ApiError { /// /// Automatically converted from [`serde_json::Error`] via the /// `#[from]` attribute. Maps to [`ErrorCode::JsonParseError`]. - /// #[error("JSON error: {0}")] Json(#[from] serde_json::Error), /// A general I/O error. /// - /// Automatically converted from [`std::io::Error`] via the `#[from]` - /// attribute. Maps to [`ErrorCode::IoReadError`]. + /// Automatically converted from [`std::io::Error`] via the `#[from]`attribute. + /// The [`ErrorCode`] is determined by inspecting the [`std::io::ErrorKind`]: /// + /// - [`ErrorKind::NotFound`] → [`ErrorCode::IoFileNotFound`] + /// - [`ErrorKind::PermissionDenied`] / [`ErrorKind::WriteZero`] → [`ErrorCode::IoWriteError`] + /// - All other kinds → [`ErrorCode::IoReadError`] + /// + /// For explicit construction, use [`ApiError::io_not_found`], [`ApiError::io_read`], + /// or [`ApiError::io_write`]. + /// + /// [`ErrorKind::NotFound`]: std::io::ErrorKind::NotFound + /// [`ErrorKind::PermissionDenied`]: std::io::ErrorKind::PermissionDenied + /// [`ErrorKind::WriteZero`]: std::io::ErrorKind::WriteZero #[error("IO error: {0}")] Io(#[from] std::io::Error), @@ -448,7 +430,6 @@ pub enum ApiError { /// invalid inputs, and timeouts. Prefer the specific constructors /// ([`ApiError::tool_not_found`], [`ApiError::tool_permission`], /// [`ApiError::tool_input_invalid`]) for richer context. - /// #[error("Tool error: {0}")] Tool(String), @@ -458,16 +439,14 @@ pub enum ApiError { /// required values. Prefer the specific constructors /// ([`ApiError::config_not_found`], [`ApiError::config_validation`]) /// for structured messages. - /// #[error("Configuration error: {0}")] Config(String), /// The operation was interrupted by a user signal. /// - /// Typically triggered by SIGINT (Ctrl-C). The agent loop checks - /// for this variant to perform graceful shutdown. Maps to + /// Typically triggered by SIGINT. The agent loop checks for + /// this variant to perform graceful shutdown. Maps to /// [`ErrorCode::Interrupted`]. - /// #[error("Interrupted")] Interrupted, @@ -475,7 +454,6 @@ pub enum ApiError { /// /// Maps to [`ErrorCode::InternalError`]. Prefer a more specific /// variant or constructor when possible. - /// #[error("Other: {0}")] Other(String), } @@ -501,34 +479,38 @@ impl ApiError { /// /// ```text /// ApiError::Api(msg) - /// ├─ "timeout" → ApiTimeout - /// ├─ "rate limit" / "429" → ApiRateLimited - /// ├─ "stream" → ApiStreamError - /// ├─ context keywords → ApiContextOverflow - /// └─ (default) → ApiRequestFailed + /// ├─ "timeout" → ApiTimeout + /// ├─ "rate limit" / "429" → ApiRateLimited + /// ├─ "stream" → ApiStreamError + /// ├─ context keywords → ApiContextOverflow + /// └─ (default) → ApiRequestFailed /// /// ApiError::Auth(msg) - /// ├─ "invalid" → AuthInvalidKey - /// └─ (default) → AuthFailed + /// ├─ "invalid" → AuthInvalidKey + /// └─ (default) → AuthFailed /// /// ApiError::Tool(msg) - /// ├─ "not found" → ToolNotFound - /// ├─ "permission" / "denied"→ ToolPermissionDenied - /// ├─ "timeout" → ToolTimeout - /// ├─ "invalid" → ToolInputInvalid - /// └─ (default) → ToolExecutionFailed + /// ├─ "not found" → ToolNotFound + /// ├─ "permission" / "denied" → ToolPermissionDenied + /// ├─ "timeout" → ToolTimeout + /// ├─ "invalid" → ToolInputInvalid + /// └─ (default) → ToolExecutionFailed /// /// ApiError::Config(msg) - /// ├─ "not found" → ConfigFileNotFound - /// ├─ "validation" → ConfigValidationError - /// ├─ "missing" → ConfigMissing - /// └─ (default) → ConfigParseError - /// - /// ApiError::Http(_) → HttpConnectionError - /// ApiError::Json(_) → JsonParseError - /// ApiError::Io(_) → IoReadError - /// ApiError::Interrupted → Interrupted - /// ApiError::Other(_) → InternalError + /// ├─ "not found" → ConfigFileNotFound + /// ├─ "validation" → ConfigValidationError + /// ├─ "missing" → ConfigMissing + /// └─ (default) → ConfigParseError + /// + /// ApiError::Http(msg) + /// ├─ "HTTP 5xx: ..." → HttpResponseError + /// ├─ "HTTP 4xx: ..." → HttpRequestError + /// └─ (default) → HttpConnectionError + /// + /// ApiError::Json(_) → JsonParseError + /// ApiError::Io(_) → IoReadError + /// ApiError::Interrupted → Interrupted + /// ApiError::Other(_) → InternalError /// ``` /// /// # When called @@ -543,7 +525,6 @@ impl ApiError { /// let err = ApiError::api("rate limit exceeded (429)"); /// assert_eq!(err.code(), ErrorCode::ApiRateLimited); /// ``` - /// #[must_use] pub fn code(&self) -> ErrorCode { match self { @@ -568,9 +549,29 @@ impl ApiError { ErrorCode::AuthFailed } } - Self::Http(_) => ErrorCode::HttpConnectionError, + Self::Http(msg) => { + if let Some(rest) = msg.strip_prefix("HTTP ") { + if let Some(colon) = rest.find(':') { + if let Ok(status) = rest[..colon].parse::() { + return if status >= 500 { + ErrorCode::HttpResponseError + } else { + ErrorCode::HttpRequestError + }; + } + } + } + ErrorCode::HttpConnectionError + } Self::Json(_) => ErrorCode::JsonParseError, - Self::Io(_) => ErrorCode::IoReadError, + Self::Io(err) => { + use std::io::ErrorKind; + match err.kind() { + ErrorKind::NotFound => ErrorCode::IoFileNotFound, + ErrorKind::PermissionDenied | ErrorKind::WriteZero => ErrorCode::IoWriteError, + _ => ErrorCode::IoReadError, + } + } Self::Tool(msg) => { let msg_lower = msg.to_lowercase(); if msg_lower.contains("not found") { @@ -625,7 +626,6 @@ impl ApiError { /// let err = ApiError::api("server error"); /// assert!(!err.is_context_overflow()); /// ``` - /// #[must_use] pub fn is_context_overflow(&self) -> bool { match self { @@ -647,25 +647,22 @@ impl ApiError { /// - `"too many tokens"` — OpenAI-style phrasing /// - `"exceeds maximum"` — generic overflow wording /// - `"max tokens"` — common provider phrasing - /// - `"context length"` — redundant with `"context"` but included - /// for explicit clarity - /// fn is_context_overflow_internal(msg: &str) -> bool { let msg_lower = msg.to_lowercase(); msg_lower.contains("context") || msg_lower.contains("too many tokens") || msg_lower.contains("exceeds maximum") || msg_lower.contains("max tokens") - || msg_lower.contains("context length") } /// Check whether this error is safe to retry. /// /// Returns `true` for transient failures where a retry (possibly /// with back-off) has a reasonable chance of succeeding: + /// /// [`ErrorCode::ApiRequestFailed`], [`ErrorCode::ApiRateLimited`], /// [`ErrorCode::ApiTimeout`], [`ErrorCode::HttpConnectionError`], - /// and [`ErrorCode::HttpRequestError`]. + /// [`ErrorCode::HttpRequestError`], and [`ErrorCode::HttpResponseError`]. /// /// # When called /// @@ -682,7 +679,6 @@ impl ApiError { /// let err = ApiError::auth("bad key"); /// assert!(!err.is_retryable()); /// ``` - /// #[must_use] pub fn is_retryable(&self) -> bool { matches!( @@ -692,6 +688,7 @@ impl ApiError { | ErrorCode::ApiTimeout | ErrorCode::HttpConnectionError | ErrorCode::HttpRequestError + | ErrorCode::HttpResponseError ) } @@ -709,7 +706,6 @@ impl ApiError { /// eprintln!("Please check your API key."); /// } /// ``` - /// #[must_use] pub const fn is_auth_error(&self) -> bool { matches!(self, Self::Auth(..)) @@ -729,7 +725,6 @@ impl ApiError { /// eprintln!("Configuration problem — check loopctl.toml."); /// } /// ``` - /// #[must_use] pub const fn is_config_error(&self) -> bool { matches!(self, Self::Config(..)) @@ -739,7 +734,6 @@ impl ApiError { /// /// Returns `true` for file-system failures such as missing files, /// permission errors, or disk-full conditions. - /// #[must_use] pub const fn is_io_error(&self) -> bool { matches!(self, Self::Io(..)) @@ -757,7 +751,6 @@ impl ApiError { /// let err = ApiError::tool_not_found("Bash"); /// assert!(err.is_tool_error()); /// ``` - /// #[must_use] pub const fn is_tool_error(&self) -> bool { matches!(self, Self::Tool(..)) @@ -784,7 +777,6 @@ impl ApiError { /// use loopctl::api_error::{ApiError, ErrorCode}; /// let err = ApiError::api("unexpected 502 from upstream"); /// ``` - /// pub fn api(msg: impl Into) -> Self { Self::Api(msg.into()) } @@ -802,8 +794,6 @@ impl ApiError { /// let err = ApiError::auth("token expired"); /// assert_eq!(err.code(), ErrorCode::AuthFailed); /// ``` - /// - /// pub fn auth(msg: impl Into) -> Self { Self::Auth(msg.into()) } @@ -821,8 +811,6 @@ impl ApiError { /// let err = ApiError::auth_invalid_key("key expired"); /// assert_eq!(err.code(), ErrorCode::AuthInvalidKey); /// ``` - /// - /// pub fn auth_invalid_key(msg: impl Into) -> Self { Self::Auth(format!("Invalid API key: {}", msg.into())) } @@ -839,8 +827,6 @@ impl ApiError { /// use loopctl::api_error::{ApiError, ErrorCode}; /// let err = ApiError::http("DNS resolution failed"); /// ``` - /// - /// pub fn http(msg: impl Into) -> Self { Self::Http(msg.into()) } @@ -848,8 +834,12 @@ impl ApiError { /// Create an [`ApiError::Http`] variant that includes the HTTP status code. /// /// Formats the message as `"HTTP {status}: {msg}"` so that downstream - /// log aggregation can extract the status code. Maps to - /// [`ErrorCode::HttpConnectionError`] via [`ApiError::code`]. + /// log aggregation can extract the status code. [`ApiError::code`] + /// inspects the embedded status to select the appropriate [`ErrorCode`]: + /// + /// - `5xx` → [`ErrorCode::HttpResponseError`] (server error, retryable) + /// - `4xx` → [`ErrorCode::HttpRequestError`] (client error, retryable) + /// - Other / unparseable → [`ErrorCode::HttpConnectionError`] /// /// # Example /// @@ -857,9 +847,11 @@ impl ApiError { /// use loopctl::api_error::{ApiError, ErrorCode}; /// let err = ApiError::http_with_status(503, "service unavailable"); /// assert!(err.to_string().contains("HTTP 503")); - /// ``` - /// + /// assert_eq!(err.code(), ErrorCode::HttpResponseError); /// + /// let err = ApiError::http_with_status(400, "bad request"); + /// assert_eq!(err.code(), ErrorCode::HttpRequestError); + /// ``` pub fn http_with_status(status: u16, msg: impl Into) -> Self { Self::Http(format!("HTTP {status}: {}", msg.into())) } @@ -877,8 +869,6 @@ impl ApiError { /// let err = ApiError::tool("execution failed"); /// assert_eq!(err.code(), ErrorCode::ToolExecutionFailed); /// ``` - /// - /// pub fn tool(msg: impl Into) -> Self { Self::Tool(msg.into()) } @@ -895,8 +885,6 @@ impl ApiError { /// let err = ApiError::tool_with_name("Read", "file not found"); /// assert!(err.to_string().contains("Read")); /// ``` - /// - /// pub fn tool_with_name(tool: impl Into, msg: impl Into) -> Self { Self::Tool(format!("{}: {}", tool.into(), msg.into())) } @@ -913,8 +901,6 @@ impl ApiError { /// let err = ApiError::tool_not_found("Bash"); /// assert_eq!(err.code(), ErrorCode::ToolNotFound); /// ``` - /// - /// pub fn tool_not_found(tool: impl Into) -> Self { Self::Tool(format!("Tool not found: {}", tool.into())) } @@ -931,8 +917,6 @@ impl ApiError { /// let err = ApiError::tool_permission("Write", "read-only filesystem"); /// assert_eq!(err.code(), ErrorCode::ToolPermissionDenied); /// ``` - /// - /// pub fn tool_permission(tool: impl Into, msg: impl Into) -> Self { Self::Tool(format!("{} permission denied: {}", tool.into(), msg.into())) } @@ -949,8 +933,6 @@ impl ApiError { /// let err = ApiError::tool_input_invalid("Read", "path contains null bytes"); /// assert_eq!(err.code(), ErrorCode::ToolInputInvalid); /// ``` - /// - /// pub fn tool_input_invalid(tool: impl Into, msg: impl Into) -> Self { Self::Tool(format!("{} invalid input: {}", tool.into(), msg.into())) } @@ -986,8 +968,6 @@ impl ApiError { /// let err = ApiError::config_not_found("/etc/loopctl.toml"); /// assert_eq!(err.code(), ErrorCode::ConfigFileNotFound); /// ``` - /// - /// pub fn config_not_found(path: impl Into) -> Self { Self::Config(format!("Configuration file not found: {}", path.into())) } @@ -1004,7 +984,6 @@ impl ApiError { /// let err = ApiError::config_validation("timeout must be positive"); /// assert_eq!(err.code(), ErrorCode::ConfigValidationError); /// ``` - /// pub fn config_validation(msg: impl Into) -> Self { Self::Config(format!("Configuration validation failed: {}", msg.into())) } @@ -1023,7 +1002,6 @@ impl ApiError { /// assert!(err.is_retryable()); /// assert_eq!(err.code(), ErrorCode::ApiTimeout); /// ``` - /// pub fn api_timeout(msg: impl Into) -> Self { Self::Api(format!("Request timeout: {}", msg.into())) } @@ -1042,7 +1020,6 @@ impl ApiError { /// assert!(err.is_retryable()); /// assert_eq!(err.code(), ErrorCode::ApiRateLimited); /// ``` - /// #[must_use] pub fn api_rate_limited() -> Self { Self::Api("Rate limit exceeded, please retry after a moment".into()) @@ -1060,7 +1037,6 @@ impl ApiError { /// let err = ApiError::api_stream("connection reset mid-stream"); /// assert_eq!(err.code(), ErrorCode::ApiStreamError); /// ``` - /// pub fn api_stream(msg: impl Into) -> Self { Self::Api(format!("Stream error: {}", msg.into())) } @@ -1077,11 +1053,78 @@ impl ApiError { /// let err = ApiError::other("something unexpected happened"); /// assert_eq!(err.code(), ErrorCode::InternalError); /// ``` - /// pub fn other(msg: impl Into) -> Self { Self::Other(msg.into()) } + /// Create an I/O error for a missing file. + /// + /// Convenience constructor for callers that know the operation was + /// a file lookup that failed. Maps to [`ErrorCode::IoFileNotFound`]. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// + /// let err = ApiError::io_not_found( + /// std::io::Error::new(std::io::ErrorKind::NotFound, "config.toml"), + /// ); + /// assert!(matches!(err, ApiError::Io(_))); + /// assert_eq!(err.code(), ErrorCode::IoFileNotFound); + /// ``` + #[must_use] + pub fn io_not_found(err: std::io::Error) -> Self { + debug_assert!( + matches!(err.kind(), std::io::ErrorKind::NotFound), + "io_not_found called with non-NotFound ErrorKind: {:?}", + err.kind() + ); + Self::Io(err) + } + + /// Create an I/O error for a read failure. + /// + /// Maps to [`ErrorCode::IoReadError`] unless the underlying + /// [`std::io::ErrorKind`] indicates otherwise (e.g. `NotFound`). + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// + /// let err = ApiError::io_read( + /// std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated"), + /// ); + /// assert!(matches!(err, ApiError::Io(_))); + /// assert_eq!(err.code(), ErrorCode::IoReadError); + /// ``` + #[must_use] + pub fn io_read(err: std::io::Error) -> Self { + Self::Io(err) + } + + /// Create an I/O error for a write failure. + /// + /// Maps to [`ErrorCode::IoWriteError`] unless the underlying + /// [`std::io::ErrorKind`] indicates otherwise. + /// + /// # Example + /// + /// ```rust + /// use loopctl::api_error::{ApiError, ErrorCode}; + /// + /// let err = ApiError::io_write( + /// std::io::Error::new(std::io::ErrorKind::WriteZero, "disk full"), + /// ); + /// assert!(matches!(err, ApiError::Io(_))); + /// assert_eq!(err.code(), ErrorCode::IoWriteError); + /// ``` + #[must_use] + pub fn io_write(err: std::io::Error) -> Self { + Self::Io(err) + } + /// Convert any [`std::error::Error`] into an [`ApiError::Http`] variant. /// /// Uses the source error's [`Display`](std::fmt::Display) output as @@ -1096,7 +1139,6 @@ impl ApiError { /// let api_err = ApiError::from_hyper(io_err); /// assert!(matches!(api_err, ApiError::Http(_))); /// ``` - /// pub fn from_hyper(e: E) -> Self { Self::Http(e.to_string()) } @@ -1143,7 +1185,9 @@ pub type Result = std::result::Result; /// - Keyword-based classification in [`ApiError::code`] selects the /// most specific code for each message pattern. /// - Boolean predicates ([`ApiError::is_retryable`], -/// [`ApiError::is_auth_error`], etc.) match the expected variants. +/// [`ApiError::is_auth_error`], [`ApiError::is_config_error`], +/// [`ApiError::is_context_overflow`], [`ApiError::is_io_error`], +/// [`ApiError::is_tool_error`]) match the expected variants. /// - `From` conversions for [`serde_json::Error`] and [`std::io::Error`] /// produce the correct variant and code. /// - [`ErrorCode`] serialisation round-trips through JSON. @@ -1217,9 +1261,31 @@ mod tests { /// #[test] fn test_http_error_with_status() { + // 5xx → HttpResponseError let error = ApiError::http_with_status(500, "Server error"); assert!(error.to_string().contains("HTTP 500")); assert!(error.to_string().contains("Server error")); + assert_eq!(error.code(), ErrorCode::HttpResponseError); + + // 4xx → HttpRequestError + let error = ApiError::http_with_status(429, "Too many requests"); + assert_eq!(error.code(), ErrorCode::HttpRequestError); + + // Transport-level http() → HttpConnectionError + let error = ApiError::http("Connection refused"); + assert_eq!(error.code(), ErrorCode::HttpConnectionError); + + // Edge: status 399 → still < 500, so HttpRequestError + let error = ApiError::http_with_status(399, "redirect-ish"); + assert_eq!(error.code(), ErrorCode::HttpRequestError); + + // Edge: manual Http message without "HTTP {n}:" prefix → HttpConnectionError + let error = ApiError::Http("generic transport failure".into()); + assert_eq!(error.code(), ErrorCode::HttpConnectionError); + + // Edge: malformed status → HttpConnectionError (fallback) + let error = ApiError::Http("HTTP abc: not a number".into()); + assert_eq!(error.code(), ErrorCode::HttpConnectionError); } #[test] @@ -1290,6 +1356,9 @@ mod tests { assert!(ApiError::api_timeout("timed out").is_retryable()); assert!(ApiError::api_rate_limited().is_retryable()); assert!(ApiError::http("connection failed").is_retryable()); + // HttpResponseError (5xx) and HttpRequestError (4xx) are retryable + assert!(ApiError::http_with_status(503, "unavailable").is_retryable()); + assert!(ApiError::http_with_status(429, "too many requests").is_retryable()); assert!(!ApiError::auth("invalid key").is_retryable()); assert!(!ApiError::tool("not found").is_retryable()); } @@ -1315,11 +1384,27 @@ mod tests { #[test] fn test_from_io_error() { + // #[from] conversion routes NotFound → IoFileNotFound let error: ApiError = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found").into(); assert!(matches!(error, ApiError::Io(_))); - assert_eq!(error.code(), ErrorCode::IoReadError); + assert_eq!(error.code(), ErrorCode::IoFileNotFound); assert!(error.is_io_error()); + + // PermissionDenied → IoWriteError + let error: ApiError = + std::io::Error::new(std::io::ErrorKind::PermissionDenied, "no access").into(); + assert_eq!(error.code(), ErrorCode::IoWriteError); + + // Generic I/O error → IoReadError + let error: ApiError = + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated").into(); + assert_eq!(error.code(), ErrorCode::IoReadError); + + // WriteZero → IoWriteError + let error: ApiError = + std::io::Error::new(std::io::ErrorKind::WriteZero, "disk full").into(); + assert_eq!(error.code(), ErrorCode::IoWriteError); } #[test] @@ -1362,6 +1447,24 @@ mod tests { fn test_error_code_serialization() { let code = ErrorCode::ApiRequestFailed; let json = serde_json::to_string(&code).unwrap(); + assert_eq!( + json, "1000", + "ErrorCode should serialize as numeric discriminant" + ); + let back: ErrorCode = serde_json::from_str(&json).unwrap(); + assert_eq!(code, back); + + // Verify round-trip for a code in the middle of the range + let code = ErrorCode::ToolPermissionDenied; + let json = serde_json::to_string(&code).unwrap(); + assert_eq!(json, "1302"); + let back: ErrorCode = serde_json::from_str(&json).unwrap(); + assert_eq!(code, back); + + // Verify round-trip for the largest code + let code = ErrorCode::Interrupted; + let json = serde_json::to_string(&code).unwrap(); + assert_eq!(json, "1999"); let back: ErrorCode = serde_json::from_str(&json).unwrap(); assert_eq!(code, back); } diff --git a/src/message.rs b/src/message.rs index f30b6a3..9ad935d 100644 --- a/src/message.rs +++ b/src/message.rs @@ -342,8 +342,6 @@ pub enum MessagePart { /// /// The most common part type. Contains a UTF-8 text string /// generated by the model or provided by the user. - /// - /// Serialized as `{"type":"text","text":"..."}`. #[serde(rename = "text")] Text { /// The text content of this part. @@ -381,8 +379,6 @@ pub enum MessagePart { /// The correlation between a `ToolCall` and its `ToolResult` is /// done via the [`id`](Self::ToolCall.id) field, which must be /// unique within a single conversation turn. - /// - /// Serialized as `{"type":"tool_call","id":"...","name":"...","input":{...}}`. #[serde(rename = "tool_call")] ToolCall { /// Unique identifier for this tool invocation. @@ -414,8 +410,6 @@ pub enum MessagePart { /// After the agent executes a tool, it wraps the output in this /// variant and appends it to the conversation history so the model /// can reason about the output. - /// - /// Serialized as `{"type":"tool_result","call_id":"...","output":"...","is_error":false}`. #[serde(rename = "tool_result")] ToolResult { /// The ID of the tool-call part this result corresponds to. @@ -743,14 +737,12 @@ pub enum ToolResult { /// A simple string result. /// /// The most common case — tools that return plain text output. - /// Serialized as a bare JSON string. Text(String), /// A multipart result with multiple parts. /// /// Used when a tool needs to return mixed content (text + images) - /// or multiple text segments. Serialized as a JSON array of - /// [`ToolResultPart`] objects. + /// or multiple text segments. Multipart(Vec), } @@ -954,8 +946,6 @@ pub enum ToolResultPart { /// /// Contains the base64-encoded image data. Useful for tools /// that capture screenshots or generate images. - /// - /// Serialized as `{"type":"image","source":{...}}`. Image { /// The image data and metadata. /// @@ -967,8 +957,6 @@ pub enum ToolResultPart { /// A text part within a Multipart tool result. /// /// The most common part type. Contains a plain text string. - /// - /// Serialized as `{"type":"text","text":"..."}`. Text { /// The text content of this part. ///