diff --git a/.cargo/config.toml b/.cargo/config.toml index 3e836ce66..ce4f8c60d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,7 +12,13 @@ xtask = "run -p build-xtask --" # Outlines the workspace with the vibelight checkout next to this repo. Cargo # builds it in release mode on first use and rebuilds it when its source # changes. Run it from the repo root: the path is relative to the cwd. -vibelight = "run --release --manifest-path ../vibelight/Cargo.toml --" +# +# The name is `outline`, not `vibelight`, on purpose. Cargo resolves an alias +# before an installed subcommand, warns that the alias shadows it, and plans to +# make that a hard error, so an alias named `vibelight` would break every +# `cargo install vibelight` user. With this name the checkout stays the default +# here, and `cargo vibelight` runs whichever version is installed. +outline = "run --release --bin vibelight --manifest-path ../vibelight/Cargo.toml --" # Clippy appends a docs-link line to every diagnostic; the lint name already # identifies it. diff --git a/Cargo.lock b/Cargo.lock index 08a967d28..33bca01a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,7 +2269,6 @@ dependencies = [ "runvil", "serde", "serde_json", - "thiserror 2.0.19", "tokio", "url", ] @@ -2280,7 +2279,6 @@ version = "0.3.0" dependencies = [ "libloading 0.8.9", "runvil-plugin", - "thiserror 2.0.19", ] [[package]] @@ -4793,7 +4791,6 @@ dependencies = [ "runvil-types", "runvil-vfs", "serde_json", - "thiserror 2.0.19", ] [[package]] diff --git a/crates/build-xtask/src/facade_shape-tests.rs b/crates/build-xtask/src/facade_shape-tests.rs index 95925e7ce..04ec69f15 100644 --- a/crates/build-xtask/src/facade_shape-tests.rs +++ b/crates/build-xtask/src/facade_shape-tests.rs @@ -114,7 +114,7 @@ fn every_allowed_form_passes() { let text = r#"#![doc = include_str!("lib.md")] pub use runvil_engine::Run; -pub use runvil_model_client::model::CompletionError as ModelFailure; +pub use runvil_model_client::model::CompletionOptions as ModelOptions; pub use ::runvil_engine::Step; /// The effects a run issues. diff --git a/crates/harness-gateway-client/Cargo.toml b/crates/harness-gateway-client/Cargo.toml index 7b1181ff8..56814f46c 100644 --- a/crates/harness-gateway-client/Cargo.toml +++ b/crates/harness-gateway-client/Cargo.toml @@ -14,9 +14,9 @@ harness.workspace = true # result, and error vocabulary, which `GatewaySearch` implements. plugin-web.workspace = true # The Engine's public API: the chat vocabulary (Message, ToolSchema, -# Completion, CompletionError) the wire code reads and builds through its -# public constructors, and the canonical metrics and model catalog -# vocabulary. +# Completion) and the universal error the wire code reads and builds +# through its public constructors, and the canonical metrics and model +# catalog vocabulary. runvil.workspace = true # `SearchProvider` is an `async_trait` trait. async-trait.workspace = true @@ -31,7 +31,6 @@ serde.workspace = true # The request body, every streamed payload, and the reassembled response # are JSON. serde_json.workspace = true -thiserror.workspace = true # `time` for the per-receive timeout. Non-test code spawns no task. tokio = { workspace = true, features = ["time"] } url.workspace = true diff --git a/crates/harness-gateway-client/src/broker.rs b/crates/harness-gateway-client/src/broker.rs index 4e96354e5..2d2fb1e78 100644 --- a/crates/harness-gateway-client/src/broker.rs +++ b/crates/harness-gateway-client/src/broker.rs @@ -14,8 +14,9 @@ use std::sync::Arc; use harness::{BoxFuture, InferenceBroker}; use runvil::effect::Round; +use runvil::error::Error; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, }; use crate::catalog::fetch_model_catalog; @@ -90,7 +91,7 @@ impl GatewayBroker { tools: Vec, options: CompletionOptions, on_piece: Arc, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { self.round(messages, tools, options, move |piece| on_piece(piece)) } @@ -101,7 +102,7 @@ impl GatewayBroker { tools: Vec, options: CompletionOptions, on_piece: impl Fn(StreamDelta) + Send + Sync + 'static, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { let client = self.client.clone(); Box::pin(async move { // An empty advertisement sends no `tools` field at all, the @@ -116,7 +117,7 @@ impl GatewayBroker { } impl InferenceBroker for GatewayBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let api_root = self.endpoint.url().to_owned(); let key = self.key.clone(); Box::pin(async move { fetch_model_catalog(&api_root, key.expose()).await }) @@ -129,7 +130,7 @@ impl InferenceBroker for GatewayBroker { tools: Vec, options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { self.round(messages, tools, options, |_| {}) } } diff --git a/crates/harness-gateway-client/src/catalog-tests.rs b/crates/harness-gateway-client/src/catalog-tests.rs index 4e8150502..1a9772dde 100644 --- a/crates/harness-gateway-client/src/catalog-tests.rs +++ b/crates/harness-gateway-client/src/catalog-tests.rs @@ -1,9 +1,19 @@ //! Tests for the model catalog fetch against an axum mock gateway: the body -//! bounds, the preserved failure sources, the entry filter, and failure -//! messages that carry no Gateway-supplied text. +//! bounds, the failure causes kept in the message, the entry filter, and +//! failure prose that carries no Gateway-supplied text. + +use runvil::error::ErrorCode; use super::*; -use crate::CompletionErrorKind; + +/// The text the fetch appended to `err`'s message after its prose: the +/// provider detail or the cause, which the prose never holds. +fn appended(err: &Error) -> &str { + err.message + .strip_prefix(err.prose.as_str()) + .and_then(|rest| rest.strip_prefix(": ")) + .unwrap_or_else(|| panic!("the message extends the prose: {err:?}")) +} async fn spawn_models(app: axum::Router) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -31,12 +41,12 @@ async fn fetch_model_catalog_bounds_and_reports_non_success_body() { let err = fetch_model_catalog(&format!("http://{addr}"), "tok") .await .expect_err("a 500 response must surface as an error"); - assert_eq!(err.kind(), CompletionErrorKind::ServerError); + assert_eq!(err.code, ErrorCode::ServerError); assert_eq!( - err.to_string(), + err.prose, "the model backend reported a fault of its own (status 500)" ); - let detail = err.detail().expect("the bounded body is the detail"); + let detail = appended(&err); assert!( detail.len() < MAX_CATALOG_ERROR_BODY + 128, "the error-path body must be bounded, got {} bytes", @@ -62,22 +72,25 @@ async fn fetch_model_catalog_bounds_an_oversized_success_body() { let err = fetch_model_catalog(&format!("http://{addr}"), "tok") .await .expect_err("an oversized success body must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert!( err.to_string().contains("exceeds"), "the bound must report the size limit, got {err:?}" ); - assert_eq!(err.detail(), None, "our own wording is not provider text"); + assert_eq!( + err.message, err.prose, + "our own wording is not provider text" + ); } #[tokio::test] -async fn fetch_model_catalog_preserves_the_json_decode_source() { +async fn fetch_model_catalog_keeps_the_json_decode_error_in_the_message() { use axum::Router; use axum::routing::get; // MODEL-009: a 200 body that is not a valid model list is classified as - // MalformedResponse, and the underlying `serde_json::Error` survives as - // the error-chain `#[source]` rather than being flattened into the text. + // MalformedResponse, and the underlying `serde_json::Error` is appended + // to the message and kept out of the prose. async fn models() -> (axum::http::StatusCode, String) { (axum::http::StatusCode::OK, "{ this is not json".to_owned()) } @@ -87,13 +100,12 @@ async fn fetch_model_catalog_preserves_the_json_decode_source() { let err = fetch_model_catalog(&format!("http://{addr}"), "tok") .await .expect_err("an undecodable body must surface as an error"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - let source = - std::error::Error::source(&err).expect("the decode error must be a preserved source"); - assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the JSON decode error, got {source}" - ); + assert_eq!(err.code, ErrorCode::MalformedResponse); + let decode = serde_json::from_slice::(b"{ this is not json") + .expect_err("the body is not JSON") + .to_string(); + assert_eq!(appended(&err), decode); + assert!(!err.prose.contains(&decode), "got {err:?}"); } #[tokio::test] @@ -194,17 +206,17 @@ async fn fetch_model_catalog_still_rejects_a_zero_context_window() { let err = fetch_model_catalog(&format!("http://{addr}"), "tok") .await .expect_err("a zero context window is malformed"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert!(err.to_string().contains("zero-token"), "got {err:?}"); - assert!(!err.to_string().contains("broken"), "got {err:?}"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + assert!(err.prose.contains("zero-token"), "got {err:?}"); + assert!(!err.prose.contains("broken"), "got {err:?}"); assert!( - err.detail().is_some_and(|detail| detail.contains("broken")), - "the model name belongs in the detail, got {err:?}" + appended(&err).contains("broken"), + "the model name belongs in the message only, got {err:?}" ); } /// Serves `data` as the gateway model list and returns the fetch failure. -async fn fetch_rejection(data: serde_json::Value) -> CompletionError { +async fn fetch_rejection(data: serde_json::Value) -> Error { let list = serde_json::json!({ "object": "list", "data": data }); let app = axum::Router::new().route( "/models", @@ -217,34 +229,32 @@ async fn fetch_rejection(data: serde_json::Value) -> CompletionError { } #[tokio::test] -async fn fetch_model_catalog_moves_the_name_of_a_model_without_a_thinking_mode_to_detail() { +async fn fetch_model_catalog_keeps_the_name_of_a_model_without_a_thinking_mode_out_of_the_prose() { let err = fetch_rejection(serde_json::json!([ { "id": "thoughtless", "object": "model", "kind": "chat", "description": "d", "context": 4096 } ])) .await; - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert!(!err.to_string().contains("thoughtless"), "got {err:?}"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + assert!(!err.prose.contains("thoughtless"), "got {err:?}"); assert!( - err.detail() - .is_some_and(|detail| detail.contains("thoughtless")), - "the model name belongs in the detail, got {err:?}" + appended(&err).contains("thoughtless"), + "the model name belongs in the message only, got {err:?}" ); } #[tokio::test] -async fn fetch_model_catalog_moves_a_duplicate_id_to_detail() { +async fn fetch_model_catalog_keeps_a_duplicate_id_out_of_the_prose() { let entry = serde_json::json!( { "id": "twin", "object": "model", "kind": "chat", "description": "d", "context": 4096, "thinking": "never" } ); let err = fetch_rejection(serde_json::json!([entry.clone(), entry])).await; - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert!(!err.to_string().contains("twin"), "got {err:?}"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + assert!(!err.prose.contains("twin"), "got {err:?}"); assert!( - err.detail() - .is_some_and(|detail| detail.contains("gateway/twin")), - "the duplicate identity belongs in the detail, got {err:?}" + appended(&err).contains("gateway/twin"), + "the duplicate identity belongs in the message only, got {err:?}" ); } @@ -262,8 +272,8 @@ async fn fetch_model_catalog_bounds_a_gateway_supplied_detail() { ]; for data in rejections { let err = fetch_rejection(data).await; - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - let detail = err.detail().expect("the model name is the detail"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + let detail = appended(&err); assert!( detail.chars().count() <= MAX_CATALOG_ERROR_BODY, "a Gateway-supplied detail must be bounded, got {} chars", @@ -279,20 +289,20 @@ async fn fetch_model_catalog_keeps_an_invalid_id_out_of_the_message() { "description": "d", "context": 4096, "thinking": "never" } ])) .await; - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); let message = err.to_string(); assert!(!message.contains("smuggled"), "got {err:?}"); assert!(!message.contains('\u{7}'), "got {err:?}"); } #[tokio::test] -async fn fetch_model_catalog_preserves_a_body_read_failure_source() { +async fn fetch_model_catalog_keeps_a_body_read_failure_in_the_message() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; // MODEL-010: a non-success response whose body cannot be fully read // (the server promises a large body then drops the connection) must - // surface as a typed transport failure that keeps the `reqwest::Error` - // as its `#[source]`, not display text. + // surface as a typed transport failure whose message, not its prose, + // holds the `reqwest::Error`. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { @@ -310,15 +320,10 @@ async fn fetch_model_catalog_preserves_a_body_read_failure_source() { let err = fetch_model_catalog(&format!("http://{addr}"), "tok") .await .expect_err("a truncated error body must surface as an error"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); - assert_eq!( - err.to_string(), - "the connection to the model backend failed" - ); - let source = - std::error::Error::source(&err).expect("the read failure must be a preserved source"); + assert_eq!(err.code, ErrorCode::Transport); + assert_eq!(err.prose, "the connection to the model backend failed"); assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the reqwest read error, got {source}" + !appended(&err).is_empty(), + "the message holds the reqwest read error, got {err:?}" ); } diff --git a/crates/harness-gateway-client/src/catalog.rs b/crates/harness-gateway-client/src/catalog.rs index a1935742f..0e65b8f3f 100644 --- a/crates/harness-gateway-client/src/catalog.rs +++ b/crates/harness-gateway-client/src/catalog.rs @@ -2,10 +2,11 @@ use std::num::NonZeroU32; -use runvil::model::{CompletionError, ModelCatalog, ModelId, ModelInfo, ThinkingMode}; +use runvil::error::Error; +use runvil::model::{ModelCatalog, ModelId, ModelInfo, ThinkingMode}; use serde::Deserialize; -use crate::failure::{malformed, transport_failure}; +use crate::failure::{malformed, transport_failure, with_detail}; use crate::wire::classify::classify_http_failure; use crate::wire::stream::escape_controls; @@ -54,7 +55,7 @@ const MAX_CATALOG_BODY: u64 = 16 * 1024 * 1024; async fn read_catalog_body_capped( mut response: reqwest::Response, cap: u64, -) -> std::result::Result, CompletionError> { +) -> std::result::Result, Error> { if let Some(len) = response.content_length() && len > cap { @@ -78,8 +79,8 @@ async fn read_catalog_body_capped( /// an oversized error body cannot exhaust memory. /// /// A read failure is returned as the concrete [`reqwest::Error`] (MODEL-010) so -/// the caller can retain it as an error-chain `#[source]`, rather than being -/// flattened into display text that severs the cause. +/// the caller can append it and its cause chain to the failure's message, +/// rather than being flattened into display text that severs the cause. async fn read_error_body_bounded( mut response: reqwest::Response, limit: usize, @@ -128,15 +129,12 @@ fn catalog_client() -> reqwest::Client { /// Sends a bearer-authed GET through the shared client (MODEL-018) and /// returns the success response, classifying every failure the same way for /// each gateway endpoint: `Transport` (or `Timeout`) when the send fails, the -/// classified kind with a bounded, control-escaped body as its detail on a -/// non-success status (MODEL-010: no unbounded buffering), and `Transport` -/// (or `Timeout`) when that error body cannot be read, keeping the -/// [`reqwest::Error`] as a typed source under the same timeout marking as a +/// classified code with a bounded, control-escaped body appended to its +/// message on a non-success status (MODEL-010: no unbounded buffering), and +/// `Transport` (or `Timeout`) when that error body cannot be read, appending +/// the [`reqwest::Error`] to the message under the same timeout marking as a /// send failure. -async fn get_authed( - url: String, - token: &str, -) -> std::result::Result { +async fn get_authed(url: String, token: &str) -> std::result::Result { let response = catalog_client() .get(url) .bearer_auth(token) @@ -161,16 +159,16 @@ async fn get_authed( /// bearer token. /// /// # Errors -/// Returns a [`CompletionError`]. Its [`kind`](CompletionError::kind) is: +/// Returns an [`Error`]. Its [`code`](Error::code) is: /// /// - `Transport` or `Timeout` when the HTTP request or a response read fails; -/// - the kind [`classify_http_failure`] picks for the response when the +/// - the code [`classify_http_failure`] picks for the response when the /// Gateway returns a status outside the 2xx range; /// - `MalformedResponse` when the body is not a valid model list. pub async fn fetch_model_catalog( base_url: &str, token: &str, -) -> std::result::Result { +) -> std::result::Result { let base = base_url.trim_end_matches('/'); let response = get_authed(format!("{base}/models"), token).await?; // Bound the success body BEFORE decoding so an oversized (or unbounded) @@ -179,10 +177,9 @@ pub async fn fetch_model_catalog( // A body that does not decode as a model list is a malformed response, not a // transport failure - matching this function's documented error contract. let list: ModelsListResponse = serde_json::from_slice(&body).map_err(|error| { - // MODEL-009: keep the decode error as a private `#[source]` cause instead - // of flattening it into the message, while the classification stays - // `MalformedResponse`. - malformed("model list response was not valid JSON").with_source(error) + // MODEL-009: keep the decode error in the message and out of the + // prose, while the classification stays `MalformedResponse`. + malformed("model list response was not valid JSON").with_cause(&error) })?; let mut models = Vec::with_capacity(list.data.len()); for entry in list.data { @@ -196,12 +193,16 @@ pub async fn fetch_model_catalog( malformed(format!("model catalog entry has an invalid id: {error}")) })?; let context = NonZeroU32::new(context).ok_or_else(|| { - malformed("a model declares a zero-token context window") - .with_detail(escape_controls(id.name(), MAX_CATALOG_ERROR_BODY)) + with_detail( + malformed("a model declares a zero-token context window"), + &escape_controls(id.name(), MAX_CATALOG_ERROR_BODY), + ) })?; let thinking = entry.thinking.ok_or_else(|| { - malformed("a model declares a context window but no thinking mode") - .with_detail(escape_controls(id.name(), MAX_CATALOG_ERROR_BODY)) + with_detail( + malformed("a model declares a context window but no thinking mode"), + &escape_controls(id.name(), MAX_CATALOG_ERROR_BODY), + ) })?; let info = ModelInfo::new(id, entry.description, context, thinking); models.push(match entry.provider { @@ -210,8 +211,10 @@ pub async fn fetch_model_catalog( }); } ModelCatalog::new(models).map_err(|error| { - malformed("gateway returned an inconsistent model catalog") - .with_detail(escape_controls(&error.to_string(), MAX_CATALOG_ERROR_BODY)) + with_detail( + malformed("gateway returned an inconsistent model catalog"), + &escape_controls(&error.to_string(), MAX_CATALOG_ERROR_BODY), + ) }) } diff --git a/crates/harness-gateway-client/src/config.rs b/crates/harness-gateway-client/src/config.rs index efd60dc62..0ff23188a 100644 --- a/crates/harness-gateway-client/src/config.rs +++ b/crates/harness-gateway-client/src/config.rs @@ -1,45 +1,43 @@ //! Client configuration: the redacted bearer secret, the validated gateway -//! endpoint, and the error a bad setup reports. +//! endpoint, and the setup errors a bad setup reports. +//! +//! A setup error happens before the client sends its first request. It is +//! an [`InvalidValue`](ErrorCode::InvalidValue) whose message names the +//! environment variable or the rule that failed, never a key, and whose +//! `Reason` fact is `missing_env`, `invalid_env`, or `invalid_config`. use std::fmt; -/// The error returned when setting up the gateway client fails because an -/// environment variable, the bearer key, or the endpoint URL is missing or -/// unusable. -/// -/// This is a setup error. It happens before the client sends its first -/// request, so it stays within client setup, separate from the Engine and -/// its [`CompletionErrorKind`](crate::CompletionErrorKind) values. The -/// message names the environment variable or the rule that failed, and never -/// contains a key. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum GatewayConfigError { - /// A required environment variable was missing. - #[error("missing environment variable: {0}")] - MissingEnv(String), - - /// An environment variable was set, but its value was invalid Unicode. - #[error("environment variable is set but not valid Unicode: {0}")] - InvalidEnv(String), +use runvil::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; + +/// The setup error for a required environment variable that is not set. +pub(crate) fn missing_env(name: &str) -> Error { + let mut error = setup_error( + "missing_env", + format!("missing environment variable: {name}"), + ); + error.facts.insert(FactKey::Name, Value::from(name)); + error +} - /// A configuration value failed validation. - #[error("{0}")] - InvalidConfig(String), +/// The setup error for an environment variable whose value is not valid +/// Unicode. +pub(crate) fn invalid_env(name: &str) -> Error { + let mut error = setup_error( + "invalid_env", + format!("environment variable is set but not valid Unicode: {name}"), + ); + error.facts.insert(FactKey::Name, Value::from(name)); + error +} - /// A configuration input was invalid, and the error keeps the concrete - /// cause as its `source`. - /// - /// The cause is, for example, a URL parse failure or an unusable secret. - #[error("{message}")] - Config { - /// A human-readable description of the problem. The text of the - /// source error appears only in `source`. - message: String, - /// The underlying error that caused this one. - #[source] - source: Box, - }, +/// A setup error with `reason`. Its prose is its message, which names the +/// variable or the rule to fix. +fn setup_error(reason: &'static str, message: String) -> Error { + let mut error = Error::new(ErrorCode::InvalidValue, message.clone(), message); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error } /// A bearer credential whose contents never appear in `Debug` or `Display` @@ -57,12 +55,21 @@ impl SecretString { /// Wraps a secret so it is redacted everywhere it is formatted. /// /// # Errors - /// Returns [`SecretError::Empty`] when `secret` is empty, so a client can - /// never be built to authenticate with a blank bearer credential. - pub fn new(secret: impl Into) -> std::result::Result { + /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error with the + /// reason `empty_secret` when `secret` is empty, so a client can never be + /// built to authenticate with a blank bearer credential. + pub fn new(secret: impl Into) -> std::result::Result { let secret = secret.into(); if secret.is_empty() { - return Err(SecretError::Empty); + let mut error = Error::new( + ErrorCode::InvalidValue, + "secret must not be empty", + "The secret is empty. Supply a non-empty secret.", + ); + error + .facts + .insert(FactKey::Reason, Value::from("empty_secret")); + return Err(error); } Ok(SecretString(secret)) } @@ -74,27 +81,6 @@ impl SecretString { } } -/// The reason constructing a [`SecretString`] failed. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum SecretError { - /// The supplied credential was empty. - #[error("secret must not be empty")] - Empty, -} - -impl From for GatewayConfigError { - fn from(error: SecretError) -> GatewayConfigError { - // An unusable credential is a client setup problem. The concrete - // `SecretError` is preserved as the source rather than flattened - // into a string (AUDIT-DISCARDED-SOURCE). - GatewayConfigError::Config { - message: "gateway bearer key is unusable".to_owned(), - source: Box::new(error), - } - } -} - impl fmt::Debug for SecretString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("SecretString()") @@ -128,21 +114,18 @@ impl GatewayEndpoint { /// The URL is checked with a strict URL parser. /// /// # Errors - /// Returns a [`GatewayConfigError`] when `url` fails to parse as an - /// absolute URL, uses a scheme other than `http` or `https`, omits the + /// Returns an `invalid_config` setup error when `url` fails to parse as + /// an absolute URL, uses a scheme other than `http` or `https`, omits the /// host, embeds credentials (a `user:pass@` component), or has a query or /// fragment. /// A query or fragment is rejected because an API root is a bare path. /// No error includes `url`, because a URL can contain a credential. - pub fn new(url: &str) -> std::result::Result { - let reject = GatewayConfigError::InvalidConfig; + pub fn new(url: &str) -> std::result::Result { + let reject = |message: String| setup_error("invalid_config", message); let trimmed = url.trim(); - // Preserve the concrete `url::ParseError` as a private source rather than - // flattening it into the message (AUDIT-DISCARDED-SOURCE). - let parsed = url::Url::parse(trimmed).map_err(|error| GatewayConfigError::Config { - message: "gateway URL is not a valid URL".to_owned(), - source: Box::new(error), - })?; + // The parse failure's own text names the rule, never the input. + let parsed = url::Url::parse(trimmed) + .map_err(|error| reject(format!("gateway URL is not a valid URL: {error}")))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(reject( "gateway URL must use the http or https scheme".to_owned(), @@ -197,9 +180,9 @@ impl GatewayEndpoint { } impl TryFrom<&str> for GatewayEndpoint { - type Error = GatewayConfigError; + type Error = Error; - fn try_from(url: &str) -> std::result::Result { + fn try_from(url: &str) -> std::result::Result { GatewayEndpoint::new(url) } } diff --git a/crates/harness-gateway-client/src/failure.rs b/crates/harness-gateway-client/src/failure.rs index 4cb06537e..99c9cdd5c 100644 --- a/crates/harness-gateway-client/src/failure.rs +++ b/crates/harness-gateway-client/src/failure.rs @@ -1,5 +1,5 @@ -//! The failures this crate builds itself: each is a [`CompletionError`] -//! made from a kind and that kind's fixed phrase. +//! The failures this crate builds itself: each is an [`Error`] made from a +//! model code and that code's fixed phrase. //! //! A non-success HTTP status is not built here; `classify_http_failure` //! reads its status and bounded body. These helpers cover the rest: a @@ -8,58 +8,77 @@ use std::fmt::Display; -use runvil::model::{CompletionError, CompletionErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::model::{completion_error, phrase}; /// A send or read that failed: `Timeout` when `error` was a timeout and -/// `Transport` otherwise, with `error` kept as the source. -pub(crate) fn transport_failure(error: reqwest::Error) -> CompletionError { +/// `Transport` otherwise, with `error` appended to the message as its +/// cause. +#[expect( + clippy::needless_pass_by_value, + reason = "a `map_err` adapter takes the error by value" +)] +pub(crate) fn transport_failure(error: reqwest::Error) -> Error { if error.is_timeout() { - return timeout(error); + return timeout(&error); } - let kind = CompletionErrorKind::Transport; - CompletionError::new(kind, kind.phrase()).with_source(error) + let code = ErrorCode::Transport; + completion_error(code, phrase(code)).with_cause(&error) } -/// A receive deadline that elapsed: a `Timeout` with the elapsed error as -/// its source. -pub(crate) fn elapsed(error: tokio::time::error::Elapsed) -> CompletionError { - timeout(error) +/// A receive deadline that elapsed: a `Timeout` with the elapsed error +/// appended to the message as its cause. +#[expect( + clippy::needless_pass_by_value, + reason = "a `map_err` adapter takes the error by value" +)] +pub(crate) fn elapsed(error: tokio::time::error::Elapsed) -> Error { + timeout(&error) } -fn timeout(source: impl std::error::Error + Send + Sync + 'static) -> CompletionError { - let kind = CompletionErrorKind::Timeout; - CompletionError::new(kind, kind.phrase()).with_source(source) +fn timeout(cause: &(dyn std::error::Error + 'static)) -> Error { + let code = ErrorCode::Timeout; + completion_error(code, phrase(code)).with_cause(cause) } /// The failure of a client built with `GatewayChat::disabled`: model /// access is off, so no round is sent. -pub(crate) fn unavailable() -> CompletionError { - let kind = CompletionErrorKind::Unavailable; - CompletionError::new(kind, kind.phrase()) +pub(crate) fn unavailable() -> Error { + let code = ErrorCode::Unavailable; + completion_error(code, phrase(code)) } -/// A `MalformedResponse` failure: the kind's fixed phrase extended with +/// A `MalformedResponse` failure: the code's fixed phrase extended with /// `: ` and `specific`, which this crate's own code wrote. Provider text /// never goes in `specific`. -pub(crate) fn malformed(specific: impl Display) -> CompletionError { - let kind = CompletionErrorKind::MalformedResponse; - CompletionError::new(kind, format!("{}: {specific}", kind.phrase())) +pub(crate) fn malformed(specific: impl Display) -> Error { + let code = ErrorCode::MalformedResponse; + completion_error(code, format!("{}: {specific}", phrase(code))) +} + +/// Appends provider `text`, already bounded and control-escaped, to +/// `error`'s message after `: `. The prose stays as it was, so provider +/// text never reaches the model. +pub(crate) fn with_detail(mut error: Error, text: &str) -> Error { + error.message.push_str(": "); + error.message.push_str(text); + error } /// A validating constructor's refusal of parts this crate decoded from a /// reply: an `InvalidParts` refusal becomes the `MalformedResponse` the /// backend's reply earned, keeping its specific, since another reply may /// hold whole parts. Any other failure passes through. -pub(crate) fn decoded(error: CompletionError) -> CompletionError { - let kind = CompletionErrorKind::InvalidParts; - if error.kind() != kind { +pub(crate) fn decoded(error: Error) -> Error { + let code = ErrorCode::InvalidParts; + if error.code != code { return error; } - let message = error.message(); - let specific = message - .strip_prefix(kind.phrase()) + let prose = error.prose.as_str(); + let specific = prose + .strip_prefix(phrase(code)) .and_then(|rest| rest.strip_prefix(": ")) - .unwrap_or(message); + .unwrap_or(prose); malformed(specific) } @@ -68,36 +87,37 @@ mod tests { use super::*; #[tokio::test] - async fn each_helper_uses_the_fixed_phrase_for_its_kind() { + async fn each_helper_uses_the_fixed_phrase_for_its_code() { let elapsed_error = tokio::time::timeout( std::time::Duration::from_millis(1), std::future::pending::<()>(), ) .await .expect_err("a pending future elapses"); + let cause = elapsed_error.to_string(); let timed_out = elapsed(elapsed_error); - assert_eq!(timed_out.kind(), CompletionErrorKind::Timeout); - assert_eq!( - timed_out.to_string(), - "the model backend did not answer in time" + assert_eq!(timed_out.code, ErrorCode::Timeout); + assert_eq!(timed_out.prose, "the model backend did not answer in time"); + assert!( + timed_out.message.contains(&cause) && !timed_out.prose.contains(&cause), + "the elapsed error is in the message only: {timed_out:?}" ); - assert!(std::error::Error::source(&timed_out).is_some()); let disabled = unavailable(); - assert_eq!(disabled.kind(), CompletionErrorKind::Unavailable); + assert_eq!(disabled.code, ErrorCode::Unavailable); assert_eq!( disabled.to_string(), "model access is turned off or not configured" ); let unreadable = malformed("model list body exceeds the 8-byte limit"); - assert_eq!(unreadable.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(unreadable.code, ErrorCode::MalformedResponse); assert_eq!( unreadable.to_string(), "the model backend sent a reply that could not be understood: \ model list body exceeds the 8-byte limit" ); - assert_eq!(unreadable.detail(), None); + assert_eq!(unreadable.message, unreadable.prose); } #[test] @@ -105,15 +125,15 @@ mod tests { use runvil::model::ToolCall; let refusal = ToolCall::from_parts(" ", "fetch", serde_json::json!({})) .expect_err("a blank id is refused"); - assert_eq!(refusal.kind(), CompletionErrorKind::InvalidParts); + assert_eq!(refusal.code, ErrorCode::InvalidParts); let reply = decoded(refusal); - assert_eq!(reply.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(reply.code, ErrorCode::MalformedResponse); assert_eq!( reply.to_string(), "the model backend sent a reply that could not be understood: tool call id was blank" ); let passed = decoded(unavailable()); - assert_eq!(passed.kind(), CompletionErrorKind::Unavailable); + assert_eq!(passed.code, ErrorCode::Unavailable); } } diff --git a/crates/harness-gateway-client/src/lib.rs b/crates/harness-gateway-client/src/lib.rs index 11a4660a9..2d6bcdfda 100644 --- a/crates/harness-gateway-client/src/lib.rs +++ b/crates/harness-gateway-client/src/lib.rs @@ -30,7 +30,7 @@ //! Under the client sits the OpenAI chat-completions wire code. It turns a //! round into a request body, a streamed reply into a //! [`Completion`](runvil::model::Completion), and a failed response -//! into the [`CompletionError`] the round fails with. +//! into the [`Error`](runvil::error::Error) the round fails with. //! //! - [`build_request_body`] builds the JSON body that every round sends. //! - [`read_completion_stream`] reads a streamed reply from a @@ -42,8 +42,8 @@ //! decodes whole. //! - [`escape_controls`] cuts a backend error body to a length limit and //! escapes its control characters. -//! - [`classify_http_failure`] turns a failure status and its body into a -//! `CompletionError` of the matching kind. [`classify_stream_error`] does +//! - [`classify_http_failure`] turns a failure status and its body into an +//! error of the matching model code. [`classify_stream_error`] does //! the same for an error envelope that arrives inside the stream. //! //! The client, or another broker, owns the connection and supplies the @@ -57,9 +57,8 @@ //! - A Gateway bearer key is never written to logs, `Debug`, `Display`, or //! error text. //! - A backend error body is bounded and control-escaped before it is -//! kept. A chat round keeps it only in the opt-in -//! `CompletionError::detail`, and a search keeps it in the -//! `GatewaySearchError` message. +//! kept. A chat round keeps it only in the error's message, never in its +//! prose, and a search keeps it in its error's message and prose. //! - A client sends requests without a key only when the caller builds it //! with `GatewayChat::keyless`, which does not check the endpoint's //! address, or when `GatewayChat::from_env` finds no key for a loopback @@ -76,15 +75,9 @@ mod wire; pub use broker::GatewayBroker; pub use catalog::fetch_model_catalog; -pub use config::GatewayConfigError; pub use config::GatewayEndpoint; -pub use config::SecretError; pub use config::SecretString; -pub use runvil::model::CompletionError; -pub use runvil::model::CompletionErrorKind; pub use search::GatewaySearch; -pub use search::GatewaySearchError; -pub use search::GatewaySearchErrorKind; pub use transport::GatewayChat; pub use wire::classify::classify_http_failure; pub use wire::classify::classify_stream_error; diff --git a/crates/harness-gateway-client/src/search-tests-provider.rs b/crates/harness-gateway-client/src/search-tests-provider.rs index 050dafe0b..093887ba7 100644 --- a/crates/harness-gateway-client/src/search-tests-provider.rs +++ b/crates/harness-gateway-client/src/search-tests-provider.rs @@ -9,10 +9,21 @@ use std::sync::{Arc, Mutex}; use axum::http::StatusCode; use plugin_web::{ - Freshness, SafeSearch, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, + Freshness, SafeSearch, SearchError, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, }; +use runvil::error::{Error, ErrorCode}; -use crate::search::GatewaySearchError; +/// Asserts `error`'s source is the Gateway's failure under `code`, whose +/// prose is `error`'s text and whose message holds that text and more. +fn assert_gateway_cause(error: &SearchError, code: ErrorCode) { + let source = std::error::Error::source(error).expect("the failure keeps a cause"); + let cause = source + .downcast_ref::() + .unwrap_or_else(|| panic!("the Gateway's error is the cause: {source}")); + assert_eq!(cause.code, code); + assert_eq!(cause.prose, error.to_string()); + assert!(cause.message.starts_with(&cause.prose)); +} /// The Authorization header and body of each search a mock received. type Seen = Arc, Value)>>>; @@ -120,11 +131,7 @@ async fn a_gateway_error_status_maps_as_backend_with_the_gateway_error_as_source .expect_err("a 502 fails the search"); assert_eq!(error.kind(), SearchErrorKind::Backend); assert_eq!(error.to_string(), "backend returned 502: upstream down"); - let source = std::error::Error::source(&error).expect("the failure keeps a cause"); - assert!( - source.is::(), - "the Gateway's error is the cause: {source}" - ); + assert_gateway_cause(&error, ErrorCode::ToolBackend); } #[tokio::test] @@ -140,9 +147,5 @@ async fn a_refused_connection_maps_as_transport_with_the_gateway_error_as_source .expect_err("a refused connection fails the search"); assert_eq!(error.kind(), SearchErrorKind::Transport); assert_eq!(error.to_string(), "request failed"); - let source = std::error::Error::source(&error).expect("the failure keeps a cause"); - assert!( - source.is::(), - "the Gateway's error is the cause: {source}" - ); + assert_gateway_cause(&error, ErrorCode::ToolTransport); } diff --git a/crates/harness-gateway-client/src/search-tests-responses.rs b/crates/harness-gateway-client/src/search-tests-responses.rs index c72b9d064..d6555724f 100644 --- a/crates/harness-gateway-client/src/search-tests-responses.rs +++ b/crates/harness-gateway-client/src/search-tests-responses.rs @@ -1,18 +1,33 @@ //! Gateway response handling: a refused connection or a stalled gateway is -//! a transport error with its source, a success body with the wrong shape -//! or invalid UTF-8 is a backend error with its source, an oversized +//! a transport failure with its cause, a success body with the wrong shape +//! or invalid UTF-8 is a backend failure with its cause, an oversized //! success body is rejected rather than truncated, a success body that -//! fails mid-read is a transport error with its source, and an error body -//! is bounded, sanitized, and keeps a mid-read failure as the error source. +//! fails mid-read is a transport failure with its cause, and an error body +//! is bounded, sanitized, and keeps a mid-read failure as the cause. A +//! cause is in the message after the prose, never in the prose. use super::*; use std::time::Duration; -use crate::search::{GatewaySearchErrorKind, MAX_ERROR_BODY, MAX_RESPONSE_BODY}; +use runvil::error::{Error, ErrorCode}; + +use crate::search::{MAX_ERROR_BODY, MAX_RESPONSE_BODY}; + +/// Asserts `err` failed as `code` with `prose`, and that its message holds +/// the prose followed by a cause the prose leaves out. +fn assert_caused(err: &Error, code: ErrorCode, prose: &str) { + assert_eq!(err.code, code); + assert_eq!(err.prose, prose); + let cause = err.message.strip_prefix(&format!("{prose}: ")); + assert!( + cause.is_some_and(|cause| !cause.is_empty()), + "the cause follows the prose in the message only: {err:?}" + ); +} #[tokio::test] -async fn transport_failure_is_transport_kind() { +async fn a_refused_connection_is_a_transport_failure_with_its_cause() { // Bind then drop the listener so the port is closed and the connection // is refused deterministically. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -23,9 +38,7 @@ async fn transport_failure_is_transport_kind() { .search(&query("hi")) .await .expect_err("a refused connection must surface as an error"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Transport); - assert_eq!(err.to_string(), "request failed"); - assert!(std::error::Error::source(&err).is_some()); + assert_caused(&err, ErrorCode::ToolTransport, "request failed"); } #[tokio::test] @@ -44,17 +57,15 @@ async fn stalling_gateway_times_out_as_transport() { .search(&query("hi")) .await .expect_err("a stalled gateway must surface as an error"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Transport); + assert_caused(&err, ErrorCode::ToolTransport, "request failed"); assert!( - std::error::Error::source(&err) - .and_then(|source| source.downcast_ref::()) - .is_some_and(reqwest::Error::is_timeout), - "the timeout must be preserved as the error's transport source" + err.message.contains("timed out"), + "the timeout must be kept in the message: {err:?}" ); } #[tokio::test] -async fn malformed_success_json_is_backend_error_with_source() { +async fn malformed_success_json_is_a_backend_failure_with_its_cause() { async fn web_search() -> Json { // Missing the required `results` array: valid JSON, wrong shape. Json(serde_json::json!({ "unexpected": true })) @@ -65,16 +76,11 @@ async fn malformed_success_json_is_backend_error_with_source() { .search(&query("hi")) .await .expect_err("a wrong-shaped success body must be rejected"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Backend); - assert_eq!(err.to_string(), "malformed search response"); - assert!( - std::error::Error::source(&err).is_some(), - "a malformed response must preserve its parse source" - ); + assert_caused(&err, ErrorCode::ToolBackend, "malformed search response"); } #[tokio::test] -async fn non_utf8_success_body_is_backend_error_with_source() { +async fn non_utf8_success_body_is_a_backend_failure_with_its_cause() { async fn web_search() -> Vec { vec![b'{', 0xff, 0xfe, b'}'] } @@ -84,13 +90,10 @@ async fn non_utf8_success_body_is_backend_error_with_source() { .search(&query("hi")) .await .expect_err("a success body that is not UTF-8 must be rejected"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Backend); - assert_eq!(err.to_string(), "response body was not valid UTF-8"); - assert!( - std::error::Error::source(&err) - .and_then(|source| source.downcast_ref::()) - .is_some(), - "the UTF-8 decode failure must be preserved as the error's source" + assert_caused( + &err, + ErrorCode::ToolBackend, + "response body was not valid UTF-8", ); } @@ -105,12 +108,13 @@ async fn oversized_success_body_is_rejected() { .search(&query("hi")) .await .expect_err("an oversized success body must be rejected, not truncated"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Backend); + assert_eq!(err.code, ErrorCode::ToolBackend); assert_eq!( - err.to_string(), + err.prose, format!("response body exceeded {MAX_RESPONSE_BODY} bytes"), "the error must name the cap overflow" ); + assert_eq!(err.message, err.prose); } #[tokio::test] @@ -127,8 +131,9 @@ async fn oversized_error_body_is_bounded_and_sanitized() { .search(&query("hi")) .await .expect_err("a 500 response must surface as an error"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Backend); - let message = err.to_string(); + assert_eq!(err.code, ErrorCode::ToolBackend); + assert_eq!(err.message, err.prose); + let message = err.prose; assert!( message.starts_with("backend returned 500: line-one\\nline-two\\ttab"), "error must name the status and escape the body: {message}" @@ -165,40 +170,29 @@ async fn spawn_truncated_reply(status: &'static str) -> (SocketAddr, tokio::task } #[tokio::test] -async fn success_body_read_failure_is_transport_error_with_source() { +async fn success_body_read_failure_is_a_transport_failure_with_its_cause() { let (addr, handle) = spawn_truncated_reply("200 OK").await; let err = search_at(&format!("http://{addr}")) .search(&query("hi")) .await .expect_err("a truncated 200 body must surface as an error"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Transport); - assert_eq!(err.to_string(), "reading response failed"); - assert!( - std::error::Error::source(&err) - .and_then(|source| source.downcast_ref::()) - .is_some(), - "the body-read failure must be preserved as the error's source, got: {err}" - ); + assert_caused(&err, ErrorCode::ToolTransport, "reading response failed"); handle.abort(); } #[tokio::test] -async fn error_body_read_failure_is_preserved_as_source() { +async fn error_body_read_failure_is_kept_as_the_cause() { let (addr, handle) = spawn_truncated_reply("500 Internal Server Error").await; let err = search_at(&format!("http://{addr}")) .search(&query("hi")) .await .expect_err("a truncated 500 body must surface as an error"); - assert_eq!(err.kind(), GatewaySearchErrorKind::Backend); - assert_eq!( - err.to_string(), - "backend returned 500, and its error body could not be read" - ); - assert!( - std::error::Error::source(&err).is_some(), - "the body-read failure must be preserved as the error's source, got: {err}" + assert_caused( + &err, + ErrorCode::ToolBackend, + "backend returned 500, and its error body could not be read", ); handle.abort(); } diff --git a/crates/harness-gateway-client/src/search.rs b/crates/harness-gateway-client/src/search.rs index d8fe72366..4337b159c 100644 --- a/crates/harness-gateway-client/src/search.rs +++ b/crates/harness-gateway-client/src/search.rs @@ -10,6 +10,8 @@ use std::time::Duration; use plugin_web::{ SearchError, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, SearchResults, }; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use crate::config::{GatewayEndpoint, SecretString}; @@ -99,61 +101,6 @@ struct GatewaySearchResult { extra_snippets: Vec, } -/// Which side of a Gateway web search failed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum GatewaySearchErrorKind { - /// Sending the request or reading a successful reply failed, timeouts - /// included. - Transport, - /// The Gateway answered with a failure status or an unusable body. - Backend, -} - -/// An error from a failed Gateway web search, with its kind, a message, and -/// the underlying cause when there is one. -/// -/// The message starts with what failed, such as `request failed` or -/// `backend returned 502: ...`. When the message includes the Gateway's -/// error body, that body is truncated to a size limit and its control -/// characters are escaped. The message never contains the bearer key. -#[derive(Debug, thiserror::Error)] -#[error("{message}")] -pub struct GatewaySearchError { - kind: GatewaySearchErrorKind, - message: String, - #[source] - source: Option>, -} - -impl GatewaySearchError { - fn new(kind: GatewaySearchErrorKind, message: impl Into) -> GatewaySearchError { - GatewaySearchError { - kind, - message: message.into(), - source: None, - } - } - - fn with_source( - kind: GatewaySearchErrorKind, - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> GatewaySearchError { - GatewaySearchError { - kind, - message: message.into(), - source: Some(Box::new(source)), - } - } - - /// Returns which side of the search failed. - #[must_use] - pub fn kind(&self) -> GatewaySearchErrorKind { - self.kind - } -} - /// The [`SearchProvider`] a Host supplies to search the web through the /// Gateway. /// @@ -203,11 +150,10 @@ impl GatewaySearch { } /// Runs one web search and returns the Gateway's parsed reply, failing - /// as the [`SearchProvider`] impl documents. - async fn search( - &self, - request: &GatewaySearchRequest, - ) -> Result { + /// as the [`SearchProvider`] impl documents: `ToolTransport` where it + /// says `Transport` and `ToolBackend` where it says `Backend`, with the + /// text it names as the prose and any cause after it in the message. + async fn search(&self, request: &GatewaySearchRequest) -> Result { let response = self .http .post(format!("{}/tools/web_search", self.base_url)) @@ -217,11 +163,7 @@ impl GatewaySearch { .send() .await .map_err(|source| { - GatewaySearchError::with_source( - GatewaySearchErrorKind::Transport, - "request failed", - source, - ) + tool_error(ErrorCode::ToolTransport, "request failed").with_cause(&source) })?; let status = response.status(); @@ -229,7 +171,7 @@ impl GatewaySearch { let code = status.as_u16(); // The error body is external gateway content: bound the read and // sanitize control characters. If the body itself cannot be read, - // keep the read failure as the returned error's `source()`. + // keep the read failure in the returned error's message. match read_bounded(response, MAX_ERROR_BODY).await { Ok(body) => { let body = if body.is_empty() { @@ -237,17 +179,17 @@ impl GatewaySearch { } else { sanitize_diagnostic(&body) }; - return Err(GatewaySearchError::new( - GatewaySearchErrorKind::Backend, + return Err(tool_error( + ErrorCode::ToolBackend, format!("backend returned {code}: {body}"), )); } Err(source) => { - return Err(GatewaySearchError::with_source( - GatewaySearchErrorKind::Backend, + return Err(tool_error( + ErrorCode::ToolBackend, format!("backend returned {code}, and its error body could not be read"), - source, - )); + ) + .with_cause(&source)); } } } @@ -256,11 +198,7 @@ impl GatewaySearch { // overflow), then parse the promised JSON shape. let body = read_capped(response, MAX_RESPONSE_BODY).await?; serde_json::from_str(&body).map_err(|source| { - GatewaySearchError::with_source( - GatewaySearchErrorKind::Backend, - "malformed search response", - source, - ) + tool_error(ErrorCode::ToolBackend, "malformed search response").with_cause(&source) }) } } @@ -271,8 +209,10 @@ impl SearchProvider for GatewaySearch { /// results with every field copied as the Gateway sent it. /// /// # Errors - /// Returns a [`SearchError`] with the message of the - /// [`GatewaySearchError`] it keeps as its source. Its kind is: + /// Returns a [`SearchError`] whose text is the prose of the [`Error`] + /// it keeps as its source. The Gateway's error body in that text is + /// truncated to a size limit and its control characters are escaped, + /// and the text never holds the bearer key. Its kind is: /// - `Transport` when sending the request fails (`request failed`) or /// reading a successful reply fails (`reading response failed`), /// including when the deadline passes; @@ -324,14 +264,15 @@ fn search_results(response: GatewaySearchResponse) -> SearchResults { } } -/// The provider's error for a failed Gateway search: its kind and text, -/// with the Gateway's error as the cause. -fn search_error(error: GatewaySearchError) -> SearchError { - let kind = match error.kind { - GatewaySearchErrorKind::Backend => SearchErrorKind::Backend, - GatewaySearchErrorKind::Transport => SearchErrorKind::Transport, +/// The provider's error for a failed Gateway search: its kind from the +/// code, its text from the prose, and the Gateway's error as the cause. +fn search_error(error: Error) -> SearchError { + let kind = if error.code == ErrorCode::ToolTransport { + SearchErrorKind::Transport + } else { + SearchErrorKind::Backend }; - SearchError::with_source(kind, error.to_string(), error) + SearchError::with_source(kind, error.prose.clone(), error) } /// Escapes control characters in an external diagnostic body so a hostile @@ -357,18 +298,11 @@ fn sanitize_diagnostic(body: &str) -> String { /// Reads at most `limit` bytes of a diagnostic body, stopping early once the cap /// is reached. Used for the error path, where a truncated, lossy rendering is an /// acceptable diagnostic. -async fn read_bounded( - mut response: reqwest::Response, - limit: usize, -) -> Result { +async fn read_bounded(mut response: reqwest::Response, limit: usize) -> Result { let mut buffer: Vec = Vec::new(); while buffer.len() < limit { let chunk = response.chunk().await.map_err(|source| { - GatewaySearchError::with_source( - GatewaySearchErrorKind::Transport, - "reading response failed", - source, - ) + tool_error(ErrorCode::ToolTransport, "reading response failed").with_cause(&source) })?; let Some(chunk) = chunk else { break }; let take = (limit - buffer.len()).min(chunk.len()); @@ -383,32 +317,21 @@ async fn read_bounded( /// Reads a success body, rejecting it once it would exceed `limit` bytes rather /// than truncating (a truncated JSON document is not a valid result set), and /// requiring valid UTF-8. -async fn read_capped( - mut response: reqwest::Response, - limit: usize, -) -> Result { +async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result { let mut buffer: Vec = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|source| { - GatewaySearchError::with_source( - GatewaySearchErrorKind::Transport, - "reading response failed", - source, - ) + tool_error(ErrorCode::ToolTransport, "reading response failed").with_cause(&source) })? { if buffer.len() + chunk.len() > limit { - return Err(GatewaySearchError::new( - GatewaySearchErrorKind::Backend, + return Err(tool_error( + ErrorCode::ToolBackend, format!("response body exceeded {limit} bytes"), )); } buffer.extend_from_slice(&chunk); } String::from_utf8(buffer).map_err(|source| { - GatewaySearchError::with_source( - GatewaySearchErrorKind::Backend, - "response body was not valid UTF-8", - source, - ) + tool_error(ErrorCode::ToolBackend, "response body was not valid UTF-8").with_cause(&source) }) } diff --git a/crates/harness-gateway-client/src/transport.rs b/crates/harness-gateway-client/src/transport.rs index 690b593db..7b68cfc10 100644 --- a/crates/harness-gateway-client/src/transport.rs +++ b/crates/harness-gateway-client/src/transport.rs @@ -11,9 +11,10 @@ use std::fmt; use std::num::NonZeroU64; use std::time::{Duration, Instant}; -use runvil::model::{Completion, CompletionError, CompletionOptions, Message, ToolSchema}; +use runvil::error::Error; +use runvil::model::{Completion, CompletionOptions, Message, ToolSchema}; -use crate::config::{GatewayConfigError, GatewayEndpoint, SecretString}; +use crate::config::{GatewayEndpoint, SecretString, invalid_env, missing_env}; use crate::failure::{elapsed, transport_failure, unavailable}; use crate::wire::classify::classify_http_failure; use crate::wire::delta::StreamDelta; @@ -62,7 +63,7 @@ struct ResponseChunks { impl ChunkSource for ResponseChunks { type Chunk = bytes::Bytes; - async fn next_chunk(&mut self) -> Result, CompletionError> { + async fn next_chunk(&mut self) -> Result, Error> { tokio::time::timeout(self.timeout, self.response.chunk()) .await .map_err(elapsed)? @@ -126,9 +127,9 @@ impl GatewayChat { /// Builds an offline client, for execution paths that must stay /// hermetic. /// - /// Any attempted model call fails with an `Unavailable`-kind - /// [`CompletionError`]. The client reads no gateway configuration and - /// sends no HTTP request. + /// Any attempted model call fails with an [`Error`] with code + /// `Unavailable`. The client reads no gateway configuration and sends no + /// HTTP request. #[must_use] pub fn disabled() -> GatewayChat { GatewayChat { @@ -184,19 +185,18 @@ impl GatewayChat { /// `Unavailable`-kind error when the gateway answers 401. /// /// # Errors - /// Returns a [`GatewayConfigError`] when `RUNVIL_GATEWAY_URL` is - /// missing or invalid, when either variable is set to a value that is not - /// valid Unicode, or when the URL's host is not loopback (a LAN or - /// remote gateway) and `RUNVIL_GATEWAY_API_KEY` is missing or empty. - pub fn from_env() -> Result { + /// Returns an [`InvalidValue`](runvil::error::ErrorCode::InvalidValue) + /// setup error when `RUNVIL_GATEWAY_URL` is missing or invalid, when + /// either variable is set to a value that is not valid Unicode, or when + /// the URL's host is not loopback (a LAN or remote gateway) and + /// `RUNVIL_GATEWAY_API_KEY` is missing or empty. + pub fn from_env() -> Result { from_env_with(|name| match std::env::var(name) { Ok(value) => Ok(Some(value)), Err(std::env::VarError::NotPresent) => Ok(None), // A set-but-non-Unicode value is a real misconfiguration, surfaced // explicitly instead of being silently treated as "not set". - Err(std::env::VarError::NotUnicode(_)) => { - Err(GatewayConfigError::InvalidEnv(name.to_owned())) - } + Err(std::env::VarError::NotUnicode(_)) => Err(invalid_env(name)), }) } @@ -229,8 +229,8 @@ impl GatewayChat { /// settings in `options` extend the request when present. /// /// # Errors - /// Returns a [`CompletionError`] whose [`kind`](CompletionError::kind) is - /// always one of the following: + /// Returns an [`Error`] whose [`code`](Error::code) is always one of the + /// following: /// - `Unavailable` when this client was built with [`GatewayChat::disabled`], /// or when the gateway answers 401 or 403. /// - `Timeout` when the request timeout elapses before the response @@ -244,8 +244,8 @@ impl GatewayChat { /// reads it. /// - `MalformedResponse` when the stream exceeds the response size cap, /// a chunk fails to parse as usable JSON (a JSON decode error is kept - /// as the error's source), or the stream ends before the `[DONE]` - /// sentinel. It is also the kind when a `length` or `content_filter` + /// in the error's message), or the stream ends before the `[DONE]` + /// sentinel. It is also the code when a `length` or `content_filter` /// finish reason cuts off a batch of tool calls, because partial /// arguments must not run. /// - `EmptyReply` when the turn has zero tool calls and blank text. @@ -255,7 +255,7 @@ impl GatewayChat { tools: Option<&[ToolSchema]>, options: &CompletionOptions, on_delta: impl Fn(StreamDelta), - ) -> Result { + ) -> Result { let GatewayTransport::Http(http) = &self.transport else { return Err(unavailable()); }; @@ -287,9 +287,8 @@ impl GatewayChat { let raw_body = read_body_capped(&mut chunks, content_length, self.max_response_bytes).await?; // F5: bound the body, then escape control characters so a hostile - // payload cannot forge log lines. The classifier keeps the escaped - // body only as the opt-in `CompletionError::detail`, never in the - // public `Display`. + // payload cannot forge log lines. The classifier appends the + // escaped body to the error's message only, never to its prose. let body = String::from_utf8_lossy(&raw_body); let body = escape_controls(&body, 2000); return Err(classify_http_failure(status.as_u16(), &body)); @@ -319,10 +318,10 @@ impl GatewayChat { /// counts as unset ([`SecretString::new`] refuses only an empty secret, and /// `Result::ok` folds that refusal into `None`). fn from_env_with( - lookup: impl Fn(&str) -> Result, GatewayConfigError>, -) -> Result { - let base_url = lookup("RUNVIL_GATEWAY_URL")? - .ok_or_else(|| GatewayConfigError::MissingEnv("RUNVIL_GATEWAY_URL".into()))?; + lookup: impl Fn(&str) -> Result, Error>, +) -> Result { + let base_url = + lookup("RUNVIL_GATEWAY_URL")?.ok_or_else(|| missing_env("RUNVIL_GATEWAY_URL"))?; let endpoint = GatewayEndpoint::new(&base_url)?; let key = lookup("RUNVIL_GATEWAY_API_KEY")? .map(SecretString::new) @@ -330,9 +329,7 @@ fn from_env_with( match key { Some(key) => Ok(GatewayChat::new(endpoint, key)), None if endpoint.is_loopback() => Ok(GatewayChat::keyless(endpoint)), - None => Err(GatewayConfigError::MissingEnv( - "RUNVIL_GATEWAY_API_KEY".into(), - )), + None => Err(missing_env("RUNVIL_GATEWAY_API_KEY")), } } diff --git a/crates/harness-gateway-client/src/transport/tests.rs b/crates/harness-gateway-client/src/transport/tests.rs index 870ec9efb..d13abc9ae 100644 --- a/crates/harness-gateway-client/src/transport/tests.rs +++ b/crates/harness-gateway-client/src/transport/tests.rs @@ -117,7 +117,7 @@ fn keyed_client(base: &str) -> GatewayChat { fn lookup_from<'a>( pairs: &'a [(&'a str, &'a str)], -) -> impl Fn(&str) -> Result, GatewayConfigError> + 'a { +) -> impl Fn(&str) -> Result, Error> + 'a { let pairs: Vec<(String, String)> = pairs .iter() .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) diff --git a/crates/harness-gateway-client/src/transport/tests/env.rs b/crates/harness-gateway-client/src/transport/tests/env.rs index bd5ebca90..4fc4656b8 100644 --- a/crates/harness-gateway-client/src/transport/tests/env.rs +++ b/crates/harness-gateway-client/src/transport/tests/env.rs @@ -3,23 +3,41 @@ use runvil::model::Message; +use runvil::error::{ErrorCode, FactKey}; +use serde_json::Value; + use super::*; -use crate::config::SecretError; +use crate::config::invalid_env; + +/// Asserts `error` is a setup error for `reason`, naming `variable` when one +/// is given, whose prose is its message. +#[track_caller] +fn assert_setup_error(error: &Error, reason: &str, variable: Option<&str>) { + assert_eq!(error.code, ErrorCode::InvalidValue, "{error:?}"); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason)), + "{error:?}" + ); + assert_eq!( + error.facts.get(&FactKey::Name), + variable.map(Value::from).as_ref(), + "{error:?}" + ); + assert_eq!(error.prose, error.message, "{error:?}"); +} #[test] fn from_env_surfaces_non_unicode_value_instead_of_dropping_it() { let err = from_env_with(|name| { if name == "RUNVIL_GATEWAY_URL" { - Err(GatewayConfigError::InvalidEnv(name.to_owned())) + Err(invalid_env(name)) } else { Ok(Some("tok".to_owned())) } }) .expect_err("a non-Unicode variable must be surfaced, not treated as missing"); - assert!( - matches!(err, GatewayConfigError::InvalidEnv(ref name) if name == "RUNVIL_GATEWAY_URL"), - "expected an explicit InvalidEnv error, got {err:?}" - ); + assert_setup_error(&err, "invalid_env", Some("RUNVIL_GATEWAY_URL")); assert_eq!( err.to_string(), "environment variable is set but not valid Unicode: RUNVIL_GATEWAY_URL" @@ -34,10 +52,7 @@ fn from_env_missing_gateway_url() { err.to_string(), "missing environment variable: RUNVIL_GATEWAY_URL" ); - assert!(matches!( - err, - GatewayConfigError::MissingEnv(name) if name == "RUNVIL_GATEWAY_URL" - )); + assert_setup_error(&err, "missing_env", Some("RUNVIL_GATEWAY_URL")); } #[test] @@ -57,10 +72,7 @@ fn from_env_missing_gateway_key() { ] { let err = from_env_with(lookup_from(&key_pairs)) .expect_err("missing key against a non-loopback gateway must fail"); - assert!( - matches!(err, GatewayConfigError::MissingEnv(ref name) if name == "RUNVIL_GATEWAY_API_KEY"), - "expected MissingEnv for {key_pairs:?}, got {err:?}" - ); + assert_setup_error(&err, "missing_env", Some("RUNVIL_GATEWAY_API_KEY")); } } @@ -221,22 +233,18 @@ fn secret_string_never_prints_its_contents() { #[test] fn secret_string_construction_rejects_an_empty_credential() { // F12: an empty bearer credential is unrepresentable. - assert!(matches!(SecretString::new(""), Err(SecretError::Empty))); - assert!(SecretString::new("tok").is_ok()); -} - -#[test] -fn an_unusable_secret_is_a_config_error_that_keeps_its_cause() { - // AUDIT-DISCARDED-SOURCE: the SecretError survives as the config - // error's source. - let secret_error = SecretString::new("").expect_err("blank key is rejected"); - let config = GatewayConfigError::from(secret_error); - assert_eq!(config.to_string(), "gateway bearer key is unusable"); - assert!( - std::error::Error::source(&config) - .is_some_and(|source| source.downcast_ref::().is_some()), - "the SecretError cause must survive" + let error = SecretString::new("").expect_err("blank key is rejected"); + assert_eq!(error.code, ErrorCode::InvalidValue); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&serde_json::Value::from("empty_secret")) ); + assert_eq!(error.message, "secret must not be empty"); + assert_eq!( + error.prose, + "The secret is empty. Supply a non-empty secret." + ); + assert!(SecretString::new("tok").is_ok()); } #[test] @@ -252,24 +260,20 @@ fn gateway_endpoint_rejects_non_http_schemes_and_missing_host() { } #[test] -fn gateway_endpoint_keeps_the_url_parse_cause() { - // AUDIT-DISCARDED-SOURCE: the url::ParseError survives as the source. +fn gateway_endpoint_keeps_the_url_parse_cause_in_its_message() { let url_error = GatewayEndpoint::new("not a url").expect_err("malformed URL is rejected"); - assert_eq!(url_error.to_string(), "gateway URL is not a valid URL"); - assert!( - std::error::Error::source(&url_error) - .is_some_and(|source| source.downcast_ref::().is_some()), - "the url::ParseError cause must survive" + assert_eq!( + url_error.message, + format!( + "gateway URL is not a valid URL: {}", + url::ParseError::RelativeUrlWithoutBase + ) ); + assert_setup_error(&url_error, "invalid_config", None); } #[test] -fn constructor_errors_preserve_sources_without_leaking_secrets() { - let parse = GatewayEndpoint::new("not a url hunter2").expect_err("malformed URL is rejected"); - assert!( - std::error::Error::source(&parse).is_some(), - "the endpoint parse failure must be preserved as the source" - ); +fn constructor_errors_do_not_leak_secrets() { for url in [ "not a url hunter2", "ftp://alice:hunter2@host/v1", @@ -285,12 +289,6 @@ fn constructor_errors_preserve_sources_without_leaking_secrets() { ); } } - let secret = - GatewayConfigError::from(SecretString::new("").expect_err("blank key is rejected")); - assert!( - std::error::Error::source(&secret).is_some(), - "the empty-key failure must be preserved as the source" - ); } #[test] diff --git a/crates/harness-gateway-client/src/transport/tests/limits.rs b/crates/harness-gateway-client/src/transport/tests/limits.rs index 83e11c169..9054b0003 100644 --- a/crates/harness-gateway-client/src/transport/tests/limits.rs +++ b/crates/harness-gateway-client/src/transport/tests/limits.rs @@ -4,10 +4,10 @@ use std::num::NonZeroU64; use std::time::Duration; +use runvil::error::ErrorCode; use runvil::model::{CompletionResult, Message}; use super::*; -use crate::CompletionErrorKind; #[tokio::test] async fn complete_on_a_disabled_client_is_an_unavailable_error() { @@ -17,7 +17,7 @@ async fn complete_on_a_disabled_client_is_an_unavailable_error() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("a disabled client cannot complete"); - assert_eq!(err.kind(), CompletionErrorKind::Unavailable); + assert_eq!(err.code, ErrorCode::Unavailable); assert_eq!( err.to_string(), "model access is turned off or not configured" @@ -25,7 +25,7 @@ async fn complete_on_a_disabled_client_is_an_unavailable_error() { } #[tokio::test] -async fn backend_error_display_is_body_free_and_body_is_opt_in_and_escaped() { +async fn backend_error_prose_is_body_free_and_the_message_holds_the_escaped_body() { use axum::Router; use axum::routing::post; @@ -43,27 +43,30 @@ async fn backend_error_display_is_body_free_and_body_is_opt_in_and_escaped() { .await .expect_err("a 502 must surface as a classified failure"); - // F5: the public Display names only the kind and the status, never the - // raw body. - assert_eq!(err.kind(), CompletionErrorKind::ServerError); - let shown = err.to_string(); + // F5: the prose the model reads names only the code and the status, + // never the raw body. + assert_eq!(err.code, ErrorCode::ServerError); + let shown = &err.prose; assert_eq!( shown, "the model backend reported a fault of its own (status 502)" ); assert!( !shown.contains("super-secret") && !shown.contains('\n'), - "the raw body must not appear in Display, got {shown}" + "the raw body must not appear in the prose, got {shown}" ); - // The bounded, control-escaped body is available only via the opt-in. - let body = err.detail().expect("the body is available opt-in"); + // The bounded, control-escaped body is in the message only. + let body = err + .message + .strip_prefix(&format!("{shown}: ")) + .expect("the message holds the body"); assert!( body.contains("\\n"), "control chars must be escaped, got {body}" ); assert!( - !body.contains('\n'), - "no raw newline in the diagnostic body" + !err.message.contains('\n'), + "no raw newline in the diagnostic message" ); } @@ -84,7 +87,7 @@ async fn complete_refuses_a_success_stream_over_the_size_cap() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an oversize stream must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -109,8 +112,8 @@ async fn complete_refuses_a_backend_error_body_over_the_size_cap() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an oversize error body must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert_eq!(err.detail(), None, "the refused body is never kept"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + assert_eq!(err.message, err.prose, "the refused body is never kept"); } #[tokio::test] @@ -133,8 +136,8 @@ async fn a_request_past_the_timeout_is_a_timeout_failure() { .await .expect_err("a stalled gateway must time out"); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "the timeout must be recognizable: {err:?}" ); } @@ -264,8 +267,8 @@ async fn a_stream_that_stalls_after_the_headers_is_a_timeout_failure() { .await .expect_err("a stream that stops arriving must time out"); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "the timeout must be recognizable: {err:?}" ); } @@ -305,17 +308,17 @@ async fn a_body_read_timeout_is_a_timeout_failure() { .expect_err("the body read stalls past the timeout"); assert!(read.is_timeout(), "reqwest reports the read as a timeout"); + let read_text = read.to_string(); let err = transport_failure(read); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "a timed-out body read is a Timeout: {err:?}" ); - assert_eq!(err.to_string(), "the model backend did not answer in time"); + assert_eq!(err.prose, "the model backend did not answer in time"); assert!( - std::error::Error::source(&err) - .is_some_and(|source| source.downcast_ref::().is_some()), - "the reqwest error is kept as the source" + err.message.contains(&read_text) && !err.prose.contains(&read_text), + "the reqwest error is kept in the message only: {err:?}" ); } @@ -329,31 +332,28 @@ async fn a_refused_connection_is_a_transport_failure() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("nothing listens on the port"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); - assert_eq!( - err.to_string(), - "the connection to the model backend failed" + assert_eq!(err.code, ErrorCode::Transport); + assert_eq!(err.prose, "the connection to the model backend failed"); + assert!( + err.message.starts_with(&format!("{}: ", err.prose)), + "the connection error joins the message: {err:?}" ); - assert!(std::error::Error::source(&err).is_some()); } #[tokio::test] async fn complete_refuses_a_malformed_stream_chunk() { // F14: a 200 whose stream holds an undecodable chunk is - // MalformedResponse, and the decode failure is preserved as the - // error-chain source. + // MalformedResponse, and the decode failure is kept in the message. let base = spawn_raw_gateway(axum::http::StatusCode::OK, "data: { not json\n\n").await; let client = keyed_client(&base); let err = client .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("undecodable chunk must fail"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - let source = - std::error::Error::source(&err).expect("the decode error must be a preserved source"); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the JSON decode error, got {source}" + err.message.starts_with(&format!("{}: ", err.prose)), + "the decode error joins the message: {err:?}" ); } @@ -372,7 +372,7 @@ async fn complete_refuses_malformed_tool_call_fragments_at_the_boundary() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("malformed tool arguments must be rejected"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); } #[tokio::test] @@ -389,7 +389,7 @@ async fn stream_without_done_sentinel_is_malformed() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("a truncated stream must fail"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ diff --git a/crates/harness-gateway-client/src/transport/tests/streaming.rs b/crates/harness-gateway-client/src/transport/tests/streaming.rs index ccf7ac8d6..0534ed9a7 100644 --- a/crates/harness-gateway-client/src/transport/tests/streaming.rs +++ b/crates/harness-gateway-client/src/transport/tests/streaming.rs @@ -1,10 +1,11 @@ //! The streamed round end to end: what goes on the wire, and how the //! stream comes back as one completion. +use runvil::error::{ErrorCode, FactKey}; use runvil::model::{CompletionResult, Message}; use super::*; -use crate::{CompletionErrorKind, StreamDelta}; +use crate::StreamDelta; #[tokio::test] async fn complete_sends_completion_options_and_stream_flags_on_the_wire() { @@ -65,11 +66,13 @@ async fn complete_hard_fails_on_empty_model_reply() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("empty product must fail"); - assert_eq!(err.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(err.code, ErrorCode::EmptyReply); assert_eq!( - err.finish_reason(), + err.facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str), Some("stop"), - "the finish_reason must survive the conversion into CompletionError" + "the finish_reason must survive the conversion into the error" ); assert_eq!( err.to_string(), @@ -77,8 +80,7 @@ async fn complete_hard_fails_on_empty_model_reply() { reasoning content was present but ignored" ); assert_eq!( - err.detail(), - None, + err.message, err.prose, "the specific is ours, not provider text" ); } @@ -260,8 +262,8 @@ async fn truncated_tool_call_batch_fails_the_completion() { .await .expect_err("a truncated tool-call batch must fail"); assert_eq!( - err.kind(), - CompletionErrorKind::MalformedResponse, + err.code, + ErrorCode::MalformedResponse, "finish_reason {reason:?}" ); assert!( @@ -309,10 +311,10 @@ async fn mid_stream_error_envelope_is_a_transport_failure() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an error envelope must fail the completion"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); + assert_eq!(err.code, ErrorCode::Transport); + assert_eq!(err.prose, "the connection to the model backend failed"); assert_eq!( - err.to_string(), - "the connection to the model backend failed" + err.message, + "the connection to the model backend failed: upstream died" ); - assert_eq!(err.detail(), Some("upstream died")); } diff --git a/crates/harness-gateway-client/src/wire/classify-tests.rs b/crates/harness-gateway-client/src/wire/classify-tests.rs index 90c941f8b..b042d8805 100644 --- a/crates/harness-gateway-client/src/wire/classify-tests.rs +++ b/crates/harness-gateway-client/src/wire/classify-tests.rs @@ -2,12 +2,26 @@ //! phrase, per token-count form, the in-stream envelope, and a body that //! matches nothing. -use runvil::model::CompletionErrorKind; +use runvil::error::{Error, ErrorCode, FactKey}; +use runvil::model::phrase; use super::{classify_http_failure, classify_stream_error}; -fn kind_of(status: u16, body: &str) -> CompletionErrorKind { - classify_http_failure(status, body).kind() +fn code_of(status: u16, body: &str) -> ErrorCode { + classify_http_failure(status, body).code +} + +/// The `(prompt_tokens, window)` counts an overflow names as its `Count` +/// and `Limit` facts. +fn overflow(error: &Error) -> (Option, Option) { + let count = |key: FactKey| { + error + .facts + .get(&key) + .and_then(serde_json::Value::as_u64) + .and_then(|count| u32::try_from(count).ok()) + }; + (count(FactKey::Count), count(FactKey::Limit)) } #[test] @@ -30,13 +44,13 @@ fn every_overflow_phrase_classifies_a_400_as_context_overflow() { for phrase in phrases { let body = format!("{{\"error\":\"the request hit a limit: {phrase} reached\"}}"); assert_eq!( - kind_of(400, &body), - CompletionErrorKind::ContextOverflow, + code_of(400, &body), + ErrorCode::ContextOverflow, "400 with {phrase:?}" ); assert_eq!( - kind_of(413, &body), - CompletionErrorKind::ContextOverflow, + code_of(413, &body), + ErrorCode::ContextOverflow, "413 with {phrase:?}" ); } @@ -45,68 +59,52 @@ fn every_overflow_phrase_classifies_a_400_as_context_overflow() { #[test] fn overflow_phrases_match_case_insensitively() { assert_eq!( - kind_of(400, "CONTEXT WINDOW exceeded"), - CompletionErrorKind::ContextOverflow + code_of(400, "CONTEXT WINDOW exceeded"), + ErrorCode::ContextOverflow ); assert_eq!( - kind_of(413, "Prompt Is Too Long"), - CompletionErrorKind::ContextOverflow + code_of(413, "Prompt Is Too Long"), + ErrorCode::ContextOverflow ); } #[test] fn provider_overflow_detection_matches_known_signatures() { - let cases: &[(u16, &str, CompletionErrorKind)] = &[ + let cases: &[(u16, &str, ErrorCode)] = &[ ( 400, "This model's maximum context length is 4096 tokens.", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "context_length_exceeded", - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, ), + (400, "context_length_exceeded", ErrorCode::ContextOverflow), ( 400, "the request exceeds the available context size", - CompletionErrorKind::ContextOverflow, - ), - ( - 413, - "prompt is too long", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "CONTEXT WINDOW exceeded", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "too many tokens in prompt", - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, ), + (413, "prompt is too long", ErrorCode::ContextOverflow), + (400, "CONTEXT WINDOW exceeded", ErrorCode::ContextOverflow), + (400, "too many tokens in prompt", ErrorCode::ContextOverflow), // A server fault never classifies as an overflow, even with // overflow wording. ( 500, "maximum context length is 4096 tokens", - CompletionErrorKind::ServerError, + ErrorCode::ServerError, ), // A client rejection without overflow wording stays a plain // rejection. ( 400, "invalid request: unknown field `stream`", - CompletionErrorKind::Rejected, + ErrorCode::Rejected, ), // Credentials win over overflow wording outside 400 and 413. - (401, "context length", CompletionErrorKind::Unavailable), + (401, "context length", ErrorCode::Unavailable), ]; for (status, body, expected) in cases { assert_eq!( - kind_of(*status, body), + code_of(*status, body), *expected, "status {status} with body {body:?}" ); @@ -115,14 +113,8 @@ fn provider_overflow_detection_matches_known_signatures() { #[test] fn an_overflow_phrase_on_another_status_is_not_an_overflow() { - assert_eq!( - kind_of(422, "context length exceeded"), - CompletionErrorKind::Rejected - ); - assert_eq!( - kind_of(429, "context length"), - CompletionErrorKind::RateLimited - ); + assert_eq!(code_of(422, "context length exceeded"), ErrorCode::Rejected); + assert_eq!(code_of(429, "context length"), ErrorCode::RateLimited); } #[test] @@ -132,22 +124,22 @@ fn the_openai_token_counts_are_read_from_the_message() { "This model's maximum context length is 4096 tokens. However, your messages \ resulted in 5120 tokens. Please reduce the length of the messages.", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5120), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5120), Some(4096))); } #[test] fn the_greater_than_maximum_token_counts_are_read_from_the_message() { let error = classify_http_failure(400, "prompt is too long: 205000 tokens > 200000 maximum"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(205_000), Some(200_000))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(205_000), Some(200_000))); } #[test] fn an_overflow_that_states_no_counts_leaves_them_unset() { let error = classify_http_failure(400, "context_length_exceeded"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (None, None)); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (None, None)); } #[test] @@ -156,8 +148,8 @@ fn a_count_too_large_for_u32_is_left_unset() { 400, "maximum context length is 99999999999 tokens, however you sent 5 tokens", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5), None)); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5), None)); } #[test] @@ -165,8 +157,8 @@ fn a_429_naming_quota_or_billing_is_quota_exhausted() { for word in ["quota", "billing", "insufficient_quota", "credit", "QUOTA"] { let body = format!("{{\"error\":\"your {word} has run out\"}}"); assert_eq!( - kind_of(429, &body), - CompletionErrorKind::QuotaExhausted, + code_of(429, &body), + ErrorCode::QuotaExhausted, "429 with {word:?}" ); } @@ -175,33 +167,27 @@ fn a_429_naming_quota_or_billing_is_quota_exhausted() { #[test] fn any_other_429_is_rate_limited() { assert_eq!( - kind_of(429, "slow down: too many requests"), - CompletionErrorKind::RateLimited - ); - assert_eq!( - kind_of(429, "(empty body)"), - CompletionErrorKind::RateLimited + code_of(429, "slow down: too many requests"), + ErrorCode::RateLimited ); + assert_eq!(code_of(429, "(empty body)"), ErrorCode::RateLimited); } #[test] fn a_503_or_529_is_overloaded() { - assert_eq!( - kind_of(503, "service unavailable"), - CompletionErrorKind::Overloaded - ); - assert_eq!(kind_of(529, "busy"), CompletionErrorKind::Overloaded); + assert_eq!(code_of(503, "service unavailable"), ErrorCode::Overloaded); + assert_eq!(code_of(529, "busy"), ErrorCode::Overloaded); } #[test] fn a_5xx_naming_overloaded_is_overloaded() { assert_eq!( - kind_of(500, "{\"type\":\"overloaded_error\"}"), - CompletionErrorKind::Overloaded + code_of(500, "{\"type\":\"overloaded_error\"}"), + ErrorCode::Overloaded ); assert_eq!( - kind_of(502, "The server is OVERLOADED"), - CompletionErrorKind::Overloaded + code_of(502, "The server is OVERLOADED"), + ErrorCode::Overloaded ); } @@ -209,8 +195,8 @@ fn a_5xx_naming_overloaded_is_overloaded() { fn every_other_5xx_is_a_server_error() { for status in [500, 501, 502, 504, 599] { assert_eq!( - kind_of(status, "internal failure"), - CompletionErrorKind::ServerError, + code_of(status, "internal failure"), + ErrorCode::ServerError, "status {status}" ); } @@ -220,9 +206,9 @@ fn every_other_5xx_is_a_server_error() { fn a_401_or_403_is_unavailable_with_the_credentials_phrase() { for status in [401, 403] { let error = classify_http_failure(status, "bad key"); - assert_eq!(error.kind(), CompletionErrorKind::Unavailable); + assert_eq!(error.code, ErrorCode::Unavailable); assert_eq!( - error.to_string(), + error.prose, format!("the model backend did not accept the credentials (status {status})") ); } @@ -232,28 +218,21 @@ fn a_401_or_403_is_unavailable_with_the_credentials_phrase() { fn a_400_naming_a_content_policy_term_is_refused() { for word in ["content_filter", "content policy", "safety", "refus"] { let body = format!("{{\"error\":\"blocked by {word} rules\"}}"); - assert_eq!( - kind_of(400, &body), - CompletionErrorKind::Refused, - "400 with {word:?}" - ); + assert_eq!(code_of(400, &body), ErrorCode::Refused, "400 with {word:?}"); } } #[test] fn a_400_naming_both_a_context_limit_and_a_safety_term_is_an_overflow() { assert_eq!( - kind_of(400, "context length exceeded; safety settings unchanged"), - CompletionErrorKind::ContextOverflow + code_of(400, "context length exceeded; safety settings unchanged"), + ErrorCode::ContextOverflow ); } #[test] fn a_content_policy_term_on_another_status_is_rejected() { - assert_eq!( - kind_of(422, "content_filter"), - CompletionErrorKind::Rejected - ); + assert_eq!(code_of(422, "content_filter"), ErrorCode::Rejected); } #[test] @@ -267,33 +246,29 @@ fn a_body_that_matches_no_rule_is_rejected_never_a_success() { (200, "ok"), ] { assert_eq!( - kind_of(status, body), - CompletionErrorKind::Rejected, + code_of(status, body), + ErrorCode::Rejected, "status {status} with body {body:?}" ); } } #[test] -fn the_message_names_the_kind_and_the_status_and_keeps_the_body_as_detail() { +fn the_prose_names_the_code_and_the_status_and_the_message_adds_the_body() { let error = classify_http_failure(503, "upstream "); + assert_eq!(error.prose, "the model backend is overloaded (status 503)"); assert_eq!( - error.to_string(), - "the model backend is overloaded (status 503)" - ); - assert_eq!( - error.message(), - "the model backend is overloaded (status 503)" + error.message, + "the model backend is overloaded (status 503): upstream " ); - assert_eq!(error.detail(), Some("upstream ")); assert!( - !error.to_string().contains("upstream"), - "Display never carries the body" + !error.prose.contains("upstream"), + "the prose never carries the body" ); } #[test] -fn each_http_kind_carries_its_fixed_phrase_and_the_status_suffix() { +fn each_http_code_carries_its_fixed_phrase_and_the_status_suffix() { let cases: &[(u16, &str, &str)] = &[ ( 400, @@ -332,42 +307,38 @@ fn each_http_kind_carries_its_fixed_phrase_and_the_status_suffix() { "the model backend did not accept the credentials (status 401)", ), ]; - for (status, body, message) in cases { + for (status, body, prose) in cases { let error = classify_http_failure(*status, body); - assert_eq!(error.to_string(), *message, "status {status}, {body:?}"); - assert_eq!(error.message(), *message); + assert_eq!(error.prose, *prose, "status {status}, {body:?}"); + assert_eq!(error.message, format!("{prose}: {body}")); } } #[test] fn a_429_is_rate_limited_unless_its_body_names_a_spent_quota() { assert_eq!( - classify_http_failure(429, "slow down").kind(), - CompletionErrorKind::RateLimited + classify_http_failure(429, "slow down").code, + ErrorCode::RateLimited ); assert_eq!( - classify_http_failure(429, "insufficient_quota").kind(), - CompletionErrorKind::QuotaExhausted + classify_http_failure(429, "insufficient_quota").code, + ErrorCode::QuotaExhausted ); } #[test] -fn provider_text_stays_in_the_detail_and_out_of_the_message() { +fn provider_text_stays_in_the_message_and_out_of_the_prose() { let error = classify_http_failure(503, "upstream "); - assert!( - error - .to_string() - .starts_with(CompletionErrorKind::Overloaded.phrase()) - ); - assert!(!error.to_string().contains("upstream")); - assert_eq!(error.detail(), Some("upstream ")); + assert!(error.prose.starts_with(phrase(ErrorCode::Overloaded))); + assert!(!error.prose.contains("upstream")); + assert!(error.message.ends_with(": upstream ")); } #[test] fn the_classifier_keeps_only_the_body_it_was_given() { let body = "x".repeat(2000); let error = classify_http_failure(400, &body); - assert_eq!(error.detail().map(str::len), Some(2000)); + assert_eq!(error.message, format!("{}: {body}", error.prose)); } #[test] @@ -375,14 +346,14 @@ fn an_in_stream_envelope_naming_a_context_limit_is_an_overflow_without_a_status( let error = classify_stream_error( "This model's maximum context length is 4096 tokens, however you sent 5000 tokens", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5000), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5000), Some(4096))); assert_eq!( - error.to_string(), + error.prose, "the request is larger than the model's context window" ); assert!( - !error.to_string().contains("status"), + !error.prose.contains("status"), "an in-stream envelope carries no status" ); } @@ -390,26 +361,26 @@ fn an_in_stream_envelope_naming_a_context_limit_is_an_overflow_without_a_status( #[test] fn an_in_stream_envelope_applies_the_other_body_text_rules() { assert_eq!( - classify_stream_error("overloaded_error: try again").kind(), - CompletionErrorKind::Overloaded + classify_stream_error("overloaded_error: try again").code, + ErrorCode::Overloaded ); assert_eq!( - classify_stream_error("insufficient_quota").kind(), - CompletionErrorKind::QuotaExhausted + classify_stream_error("insufficient_quota").code, + ErrorCode::QuotaExhausted ); assert_eq!( - classify_stream_error("blocked by content_filter").kind(), - CompletionErrorKind::Refused + classify_stream_error("blocked by content_filter").code, + ErrorCode::Refused ); } #[test] fn an_in_stream_envelope_that_matches_no_rule_stays_a_transport_failure() { let error = classify_stream_error("upstream\\ndied"); - assert_eq!(error.kind(), CompletionErrorKind::Transport); + assert_eq!(error.code, ErrorCode::Transport); + assert_eq!(error.prose, "the connection to the model backend failed"); assert_eq!( - error.to_string(), - "the connection to the model backend failed" + error.message, + "the connection to the model backend failed: upstream\\ndied" ); - assert_eq!(error.detail(), Some("upstream\\ndied")); } diff --git a/crates/harness-gateway-client/src/wire/classify.rs b/crates/harness-gateway-client/src/wire/classify.rs index 2073871b9..fb13de68a 100644 --- a/crates/harness-gateway-client/src/wire/classify.rs +++ b/crates/harness-gateway-client/src/wire/classify.rs @@ -1,11 +1,11 @@ //! The HTTP failure classifier: the one place that reads a status code and -//! response text and turns them into a [`CompletionErrorKind`]. +//! response text and turns them into a model [`ErrorCode`]. //! //! [`classify_http_failure`] takes a non-success status and its bounded, //! control-escaped body. [`classify_stream_error`] applies the same body-text //! rules to an error envelope that arrives inside a 200 stream, where there -//! is no status. Both keep the body as the error's opt-in -//! [`detail`](CompletionError::detail) and never put it in the message. +//! is no status. Both append the body to the error's message and never put +//! it in the prose. //! //! The rules run in this order, and the first match wins: //! @@ -18,7 +18,10 @@ //! 5. A 400 whose body names a content policy term is `Refused`. //! 6. Every other status is `Rejected`. -use runvil::model::{CompletionError, CompletionErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::model::{completion_error, context_overflow, phrase}; + +use crate::failure::with_detail; /// Body phrases the known backends emit when a request exceeds the model's /// context window (OpenAI and compatible gateways, Anthropic, llama.cpp, @@ -48,50 +51,46 @@ const REFUSAL_WORDS: &[&str] = &["content_filter", "content policy", "safety", " /// unavailable phrase. const CREDENTIALS_PHRASE: &str = "the model backend did not accept the credentials"; -/// Classifies a failed HTTP response into a [`CompletionError`]. +/// Classifies a failed HTTP response into an [`Error`] under a model code. /// /// `body` must already be bounded and passed through -/// [`escape_controls`](crate::escape_controls). The error keeps exactly that -/// text as its [`detail`](CompletionError::detail). The message never -/// includes it. +/// [`escape_controls`](crate::escape_controls). The error's message ends +/// with exactly that text after `: `. The prose never includes it. /// -/// The message is the kind's fixed phrase with ` (status N)` appended. A 401 -/// or 403 is `Unavailable`, and its message uses +/// The prose is the code's fixed phrase with ` (status N)` appended. A 401 +/// or 403 is `Unavailable`, and its prose uses /// `the model backend did not accept the credentials` in place of the /// phrase. /// /// The result is always an error. When the body matches a rule, that rule -/// sets the kind. Otherwise a 429 is `RateLimited`, a 503 or 529 is +/// sets the code. Otherwise a 429 is `RateLimited`, a 503 or 529 is /// `Overloaded`, any other 5xx is `ServerError`, and any other status except /// 401 and 403 is `Rejected`. #[must_use] -pub fn classify_http_failure(status: u16, body: &str) -> CompletionError { +pub fn classify_http_failure(status: u16, body: &str) -> Error { let lower = body.to_lowercase(); - let http = |kind: CompletionErrorKind| { - http_error(kind, kind.phrase(), status).with_detail(body.to_owned()) - }; + let http = |code: ErrorCode| with_detail(http_error(code, phrase(code), status), body); if matches!(status, 400 | 413) && names_any(&lower, OVERFLOW_PHRASES) { let (prompt_tokens, window) = overflow_counts(&lower); - return CompletionError::context_overflow( + let overflow = context_overflow( prompt_tokens, window, - format!( - "{} (status {status})", - CompletionErrorKind::ContextOverflow.phrase() - ), - ) - .with_detail(body.to_owned()); + format!("{} (status {status})", phrase(ErrorCode::ContextOverflow)), + ); + return with_detail(overflow, body); } match status { - 429 if names_any(&lower, QUOTA_WORDS) => http(CompletionErrorKind::QuotaExhausted), - 429 => http(CompletionErrorKind::RateLimited), - 503 | 529 => http(CompletionErrorKind::Overloaded), - 500..=599 if lower.contains("overloaded") => http(CompletionErrorKind::Overloaded), - 500..=599 => http(CompletionErrorKind::ServerError), - 401 | 403 => http_error(CompletionErrorKind::Unavailable, CREDENTIALS_PHRASE, status) - .with_detail(body.to_owned()), - 400 if names_any(&lower, REFUSAL_WORDS) => http(CompletionErrorKind::Refused), - _ => http(CompletionErrorKind::Rejected), + 429 if names_any(&lower, QUOTA_WORDS) => http(ErrorCode::QuotaExhausted), + 429 => http(ErrorCode::RateLimited), + 503 | 529 => http(ErrorCode::Overloaded), + 500..=599 if lower.contains("overloaded") => http(ErrorCode::Overloaded), + 500..=599 => http(ErrorCode::ServerError), + 401 | 403 => with_detail( + http_error(ErrorCode::Unavailable, CREDENTIALS_PHRASE, status), + body, + ), + 400 if names_any(&lower, REFUSAL_WORDS) => http(ErrorCode::Refused), + _ => http(ErrorCode::Rejected), } } @@ -104,37 +103,33 @@ pub fn classify_http_failure(status: u16, body: &str) -> CompletionError { /// `Refused`. Any other text is `Transport`, because the stream died in /// flight. /// -/// The message is the kind's fixed phrase alone. `body` must already be -/// bounded and control-escaped. The error keeps it as its -/// [`detail`](CompletionError::detail). +/// The prose is the code's fixed phrase alone. `body` must already be +/// bounded and control-escaped. The error's message ends with it after +/// `: `. #[must_use] -pub fn classify_stream_error(body: &str) -> CompletionError { +pub fn classify_stream_error(body: &str) -> Error { let lower = body.to_lowercase(); let error = if names_any(&lower, OVERFLOW_PHRASES) { let (prompt_tokens, window) = overflow_counts(&lower); - CompletionError::context_overflow( - prompt_tokens, - window, - CompletionErrorKind::ContextOverflow.phrase(), - ) + context_overflow(prompt_tokens, window, phrase(ErrorCode::ContextOverflow)) } else { - let kind = if names_any(&lower, QUOTA_WORDS) { - CompletionErrorKind::QuotaExhausted + let code = if names_any(&lower, QUOTA_WORDS) { + ErrorCode::QuotaExhausted } else if lower.contains("overloaded") { - CompletionErrorKind::Overloaded + ErrorCode::Overloaded } else if names_any(&lower, REFUSAL_WORDS) { - CompletionErrorKind::Refused + ErrorCode::Refused } else { - CompletionErrorKind::Transport + ErrorCode::Transport }; - CompletionError::new(kind, kind.phrase()) + completion_error(code, phrase(code)) }; - error.with_detail(body.to_owned()) + with_detail(error, body) } /// Builds an HTTP failure: `phrase` with the status appended. -fn http_error(kind: CompletionErrorKind, phrase: &str, status: u16) -> CompletionError { - CompletionError::new(kind, format!("{phrase} (status {status})")) +fn http_error(code: ErrorCode, phrase: &str, status: u16) -> Error { + completion_error(code, format!("{phrase} (status {status})")) } fn names_any(lower: &str, words: &[&str]) -> bool { diff --git a/crates/harness-gateway-client/src/wire/parse-tests.rs b/crates/harness-gateway-client/src/wire/parse-tests.rs index c8e1b928b..10f2b9e3c 100644 --- a/crates/harness-gateway-client/src/wire/parse-tests.rs +++ b/crates/harness-gateway-client/src/wire/parse-tests.rs @@ -2,18 +2,26 @@ //! reasoning side channel. use super::*; -use runvil::model::CompletionErrorKind; +use runvil::error::ErrorCode; const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; /// Whether `result` failed as a `MalformedResponse`. -fn is_malformed(result: Result) -> bool { - result.is_err_and(|error| error.kind() == CompletionErrorKind::MalformedResponse) +fn is_malformed(result: Result) -> bool { + result.is_err_and(|error| error.code == ErrorCode::MalformedResponse) } /// Whether `result` failed as an `EmptyReply`. -fn is_empty_reply(result: Result) -> bool { - result.is_err_and(|error| error.kind() == CompletionErrorKind::EmptyReply) +fn is_empty_reply(result: Result) -> bool { + result.is_err_and(|error| error.code == ErrorCode::EmptyReply) +} + +/// The backend's finish reason an empty reply names, when it names one. +fn finish_reason(error: &Error) -> Option<&str> { + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str) } /// Wraps one assistant message in the gateway's one-choice envelope. @@ -249,13 +257,13 @@ fn empty_content_with_reasoning_is_error() { }); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!( error.to_string(), format!("{EMPTY_REPLY_PHRASE}: reasoning content was present but ignored") ); assert_eq!( - error.finish_reason(), + finish_reason(&error), Some("stop"), "the choice's finish_reason must survive on the error" ); @@ -269,9 +277,9 @@ fn empty_string_content_without_tools_is_error() { })); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); - assert_eq!(error.finish_reason(), None, "no finish_reason on the wire"); + assert_eq!(finish_reason(&error), None, "no finish_reason on the wire"); } #[test] @@ -282,7 +290,7 @@ fn null_content_without_tools_is_error() { })); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); } diff --git a/crates/harness-gateway-client/src/wire/parse.rs b/crates/harness-gateway-client/src/wire/parse.rs index 8a34df1b2..fe20f2c94 100644 --- a/crates/harness-gateway-client/src/wire/parse.rs +++ b/crates/harness-gateway-client/src/wire/parse.rs @@ -23,8 +23,9 @@ //! completion: a malformed section degrades to `None` with a returned //! diagnostic naming it. +use runvil::error::{Error, FactKey}; use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics}; -use runvil::model::{CompletionError, CompletionResult, ToolCall}; +use runvil::model::{CompletionResult, ToolCall, empty_reply}; use serde::Deserialize; use serde_json::Value; @@ -59,7 +60,7 @@ struct TurnContext<'a> { /// non-empty array of objects, `finish_reason` is a present non-string, /// `message` is missing or not an object, or a reasoning field has the wrong /// type. -fn turn_context(body: &Value) -> Result, CompletionError> { +fn turn_context(body: &Value) -> Result, Error> { let choices = match body.get("choices") { None => return Err(malformed("no choices in response")), Some(Value::Array(choices)) => choices, @@ -96,7 +97,7 @@ fn turn_context(body: &Value) -> Result, CompletionError> { /// Returns a `MalformedResponse`-kind failure when the body has no usable /// choice shape, and an `EmptyReply`-kind one when the choice has neither /// non-empty tool calls nor non-empty text. -pub(super) fn normalize(body: &Value) -> Result { +pub(super) fn normalize(body: &Value) -> Result { let TurnContext { message, finish_reason, @@ -136,11 +137,13 @@ pub(super) fn normalize(body: &Value) -> Result }); } - let error = CompletionError::empty_reply(reasoning_content.is_some()); - Err(match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - }) + let mut error = empty_reply(reasoning_content.is_some()); + if let Some(reason) = finish_reason { + error + .facts + .insert(FactKey::FinishReason, Value::from(reason)); + } + Err(error) } /// Parses the OpenAI `message.tool_calls` array into [`ToolCall`]s. @@ -151,7 +154,7 @@ pub(super) fn normalize(body: &Value) -> Result /// null arguments are rejected rather than coerced. [`ToolCall::from_parts`] /// then refuses a blank id, a blank name, and arguments that do not decode /// to an object. -fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, CompletionError> { +fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, Error> { let mut calls = Vec::with_capacity(raw_calls.len()); for raw in raw_calls { if !raw.is_object() { @@ -211,7 +214,7 @@ fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, Complet /// # Errors /// Returns a `MalformedResponse`-kind failure when a present reasoning field is not a /// string or null. -fn extract_reasoning(message: &Value) -> Result, CompletionError> { +fn extract_reasoning(message: &Value) -> Result, Error> { for key in ["reasoning_content", "reasoning", "thinking"] { match message.get(key) { None | Some(Value::Null) => {} diff --git a/crates/harness-gateway-client/src/wire/read-tests.rs b/crates/harness-gateway-client/src/wire/read-tests.rs index 49b6f4c86..0c50bddc0 100644 --- a/crates/harness-gateway-client/src/wire/read-tests.rs +++ b/crates/harness-gateway-client/src/wire/read-tests.rs @@ -7,14 +7,15 @@ use std::pin::pin; use std::task::{Context, Poll, Waker}; use std::time::{Duration, Instant}; -use runvil::model::{CompletionErrorKind, CompletionResult, RawExchange}; +use runvil::error::ErrorCode; +use runvil::model::{CompletionResult, RawExchange, completion_error}; use serde_json::json; use super::*; /// A chunk source over canned chunks; it never pends, so the tests need /// no executor. -struct Canned(VecDeque, CompletionError>>); +struct Canned(VecDeque, Error>>); impl Canned { fn of(chunks: &[&str]) -> Canned { @@ -30,9 +31,7 @@ impl Canned { impl ChunkSource for Canned { type Chunk = Vec; - fn next_chunk( - &mut self, - ) -> impl Future, CompletionError>> + Send { + fn next_chunk(&mut self) -> impl Future, Error>> + Send { std::future::ready(self.0.pop_front().transpose()) } } @@ -67,13 +66,13 @@ fn read_body_capped_refuses_an_advertised_oversize_length_before_reading() { let mut source = Canned::of(&["never read"]); let err = block_on(read_body_capped(&mut source, Some(100), 8)) .expect_err("an advertised length over the cap is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ response body of 100 bytes exceeds the 8-byte limit" ); - assert_eq!(err.detail(), None); + assert_eq!(err.message, err.prose); assert_eq!(source.0.len(), 1, "nothing was read"); } @@ -82,7 +81,7 @@ fn read_body_capped_refuses_streamed_chunks_over_the_cap() { let mut source = Canned::of(&["12345", "6789"]); let err = block_on(read_body_capped(&mut source, None, 8)) .expect_err("chunks past the cap are refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -255,7 +254,7 @@ fn read_completion_stream_refuses_a_stream_over_the_byte_cap() { || started, )) .expect_err("an oversize stream is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -277,24 +276,23 @@ fn read_completion_stream_refuses_a_stream_without_the_sentinel() { || started, )) .expect_err("a cut-off stream is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ completion stream ended without the [DONE] sentinel" ); - assert_eq!(err.detail(), None); + assert_eq!(err.message, err.prose); } #[test] fn read_completion_stream_returns_the_source_failure_as_is() { - let timed_out = - CompletionError::new(CompletionErrorKind::Timeout, "no chunk in time").with_source( - std::io::Error::new(std::io::ErrorKind::TimedOut, "deadline"), - ); + let timed_out = completion_error(ErrorCode::Timeout, "no chunk in time").with_cause( + &std::io::Error::new(std::io::ErrorKind::TimedOut, "deadline"), + ); let mut source = Canned(VecDeque::from([ Ok(sse(&[text_chunk("par")]).into_bytes()), - Err(timed_out), + Err(timed_out.clone()), ])); let started = Instant::now(); let err = block_on(read_completion_stream( @@ -306,7 +304,8 @@ fn read_completion_stream_returns_the_source_failure_as_is() { || started, )) .expect_err("a read failure fails the round"); - assert_eq!(err.kind(), CompletionErrorKind::Timeout); - assert_eq!(err.to_string(), "no chunk in time"); - assert!(std::error::Error::source(&err).is_some()); + assert_eq!(err, timed_out); + assert_eq!(err.code, ErrorCode::Timeout); + assert_eq!(err.prose, "no chunk in time"); + assert_eq!(err.message, "no chunk in time: deadline"); } diff --git a/crates/harness-gateway-client/src/wire/read.rs b/crates/harness-gateway-client/src/wire/read.rs index 3273facd1..3f44627b3 100644 --- a/crates/harness-gateway-client/src/wire/read.rs +++ b/crates/harness-gateway-client/src/wire/read.rs @@ -11,8 +11,9 @@ use std::future::Future; use std::time::{Duration, Instant}; +use runvil::error::Error; use runvil::metrics::ClientTiming; -use runvil::model::{Completion, CompletionError}; +use runvil::model::Completion; use serde_json::Value; use super::delta::StreamDelta; @@ -46,15 +47,12 @@ pub trait ChunkSource { /// Returns the next chunk, or `None` once the body is exhausted. /// - /// When a read fails, the implementation returns the - /// [`CompletionError`] that the round fails with. It is a - /// `Timeout`-kind error when the read ran out of time and a - /// `Transport`-kind error otherwise. Build it with - /// [`CompletionError::new`] and attach the transport's own error with - /// [`CompletionError::with_source`]. - fn next_chunk( - &mut self, - ) -> impl Future, CompletionError>> + Send; + /// When a read fails, the implementation returns the [`Error`] that + /// the round fails with. Its code is `Timeout` when the read ran out of + /// time and `Transport` otherwise. Build it with + /// [`completion_error`](runvil::model::completion_error) and attach the + /// transport's own error with [`Error::with_cause`]. + fn next_chunk(&mut self) -> impl Future, Error>> + Send; } /// Reads a whole response body from `source`, refusing it once it would @@ -71,13 +69,13 @@ pub trait ChunkSource { /// force an unbounded allocation before the body is decoded. /// /// # Errors -/// Returns a `MalformedResponse`-kind [`CompletionError`] when the body -/// would exceed `cap`, and the source's own error when a read fails. +/// Returns an [`Error`] with code `MalformedResponse` when the body would +/// exceed `cap`, and the source's own error when a read fails. pub async fn read_body_capped( source: &mut S, content_length: Option, cap: u64, -) -> Result, CompletionError> { +) -> Result, Error> { if let Some(len) = content_length && len > cap { @@ -120,7 +118,7 @@ pub async fn read_body_capped( /// every clock reading from `started` and `now`. /// /// # Errors -/// Returns a `MalformedResponse`-kind [`CompletionError`] when the stream +/// Returns an [`Error`] with code `MalformedResponse` when the stream /// exceeds `max_bytes` or ends before the sentinel, and the source's own /// error when a read fails. Also returns the error that reassembling the /// reply raises for a malformed chunk, a mid-stream error envelope, a @@ -132,7 +130,7 @@ pub async fn read_completion_stream( on_delta: impl Fn(StreamDelta), started: Instant, now: impl Fn() -> Instant, -) -> Result { +) -> Result { let mut scanner = SseScanner::new(); let mut accumulator = StreamAccumulator::new(); let mut received: u64 = 0; diff --git a/crates/harness-gateway-client/src/wire/stream-tests.rs b/crates/harness-gateway-client/src/wire/stream-tests.rs index dd6d136a1..1e71d56b0 100644 --- a/crates/harness-gateway-client/src/wire/stream-tests.rs +++ b/crates/harness-gateway-client/src/wire/stream-tests.rs @@ -1,6 +1,7 @@ //! Tests for the streaming accumulator and the SSE data-line scanner. -use runvil::model::{CompletionErrorKind, CompletionResult}; +use runvil::error::{ErrorCode, FactKey}; +use runvil::model::CompletionResult; use serde_json::Value; use super::*; @@ -178,15 +179,15 @@ fn finish_fails_a_tool_call_batch_truncated_by_length_or_content_filter() { .finish(Value::Null, None) .expect_err("a truncated tool-call batch must fail"); assert_eq!( - error.kind(), - CompletionErrorKind::MalformedResponse, + error.code, + ErrorCode::MalformedResponse, "finish_reason {reason:?}" ); assert!( error.to_string().contains("truncated"), "the error's message names the truncation: {error:?}" ); - assert_eq!(error.detail(), None); + assert_eq!(error.message, error.prose); } } @@ -208,7 +209,7 @@ fn finish_fails_a_tool_call_batch_sharing_an_id_as_a_retryable_malformed_reply() ]) .finish(Value::Null, None) .expect_err("two calls sharing an id must fail"); - assert_eq!(error.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(error.code, ErrorCode::MalformedResponse); } #[test] @@ -242,11 +243,14 @@ fn finish_hard_fails_on_an_empty_model_reply() { ]) .finish(Value::Null, None) .expect_err("empty product must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!( - error.finish_reason(), + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str), Some("stop"), - "the finish_reason must survive the conversion into CompletionError" + "the finish_reason must survive the conversion into the error" ); assert_eq!( error.to_string(), @@ -254,7 +258,7 @@ fn finish_hard_fails_on_an_empty_model_reply() { reasoning content was present but ignored", "the ignored-reasoning note extends the message" ); - assert_eq!(error.detail(), None); + assert_eq!(error.message, error.prose); } #[test] @@ -351,7 +355,7 @@ fn empty_choices_usage_chunk_is_metadata_not_a_turn() { } #[test] -fn error_envelope_fails_the_stream_with_the_escaped_message_as_detail() { +fn error_envelope_fails_the_stream_with_the_escaped_message_in_its_message_only() { let mut accumulator = StreamAccumulator::new(); let error = accumulator .apply( @@ -360,12 +364,12 @@ fn error_envelope_fails_the_stream_with_the_escaped_message_as_detail() { &no_delta, ) .expect_err("an error envelope must fail the stream"); - assert_eq!(error.kind(), CompletionErrorKind::Transport); + assert_eq!(error.code, ErrorCode::Transport); + assert_eq!(error.prose, "the connection to the model backend failed"); assert_eq!( - error.to_string(), - "the connection to the model backend failed" + error.message, + "the connection to the model backend failed: upstream\\ndied" ); - assert_eq!(error.detail(), Some("upstream\\ndied")); } #[test] @@ -382,8 +386,14 @@ fn error_envelope_naming_a_context_limit_is_a_context_overflow() { &no_delta, ) .expect_err("an error envelope must fail the stream"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5000), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!( + ( + error.facts.get(&FactKey::Count), + error.facts.get(&FactKey::Limit) + ), + (Some(&Value::from(5000)), Some(&Value::from(4096))) + ); } #[test] @@ -404,32 +414,33 @@ fn malformed_chunks_are_rejected_not_skipped() { let mut accumulator = StreamAccumulator::new(); let error = accumulator.apply(payload, &no_delta).expect_err(label); assert_eq!( - error.kind(), - CompletionErrorKind::MalformedResponse, + error.code, + ErrorCode::MalformedResponse, "{label}: {error:?}" ); assert!( error - .to_string() + .prose .starts_with("the model backend sent a reply that could not be understood: "), - "{label}: the message extends the fixed phrase: {error}" + "{label}: the prose extends the fixed phrase: {error:?}" ); - assert_eq!(error.detail(), None, "{label}"); } } #[test] -fn a_malformed_chunk_preserves_the_decode_source() { +fn a_malformed_chunk_keeps_the_decode_error_in_its_message_only() { + let decode = serde_json::from_str::("{ not json") + .expect_err("the payload is not JSON") + .to_string(); let mut accumulator = StreamAccumulator::new(); let error = accumulator .apply("{ not json", &no_delta) .expect_err("undecodable chunk must fail"); - let source = - std::error::Error::source(&error).expect("the decode error must be a preserved source"); assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the JSON decode error, got {source}" + error.message.contains(&decode), + "the message holds the JSON decode error: {error:?}" ); + assert!(!error.prose.contains(&decode), "{error:?}"); } #[test] diff --git a/crates/harness-gateway-client/src/wire/stream.rs b/crates/harness-gateway-client/src/wire/stream.rs index c4a57526b..392d70ab6 100644 --- a/crates/harness-gateway-client/src/wire/stream.rs +++ b/crates/harness-gateway-client/src/wire/stream.rs @@ -21,8 +21,9 @@ use std::collections::BTreeMap; +use runvil::error::Error; use runvil::metrics::ClientTiming; -use runvil::model::{Completion, CompletionError, RawExchange}; +use runvil::model::{Completion, RawExchange}; use serde_json::{Map, Value}; use super::classify::classify_stream_error; @@ -145,7 +146,7 @@ impl StreamAccumulator { /// reasoning fragment it contains. /// /// # Errors - /// Returns a `MalformedResponse`-kind [`CompletionError`] when the + /// Returns an [`Error`] with code `MalformedResponse` when the /// payload is not valid JSON or a recognized field has the wrong /// shape, and the [`classify_stream_error`] result when the payload is /// a mid-stream error envelope (`Transport` unless its text names a @@ -154,12 +155,12 @@ impl StreamAccumulator { &mut self, data: &str, on_delta: &impl Fn(StreamDelta), - ) -> Result { + ) -> Result { if data == "[DONE]" { return Ok(Applied::Done); } let chunk: Value = serde_json::from_str(data) - .map_err(|error| malformed("stream chunk was not valid JSON").with_source(error))?; + .map_err(|error| malformed("stream chunk was not valid JSON").with_cause(&error))?; // A mid-stream `error` envelope is how the gateway (and llama.cpp) // report a failure after the 200 has already been sent: the // completion died in flight, so it is a transport failure unless @@ -208,7 +209,7 @@ impl StreamAccumulator { &mut self, choice: &Value, on_delta: &impl Fn(StreamDelta), - ) -> Result { + ) -> Result { let Some(index) = choice.get("index").and_then(Value::as_u64) else { return Err(malformed("stream choice had no integer index")); }; @@ -270,7 +271,7 @@ impl StreamAccumulator { } /// Merges one tool-call fragment into its index-keyed buffer. - fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), CompletionError> { + fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), Error> { let Some(index) = fragment.get("index").and_then(Value::as_u64) else { return Err(malformed("stream tool-call fragment had no integer index")); }; @@ -321,7 +322,7 @@ impl StreamAccumulator { /// absent when nothing was measured. /// /// # Errors - /// Returns a `MalformedResponse`-kind [`CompletionError`] when a + /// Returns an [`Error`] with code `MalformedResponse` when a /// tool-call batch was cut short by a `length` or `content_filter` /// finish (partial arguments must not execute), the normalizer's own /// errors (`EmptyReply` for a turn with neither non-empty tool calls nor @@ -331,7 +332,7 @@ impl StreamAccumulator { self, request_body: Value, client_timing: Option, - ) -> Result { + ) -> Result { // The truncation rule runs before normalization: a tool-call batch // cut short by `length` or `content_filter` may hold partial JSON // arguments, and partial arguments must not execute. @@ -434,7 +435,7 @@ fn append_string_fragment( key: &str, slot: &mut Option, label: &str, -) -> Result, CompletionError> { +) -> Result, Error> { match delta.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(text)) => { diff --git a/crates/harness-internal/plugin-loader/Cargo.toml b/crates/harness-internal/plugin-loader/Cargo.toml index ed7161826..33fd36153 100644 --- a/crates/harness-internal/plugin-loader/Cargo.toml +++ b/crates/harness-internal/plugin-loader/Cargo.toml @@ -10,10 +10,9 @@ publish = false # Opens a Plugin's dynamic library and resolves its `runvil_plugin` symbol. libloading.workspace = true # The C contract's Rust mirror (`runvil_plugin::abi`), the Pack registry the -# inserts take, and `PackId`, which validates a Plugin's name. +# inserts take, `PackId`, which validates a Plugin's name, and the +# universal error a failed load is. runvil-plugin.workspace = true -# `LoadError`, why one Plugin did not load. -thiserror.workspace = true [lints] workspace = true diff --git a/crates/harness-internal/plugin-loader/src/fakes-tests.rs b/crates/harness-internal/plugin-loader/src/fakes-tests.rs index c0c673a8d..3ec5c1d25 100644 --- a/crates/harness-internal/plugin-loader/src/fakes-tests.rs +++ b/crates/harness-internal/plugin-loader/src/fakes-tests.rs @@ -7,14 +7,14 @@ use std::ffi::c_void; use std::ptr; use std::sync::Arc; -use runvil_plugin::PackRegistry; pub(crate) use runvil_plugin::abi::{ PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_OK, RV_PLUGIN_LOAD, RV_PLUGIN_UNLOAD, RvCall, RvHost, RvPluginInfo, RvStatus, RvStr, RvVerb, }; +use runvil_plugin::{Error, PackRegistry}; use crate::host::HOST; -use crate::plugin::{Plugin, PluginFailure, insert_plugin}; +use crate::plugin::{Plugin, insert_plugin}; /// A status no verb returns, for a fake that fails with its own code. pub(crate) const ODD_STATUS: RvStatus = 77; @@ -77,7 +77,7 @@ macro_rules! fake { pub(crate) use fake; /// Inserts the linked Plugin `entry` into a fresh Pack registry. -pub(crate) fn insert(entry: PluginEntry) -> Result, PluginFailure> { +pub(crate) fn insert(entry: PluginEntry) -> Result, Error> { insert_plugin(&mut PackRegistry::new(), entry) } @@ -87,10 +87,15 @@ pub(crate) fn loaded(entry: PluginEntry) -> Arc { } /// Inserts `entry`, which must fail to load. -pub(crate) fn failure(entry: PluginEntry) -> PluginFailure { +pub(crate) fn failure(entry: PluginEntry) -> Error { insert(entry).expect_err("the Plugin fails to load") } +/// The `Reason` fact of a load failure. +pub(crate) fn reason(failure: &Error) -> Option<&str> { + failure.facts.get(&runvil_plugin::FactKey::Reason)?.as_str() +} + /// Whether `call` carries the fields the contract gives a call that sends /// `verb` in version 0.1, and its table says the contract's version. A /// load call carries no instance yet; an unload call carries one. diff --git a/crates/harness-internal/plugin-loader/src/host-tests.rs b/crates/harness-internal/plugin-loader/src/host-tests.rs index 153fa953e..f114617d2 100644 --- a/crates/harness-internal/plugin-loader/src/host-tests.rs +++ b/crates/harness-internal/plugin-loader/src/host-tests.rs @@ -40,7 +40,8 @@ macro_rules! misuse_test { } ); let failure = failure(plug::entry); - assert_eq!(failure.reason(), misuse_reason!($detail)); + assert_eq!(failure.message, misuse_reason!($detail)); + assert_eq!(reason(&failure), Some("misuse")); assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); assert_eq!(plug::UNLOADS.load(Ordering::SeqCst), 0); } @@ -261,7 +262,7 @@ fn a_refusal_reason_of_exactly_1024_bytes_is_accepted() { fake!(plug, |call| refuse(call, &"r".repeat(1024))); let failure = failure(plug::entry); assert_eq!( - failure.reason(), + failure.message, format!("the Plugin refused: {}", "r".repeat(1024)) ); } @@ -286,7 +287,7 @@ fn a_refusal_with_no_reason_is_accepted() { } ); let failure = failure(plug::entry); - assert_eq!(failure.reason(), "the Plugin refused: "); + assert_eq!(failure.message, "the Plugin refused: "); assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_REFUSED as usize); } @@ -305,7 +306,7 @@ fn a_second_refusal_is_misuse_and_the_first_is_reported() { } ); let failure = failure(plug::entry); - assert_eq!(failure.reason(), "the Plugin refused: first"); + assert_eq!(failure.message, "the Plugin refused: first"); assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); } @@ -316,7 +317,7 @@ fn a_refusal_wins_over_a_misuse_made_before_it() { refuse(call, "no thanks") }); assert_eq!( - failure(plug::entry).reason(), + failure(plug::entry).message, "the Plugin refused: no thanks" ); } @@ -327,7 +328,7 @@ fn a_misuse_followed_by_a_non_ok_status_reports_the_misuse() { describe(call, "", "1.0.0"); ODD_STATUS }); - assert_eq!(failure(plug::entry).reason(), misuse_reason!(bad_name!())); + assert_eq!(failure(plug::entry).message, misuse_reason!(bad_name!())); } #[test] @@ -344,7 +345,7 @@ fn a_misuse_is_reported_even_when_a_good_description_follows() { } ); let failure = failure(plug::entry); - assert_eq!(failure.reason(), misuse_reason!(bad_name!())); + assert_eq!(failure.message, misuse_reason!(bad_name!())); assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); } diff --git a/crates/harness-internal/plugin-loader/src/host.rs b/crates/harness-internal/plugin-loader/src/host.rs index f39c9810d..900fb581c 100644 --- a/crates/harness-internal/plugin-loader/src/host.rs +++ b/crates/harness-internal/plugin-loader/src/host.rs @@ -7,13 +7,13 @@ use std::ffi::c_void; use std::mem::size_of; use std::{ptr, slice}; -use runvil_plugin::PackId; use runvil_plugin::abi::{ PluginEntry, RV_API_MAJOR, RV_API_MINOR, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_OK, RV_PLUGIN_LOAD, RV_PLUGIN_UNLOAD, RvCall, RvHost, RvPluginInfo, RvStatus, RvStr, }; +use runvil_plugin::{Error, PackId}; -use crate::load::LoadError; +use crate::load::load_error; #[cfg(test)] #[path = "host-tests.rs"] @@ -271,7 +271,7 @@ unsafe fn describe(session: &mut Session, info: *const RvPluginInfo) -> Result<( /// # Safety /// `entry` must follow the C contract in `runvil_plugin::abi`, and must /// not unwind. -pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result<(Described, *mut c_void), LoadError> { +pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result<(Described, *mut c_void), Error> { let mut call = RvCall { size: size_field::(), verb: RV_PLUGIN_LOAD, @@ -291,17 +291,29 @@ pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result<(Described, *mut c_ // swap returns the session opened above. let session = SESSION.with(|cell| cell.replace(outer)); if let Some(reason) = session.refusal { - return Err(LoadError::Refused(reason)); + return Err(load_error( + "refused", + format!("the Plugin refused: {reason}"), + )); } if let Some(reason) = session.misuse { - return Err(LoadError::Misuse(reason)); + return Err(load_error( + "misuse", + format!("the Plugin misused the RvHost table: {reason}"), + )); } if status != RV_OK { - return Err(LoadError::Status(status)); + return Err(load_error( + "status", + format!("RV_PLUGIN_LOAD returned status {status}"), + )); } match session.described { Some(described) => Ok((described, call.plugin)), - None => Err(LoadError::Undescribed), + None => Err(load_error( + "undescribed", + "RV_PLUGIN_LOAD returned without describing the Plugin".to_owned(), + )), } } diff --git a/crates/harness-internal/plugin-loader/src/lib.rs b/crates/harness-internal/plugin-loader/src/lib.rs index 2d1882b0a..d1c779df9 100644 --- a/crates/harness-internal/plugin-loader/src/lib.rs +++ b/crates/harness-internal/plugin-loader/src/lib.rs @@ -31,6 +31,4 @@ mod host; mod load; mod plugin; -pub use plugin::{ - Plugin, PluginFailure, PluginReport, PluginSource, insert_plugin, insert_plugin_dir, -}; +pub use plugin::{Plugin, PluginReport, PluginSource, insert_plugin, insert_plugin_dir}; diff --git a/crates/harness-internal/plugin-loader/src/load-tests.rs b/crates/harness-internal/plugin-loader/src/load-tests.rs index ac9d51ff4..bf8513436 100644 --- a/crates/harness-internal/plugin-loader/src/load-tests.rs +++ b/crates/harness-internal/plugin-loader/src/load-tests.rs @@ -252,13 +252,14 @@ fn system_library() -> PathBuf { ))] #[test] fn a_library_without_runvil_plugin_fails_as_a_missing_entry_point() { - use crate::load::{LoadError, open_entry}; + use crate::fakes::reason; + use crate::load::open_entry; let error = open_entry(&system_library()) .expect_err("a library with no `runvil_plugin` does not open as a Plugin"); - assert!(matches!(error, LoadError::MissingEntry(_)), "{error:?}"); + assert_eq!(reason(&error), Some("missing_entry"), "{error:?}"); assert!( - error.to_string().starts_with("runvil_plugin is missing: "), + error.message.starts_with("runvil_plugin is missing: "), "{error}" ); } diff --git a/crates/harness-internal/plugin-loader/src/load.rs b/crates/harness-internal/plugin-loader/src/load.rs index 20f4b7248..9501bce41 100644 --- a/crates/harness-internal/plugin-loader/src/load.rs +++ b/crates/harness-internal/plugin-loader/src/load.rs @@ -6,7 +6,8 @@ use std::ffi::{OsStr, c_void}; use std::io; use std::path::{Path, PathBuf}; -use runvil_plugin::abi::{PluginEntry, RvStatus}; +use runvil_plugin::abi::PluginEntry; +use runvil_plugin::{Error, ErrorCode, FactKey}; use crate::host::send_unload; @@ -114,13 +115,18 @@ fn open_error(error: libloading::Error) -> io::Error { /// Opens the library at `path` and resolves `ENTRY_POINT`. The entry point /// is valid only while the returned library stays open. When the symbol is /// missing, the library closes before this returns. -pub(crate) fn open_entry(path: &Path) -> Result<(libloading::Library, PluginEntry), LoadError> { - let library = open_library(path).map_err(LoadError::Open)?; +pub(crate) fn open_entry(path: &Path) -> Result<(libloading::Library, PluginEntry), Error> { + let library = open_library(path) + .map_err(|error| load_error("open", format!("the library could not be opened: {error}")))?; let entry = { // SAFETY: the C contract gives `runvil_plugin` the shape of // `PluginEntry`, and `ENTRY_POINT` ends in NUL. - let symbol = - unsafe { library.get::(ENTRY_POINT) }.map_err(LoadError::MissingEntry)?; + let symbol = unsafe { library.get::(ENTRY_POINT) }.map_err(|error| { + load_error( + "missing_entry", + format!("runvil_plugin is missing: {error}"), + ) + })?; *symbol }; Ok((library, entry)) @@ -177,21 +183,12 @@ impl Loaded { } } -/// Why one Plugin did not load; its message becomes the failure's reason. -#[derive(Debug, thiserror::Error)] -pub(crate) enum LoadError { - #[error("the library could not be opened: {0}")] - Open(io::Error), - #[error("runvil_plugin is missing: {0}")] - MissingEntry(libloading::Error), - #[error("the Plugin refused: {0}")] - Refused(String), - #[error("the Plugin misused the RvHost table: {0}")] - Misuse(&'static str), - #[error("RV_PLUGIN_LOAD returned status {0}")] - Status(RvStatus), - #[error("RV_PLUGIN_LOAD returned without describing the Plugin")] - Undescribed, - #[error("a Plugin named {0} was already loaded from this directory")] - Duplicate(String), +/// Why one Plugin did not load: a `PluginLoadFailed` error with `message` +/// and the `Reason` fact `reason`, one of `open`, `missing_entry`, +/// `refused`, `misuse`, `status`, `undescribed`, or `duplicate`. +pub(crate) fn load_error(reason: &str, message: String) -> Error { + let code = ErrorCode::PluginLoadFailed; + let mut error = Error::new(code, message, code.prose()); + error.facts.insert(FactKey::Reason, reason.into()); + error } diff --git a/crates/harness-internal/plugin-loader/src/plugin-tests.rs b/crates/harness-internal/plugin-loader/src/plugin-tests.rs index 40c342489..2f0d9a811 100644 --- a/crates/harness-internal/plugin-loader/src/plugin-tests.rs +++ b/crates/harness-internal/plugin-loader/src/plugin-tests.rs @@ -6,16 +6,15 @@ use std::cell::RefCell; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; -use runvil_plugin::PackRegistry; +use runvil_plugin::{ErrorCode, FactKey, PackRegistry}; use crate::fakes::*; -use crate::plugin::{Plugin, PluginFailure, PluginSource, insert_plugin}; +use crate::plugin::{Plugin, PluginSource, insert_plugin}; -/// A Plugin and a failure may cross threads, and a Plugin may be shared. +/// A Plugin may cross threads and be shared. const _: () = { const fn send_and_sync() {} send_and_sync::(); - send_and_sync::(); }; #[test] @@ -48,18 +47,30 @@ fn a_load_that_returns_ok_without_describing_fails() { fake!(plug, |_call| RV_OK); let failure = failure(plug::entry); assert_eq!( - failure.reason(), + failure.message, "RV_PLUGIN_LOAD returned without describing the Plugin" ); - assert_eq!(failure.source(), &PluginSource::Linked); + assert_eq!(reason(&failure), Some("undescribed")); + assert_eq!( + failure.facts.get(&FactKey::Path), + None, + "a linked Plugin has no path" + ); } #[test] fn a_refusal_fails_with_its_reason() { fake!(plug, |call| refuse(call, "needs a license key")); let failure = failure(plug::entry); - assert_eq!(failure.reason(), "the Plugin refused: needs a license key"); - assert_eq!(failure.source(), &PluginSource::Linked); + assert_eq!(failure.code, ErrorCode::PluginLoadFailed); + assert_eq!(failure.message, "the Plugin refused: needs a license key"); + assert_eq!(failure.prose, ErrorCode::PluginLoadFailed.prose()); + assert_eq!(reason(&failure), Some("refused")); + assert_eq!( + failure.facts.get(&FactKey::Path), + None, + "a linked Plugin has no path" + ); } #[test] @@ -70,7 +81,7 @@ fn a_refusal_wins_over_a_description_and_an_ok_status() { RV_OK }); assert_eq!( - failure(plug::entry).reason(), + failure(plug::entry).message, "the Plugin refused: changed my mind" ); } @@ -82,16 +93,15 @@ fn a_refusal_before_the_description_still_wins() { describe(call, "acme-url", "1.0.0"); RV_OK }); - assert_eq!(failure(plug::entry).reason(), "the Plugin refused: first"); + assert_eq!(failure(plug::entry).message, "the Plugin refused: first"); } #[test] fn a_non_ok_status_alone_reports_the_status() { fake!(plug, |_call| ODD_STATUS); - assert_eq!( - failure(plug::entry).reason(), - "RV_PLUGIN_LOAD returned status 77" - ); + let failure = failure(plug::entry); + assert_eq!(failure.message, "RV_PLUGIN_LOAD returned status 77"); + assert_eq!(reason(&failure), Some("status")); } #[test] @@ -101,7 +111,7 @@ fn a_non_ok_status_fails_a_load_that_described_itself() { ODD_STATUS }); assert_eq!( - failure(plug::entry).reason(), + failure(plug::entry).message, "RV_PLUGIN_LOAD returned status 77" ); } diff --git a/crates/harness-internal/plugin-loader/src/plugin.rs b/crates/harness-internal/plugin-loader/src/plugin.rs index 7b6bac493..52d85d9f5 100644 --- a/crates/harness-internal/plugin-loader/src/plugin.rs +++ b/crates/harness-internal/plugin-loader/src/plugin.rs @@ -7,11 +7,11 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use runvil_plugin::PackRegistry; use runvil_plugin::abi::PluginEntry; +use runvil_plugin::{Error, FactKey, PackRegistry}; use crate::host::send_load; -use crate::load::{Instance, LoadError, Loaded, open_entry, plugin_files}; +use crate::load::{Instance, Loaded, load_error, open_entry, plugin_files}; #[cfg(test)] #[path = "plugin-tests.rs"] @@ -54,26 +54,39 @@ pub fn insert_plugin_dir(packs: &mut PackRegistry, directory: &Path) -> io::Resu .iter() .any(|loaded| loaded.name == plugin.name) => { - let repeat = LoadError::Duplicate(plugin.name.clone()); - report - .failures - .push(PluginFailure::new(PluginSource::Library(path), &repeat)); + let mut repeat = load_error( + "duplicate", + format!( + "a Plugin named {} was already loaded from this directory", + plugin.name + ), + ); + repeat + .facts + .insert(FactKey::Name, plugin.name.as_str().into()); + report.failures.push(with_path(repeat, &path)); // It did load, so this drop sends RV_PLUGIN_UNLOAD and then // closes its library, like any other Plugin's. drop(plugin); } Ok(plugin) => report.plugins.push(Arc::new(plugin)), - Err(error) => report - .failures - .push(PluginFailure::new(PluginSource::Library(path), &error)), + Err(error) => report.failures.push(with_path(error, &path)), } } Ok(report) } +/// `error` with the library's `path` as its `Path` fact. +fn with_path(mut error: Error, path: &Path) -> Error { + error + .facts + .insert(FactKey::Path, path.display().to_string().into()); + error +} + /// Opens the library at `path` and loads its Plugin. A failing load closes /// the library before this returns. -fn load_library(path: &Path) -> Result { +fn load_library(path: &Path) -> Result { let (library, entry) = open_entry(path)?; // SAFETY: `insert_plugin_dir`'s documentation requires every // `runvil_plugin` to follow the C contract and not unwind, and @@ -93,7 +106,7 @@ unsafe fn load( source: PluginSource, entry: PluginEntry, library: Option, -) -> Result { +) -> Result { // SAFETY: the caller guarantees `entry` follows the C contract and does // not unwind. let (described, instance) = unsafe { send_load(entry) }?; @@ -115,22 +128,17 @@ unsafe fn load( /// must not unwind. /// /// # Errors -/// Returns the failure, with the source `Linked`, when the Plugin refuses, -/// misuses the `RvHost` functions, or does not describe itself. +/// Returns the `PluginLoadFailed` failure, with no `Path` fact, when the +/// Plugin refuses, misuses the `RvHost` functions, or does not describe +/// itself. #[expect( unused_variables, reason = "Plugins insert no Packs until insert_pack exists" )] -pub fn insert_plugin( - packs: &mut PackRegistry, - entry: PluginEntry, -) -> Result, PluginFailure> { +pub fn insert_plugin(packs: &mut PackRegistry, entry: PluginEntry) -> Result, Error> { // SAFETY: this function's documentation requires `entry` to follow the // C contract and not unwind, and a linked Plugin has no library. - match unsafe { load(PluginSource::Linked, entry, None) } { - Ok(plugin) => Ok(Arc::new(plugin)), - Err(error) => Err(PluginFailure::new(PluginSource::Linked, &error)), - } + unsafe { load(PluginSource::Linked, entry, None) }.map(Arc::new) } /// What one `insert_plugin_dir` call loaded and what failed. It holds one @@ -138,7 +146,7 @@ pub fn insert_plugin( #[derive(Debug)] pub struct PluginReport { plugins: Vec>, - failures: Vec, + failures: Vec, } impl PluginReport { @@ -148,9 +156,12 @@ impl PluginReport { &self.plugins } - /// Every failure, in the order it happened. + /// Every failure, in the order it happened: a `PluginLoadFailed` error + /// whose message says why, with the library's path as its `Path` fact, + /// the failure's kind as its `Reason` fact, and, for a repeat, the + /// repeated Plugin name as its `Name` fact. #[must_use] - pub fn failures(&self) -> &[PluginFailure] { + pub fn failures(&self) -> &[Error] { &self.failures } } @@ -210,35 +221,3 @@ impl fmt::Debug for Plugin { .finish_non_exhaustive() } } - -/// One inserted entry point that did not open or load. -#[derive(Debug, Clone)] -pub struct PluginFailure { - source: PluginSource, - /// Readable, such as "RV_PLUGIN_LOAD returned without describing the - /// Plugin". - reason: String, -} - -impl PluginFailure { - /// The failure of the entry point from `source`, with the message of - /// `error` as its reason. - fn new(source: PluginSource, error: &LoadError) -> PluginFailure { - PluginFailure { - source, - reason: error.to_string(), - } - } - - /// Where the entry point that failed came from. - #[must_use] - pub fn source(&self) -> &PluginSource { - &self.source - } - - /// Why it failed, readable. - #[must_use] - pub fn reason(&self) -> &str { - &self.reason - } -} diff --git a/crates/harness-internal/runner/src/effect_loop.rs b/crates/harness-internal/runner/src/effect_loop.rs index c9663e1e3..1e10c17bf 100644 --- a/crates/harness-internal/runner/src/effect_loop.rs +++ b/crates/harness-internal/runner/src/effect_loop.rs @@ -59,6 +59,7 @@ use std::task::Poll; use runvil::cancel::CancelHandle; use runvil::effect::{Effect, EffectAnswer, EffectId, IssuedEffect}; +use runvil::error::Error; use runvil::event::Event; use runvil::ids::Provenance; use runvil::vfs::{Access, VfsOp, perform_vfs_op}; @@ -67,7 +68,7 @@ use runvil::{Run, Step}; use crate::harness::StopSignal; use crate::performers::{BoxFuture, Performers}; use crate::recorder::{ - Record, RecordKind, RecorderError, RunErrorKind, RunId, RunOutcome, RunRecorder, + Record, RecordKind, RunErrorKind, RunId, RunOutcome, RunRecorder, recorder_error, }; #[path = "effect_loop-flight.rs"] @@ -102,7 +103,7 @@ pub async fn drive_run( recorder: Arc, run_id: RunId, cancel: CancelHandle, -) -> Result { +) -> Result { drive(run, performers, recorder, run_id, cancel, Arc::default()).await } @@ -115,7 +116,7 @@ pub(crate) async fn drive( run_id: RunId, cancel: CancelHandle, stop: Arc, -) -> Result { +) -> Result { let cap = performers.tool.max_in_flight(); let mut driver = Driver { run, @@ -154,7 +155,7 @@ enum Woken { } impl Driver { - async fn drive(&mut self) -> Result { + async fn drive(&mut self) -> Result { loop { match self.run.step() { Step::Done { outcome, events } => { @@ -240,7 +241,7 @@ impl Driver { } /// Ends the run at the recorder with `outcome` and returns it. - async fn end(&mut self, outcome: RunOutcome) -> Result { + async fn end(&mut self, outcome: RunOutcome) -> Result { self.recorder.end_run(self.run_id, outcome.clone()).await?; Ok(outcome) } @@ -248,7 +249,7 @@ impl Driver { /// Waits for the next answer, then answers every effect already /// landed behind it, or acts on a cancel or a stop as soon as one is /// raised. - async fn await_answer(&mut self) -> Result<(), RecorderError> { + async fn await_answer(&mut self) -> Result<(), Error> { match self.wake().await { Woken::Cancel => self.drop_in_flight(Reach::All).await, Woken::Stop => self.act_on_stop().await.map(drop), @@ -263,7 +264,7 @@ impl Driver { /// calls that survive stops, which may be nothing, then lowers the /// stop. Returns whether it answered any effect, since each answer /// resumes a chain the run must step. - async fn act_on_stop(&mut self) -> Result { + async fn act_on_stop(&mut self) -> Result { let owed = self.flights.len(); self.drop_in_flight(Reach::AllButSurvivors).await?; self.stop.lower(); @@ -294,7 +295,7 @@ impl Driver { } /// Answers every effect that has already landed, without waiting. - async fn answer_landed(&mut self) -> Result<(), RecorderError> { + async fn answer_landed(&mut self) -> Result<(), Error> { while let Some((id, provenance, answer)) = self.flights.landed_now() { self.answer(id, &provenance, answer).await?; } @@ -305,7 +306,7 @@ impl Driver { /// flight that `reach` takes. The aborted futures are then torn down, /// so whatever a dropped performer held is gone before the run steps /// on; an effect `reach` spared that landed meanwhile is answered too. - async fn drop_in_flight(&mut self, reach: Reach<'_>) -> Result<(), RecorderError> { + async fn drop_in_flight(&mut self, reach: Reach<'_>) -> Result<(), Error> { for (id, provenance) in self.flights.abort(reach) { self.drop_effect(id, &provenance).await?; } @@ -314,11 +315,7 @@ impl Driver { /// Records the `Dropped` answer for one effect and resumes the run /// with it. - async fn drop_effect( - &mut self, - id: EffectId, - provenance: &Provenance, - ) -> Result<(), RecorderError> { + async fn drop_effect(&mut self, id: EffectId, provenance: &Provenance) -> Result<(), Error> { self.answer(id, provenance, EffectAnswer::Dropped).await } @@ -328,7 +325,7 @@ impl Driver { id: EffectId, provenance: &Provenance, answer: EffectAnswer, - ) -> Result<(), RecorderError> { + ) -> Result<(), Error> { self.commit_answer(id, provenance, &answer).await?; self.run.resume(id, answer); Ok(()) @@ -405,13 +402,13 @@ impl Driver { } /// Appends one step's events to the recorder. - async fn commit_events(&mut self, events: Vec) -> Result<(), RecorderError> { + async fn commit_events(&mut self, events: Vec) -> Result<(), Error> { for event in events { let record = record( event.provenance(), RecordKind::Event, None, - serde_json::to_value(&event).map_err(RecorderError::new)?, + serde_json::to_value(&event).map_err(recorder_error)?, ); self.append(record).await?; } @@ -419,7 +416,7 @@ impl Driver { } /// Appends one issued effect's record. - async fn commit_effect(&mut self, issued: &IssuedEffect) -> Result<(), RecorderError> { + async fn commit_effect(&mut self, issued: &IssuedEffect) -> Result<(), Error> { self.append(record( &issued.provenance, RecordKind::Effect, @@ -435,7 +432,7 @@ impl Driver { id: EffectId, provenance: &Provenance, answer: &EffectAnswer, - ) -> Result<(), RecorderError> { + ) -> Result<(), Error> { self.append(record( provenance, RecordKind::Answer, @@ -445,7 +442,7 @@ impl Driver { .await } - async fn append(&mut self, record: Record) -> Result<(), RecorderError> { + async fn append(&mut self, record: Record) -> Result<(), Error> { self.recorder.append(self.run_id, record).await } } diff --git a/crates/harness-internal/runner/src/environment-tests.rs b/crates/harness-internal/runner/src/environment-tests.rs index c6b9addad..cea04d0f4 100644 --- a/crates/harness-internal/runner/src/environment-tests.rs +++ b/crates/harness-internal/runner/src/environment-tests.rs @@ -2,9 +2,10 @@ //! resolve. use runvil::effect::Round; +use runvil::error::ErrorCode; use runvil::model::{ - Completion, CompletionErrorKind, CompletionOptions, Message, ModelBinding, ModelCatalog, - ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use super::*; @@ -14,7 +15,7 @@ use crate::performers::BoxFuture; struct EmptyBroker; impl InferenceBroker for EmptyBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -25,9 +26,9 @@ impl InferenceBroker for EmptyBroker { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/harness-internal/runner/src/environment.rs b/crates/harness-internal/runner/src/environment.rs index bfe7df895..ecf0c1849 100644 --- a/crates/harness-internal/runner/src/environment.rs +++ b/crates/harness-internal/runner/src/environment.rs @@ -9,7 +9,8 @@ use std::path::PathBuf; -use runvil::model::{CompletionError, ModelId, ModelInfo}; +use runvil::error::Error; +use runvil::model::{ModelId, ModelInfo}; use crate::performers::InferenceBroker; use crate::recorder::RunErrorKind; @@ -52,7 +53,7 @@ pub enum CurrentModelError { /// The inference broker failed to fetch its model list. The broker's /// error is the source. #[error("the model catalog could not be fetched")] - CatalogFetchFailed(#[source] CompletionError), + CatalogFetchFailed(#[source] Error), /// The selected id is absent from the fetched catalog. #[error("the selected model `{0}` is absent from the fetched catalog")] SelectionAbsent(String), diff --git a/crates/harness-internal/runner/src/harness.rs b/crates/harness-internal/runner/src/harness.rs index f3eff245a..85e76375e 100644 --- a/crates/harness-internal/runner/src/harness.rs +++ b/crates/harness-internal/runner/src/harness.rs @@ -27,7 +27,7 @@ use crate::files::{OutputError, report_output}; use crate::host::HostContext; use crate::performers::{InferenceBroker, Timer}; use crate::prepare::{Services, begin, prepare}; -use crate::recorder::{RecorderError, RunId, RunOutcome, RunRecorder}; +use crate::recorder::{RunId, RunOutcome, RunRecorder}; #[path = "harness-control.rs"] mod control; @@ -113,7 +113,7 @@ pub enum HarnessError { run: Option, /// The error the recorder returned. #[source] - source: RecorderError, + source: Box, }, } @@ -205,13 +205,17 @@ impl Harness { host, } = request; - let begun = begin(&*recorder, &name, &source) - .await - .map_err(|source| HarnessError::Recorder { run: None, source })?; + let begun = + begin(&*recorder, &name, &source) + .await + .map_err(|source| HarnessError::Recorder { + run: None, + source: Box::new(source), + })?; let run_id = begun.run_id; let recorded = |source| HarnessError::Recorder { run: Some(run_id), - source, + source: Box::new(source), }; let model = match resolve_model(&host, &*broker, control.cancel_handle()).await { diff --git a/crates/harness-internal/runner/src/host-run.rs b/crates/harness-internal/runner/src/host-run.rs index 5c6c05454..10bfbdcbf 100644 --- a/crates/harness-internal/runner/src/host-run.rs +++ b/crates/harness-internal/runner/src/host-run.rs @@ -18,9 +18,10 @@ use std::sync::Arc; use runvil::Prompt; use runvil::effect::{PackToolSets, ToolCallOrigin}; +use runvil::error::{Error, ErrorCode}; use runvil::model::ModelInfo; use runvil::packs::{Prelude, RosterEntry}; -use runvil::tools::{ToolCatalog, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput, tool_error}; use runvil::vfs::Access; use runvil_plugin::{HostServices, Pack, PackId, PackInfo, RenderContext, ServiceId, ToolContext}; use serde_json::Value; @@ -187,7 +188,7 @@ impl ToolPerformer for HostRunContext { access: Arc, origin: ToolCallOrigin, args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let pack = self.usable(&tool.pack()).cloned(); let services = self.services.clone(); Box::pin(async move { @@ -197,10 +198,10 @@ impl ToolPerformer for HostRunContext { alias = %alias, "a ToolCall names a tool of no usable Pack of the run" ); - return Err(ToolError::message(format!( - "tool `{alias}` ({tool}) belongs to no usable Pack of this run" - )) - .with_kind(ToolErrorKind::Other)); + return Err(tool_error( + ErrorCode::ToolOther, + format!("tool `{alias}` ({tool}) belongs to no usable Pack of this run"), + )); }; let cx = ToolContext::new(&tool, &access, &origin, &services); pack.call(cx, args).await diff --git a/crates/harness-internal/runner/src/host-tests-render.rs b/crates/harness-internal/runner/src/host-tests-render.rs index 1002a3909..284be00dd 100644 --- a/crates/harness-internal/runner/src/host-tests-render.rs +++ b/crates/harness-internal/runner/src/host-tests-render.rs @@ -37,10 +37,10 @@ fn rendering( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let renders = services .get(&RENDERS) - .ok_or_else(|| ToolError::message("rendering needs tests/renders"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "rendering needs tests/renders"))?; Ok(Arc::new(Rendering { name: name.clone(), listed: listed(name, config, services)?, @@ -57,7 +57,7 @@ impl Pack for Rendering { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.listed.call(cx, args) } diff --git a/crates/harness-internal/runner/src/host-tests.rs b/crates/harness-internal/runner/src/host-tests.rs index 381f41188..eaa850933 100644 --- a/crates/harness-internal/runner/src/host-tests.rs +++ b/crates/harness-internal/runner/src/host-tests.rs @@ -10,13 +10,15 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use runvil::effect::{ToolCallOrigin, ToolCaller}; +use runvil::error::{Error, ErrorCode}; use runvil::packs::RosterEntry; use runvil::timestamp::Timestamp; +use runvil::tools::tool_error; use runvil::vfs::{Origin, VfsRef}; use runvil::{Prompt, Run, RunConfig, RunOutcome, RunSeed, Step}; use runvil_plugin::{ HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceId, ServiceKey, - ToolContext, ToolError, ToolId, ToolInfo, ToolOutput, + ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -55,11 +57,11 @@ impl Pack for Listed { self.tools.clone() } - fn ready(&self) -> PackFuture<'_, Result<(), ToolError>> { + fn ready(&self) -> PackFuture<'_, Result<(), Error>> { match self.ready.as_deref() { Some("fails") => { let reason = format!("{} will not be ready", self.name); - Box::pin(async move { Err(ToolError::message(reason)) }) + Box::pin(async move { Err(tool_error(ErrorCode::ToolOther, reason)) }) } Some("pends") => Box::pin(std::future::pending()), _ => Box::pin(async { Ok(()) }), @@ -70,7 +72,7 @@ impl Pack for Listed { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { Ok(ToolOutput::trusted(format!("{}:{}", self.name, cx.tool()))) }) } } @@ -83,11 +85,7 @@ impl Pack for Listed { clippy::unnecessary_wraps, reason = "the PackInfo construct signature fixes the argument and return types" )] -fn listed( - name: &PackId, - config: Value, - services: &HostServices, -) -> Result, ToolError> { +fn listed(name: &PackId, config: Value, services: &HostServices) -> Result, Error> { if let Some(built) = services.get(&BUILT) { built.lock().unwrap().push(name.to_string()); } @@ -115,13 +113,10 @@ fn listed_tool(id: &str) -> ToolInfo { /// Builds a [`Listed`] with one `/use` tool, failing when the Host /// provides no [`BACKEND`]. -fn backed( - name: &PackId, - _config: Value, - services: &HostServices, -) -> Result, ToolError> { +fn backed(name: &PackId, _config: Value, services: &HostServices) -> Result, Error> { if services.get(&BACKEND).is_none() { - return Err(ToolError::message( + return Err(tool_error( + ErrorCode::ToolOther, "backed needs tests/backend, and this host provides none", )); } @@ -447,7 +442,7 @@ fn preludes_follow_the_prompts_declaration_order_and_carry_the_pack_id() { } /// Calls `tool` through `run` from a script and returns its result. -async fn call(run: &dyn ToolPerformer, tool: &str) -> Result { +async fn call(run: &dyn ToolPerformer, tool: &str) -> Result { let access = VfsRef::default().acquire(Origin::new("host test")).unwrap(); let origin = ToolCallOrigin::new("host-test", "Only", ToolCaller::Script); let tool = ToolId::parse(tool).unwrap(); @@ -469,7 +464,7 @@ async fn a_call_goes_to_the_pack_its_tool_ids_first_segment_names() { ]); let run = begin(&host, HostServices::new(), BTreeMap::new(), &prompt("")); - let answer = |result: Result| result.unwrap().text().to_owned(); + let answer = |result: Result| result.unwrap().text().to_owned(); assert_eq!(answer(call(&run, "one/echo").await), "one:one/echo"); assert_eq!(answer(call(&run, "two/echo").await), "two:two/echo"); let error = call(&run, "three/echo") diff --git a/crates/harness-internal/runner/src/lib.rs b/crates/harness-internal/runner/src/lib.rs index b1cee226e..4a86ad8b4 100644 --- a/crates/harness-internal/runner/src/lib.rs +++ b/crates/harness-internal/runner/src/lib.rs @@ -49,6 +49,6 @@ pub use display_chain::display_chain; pub use harness::{Harness, HarnessError, RunControl, RunReport, RunRequest}; pub use host::HostContext; pub use recorder::{ - MemoryRecorder, Record, RecordKind, RecorderError, RecorderFuture, RunErrorKind, RunId, - RunMeta, RunOutcome, RunRecorder, + MemoryRecorder, Record, RecordKind, RecorderFuture, RunErrorKind, RunId, RunMeta, RunOutcome, + RunRecorder, recorder_error, }; diff --git a/crates/harness-internal/runner/src/performers.rs b/crates/harness-internal/runner/src/performers.rs index b72d48fd3..0b874fabe 100644 --- a/crates/harness-internal/runner/src/performers.rs +++ b/crates/harness-internal/runner/src/performers.rs @@ -33,11 +33,11 @@ use std::sync::Arc; use std::time::Duration; use runvil::effect::{PackToolSets, Round, ToolCallOrigin}; +use runvil::error::Error; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ModelInfo, - ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ModelInfo, ToolSchema, }; -use runvil::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; use serde_json::Value; @@ -57,7 +57,7 @@ pub type BoxFuture = Pin + Send + 'static>>; /// to the Host's own runtime. pub trait InferenceBroker: Send + Sync { /// Lists the models the broker serves. - fn models(&self) -> BoxFuture>; + fn models(&self) -> BoxFuture>; /// Runs one model round and returns the finished reply. /// @@ -75,7 +75,7 @@ pub trait InferenceBroker: Send + Sync { tools: Vec, options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>>; + ) -> BoxFuture, Error>>; } /// The tool calls a run has in flight at once unless its performer says @@ -99,7 +99,7 @@ pub trait ToolPerformer: Send + Sync { access: Arc, origin: ToolCallOrigin, args: Value, - ) -> BoxFuture>; + ) -> BoxFuture>; /// Renders the tools the section entry `entry` offered from each Pack /// in `offered`, for `model`, the model of the entry's first round, and diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index fdb7cb994..3e2f0bd51 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use runvil::cancel::CancelHandle; +use runvil::error::Error; use runvil::event::Event; use runvil::model::{ModelInfo, ModelRole}; use runvil::prompt::FileDecl; @@ -49,7 +50,7 @@ use crate::files::{InputFileError, stage_input}; use crate::host::HostContext; use crate::performers::{InferenceBroker, Performers, Timer}; use crate::recorder::{ - Record, RecordKind, RecorderError, RunErrorKind, RunId, RunMeta, RunOutcome, RunRecorder, + Record, RecordKind, RunErrorKind, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error, }; /// What the caller owns and preparation borrows: the Host's built @@ -168,18 +169,18 @@ pub enum PrepareError { run: RunId, /// The recorder's refusal. #[source] - source: RecorderError, + source: Box, }, } impl PrepareError { /// The failed outcome this failure ended the run with, or, for a /// recorder refusal, which ends nothing, the refusal. - pub(crate) fn ended(self) -> Result { + pub(crate) fn ended(self) -> Result { match self { PrepareError::Parse { source, .. } => Ok(failed(RunErrorKind::Parse, &source)), PrepareError::Input { source, .. } => Ok(failed(source.kind(), &source)), - PrepareError::Recorder { source, .. } => Err(source), + PrepareError::Recorder { source, .. } => Err(*source), } } } @@ -190,11 +191,7 @@ impl PrepareError { /// /// # Errors /// Returns the recorder's refusal to begin the run. -pub async fn begin( - recorder: &dyn RunRecorder, - name: &str, - source: &str, -) -> Result { +pub async fn begin(recorder: &dyn RunRecorder, name: &str, source: &str) -> Result { let seed = RunSeed::from_bytes(rand::random()); let started_at = now_timestamp(); let run_id = recorder @@ -259,7 +256,7 @@ pub async fn prepare( } = begun; let recorded = |source| PrepareError::Recorder { run: run_id, - source, + source: Box::new(source), }; // Parse-time events are the run's first records, whether or not the @@ -353,14 +350,14 @@ fn failed(kind: RunErrorKind, source: &dyn std::error::Error) -> RunOutcome { } /// One event's record under its own provenance. -fn event_record(event: &Event) -> Result { +fn event_record(event: &Event) -> Result { let provenance = event.provenance(); Ok(Record { task_id: provenance.task.to_string(), task_seq: provenance.seq, kind: RecordKind::Event, effect_id: None, - payload: serde_json::to_value(event).map_err(RecorderError::new)?, + payload: serde_json::to_value(event).map_err(recorder_error)?, }) } diff --git a/crates/harness-internal/runner/src/recorder-memory.rs b/crates/harness-internal/runner/src/recorder-memory.rs index 967914a7f..2230bc5b5 100644 --- a/crates/harness-internal/runner/src/recorder-memory.rs +++ b/crates/harness-internal/runner/src/recorder-memory.rs @@ -2,7 +2,10 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; -use super::{Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder}; +use runvil::error::{Error, FactKey}; +use serde_json::Value; + +use super::{Record, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error}; /// A recorder that keeps every run in memory, for tests and doc examples. /// @@ -10,7 +13,8 @@ use super::{Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, R /// in the order runs begin. A write to an id outside the runs this recorder /// began, a write to a run that has ended, and a second /// [`end_run`](RunRecorder::end_run) each fail, so a test notices a Harness -/// that breaks the call order. +/// that breaks the call order. Each such failure has the `Reason` fact +/// `unknown_run` or `run_ended` and the run id as the `Run` fact. #[derive(Debug, Default)] pub struct MemoryRecorder { runs: Mutex>, @@ -23,14 +27,6 @@ struct MemoryRun { outcome: Option, } -#[derive(Debug, thiserror::Error)] -enum MemoryError { - #[error("memory recorder: unknown run {0}")] - UnknownRun(RunId), - #[error("memory recorder: run {0} has ended")] - RunEnded(RunId), -} - impl MemoryRecorder { /// An empty recorder. #[must_use] @@ -85,21 +81,38 @@ fn slot(run: RunId) -> usize { } /// The run `run`, if it exists and still takes writes. -fn open_run(runs: &mut [MemoryRun], run: RunId) -> Result<&mut MemoryRun, MemoryError> { - let found = runs - .get_mut(slot(run)) - .ok_or(MemoryError::UnknownRun(run))?; +fn open_run(runs: &mut [MemoryRun], run: RunId) -> Result<&mut MemoryRun, Error> { + let found = runs.get_mut(slot(run)).ok_or_else(|| { + refused( + run, + "unknown_run", + format!("memory recorder: unknown run {run}"), + ) + })?; if found.outcome.is_some() { - return Err(MemoryError::RunEnded(run)); + return Err(refused( + run, + "run_ended", + format!("memory recorder: run {run} has ended"), + )); } Ok(found) } +/// A write to `run` the recorder refuses for `reason`, with `text` as its +/// cause. +fn refused(run: RunId, reason: &str, text: String) -> Error { + let mut error = recorder_error(text); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error.facts.insert(FactKey::Run, Value::from(run.get())); + error +} + impl RunRecorder for MemoryRecorder { fn begin_run(&self, meta: RunMeta) -> RecorderFuture<'_, RunId> { Box::pin(async move { let mut runs = self.lock(); - let id = i64::try_from(runs.len() + 1).map_err(RecorderError::new)?; + let id = i64::try_from(runs.len() + 1).map_err(recorder_error)?; runs.push(MemoryRun { meta, records: Vec::new(), @@ -112,18 +125,14 @@ impl RunRecorder for MemoryRecorder { fn append(&self, run: RunId, record: Record) -> RecorderFuture<'_, ()> { Box::pin(async move { let mut runs = self.lock(); - open_run(&mut runs, run) - .map(|open| open.records.push(record)) - .map_err(RecorderError::new) + open_run(&mut runs, run).map(|open| open.records.push(record)) }) } fn end_run(&self, run: RunId, outcome: RunOutcome) -> RecorderFuture<'_, ()> { Box::pin(async move { let mut runs = self.lock(); - open_run(&mut runs, run) - .map(|open| open.outcome = Some(outcome)) - .map_err(RecorderError::new) + open_run(&mut runs, run).map(|open| open.outcome = Some(outcome)) }) } } diff --git a/crates/harness-internal/runner/src/recorder.rs b/crates/harness-internal/runner/src/recorder.rs index 1a4145ba9..c7ae70253 100644 --- a/crates/harness-internal/runner/src/recorder.rs +++ b/crates/harness-internal/runner/src/recorder.rs @@ -22,12 +22,12 @@ //! input cannot be put in place. A run the effect loop loses track of //! ends as failed with the kind `Internal`. -use std::error::Error; use std::fmt; use std::future::Future; use std::pin::Pin; use runvil::RunSeed; +use runvil::error::{Error, ErrorCode}; pub use runvil::{RunErrorKind, RunOutcome}; #[path = "recorder-memory.rs"] @@ -38,30 +38,19 @@ pub use memory::MemoryRecorder; /// The future that each [`RunRecorder`] method returns. /// /// It is boxed, can be sent between threads, and borrows the recorder. -pub type RecorderFuture<'a, T> = - Pin> + Send + 'a>>; +pub type RecorderFuture<'a, T> = Pin> + Send + 'a>>; -/// The error a recorder returns when it fails to store a write. +/// Builds the `RecorderFailed` error a recorder returns when it fails to +/// store a write, from the error or the message that made it fail. /// -/// It wraps the recorder's own error as its source. The Harness stops the -/// run that received it. Its message says only that the run recorder -/// failed, so read the source chain for the reason. -#[derive(Debug, thiserror::Error)] -#[error("the run recorder failed")] -pub struct RecorderError { - /// The recorder's own error. - #[source] - source: Box, -} - -impl RecorderError { - /// Wraps the error, or the message, that made the recorder fail. - #[must_use] - pub fn new(source: impl Into>) -> Self { - Self { - source: source.into(), - } - } +/// The Harness stops the run that received it. Its message is `the run +/// recorder failed: ` followed by the cause chain, and its prose is the +/// code's default. +#[must_use] +pub fn recorder_error(cause: impl Into>) -> Error { + let cause = cause.into(); + let code = ErrorCode::RecorderFailed; + Error::new(code, "the run recorder failed", code.prose()).with_cause(&*cause) } /// The storage that the Harness writes each run's history to. @@ -76,14 +65,14 @@ pub trait RunRecorder: Send + Sync { /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn begin_run(&self, meta: RunMeta) -> RecorderFuture<'_, RunId>; /// Adds one record to an open run. /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn append(&self, run: RunId, record: Record) -> RecorderFuture<'_, ()>; /// Closes a run with its outcome. The Harness calls it once for each @@ -91,7 +80,7 @@ pub trait RunRecorder: Send + Sync { /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn end_run(&self, run: RunId, outcome: RunOutcome) -> RecorderFuture<'_, ()>; } diff --git a/crates/harness-internal/runner/tests/it/asker.rs b/crates/harness-internal/runner/tests/it/asker.rs index 8ff6bd587..e6a36e006 100644 --- a/crates/harness-internal/runner/tests/it/asker.rs +++ b/crates/harness-internal/runner/tests/it/asker.rs @@ -1,14 +1,16 @@ //! The fixture ask Pack the Harness suites build in place of a real //! user-input Pack, which Harness crates may not name: an ask tool that //! survives stops, a prelude defining `input.ask()`, a need for the run's -//! broker, and a broker that fails with a `ToolError`. A prompt declares +//! broker, and a broker that fails with a tool error. A prompt declares //! it as `tests/user-input`. use std::sync::Arc; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, PackInfo, ServiceId, ServiceKey, ToolContext, - ToolError, ToolId, ToolInfo, ToolOutput, + HostServices, Pack, PackFuture, PackId, PackInfo, ServiceId, ServiceKey, ToolContext, ToolId, + ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -35,7 +37,7 @@ end /// Delivers the operator's answer to the fixture's ask tool. #[async_trait::async_trait] pub(crate) trait AskBroker: Send + Sync { - async fn wait(&self) -> Result; + async fn wait(&self) -> Result; } /// Run services holding `broker` under [`BROKER`]. @@ -57,7 +59,7 @@ fn construct( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let ask = ToolInfo::new( ToolId::parse(&format!("{name}/ask")).unwrap(), "Wait for the operator's next message.", @@ -76,11 +78,11 @@ impl Pack for Asker { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { - let broker = cx - .service(&BROKER) - .ok_or_else(|| ToolError::message("no operator is connected to this run"))?; + let broker = cx.service(&BROKER).ok_or_else(|| { + tool_error(ErrorCode::ToolOther, "no operator is connected to this run") + })?; broker.wait().await.map(ToolOutput::trusted) }) } diff --git a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs index 32285b74d..bbf61efd2 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs @@ -12,11 +12,12 @@ use harness_runner::performers::{BoxFuture, InferenceBroker, Performers}; use harness_runner::recorder::{RecordKind, RunOutcome}; use runvil::cancel::CancelHandle; use runvil::effect::Round; +use runvil::error::Error; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, - ModelCatalog, ModelId, ModelInfo, ModelRole, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ModelRole, ThinkingMode, ToolSchema, }; use runvil::timestamp::Timestamp; use runvil::{Prompt, Run, RunConfig}; @@ -64,7 +65,7 @@ struct RecordingBroker { } impl InferenceBroker for RecordingBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { unreachable!("the effect loop never lists models") } @@ -75,7 +76,7 @@ impl InferenceBroker for RecordingBroker { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { let asked = messages .last() .map(|message| message.content().to_owned()) diff --git a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index cdbb88770..9177ba70e 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs @@ -15,12 +15,12 @@ use harness_runner::performers::{BoxFuture, ToolPerformer}; use harness_runner::recorder::{MemoryRecorder, RecordKind, RunId, RunOutcome}; use runvil::cancel::CancelHandle; use runvil::effect::ToolCallOrigin; +use runvil::error::Error; use runvil::model::{ - Completion, CompletionError, CompletionResult, ModelId, ModelInfo, ModelRole, ThinkingMode, - ToolCall, + Completion, CompletionResult, ModelId, ModelInfo, ModelRole, ThinkingMode, ToolCall, }; use runvil::timestamp::Timestamp; -use runvil::tools::{ToolCatalog, ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; use runvil::{Prompt, Run, RunConfig}; use serde_json::{Value, json}; @@ -138,7 +138,7 @@ impl ToolPerformer for ParkingTool { _access: Arc, _origin: ToolCallOrigin, args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let name = args["name"].as_str().unwrap_or_default().to_owned(); let gate = Arc::clone(&self.gate); let started = Arc::clone(&self.started); @@ -173,7 +173,7 @@ impl Drop for TornDown { /// Plays each round through `script`, handed the round's last message. fn answering( - script: impl Fn(&str) -> BoxFuture, CompletionError>> + Send + Sync + 'static, + script: impl Fn(&str) -> BoxFuture, Error>> + Send + Sync + 'static, ) -> ScriptedBroker { ScriptedBroker::new(move |_round, messages| { let last = messages @@ -186,7 +186,7 @@ fn answering( /// A completion asking for [`TOOL`] once per entry of `names`, each /// call's id and `name` argument the entry. -fn parks(names: &[&str]) -> Result, CompletionError> { +fn parks(names: &[&str]) -> Result, Error> { let calls = names .iter() .map(|name| ToolCall::from_parts(*name, WIRE, json!({ "name": name }))) diff --git a/crates/harness-internal/runner/tests/it/harness-errors.rs b/crates/harness-internal/runner/tests/it/harness-errors.rs index 38b9abf06..5761b6dec 100644 --- a/crates/harness-internal/runner/tests/it/harness-errors.rs +++ b/crates/harness-internal/runner/tests/it/harness-errors.rs @@ -15,7 +15,8 @@ use harness_runner::display_chain; use harness_runner::environment::{CurrentModelError, HostSnapshot}; use harness_runner::recorder::{MemoryRecorder, RunErrorKind}; use harness_runner::{Harness, HarnessError, HostContext, RunReport, RunRequest}; -use runvil::model::{Completion, CompletionError, CompletionErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::model::{Completion, completion_error, phrase}; use runvil_plugin::{HostServices, PackRegistry}; use super::{ @@ -74,14 +75,14 @@ fn assert_ended_before_the_parse(recorder: &FailingRecorder, report: &RunReport) #[tokio::test] async fn a_model_list_the_broker_cannot_fetch_ends_the_recorded_run_as_a_completion_failure() { let recorder = Arc::new(FailingRecorder::never_failing()); - let kind = CompletionErrorKind::Transport; - let broker = ScriptedBroker::replying().listing(Listing::Fails(kind)); + let code = ErrorCode::Transport; + let broker = ScriptedBroker::replying().listing(Listing::Fails(code)); let report = harness_over(&recorder, broker) .run(request(PLAIN)) .await .expect("a failed listing is the run's outcome"); - let lookup = CurrentModelError::CatalogFetchFailed(CompletionError::new(kind, kind.phrase())); + let lookup = CurrentModelError::CatalogFetchFailed(completion_error(code, phrase(code))); assert_eq!( report.outcome, RunOutcome::Failed { @@ -230,7 +231,7 @@ async fn a_panicking_broker_answers_its_round_dropped() { }); let in_the_round = ScriptedBroker::new(|_round, _messages| { Box::pin(std::future::poll_fn( - |_cx| -> Poll, CompletionError>> { + |_cx| -> Poll, Error>> { panic!("the broker panics in its round") }, )) diff --git a/crates/harness-internal/runner/tests/it/performers.rs b/crates/harness-internal/runner/tests/it/performers.rs index 83543d197..ceff1f74d 100644 --- a/crates/harness-internal/runner/tests/it/performers.rs +++ b/crates/harness-internal/runner/tests/it/performers.rs @@ -14,7 +14,8 @@ use harness_runner::recorder::{ }; use runvil::cancel::CancelHandle; use runvil::effect::ToolCallOrigin; -use runvil::tools::{ToolError, ToolId, ToolOutput}; +use runvil::error::Error; +use runvil::tools::{ToolId, ToolOutput}; use runvil::vfs::Access; use serde_json::{Value, json}; @@ -112,7 +113,7 @@ impl ToolPerformer for DelayedTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let delay = self.delay; let text = self.text; Box::pin(async move { diff --git a/crates/harness-internal/runner/tests/it/prepare-host-services.rs b/crates/harness-internal/runner/tests/it/prepare-host-services.rs index c92daad21..6f8f6a491 100644 --- a/crates/harness-internal/runner/tests/it/prepare-host-services.rs +++ b/crates/harness-internal/runner/tests/it/prepare-host-services.rs @@ -33,7 +33,7 @@ fn construct_greeter( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let greet = ToolInfo::new( ToolId::parse(&format!("{name}/greet")).unwrap(), "Answer with the run's greeting.", @@ -51,11 +51,11 @@ impl Pack for Greeter { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let greeting = cx .service(&GREETING) - .ok_or_else(|| ToolError::message("no greeting in this run"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "no greeting in this run"))?; Ok(ToolOutput::trusted(greeting.to_string())) }) } diff --git a/crates/harness-internal/runner/tests/it/prepare-input.rs b/crates/harness-internal/runner/tests/it/prepare-input.rs index 0013a9b51..34708f38c 100644 --- a/crates/harness-internal/runner/tests/it/prepare-input.rs +++ b/crates/harness-internal/runner/tests/it/prepare-input.rs @@ -20,21 +20,21 @@ struct Scripted(&'static str); #[async_trait::async_trait] impl AskBroker for Scripted { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Ok(self.0.to_owned()) } } /// A broker whose every wait fails, with a hidden cause behind the -/// message. +/// prose. struct Failing; #[async_trait::async_trait] impl AskBroker for Failing { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Err( - ToolError::new(ToolErrorKind::Backend, "the operator's window closed") - .with_source(std::io::Error::other("socket reset")), + tool_error(ErrorCode::ToolBackend, "the operator's window closed") + .with_cause(&std::io::Error::other("socket reset")), ) } } @@ -56,7 +56,7 @@ fn construct_probe( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let saw = ToolInfo::new( ToolId::parse(&format!("{name}/saw")).unwrap(), "Answer whether the call was lent a broker.", @@ -74,7 +74,7 @@ impl Pack for Probe { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { let saw = cx.service(&BROKER).is_some(); Box::pin(async move { Ok(ToolOutput::trusted(saw.to_string())) }) } diff --git a/crates/harness-internal/runner/tests/it/prepare-ready.rs b/crates/harness-internal/runner/tests/it/prepare-ready.rs index c6dcc259c..fe0fdacc9 100644 --- a/crates/harness-internal/runner/tests/it/prepare-ready.rs +++ b/crates/harness-internal/runner/tests/it/prepare-ready.rs @@ -89,14 +89,15 @@ fn construct_starting( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let startup = services .get(&STARTUPS) .and_then(|startups| startups.get(&name.to_string()).cloned()) .ok_or_else(|| { - ToolError::message(format!( - "{name} needs its startup in tests/startups, and this host provides none" - )) + tool_error( + ErrorCode::ToolOther, + format!("{name} needs its startup in tests/startups, and this host provides none"), + ) })?; Ok(Arc::new(Starting { echo: construct_echo(name, config, services)?, @@ -112,16 +113,21 @@ impl Pack for Starting { } } - fn ready(&self) -> PackFuture<'_, Result<(), ToolError>> { + fn ready(&self) -> PackFuture<'_, Result<(), Error>> { let mut phase = self.startup.phase.clone(); Box::pin(async move { self.startup.asked.notify_one(); let settled = *phase .wait_for(|phase| !matches!(phase, Phase::Starting)) .await - .map_err(|_closed| ToolError::message("the test dropped the fixture's startup"))?; + .map_err(|_closed| { + tool_error( + ErrorCode::ToolOther, + "the test dropped the fixture's startup", + ) + })?; match settled { - Phase::Failed(reason) => Err(ToolError::message(reason)), + Phase::Failed(reason) => Err(tool_error(ErrorCode::ToolOther, reason)), Phase::Starting | Phase::Ready => Ok(()), } }) @@ -131,7 +137,7 @@ impl Pack for Starting { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.echo.call(cx, args) } } diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index ff3c8ec71..a476be954 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -21,10 +21,11 @@ use harness_runner::prepare::{PrepareError, Prepared, Services, begin, prepare}; use harness_runner::recorder::{MemoryRecorder, RecordKind, RunErrorKind, RunId, RunOutcome}; use harness_runner::{HostContext, display_chain}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode}; use runvil::event::Event; +use runvil::tools::tool_error; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolError, ToolErrorKind, - ToolId, ToolInfo, ToolOutput, + HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -151,7 +152,7 @@ fn construct_echo( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let echo = ToolInfo::new( ToolId::parse(&format!("{name}/echo")).unwrap(), "Echo the value argument.", @@ -169,12 +170,12 @@ impl Pack for Echo { &'a self, _cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let value = args .get("value") .and_then(Value::as_str) - .ok_or_else(|| ToolError::message("echo: missing `value`"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "echo: missing `value`"))?; Ok(ToolOutput::trusted(value.to_owned())) }) } diff --git a/crates/harness-internal/runner/tests/it/recorder.rs b/crates/harness-internal/runner/tests/it/recorder.rs index 33effe667..051025602 100644 --- a/crates/harness-internal/runner/tests/it/recorder.rs +++ b/crates/harness-internal/runner/tests/it/recorder.rs @@ -8,13 +8,22 @@ use std::sync::Arc; use harness_runner::display_chain; use harness_runner::recorder::{ - MemoryRecorder, Record, RecordKind, RecorderError, RunErrorKind, RunId, RunMeta, RunOutcome, - RunRecorder, + MemoryRecorder, Record, RecordKind, RunErrorKind, RunId, RunMeta, RunOutcome, RunRecorder, + recorder_error, }; +use runvil::error::{ErrorCode, FactKey}; use serde_json::json; use crate::support::TEST_SEED; +/// Asserts `error` is the memory recorder's refusal of run `run` for +/// `reason`. +fn assert_refused(error: &runvil::error::Error, run: i64, reason: &str) { + assert_eq!(error.code, ErrorCode::RecorderFailed); + assert_eq!(error.facts.get(&FactKey::Reason), Some(&json!(reason))); + assert_eq!(error.facts.get(&FactKey::Run), Some(&json!(run))); +} + fn meta(name: &str) -> RunMeta { RunMeta { name: name.to_owned(), @@ -183,6 +192,7 @@ async fn an_append_to_an_unknown_run_fails() { .await .expect_err("run 99 was never begun"); + assert_refused(&error, 99, "unknown_run"); let text = display_chain(&error); assert!( text.contains("unknown run 99"), @@ -199,6 +209,7 @@ async fn ending_an_unknown_run_fails() { .await .expect_err("run 5 was never begun"); + assert_refused(&error, 5, "unknown_run"); let text = display_chain(&error); assert!( text.contains("unknown run 5"), @@ -221,6 +232,7 @@ async fn an_append_after_end_run_fails_and_changes_nothing() { .await .expect_err("an ended run accepts no more records"); + assert_refused(&error, 1, "run_ended"); let text = display_chain(&error); assert!( text.contains("run 1 has ended"), @@ -250,6 +262,7 @@ async fn a_second_end_run_fails_and_keeps_the_first_outcome() { .await .expect_err("a run ends exactly once"); + assert_refused(&error, 1, "run_ended"); let text = display_chain(&error); assert!( text.contains("run 1 has ended"), @@ -310,25 +323,21 @@ impl fmt::Display for DiskFull { impl Error for DiskFull {} #[test] -fn a_recorder_error_reports_its_cause_as_source() { - let error = RecorderError::new(DiskFull); +fn a_recorder_error_keeps_its_cause_in_the_message_only() { + let error = recorder_error(DiskFull); - let source = error.source().expect("the wrapped error is the source"); - assert_eq!(source.to_string(), "the disk is full"); - assert!( - source.downcast_ref::().is_some(), - "the source is the very error the recorder returned" - ); + assert_eq!(error.code, ErrorCode::RecorderFailed); assert_eq!( display_chain(&error), "the run recorder failed: the disk is full", "the rendered chain shows the recorder's cause" ); + assert_eq!(error.prose, ErrorCode::RecorderFailed.prose()); } #[test] fn a_recorder_error_takes_a_plain_message() { - let error = RecorderError::new("the store is read-only"); + let error = recorder_error("the store is read-only"); assert_eq!( display_chain(&error), diff --git a/crates/harness-internal/runner/tests/it/render.rs b/crates/harness-internal/runner/tests/it/render.rs index 2ee593b13..e94be28bd 100644 --- a/crates/harness-internal/runner/tests/it/render.rs +++ b/crates/harness-internal/runner/tests/it/render.rs @@ -11,10 +11,12 @@ use std::sync::{Arc, Mutex}; use harness_runner::recorder::{MemoryRecorder, Record, RecordKind, RunOutcome}; use harness_runner::{Harness, HostContext}; +use runvil::error::{Error, ErrorCode}; use runvil::model::{Completion, CompletionResult, ToolCall}; +use runvil::tools::tool_error; use runvil_plugin::{ HostServices, ModelInfo, Pack, PackFuture, PackId, PackInfo, PackRegistry, RenderContext, - ServiceKey, ToolContext, ToolError, ToolId, ToolInfo, ToolOutput, + ServiceKey, ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; use tokio::sync::Semaphore; @@ -138,10 +140,13 @@ fn construct( _name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { - let seen = services - .get(&SEEN) - .ok_or_else(|| ToolError::message("the render Pack needs tests/render-seen"))?; +) -> Result, Error> { + let seen = services.get(&SEEN).ok_or_else(|| { + tool_error( + ErrorCode::ToolOther, + "the render Pack needs tests/render-seen", + ) + })?; let surviving = config["surviving"] .as_array() .into_iter() @@ -197,7 +202,7 @@ impl Pack for RenderPack { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { let origin = cx.origin(); self.seen.calls.lock().unwrap().push(Called { tool: cx.tool().to_string(), diff --git a/crates/harness-internal/runner/tests/it/scripted.rs b/crates/harness-internal/runner/tests/it/scripted.rs index a3c3dbaeb..765b53f1b 100644 --- a/crates/harness-internal/runner/tests/it/scripted.rs +++ b/crates/harness-internal/runner/tests/it/scripted.rs @@ -12,13 +12,15 @@ use std::time::Duration; use harness_runner::HostContext; use harness_runner::performers::{BoxFuture, InferenceBroker, Timer}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, CompletionResult, Message, - ModelBinding, ModelCatalog, ModelId, ModelInfo, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ThinkingMode, ToolSchema, completion_error, phrase, }; +use runvil::tools::tool_error; use runvil_plugin::{ HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceKey, ToolContext, - ToolError, ToolId, ToolInfo, ToolOutput, + ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; use tokio::sync::mpsc; @@ -32,17 +34,16 @@ pub(crate) const MODEL: &str = "m"; pub(crate) type Kept = Arc>>; /// Plays one model round: the round and its messages in, its answer out. -type ChatScript = dyn Fn(Round, Vec) -> BoxFuture, CompletionError>> - + Send - + Sync; +type ChatScript = + dyn Fn(Round, Vec) -> BoxFuture, Error>> + Send + Sync; /// What a scripted broker's `models()` does. #[derive(Clone, Copy)] pub(crate) enum Listing { /// Lists the one scripted model. Lists, - /// Fails as this kind with the kind's fixed phrase. - Fails(CompletionErrorKind), + /// Fails under this code with the code's fixed phrase. + Fails(ErrorCode), /// Never answers. Holds, } @@ -60,7 +61,7 @@ pub(crate) struct ScriptedBroker { impl ScriptedBroker { /// A broker that plays every round through `script`. pub(crate) fn new( - script: impl Fn(Round, Vec) -> BoxFuture, CompletionError>> + script: impl Fn(Round, Vec) -> BoxFuture, Error>> + Send + Sync + 'static, @@ -102,12 +103,12 @@ impl ScriptedBroker { } impl InferenceBroker for ScriptedBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { self.listings.fetch_add(1, Ordering::SeqCst); match self.listing { Listing::Lists => Box::pin(async { Ok(catalog()) }), - Listing::Fails(kind) => { - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + Listing::Fails(code) => { + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } Listing::Holds => Box::pin(std::future::pending()), } @@ -120,7 +121,7 @@ impl InferenceBroker for ScriptedBroker { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { self.rounds.lock().unwrap().push(round); (self.script)(round, messages) } @@ -142,7 +143,7 @@ fn catalog() -> ModelCatalog { } /// A text completion of `text` from the scripted model. -pub(crate) fn reply(text: impl Into) -> Result, CompletionError> { +pub(crate) fn reply(text: impl Into) -> Result, Error> { Completion::from_result(CompletionResult::Text(text.into()), MODEL).map(Box::new) } @@ -221,10 +222,10 @@ fn construct_hold( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let held = services .get(&HELD) - .ok_or_else(|| ToolError::message("the hold Pack needs tests/held"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "the hold Pack needs tests/held"))?; let info = ToolInfo::new( ToolId::parse(&format!("{name}/hold")).unwrap(), "Hold until the call is dropped.", @@ -246,7 +247,7 @@ impl Pack for HoldPack { &'a self, _cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.held.hold() } } @@ -303,12 +304,12 @@ impl Operator { #[async_trait::async_trait] impl AskBroker for Operator { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { self.asked.fetch_add(1, Ordering::SeqCst); let mut unanswered = Unanswered(Some(Arc::clone(&self.abandoned))); let text = self.answers.lock().await.recv().await; unanswered.0 = None; - text.ok_or_else(|| ToolError::message("the operator left")) + text.ok_or_else(|| tool_error(ErrorCode::ToolOther, "the operator left")) } } diff --git a/crates/harness-internal/runner/tests/it/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 8e667b24f..f5f042a84 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -10,15 +10,16 @@ use std::time::Duration; use harness_runner::performers::{BoxFuture, InferenceBroker, Performers, Timer, ToolPerformer}; use harness_runner::recorder::{ - MemoryRecorder, Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, + MemoryRecorder, Record, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error, }; use runvil::cancel::CancelHandle; use runvil::effect::{Round, ToolCallOrigin}; +use runvil::error::Error; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, }; use runvil::timestamp::Timestamp; -use runvil::tools::{ToolCatalog, ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; use runvil::vfs::{ Access, AcquireContext, Entry, ExecId, MemoryBackend, Stat, Vfs, VfsAccess, VfsError, VfsPath, VfsRef, @@ -127,7 +128,7 @@ pub(crate) const TIMED_MAIN: &str = "local t = tasks.spawn('## Child')\n\ pub(crate) struct Unused; impl InferenceBroker for Unused { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { unreachable!("the effect loop never lists models") } @@ -138,7 +139,7 @@ impl InferenceBroker for Unused { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { unreachable!("no test issues a Chat effect") } } @@ -151,7 +152,7 @@ impl ToolPerformer for Unused { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { unreachable!("this test issues no ToolCall effect") } } @@ -184,7 +185,7 @@ impl ToolPerformer for TextTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let text = self.0; Box::pin(async move { Ok(ToolOutput::trusted(text)) }) } @@ -201,7 +202,7 @@ impl ToolPerformer for PendingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { Box::pin(std::future::pending()) } } @@ -218,9 +219,9 @@ impl ToolPerformer for PanickingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { Box::pin(std::future::poll_fn( - |_cx| -> Poll> { + |_cx| -> Poll> { panic!("the tool performer panics instead of answering") }, )) @@ -242,7 +243,7 @@ impl ToolPerformer for ClosingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let recorder = Arc::clone(&self.recorder); let run_id = self.run_id; Box::pin(async move { @@ -300,10 +301,10 @@ impl FailingRecorder { &self.inner } - fn count(&self) -> Result<(), RecorderError> { + fn count(&self) -> Result<(), runvil::error::Error> { let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call == self.fail_on { - return Err(RecorderError::new(format!( + return Err(recorder_error(format!( "the fixture recorder refuses call {call}" ))); } @@ -377,7 +378,7 @@ impl ToolPerformer for GatedTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let gate = Arc::clone(&self.gate); Box::pin(async move { gate.acquire() diff --git a/crates/harness-internal/runner/tests/it/tool_context.rs b/crates/harness-internal/runner/tests/it/tool_context.rs index b4a9372e3..d582e3f52 100644 --- a/crates/harness-internal/runner/tests/it/tool_context.rs +++ b/crates/harness-internal/runner/tests/it/tool_context.rs @@ -8,9 +8,10 @@ use std::sync::Arc; use harness_runner::effect_loop::drive_run; use harness_runner::recorder::{MemoryRecorder, RunOutcome}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolError, ToolId, ToolInfo, - ToolOutput, + HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -43,7 +44,7 @@ fn construct( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let record = ToolInfo::new( ToolId::parse(&format!("{name}/record")).unwrap(), "Write a file through the call's access and name the caller.", @@ -63,11 +64,11 @@ impl Pack for Record { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { cx.access() .write("/from-tool.md", WRITTEN.as_bytes()) - .map_err(|error| ToolError::message(format!("record: {error}")))?; + .map_err(|error| tool_error(ErrorCode::ToolOther, format!("record: {error}")))?; Ok(ToolOutput::trusted(format!("{:?}", cx.origin().caller))) }) } diff --git a/crates/harness-internal/test-plugin/tests/it/insert.rs b/crates/harness-internal/test-plugin/tests/it/insert.rs index 18ba63a30..829d852af 100644 --- a/crates/harness-internal/test-plugin/tests/it/insert.rs +++ b/crates/harness-internal/test-plugin/tests/it/insert.rs @@ -7,7 +7,7 @@ use std::io; use std::path::Path; use harness_plugin_loader::{PluginReport, PluginSource, insert_plugin_dir}; -use runvil_plugin::PackRegistry; +use runvil_plugin::{Error, FactKey, PackRegistry}; use crate::support::{copy_fixture, remove, scratch}; @@ -21,6 +21,19 @@ fn library_source(path: &Path) -> PluginSource { PluginSource::Library(std::path::absolute(path).expect("the working directory is known")) } +/// The fact `key` of a load failure, as text. +fn fact(failure: &Error, key: FactKey) -> Option<&str> { + failure.facts.get(&key)?.as_str() +} + +/// The `Path` fact a failure of the library at `path` holds. +fn library_path(path: &Path) -> String { + std::path::absolute(path) + .expect("the working directory is known") + .display() + .to_string() +} + #[test] fn insert_plugin_dir_loads_the_fixture() { let dir = scratch("insert_plugin_dir_loads_the_fixture"); @@ -71,7 +84,8 @@ fn a_garbage_library_fails_at_insert_and_the_fixture_beside_it_still_loads() { let [failure] = report.failures() else { panic!("one file fails, got {:?}", report.failures()); }; - let reason = failure.reason(); + let reason = &failure.message; + assert_eq!(fact(failure, FactKey::Reason), Some("open")); let cause = reason .strip_prefix("the library could not be opened: ") .unwrap_or_else(|| panic!("the reason starts with the open failure: {reason}")); @@ -86,7 +100,10 @@ fn a_garbage_library_fails_at_insert_and_the_fixture_beside_it_still_loads() { ); #[cfg(not(windows))] assert!(!cause.is_empty(), "{reason}"); - assert_eq!(failure.source(), &library_source(&garbage)); + assert_eq!( + fact(failure, FactKey::Path), + Some(library_path(&garbage).as_str()) + ); let [plugin] = report.plugins() else { panic!("the fixture loads, got {:?}", report.plugins()); }; @@ -122,10 +139,15 @@ fn a_second_copy_with_the_same_plugin_name_fails_as_a_duplicate() { panic!("the repeat fails, got {:?}", report.failures()); }; assert_eq!( - failure.reason(), + failure.message, "a Plugin named test-plugin was already loaded from this directory" ); - assert_eq!(failure.source(), &library_source(&second)); + assert_eq!(fact(failure, FactKey::Reason), Some("duplicate")); + assert_eq!(fact(failure, FactKey::Name), Some("test-plugin")); + assert_eq!( + fact(failure, FactKey::Path), + Some(library_path(&second).as_str()) + ); drop(report); remove(&dir); } diff --git a/crates/harness/examples/run-prompt.rs b/crates/harness/examples/run-prompt.rs index 0b62376c0..ed90394a1 100644 --- a/crates/harness/examples/run-prompt.rs +++ b/crates/harness/examples/run-prompt.rs @@ -12,9 +12,10 @@ use harness::record::{MemoryRecorder, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest, Timer}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; /// Reads the line in `name.txt` and writes its greeting to `reply.txt`. @@ -31,7 +32,7 @@ const GREET: &str = concat!( struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -42,9 +43,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index d06f3beec..8af777121 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -38,7 +38,6 @@ pub mod pack { pub use harness_runner::HostContext; pub use runvil_plugin::HostServices; - pub use runvil_plugin::InstallError; pub use runvil_plugin::PackId; pub use runvil_plugin::PackOrigin; pub use runvil_plugin::PackRegistry; @@ -50,7 +49,6 @@ pub mod plugin { //! loaded. pub use harness_plugin_loader::Plugin; - pub use harness_plugin_loader::PluginFailure; pub use harness_plugin_loader::PluginReport; pub use harness_plugin_loader::PluginSource; pub use harness_plugin_loader::insert_plugin; @@ -65,13 +63,13 @@ pub mod record { pub use harness_runner::recorder::MemoryRecorder; pub use harness_runner::recorder::Record; pub use harness_runner::recorder::RecordKind; - pub use harness_runner::recorder::RecorderError; pub use harness_runner::recorder::RecorderFuture; pub use harness_runner::recorder::RunErrorKind; pub use harness_runner::recorder::RunId; pub use harness_runner::recorder::RunMeta; pub use harness_runner::recorder::RunOutcome; pub use harness_runner::recorder::RunRecorder; + pub use harness_runner::recorder::recorder_error; pub use runvil::RunSeed; } diff --git a/crates/harness/tests/suite/asker.rs b/crates/harness/tests/suite/asker.rs index db20d2e00..8c5d3fa0e 100644 --- a/crates/harness/tests/suite/asker.rs +++ b/crates/harness/tests/suite/asker.rs @@ -1,15 +1,17 @@ //! The fixture ask Pack a suite Host builds in place of a real //! user-input Pack, which Harness crates may not name: an ask tool that //! survives stops, a prelude defining `input.ask()`, a need for the run's -//! broker, and a broker that fails with a `ToolError`. A prompt declares +//! broker, and a broker that fails with a tool error. A prompt declares //! it as `tests/user-input`. use std::sync::Arc; use harness::pack::{HostContext, HostServices, PackRegistry}; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use runvil_plugin::{ - Pack, PackFuture, PackId, PackInfo, ServiceId, ServiceKey, ToolContext, ToolError, - ToolErrorKind, ToolId, ToolInfo, ToolOutput, + Pack, PackFuture, PackId, PackInfo, ServiceId, ServiceKey, ToolContext, ToolId, ToolInfo, + ToolOutput, }; use serde_json::{Value, json}; @@ -36,7 +38,7 @@ end /// Delivers the operator's answer to the fixture's ask tool. #[async_trait::async_trait] pub(crate) trait AskBroker: Send + Sync { - async fn wait(&self) -> Result; + async fn wait(&self) -> Result; } /// A Host that built the fixture, as `user-input`. @@ -65,13 +67,13 @@ fn construct( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let id = ToolId::parse(&format!("{name}/ask")).map_err(|e| { - ToolError::new( - ToolErrorKind::Backend, + tool_error( + ErrorCode::ToolBackend, "the fixture could not name its ask tool", ) - .with_source(e) + .with_cause(&e) })?; let ask = ToolInfo::new( id, @@ -91,11 +93,11 @@ impl Pack for Asker { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { - let broker = cx - .service(&BROKER) - .ok_or_else(|| ToolError::message("no operator is connected to this run"))?; + let broker = cx.service(&BROKER).ok_or_else(|| { + tool_error(ErrorCode::ToolOther, "no operator is connected to this run") + })?; broker.wait().await.map(ToolOutput::trusted) }) } diff --git a/crates/harness/tests/suite/broker.rs b/crates/harness/tests/suite/broker.rs index 81cf5ee64..654935d7c 100644 --- a/crates/harness/tests/suite/broker.rs +++ b/crates/harness/tests/suite/broker.rs @@ -10,11 +10,12 @@ use harness::record::{MemoryRecorder, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use runvil::effect::Round; +use runvil::error::Error; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, - ModelCatalog, ModelId, ModelInfo, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ThinkingMode, ToolSchema, }; use crate::support::Clock; @@ -42,7 +43,7 @@ struct ScriptedBroker { } impl InferenceBroker for ScriptedBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -54,7 +55,7 @@ impl InferenceBroker for ScriptedBroker { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { self.rounds .lock() .unwrap_or_else(PoisonError::into_inner) diff --git a/crates/harness/tests/suite/host.rs b/crates/harness/tests/suite/host.rs index 08ecc7c99..134106ce1 100644 --- a/crates/harness/tests/suite/host.rs +++ b/crates/harness/tests/suite/host.rs @@ -17,10 +17,9 @@ use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use runvil::effect::Round; use runvil::event::ReplyOrigin; use runvil::model::{ - Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, - ModelCatalog, ModelId, ModelInfo, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ThinkingMode, ToolSchema, }; -use runvil_plugin::ToolError; use crate::asker::{AskBroker, asker_host, broker_services}; use crate::support::{Clock, Offline}; @@ -85,7 +84,7 @@ struct Operator(&'static str); #[async_trait] impl AskBroker for Operator { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Ok(self.0.to_owned()) } } @@ -122,7 +121,7 @@ struct Stuck { } impl InferenceBroker for Stuck { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -134,7 +133,7 @@ impl InferenceBroker for Stuck { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { self.started.raise(); Box::pin(std::future::pending()) } @@ -167,7 +166,7 @@ struct Streaming { } impl InferenceBroker for Streaming { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -179,7 +178,7 @@ impl InferenceBroker for Streaming { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { let window = (round.origin == ReplyOrigin::Chat).then(|| self.window.clone()); let said = messages .last() diff --git a/crates/harness/tests/suite/support.rs b/crates/harness/tests/suite/support.rs index 40a1469de..26dc1f956 100644 --- a/crates/harness/tests/suite/support.rs +++ b/crates/harness/tests/suite/support.rs @@ -5,16 +5,17 @@ use std::time::Duration; use harness::{BoxFuture, InferenceBroker, Timer}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; /// Lists no model and refuses every round as `Unavailable`. pub(crate) struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -25,9 +26,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/harness/tests/suite/vfs.rs b/crates/harness/tests/suite/vfs.rs index 0c21664d2..3533391db 100644 --- a/crates/harness/tests/suite/vfs.rs +++ b/crates/harness/tests/suite/vfs.rs @@ -9,8 +9,8 @@ use async_trait::async_trait; use harness::record::MemoryRecorder; use harness::vfs::{Origin, VfsError, VfsRef}; use harness::{Harness, HostSnapshot, RunRequest}; +use runvil::error::Error; use runvil::vfs::{MemoryBackend, Op, Policy, Verdict, VfsPath}; -use runvil_plugin::ToolError; use crate::asker::{AskBroker, asker_host, broker_services}; use crate::support::{Clock, Offline}; @@ -89,7 +89,7 @@ struct Edit { #[async_trait] impl AskBroker for Edit { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { let edit = self .vfs .acquire_store(Origin::new("desk edit")) diff --git a/crates/plugin-mcp/src/dynamic.rs b/crates/plugin-mcp/src/dynamic.rs index a1f558639..75615f5fe 100644 --- a/crates/plugin-mcp/src/dynamic.rs +++ b/crates/plugin-mcp/src/dynamic.rs @@ -7,8 +7,8 @@ use std::collections::BTreeMap; use rmcp::model::JsonObject; use rmcp::service::{Peer, RoleClient}; use runvil_plugin::{ - Pack, PackFuture, PackId, ToolCaller, ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, - ToolOutput, + Error, ErrorCode, Pack, PackFuture, PackId, ToolCaller, ToolContext, ToolId, ToolInfo, + ToolOutput, tool_error, }; use serde_json::{Map, Value, json}; use tokio::runtime::Handle; @@ -81,13 +81,13 @@ fn marked(caller: ToolCaller, text: String) -> ToolOutput { } } -fn invalid(text: impl Into) -> ToolError { - ToolError::message(text).with_kind(ToolErrorKind::InvalidArguments) +fn invalid(text: impl Into) -> Error { + tool_error(ErrorCode::ToolInvalidArguments, text) } /// The members of a tool's arguments, which must be a JSON object or /// `null`. -fn members(args: Value, refusal: &str) -> Result, ToolError> { +fn members(args: Value, refusal: &str) -> Result, Error> { match args { Value::Null => Ok(Map::new()), Value::Object(members) => Ok(members), @@ -96,7 +96,7 @@ fn members(args: Value, refusal: &str) -> Result, ToolError> } /// The string at `key`, or `None` when it is absent or `null`. -fn string<'a>(members: &'a Map, key: &str) -> Result, ToolError> { +fn string<'a>(members: &'a Map, key: &str) -> Result, Error> { match members.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(text)) => Ok(Some(text)), @@ -138,11 +138,10 @@ impl Mcp { name: &PackId, namespaces: BTreeMap, runtime: Handle, - ) -> Result { + ) -> Result { let id = |tool: &str| { ToolId::parse(&format!("{name}/{tool}")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "MCP could not name its tools") - .with_source(e) + tool_error(ErrorCode::ToolBackend, "MCP could not name its tools").with_cause(&e) }) }; let get = ToolInfo::new( @@ -194,7 +193,7 @@ impl Mcp { } /// The server under `name`, or an error that says how to list them. - fn namespace(&self, name: &str) -> Result<&Namespace, ToolError> { + fn namespace(&self, name: &str) -> Result<&Namespace, Error> { self.namespaces.get(name).ok_or_else(|| { invalid(format!( "no configured MCP server has the namespace `{name}`; call {} with no arguments \ @@ -204,7 +203,7 @@ impl Mcp { }) } - fn unknown_tool(&self, namespace: &str, tool: &str) -> ToolError { + fn unknown_tool(&self, namespace: &str, tool: &str) -> Error { invalid(format!( "the MCP server `{namespace}` has no tool `{tool}`; call {} with namespace \ `{namespace}` to list its tools", @@ -213,7 +212,7 @@ impl Mcp { } /// Answers `get_dynamic_tools` as JSON text. - fn list(&self, args: Value) -> Result { + fn list(&self, args: Value) -> Result { let members = members( args, "get_dynamic_tools takes a JSON object with the optional strings `namespace` and `toolName`", @@ -259,7 +258,7 @@ impl Mcp { /// The peer and tool name a `call_dynamic_tool` goes to, or why it /// cannot go out. - fn target(&self, namespace: &str, tool: &str) -> Result<(Peer, String), ToolError> { + fn target(&self, namespace: &str, tool: &str) -> Result<(Peer, String), Error> { self.namespace(namespace)?.with_status(|status| match status { Status::Ready(ready) => ready .tools @@ -267,19 +266,23 @@ impl Mcp { .find(|listed| listed.name == tool) .map(|listed| (ready.peer.clone(), listed.name.clone())) .ok_or_else(|| self.unknown_tool(namespace, tool)), - Status::Loading => Err(ToolError::message(format!( - "the MCP server `{namespace}` is still loading; call {} later to see when it is ready", - self.get.id - ))), - Status::Error(reason) => Err(ToolError::message(format!( - "the MCP server `{namespace}` is unavailable: {reason}" - ))), + Status::Loading => Err(tool_error( + ErrorCode::ToolOther, + format!( + "the MCP server `{namespace}` is still loading; call {} later to see when it is ready", + self.get.id + ), + )), + Status::Error(reason) => Err(tool_error( + ErrorCode::ToolOther, + format!("the MCP server `{namespace}` is unavailable: {reason}"), + )), }) } /// Performs `call_dynamic_tool` and returns the result's text. Every /// argument is checked before anything is sent. - async fn call_tool(&self, args: Value) -> Result { + async fn call_tool(&self, args: Value) -> Result { let mut members = members( args, "call_dynamic_tool takes a JSON object with the strings `namespace` and `toolName` \ @@ -312,7 +315,7 @@ impl Pack for Mcp { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let tool = cx.tool(); let caller = cx.origin().caller; @@ -321,7 +324,10 @@ impl Pack for Mcp { } else if *tool == self.call.id { self.call_tool(args).await.map(|text| marked(caller, text)) } else { - Err(ToolError::message(format!("MCP has no tool {tool}"))) + Err(tool_error( + ErrorCode::ToolOther, + format!("MCP has no tool {tool}"), + )) } }) } diff --git a/crates/plugin-mcp/src/entry.rs b/crates/plugin-mcp/src/entry.rs index 5d6ed4465..d5c1c3407 100644 --- a/crates/plugin-mcp/src/entry.rs +++ b/crates/plugin-mcp/src/entry.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt; use reqwest::header::{HeaderName, HeaderValue}; -use runvil_plugin::ToolError; +use runvil_plugin::{Error, ErrorCode, tool_error}; use serde_json::{Map, Value}; /// The reason every local entry is refused with, until local servers @@ -41,8 +41,8 @@ pub(crate) struct Vars<'a> { pub(crate) home: Option, } -fn refuse(text: impl Into) -> ToolError { - ToolError::message(text) +fn refuse(text: impl Into) -> Error { + tool_error(ErrorCode::ToolOther, text) } /// Parses `config` into a remote entry. @@ -52,7 +52,7 @@ fn refuse(text: impl Into) -> ToolError { /// both shapes or neither, a wrong value type, the legacy `sse` type, an /// unset `${env:NAME}`, and a header rmcp owns or that is not a valid /// header. No message holds an entry value. -pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result { +pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result { let entry = config.as_object().ok_or_else(|| { refuse("an MCP entry must be a JSON object with `url`, or with `command`") })?; @@ -73,7 +73,7 @@ pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result) -> Result<(), ToolError> { +fn check_local_shape(entry: &Map) -> Result<(), Error> { string_field(entry, "command")?; if let Some(args) = entry.get("args") { let all_strings = args @@ -90,7 +90,7 @@ fn check_local_shape(entry: &Map) -> Result<(), ToolError> { Ok(()) } -fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result { +fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result { if entry.get("type").and_then(Value::as_str) == Some("sse") { return Err(refuse( "type `sse` is the legacy MCP transport, and only Streamable HTTP is supported", @@ -118,7 +118,7 @@ fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result) -> Result<(HeaderName, HeaderValue), ToolError> { +fn header(key: &str, raw: &str, vars: &Vars<'_>) -> Result<(HeaderName, HeaderValue), Error> { let name = HeaderName::from_bytes(key.as_bytes()) .map_err(|_| refuse(format!("header `{key}` is not a valid header name")))?; if RESERVED_HEADERS.contains(&name.as_str()) { @@ -136,15 +136,12 @@ fn header(key: &str, raw: &str, vars: &Vars<'_>) -> Result<(HeaderName, HeaderVa Ok((name, value)) } -fn wrong_type(field: &str, wanted: &str) -> ToolError { +fn wrong_type(field: &str, wanted: &str) -> Error { refuse(format!("`{field}` must be {wanted}")) } /// The string at `field`, or `None` when it is absent. -fn string_field<'a>( - entry: &'a Map, - field: &str, -) -> Result, ToolError> { +fn string_field<'a>(entry: &'a Map, field: &str) -> Result, Error> { match entry.get(field) { None => Ok(None), Some(Value::String(text)) => Ok(Some(text)), @@ -153,7 +150,7 @@ fn string_field<'a>( } /// The key and string value of each member of the object at `value`. -fn string_map<'a>(value: &'a Value, field: &str) -> Result, ToolError> { +fn string_map<'a>(value: &'a Value, field: &str) -> Result, Error> { let members = value .as_object() .ok_or_else(|| wrong_type(field, "an object of strings"))?; diff --git a/crates/plugin-mcp/src/lib.rs b/crates/plugin-mcp/src/lib.rs index 36827b890..a7140f0b3 100644 --- a/crates/plugin-mcp/src/lib.rs +++ b/crates/plugin-mcp/src/lib.rs @@ -107,8 +107,8 @@ use std::collections::BTreeMap; use std::sync::Arc; use runvil_plugin::{ - HostServices, InstallError, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME, - ToolError, + Error, ErrorCode, HostServices, Pack, PackId, PackInfo, PackRegistry, ServiceKey, + TOKIO_RUNTIME, tool_error, }; use serde_json::{Map, Value}; use tokio::runtime::Handle; @@ -131,14 +131,14 @@ const MCP: PackInfo = /// is `null`, meaning no servers, when `config` has no `mcp` key. /// /// # Errors -/// Returns the registry's [`InstallError`], such as -/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/mcp` -/// already. +/// Returns the registry's refusal, such as an +/// [`InvalidValue`](runvil_plugin::ErrorCode::InvalidValue) error with the +/// reason `duplicate_pack` when `packs` holds `runvil/mcp` already. #[expect( clippy::needless_pass_by_value, reason = "the Plugin signature fixes the argument types" )] -pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { +pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> { packs.insert(MCP, config["mcp"].clone()) } @@ -151,19 +151,23 @@ fn construct( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let entries = match config { Value::Null => Map::new(), Value::Object(entries) => entries, _ => { - return Err(ToolError::message( + return Err(tool_error( + ErrorCode::ToolOther, "the MCP configuration must be a JSON object of servers by name, as the \ `mcpServers` object of an mcp.json file is", )); } }; let runtime = services.get(&RUNTIME).ok_or_else(|| { - ToolError::message("MCP needs runvil/tokio-runtime, and this host provides none") + tool_error( + ErrorCode::ToolOther, + "MCP needs runvil/tokio-runtime, and this host provides none", + ) })?; let env = |key: &str| std::env::var(key).ok(); let home = std::env::home_dir().map(|dir| dir.to_string_lossy().into_owned()); diff --git a/crates/plugin-mcp/src/result-tests.rs b/crates/plugin-mcp/src/result-tests.rs index 1e31254b7..6d92150dd 100644 --- a/crates/plugin-mcp/src/result-tests.rs +++ b/crates/plugin-mcp/src/result-tests.rs @@ -4,7 +4,7 @@ use std::time::Duration; use rmcp::ServiceError; use rmcp::model::{CallToolResult, ServerResult}; -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::{Value, json}; use super::{output, render, service_error}; @@ -91,14 +91,14 @@ fn an_is_error_result_is_a_backend_error_with_the_servers_text() { { "type": "text", "text": "no such paper" } ] }))) .expect_err("an isError result fails"); - assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.code, ErrorCode::ToolBackend); assert_eq!(error.to_string(), "no such paper"); } #[test] fn an_is_error_result_without_text_still_says_it_failed() { let error = render(&result(json!({ "isError": true, "content": [] }))).expect_err("fails"); - assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.code, ErrorCode::ToolBackend); assert!(error.to_string().contains("error")); } @@ -115,15 +115,15 @@ fn a_timeout_is_a_transport_error_naming_the_deadline() { let error = service_error(&ServiceError::Timeout { timeout: Duration::from_secs(300), }); - assert_eq!(error.kind(), ToolErrorKind::Transport); + assert_eq!(error.code, ErrorCode::ToolTransport); assert!(error.to_string().contains("300 seconds")); } #[test] fn a_lost_connection_is_a_transport_error() { assert_eq!( - service_error(&ServiceError::TransportClosed).kind(), - ToolErrorKind::Transport + service_error(&ServiceError::TransportClosed).code, + ErrorCode::ToolTransport ); } @@ -140,7 +140,7 @@ fn a_dropped_transport_error_does_not_repeat_the_url_in_its_text() { "error sending request for url (http://host/mcp?key=ghp_secret)".into(), ), )); - assert_eq!(error.kind(), ToolErrorKind::Transport); + assert_eq!(error.code, ErrorCode::ToolTransport); assert!(!error.to_string().contains("ghp_secret"), "{error}"); assert!(!error.to_string().contains("http://"), "{error}"); } @@ -150,14 +150,14 @@ fn a_json_rpc_error_from_the_server_is_a_backend_error_with_its_message() { let error = service_error(&ServiceError::McpError(rmcp::ErrorData::invalid_params( "bad id", None, ))); - assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.code, ErrorCode::ToolBackend); assert!(error.to_string().contains("bad id")); } #[test] fn a_failed_request_maps_through_output_and_a_good_one_renders() { let failed = output(Err(ServiceError::TransportClosed)).expect_err("fails"); - assert_eq!(failed.kind(), ToolErrorKind::Transport); + assert_eq!(failed.code, ErrorCode::ToolTransport); let good = output(Ok(ServerResult::CallToolResult(result( json!({ "content": [{ "type": "text", "text": "fine" }] }), )))) @@ -168,5 +168,5 @@ fn a_failed_request_maps_through_output_and_a_good_one_renders() { #[test] fn an_answer_that_is_not_a_tool_result_is_a_backend_error() { let other = output(Ok(ServerResult::empty(()))).expect_err("not a tool result"); - assert_eq!(other.kind(), ToolErrorKind::Backend); + assert_eq!(other.code, ErrorCode::ToolBackend); } diff --git a/crates/plugin-mcp/src/result.rs b/crates/plugin-mcp/src/result.rs index 0eaae44c1..54178d75b 100644 --- a/crates/plugin-mcp/src/result.rs +++ b/crates/plugin-mcp/src/result.rs @@ -2,7 +2,7 @@ use rmcp::ServiceError; use rmcp::model::{CallToolResult, ContentBlock, ResourceContents, ServerResult}; -use runvil_plugin::{ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, tool_error}; /// What a content block this change does not know becomes. rmcp's content /// types are `#[non_exhaustive]`, so a new kind reaches this text. @@ -23,12 +23,12 @@ pub(crate) fn service_cause(error: &ServiceError) -> &'static str { } } -/// Maps a transport-level failure to a tool error, with the kind that says +/// Maps a transport-level failure to a tool error, with the code that says /// whether retrying could help. -pub(crate) fn service_error(error: &ServiceError) -> ToolError { - let kind = match error { - ServiceError::McpError(_) | ServiceError::UnexpectedResponse => ToolErrorKind::Backend, - _ => ToolErrorKind::Transport, +pub(crate) fn service_error(error: &ServiceError) -> Error { + let code = match error { + ServiceError::McpError(_) | ServiceError::UnexpectedResponse => ErrorCode::ToolBackend, + _ => ErrorCode::ToolTransport, }; let text = match error { ServiceError::Timeout { timeout } => format!( @@ -38,23 +38,23 @@ pub(crate) fn service_error(error: &ServiceError) -> ToolError { ServiceError::McpError(e) => format!("the MCP server refused the call: {}", e.message), other => format!("the MCP call failed: {}", service_cause(other)), }; - ToolError::message(text).with_kind(kind) + tool_error(code, text) } /// Maps the answer to a `tools/call` request to its text. -pub(crate) fn output(response: Result) -> Result { +pub(crate) fn output(response: Result) -> Result { match response.map_err(|e| service_error(&e))? { ServerResult::CallToolResult(result) => render(&result), - _ => Err(ToolError::message( + _ => Err(tool_error( + ErrorCode::ToolBackend, "the MCP server answered a tool call with something other than a tool result", - ) - .with_kind(ToolErrorKind::Backend)), + )), } } /// Joins a result's text. An `is_error` result becomes a backend error that /// carries the server's text. -pub(crate) fn render(result: &CallToolResult) -> Result { +pub(crate) fn render(result: &CallToolResult) -> Result { let text = text_of(result); if result.is_error == Some(true) { let message = if text.is_empty() { @@ -62,7 +62,7 @@ pub(crate) fn render(result: &CallToolResult) -> Result { } else { text }; - return Err(ToolError::message(message).with_kind(ToolErrorKind::Backend)); + return Err(tool_error(ErrorCode::ToolBackend, message)); } Ok(text) } diff --git a/crates/plugin-mcp/src/server.rs b/crates/plugin-mcp/src/server.rs index 9d3c0d9b2..782a8c24c 100644 --- a/crates/plugin-mcp/src/server.rs +++ b/crates/plugin-mcp/src/server.rs @@ -8,7 +8,7 @@ use rmcp::model::{ RequestId, Tool, }; use rmcp::service::{Peer, PeerRequestOptions, RoleClient}; -use runvil_plugin::{ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, tool_error}; use serde_json::Value; use tokio::runtime::Handle; use tokio::sync::watch; @@ -96,7 +96,7 @@ impl Drop for Server { } /// A spawned call, aborted when the call awaiting it is dropped. -struct AbortOnDrop(JoinHandle>); +struct AbortOnDrop(JoinHandle>); impl Drop for AbortOnDrop { fn drop(&mut self) { @@ -132,7 +132,7 @@ async fn send( name: String, arguments: JsonObject, runtime: Handle, -) -> Result { +) -> Result { let request = ClientRequest::CallToolRequest(CallToolRequest::new( CallToolRequestParams::new(name).with_arguments(arguments), )); @@ -157,15 +157,14 @@ pub(crate) async fn call( peer: Peer, name: String, arguments: JsonObject, -) -> Result { +) -> Result { let mut task = AbortOnDrop(runtime.spawn(send(peer, name, arguments, runtime.clone()))); match (&mut task.0).await { Ok(outcome) => outcome, - Err(join) => Err(ToolError::new( - ToolErrorKind::Transport, - "the MCP call task did not finish", - ) - .with_source(join)), + Err(join) => Err( + tool_error(ErrorCode::ToolTransport, "the MCP call task did not finish") + .with_cause(&join), + ), } } diff --git a/crates/plugin-mcp/tests/it/calls.rs b/crates/plugin-mcp/tests/it/calls.rs index 8e0c1a7bb..71e54d798 100644 --- a/crates/plugin-mcp/tests/it/calls.rs +++ b/crates/plugin-mcp/tests/it/calls.rs @@ -5,7 +5,7 @@ use std::time::Duration; use runvil_plugin::testing::TestCall; -use runvil_plugin::{OutputTrust, ToolErrorKind, ToolId}; +use runvil_plugin::{ErrorCode, OutputTrust, ToolId}; use serde_json::json; use crate::fixture::Fixture; @@ -53,7 +53,7 @@ async fn a_script_calls_is_error_result_is_the_same_backend_error_as_a_models() ) .await .expect_err("isError fails"); - assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.code, ErrorCode::ToolBackend); assert_eq!(error.to_string(), "it failed"); } @@ -83,7 +83,7 @@ async fn an_is_error_result_becomes_a_backend_error() { let error = call(&pack, "GitHub", "fail", json!({})) .await .expect_err("isError fails"); - assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.code, ErrorCode::ToolBackend); assert_eq!(error.to_string(), "it failed"); } @@ -96,7 +96,7 @@ async fn arguments_that_are_not_an_object_are_refused_before_anything_is_sent() let error = call(&pack, "GitHub", "Echo", json!([1])) .await .expect_err("refused"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert_eq!( error.to_string(), "MCP tool arguments must be a JSON object" @@ -110,7 +110,7 @@ async fn arguments_that_are_not_an_object_are_refused_before_anything_is_sent() let error = invoke(&pack, "call_dynamic_tool", args) .await .expect_err("refused"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments, "{error}"); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments, "{error}"); } assert!(!fixture.methods().contains(&"tools/call".to_owned())); } @@ -124,12 +124,12 @@ async fn an_unknown_namespace_or_tool_is_refused_without_a_call() { let error = call(&pack, "github", "Echo", json!({})) .await .expect_err("namespaces match exactly"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert!(error.to_string().contains("namespace `github`"), "{error}"); let error = call(&pack, "GitHub", "echo", json!({})) .await .expect_err("tool names match exactly"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert!(error.to_string().contains("no tool `echo`"), "{error}"); assert!(!fixture.methods().contains(&"tools/call".to_owned())); } diff --git a/crates/plugin-mcp/tests/it/listing.rs b/crates/plugin-mcp/tests/it/listing.rs index a557a993a..44f815f44 100644 --- a/crates/plugin-mcp/tests/it/listing.rs +++ b/crates/plugin-mcp/tests/it/listing.rs @@ -3,7 +3,7 @@ use std::time::Duration; -use runvil_plugin::{OutputTrust, ToolErrorKind}; +use runvil_plugin::{ErrorCode, OutputTrust}; use serde_json::{Value, json}; use crate::fixture::Fixture; @@ -177,27 +177,27 @@ async fn a_tool_name_without_a_namespace_an_unknown_namespace_and_an_unknown_too }; let error = refused(json!({ "toolName": "Echo" })).await; - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert_eq!( error.to_string(), "`toolName` names a tool of one server, so it needs `namespace` too" ); let error = refused(json!({ "namespace": "github" })).await; - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert_eq!( error.to_string(), "no configured MCP server has the namespace `github`; call mcp/get_dynamic_tools with \ no arguments to list the namespaces" ); let error = refused(json!({ "namespace": "GitHub", "toolName": "echo" })).await; - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert_eq!( error.to_string(), "the MCP server `GitHub` has no tool `echo`; call mcp/get_dynamic_tools with namespace \ `GitHub` to list its tools" ); let error = refused(json!({ "namespace": 1 })).await; - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); let error = refused(json!(["GitHub"])).await; - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } diff --git a/crates/plugin-mcp/tests/it/support.rs b/crates/plugin-mcp/tests/it/support.rs index eab5c40ab..708ad0467 100644 --- a/crates/plugin-mcp/tests/it/support.rs +++ b/crates/plugin-mcp/tests/it/support.rs @@ -7,8 +7,8 @@ use std::time::Duration; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, OutputTrust, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME, - ToolCaller, ToolError, ToolId, ToolOutput, + Error, HostServices, OutputTrust, Pack, PackId, PackInfo, PackRegistry, ServiceKey, + TOKIO_RUNTIME, ToolCaller, ToolId, ToolOutput, }; use serde_json::{Map, Value, json}; use tokio::net::TcpListener; @@ -44,10 +44,7 @@ pub(crate) fn inserted(config: Value) -> Vec<(PackInfo, PackId, Value)> { /// Builds the Pack `install` inserts, under the Pack id and with the config /// the registry holds it with, from the server map `servers`. -pub(crate) fn construct( - servers: Value, - services: &HostServices, -) -> Result, ToolError> { +pub(crate) fn construct(servers: Value, services: &HostServices) -> Result, Error> { let config = Map::from_iter([("mcp".to_owned(), servers)]); let mut inserted = inserted(Value::Object(config)); let (info, id, config) = inserted.pop().expect("install inserts one Pack"); @@ -70,11 +67,7 @@ fn tool_call(tool: &str) -> TestCall { /// Calls the Pack's tool `mcp/` with `args` from a model round and /// returns its text, checking that the output is untrusted. -pub(crate) async fn invoke( - pack: &Arc, - tool: &str, - args: Value, -) -> Result { +pub(crate) async fn invoke(pack: &Arc, tool: &str, args: Value) -> Result { let test = tool_call(tool).with_caller(ToolCaller::Model); let output = pack.call(test.context(), args).await?; assert_eq!( @@ -91,7 +84,7 @@ pub(crate) async fn invoke_as_script( pack: &Arc, tool: &str, args: Value, -) -> Result { +) -> Result { pack.call(tool_call(tool).context(), args).await } @@ -110,7 +103,7 @@ pub(crate) async fn call( namespace: &str, tool: &str, arguments: Value, -) -> Result { +) -> Result { invoke( pack, "call_dynamic_tool", diff --git a/crates/plugin-std/src/check-tests.rs b/crates/plugin-std/src/check-tests.rs index cfe83e58a..46f531818 100644 --- a/crates/plugin-std/src/check-tests.rs +++ b/crates/plugin-std/src/check-tests.rs @@ -1,7 +1,7 @@ //! Argument checking: a matching call passes, and each kind of mismatch //! fails naming the parameter. -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::json; use super::{check, invalid_pattern, plural, refuse_blank, refuse_unknown}; @@ -22,12 +22,12 @@ fn replace() -> serde_json::Value { }) } -/// The refusal of `args` against `schema`, as the message and kind. -fn refused(schema: &serde_json::Value, args: &serde_json::Value) -> (String, ToolErrorKind) { +/// The refusal of `args` against `schema`, as the message and code. +fn refused(schema: &serde_json::Value, args: &serde_json::Value) -> (String, ErrorCode) { let Err(error) = check("StrReplace", schema, args) else { panic!("{args} is refused"); }; - (error.to_string(), error.kind()) + (error.to_string(), error.code) } #[test] @@ -57,7 +57,7 @@ fn a_missing_required_parameter_fails_naming_it() { message, "std_StrReplace: `new_string` is required, and the call left it out" ); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); } #[test] @@ -68,7 +68,7 @@ fn a_required_parameter_passed_as_null_fails_naming_it() { message, "std_StrReplace: `path` is required, and the call passed null" ); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); } #[test] @@ -79,7 +79,7 @@ fn a_parameter_of_the_wrong_type_fails_naming_it_and_the_value() { message, "std_StrReplace: `new_string` must be a JSON string, and the call passed 2" ); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); } #[test] @@ -90,7 +90,7 @@ fn arguments_that_are_not_an_object_fail() { message, "std_StrReplace: the arguments must be a JSON object" ); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); } #[test] @@ -106,7 +106,7 @@ fn a_value_outside_an_enum_fails_naming_the_allowed_values() { message, "std_StrReplace: `output_mode` must be one of \"content\", \"count\", and the call passed \"files\"" ); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); } #[test] @@ -165,7 +165,7 @@ fn an_undeclared_key_is_refused_naming_it_and_listing_the_parameters_sorted() { "std_Read: `lines` is not a parameter of this tool. \ The parameters are `limit`, `offset` and `path`." ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -258,7 +258,7 @@ fn a_blank_value_fails_naming_the_parameter_and_echoing_the_value() { error.to_string(), "std_Read: `path` is empty or only whitespace, and the call passed \" \"" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -299,7 +299,7 @@ fn a_bad_pattern_names_the_parameter_echoes_the_pattern_and_gives_the_reason() { "std_ListDir: `ignore_globs` is invalid: \"a***\" does not parse: \ more than two consecutive '*' are not supported." ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] diff --git a/crates/plugin-std/src/check.rs b/crates/plugin-std/src/check.rs index 658f9d970..7863bbed2 100644 --- a/crates/plugin-std/src/check.rs +++ b/crates/plugin-std/src/check.rs @@ -1,7 +1,7 @@ //! Argument checking against a tool's own JSON Schema, and the helpers //! the file tools share to refuse what the schema alone cannot. -use runvil_plugin::{ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, tool_error}; use serde_json::Value; use crate::matcher::PatternError; @@ -16,9 +16,9 @@ use crate::text::clip_to_boundary; /// call, because the tool's own body decides what it can do with the value. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter and +/// Returns an invalid-arguments [`Error`] naming the parameter and /// what it expected. -pub(crate) fn check(tool: &str, schema: &Value, args: &Value) -> Result<(), ToolError> { +pub(crate) fn check(tool: &str, schema: &Value, args: &Value) -> Result<(), Error> { let args = if args.is_null() { &Value::Object(serde_json::Map::new()) } else { @@ -62,7 +62,7 @@ pub(crate) fn check(tool: &str, schema: &Value, args: &Value) -> Result<(), Tool } /// Checks one parameter's `value` against its `property` schema. -fn check_value(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), ToolError> { +fn check_value(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), Error> { if let Some(allowed) = property.get("enum").and_then(Value::as_array) && !allowed.contains(value) { @@ -104,7 +104,7 @@ fn check_value(tool: &str, name: &str, property: &Value, value: &Value) -> Resul /// Checks every element of `value`, when it is an array with an `items` /// schema. -fn check_items(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), ToolError> { +fn check_items(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), Error> { let (Some(items), Some(elements)) = ( property.get("items"), value.as_array().filter(|_| property.get("items").is_some()), @@ -128,8 +128,8 @@ fn check_items(tool: &str, name: &str, property: &Value, value: &Value) -> Resul /// `properties`, and arguments that are not an object, refuse nothing. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the key. -pub(crate) fn refuse_unknown(tool: &str, schema: &Value, args: &Value) -> Result<(), ToolError> { +/// Returns an invalid-arguments [`Error`] naming the key. +pub(crate) fn refuse_unknown(tool: &str, schema: &Value, args: &Value) -> Result<(), Error> { let (Some(properties), Some(object)) = ( schema.get("properties").and_then(Value::as_object), args.as_object(), @@ -164,8 +164,8 @@ pub(crate) fn refuse_unknown(tool: &str, schema: &Value, args: &Value) -> Result /// filesystem. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter. -pub(crate) fn refuse_blank(tool: &str, name: &str, value: &str) -> Result<(), ToolError> { +/// Returns an invalid-arguments [`Error`] naming the parameter. +pub(crate) fn refuse_blank(tool: &str, name: &str, value: &str) -> Result<(), Error> { if value.trim().is_empty() { return Err(invalid_arguments( tool, @@ -184,12 +184,7 @@ const PATTERN_ECHO_BYTES: usize = 80; /// The error echoes the pattern, cut to 80 bytes on a character boundary /// with `...` added when it was cut, so a long pattern does not fill the /// message. -pub(crate) fn invalid_pattern( - tool: &str, - name: &str, - pattern: &str, - error: PatternError, -) -> ToolError { +pub(crate) fn invalid_pattern(tool: &str, name: &str, pattern: &str, error: PatternError) -> Error { let shown = clip_to_boundary(pattern, 0, PATTERN_ECHO_BYTES); let dots = if shown.len() < pattern.len() { "..." @@ -226,9 +221,9 @@ pub(crate) fn plural(n: usize, noun: &str) -> String { /// An invalid-arguments error whose message opens with the tool's id /// segment. -pub(crate) fn invalid_arguments(tool: &str, reason: impl std::fmt::Display) -> ToolError { - ToolError::new( - ToolErrorKind::InvalidArguments, +pub(crate) fn invalid_arguments(tool: &str, reason: impl std::fmt::Display) -> Error { + tool_error( + ErrorCode::ToolInvalidArguments, format!("std_{tool}: {reason}"), ) } diff --git a/crates/plugin-std/src/dir.rs b/crates/plugin-std/src/dir.rs index 6b20d4709..dc3a985a8 100644 --- a/crates/plugin-std/src/dir.rs +++ b/crates/plugin-std/src/dir.rs @@ -15,7 +15,7 @@ mod walk; #[cfg(test)] mod tests; -use runvil_plugin::{Access, FileType, ToolError, ToolOutput, VfsError}; +use runvil_plugin::{Access, Error, ErrorCode, FileType, ToolOutput, VfsError, tool_error}; use serde_json::Value; use crate::refusal::{missing_root, not_a_directory, refusal}; @@ -28,14 +28,14 @@ use self::args::Request; /// schema check. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter for a +/// Returns an invalid-arguments [`Error`] naming the parameter for a /// bad argument, and a message error for any other failure, worded for a /// model to read. pub(crate) fn call( access: &Access, definition: &Definition, args: &Value, -) -> Result { +) -> Result { let request = args::parse(definition, args)?; list_directory(access, &definition.wire_name, &request, BODY_CAP) } @@ -47,17 +47,18 @@ fn list_directory( tool: &str, request: &Request<'_>, cap: usize, -) -> Result { +) -> Result { let target = request.target; - let failed = |error: VfsError| ToolError::message(refusal(tool, target, &error)); + let message = |text: String| tool_error(ErrorCode::ToolOther, text); + let failed = |error: VfsError| message(refusal(tool, target, &error)); let stat = match access.stat(target) { Ok(stat) => stat, - Err(VfsError::NotFound { .. }) => return Err(ToolError::message(missing_root(target))), + Err(VfsError::NotFound { .. }) => return Err(message(missing_root(target))), Err(error) => return Err(failed(error)), }; - check_directory(target, stat.file_type).map_err(ToolError::message)?; + check_directory(target, stat.file_type).map_err(message)?; let tree = walk::walk(target, &request.ignore, cap, |path| access.list(path)) - .map_err(|failure| ToolError::message(refusal(tool, &failure.path, &failure.error)))?; + .map_err(|failure| message(refusal(tool, &failure.path, &failure.error)))?; Ok(ToolOutput::untrusted(render::render(&tree))) } diff --git a/crates/plugin-std/src/dir/args.rs b/crates/plugin-std/src/dir/args.rs index 52abc9ef9..3127fcaf4 100644 --- a/crates/plugin-std/src/dir/args.rs +++ b/crates/plugin-std/src/dir/args.rs @@ -1,7 +1,7 @@ //! ListDir's arguments: what the call asks for, and what is refused before //! any path is looked at. -use runvil_plugin::ToolError; +use runvil_plugin::Error; use serde_json::Value; use crate::check::{invalid_pattern, refuse_blank, refuse_unknown}; @@ -24,12 +24,9 @@ pub(super) struct Request<'a> { /// hears about the same one. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter. A +/// Returns an invalid-arguments [`Error`] naming the parameter. A /// refused pattern is echoed with the rule it broke. -pub(super) fn parse<'a>( - definition: &Definition, - args: &'a Value, -) -> Result, ToolError> { +pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { let tool = definition.wire_name.as_str(); refuse_unknown(tool, &definition.parameters, args)?; let target = args @@ -47,6 +44,6 @@ pub(super) fn parse<'a>( Matcher::new(pattern) .map_err(|error| invalid_pattern(tool, "ignore_globs", pattern, error)) }) - .collect::, ToolError>>()?; + .collect::, Error>>()?; Ok(Request { target, ignore }) } diff --git a/crates/plugin-std/src/dir/tests.rs b/crates/plugin-std/src/dir/tests.rs index 83c6aa5f1..f5139339b 100644 --- a/crates/plugin-std/src/dir/tests.rs +++ b/crates/plugin-std/src/dir/tests.rs @@ -9,7 +9,7 @@ mod ignore; mod tree; use runvil_plugin::testing::TestCall; -use runvil_plugin::{OutputTrust, ToolError, ToolId, ToolOutput}; +use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; use serde_json::Value; use super::args::Request; @@ -45,7 +45,7 @@ fn definition() -> &'static Definition { /// Calls ListDir with `args` as the Pack does: the schema check first, /// then the tool. -fn list(call: &TestCall, args: &Value) -> Result { +fn list(call: &TestCall, args: &Value) -> Result { let definition = definition(); crate::check::check(&definition.wire_name, &definition.parameters, args)?; super::call(call.context().access(), definition, args) @@ -57,7 +57,7 @@ fn list_capped( target: &str, ignore: &[&str], cap: usize, -) -> Result { +) -> Result { let request = Request { target, ignore: ignore @@ -69,14 +69,14 @@ fn list_capped( } /// The text of a successful listing, which must be untrusted output. -fn text_of(result: Result) -> String { +fn text_of(result: Result) -> String { let output = result.expect("the listing succeeds"); assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); output.text().to_owned() } /// The message of a failed listing. -fn failure_of(result: Result) -> String { +fn failure_of(result: Result) -> String { match result { Ok(output) => panic!("the listing fails, but it answered {:?}", output.text()), Err(error) => error.to_string(), diff --git a/crates/plugin-std/src/dir/tests/arguments.rs b/crates/plugin-std/src/dir/tests/arguments.rs index 4c62b90e7..f70b9bf9e 100644 --- a/crates/plugin-std/src/dir/tests/arguments.rs +++ b/crates/plugin-std/src/dir/tests/arguments.rs @@ -1,6 +1,6 @@ //! What ListDir refuses about its arguments, and in which order. -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::json; use super::{failure_of, list, new_call}; @@ -13,7 +13,7 @@ fn a_missing_target_names_the_parameter() { error.to_string(), "std_ListDir: `target_directory` is required, and the call left it out" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -54,7 +54,7 @@ fn an_undeclared_key_is_refused_and_the_parameters_are_named() { "std_ListDir: `path` is not a parameter of this tool. \ The parameters are `ignore_globs` and `target_directory`." ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] diff --git a/crates/plugin-std/src/dir/tests/ignore.rs b/crates/plugin-std/src/dir/tests/ignore.rs index 0ca4bee92..4f2f5f2e2 100644 --- a/crates/plugin-std/src/dir/tests/ignore.rs +++ b/crates/plugin-std/src/dir/tests/ignore.rs @@ -1,7 +1,7 @@ //! `ignore_globs`: where a pattern matches, what a match leaves out, and //! what a refused pattern says. -use runvil_plugin::{Entry, FileType, Stat, ToolErrorKind, VfsError}; +use runvil_plugin::{Entry, ErrorCode, FileType, Stat, VfsError}; use serde_json::json; use super::{failure_of, list, seeded, text_of}; @@ -137,7 +137,7 @@ fn a_refused_pattern_is_an_invalid_arguments_error_with_the_reason() { ] { let args = json!({ "target_directory": "/p", "ignore_globs": [pattern] }); let error = list(&call, &args).expect_err("the pattern is refused"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments, "{pattern:?}"); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments, "{pattern:?}"); assert_eq!( error.to_string(), format!( diff --git a/crates/plugin-std/src/grep.rs b/crates/plugin-std/src/grep.rs index 81c3ea024..b5310b35f 100644 --- a/crates/plugin-std/src/grep.rs +++ b/crates/plugin-std/src/grep.rs @@ -22,7 +22,7 @@ mod window; #[cfg(test)] mod tests; -use runvil_plugin::{Access, ToolError, ToolOutput}; +use runvil_plugin::{Access, Error, ErrorCode, ToolOutput, tool_error}; use serde_json::Value; use crate::spec::Definition; @@ -35,14 +35,14 @@ use self::source::Source; /// check. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter for a +/// Returns an invalid-arguments [`Error`] naming the parameter for a /// bad argument, and a message error for a root that cannot be searched, /// worded for a model to read. pub(crate) async fn call( access: &Access, definition: &Definition, args: &Value, -) -> Result { +) -> Result { let request = args::parse(definition, args)?; search(access, &definition.wire_name, &request, &Limits::default()).await } @@ -54,9 +54,9 @@ async fn search( tool: &str, request: &Request<'_>, limits: &Limits, -) -> Result { +) -> Result { let found = find::find(source, tool, request, limits) .await - .map_err(ToolError::message)?; + .map_err(|text| tool_error(ErrorCode::ToolOther, text))?; Ok(render::render(&found, request, limits)) } diff --git a/crates/plugin-std/src/grep/args.rs b/crates/plugin-std/src/grep/args.rs index ac52bfce5..ebf4975c9 100644 --- a/crates/plugin-std/src/grep/args.rs +++ b/crates/plugin-std/src/grep/args.rs @@ -2,7 +2,7 @@ //! any path is looked at. use regex::{Regex, RegexBuilder}; -use runvil_plugin::ToolError; +use runvil_plugin::Error; use serde_json::Value; use super::types; @@ -62,11 +62,8 @@ const ACCEPTED_UNDECLARED: &str = "-n"; /// compiled before any filesystem call. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter. -pub(super) fn parse<'a>( - definition: &Definition, - args: &'a Value, -) -> Result, ToolError> { +/// Returns an invalid-arguments [`Error`] naming the parameter. +pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { let tool = definition.wire_name.as_str(); let mut declared = args.clone(); if let Some(object) = declared.as_object_mut() { @@ -119,7 +116,7 @@ pub(super) fn parse<'a>( /// Refuses `-n` unless it is `true` or left out, because Grep always shows /// line numbers. -fn refuse_line_numbers_off(tool: &str, args: &Value) -> Result<(), ToolError> { +fn refuse_line_numbers_off(tool: &str, args: &Value) -> Result<(), Error> { match args.get(ACCEPTED_UNDECLARED) { None | Some(Value::Null | Value::Bool(true)) => Ok(()), Some(_) => Err(invalid_arguments( @@ -134,7 +131,7 @@ fn refuse_line_numbers_off(tool: &str, args: &Value) -> Result<(), ToolError> { /// /// `unit` names what a context line count counts, and is empty for /// `head_limit` and `offset`, whose refusal is shorter. -fn number(tool: &str, args: &Value, name: &str, unit: &str) -> Result, ToolError> { +fn number(tool: &str, args: &Value, name: &str, unit: &str) -> Result, Error> { let Some(value) = args.get(name).filter(|value| !value.is_null()) else { return Ok(None); }; @@ -169,7 +166,7 @@ fn count(value: f64, up: bool) -> usize { /// The extensions of the `type` the call names, or `None` when it names /// none. An empty string names none. -fn kind(tool: &str, args: &Value) -> Result, ToolError> { +fn kind(tool: &str, args: &Value) -> Result, Error> { let Some(name) = text(args, "type") else { return Ok(None); }; @@ -185,7 +182,7 @@ fn kind(tool: &str, args: &Value) -> Result, Too } /// The `glob` the call names, compiled, or `None` when it names none. -fn glob<'a>(tool: &str, args: &'a Value) -> Result, ToolError> { +fn glob<'a>(tool: &str, args: &'a Value) -> Result, Error> { let Some(pattern) = text(args, "glob") else { return Ok(None); }; @@ -196,7 +193,7 @@ fn glob<'a>(tool: &str, args: &'a Value) -> Result, T /// The `path` the call names, or `None` when it names none, refused when /// it is only whitespace. -fn path<'a>(tool: &str, args: &'a Value) -> Result, ToolError> { +fn path<'a>(tool: &str, args: &'a Value) -> Result, Error> { let Some(path) = text(args, "path") else { return Ok(None); }; @@ -231,7 +228,7 @@ fn flag(args: &Value, name: &str) -> bool { /// Matching is case-sensitive unless `-i` is set, and `^` and `$` match at /// each line. With `multiline`, `.` also matches a line break and a line /// break is `\r\n` or `\n`. `literal` matches the pattern as plain text. -fn compile(tool: &str, pattern: &str, args: &Value, multiline: bool) -> Result { +fn compile(tool: &str, pattern: &str, args: &Value, multiline: bool) -> Result { let source = if flag(args, "literal") { regex::escape(pattern) } else { diff --git a/crates/plugin-std/src/grep/tests.rs b/crates/plugin-std/src/grep/tests.rs index c8293c7d1..826706dfb 100644 --- a/crates/plugin-std/src/grep/tests.rs +++ b/crates/plugin-std/src/grep/tests.rs @@ -19,7 +19,7 @@ use std::pin::pin; use std::task::{Context, Poll, Waker}; use runvil_plugin::testing::TestCall; -use runvil_plugin::{OutputTrust, ToolError, ToolId, ToolOutput}; +use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; use serde_json::Value; use super::args; @@ -81,14 +81,14 @@ fn definition() -> &'static Definition { /// Calls Grep with `args` as the Pack does: the schema check first, then /// the tool. -fn grep(call: &TestCall, args: &Value) -> Result { +fn grep(call: &TestCall, args: &Value) -> Result { let definition = definition(); crate::check::check(&definition.wire_name, &definition.parameters, args)?; block_on(super::call(call.context().access(), definition, args)) } /// Calls Grep over `source` with `args`, under `limits`. -fn grep_over(source: &impl Source, args: &Value, limits: &Limits) -> Result { +fn grep_over(source: &impl Source, args: &Value, limits: &Limits) -> Result { let definition = definition(); crate::check::check(&definition.wire_name, &definition.parameters, args)?; let request = args::parse(definition, args)?; @@ -96,19 +96,19 @@ fn grep_over(source: &impl Source, args: &Value, limits: &Limits) -> Result Result { +fn grep_limited(call: &TestCall, args: &Value, limits: &Limits) -> Result { grep_over(call.context().access(), args, limits) } /// The text of a successful search, which must be untrusted output. -fn text_of(result: Result) -> String { +fn text_of(result: Result) -> String { let output = result.expect("the search succeeds"); assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); output.text().to_owned() } /// The message of a failed search. -fn failure_of(result: Result) -> String { +fn failure_of(result: Result) -> String { match result { Ok(output) => panic!("the search fails, but it answered {:?}", output.text()), Err(error) => error.to_string(), diff --git a/crates/plugin-std/src/grep/tests/arguments.rs b/crates/plugin-std/src/grep/tests/arguments.rs index d427a54c2..8d0ea87f7 100644 --- a/crates/plugin-std/src/grep/tests/arguments.rs +++ b/crates/plugin-std/src/grep/tests/arguments.rs @@ -1,6 +1,6 @@ //! What Grep refuses about its arguments, and in which order. -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::{Value, json}; use super::{definition, failure_of, grep, new_call, seeded}; @@ -28,7 +28,7 @@ fn a_missing_pattern_names_the_parameter() { error.to_string(), "std_Grep: `pattern` is required, and the call left it out" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -41,7 +41,7 @@ fn an_undeclared_key_is_refused_naming_it_and_listing_the_sorted_parameters() { "std_Grep: `context` is not a parameter of this tool. The parameters are {PARAMETERS}." ) ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] diff --git a/crates/plugin-std/src/grep/tests/patterns.rs b/crates/plugin-std/src/grep/tests/patterns.rs index fbdedbdc9..d11322acf 100644 --- a/crates/plugin-std/src/grep/tests/patterns.rs +++ b/crates/plugin-std/src/grep/tests/patterns.rs @@ -1,7 +1,7 @@ //! How Grep compiles and applies the pattern: the dialect, the flags, and //! what a bad pattern says. -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::json; use super::{failure_of, grep, new_call, seeded, text_of}; @@ -32,7 +32,7 @@ fn a_pattern_the_dialect_cannot_compile_is_an_error_naming_the_pattern_the_reaso error.to_string(), format!("std_Grep: Invalid regular expression \"{pattern}\": {reason}. {ADVICE}") ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments, "{pattern}"); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments, "{pattern}"); } } diff --git a/crates/plugin-std/src/lib.rs b/crates/plugin-std/src/lib.rs index 2d51aec1c..194231de8 100644 --- a/crates/plugin-std/src/lib.rs +++ b/crates/plugin-std/src/lib.rs @@ -62,7 +62,7 @@ //! Write, StrReplace, Delete, and Glob are still stubs: a call with //! arguments that match the schema answers [`NOT_IMPLEMENTED`]. For every //! tool, a call whose arguments do not match fails with an -//! invalid-arguments [`ToolError`](runvil_plugin::ToolError) naming the +//! invalid-arguments [`Error`](runvil_plugin::Error) naming the //! parameter. The stubs exist so that the crate, the definitions, the //! ids, the wire names, and the Lua path can be proven before any file //! logic exists. @@ -74,7 +74,7 @@ //! puts every read and write under the run's `ModePolicy`, so a run in //! `Mode::Ask` refuses every mutation without the Pack deciding anything, //! and a run in `Mode::Plan` allows mutations to markdown paths alone. The -//! tools only report the resulting `VfsError` as a `ToolError` the model +//! tools only report the resulting `VfsError` as a tool error the model //! can read. //! //! Shell is deliberately absent. An in-process shell over the same @@ -91,7 +91,7 @@ //! unchanged, except for the edits `schemas/EDITS.md` lists. A test per //! tool holds the wire name in place. //! - A call whose arguments the schema refuses is an -//! [`InvalidArguments`](runvil_plugin::ToolErrorKind::InvalidArguments) +//! [`ToolInvalidArguments`](runvil_plugin::ErrorCode::ToolInvalidArguments) //! failure that names the parameter. No other outcome reports the //! arguments, so a model can tell a bad call from a missing //! implementation. diff --git a/crates/plugin-std/src/pack.rs b/crates/plugin-std/src/pack.rs index 47dce1e51..c0bfabb93 100644 --- a/crates/plugin-std/src/pack.rs +++ b/crates/plugin-std/src/pack.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use runvil_plugin::{ - HostServices, InstallError, Pack, PackFuture, PackId, PackInfo, PackRegistry, ToolContext, - ToolError, ToolId, ToolInfo, ToolOutput, + Error, ErrorCode, HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ToolContext, + ToolId, ToolInfo, ToolOutput, tool_error, }; use serde_json::{Value, json}; @@ -38,14 +38,14 @@ const STD: PackInfo = /// which is `null` when `config` has no `std` key. /// /// # Errors -/// Returns the registry's [`InstallError`], such as -/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/std` -/// already. +/// Returns the registry's refusal, such as an +/// [`InvalidValue`](runvil_plugin::ErrorCode::InvalidValue) error with the +/// reason `duplicate_pack` when `packs` holds `runvil/std` already. #[expect( clippy::needless_pass_by_value, reason = "the Plugin signature fixes the argument types" )] -pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { +pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> { packs.insert(STD, config["std"].clone()) } @@ -65,19 +65,22 @@ fn construct( name: &PackId, config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !(config.is_null() || config == json!({})) { - return Err(ToolError::message("std takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "std takes no configuration", + )); } let tools = spec::all()? .iter() .map(|definition| { let id = ToolId::parse(&format!("{name}/{}", definition.segment)).map_err(|e| { - ToolError::message(format!( - "std could not name its {} tool", - definition.segment - )) - .with_source(e) + tool_error( + ErrorCode::ToolOther, + format!("std could not name its {} tool", definition.segment), + ) + .with_cause(&e) })?; let info = ToolInfo::new( id, @@ -87,7 +90,7 @@ fn construct( .wire_name(definition.wire_name.clone()); Ok(info) }) - .collect::, ToolError>>()?; + .collect::, Error>>()?; Ok(Arc::new(Std { tools })) } @@ -113,11 +116,14 @@ impl Pack for Std { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let name = cx.tool().name(); let Some(definition) = definition(name) else { - return Err(ToolError::message(format!("std has no tool named {name}"))); + return Err(tool_error( + ErrorCode::ToolOther, + format!("std has no tool named {name}"), + )); }; crate::check::check(&definition.wire_name, &definition.parameters, &args)?; match definition.segment { diff --git a/crates/plugin-std/src/read.rs b/crates/plugin-std/src/read.rs index 789759242..1f8a97b89 100644 --- a/crates/plugin-std/src/read.rs +++ b/crates/plugin-std/src/read.rs @@ -13,7 +13,7 @@ mod window; #[cfg(test)] mod tests; -use runvil_plugin::{Access, FileType, ToolError, ToolOutput, VfsError}; +use runvil_plugin::{Access, Error, ErrorCode, FileType, ToolOutput, VfsError, tool_error}; use serde_json::Value; use crate::check::plural; @@ -31,14 +31,14 @@ pub(crate) const BYTE_CAP: usize = 100_000; /// check. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter for a +/// Returns an invalid-arguments [`Error`] naming the parameter for a /// bad argument, and a message error for any other failure, worded for a /// model to read. pub(crate) fn call( access: &Access, definition: &Definition, args: &Value, -) -> Result { +) -> Result { let request = args::parse(definition, args)?; read_file(access, &definition.wire_name, &request, BYTE_CAP) } @@ -51,21 +51,22 @@ fn read_file( tool: &str, request: &Request<'_>, cap: usize, -) -> Result { +) -> Result { let path = request.path; - let failed = |error: VfsError| ToolError::message(refusal(tool, path, &error)); + let message = |text: String| tool_error(ErrorCode::ToolOther, text); + let failed = |error: VfsError| message(refusal(tool, path, &error)); let stat = access.stat(path).map_err(failed)?; - check_type(path, stat.file_type).map_err(ToolError::message)?; + check_type(path, stat.file_type).map_err(message)?; let bytes = access .read_range(path, 0, MAX_FILE_BYTES + 1) .map_err(failed)?; if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { - return Err(ToolError::message(format!( + return Err(message(format!( "File is too large to read: {path} is over the limit of {MAX_FILE_BYTES} bytes." ))); } if sniff_binary(&bytes) { - return Err(ToolError::message(format!( + return Err(message(format!( "File content is binary data, not text: {path}" ))); } @@ -75,7 +76,7 @@ fn read_file( return Ok(ToolOutput::untrusted("File is empty.")); } let Some(window) = window::window(lines.len(), request.offset, request.limit) else { - return Err(ToolError::message(format!( + return Err(message(format!( "Offset {} is beyond the end of {path}, which has {}.", request.offset.unwrap_or_default(), plural(lines.len(), "line") diff --git a/crates/plugin-std/src/read/args.rs b/crates/plugin-std/src/read/args.rs index 94721399d..b8476b7d5 100644 --- a/crates/plugin-std/src/read/args.rs +++ b/crates/plugin-std/src/read/args.rs @@ -1,7 +1,7 @@ //! Read's arguments: what the call asks for, and what is refused before //! any file is touched. -use runvil_plugin::ToolError; +use runvil_plugin::Error; use serde_json::Value; use crate::check::{invalid_arguments, refuse_blank, refuse_unknown}; @@ -26,11 +26,8 @@ pub(super) struct Request<'a> { /// about the same one. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] naming the parameter. -pub(super) fn parse<'a>( - definition: &Definition, - args: &'a Value, -) -> Result, ToolError> { +/// Returns an invalid-arguments [`Error`] naming the parameter. +pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { let tool = definition.wire_name.as_str(); refuse_unknown(tool, &definition.parameters, args)?; let path = args.get("path").and_then(Value::as_str).unwrap_or_default(); @@ -59,11 +56,7 @@ pub(super) fn parse<'a>( /// The shared check has already refused anything that is not a whole /// number, so a value that still has no `i64` is a whole number past the /// 64-bit range, which is refused here. -fn integer<'a>( - tool: &str, - name: &str, - args: &'a Value, -) -> Result, ToolError> { +fn integer<'a>(tool: &str, name: &str, args: &'a Value) -> Result, Error> { let Some(value) = args.get(name).filter(|value| !value.is_null()) else { return Ok(None); }; diff --git a/crates/plugin-std/src/read/tests.rs b/crates/plugin-std/src/read/tests.rs index 2500c0e73..27d2b8b03 100644 --- a/crates/plugin-std/src/read/tests.rs +++ b/crates/plugin-std/src/read/tests.rs @@ -10,7 +10,7 @@ mod render; mod window; use runvil_plugin::testing::TestCall; -use runvil_plugin::{OutputTrust, ToolError, ToolId, ToolOutput}; +use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; use serde_json::Value; use super::args::Request; @@ -47,7 +47,7 @@ fn definition() -> &'static Definition { /// Calls Read with `args` as the Pack does: the schema check first, then /// the tool. -fn read(call: &TestCall, args: &Value) -> Result { +fn read(call: &TestCall, args: &Value) -> Result { let definition = definition(); crate::check::check(&definition.wire_name, &definition.parameters, args)?; super::call(call.context().access(), definition, args) @@ -61,7 +61,7 @@ fn read_capped( offset: Option, limit: Option, cap: usize, -) -> Result { +) -> Result { let request = Request { path, offset, @@ -71,14 +71,14 @@ fn read_capped( } /// The text of a successful read, which must be untrusted output. -fn text_of(result: Result) -> String { +fn text_of(result: Result) -> String { let output = result.expect("the read succeeds"); assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); output.text().to_owned() } /// The message of a failed read. -fn failure_of(result: Result) -> String { +fn failure_of(result: Result) -> String { match result { Ok(output) => panic!("the read fails, but it answered {:?}", output.text()), Err(error) => error.to_string(), diff --git a/crates/plugin-std/src/read/tests/arguments.rs b/crates/plugin-std/src/read/tests/arguments.rs index 7918da741..b08f58032 100644 --- a/crates/plugin-std/src/read/tests/arguments.rs +++ b/crates/plugin-std/src/read/tests/arguments.rs @@ -1,17 +1,17 @@ //! The argument rules: what Read refuses before it touches a file, and in //! what order. -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; use serde_json::{Value, json}; use super::{new_call, numbered_file, read, seeded, text_of}; -/// The error kind and message of a refused call over an empty filesystem. -fn refused(args: &Value) -> (ToolErrorKind, String) { +/// The error code and message of a refused call over an empty filesystem. +fn refused(args: &Value) -> (ErrorCode, String) { let call = new_call(); match read(&call, args) { Ok(output) => panic!("{args} is refused, but it answered {:?}", output.text()), - Err(error) => (error.kind(), error.to_string()), + Err(error) => (error.code, error.to_string()), } } @@ -19,7 +19,7 @@ fn refused(args: &Value) -> (ToolErrorKind, String) { fn a_limit_below_one_is_refused_with_the_value_as_given() { for (limit, passed) in [(json!(0), "0"), (json!(-3), "-3")] { let (kind, message) = refused(&json!({ "path": "/a.md", "limit": limit })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, format!("std_Read: `limit` must be at least 1, and the call passed {passed}") @@ -30,7 +30,7 @@ fn a_limit_below_one_is_refused_with_the_value_as_given() { #[test] fn a_string_offset_is_refused_and_not_coerced() { let (kind, message) = refused(&json!({ "path": "/a.md", "offset": "3" })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, "std_Read: `offset` must be a JSON integer, and the call passed \"3\"" @@ -57,7 +57,7 @@ fn an_integer_that_does_not_fit_in_64_bits_is_refused() { let mut args = json!({ "path": "/a.md" }); args[name] = json!(u64::MAX); let (kind, message) = refused(&args); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, format!( @@ -108,7 +108,7 @@ fn a_whole_number_float_past_the_64_bit_range_is_refused() { let mut args = json!({ "path": "/a.md" }); args[name] = passed.clone(); let (kind, message) = refused(&args); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, format!( @@ -122,7 +122,7 @@ fn a_whole_number_float_past_the_64_bit_range_is_refused() { #[test] fn a_float_limit_below_one_is_refused_with_the_value_as_given() { let (kind, message) = refused(&json!({ "path": "/a.md", "limit": 0.0 })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, "std_Read: `limit` must be at least 1, and the call passed 0.0" @@ -132,7 +132,7 @@ fn a_float_limit_below_one_is_refused_with_the_value_as_given() { #[test] fn an_undeclared_key_is_refused_naming_it_and_the_parameters() { let (kind, message) = refused(&json!({ "path": "/a.md", "lines": 2 })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, "std_Read: `lines` is not a parameter of this tool. \ @@ -144,7 +144,7 @@ fn an_undeclared_key_is_refused_naming_it_and_the_parameters() { fn a_blank_path_is_refused_before_any_io() { for (blank, shown) in [("", ""), (" ", " ")] { let (kind, message) = refused(&json!({ "path": blank })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, format!( @@ -157,7 +157,7 @@ fn a_blank_path_is_refused_before_any_io() { #[test] fn a_missing_path_is_refused_by_the_shared_check() { let (kind, message) = refused(&json!({})); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, "std_Read: `path` is required, and the call left it out" @@ -182,14 +182,14 @@ fn the_blank_path_is_refused_before_the_limit() { #[test] fn the_limit_is_refused_before_the_file_is_looked_up() { let (kind, message) = refused(&json!({ "path": "/missing.md", "limit": 0 })); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert!(message.contains("`limit` must be at least 1"), "{message}"); } #[test] fn a_missing_file_is_a_plain_message_and_not_an_invalid_arguments_error() { let (kind, message) = refused(&json!({ "path": "/missing.md" })); - assert_ne!(kind, ToolErrorKind::InvalidArguments); + assert_ne!(kind, ErrorCode::ToolInvalidArguments); assert_eq!(message, "File not found: /missing.md"); } @@ -208,7 +208,7 @@ fn a_null_offset_and_a_null_limit_read_as_absent() { #[test] fn a_null_argument_list_is_refused_for_its_missing_path() { let (kind, message) = refused(&Value::Null); - assert_eq!(kind, ToolErrorKind::InvalidArguments); + assert_eq!(kind, ErrorCode::ToolInvalidArguments); assert_eq!( message, "std_Read: `path` is required, and the call left it out" diff --git a/crates/plugin-std/src/spec.rs b/crates/plugin-std/src/spec.rs index 70a2a006e..6b822812b 100644 --- a/crates/plugin-std/src/spec.rs +++ b/crates/plugin-std/src/spec.rs @@ -9,7 +9,7 @@ use std::sync::LazyLock; -use runvil_plugin::{ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, tool_error}; use serde::Deserialize; use serde_json::Value; @@ -50,16 +50,16 @@ const SOURCES: [(&str, &str); 7] = [ ]; /// The parsed definitions, one per [`SOURCES`] entry in order. -static DEFINITIONS: LazyLock, ToolError>> = LazyLock::new(|| { +static DEFINITIONS: LazyLock, Error>> = LazyLock::new(|| { SOURCES .iter() .map(|(segment, source)| { let file: File = serde_json::from_str(source).map_err(|e| { - ToolError::new( - ToolErrorKind::Backend, + tool_error( + ErrorCode::ToolBackend, format!("std could not read the definition of its {segment} tool"), ) - .with_source(e) + .with_cause(&e) })?; Ok(Definition { segment, @@ -92,12 +92,13 @@ fn fill(segment: &str, description: &str) -> String { /// Every definition, in [`SOURCES`] order. /// /// # Errors -/// Returns a backend [`ToolError`] naming the tool whose file does not -/// parse. The files are embedded, so this can only happen after an edit. -pub(crate) fn all() -> Result<&'static [Definition], ToolError> { +/// Returns a [`ToolBackend`](ErrorCode::ToolBackend) error naming the tool +/// whose file does not parse. The files are embedded, so this can only +/// happen after an edit. +pub(crate) fn all() -> Result<&'static [Definition], Error> { match &*DEFINITIONS { Ok(definitions) => Ok(definitions), - Err(error) => Err(ToolError::new(error.kind(), error.to_string())), + Err(error) => Err(error.clone()), } } diff --git a/crates/plugin-std/tests/it/grep.rs b/crates/plugin-std/tests/it/grep.rs index 6c2c8b817..6daaf2775 100644 --- a/crates/plugin-std/tests/it/grep.rs +++ b/crates/plugin-std/tests/it/grep.rs @@ -7,7 +7,7 @@ use std::pin::pin; use std::task::{Context, Poll, Waker}; use runvil_plugin::testing::TestCall; -use runvil_plugin::{OutputTrust, ToolError, ToolOutput}; +use runvil_plugin::{Error, OutputTrust, ToolOutput}; use serde_json::{Value, json}; use crate::tools::{built, id}; @@ -39,7 +39,7 @@ fn a_search_over_a_thousand_files_yields_to_the_executor_before_it_completes() { let mut future = pin!(pack.call(call.context(), args())); let mut cx = Context::from_waker(Waker::noop()); let mut pending = 0; - let result: Result = loop { + let result: Result = loop { match future.as_mut().poll(&mut cx) { Poll::Ready(result) => break result, Poll::Pending => pending += 1, diff --git a/crates/plugin-std/tests/it/tools.rs b/crates/plugin-std/tests/it/tools.rs index d6d895fc2..28adf6f5f 100644 --- a/crates/plugin-std/tests/it/tools.rs +++ b/crates/plugin-std/tests/it/tools.rs @@ -10,8 +10,8 @@ use std::task::{Context, Poll, Waker}; use plugin_std::{NOT_IMPLEMENTED, install}; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, InstallError, OutputTrust, Pack, PackId, PackRegistry, ToolError, ToolErrorKind, - ToolId, ToolOutput, + Error, ErrorCode, FactKey, HostServices, OutputTrust, Pack, PackId, PackRegistry, ToolId, + ToolOutput, }; use serde_json::{Value, json}; @@ -50,7 +50,7 @@ fn inserted(config: Value) -> Vec<(String, String, Value)> { } /// The Pack built under the Pack id `name` with `config`. -fn construct(name: &str, config: Value) -> Result, ToolError> { +fn construct(name: &str, config: Value) -> Result, Error> { let name = PackId::parse(name).expect("a one-segment Pack id parses"); (std_pack().construct)(&name, config, &HostServices::new()) } @@ -69,17 +69,13 @@ pub(crate) fn id(segment: &str) -> ToolId { } /// Calls the tool `segment` with `args`. -fn call(segment: &str, args: Value) -> Result { +fn call(segment: &str, args: Value) -> Result { call_seeded(segment, &[], args) } /// Calls the tool `segment` with `args` after writing each of `files`, a /// path beside its bytes, through the call's own access. -fn call_seeded( - segment: &str, - files: &[(&str, &[u8])], - args: Value, -) -> Result { +fn call_seeded(segment: &str, files: &[(&str, &[u8])], args: Value) -> Result { let pack = built(); let call = TestCall::new(id(segment)); for (path, bytes) in files { @@ -120,9 +116,15 @@ fn install_adds_the_std_pack_with_the_config_under_its_pack_id() { fn install_returns_the_registrys_refusal() { let mut packs = PackRegistry::new(); install(&mut packs, Value::Null).expect("the first install inserts std"); + let error = install(&mut packs, Value::Null).expect_err("a second install is refused"); + assert_eq!(error.code, ErrorCode::InvalidValue); assert_eq!( - install(&mut packs, Value::Null), - Err(InstallError::DuplicatePack { fqn: "runvil/std" }) + error.facts.get(&FactKey::Reason), + Some(&Value::from("duplicate_pack")) + ); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from("runvil/std")) ); } @@ -240,7 +242,7 @@ fn a_missing_required_parameter_fails_naming_it() { error.to_string(), "std_Grep: `pattern` is required, and the call left it out" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -250,7 +252,7 @@ fn a_parameter_of_the_wrong_type_fails_naming_it() { error.to_string(), "std_Read: `path` must be a JSON string, and the call passed 7" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -262,7 +264,7 @@ fn a_value_outside_an_enum_fails_listing_the_allowed_values() { "std_Grep: `output_mode` must be one of \"content\", \"files_with_matches\", \"count\", \ and the call passed \"lines\"" ); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); } #[test] @@ -375,7 +377,7 @@ fn grep_of_a_missing_path_fails_with_the_does_not_exist_text() { fn grep_refuses_an_undeclared_key_by_name_while_the_stubs_let_it_through() { let error = call("grep", json!({ "pattern": "x", "context": 3 })) .expect_err("context is not a parameter"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert!( error .to_string() diff --git a/crates/plugin-user-input/src/ask.rs b/crates/plugin-user-input/src/ask.rs index da35d8949..18f837232 100644 --- a/crates/plugin-user-input/src/ask.rs +++ b/crates/plugin-user-input/src/ask.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, ToolContext, ToolError, ToolErrorKind, ToolId, - ToolInfo, ToolOutput, + Error, ErrorCode, HostServices, Pack, PackFuture, PackId, ToolContext, ToolId, ToolInfo, + ToolOutput, tool_error, }; use serde_json::{Value, json}; @@ -26,16 +26,19 @@ pub(crate) fn construct( name: &PackId, config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !(config.is_null() || config == json!({})) { - return Err(ToolError::message("user-input takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "user-input takes no configuration", + )); } let id = ToolId::parse(&format!("{name}/{ASK}")).map_err(|e| { - ToolError::new( - ToolErrorKind::Backend, + tool_error( + ErrorCode::ToolBackend, "user-input could not name its ask tool", ) - .with_source(e) + .with_cause(&e) })?; let ask = ToolInfo::new( id, @@ -55,13 +58,13 @@ impl Pack for UserInput { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { // The Pack's needs keep a run without a broker from seeing // the ask tool, so this branch is defensive. - let broker = cx - .service(&INPUT_BROKER) - .ok_or_else(|| ToolError::message("no operator is connected to this run"))?; + let broker = cx.service(&INPUT_BROKER).ok_or_else(|| { + tool_error(ErrorCode::ToolOther, "no operator is connected to this run") + })?; broker.wait().await.map(ToolOutput::trusted) }) } diff --git a/crates/plugin-user-input/src/lib.rs b/crates/plugin-user-input/src/lib.rs index d934518cc..ef72cc82a 100644 --- a/crates/plugin-user-input/src/lib.rs +++ b/crates/plugin-user-input/src/lib.rs @@ -42,7 +42,7 @@ //! //! # Failed and cancelled waits //! -//! A broker failure is a [`ToolError`], and it becomes the ask call's +//! A broker failure is an [`Error`], and it becomes the ask call's //! error unchanged. It raises at the script's `input.ask()` call as a Lua //! error of kind `"tool"` whose message is the broker's message, so //! `pcall` catches it; uncaught, it ends the run as a tool failure. The @@ -77,7 +77,7 @@ mod ask; -use runvil_plugin::{InstallError, PackInfo, PackRegistry, ServiceId, ServiceKey, ToolError}; +use runvil_plugin::{Error, PackInfo, PackRegistry, ServiceId, ServiceKey}; use serde_json::Value; /// The Pack's `PackInfo`. @@ -94,14 +94,14 @@ const USER_INPUT: PackInfo = PackInfo::new("runvil/user-input", ask::construct) /// `user-input` key. /// /// # Errors -/// Returns the registry's [`InstallError`], such as -/// [`InstallError::DuplicatePack`] when `packs` holds -/// `runvil/user-input` already. +/// Returns the registry's refusal, such as an +/// [`InvalidValue`](runvil_plugin::ErrorCode::InvalidValue) error with the +/// reason `duplicate_pack` when `packs` holds `runvil/user-input` already. #[expect( clippy::needless_pass_by_value, reason = "the Plugin signature fixes the argument types" )] -pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { +pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> { packs.insert(USER_INPUT, config["user-input"].clone()) } @@ -149,9 +149,9 @@ pub trait InputBroker: Send + Sync { /// Waits for the operator's next message and returns it byte-exact. /// /// # Errors - /// Returns a [`ToolError`], whose message is safe to show the model, + /// Returns an [`Error`], whose prose is safe to show the model, /// when the broker fails to produce the operator's message, for /// example because the Host withdrew the wait. It is the ask call's /// error unchanged. - async fn wait(&self) -> Result; + async fn wait(&self) -> Result; } diff --git a/crates/plugin-user-input/tests/it/ask.rs b/crates/plugin-user-input/tests/it/ask.rs index 5eaa10e6b..e88806f86 100644 --- a/crates/plugin-user-input/tests/it/ask.rs +++ b/crates/plugin-user-input/tests/it/ask.rs @@ -11,8 +11,8 @@ use std::task::{Context, Poll, Waker}; use plugin_user_input::{ASK, INPUT_BROKER, InputBroker, install}; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, OutputTrust, Pack, PackId, PackInfo, PackRegistry, ToolError, ToolErrorKind, - ToolId, + Error, ErrorCode, HostServices, OutputTrust, Pack, PackId, PackInfo, PackRegistry, ToolId, + tool_error, }; use serde_json::{Value, json}; @@ -21,21 +21,21 @@ struct Scripted(&'static str); #[async_trait::async_trait] impl InputBroker for Scripted { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Ok(self.0.to_owned()) } } /// A broker whose every wait fails with a hidden cause behind the -/// message. +/// prose. struct Failing; #[async_trait::async_trait] impl InputBroker for Failing { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Err( - ToolError::new(ToolErrorKind::Backend, "the operator's window closed") - .with_source(std::io::Error::other("socket reset")), + tool_error(ErrorCode::ToolBackend, "the operator's window closed") + .with_cause(&std::io::Error::other("socket reset")), ) } } @@ -70,7 +70,7 @@ fn user_input() -> PackInfo { } /// The Pack built under the Pack id `name` with `config`. -fn construct(name: &str, config: Value) -> Result, ToolError> { +fn construct(name: &str, config: Value) -> Result, Error> { let name = PackId::parse(name).expect("a one-segment Pack id parses"); (user_input().construct)(&name, config, &HostServices::new()) } @@ -94,7 +94,7 @@ fn with_broker(broker: impl InputBroker + 'static) -> HostServices { /// Asks once through the Pack built under `user-input`, with `services` /// as the run's services. -fn ask(services: HostServices) -> Result { +fn ask(services: HostServices) -> Result { let pack = built("user-input"); let call = TestCall::new(pack.tools()[0].id.clone()).with_services(services); block_on(pack.call(call.context(), json!({}))) @@ -169,10 +169,12 @@ fn the_ask_tool_returns_the_brokers_text_trusted_and_byte_exact() { #[test] fn a_broker_failure_is_the_ask_calls_error_unchanged() { let error = ask(with_broker(Failing)).expect_err("a failed wait fails the call"); - assert_eq!(error.to_string(), "the operator's window closed"); - assert_eq!(error.kind(), ToolErrorKind::Backend); - let cause = std::error::Error::source(&error).map(ToString::to_string); - assert_eq!(cause.as_deref(), Some("socket reset")); + assert_eq!(error.prose, "the operator's window closed"); + assert_eq!(error.code, ErrorCode::ToolBackend); + assert_eq!( + error.message, "the operator's window closed: socket reset", + "the cause stays in the message, out of the prose" + ); } #[test] diff --git a/crates/plugin-web/src/error.rs b/crates/plugin-web/src/error.rs index f8b165c37..2987eaca6 100644 --- a/crates/plugin-web/src/error.rs +++ b/crates/plugin-web/src/error.rs @@ -3,26 +3,26 @@ //! [`FetchError`] is the single internal error type for every `web_fetch` //! failure mode: the URL and address policies, redirects, DNS, the size caps, //! the timeouts, the body read, and the content-type and decoding refusals. It -//! is never public; `call` maps it to a narrow `ToolError` +//! is never public; `call` maps it to a narrow tool error //! through [`FetchError::classify`]. It provides a model-facing rendering //! that withholds internal detail, and preserves dependency and I/O causes //! behind `#[source]` fields so a log can inspect the chain. use std::net::IpAddr; -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; /// How `call` should treat a [`FetchError`]. /// /// A [`Disposition::SoftOutput`] error is returned to the model as untrusted /// tool text so it can try a different URL; a [`Disposition::Hard`] error aborts -/// the call with the given [`ToolErrorKind`]. +/// the call with the given [`ErrorCode`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Disposition { /// Returns the model-facing text as untrusted tool output. SoftOutput, - /// Aborts the call with this error kind. - Hard(ToolErrorKind), + /// Aborts the call with this error code. + Hard(ErrorCode), } /// A URL stored for diagnostics with its secrets redacted. @@ -227,7 +227,7 @@ impl FetchError { /// Decides how `call` should treat this error. /// /// The match is exhaustive with no wildcard, so a new variant forces an - /// explicit disposition and kind decision at compile time. Target, network, + /// explicit disposition and code decision at compile time. Target, network, /// content, redirect, and blocked-scheme outcomes are soft (returned as tool /// text the model can act on); admission and SSRF refusals, decided before a /// useful retry shape exists, are hard invalid-arguments errors. @@ -250,7 +250,7 @@ impl FetchError { | FetchError::IpLiteral(_) | FetchError::BlockedAddress { .. } | FetchError::NoAllowedAddress { .. } => { - Disposition::Hard(ToolErrorKind::InvalidArguments) + Disposition::Hard(ErrorCode::ToolInvalidArguments) } } } @@ -261,7 +261,7 @@ mod tests { use std::error::Error as _; use std::net::IpAddr; - use runvil_plugin::ToolErrorKind; + use runvil_plugin::ErrorCode; use super::{Disposition, FetchError, SafeUrl}; @@ -365,7 +365,7 @@ mod tests { for err in &hard { assert_eq!( err.classify(), - Disposition::Hard(ToolErrorKind::InvalidArguments), + Disposition::Hard(ErrorCode::ToolInvalidArguments), "{err} must be hard invalid-arguments" ); } diff --git a/crates/plugin-web/src/fetch.rs b/crates/plugin-web/src/fetch.rs index 131339052..95a623be1 100644 --- a/crates/plugin-web/src/fetch.rs +++ b/crates/plugin-web/src/fetch.rs @@ -16,7 +16,7 @@ use tokio::runtime::Handle; use tokio::task::JoinHandle; use url::Url; -use runvil_plugin::{ToolError, ToolErrorKind, ToolOutput}; +use runvil_plugin::{Error, ErrorCode, ToolOutput, tool_error}; use crate::config::{ConfigError, FetchConfig}; use crate::error::{Disposition, FetchError, SafeUrl}; @@ -29,8 +29,8 @@ use crate::response::{ use crate::url_policy::check_url; /// The result [`WebFetch::call`] returns: untrusted page text on success, -/// a narrow [`ToolError`] on a hard failure. -type CallResult = Result; +/// a narrow tool [`Error`] on a hard failure. +type CallResult = Result; /// The hardened HTTP client the fetch tool sends every request through, /// with the validated [`FetchConfig`] policy it enforces. @@ -259,7 +259,7 @@ impl Drop for AbortOnDrop { fn soft_or_hard(err: &FetchError) -> CallResult { match err.classify() { Disposition::SoftOutput => Ok(ToolOutput::untrusted(err.model_facing())), - Disposition::Hard(kind) => Err(ToolError::message(err.model_facing()).with_kind(kind)), + Disposition::Hard(code) => Err(tool_error(code, err.model_facing())), } } @@ -283,9 +283,7 @@ fn map_send_error_to_outcome(err: &reqwest::Error, url: &str) -> CallResult { if let Some(fetch_err) = current.downcast_ref::() { return match fetch_err.classify() { Disposition::SoftOutput => Ok(ToolOutput::untrusted(fetch_err.model_facing())), - Disposition::Hard(kind) => { - Err(ToolError::message(fetch_err.model_facing()).with_kind(kind)) - } + Disposition::Hard(code) => Err(tool_error(code, fetch_err.model_facing())), }; } source = current.source(); @@ -344,7 +342,7 @@ impl WebFetch { /// fetch. /// /// # Errors - /// Returns a [`ToolError`] for invalid arguments, a URL the policy + /// Returns an [`Error`] for invalid arguments, a URL the policy /// refuses outright, or a fetch task that did not finish. A page the /// fetch could not render is a soft, untrusted output instead. pub(crate) async fn call(&self, args: serde_json::Value) -> CallResult { @@ -352,8 +350,10 @@ impl WebFetch { .get("url") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - ToolError::message("web_fetch: missing url argument") - .with_kind(ToolErrorKind::InvalidArguments) + tool_error( + ErrorCode::ToolInvalidArguments, + "web_fetch: missing url argument", + ) })?; // A per-call `max_chars` is clamped to the configured ceiling; absent, @@ -373,11 +373,11 @@ impl WebFetch { ); match (&mut task.0).await { Ok(result) => result, - Err(join) => Err(ToolError::new( - ToolErrorKind::Other, + Err(join) => Err(tool_error( + ErrorCode::ToolOther, "web_fetch: the fetch task did not finish", ) - .with_source(join)), + .with_cause(&join)), } } } @@ -385,9 +385,9 @@ impl WebFetch { /// Parses the optional `max_chars` argument, clamped to `ceiling`. /// /// # Errors -/// Returns an invalid-arguments [`ToolError`] if `max_chars` is present but is +/// Returns an invalid-arguments [`Error`] if `max_chars` is present but is /// not a positive integer. -fn parse_max_chars(args: &serde_json::Value, ceiling: usize) -> Result { +fn parse_max_chars(args: &serde_json::Value, ceiling: usize) -> Result { let Some(value) = args.get("max_chars") else { return Ok(ceiling); }; @@ -395,8 +395,10 @@ fn parse_max_chars(args: &serde_json::Value, ceiling: usize) -> Result= 1).ok_or_else(|| { - ToolError::message("web_fetch: max_chars must be a positive integer") - .with_kind(ToolErrorKind::InvalidArguments) + tool_error( + ErrorCode::ToolInvalidArguments, + "web_fetch: max_chars must be a positive integer", + ) })?; let requested = usize::try_from(n).unwrap_or(usize::MAX); Ok(requested.min(ceiling)) @@ -405,15 +407,17 @@ fn parse_max_chars(args: &serde_json::Value, ceiling: usize) -> Result Result { +fn parse_raw(args: &serde_json::Value) -> Result { match args.get("raw") { None => Ok(false), Some(value) if value.is_null() => Ok(false), Some(value) => value.as_bool().ok_or_else(|| { - ToolError::message("web_fetch: raw must be a boolean") - .with_kind(ToolErrorKind::InvalidArguments) + tool_error( + ErrorCode::ToolInvalidArguments, + "web_fetch: raw must be a boolean", + ) }), } } diff --git a/crates/plugin-web/src/fetch/tests-policy.rs b/crates/plugin-web/src/fetch/tests-policy.rs index 50e08b8da..7725dfeae 100644 --- a/crates/plugin-web/src/fetch/tests-policy.rs +++ b/crates/plugin-web/src/fetch/tests-policy.rs @@ -6,7 +6,7 @@ use super::*; -use runvil_plugin::ToolErrorKind; +use runvil_plugin::ErrorCode; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn slow_server_past_total_timeout_yields_timeout() { @@ -197,7 +197,7 @@ async fn call_rejects_bad_urls_before_network() { .await .expect_err(&format!("expected {raw} to be refused before any network")); assert!( - err.kind() == ToolErrorKind::InvalidArguments, + err.code == ErrorCode::ToolInvalidArguments, "expected a policy rejection for {raw}, got: {err:?}" ); assert!( @@ -260,7 +260,7 @@ async fn blocked_url_still_hard_fails() { .expect_err("a bare IP literal URL must still be a hard error"); assert!( - err.kind() == ToolErrorKind::InvalidArguments && err.to_string().contains("ip literal"), + err.code == ErrorCode::ToolInvalidArguments && err.to_string().contains("ip literal"), "got: {err:?}" ); } diff --git a/crates/plugin-web/src/provider.rs b/crates/plugin-web/src/provider.rs index 96f75ff47..3c8fd8ea0 100644 --- a/crates/plugin-web/src/provider.rs +++ b/crates/plugin-web/src/provider.rs @@ -166,8 +166,8 @@ pub enum SearchErrorKind { /// The error a [`SearchProvider`] returns when a search fails. /// /// It holds a kind, a message, and an optional cause. The search tool -/// keeps the whole error as the source of the tool error it returns, so -/// the cause stays in the error chain. +/// keeps the whole error as the cause of the tool error it returns, so +/// the cause's text stays in that error's message. #[derive(Debug, thiserror::Error)] #[error("{message}")] pub struct SearchError { diff --git a/crates/plugin-web/src/search-request.rs b/crates/plugin-web/src/search-request.rs index 441ea2f1b..5bfb402ec 100644 --- a/crates/plugin-web/src/search-request.rs +++ b/crates/plugin-web/src/search-request.rs @@ -3,7 +3,7 @@ //! cannot express. Only a value that passed [`SearchRequest::from_args`] //! becomes the provider's [`SearchQuery`]. -use runvil_plugin::{ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, tool_error}; use super::{MAX_COUNT, MAX_DOMAINS, MAX_QUERY_LEN, MAX_STRING_LEN}; use crate::provider::{Freshness, SafeSearch, SearchQuery}; @@ -44,13 +44,13 @@ pub(super) struct SearchRequest { impl SearchRequest { /// Deserializes and validates the raw call arguments. - pub(super) fn from_args(args: serde_json::Value) -> Result { + pub(super) fn from_args(args: serde_json::Value) -> Result { let request: SearchRequest = serde_json::from_value(args).map_err(|error| { - ToolError::new( - ToolErrorKind::InvalidArguments, + tool_error( + ErrorCode::ToolInvalidArguments, "web_search: invalid arguments", ) - .with_source(error) + .with_cause(&error) })?; request.validate()?; Ok(request) @@ -72,10 +72,8 @@ impl SearchRequest { } /// Enforces the bounds the type alone cannot express. - fn validate(&self) -> Result<(), ToolError> { - let invalid = |message: String| { - ToolError::message(message).with_kind(ToolErrorKind::InvalidArguments) - }; + fn validate(&self) -> Result<(), Error> { + let invalid = |message: String| tool_error(ErrorCode::ToolInvalidArguments, message); if self.query.trim().is_empty() { return Err(invalid("web_search: query must not be empty".to_owned())); } diff --git a/crates/plugin-web/src/search-tests.rs b/crates/plugin-web/src/search-tests.rs index 6295bdd94..c3ec1505b 100644 --- a/crates/plugin-web/src/search-tests.rs +++ b/crates/plugin-web/src/search-tests.rs @@ -9,7 +9,7 @@ use crate::provider::{ Freshness, SafeSearch, SearchError, SearchErrorKind, SearchProvider, SearchQuery, SearchResult, SearchResults, }; -use runvil_plugin::{OutputTrust, ToolError, ToolErrorKind}; +use runvil_plugin::{Error, ErrorCode, OutputTrust}; /// A provider that records every query and answers each with `reply`. struct Fake { @@ -60,7 +60,7 @@ fn tool() -> (WebSearch, Arc) { /// Calls the tool with `args`, expecting a refusal, and checks the /// provider was never asked. -async fn refused(args: serde_json::Value) -> ToolError { +async fn refused(args: serde_json::Value) -> Error { let (tool, fake) = tool(); let err = tool .call(args) @@ -240,7 +240,7 @@ async fn success_body_with_empty_url_is_rejected() { .call(serde_json::json!({ "query": "hi" })) .await .expect_err("an empty result url must be rejected"); - assert_eq!(err.kind(), ToolErrorKind::Backend); + assert_eq!(err.code, ErrorCode::ToolBackend); assert_eq!( err.to_string(), "web_search: malformed search response: result 1 has an empty url" @@ -263,22 +263,17 @@ fn backend_failure() -> Result { } #[tokio::test] -async fn provider_errors_map_their_kind_and_message_and_stay_the_source() { +async fn provider_errors_map_their_kind_and_message_and_keep_the_cause_out_of_the_prose() { let (tool, _fake) = tool_answering(transport_failure); let err = tool .call(serde_json::json!({ "query": "hi" })) .await .expect_err("a transport failure fails the call"); - assert_eq!(err.kind(), ToolErrorKind::Transport); - assert_eq!(err.to_string(), "web_search: request failed"); - let source = std::error::Error::source(&err) - .and_then(|source| source.downcast_ref::()) - .expect("the provider error is the tool error's source"); - assert_eq!(source.kind(), SearchErrorKind::Transport); + assert_eq!(err.code, ErrorCode::ToolTransport); + assert_eq!(err.prose, "web_search: request failed"); assert_eq!( - std::error::Error::source(source).map(ToString::to_string), - Some("connection refused".to_owned()), - "the provider's own cause survives the trait boundary" + err.message, "web_search: request failed: connection refused", + "the provider's own cause survives the trait boundary, in the message" ); let (tool, _fake) = tool_answering(backend_failure); @@ -286,29 +281,26 @@ async fn provider_errors_map_their_kind_and_message_and_stay_the_source() { .call(serde_json::json!({ "query": "hi" })) .await .expect_err("a backend failure fails the call"); - assert_eq!(err.kind(), ToolErrorKind::Backend); - assert_eq!( - err.to_string(), - "web_search: backend returned 502: upstream down" - ); + assert_eq!(err.code, ErrorCode::ToolBackend); + assert_eq!(err.prose, "web_search: backend returned 502: upstream down"); } #[tokio::test] async fn rejects_missing_query() { let err = refused(serde_json::json!({ "count": 3 })).await; - assert_eq!(err.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(err.code, ErrorCode::ToolInvalidArguments); } #[tokio::test] async fn rejects_empty_and_oversized_query() { assert_eq!( - refused(serde_json::json!({ "query": " " })).await.kind(), - ToolErrorKind::InvalidArguments + refused(serde_json::json!({ "query": " " })).await.code, + ErrorCode::ToolInvalidArguments ); let long = "x".repeat(MAX_QUERY_LEN + 1); assert_eq!( - refused(serde_json::json!({ "query": long })).await.kind(), - ToolErrorKind::InvalidArguments + refused(serde_json::json!({ "query": long })).await.code, + ErrorCode::ToolInvalidArguments ); } @@ -316,43 +308,46 @@ async fn rejects_empty_and_oversized_query() { async fn rejects_unknown_fields_and_bad_optional_types() { // Unknown field. let err = refused(serde_json::json!({ "query": "hi", "nonsense": 1 })).await; - assert_eq!(err.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(err.code, ErrorCode::ToolInvalidArguments); + assert_eq!(err.prose, "web_search: invalid arguments"); assert!( - std::error::Error::source(&err).is_some(), - "a deserialization failure must preserve its serde source" + err.message.starts_with("web_search: invalid arguments: ") + && err.message.contains("nonsense"), + "a deserialization failure must keep serde's text in the message: {}", + err.message ); // Wrong type for count. assert_eq!( refused(serde_json::json!({ "query": "hi", "count": "five" })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); // Out-of-range count. assert_eq!( refused(serde_json::json!({ "query": "hi", "count": MAX_COUNT + 1 })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); assert_eq!( refused(serde_json::json!({ "query": "hi", "count": 0 })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); // Unknown enum values. assert_eq!( refused(serde_json::json!({ "query": "hi", "freshness": "yesterday" })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); assert_eq!( refused(serde_json::json!({ "query": "hi", "safesearch": "maybe" })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); } @@ -361,14 +356,14 @@ async fn rejects_invalid_domain_lists() { assert_eq!( refused(serde_json::json!({ "query": "hi", "include_domains": ["ok.com", "bad/host"] })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); let many: Vec = (0..30).map(|i| format!("h{i}.com")).collect(); assert_eq!( refused(serde_json::json!({ "query": "hi", "exclude_domains": many })) .await - .kind(), - ToolErrorKind::InvalidArguments + .code, + ErrorCode::ToolInvalidArguments ); } diff --git a/crates/plugin-web/src/search.rs b/crates/plugin-web/src/search.rs index ee5f83e05..572d31052 100644 --- a/crates/plugin-web/src/search.rs +++ b/crates/plugin-web/src/search.rs @@ -5,7 +5,7 @@ use std::fmt; use std::sync::Arc; -use runvil_plugin::{ToolError, ToolErrorKind, ToolOutput}; +use runvil_plugin::{Error, ErrorCode, ToolOutput, tool_error}; use crate::provider::{SearchErrorKind, SearchProvider}; @@ -120,32 +120,32 @@ impl WebSearch { /// the results as untrusted compact JSON. /// /// # Errors - /// Returns a [`ToolError`] for invalid arguments, a provider failure, + /// Returns an [`Error`] for invalid arguments, a provider failure, /// or a malformed response. - pub(crate) async fn call(&self, args: serde_json::Value) -> Result { + pub(crate) async fn call(&self, args: serde_json::Value) -> Result { // Validate and normalize arguments before the provider spends a // round; only the validated query reaches it. let query = SearchRequest::from_args(args)?.into_query(); let results = self.provider.search(query).await.map_err(|error| { - let kind = match error.kind() { - SearchErrorKind::Transport => ToolErrorKind::Transport, - SearchErrorKind::Backend => ToolErrorKind::Backend, + let code = match error.kind() { + SearchErrorKind::Transport => ErrorCode::ToolTransport, + SearchErrorKind::Backend => ErrorCode::ToolBackend, }; - ToolError::new(kind, format!("web_search: {error}")).with_source(error) + tool_error(code, format!("web_search: {error}")).with_cause(&error) })?; if let Some(index) = results.results.iter().position(|r| r.url.trim().is_empty()) { - return Err(ToolError::message(format!( - "web_search: malformed search response: result {index} has an empty url" - )) - .with_kind(ToolErrorKind::Backend)); + return Err(tool_error( + ErrorCode::ToolBackend, + format!("web_search: malformed search response: result {index} has an empty url"), + )); } let body = serde_json::to_string(&results).map_err(|source| { - ToolError::new( - ToolErrorKind::Other, + tool_error( + ErrorCode::ToolOther, "web_search: the results could not be rendered", ) - .with_source(source) + .with_cause(&source) })?; // The results embed third-party titles, URLs, and descriptions, so diff --git a/crates/plugin-web/src/web-tests.rs b/crates/plugin-web/src/web-tests.rs index 9af3c11aa..8ae0cd98d 100644 --- a/crates/plugin-web/src/web-tests.rs +++ b/crates/plugin-web/src/web-tests.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, InstallError, Pack, PackId, PackRegistry, ServiceKey, TOKIO_RUNTIME, ToolError, - ToolErrorKind, ToolId, + Error, ErrorCode, FactKey, HostServices, Pack, PackId, PackRegistry, ServiceKey, TOKIO_RUNTIME, + ToolId, }; use serde_json::{Value, json}; use tokio::runtime::Handle; @@ -57,7 +57,7 @@ fn name(text: &str) -> PackId { } /// The Pack built under the Pack id `id` with `config` and both services. -fn construct(id: &str, config: Value) -> Result, ToolError> { +fn construct(id: &str, config: Value) -> Result, Error> { (WEB.construct)(&name(id), config, &services_with(true, true)) } @@ -94,9 +94,15 @@ fn install_adds_the_web_pack_with_the_config_under_its_pack_id() { fn install_returns_the_registrys_refusal() { let mut packs = PackRegistry::new(); install(&mut packs, Value::Null).expect("the first install inserts web"); + let error = install(&mut packs, Value::Null).expect_err("a second install is refused"); + assert_eq!(error.code, ErrorCode::InvalidValue); assert_eq!( - install(&mut packs, Value::Null), - Err(InstallError::DuplicatePack { fqn: "runvil/web" }) + error.facts.get(&FactKey::Reason), + Some(&Value::from("duplicate_pack")) + ); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from("runvil/web")) ); } @@ -194,7 +200,7 @@ async fn a_call_reaches_the_tool_its_last_segment_names() { .call(fetch.context(), json!({ "url": "https://1.2.3.4/" })) .await .expect_err("the fetch policy refuses an IP literal host"); - assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); + assert_eq!(error.code, ErrorCode::ToolInvalidArguments); assert!( error.to_string().contains("ip literal host not allowed"), "the fetch tool's policy refused the call, got: {error}" diff --git a/crates/plugin-web/src/web.rs b/crates/plugin-web/src/web.rs index 6221961af..cb9fbcf09 100644 --- a/crates/plugin-web/src/web.rs +++ b/crates/plugin-web/src/web.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use runvil_plugin::{ - HostServices, InstallError, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceKey, - ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput, + Error, ErrorCode, HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceKey, + ToolContext, ToolId, ToolInfo, ToolOutput, tool_error, }; use serde_json::{Value, json}; use tokio::runtime::Handle; @@ -34,14 +34,14 @@ const WEB: PackInfo = /// is `null` when `config` has no `web` key. /// /// # Errors -/// Returns the registry's [`InstallError`], such as -/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/web` -/// already. +/// Returns the registry's refusal, such as an +/// [`InvalidValue`](runvil_plugin::ErrorCode::InvalidValue) error with the +/// reason `duplicate_pack` when `packs` holds `runvil/web` already. #[expect( clippy::needless_pass_by_value, reason = "the Plugin signature fixes the argument types" )] -pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { +pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> { packs.insert(WEB, config["web"].clone()) } @@ -76,15 +76,24 @@ fn construct( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !(config.is_null() || config == json!({})) { - return Err(ToolError::message("web takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "web takes no configuration", + )); } let provider = services.get(&SEARCH_PROVIDER).ok_or_else(|| { - ToolError::message("web needs runvil/search-provider, and this host provides none") + tool_error( + ErrorCode::ToolOther, + "web needs runvil/search-provider, and this host provides none", + ) })?; let runtime = services.get(&TOKIO_RUNTIME).ok_or_else(|| { - ToolError::message("web needs runvil/tokio-runtime, and this host provides none") + tool_error( + ErrorCode::ToolOther, + "web needs runvil/tokio-runtime, and this host provides none", + ) })?; let web = Web::new( name, @@ -96,11 +105,10 @@ fn construct( impl Web { /// The Pack over `fetch` and `search`, its tools named under `name`. - fn new(name: &PackId, fetch: WebFetch, search: WebSearch) -> Result { + fn new(name: &PackId, fetch: WebFetch, search: WebSearch) -> Result { let id = |tool: &str| { ToolId::parse(&format!("{name}/{tool}")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "web could not name its tools") - .with_source(e) + tool_error(ErrorCode::ToolBackend, "web could not name its tools").with_cause(&e) }) }; let tools = vec![ @@ -141,12 +149,15 @@ impl Pack for Web { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { match cx.tool().name() { "fetch" => self.fetch.call(args).await, "search" => self.search.call(args).await, - other => Err(ToolError::message(format!("web has no tool named {other}"))), + other => Err(tool_error( + ErrorCode::ToolOther, + format!("web has no tool named {other}"), + )), } }) } diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 3a0c5b316..6a4fe317d 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -3,10 +3,10 @@ //! [`Error`] is a `pub(crate)` internal error type, never part of the public API. //! A run it ends reports its kind and message in //! [`RunOutcome::Failed`](crate::RunOutcome::Failed), and -//! [`ParseError`](crate::prompt::ParseError), -//! [`CompletionError`](crate::model::CompletionError), and -//! [`runvil_types::tools::ToolError`] each reach that outcome through this -//! type, which classifies them and keeps their causes. +//! [`ParseError`](crate::prompt::ParseError), and the +//! [`runvil_types::error::Error`] a model round or a tool fails with, each +//! reach that outcome through this type, which classifies them and keeps +//! their causes. //! The `From` bridges that let internal `?` reach this type live in `convert`. //! //! A variant that keeps an originating error renders that error's text in @@ -125,14 +125,14 @@ pub(crate) enum Error { /// A model round or a catalog fetch failed, as the broker that performed /// it reported. /// - /// Holds the broker's [`CompletionError`](crate::model::CompletionError) - /// whole, so the closed kind, the fixed message, the opt-in detail, and - /// the cause chain survive until the run's failure message is built + /// Holds the broker's [`runvil_types::error::Error`] whole, so its model + /// code, its message with the provider detail and the cause chain, its + /// prose, and its facts survive until the run's failure message is built /// from them. The Engine branches on - /// its kind only: a context overflow takes the provider overflow path + /// its code only: a context overflow takes the provider overflow path /// and an empty reply takes the empty-answer path. #[error(transparent)] - Completion(crate::model::CompletionError), + Completion(runvil_types::error::Error), /// The run was cancelled, the caller dropped an effect the run was /// waiting on, or a block raised a caught `cancelled` error value again @@ -359,9 +359,9 @@ pub(crate) enum Error { /// A dispatched tool returned a model-safe failure. /// - /// The tool's own [`runvil_types::tools::ToolError`] is kept as the - /// cause, so the typed failure (and any transport/parse error the tool - /// wrapped) survives instead of being flattened to a string. + /// The tool's own [`runvil_types::error::Error`] is kept as the cause, + /// so its code and facts survive instead of being flattened to a + /// string. #[error("tool call failure: {message}")] Tool { /// The tool's model-safe failure message. diff --git a/crates/runvil-internal/engine/src/error/convert.rs b/crates/runvil-internal/engine/src/error/convert.rs index 78f1e59fa..caca53734 100644 --- a/crates/runvil-internal/engine/src/error/convert.rs +++ b/crates/runvil-internal/engine/src/error/convert.rs @@ -123,8 +123,8 @@ impl From for Error { } } -impl From for Error { - fn from(error: crate::model::CompletionError) -> Error { +impl From for Error { + fn from(error: runvil_types::error::Error) -> Error { Error::Completion(error) } } @@ -310,20 +310,21 @@ impl Error { None => Error::lua_message(raised.message), }, runvil_lua::ErrorKind::EmptyModelReply => { - let error = crate::model::CompletionError::new( - crate::model::CompletionErrorKind::EmptyReply, + let mut error = crate::model::completion_error( + runvil_types::error::ErrorCode::EmptyReply, raised.message, ); - Error::Completion( - match raised - .fields - .get("finish_reason") - .and_then(runvil_lua::ErrorField::as_str) - { - Some(reason) => error.with_finish_reason(reason), - None => error, - }, - ) + if let Some(reason) = raised + .fields + .get("finish_reason") + .and_then(runvil_lua::ErrorField::as_str) + { + error.facts.insert( + runvil_types::error::FactKey::FinishReason, + serde_json::Value::from(reason), + ); + } + Error::Completion(error) } runvil_lua::ErrorKind::Cancelled => Error::Interrupted, runvil_lua::ErrorKind::Tool => Error::Tool { diff --git a/crates/runvil-internal/engine/src/error/tests-chain.rs b/crates/runvil-internal/engine/src/error/tests-chain.rs index daa7902c6..d0fbe810b 100644 --- a/crates/runvil-internal/engine/src/error/tests-chain.rs +++ b/crates/runvil-internal/engine/src/error/tests-chain.rs @@ -1,12 +1,13 @@ //! The error-chain convention: a cause-bearing failure's message holds the //! cause's text, its source chain shows that text once, and its cause stays -//! reachable through `originating_error()`; a variant that still keeps a `source()` -//! shows each segment of its chain once, and so does the message a run -//! ends with; an internal failure built with a cause appends it once; and -//! the store probe names a missing store. +//! reachable through `originating_error()`; a model failure's message holds +//! its cause, so the message a run ends with shows it once; an internal +//! failure built with a cause appends it once; and the store probe names a +//! missing store. use runvil_lua::VfsOp; -use runvil_types::tools::{ToolError, ToolErrorKind}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::tool_error; use runvil_vfs::VfsError; use super::*; @@ -34,7 +35,7 @@ fn cause_bearing() -> Vec { message: "unexpected symbol near ".to_owned(), incomplete_input: true, }; - let tool = ToolError::new(ToolErrorKind::Backend, "the backend refused"); + let tool = tool_error(ErrorCode::ToolBackend, "the backend refused"); let schema = runvil_model_client::detail::tool_schema_new("echo", "", serde_json::json!([])) .expect_err("an array is not an object schema"); vec![ @@ -52,7 +53,7 @@ fn cause_bearing() -> Vec { cause: Box::new(syntax), }, Error::Tool { - message: tool.to_string(), + message: tool.prose.clone(), cause: Box::new(tool), }, Error::store_op( @@ -76,15 +77,13 @@ fn cause_bearing() -> Vec { ] } -/// One failure of each variant that still keeps its cause as a `source()`, -/// so a chain printer walks more than one segment. -#[track_caller] -fn source_bearing() -> Vec { - let transport = crate::model::CompletionErrorKind::Transport; - vec![Error::Completion( - crate::model::CompletionError::new(transport, transport.phrase()) - .with_source(std::io::Error::other("connection reset by peer")), - )] +/// A model failure built with a cause, which its message holds. +fn caused_completion() -> Error { + let transport = ErrorCode::Transport; + Error::Completion( + crate::model::completion_error(transport, crate::model::phrase(transport)) + .with_cause(&std::io::Error::other("connection reset by peer")), + ) } /// Asserts that no segment of `error`'s source chain repeats inside an @@ -117,17 +116,13 @@ fn no_segment_of_an_error_chain_repeats() { ); assert_eq!(assert_no_segment_repeats(&error), 1); } - for error in source_bearing() { - assert!( - assert_no_segment_repeats(&error) > 1, - "required a chain of at least two segments to walk, actual one for {error:?}" - ); - assert_eq!( - error.message(), - chain(&error).join(": "), - "the run's message holds each segment of the chain once" - ); - } + let completion = caused_completion(); + assert_eq!(assert_no_segment_repeats(&completion), 1); + assert_eq!( + completion.message(), + "the connection to the model backend failed: connection reset by peer", + "the run's message holds the model failure's cause once" + ); } #[test] diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index b5e70d210..a7d049628 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -59,8 +59,12 @@ fn a_schema_build_failure_renders_its_refusal_and_keeps_it_as_the_cause() { assert_eq!(bind.message(), bind.to_string()); let cause = bind .originating_error() - .and_then(|cause| cause.downcast_ref::()); - assert!(cause.is_some(), "the schema refusal is the cause"); + .and_then(|cause| cause.downcast_ref::()) + .expect("the schema refusal is the cause"); + assert_eq!( + cause.code, + runvil_types::error::ErrorCode::ToolSchemaInvalid + ); } #[test] @@ -113,33 +117,28 @@ fn typed_error_survives_the_lua_external_boundary() { #[test] fn completion_errors_preserve_their_causes_across_the_error_type_bridge() { - // A broker's failure arrives as a `CompletionError` with its concrete - // cause attached; the cause survives both the public - // `CompletionError::source` and the mapping onto this crate's error - // type. - use crate::model::CompletionError; - use runvil_model_client::model::CompletionErrorKind; + // A broker's failure arrives with its concrete cause in its message; + // the mapping onto this crate's error type keeps the failure whole, so + // the cause stays in the message and out of the prose. + use runvil_types::error::ErrorCode; let cause = std::io::Error::other("connection refused"); - let completion = CompletionError::new( - CompletionErrorKind::Unavailable, + let completion = crate::model::completion_error( + ErrorCode::Unavailable, "model access is turned off or not configured", ) - .with_source(cause); - assert_eq!(completion.kind(), CompletionErrorKind::Unavailable); - assert!( - std::error::Error::source(&completion).is_some(), - "the configuration cause must survive the public wrapper" - ); + .with_cause(&cause); let bridged = Error::from(completion); + let Error::Completion(error) = &bridged else { + panic!("the error type holds the completion error whole, got {bridged:?}"); + }; + assert_eq!(error.code, ErrorCode::Unavailable); assert!( - matches!(&bridged, Error::Completion(error) if error.kind() == CompletionErrorKind::Unavailable), - "the error type holds the completion error whole, got {bridged:?}" - ); - assert!( - std::error::Error::source(&bridged).is_some(), - "the cause must survive the bridge" + error.message.contains("connection refused"), + "the cause must survive the bridge: {error:?}" ); + assert_eq!(error.prose, "model access is turned off or not configured"); + assert!(bridged.to_string().contains("connection refused")); } #[test] diff --git a/crates/runvil-internal/engine/src/error/value.rs b/crates/runvil-internal/engine/src/error/value.rs index 1c6b0a5ba..a611c11af 100644 --- a/crates/runvil-internal/engine/src/error/value.rs +++ b/crates/runvil-internal/engine/src/error/value.rs @@ -1,8 +1,9 @@ //! The internal error as a Lua error value: the kind an author branches //! on and the kind's fields. +use runvil_types::error::{ErrorCode, FactKey}; + use super::{Error, join_task_ids}; -use crate::model::CompletionErrorKind; /// The internal type's rendering into the Lua error table: the kind an author /// branches on and the kind's fields. A failed model round renders as @@ -22,7 +23,7 @@ impl runvil_lua::ErrorValue for Error { | Error::CallDepthExceeded { .. } | Error::Substitution(_) => ErrorKind::Lua, Error::ContextExhausted { .. } => ErrorKind::ContextExhausted, - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { + Error::Completion(error) if error.code == ErrorCode::EmptyReply => { ErrorKind::EmptyModelReply } Error::Completion(_) => ErrorKind::ModelFailure, @@ -47,14 +48,26 @@ impl runvil_lua::ErrorValue for Error { } } + /// A model failure's table holds its prose, so the provider detail and + /// the causes its message keeps stay out of the text an author may hand + /// a model. + fn table_message(&self) -> String { + match self { + Error::Completion(error) => error.prose.clone(), + other => other.to_string(), + } + } + fn fields(&self) -> Vec<(String, runvil_lua::ErrorField)> { use runvil_lua::ErrorField; match self { Error::ContextExhausted { reason } => { vec![("reason".to_owned(), ErrorField::String(reason.clone()))] } - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => error - .finish_reason() + Error::Completion(error) if error.code == ErrorCode::EmptyReply => error + .facts + .get(&FactKey::FinishReason) + .and_then(serde_json::Value::as_str) .map(|reason| { vec![( "finish_reason".to_owned(), diff --git a/crates/runvil-internal/engine/src/execute/infer-tests.rs b/crates/runvil-internal/engine/src/execute/infer-tests.rs index ac93b6b3a..f1568a57f 100644 --- a/crates/runvil-internal/engine/src/execute/infer-tests.rs +++ b/crates/runvil-internal/engine/src/execute/infer-tests.rs @@ -8,7 +8,7 @@ use runvil_types::ids::RoundId; use serde_json::json; use super::*; -use crate::model::{CompletionErrorKind, ToolCall}; +use crate::model::ToolCall; use crate::test_support::recording::null_emitter; #[test] @@ -31,8 +31,8 @@ fn an_infer_round_answered_with_tool_calls_fails_as_a_retryable_malformed_reply( panic!("required a completion failure, actual {error:?}"); }; assert_eq!( - failure.kind(), - CompletionErrorKind::MalformedResponse, + failure.code, + ErrorCode::MalformedResponse, "a reply that broke protocol is malformed, so it may come back well formed" ); } diff --git a/crates/runvil-internal/engine/src/execute/infer.rs b/crates/runvil-internal/engine/src/execute/infer.rs index 56c6b70bf..bcc103550 100644 --- a/crates/runvil-internal/engine/src/execute/infer.rs +++ b/crates/runvil-internal/engine/src/execute/infer.rs @@ -12,12 +12,13 @@ //! reports the round and renders its text, and the yielding chain resumes //! with the outcome. +use runvil_types::error::ErrorCode; use runvil_types::event::ReplyOrigin; use runvil_types::event::lifecycle; use runvil_types::ids::RoundId; use crate::Error; -use crate::model::{Completion, CompletionError, CompletionErrorKind, CompletionResult}; +use crate::model::{Completion, CompletionResult, completion_error, phrase}; use super::context::ReportingHandles; use super::turn; @@ -59,10 +60,10 @@ fn accept_infer_completion( /// The malformed-reply failure for an infer round whose outcome breaks the /// round's protocol, with `specific` naming how. fn malformed_reply(specific: &str) -> Error { - let kind = CompletionErrorKind::MalformedResponse; - Error::Completion(CompletionError::new( - kind, - format!("{}: {specific}", kind.phrase()), + let code = ErrorCode::MalformedResponse; + Error::Completion(completion_error( + code, + format!("{}: {specific}", phrase(code)), )) } @@ -76,11 +77,11 @@ fn malformed_reply(specific: &str) -> Error { /// cancellation, so no `MODEL_TURN_FAILED` fires for it. /// /// # Errors -/// Returns the completion's failure, or an [`Error::Completion`] of kind +/// Returns the completion's failure, or an [`Error::Completion`] with code /// `MalformedResponse` when the round produced tool calls (none were /// advertised) or an unrecognized outcome. pub(super) fn accept_infer( - result: std::result::Result, CompletionError>, + result: std::result::Result, runvil_types::error::Error>, round: RoundId, reporting: &ReportingHandles, section: &str, diff --git a/crates/runvil-internal/engine/src/execute/run/effect-record.rs b/crates/runvil-internal/engine/src/execute/run/effect-record.rs index 0cdbae799..394083ff5 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect-record.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect-record.rs @@ -148,11 +148,12 @@ pub(crate) enum AnswerRecord { /// A failed answer as the log records it, serialized as /// `{"kind": .., "message": ..}`. /// -/// `kind` is the stable tag of the failure's kind: the `CompletionErrorKind` -/// tag for a model round (such as `malformed_response`), the -/// `ToolErrorKind` tag for a tool call (such as `invalid_arguments`), or -/// the `VfsError` tag for a store operation (such as `not_found`). -/// `message` is the failure's display text. +/// `kind` is the stable tag of the failure's kind: the model code's tag for +/// a model round (such as `malformed_response`), the tool kind's tag for a +/// tool call (such as `invalid_arguments`), or the `VfsError` tag for a +/// store operation (such as `not_found`). `message` is the failure's +/// display text, which for a model round or a tool call is the prose the +/// model read. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct FailureRecord { /// The stable tag of the failure's kind. diff --git a/crates/runvil-internal/engine/src/execute/run/effect-tests-store.rs b/crates/runvil-internal/engine/src/execute/run/effect-tests-store.rs new file mode 100644 index 000000000..4f7a1e488 --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/run/effect-tests-store.rs @@ -0,0 +1,61 @@ +//! The store answer's record: each outcome's fixed run-log JSON, and a +//! failure's tag and display text. + +use super::*; + +#[test] +fn the_store_answer_records_byte_identical_json_for_every_outcome() { + // `AnswerRecord::Vfs` holds the `VfsOutcome` itself, and its JSON + // is a fixed run-log format: the unit outcome as a bare string, the + // payload outcomes in serde's externally tagged form. + let unit = EffectAnswer::Vfs(Ok(VfsOutcome::Unit)).to_record(); + assert_eq!( + serde_json::to_string(&unit).expect("a store answer records"), + r#"{"Vfs":{"Ok":"Unit"}}"# + ); + assert_eq!(answer_round_trip(&unit), unit); + + let text = EffectAnswer::Vfs(Ok(VfsOutcome::Text("the read".to_owned()))).to_record(); + assert_eq!( + serde_json::to_string(&text).expect("a store answer records"), + r#"{"Vfs":{"Ok":{"Text":"the read"}}}"# + ); + assert_eq!(answer_round_trip(&text), text); + + let paths = EffectAnswer::Vfs(Ok(VfsOutcome::Paths(vec![ + "a.txt".to_owned(), + "b.txt".to_owned(), + ]))) + .to_record(); + assert_eq!( + serde_json::to_string(&paths).expect("a store answer records"), + r#"{"Vfs":{"Ok":{"Paths":["a.txt","b.txt"]}}}"# + ); + assert_eq!(answer_round_trip(&paths), paths); + + let flag = EffectAnswer::Vfs(Ok(VfsOutcome::Bool(true))).to_record(); + assert_eq!( + serde_json::to_string(&flag).expect("a store answer records"), + r#"{"Vfs":{"Ok":{"Bool":true}}}"# + ); + assert_eq!(answer_round_trip(&flag), flag); +} + +#[test] +fn a_failed_store_answer_records_the_vfs_errors_tag_and_display_text() { + let error = VfsError::NotFound { + path: "missing.txt".to_owned(), + }; + let expected = error.to_string(); + assert_eq!(expected, "not found: missing.txt"); + let record = EffectAnswer::Vfs(Err(error)).to_record(); + assert_eq!( + record, + AnswerRecord::Vfs(Err(FailureRecord::new("not_found", expected))) + ); + assert_eq!( + serde_json::to_string(&record).expect("a store answer records"), + r#"{"Vfs":{"Err":{"kind":"not_found","message":"not found: missing.txt"}}}"# + ); + assert_eq!(answer_round_trip(&record), record); +} diff --git a/crates/runvil-internal/engine/src/execute/run/effect-tests.rs b/crates/runvil-internal/engine/src/execute/run/effect-tests.rs index c60be26f1..9f94fd822 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect-tests.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect-tests.rs @@ -6,23 +6,27 @@ //! model and finish reason, never the bodies; a `Render` answer keeps its //! rendered tools whole; a `ToolCall` answer projects to its text and //! trust; and every failure projects to its kind's tag and its display -//! text. Each record round-trips through serde, as a run log and a replay +//! text, a model round's or a tool call's to its code's tag and its prose. Each record round-trips through serde, as a run log and a replay //! depend on, the public JSON record of every kind is the typed record's //! JSON and reads back identical, and a record written before the render -//! existed reads unchanged. +//! existed reads unchanged. The store answer's records are in the `store` +//! child. use std::fmt; use runvil_model_client::client::{CompletionResult, RawExchange, ToolCall}; -use runvil_model_client::model::CompletionErrorKind; +use runvil_model_client::model::completion_error; use runvil_types::packs::PackId; -use runvil_types::tools::{OutputTrust, ToolErrorKind}; +use runvil_types::tools::{OutputTrust, tool_error}; use serde::de::DeserializeOwned; use serde_json::json; use super::*; use crate::test_support::scenario::test_binding; +#[path = "effect-tests-store.rs"] +mod store; + /// Serializes the answer record and reads it back. #[track_caller] fn answer_round_trip(record: &AnswerRecord) -> AnswerRecord { @@ -269,14 +273,18 @@ fn a_canned_chat_answer_records_no_finish_reason() { } #[test] -fn a_failed_chat_answer_records_the_kinds_tag_and_the_errors_display_text() { - let error = CompletionError::new( - CompletionErrorKind::MalformedResponse, +fn a_failed_chat_answer_records_the_codes_tag_and_the_errors_prose() { + let mut error = completion_error( + ErrorCode::MalformedResponse, "the model backend sent a reply that could not be understood: \ completion stream ended without the [DONE] sentinel", - ) - .with_detail("provider said "); - let expected = error.to_string(); + ); + error.message.push_str(": provider said "); + assert!( + error.message.contains("hidden"), + "the message holds the detail" + ); + let expected = error.prose.clone(); assert!( expected.ends_with("without the [DONE] sentinel"), "the recorded text keeps the crate-authored specific: {expected}" @@ -312,31 +320,53 @@ fn a_tool_call_answer_records_the_outputs_text_and_trust() { } #[test] -fn a_failed_tool_call_answer_records_the_errors_display_text_and_hides_its_source() { - let error = ToolError::new(ToolErrorKind::Backend, "backend failed") - .with_source(std::io::Error::other("boom")); +fn a_failed_tool_call_answer_records_the_errors_prose_and_hides_its_cause() { + let error = tool_error(ErrorCode::ToolBackend, "backend failed") + .with_cause(&std::io::Error::other("boom")); + assert!( + error.message.contains("boom"), + "the message holds the cause" + ); let record = EffectAnswer::ToolCall(Err(error)).to_record(); assert_eq!( record, AnswerRecord::ToolCall(Err(FailureRecord::new("backend", "backend failed"))), - "the display text is the model-safe message; the source stays behind it" + "the prose is the model-safe text; the cause stays in the message" ); assert_eq!(answer_round_trip(&record), record); } +#[test] +fn a_failed_tool_call_answer_records_each_tool_codes_tag_and_any_other_code_as_other() { + for (code, tag) in [ + (ErrorCode::ToolInvalidArguments, "invalid_arguments"), + (ErrorCode::ToolBackend, "backend"), + (ErrorCode::ToolTransport, "transport"), + (ErrorCode::ToolOther, "other"), + (ErrorCode::Internal, "other"), + ] { + let record = EffectAnswer::ToolCall(Err(tool_error(code, "failed"))).to_record(); + assert_eq!( + record, + AnswerRecord::ToolCall(Err(FailureRecord::new(tag, "failed"))), + "{code:?}" + ); + } +} + #[test] fn a_failure_record_of_each_kind_round_trips_through_json() { let cases = [ ( - EffectAnswer::Chat(Err(CompletionError::new( - CompletionErrorKind::RateLimited, + EffectAnswer::Chat(Err(completion_error( + ErrorCode::RateLimited, "the model backend is limiting the request rate", ))), r#"{"Chat":{"Err":{"kind":"rate_limited","message":"the model backend is limiting the request rate"}}}"#, ), ( - EffectAnswer::ToolCall(Err(ToolError::new( - ToolErrorKind::InvalidArguments, + EffectAnswer::ToolCall(Err(tool_error( + ErrorCode::ToolInvalidArguments, "path is required", ))), r#"{"ToolCall":{"Err":{"kind":"invalid_arguments","message":"path is required"}}}"#, @@ -358,63 +388,6 @@ fn a_failure_record_of_each_kind_round_trips_through_json() { } } -#[test] -fn the_store_answer_records_byte_identical_json_for_every_outcome() { - // `AnswerRecord::Vfs` holds the `VfsOutcome` itself, and its JSON - // is a fixed run-log format: the unit outcome as a bare string, the - // payload outcomes in serde's externally tagged form. - let unit = EffectAnswer::Vfs(Ok(VfsOutcome::Unit)).to_record(); - assert_eq!( - serde_json::to_string(&unit).expect("a store answer records"), - r#"{"Vfs":{"Ok":"Unit"}}"# - ); - assert_eq!(answer_round_trip(&unit), unit); - - let text = EffectAnswer::Vfs(Ok(VfsOutcome::Text("the read".to_owned()))).to_record(); - assert_eq!( - serde_json::to_string(&text).expect("a store answer records"), - r#"{"Vfs":{"Ok":{"Text":"the read"}}}"# - ); - assert_eq!(answer_round_trip(&text), text); - - let paths = EffectAnswer::Vfs(Ok(VfsOutcome::Paths(vec![ - "a.txt".to_owned(), - "b.txt".to_owned(), - ]))) - .to_record(); - assert_eq!( - serde_json::to_string(&paths).expect("a store answer records"), - r#"{"Vfs":{"Ok":{"Paths":["a.txt","b.txt"]}}}"# - ); - assert_eq!(answer_round_trip(&paths), paths); - - let flag = EffectAnswer::Vfs(Ok(VfsOutcome::Bool(true))).to_record(); - assert_eq!( - serde_json::to_string(&flag).expect("a store answer records"), - r#"{"Vfs":{"Ok":{"Bool":true}}}"# - ); - assert_eq!(answer_round_trip(&flag), flag); -} - -#[test] -fn a_failed_store_answer_records_the_vfs_errors_tag_and_display_text() { - let error = VfsError::NotFound { - path: "missing.txt".to_owned(), - }; - let expected = error.to_string(); - assert_eq!(expected, "not found: missing.txt"); - let record = EffectAnswer::Vfs(Err(error)).to_record(); - assert_eq!( - record, - AnswerRecord::Vfs(Err(FailureRecord::new("not_found", expected))) - ); - assert_eq!( - serde_json::to_string(&record).expect("a store answer records"), - r#"{"Vfs":{"Err":{"kind":"not_found","message":"not found: missing.txt"}}}"# - ); - assert_eq!(answer_round_trip(&record), record); -} - /// Checks that `record`, a public record, is the JSON of `typed`, and that /// it reads back identical through its text and through the typed form. #[track_caller] @@ -482,8 +455,8 @@ fn every_kinds_public_record_is_json_that_round_trips_exactly() { CompletionResult::Text("the reply".to_owned()), "stop", )))), - EffectAnswer::Chat(Err(CompletionError::new( - CompletionErrorKind::RateLimited, + EffectAnswer::Chat(Err(completion_error( + ErrorCode::RateLimited, "the model backend is limiting the request rate", ))), EffectAnswer::Render(tools_pack("Shout the value.", "shout")), diff --git a/crates/runvil-internal/engine/src/execute/run/effect.rs b/crates/runvil-internal/engine/src/execute/run/effect.rs index a3dde60a8..573a5af55 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect.rs @@ -29,16 +29,17 @@ mod record; use std::sync::Arc; use std::time::Duration; +use runvil_types::error::ErrorCode; use runvil_types::event::ReplyOrigin; use runvil_types::ids::RoundId; pub use runvil_types::tools::{ToolCallOrigin, ToolCaller}; -use runvil_types::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil_types::tools::{ToolId, ToolInfo, ToolOutput}; use runvil_vfs::{Access, VfsError}; use serde::Serialize; use serde_json::Value; use crate::lua::{VfsOp, VfsOutcome}; -use crate::model::{Completion, CompletionError, Message, ToolSchema}; +use crate::model::{Completion, Message, ToolSchema}; use crate::model::{ModelBinding, Temperature}; pub use record::PackToolSets; @@ -318,7 +319,7 @@ impl Effect { #[derive(Debug)] pub enum EffectAnswer { /// The model round's completion or its failure. - Chat(std::result::Result, CompletionError>), + Chat(std::result::Result, runvil_types::error::Error>), /// Each offered Pack's rendered tools for each set, in the order the /// render asked: the same Packs, with each set the Pack was not /// offered into left empty. Every rendered tool's id starts with its @@ -332,7 +333,7 @@ pub enum EffectAnswer { Render(Vec>), /// The tool's own output or its own failure, before the Engine applies /// its trust rule. - ToolCall(std::result::Result), + ToolCall(std::result::Result), /// The outcome of the operation on the run's store view, or the /// store's own structured failure. Vfs(std::result::Result), @@ -354,8 +355,9 @@ impl EffectAnswer { /// The value is keyed by the answer's kind, such as /// `{"ToolCall":{"Ok":{"text":"done","trust":"trusted"}}}` or /// `"Dropped"`, and it round-trips exactly through `serde_json`. A - /// failure is recorded as `{"kind":..,"message":..}`: its kind's stable - /// tag and its display text. A completion is recorded as its reply or + /// failure is recorded as `{"kind":..,"message":..}`: its stable tag and + /// its text, which is the prose of a model or tool failure and the + /// display text of a store failure. A completion is recorded as its reply or /// its requested tool names beside the model and finish reason, and a /// render as its rendered tools whole. The round's request and response /// bodies travel as debug events, and its metrics travel in the turn's @@ -371,12 +373,15 @@ impl EffectAnswer { match self { EffectAnswer::Chat(result) => AnswerRecord::Chat(match result { Ok(completion) => Ok(ChatAnswerRecord::from(completion.as_ref())), - Err(error) => Err(FailureRecord::new(error.kind().tag(), error.to_string())), + Err(error) => Err(FailureRecord::new(error.code.tag(), error.prose.clone())), }), EffectAnswer::Render(rendered) => AnswerRecord::Render(rendered.clone()), EffectAnswer::ToolCall(result) => AnswerRecord::ToolCall(match result { Ok(output) => Ok(ToolAnswerRecord::new(output.text(), output.trust())), - Err(error) => Err(FailureRecord::new(error.kind().tag(), error.to_string())), + Err(error) => Err(FailureRecord::new( + tool_failure_tag(error.code), + error.prose.clone(), + )), }), EffectAnswer::Vfs(result) => AnswerRecord::Vfs(match result { Ok(outcome) => Ok(outcome.clone()), @@ -388,6 +393,18 @@ impl EffectAnswer { } } +/// The tag a failed tool call's record keeps. Stored run logs hold these +/// tags, so each tool code keeps the tag its kind had, and any other code is +/// `other`. +fn tool_failure_tag(code: ErrorCode) -> &'static str { + match code { + ErrorCode::ToolInvalidArguments => "invalid_arguments", + ErrorCode::ToolBackend => "backend", + ErrorCode::ToolTransport => "transport", + _ => "other", + } +} + #[cfg(test)] #[path = "effect-tests.rs"] mod tests; diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index a697f134f..d7db2eb23 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -19,7 +19,7 @@ use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_types::ids::{RoundId, TaskId}; -use runvil_types::tools::{ToolError, ToolOutput}; +use runvil_types::tools::ToolOutput; use runvil_vfs::{ExecId, VfsError, detail}; use crate::execute::infer::accept_infer; @@ -27,7 +27,7 @@ use crate::lua::{ Answer, ModelReport, ToolCallOutcome, VfsOp, VfsOutcome, prepare_dispatch, prepare_model_dispatch, }; -use crate::model::{Completion, CompletionError}; +use crate::model::Completion; use crate::{Error, Result}; use super::tasks::Stop; @@ -244,7 +244,7 @@ impl ChainOps<'_> { fn accept_infer( &self, round: RoundId, - result: std::result::Result, CompletionError>, + result: std::result::Result, runvil_types::error::Error>, ) -> Result { let chain = &*self.chain; accept_infer(result, round, chain.ctx.reporting(), chain.section_name()) @@ -260,7 +260,7 @@ impl ChainOps<'_> { fn accept_tool_call( &mut self, call: &ToolCallContinuation, - result: std::result::Result, + result: std::result::Result, ) -> Result { let chain = &mut *self.chain; // The shared dispatch body reports through this chain's emitter, so diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index e7e9b3bde..d750511c3 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs @@ -25,6 +25,7 @@ use std::sync::atomic::Ordering; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::event::{ReplyOrigin, lifecycle}; use runvil_types::ids::RoundId; use runvil_types::metrics::ToolCallEvent; @@ -38,9 +39,7 @@ use crate::lua::{ Answer, ChatOutcome, ChatResult, MessageList, OverflowReason, RenderedTools, ToolSet, output_reserve, precheck, project_messages, wire_name_of, }; -use crate::model::{ - Completion, CompletionErrorKind, CompletionResult, ModelBinding, ToolCall, detail, -}; +use crate::model::{Completion, CompletionResult, ModelBinding, ToolCall, detail}; use crate::{Error, Result}; use super::dispatch::Dispatched; @@ -341,7 +340,7 @@ impl ArrivedRound { /// other failure is a failed turn and the call's error. fn failed(&self, error: Error) -> std::result::Result, Error> { match error { - Error::Completion(error) if error.kind() == CompletionErrorKind::ContextOverflow => { + Error::Completion(error) if error.code == ErrorCode::ContextOverflow => { self.reporting .emitter .report(&self.section, lifecycle::MODEL_TURN_FAILED); @@ -350,14 +349,18 @@ impl ArrivedRound { self.reporting.turns.load(Ordering::Relaxed), ))) } - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { + Error::Completion(error) if error.code == ErrorCode::EmptyReply => { let turn = advance_turn(&self.reporting.turns); self.reporting .emitter .report(&self.section, lifecycle::MODEL_TURN_COMPLETED); Ok(Box::new(ChatResult { - outcome: ChatOutcome::Empty(Some(error.message().to_owned())), - finish_reason: error.finish_reason().map(str::to_owned), + outcome: ChatOutcome::Empty(Some(error.prose.clone())), + finish_reason: error + .facts + .get(&FactKey::FinishReason) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), turn, })) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs index a46305bce..2863d25ff 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -107,9 +107,7 @@ impl Scheduler { let worker = resolved.pos.section(&prompt).ok_or(Error::internal( "a resolved worker is absent from its slice", ))?; - // A section is list-only exactly when it parsed into list items, which - // the parser refuses for any section holding a Lua block. - if !worker.items().is_empty() { + if worker.is_list() { return Err(Error::lua_message(format!( "section `{}` is a list section, not a worker template", worker.name() diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index fdbe91fcc..e3db706de 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex}; use runvil_lua::LuaProgram; use runvil_model_client::client::Role; -use runvil_types::tools::{ToolError, ToolErrorKind, ToolId, ToolOutput}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::{ToolId, ToolOutput, tool_error}; use runvil_types::untrusted::GuardNonce; use runvil_vfs::{Access, VfsError}; use serde_json::{Value, json}; diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs b/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs index a49e230bf..60e62e4ab 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs @@ -8,14 +8,16 @@ use std::cell::RefCell; use std::collections::BTreeMap; use std::rc::Rc; +use runvil_model_client::model::context_overflow; use runvil_prompt::test_support::one_section; +use runvil_types::error::ErrorCode; use runvil_types::ids::RoundId; use super::fixtures::echo_tool; use super::models_loop::loop_scenario; use super::*; use crate::execute::run::{Effect, EffectAnswer, EffectRecord}; -use crate::model::{CompletionError, CompletionErrorKind}; +use crate::model::completion_error; use crate::test_support::scenario::chat_answer; /// One issued round as the driver saw it: its record, and the wire @@ -230,7 +232,7 @@ fn an_add_then_a_remove_between_two_sends_adds_nothing_to_the_log() { #[test] fn a_resend_with_no_changes_logs_no_messages_and_keeps_the_whole_request() { - let failure = CompletionError::new(CompletionErrorKind::MalformedResponse, "garbled reply"); + let failure = completion_error(ErrorCode::MalformedResponse, "garbled reply"); let (scenario, log) = echo_rounds( "messages:user('one')\n\ assert(not pcall(models.loop), 'the first round fails')\n\ @@ -285,7 +287,7 @@ fn re_inserting_an_identical_record_adds_nothing_to_the_log() { #[test] fn a_run_that_compacted_after_an_overflow_rebuilds_every_request_from_its_records() { - let overflow = CompletionError::context_overflow(Some(5000), Some(4096), "too large"); + let overflow = context_overflow(Some(5000), Some(4096), "too large"); let (scenario, log) = echo_rounds( "messages:system('be brief')\n\ messages:user('one')\n\ diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction.rs b/crates/runvil-internal/engine/src/execute/tests/compaction.rs index 45de2489a..50db999c1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction.rs @@ -6,12 +6,12 @@ //! `models_compact`. use runvil_prompt::test_support::{one_section, prompt}; +use runvil_types::error::ErrorCode; use super::fixtures::echo_tool; use super::gateway::overflowing; use super::models_loop::loop_scenario; use super::*; -use crate::model::CompletionErrorKind; #[path = "compaction-rules.rs"] mod rules; @@ -223,7 +223,7 @@ fn a_loop_inside_a_compactor_raises_loop_running() { fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { let gateway = ScriptedChat::new(vec![ fail( - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, 400, "This model's maximum context length is 4096 tokens.", ), diff --git a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs index 703597438..4922f9651 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -5,14 +5,22 @@ //! raise `empty_model_reply`). use runvil_prompt::test_support::{PromptSource, one_section, prompt}; +use runvil_types::error::{ErrorCode, FactKey}; use super::context::Recorder; use super::fixtures::echo_tool; use super::models_loop::{LOOP_TO_TEXT, loop_events, loop_scenario}; use super::*; -use crate::model::CompletionErrorKind; use crate::test_support::scenario::Trace; +/// The backend's finish reason a model failure names, when it names one. +fn finish_reason(error: &runvil_types::error::Error) -> Option<&str> { + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str) +} + #[test] fn falls_through_to_next_section() { let source = prompt( @@ -272,12 +280,11 @@ fn empty_stop_turn_without_tool_calls_fails() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), Some("stop")); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), Some("stop")); assert_eq!( - error.message(), - "the model replied with no text and no tool calls", - "the fixed phrase for the kind is the message" + error.prose, "the model replied with no text and no tool calls", + "the fixed phrase for the code is the prose" ); } other => panic!("expected an EmptyReply failure, got {other:?}"), @@ -296,8 +303,8 @@ fn empty_truncated_final_text_fails_without_truncation_detail() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), Some("length")); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), Some("length")); } other => panic!("expected an EmptyReply failure, got {other:?}"), } @@ -316,8 +323,8 @@ fn empty_turn_without_finish_reason_after_tool_call_fails() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), None); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), None); } other => panic!("expected an EmptyReply failure, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/execute/tests/fixtures.rs b/crates/runvil-internal/engine/src/execute/tests/fixtures.rs index 272446f09..d1da91641 100644 --- a/crates/runvil-internal/engine/src/execute/tests/fixtures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/fixtures.rs @@ -60,12 +60,17 @@ pub(super) fn untrusted_echo_tool() -> FnTool { /// /// The fixtures declare their arguments `required` in their JSON schema, so a /// missing or non-string value is a malformed call, not something to paper over -/// with an empty string. Returning a concrete [`ToolError`] makes a malformed +/// with an empty string. Returning a concrete tool error makes a malformed /// fixture call fail loudly instead of silently succeeding on `""`. -fn require_string_arg<'a>(args: &'a Value, key: &str) -> std::result::Result<&'a str, ToolError> { +fn require_string_arg<'a>( + args: &'a Value, + key: &str, +) -> std::result::Result<&'a str, runvil_types::error::Error> { args.get(key).and_then(Value::as_str).ok_or_else(|| { - ToolError::message(format!("fixture tool requires a string `{key}` argument")) - .with_kind(ToolErrorKind::InvalidArguments) + tool_error( + ErrorCode::ToolInvalidArguments, + format!("fixture tool requires a string `{key}` argument"), + ) }) } @@ -94,13 +99,10 @@ pub(super) fn structured_tool(body: &'static str, trusted: bool) -> FnTool { #[track_caller] pub(super) fn failing_tool() -> FnTool { FnTool::new("tools/failing", no_arguments(), |_| { - // Attach an inner cause so the executor's error can be checked for source - // preservation (item 4): the tool error must not be flattened to a string. + // The cause lands in the error's message only; the model reads the + // prose, which stays the tool's own text. let cause = std::io::Error::other("upstream socket reset"); - Err( - ToolError::new(ToolErrorKind::Backend, "the tool's own backend failed") - .with_source(cause), - ) + Err(tool_error(ErrorCode::ToolBackend, "the tool's own backend failed").with_cause(&cause)) }) .description("Always fail.") } diff --git a/crates/runvil-internal/engine/src/execute/tests/gateway.rs b/crates/runvil-internal/engine/src/execute/tests/gateway.rs index f330ee41c..faad41bc1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/gateway.rs +++ b/crates/runvil-internal/engine/src/execute/tests/gateway.rs @@ -5,8 +5,8 @@ use super::*; /// The broker-reported context overflow every round of the overflowing /// model fails with. -fn context_overflow() -> crate::model::CompletionError { - crate::model::CompletionError::context_overflow( +fn context_overflow() -> runvil_types::error::Error { + runvil_model_client::model::context_overflow( Some(5000), Some(4096), "the request is larger than the model's context window", diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs index 80e44d1fa..d6b6a0de3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs @@ -209,7 +209,7 @@ fn the_chat_agent_shape_leaves_the_oldest_records_out_of_a_summary_past_its_budg let mut replies = vec![text("ok"); long_turns]; replies.extend([ fail( - crate::model::CompletionErrorKind::ContextOverflow, + runvil_types::error::ErrorCode::ContextOverflow, 400, "This model's maximum context length is 131072 tokens.", ), diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs index 0060adc8f..a15d209de 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs @@ -57,7 +57,7 @@ fn models_loop_raises_context_exhaustion_at_the_call_site() { #[test] fn an_explicit_compactors_fail_invocation_reports_the_provider_reason() { let gateway = ScriptedChat::new(vec![fail( - crate::model::CompletionErrorKind::ContextOverflow, + runvil_types::error::ErrorCode::ContextOverflow, 400, "This model's maximum context length is 4096 tokens.", )]); diff --git a/crates/runvil-internal/engine/src/execute/tests/offering.rs b/crates/runvil-internal/engine/src/execute/tests/offering.rs index da5c15456..ca3d71b2b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering.rs @@ -21,7 +21,7 @@ fn tool_at(id: &str, schema: Value) -> FnTool { let tool = FnTool::new(id, schema, move |args| { let value = args["value"] .as_str() - .ok_or_else(|| ToolError::message("the value argument is required"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "the value argument is required"))?; Ok(ToolOutput::trusted(format!("{label}: {value}"))) }); tool.description(id) diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs index 304098168..6a14b8f02 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs @@ -29,15 +29,21 @@ const FILES: &str = "test/tools/files"; #[track_caller] fn files_tool() -> FnTool { FnTool::new(FILES, json!({ "type": "object" }), |_| { - Err(ToolError::message("the suite's driver performs this tool")) + Err(tool_error( + ErrorCode::ToolOther, + "the suite's driver performs this tool", + )) }) .description("reads or writes one of the run's files") } /// Performs one files call through `access`: `read` names a file whose /// text is the output, and `write` names a file that receives `text`. -fn use_access(access: &Access, args: &Value) -> std::result::Result { - let failed = |error: VfsError| ToolError::message(error.to_string()); +fn use_access( + access: &Access, + args: &Value, +) -> std::result::Result { + let failed = |error: VfsError| tool_error(ErrorCode::ToolOther, error.to_string()); if let Some(path) = args["read"].as_str() { return access .read_string(path) diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs index 9cec22a24..61ba09825 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs @@ -43,7 +43,7 @@ fn trusted_listing_tool() -> FnTool { /// `tools/failing_listing`, which describes tools and always fails. fn failing_listing_tool() -> FnTool { FnTool::new("tools/failing_listing", no_arguments(), |_| { - Err(ToolError::message("the servers are down")) + Err(tool_error(ErrorCode::ToolOther, "the servers are down")) }) .description("List the tools of servers that are down.") .describes_tools() diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs index 2466407e0..32c83bced 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs @@ -9,13 +9,13 @@ //! and `compaction`. use runvil_prompt::test_support::{one_section, prompt}; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use super::context::Recorder; use super::fixtures::{echo_tool, failing_tool, untrusted_echo_tool}; use super::models_loop::{LOOP_TO_TEXT, loop_events, loop_scenario}; use super::*; -use crate::model::CompletionErrorKind; use crate::test_support::scenario::{EXECUTION, assert_model_failure}; /// The one-section loop prompt running `lua` under an explicit @@ -153,8 +153,8 @@ fn tool_loop_dispatches_then_returns_text() { #[test] fn a_failing_tool_becomes_an_untrusted_error_result_and_the_loop_continues() { - // A bound tool's own failure is the call's result record - the ToolError - // message, nonce-wrapped as untrusted - with TOOL_CALL_FAILED firing + // A bound tool's own failure is the call's result record - the tool + // error's prose, nonce-wrapped as untrusted - with TOOL_CALL_FAILED firing // alongside it, and the loop continues to the terminal reply. let gateway = ScriptedChat::new(vec![ calls(&[("call_x", "echo", "{\"value\":\"x\"}")]), @@ -235,7 +235,7 @@ fn repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap() { #[test] fn a_failing_model_turn_is_reported_before_the_error_propagates() { let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::ServerError, + ErrorCode::ServerError, 500, "private backend response", )]); @@ -247,7 +247,7 @@ fn a_failing_model_turn_is_reported_before_the_error_propagates() { let trace = loop_scenario(source).chat(&gateway).run(); assert_model_failure( trace.error(), - CompletionErrorKind::ServerError, + ErrorCode::ServerError, "the model backend reported a fault of its own (status 500)", ); let recorder = Recorder::default(); @@ -267,11 +267,7 @@ fn a_failing_model_turn_is_reported_before_the_error_propagates() { #[test] fn a_caught_model_failure_reads_as_the_model_failure_kind() { - let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::ServerError, - 500, - "backend fault", - )]); + let gateway = ScriptedChat::new(vec![fail(ErrorCode::ServerError, 500, "backend fault")]); let source = one_section( "messages:user('go') local ok, err = pcall(models.loop) @@ -307,7 +303,7 @@ fn a_client_rejection_without_overflow_signatures_stays_a_rejection() { // overflow, so the bare rejection propagates and no compactor is // invoked. let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::Rejected, + ErrorCode::Rejected, 400, "invalid request: unknown field", )]); @@ -316,7 +312,7 @@ fn a_client_rejection_without_overflow_signatures_stays_a_rejection() { .run(); assert_model_failure( trace.error(), - CompletionErrorKind::Rejected, + ErrorCode::Rejected, "the model backend rejected the request (status 400)", ); } diff --git a/crates/runvil-internal/engine/src/lua/tests/errors.rs b/crates/runvil-internal/engine/src/lua/tests/errors.rs index d90fef503..c6a895c4c 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors.rs @@ -368,9 +368,15 @@ fn a_raised_table_maps_onto_the_executor_error_type_by_kind() { }; match crate::Error::from(Error::Raised(empty)) { crate::Error::Completion(error) => { - assert_eq!(error.kind(), crate::model::CompletionErrorKind::EmptyReply); - assert_eq!(error.message(), "the model returned an empty turn"); - assert_eq!(error.finish_reason(), Some("length")); + assert_eq!(error.code, runvil_types::error::ErrorCode::EmptyReply); + assert_eq!(error.prose, "the model returned an empty turn"); + assert_eq!( + error + .facts + .get(&runvil_types::error::FactKey::FinishReason) + .and_then(serde_json::Value::as_str), + Some("length") + ); } other => panic!("expected the empty-reply failure, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/model.rs b/crates/runvil-internal/engine/src/model.rs index 252de57ea..69449916f 100644 --- a/crates/runvil-internal/engine/src/model.rs +++ b/crates/runvil-internal/engine/src/model.rs @@ -1,7 +1,8 @@ //! The model vocabulary a caller exchanges with a run: what a `Chat` effect //! includes ([`Message`], [`ToolSchema`], [`ModelBinding`]) and what its -//! answer returns ([`Completion`], [`CompletionResult`], -//! [`CompletionError`]), plus the prompt-local binding vocabulary +//! answer returns ([`Completion`], [`CompletionResult`], or the +//! [`runvil_types::error::Error`] [`completion_error`] builds), plus the +//! prompt-local binding vocabulary //! ([`ModelSet`], [`ModelInvocation`]) and the model //! identity a run's roles are bound to ([`ModelInfo`], [`ModelId`]). //! @@ -15,7 +16,7 @@ //! //! - This crate's test support and its test suites, which also need names //! the production surface does not re-export, such as -//! `CompletionOptions`, `RawExchange`, `Role`, and `ToolSchemaError`. +//! `CompletionOptions`, `RawExchange`, and `Role`. //! - The crate's bench, `benches/models_loop.rs`, a separate crate that //! cannot see a `pub(crate)` surface. //! @@ -30,7 +31,7 @@ pub(crate) use runvil_model_client::client::{ // documented. pub(crate) use runvil_model_client::detail; pub(crate) use runvil_model_client::model::{ - CompletionError, CompletionErrorKind, ModelBinding, ModelInvocation, ModelSet, Temperature, + ModelBinding, ModelInvocation, ModelSet, Temperature, completion_error, phrase, }; pub(crate) use runvil_types::models::{ModelId, ModelInfo, ModelRole, ThinkingMode}; diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index e30c1f67d..40739fbf4 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -26,8 +26,9 @@ //! suites hold without rewriting their assertions. `FnTool` is the //! fixture tool, a closure answered at once. +use runvil_types::error::ErrorCode; use runvil_types::event::Event; -use runvil_types::tools::ToolError; +use runvil_types::tools::tool_error; use crate::Error; use crate::execute::{Effect, EffectAnswer, EffectId, IssuedEffect, Run, RunOutcome, Step}; @@ -88,9 +89,10 @@ pub fn perform_locally( match effect { Effect::Chat { .. } => chat(effect), Effect::Render { offered, .. } => EffectAnswer::Render(offered.clone()), - Effect::ToolCall { alias, .. } => EffectAnswer::ToolCall(Err(ToolError::message(format!( - "no tool is bound as {alias} in this test" - )))), + Effect::ToolCall { alias, .. } => EffectAnswer::ToolCall(Err(tool_error( + ErrorCode::ToolOther, + format!("no tool is bound as {alias} in this test"), + ))), Effect::Vfs { access, op } => EffectAnswer::Vfs(run_store_op(access, op.clone())), Effect::Timer { .. } => EffectAnswer::Timer, } diff --git a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs index ad0e8dbc2..405473428 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs @@ -10,8 +10,9 @@ use std::sync::mpsc::Sender; use std::thread::JoinHandle; use runvil_types::cancel::CancelHandle; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; -use runvil_types::tools::{ToolError, ToolId}; +use runvil_types::tools::{ToolId, tool_error}; use runvil_vfs::{Origin, VfsError, VfsRef}; use super::super::FnTool; @@ -23,7 +24,7 @@ use super::{Chat, Matcher}; use crate::Error; use crate::execute::{AnswerRecord, Effect, EffectAnswer, EffectId, EffectRecord, Run, RunOutcome}; use crate::lua::run_store_op; -use crate::model::{CompletionError, CompletionErrorKind}; +use crate::model::completion_error; /// How a scenario answers its run's effects: a store operation on the /// effect's own access, a timer at once, a render with every offered tool @@ -73,15 +74,16 @@ impl Answers { EffectAnswer::Chat(answer.map(Box::new)) } Some(Chat::Closure(answer)) => answer(effect), - None => EffectAnswer::Chat(Err(CompletionError::new( - CompletionErrorKind::Unavailable, + None => EffectAnswer::Chat(Err(completion_error( + ErrorCode::Unavailable, "model access is turned off or not configured", ))), }, Effect::Render { offered, .. } => EffectAnswer::Render(offered.clone()), Effect::ToolCall { tool, args, .. } => match self.tools.get(tool) { Some(tool) => EffectAnswer::ToolCall(tool.call_now(args.clone())), - None => EffectAnswer::ToolCall(Err(ToolError::message( + None => EffectAnswer::ToolCall(Err(tool_error( + ErrorCode::ToolOther, "the tool the call names has no implementation in the test driver's tool table", ))), }, diff --git a/crates/runvil-internal/engine/src/test_support/scenario.rs b/crates/runvil-internal/engine/src/test_support/scenario.rs index 3b4fd372c..d51815813 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario.rs @@ -22,6 +22,7 @@ use std::thread; use runvil_lua::ToolOutputKind; use runvil_model_client::model::ModelInvocation; use runvil_types::cancel::CancelHandle; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use runvil_types::seed::RunSeed; use runvil_types::timestamp::Timestamp; @@ -33,9 +34,7 @@ use super::scripted_chat::ScriptedChat; use crate::Error; use crate::execute::{Effect, EffectAnswer, Run, RunConfig, RunLimits}; use crate::lua::ToolBinding; -use crate::model::{ - CompletionErrorKind, ModelBinding, ModelId, ModelInfo, ModelRole, ModelSet, ThinkingMode, -}; +use crate::model::{ModelBinding, ModelId, ModelInfo, ModelRole, ModelSet, ThinkingMode}; use crate::prompt::Prompt; #[path = "scenario-driving.rs"] @@ -95,16 +94,13 @@ pub(crate) fn test_binding(alias: &str, description: &str, model: &str) -> Model ) } -/// Asserts `error` is a model failure of `kind` whose message is `message`. +/// Asserts `error` is a model failure of `code` whose prose is `prose`. #[track_caller] -pub(crate) fn assert_model_failure(error: &Error, kind: CompletionErrorKind, message: &str) { +pub(crate) fn assert_model_failure(error: &Error, code: ErrorCode, prose: &str) { let Error::Completion(failure) = error else { panic!("expected a model failure, got {error:?}"); }; - assert_eq!( - (failure.kind(), failure.to_string().as_str()), - (kind, message) - ); + assert_eq!((failure.code, failure.prose.as_str()), (code, prose)); } /// The edit a scenario applies to its run's config before the run is diff --git a/crates/runvil-internal/engine/src/test_support/scripted_chat.rs b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs index 25bdcb4c5..9482befaa 100644 --- a/crates/runvil-internal/engine/src/test_support/scripted_chat.rs +++ b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs @@ -13,7 +13,8 @@ use std::sync::{Arc, Mutex, PoisonError}; use runvil_model_client::client::{Completion, CompletionResult, Message, ToolCall, ToolSchema}; -use runvil_model_client::model::{CompletionError, CompletionErrorKind, CompletionOptions}; +use runvil_model_client::model::{CompletionOptions, completion_error, empty_reply, phrase}; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::metrics::CallMetrics; use serde_json::Value; @@ -69,15 +70,15 @@ pub(crate) fn calls(calls: &[(&str, &str, &str)]) -> ScriptedReply { } } -/// A failed round of `kind`, with HTTP `status` and response `body`. +/// A failed round of `code`, with HTTP `status` and response `body`. /// -/// For every kind it builds the kind's phrase with the status appended and -/// the body as the detail, so each test names its kind directly. +/// For every code it builds the code's phrase with the status appended and +/// the body as the detail, so each test names its code directly. #[must_use] -pub(crate) fn fail(kind: CompletionErrorKind, status: u16, body: &str) -> ScriptedReply { +pub(crate) fn fail(code: ErrorCode, status: u16, body: &str) -> ScriptedReply { ScriptedReply::Failure { - kind, - message: format!("{} (status {status})", kind.phrase()), + code, + message: format!("{} (status {status})", phrase(code)), detail: Some(body.to_owned()), } } @@ -109,11 +110,11 @@ pub enum ScriptedReply { }, /// A failed round. /// - /// A fresh [`CompletionError`] is built for every answer, because the - /// error is not `Clone`. + /// A fresh [`Error`] is built for every answer, and the detail joins its + /// message only, never its prose. Failure { - /// The failure's kind. - kind: CompletionErrorKind, + /// The failure's code. + code: ErrorCode, /// The failure's message. message: String, /// The provider text behind the failure, when there is some. @@ -124,7 +125,7 @@ pub enum ScriptedReply { impl ScriptedReply { /// Answers a round with this reply: the answer /// [`ScriptedChat::complete_now`] gives. - pub(crate) fn answer(&self) -> Result { + pub(crate) fn answer(&self) -> Result { match self { ScriptedReply::Text { model, @@ -135,11 +136,13 @@ impl ScriptedReply { } => { let reasoning = reasoning.as_deref().filter(|text| !text.trim().is_empty()); if content.trim().is_empty() { - let error = CompletionError::empty_reply(reasoning.is_some()); - return Err(match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - }); + let mut error = empty_reply(reasoning.is_some()); + if let Some(reason) = finish_reason { + error + .facts + .insert(FactKey::FinishReason, Value::from(reason.as_str())); + } + return Err(error); } let mut completion = Completion::from_result(CompletionResult::Text(content.clone()), model)?; @@ -162,13 +165,14 @@ impl ScriptedReply { Completion::from_result(CompletionResult::ToolCalls(calls), model) } ScriptedReply::Failure { - kind, + code, message, detail, } => { - let mut error = CompletionError::new(*kind, message); + let mut error = completion_error(*code, message); if let Some(detail) = detail { - error = error.with_detail(detail); + error.message.push_str(": "); + error.message.push_str(detail); } Err(error) } @@ -285,7 +289,7 @@ impl ScriptedChat { messages: &[Message], tools: &[ToolSchema], options: &CompletionOptions, - ) -> Result { + ) -> Result { let index = { let mut requests = self.requests.lock().unwrap_or_else(PoisonError::into_inner); let index = requests.len(); diff --git a/crates/runvil-internal/engine/src/test_support/tools.rs b/crates/runvil-internal/engine/src/test_support/tools.rs index c06ee7e44..101d2f69a 100644 --- a/crates/runvil-internal/engine/src/test_support/tools.rs +++ b/crates/runvil-internal/engine/src/test_support/tools.rs @@ -10,11 +10,11 @@ use std::sync::Arc; -use runvil_types::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil_types::error::Error; +use runvil_types::tools::{ToolId, ToolInfo, ToolOutput}; /// The closure an [`FnTool`] answers its calls with. -type FnToolHandler = - dyn Fn(serde_json::Value) -> Result + Send + Sync + 'static; +type FnToolHandler = dyn Fn(serde_json::Value) -> Result + Send + Sync + 'static; /// A fixture tool whose call is a closure, answered at once by the serial /// test drivers. @@ -37,7 +37,7 @@ impl FnTool { pub(crate) fn new( id: &str, schema: serde_json::Value, - handler: impl Fn(serde_json::Value) -> Result + Send + Sync + 'static, + handler: impl Fn(serde_json::Value) -> Result + Send + Sync + 'static, ) -> FnTool { let id = ToolId::parse(id).unwrap_or_else(|error| panic!("tool id {id:?}: {error}")); FnTool { @@ -106,7 +106,7 @@ impl FnTool { } /// Answers one call with `args` at once. - pub(crate) fn call_now(&self, args: serde_json::Value) -> Result { + pub(crate) fn call_now(&self, args: serde_json::Value) -> Result { (self.handler)(args) } } diff --git a/crates/runvil-internal/lua/src/alias.rs b/crates/runvil-internal/lua/src/alias.rs index 2abcc5947..b3c2e0887 100644 --- a/crates/runvil-internal/lua/src/alias.rs +++ b/crates/runvil-internal/lua/src/alias.rs @@ -1,12 +1,13 @@ -//! The one prompt-local alias grammar, shared by the `tools` and `models` -//! Engine globals. +//! The [`Alias`] type: an adhoc tool's name, checked against the +//! prompt-local alias grammar of [`runvil_types::names::is_alias`]. + +use runvil_types::names::is_alias; use crate::error::{Error, Result}; /// A prompt-local alias that passed the supported wire grammar. /// -/// An adhoc tool's name and a model label share the one rule. An alias -/// never contains `/`, so it never reads as a tool id. +/// An alias never contains `/`, so it never reads as a tool id. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Alias(String); @@ -18,13 +19,7 @@ impl Alias { /// with a non-letter, or contains a character other than a letter, /// digit, `_`, or `-` after its first byte. pub(crate) fn parse(alias: String) -> Result { - let bytes = alias.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if valid { + if is_alias(&alias) { Ok(Alias(alias)) } else { Err(Error::Lua(format!( @@ -44,38 +39,8 @@ impl From for String { mod tests { use super::*; - #[test] - fn the_alias_grammar_accepts_letters_digits_underscores_and_dashes() { - for alias in [ - "a", - "A", - "search", - "web_fetch", - "web-fetch2", - &"z".repeat(64), - ] { - let parsed = Alias::parse(alias.to_owned()).expect("the alias validates"); - assert_eq!(String::from(parsed), alias, "{alias:?} keeps its text"); - } - } - #[test] fn the_alias_grammar_rejects_every_other_shape() { - for alias in [ - "", - "1search", - "_search", - "-search", - "web fetch", - "web.fetch", - "web/fetch", - &"a".repeat(65), - ] { - assert!( - Alias::parse(alias.to_owned()).is_err(), - "{alias:?} must not validate" - ); - } let error = Alias::parse("1search".to_owned()).expect_err("the message names the grammar"); assert!( error diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index a7e73f7a4..7f8ed675e 100644 --- a/crates/runvil-internal/lua/src/dispatch-tests.rs +++ b/crates/runvil-internal/lua/src/dispatch-tests.rs @@ -2,7 +2,8 @@ //! `prepare_dispatch` and `prepare_model_dispatch` over fixture tools as //! data and canned outputs, reported to a recorder. -use runvil_types::tools::{OutputTrust, ToolError, ToolErrorKind, ToolInfo, ToolOutput}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::{OutputTrust, ToolInfo, ToolOutput, tool_error}; use super::*; use crate::test_vm::{TEST_SECTION, echo_tool, test_nonce, tool_at}; @@ -72,7 +73,7 @@ fn prepare_dispatch_turns_a_canned_tool_error_into_the_typed_error() { let error = dispatch_body( &failing, None, - Err(ToolError::message("canned failure").with_kind(ToolErrorKind::Backend)), + Err(tool_error(ErrorCode::ToolBackend, "canned failure")), &test_nonce(), recorder.emitter(), TEST_SECTION, @@ -105,7 +106,10 @@ fn a_model_issued_tool_failure_becomes_untrusted_failure_text_under_its_call_id( let outcome = prepare_model_dispatch( &failing, None, - Err(ToolError::message("the tool's own backend failed").with_kind(ToolErrorKind::Backend)), + Err(tool_error( + ErrorCode::ToolBackend, + "the tool's own backend failed", + )), &nonce, recorder.emitter(), TEST_SECTION, @@ -217,7 +221,7 @@ fn the_listing_rule_needs_the_flag_and_untrusted_output() { let failed = prepare_dispatch( &binding("tool", &listing_tool()), None, - Err(ToolError::message("the servers are down")), + Err(tool_error(ErrorCode::ToolOther, "the servers are down")), &nonce, Recorder::default().emitter(), TEST_SECTION, diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 312a4ef18..30f97a813 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -14,7 +14,7 @@ use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; -use runvil_types::tools::{OutputTrust, ToolError, ToolOutput}; +use runvil_types::tools::{OutputTrust, ToolOutput}; use runvil_types::untrusted::GuardNonce; use crate::bindings::ToolBinding; @@ -108,7 +108,7 @@ impl ToolDispatch { pub fn prepare_dispatch( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, @@ -135,7 +135,7 @@ pub fn prepare_dispatch( pub(crate) fn dispatch_body( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, @@ -206,7 +206,7 @@ pub(crate) fn dispatch_body( pub fn prepare_model_dispatch( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, diff --git a/crates/runvil-internal/lua/src/error-tests.rs b/crates/runvil-internal/lua/src/error-tests.rs index eb53001d2..1f19d180c 100644 --- a/crates/runvil-internal/lua/src/error-tests.rs +++ b/crates/runvil-internal/lua/src/error-tests.rs @@ -1,7 +1,8 @@ //! Tests for the internal error: the cause-bearing variants' chains and the //! quota variant's message. -use runvil_types::tools::{ToolError, ToolErrorKind}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::tool_error; use runvil_vfs::VfsError; use super::{Error, QuotaResource}; @@ -34,10 +35,7 @@ fn no_segment_of_a_cause_bearing_error_chain_repeats() { message: syntax.to_string(), cause: Box::new(syntax), }, - Error::tool(ToolError::new( - ToolErrorKind::Backend, - "the backend refused", - )), + Error::tool(tool_error(ErrorCode::ToolBackend, "the backend refused")), Error::store( &VfsOp::Read { path: "a.txt".to_owned(), diff --git a/crates/runvil-internal/lua/src/error-value-store.rs b/crates/runvil-internal/lua/src/error-value-store.rs index 44f670dfe..3dd34c4c6 100644 --- a/crates/runvil-internal/lua/src/error-value-store.rs +++ b/crates/runvil-internal/lua/src/error-value-store.rs @@ -1,5 +1,9 @@ //! The field shape a store failure's error value carries: its `reason` -//! tag and the [`VfsError`](runvil_vfs::VfsError) variant's fields. +//! tag and the [`VfsError`](runvil_vfs::VfsError) variant's fields, and the +//! universal error the failure becomes where it leaves the VFS. + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; use super::ErrorField; @@ -74,3 +78,59 @@ pub fn store_error_value_fields(error: &runvil_vfs::VfsError) -> Vec<(String, Er fields.extend(store_error_fields(error)); fields } + +/// Returns the universal error a store failure becomes where it leaves the +/// VFS: the code named after the variant, the variant's fields as facts under +/// the names a Lua error table gives them, the failure's display text as the +/// message, and the code's default prose. +#[must_use] +pub fn store_error(error: &runvil_vfs::VfsError) -> Error { + let code = store_error_code(error); + let mut converted = Error::new(code, error.to_string(), code.prose()); + for (name, field) in store_error_fields(error) { + let key = match name.as_str() { + "path" => FactKey::Path, + "valid_up_to" => FactKey::ValidUpTo, + "rule" => FactKey::Rule, + "anchor" => FactKey::Anchor, + "count" => FactKey::Count, + _ => continue, + }; + let value = match field { + ErrorField::String(text) => Value::from(text), + ErrorField::Integer(number) => Value::from(number), + }; + converted.facts.insert(key, value); + } + converted +} + +/// The code named after the variant. +fn store_error_code(error: &runvil_vfs::VfsError) -> ErrorCode { + use runvil_vfs::VfsError; + match error { + VfsError::NotFound { .. } => ErrorCode::NotFound, + VfsError::AlreadyExists { .. } => ErrorCode::AlreadyExists, + VfsError::NotADirectory { .. } => ErrorCode::NotADirectory, + VfsError::IsADirectory { .. } => ErrorCode::IsADirectory, + VfsError::DirectoryNotEmpty { .. } => ErrorCode::DirectoryNotEmpty, + VfsError::NotUtf8 { .. } => ErrorCode::NotUtf8, + VfsError::InvalidPath { .. } => ErrorCode::InvalidPath, + VfsError::InvalidRange { .. } => ErrorCode::InvalidRange, + VfsError::Anchor { .. } => ErrorCode::Anchor, + VfsError::PermissionDenied { .. } => ErrorCode::PermissionDenied, + VfsError::ApprovalRequired { .. } => ErrorCode::ApprovalRequired, + VfsError::ScopeEnded { .. } => ErrorCode::ScopeEnded, + VfsError::ReadOnly { .. } => ErrorCode::ReadOnly, + VfsError::OutsideRoot { .. } => ErrorCode::OutsideRoot, + VfsError::RootProtected { .. } => ErrorCode::RootProtected, + VfsError::NoStore => ErrorCode::NoStore, + VfsError::CrossMount { .. } => ErrorCode::CrossMount, + VfsError::Io { .. } => ErrorCode::Io, + VfsError::Unsupported { .. } => ErrorCode::Unsupported, + VfsError::Conflict { .. } => ErrorCode::Conflict, + // `VfsError` is `#[non_exhaustive]`: a variant added later is a + // backend failure until it is named here. + _ => ErrorCode::Backend, + } +} diff --git a/crates/runvil-internal/lua/src/error-value-tests.rs b/crates/runvil-internal/lua/src/error-value-tests.rs index 36ec9e2af..21a400320 100644 --- a/crates/runvil-internal/lua/src/error-value-tests.rs +++ b/crates/runvil-internal/lua/src/error-value-tests.rs @@ -1,7 +1,12 @@ //! Tests for the error value's kind vocabulary: its tags parse back, and a -//! rejected tag's error names every accepted one. +//! rejected tag's error names every accepted one. Also the universal error a +//! store failure becomes: its code, its facts, and its texts. -use super::{ErrorKind, ParseErrorKindError}; +use runvil_types::error::{ErrorCode, FactKey}; +use runvil_vfs::{PathReason, VfsError}; +use serde_json::Value; + +use super::{ErrorKind, store_error, store_error_value_fields}; #[test] fn every_error_kind_tag_parses_back_to_its_kind() { @@ -12,10 +17,12 @@ fn every_error_kind_tag_parses_back_to_its_kind() { #[test] fn an_unknown_error_kind_names_the_text_and_every_accepted_tag() { - let error: ParseErrorKindError = "stoer" + let error = "stoer" .parse::() .expect_err("a misspelled tag is not a kind"); - assert_eq!(error.input(), "stoer"); + assert_eq!(error.code, ErrorCode::InvalidValue); + assert_eq!(error.facts.get(&FactKey::Name), Some(&Value::from("stoer"))); + assert_eq!(error.prose, error.message); let message = error.to_string(); assert!(message.contains("`stoer`"), "the rejected text: {message}"); for kind in ErrorKind::ALL { @@ -26,3 +33,174 @@ fn an_unknown_error_kind_names_the_text_and_every_accepted_tag() { ); } } + +/// Every store failure beside the code named after its variant. +#[expect( + clippy::too_many_lines, + reason = "one table holds every variant beside its code" +)] +fn every_store_failure() -> Vec<(VfsError, ErrorCode)> { + let path = || "a.md".to_owned(); + let text = || "why".to_owned(); + vec![ + (VfsError::NotFound { path: path() }, ErrorCode::NotFound), + ( + VfsError::AlreadyExists { path: path() }, + ErrorCode::AlreadyExists, + ), + ( + VfsError::NotADirectory { path: path() }, + ErrorCode::NotADirectory, + ), + ( + VfsError::IsADirectory { path: path() }, + ErrorCode::IsADirectory, + ), + ( + VfsError::DirectoryNotEmpty { path: path() }, + ErrorCode::DirectoryNotEmpty, + ), + ( + VfsError::NotUtf8 { + path: path(), + valid_up_to: 3, + }, + ErrorCode::NotUtf8, + ), + ( + VfsError::InvalidPath { + path: path(), + reason: PathReason::Traversal, + }, + ErrorCode::InvalidPath, + ), + ( + VfsError::InvalidRange { + path: path(), + reason: "backwards", + }, + ErrorCode::InvalidRange, + ), + ( + VfsError::Anchor { + path: path(), + anchor: text(), + count: 2, + }, + ErrorCode::Anchor, + ), + ( + VfsError::PermissionDenied { + path: path(), + reason: text(), + }, + ErrorCode::PermissionDenied, + ), + ( + VfsError::ApprovalRequired { + path: path(), + reason: text(), + }, + ErrorCode::ApprovalRequired, + ), + (VfsError::ScopeEnded { path: path() }, ErrorCode::ScopeEnded), + (VfsError::ReadOnly { path: path() }, ErrorCode::ReadOnly), + ( + VfsError::OutsideRoot { + path: path(), + detail: text(), + }, + ErrorCode::OutsideRoot, + ), + ( + VfsError::RootProtected { path: path() }, + ErrorCode::RootProtected, + ), + (VfsError::NoStore, ErrorCode::NoStore), + ( + VfsError::CrossMount { + path: path(), + detail: text(), + }, + ErrorCode::CrossMount, + ), + ( + VfsError::Io { + path: path(), + kind: std::io::ErrorKind::Other, + raw_os_error: None, + message: text(), + }, + ErrorCode::Io, + ), + ( + VfsError::Unsupported { + path: path(), + detail: text(), + }, + ErrorCode::Unsupported, + ), + ( + VfsError::Conflict { + path: path(), + detail: text(), + }, + ErrorCode::Conflict, + ), + (VfsError::Backend { message: text() }, ErrorCode::Backend), + ] +} + +#[test] +fn a_store_failure_takes_the_code_named_after_its_variant() { + for (error, code) in every_store_failure() { + let converted = store_error(&error); + assert_eq!(converted.code, code, "{error:?}"); + assert_eq!(converted.message, error.to_string(), "{error:?}"); + assert_eq!(converted.prose, code.prose(), "{error:?}"); + assert_eq!(converted.at, None, "{error:?}"); + } +} + +#[test] +fn a_store_failure_keeps_the_lua_table_fields_as_facts() { + let anchor = store_error(&VfsError::Anchor { + path: "a.md".to_owned(), + anchor: "x".to_owned(), + count: 2, + }); + assert_eq!(anchor.facts.get(&FactKey::Path), Some(&Value::from("a.md"))); + assert_eq!(anchor.facts.get(&FactKey::Anchor), Some(&Value::from("x"))); + assert_eq!(anchor.facts.get(&FactKey::Count), Some(&Value::from(2))); + let invalid = store_error(&VfsError::InvalidPath { + path: "../a".to_owned(), + reason: PathReason::Traversal, + }); + assert_eq!( + invalid.facts.get(&FactKey::Rule), + Some(&Value::from("traversal")) + ); + let not_utf8 = store_error(&VfsError::NotUtf8 { + path: "a.bin".to_owned(), + valid_up_to: 3, + }); + assert_eq!( + not_utf8.facts.get(&FactKey::ValidUpTo), + Some(&Value::from(3)) + ); + for error in [ + VfsError::Anchor { + path: "a.md".to_owned(), + anchor: "x".to_owned(), + count: 2, + }, + VfsError::NoStore, + VfsError::Backend { + message: "down".to_owned(), + }, + ] { + // Every Lua table field but `reason`, which the code names. + let fields = store_error_value_fields(&error).len() - 1; + assert_eq!(store_error(&error).facts.len(), fields, "{error:?}"); + } +} diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index ebb5c9606..f6ab27636 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -29,10 +29,12 @@ use std::str::FromStr; use mlua::{Function, Lua, Table, Value}; +use runvil_types::error::{ErrorCode, FactKey}; + use crate::error::{Error, accepted_tags}; pub(crate) use classify::{install_normalize_failure, normalized}; -pub use store::store_error_value_fields; +pub use store::{store_error, store_error_value_fields}; /// The registry key of the shared error metatable, created on first use per /// VM so every error table on that VM - Lua-built or Rust-built - shares @@ -166,36 +168,27 @@ impl std::fmt::Display for ErrorKind { } impl FromStr for ErrorKind { - type Err = ParseErrorKindError; - - /// Parses a kind from its exact tag, as `ErrorKind::from_tag` does. - fn from_str(tag: &str) -> Result { - ErrorKind::from_tag(tag).ok_or_else(|| ParseErrorKindError { - input: tag.to_owned(), + type Err = runvil_types::error::Error; + + /// Parses a kind from its exact tag, as `ErrorKind::from_tag` does. Other + /// text is an [`InvalidValue`](ErrorCode::InvalidValue) error that names + /// it and lists every tag. + fn from_str(tag: &str) -> Result { + ErrorKind::from_tag(tag).ok_or_else(|| { + let text = format!( + "invalid error kind `{tag}`: required one of {}", + accepted_tags(ErrorKind::ALL.iter().map(|kind| kind.tag())) + ); + let mut error = + runvil_types::error::Error::new(ErrorCode::InvalidValue, text.clone(), text); + error + .facts + .insert(FactKey::Name, serde_json::Value::from(tag)); + error }) } } -/// The error returned when text is not an [`ErrorKind`] tag. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error( - "invalid error kind `{input}`: required one of {}", - accepted_tags(ErrorKind::ALL.iter().map(|kind| kind.tag())) -)] -pub struct ParseErrorKindError { - /// The rejected text. - input: String, -} - -impl ParseErrorKindError { - /// Returns the rejected text. - #[cfg(test)] - #[must_use] - pub(crate) fn input(&self) -> &str { - &self.input - } -} - /// One field of an error table: a string or an integer, so a field like /// a store anchor's `count` stays numeric in Lua instead of being /// stringified. @@ -228,8 +221,8 @@ impl ErrorField { } } -/// A Rust error's rendering into the Lua error table: its kind and the -/// kind's own fields. `Display` supplies `message`. +/// A Rust error's rendering into the Lua error table: its kind, its +/// message, and the kind's own fields. /// /// The envelope renderer requires this of the driver's error type, so a /// failure answered to Lua always has a kind; an internal type that gains a @@ -238,6 +231,13 @@ pub trait ErrorValue: std::fmt::Display { /// The kind the table's `kind` field names. fn kind(&self) -> ErrorKind; + /// The text the table's `message` field holds, which an author may hand + /// a model. `Display` supplies it unless a variant keeps a model-safe + /// text apart from its diagnostic. + fn table_message(&self) -> String { + self.to_string() + } + /// The kind's own fields, as `(name, value)` pairs set beside /// `kind` and `message`. Empty for kinds without fields. fn fields(&self) -> Vec<(String, ErrorField)> { @@ -353,13 +353,14 @@ fn finish_table(lua: &Lua, kind: ErrorKind, fields: Table) -> mlua::Result mlua::Result
{ let table = lua.create_table()?; - table.raw_set("message", error.to_string())?; + table.raw_set("message", error.table_message())?; for (name, value) in error.fields() { let value = match value { ErrorField::String(value) => Value::String(lua.create_string(value)?), diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index b2e4ccc29..5474d660f 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -222,11 +222,12 @@ impl Error { } } - /// Wraps a tool failure as [`Error::Tool`], keeping the tool's own - /// error as the cause rather than discarding it. - pub(crate) fn tool(source: runvil_types::tools::ToolError) -> Error { + /// Wraps a tool failure as [`Error::Tool`]: the message is the tool's + /// prose, the text that is safe for the model, and the tool's own error + /// is kept as the cause rather than discarded. + pub(crate) fn tool(source: runvil_types::error::Error) -> Error { Error::Tool { - message: source.to_string(), + message: source.prose.clone(), cause: Box::new(source), } } diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index a5982e632..03c0b9227 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -127,7 +127,7 @@ pub use crate::engine_globals::{ }; pub use crate::error::{Error, QuotaResource}; pub use crate::error_value::{ - ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error_value_fields, + ErrorField, ErrorKind, ErrorValue, Raised, store_error, store_error_value_fields, }; pub use crate::globals::{RESERVED_NAMES, Reserved, reserved_name}; pub use crate::messages::MessageList; diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index fc8f06ee7..34ea3acb8 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -14,9 +14,7 @@ use std::sync::Mutex; use mlua::{Lua, MultiValue, Table, Value}; -use runvil_model_client::model::{ - ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature, TemperatureError, -}; +use runvil_model_client::model::{ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature}; use runvil_types::models::ModelRole; use crate::error::{Error, Result}; @@ -149,9 +147,12 @@ fn decode_temperature(value: &Value) -> mlua::Result { } other => Err(invalid_option("temperature", "a number", other.type_name())), }?; - Temperature::new(number).map_err(|error| match error { - TemperatureError::NotFinite => invalid_option("temperature", "finite", number), - other => mlua::Error::external(format!("models.use option {other}")), + Temperature::new(number).map_err(|error| { + if number.is_finite() { + mlua::Error::external(format!("models.use option {error}")) + } else { + invalid_option("temperature", "finite", number) + } }) } diff --git a/crates/runvil-internal/model-client/src/client.rs b/crates/runvil-internal/model-client/src/client.rs index 72bfa3791..4340bb700 100644 --- a/crates/runvil-internal/model-client/src/client.rs +++ b/crates/runvil-internal/model-client/src/client.rs @@ -14,10 +14,10 @@ mod role; mod wire; -pub use role::{ParseRoleError, Role}; +pub use role::Role; pub use wire::{ ArgumentNames, Completion, CompletionResult, Message, RawExchange, ToolArguments, ToolCall, - ToolSchema, ToolSchemaError, + ToolSchema, }; #[cfg(test)] diff --git a/crates/runvil-internal/model-client/src/client/role.rs b/crates/runvil-internal/model-client/src/client/role.rs index 55183afb8..0f1af8847 100644 --- a/crates/runvil-internal/model-client/src/client/role.rs +++ b/crates/runvil-internal/model-client/src/client/role.rs @@ -1,8 +1,11 @@ -//! The chat-completions message role and its parse error. +//! The chat-completions message role. use std::fmt; use std::str::FromStr; +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; + /// Names who speaks one message in a chat-completions conversation. /// /// It serializes as its lowercase wire name: `system`, `user`, `assistant`, @@ -45,46 +48,22 @@ impl fmt::Display for Role { } impl FromStr for Role { - type Err = ParseRoleError; - - /// Parses a role from its exact wire name, never case-folded. - fn from_str(name: &str) -> Result { - Role::ALL - .iter() - .copied() - .find(|role| role.as_str() == name) - .ok_or_else(|| ParseRoleError { - input: name.to_owned(), - }) - } -} - -/// The error returned when text is not a [`Role`]'s wire name. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseRoleError { - /// The rejected text. - input: String, -} + type Err = Error; -impl ParseRoleError { - /// Returns the rejected text. - #[must_use] - pub fn input(&self) -> &str { - &self.input - } -} - -impl fmt::Display for ParseRoleError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid role `{}`: required one of ", self.input)?; - for (position, role) in Role::ALL.iter().enumerate() { - if position > 0 { - f.write_str(", ")?; - } - write!(f, "`{role}`")?; + /// Parses a role from its exact wire name, never case-folded. Other text + /// is an [`InvalidValue`](ErrorCode::InvalidValue) error that names it + /// and lists every role. + fn from_str(name: &str) -> Result { + if let Some(role) = Role::ALL.iter().copied().find(|role| role.as_str() == name) { + return Ok(role); } - Ok(()) + let roles: Vec = Role::ALL.iter().map(|role| format!("`{role}`")).collect(); + let text = format!( + "invalid role `{name}`: required one of {}", + roles.join(", ") + ); + let mut error = Error::new(ErrorCode::InvalidValue, text.clone(), text); + error.facts.insert(FactKey::Name, Value::from(name)); + Err(error) } } - -impl std::error::Error for ParseRoleError {} diff --git a/crates/runvil-internal/model-client/src/client/tests.rs b/crates/runvil-internal/model-client/src/client/tests.rs index e6153f3af..9a6db7884 100644 --- a/crates/runvil-internal/model-client/src/client/tests.rs +++ b/crates/runvil-internal/model-client/src/client/tests.rs @@ -1,11 +1,11 @@ //! Tests for the wire message constructors and tool schema validation. +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::metrics::{CallMetrics, Usage}; use serde_json::{Value, json}; use super::*; use crate::detail::{message_from_validated_parts, tool_call_arguments, tool_schema_new}; -use crate::model::CompletionErrorKind; use crate::test_support::{assert_json, pong}; #[test] @@ -75,11 +75,15 @@ fn a_role_parses_from_its_name_and_a_refusal_lists_the_accepted_roles() { let error = "wizard" .parse::() .expect_err("an unknown role is refused"); - assert_eq!(error.input(), "wizard"); + let text = "invalid role `wizard`: required one of `system`, `user`, `assistant`, `tool`"; + assert_eq!(error.code, runvil_types::error::ErrorCode::InvalidValue); assert_eq!( - error.to_string(), - "invalid role `wizard`: required one of `system`, `user`, `assistant`, `tool`" + error.facts.get(&runvil_types::error::FactKey::Name), + Some(&Value::from("wizard")) ); + assert_eq!(error.message, text); + assert_eq!(error.prose, text); + assert_eq!(error.at, None); } #[test] @@ -146,8 +150,8 @@ fn from_parts_refuses_a_blank_id_a_blank_name_and_non_object_arguments() { ] { let error = ToolCall::from_parts(id, name, arguments).expect_err(label); assert_eq!( - error.kind(), - CompletionErrorKind::InvalidParts, + error.code, + ErrorCode::InvalidParts, "{label} must be refused" ); } @@ -159,12 +163,12 @@ fn from_parts_refuses_a_blank_id_a_blank_name_and_non_object_arguments() { fn from_result_refuses_an_empty_batch_and_duplicate_ids_and_accepts_text() { let empty = Completion::from_result(CompletionResult::ToolCalls(Vec::new()), "m") .expect_err("an empty batch is refused"); - assert_eq!(empty.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(empty.finish_reason(), None); + assert_eq!(empty.code, ErrorCode::EmptyReply); + assert_eq!(empty.facts.get(&FactKey::FinishReason), None); let call = ToolCall::from_parts("call_1", "fetch", json_object()).expect("a whole call"); let twice = CompletionResult::ToolCalls(vec![call.clone(), call.clone()]); let duplicate = Completion::from_result(twice, "m").expect_err("a duplicate id is refused"); - assert_eq!(duplicate.kind(), CompletionErrorKind::InvalidParts); + assert_eq!(duplicate.code, ErrorCode::InvalidParts); assert!( duplicate.to_string().contains("\"call_1\""), "the message names the repeated id: {duplicate}" @@ -266,23 +270,36 @@ fn tool_schema_new_validates_wire_name_and_object_schema() { "bad name", &format!("a{}", "z".repeat(64)), ] { - assert!( - matches!( - tool_schema_new(name, "d", json_object()), - Err(ToolSchemaError::InvalidName { .. }) - ), - "{name:?} must be refused" - ); + assert_schema_refused(name, json_object(), "invalid_name"); } // A non-object JSON Schema is rejected. - assert!(matches!( - tool_schema_new("ok", "d", json!([1, 2, 3])), - Err(ToolSchemaError::NonObjectSchema { .. }) - )); - assert!(matches!( - tool_schema_new("ok", "d", json!("scalar")), - Err(ToolSchemaError::NonObjectSchema { .. }) - )); + assert_schema_refused("ok", json!([1, 2, 3]), "non_object_schema"); + assert_schema_refused("ok", json!("scalar"), "non_object_schema"); +} + +/// Asserts the schema for `name` and `parameters` is refused as an invalid +/// tool schema naming the tool, for `reason`. +#[track_caller] +fn assert_schema_refused(name: &str, parameters: Value, reason: &str) { + use runvil_types::error::{ErrorCode, FactKey}; + let error = tool_schema_new(name, "d", parameters).expect_err("the schema must be refused"); + assert_eq!(error.code, ErrorCode::ToolSchemaInvalid, "{name:?}"); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from(name)), + "{name:?}" + ); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason)), + "{name:?}" + ); + assert_eq!( + error.prose, + ErrorCode::ToolSchemaInvalid.prose(), + "{name:?}" + ); + assert!(error.message.contains(&format!("{name:?}")), "{error:?}"); } fn json_object() -> Value { diff --git a/crates/runvil-internal/model-client/src/client/wire-canned.rs b/crates/runvil-internal/model-client/src/client/wire-canned.rs index 8354bb949..5cf6035cb 100644 --- a/crates/runvil-internal/model-client/src/client/wire-canned.rs +++ b/crates/runvil-internal/model-client/src/client/wire-canned.rs @@ -8,11 +8,11 @@ use std::collections::HashSet; +use runvil_types::error::Error; use runvil_types::metrics::CallMetrics; use serde_json::Value; use super::{Completion, CompletionResult, RawExchange, ToolCall}; -use crate::model::CompletionError; use crate::normalize::{ check_call_id, check_call_name, check_unique_call_id, checked_call_arguments, empty_reply_error, }; @@ -31,14 +31,14 @@ impl Completion { /// beyond the result. /// /// # Errors - /// Returns an `EmptyReply`-kind [`CompletionError`] for an empty - /// tool-call batch. Returns an `InvalidParts`-kind one when two calls in - /// the batch share an id. Neither is retryable: the same parts fail the - /// same way. + /// Returns an [`Error`] with code `EmptyReply` for an empty tool-call + /// batch. Returns one with code `InvalidParts` when two calls in the + /// batch share an id. Neither is retryable: the same parts fail the same + /// way. pub fn from_result( result: CompletionResult, model: impl Into, - ) -> Result { + ) -> Result { if let CompletionResult::ToolCalls(calls) = &result { if calls.is_empty() { return Err(empty_reply_error()); @@ -128,14 +128,14 @@ impl ToolCall { /// JSON text. /// /// # Errors - /// Returns an `InvalidParts`-kind [`CompletionError`] when `id` or - /// `name` is blank or `arguments` is not a JSON object. It is not - /// retryable: the same parts fail the same way. + /// Returns an [`Error`] with code `InvalidParts` when `id` or `name` is + /// blank or `arguments` is not a JSON object. It is not retryable: the + /// same parts fail the same way. pub fn from_parts( id: impl Into, name: impl Into, arguments: Value, - ) -> Result { + ) -> Result { let id = id.into(); let name = name.into(); check_call_id(&id)?; diff --git a/crates/runvil-internal/model-client/src/client/wire.rs b/crates/runvil-internal/model-client/src/client/wire.rs index 1b541d094..fe4d21f6c 100644 --- a/crates/runvil-internal/model-client/src/client/wire.rs +++ b/crates/runvil-internal/model-client/src/client/wire.rs @@ -136,33 +136,6 @@ impl ToolSchema { } } -/// The reason a [`ToolSchema`] could not be built from its wire parts. -/// -/// `ToolSchema` is built only inside the Engine (from the executor's `Tool` -/// contract, through [`crate::detail::tool_schema_new`]), so the raw-`Value` -/// validation and its error stay off the facade. The type is public so -/// `runvil-engine` can box it as an error source. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum ToolSchemaError { - /// The wire name did not match `[A-Za-z][A-Za-z0-9_-]{0,63}`. - #[error("invalid tool wire name {name:?}: {reason}")] - #[non_exhaustive] - InvalidName { - /// The rejected wire name. - name: String, - /// Why it was rejected. - reason: &'static str, - }, - /// The parameters JSON Schema was not a JSON object. - #[error("tool {name:?} parameters schema must be a JSON object")] - #[non_exhaustive] - NonObjectSchema { - /// The tool whose schema was rejected. - name: String, - }, -} - /// A tool call the model asked for: a call id, a tool name, and arguments. /// /// `OpenAI` sends a call's `function.arguments` as a JSON-encoded string. diff --git a/crates/runvil-internal/model-client/src/detail.rs b/crates/runvil-internal/model-client/src/detail.rs index 392b39aae..9a50dd566 100644 --- a/crates/runvil-internal/model-client/src/detail.rs +++ b/crates/runvil-internal/model-client/src/detail.rs @@ -6,11 +6,11 @@ //! one that splits a completion into the result the tool loop consumes and //! the metadata the round reports. +use runvil_types::error::{Error, ErrorCode, FactKey}; +use runvil_types::names::is_alias; use serde_json::Value; -use crate::client::{ - Completion, CompletionResult, Message, Role, ToolCall, ToolSchema, ToolSchemaError, -}; +use crate::client::{Completion, CompletionResult, Message, Role, ToolCall, ToolSchema}; /// Constructs a message from parts a caller has already validated. /// @@ -57,31 +57,30 @@ pub fn message_raw_tool_calls(message: &Message) -> Option<&[Value]> { /// facade-visible constructor. /// /// # Errors -/// Returns [`ToolSchemaError::InvalidName`] when `name` does not match -/// `[A-Za-z][A-Za-z0-9_-]{0,63}`, and -/// [`ToolSchemaError::NonObjectSchema`] when `parameters` is not a JSON -/// object, so a tool can never be advertised to the model with an unusable -/// name or a non-object JSON Schema. +/// Returns a [`ToolSchemaInvalid`](ErrorCode::ToolSchemaInvalid) error +/// with the reason `invalid_name` when `name` does not match +/// `[A-Za-z][A-Za-z0-9_-]{0,63}`, and `non_object_schema` when +/// `parameters` is not a JSON object, so a tool can never be advertised to +/// the model with an unusable name or a non-object JSON Schema. pub fn tool_schema_new( name: impl Into, description: impl Into, parameters: Value, -) -> Result { +) -> Result { let name = name.into(); - let bytes = name.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if !valid { - return Err(ToolSchemaError::InvalidName { - name, - reason: "expected [A-Za-z][A-Za-z0-9_-]{0,63}", - }); + if !is_alias(&name) { + return Err(schema_error( + "invalid_name", + format!("invalid tool wire name {name:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}"), + &name, + )); } if !parameters.is_object() { - return Err(ToolSchemaError::NonObjectSchema { name }); + return Err(schema_error( + "non_object_schema", + format!("tool {name:?} parameters schema must be a JSON object"), + &name, + )); } Ok(ToolSchema { name, @@ -90,6 +89,19 @@ pub fn tool_schema_new( }) } +/// The error for a refused tool schema: a `ToolSchemaInvalid` that names the +/// tool, with the broken rule as its reason. +fn schema_error(reason: &'static str, message: String, name: &str) -> Error { + let mut error = Error::new( + ErrorCode::ToolSchemaInvalid, + message, + ErrorCode::ToolSchemaInvalid.prose(), + ); + error.facts.insert(FactKey::Name, Value::from(name)); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error +} + /// Returns a tool call's parsed arguments as the raw wire JSON the /// dispatcher hands a tool. /// diff --git a/crates/runvil-internal/model-client/src/lib.rs b/crates/runvil-internal/model-client/src/lib.rs index 30f97cc10..762a7e827 100644 --- a/crates/runvil-internal/model-client/src/lib.rs +++ b/crates/runvil-internal/model-client/src/lib.rs @@ -11,8 +11,9 @@ //! caller supplies, the validated [`ModelId`](runvil_types::models::ModelId) //! identity, and the //! [`model::ModelBinding`]/[`model::ModelSet`] types -//! model selections resolve and freeze through, with -//! [`model::CompletionError`] as the failure a round reports. +//! model selections resolve and freeze through, with the universal +//! [`Error`](runvil_types::error::Error) under a model code, built through +//! [`model::completion_error`], as the failure a round reports. //! //! The metrics vocabulary in [`runvil_types::metrics`] (`Usage`, //! `LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) is @@ -41,4 +42,4 @@ mod normalize; mod test_support; /// Crate-internal result alias over the failure a model round reports. -type Result = std::result::Result; +type Result = std::result::Result; diff --git a/crates/runvil-internal/model-client/src/model.rs b/crates/runvil-internal/model-client/src/model.rs index 1fbb6ef5e..5d168b3ae 100644 --- a/crates/runvil-internal/model-client/src/model.rs +++ b/crates/runvil-internal/model-client/src/model.rs @@ -9,13 +9,11 @@ //! a section, and a section that never calls it runs on the `default` role. //! A model-facing section with no model fails with a model-binding failure //! surfaced through the run error the caller receives. -mod error; +pub(crate) mod error; mod options; -pub use error::{CompletionError, CompletionErrorKind}; -pub use options::{ - CompletionOptions, ModelBinding, ModelInvocation, ModelSet, Temperature, TemperatureError, -}; +pub use error::{completion_error, context_overflow, empty_reply, phrase}; +pub use options::{CompletionOptions, ModelBinding, ModelInvocation, ModelSet, Temperature}; // `runvil-lua` names the model id through this path; the rest of the catalog // vocabulary is imported from `runvil-types` directly. pub use runvil_types::models::ModelId; diff --git a/crates/runvil-internal/model-client/src/model/error-tests.rs b/crates/runvil-internal/model-client/src/model/error-tests.rs index bc7d4f9fa..36ca279fe 100644 --- a/crates/runvil-internal/model-client/src/model/error-tests.rs +++ b/crates/runvil-internal/model-client/src/model/error-tests.rs @@ -1,27 +1,27 @@ //! Tests for the failure vocabulary: the tags, the fixed phrases, the -//! specifics that extend them, and the detail channel. +//! specifics that extend them, and the constructors' facts. use super::*; -const ALL_KINDS: [CompletionErrorKind; 13] = [ - CompletionErrorKind::ContextOverflow, - CompletionErrorKind::RateLimited, - CompletionErrorKind::QuotaExhausted, - CompletionErrorKind::Overloaded, - CompletionErrorKind::Refused, - CompletionErrorKind::Timeout, - CompletionErrorKind::Transport, - CompletionErrorKind::ServerError, - CompletionErrorKind::Rejected, - CompletionErrorKind::MalformedResponse, - CompletionErrorKind::EmptyReply, - CompletionErrorKind::Unavailable, - CompletionErrorKind::InvalidParts, +const ALL_CODES: [ErrorCode; 13] = [ + ErrorCode::ContextOverflow, + ErrorCode::RateLimited, + ErrorCode::QuotaExhausted, + ErrorCode::Overloaded, + ErrorCode::Refused, + ErrorCode::Timeout, + ErrorCode::Transport, + ErrorCode::ServerError, + ErrorCode::Rejected, + ErrorCode::MalformedResponse, + ErrorCode::EmptyReply, + ErrorCode::Unavailable, + ErrorCode::InvalidParts, ]; #[test] -fn each_kind_tags_as_its_snake_case_name() { - let tags: Vec<&str> = ALL_KINDS.iter().map(|kind| kind.tag()).collect(); +fn each_code_tags_as_its_snake_case_name() { + let tags: Vec<&str> = ALL_CODES.iter().map(|code| code.tag()).collect(); assert_eq!( tags, [ @@ -43,197 +43,183 @@ fn each_kind_tags_as_its_snake_case_name() { } #[test] -fn each_kind_has_its_fixed_phrase() { +fn each_code_has_its_fixed_phrase() { let phrases = [ ( - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, "the request is larger than the model's context window", ), ( - CompletionErrorKind::RateLimited, + ErrorCode::RateLimited, "the model backend is limiting the request rate", ), ( - CompletionErrorKind::QuotaExhausted, + ErrorCode::QuotaExhausted, "the model backend says the usage quota is spent", ), + (ErrorCode::Overloaded, "the model backend is overloaded"), ( - CompletionErrorKind::Overloaded, - "the model backend is overloaded", - ), - ( - CompletionErrorKind::Refused, + ErrorCode::Refused, "the model backend refused the request on content policy grounds", ), ( - CompletionErrorKind::Timeout, + ErrorCode::Timeout, "the model backend did not answer in time", ), ( - CompletionErrorKind::Transport, + ErrorCode::Transport, "the connection to the model backend failed", ), ( - CompletionErrorKind::ServerError, + ErrorCode::ServerError, "the model backend reported a fault of its own", ), ( - CompletionErrorKind::Rejected, + ErrorCode::Rejected, "the model backend rejected the request", ), ( - CompletionErrorKind::MalformedResponse, + ErrorCode::MalformedResponse, "the model backend sent a reply that could not be understood", ), ( - CompletionErrorKind::EmptyReply, + ErrorCode::EmptyReply, "the model replied with no text and no tool calls", ), ( - CompletionErrorKind::Unavailable, + ErrorCode::Unavailable, "model access is turned off or not configured", ), ( - CompletionErrorKind::InvalidParts, + ErrorCode::InvalidParts, "the completion was built from invalid parts", ), ]; - assert_eq!(phrases.len(), ALL_KINDS.len()); - for (kind, phrase) in phrases { - assert_eq!(kind.phrase(), phrase, "phrase of {kind:?}"); + assert_eq!(phrases.len(), ALL_CODES.len()); + for (code, text) in phrases { + assert_eq!(phrase(code), text, "phrase of {code:?}"); } } #[test] -fn display_shows_the_message_and_never_the_detail() { - let error = CompletionError::new(CompletionErrorKind::Rejected, "the message") - .with_detail("provider says: "); - assert_eq!(error.to_string(), "the message"); - assert_eq!(error.message(), "the message"); - assert_eq!(error.detail(), Some("provider says: ")); - assert!(!format!("{error}").contains("secret")); - assert!( - !format!("{error:?}").is_empty(), - "Debug stays available to operators" - ); +fn a_code_outside_the_model_family_phrases_as_its_default_prose() { + for code in [ + ErrorCode::NotFound, + ErrorCode::ContextExhausted, + ErrorCode::LuaRuntime, + ] { + assert_eq!(phrase(code), code.prose(), "phrase of {code:?}"); + } } #[test] -fn a_new_error_has_no_extras() { - let error = CompletionError::new(CompletionErrorKind::Transport, "down"); - assert_eq!(error.overflow(), (None, None)); - assert_eq!(error.finish_reason(), None); - assert_eq!(error.detail(), None); - assert!(std::error::Error::source(&error).is_none()); +fn completion_error_uses_the_message_as_both_texts_and_adds_nothing_else() { + let error = completion_error(ErrorCode::Rejected, "the message"); + assert_eq!(error.code, ErrorCode::Rejected); + assert_eq!(error.message, "the message"); + assert_eq!(error.prose, "the message"); + assert_eq!(error.to_string(), "the message"); + assert_eq!(error.at, None); + assert!(error.facts.is_empty()); } #[test] -fn context_overflow_holds_the_counts() { - let error = CompletionError::context_overflow(Some(10), None, "too big"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(10), None)); -} +fn context_overflow_holds_the_counts_it_was_given_as_facts() { + let both = context_overflow(Some(10), Some(8), "too big"); + assert_eq!(both.code, ErrorCode::ContextOverflow); + assert_eq!( + (both.message.as_str(), both.prose.as_str()), + ("too big", "too big") + ); + assert_eq!(both.facts.get(&FactKey::Count), Some(&Value::from(10))); + assert_eq!(both.facts.get(&FactKey::Limit), Some(&Value::from(8))); -#[test] -fn with_source_keeps_the_cause_in_the_chain() { - let error = CompletionError::new(CompletionErrorKind::Transport, "down") - .with_source(std::io::Error::other("reset by peer")); - let source = std::error::Error::source(&error).expect("the cause is kept"); - assert_eq!(source.to_string(), "reset by peer"); -} + let prompt_only = context_overflow(Some(10), None, "too big"); + assert_eq!( + prompt_only.facts.get(&FactKey::Count), + Some(&Value::from(10)) + ); + assert_eq!(prompt_only.facts.get(&FactKey::Limit), None); -#[test] -fn with_finish_reason_is_read_back() { - let error = - CompletionError::new(CompletionErrorKind::EmptyReply, "empty").with_finish_reason("stop"); - assert_eq!(error.finish_reason(), Some("stop")); + let neither = context_overflow(None, None, "too big"); + assert_eq!(neither.code, ErrorCode::ContextOverflow); + assert!(neither.facts.is_empty()); } #[test] -fn a_timeout_and_a_transport_failure_are_built_from_a_kind_and_a_source() { - let timed_out = CompletionError::new( - CompletionErrorKind::Timeout, +fn a_timeout_and_a_transport_failure_keep_their_cause_out_of_the_prose() { + let timed_out = completion_error( + ErrorCode::Timeout, "the model backend did not answer in time", ) - .with_source(std::io::Error::new( + .with_cause(&std::io::Error::new( std::io::ErrorKind::TimedOut, "deadline", )); - assert_eq!(timed_out.kind(), CompletionErrorKind::Timeout); + assert_eq!(timed_out.code, ErrorCode::Timeout); assert_eq!( - timed_out.to_string(), - "the model backend did not answer in time" - ); - assert_eq!( - std::error::Error::source(&timed_out) - .expect("the cause is kept") - .to_string(), - "deadline" + timed_out.message, + "the model backend did not answer in time: deadline" ); + assert_eq!(timed_out.prose, "the model backend did not answer in time"); - let refused = CompletionError::new( - CompletionErrorKind::Transport, + let refused = completion_error( + ErrorCode::Transport, "the connection to the model backend failed", ) - .with_source(std::io::Error::other("connection refused")); - assert_eq!(refused.kind(), CompletionErrorKind::Transport); + .with_cause(&std::io::Error::other("connection refused")); + assert_eq!(refused.code, ErrorCode::Transport); + assert!(refused.message.contains("connection refused")); + assert!(!refused.prose.contains("connection refused")); } #[test] fn a_phrased_failure_shows_only_the_fixed_phrase() { - for kind in ALL_KINDS { - let error = CompletionError::phrased(kind); - assert_eq!(error.kind(), kind); - assert_eq!(error.to_string(), kind.phrase()); - assert_eq!(error.detail(), None); + for code in ALL_CODES { + let error = phrased(code); + assert_eq!(error.code, code); + assert_eq!(error.to_string(), phrase(code)); + assert_eq!(error.prose, phrase(code)); + assert!(error.facts.is_empty()); } } #[test] -fn a_specific_extends_the_phrase_after_a_colon_and_never_adds_detail() { - let malformed = CompletionError::specific( - CompletionErrorKind::MalformedResponse, - "no choices in response", - ); - assert_eq!(malformed.kind(), CompletionErrorKind::MalformedResponse); +fn a_specific_extends_the_phrase_after_a_colon_in_both_texts() { + let malformed = specific(ErrorCode::MalformedResponse, "no choices in response"); + assert_eq!(malformed.code, ErrorCode::MalformedResponse); assert_eq!( malformed.to_string(), "the model backend sent a reply that could not be understood: no choices in response" ); - assert_eq!(malformed.message(), malformed.to_string()); - assert_eq!(malformed.detail(), None); + assert_eq!(malformed.prose, malformed.message); - let empty = CompletionError::specific( - CompletionErrorKind::EmptyReply, - "reasoning content was present but ignored", - ) - .with_finish_reason("stop"); + let refused = invalid_parts("tool call id was blank"); + assert_eq!(refused.code, ErrorCode::InvalidParts); assert_eq!( - empty.to_string(), - "the model replied with no text and no tool calls: \ - reasoning content was present but ignored" + refused.prose, + "the completion was built from invalid parts: tool call id was blank" ); - assert_eq!(empty.finish_reason(), Some("stop")); } #[test] fn the_shared_empty_reply_owner_builds_the_phrase_and_the_reasoning_specific() { - // One owner for the empty-reply message: the bare phrase when the turn - // carried no reasoning, the phrase plus the reasoning-ignored specific - // when one was dropped, and the caller's finish reason either way. - let plain = CompletionError::empty_reply(false); - assert_eq!(plain.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(plain.to_string(), CompletionErrorKind::EmptyReply.phrase()); - assert_eq!(plain.finish_reason(), None); - - let reasoned = CompletionError::empty_reply(true).with_finish_reason("stop"); - assert_eq!( - reasoned.to_string(), - format!( - "{}: reasoning content was present but ignored", - CompletionErrorKind::EmptyReply.phrase() - ) + // One owner for the empty-reply text: the bare phrase when the turn + // carried no reasoning, and the phrase plus the reasoning-ignored + // specific when one was dropped. The finish reason is the caller's fact. + let plain = empty_reply(false); + assert_eq!(plain.code, ErrorCode::EmptyReply); + assert_eq!(plain.to_string(), phrase(ErrorCode::EmptyReply)); + assert_eq!(plain.prose, phrase(ErrorCode::EmptyReply)); + assert_eq!(plain.facts.get(&FactKey::FinishReason), None); + + let reasoned = empty_reply(true); + let expected = format!( + "{}: reasoning content was present but ignored", + phrase(ErrorCode::EmptyReply) ); - assert_eq!(reasoned.finish_reason(), Some("stop")); - assert_eq!(reasoned.detail(), None); + assert_eq!(reasoned.code, ErrorCode::EmptyReply); + assert_eq!(reasoned.message, expected); + assert_eq!(reasoned.prose, expected); + assert!(reasoned.facts.is_empty()); } diff --git a/crates/runvil-internal/model-client/src/model/error.rs b/crates/runvil-internal/model-client/src/model/error.rs index ba6d80a77..cb36dbe9c 100644 --- a/crates/runvil-internal/model-client/src/model/error.rs +++ b/crates/runvil-internal/model-client/src/model/error.rs @@ -1,303 +1,100 @@ -//! The model failure vocabulary: the closed set of kinds a broker maps its -//! failures into, and the error that carries one. +//! The model failure vocabulary: the fixed phrase each model code's text +//! starts with, and the constructors of a model round's failure. //! -//! The message is the kind's fixed phrase. A failure built from an HTTP -//! status appends ` (status N)`, and a 401 or 403 is `Unavailable` with -//! `the model backend did not accept the credentials`. Only -//! `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts` +//! A failure's message and prose start as the code's fixed phrase. A failure +//! built from an HTTP status appends ` (status N)`, and a 401 or 403 is +//! `Unavailable` with `the model backend did not accept the credentials`. +//! Only `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts` //! extend it with `: ` and text this repository's own code wrote. Provider -//! text never enters the message; it goes in `detail`. +//! text never enters the prose; a broker appends it to the message only. -/// A type-erased owned error cause. -type BoxedSource = Box; +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; /// The specific an empty reply adds to its phrase when the reader dropped a /// reasoning side channel that it could not use as answer text. const REASONING_IGNORED: &str = "reasoning content was present but ignored"; -/// The kind of failure a model round ended with. -/// -/// The kinds form a closed set, so a caller can branch on the kind alone. -/// The caller that performs a round maps every failure into one of these -/// kinds. Each kind is either retryable or permanent, as its own doc says. -/// The enum is `#[non_exhaustive]`, so a `match` on it needs a wildcard -/// arm. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CompletionErrorKind { - /// The request exceeds the model's context window. Permanent. - ContextOverflow, - /// The backend is limiting the request rate. Retryable. - RateLimited, - /// The billing or usage quota is spent. Permanent. - QuotaExhausted, - /// The backend is temporarily at capacity. Retryable. - Overloaded, - /// The provider declined the content on policy grounds. Permanent. - Refused, - /// The wait for a reply or the next chunk timed out. Retryable. - Timeout, - /// The connection failed or the stream broke. Retryable. - Transport, - /// The backend reported a fault of its own. Retryable. - ServerError, - /// The backend refused the request for any other reason. Permanent. - Rejected, - /// The reply was malformed, or it was larger than the size limit for a - /// reply. Retryable. - MalformedResponse, - /// The model's reply was empty of text and tool calls. Permanent. - EmptyReply, - /// Model access is turned off or lacks a configuration. Permanent. - Unavailable, - /// A caller built a tool call or completion from parts the validating - /// constructors refuse. Permanent. - InvalidParts, -} - -impl CompletionErrorKind { - /// Returns the fixed phrase for this kind: one lowercase phrase written - /// for a model to read. - /// - /// The module page gives the message shapes a caller builds from it. - #[must_use] - pub fn phrase(self) -> &'static str { - match self { - CompletionErrorKind::ContextOverflow => { - "the request is larger than the model's context window" - } - CompletionErrorKind::RateLimited => "the model backend is limiting the request rate", - CompletionErrorKind::QuotaExhausted => { - "the model backend says the usage quota is spent" - } - CompletionErrorKind::Overloaded => "the model backend is overloaded", - CompletionErrorKind::Refused => { - "the model backend refused the request on content policy grounds" - } - CompletionErrorKind::Timeout => "the model backend did not answer in time", - CompletionErrorKind::Transport => "the connection to the model backend failed", - CompletionErrorKind::ServerError => "the model backend reported a fault of its own", - CompletionErrorKind::Rejected => "the model backend rejected the request", - CompletionErrorKind::MalformedResponse => { - "the model backend sent a reply that could not be understood" - } - CompletionErrorKind::EmptyReply => "the model replied with no text and no tool calls", - CompletionErrorKind::Unavailable => "model access is turned off or not configured", - CompletionErrorKind::InvalidParts => "the completion was built from invalid parts", - } - } - - /// Returns the kind's stable tag: its name in snake_case, such as - /// `malformed_response`. A run log records a failed round under it. - #[must_use] - pub fn tag(self) -> &'static str { - match self { - CompletionErrorKind::ContextOverflow => "context_overflow", - CompletionErrorKind::RateLimited => "rate_limited", - CompletionErrorKind::QuotaExhausted => "quota_exhausted", - CompletionErrorKind::Overloaded => "overloaded", - CompletionErrorKind::Refused => "refused", - CompletionErrorKind::Timeout => "timeout", - CompletionErrorKind::Transport => "transport", - CompletionErrorKind::ServerError => "server_error", - CompletionErrorKind::Rejected => "rejected", - CompletionErrorKind::MalformedResponse => "malformed_response", - CompletionErrorKind::EmptyReply => "empty_reply", - CompletionErrorKind::Unavailable => "unavailable", - CompletionErrorKind::InvalidParts => "invalid_parts", +/// Returns the fixed phrase of a model code: one lowercase phrase written +/// for a model to read. Any other code returns its default prose. +#[must_use] +pub fn phrase(code: ErrorCode) -> &'static str { + match code { + ErrorCode::ContextOverflow => "the request is larger than the model's context window", + ErrorCode::RateLimited => "the model backend is limiting the request rate", + ErrorCode::QuotaExhausted => "the model backend says the usage quota is spent", + ErrorCode::Overloaded => "the model backend is overloaded", + ErrorCode::Refused => "the model backend refused the request on content policy grounds", + ErrorCode::Timeout => "the model backend did not answer in time", + ErrorCode::Transport => "the connection to the model backend failed", + ErrorCode::ServerError => "the model backend reported a fault of its own", + ErrorCode::Rejected => "the model backend rejected the request", + ErrorCode::MalformedResponse => { + "the model backend sent a reply that could not be understood" } + ErrorCode::EmptyReply => "the model replied with no text and no tool calls", + ErrorCode::Unavailable => "model access is turned off or not configured", + ErrorCode::InvalidParts => "the completion was built from invalid parts", + other => other.prose(), } } -/// The error a model round or a catalog fetch fails with. -/// -/// The caller that performs the round or fetch reports this error. For a -/// model round, it comes back into the Engine in a `Chat` effect's answer. -/// -/// It holds a [`kind`](CompletionError::kind) from a closed set, a -/// [`message`](CompletionError::message) written for the operator and the -/// model, and optional extras: the token counts of a context overflow, the -/// backend's `finish_reason` for an empty reply, a provider -/// [`detail`](CompletionError::detail), and the underlying cause behind -/// [`std::error::Error::source`]. `Display` shows only the message. The -/// detail is provider text that the caller bounded and control-escaped. It -/// is an opt-in channel and never appears in `Display`. -/// -/// The caller builds one with [`new`](CompletionError::new), -/// [`empty_reply`](CompletionError::empty_reply), or -/// [`context_overflow`](CompletionError::context_overflow) and adds the -/// extras with the `with_` methods. `empty_reply` is the builder that -/// chooses between the bare phrase and the phrase extended with the -/// reasoning-ignored text. -/// -/// See the module page for the message shapes. -#[derive(Debug)] -#[non_exhaustive] -pub struct CompletionError { - kind: CompletionErrorKind, - message: String, - prompt_tokens: Option, - window: Option, - finish_reason: Option, - detail: Option, - source: Option, +/// Builds the failure of a model round or a catalog fetch, with `message` as +/// both its message and its prose. `code` is one of the model codes, and +/// `message` is its phrase, extended as the module page says. +#[must_use] +pub fn completion_error(code: ErrorCode, message: impl Into) -> Error { + let message = message.into(); + Error::new(code, message.clone(), message) } -impl CompletionError { - /// Builds a failure of `kind` with `message` as its display text. - /// - /// The caller passes the kind's fixed - /// [`phrase`](CompletionErrorKind::phrase); the extension rules live on - /// the module page. Provider text goes to - /// [`with_detail`](CompletionError::with_detail). - #[must_use] - pub fn new(kind: CompletionErrorKind, message: impl Into) -> CompletionError { - CompletionError { - kind, - message: message.into(), - prompt_tokens: None, - window: None, - finish_reason: None, - detail: None, - source: None, - } - } - - /// Builds an [`EmptyReply`](CompletionErrorKind::EmptyReply) failure for a - /// turn that carried no text and no tool calls. - /// - /// The message is the kind's fixed phrase, extended with - /// `: reasoning content was present but ignored` when - /// `reasoning_present` says the turn's reasoning side channel was - /// dropped rather than used as answer text. A caller that knows the - /// backend's `finish_reason` adds it with - /// [`with_finish_reason`](CompletionError::with_finish_reason). - #[must_use] - pub fn empty_reply(reasoning_present: bool) -> CompletionError { - if reasoning_present { - CompletionError::specific(CompletionErrorKind::EmptyReply, REASONING_IGNORED) - } else { - CompletionError::phrased(CompletionErrorKind::EmptyReply) - } - } - - /// Builds a [`ContextOverflow`](CompletionErrorKind::ContextOverflow) - /// failure with the token counts the provider stated. Pass `None` for a - /// count the provider omitted. - #[must_use] - pub fn context_overflow( - prompt_tokens: Option, - window: Option, - message: impl Into, - ) -> CompletionError { - CompletionError { - prompt_tokens, - window, - ..CompletionError::new(CompletionErrorKind::ContextOverflow, message) - } - } - - /// Keeps `source` as the underlying cause, reachable through - /// [`std::error::Error::source`]. - #[must_use] - pub fn with_source( - mut self, - source: impl Into>, - ) -> CompletionError { - self.source = Some(source.into()); - self - } - - /// Records the `finish_reason` the backend gave, for an - /// [`EmptyReply`](CompletionErrorKind::EmptyReply) failure. - #[must_use] - pub fn with_finish_reason(mut self, reason: impl Into) -> CompletionError { - self.finish_reason = Some(reason.into()); - self - } - - /// Records provider text that explains the failure. The caller bounds - /// and control-escapes the text before passing it. The text never - /// appears in `Display`. - #[must_use] - pub fn with_detail(mut self, text: impl Into) -> CompletionError { - self.detail = Some(text.into()); - self - } - - /// Builds a failure whose message is the kind's fixed phrase. - pub(crate) fn phrased(kind: CompletionErrorKind) -> CompletionError { - CompletionError::new(kind, kind.phrase()) - } - - /// Builds a failure whose message is the kind's fixed phrase extended - /// with `: ` and a specific this crate wrote. - fn specific(kind: CompletionErrorKind, specific: impl std::fmt::Display) -> CompletionError { - CompletionError::new(kind, format!("{}: {specific}", kind.phrase())) - } - - /// Builds an `InvalidParts` failure naming which part was refused. - pub(crate) fn invalid_parts(specific: impl std::fmt::Display) -> CompletionError { - CompletionError::specific(CompletionErrorKind::InvalidParts, specific) - } - - /// Returns the kind of this failure. - #[must_use] - pub fn kind(&self) -> CompletionErrorKind { - self.kind - } - - /// Returns the token counts that a context overflow reported, as - /// `(prompt_tokens, window)`. A count is `None` when the provider - /// omitted it, and both are `None` for every kind other than - /// `ContextOverflow`. - #[must_use] - pub fn overflow(&self) -> (Option, Option) { - (self.prompt_tokens, self.window) +/// Builds an `EmptyReply` failure for a turn that carried no text and no tool +/// calls. Its text is the phrase, extended with `: reasoning content was +/// present but ignored` when `reasoning_present` says the turn's reasoning +/// side channel was dropped rather than used as answer text. A caller that +/// knows the backend's finish reason adds it as the `FinishReason` fact. +#[must_use] +pub fn empty_reply(reasoning_present: bool) -> Error { + if reasoning_present { + specific(ErrorCode::EmptyReply, REASONING_IGNORED) + } else { + phrased(ErrorCode::EmptyReply) } +} - /// Returns the backend's `finish_reason` when the failure was an empty - /// model reply and the backend supplied one. - /// - /// The Engine's tool loop reads this value. An empty reply with - /// `Some("stop")` after at least one answered tool call, including a - /// call whose tool failed, is the model's clean exit. An empty reply - /// with a missing or `"length"` reason stays a hard failure. - #[must_use] - pub fn finish_reason(&self) -> Option<&str> { - self.finish_reason.as_deref() +/// Builds a `ContextOverflow` failure with the token counts the provider +/// stated: the prompt tokens as the `Count` fact and the window as the +/// `Limit` fact. A count the provider omitted is absent. +#[must_use] +pub fn context_overflow( + prompt_tokens: Option, + window: Option, + message: impl Into, +) -> Error { + let mut error = completion_error(ErrorCode::ContextOverflow, message); + if let Some(tokens) = prompt_tokens { + error.facts.insert(FactKey::Count, Value::from(tokens)); } - - /// Returns the message `Display` shows. - #[must_use] - pub fn message(&self) -> &str { - &self.message + if let Some(window) = window { + error.facts.insert(FactKey::Limit, Value::from(window)); } + error +} - /// Returns the provider text behind the failure, which the caller - /// bounded and control-escaped. - /// - /// The detail is an explicit opt-in diagnostic channel. The text never - /// appears in [`Display`](std::fmt::Display), so a hostile or sensitive - /// payload cannot forge log lines or leak into an error message. - #[must_use] - pub fn detail(&self) -> Option<&str> { - self.detail.as_deref() - } +/// Builds a failure whose text is the code's fixed phrase. +pub(crate) fn phrased(code: ErrorCode) -> Error { + completion_error(code, phrase(code)) } -impl std::fmt::Display for CompletionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } +/// Builds a failure whose text is the code's fixed phrase extended with `: ` +/// and a specific this crate wrote. +fn specific(code: ErrorCode, specific: impl std::fmt::Display) -> Error { + completion_error(code, format!("{}: {specific}", phrase(code))) } -impl std::error::Error for CompletionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.source - .as_deref() - .map(|source| source as &(dyn std::error::Error + 'static)) - } +/// Builds an `InvalidParts` failure naming which part was refused. +pub(crate) fn invalid_parts(part: impl std::fmt::Display) -> Error { + specific(ErrorCode::InvalidParts, part) } #[cfg(test)] diff --git a/crates/runvil-internal/model-client/src/model/options-tests.rs b/crates/runvil-internal/model-client/src/model/options-tests.rs index 63f1199e8..27c2f70b5 100644 --- a/crates/runvil-internal/model-client/src/model/options-tests.rs +++ b/crates/runvil-internal/model-client/src/model/options-tests.rs @@ -146,22 +146,29 @@ fn completion_options_reports_the_invocation_it_holds() { #[test] fn with_temperature_rejects_non_finite_and_out_of_range() { let base = || CompletionOptions::new("m"); + let reason = |temperature: f64| { + let error = base() + .with_temperature(temperature) + .expect_err("the temperature must be rejected"); + assert_eq!(error.code, ErrorCode::InvalidValue); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from("temperature")) + ); + assert!(!error.prose.contains(&error.message), "{error:?}"); + error.facts.get(&FactKey::Reason).cloned() + }; + assert_eq!(reason(f64::NAN), Some(Value::from("not_finite"))); + assert_eq!(reason(f64::INFINITY), Some(Value::from("not_finite"))); + assert_eq!(reason(-0.1), Some(Value::from("out_of_range"))); + assert_eq!(reason(2.5), Some(Value::from("out_of_range"))); assert_eq!( - base().with_temperature(f64::NAN), - Err(TemperatureError::NotFinite) - ); - assert_eq!( - base().with_temperature(f64::INFINITY), - Err(TemperatureError::NotFinite) + base() + .with_temperature(2.5) + .expect_err("2.5 is out of range") + .message, + "temperature 2.5 is outside the supported range [0.0, 2.0]" ); - assert!(matches!( - base().with_temperature(-0.1), - Err(TemperatureError::OutOfRange { .. }) - )); - assert!(matches!( - base().with_temperature(2.5), - Err(TemperatureError::OutOfRange { .. }) - )); // The range endpoints and an interior value are accepted. assert_eq!( base() diff --git a/crates/runvil-internal/model-client/src/model/options.rs b/crates/runvil-internal/model-client/src/model/options.rs index 66deddd73..c946759fa 100644 --- a/crates/runvil-internal/model-client/src/model/options.rs +++ b/crates/runvil-internal/model-client/src/model/options.rs @@ -3,7 +3,9 @@ use std::num::NonZeroU32; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::models::{ModelId, ModelInfo, ThinkingMode}; +use serde_json::Value; /// The largest sampling temperature the backend accepts. const TEMPERATURE_MAX: f64 = 2.0; @@ -22,14 +24,21 @@ impl Temperature { /// Builds a temperature from a finite `value` within `[0.0, 2.0]`. /// /// # Errors - /// Returns [`TemperatureError`] when `value` is `NaN` or an infinity, or - /// falls outside `[0.0, 2.0]`. - pub fn new(value: f64) -> std::result::Result { + /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error with the + /// reason `not_finite` when `value` is `NaN` or an infinity, or + /// `out_of_range` when it falls outside `[0.0, 2.0]`. + pub fn new(value: f64) -> std::result::Result { if !value.is_finite() { - return Err(TemperatureError::NotFinite); + return Err(invalid_temperature( + "not_finite", + "temperature must be finite".to_owned(), + )); } if !(0.0..=TEMPERATURE_MAX).contains(&value) { - return Err(TemperatureError::OutOfRange { value }); + return Err(invalid_temperature( + "out_of_range", + format!("temperature {value} is outside the supported range [0.0, 2.0]"), + )); } Ok(Temperature(value)) } @@ -41,20 +50,19 @@ impl Temperature { } } -/// The reason a sampling temperature was rejected. -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum TemperatureError { - /// The temperature was `NaN` or an infinity. - #[error("temperature must be finite")] - NotFinite, - /// The temperature fell outside the supported `[0.0, 2.0]` range. - #[error("temperature {value} is outside the supported range [0.0, 2.0]")] - #[non_exhaustive] - OutOfRange { - /// The rejected value. - value: f64, - }, +/// The error for a rejected temperature: an `InvalidValue` that names the +/// `temperature` setting, with the broken rule as its reason. +fn invalid_temperature(reason: &'static str, message: String) -> Error { + let mut error = Error::new( + ErrorCode::InvalidValue, + message, + "The temperature must be a finite number from 0.0 to 2.0. Use a value in that range.", + ); + error + .facts + .insert(FactKey::Name, Value::from("temperature")); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error } /// The fixed request settings that a model binding applies to every completion. @@ -271,12 +279,13 @@ impl CompletionOptions { /// the backend-supported range `[0.0, 2.0]`. /// /// # Errors - /// Returns [`TemperatureError`] when `temperature` is `NaN` or an infinity, - /// or falls outside `[0.0, 2.0]`, so only a valid temperature is sent. + /// Returns the [`InvalidValue`](ErrorCode::InvalidValue) error of + /// [`Temperature::new`] when `temperature` is `NaN` or an infinity, or + /// falls outside `[0.0, 2.0]`, so only a valid temperature is sent. pub fn with_temperature( mut self, temperature: f64, - ) -> std::result::Result { + ) -> std::result::Result { self.invocation.temperature = Some(Temperature::new(temperature)?); Ok(self) } diff --git a/crates/runvil-internal/model-client/src/normalize-tests.rs b/crates/runvil-internal/model-client/src/normalize-tests.rs index 47c93c188..19abf4609 100644 --- a/crates/runvil-internal/model-client/src/normalize-tests.rs +++ b/crates/runvil-internal/model-client/src/normalize-tests.rs @@ -1,19 +1,19 @@ //! Tests for the neutral reply checks, driven through the validating //! constructors that run them: each check's kind and exact message. +use runvil_types::error::{ErrorCode, FactKey}; use serde_json::json; use super::*; use crate::client::{Completion, CompletionResult, ToolCall}; -use crate::model::CompletionErrorKind; const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; const INVALID_PARTS_PHRASE: &str = "the completion was built from invalid parts"; -/// The kind and message `ToolCall::from_parts` refuses these parts with. -fn refusal(id: &str, name: &str, arguments: Value) -> (CompletionErrorKind, String) { +/// The code and message `ToolCall::from_parts` refuses these parts with. +fn refusal(id: &str, name: &str, arguments: Value) -> (ErrorCode, String) { let error = ToolCall::from_parts(id, name, arguments).expect_err("the parts are refused"); - (error.kind(), error.to_string()) + (error.code, error.to_string()) } #[test] @@ -21,7 +21,7 @@ fn blank_tool_call_id_is_rejected() { assert_eq!( refusal(" ", "ping", json!({})), ( - CompletionErrorKind::InvalidParts, + ErrorCode::InvalidParts, format!("{INVALID_PARTS_PHRASE}: tool call id was blank") ) ); @@ -32,7 +32,7 @@ fn blank_tool_call_name_is_rejected() { assert_eq!( refusal("call_1", "\t", json!({})), ( - CompletionErrorKind::InvalidParts, + ErrorCode::InvalidParts, format!("{INVALID_PARTS_PHRASE}: tool call name was blank") ) ); @@ -44,7 +44,7 @@ fn non_object_arguments_are_rejected() { assert_eq!( refusal("call_arr", "ping", arguments), ( - CompletionErrorKind::InvalidParts, + ErrorCode::InvalidParts, format!("{INVALID_PARTS_PHRASE}: tool call arguments were not a JSON object") ) ); @@ -72,7 +72,7 @@ fn duplicate_tool_call_ids_are_rejected() { let error = Completion::from_result(CompletionResult::ToolCalls(vec![call("a"), call("b")]), "m") .expect_err("two calls sharing an id are refused"); - assert_eq!(error.kind(), CompletionErrorKind::InvalidParts); + assert_eq!(error.code, ErrorCode::InvalidParts); assert_eq!( error.to_string(), format!("{INVALID_PARTS_PHRASE}: duplicate tool call id \"dup\" within one turn") @@ -83,7 +83,7 @@ fn duplicate_tool_call_ids_are_rejected() { fn an_empty_tool_call_batch_is_an_empty_reply() { let error = Completion::from_result(CompletionResult::ToolCalls(Vec::new()), "m") .expect_err("an empty batch is refused"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); - assert_eq!(error.finish_reason(), None); + assert_eq!(error.facts.get(&FactKey::FinishReason), None); } diff --git a/crates/runvil-internal/model-client/src/normalize.rs b/crates/runvil-internal/model-client/src/normalize.rs index b26219941..0ce0dce4f 100644 --- a/crates/runvil-internal/model-client/src/normalize.rs +++ b/crates/runvil-internal/model-client/src/normalize.rs @@ -9,20 +9,22 @@ use std::collections::HashSet; +use runvil_types::error::Error; use serde_json::Value; use crate::Result; -use crate::model::CompletionError; +use crate::model::empty_reply; +use crate::model::error::invalid_parts; /// The empty-reply error for a turn with no product. -pub(crate) fn empty_reply_error() -> CompletionError { - CompletionError::empty_reply(false) +pub(crate) fn empty_reply_error() -> Error { + empty_reply(false) } /// Refuses a blank tool-call id. pub(crate) fn check_call_id(id: &str) -> Result<()> { if id.trim().is_empty() { - return Err(CompletionError::invalid_parts("tool call id was blank")); + return Err(invalid_parts("tool call id was blank")); } Ok(()) } @@ -30,7 +32,7 @@ pub(crate) fn check_call_id(id: &str) -> Result<()> { /// Refuses a blank tool-call name. pub(crate) fn check_call_name(name: &str) -> Result<()> { if name.trim().is_empty() { - return Err(CompletionError::invalid_parts("tool call name was blank")); + return Err(invalid_parts("tool call name was blank")); } Ok(()) } @@ -45,16 +47,14 @@ pub(crate) fn check_call_name(name: &str) -> Result<()> { pub(crate) fn checked_call_arguments(arguments: Value) -> Result> { match arguments { Value::Object(map) => Ok(map), - _ => Err(CompletionError::invalid_parts( - "tool call arguments were not a JSON object", - )), + _ => Err(invalid_parts("tool call arguments were not a JSON object")), } } /// Records `id` in `seen`, refusing an id another call in the turn has. pub(crate) fn check_unique_call_id<'a>(seen: &mut HashSet<&'a str>, id: &'a str) -> Result<()> { if !seen.insert(id) { - return Err(CompletionError::invalid_parts(format!( + return Err(invalid_parts(format!( "duplicate tool call id {id:?} within one turn" ))); } diff --git a/crates/runvil-internal/prompt/README.md b/crates/runvil-internal/prompt/README.md index ece6bf035..8b3227e8b 100644 --- a/crates/runvil-internal/prompt/README.md +++ b/crates/runvil-internal/prompt/README.md @@ -1,3 +1,3 @@ # Prompt loader -This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. It does no execution. +This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence in the H1 body and the sections compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. It does no execution. diff --git a/crates/runvil-internal/prompt/src/contract.rs b/crates/runvil-internal/prompt/src/contract.rs index ff6a68e06..93b08e6b4 100644 --- a/crates/runvil-internal/prompt/src/contract.rs +++ b/crates/runvil-internal/prompt/src/contract.rs @@ -2,14 +2,16 @@ //! //! The YAML is the whole contract: Packs install, models declare, args //! type. Parsing validates the static shape - each `uses:` entry a bare -//! Pack id or vendor/name, no Pack id declared twice, the alias grammar -//! on arg names, the five model role names, arg name and type sanity - and -//! exposes the FULL declaration on the parsed [`Prompt`](crate::Prompt); -//! checking the declaration against the caller's environment is the job of -//! the run's first step, never the parser's. +//! Pack id or vendor/name, no Pack id declared twice, no reserved Pack id, +//! the alias grammar on arg names, the five model role names, and each arg +//! default against its declared type - and exposes the FULL declaration on +//! the parsed [`Prompt`](crate::Prompt); checking the declaration against +//! the caller's environment is the job of the run's first step, never the +//! parser's. //! -//! `args` and `models` are defined in submodules; this root owns the -//! duplicate-Pack check and the map deserializer the two map keys share. +//! `args` and `models` are defined in submodules; this root owns the `uses:` +//! checks, the arg-name check, and the map deserializer the two map keys +//! share. use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -17,6 +19,7 @@ use std::fmt; use std::marker::PhantomData; use runvil_types::models::ModelRole; +use runvil_types::names::is_alias; use runvil_types::packs::{PackId, PackRef}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; @@ -31,20 +34,6 @@ pub use args::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter}; pub(crate) use models::RoleMap; pub use models::RoleRequirements; -/// The prompt-local alias grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. -/// -/// Args field names are prompt-local and never global names. (The same rule -/// sits in `runvil-lua`'s `alias` module, the run-time counterpart to this -/// parse-time check.) -fn is_valid_alias(alias: &str) -> bool { - let bytes = alias.as_bytes(); - (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - /// A key of a contract map: how it is checked and how an error names it. trait ContractKey: Ord { /// Refuses a key the map's grammar does not allow. @@ -56,7 +45,7 @@ trait ContractKey: Ord { impl ContractKey for String { fn check(&self, what: &str) -> Result<(), String> { - if is_valid_alias(self) { + if is_alias(self) { Ok(()) } else { Err(format!( diff --git a/crates/runvil-internal/prompt/src/contract/args.rs b/crates/runvil-internal/prompt/src/contract/args.rs index 8dd5c634e..852ad8c23 100644 --- a/crates/runvil-internal/prompt/src/contract/args.rs +++ b/crates/runvil-internal/prompt/src/contract/args.rs @@ -1,9 +1,8 @@ //! Parses the `args` frontmatter key into typed arg declarations. //! -//! The declaration advertises, documents, and derives the tool schema; it -//! does not enforce - enforcement belongs to the prompt's H1. There are no -//! freeform prompts: a prompt with no `args:` key gets the default -//! declaration of one optional string field named `prose`. +//! The declaration advertises and documents the prompt's args; it does not +//! enforce - enforcement belongs to the prompt's H1. There are no freeform +//! prompts: a prompt with no `args:` key gets the default [`ArgsDecl`]. use std::collections::{BTreeMap, btree_map}; @@ -47,7 +46,6 @@ impl std::fmt::Display for ArgType { /// The declaration of one prompt arg: its type, whether it is optional, its /// default, and its description. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct ArgDecl { kind: ArgType, optional: bool, @@ -84,7 +82,8 @@ impl ArgDecl { } } -/// The map form of an arg declaration (`type` is a Rust keyword). +/// The unchecked map form of an arg declaration: [`ArgDecl`]'s `Deserialize` +/// reads it, then refuses a `default` whose YAML type does not match `type`. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ArgDeclWire { @@ -155,18 +154,12 @@ impl<'de> Deserialize<'de> for ArgDecl { /// /// Every prompt has an args declaration. A prompt whose frontmatter omits /// the `args` key gets the default declaration: one optional string arg -/// named `prose`. The input text of such a prompt arrives wrapped as +/// named `prose`. The argument string of such a prompt arrives wrapped as /// `argv = { prose = "" }`. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct ArgsDecl { fields: BTreeMap, - /// True only when the declaration is the implicit default (the `args:` - /// key was absent). - /// - /// Interface prose then wraps into `argv.prose`. An explicit `args:` - /// key - even one identical to the default shape - is a structured - /// declaration and never wraps. + /// Whether the `args:` key was absent; see [`ArgsDecl::is_default`]. implicit: bool, } @@ -174,9 +167,8 @@ impl ArgsDecl { /// Returns whether this is the default declaration, which a prompt gets /// when its frontmatter omits the `args` key. /// - /// The input text of such a prompt arrives wrapped as `argv.prose`. This - /// returns `false` for every explicit `args` key, even one that declares - /// the same single `prose` arg. + /// This returns `false` for every explicit `args` key, even one that + /// declares the same single `prose` arg. #[must_use] pub fn is_default(&self) -> bool { self.implicit @@ -242,7 +234,6 @@ impl<'a> Iterator for ArgsDeclIter<'a> { impl ExactSizeIterator for ArgsDeclIter<'_> {} impl Default for ArgsDecl { - /// The default declaration: one optional string field named `prose`. fn default() -> Self { let mut fields = BTreeMap::new(); fields.insert( diff --git a/crates/runvil-internal/prompt/src/contract/models.rs b/crates/runvil-internal/prompt/src/contract/models.rs index 7634bdd3c..1f1a9a326 100644 --- a/crates/runvil-internal/prompt/src/contract/models.rs +++ b/crates/runvil-internal/prompt/src/contract/models.rs @@ -24,7 +24,6 @@ use crate::contract; /// requires a model that can run without thinking and turns it off. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct RoleRequirements { /// The minimum context window in tokens. #[serde(default)] diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index 15a7f24ea..958907185 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -24,19 +24,22 @@ type BoxedSource = Box; /// internal type variant-for-variant; the facade does not re-export it. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The prompt frontmatter was not valid YAML. + /// The prompt frontmatter did not decode: it was not valid YAML, or a key + /// or value broke the frontmatter schema, such as an unknown key or an arg + /// default of the wrong type. /// /// Keeps the decode failure as the cause beside the location it reports. #[error("invalid frontmatter: {message}")] ParseFrontmatter { /// The human-readable diagnostic (no raw source dump). message: String, - /// The originating YAML parse failure, whose text `message` holds. + /// The originating YAML decode failure, whose text `message` holds. cause: BoxedSource, /// The 1-based file line of the YAML failure, surfaced from the /// cause's location when it has one. line: Option, - /// The 1-based file column of the YAML failure, when known. + /// The 1-based file column of the YAML failure, counted in characters + /// as the YAML parser counts them, when known. column: Option, }, @@ -54,8 +57,8 @@ pub enum Error { span: Option>, /// The human-readable diagnostic. message: String, - /// The prompt's frontmatter name, stamped when the failure postdates - /// the frontmatter (a frontmatter failure predates the name). + /// The prompt's frontmatter name, stamped on a failure found in the + /// body or raised by the Engine. name: Option, /// The 1-based file line of the span's start, computed against the /// source when a span is known. @@ -64,9 +67,9 @@ pub enum Error { column: Option, }, - /// A Lua region failed to compile at parse time, preserved as the - /// `runvil-lua` internal error type so the compiler diagnostic - /// chain survives unchanged. + /// A Lua region failed to compile at parse time, or the compiler VM could + /// not start, preserved as the `runvil-lua` internal error type so the + /// compiler diagnostic chain survives unchanged. #[error(transparent)] Lua(#[from] runvil_lua::Error), @@ -129,8 +132,8 @@ impl Error { /// /// The position is the span's 1-based file line and byte column, when /// the failure has a span. Every other variant passes through unchanged: - /// a frontmatter failure predates the name, a Lua compile failure already - /// reports its own position, and an internal failure has none. + /// a frontmatter failure predates the name, a Lua failure reports its own + /// position, if it has one, and an internal failure has none. pub(crate) fn with_prompt_context( self, name: &str, @@ -165,9 +168,9 @@ impl Error { /// Returns the 1-based file line and byte column of `byte_offset` within /// `body`, offset past the frontmatter lines. /// -/// Both are `None` when the offset is out of bounds or the arithmetic -/// overflows - an invariant break that must not replace the original parse -/// failure. +/// Both are `None` when the offset is out of bounds, and either is `None` +/// alone when its own arithmetic overflows - an invariant break that must not +/// replace the original parse failure. fn body_line_column( body: &str, byte_offset: usize, @@ -208,7 +211,7 @@ pub enum ParseErrorKind { /// fence looked like a `lua` opening line (Lookalike). Lines inside the /// body of another fenced code block are never inspected. Fence, - /// A list-only section contained prose lines or empty items. + /// A list section had an empty item. List, /// A Lua region failed to compile because of a syntax error. Lua, @@ -223,7 +226,6 @@ pub enum ParseErrorKind { /// failure happened. It keeps the originating error, when there is one, /// which [`cause`](ParseError::cause) returns. #[derive(Debug)] -#[non_exhaustive] pub struct ParseError { inner: Box, } @@ -296,13 +298,12 @@ impl ParseError { classify_parse_error(&self.inner).span } - /// Returns the prompt's frontmatter name, when the failure came after the - /// frontmatter was read. + /// Returns the prompt's frontmatter name for a `Structure`, `Fence`, or + /// `List` failure found past the frontmatter. /// - /// A frontmatter failure happens before the parser learns the name, which - /// comes from the frontmatter itself. It returns `None`, and the caller - /// can use its own label for the source. A Lua compile failure - /// also returns `None`. + /// Every other failure returns `None`, including every `Frontmatter` + /// failure, a refused `uses:` list among them, and every `Lua` and + /// `Internal` failure. The caller can use its own label for the source. #[must_use] pub fn name(&self) -> Option<&str> { match &*self.inner { @@ -322,6 +323,10 @@ impl ParseError { } /// Returns the 1-based column of the failure, when known. + /// + /// For a frontmatter YAML failure, this is the column the YAML parser + /// reported, which counts characters. For a failure with a byte span, it + /// is the byte column where the span starts. #[must_use] pub fn column(&self) -> Option { classify_parse_error(&self.inner).column diff --git a/crates/runvil-internal/prompt/src/fence.rs b/crates/runvil-internal/prompt/src/fence.rs index 62395932f..dd4d49a1d 100644 --- a/crates/runvil-internal/prompt/src/fence.rs +++ b/crates/runvil-internal/prompt/src/fence.rs @@ -1,78 +1,40 @@ //! Checks the strict `lua` fence rule and splits content into Lua and prose blocks. //! //! H1 and section content is an alternating sequence of `lua` fences and -//! prose. A `lua` fence opens with a line that is exactly three backticks -//! then `lua` and closes with a line that is exactly three backticks. This -//! module finds the first fence in the body that breaks the Label, Opener, or -//! Closer rule, or the first Lookalike line, so the parse can reject it, and -//! splits content into [`RawBlock`]s that the facade compiles into -//! [`Block`]s. Lines inside the body of another fenced code block are never -//! inspected, which is how a document shows a Lua fence as an example. +//! prose. [`Prompt::parse`](crate::Prompt::parse) states the strict fence +//! rule. This module finds the first fence or line in the body that breaks +//! its Label, Opener, Closer, or Lookalike case, so the parse can reject it, +//! and splits content into [`RawBlock`]s, which this crate compiles into +//! [`Block`](crate::Block)s. use std::ops::Range; -use std::sync::Arc; use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; -use runvil_types::emitter::Emitter; -use crate::{Block, Error, ParseErrorKind, Result, position}; -use runvil_lua::LuaProgram; +use crate::{Error, ParseErrorKind, Result, position}; /// Uncompiled block produced while scanning section content. -pub(super) enum RawBlock<'a> { +pub(crate) enum RawBlock<'a> { Lua { source: String, /// Lines before the first line of Lua source within section content. line_offset: u32, + /// The block's place in its section, which names it in its compile + /// location. + role: LuaRole, }, Prose(&'a str), } -/// Compiles H1 content as alternating live Lua and prose blocks. -/// -/// `content` is the H1's stretch of `body`, and `content_abs_line` is the -/// 1-based line number in the full input where `content` begins. -/// -/// # Errors -/// Returns a fence-classified parse error for an unclosed fence, and a Lua -/// compilation error for invalid Lua. -pub(super) fn split_h1( - body: &Arc, - content: &str, - title: &str, - content_abs_line: u32, - emitter: &Emitter, -) -> Result> { - let raw_blocks = split_section_blocks(content, title)?; - let mut blocks = Vec::with_capacity(raw_blocks.len()); - for raw in raw_blocks { - match raw { - RawBlock::Prose(text) => blocks.push(Block::Prose { - text: position::body_text(body, text)?, - }), - RawBlock::Lua { - source, - line_offset, - } => { - let location = format!("H1 `{title}` lua"); - let line = position::line_add(content_abs_line, line_offset)?; - blocks.push(Block::Lua(LuaProgram::compile( - &source, - &location, - position::nz_source_line(line)?, - emitter, - title, - )?)); - } - } - } - if matches!( - blocks.as_slice(), - [Block::Prose { text }] if text.as_str().is_empty() - ) { - blocks.clear(); - } - Ok(blocks) +/// A Lua block's place among its section's blocks. +pub(crate) enum LuaRole { + /// The first block. + Prologue, + /// Neither first nor last, with its 1-based place among the section's + /// Lua blocks. + Middle(usize), + /// The last block, when it is not also the first. + Epilog, } /// Removes complete leading lines that contain only whitespace. @@ -92,7 +54,7 @@ pub(crate) fn trim_leading_blank_lines(content: &str) -> &str { /// closes the fence on. The validator refuses any closing line that is not /// exactly three backticks before a split runs, so a split that meets one has /// broken an invariant, and it reports an internal error. -fn extract_exact_fence<'a>(content: &'a str, label: &str) -> Result<(String, &'a str)> { +fn extract_exact_fence<'a>(content: &'a str, location: &str) -> Result<(String, &'a str)> { let mut offset = 0; for line in content.split_inclusive('\n') { let text = line.strip_suffix('\n').unwrap_or(line); @@ -110,7 +72,7 @@ fn extract_exact_fence<'a>(content: &'a str, label: &str) -> Result<(String, &'a } Err(Error::parse( ParseErrorKind::Fence, - format!("{label} fence is not closed"), + format!("{location} fence is not closed"), )) } @@ -153,7 +115,7 @@ fn exact_lua_openings(content: &str) -> Vec { } /// The rule a fence or line breaks, as found by [`first_fence_violation`]. -pub(super) enum FenceViolation { +pub(crate) enum FenceViolation { /// Label: the fence's label starts with `lua` in any letter case without /// being exactly `lua`. Label(String), @@ -177,7 +139,7 @@ pub(super) enum FenceViolation { /// fence, such as in an indented code block or a raw HTML block, and breaks /// the Opener rule. Lines inside a fence's extent are never checked against /// the Lookalike rule, so a fence nested in another fence's body never fails. -pub(super) fn first_fence_violation(body: &str) -> Option<(Range, FenceViolation)> { +pub(crate) fn first_fence_violation(body: &str) -> Option<(Range, FenceViolation)> { let mut extents = Vec::new(); let mut fence = None; for (extent, label) in fence_openings(body) { @@ -304,7 +266,7 @@ fn pending_prose<'a>(content: &'a str, segment: Range, rules: &[Range(content: &'a str, segment: Range, rules: &[Range( +pub(crate) fn split_section_blocks<'a>( content: &'a str, section: &str, ) -> Result>> { @@ -344,15 +306,13 @@ pub(super) fn split_section_blocks<'a>( "parser: section fence split disagrees with the Markdown parser", )); }; - // Preserve classic location wording for unclosed leading/trailing fences. - let label = if index == 0 && opening == leading_start { + // An unclosed fence runs to the end of its content, so it is always the last block. + let location = if index == 0 && opening == leading_start { format!("section `{section}` prologue `lua`") - } else if index + 1 == openings.len() { - format!("section `{section}` epilog `lua`") } else { - format!("section `{section}` `lua`") + format!("section `{section}` epilog `lua`") }; - let (source, rest) = extract_exact_fence(after_open, &label)?; + let (source, rest) = extract_exact_fence(after_open, &location)?; let fence_end = content.len() - rest.len(); if !(pos == 0 && opening == leading_start) { @@ -364,6 +324,7 @@ pub(super) fn split_section_blocks<'a>( blocks.push(RawBlock::Lua { source, line_offset, + role: LuaRole::Middle(index + 1), }); pos = fence_end; } @@ -373,30 +334,29 @@ pub(super) fn split_section_blocks<'a>( blocks.push(RawBlock::Prose(trailing)); } + if let Some(RawBlock::Lua { role, .. }) = blocks.first_mut() { + *role = LuaRole::Prologue; + } + if blocks.len() > 1 + && let Some(RawBlock::Lua { role, .. }) = blocks.last_mut() + { + *role = LuaRole::Epilog; + } + Ok(blocks) } -/// Compile label for one Lua block of the section named by `label`, -/// preserving classic prologue/epilog names. +/// The compile location of one Lua block, from its section's location and +/// its role. /// -/// Every other block is named by `ordinal`, its 1-based place among the -/// section's Lua blocks. The label is the display name mapped runtime -/// errors give the block; mapping itself matches the block's line-based -/// chunk name, so the label need not be unique or short. -pub(super) fn lua_block_location( - label: &str, - index: usize, - total: usize, - has_prose: bool, - ordinal: usize, -) -> String { - let is_first = index == 0; - let is_last = index + 1 == total; - if is_first { - return format!("{label} prologue"); - } - if is_last && has_prose { - return format!("{label} epilog"); +/// A middle block is named by its 1-based place among the section's Lua +/// blocks. The location is the display name mapped runtime errors give the +/// block; mapping itself matches the block's line-based chunk name, so the +/// location need not be unique or short. +pub(crate) fn lua_block_location(section_location: &str, role: &LuaRole) -> String { + match role { + LuaRole::Prologue => format!("{section_location} prologue"), + LuaRole::Epilog => format!("{section_location} epilog"), + LuaRole::Middle(n) => format!("{section_location} lua {n}"), } - format!("{label} lua {ordinal}") } diff --git a/crates/runvil-internal/prompt/src/frontmatter.rs b/crates/runvil-internal/prompt/src/frontmatter.rs index 0ed15e91f..34ba41bab 100644 --- a/crates/runvil-internal/prompt/src/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/frontmatter.rs @@ -11,11 +11,9 @@ use crate::{ArgsDecl, Error, ParseErrorKind, Result, RoleRequirements}; /// A declared input or output file in a prompt's frontmatter. /// /// The `path` is the file's name in the store, which the prompt reads or -/// writes. The `description` documents the file's purpose and also feeds MCP -/// schema generation. +/// writes. The `description` documents the file's purpose for the caller. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct FileDecl { /// The store-internal path (e.g. `"paper.md"`). path: String, @@ -43,18 +41,17 @@ impl FileDecl { /// a misspelled or extra one, is a prompt authoring error and fails the parse. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct Frontmatter { - /// The prompt's identifier, supplied explicitly by a caller. - pub(crate) name: String, - /// One-line description shown in prompt listings and name retrieval. - pub(crate) description: String, + /// The prompt's identifier, from the `name:` key. + name: String, + /// The prompt's one-line summary, from the `description:` key. + description: String, /// The runvil Engine major this file targets, when declared. /// /// Its presence marks the file as a runvil prompt; `None` means the /// file is not one. #[serde(default)] - pub(crate) runvil: Option, + runvil: Option, /// Maximum model round trips a section's tool-call loop may take. /// /// An absent value leaves the runtime's own cap in force. A declared @@ -62,7 +59,7 @@ pub struct Frontmatter { /// negatives, and values above [`MAX_TOOL_ITERATIONS`], so no invalid /// cap can reach execution. #[serde(default, deserialize_with = "max_tool_iterations")] - pub(crate) max_tool_iterations: Option, + max_tool_iterations: Option, /// A file the prompt expects to find in the store when it starts. #[serde(default)] input: Option, @@ -75,7 +72,7 @@ pub struct Frontmatter { #[serde(default)] uses: Vec, /// The typed args declaration; an absent `args:` key yields the default - /// declaration (one optional string field named `prose`). + /// [`ArgsDecl`]. #[serde(default)] args: ArgsDecl, /// The requirements declared per model role. @@ -104,15 +101,16 @@ where "max_tool_iterations must be a positive integer (>= 1), got {raw}" ))); } - if raw > i64::from(MAX_TOOL_ITERATIONS) { - return Err(serde::de::Error::custom(format!( - "max_tool_iterations must be <= {MAX_TOOL_ITERATIONS}, got {raw}" - ))); - } - // 1 <= raw <= MAX_TOOL_ITERATIONS, so both conversions are infallible. - let value = u32::try_from(raw) - .map_err(|_| serde::de::Error::custom("max_tool_iterations is out of range"))?; - Ok(NonZeroU32::new(value)) + u32::try_from(raw) + .ok() + .and_then(NonZeroU32::new) + .filter(|v| v.get() <= MAX_TOOL_ITERATIONS) + .map(Some) + .ok_or_else(|| { + serde::de::Error::custom(format!( + "max_tool_iterations must be <= {MAX_TOOL_ITERATIONS}, got {raw}" + )) + }) } impl Frontmatter { @@ -122,7 +120,7 @@ impl Frontmatter { &self.name } - /// Returns the one-line description shown in listings. + /// Returns the prompt's one-line summary. #[must_use] pub fn description(&self) -> &str { &self.description @@ -172,8 +170,7 @@ impl Frontmatter { /// Returns the typed args declaration. /// - /// A prompt that omits the `args:` key yields the default declaration - /// (one optional string field named `prose`). + /// A prompt that omits the `args:` key yields the default [`ArgsDecl`]. #[must_use] pub fn args(&self) -> &ArgsDecl { &self.args @@ -187,14 +184,25 @@ impl Frontmatter { } } -/// Splits a file into its YAML frontmatter, its markdown body, and the -/// number of lines consumed by the frontmatter block (both `---` delimiters -/// and everything between them). +/// A prompt file split at its frontmatter by [`split_frontmatter`]. +#[derive(Debug)] +pub(crate) struct SplitSource { + /// The YAML text between the two `---` delimiters. + pub(crate) yaml: String, + /// The markdown body after the closing `---` line. + pub(crate) body: String, + /// The number of lines consumed by the frontmatter block (both `---` + /// delimiters and everything between them). + pub(crate) frontmatter_lines: u32, +} + +/// Splits a file into its YAML frontmatter and its markdown body. /// /// The file must open with a `---` line and close the frontmatter with another /// `---` line starting at column 0; an indented `---` is YAML block scalar -/// text. `str::lines` handles both `\n` and `\r\n`. -pub(crate) fn split_frontmatter(input: &str) -> Result<(String, String, u32)> { +/// text. `str::lines` handles both `\n` and `\r\n`. Whitespace before or after +/// the dashes of the opening line is ignored. +pub(crate) fn split_frontmatter(input: &str) -> Result { let input = input.strip_prefix('\u{feff}').unwrap_or(input); // drop BOM let mut lines = input.lines(); match lines.next() { @@ -225,5 +233,9 @@ pub(crate) fn split_frontmatter(input: &str) -> Result<(String, String, u32)> { )); } let body = lines.collect::>().join("\n"); - Ok((yaml, body, line_count)) + Ok(SplitSource { + yaml, + body, + frontmatter_lines: line_count, + }) } diff --git a/crates/runvil-internal/prompt/src/lib.rs b/crates/runvil-internal/prompt/src/lib.rs index 1a40cd632..782703d43 100644 --- a/crates/runvil-internal/prompt/src/lib.rs +++ b/crates/runvil-internal/prompt/src/lib.rs @@ -4,7 +4,8 @@ //! blocks, then H2 sections. //! H1 and section content are alternating sequences of exact `lua` fences and //! prose ([`Block`]). Sections nest recursively (H3 under H2, H4 under H3, and -//! so on through H6). Classic prologue/prose/epilog is exactly `[Lua, Prose, Lua]`. +//! so on through H6). A section with a prologue, prose, and an epilog is +//! exactly `[Lua, Prose, Lua]`. //! //! Prose capture follows the pending-Markdown model: Markdown accumulates //! after each heading or ordinary `lua` fence, and each prose block is the @@ -14,8 +15,9 @@ //! normally. Markdown left after the final `lua` fence is inert trailing //! commentary, never an error. //! -//! Every `lua` fence compiles at load time, so a loaded [`Prompt`] is ready to -//! run: a syntax error surfaces here, never partway through a run. +//! Every `lua` fence in the H1 body and the sections compiles at load time, so +//! a loaded [`Prompt`] is ready to run: a syntax error surfaces here, never +//! partway through a run. //! //! ## Invariants //! diff --git a/crates/runvil-internal/prompt/src/list.rs b/crates/runvil-internal/prompt/src/list.rs index c844e945a..b4a1302c1 100644 --- a/crates/runvil-internal/prompt/src/list.rs +++ b/crates/runvil-internal/prompt/src/list.rs @@ -1,24 +1,18 @@ //! Detects list-only sections and extracts their bullet items. -//! -//! A no-Lua section whose every nonblank line is a bullet marker is a list; one -//! marker classifier (`classify_list_line`) feeds both detection -//! ([`is_all_list_markers`]) and extraction ([`parse_bullet_items`]) so the two -//! can never disagree about what counts as a marker. use crate::{Error, ParseErrorKind, Result}; -/// Parses bullet items from prose in a list-only section. -/// -/// A list-only section has no Lua blocks. Its prose must contain only unordered -/// (`- ` or `* `) or ordered (`N. ` or `N) `) bullet lines, with blank lines -/// ignored. Returns an error if the section contains non-list content or if the -/// items list is empty. +/// Returns the items of a list section's prose, or `None` when the prose is +/// not a list. Prose is a list when every nonblank line is a bullet marker +/// and at least one is, so one incidental bullet in ordinary prose leaves it +/// prose. /// /// # Errors -/// Returns a list-classified parse error when a line is a non-list line, an -/// empty marker, or when no items were found. -pub(super) fn parse_bullet_items<'a>(prose: &'a str, section: &str) -> Result> { +/// Returns a list-classified parse error when the prose is a list and a +/// marker has no content. +pub(crate) fn bullet_items<'a>(prose: &'a str, section: &str) -> Result>> { let mut items = Vec::new(); + let mut has_empty_marker = false; for line in prose.lines() { let trimmed = line.trim(); if trimmed.is_empty() { @@ -26,60 +20,20 @@ pub(super) fn parse_bullet_items<'a>(prose: &'a str, section: &str) -> Result items.push(content), - ListLine::EmptyMarker => { - return Err(Error::parse( - ParseErrorKind::List, - format!("empty bullet item in list section `{section}`"), - )); - } - ListLine::NotAMarker => { - return Err(Error::parse( - ParseErrorKind::List, - format!( - "section `{section}` is a list section but contains non-list content: {trimmed}" - ), - )); - } + ListLine::EmptyMarker => has_empty_marker = true, + ListLine::NotAMarker => return Ok(None), } } - if items.is_empty() { + if has_empty_marker { return Err(Error::parse( ParseErrorKind::List, - format!("section `{section}` is a list section but has no items"), + format!("empty bullet item in list section `{section}`"), )); } - Ok(items) -} - -/// Returns true when `prose` is entirely list markers. -/// -/// Every nonblank line must be a list marker (well-formed or empty), and at -/// least one such line must exist. This is the list-only invariant: a single -/// incidental bullet line in otherwise ordinary prose leaves a non-marker -/// line present, so the section is classified as prose rather than forced -/// through strict list parsing. A section whose lines are all markers but -/// include an empty one is still a list, so [`parse_bullet_items`] reports -/// the empty item. -pub(super) fn is_all_list_markers(prose: &str) -> bool { - let mut saw_marker = false; - for line in prose.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match classify_list_line(trimmed) { - ListLine::NotAMarker => return false, - ListLine::Item(_) | ListLine::EmptyMarker => saw_marker = true, - } - } - saw_marker + Ok((!items.is_empty()).then_some(items)) } /// The classification of a single nonblank prose line as a list marker. -/// -/// One classifier feeds both list *detection* (is this section a list?) and -/// *extraction* (parse its items), so the two can never disagree about what -/// counts as a marker. enum ListLine<'a> { /// A well-formed marker with nonblank content (the returned item text). Item(&'a str), @@ -165,8 +119,11 @@ mod tests { #[test] fn all_markers_predicate() { - assert!(is_all_list_markers("- a\n1. b")); - assert!(!is_all_list_markers("- a\nplain line")); - assert!(!is_all_list_markers(" \n ")); + assert_eq!( + bullet_items("- a\n1. b", "test").unwrap(), + Some(vec!["a", "b"]) + ); + assert_eq!(bullet_items("- a\nplain line", "test").unwrap(), None); + assert_eq!(bullet_items(" \n ", "test").unwrap(), None); } } diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 52d408db2..72085e3c9 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -9,8 +9,8 @@ use runvil_types::event::{Event, lifecycle}; use crate::contract::{self, PackListError}; use crate::fence::{self, FenceViolation}; use crate::position; -use crate::sections::{self, Heading}; -use crate::{Error, Frontmatter, ParseError, ParseErrorKind, Prompt, Result, frontmatter}; +use crate::sections::{self, Heading, SectionWalker}; +use crate::{Block, Error, Frontmatter, ParseError, ParseErrorKind, Prompt, Result, frontmatter}; impl Prompt { /// Parses the full source text of a prompt file into a [`Prompt`] and @@ -32,7 +32,8 @@ impl Prompt { /// /// - `Frontmatter` when the `---` delimiters are missing, the frontmatter /// fails to decode, a `uses:` entry is neither a Pack id nor - /// vendor/name, or two entries share a Pack id. + /// vendor/name, two entries share a Pack id, or an entry names a Pack id + /// the Engine reserves. /// - `Structure` when the H1 is missing, there is more than one H1, the H1 /// title is empty, a section heading skips a level (such as an H4 /// directly under an H2), a section heading is empty, or two sibling @@ -76,9 +77,10 @@ impl Prompt { } fn parse_inner(input: &str, emitter: &Emitter) -> Result { - let (yaml, body, frontmatter_lines) = frontmatter::split_frontmatter(input)?; - let body: Arc = Arc::from(body); - let frontmatter: Frontmatter = serde_yaml_ng::from_str(&yaml).map_err(|e| { + let split = frontmatter::split_frontmatter(input)?; + let frontmatter_lines = split.frontmatter_lines; + let shared: Arc = Arc::from(split.body); + let frontmatter: Frontmatter = serde_yaml_ng::from_str(&split.yaml).map_err(|e| { // The location is relative to the frontmatter block, which starts // on file line 2 (line 1 is the opening `---` delimiter). let (line, column) = e.location().map_or((None, None), |location| { @@ -109,8 +111,8 @@ impl Prompt { // Everything past the frontmatter postdates the prompt's name, so a // failure from here on is stamped with it (and its span's position). let name = frontmatter.name().to_owned(); - Self::parse_body(frontmatter, &body, frontmatter_lines, emitter) - .map_err(|error| error.with_prompt_context(&name, &body, frontmatter_lines)) + Self::parse_body(frontmatter, &shared, frontmatter_lines, emitter) + .map_err(|error| error.with_prompt_context(&name, &shared, frontmatter_lines)) } fn parse_body( @@ -137,7 +139,7 @@ impl Prompt { let lines = h1_positions .iter() .map(|&index| { - position::line_add(frontmatter_lines, headings[index].source_line) + position::line_add(frontmatter_lines, headings[index].body_line) .map(|line| line.to_string()) }) .collect::>>()?; @@ -151,14 +153,15 @@ impl Prompt { )); }; let h1 = &headings[*h1_index]; - if h1.title.trim().is_empty() { + if h1.title.is_empty() { return Err(Error::parse( ParseErrorKind::Structure, "prompt H1 title must not be empty", )); } let title = h1.title.clone(); - let h1_content_abs_line = position::line_add(frontmatter_lines, h1.content_start_line)?; + let h1_content = h1.content; + let h1_content_abs_line = position::line_add(frontmatter_lines, h1.content_body_line)?; if let Some((line, violation)) = fence::first_fence_violation(body) { let message = match violation { FenceViolation::Label(label) => { @@ -171,7 +174,6 @@ impl Prompt { }; return Err(Error::parse_at(ParseErrorKind::Fence, line, message)); } - let h1_blocks = fence::split_h1(shared, h1.content, &title, h1_content_abs_line, emitter)?; // Everything before the H1 is preface and has no prompt semantics. // Sections are headings after the H1 at level 2 or deeper. @@ -180,15 +182,20 @@ impl Prompt { .skip(*h1_index + 1) .filter(|h| h.level >= 2) .collect(); - let mut pos = 0; - let sections = sections::build_sections( - shared, - §ion_headings, - &mut pos, - 1, - frontmatter_lines, - emitter, + let mut walker = SectionWalker::new(shared, §ion_headings, frontmatter_lines, emitter); + let mut h1_blocks = walker.compile_blocks( + fence::split_section_blocks(h1_content, &title)?, + h1_content_abs_line, + &title, + |_| format!("H1 `{title}` lua"), )?; + if matches!( + h1_blocks.as_slice(), + [Block::Prose { text }] if text.as_str().is_empty() + ) { + h1_blocks.clear(); + } + let sections = walker.build_sections(1)?; Ok(Prompt { frontmatter, diff --git a/crates/runvil-internal/prompt/src/position.rs b/crates/runvil-internal/prompt/src/position.rs index db0d83b85..633bb1f1a 100644 --- a/crates/runvil-internal/prompt/src/position.rs +++ b/crates/runvil-internal/prompt/src/position.rs @@ -9,9 +9,8 @@ use crate::{BodyText, Error, Result}; /// Wraps a computed 1-based source line for the Lua compiler. /// -/// Line numbers computed during parsing are always at least 1. A zero here means -/// a broken source-position invariant; rather than silently coercing it to line -/// one, this reports a structured internal parse error. +/// Line numbers computed during parsing are always at least 1, so a zero means +/// a broken source-position invariant. /// /// # Errors /// Returns [`Error::Internal`] when `line` is zero. @@ -21,7 +20,8 @@ pub(crate) fn nz_source_line(line: u32) -> Result { )) } -/// Adds two 1-based line components with overflow checking. +/// Adds two line components, at most one of them 1-based, with overflow +/// checking. /// /// # Errors /// Returns [`Error::Internal`] when the sum overflows `u32`, rather than @@ -31,12 +31,12 @@ pub(crate) fn line_add(a: u32, b: u32) -> Result { .ok_or(Error::Internal("parser: source line arithmetic overflowed")) } -/// Locates `part` inside the shared `text` it was sliced from. +/// Locates `part` inside `shared`, the text it was sliced from. /// /// # Errors -/// Returns [`Error::Internal`] when `part` is not a slice of `text`. -pub(crate) fn body_text(text: &Arc, part: &str) -> Result { - BodyText::within(text, part).ok_or(Error::Internal( +/// Returns [`Error::Internal`] when `part` is not a slice of `shared`. +pub(crate) fn body_text(shared: &Arc, part: &str) -> Result { + BodyText::within(shared, part).ok_or(Error::Internal( "parser: section text is not a slice of the prompt body", )) } @@ -53,3 +53,6 @@ pub(crate) fn newlines_before(text: &str, byte_offset: usize) -> Result { u32::try_from(prefix.matches('\n').count()) .map_err(|_| Error::Internal("parser: newline count exceeded u32 range")) } + +#[cfg(test)] +mod tests; diff --git a/crates/runvil-internal/prompt/src/sections-tests.rs b/crates/runvil-internal/prompt/src/position/tests.rs similarity index 92% rename from crates/runvil-internal/prompt/src/sections-tests.rs rename to crates/runvil-internal/prompt/src/position/tests.rs index 9d9648576..4d4e974ba 100644 --- a/crates/runvil-internal/prompt/src/sections-tests.rs +++ b/crates/runvil-internal/prompt/src/position/tests.rs @@ -1,7 +1,7 @@ //! Tests for the source-position helpers. +use super::newlines_before; use crate::Error; -use crate::position::newlines_before; #[test] fn newlines_before_reports_broken_byte_offset_invariants() { diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index 0ac9e1781..436a77e32 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -4,7 +4,7 @@ //! below them is the Engine's, reached through [`crate::detail`]. //! //! Construction happens in the parsing modules; this module holds the value -//! types and the invariant-preserving operations on them. +//! types and their accessors. use crate::{BodyText, Frontmatter}; use runvil_lua::LuaProgram; @@ -22,14 +22,13 @@ pub enum Block { /// template. #[non_exhaustive] Prose { - /// Captured Markdown, trimmed of surrounding blank lines. + /// Captured Markdown, trimmed of leading and trailing whitespace. text: BodyText, }, } /// One section of a prompt: a heading, ordered blocks, and children. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct Section { /// The heading text (the section's address). pub(crate) name: String, @@ -39,9 +38,8 @@ pub struct Section { pub(crate) blocks: Vec, /// Child sections nested under this one (deeper heading levels). pub(crate) children: Vec
, - /// Pre-parsed bullet items for list-only sections (no lua blocks). - /// - /// Empty for non-list sections. + /// The list items of a list section, which has at least one; empty for + /// every other section. pub(crate) items: Vec, /// The section's own raw Markdown, without its heading line or children. pub(crate) markdown: BodyText, @@ -72,7 +70,7 @@ impl Section { &self.children } - /// Returns the pre-parsed bullet items for a list-only section. + /// Returns the list items of a list section; empty for every other section. #[must_use] pub fn items(&self) -> &[BodyText] { &self.items @@ -85,7 +83,7 @@ impl Section { self.markdown.as_str() } - /// Classic leading Lua fence when the first block is Lua. + /// The prologue: the first block, when it is Lua. #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn prologue(&self) -> Option<&LuaProgram> { @@ -109,8 +107,7 @@ impl Section { .unwrap_or("") } - /// Classic trailing Lua fence when the last block is Lua and not the sole - /// leading prologue (a section that is only one Lua block has no epilog). + /// The epilog: the last block, when it is Lua and not the only block. #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn epilog(&self) -> Option<&LuaProgram> { @@ -121,22 +118,15 @@ impl Section { } } - /// True when this section is a validated bullet list. - /// - /// A section is list-only exactly when it parsed into non-empty - /// [`items`](Self::items) - i.e. it had no Lua blocks and every nonblank - /// prose line was a valid list item. Ordinary prose (even prose that - /// happens to contain a single bullet line) is not list-only. - #[cfg(test)] + /// Returns whether this is a list section, which has at least one item. #[must_use] - pub(crate) fn is_list_only(&self) -> bool { + pub fn is_list(&self) -> bool { !self.items.is_empty() } } /// A fully parsed prompt file. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct Prompt { /// The parsed YAML frontmatter. pub(crate) frontmatter: Frontmatter, diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index 301a3e825..0b218ad82 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -3,10 +3,10 @@ use std::ops::Range; use std::sync::Arc; -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use runvil_types::emitter::Emitter; -use crate::fence::{self, RawBlock}; +use crate::fence::{self, LuaRole, RawBlock}; use crate::position::{body_text, line_add, newlines_before, nz_source_line}; use crate::{Block, BodyText, Error, ParseErrorKind, Result, Section, list}; use runvil_lua::LuaProgram; @@ -16,28 +16,17 @@ use runvil_lua::LuaProgram; #[derive(Debug, Clone)] pub(crate) struct Heading<'a> { pub(crate) level: u8, + /// The heading's text, trimmed. pub(crate) title: String, pub(crate) content: &'a str, /// 1-based line number within `body` where `content` begins. - pub(crate) content_start_line: u32, + pub(crate) content_body_line: u32, /// 1-based line number within `body` of the heading line itself. - pub(crate) source_line: u32, + pub(crate) body_line: u32, /// Byte range of the heading within `body`, kept for span diagnostics. span: Range, } -/// Converts a `HeadingLevel` to its numeric level. -fn level_num(level: HeadingLevel) -> u8 { - match level { - HeadingLevel::H1 => 1, - HeadingLevel::H2 => 2, - HeadingLevel::H3 => 3, - HeadingLevel::H4 => 4, - HeadingLevel::H5 => 5, - HeadingLevel::H6 => 6, - } -} - /// Walks the markdown body and collects every heading with the content that /// follows it, up to the next heading of any level. /// @@ -61,7 +50,7 @@ pub(crate) fn collect_headings(body: &str) -> Result>> { container_depth = container_depth.saturating_sub(1); } Event::Start(Tag::Heading { level, .. }) if container_depth == 0 => { - current = Some((level_num(level), range.clone(), String::new())); + current = Some((level as u8, range.clone(), String::new())); } Event::End(TagEnd::Heading(_)) => { if let Some((level, range, title)) = current.take() { @@ -90,66 +79,20 @@ pub(crate) fn collect_headings(body: &str) -> Result>> { let end = raws.peek().map_or(body.len(), |next| next.range.start); // +1 because newlines_before gives 0-based offset, and we want // the 1-based line number of the first content line. - let content_start_line = line_add(newlines_before(body, start)?, 1)?; - let source_line = line_add(newlines_before(body, raw.range.start)?, 1)?; + let content_body_line = line_add(newlines_before(body, start)?, 1)?; + let body_line = line_add(newlines_before(body, raw.range.start)?, 1)?; headings.push(Heading { level: raw.level, title: raw.title, content: &body[start..end], - content_start_line, - source_line, + content_body_line, + body_line, span: raw.range, }); } Ok(headings) } -/// Builds one heading's executable blocks while preserving source positions. -/// -/// Prose reads in place from `body`, the shared text `heading` was sliced from. -/// `label` names the section in each Lua block's location. -fn build_heading_blocks( - body: &Arc, - heading: &Heading<'_>, - name: &str, - label: &str, - frontmatter_lines: u32, - emitter: &Emitter, -) -> Result> { - let content_abs_line = line_add(frontmatter_lines, heading.content_start_line)?; - let raw_blocks = fence::split_section_blocks(heading.content, name)?; - let has_prose = raw_blocks - .iter() - .any(|block| matches!(block, RawBlock::Prose(_))); - let total = raw_blocks.len(); - let mut blocks = Vec::with_capacity(total); - let mut ordinal = 0; - for (index, raw) in raw_blocks.into_iter().enumerate() { - match raw { - RawBlock::Prose(text) => blocks.push(Block::Prose { - text: body_text(body, text)?, - }), - RawBlock::Lua { - source, - line_offset, - } => { - ordinal += 1; - let abs_line = line_add(content_abs_line, line_offset)?; - let location = fence::lua_block_location(label, index, total, has_prose, ordinal); - let program = LuaProgram::compile( - &source, - &location, - nz_source_line(abs_line)?, - emitter, - name, - )?; - blocks.push(Block::Lua(program)); - } - } - } - Ok(blocks) -} - /// Rejects an orphan deep heading, one that skips a level. /// /// Examples are an H4 under an H2 with no intervening H3, or an H3/H4 @@ -164,7 +107,7 @@ fn check_child_level(heading: &Heading<'_>, parent_level: u8) -> Result<()> { heading.span.clone(), format!( "section `{}` is an orphan H{level} heading with no parent H{}", - heading.title.trim(), + heading.title, parent_level + 1 ), )); @@ -176,64 +119,18 @@ fn check_child_level(heading: &Heading<'_>, parent_level: u8) -> Result<()> { /// /// A section's name is its runtime address (jumps, lookups, fanout), so an /// empty/whitespace heading is unaddressable and must be rejected at parse. -fn section_name(heading: &Heading<'_>, level: u8) -> Result { +fn section_name(heading: &Heading<'_>) -> Result { let name = heading.title.clone(); - if name.trim().is_empty() { + if name.is_empty() { return Err(Error::parse_at( ParseErrorKind::Structure, heading.span.clone(), - format!("an H{level} section heading must not be empty"), + format!("an H{} section heading must not be empty", heading.level), )); } Ok(name) } -/// Returns the section's list items, or none when it is not a list. -/// -/// A section is a list only when it has no Lua and every nonblank prose line -/// is a list marker; one incidental bullet in mixed prose leaves a non-marker -/// line, so the section stays prose. -fn list_items(body: &Arc, blocks: &[Block], name: &str) -> Result> { - let has_no_lua = blocks - .iter() - .all(|block| matches!(block, Block::Prose { .. })); - let prose_for_items = blocks.iter().find_map(|block| match block { - Block::Prose { text, .. } => Some(text.as_str()), - Block::Lua(_) => None, - }); - if has_no_lua - && let Some(prose) = prose_for_items - && list::is_all_list_markers(prose) - { - list::parse_bullet_items(prose, name)? - .into_iter() - .map(|item| body_text(body, item)) - .collect() - } else { - Ok(Vec::new()) - } -} - -/// Names a section in its Lua block locations. -/// -/// The name is the title alone, or the title and its heading line when -/// another section in `headings` shares the title, so a reader can tell the -/// two sections' errors apart. The locations are display names; each block's -/// line-based chunk name, not its location, decides which program maps an -/// error. -fn block_label(headings: &[Heading<'_>], name: &str, heading_abs_line: u32) -> String { - if headings - .iter() - .filter(|other| other.title == name) - .nth(1) - .is_some() - { - format!("section `{name}` (line {heading_abs_line})") - } else { - format!("section `{name}`") - } -} - /// Rejects a section whose name an earlier sibling already took. /// /// Sections are addressed by name (jumps, lookups), so two siblings sharing a @@ -257,62 +154,204 @@ fn check_unique_sibling( Ok(()) } -/// Builds a section tree from a flat, document-ordered list of headings. -/// -/// Recursion consumes headings whose level is deeper than `parent_level`; a -/// heading at or above `parent_level` belongs to an ancestor and stops the -/// current level. A heading that skips a level (an orphan deep heading, such as -/// an H4 directly under an H2, or an H3 top-level section with no parent H2) is -/// rejected: every heading must be exactly one level deeper than its parent. +/// Compiles a prompt body's H1 and section blocks, and builds its section +/// tree. /// -/// # Errors -/// Returns [`Error::ParseStructured`] for an orphan heading, an empty heading, -/// duplicate sibling names, or malformed section content. Propagates -/// [`Error::Lua`] from Lua compilation and [`Error::Internal`] from invalid -/// or overflowing source-line calculations, from a fence split that disagrees -/// with the Markdown parser, or from section text that is not a slice of -/// `body`. -pub(crate) fn build_sections( - body: &Arc, - headings: &[Heading<'_>], - pos: &mut usize, - parent_level: u8, +/// [`SectionWalker::build_sections`] consumes `headings` from `pos` onward, so +/// one walker builds one tree. +pub(crate) struct SectionWalker<'a> { + /// The prompt body that every heading and block is sliced from. + shared: &'a Arc, + /// The section headings, every heading after the H1 at level 2 or deeper, + /// in document order. + headings: &'a [Heading<'a>], + /// The file lines before the body, added to a body line to give its file + /// line. frontmatter_lines: u32, - emitter: &Emitter, -) -> Result> { - let mut result = Vec::new(); - // Parallel to `result`: each sibling's name and its 1-based heading line, so - // a duplicate can name both the first and the offending location. - let mut sibling_lines: Vec<(String, u32)> = Vec::new(); - while *pos < headings.len() { - let h = &headings[*pos]; - let level = h.level; - if level <= parent_level { - break; + /// Receives each Lua block's compile events. + emitter: &'a Emitter, + /// Index in `headings` of the next heading to build. + pos: usize, +} + +impl<'a> SectionWalker<'a> { + /// Returns a walker positioned at the first of `headings`. + pub(crate) fn new( + shared: &'a Arc, + headings: &'a [Heading<'a>], + frontmatter_lines: u32, + emitter: &'a Emitter, + ) -> Self { + Self { + shared, + headings, + frontmatter_lines, + emitter, + pos: 0, + } + } + + /// Builds a section tree from the walker's flat, document-ordered headings. + /// + /// Recursion consumes headings whose level is deeper than `parent_level`; a + /// heading at or above `parent_level` belongs to an ancestor and stops the + /// current level. A heading that skips a level (an orphan deep heading, such + /// as an H4 directly under an H2, or an H3 top-level section with no parent + /// H2) is rejected: every heading must be exactly one level deeper than its + /// parent. + /// + /// Each section's checks run in a fixed order that decides which error a + /// malformed prompt reports: its level, its name, its blocks, its children, + /// its list items, and last its name against earlier siblings. + /// + /// # Errors + /// Returns [`Error::ParseStructured`] for an orphan heading, an empty + /// heading, duplicate sibling names, or malformed section content. + /// Propagates [`Error::Lua`] from Lua compilation and [`Error::Internal`] + /// from invalid or overflowing source-line calculations, from a fence split + /// that disagrees with the Markdown parser, or from section text that is + /// not a slice of `shared`. + pub(crate) fn build_sections(&mut self, parent_level: u8) -> Result> { + let headings = self.headings; + let mut result = Vec::new(); + // Parallel to `result`: each sibling's name and its 1-based heading line, so + // a duplicate can name both the first and the offending location. + let mut sibling_lines: Vec<(String, u32)> = Vec::new(); + while self.pos < headings.len() { + let h = &headings[self.pos]; + let level = h.level; + if level <= parent_level { + break; + } + check_child_level(h, parent_level)?; + let name = section_name(h)?; + let heading_abs_line = line_add(self.frontmatter_lines, h.body_line)?; + let location = self.section_location(&name, heading_abs_line); + let blocks = self.build_heading_blocks(h, &name, &location)?; + self.pos += 1; + let children = self.build_sections(level)?; + let items = self.list_items(&blocks, &name)?; + check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; + sibling_lines.push((name.clone(), heading_abs_line)); + + result.push(Section { + name, + level, + blocks, + children, + items, + markdown: body_text( + self.shared, + fence::trim_leading_blank_lines(h.content).trim_end(), + )?, + }); + } + Ok(result) + } + + /// Builds one heading's executable blocks while preserving source positions. + /// + /// `location` names the section in each Lua block's location. + fn build_heading_blocks( + &self, + heading: &Heading<'_>, + name: &str, + location: &str, + ) -> Result> { + let content_abs_line = line_add(self.frontmatter_lines, heading.content_body_line)?; + let raw_blocks = fence::split_section_blocks(heading.content, name)?; + self.compile_blocks(raw_blocks, content_abs_line, name, |role| { + fence::lua_block_location(location, role) + }) + } + + /// Compiles split content into blocks, in source order. + /// + /// Every prose block must be a slice of `shared`, which it reads from in + /// place. `content_abs_line` is the 1-based file line where the content + /// begins, and `location` gives each Lua block's compile location from its + /// role. + /// + /// # Errors + /// Propagates [`Error::Lua`] from Lua compilation, and returns + /// [`Error::Internal`] for an overflowing or zero source line or for prose + /// that is not a slice of `shared`. + pub(crate) fn compile_blocks( + &self, + raw_blocks: Vec>, + content_abs_line: u32, + name: &str, + location: impl Fn(&LuaRole) -> String, + ) -> Result> { + let mut blocks = Vec::with_capacity(raw_blocks.len()); + for raw in raw_blocks { + match raw { + RawBlock::Prose(text) => blocks.push(Block::Prose { + text: body_text(self.shared, text)?, + }), + RawBlock::Lua { + source, + line_offset, + role, + } => { + let abs_line = line_add(content_abs_line, line_offset)?; + let program = LuaProgram::compile( + &source, + &location(&role), + nz_source_line(abs_line)?, + self.emitter, + name, + )?; + blocks.push(Block::Lua(program)); + } + } } - check_child_level(h, parent_level)?; - let name = section_name(h, level)?; - let heading_abs_line = line_add(frontmatter_lines, h.source_line)?; - let label = block_label(headings, &name, heading_abs_line); - let blocks = build_heading_blocks(body, h, &name, &label, frontmatter_lines, emitter)?; - *pos += 1; - let children = build_sections(body, headings, pos, level, frontmatter_lines, emitter)?; - let items = list_items(body, &blocks, &name)?; - check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; - sibling_lines.push((name.clone(), heading_abs_line)); + Ok(blocks) + } - result.push(Section { - name, - level, - blocks, - children, - items, - markdown: body_text(body, fence::trim_leading_blank_lines(h.content).trim_end())?, + /// Returns the section's list items, or none when it is not a list. + /// + /// A section is a list when it has no Lua and [`list::bullet_items`] returns + /// items. + fn list_items(&self, blocks: &[Block], name: &str) -> Result> { + let has_no_lua = blocks + .iter() + .all(|block| matches!(block, Block::Prose { .. })); + let prose_for_items = blocks.iter().find_map(|block| match block { + Block::Prose { text, .. } => Some(text.as_str()), + Block::Lua(_) => None, }); + if has_no_lua + && let Some(prose) = prose_for_items + && let Some(items) = list::bullet_items(prose, name)? + { + items + .into_iter() + .map(|item| body_text(self.shared, item)) + .collect() + } else { + Ok(Vec::new()) + } } - Ok(result) -} -#[cfg(test)] -#[path = "sections-tests.rs"] -mod position_tests; + /// Names a section in its Lua block locations. + /// + /// The name is the title alone, or the title and its heading line when + /// another section in `headings` shares the title, so a reader can tell the + /// two sections' errors apart. The locations are display names; each block's + /// line-based chunk name, not its location, decides which program maps an + /// error. + fn section_location(&self, name: &str, heading_abs_line: u32) -> String { + if self + .headings + .iter() + .filter(|other| other.title == name) + .nth(1) + .is_some() + { + format!("section `{name}` (line {heading_abs_line})") + } else { + format!("section `{name}`") + } + } +} diff --git a/crates/runvil-internal/prompt/src/test_support.rs b/crates/runvil-internal/prompt/src/test_support.rs index 00abfcc2b..6e08d5962 100644 --- a/crates/runvil-internal/prompt/src/test_support.rs +++ b/crates/runvil-internal/prompt/src/test_support.rs @@ -1,11 +1,12 @@ //! Test support for the parser's own tests and, through the `test-support` //! feature, for companion crates' tests. //! -//! `Section` and `Block::Prose` are `#[non_exhaustive]`, so a companion -//! crate's tests cannot construct them literally; these constructors are the -//! cross-crate seam for synthetic section trees. The source builder writes a -//! prompt from its parts, and the parse assertions show the source and the -//! actual outcome when a parse goes the other way. +//! `Section`'s fields are crate-private and `Block::Prose` is +//! `#[non_exhaustive]`, so a companion crate's tests cannot construct them +//! literally; these constructors are the cross-crate seam for synthetic +//! section trees. The source builder writes a prompt from its parts, and the +//! parse assertions show the source and the actual outcome when a parse goes +//! the other way. use crate::{Block, BodyText, ParseError, ParseErrorKind, Prompt, Section}; diff --git a/crates/runvil-internal/prompt/src/tests.rs b/crates/runvil-internal/prompt/src/tests.rs index fc8ea5e62..cea54838a 100644 --- a/crates/runvil-internal/prompt/src/tests.rs +++ b/crates/runvil-internal/prompt/src/tests.rs @@ -193,8 +193,8 @@ fn parses_multi_section_with_all_features() { .build(); let p = parse_ok(&src); - assert_eq!(p.frontmatter.name, "demo"); - assert_eq!(p.frontmatter.description, "d"); + assert_eq!(p.frontmatter.name(), "demo"); + assert_eq!(p.frontmatter.description(), "d"); assert_eq!(p.title, "Demo Title"); assert_eq!( p.h1_blocks, diff --git a/crates/runvil-internal/prompt/src/tests/breaks.rs b/crates/runvil-internal/prompt/src/tests/breaks.rs index 58ab9cb60..70537f793 100644 --- a/crates/runvil-internal/prompt/src/tests/breaks.rs +++ b/crates/runvil-internal/prompt/src/tests/breaks.rs @@ -61,7 +61,7 @@ fn list_items_below_a_leading_break_parse() { let body = "---\n\n- alpha\n- beta"; let prompt = parse_ok(&PromptSource::new().prose("Items", body).build()); let section = &prompt.sections[0]; - assert!(section.is_list_only()); + assert!(section.is_list()); assert_eq!(section.items(), ["alpha", "beta"]); } diff --git a/crates/runvil-internal/prompt/src/tests/frontmatter.rs b/crates/runvil-internal/prompt/src/tests/frontmatter.rs index e497890a3..96d98ff90 100644 --- a/crates/runvil-internal/prompt/src/tests/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/tests/frontmatter.rs @@ -6,7 +6,7 @@ use super::*; #[test] fn name_and_description_are_sufficient_frontmatter_for_parsing() { let prompt = parse_ok("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n"); - assert_eq!(prompt.frontmatter.name, "x"); + assert_eq!(prompt.frontmatter.name(), "x"); } #[test] @@ -36,12 +36,12 @@ fn unknown_frontmatter_field_is_rejected() { fn max_tool_iterations_parses_positive_and_is_absent_when_unset() { let p = parse_ok(&prompt("max_tool_iterations: 20", &[])); assert_eq!( - p.frontmatter.max_tool_iterations, + p.frontmatter.max_tool_iterations(), Some(std::num::NonZeroU32::new(20).unwrap()) ); let p = parse_ok(&prompt("", &[])); - assert_eq!(p.frontmatter.max_tool_iterations, None); + assert_eq!(p.frontmatter.max_tool_iterations(), None); } #[test] @@ -56,6 +56,24 @@ fn max_tool_iterations_rejects_zero_negative_and_overflow() { } } +#[test] +fn max_tool_iterations_reports_the_guides_messages() { + for raw in ["0", "-1"] { + assert_parse_error( + &prompt(&format!("max_tool_iterations: {raw}"), &[]), + ParseErrorKind::Frontmatter, + &format!("max_tool_iterations must be a positive integer (>= 1), got {raw}"), + ); + } + for raw in ["1001", "100000000000"] { + assert_parse_error( + &prompt(&format!("max_tool_iterations: {raw}"), &[]), + ParseErrorKind::Frontmatter, + &format!("max_tool_iterations must be <= 1000, got {raw}"), + ); + } +} + #[test] fn max_tool_iterations_accepts_the_upper_boundary() { let p = parse_ok(&prompt( @@ -63,7 +81,7 @@ fn max_tool_iterations_accepts_the_upper_boundary() { &[], )); assert_eq!( - p.frontmatter.max_tool_iterations, + p.frontmatter.max_tool_iterations(), Some(std::num::NonZeroU32::new(MAX_TOOL_ITERATIONS).unwrap()) ); } @@ -71,10 +89,10 @@ fn max_tool_iterations_accepts_the_upper_boundary() { #[test] fn frontmatter_exposes_runvil_field() { let with = "---\nname: x\ndescription: d\nrunvil: 0\n---\n\n# T\n\n## S\n\np\n"; - assert_eq!(parse_ok(with).frontmatter.runvil, Some(0)); + assert_eq!(parse_ok(with).frontmatter.runvil(), Some(0)); let without = "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n"; - assert_eq!(parse_ok(without).frontmatter.runvil, None); + assert_eq!(parse_ok(without).frontmatter.runvil(), None); } #[test] diff --git a/crates/runvil-internal/prompt/src/tests/lists.rs b/crates/runvil-internal/prompt/src/tests/lists.rs index 9bb19d804..f1e4d05a2 100644 --- a/crates/runvil-internal/prompt/src/tests/lists.rs +++ b/crates/runvil-internal/prompt/src/tests/lists.rs @@ -1,7 +1,7 @@ //! List section tests: list detection and the bullet parser. use super::*; -use crate::list::parse_bullet_items; +use crate::list::bullet_items; #[test] fn mixed_prose_with_one_bullet_is_not_a_list() { @@ -10,7 +10,7 @@ fn mixed_prose_with_one_bullet_is_not_a_list() { let body = "Here is context.\n- one incidental bullet\nMore prose follows."; let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); let section = &prompt.sections[0]; - assert!(!section.is_list_only(), "mixed prose is not a list"); + assert!(!section.is_list(), "mixed prose is not a list"); assert!(section.items().is_empty()); assert!(section.prose().contains("incidental bullet")); } @@ -20,7 +20,7 @@ fn pure_list_section_parses_items() { let body = "- alpha\n- beta\n3. gamma"; let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); let section = &prompt.sections[0]; - assert!(section.is_list_only()); + assert!(section.is_list()); assert_eq!(section.items(), ["alpha", "beta", "gamma"]); } @@ -33,6 +33,14 @@ fn all_marker_list_with_empty_item_is_rejected() { assert_eq!(error.kind(), ParseErrorKind::List); } +#[test] +fn an_empty_marker_before_a_prose_line_stays_prose() { + let prompt = parse_ok(&PromptSource::new().prose("S", "- alpha\n-\nmore").build()); + let section = &prompt.sections[0]; + assert!(section.items().is_empty()); + assert_eq!(section.prose(), "- alpha\n-\nmore"); +} + #[test] fn list_error_kind_does_not_depend_on_the_section_name() { for section in ["frontmatter", "fence"] { @@ -43,42 +51,25 @@ fn list_error_kind_does_not_depend_on_the_section_name() { #[test] fn bullet_parser_strips_unordered_markers() { - let items = parse_bullet_items("- alpha\n* beta\n- gamma", "test").unwrap(); - assert_eq!(items, vec!["alpha", "beta", "gamma"]); + let items = bullet_items("- alpha\n* beta\n- gamma", "test").unwrap(); + assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); } #[test] fn bullet_parser_strips_ordered_markers() { - let items = parse_bullet_items("1. first\n2. second\n3) third", "test").unwrap(); - assert_eq!(items, vec!["first", "second", "third"]); + let items = bullet_items("1. first\n2. second\n3) third", "test").unwrap(); + assert_eq!(items, Some(vec!["first", "second", "third"])); } #[test] fn bullet_parser_ignores_blank_lines() { - let items = parse_bullet_items("- alpha\n\n- beta\n \n- gamma", "test").unwrap(); - assert_eq!(items, vec!["alpha", "beta", "gamma"]); -} - -#[test] -fn bullet_parser_rejects_non_list_content() { - let err = parse_bullet_items("- alpha\nnot a bullet\n- gamma", "test") - .expect_err("non-list content must error"); - assert!( - err.to_string().contains("non-list content"), - "error was: {err}" - ); -} - -#[test] -fn bullet_parser_rejects_empty_list() { - let err = parse_bullet_items("", "test").expect_err("empty list must error"); - assert!(err.to_string().contains("no items"), "error was: {err}"); + let items = bullet_items("- alpha\n\n- beta\n \n- gamma", "test").unwrap(); + assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); } #[test] fn bullet_parser_rejects_empty_item() { - let err = - parse_bullet_items("- alpha\n- \n- gamma", "test").expect_err("empty item must error"); + let err = bullet_items("- alpha\n- \n- gamma", "test").expect_err("empty item must error"); assert!( err.to_string().contains("empty bullet item"), "error was: {err}" diff --git a/crates/runvil-internal/types/README.md b/crates/runvil-internal/types/README.md index b52f7cd2e..c99b2ac2c 100644 --- a/crates/runvil-internal/types/README.md +++ b/crates/runvil-internal/types/README.md @@ -1,3 +1,3 @@ # Engine shared vocabulary -This crate holds the vocabulary and run-support primitives the rest of the Engine builds on: tool, Pack, and model identity, run and task identity for replay, the event and metrics vocabulary a run reports, untrusted-content guards, and cooperative cancellation. It depends on no other Runvil crate and uses no async runtime. +This crate holds the vocabulary and run-support primitives the rest of the Engine builds on: tool, Pack, and model identity, run and task identity for replay, the event and metrics vocabulary a run reports, the universal error, untrusted-content guards, and cooperative cancellation. It depends on no other Runvil crate and uses no async runtime. diff --git a/crates/runvil-internal/types/src/error-prose.rs b/crates/runvil-internal/types/src/error-prose.rs new file mode 100644 index 000000000..94548b841 --- /dev/null +++ b/crates/runvil-internal/types/src/error-prose.rs @@ -0,0 +1,216 @@ +//! The default text the model reads for each code. + +use super::ErrorCode; + +impl ErrorCode { + /// Returns the default text the model reads for this code: what went + /// wrong and, when the model can act on it, what to do instead. It never + /// names a file, a line, or an internal cause. A site with specifics + /// writes its own. The text for a failure in the prompt itself speaks to + /// a model fixing the prompt, and its site puts the location in front. + #[must_use] + #[expect( + clippy::too_many_lines, + reason = "one arm per code holds its default text" + )] + pub const fn prose(self) -> &'static str { + match self { + ErrorCode::FrontmatterInvalid => { + "The frontmatter is invalid. Fix the YAML between the `---` lines so it matches the \ + frontmatter schema." + } + ErrorCode::StructureInvalid => { + "The prompt's headings are invalid. Give the prompt exactly one H1 title, and give \ + every section a non-empty name that is unique among its siblings and a parent \ + heading." + } + ErrorCode::FenceInvalid => { + "A Lua code fence is malformed. Open it with exactly ```lua and close it with \ + exactly ```." + } + ErrorCode::ListEmptyItem => { + "A list section has an empty item. Fill it in or remove it." + } + ErrorCode::UnsupportedVersion => { + "The prompt declares a `runvil:` version this build does not support. Declare a \ + version it supports." + } + ErrorCode::InvalidValue => { + "A value was missing or not valid. Check it against what the operation expects and \ + try again with a valid one." + } + ErrorCode::LuaRuntime => { + "The Lua code failed at runtime. Fix the code at this location." + } + ErrorCode::LuaCompile => { + "The Lua code does not compile. Fix the syntax at this location." + } + ErrorCode::LuaQuota => "The section logged more than its budget allows. Log less.", + ErrorCode::CallDepthExceeded => { + "Calls nest deeper than the cap allows. Nest `call`, `tasks.spawn`, and \ + `tasks.fanout` less deeply." + } + ErrorCode::Substitution => { + "A placeholder in the section's prose could not be filled. Fix the placeholder, or \ + set the value it reads before the prose is read." + } + ErrorCode::TasksLive => { + "The chain ended with tasks still running. Wait on or cancel every task the chain \ + spawns before it ends." + } + ErrorCode::TaskNotOwned => { + "The task is not one this chain owns. Wait on, inspect, or cancel only the tasks \ + this chain spawned." + } + ErrorCode::TaskConsumed => { + "The task's result was already delivered. Wait on each task only once." + } + ErrorCode::TaskCancelled => { + "The task was cancelled before it finished, so it has no result. Handle the \ + cancellation where the task is waited on." + } + ErrorCode::ContextOverflow => { + "The request is larger than the model's context window. Send less text and try again." + } + ErrorCode::RateLimited => { + "The model backend is limiting the request rate. Wait, then try again." + } + ErrorCode::QuotaExhausted => { + "The model backend's usage quota is spent, so retrying will not work until it is \ + restored." + } + ErrorCode::Overloaded => "The model backend is overloaded. Wait, then try again.", + ErrorCode::Refused => { + "The model backend refused the request on content policy grounds. Retrying the same \ + request will not work; change it instead." + } + ErrorCode::Timeout => "The model backend did not answer in time. Try again.", + ErrorCode::Transport => "The connection to the model backend failed. Try again.", + ErrorCode::ServerError => "The model backend reported a fault of its own. Try again.", + ErrorCode::Rejected => { + "The model backend rejected the request. Retrying the same request will not work; \ + change it instead." + } + ErrorCode::MalformedResponse => { + "The model backend sent a reply that could not be understood. Try again." + } + ErrorCode::EmptyReply => { + "The reply had no text and no tool calls. Reply with text or call a tool." + } + ErrorCode::Unavailable => { + "Model access is turned off or not configured, so model calls will not work." + } + ErrorCode::InvalidParts => { + "The tool call or completion was built from invalid parts, so it was not sent." + } + ErrorCode::ContextExhausted => { + "The conversation no longer fits the model's context window and could not be \ + shortened. Finish with what you have." + } + ErrorCode::ModelRequired => { + "The round has no model. Select a model before `models.infer` or `models.loop`, or \ + bind a default model." + } + ErrorCode::ToolInvalidArguments => { + "The tool rejected its arguments. Check the tool's parameters and call it again with \ + valid arguments." + } + ErrorCode::ToolBackend => { + "The tool's backend failed or refused the request. Try again, or use a different tool." + } + ErrorCode::ToolTransport => "The tool could not reach its backend. Try again.", + ErrorCode::ToolOther => "The tool failed.", + ErrorCode::OutOfScopeTool => { + "That tool is not offered here. Call only the tools you were offered." + } + ErrorCode::UnboundTool => { + "No tool in this run has that id. Call a tool id from the run's catalog." + } + ErrorCode::ModelOnlyTool => { + "Only a model may call that tool, so a script call to it will never work. Offer it \ + with `tools.offer` and let the model call it." + } + ErrorCode::ToolLoopExhausted => { + "The tool-call limit was reached before a final reply. Stop calling tools and reply \ + with what you have." + } + ErrorCode::ToolSchemaInvalid => { + "A tool's model-facing schema did not build. Fix the tool's name or its parameter \ + schema." + } + ErrorCode::ToolSetsInvalid => { + "The section's tool sets break a rule of the merge that builds them. Fix the offers, \ + or the render that answered them." + } + ErrorCode::NotFound => { + "That path does not exist. Check the path, or list its directory to find the right \ + one." + } + ErrorCode::AlreadyExists => { + "That path already exists. Use a different path, or remove the existing one first." + } + ErrorCode::NotADirectory => { + "That path is not a directory. Use a directory path for this operation." + } + ErrorCode::IsADirectory => { + "That path is a directory. Use a file path for this operation." + } + ErrorCode::DirectoryNotEmpty => { + "That directory is not empty. Remove it recursively, or empty it first." + } + ErrorCode::NotUtf8 => "That file is not UTF-8 text, so it cannot be read as text.", + ErrorCode::InvalidPath => { + "That path is not valid. Use a relative path with no `.` or `..` segments, no \ + backslashes, and no empty segments." + } + ErrorCode::InvalidRange => { + "That line range is not valid. Use a range within the file's lines." + } + ErrorCode::Anchor => { + "The text to replace was empty, missing, or found more than once. Use text that \ + appears exactly once in the file." + } + ErrorCode::PermissionDenied => { + "The policy does not allow that operation, so it will never work here." + } + ErrorCode::ApprovalRequired => { + "That operation needs an approval that cannot be given here, so it will never work." + } + ErrorCode::ScopeEnded => { + "The access for that operation has ended, so it will never work again." + } + ErrorCode::ReadOnly => { + "That path is read-only, so writing to it will never work. Write to a different path." + } + ErrorCode::OutsideRoot => { + "That path leads outside the allowed directory, so it will never work." + } + ErrorCode::RootProtected => "The root directory cannot be removed or replaced.", + ErrorCode::NoStore => "This run has no store, so file operations will not work.", + ErrorCode::CrossMount => { + "Those paths are on different mounts, so this will never work. Use paths on the same \ + mount." + } + ErrorCode::Io => "The file operation failed. Try again.", + ErrorCode::Unsupported => "That operation is not supported for this path.", + ErrorCode::Conflict => { + "Another operation is using that path at the same time. Try again after it finishes." + } + ErrorCode::Backend => "The file operation failed.", + ErrorCode::Determinism => { + "Two concurrent operations used the same store path, so the run stopped." + } + ErrorCode::RequirementsUnmet => { + "The environment does not provide what the prompt needs. Supply the missing Packs or \ + models, or change what the prompt declares." + } + ErrorCode::Interrupted => "The operation was cancelled.", + ErrorCode::Internal => "An internal error occurred. Retrying will not help.", + ErrorCode::RecorderFailed => "The run could not be recorded, so it stopped.", + ErrorCode::PluginLoadFailed => { + "A plugin could not be loaded, so its tools are not available." + } + ErrorCode::NoOutput => "The run produced no output text.", + } + } +} diff --git a/crates/runvil-internal/types/src/error-tag.rs b/crates/runvil-internal/types/src/error-tag.rs new file mode 100644 index 000000000..6c7f43705 --- /dev/null +++ b/crates/runvil-internal/types/src/error-tag.rs @@ -0,0 +1,83 @@ +//! The stable tag of each code. + +use super::ErrorCode; + +impl ErrorCode { + /// Returns the code's stable tag: its name in snake_case, such as + /// `not_found` or `rate_limited`. No two codes share a tag. Each store + /// and model code keeps the tag a run log already records for it, so + /// stored records keep their meaning. + #[must_use] + pub const fn tag(self) -> &'static str { + match self { + ErrorCode::FrontmatterInvalid => "frontmatter_invalid", + ErrorCode::StructureInvalid => "structure_invalid", + ErrorCode::FenceInvalid => "fence_invalid", + ErrorCode::ListEmptyItem => "list_empty_item", + ErrorCode::UnsupportedVersion => "unsupported_version", + ErrorCode::InvalidValue => "invalid_value", + ErrorCode::LuaRuntime => "lua_runtime", + ErrorCode::LuaCompile => "lua_compile", + ErrorCode::LuaQuota => "lua_quota", + ErrorCode::CallDepthExceeded => "call_depth_exceeded", + ErrorCode::Substitution => "substitution", + ErrorCode::TasksLive => "tasks_live", + ErrorCode::TaskNotOwned => "task_not_owned", + ErrorCode::TaskConsumed => "task_consumed", + ErrorCode::TaskCancelled => "task_cancelled", + ErrorCode::ContextOverflow => "context_overflow", + ErrorCode::RateLimited => "rate_limited", + ErrorCode::QuotaExhausted => "quota_exhausted", + ErrorCode::Overloaded => "overloaded", + ErrorCode::Refused => "refused", + ErrorCode::Timeout => "timeout", + ErrorCode::Transport => "transport", + ErrorCode::ServerError => "server_error", + ErrorCode::Rejected => "rejected", + ErrorCode::MalformedResponse => "malformed_response", + ErrorCode::EmptyReply => "empty_reply", + ErrorCode::Unavailable => "unavailable", + ErrorCode::InvalidParts => "invalid_parts", + ErrorCode::ContextExhausted => "context_exhausted", + ErrorCode::ModelRequired => "model_required", + ErrorCode::ToolInvalidArguments => "tool_invalid_arguments", + ErrorCode::ToolBackend => "tool_backend", + ErrorCode::ToolTransport => "tool_transport", + ErrorCode::ToolOther => "tool_other", + ErrorCode::OutOfScopeTool => "out_of_scope_tool", + ErrorCode::UnboundTool => "unbound_tool", + ErrorCode::ModelOnlyTool => "model_only_tool", + ErrorCode::ToolLoopExhausted => "tool_loop_exhausted", + ErrorCode::ToolSchemaInvalid => "tool_schema_invalid", + ErrorCode::ToolSetsInvalid => "tool_sets_invalid", + ErrorCode::NotFound => "not_found", + ErrorCode::AlreadyExists => "already_exists", + ErrorCode::NotADirectory => "not_a_directory", + ErrorCode::IsADirectory => "is_a_directory", + ErrorCode::DirectoryNotEmpty => "directory_not_empty", + ErrorCode::NotUtf8 => "not_utf8", + ErrorCode::InvalidPath => "invalid_path", + ErrorCode::InvalidRange => "invalid_range", + ErrorCode::Anchor => "anchor", + ErrorCode::PermissionDenied => "permission_denied", + ErrorCode::ApprovalRequired => "approval_required", + ErrorCode::ScopeEnded => "scope_ended", + ErrorCode::ReadOnly => "read_only", + ErrorCode::OutsideRoot => "outside_root", + ErrorCode::RootProtected => "root_protected", + ErrorCode::NoStore => "no_store", + ErrorCode::CrossMount => "cross_mount", + ErrorCode::Io => "io", + ErrorCode::Unsupported => "unsupported", + ErrorCode::Conflict => "conflict", + ErrorCode::Backend => "backend", + ErrorCode::Determinism => "determinism", + ErrorCode::RequirementsUnmet => "requirements_unmet", + ErrorCode::Interrupted => "interrupted", + ErrorCode::Internal => "internal", + ErrorCode::RecorderFailed => "recorder_failed", + ErrorCode::PluginLoadFailed => "plugin_load_failed", + ErrorCode::NoOutput => "no_output", + } + } +} diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs new file mode 100644 index 000000000..f6cccbe8b --- /dev/null +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -0,0 +1,309 @@ +//! Tests for `ErrorCode` numbering, `Location` ranges, and the cause chain +//! `with_cause` appends. + +use std::collections::BTreeSet; +use std::fmt; +use std::num::NonZeroU32; + +use super::{Error, ErrorCode, Location}; + +/// A cause with an optional cause of its own. +#[derive(Debug)] +struct Layer(&'static str, Option>); + +impl fmt::Display for Layer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for Layer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.1 + .as_deref() + .map(|layer| layer as &(dyn std::error::Error + 'static)) + } +} + +#[test] +fn with_cause_appends_the_chain_to_the_message_and_keeps_the_prose() { + let cause = Layer("disk gone", Some(Box::new(Layer("sector 9", None)))); + let error = Error::new(ErrorCode::Io, "read failed", "Try again.").with_cause(&cause); + assert_eq!(error.message, "read failed: disk gone: sector 9"); + assert_eq!(error.prose, "Try again."); +} + +#[test] +fn with_cause_skips_text_the_message_already_holds() { + let cause = Layer("disk gone", Some(Box::new(Layer("", None)))); + let error = + Error::new(ErrorCode::Io, "read failed: disk gone", "Try again.").with_cause(&cause); + assert_eq!(error.message, "read failed: disk gone"); +} + +#[test] +fn with_cause_adds_only_what_follows_text_the_message_ends_with() { + let inner = Error::new(ErrorCode::ToolTransport, "request failed", "request failed") + .with_cause(&Layer("refused", None)); + let error = Error::new( + ErrorCode::ToolTransport, + "search: request failed", + "Try again.", + ) + .with_cause(&inner); + assert_eq!(error.message, "search: request failed: refused"); + + let unrelated = Error::new(ErrorCode::Io, "read failed", "Try again.") + .with_cause(&Layer("disk gone: sector 9", None)); + assert_eq!(unrelated.message, "read failed: disk gone: sector 9"); +} + +/// Every code, in declaration order. +const ALL: [ErrorCode; 68] = [ + ErrorCode::FrontmatterInvalid, + ErrorCode::StructureInvalid, + ErrorCode::FenceInvalid, + ErrorCode::ListEmptyItem, + ErrorCode::UnsupportedVersion, + ErrorCode::InvalidValue, + ErrorCode::LuaRuntime, + ErrorCode::LuaCompile, + ErrorCode::LuaQuota, + ErrorCode::CallDepthExceeded, + ErrorCode::Substitution, + ErrorCode::TasksLive, + ErrorCode::TaskNotOwned, + ErrorCode::TaskConsumed, + ErrorCode::TaskCancelled, + ErrorCode::ContextOverflow, + ErrorCode::RateLimited, + ErrorCode::QuotaExhausted, + ErrorCode::Overloaded, + ErrorCode::Refused, + ErrorCode::Timeout, + ErrorCode::Transport, + ErrorCode::ServerError, + ErrorCode::Rejected, + ErrorCode::MalformedResponse, + ErrorCode::EmptyReply, + ErrorCode::Unavailable, + ErrorCode::InvalidParts, + ErrorCode::ContextExhausted, + ErrorCode::ModelRequired, + ErrorCode::ToolInvalidArguments, + ErrorCode::ToolBackend, + ErrorCode::ToolTransport, + ErrorCode::ToolOther, + ErrorCode::OutOfScopeTool, + ErrorCode::UnboundTool, + ErrorCode::ModelOnlyTool, + ErrorCode::ToolLoopExhausted, + ErrorCode::ToolSchemaInvalid, + ErrorCode::ToolSetsInvalid, + ErrorCode::NotFound, + ErrorCode::AlreadyExists, + ErrorCode::NotADirectory, + ErrorCode::IsADirectory, + ErrorCode::DirectoryNotEmpty, + ErrorCode::NotUtf8, + ErrorCode::InvalidPath, + ErrorCode::InvalidRange, + ErrorCode::Anchor, + ErrorCode::PermissionDenied, + ErrorCode::ApprovalRequired, + ErrorCode::ScopeEnded, + ErrorCode::ReadOnly, + ErrorCode::OutsideRoot, + ErrorCode::RootProtected, + ErrorCode::NoStore, + ErrorCode::CrossMount, + ErrorCode::Io, + ErrorCode::Unsupported, + ErrorCode::Conflict, + ErrorCode::Backend, + ErrorCode::Determinism, + ErrorCode::RequirementsUnmet, + ErrorCode::Interrupted, + ErrorCode::Internal, + ErrorCode::RecorderFailed, + ErrorCode::PluginLoadFailed, + ErrorCode::NoOutput, +]; + +/// The family hundred of each code. The match is exhaustive, so a new code +/// fails to build here until it is placed in a family; add it to `ALL` too. +fn family(code: ErrorCode) -> u16 { + match code { + ErrorCode::FrontmatterInvalid + | ErrorCode::StructureInvalid + | ErrorCode::FenceInvalid + | ErrorCode::ListEmptyItem + | ErrorCode::UnsupportedVersion + | ErrorCode::InvalidValue => 1, + ErrorCode::LuaRuntime + | ErrorCode::LuaCompile + | ErrorCode::LuaQuota + | ErrorCode::CallDepthExceeded + | ErrorCode::Substitution => 2, + ErrorCode::TasksLive + | ErrorCode::TaskNotOwned + | ErrorCode::TaskConsumed + | ErrorCode::TaskCancelled => 3, + ErrorCode::ContextOverflow + | ErrorCode::RateLimited + | ErrorCode::QuotaExhausted + | ErrorCode::Overloaded + | ErrorCode::Refused + | ErrorCode::Timeout + | ErrorCode::Transport + | ErrorCode::ServerError + | ErrorCode::Rejected + | ErrorCode::MalformedResponse + | ErrorCode::EmptyReply + | ErrorCode::Unavailable + | ErrorCode::InvalidParts + | ErrorCode::ContextExhausted + | ErrorCode::ModelRequired => 4, + ErrorCode::ToolInvalidArguments + | ErrorCode::ToolBackend + | ErrorCode::ToolTransport + | ErrorCode::ToolOther + | ErrorCode::OutOfScopeTool + | ErrorCode::UnboundTool + | ErrorCode::ModelOnlyTool + | ErrorCode::ToolLoopExhausted + | ErrorCode::ToolSchemaInvalid + | ErrorCode::ToolSetsInvalid => 5, + ErrorCode::NotFound + | ErrorCode::AlreadyExists + | ErrorCode::NotADirectory + | ErrorCode::IsADirectory + | ErrorCode::DirectoryNotEmpty + | ErrorCode::NotUtf8 + | ErrorCode::InvalidPath + | ErrorCode::InvalidRange + | ErrorCode::Anchor + | ErrorCode::PermissionDenied + | ErrorCode::ApprovalRequired + | ErrorCode::ScopeEnded + | ErrorCode::ReadOnly + | ErrorCode::OutsideRoot + | ErrorCode::RootProtected + | ErrorCode::NoStore + | ErrorCode::CrossMount + | ErrorCode::Io + | ErrorCode::Unsupported + | ErrorCode::Conflict + | ErrorCode::Backend + | ErrorCode::Determinism => 6, + ErrorCode::RequirementsUnmet | ErrorCode::Interrupted | ErrorCode::Internal => 7, + ErrorCode::RecorderFailed | ErrorCode::PluginLoadFailed | ErrorCode::NoOutput => 8, + } +} + +fn nonzero(n: u32) -> NonZeroU32 { + NonZeroU32::new(n).expect("tests pass non-zero numbers") +} + +#[test] +fn every_code_is_listed_once() { + let numbers: BTreeSet = ALL.into_iter().map(ErrorCode::number).collect(); + assert_eq!(numbers.len(), ALL.len()); +} + +#[test] +fn every_code_number_falls_in_its_family_hundred() { + for code in ALL { + assert_eq!(code.number() / 100, family(code), "{code:?}"); + } +} + +#[test] +fn every_code_has_its_own_snake_case_tag() { + let tags: BTreeSet<&str> = ALL.into_iter().map(ErrorCode::tag).collect(); + assert_eq!(tags.len(), ALL.len(), "no two codes share a tag"); + for tag in tags { + assert!( + tag.bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'), + "{tag}" + ); + } +} + +#[test] +fn store_and_model_codes_keep_the_tags_stored_records_hold() { + // The store and model tags a run log already holds. + for (code, tag) in [ + (ErrorCode::NotFound, "not_found"), + (ErrorCode::AlreadyExists, "already_exists"), + (ErrorCode::NotADirectory, "not_a_directory"), + (ErrorCode::IsADirectory, "is_a_directory"), + (ErrorCode::DirectoryNotEmpty, "directory_not_empty"), + (ErrorCode::NotUtf8, "not_utf8"), + (ErrorCode::InvalidPath, "invalid_path"), + (ErrorCode::InvalidRange, "invalid_range"), + (ErrorCode::Anchor, "anchor"), + (ErrorCode::PermissionDenied, "permission_denied"), + (ErrorCode::ApprovalRequired, "approval_required"), + (ErrorCode::ScopeEnded, "scope_ended"), + (ErrorCode::ReadOnly, "read_only"), + (ErrorCode::OutsideRoot, "outside_root"), + (ErrorCode::RootProtected, "root_protected"), + (ErrorCode::NoStore, "no_store"), + (ErrorCode::CrossMount, "cross_mount"), + (ErrorCode::Io, "io"), + (ErrorCode::Unsupported, "unsupported"), + (ErrorCode::Conflict, "conflict"), + (ErrorCode::Backend, "backend"), + (ErrorCode::ContextOverflow, "context_overflow"), + (ErrorCode::RateLimited, "rate_limited"), + (ErrorCode::QuotaExhausted, "quota_exhausted"), + (ErrorCode::Overloaded, "overloaded"), + (ErrorCode::Refused, "refused"), + (ErrorCode::Timeout, "timeout"), + (ErrorCode::Transport, "transport"), + (ErrorCode::ServerError, "server_error"), + (ErrorCode::Rejected, "rejected"), + (ErrorCode::MalformedResponse, "malformed_response"), + (ErrorCode::EmptyReply, "empty_reply"), + (ErrorCode::Unavailable, "unavailable"), + (ErrorCode::InvalidParts, "invalid_parts"), + ] { + assert_eq!(code.tag(), tag); + } +} + +#[test] +fn every_code_has_default_prose_in_whole_sentences() { + for code in ALL { + let prose = code.prose(); + assert!(!prose.is_empty(), "{code:?}"); + assert!(prose.ends_with('.'), "{code:?}: {prose}"); + } +} + +#[test] +fn a_location_cannot_end_before_it_starts() { + let at = + |sl, sc, el, ec| Location::new("a.md", nonzero(sl), nonzero(sc), nonzero(el), nonzero(ec)); + assert!(at(2, 5, 2, 4).is_none()); + assert!(at(2, 5, 1, 9).is_none()); + assert!(at(2, 5, 2, 5).is_some()); + assert!(at(1, 9, 2, 1).is_some()); +} + +#[test] +fn a_line_location_covers_the_whole_line() { + let line = Location::line("a.md", nonzero(7)); + assert_eq!( + (line.start_line, line.start_column), + (nonzero(7), nonzero(1)) + ); + assert_eq!((line.end_line, line.end_column), (nonzero(8), nonzero(1))); + let last = Location::line("a.md", NonZeroU32::MAX); + assert_eq!( + (last.end_line, last.end_column), + (NonZeroU32::MAX, nonzero(1)) + ); +} diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs new file mode 100644 index 000000000..1ebf2f3ac --- /dev/null +++ b/crates/runvil-internal/types/src/error.rs @@ -0,0 +1,331 @@ +//! The universal error: one [`Error`] record that can describe any Engine +//! failure, by a stable [`ErrorCode`], text for people and for the model, +//! an optional [`Location`], and values named by [`FactKey`]. + +use std::collections::BTreeMap; +use std::fmt; +use std::num::NonZeroU32; + +#[path = "error-prose.rs"] +mod prose; +#[path = "error-tag.rs"] +mod tag; +#[cfg(test)] +#[path = "error-tests.rs"] +mod tests; + +/// One failure: what it is, the text for people and for the model, where it +/// happened, and the values it names. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Error { + /// Which failure this is. + pub code: ErrorCode, + /// The full diagnostic for logs and people, causes included. + pub message: String, + /// The text the model reads: what failed, and what to do instead or that + /// it can never work. + pub prose: String, + /// Where it happened, when known. + pub at: Option, + /// The values the failure names, such as a path or a task id. + pub facts: BTreeMap, +} + +impl Error { + /// Builds an error with no location and no facts. + #[must_use] + pub fn new(code: ErrorCode, message: impl Into, prose: impl Into) -> Error { + Error { + code, + message: message.into(), + prose: prose.into(), + at: None, + facts: BTreeMap::new(), + } + } + + /// Appends `cause`, and each cause under it, to the message after `: `, + /// skipping any text the message already holds. A cause whose text + /// starts with the message's end and then `: ` adds only what follows. + /// The prose stays as it was. + #[must_use] + pub fn with_cause(mut self, cause: &(dyn std::error::Error + 'static)) -> Error { + let mut next = Some(cause); + while let Some(current) = next { + let text = current.to_string(); + if !text.is_empty() && !self.message.contains(&text) { + let repeated = text + .rmatch_indices(": ") + .map(|(at, _)| at) + .find(|&at| at > 0 && self.message.ends_with(&text[..at])); + if let Some(at) = repeated { + self.message.push_str(&text[at..]); + } else { + self.message.push_str(": "); + self.message.push_str(&text); + } + } + next = current.source(); + } + self + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for Error {} + +/// A range in a file, from the start position up to but not including the +/// end. Lines and columns are 1-based, and columns count characters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + /// The file. Its extension tells its kind, such as `.md` for a prompt or + /// `.rs` for Rust. + pub file: String, + /// The line the range starts on. + pub start_line: NonZeroU32, + /// The column the range starts at. + pub start_column: NonZeroU32, + /// The line of the end position. + pub end_line: NonZeroU32, + /// The column of the end position, which the range excludes. + pub end_column: NonZeroU32, +} + +impl Location { + /// Builds a range, or `None` when the end comes before the start. + #[must_use] + pub fn new( + file: impl Into, + start_line: NonZeroU32, + start_column: NonZeroU32, + end_line: NonZeroU32, + end_column: NonZeroU32, + ) -> Option { + ((end_line, end_column) >= (start_line, start_column)).then(|| Location { + file: file.into(), + start_line, + start_column, + end_line, + end_column, + }) + } + + /// Builds the range covering all of `line`, from column 1 up to column 1 + /// of the next line. On line `u32::MAX` the range is empty. + #[must_use] + pub fn line(file: impl Into, line: NonZeroU32) -> Location { + Location { + file: file.into(), + start_line: line, + start_column: NonZeroU32::MIN, + end_line: line.saturating_add(1), + end_column: NonZeroU32::MIN, + } + } +} + +/// The name of a fact. Each mirrors a field name in today's Lua error tables, +/// apart from `Limit`, `Section`, `Chain`, and `Run`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum FactKey { + /// A file or store path, or a glob pattern, as a string. + Path, + /// The name the failure is about, such as a tool alias, a Pack id, or + /// rejected input, as a string. + Name, + /// A task id, as a dotted string. + Task, + /// Task ids, as an array of dotted strings. + Tasks, + /// An edit's anchor text, as a string. + Anchor, + /// A count, such as anchor matches or prompt tokens, as an integer. + Count, + /// The limit that was hit, such as a context window or a depth cap, as an + /// integer. + Limit, + /// The tag of the rule that was broken, as a string. + Rule, + /// The byte length of the valid UTF-8 prefix, as an integer. + ValidUpTo, + /// The tag of a sub-reason within the code, as a string. + Reason, + /// The model backend's finish reason, as a string. + FinishReason, + /// The section or prompt region the failure is in, as a string. + Section, + /// The chain the failure happened in, as a dotted string. + Chain, + /// The run id a recorder issued, as an integer. + Run, +} + +/// A stable failure code. Numbers group by family in hundreds: 1xx parse, +/// 2xx Lua, 3xx tasks, 4xx model, 5xx tools, 6xx store, 7xx run, 8xx +/// harness. A number is never reused. Store and model codes keep the names +/// of the store and model failure kinds a run log already records. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ErrorCode { + /// The frontmatter is missing, unclosed, not valid YAML, or breaks its + /// schema. + FrontmatterInvalid = 101, + /// The document structure is invalid, such as a missing H1 or a + /// duplicate sibling section. + StructureInvalid = 102, + /// A `lua` fence is unclosed or breaks the strict fence rule. + FenceInvalid = 103, + /// A list section has an empty item. + ListEmptyItem = 104, + /// The prompt declares a `runvil:` major this build does not support. + UnsupportedVersion = 105, + /// A value is missing, or failed to parse or validate, such as an id, a + /// tag, a temperature, or a setting. + InvalidValue = 106, + + /// Lua failed at runtime, or a call from Lua was refused. + LuaRuntime = 201, + /// A Lua region did not compile. + LuaCompile = 202, + /// A Lua quota ran out, such as the `log()` event or byte budget. + LuaQuota = 203, + /// A `call`, `tasks.spawn`, or `tasks.fanout` arm passed the call-depth + /// cap. + CallDepthExceeded = 204, + /// A `{{ }}` prose substitution failed. + Substitution = 205, + + /// A chain ended with tasks still live. + TasksLive = 301, + /// A task operation named a task the caller does not own. + TaskNotOwned = 302, + /// A wait named a task whose result was already delivered. + TaskConsumed = 303, + /// A wait delivered a task its owner cancelled. + TaskCancelled = 304, + + /// The request exceeds the model's context window. + ContextOverflow = 401, + /// The model backend is limiting the request rate. + RateLimited = 402, + /// The billing or usage quota is spent. + QuotaExhausted = 403, + /// The model backend is temporarily at capacity. + Overloaded = 404, + /// The provider declined the content on policy grounds. + Refused = 405, + /// The wait for a reply or the next chunk timed out. + Timeout = 406, + /// The connection to the model backend failed or the stream broke. + Transport = 407, + /// The model backend reported a fault of its own. + ServerError = 408, + /// The model backend refused the request for any other reason. + Rejected = 409, + /// The reply was malformed or over the size limit. + MalformedResponse = 410, + /// The model's reply had no text and no tool calls. + EmptyReply = 411, + /// Model access is turned off or not configured. + Unavailable = 412, + /// A tool call or completion was built from invalid parts. + InvalidParts = 413, + /// The context window overflowed and the compactor did not compact. + ContextExhausted = 414, + /// A model round found no current model. + ModelRequired = 415, + + /// The tool rejected the arguments it was given. + ToolInvalidArguments = 501, + /// The tool's backend refused or failed the request. + ToolBackend = 502, + /// The tool call failed at the transport layer. + ToolTransport = 503, + /// The tool failed for any other reason. + ToolOther = 504, + /// The model called a tool outside the section's offered sets. + OutOfScopeTool = 505, + /// A script `tools.call` named no tool in the run. + UnboundTool = 506, + /// A script `tools.call` named a tool only a model may call. + ModelOnlyTool = 507, + /// The tool-call loop reached its cap without a final reply. + ToolLoopExhausted = 508, + /// A tool's model-facing schema did not build. + ToolSchemaInvalid = 509, + /// A section's tool sets broke a rule of the merge that builds them. + ToolSetsInvalid = 510, + + /// The path does not exist. + NotFound = 601, + /// The path already exists where creation required absence. + AlreadyExists = 602, + /// A directory operation named a non-directory. + NotADirectory = 603, + /// A file operation named a directory. + IsADirectory = 604, + /// A non-recursive removal named a directory that has entries. + DirectoryNotEmpty = 605, + /// Bytes that are not UTF-8 appeared where text was required. + NotUtf8 = 606, + /// The path or glob pattern is malformed or escapes the namespace root. + InvalidPath = 607, + /// A line range was rejected. + InvalidRange = 608, + /// An edit's anchor text was empty, absent, or found more than once. + Anchor = 609, + /// The policy denied the operation. + PermissionDenied = 610, + /// The policy asked for an approval this layer never grants. + ApprovalRequired = 611, + /// The run or tool call that owned the access has ended. + ScopeEnded = 612, + /// The mount or backend that serves the path is read-only. + ReadOnly = 613, + /// The path resolves outside its backend's root. + OutsideRoot = 614, + /// The operation would remove or replace the mounted root. + RootProtected = 615, + /// The run declares no store. + NoStore = 616, + /// A two-path operation named paths that different mounts serve. + CrossMount = 617, + /// An operating-system I/O call failed. + Io = 618, + /// The backend does not implement the operation. + Unsupported = 619, + /// The operation conflicts with another access's claim on the path. + Conflict = 620, + /// The backend failed for any other reason. + Backend = 621, + /// Two live executions claimed the same store path. + Determinism = 622, + + /// The environment falls short of what the prompt needs. + RequirementsUnmet = 701, + /// The run was cancelled, or the call it waited on was dropped. + Interrupted = 702, + /// An internal invariant broke; nothing the prompt can fix. + Internal = 703, + + /// The run recorder failed to store a write. + RecorderFailed = 801, + /// A Plugin entry point did not open or load. + PluginLoadFailed = 802, + /// The run report has no output text: the run did not complete, or the + /// prompt declares no `output:` file. + NoOutput = 803, +} + +impl ErrorCode { + /// Returns the code's number. + #[must_use] + pub const fn number(self) -> u16 { + self as u16 + } +} diff --git a/crates/runvil-internal/types/src/ids.rs b/crates/runvil-internal/types/src/ids.rs index 85748ef96..e6d29064b 100644 --- a/crates/runvil-internal/types/src/ids.rs +++ b/crates/runvil-internal/types/src/ids.rs @@ -29,7 +29,8 @@ use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::names::IdError; +use crate::error::Error; +use crate::names::invalid_id; #[cfg(test)] #[path = "ids-tests.rs"] @@ -84,15 +85,19 @@ impl fmt::Display for ChainId { } impl FromStr for ChainId { - type Err = IdError; + type Err = Error; /// Parses a path: one or more components joined by dots, such as /// `0.2.0`, each plain decimal digits that fit in a `u32`. fn from_str(input: &str) -> Result { let reject = || { - IdError::new(format!( - "invalid chain id `{input}`: required a dot-separated path of decimal components" - )) + invalid_id( + format!( + "invalid chain id `{input}`: required a dot-separated path of decimal \ + components" + ), + input, + ) }; if input.is_empty() { return Err(reject()); @@ -108,7 +113,7 @@ impl FromStr for ChainId { } component.parse::().map_err(|_| reject()) }) - .collect::, IdError>>() + .collect::, Error>>() .map(Self) } } @@ -148,7 +153,7 @@ impl fmt::Display for TaskId { } impl FromStr for TaskId { - type Err = IdError; + type Err = Error; fn from_str(input: &str) -> Result { input.parse().map(Self) diff --git a/crates/runvil-internal/types/src/lib.rs b/crates/runvil-internal/types/src/lib.rs index 91e5a562a..12923bf6f 100644 --- a/crates/runvil-internal/types/src/lib.rs +++ b/crates/runvil-internal/types/src/lib.rs @@ -22,8 +22,9 @@ //! replay key stamped on every effect and event; [`timestamp`] is the UTC //! instant a run starts from, rendered over std alone; and [`seed`] is the //! caller's [`RunSeed`](seed::RunSeed) and the one-way derivations from -//! it. This crate depends on no other runvil crate, so every runvil crate -//! may depend on it, and it declares no async runtime. +//! it. [`error`] is the universal [`Error`](error::Error) record that can +//! describe any Engine failure. This crate depends on no other runvil crate, +//! so every runvil crate may depend on it, and it declares no async runtime. //! //! ## Invariants //! @@ -35,6 +36,7 @@ pub mod cancel; pub mod emitter; +pub mod error; pub mod event; pub mod ids; pub mod metrics; diff --git a/crates/runvil-internal/types/src/models.rs b/crates/runvil-internal/types/src/models.rs index c76abc14b..e51efaefb 100644 --- a/crates/runvil-internal/types/src/models.rs +++ b/crates/runvil-internal/types/src/models.rs @@ -9,7 +9,8 @@ use std::num::NonZeroU32; use serde::Deserialize; -use crate::names::IdError; +use crate::error::Error; +use crate::names::invalid_id; /// The stable identity of one catalogued model. /// @@ -35,12 +36,13 @@ impl ModelId { /// Builds an identity from its server namespace and model name. /// /// # Errors - /// Returns [`IdError`] if `server` or `name` is empty or contains a - /// control character, so every `ModelId` is usable. + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error if `server` or `name` is empty or contains a control character, + /// so every `ModelId` is usable. pub fn new( server: impl Into, name: impl Into, - ) -> std::result::Result { + ) -> std::result::Result { let server = server.into(); let name = name.into(); Self::validate("server", &server)?; @@ -53,9 +55,9 @@ impl ModelId { /// `name` is the name the gateway lists the model under. /// /// # Errors - /// Returns [`IdError`] if `name` is empty or contains a control - /// character. - pub fn gateway(name: impl Into) -> std::result::Result { + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error if `name` is empty or contains a control character. + pub fn gateway(name: impl Into) -> std::result::Result { Self::new(Self::GATEWAY, name) } @@ -64,7 +66,7 @@ impl ModelId { /// Rejection is by Unicode scalar, not raw byte: every control /// character is refused, including C1 controls such as U+0085 (NEL) whose /// UTF-8 encoding a byte-range scan would miss. - fn validate(field: &'static str, value: &str) -> std::result::Result<(), IdError> { + fn validate(field: &'static str, value: &str) -> std::result::Result<(), Error> { let reason = if value.is_empty() { "must not be empty" } else if value.chars().any(char::is_control) { @@ -72,7 +74,10 @@ impl ModelId { } else { return Ok(()); }; - Err(IdError::new(format!("invalid model id: {field} {reason}"))) + Err(invalid_id( + format!("invalid model id: {field} {reason}"), + value, + )) } /// Returns the server namespace. @@ -238,20 +243,20 @@ impl ModelCatalog { /// given in. /// /// # Errors - /// Returns [`IdError`] when two `ModelInfo` values share one stable - /// [`ModelId`], so each identity in the catalog names exactly one model - /// info. + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error when two `ModelInfo` values share one stable [`ModelId`], so + /// each identity in the catalog names exactly one model info. pub fn new( models: impl IntoIterator, - ) -> std::result::Result { + ) -> std::result::Result { let models: Vec = models.into_iter().collect(); for (index, model) in models.iter().enumerate() { if models[..index].iter().any(|prior| prior.id() == model.id()) { - return Err(IdError::new(format!( - "duplicate model identity in catalog: {}/{}", - model.id().server(), - model.id().name() - ))); + let identity = format!("{}/{}", model.id().server(), model.id().name()); + return Err(invalid_id( + format!("duplicate model identity in catalog: {identity}"), + &identity, + )); } } Ok(Self { models }) @@ -302,7 +307,7 @@ mod tests { #[test] fn model_id_rejects_empty_and_control_characters() { - let message = |result: std::result::Result| { + let message = |result: std::result::Result| { result .expect_err("the model id must be rejected") .to_string() diff --git a/crates/runvil-internal/types/src/names-tests.rs b/crates/runvil-internal/types/src/names-tests.rs index 8b3ce5387..943001435 100644 --- a/crates/runvil-internal/types/src/names-tests.rs +++ b/crates/runvil-internal/types/src/names-tests.rs @@ -1,6 +1,96 @@ -//! Tests for `GlobalName` parsing and its rejection messages. +//! Tests for `GlobalName` parsing and its rejection messages, for the alias +//! grammar, and for the error every id type and catalog rejects with. -use super::GlobalName; +use std::num::NonZeroU32; + +use serde_json::{Value, json}; + +use super::{GlobalName, is_alias}; +use crate::error::{Error, ErrorCode, FactKey}; +use crate::ids::{ChainId, TaskId}; +use crate::models::{ModelCatalog, ModelId, ModelInfo, ThinkingMode}; +use crate::packs::PackId; +use crate::tools::{ToolCatalog, ToolId, ToolInfo}; + +/// Asserts `error` is an invalid value that names `rejected` and keeps +/// `message` as its message. +fn assert_invalid_id(error: &Error, rejected: &str, message: &str) { + assert_eq!(error.code, ErrorCode::InvalidValue, "{error:?}"); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from(rejected)), + "{error:?}" + ); + assert_eq!(error.message, message, "{error:?}"); + assert_eq!(error.prose, ErrorCode::InvalidValue.prose(), "{error:?}"); + assert_eq!(error.at, None, "{error:?}"); +} + +#[test] +fn every_id_type_rejects_with_an_invalid_value_naming_the_input() { + let rejected = |result: Result<(), Error>| result.expect_err("the id must be rejected"); + assert_invalid_id( + &rejected(GlobalName::parse("Web").map(drop)), + "Web", + "invalid global name: segments may contain only lowercase ASCII letters, digits, '-', \ + '_', '.'", + ); + assert_invalid_id( + &rejected(PackId::parse("a/b").map(drop)), + "a/b", + "invalid Pack id: a Pack id is one segment, such as `web`, with no '/'", + ); + assert_invalid_id( + &rejected(ToolId::parse("web").map(drop)), + "web", + "invalid tool id: a tool id has two or more segments, its Pack's name first, such as \ + `web/fetch`", + ); + assert_invalid_id( + &rejected("0.".parse::().map(drop)), + "0.", + "invalid chain id `0.`: required a dot-separated path of decimal components", + ); + assert_invalid_id( + &rejected("x".parse::().map(drop)), + "x", + "invalid chain id `x`: required a dot-separated path of decimal components", + ); + assert_invalid_id( + &rejected(ModelId::gateway("").map(drop)), + "", + "invalid model id: name must not be empty", + ); +} + +#[test] +fn a_catalog_that_repeats_an_id_rejects_with_an_invalid_value_naming_it() { + let tool = || { + ToolInfo::new( + ToolId::parse("web/fetch").expect("a valid tool id"), + "Fetches a page.", + json!({ "type": "object" }), + ) + }; + assert_invalid_id( + &ToolCatalog::new([tool(), tool()]).expect_err("a repeated tool id is refused"), + "web/fetch", + "duplicate tool identity `web/fetch` in the tool catalog", + ); + let model = || { + ModelInfo::new( + ModelId::gateway("small").expect("a valid model id"), + "A small model.", + NonZeroU32::MIN, + ThinkingMode::Never, + ) + }; + assert_invalid_id( + &ModelCatalog::new([model(), model()]).expect_err("a repeated model id is refused"), + "gateway/small", + "duplicate model identity in catalog: gateway/small", + ); +} const EMPTY: &str = "invalid global name: segments must not be empty"; const CONTROL: &str = "invalid global name: segments must not contain a control character"; @@ -114,3 +204,31 @@ fn from_str_parses_like_parse() { ); assert_eq!(error.to_string(), CHARSET); } + +#[test] +fn is_alias_follows_the_alias_grammar() { + for name in [ + "a", + "A", + "search", + "web_fetch", + "web-fetch2", + "a_b-1", + &"z".repeat(64), + ] { + assert!(is_alias(name), "{name:?} must be an alias"); + } + for name in [ + "", + "1search", + "_search", + "-search", + "web fetch", + "web.fetch", + "web/fetch", + "\u{e9}", + &"a".repeat(65), + ] { + assert!(!is_alias(name), "{name:?} must not be an alias"); + } +} diff --git a/crates/runvil-internal/types/src/names.rs b/crates/runvil-internal/types/src/names.rs index 41ba0ac3d..2c235f597 100644 --- a/crates/runvil-internal/types/src/names.rs +++ b/crates/runvil-internal/types/src/names.rs @@ -8,12 +8,20 @@ //! alphanumeric plus `-`, `_`, `.`, and comparison is case-sensitive. A //! name carries no version, so a `@` is a parse error. //! -//! [`IdError`] is the error every id type and every catalog of ids returns, -//! whether the id is built on a name or not. +//! [`is_alias`] holds a second grammar, for prompt-local aliases, which are +//! never global names. +//! +//! Every id type and every catalog of ids rejects an id with an +//! [`InvalidValue`](ErrorCode::InvalidValue) error, whether the id is built +//! on a name or not. use std::fmt; use std::str::FromStr; +use serde_json::Value; + +use crate::error::{Error, ErrorCode, FactKey}; + #[cfg(test)] #[path = "names-tests.rs"] mod tests; @@ -43,14 +51,14 @@ impl GlobalName { /// /// # Errors /// - /// Returns [`IdError`] when: + /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error when: /// /// - a segment is empty, including the whole string being empty; /// - a segment contains a control byte; /// - a segment contains any other character outside the set. - pub fn parse(s: &str) -> Result { + pub fn parse(s: &str) -> Result { GlobalName::validate(s) - .map_err(|reason| IdError::new(format!("invalid global name: {reason}"))) + .map_err(|reason| invalid_id(format!("invalid global name: {reason}"), s)) } /// Parses `s` as [`GlobalName::parse`] does, but returns only the @@ -93,9 +101,9 @@ impl GlobalName { } impl FromStr for GlobalName { - type Err = IdError; + type Err = Error; - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> Result { GlobalName::parse(s) } } @@ -134,38 +142,30 @@ fn validate_segment(segment: &str) -> Result<(), &'static str> { Ok(()) } -/// The error returned when an id, or a catalog of ids, is invalid. -/// -/// Parsing a [`GlobalName`], a [`PackId`], a [`ToolId`], a [`ChainId`] or a -/// [`TaskId`], or building a [`ModelId`], returns it for a malformed id. -/// Building a [`ToolCatalog`] or a [`ModelCatalog`] returns it when two -/// entries share an id. Its message names what was rejected and why, such -/// as `invalid Pack id: segments must not be empty`. -/// -/// [`PackId`]: crate::packs::PackId -/// [`ToolId`]: crate::tools::ToolId -/// [`ChainId`]: crate::ids::ChainId -/// [`TaskId`]: crate::ids::TaskId -/// [`ModelId`]: crate::models::ModelId -/// [`ToolCatalog`]: crate::tools::ToolCatalog -/// [`ModelCatalog`]: crate::models::ModelCatalog -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdError { - /// The rendered message. - message: String, +/// Returns whether `name` follows the prompt-local alias grammar +/// `[A-Za-z][A-Za-z0-9_-]{0,63}`: 1 to 64 bytes, an ASCII letter first, then +/// ASCII letters, digits, `_`, or `-`. An alias, such as an arg name or an +/// adhoc tool name, is never a global name. +#[must_use] +pub fn is_alias(name: &str) -> bool { + let bytes = name.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphabetic() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) } -impl IdError { - /// Builds an error that renders as `message`. - pub(crate) fn new(message: String) -> IdError { - IdError { message } - } -} - -impl fmt::Display for IdError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.message) - } +/// The error for an invalid id, or for a catalog that repeats one: an +/// [`InvalidValue`](ErrorCode::InvalidValue) whose message says what was +/// rejected and why, such as `invalid Pack id: segments must not be empty`, +/// with the rejected text as its `Name` fact. +pub(crate) fn invalid_id(message: String, rejected: &str) -> Error { + let mut error = Error::new( + ErrorCode::InvalidValue, + message, + ErrorCode::InvalidValue.prose(), + ); + error.facts.insert(FactKey::Name, Value::from(rejected)); + error } - -impl std::error::Error for IdError {} diff --git a/crates/runvil-internal/types/src/packs.rs b/crates/runvil-internal/types/src/packs.rs index 965b9eb48..766950b7b 100644 --- a/crates/runvil-internal/types/src/packs.rs +++ b/crates/runvil-internal/types/src/packs.rs @@ -16,7 +16,8 @@ use std::str::FromStr; -use crate::names::{GlobalName, IdError}; +use crate::error::Error; +use crate::names::{GlobalName, invalid_id}; use crate::tools::ToolId; #[cfg(test)] @@ -45,12 +46,14 @@ impl PackId { /// Parses a Pack identity, requiring exactly one segment. /// /// # Errors - /// Returns [`IdError`] when the id has more than one segment, is empty - /// or has an empty segment, a segment contains a control byte, or a - /// segment contains any other character than a lowercase ASCII letter, - /// a digit, `-`, `_`, or `.`. - pub fn parse(name: &str) -> Result { - PackId::validate(name).map_err(|reason| IdError::new(format!("invalid Pack id: {reason}"))) + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error when the id has more than one segment, is empty or has an empty + /// segment, a segment contains a control byte, or a segment contains any + /// other character than a lowercase ASCII letter, a digit, `-`, `_`, or + /// `.`. + pub fn parse(name: &str) -> Result { + PackId::validate(name) + .map_err(|reason| invalid_id(format!("invalid Pack id: {reason}"), name)) } /// Parses `name` as [`PackId::parse`] does, but returns only the reason @@ -92,9 +95,9 @@ impl PackId { } impl FromStr for PackId { - type Err = IdError; + type Err = Error; - fn from_str(name: &str) -> Result { + fn from_str(name: &str) -> Result { PackId::parse(name) } } diff --git a/crates/runvil-internal/types/src/tools.rs b/crates/runvil-internal/types/src/tools.rs index a47069aae..351bf821a 100644 --- a/crates/runvil-internal/types/src/tools.rs +++ b/crates/runvil-internal/types/src/tools.rs @@ -7,8 +7,8 @@ //! //! This module holds vocabulary only: the implementation-free //! [`ToolInfo`] and the caller-supplied [`ToolCatalog`] of `ToolInfo` values, -//! trusted output ([`ToolOutput`], [`OutputTrust`]), the model-safe -//! [`ToolError`], and a call's [`ToolCallOrigin`]. The implementation +//! trusted output ([`ToolOutput`], [`OutputTrust`]), the [`tool_error`] a +//! failed call returns, and a call's [`ToolCallOrigin`]. The implementation //! trait behind a tool info (`Pack`) is the Plugin contract's, in //! `runvil-plugin`, which the `plugin-*` crates implement; the prompt //! parser and the executor sit in their own crates and depend on @@ -23,7 +23,7 @@ mod registry; pub use ids::ToolId; pub use info::ToolInfo; pub use origin::{ToolCallOrigin, ToolCaller}; -pub use output::{OutputTrust, ToolError, ToolErrorKind, ToolOutput}; +pub use output::{OutputTrust, ToolOutput, tool_error}; pub use registry::ToolCatalog; #[cfg(test)] diff --git a/crates/runvil-internal/types/src/tools/ids.rs b/crates/runvil-internal/types/src/tools/ids.rs index 6b6ac12aa..6355c4a75 100644 --- a/crates/runvil-internal/types/src/tools/ids.rs +++ b/crates/runvil-internal/types/src/tools/ids.rs @@ -2,7 +2,8 @@ use std::str::FromStr; -use crate::names::{GlobalName, IdError}; +use crate::error::Error; +use crate::names::{GlobalName, invalid_id}; use crate::packs::PackId; /// Identifies a tool by its Pack's local name and the Pack's own segments. @@ -24,12 +25,13 @@ impl ToolId { /// Parses a tool id of two or more segments (`pack/name`). /// /// # Errors - /// Returns [`IdError`] when the id has fewer than two segments, is - /// empty or has an empty segment, a segment contains a control byte, or - /// a segment contains any other character outside the set of lowercase - /// ASCII letters, digits, `-`, `_`, and `.`. - pub fn parse(id: &str) -> Result { - let invalid = |reason: &str| IdError::new(format!("invalid tool id: {reason}")); + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error when the id has fewer than two segments, is empty or has an + /// empty segment, a segment contains a control byte, or a segment + /// contains any other character outside the set of lowercase ASCII + /// letters, digits, `-`, `_`, and `.`. + pub fn parse(id: &str) -> Result { + let invalid = |reason: &str| invalid_id(format!("invalid tool id: {reason}"), id); let name = GlobalName::validate(id).map_err(invalid)?; if name.segments().len() < 2 { return Err(invalid( @@ -67,9 +69,9 @@ impl ToolId { } impl FromStr for ToolId { - type Err = IdError; + type Err = Error; - fn from_str(id: &str) -> Result { + fn from_str(id: &str) -> Result { ToolId::parse(id) } } diff --git a/crates/runvil-internal/types/src/tools/output.rs b/crates/runvil-internal/types/src/tools/output.rs index 8ca8d574a..6c1510c62 100644 --- a/crates/runvil-internal/types/src/tools/output.rs +++ b/crates/runvil-internal/types/src/tools/output.rs @@ -1,4 +1,7 @@ -//! Tool output with mandatory trust, and the narrow model-safe tool error. +//! Tool output with mandatory trust, and the constructor of a tool call's +//! failure. + +use crate::error::{Error, ErrorCode}; /// Whether a tool's output is trusted or must be treated as untrusted data. /// @@ -59,103 +62,13 @@ impl ToolOutput { } } -/// A stable classification of a [`ToolError`] that code can match on. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum ToolErrorKind { - /// The model supplied arguments that the tool rejected. - InvalidArguments, - /// The tool's backend refused or failed the request. - Backend, - /// The request failed at the transport layer, such as a network error or a - /// timeout. - Transport, - /// Any other tool failure. - Other, -} - -impl ToolErrorKind { - /// Returns the kind's stable tag: its name in snake_case, such as - /// `invalid_arguments`. A run log records a failed call under it. - #[must_use] - pub fn tag(self) -> &'static str { - match self { - ToolErrorKind::InvalidArguments => "invalid_arguments", - ToolErrorKind::Backend => "backend", - ToolErrorKind::Transport => "transport", - ToolErrorKind::Other => "other", - } - } -} - -/// An error from a tool call, with a message that is safe to show the model. -/// -/// The `Display` message is meant for the caller and is safe to hand back to -/// the model. Any underlying cause stays out of that message and is available -/// only through [`std::error::Error::source`]. Match on [`ToolError::kind`] to -/// tell failures apart. -#[derive(Debug)] -#[non_exhaustive] -pub struct ToolError { - kind: ToolErrorKind, - message: String, - source: Option>, -} - -impl ToolError { - /// Builds an error of `kind` with `text` as its model-safe message. - #[must_use] - pub fn new(kind: ToolErrorKind, text: impl Into) -> ToolError { - ToolError { - kind, - message: text.into(), - source: None, - } - } - - /// Builds an error from a message alone, with the kind `Other`. - #[must_use] - pub fn message(text: impl Into) -> ToolError { - ToolError::new(ToolErrorKind::Other, text) - } - - /// Keeps `source` as the underlying cause, returning the updated error. - /// - /// The cause is not part of the message. It is available only through - /// `Error::source`. The kind stays as it was built. - #[must_use] - pub fn with_source( - mut self, - source: impl Into>, - ) -> ToolError { - self.source = Some(source.into()); - self - } - - /// Sets the classification, returning the updated error. - #[must_use] - pub fn with_kind(mut self, kind: ToolErrorKind) -> ToolError { - self.kind = kind; - self - } - - /// Returns the stable classification of this error. - #[must_use] - pub fn kind(&self) -> ToolErrorKind { - self.kind - } -} - -impl std::fmt::Display for ToolError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for ToolError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.source - .as_ref() - .map(|boxed| boxed.as_ref() as &(dyn std::error::Error + 'static)) - } +/// Builds a tool call's failure: `code` is one of the tool codes, such as +/// [`ToolInvalidArguments`](ErrorCode::ToolInvalidArguments), and `text` is +/// both the message and the prose the model reads, so it must be safe to +/// show the model. A cause goes in the message only, through +/// [`Error::with_cause`]. +#[must_use] +pub fn tool_error(code: ErrorCode, text: impl Into) -> Error { + let text = text.into(); + Error::new(code, text.clone(), text) } diff --git a/crates/runvil-internal/types/src/tools/registry.rs b/crates/runvil-internal/types/src/tools/registry.rs index e715d61bb..c08da265f 100644 --- a/crates/runvil-internal/types/src/tools/registry.rs +++ b/crates/runvil-internal/types/src/tools/registry.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use super::ids::ToolId; use super::info::ToolInfo; -use crate::names::IdError; +use crate::error::Error; +use crate::names::invalid_id; /// A catalog of the tools a run may offer, given as `ToolInfo` values. /// @@ -45,16 +46,17 @@ impl ToolCatalog { /// This is the only place the catalog checks identities. /// /// # Errors - /// Returns [`IdError`] if two `ToolInfo` values share a [`ToolId`]. - pub fn new(tools: impl IntoIterator) -> Result { + /// Returns an [`InvalidValue`](crate::error::ErrorCode::InvalidValue) + /// error if two `ToolInfo` values share a [`ToolId`]. + pub fn new(tools: impl IntoIterator) -> Result { let tools: Vec = tools.into_iter().collect(); let mut seen = std::collections::BTreeSet::new(); for tool in &tools { if !seen.insert(&tool.id) { - return Err(IdError::new(format!( - "duplicate tool identity `{}` in the tool catalog", - tool.id - ))); + return Err(invalid_id( + format!("duplicate tool identity `{}` in the tool catalog", tool.id), + &tool.id.to_string(), + )); } } Ok(Self { diff --git a/crates/runvil-internal/types/src/tools/tests.rs b/crates/runvil-internal/types/src/tools/tests.rs index ae1da824d..4420a3ccb 100644 --- a/crates/runvil-internal/types/src/tools/tests.rs +++ b/crates/runvil-internal/types/src/tools/tests.rs @@ -1,4 +1,5 @@ -//! Tests for `ToolId`, `ToolInfo`, `ToolCatalog`, and tool output trust. +//! Tests for `ToolId`, `ToolInfo`, `ToolCatalog`, tool output trust, and +//! `tool_error`. use serde_json::json; @@ -48,19 +49,6 @@ fn output_trust_serializes_as_its_snake_case_name() { assert_wire(&OutputTrust::Untrusted, r#""untrusted""#); } -#[test] -fn each_tool_error_kind_tags_as_its_snake_case_name() { - use super::ToolErrorKind; - for (kind, tag) in [ - (ToolErrorKind::InvalidArguments, "invalid_arguments"), - (ToolErrorKind::Backend, "backend"), - (ToolErrorKind::Transport, "transport"), - (ToolErrorKind::Other, "other"), - ] { - assert_eq!(kind.tag(), tag); - } -} - #[test] fn tool_catalog_is_send_and_sync() { // The public catalog must stay `Send + Sync` so downstream callers can @@ -70,40 +58,23 @@ fn tool_catalog_is_send_and_sync() { } #[test] -fn tool_error_classifies_and_hides_source() { - use super::{ToolError, ToolErrorKind}; - assert_send_sync::(); - - let plain = ToolError::message("model-safe"); - assert_eq!(plain.kind(), ToolErrorKind::Other); - assert_eq!(plain.to_string(), "model-safe"); - - let transport = ToolError::message("net").with_kind(ToolErrorKind::Transport); - assert_eq!(transport.kind(), ToolErrorKind::Transport); - - let sourced = ToolError::message("wrap").with_source(std::io::Error::other("cause")); - assert!(std::error::Error::source(&sourced).is_some()); - assert!( - !sourced.to_string().contains("cause"), - "Display must not expose the tool error source: {sourced}" - ); -} +fn a_tool_error_shows_its_text_as_message_and_prose_and_keeps_a_cause_out_of_the_prose() { + use super::tool_error; + use crate::error::ErrorCode; + let error = tool_error(ErrorCode::ToolBackend, "backend failed"); + assert_eq!(error.code, ErrorCode::ToolBackend); + assert_eq!(error.message, "backend failed"); + assert_eq!(error.prose, "backend failed"); -#[test] -fn a_tool_error_takes_its_kind_at_construction_and_a_source_as_a_builder() { - use super::{ToolError, ToolErrorKind}; - let err = ToolError::new(ToolErrorKind::Backend, "backend failed") - .with_source(std::io::Error::other("boom")); - assert_eq!(err.kind(), ToolErrorKind::Backend); - assert_eq!(err.to_string(), "backend failed"); - assert!(std::error::Error::source(&err).is_some()); - - let kept = ToolError::new(ToolErrorKind::Transport, "net down") - .with_source(std::io::Error::other("reset")); + let caused = error.with_cause(&std::io::Error::other("boom")); assert_eq!( - kept.kind(), - ToolErrorKind::Transport, - "attaching a source keeps the kind the error was built with" + caused.prose, "backend failed", + "a cause never reaches the prose the model reads" + ); + assert!( + caused.message.contains("boom"), + "the message holds the cause: {}", + caused.message ); } diff --git a/crates/runvil-plugin/Cargo.toml b/crates/runvil-plugin/Cargo.toml index dbe379208..e4e2e819d 100644 --- a/crates/runvil-plugin/Cargo.toml +++ b/crates/runvil-plugin/Cargo.toml @@ -13,8 +13,6 @@ runvil-types.workspace = true runvil-vfs.workspace = true # The JSON a Pack's configuration and every tool call's arguments arrive as. serde_json.workspace = true -# `InstallError`, the registry's refusal. -thiserror.workspace = true [features] # `testing::TestCall`, which lends a `ToolContext` to a Plugin crate's own diff --git a/crates/runvil-plugin/src/lib.rs b/crates/runvil-plugin/src/lib.rs index f4cb3c4d7..ca4fcd45b 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -8,7 +8,7 @@ //! `install` function of a Plugin crate (a `plugin-*` crate): //! //! ```text -//! pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> +//! pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> //! ``` //! //! It calls [`PackRegistry::insert`] once for each Pack it offers, with the @@ -97,13 +97,13 @@ pub mod testing; pub use context::{RenderContext, ToolContext}; pub use pack::{Pack, PackFuture, PackInfo}; -pub use registry::{InstallError, PackOrigin, PackRegistry}; -pub use service::{HostServices, ServiceError, ServiceId, ServiceKey, TOKIO_RUNTIME}; +pub use registry::{PackOrigin, PackRegistry}; +pub use service::{HostServices, ServiceId, ServiceKey, TOKIO_RUNTIME}; +pub use runvil_types::error::{Error, ErrorCode, FactKey, Location}; pub use runvil_types::models::{ModelId, ModelInfo, ThinkingMode}; -pub use runvil_types::names::IdError; pub use runvil_types::packs::PackId; pub use runvil_types::tools::{ - OutputTrust, ToolCallOrigin, ToolCaller, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput, + OutputTrust, ToolCallOrigin, ToolCaller, ToolId, ToolInfo, ToolOutput, tool_error, }; pub use runvil_vfs::{Access, Entry, FileType, PathReason, Stat, VfsError}; diff --git a/crates/runvil-plugin/src/pack.rs b/crates/runvil-plugin/src/pack.rs index 3529b7ab0..3e13ac58a 100644 --- a/crates/runvil-plugin/src/pack.rs +++ b/crates/runvil-plugin/src/pack.rs @@ -5,8 +5,9 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use runvil_types::error::Error; use runvil_types::packs::PackId; -use runvil_types::tools::{ToolError, ToolInfo, ToolOutput}; +use runvil_types::tools::{ToolInfo, ToolOutput}; use crate::{HostServices, RenderContext, ServiceId, ToolContext}; @@ -57,10 +58,10 @@ pub trait Pack: Send + Sync { /// has changed. /// /// # Errors - /// Returns a [`ToolError`], whose message is safe to show the model, - /// when the Pack can't offer its tools now, such as when the backend - /// it connects to refuses it or is down. - fn ready(&self) -> PackFuture<'_, Result<(), ToolError>> { + /// Returns an [`Error`], whose prose is safe to show the model, when the + /// Pack can't offer its tools now, such as when the backend it connects + /// to refuses it or is down. + fn ready(&self) -> PackFuture<'_, Result<(), Error>> { Box::pin(async { Ok(()) }) } @@ -94,14 +95,15 @@ pub trait Pack: Send + Sync { /// stall the run until a stop or a cancel. /// /// # Errors - /// Returns a [`ToolError`], whose message is safe to show the model, - /// when the call rejects the arguments, its backend refuses, its + /// Returns an [`Error`] with a tool code, built with + /// [`tool_error`](crate::tool_error) so its prose is safe to show the + /// model, when the call rejects the arguments, its backend refuses, its /// transport fails, or the run is cancelled. fn call<'a>( &'a self, cx: ToolContext<'a>, args: serde_json::Value, - ) -> PackFuture<'a, Result>; + ) -> PackFuture<'a, Result>; /// Returns the tools the model sees, given the tools a section offered /// from this Pack in one set. @@ -147,7 +149,7 @@ pub trait Pack: Send + Sync { /// .prelude(PRELUDE) /// .needs(NEEDS); /// -/// pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { +/// pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), Error> { /// packs.insert(GREETER, config["greeter"].clone()) /// } /// ``` @@ -179,7 +181,7 @@ pub struct PackInfo { name: &PackId, config: serde_json::Value, services: &HostServices, - ) -> Result, ToolError>, + ) -> Result, Error>, /// One line saying what the Pack offers, for a Host listing the /// Packs its registry holds; empty unless the Plugin crate sets it. pub description: &'static str, @@ -199,7 +201,7 @@ impl PackInfo { name: &PackId, config: serde_json::Value, services: &HostServices, - ) -> Result, ToolError>, + ) -> Result, Error>, ) -> PackInfo { PackInfo { fqn, diff --git a/crates/runvil-plugin/src/registry-tests.rs b/crates/runvil-plugin/src/registry-tests.rs index 2a226db00..5965b8989 100644 --- a/crates/runvil-plugin/src/registry-tests.rs +++ b/crates/runvil-plugin/src/registry-tests.rs @@ -8,11 +8,32 @@ //! Pack id and builds a Pack that offers no tools and carries no prelude //! and no needs. +use runvil_types::error::{Error, ErrorCode, FactKey}; use serde_json::{Value, json}; -use super::{BUILTIN, InstallError, PackOrigin, PackRegistry, builtin, pack_id}; +use super::{BUILTIN, PackOrigin, PackRegistry, builtin, pack_id}; use crate::{HostServices, PackId, PackInfo, ServiceId, ServiceKey}; +/// Asserts `result` refuses the insert of `fqn` for `reason`, with its +/// message as its prose, and returns the message. +#[track_caller] +fn refused(result: Result<(), Error>, reason: &str, fqn: &str) -> String { + let error = result.expect_err("the insert must be refused"); + assert_eq!(error.code, ErrorCode::InvalidValue, "{error:?}"); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason)), + "{error:?}" + ); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from(fqn)), + "{error:?}" + ); + assert_eq!(error.prose, error.message, "{error:?}"); + error.message +} + /// The built-in's FQN. const SUBAGENTS: &str = "runvil/subagents"; @@ -69,10 +90,10 @@ fn add_refuses_an_fqn_that_is_not_a_vendor_name_pair() { "Acme/web", "acme/Web", ] { - assert_eq!( + refused( PackRegistry::new().insert(fixture(bad), Value::Null), - Err(InstallError::InvalidFqn { fqn: bad }), - "{bad}" + "invalid_fqn", + bad, ); } } @@ -82,10 +103,10 @@ fn add_refuses_an_fqn_the_registry_holds_already_the_builtins_included() { let mut packs = PackRegistry::new(); packs.insert(fixture("tests/tools"), Value::Null).unwrap(); for taken in ["tests/tools", SUBAGENTS] { - assert_eq!( + refused( packs.insert(fixture(taken), Value::Null), - Err(InstallError::DuplicatePack { fqn: taken }), - "{taken}" + "duplicate_pack", + taken, ); } assert!( @@ -99,13 +120,10 @@ fn add_refuses_an_fqn_the_registry_holds_already_the_builtins_included() { #[test] fn add_refuses_a_third_party_pack_taking_the_builtins_pack_id() { let mut packs = PackRegistry::new(); - let refused = packs.insert(fixture("acme/subagents"), Value::Null); - assert_eq!( - refused, - Err(InstallError::ReservedName { - fqn: "acme/subagents", - name: name("subagents"), - }) + refused( + packs.insert(fixture("acme/subagents"), Value::Null), + "reserved_name", + "acme/subagents", ); assert!( !packs.list().any(|(info, _)| info.fqn == "acme/subagents"), @@ -122,14 +140,14 @@ fn add_refuses_a_pack_taking_a_reserved_pack_id_from_any_vendor() { ("runvil/dynamic", "dynamic"), ] { let mut packs = PackRegistry::new(); - let refused = packs.insert(fixture(fqn), Value::Null); - assert_eq!( - refused, - Err(InstallError::ReservedPackId { - fqn, - id: name(reserved), - }), - "{fqn}" + let message = refused( + packs.insert(fixture(fqn), Value::Null), + "reserved_pack_id", + fqn, + ); + assert!( + message.contains(&format!("the Pack id {reserved},")), + "{message}" ); assert!( !packs.list().any(|(info, _)| info.fqn == fqn), @@ -204,23 +222,16 @@ fn the_registrys_debug_output_shows_each_fqn_origin_and_pack_id_and_no_config() #[test] fn the_registry_refusals_name_the_pack() { - let refusals = [ - InstallError::InvalidFqn { fqn: "web" }, - InstallError::DuplicatePack { fqn: "acme/web" }, - InstallError::ReservedName { - fqn: "acme/subagents", - name: name("subagents"), - }, - InstallError::ReservedPackId { - fqn: "acme/adhoc", - id: name("adhoc"), - }, - InstallError::ReservedPackId { - fqn: "acme/dynamic", - id: name("dynamic"), - }, + let mut packs = PackRegistry::new(); + packs.insert(fixture("acme/web"), Value::Null).unwrap(); + let mut insert = |fqn| packs.insert(fixture(fqn), Value::Null); + let messages = [ + refused(insert("web"), "invalid_fqn", "web"), + refused(insert("acme/web"), "duplicate_pack", "acme/web"), + refused(insert("acme/subagents"), "reserved_name", "acme/subagents"), + refused(insert("acme/adhoc"), "reserved_pack_id", "acme/adhoc"), + refused(insert("acme/dynamic"), "reserved_pack_id", "acme/dynamic"), ]; - let messages: Vec = refusals.iter().map(ToString::to_string).collect(); assert_eq!( messages, [ diff --git a/crates/runvil-plugin/src/registry.rs b/crates/runvil-plugin/src/registry.rs index 2a9db4c49..c992878c8 100644 --- a/crates/runvil-plugin/src/registry.rs +++ b/crates/runvil-plugin/src/registry.rs @@ -1,14 +1,14 @@ //! The Packs a Host builds: [`PackRegistry`], holding the built-ins -//! from the start and each Pack a Plugin inserts with its config, the -//! [`PackOrigin`] that says which a Pack is, and the [`InstallError`] an -//! insert refuses with. +//! from the start and each Pack a Plugin inserts with its config, and the +//! [`PackOrigin`] that says which a Pack is. use std::collections::BTreeMap; use std::fmt; use std::sync::Arc; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::packs::PackId; -use runvil_types::tools::{ToolError, ToolInfo, ToolOutput}; +use runvil_types::tools::{ToolInfo, ToolOutput, tool_error}; use serde_json::Value; use crate::{HostServices, Pack, PackFuture, PackInfo, ToolContext}; @@ -87,26 +87,52 @@ impl PackRegistry { /// Pack id, such as `runvil/web` and `acme/web`. /// /// # Errors - /// Returns [`InstallError::InvalidFqn`] when the FQN is not a - /// `vendor/name` pair of valid one-segment names, - /// [`InstallError::DuplicatePack`] when the registry holds the FQN - /// already, as it holds each built-in's from the start, - /// [`InstallError::ReservedPackId`] when the Pack id is - /// [reserved](PackId::is_reserved), such as `adhoc`, and - /// [`InstallError::ReservedName`] when the Pack id is a built-in's. - pub fn insert(&mut self, info: PackInfo, config: Value) -> Result<(), InstallError> { - let id = pack_id(info.fqn).ok_or(InstallError::InvalidFqn { fqn: info.fqn })?; - if self.entries.contains_key(info.fqn) { - return Err(InstallError::DuplicatePack { fqn: info.fqn }); + /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error that names + /// the FQN, so the Host's author sees the refusal at startup, with the + /// reason: + /// + /// - `invalid_fqn` when the FQN is not a `vendor/name` pair of valid + /// one-segment names; + /// - `duplicate_pack` when the registry holds the FQN already, as it + /// holds each built-in's from the start; + /// - `reserved_pack_id` when the Pack id is + /// [reserved](PackId::is_reserved), such as `adhoc`; + /// - `reserved_name` when the Pack id is a built-in's. + /// + /// A Pack whose `construct` fails is not refused here; the Host builds + /// it as unavailable instead. + pub fn insert(&mut self, info: PackInfo, config: Value) -> Result<(), Error> { + let fqn = info.fqn; + let id = pack_id(fqn).ok_or_else(|| { + refusal( + "invalid_fqn", + format!("Pack fully qualified name `{fqn}` is not a vendor/name pair"), + fqn, + ) + })?; + if self.entries.contains_key(fqn) { + return Err(refusal( + "duplicate_pack", + format!("Pack `{fqn}` is already in the registry"), + fqn, + )); } if id.is_reserved() { - return Err(InstallError::ReservedPackId { fqn: info.fqn, id }); + return Err(refusal( + "reserved_pack_id", + format!("Pack `{fqn}` cannot take the Pack id {id}, which the Engine reserves"), + fqn, + )); } if self.reserves(&id) { - return Err(InstallError::ReservedName { - fqn: info.fqn, - name: id, - }); + return Err(refusal( + "reserved_name", + format!( + "Pack `{fqn}` cannot take the Pack id {id}, which the built-in runvil/{id} \ + reserves" + ), + fqn, + )); } self.entries.insert( info.fqn, @@ -160,49 +186,14 @@ impl fmt::Debug for PackRegistry { } } -/// A Pack [`PackRegistry::insert`] refuses outright, so the Host's author -/// sees it at startup. -/// -/// A Pack whose `construct` fails is not one of these; the Host builds it -/// as unavailable instead. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum InstallError { - /// The inserted Pack's FQN is not `vendor/name`, or a segment of it is - /// not a valid one-segment name. - #[error("Pack fully qualified name `{fqn}` is not a vendor/name pair")] - InvalidFqn { - /// The FQN as its `PackInfo` spells it. - fqn: &'static str, - }, - /// The registry holds the inserted Pack's FQN already, as a built-in or - /// as a Pack inserted before. - #[error("Pack `{fqn}` is already in the registry")] - DuplicatePack { - /// The FQN as its `PackInfo` spells it. - fqn: &'static str, - }, - /// The inserted Pack's id is a built-in's, which only that built-in may - /// have. - #[error( - "Pack `{fqn}` cannot take the Pack id {name}, which the built-in runvil/{name} reserves" - )] - ReservedName { - /// The FQN as its `PackInfo` spells it. - fqn: &'static str, - /// The Pack id it would have taken. - name: PackId, - }, - /// The inserted Pack's id is one the Engine - /// [reserves](PackId::is_reserved), such as `adhoc`, which holds a - /// section's own tools, whatever the vendor. - #[error("Pack `{fqn}` cannot take the Pack id {id}, which the Engine reserves")] - ReservedPackId { - /// The FQN as its `PackInfo` spells it. - fqn: &'static str, - /// The reserved Pack id it would have taken. - id: PackId, - }, +/// The refusal of an insert: an `InvalidValue` that names the FQN, with the +/// broken rule as its reason. Its prose is its message, which names the Pack +/// and the rule. +fn refusal(reason: &'static str, message: String, fqn: &str) -> Error { + let mut error = Error::new(ErrorCode::InvalidValue, message.clone(), message); + error.facts.insert(FactKey::Name, Value::from(fqn)); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error } /// The built-ins every registry holds from the start. @@ -229,7 +220,7 @@ fn builtin( _name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { Ok(Arc::new(Builtin)) } @@ -245,12 +236,12 @@ impl Pack for Builtin { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { let message = format!( "{} belongs to a built-in Pack, whose calls the Engine answers", cx.tool() ); - Box::pin(async move { Err(ToolError::message(message)) }) + Box::pin(async move { Err(tool_error(ErrorCode::ToolOther, message)) }) } } diff --git a/crates/runvil-plugin/src/service-tests.rs b/crates/runvil-plugin/src/service-tests.rs index 1f3f47f0e..a0976dd25 100644 --- a/crates/runvil-plugin/src/service-tests.rs +++ b/crates/runvil-plugin/src/service-tests.rs @@ -4,7 +4,28 @@ use std::collections::HashSet; use std::sync::Arc; -use super::{HostServices, ServiceError, ServiceId, ServiceKey, TOKIO_RUNTIME}; +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; + +use super::{HostServices, ServiceId, ServiceKey, TOKIO_RUNTIME}; + +/// Asserts `error` refuses the provider under `literal` for `reason`, with +/// its message as its prose. +#[track_caller] +fn assert_refused(error: &Error, reason: &str, literal: &str) { + assert_eq!(error.code, ErrorCode::InvalidValue, "{error:?}"); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason)), + "{error:?}" + ); + assert_eq!( + error.facts.get(&FactKey::Name), + Some(&Value::from(literal)), + "{error:?}" + ); + assert_eq!(error.prose, error.message, "{error:?}"); +} /// A text service, built in a `const` as every key is. const GREETING: ServiceKey = ServiceKey::new("acme/greeting"); @@ -59,15 +80,7 @@ fn a_second_provider_under_a_provided_id_is_refused_and_the_first_stays() { let error = services .provide(&GREETING, Arc::from("second")) .expect_err("a duplicate id is refused"); - assert!( - matches!( - error, - ServiceError::DuplicateId { - id: "acme/greeting" - } - ), - "{error:?}" - ); + assert_refused(&error, "duplicate_id", "acme/greeting"); assert_eq!( error.to_string(), "a service with id acme/greeting is already provided" @@ -76,15 +89,7 @@ fn a_second_provider_under_a_provided_id_is_refused_and_the_first_stays() { let other_type = services .provide(&GREETING_AS_NUMBER, Arc::new(7)) .expect_err("a duplicate literal is refused whatever its type"); - assert!( - matches!( - other_type, - ServiceError::DuplicateId { - id: "acme/greeting" - } - ), - "{other_type:?}" - ); + assert_refused(&other_type, "duplicate_id", "acme/greeting"); assert_eq!(services.get(&GREETING).as_deref(), Some("first")); } @@ -102,10 +107,7 @@ fn an_id_outside_the_namespace_name_grammar_is_refused() { let error = services .provide(&key, Arc::from("hello")) .expect_err("an unparseable id is refused"); - let ServiceError::InvalidId { id } = &error else { - panic!("{literal}: the refusal names an invalid id: {error:?}"); - }; - assert_eq!(*id, literal); + assert_refused(&error, "invalid_id", literal); assert_eq!( error.to_string(), format!("service id {literal} is not a namespace/name id") @@ -129,10 +131,7 @@ fn a_service_id_is_a_global_name_with_exactly_one_slash() { let error = services .provide(&key, Arc::from("hello")) .expect_err("a literal outside the service id grammar is refused"); - assert!( - matches!(&error, ServiceError::InvalidId { id, .. } if *id == literal), - "{literal}: {error:?}" - ); + assert_refused(&error, "invalid_id", literal); assert!( std::error::Error::source(&error).is_none(), "{literal}: the refusal carries no Pack id parse failure: {error:?}" diff --git a/crates/runvil-plugin/src/service.rs b/crates/runvil-plugin/src/service.rs index 693a61de0..0d5106004 100644 --- a/crates/runvil-plugin/src/service.rs +++ b/crates/runvil-plugin/src/service.rs @@ -17,7 +17,9 @@ use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::sync::Arc; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::names::GlobalName; +use serde_json::Value; #[cfg(test)] #[path = "service-tests.rs"] @@ -157,22 +159,31 @@ impl HostServices { /// Adds `provider` to the map under the id of `key`. /// /// # Errors - /// Returns [`ServiceError::InvalidId`] when the id literal of `key` - /// fails to parse as a two-segment `namespace/name` id. Returns - /// [`ServiceError::DuplicateId`] when the map already holds a provider - /// under that literal, whatever the provider's type. The map stays as - /// it was on either error. + /// Returns an [`InvalidValue`](ErrorCode::InvalidValue) error that names + /// the literal, with the reason `invalid_id` when the id literal of `key` + /// fails to parse as a two-segment `namespace/name` id, and + /// `duplicate_id` when the map already holds a provider under that + /// literal, whatever the provider's type. The map stays as it was on + /// either error. pub fn provide( &mut self, key: &ServiceKey, provider: Arc, - ) -> Result<(), ServiceError> { + ) -> Result<(), Error> { let literal = key.id.literal; if GlobalName::parse(literal).is_err() || literal.matches('/').count() != 1 { - return Err(ServiceError::InvalidId { id: literal }); + return Err(refusal( + "invalid_id", + format!("service id {literal} is not a namespace/name id"), + literal, + )); } if self.entries.contains_key(literal) { - return Err(ServiceError::DuplicateId { id: literal }); + return Err(refusal( + "duplicate_id", + format!("a service with id {literal} is already provided"), + literal, + )); } self.insert(key, provider); Ok(()) @@ -222,33 +233,12 @@ impl fmt::Debug for HostServices { } } -/// The error [`HostServices::provide`] returns when it refuses a provider. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum ServiceError { - /// The id literal fails to parse as a two-segment `namespace/name` id. - InvalidId { - /// The id literal that was refused. - id: &'static str, - }, - /// The map already holds a provider under the id. - DuplicateId { - /// The id literal that already has a provider. - id: &'static str, - }, -} - -impl fmt::Display for ServiceError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServiceError::InvalidId { id } => { - write!(f, "service id {id} is not a namespace/name id") - } - ServiceError::DuplicateId { id } => { - write!(f, "a service with id {id} is already provided") - } - } - } +/// The refusal of a provider: an `InvalidValue` that names the id literal, +/// with the broken rule as its reason. Its prose is its message, which names +/// the id and the rule. +fn refusal(reason: &'static str, message: String, literal: &str) -> Error { + let mut error = Error::new(ErrorCode::InvalidValue, message.clone(), message); + error.facts.insert(FactKey::Name, Value::from(literal)); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error } - -impl std::error::Error for ServiceError {} diff --git a/crates/runvil-plugin/tests/it/pack_info.rs b/crates/runvil-plugin/tests/it/pack_info.rs index 594e12775..cd275ce89 100644 --- a/crates/runvil-plugin/tests/it/pack_info.rs +++ b/crates/runvil-plugin/tests/it/pack_info.rs @@ -12,9 +12,9 @@ use std::task::{Context, Poll, Waker}; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, ModelId, ModelInfo, OutputTrust, Pack, PackFuture, PackId, PackInfo, - RenderContext, ServiceId, ServiceKey, ThinkingMode, ToolContext, ToolError, ToolErrorKind, - ToolId, ToolInfo, ToolOutput, + Error, ErrorCode, HostServices, ModelId, ModelInfo, OutputTrust, Pack, PackFuture, PackId, + PackInfo, RenderContext, ServiceId, ServiceKey, ThinkingMode, ToolContext, ToolId, ToolInfo, + ToolOutput, tool_error, }; use serde_json::{Value, json}; @@ -44,15 +44,21 @@ fn construct( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !config.is_null() { - return Err(ToolError::message("greeter takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "greeter takes no configuration", + )); } let greeting = services.get(&GREETING).ok_or_else(|| { - ToolError::message("greeter needs acme/greeting, and this Host provides none") + tool_error( + ErrorCode::ToolOther, + "greeter needs acme/greeting, and this Host provides none", + ) })?; let id = ToolId::parse(&format!("{name}/greet")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "greeter could not name its tool").with_source(e) + tool_error(ErrorCode::ToolBackend, "greeter could not name its tool").with_cause(&e) })?; let tools = vec![ToolInfo::new( id, @@ -71,22 +77,23 @@ impl Pack for Greeter { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { match cx.tool().name() { "greet" => { - let visitor = cx - .service(&VISITOR) - .ok_or_else(|| ToolError::message("no visitor is in this run"))?; + let visitor = cx.service(&VISITOR).ok_or_else(|| { + tool_error(ErrorCode::ToolOther, "no visitor is in this run") + })?; let mark = args.get("mark").and_then(Value::as_str).unwrap_or("."); Ok(ToolOutput::untrusted(format!( "{}, {visitor}{mark}", self.greeting ))) } - other => Err(ToolError::message(format!( - "greeter has no tool named {other}" - ))), + other => Err(tool_error( + ErrorCode::ToolOther, + format!("greeter has no tool named {other}"), + )), } }) } diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 150521ec0..56e4b4938 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -4,15 +4,12 @@ #[non_exhaustive] pub enum runvil::event::Event #[non_exhaustive] pub enum runvil::event::ReplyOrigin #[non_exhaustive] pub enum runvil::ids::AbandonReason -#[non_exhaustive] pub enum runvil::model::CompletionErrorKind #[non_exhaustive] pub enum runvil::model::CompletionResult #[non_exhaustive] pub enum runvil::model::ModelRole #[non_exhaustive] pub enum runvil::model::Role -#[non_exhaustive] pub enum runvil::model::TemperatureError #[non_exhaustive] pub enum runvil::model::ThinkingMode #[non_exhaustive] pub enum runvil::prompt::ArgType #[non_exhaustive] pub enum runvil::tools::OutputTrust -#[non_exhaustive] pub enum runvil::tools::ToolErrorKind #[non_exhaustive] pub enum runvil::vfs::FileType #[non_exhaustive] pub enum runvil::vfs::Mode #[non_exhaustive] pub enum runvil::vfs::Op @@ -22,9 +19,6 @@ #[non_exhaustive] pub enum runvil::vfs::VfsOp #[non_exhaustive] pub enum runvil::vfs::VfsOutcome #[non_exhaustive] pub enum runvil::vfs::Writability -#[non_exhaustive] pub runvil::model::TemperatureError::OutOfRange { .. } -#[non_exhaustive] pub struct runvil::ParseError { /* private fields */ } -#[non_exhaustive] pub struct runvil::Prompt { /* private fields */ } #[non_exhaustive] pub struct runvil::RunLimits { .. } #[non_exhaustive] pub struct runvil::effect::IssuedEffect { .. } #[non_exhaustive] pub struct runvil::effect::PackToolSets { .. } @@ -38,7 +32,6 @@ #[non_exhaustive] pub struct runvil::metrics::Usage { .. } #[non_exhaustive] pub struct runvil::metrics::VllmMetrics { .. } #[non_exhaustive] pub struct runvil::model::Completion { /* private fields */ } -#[non_exhaustive] pub struct runvil::model::CompletionError { /* private fields */ } #[non_exhaustive] pub struct runvil::model::CompletionOptions { /* private fields */ } #[non_exhaustive] pub struct runvil::model::Message { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ModelCatalog { /* private fields */ } @@ -50,13 +43,7 @@ #[non_exhaustive] pub struct runvil::model::ToolCall { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ToolSchema { /* private fields */ } #[non_exhaustive] pub struct runvil::packs::PackId(/* private fields */) -#[non_exhaustive] pub struct runvil::prompt::ArgDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::ArgsDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::FileDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::Frontmatter { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::RoleRequirements { /* private fields */ } #[non_exhaustive] pub struct runvil::tools::ToolCatalog { /* private fields */ } -#[non_exhaustive] pub struct runvil::tools::ToolError { /* private fields */ } #[non_exhaustive] pub struct runvil::tools::ToolId(/* private fields */) #[non_exhaustive] pub struct runvil::tools::ToolInfo { .. } #[non_exhaustive] pub struct runvil::tools::ToolOutput { /* private fields */ } @@ -78,17 +65,20 @@ auto runvil::Step: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::cancel::CancelHandle: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::cancel::Cancelled: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::Effect: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::effect::EffectAnswer: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe +auto runvil::effect::EffectAnswer: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::EffectId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::IssuedEffect: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::effect::Round: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::ToolCallOrigin: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::ToolCaller: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::error::Error: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::error::ErrorCode: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::error::FactKey: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::error::Location: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::event::Event: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::event::ReplyOrigin: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::ids::AbandonReason: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::ids::ChainId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::ids::IdError: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::ids::Provenance: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::ids::RoundId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::ids::TaskId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -100,8 +90,6 @@ auto runvil::metrics::Usage: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSaf auto runvil::metrics::VllmMetrics: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ArgumentNames: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Completion: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::model::CompletionError: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::model::CompletionErrorKind: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::CompletionOptions: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::CompletionResult: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Message: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -111,11 +99,9 @@ auto runvil::model::ModelId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSaf auto runvil::model::ModelInfo: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ModelInvocation: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ModelRole: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::model::ParseRoleError: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::RawExchange: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Role: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Temperature: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::model::TemperatureError: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ThinkingMode: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ToolArguments: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ToolCall: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -135,8 +121,6 @@ auto runvil::prompt::RoleRequirements: Freeze, RefUnwindSafe, Send, Sync, Unpin, auto runvil::timestamp::Timestamp: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::tools::OutputTrust: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::tools::ToolCatalog: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::tools::ToolError: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::tools::ToolErrorKind: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::tools::ToolId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::tools::ToolInfo: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::tools::ToolOutput: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -185,11 +169,14 @@ derives runvil::effect::IssuedEffect: Debug derives runvil::effect::Round: Clone, Copy, Debug, Eq, PartialEq derives runvil::effect::ToolCallOrigin: Clone, Debug, Deserialize, Eq, PartialEq, Serialize derives runvil::effect::ToolCaller: Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize +derives runvil::error::Error: Clone, Debug, Eq, PartialEq +derives runvil::error::ErrorCode: Clone, Copy, Debug, Eq, Hash, PartialEq +derives runvil::error::FactKey: Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd +derives runvil::error::Location: Clone, Debug, Eq, PartialEq derives runvil::event::Event: Clone, Debug, Deserialize, PartialEq, Serialize derives runvil::event::ReplyOrigin: Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize derives runvil::ids::AbandonReason: Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize derives runvil::ids::ChainId: Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize -derives runvil::ids::IdError: Clone, Debug, Eq, PartialEq derives runvil::ids::Provenance: Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize derives runvil::ids::RoundId: Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize derives runvil::ids::TaskId: Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize @@ -201,8 +188,6 @@ derives runvil::metrics::Usage: Clone, Debug, Deserialize, Eq, PartialEq, Serial derives runvil::metrics::VllmMetrics: Clone, Debug, Default, Deserialize, PartialEq, Serialize derives runvil::model::ArgumentNames: Clone, Debug derives runvil::model::Completion: Debug -derives runvil::model::CompletionError: Debug -derives runvil::model::CompletionErrorKind: Clone, Copy, Debug, Eq, Hash, PartialEq derives runvil::model::CompletionOptions: Clone, Debug, Eq, PartialEq derives runvil::model::CompletionResult: Clone, Debug, Eq, PartialEq derives runvil::model::Message: Clone, Debug, Eq, PartialEq, Serialize @@ -212,11 +197,9 @@ derives runvil::model::ModelId: Clone, Debug, Eq, Hash, Ord, PartialEq, PartialO derives runvil::model::ModelInfo: Clone, Debug, Eq, PartialEq derives runvil::model::ModelInvocation: Clone, Debug, Default, Eq, PartialEq derives runvil::model::ModelRole: Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd -derives runvil::model::ParseRoleError: Clone, Debug, Eq, PartialEq derives runvil::model::RawExchange: Clone, Debug, Eq, PartialEq derives runvil::model::Role: Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize derives runvil::model::Temperature: Clone, Copy, Debug, Eq, PartialEq -derives runvil::model::TemperatureError: Clone, Debug, PartialEq derives runvil::model::ThinkingMode: Clone, Copy, Debug, Deserialize, Eq, PartialEq derives runvil::model::ToolArguments: Clone, Copy, Debug derives runvil::model::ToolCall: Clone, Debug, Eq, PartialEq @@ -236,8 +219,6 @@ derives runvil::prompt::RoleRequirements: Clone, Copy, Debug, Deserialize, Eq, P derives runvil::timestamp::Timestamp: Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize derives runvil::tools::OutputTrust: Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize derives runvil::tools::ToolCatalog: Clone, Debug, Default -derives runvil::tools::ToolError: Debug -derives runvil::tools::ToolErrorKind: Clone, Copy, Debug, Eq, PartialEq derives runvil::tools::ToolId: Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize derives runvil::tools::ToolInfo: Clone, Debug, Deserialize, Eq, PartialEq, Serialize derives runvil::tools::ToolOutput: Clone, Debug, Eq, PartialEq @@ -270,28 +251,20 @@ derives runvil::vfs::Writability: Clone, Copy, Debug, Eq, Hash, PartialEq impl core::convert::From for runvil::ids::TaskId impl core::convert::From for runvil::vfs::VfsPathBuf impl core::error::Error for runvil::ParseError -impl core::error::Error for runvil::ids::IdError -impl core::error::Error for runvil::model::CompletionError -impl core::error::Error for runvil::model::ParseRoleError -impl core::error::Error for runvil::model::TemperatureError -impl core::error::Error for runvil::tools::ToolError +impl core::error::Error for runvil::error::Error impl core::error::Error for runvil::vfs::ParsePathReasonError impl core::error::Error for runvil::vfs::VfsError impl core::fmt::Display for runvil::ParseError impl core::fmt::Display for runvil::RunSeed impl core::fmt::Display for runvil::effect::EffectId +impl core::fmt::Display for runvil::error::Error impl core::fmt::Display for runvil::ids::ChainId -impl core::fmt::Display for runvil::ids::IdError impl core::fmt::Display for runvil::ids::TaskId -impl core::fmt::Display for runvil::model::CompletionError -impl core::fmt::Display for runvil::model::ParseRoleError impl core::fmt::Display for runvil::model::Role -impl core::fmt::Display for runvil::model::TemperatureError impl core::fmt::Display for runvil::packs::GlobalName impl core::fmt::Display for runvil::packs::PackId impl core::fmt::Display for runvil::prompt::ArgType impl core::fmt::Display for runvil::timestamp::Timestamp -impl core::fmt::Display for runvil::tools::ToolError impl core::fmt::Display for runvil::tools::ToolId impl core::fmt::Display for runvil::vfs::ParsePathReasonError impl core::fmt::Display for runvil::vfs::PathReason @@ -303,17 +276,17 @@ impl core::future::future::Future for runvil::cancel::Cancelled { type Output = impl core::iter::traits::exact_size::ExactSizeIterator for runvil::prompt::ArgsDeclIter<'_> impl core::ops::drop::Drop for runvil::vfs::Access impl core::str::traits::FromStr for runvil::ids::ChainId -impl core::str::traits::FromStr for runvil::ids::ChainId { type Err = runvil::ids::IdError } +impl core::str::traits::FromStr for runvil::ids::ChainId { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::ids::TaskId -impl core::str::traits::FromStr for runvil::ids::TaskId { type Err = runvil::ids::IdError } +impl core::str::traits::FromStr for runvil::ids::TaskId { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::model::Role -impl core::str::traits::FromStr for runvil::model::Role { type Err = runvil::model::ParseRoleError } +impl core::str::traits::FromStr for runvil::model::Role { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::packs::GlobalName -impl core::str::traits::FromStr for runvil::packs::GlobalName { type Err = runvil::ids::IdError } +impl core::str::traits::FromStr for runvil::packs::GlobalName { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::packs::PackId -impl core::str::traits::FromStr for runvil::packs::PackId { type Err = runvil::ids::IdError } +impl core::str::traits::FromStr for runvil::packs::PackId { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::tools::ToolId -impl core::str::traits::FromStr for runvil::tools::ToolId { type Err = runvil::ids::IdError } +impl core::str::traits::FromStr for runvil::tools::ToolId { type Err = runvil::error::Error } impl core::str::traits::FromStr for runvil::vfs::PathReason impl core::str::traits::FromStr for runvil::vfs::PathReason { type Err = runvil::vfs::ParsePathReasonError } impl runvil::vfs::Policy for runvil::vfs::AllowAll @@ -346,6 +319,9 @@ pub const fn runvil::RunOutcome::as_str(&self) -> &'static str pub const fn runvil::RunSeed::as_bytes(&self) -> &[u8; 32] pub const fn runvil::RunSeed::from_bytes(bytes: [u8; 32]) -> runvil::RunSeed pub const fn runvil::effect::EffectId::get(self) -> u64 +pub const fn runvil::error::ErrorCode::number(self) -> u16 +pub const fn runvil::error::ErrorCode::prose(self) -> &'static str +pub const fn runvil::error::ErrorCode::tag(self) -> &'static str pub const fn runvil::ids::RoundId::get(self) -> u64 pub const fn runvil::ids::RoundId::new(id: u64) -> Self pub const fn runvil::model::ModelRole::as_str(self) -> &'static str @@ -359,6 +335,8 @@ pub enum runvil::RunOutcome pub enum runvil::Step pub enum runvil::effect::Effect pub enum runvil::effect::EffectAnswer +pub enum runvil::error::ErrorCode +pub enum runvil::error::FactKey pub enum runvil::packs::RosterEntry pub fn runvil::ParseError::cause(&self) -> core::option::Option<&dyn core::error::Error + core::marker::Send + core::marker::Sync + 'static> pub fn runvil::ParseError::column(&self) -> core::option::Option @@ -397,6 +375,10 @@ pub fn runvil::effect::Round::new(id: runvil::ids::RoundId, origin: runvil::even pub fn runvil::effect::ToolCallOrigin::new(execution: impl core::convert::Into, section: impl core::convert::Into, caller: runvil::effect::ToolCaller) -> runvil::effect::ToolCallOrigin pub fn runvil::effect::ToolCallOrigin::with_entry(self, entry: impl core::convert::Into) -> runvil::effect::ToolCallOrigin pub fn runvil::effect::ToolCallOrigin::with_model(self, model: impl core::convert::Into) -> runvil::effect::ToolCallOrigin +pub fn runvil::error::Error::new(code: runvil::error::ErrorCode, message: impl core::convert::Into, prose: impl core::convert::Into) -> runvil::error::Error +pub fn runvil::error::Error::with_cause(self, cause: &dyn core::error::Error + 'static) -> runvil::error::Error +pub fn runvil::error::Location::line(file: impl core::convert::Into, line: core::num::nonzero::NonZeroU32) -> runvil::error::Location +pub fn runvil::error::Location::new(file: impl core::convert::Into, start_line: core::num::nonzero::NonZeroU32, start_column: core::num::nonzero::NonZeroU32, end_line: core::num::nonzero::NonZeroU32, end_column: core::num::nonzero::NonZeroU32) -> core::option::Option pub fn runvil::event::Event::execution(&self) -> &str pub fn runvil::event::Event::provenance(&self) -> &runvil::ids::Provenance pub fn runvil::event::Event::section(&self) -> &str @@ -429,7 +411,7 @@ pub fn runvil::metrics::VllmMetrics::with_queue_time_ms(self, ms: core::option:: pub fn runvil::metrics::VllmMetrics::with_time_to_first_token_ms(self, ms: core::option::Option) -> runvil::metrics::VllmMetrics pub fn runvil::metrics::VllmMetrics::with_tokens_per_second(self, rate: core::option::Option) -> runvil::metrics::VllmMetrics pub fn runvil::model::Completion::finish_reason(&self) -> core::option::Option<&str> -pub fn runvil::model::Completion::from_result(result: runvil::model::CompletionResult, model: impl core::convert::Into) -> core::result::Result +pub fn runvil::model::Completion::from_result(result: runvil::model::CompletionResult, model: impl core::convert::Into) -> core::result::Result pub fn runvil::model::Completion::metadata_diagnostics(&self) -> &[alloc::string::String] pub fn runvil::model::Completion::metrics(&self) -> core::option::Option<&runvil::metrics::CallMetrics> pub fn runvil::model::Completion::model(&self) -> &str @@ -442,19 +424,6 @@ pub fn runvil::model::Completion::with_metrics(self, metrics: runvil::metrics::C pub fn runvil::model::Completion::with_model(self, model: impl core::convert::Into) -> runvil::model::Completion pub fn runvil::model::Completion::with_raw(self, raw: runvil::model::RawExchange) -> runvil::model::Completion pub fn runvil::model::Completion::with_reasoning_content(self, reasoning_content: impl core::convert::Into) -> runvil::model::Completion -pub fn runvil::model::CompletionError::context_overflow(prompt_tokens: core::option::Option, window: core::option::Option, message: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::detail(&self) -> core::option::Option<&str> -pub fn runvil::model::CompletionError::empty_reply(reasoning_present: bool) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::finish_reason(&self) -> core::option::Option<&str> -pub fn runvil::model::CompletionError::kind(&self) -> runvil::model::CompletionErrorKind -pub fn runvil::model::CompletionError::message(&self) -> &str -pub fn runvil::model::CompletionError::new(kind: runvil::model::CompletionErrorKind, message: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::overflow(&self) -> (core::option::Option, core::option::Option) -pub fn runvil::model::CompletionError::with_detail(self, text: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::with_finish_reason(self, reason: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::with_source(self, source: impl core::convert::Into>) -> runvil::model::CompletionError -pub fn runvil::model::CompletionErrorKind::phrase(self) -> &'static str -pub fn runvil::model::CompletionErrorKind::tag(self) -> &'static str pub fn runvil::model::CompletionOptions::invocation(&self) -> &runvil::model::ModelInvocation pub fn runvil::model::CompletionOptions::max_tokens(&self) -> core::option::Option pub fn runvil::model::CompletionOptions::model(&self) -> &str @@ -464,7 +433,7 @@ pub fn runvil::model::CompletionOptions::thinking(&self) -> core::option::Option pub fn runvil::model::CompletionOptions::with_invocation(self, invocation: runvil::model::ModelInvocation) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::with_max_tokens(self, max_tokens: core::num::nonzero::NonZeroU32) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::with_model(self, model: impl core::convert::Into) -> runvil::model::CompletionOptions -pub fn runvil::model::CompletionOptions::with_temperature(self, temperature: f64) -> core::result::Result +pub fn runvil::model::CompletionOptions::with_temperature(self, temperature: f64) -> core::result::Result pub fn runvil::model::CompletionOptions::with_thinking(self, thinking: bool) -> runvil::model::CompletionOptions pub fn runvil::model::Message::assistant(content: impl core::convert::Into) -> runvil::model::Message pub fn runvil::model::Message::content(&self) -> &str @@ -487,10 +456,10 @@ pub fn runvil::model::ModelCatalog::contains(&self, id: &runvil::model::ModelId) pub fn runvil::model::ModelCatalog::empty() -> Self pub fn runvil::model::ModelCatalog::get(&self, id: &runvil::model::ModelId) -> core::option::Option<&runvil::model::ModelInfo> pub fn runvil::model::ModelCatalog::models(&self) -> &[runvil::model::ModelInfo] -pub fn runvil::model::ModelCatalog::new(models: impl core::iter::traits::collect::IntoIterator) -> core::result::Result -pub fn runvil::model::ModelId::gateway(name: impl core::convert::Into) -> core::result::Result +pub fn runvil::model::ModelCatalog::new(models: impl core::iter::traits::collect::IntoIterator) -> core::result::Result +pub fn runvil::model::ModelId::gateway(name: impl core::convert::Into) -> core::result::Result pub fn runvil::model::ModelId::name(&self) -> &str -pub fn runvil::model::ModelId::new(server: impl core::convert::Into, name: impl core::convert::Into) -> core::result::Result +pub fn runvil::model::ModelId::new(server: impl core::convert::Into, name: impl core::convert::Into) -> core::result::Result pub fn runvil::model::ModelInfo::context(&self) -> core::num::nonzero::NonZeroU32 pub fn runvil::model::ModelInfo::description(&self) -> &str pub fn runvil::model::ModelInfo::id(&self) -> &runvil::model::ModelId @@ -502,28 +471,31 @@ pub fn runvil::model::ModelInvocation::new() -> runvil::model::ModelInvocation pub fn runvil::model::ModelInvocation::with_max_tokens(self, max_tokens: core::option::Option) -> runvil::model::ModelInvocation pub fn runvil::model::ModelInvocation::with_temperature(self, temperature: core::option::Option) -> runvil::model::ModelInvocation pub fn runvil::model::ModelInvocation::with_thinking(self, thinking: core::option::Option) -> runvil::model::ModelInvocation -pub fn runvil::model::ParseRoleError::input(&self) -> &str pub fn runvil::model::RawExchange::new(request: serde_json::value::Value, response: serde_json::value::Value) -> runvil::model::RawExchange pub fn runvil::model::RawExchange::request(&self) -> &serde_json::value::Value pub fn runvil::model::RawExchange::response(&self) -> &serde_json::value::Value pub fn runvil::model::Role::as_str(self) -> &'static str pub fn runvil::model::Temperature::get(self) -> f64 -pub fn runvil::model::Temperature::new(value: f64) -> core::result::Result +pub fn runvil::model::Temperature::new(value: f64) -> core::result::Result pub fn runvil::model::ToolArguments::contains(&self, key: &str) -> bool pub fn runvil::model::ToolArguments::is_empty(&self) -> bool pub fn runvil::model::ToolArguments::names(&self) -> runvil::model::ArgumentNames<'_> pub fn runvil::model::ToolArguments::to_json_string(&self) -> alloc::string::String pub fn runvil::model::ToolCall::arguments(&self) -> runvil::model::ToolArguments<'_> -pub fn runvil::model::ToolCall::from_parts(id: impl core::convert::Into, name: impl core::convert::Into, arguments: serde_json::value::Value) -> core::result::Result +pub fn runvil::model::ToolCall::from_parts(id: impl core::convert::Into, name: impl core::convert::Into, arguments: serde_json::value::Value) -> core::result::Result pub fn runvil::model::ToolCall::id(&self) -> &str pub fn runvil::model::ToolCall::name(&self) -> &str pub fn runvil::model::ToolSchema::description(&self) -> &str pub fn runvil::model::ToolSchema::name(&self) -> &str pub fn runvil::model::ToolSchema::parameters(&self) -> &serde_json::value::Value -pub fn runvil::packs::GlobalName::parse(s: &str) -> core::result::Result +pub fn runvil::model::completion_error(code: runvil::error::ErrorCode, message: impl core::convert::Into) -> runvil::error::Error +pub fn runvil::model::context_overflow(prompt_tokens: core::option::Option, window: core::option::Option, message: impl core::convert::Into) -> runvil::error::Error +pub fn runvil::model::empty_reply(reasoning_present: bool) -> runvil::error::Error +pub fn runvil::model::phrase(code: runvil::error::ErrorCode) -> &'static str +pub fn runvil::packs::GlobalName::parse(s: &str) -> core::result::Result pub fn runvil::packs::PackId::contains(&self, tool: &runvil::tools::ToolId) -> bool pub fn runvil::packs::PackId::is_reserved(&self) -> bool -pub fn runvil::packs::PackId::parse(name: &str) -> core::result::Result +pub fn runvil::packs::PackId::parse(name: &str) -> core::result::Result pub fn runvil::packs::PackRef::id(&self) -> &runvil::packs::PackId pub fn runvil::packs::PackRef::vendor(&self) -> &str pub fn runvil::packs::Prelude::new(pack: runvil::packs::PackId, source: impl core::convert::Into) -> runvil::packs::Prelude @@ -553,17 +525,11 @@ pub fn runvil::prompt::RoleRequirements::min_context(&self) -> core::option::Opt pub fn runvil::prompt::RoleRequirements::thinking(&self) -> core::option::Option pub fn runvil::timestamp::Timestamp::to_rfc3339(self) -> alloc::string::String pub fn runvil::tools::ToolCatalog::get(&self, id: &runvil::tools::ToolId) -> core::option::Option<&runvil::tools::ToolInfo> -pub fn runvil::tools::ToolCatalog::new(tools: impl core::iter::traits::collect::IntoIterator) -> core::result::Result +pub fn runvil::tools::ToolCatalog::new(tools: impl core::iter::traits::collect::IntoIterator) -> core::result::Result pub fn runvil::tools::ToolCatalog::tools(&self) -> &[runvil::tools::ToolInfo] -pub fn runvil::tools::ToolError::kind(&self) -> runvil::tools::ToolErrorKind -pub fn runvil::tools::ToolError::message(text: impl core::convert::Into) -> runvil::tools::ToolError -pub fn runvil::tools::ToolError::new(kind: runvil::tools::ToolErrorKind, text: impl core::convert::Into) -> runvil::tools::ToolError -pub fn runvil::tools::ToolError::with_kind(self, kind: runvil::tools::ToolErrorKind) -> runvil::tools::ToolError -pub fn runvil::tools::ToolError::with_source(self, source: impl core::convert::Into>) -> runvil::tools::ToolError -pub fn runvil::tools::ToolErrorKind::tag(self) -> &'static str pub fn runvil::tools::ToolId::name(&self) -> &str pub fn runvil::tools::ToolId::pack(&self) -> runvil::packs::PackId -pub fn runvil::tools::ToolId::parse(id: &str) -> core::result::Result +pub fn runvil::tools::ToolId::parse(id: &str) -> core::result::Result pub fn runvil::tools::ToolInfo::describes_tools(self, describes: bool) -> runvil::tools::ToolInfo pub fn runvil::tools::ToolInfo::model_only_call(self, model_only: bool) -> runvil::tools::ToolInfo pub fn runvil::tools::ToolInfo::new(id: runvil::tools::ToolId, description: impl core::convert::Into, parameters_schema: serde_json::value::Value) -> runvil::tools::ToolInfo @@ -574,6 +540,7 @@ pub fn runvil::tools::ToolOutput::text(&self) -> &str pub fn runvil::tools::ToolOutput::trust(&self) -> runvil::tools::OutputTrust pub fn runvil::tools::ToolOutput::trusted(text: impl core::convert::Into) -> runvil::tools::ToolOutput pub fn runvil::tools::ToolOutput::untrusted(text: impl core::convert::Into) -> runvil::tools::ToolOutput +pub fn runvil::tools::tool_error(code: runvil::error::ErrorCode, text: impl core::convert::Into) -> runvil::error::Error pub fn runvil::vfs::Access::append(&self, path: &str, contents: &[u8]) -> core::result::Result<(), runvil::vfs::VfsError> pub fn runvil::vfs::Access::close(self) -> core::result::Result<(), runvil::vfs::VfsError> pub fn runvil::vfs::Access::copy(&self, from: &str, to: &str) -> core::result::Result<(), runvil::vfs::VfsError> @@ -648,8 +615,10 @@ pub fn runvil::vfs::VfsRefBuilder::on_op(self, sink: impl core::ops::function::F pub fn runvil::vfs::VfsRefBuilder::policy(self, policy: impl runvil::vfs::Policy + core::marker::Sync + 'static) -> runvil::vfs::VfsRefBuilder pub fn runvil::vfs::VfsRefBuilder::store(self, root: &str, backend: impl runvil::vfs::Vfs + 'static) -> runvil::vfs::VfsRefBuilder pub fn runvil::vfs::perform_vfs_op(access: &runvil::vfs::Access, op: runvil::vfs::VfsOp) -> core::result::Result +pub fn runvil::vfs::store_error(error: &runvil::vfs::VfsError) -> runvil::error::Error pub mod runvil::cancel pub mod runvil::effect +pub mod runvil::error pub mod runvil::event pub mod runvil::ids pub mod runvil::metrics @@ -715,13 +684,13 @@ pub runvil::effect::Effect::Vfs { .. } pub runvil::effect::Effect::Vfs::access: alloc::sync::Arc pub runvil::effect::Effect::Vfs::op: runvil::vfs::VfsOp pub runvil::effect::EffectAnswer::Chat(..) -pub runvil::effect::EffectAnswer::Chat::0: core::result::Result, runvil::model::CompletionError> +pub runvil::effect::EffectAnswer::Chat::0: core::result::Result, runvil::error::Error> pub runvil::effect::EffectAnswer::Dropped pub runvil::effect::EffectAnswer::Render(..) pub runvil::effect::EffectAnswer::Render::0: alloc::vec::Vec> pub runvil::effect::EffectAnswer::Timer pub runvil::effect::EffectAnswer::ToolCall(..) -pub runvil::effect::EffectAnswer::ToolCall::0: core::result::Result +pub runvil::effect::EffectAnswer::ToolCall::0: core::result::Result pub runvil::effect::EffectAnswer::Vfs(..) pub runvil::effect::EffectAnswer::Vfs::0: core::result::Result pub runvil::effect::IssuedEffect::effect: runvil::effect::Effect @@ -739,6 +708,98 @@ pub runvil::effect::ToolCallOrigin::model: core::option::Option +pub runvil::error::Error::code: runvil::error::ErrorCode +pub runvil::error::Error::facts: alloc::collections::btree::map::BTreeMap +pub runvil::error::Error::message: alloc::string::String +pub runvil::error::Error::prose: alloc::string::String +pub runvil::error::ErrorCode::AlreadyExists = 602 +pub runvil::error::ErrorCode::Anchor = 609 +pub runvil::error::ErrorCode::ApprovalRequired = 611 +pub runvil::error::ErrorCode::Backend = 621 +pub runvil::error::ErrorCode::CallDepthExceeded = 204 +pub runvil::error::ErrorCode::Conflict = 620 +pub runvil::error::ErrorCode::ContextExhausted = 414 +pub runvil::error::ErrorCode::ContextOverflow = 401 +pub runvil::error::ErrorCode::CrossMount = 617 +pub runvil::error::ErrorCode::Determinism = 622 +pub runvil::error::ErrorCode::DirectoryNotEmpty = 605 +pub runvil::error::ErrorCode::EmptyReply = 411 +pub runvil::error::ErrorCode::FenceInvalid = 103 +pub runvil::error::ErrorCode::FrontmatterInvalid = 101 +pub runvil::error::ErrorCode::Internal = 703 +pub runvil::error::ErrorCode::Interrupted = 702 +pub runvil::error::ErrorCode::InvalidParts = 413 +pub runvil::error::ErrorCode::InvalidPath = 607 +pub runvil::error::ErrorCode::InvalidRange = 608 +pub runvil::error::ErrorCode::InvalidValue = 106 +pub runvil::error::ErrorCode::Io = 618 +pub runvil::error::ErrorCode::IsADirectory = 604 +pub runvil::error::ErrorCode::ListEmptyItem = 104 +pub runvil::error::ErrorCode::LuaCompile = 202 +pub runvil::error::ErrorCode::LuaQuota = 203 +pub runvil::error::ErrorCode::LuaRuntime = 201 +pub runvil::error::ErrorCode::MalformedResponse = 410 +pub runvil::error::ErrorCode::ModelOnlyTool = 507 +pub runvil::error::ErrorCode::ModelRequired = 415 +pub runvil::error::ErrorCode::NoOutput = 803 +pub runvil::error::ErrorCode::NoStore = 616 +pub runvil::error::ErrorCode::NotADirectory = 603 +pub runvil::error::ErrorCode::NotFound = 601 +pub runvil::error::ErrorCode::NotUtf8 = 606 +pub runvil::error::ErrorCode::OutOfScopeTool = 505 +pub runvil::error::ErrorCode::OutsideRoot = 614 +pub runvil::error::ErrorCode::Overloaded = 404 +pub runvil::error::ErrorCode::PermissionDenied = 610 +pub runvil::error::ErrorCode::PluginLoadFailed = 802 +pub runvil::error::ErrorCode::QuotaExhausted = 403 +pub runvil::error::ErrorCode::RateLimited = 402 +pub runvil::error::ErrorCode::ReadOnly = 613 +pub runvil::error::ErrorCode::RecorderFailed = 801 +pub runvil::error::ErrorCode::Refused = 405 +pub runvil::error::ErrorCode::Rejected = 409 +pub runvil::error::ErrorCode::RequirementsUnmet = 701 +pub runvil::error::ErrorCode::RootProtected = 615 +pub runvil::error::ErrorCode::ScopeEnded = 612 +pub runvil::error::ErrorCode::ServerError = 408 +pub runvil::error::ErrorCode::StructureInvalid = 102 +pub runvil::error::ErrorCode::Substitution = 205 +pub runvil::error::ErrorCode::TaskCancelled = 304 +pub runvil::error::ErrorCode::TaskConsumed = 303 +pub runvil::error::ErrorCode::TaskNotOwned = 302 +pub runvil::error::ErrorCode::TasksLive = 301 +pub runvil::error::ErrorCode::Timeout = 406 +pub runvil::error::ErrorCode::ToolBackend = 502 +pub runvil::error::ErrorCode::ToolInvalidArguments = 501 +pub runvil::error::ErrorCode::ToolLoopExhausted = 508 +pub runvil::error::ErrorCode::ToolOther = 504 +pub runvil::error::ErrorCode::ToolSchemaInvalid = 509 +pub runvil::error::ErrorCode::ToolSetsInvalid = 510 +pub runvil::error::ErrorCode::ToolTransport = 503 +pub runvil::error::ErrorCode::Transport = 407 +pub runvil::error::ErrorCode::Unavailable = 412 +pub runvil::error::ErrorCode::UnboundTool = 506 +pub runvil::error::ErrorCode::Unsupported = 619 +pub runvil::error::ErrorCode::UnsupportedVersion = 105 +pub runvil::error::FactKey::Anchor +pub runvil::error::FactKey::Chain +pub runvil::error::FactKey::Count +pub runvil::error::FactKey::FinishReason +pub runvil::error::FactKey::Limit +pub runvil::error::FactKey::Name +pub runvil::error::FactKey::Path +pub runvil::error::FactKey::Reason +pub runvil::error::FactKey::Rule +pub runvil::error::FactKey::Run +pub runvil::error::FactKey::Section +pub runvil::error::FactKey::Task +pub runvil::error::FactKey::Tasks +pub runvil::error::FactKey::ValidUpTo +pub runvil::error::Location::end_column: core::num::nonzero::NonZeroU32 +pub runvil::error::Location::end_line: core::num::nonzero::NonZeroU32 +pub runvil::error::Location::file: alloc::string::String +pub runvil::error::Location::start_column: core::num::nonzero::NonZeroU32 +pub runvil::error::Location::start_line: core::num::nonzero::NonZeroU32 pub runvil::event::Event::AssistantReply { .. } pub runvil::event::Event::AssistantReply::execution: alloc::string::String pub runvil::event::Event::AssistantReply::finish_reason: core::option::Option @@ -1047,19 +1108,6 @@ pub runvil::metrics::VllmMetrics::mean_itl_ms: core::option::Option pub runvil::metrics::VllmMetrics::queue_time_ms: core::option::Option pub runvil::metrics::VllmMetrics::time_to_first_token_ms: core::option::Option pub runvil::metrics::VllmMetrics::tokens_per_second: core::option::Option -pub runvil::model::CompletionErrorKind::ContextOverflow -pub runvil::model::CompletionErrorKind::EmptyReply -pub runvil::model::CompletionErrorKind::InvalidParts -pub runvil::model::CompletionErrorKind::MalformedResponse -pub runvil::model::CompletionErrorKind::Overloaded -pub runvil::model::CompletionErrorKind::QuotaExhausted -pub runvil::model::CompletionErrorKind::RateLimited -pub runvil::model::CompletionErrorKind::Refused -pub runvil::model::CompletionErrorKind::Rejected -pub runvil::model::CompletionErrorKind::ServerError -pub runvil::model::CompletionErrorKind::Timeout -pub runvil::model::CompletionErrorKind::Transport -pub runvil::model::CompletionErrorKind::Unavailable pub runvil::model::CompletionResult::Text(..) pub runvil::model::CompletionResult::Text::0: alloc::string::String pub runvil::model::CompletionResult::ToolCalls(..) @@ -1076,8 +1124,6 @@ pub runvil::model::Role::Assistant pub runvil::model::Role::System pub runvil::model::Role::Tool pub runvil::model::Role::User -pub runvil::model::TemperatureError::NotFinite -pub runvil::model::TemperatureError::OutOfRange::value: f64 pub runvil::model::ThinkingMode::Always pub runvil::model::ThinkingMode::Never pub runvil::model::ThinkingMode::Switchable @@ -1091,10 +1137,6 @@ pub runvil::prompt::ArgType::Number pub runvil::prompt::ArgType::String pub runvil::tools::OutputTrust::Trusted pub runvil::tools::OutputTrust::Untrusted -pub runvil::tools::ToolErrorKind::Backend -pub runvil::tools::ToolErrorKind::InvalidArguments -pub runvil::tools::ToolErrorKind::Other -pub runvil::tools::ToolErrorKind::Transport pub runvil::tools::ToolInfo::describes_tools: bool pub runvil::tools::ToolInfo::description: alloc::string::String pub runvil::tools::ToolInfo::id: runvil::tools::ToolId @@ -1242,24 +1284,31 @@ pub runvil::vfs::VfsOutcome::Text::0: alloc::string::String pub runvil::vfs::VfsOutcome::Unit pub runvil::vfs::Writability::ReadOnly pub runvil::vfs::Writability::ReadWrite +pub struct runvil::ParseError { /* private fields */ } +pub struct runvil::Prompt { /* private fields */ } pub struct runvil::Run { /* private fields */ } pub struct runvil::RunConfig { /* private fields */ } pub struct runvil::RunSeed(/* private fields */) pub struct runvil::cancel::CancelHandle { /* private fields */ } pub struct runvil::cancel::Cancelled { /* private fields */ } pub struct runvil::effect::EffectId(/* private fields */) +pub struct runvil::error::Error { .. } +pub struct runvil::error::Location { .. } pub struct runvil::ids::ChainId(/* private fields */) -pub struct runvil::ids::IdError { /* private fields */ } pub struct runvil::ids::RoundId(/* private fields */) pub struct runvil::ids::TaskId(/* private fields */) pub struct runvil::model::ArgumentNames<'a> { /* private fields */ } pub struct runvil::model::ModelBinding { /* private fields */ } -pub struct runvil::model::ParseRoleError { /* private fields */ } pub struct runvil::model::Temperature(/* private fields */) pub struct runvil::packs::GlobalName { /* private fields */ } pub struct runvil::packs::PackRef { /* private fields */ } pub struct runvil::packs::Prelude { /* private fields */ } +pub struct runvil::prompt::ArgDecl { /* private fields */ } +pub struct runvil::prompt::ArgsDecl { /* private fields */ } pub struct runvil::prompt::ArgsDeclIter<'a> { /* private fields */ } +pub struct runvil::prompt::FileDecl { /* private fields */ } +pub struct runvil::prompt::Frontmatter { /* private fields */ } +pub struct runvil::prompt::RoleRequirements { /* private fields */ } pub struct runvil::timestamp::Timestamp(/* private fields */) pub struct runvil::vfs::Access { /* private fields */ } pub struct runvil::vfs::AcquireContext { /* private fields */ } diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 83ce27526..128ad5f53 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -46,17 +46,25 @@ pub mod event { pub use runvil_types::event::ReplyOrigin; } +pub mod error { + //! The universal error: its code, its text for people and for the model, + //! where it happened, and the values it names. + + pub use runvil_types::error::Error; + pub use runvil_types::error::ErrorCode; + pub use runvil_types::error::FactKey; + pub use runvil_types::error::Location; +} + pub mod ids { - //! Task, chain, and round ids, the provenance that orders a run's - //! records, and the error an invalid id, or a catalog that repeats one, - //! reports. + //! Task, chain, and round ids, and the provenance that orders a run's + //! records. pub use runvil_types::ids::AbandonReason; pub use runvil_types::ids::ChainId; pub use runvil_types::ids::Provenance; pub use runvil_types::ids::RoundId; pub use runvil_types::ids::TaskId; - pub use runvil_types::names::IdError; } pub mod model { @@ -67,19 +75,19 @@ pub mod model { pub use runvil_model_client::client::Completion; pub use runvil_model_client::client::CompletionResult; pub use runvil_model_client::client::Message; - pub use runvil_model_client::client::ParseRoleError; pub use runvil_model_client::client::RawExchange; pub use runvil_model_client::client::Role; pub use runvil_model_client::client::ToolArguments; pub use runvil_model_client::client::ToolCall; pub use runvil_model_client::client::ToolSchema; - pub use runvil_model_client::model::CompletionError; - pub use runvil_model_client::model::CompletionErrorKind; pub use runvil_model_client::model::CompletionOptions; pub use runvil_model_client::model::ModelBinding; pub use runvil_model_client::model::ModelInvocation; pub use runvil_model_client::model::Temperature; - pub use runvil_model_client::model::TemperatureError; + pub use runvil_model_client::model::completion_error; + pub use runvil_model_client::model::context_overflow; + pub use runvil_model_client::model::empty_reply; + pub use runvil_model_client::model::phrase; pub use runvil_types::models::ModelCatalog; pub use runvil_types::models::ModelId; pub use runvil_types::models::ModelInfo; @@ -93,11 +101,10 @@ pub mod tools { pub use runvil_types::tools::OutputTrust; pub use runvil_types::tools::ToolCatalog; - pub use runvil_types::tools::ToolError; - pub use runvil_types::tools::ToolErrorKind; pub use runvil_types::tools::ToolId; pub use runvil_types::tools::ToolInfo; pub use runvil_types::tools::ToolOutput; + pub use runvil_types::tools::tool_error; } pub mod packs { @@ -132,6 +139,7 @@ pub mod vfs { pub use runvil_engine::perform_vfs_op; pub use runvil_lua::VfsOp; pub use runvil_lua::VfsOutcome; + pub use runvil_lua::store_error; pub use runvil_vfs::Access; pub use runvil_vfs::AcquireContext; pub use runvil_vfs::AllowAll; diff --git a/crates/runvil/tests/suite/model.rs b/crates/runvil/tests/suite/model.rs index 847de95e8..c6db214f4 100644 --- a/crates/runvil/tests/suite/model.rs +++ b/crates/runvil/tests/suite/model.rs @@ -1,6 +1,7 @@ //! Describing models and answering their rounds. -use runvil::model::{CompletionErrorKind, ModelCatalog, ModelId, ThinkingMode}; +use runvil::error::ErrorCode; +use runvil::model::{ModelCatalog, ModelId, ThinkingMode}; use runvil::{RunErrorKind, RunOutcome}; use super::support::{Reply, Scenario, TestResult, fail, model, prompt}; @@ -43,7 +44,7 @@ fn a_catalog_finds_a_model_by_id_and_refuses_a_repeated_id_as_a_whole() -> TestR #[test] fn a_failed_model_round_fails_the_run_as_a_completion_failure() -> TestResult { - let RunOutcome::Failed { kind, .. } = run_asking(fail(CompletionErrorKind::Overloaded))? else { + let RunOutcome::Failed { kind, .. } = run_asking(fail(ErrorCode::Overloaded))? else { return Err("a failed round fails the run".into()); }; assert_eq!(kind, RunErrorKind::Completion); diff --git a/crates/runvil/tests/suite/support.rs b/crates/runvil/tests/suite/support.rs index 6cdb21dcf..bcdec57cf 100644 --- a/crates/runvil/tests/suite/support.rs +++ b/crates/runvil/tests/suite/support.rs @@ -7,11 +7,11 @@ use std::num::NonZeroU32; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use runvil::error::ErrorCode; use runvil::model::{ - CompletionError, CompletionErrorKind, CompletionResult, ModelId, ModelInfo, ThinkingMode, - ToolCall, + CompletionResult, ModelId, ModelInfo, ThinkingMode, ToolCall, completion_error, phrase, }; -use runvil::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolId, ToolInfo, ToolOutput, tool_error}; use serde_json::Value; mod scenario; @@ -20,7 +20,7 @@ pub(super) use scenario::{Built, Scenario, Trace}; pub(super) type TestResult = Result>; -type Handler = Box Result>; +type Handler = Box Result>; /// A tool a scenario registers under its full id, not its wire name. pub(super) struct Tool { @@ -78,7 +78,7 @@ fn push_trimmed(source: &mut String, text: &str) { pub(super) enum Reply { Completion(CompletionResult), - Failure(CompletionError), + Failure(runvil::error::Error), } pub(super) fn text(reply: &str) -> Reply { @@ -91,8 +91,8 @@ pub(super) fn calls(name: &str, args: Value) -> TestResult { Ok(Reply::Completion(CompletionResult::ToolCalls(vec![call]))) } -pub(super) fn fail(kind: CompletionErrorKind) -> Reply { - Reply::Failure(CompletionError::new(kind, kind.phrase())) +pub(super) fn fail(code: ErrorCode) -> Reply { + Reply::Failure(completion_error(code, phrase(code))) } pub(super) fn shout() -> Tool { @@ -100,9 +100,10 @@ pub(super) fn shout() -> Tool { id: "example/text/shout", handler: Box::new(|args| match args.get("text").and_then(Value::as_str) { Some(text) => Ok(ToolOutput::trusted(text.to_uppercase())), - None => Err(ToolError::message(format!( - "shout needs a string `text` argument; got {args}" - ))), + None => Err(tool_error( + ErrorCode::ToolOther, + format!("shout needs a string `text` argument; got {args}"), + )), }), } } diff --git a/crates/workshop/agents/src/input-tests.rs b/crates/workshop/agents/src/input-tests.rs index a09ec692b..08d907a8f 100644 --- a/crates/workshop/agents/src/input-tests.rs +++ b/crates/workshop/agents/src/input-tests.rs @@ -11,7 +11,7 @@ use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use plugin_user_input::{INPUT_BROKER, InputBroker}; use runvil::effect::Round; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, }; use tokio::sync::broadcast; @@ -225,8 +225,8 @@ async fn a_registry_cancel_fails_the_broker_call_and_emits_cancelled() { .expect_err("a cancelled wait fails the broker call"); assert_eq!(error.to_string(), "the user-input wait was cancelled"); assert_eq!( - error.kind(), - runvil::tools::ToolErrorKind::Backend, + error.code, + runvil::error::ErrorCode::ToolBackend, "the ask call fails as a backend failure" ); let frame = socket.recv().await.expect("the cancellation frame arrives"); @@ -242,7 +242,7 @@ async fn a_registry_cancel_fails_the_broker_call_and_emits_cancelled() { struct NoChat; impl InferenceBroker for NoChat { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -253,7 +253,7 @@ impl InferenceBroker for NoChat { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { unreachable!("the prompt makes no model round") } } diff --git a/crates/workshop/agents/src/input-tool.rs b/crates/workshop/agents/src/input-tool.rs index eba94cf1f..7ed00a133 100644 --- a/crates/workshop/agents/src/input-tool.rs +++ b/crates/workshop/agents/src/input-tool.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use async_trait::async_trait; use plugin_user_input::InputBroker; -use runvil::tools::{ToolError, ToolErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use tokio::sync::broadcast; use super::{WaitFrame, WaitRegistry}; @@ -84,9 +85,10 @@ impl InputBroker for SessionInputBroker { /// cancelled out of the registry - the drop guard removes the wait and /// pushes [`WaitFrame::Cancelled`], so no path leaks a wait or a stale /// prompt. A wait cancelled out of the registry resolves here as the - /// broker's failure policy: a [`ToolError`] of kind `Backend` the run - /// raises at the Lua call site. - async fn wait(&self) -> Result { + /// broker's failure policy: an [`Error`] with the code + /// [`ToolBackend`](ErrorCode::ToolBackend) the run raises at the Lua + /// call site. + async fn wait(&self) -> Result { let (token, receiver) = self.registry.create(); let mut guard = WaitGuard { registry: Arc::clone(&self.registry), @@ -109,8 +111,10 @@ impl InputBroker for SessionInputBroker { // of the registry. The still-armed guard pushes the cancelled // frame on scope exit, so this path clears the client's prompt // too. - Err(_) => Err(ToolError::message("the user-input wait was cancelled") - .with_kind(ToolErrorKind::Backend)), + Err(_) => Err(tool_error( + ErrorCode::ToolBackend, + "the user-input wait was cancelled", + )), } } } diff --git a/crates/workshop/agents/src/tee-tests.rs b/crates/workshop/agents/src/tee-tests.rs index f530293d3..ed2c030b3 100644 --- a/crates/workshop/agents/src/tee-tests.rs +++ b/crates/workshop/agents/src/tee-tests.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex}; use harness::record::{ - MemoryRecorder, Record, RecordKind, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, - RunRecorder, RunSeed, + MemoryRecorder, Record, RecordKind, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, + RunSeed, recorder_error, }; use runvil::event::Event; use runvil::ids::Provenance; @@ -68,7 +68,7 @@ impl RunRecorder for Watching { .unwrap() .push(self.conversation.transcript(0).len()); if self.refuse { - return Box::pin(async { Err(RecorderError::new("the log is full")) }); + return Box::pin(async { Err(recorder_error("the log is full")) }); } self.inner.append(run, record) } diff --git a/crates/workshop/agents/tests/it/support.rs b/crates/workshop/agents/tests/it/support.rs index 29b7a65d3..9ea6a41d6 100644 --- a/crates/workshop/agents/tests/it/support.rs +++ b/crates/workshop/agents/tests/it/support.rs @@ -16,9 +16,10 @@ use harness::record::MemoryRecorder; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use workshop_agents::{Conversation, TokioTimer}; @@ -26,7 +27,7 @@ use workshop_agents::{Conversation, TokioTimer}; struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -37,9 +38,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/workshop/desktop/build.rs b/crates/workshop/desktop/build.rs index dbd381493..74ce82a71 100644 --- a/crates/workshop/desktop/build.rs +++ b/crates/workshop/desktop/build.rs @@ -57,6 +57,10 @@ fn install_gateway_sidecar() -> Result<(), Box> { .join(format!("runvil-gateway-{target}{exe_suffix}")); println!("cargo:rerun-if-env-changed={GATEWAY_SIDECAR_VARIABLE}"); + // The marker is written only by `tools/fetch-gateway-sidecar.mjs`, never + // here, so watching it cannot loop. Watching it means re-staging a + // release over a stub re-runs this script and the warning below clears. + println!("cargo:rerun-if-changed={}", pin_marker(&sidecar).display()); if let Some(named) = env::var_os(GATEWAY_SIDECAR_VARIABLE) { let named = PathBuf::from(named); if named.as_os_str().is_empty() { @@ -86,6 +90,19 @@ fn install_gateway_sidecar() -> Result<(), Box> { // `target//runvil-gateway` from an earlier day must not // silently replace a downloaded release. if sidecar_is_pinned(&sidecar) { + if sidecar_is_stub(&sidecar) { + // CI stages a stub for compile-only jobs, so a build that copies + // one is expected there. A person building locally gets the same + // warning, which is the last chance to notice before an app that + // cannot start exists on disk. + println!( + "cargo:warning={} is a compile-only stub Gateway and cannot run; \ + run `node tools/fetch-gateway-sidecar.mjs --target {}` and rebuild for a \ + Gateway that starts", + sidecar.display(), + target + ); + } return Ok(()); } if !built.is_file() || sidecar_is_current(&built, &sidecar) { @@ -119,9 +136,26 @@ fn profile_dir_from_out_dir(out_dir: &Path) -> Option { /// Whether `tools/fetch-gateway-sidecar.mjs` staged the sidecar and said /// so: the file is then the authority, whatever a workspace build wrote. fn sidecar_is_pinned(sidecar: &Path) -> bool { + pin_marker(sidecar).is_file() +} + +/// The marker beside the sidecar: `.pinned`. +fn pin_marker(sidecar: &Path) -> PathBuf { let mut marker = sidecar.as_os_str().to_owned(); marker.push(".pinned"); - Path::new(&marker).is_file() + PathBuf::from(marker) +} + +/// Whether the marker says the staged sidecar is the compile-only stub +/// rather than the pinned release. The marker is the fetch script's JSON, +/// so a substring check keeps this script free of a JSON dependency. A +/// malformed marker reads as unknown, and unknown is not a stub: the app's +/// own boot check still refuses to run one. +fn sidecar_is_stub(sidecar: &Path) -> bool { + let Ok(text) = fs::read_to_string(pin_marker(sidecar)) else { + return false; + }; + text.contains("\"kind\": \"stub\"") || text.contains("\"kind\":\"stub\"") } /// The sidecar is current when it exists with the same length and a diff --git a/crates/workshop/desktop/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs index cd46247f5..8a0c4f14b 100644 --- a/crates/workshop/desktop/src/gateway/boot.rs +++ b/crates/workshop/desktop/src/gateway/boot.rs @@ -88,6 +88,9 @@ pub(crate) fn ensure_gateway(config: &Config) -> anyhow::Result Ok(GatewayAttachment::Config), GatewayPlan::Fail => Err(no_gateway_error()), GatewayPlan::Launch(exe) => { + if sibling_is_placeholder(&exe) { + return Err(placeholder_gateway_error(&exe)); + } launch_and_attach_cancellable(&run_dir, &exe, &CancellationToken::new()) .and_then(validated_recovery_attachment) .context("launch the sidecar gateway") @@ -145,6 +148,41 @@ pub(super) fn sibling_gateway(exe_dir: &Path) -> Option { candidate.is_file().then_some(candidate) } +/// The text `tools/fetch-gateway-sidecar.mjs --stub` writes, and the only +/// content a file may hold and still be called a Gateway. Checked before a +/// launch because Windows answers a text file with "This version of %1 is +/// not compatible with the version of Windows you're running" and no clue +/// which file was at fault. +const STUB_PREFIX: &str = "This is a placeholder staged by"; + +/// Whether the sibling Gateway is a compile-only stub rather than an +/// executable. A real Gateway starts with `MZ` on Windows and something +/// other than this text everywhere else, so a byte-prefix compare is the +/// whole test. +pub(super) fn sibling_is_placeholder(exe: &Path) -> bool { + use std::io::Read as _; + let Ok(mut file) = std::fs::File::open(exe) else { + return false; + }; + let mut prefix = vec![0_u8; STUB_PREFIX.len()]; + let Ok(read) = file.read(&mut prefix) else { + return false; + }; + prefix.truncate(read); + prefix == STUB_PREFIX.as_bytes() +} + +/// The failure for a stub sidecar, naming the file and the one command that +/// replaces it with a runnable Gateway. +pub(super) fn placeholder_gateway_error(exe: &Path) -> anyhow::Error { + anyhow::anyhow!( + "the gateway beside this program is a compile-only stub, not an executable: {}; \ + run `node tools/fetch-gateway-sidecar.mjs` from the repository root to stage the \ + pinned Gateway release, then rebuild", + exe.display() + ) +} + /// Builds the loud boot failure naming both supported remedies. pub(super) fn no_gateway_error() -> anyhow::Error { anyhow::anyhow!( diff --git a/crates/workshop/desktop/src/gateway/tests/boot.rs b/crates/workshop/desktop/src/gateway/tests/boot.rs index 246bd302b..d2f5d9585 100644 --- a/crates/workshop/desktop/src/gateway/tests/boot.rs +++ b/crates/workshop/desktop/src/gateway/tests/boot.rs @@ -13,7 +13,8 @@ use super::{ #[cfg(windows)] use crate::gateway::boot::spawn_detached_windows_with; use crate::gateway::boot::{ - GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, plan_gateway, sibling_gateway, spawn_detached, + GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, placeholder_gateway_error, plan_gateway, + sibling_gateway, sibling_is_placeholder, spawn_detached, }; use crate::gateway::identity::GatewayAttachment; use crate::gateway::supervisor::RECOVERY_POLL_INTERVAL; @@ -265,6 +266,68 @@ fn the_no_gateway_error_names_both_remedies() { ); } +/// The stub `tools/fetch-gateway-sidecar.mjs --stub` writes, which CI stages +/// so compile-only jobs need no Gateway release. +const STUB_TEXT: &str = "This is a placeholder staged by tools/fetch-gateway-sidecar.mjs --stub.\n\ + It stands in for the Gateway sidecar so compile-only jobs need no release.\n"; + +fn sibling_with(content: &[u8]) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let exe = dir.path().join(GATEWAY_EXE_NAME); + std::fs::write(&exe, content).expect("write the sibling"); + (dir, exe) +} + +#[test] +fn a_stub_sibling_is_recognized_before_a_launch_attempt() { + let (_dir, exe) = sibling_with(STUB_TEXT.as_bytes()); + assert!( + sibling_is_placeholder(&exe), + "the compile-only stub must be caught before spawning it" + ); +} + +#[test] +fn a_real_executable_sibling_is_not_a_placeholder() { + let (_dir, exe) = sibling_with(b"MZ\x90\x00\x03\x00\x00\x00"); + assert!( + !sibling_is_placeholder(&exe), + "an executable is left to the spawn to judge" + ); +} + +#[test] +fn an_empty_sibling_is_not_treated_as_a_placeholder() { + let (_dir, exe) = sibling_with(b""); + assert!( + !sibling_is_placeholder(&exe), + "an unreadable prefix must not read as a stub" + ); +} + +#[test] +fn a_missing_sibling_is_not_treated_as_a_placeholder() { + let dir = tempfile::TempDir::new().expect("tempdir"); + assert!( + !sibling_is_placeholder(&dir.path().join(GATEWAY_EXE_NAME)), + "a file that is not there cannot be a stub" + ); +} + +#[test] +fn the_placeholder_error_names_the_file_and_the_fetch_command() { + let exe = PathBuf::from("/install/runvil-gateway"); + let message = placeholder_gateway_error(&exe).to_string(); + assert!( + message.contains("runvil-gateway"), + "the error names the file that is not runnable: {message}" + ); + assert!( + message.contains("node tools/fetch-gateway-sidecar.mjs"), + "the error names the one command that stages a real Gateway: {message}" + ); +} + #[test] fn matching_boot_publication_survives_closure_and_server_teardown() { let mut launched = super::validated_gateway("launched-key"); diff --git a/crates/workshop/run-log/src/recorder.rs b/crates/workshop/run-log/src/recorder.rs index 701477b7c..c0f19cd57 100644 --- a/crates/workshop/run-log/src/recorder.rs +++ b/crates/workshop/run-log/src/recorder.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use harness::record::{ - Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, + Record, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error, }; use tokio::sync::Mutex; @@ -24,8 +24,8 @@ const RUNS_FILE: &str = "runs.db"; /// /// The Harness writes through the handle [`for_agent`](Self::for_agent) /// returns, which names the launched agent in the row of each run it -/// begins. A failed write comes back as a [`RecorderError`] whose cause is -/// the log's [`LogError`]. The Harness stops the run that hit it. +/// begins. A failed write comes back as a [`recorder_error`] whose message +/// holds the log's [`LogError`]. The Harness stops the run that hit it. #[derive(Debug)] pub struct TursoRecorder { /// The directory the database file sits in. @@ -99,10 +99,10 @@ impl RunRecorder for AgentRecorder { .recorder .open(&mut slot) .await - .map_err(RecorderError::new)?; + .map_err(recorder_error)?; log.begin_run(meta, &self.agent) .await - .map_err(RecorderError::new) + .map_err(recorder_error) }) } @@ -113,11 +113,11 @@ impl RunRecorder for AgentRecorder { .recorder .open(&mut slot) .await - .map_err(RecorderError::new)?; + .map_err(recorder_error)?; log.append(run, record) .await .map(drop) - .map_err(RecorderError::new) + .map_err(recorder_error) }) } @@ -128,8 +128,8 @@ impl RunRecorder for AgentRecorder { .recorder .open(&mut slot) .await - .map_err(RecorderError::new)?; - log.end_run(run, outcome).await.map_err(RecorderError::new) + .map_err(recorder_error)?; + log.end_run(run, outcome).await.map_err(recorder_error) }) } } diff --git a/crates/workshop/run-log/tests/it/fidelity.rs b/crates/workshop/run-log/tests/it/fidelity.rs index a7abe1222..776f6c421 100644 --- a/crates/workshop/run-log/tests/it/fidelity.rs +++ b/crates/workshop/run-log/tests/it/fidelity.rs @@ -6,8 +6,9 @@ //! order changes, would make a replayed run differ from the run that ran. use harness::record::RunSeed; +use runvil::error::Error; use runvil::event::{Event, ReplyOrigin}; -use runvil::ids::{IdError, Provenance, RoundId, TaskId}; +use runvil::ids::{Provenance, RoundId, TaskId}; use runvil::metrics::{CallMetrics, ClientTiming, LlamaTimings, Usage, VllmMetrics}; use serde_json::{Map, Value, json}; use workshop_run_log::{Record, RecordFilter, RecordKind, RunLog, RunMeta}; @@ -34,7 +35,7 @@ fn meta() -> RunMeta { } /// The replay key every fidelity event carries. -fn provenance() -> Result { +fn provenance() -> Result { Ok(Provenance::new("0".parse::()?, 0)) } diff --git a/crates/workshop/run-log/tests/it/recorder.rs b/crates/workshop/run-log/tests/it/recorder.rs index 78793781b..fccec6261 100644 --- a/crates/workshop/run-log/tests/it/recorder.rs +++ b/crates/workshop/run-log/tests/it/recorder.rs @@ -8,14 +8,12 @@ reason = "test helpers fail by panicking with the failure shown" )] -use std::error::Error as _; use std::fs; use std::path::Path; use std::sync::Arc; -use harness::record::{ - Record, RecordKind, RecorderError, RunId, RunMeta, RunOutcome, RunRecorder, RunSeed, -}; +use harness::record::{Record, RecordKind, RunId, RunMeta, RunOutcome, RunRecorder, RunSeed}; +use runvil::error::{Error, ErrorCode}; use serde_json::json; use workshop_run_log::{LogError, RecordFilter, RunLog, TursoRecorder}; @@ -56,15 +54,13 @@ async fn read_back(path: &Path) -> RunLog { RunLog::open(path).await.unwrap() } -/// The log failure behind a recorder error. -fn log_failure(error: &RecorderError) -> &LogError { - let Some(cause) = error.source() else { - panic!("a recorder error carries its cause as source()"); - }; - let Some(failure) = cause.downcast_ref::() else { - panic!("the cause is the run log's own error: {cause}"); +/// The text of the log failure behind a recorder error. +fn log_failure(error: &Error) -> &str { + assert_eq!(error.code, ErrorCode::RecorderFailed); + let Some(cause) = error.message.strip_prefix("the run recorder failed: ") else { + panic!("a recorder error carries its cause in its message: {error:?}"); }; - failure + cause } #[tokio::test] @@ -167,7 +163,7 @@ async fn a_failed_open_is_retried_by_the_next_call() { let error = recorder.begin_run(meta("session-1")).await.unwrap_err(); assert!( - matches!(log_failure(&error), LogError::Io { .. }), + log_failure(&error).starts_with("run log file: "), "the directory cannot be created: {error:?}" ); @@ -238,7 +234,7 @@ async fn a_write_the_log_refuses_reaches_the_caller_as_a_recorder_error() { .await .unwrap_err(); assert!( - matches!(log_failure(&error), LogError::UnknownRun(run) if *run == unknown), + log_failure(&error) == LogError::UnknownRun(unknown).to_string(), "{error:?}" ); @@ -249,7 +245,7 @@ async fn a_write_the_log_refuses_reaches_the_caller_as_a_recorder_error() { .await .unwrap_err(); assert!( - matches!(log_failure(&error), LogError::RunEnded(ended) if *ended == run), + log_failure(&error) == LogError::RunEnded(run).to_string(), "{error:?}" ); } diff --git a/crates/workshop/server/src/agents/broker-tests.rs b/crates/workshop/server/src/agents/broker-tests.rs index ce1e7488d..513f876e8 100644 --- a/crates/workshop/server/src/agents/broker-tests.rs +++ b/crates/workshop/server/src/agents/broker-tests.rs @@ -17,12 +17,13 @@ use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use harness::InferenceBroker; -use harness_gateway_client::{CompletionError, CompletionErrorKind, StreamDelta}; +use harness_gateway_client::StreamDelta; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - CompletionOptions, CompletionResult, Message, ModelBinding, ModelId, ModelInvocation, + CompletionOptions, CompletionResult, Message, ModelBinding, ModelId, ModelInvocation, phrase, }; use serde_json::json; use workshop_agents::{Conversations, DeltaKind}; @@ -112,7 +113,7 @@ fn binding() -> ModelBinding { } /// Runs one round on `broker` and returns its reply text. -async fn round(broker: &WorkshopBroker) -> Result { +async fn round(broker: &WorkshopBroker) -> Result { let completion = broker .chat( binding(), @@ -130,9 +131,7 @@ async fn round(broker: &WorkshopBroker) -> Result { /// Runs one streaming round on `broker` and returns its reply text beside /// every piece its callback was handed. -async fn streamed_round( - broker: &WorkshopBroker, -) -> (Result, Vec) { +async fn streamed_round(broker: &WorkshopBroker) -> (Result, Vec) { let kept: Arc>> = Arc::default(); let sink = Arc::clone(&kept); let completion = broker @@ -157,7 +156,7 @@ async fn streamed_round( } /// Lists the models on `broker` and returns their names. -async fn listed(broker: &WorkshopBroker) -> Result, CompletionError> { +async fn listed(broker: &WorkshopBroker) -> Result, Error> { let catalog = broker.models().await?; Ok(catalog .models() @@ -166,14 +165,14 @@ async fn listed(broker: &WorkshopBroker) -> Result, CompletionError> .collect()) } -/// Asserts `error` is `Unavailable` with exactly the kind's fixed phrase, +/// Asserts `error` is `Unavailable` with exactly the code's fixed phrase, /// so no URL or key rides in its message. -fn assert_unavailable(error: &CompletionError) { - let kind = CompletionErrorKind::Unavailable; - assert_eq!(error.kind(), kind); +fn assert_unavailable(error: &Error) { + let code = ErrorCode::Unavailable; + assert_eq!(error.code, code); assert_eq!( error.to_string(), - kind.phrase(), + phrase(code), "the message is the fixed phrase alone" ); } diff --git a/crates/workshop/server/src/agents/broker.rs b/crates/workshop/server/src/agents/broker.rs index a7ad88989..7ab7dded9 100644 --- a/crates/workshop/server/src/agents/broker.rs +++ b/crates/workshop/server/src/agents/broker.rs @@ -19,11 +19,13 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex, PoisonError}; use harness::{BoxFuture, InferenceBroker}; -use harness_gateway_client::{CompletionError, CompletionErrorKind, GatewayBroker, StreamDelta}; +use harness_gateway_client::{GatewayBroker, StreamDelta}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::event::ReplyOrigin; use runvil::model::{ Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use workshop_agents::{Conversation, DeltaKind}; use workshop_menu::{CatalogBus, MenuHandles}; @@ -54,11 +56,11 @@ impl WorkshopBroker { /// The Gateway broker for the current generation, built on the first /// call under it. No usable gateway fails as `Unavailable` with the - /// kind's fixed phrase, so no URL or key reaches the message. - fn current(&self) -> Result { - let unavailable = CompletionErrorKind::Unavailable; + /// code's fixed phrase, so no URL or key reaches the message. + fn current(&self) -> Result { + let unavailable = ErrorCode::Unavailable; let gateway = usable_gateway(&self.registry) - .ok_or_else(|| CompletionError::new(unavailable, unavailable.phrase()))?; + .ok_or_else(|| completion_error(unavailable, phrase(unavailable)))?; // A poisoned lock holds a pair written whole by one store. let mut cached = self.cached.lock().unwrap_or_else(PoisonError::into_inner); if let Some((generation, broker)) = cached.as_ref() @@ -124,7 +126,7 @@ impl WorkshopBroker { tools: Vec, options: CompletionOptions, on_piece: Arc, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { match self.current() { Ok(broker) => { let options = self.routed(&binding, options); @@ -136,7 +138,7 @@ impl WorkshopBroker { } impl InferenceBroker for WorkshopBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let broker = self.clone(); Box::pin(async move { broker.chat_model_ready().await; @@ -151,7 +153,7 @@ impl InferenceBroker for WorkshopBroker { tools: Vec, options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { match self.current() { Ok(broker) => { let options = self.routed(&binding, options); @@ -198,7 +200,7 @@ impl RunBroker { } impl InferenceBroker for RunBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { self.inner.models() } @@ -209,7 +211,7 @@ impl InferenceBroker for RunBroker { tools: Vec, options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { // Only a section's own round has a live reader; a nested // `models.infer` round is read whole from its answer. if round.origin != ReplyOrigin::Chat { diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index 0373bce34..59b88648b 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -18,11 +18,12 @@ use harness::pack::{HostContext, HostServices}; use harness::record::{MemoryRecorder, RunErrorKind, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; -use harness_gateway_client::{CompletionError, CompletionErrorKind}; use plugin_web::SEARCH_PROVIDER; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use runvil::tools::ToolId; use runvil_plugin::{ServiceKey, TOKIO_RUNTIME}; @@ -51,7 +52,7 @@ fn requiring(pack: &str) -> String { struct OfflineBroker; impl InferenceBroker for OfflineBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -62,9 +63,9 @@ impl InferenceBroker for OfflineBroker { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } }