diff --git a/AGENTS.md b/AGENTS.md index 233b09ec9..bc52ba28b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - A Cargo feature gates a real constraint such as a toolchain requirement or heavy native build. It does not describe product shape. Feature-disabled builds must not leak optional types into core paths. - Runtime and serve paths never compile native dependencies or invoke build tools. - Unsafe code stays in its explicitly owned boundary. +- Engine and Harness crates report every failure as the universal `Error` in `runvil-types`, which the `runvil` facade re-exports. A new kind of failure is a new `ErrorCode` variant in its hundreds family, with a number never used before, plus a `FactKey` variant only when no existing key names the value. They add no other error enum or struct, no `thiserror` derive, and no other `impl std::error::Error`; a foreign cause joins the message through `Error::with_cause`. `VfsError` and `ParsePathReasonError` in `runvil-vfs` are the only exceptions, and test code may define a throwaway error type to exercise `with_cause`. - Comments explain a non-obvious constraint, ordering requirement, or workaround. Every platform or external-bug workaround cites its upstream issue URL in the explanatory comment. - JSON that reaches a recorder or a replay comparison round-trips exactly - `to_value`, `to_string`, `from_str` yield an identical value, object keys stay canonical (sorted), numbers must be finite. Exact parsing (`float_roundtrip`) carries that guarantee; a value derived and then logged is additionally rounded to its meaningful precision at the source. diff --git a/Cargo.lock b/Cargo.lock index 08a967d28..80ace64a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" dependencies = [ "async-task", "concurrent-queue", @@ -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]] @@ -4756,7 +4754,6 @@ dependencies = [ "runvil-vfs", "serde", "serde_json", - "thiserror 2.0.19", ] [[package]] @@ -4771,7 +4768,6 @@ dependencies = [ "runvil-vfs", "serde", "serde_json", - "thiserror 2.0.19", ] [[package]] @@ -4782,7 +4778,6 @@ dependencies = [ "runvil-types", "serde", "serde_json", - "thiserror 2.0.19", ] [[package]] @@ -4793,7 +4788,6 @@ dependencies = [ "runvil-types", "runvil-vfs", "serde_json", - "thiserror 2.0.19", ] [[package]] @@ -4807,7 +4801,6 @@ dependencies = [ "runvil-types", "serde", "serde_yaml_ng", - "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/request-tests.rs b/crates/harness-gateway-client/src/wire/request-tests.rs index d5e09d91a..f1a730a99 100644 --- a/crates/harness-gateway-client/src/wire/request-tests.rs +++ b/crates/harness-gateway-client/src/wire/request-tests.rs @@ -53,7 +53,7 @@ fn tool_round(offered: &[Offered]) -> Round { models.loop()\nreturn messages[#messages].content\n```\n" ); let first_wire_name = format!("example_test_{}", offered[0].0); - let (parsed, _parse_events) = Prompt::parse(&source, "round"); + let (parsed, _parse_events) = Prompt::parse(&source, "prompt", "round"); let prompt = parsed.expect("the round prompt parses"); let tools: Vec = offered .iter() 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/Cargo.toml b/crates/harness-internal/runner/Cargo.toml index 8389e56aa..e2b3d104a 100644 --- a/crates/harness-internal/runner/Cargo.toml +++ b/crates/harness-internal/runner/Cargo.toml @@ -26,13 +26,14 @@ serde_json.workspace = true # The prompt's content hash for the run row, so a transcript can be # matched to the exact text that produced it. sha2.workspace = true -thiserror.workspace = true tracing.workspace = true [dev-dependencies] # The scripted Packs' tools and input brokers are `async_trait` # traits. async-trait.workspace = true +# The `display_chain` tests derive the source chains they render. +thiserror.workspace = true # The suites drive runs on tokio's test runtime, pace performers with its # clock, and gate them with its semaphore and channels. tokio = { workspace = true, features = ["macros", "rt", "sync", "test-util", "time"] } 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..f3d49ac6d 100644 --- a/crates/harness-internal/runner/src/environment-tests.rs +++ b/crates/harness-internal/runner/src/environment-tests.rs @@ -1,11 +1,13 @@ -//! Tests for the Host snapshot and for model resolution with nothing to -//! resolve. +//! Tests for the Host snapshot and for model resolution: nothing to +//! resolve, a model list the broker cannot fetch, and a selection the list +//! lacks. use runvil::effect::Round; use runvil::model::{ - Completion, CompletionErrorKind, CompletionOptions, Message, ModelBinding, ModelCatalog, - ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; +use serde_json::json; use super::*; use crate::performers::BoxFuture; @@ -14,7 +16,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 +27,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))) }) } } @@ -57,3 +59,62 @@ async fn no_selection_and_an_empty_broker_catalog_bind_no_model() { .expect("an empty list is not a failure"); assert!(model.is_none()); } + +/// Fails its model list as `Transport`; a round is never made. +struct UnlistedBroker; + +impl InferenceBroker for UnlistedBroker { + fn models(&self) -> BoxFuture> { + let code = ErrorCode::Transport; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) + } + + fn chat( + &self, + binding: ModelBinding, + messages: Vec, + tools: Vec, + options: CompletionOptions, + round: Round, + ) -> BoxFuture, Error>> { + EmptyBroker.chat(binding, messages, tools, options, round) + } +} + +#[tokio::test] +async fn a_model_list_the_broker_cannot_fetch_keeps_the_brokers_failure() { + let error = current_model(&HostSnapshot::default(), &UnlistedBroker) + .await + .expect_err("a failed listing fails the lookup"); + let phrase = phrase(ErrorCode::Transport); + assert_eq!(error.code, ErrorCode::Transport); + assert_eq!( + error.message, + format!("the model catalog could not be fetched: {phrase}") + ); + assert_eq!(error.prose, phrase, "the broker's prose stays"); + assert_eq!(model_kind(&error), RunErrorKind::Completion); +} + +#[tokio::test] +async fn a_selection_the_list_lacks_is_an_unmet_requirement_naming_the_selection() { + let host = HostSnapshot { + selected_model: Some("absent".to_owned()), + ..HostSnapshot::default() + }; + let error = current_model(&host, &EmptyBroker) + .await + .expect_err("an absent selection fails the lookup"); + assert_eq!(error.code, ErrorCode::RequirementsUnmet); + assert_eq!( + error.message, + "the selected model `absent` is absent from the fetched catalog" + ); + assert_eq!(error.prose, ErrorCode::RequirementsUnmet.prose()); + assert_eq!(error.facts.get(&FactKey::Name), Some(&json!("absent"))); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&json!("model_absent")) + ); + assert_eq!(model_kind(&error), RunErrorKind::RequirementsUnmet); +} diff --git a/crates/harness-internal/runner/src/environment.rs b/crates/harness-internal/runner/src/environment.rs index bfe7df895..413b07819 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, ErrorCode, FactKey}; +use runvil::model::{ModelId, ModelInfo}; use crate::performers::InferenceBroker; use crate::recorder::RunErrorKind; @@ -42,31 +43,14 @@ impl HostSnapshot { } } -/// The reason a run failed to resolve its model at launch. -/// -/// Each variant ends the recorded run as failed, with the error's cause -/// chain as the message. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -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), - /// The selected id is absent from the fetched catalog. - #[error("the selected model `{0}` is absent from the fetched catalog")] - SelectionAbsent(String), -} - -impl CurrentModelError { - /// The Engine's kind for the run this failure ends: a model list the - /// broker cannot fetch is a completion failure, and a selection the - /// list lacks is an unmet requirement. - pub(crate) fn kind(&self) -> RunErrorKind { - match self { - CurrentModelError::CatalogFetchFailed(_) => RunErrorKind::Completion, - CurrentModelError::SelectionAbsent(_) => RunErrorKind::RequirementsUnmet, - } +/// The Engine's kind for the run a failure of [`current_model`] ends: a +/// model list the broker cannot fetch is a completion failure, and a +/// selection the list lacks is an unmet requirement. +pub(crate) fn model_kind(error: &Error) -> RunErrorKind { + if error.code == ErrorCode::RequirementsUnmet { + RunErrorKind::RequirementsUnmet + } else { + RunErrorKind::Completion } } @@ -77,21 +61,23 @@ impl CurrentModelError { /// The model is `None` only when there is no selection and the broker /// lists no model, or the selected id is not representable; the prompt's /// declared roles then stay unbound. A failed listing or a selection -/// absent from the list is a reported [`CurrentModelError`], never a -/// fabricated fallback model info. +/// absent from the list is a reported failure, never a fabricated +/// fallback model info. The recorded run ends as failed, with the +/// failure's message. /// /// # Errors -/// Returns [`CurrentModelError::CatalogFetchFailed`] when the broker's -/// model list cannot be fetched and [`CurrentModelError::SelectionAbsent`] -/// when the selected id is not in it. +/// Returns the broker's own failure, its message after `the model catalog +/// could not be fetched: `, when the model list cannot be fetched, and +/// `RequirementsUnmet` with the selected id as the `Name` fact and the +/// `Reason` fact `model_absent` when the selected id is not in it. pub async fn current_model( host: &HostSnapshot, broker: &dyn InferenceBroker, -) -> Result, CurrentModelError> { - let catalog = broker - .models() - .await - .map_err(CurrentModelError::CatalogFetchFailed)?; +) -> Result, Error> { + let catalog = broker.models().await.map_err(|mut error| { + error.message = format!("the model catalog could not be fetched: {}", error.message); + error + })?; let Some(selected) = host.selected_model.clone() else { return Ok(catalog.models().first().cloned()); }; @@ -102,11 +88,17 @@ pub async fn current_model( return Ok(None); } }; - catalog - .get(&id) - .cloned() - .map(Some) - .ok_or(CurrentModelError::SelectionAbsent(selected)) + catalog.get(&id).cloned().map(Some).ok_or_else(|| { + let code = ErrorCode::RequirementsUnmet; + let mut error = Error::new( + code, + format!("the selected model `{selected}` is absent from the fetched catalog"), + code.prose(), + ); + error.facts.insert(FactKey::Name, selected.into()); + error.facts.insert(FactKey::Reason, "model_absent".into()); + error + }) } #[cfg(test)] diff --git a/crates/harness-internal/runner/src/files-tests.rs b/crates/harness-internal/runner/src/files-tests.rs new file mode 100644 index 000000000..b7b0dc016 --- /dev/null +++ b/crates/harness-internal/runner/src/files-tests.rs @@ -0,0 +1,94 @@ +//! The report's output: a completed run's declared file, and the failure +//! that replaces it when the run did not complete, the prompt declares no +//! output, the run left no file, or the store refuses the read. + +use runvil::error::{ErrorCode, FactKey}; +use runvil::vfs::{Origin, VfsRef}; +use serde_json::json; + +use super::report_output; +use crate::recorder::{RunErrorKind, RunOutcome}; + +fn completed() -> RunOutcome { + RunOutcome::Completed { + final_text: String::new(), + } +} + +/// A default handle whose store holds `contents` at `path`. +fn seeded(path: &str, contents: &str) -> VfsRef { + let vfs = VfsRef::default(); + vfs.acquire_store(Origin::new("files test")) + .expect("the default handle declares a store") + .write(path, contents.as_bytes()) + .expect("the seed is written"); + vfs +} + +#[test] +fn a_completed_run_reports_its_declared_output_file() { + let vfs = seeded("report.md", "done"); + assert_eq!( + report_output(&vfs, Some("report.md"), &completed()), + Ok("done".to_owned()) + ); +} + +#[test] +fn a_run_that_did_not_complete_reports_no_output() { + let failed = RunOutcome::Failed { + kind: RunErrorKind::Internal, + message: String::new(), + }; + for outcome in [RunOutcome::Cancelled, failed] { + let error = report_output(&VfsRef::default(), Some("report.md"), &outcome) + .expect_err("a run that did not complete has no output"); + assert_eq!(error.code, ErrorCode::NoOutput); + assert_eq!(error.message, "the run did not complete"); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&json!("not_completed")) + ); + } +} + +#[test] +fn a_prompt_without_an_output_declaration_reports_no_output() { + let error = report_output(&VfsRef::default(), None, &completed()) + .expect_err("an undeclared output has no text"); + assert_eq!(error.code, ErrorCode::NoOutput); + assert_eq!(error.message, "the prompt declares no `output:` file"); + assert_eq!(error.prose, ErrorCode::NoOutput.prose()); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&json!("undeclared")) + ); +} + +#[test] +fn a_completed_run_without_its_output_file_reports_not_found() { + let error = report_output(&VfsRef::default(), Some("report.md"), &completed()) + .expect_err("the run wrote no output file"); + assert_eq!(error.code, ErrorCode::NotFound); + assert_eq!( + error.message, + "the run completed without writing its declared output file `report.md`" + ); + assert_eq!(error.facts.get(&FactKey::Path), Some(&json!("report.md"))); +} + +#[test] +fn a_refused_read_reports_the_stores_failure_with_its_cause_in_the_message() { + let vfs = seeded("out/report.md", "done"); + let error = report_output(&vfs, Some("out"), &completed()) + .expect_err("a directory is not an output file"); + assert_eq!(error.code, ErrorCode::IsADirectory); + assert!( + error + .message + .starts_with("the output file `out` could not be read: "), + "{error:?}" + ); + assert_eq!(error.prose, ErrorCode::IsADirectory.prose()); + assert_eq!(error.facts.get(&FactKey::Path), Some(&json!("out"))); +} diff --git a/crates/harness-internal/runner/src/files.rs b/crates/harness-internal/runner/src/files.rs index 4923c85d5..d87f72f7a 100644 --- a/crates/harness-internal/runner/src/files.rs +++ b/crates/harness-internal/runner/src/files.rs @@ -8,82 +8,24 @@ //! each operation like any other. Both are synchronous, like the VFS, and //! the Harness runs them inline in the run's future. -use runvil::vfs::{Origin, VfsError, VfsOp, VfsOutcome, VfsRef, perform_vfs_op}; +use runvil::error::{Error, ErrorCode, FactKey}; +use runvil::vfs::{Origin, VfsError, VfsOp, VfsOutcome, VfsRef, perform_vfs_op, store_error}; use crate::recorder::{RunErrorKind, RunOutcome}; -/// Why a run's report omits its output text. -/// -/// The report keeps the run's own outcome. This error replaces only its -/// output text. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum OutputError { - /// The run failed or was cancelled, possibly before it began, so the - /// report skips its output file. - #[error("the run did not complete")] - NotCompleted, - /// The prompt's frontmatter omits the `output:` declaration. - #[error("the prompt declares no `output:` file")] - Undeclared, - /// The run completed, but its declared output file is missing from the - /// store. - #[error("the run completed without writing its declared output file `{path}`")] - Missing { - /// The declared output path. - path: String, - }, - /// The store refused the read of the declared output file. - #[error("the output file `{path}` could not be read")] - Vfs { - /// The declared output path. - path: String, - /// The store's failure. - #[source] - source: VfsError, - }, -} - -/// Why a run's declared input file could not be put in place. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum InputFileError { - /// The launch supplied input text, but the prompt declares no - /// `input:` file to stage it at. - #[error("the launch supplied input text, but the prompt declares no `input:` file")] - Undeclared, - /// The prompt declares an input file that the launch did not supply - /// and the store does not already hold. - #[error( - "the prompt declares the input file `{path}`, but the launch supplied no input text \ - and the store has no such file" - )] - Missing { - /// The declared input path. - path: String, - }, - /// The store refused the staging write or the existence check. - #[error("the input file `{path}` could not be staged")] - Vfs { - /// The declared input path. - path: String, - /// The store's failure. - #[source] - source: VfsError, - }, -} +#[cfg(test)] +#[path = "files-tests.rs"] +mod tests; -impl InputFileError { - /// The Engine's kind for the run this failure ends: input text the - /// prompt does not declare and a declared input nobody supplied are - /// unmet requirements, and a store refusal is a store failure. - pub(crate) fn kind(&self) -> RunErrorKind { - match self { - InputFileError::Undeclared | InputFileError::Missing { .. } => { - RunErrorKind::RequirementsUnmet - } - InputFileError::Vfs { .. } => RunErrorKind::Vfs, - } +/// The Engine's kind for the run an input failure from [`stage_input`] +/// ends: input text the prompt does not declare and a declared input +/// nobody supplied are unmet requirements, and a store refusal is a store +/// failure. +pub(crate) fn input_kind(error: &Error) -> RunErrorKind { + if error.code == ErrorCode::RequirementsUnmet { + RunErrorKind::RequirementsUnmet + } else { + RunErrorKind::Vfs } } @@ -92,23 +34,33 @@ impl InputFileError { /// supplied no text, checks that the store already holds the file. /// /// # Errors -/// Returns [`InputFileError::Undeclared`] for text the prompt declares no -/// file for, [`InputFileError::Missing`] for a declared file that is -/// neither supplied nor present, and [`InputFileError::Vfs`] when the -/// store refuses the write or the check. +/// Returns `RequirementsUnmet` with the `Reason` fact `input_undeclared` +/// for text the prompt declares no file for, `RequirementsUnmet` with the +/// `Reason` fact `input_missing` for a declared file that is neither +/// supplied nor present, and the store failure's code when the store +/// refuses the write or the check. The last two carry the declared path +/// as the `Path` fact. The prose is the code's default. pub fn stage_input( vfs: &VfsRef, declared: Option<&str>, text: Option, -) -> Result<(), InputFileError> { +) -> Result<(), Error> { let path = match (declared, &text) { (None, None) => return Ok(()), - (None, Some(_)) => return Err(InputFileError::Undeclared), + (None, Some(_)) => { + return Err(reasoned( + ErrorCode::RequirementsUnmet, + "input_undeclared", + "the launch supplied input text, but the prompt declares no `input:` file", + )); + } (Some(path), _) => path, }; - let store = |source| InputFileError::Vfs { - path: path.to_owned(), - source, + let store = |source: VfsError| { + let mut error = store_error(&source); + error.message = format!("the input file `{path}` could not be staged: {source}"); + error.facts.insert(FactKey::Path, path.into()); + error }; let view = vfs .acquire_store(Origin::new(format!("input: {path}"))) @@ -120,7 +72,18 @@ pub fn stage_input( .map_err(store), None => match perform_vfs_op(&view, VfsOp::Exists { path: path.clone() }) { Ok(VfsOutcome::Bool(true)) => Ok(()), - Ok(_) => Err(InputFileError::Missing { path }), + Ok(_) => { + let mut error = reasoned( + ErrorCode::RequirementsUnmet, + "input_missing", + format!( + "the prompt declares the input file `{path}`, but the launch supplied no \ + input text and the store has no such file" + ), + ); + error.facts.insert(FactKey::Path, path.into()); + Err(error) + } Err(source) => Err(store(source)), }, } @@ -147,24 +110,54 @@ pub fn read_output(vfs: &VfsRef, path: &str) -> Result { } /// The output a run's report carries: what a completed run left at its -/// declared `output:` path in `vfs`'s store, and -/// [`OutputError::NotCompleted`] for every other outcome. +/// declared `output:` path in `vfs`'s store, or why the report omits it. +/// +/// A run that did not complete fails with [`not_completed`], and a prompt +/// with no `output:` file with `NoOutput` and the `Reason` fact +/// `undeclared`. A completed run that left no file fails with `NotFound`, +/// and a refused read with the store failure's code. Both carry the +/// declared path as the `Path` fact. The prose is the code's default. pub(crate) fn report_output( vfs: &VfsRef, path: Option<&str>, outcome: &RunOutcome, -) -> Result { +) -> Result { if !matches!(outcome, RunOutcome::Completed { .. }) { - return Err(OutputError::NotCompleted); + return Err(not_completed()); } - let path = path.ok_or(OutputError::Undeclared)?; - read_output(vfs, path).map_err(|error| match error { - VfsError::NotFound { .. } => OutputError::Missing { - path: path.to_owned(), - }, - source => OutputError::Vfs { - path: path.to_owned(), - source, - }, + let path = path.ok_or_else(|| { + reasoned( + ErrorCode::NoOutput, + "undeclared", + "the prompt declares no `output:` file", + ) + })?; + read_output(vfs, path).map_err(|error| { + let mut converted = store_error(&error); + converted.message = if matches!(error, VfsError::NotFound { .. }) { + format!("the run completed without writing its declared output file `{path}`") + } else { + format!("the output file `{path}` could not be read: {error}") + }; + converted.facts.insert(FactKey::Path, path.into()); + converted }) } + +/// The output failure of a run that failed or was cancelled, possibly +/// before it began: `NoOutput` with the `Reason` fact `not_completed`. +pub(crate) fn not_completed() -> Error { + reasoned( + ErrorCode::NoOutput, + "not_completed", + "the run did not complete", + ) +} + +/// A failure under `code` with `message`, the `Reason` fact `reason`, and +/// the code's default prose. +fn reasoned(code: ErrorCode, reason: &str, message: impl Into) -> Error { + let mut error = Error::new(code, message, code.prose()); + error.facts.insert(FactKey::Reason, reason.into()); + error +} diff --git a/crates/harness-internal/runner/src/harness.rs b/crates/harness-internal/runner/src/harness.rs index f3eff245a..087c0ecb5 100644 --- a/crates/harness-internal/runner/src/harness.rs +++ b/crates/harness-internal/runner/src/harness.rs @@ -16,18 +16,19 @@ use std::sync::Arc; use futures_util::future::{Either, select}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode, FactKey}; use runvil::model::ModelInfo; use runvil::vfs::VfsRef; use runvil_plugin::HostServices; use crate::display_chain::display_chain; use crate::effect_loop::drive; -use crate::environment::{HostSnapshot, current_model}; -use crate::files::{OutputError, report_output}; +use crate::environment::{HostSnapshot, current_model, model_kind}; +use crate::files::{not_completed, 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::prepare::{Services, begin, ended, prepare}; +use crate::recorder::{RunId, RunOutcome, RunRecorder}; #[path = "harness-control.rs"] mod control; @@ -69,6 +70,10 @@ pub struct RunRequest { /// The prompt's Markdown source. The Harness reads the prompt only /// from this text. pub source: String, + /// The file the source came from, as the Host names it, such as + /// `agents/research.md`, or `prompt` when it came from no file. The + /// run's failures name it, so a reader can open the file. + pub file: String, /// The run's argument text. pub args: String, /// Text written to the prompt's declared `input:` file before the run @@ -93,28 +98,30 @@ pub struct RunReport { /// How the run ended. pub outcome: RunOutcome, /// The text a completed run left at its declared `output:` file, or - /// the reason the report omits that text. - pub output: Result, + /// the reason the report omits that text: `NoOutput` with the `Reason` + /// fact `not_completed` or `undeclared`, `NotFound` for a completed + /// run that left no file, or the store's failure, each with the + /// declared path as the `Path` fact when there is one. + pub output: Result, } -/// The error that stopped a run before `Harness::run` could record its -/// outcome: the recorder refused a write. +/// The failure that stops a run before `Harness::run` can record its +/// outcome: the recorder refused a write for run `run`, or refused to +/// begin it when `run` is `None`. /// -/// `Harness::run` returns it as its `Err` value. Every other way a run -/// ends is a recorded outcome in a [`RunReport`]. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum HarnessError { - /// The recorder refused a write, so the Harness stops driving the run. - #[error("the run could not be recorded")] - Recorder { - /// The id of the run the recorder issued before it refused. `None` - /// when the recorder refused to begin the run. - run: Option, - /// The error the recorder returned. - #[source] - source: RecorderError, - }, +/// It is `RecorderFailed`, with the refusal after `the run could not be +/// recorded: ` in its message, the refusal's facts, and the run's `Run` +/// fact when the recorder issued one. Every other way a run ends is a +/// recorded outcome in a [`RunReport`]. +fn unrecorded(run: Option, refusal: &Error) -> Error { + let code = ErrorCode::RecorderFailed; + let mut error = + Error::new(code, "the run could not be recorded", code.prose()).with_cause(refusal); + error.facts.clone_from(&refusal.facts); + if let Some(run) = run { + error.facts.insert(FactKey::Run, run.get().into()); + } + error } impl Harness { @@ -180,14 +187,16 @@ impl Harness { /// `Internal`. /// /// # Errors - /// Returns [`HarnessError::Recorder`] when the recorder refuses a - /// write. That is the only error: the run is then not recorded to its - /// end. - pub async fn run(self, request: RunRequest) -> Result { + /// Returns `RecorderFailed` when the recorder refuses a write. That is + /// the only error: the run is then not recorded to its end. Its + /// message holds the refusal after `the run could not be recorded: `, + /// and its `Run` fact is the run the recorder issued, absent when the + /// recorder refused to begin the run. + pub async fn run(self, request: RunRequest) -> Result { Box::pin(self.run_to_end(request)).await } - async fn run_to_end(self, request: RunRequest) -> Result { + async fn run_to_end(self, request: RunRequest) -> Result { let Harness { recorder, broker, @@ -199,6 +208,7 @@ impl Harness { let RunRequest { name, source, + file, args, input_text, vfs, @@ -207,12 +217,9 @@ impl Harness { let begun = begin(&*recorder, &name, &source) .await - .map_err(|source| HarnessError::Recorder { run: None, source })?; + .map_err(|refusal| unrecorded(None, &refusal))?; let run_id = begun.run_id; - let recorded = |source| HarnessError::Recorder { - run: Some(run_id), - source, - }; + let recorded = |refusal: Error| unrecorded(Some(run_id), &refusal); let model = match resolve_model(&host, &*broker, control.cancel_handle()).await { Ok(model) => model, @@ -224,7 +231,7 @@ impl Harness { return Ok(RunReport { run_id, outcome, - output: Err(OutputError::NotCompleted), + output: Err(not_completed()), }); } }; @@ -239,17 +246,18 @@ impl Harness { broker, timer, name, + file, model, ui: Some(host.ui()), }; let prepared = match prepare(&source, &args, begun, services).await { Ok(prepared) => prepared, Err(error) => { - return match error.ended() { + return match ended(error) { Ok(outcome) => Ok(RunReport { run_id, outcome, - output: Err(OutputError::NotCompleted), + output: Err(not_completed()), }), Err(source) => Err(recorded(source)), }; @@ -290,7 +298,7 @@ async fn resolve_model( match select(cancel.cancelled(), resolving).await { Either::Left(((), _)) => Err(RunOutcome::Cancelled), Either::Right((resolved, _)) => resolved.map_err(|error| RunOutcome::Failed { - kind: error.kind(), + kind: model_kind(&error), message: display_chain(&error), }), } 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..a2716dfec 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", )); } @@ -174,7 +169,7 @@ fn prompt(frontmatter: &str) -> Prompt { "---\nname: host-test\ndescription: d\nrunvil: 0\n{frontmatter}---\n\n\ # Title\n\n## Only\n\nDone.\n" ); - let (prompt, _events) = Prompt::parse(&source, "host-test"); + let (prompt, _events) = Prompt::parse(&source, "prompt", "host-test"); prompt.unwrap() } @@ -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..2eca91e04 100644 --- a/crates/harness-internal/runner/src/lib.rs +++ b/crates/harness-internal/runner/src/lib.rs @@ -46,9 +46,9 @@ pub mod prepare; pub mod recorder; pub use display_chain::display_chain; -pub use harness::{Harness, HarnessError, RunControl, RunReport, RunRequest}; +pub use harness::{Harness, 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..5236adae1 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -35,27 +35,28 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode, FactKey}; use runvil::event::Event; use runvil::model::{ModelInfo, ModelRole}; use runvil::prompt::FileDecl; use runvil::timestamp::Timestamp; use runvil::vfs::VfsRef; -use runvil::{ParseError, Prompt, Run, RunConfig, RunSeed}; +use runvil::{Prompt, Run, RunConfig, RunSeed}; use runvil_plugin::HostServices; use sha2::{Digest as _, Sha256}; use crate::display_chain::display_chain; -use crate::files::{InputFileError, stage_input}; +use crate::files::{input_kind, 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 /// Packs and the run's own services, the real directories, the run's /// cancel flag, the recorder, the inference broker and the timer that -/// reach beyond the runner, and the run's name. +/// reach beyond the runner, and the run's name and prompt file. pub struct Services { /// The Host's built Packs, from which the run picks its own and takes /// their snapshot. @@ -82,6 +83,8 @@ pub struct Services { /// The run's name, the one it began under: every event's /// `execution`. pub name: String, + /// The file the prompt's source came from, which the parse records. + pub file: String, /// The Host's default model, when one is selected: the run fills all /// five roles with it, and its first step checks each declared role's /// requirements against it. @@ -133,68 +136,50 @@ pub struct Prepared { pub output_path: Option, } -/// Why a run could not be prepared. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum PrepareError { - /// The prompt does not parse. The run is ended as failed with kind - /// `Parse`; the parse failure is the source. - #[error("the prompt does not parse")] - Parse { - /// The run, ended with this failure. - run_id: RunId, - /// The parse failure. - #[source] - source: ParseError, - }, - /// The prompt's declared input file could not be put in place: the - /// launch supplied text the prompt declares no file for, the prompt - /// declares a file that is neither supplied nor in the store, or the - /// store refused. The run is ended as failed with kind - /// `RequirementsUnmet`, or `Vfs` when the store refused. - #[error("the prompt's declared input file cannot be put in place")] - Input { - /// The run, ended with this refusal. - run_id: RunId, - /// Why the input could not be put in place. - #[source] - source: InputFileError, - }, - /// The recorder refused a write; the run cannot be recorded, so it is - /// not prepared. - #[error("the run could not be recorded")] - Recorder { - /// The run the recorder refused a write for. - run: RunId, - /// The recorder's refusal. - #[source] - source: RecorderError, - }, +/// The failed outcome a [`prepare`] failure ended its run with, or, for a +/// `RecorderFailed` refusal, which ends nothing, the refusal. +pub(crate) fn ended(error: Error) -> Result { + if error.code == ErrorCode::RecorderFailed { + return Err(error); + } + Ok(failed(ended_kind(&error), &error)) } -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 { - match self { - PrepareError::Parse { source, .. } => Ok(failed(RunErrorKind::Parse, &source)), - PrepareError::Input { source, .. } => Ok(failed(source.kind(), &source)), - PrepareError::Recorder { source, .. } => Err(source), - } +/// The kind a [`prepare`] failure ends its run under: the input's kind for +/// an unmet or refused input, and `Parse` for a prompt that does not parse. +fn ended_kind(error: &Error) -> RunErrorKind { + if error.code == ErrorCode::RequirementsUnmet || error.code.number() / 100 == 6 { + input_kind(error) + } else { + RunErrorKind::Parse } } +/// `error` with run `run`'s id as its `Run` fact. +fn of_run(run: RunId, mut error: Error) -> Error { + error.facts.insert(FactKey::Run, run.get().into()); + error +} + +/// A recorder's refusal of a write for run `run`: `RecorderFailed` with +/// the run's `Run` fact, the refusal built with [`recorder_error`] when +/// the recorder returned another code. +fn refused(run: RunId, error: Error) -> Error { + let error = if error.code == ErrorCode::RecorderFailed { + error + } else { + recorder_error(error) + }; + of_run(run, error) +} + /// Begins one run named `name` of the prompt `source` at `recorder`: draws /// the run's seed and start and hands both to the recorder with the /// prompt's hash. /// /// # 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 @@ -227,18 +212,19 @@ pub async fn begin( /// notice, or as cancelled when the run's cancel fired first. /// /// # Errors -/// Returns [`PrepareError::Parse`] when the source does not parse and -/// [`PrepareError::Input`] when its declared input file cannot be put in -/// place (in both cases the run is ended as failed, and -/// `Harness::run_to_end` reports it while the events recorded so far stay -/// at the recorder), and [`PrepareError::Recorder`] when the recorder -/// refuses a write. +/// Returns the parse failure when the source does not parse and the input +/// failure from `stage_input` when its declared input file cannot be put +/// in place. In both cases the run is ended as failed, the failure keeps +/// its own code and gains the run's `Run` fact, and `Harness::run_to_end` +/// reports it while the events recorded so far stay at the recorder. +/// Returns `RecorderFailed` with the `Run` fact when the recorder refuses +/// a write. pub async fn prepare( source: &str, args: &str, begun: Begun, services: Services, -) -> Result { +) -> Result { let Services { host, services, @@ -249,6 +235,7 @@ pub async fn prepare( broker, timer, name, + file, model, ui, } = services; @@ -257,14 +244,11 @@ pub async fn prepare( seed, started_at, } = begun; - let recorded = |source| PrepareError::Recorder { - run: run_id, - source, - }; + let recorded = |source| refused(run_id, source); // Parse-time events are the run's first records, whether or not the // parse succeeds. - let (prompt, parse_events) = Prompt::parse(source, &name); + let (prompt, parse_events) = Prompt::parse(source, &file, &name); for event in &parse_events { let record = event_record(event).map_err(recorded)?; recorder.append(run_id, record).await.map_err(recorded)?; @@ -273,10 +257,10 @@ pub async fn prepare( Ok(prompt) => prompt, Err(source) => { recorder - .end_run(run_id, failed(RunErrorKind::Parse, &source)) + .end_run(run_id, failed(ended_kind(&source), &source)) .await .map_err(recorded)?; - return Err(PrepareError::Parse { run_id, source }); + return Err(of_run(run_id, source)); } }; @@ -285,10 +269,10 @@ pub async fn prepare( let declared = prompt.frontmatter().input().map(FileDecl::path); if let Err(source) = stage_input(&vfs, declared, input_text) { recorder - .end_run(run_id, failed(source.kind(), &source)) + .end_run(run_id, failed(ended_kind(&source), &source)) .await .map_err(recorded)?; - return Err(PrepareError::Input { run_id, source }); + return Err(of_run(run_id, source)); } let output_path = prompt .frontmatter() @@ -353,14 +337,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..16da9fcb5 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}; @@ -40,7 +41,7 @@ const INFERS_THEN_CHATS: &str = "---\nname: runner-broker\ndescription: infers t /// A run over [`INFERS_THEN_CHATS`] with the `default` role filled by one /// current model. fn infers_then_chats() -> Run { - let (prompt, _parse_events) = Prompt::parse(INFERS_THEN_CHATS, EXECUTION); + let (prompt, _parse_events) = Prompt::parse(INFERS_THEN_CHATS, "prompt", EXECUTION); let prompt = Arc::new(prompt.expect("the broker fixture parses")); let model = ModelInfo::new( ModelId::gateway("m").expect("the model id is valid"), @@ -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..b848d5a64 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}; @@ -73,7 +73,7 @@ fn looping(section: &str, user: &str, result: &str) -> String { /// one model and [`TOOL`] in its catalog, under the cancel flag `cancel`. fn run_of(sections: &str, cancel: &CancelHandle) -> Run { let source = format!("{PREAMBLE}{sections}"); - let (prompt, _parse_events) = Prompt::parse(&source, EXECUTION); + let (prompt, _parse_events) = Prompt::parse(&source, "prompt", EXECUTION); let prompt = Arc::new(prompt.expect("the cap fixture parses")); let model = ModelInfo::new( ModelId::gateway(MODEL).expect("the model id is valid"), @@ -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..1500bd2ff 100644 --- a/crates/harness-internal/runner/tests/it/harness-errors.rs +++ b/crates/harness-internal/runner/tests/it/harness-errors.rs @@ -3,8 +3,8 @@ //! fetch ends the recorded run as a `Completion` failure, a list without //! the selection as a `RequirementsUnmet` failure, and a cancel before //! `run` or while the broker holds its list as cancelled, each before the -//! prompt is parsed; a recorder that refuses a write is -//! `HarnessError::Recorder` holding the run id it issued; and a broker +//! prompt is parsed; a recorder that refuses a write is a +//! `RecorderFailed` failure naming the run id it issued; and a broker //! that panics answers its round `Dropped`. use std::sync::Arc; @@ -12,22 +12,30 @@ use std::sync::atomic::Ordering; use std::task::Poll; 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 harness_runner::environment::HostSnapshot; +use harness_runner::recorder::{MemoryRecorder, RunErrorKind, RunId}; +use harness_runner::{Harness, HostContext, RunReport, RunRequest}; +use runvil::error::{Error, ErrorCode, FactKey}; +use runvil::model::{Completion, phrase}; use runvil_plugin::{HostServices, PackRegistry}; -use super::{ - OutputError, PendingTimer, RunOutcome, answers_to, plain_harness, request, run_beside, until, -}; +use super::{PendingTimer, RunOutcome, answers_to, plain_harness, request, run_beside, until}; use crate::scripted::{Listing, ScriptedBroker}; -use crate::support::FailingRecorder; +use crate::support::{FailingRecorder, assert_not_completed}; /// A Pack-free prompt whose one section returns a constant. const PLAIN: &str = "---\nname: plain\ndescription: d\nrunvil: 0\n---\n\n\ # Title\n\n## Only\n\n```lua\nreturn 'plain'\n```\n"; +/// The run a recorder failure names in its `Run` fact, if any. +fn run_of(error: &Error) -> Option { + error + .facts + .get(&FactKey::Run) + .and_then(serde_json::Value::as_i64) + .map(RunId::from_raw) +} + /// A prompt of one store write and one read. const STORES: &str = "---\nname: stores\ndescription: d\nrunvil: 0\n---\n\n\ # Title\n\n## Only\n\n```lua\nstore.write('a.md', '1')\nreturn store.read('a.md')\n```\n"; @@ -68,27 +76,26 @@ fn assert_ended_before_the_parse(recorder: &FailingRecorder, report: &RunReport) "the run ended before its prompt was parsed" ); assert_eq!(recorder.calls(), 2, "the run's begin and its end"); - assert_eq!(report.output, Err(OutputError::NotCompleted)); + assert_not_completed(&report.output); } #[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())); assert_eq!( report.outcome, RunOutcome::Failed { kind: RunErrorKind::Completion, - message: display_chain(&lookup), + message: format!("the model catalog could not be fetched: {}", phrase(code)), }, - "the message is the lookup's cause chain, the broker's message included" + "the message is the lookup's, the broker's message included" ); assert_ended_before_the_parse(&recorder, &report); } @@ -162,10 +169,18 @@ async fn a_recorder_failure_returns_the_recorder_error_holding_the_issued_run_id .run(request(STORES)) .await .expect_err("a refused begin is the run's error"); - let HarnessError::Recorder { run, .. } = error else { - panic!("the refusal is the recorder's: {error:?}"); - }; - assert_eq!(run, None, "a refused begin issued no run"); + assert_eq!( + error.code, + ErrorCode::RecorderFailed, + "the refusal is the recorder's" + ); + assert_eq!(run_of(&error), None, "a refused begin issued no run"); + assert!( + error + .message + .starts_with("the run could not be recorded: the run recorder failed: "), + "{error:?}" + ); // A run that ends before its prompt is parsed has one write after its // begin: its end. @@ -176,11 +191,9 @@ async fn a_recorder_failure_returns_the_recorder_error_holding_the_issued_run_id .run(request(STORES)) .await .expect_err("a refused end is the run's error"); - let HarnessError::Recorder { run, .. } = error else { - panic!("the refusal is the recorder's: {error:?}"); - }; + assert_eq!(error.code, ErrorCode::RecorderFailed); assert_eq!( - run, + run_of(&error), Some(refuses_end.begun()[0]), "the error holds the issued run id" ); @@ -204,20 +217,22 @@ async fn a_recorder_failure_returns_the_recorder_error_holding_the_issued_run_id .run(request(STORES)) .await .expect_err("a refused write is the run's error"); - let HarnessError::Recorder { run, source } = error else { - panic!("call {call} of {total}: the refusal is the recorder's: {error:?}"); - }; + assert_eq!( + error.code, + ErrorCode::RecorderFailed, + "call {call} of {total}: the refusal is the recorder's" + ); let issued = recorder.begun(); assert_eq!(issued.len(), 1, "call {call} of {total}: one run began"); assert_eq!( - run, + run_of(&error), Some(issued[0]), "call {call} of {total}: the error holds the issued run id" ); assert!( - display_chain(&source).contains(&format!("refuses call {call}")), + display_chain(&error).contains(&format!("refuses call {call}")), "call {call} of {total}: {}", - display_chain(&source) + display_chain(&error) ); assert_eq!(recorder.calls(), call, "nothing is sent after the refusal"); } @@ -230,7 +245,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/harness.rs b/crates/harness-internal/runner/tests/it/harness.rs index aaef7ff1f..ec0808515 100644 --- a/crates/harness-internal/runner/tests/it/harness.rs +++ b/crates/harness-internal/runner/tests/it/harness.rs @@ -19,15 +19,15 @@ use std::thread::{self, Thread, ThreadId}; use std::time::Duration; use harness_runner::environment::HostSnapshot; -use harness_runner::files::OutputError; use harness_runner::recorder::{MemoryRecorder, Record, RecordKind, RunErrorKind, RunOutcome}; -use harness_runner::{Harness, HarnessError, HostContext, RunControl, RunReport, RunRequest}; +use harness_runner::{Harness, HostContext, RunControl, RunReport, RunRequest}; +use runvil::error::Error; use runvil::vfs::VfsRef; use runvil_plugin::{HostServices, PackRegistry}; use serde_json::{Value, json}; use crate::scripted::{Kept, MODEL, ScriptedBroker}; -use crate::support::{HookedBackend, PendingTimer}; +use crate::support::{HookedBackend, PendingTimer, assert_not_completed}; #[path = "harness-errors.rs"] mod errors; @@ -51,6 +51,7 @@ pub(crate) fn request(source: &str) -> RunRequest { RunRequest { name: NAME.to_owned(), source: source.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), @@ -92,7 +93,7 @@ pub(crate) async fn run_beside( harness: Harness, request: RunRequest, steer: impl FnOnce(RunControl) -> F, -) -> Result +) -> Result where F: Future, { @@ -240,7 +241,7 @@ async fn source_that_fails_to_parse_ends_as_a_recorded_failed_run_with_its_parse }; assert_eq!(*kind, RunErrorKind::Parse); assert_eq!(recorder.outcome(run_id), Some(report.outcome.clone())); - assert_eq!(report.output, Err(OutputError::NotCompleted)); + assert_not_completed(&report.output); let records = recorder.records(run_id); assert!( records 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-files.rs b/crates/harness-internal/runner/tests/it/prepare-files.rs index 7c30ccf33..1d90ab26e 100644 --- a/crates/harness-internal/runner/tests/it/prepare-files.rs +++ b/crates/harness-internal/runner/tests/it/prepare-files.rs @@ -12,14 +12,18 @@ use std::sync::Arc; use harness_runner::display_chain; use harness_runner::effect_loop::drive_run; -use harness_runner::files::{InputFileError, read_output}; -use harness_runner::prepare::{PrepareError, Prepared}; +use harness_runner::files::read_output; +use harness_runner::prepare::Prepared; use harness_runner::recorder::{MemoryRecorder, RunErrorKind, RunOutcome}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode, FactKey}; use runvil::event::Event; -use runvil::vfs::{MemoryBackend, Mode, ModePolicy, Origin, VfsError, VfsRef}; +use runvil::vfs::{MemoryBackend, Mode, ModePolicy, Origin, VfsRef}; +use serde_json::json; -use super::{PLAIN, bare, begin_and_prepare, completed, recorded_parse_events, recorder, services}; +use super::{ + PLAIN, bare, begin_and_prepare, completed, recorded_parse_events, recorder, run_of, services, +}; /// A prompt declaring `paper.md` as its input and `report.md` as its /// output, whose one section writes the output from the input. @@ -34,7 +38,7 @@ async fn prepare_over( source: &str, vfs: &VfsRef, input_text: Option<&str>, -) -> (Arc, Result) { +) -> (Arc, Result) { let recorder = recorder(); let mut services = services(&recorder, bare()); services.vfs = vfs.clone(); @@ -45,16 +49,14 @@ async fn prepare_over( /// Checks that `error` is an input refusal whose run ended under `kind` /// with the cause chain as its message, and that the recorder holds only -/// the run's parse events. Returns the cause. -fn refused(recorder: &MemoryRecorder, error: PrepareError, kind: RunErrorKind) -> InputFileError { - let PrepareError::Input { run_id, source, .. } = error else { - panic!("the failure is an input refusal: {error}"); - }; +/// the run's parse events. Returns the refusal. +fn refused(recorder: &MemoryRecorder, error: Error, kind: RunErrorKind) -> Error { + let run_id = run_of(&error); assert_eq!( recorder.outcome(run_id), Some(RunOutcome::Failed { kind, - message: display_chain(&source), + message: display_chain(&error), }), "the recorder holds the refusal under the {kind:?} kind" ); @@ -63,7 +65,7 @@ fn refused(recorder: &MemoryRecorder, error: PrepareError, kind: RunErrorKind) - matches!(events.last(), Some(Event::ParseSucceeded { .. })), "the prompt parsed before its input was refused: {events:?}" ); - source + error } #[tokio::test] @@ -120,7 +122,16 @@ async fn input_text_for_a_prompt_that_declares_no_input_is_refused() { prepared.expect_err("undeclared input refuses"), RunErrorKind::RequirementsUnmet, ); - assert!(matches!(source, InputFileError::Undeclared), "{source:?}"); + assert_eq!(source.code, ErrorCode::RequirementsUnmet); + assert_eq!( + source.message, + "the launch supplied input text, but the prompt declares no `input:` file" + ); + assert_eq!(source.prose, ErrorCode::RequirementsUnmet.prose()); + assert_eq!( + source.facts.get(&FactKey::Reason), + Some(&json!("input_undeclared")) + ); } #[tokio::test] @@ -131,10 +142,16 @@ async fn a_declared_input_neither_supplied_nor_in_the_store_is_refused() { prepared.expect_err("a missing input refuses"), RunErrorKind::RequirementsUnmet, ); - let InputFileError::Missing { path } = source else { - panic!("the refusal names the missing file: {source:?}"); - }; - assert_eq!(path, "paper.md"); + assert_eq!(source.code, ErrorCode::RequirementsUnmet); + assert_eq!( + source.facts.get(&FactKey::Reason), + Some(&json!("input_missing")) + ); + assert_eq!( + source.facts.get(&FactKey::Path), + Some(&json!("paper.md")), + "the refusal names the missing file" + ); } #[tokio::test] @@ -149,14 +166,18 @@ async fn a_store_that_refuses_the_staging_write_refuses_the_run() { prepared.expect_err("a refused write refuses"), RunErrorKind::Vfs, ); - let InputFileError::Vfs { path, source } = source else { - panic!("the refusal is the store's: {source:?}"); - }; - assert_eq!(path, "paper.md"); + assert_eq!( + source.code, + ErrorCode::ApprovalRequired, + "the refusal is the store's" + ); assert!( - matches!(source, VfsError::ApprovalRequired { .. }), - "{source:?}" + source + .message + .starts_with("the input file `paper.md` could not be staged: "), + "the message holds the store's refusal: {source:?}" ); + assert_eq!(source.facts.get(&FactKey::Path), Some(&json!("paper.md"))); } #[tokio::test] 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..ab760cad5 100644 --- a/crates/harness-internal/runner/tests/it/prepare-ready.rs +++ b/crates/harness-internal/runner/tests/it/prepare-ready.rs @@ -18,7 +18,6 @@ use std::time::Duration; use futures_util::FutureExt as _; use harness_runner::environment::HostSnapshot; -use harness_runner::files::OutputError; use harness_runner::recorder::{Record, RecorderFuture, RunMeta, RunRecorder}; use harness_runner::{Harness, RunControl, RunRequest}; use runvil::vfs::VfsRef; @@ -89,14 +88,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 +112,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 +136,7 @@ impl Pack for Starting { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.echo.call(cx, args) } } @@ -172,6 +177,7 @@ fn request(source: &str) -> RunRequest { RunRequest { name: "session-1".to_owned(), source: source.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), @@ -377,7 +383,7 @@ async fn a_cancel_while_a_declared_pack_is_still_starting_ends_the_run_cancelled let report = report.expect("a cancelled run is reported"); assert_eq!(report.outcome, RunOutcome::Cancelled); - assert_eq!(report.output, Err(OutputError::NotCompleted)); + crate::support::assert_not_completed(&report.output); assert_eq!( recorder.outcome(report.run_id), Some(RunOutcome::Cancelled), diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index ff3c8ec71..21d576f99 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -17,14 +17,18 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use harness_runner::effect_loop::drive_run; -use harness_runner::prepare::{PrepareError, Prepared, Services, begin, prepare}; -use harness_runner::recorder::{MemoryRecorder, RecordKind, RunErrorKind, RunId, RunOutcome}; +use harness_runner::prepare::{Prepared, Services, begin, prepare}; +use harness_runner::recorder::{ + MemoryRecorder, Record, RecordKind, RecorderFuture, RunErrorKind, RunId, RunMeta, RunOutcome, + RunRecorder, +}; use harness_runner::{HostContext, display_chain}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode, FactKey}; 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}; @@ -108,6 +112,7 @@ pub(crate) fn services(recorder: &Arc, host: HostContext) -> Ser broker: Arc::new(Unused), timer: Arc::new(Unused), name: "session-1".to_owned(), + file: "prompt".to_owned(), model: None, ui: None, } @@ -115,16 +120,24 @@ pub(crate) fn services(recorder: &Arc, host: HostContext) -> Ser /// Begins a run of `source` under the name `services` holds at the /// recorder it holds, then prepares the run with no arguments. -pub(crate) async fn begin_and_prepare( - source: &str, - services: Services, -) -> Result { +pub(crate) async fn begin_and_prepare(source: &str, services: Services) -> Result { let begun = begin(&*services.recorder, &services.name, source) .await .expect("the memory recorder begins the run"); prepare(source, "", begun, services).await } +/// The run a preparation failure names in its `Run` fact. +pub(crate) fn run_of(error: &Error) -> RunId { + RunId::from_raw( + error + .facts + .get(&FactKey::Run) + .and_then(Value::as_i64) + .expect("a preparation failure names its run"), + ) +} + /// The fixture Pack `tools`: one tool, `tools/echo`, echoing its /// `value` argument as trusted text. const TOOLS: PackInfo = echo_pack("tests/tools", None); @@ -151,7 +164,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 +182,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())) }) } @@ -229,17 +242,19 @@ async fn a_prompt_that_does_not_parse_fails_preparation_and_its_run_ends_as_a_pa .await .expect_err("a prompt without a closed frontmatter does not parse"); - let PrepareError::Parse { run_id, source, .. } = error else { - panic!("the failure is a parse failure: {error}"); - }; - + assert_eq!( + error.code, + ErrorCode::FrontmatterInvalid, + "the failure is the parse failure" + ); + let run_id = run_of(&error); assert_eq!( recorder.outcome(run_id), Some(RunOutcome::Failed { kind: RunErrorKind::Parse, - message: display_chain(&source), + message: display_chain(&error), }), - "the recorder holds the parse failure and its cause chain under the Parse kind" + "the recorder holds the parse failure under the Parse kind" ); let events = recorded_parse_events(&recorder, run_id); assert!( @@ -248,6 +263,37 @@ async fn a_prompt_that_does_not_parse_fails_preparation_and_its_run_ends_as_a_pa ); } +/// A recorder that begins runs in memory and refuses every append with a +/// store error of its own. +struct RefusingAppends(MemoryRecorder); + +impl RunRecorder for RefusingAppends { + fn begin_run(&self, meta: RunMeta) -> RecorderFuture<'_, RunId> { + self.0.begin_run(meta) + } + + fn append(&self, _run: RunId, _record: Record) -> RecorderFuture<'_, ()> { + let code = ErrorCode::Io; + Box::pin(async move { Err(Error::new(code, "the disk is full", code.prose())) }) + } + + fn end_run(&self, run: RunId, outcome: RunOutcome) -> RecorderFuture<'_, ()> { + self.0.end_run(run, outcome) + } +} + +#[tokio::test] +async fn a_refused_parse_event_fails_preparation_as_a_recorder_failure_naming_the_run() { + let mut services = services(&recorder(), bare()); + services.recorder = Arc::new(RefusingAppends(MemoryRecorder::new())); + let error = begin_and_prepare(PLAIN, services) + .await + .expect_err("the first parse event is refused"); + assert_eq!(error.code, ErrorCode::RecorderFailed); + assert_eq!(error.message, "the run recorder failed: the disk is full"); + assert_eq!(run_of(&error), RunId::from_raw(1)); +} + #[tokio::test] async fn two_prepared_runs_draw_different_seeds_and_both_begin_at_the_recorder() { let recorder = recorder(); 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..8070c129a 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, ErrorCode, FactKey}; 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, @@ -33,6 +34,19 @@ pub(crate) const EXECUTION: &str = "runner-test"; /// The fixed seed every fixture run and recorded meta takes. pub(crate) const TEST_SEED: RunSeed = RunSeed::from_bytes([7; 32]); +/// Asserts a report's `output` is the failure of a run that did not +/// complete. +pub(crate) fn assert_not_completed(output: &Result) { + let Err(error) = output else { + panic!("a run that did not complete reports no output: {output:?}"); + }; + assert_eq!(error.code, ErrorCode::NoOutput); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&json!("not_completed")) + ); +} + /// A registry holding the built-ins and each of `infos` with a `null` /// config, which a suite builds its Host over. pub(crate) fn registry(infos: &[PackInfo]) -> PackRegistry { @@ -69,7 +83,7 @@ fn prompt(sections: &[(&str, &str)]) -> Arc { for (name, lua) in sections { write!(source, "\n## {name}\n\n```lua\n{lua}\n```\n").expect("a String takes any write"); } - let (prompt, _parse_events) = Prompt::parse(&source, EXECUTION); + let (prompt, _parse_events) = Prompt::parse(&source, "prompt", EXECUTION); Arc::new(prompt.expect("the fixture prompt parses")) } @@ -127,7 +141,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 +152,7 @@ impl InferenceBroker for Unused { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { unreachable!("no test issues a Chat effect") } } @@ -151,7 +165,7 @@ impl ToolPerformer for Unused { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { unreachable!("this test issues no ToolCall effect") } } @@ -184,7 +198,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 +215,7 @@ impl ToolPerformer for PendingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { Box::pin(std::future::pending()) } } @@ -218,9 +232,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 +256,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 +314,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 +391,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..c158f3766 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))) }) } } @@ -75,6 +76,7 @@ async fn main() -> Result<(), Box> { let request = RunRequest { name: "desk-greet-1".to_owned(), source: GREET.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: Some("desk".to_owned()), vfs: VfsRef::default(), diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index d06f3beec..9f91fc71e 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -13,19 +13,17 @@ //! Every run gets a record and ends with one outcome: completed, failed //! with a kind and a message, or cancelled. The record begins before //! anything else, so a run cancelled before it starts, or one whose model -//! cannot be resolved, is recorded too. The only [`HarnessError`] is a -//! recorder that refuses a write. +//! cannot be resolved, is recorded too. The only error [`Harness::run`] +//! returns is a `RecorderFailed` failure: a recorder that refuses a write. //! //! The crate's `run-prompt` example runs one prompt from start to end. pub use harness_runner::Harness; -pub use harness_runner::HarnessError; pub use harness_runner::RunControl; pub use harness_runner::RunReport; pub use harness_runner::RunRequest; pub use harness_runner::display_chain; pub use harness_runner::environment::HostSnapshot; -pub use harness_runner::files::OutputError; pub use harness_runner::performers::BoxFuture; pub use harness_runner::performers::InferenceBroker; pub use harness_runner::performers::Timer; @@ -38,7 +36,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 +47,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 +61,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..85562ea2d 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) @@ -94,6 +95,7 @@ async fn a_sections_round_reaches_the_broker_as_chat_and_a_nested_infer_round_as .run(RunRequest { name: "rounds-1".to_owned(), source: INFERS_THEN_CHATS.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), diff --git a/crates/harness/tests/suite/host.rs b/crates/harness/tests/suite/host.rs index 08ecc7c99..6636002a5 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}; @@ -61,6 +60,7 @@ fn request(name: &str, source: &str) -> RunRequest { RunRequest { name: name.to_owned(), source: source.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), @@ -85,7 +85,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 +122,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 +134,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 +167,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 +179,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/launch.rs b/crates/harness/tests/suite/launch.rs index 649c00da8..9e736e068 100644 --- a/crates/harness/tests/suite/launch.rs +++ b/crates/harness/tests/suite/launch.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use harness::pack::{HostContext, HostServices, PackRegistry}; use harness::record::{MemoryRecorder, RunOutcome}; -use harness::vfs::{Origin, VfsError, VfsRef}; -use harness::{Harness, HostSnapshot, OutputError, RunRequest}; +use harness::vfs::{Origin, VfsRef}; +use harness::{Harness, HostSnapshot, RunRequest}; +use runvil::error::{ErrorCode, FactKey}; use crate::support::{Clock, Offline}; @@ -35,6 +36,7 @@ fn request(source: &str, vfs: VfsRef) -> RunRequest { RunRequest { name: "shouts-1".to_owned(), source: source.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: Some("hello".to_owned()), vfs, @@ -72,20 +74,10 @@ async fn a_run_that_does_not_complete_reports_no_output() { .await .expect("the run reaches an outcome"); assert!(matches!(report.outcome, RunOutcome::Failed { .. })); - assert_eq!(report.output, Err(OutputError::NotCompleted)); -} - -#[test] -fn an_output_store_failure_renders_its_own_message_and_sources_the_engines_error() { - let error = OutputError::Vfs { - path: "report.md".to_owned(), - source: VfsError::NotFound { - path: "report.md".to_owned(), - }, - }; + let error = report.output.expect_err("a failed run reports no output"); + assert_eq!(error.code, ErrorCode::NoOutput); assert_eq!( - error.to_string(), - "the output file `report.md` could not be read" + error.facts.get(&FactKey::Reason), + Some(&serde_json::json!("not_completed")) ); - assert!(std::error::Error::source(&error).is_some()); } 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..2f1267b15 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")) @@ -140,6 +140,7 @@ async fn a_hosts_edit_of_a_file_the_run_read_conflicts_while_the_run_waits_on_it let request = RunRequest { name: "desk-review-1".to_owned(), source: REVIEW.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: vfs.clone(), 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/Cargo.toml b/crates/runvil-internal/engine/Cargo.toml index 9a9738fb5..d7efaeeaa 100644 --- a/crates/runvil-internal/engine/Cargo.toml +++ b/crates/runvil-internal/engine/Cargo.toml @@ -16,7 +16,6 @@ runvil-vfs.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true -thiserror.workspace = true mlua.workspace = true [features] diff --git a/crates/runvil-internal/engine/benches/models_loop.rs b/crates/runvil-internal/engine/benches/models_loop.rs index 796c61382..a70b68061 100644 --- a/crates/runvil-internal/engine/benches/models_loop.rs +++ b/crates/runvil-internal/engine/benches/models_loop.rs @@ -73,7 +73,7 @@ const LOOP_PROMPT: &str = "---\nname: bench_loop\ndescription: d\nrunvil: 0\n--- /// Parses the loop prompt once for the whole benchmark. fn parse_loop_prompt() -> Prompt { - Prompt::parse(LOOP_PROMPT, EXECUTION) + Prompt::parse(LOOP_PROMPT, "prompt", EXECUTION) .0 .expect("the bench prompt parses") } diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 3a0c5b316..6e58cefcb 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -1,92 +1,57 @@ -//! The crate's internal error type. +//! The crate's error: the universal [`Error`] every Engine failure is, and +//! the constructors that build each failure the Engine raises itself. //! -//! [`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. -//! The `From` bridges that let internal `?` reach this type live in `convert`. +//! A run a failure ends reports a kind and a message in +//! [`RunOutcome::Failed`](crate::RunOutcome::Failed), both read from the +//! error's code, message, location, and facts. A failure that reaches an +//! author's Lua renders as the error table the Lua crate builds from the +//! same code and facts. A parse, a model round, a tool, a `{{ }}` +//! substitution, and the Lua crate fail with this type already; the Lua +//! crate owns the constructors of the failures it shares with the Engine, +//! which this module re-exports. //! -//! A variant that keeps an originating error renders that error's text in -//! its own `Display` and holds the error as a plain `cause` field, never as -//! a `#[source]`, so a printer that walks the source chain shows the -//! cause's text once. -//! -//! Three variants are `#[error(transparent)]` and so keep no `cause` field -//! of their own: `Parse`, `Completion`, and `Substitution` hand their whole -//! `Display` and `source` to the error they hold. That is the exception to -//! the field rule above, and it is why `Error::originating_error` answers -//! `None` for `Completion` and `Substitution` and the loader's own cause for -//! `Parse`. +//! Each constructor gives its failure the text it has always had as the +//! message, and the code's default prose unless the failure has +//! model-facing text of its own. A cause's text joins the message through +//! [`Error::with_cause`]. -use runvil_types::ids::TaskId; +use std::borrow::Cow; -use crate::lua::DiscoveryTool; +#[cfg(test)] +pub(crate) use runvil_lua::{context_exhausted, lua_quota, tool_loop_exhausted}; +pub(crate) use runvil_lua::{ + interrupted, lua_runtime, task_cancelled, task_consumed, task_not_owned, +}; +pub(crate) use runvil_types::error::Error; +use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::ids::TaskId; +use serde_json::Value; -mod convert; mod sets; #[cfg(test)] mod tests; -mod value; +mod tools; -pub(crate) use sets::{SetEvidence, SetRule, SetTool}; +pub(crate) use sets::{SetEvidence, SetRule, SetTool, tool_sets}; +pub(crate) use tools::{bind_schema, model_only_tool, out_of_scope_tool, unbound_tool}; -/// A type-erased owned error cause used by the internal error type. -type BoxedSource = Box; - -/// Renders task ids as a comma-separated list: the [`Error::TasksLive`] -/// message and its `tasks` field. -fn join_task_ids(tasks: &[TaskId]) -> String { - tasks - .iter() - .map(ToString::to_string) - .collect::>() - .join(", ") -} +/// Crate-internal result alias over [`Error`]. +pub(crate) type Result = std::result::Result; -/// The text an optional cause appends to a message: `: ` and the cause's -/// own text, or nothing when there is no cause. -fn cause_suffix(cause: Option<&BoxedSource>) -> String { - cause.map(|cause| format!(": {cause}")).unwrap_or_default() +/// Builds a failure with `code`, `message`, and the code's default prose. +fn failure(code: ErrorCode, message: impl Into) -> Error { + Error::new(code, message, code.prose()) } -/// The [`Error::OutOfScopeToolCall`] message: the name the model tried, -/// the Static Set's aliases labelled as what they are, the offered-but- -/// unscoped hint when the name is a catalog tool's wire name, and where a -/// Dynamic Set tool is reached from when the section has a Dynamic Set. -fn out_of_scope_message( - name: &str, - global_exists: bool, - static_aliases: &[String], - has_dynamic_set: bool, -) -> String { - let hint = if global_exists { - " (a catalog tool that was not offered in this section)" - } else { - "" - }; - // A section whose Dynamic Set holds nothing freezes no discovery tool, - // so naming one would point the model at a tool it cannot call. - let dynamic_set = if has_dynamic_set { - format!( - "; a Dynamic Set tool is reached through {} and {}", - DiscoveryTool::Search.wire_name(), - DiscoveryTool::Call.wire_name() - ) - } else { - String::new() - }; - format!( - "tool {name:?} is not in this section's scope; aliases this section offers: \ - {static_aliases:?}{hint}{dynamic_set}" - ) +/// Gives `error` the fact `key` with `value`. +fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { + error.facts.insert(key, value.into()); + error } -/// The call name [`Error::CallDepthExceeded`] reports for a nesting that -/// passed the call-depth cap. The name is the cap's, not the author's -/// construct: `tasks.fanout` for a fanout arm, and `call` for a `call` or a +/// The call that passed the call-depth cap, which a `CallDepthExceeded` +/// failure names. The name is the cap's, not the author's construct: +/// `tasks.fanout` for a fanout arm, and `call` for a `call` or a /// `tasks.spawn`, which shares its cap. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DepthVia { @@ -96,6 +61,16 @@ pub(crate) enum DepthVia { Fanout, } +impl DepthVia { + /// The failure's `Reason` fact: `call` or `fanout`. + fn tag(self) -> &'static str { + match self { + DepthVia::Call => "call", + DepthVia::Fanout => "fanout", + } + } +} + impl std::fmt::Display for DepthVia { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { @@ -105,392 +80,121 @@ impl std::fmt::Display for DepthVia { } } -/// The crate's internal error type, spanning parsing, model, and execution -/// failures. -/// -/// The model variant holds the broker's closed error whole, so no HTTP -/// client's error type leaks through its `source()`. -#[derive(Debug, thiserror::Error)] -pub(crate) enum Error { - /// The prompt failed to parse: invalid frontmatter YAML or a - /// structurally-classified failure. - /// - /// Holds the loader's [`ParseError`](crate::prompt::ParseError) whole, so - /// its stable kind, its location fields, and the YAML decode failure a - /// frontmatter failure keeps as its cause survive until the run's - /// failure message is built from them. - #[error(transparent)] - Parse(crate::prompt::ParseError), - - /// 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 - /// from them. The Engine branches on - /// its kind only: a context overflow takes the provider overflow path - /// and an empty reply takes the empty-answer path. - #[error(transparent)] - Completion(crate::model::CompletionError), - - /// The run was cancelled, the caller dropped an effect the run was - /// waiting on, or a block raised a caught `cancelled` error value again - /// after another suspending call. - #[error("interrupted: the run was cancelled or this call was stopped")] - Interrupted, - - /// A condition the prompt author caused and can fix in Lua, such as an - /// empty `messages` list, a heading that names no section, or a - /// section's Lua phase failing at runtime or while bridging Engine - /// values. - /// - /// It also holds the message of a raised error table whose kind needs - /// structure the table does not hold (see `Error::from_raised`). - /// `cause` holds the originating `mlua` error when there is one. The - /// message is the specific failure as a noun phrase; the run's failed - /// outcome classifies this as a Lua failure, so no redundant - /// `lua error:` type label is prepended. - #[error("{message}")] - Lua { - /// The diagnostic, location-tagged when it maps to a prompt line. - message: String, - /// The originating Lua error, whose text `message` already holds. - cause: Option, - }, - - /// Lua source was not syntactically valid at its prompt location. - /// - /// Keeps the originating `mlua` compile error as the cause beside the - /// location metadata, so the typed compiler diagnostic survives - /// instead of being flattened into `message` alone. - #[error("lua compilation error at {location} (line {source_line}): {message}")] - LuaCompile { - /// The prompt region supplied by the parser, such as a section prologue. - location: String, - /// 1-based line number in the prompt source where this Lua region starts. - source_line: u32, - /// The retained source that failed to compile. - lua_source: String, - /// The Lua 5.5 compiler diagnostic. - message: String, - /// The originating `mlua` compile error, whose text `message` holds. - cause: BoxedSource, - }, - - /// Building a model-facing tool schema for a bound alias failed. The - /// message renders the schema validation failure, kept as the cause. - /// - /// Constructed only by the tool-scope preparation. - #[error("model-facing schema build failure for tool alias {alias:?}: {cause}")] - BindSchema { - /// The prompt-local alias whose schema could not be built. - alias: String, - /// The originating schema validation failure, whose text the - /// message holds. - cause: BoxedSource, - }, - - /// A section entry's frozen sets, or the render answer they are merged - /// from, broke a rule of the merge that builds them at the entry's - /// first loop round, which fails that round. The message names each - /// tool that broke it, with its Pack and its set, or the Packs a - /// render answered, and the rule. - /// - /// Constructed only by the merge. - #[error("{}", sets::sets_message(.evidence))] - ToolSets { - /// What broke the rule: the tools that broke one, or the Packs a - /// render answered. - evidence: SetEvidence, - }, - - /// A `{{ }}` prose substitution failed (unknown/missing path, unclosed). - /// - /// Holds a typed [`crate::subst::SubstitutionError`] with a stable kind, - /// the byte offset of the offending placeholder, a bounded preview, and any - /// preserved serialization source, rather than a flattened string. The - /// substitution error is the whole message and its cause chain, so the - /// variant is transparent over it. - #[error(transparent)] - Substitution(Box), - - /// The tool-call loop ran its iteration cap without a final text reply. - #[error("tool-call loop did not converge")] - ToolLoopExhausted, - - /// A chain ended while tasks it owned were still live. - /// - /// A spawned task ends with its owner, so a task the author neither - /// waited on nor cancelled is the author's bug: the chain's outcome - /// becomes this error (the run's for the root walk, the call's answer - /// for a `call` chain) and the leaked tasks are abandoned. The message - /// names the ids in spawn order, and the Lua table holds them as one - /// `tasks` string joined by `, `. - #[error( - "chain ended with tasks still live: {}; wait on or cancel every task a chain spawns before it ends", - join_task_ids(.tasks) - )] - TasksLive { - /// The live tasks, in spawn order. - tasks: Vec, - }, - - /// A task operation named a task the caller does not own. - /// - /// Only the task's owner - its spawner, or the root walk for a task the - /// H1 pass spawned - may wait on, inspect, or cancel it; a chain may - /// additionally read the status of, and annotate, the task it runs - /// inside. An id that names no task at all is refused the same - /// way, so a caller learns nothing about tasks it never started. - #[error("task `{task}` is not a task this chain owns")] - TaskNotOwned { - /// The task the caller reached for. - task: TaskId, - }, - - /// A wait named a task whose result was already delivered once. - #[error("task `{task}` was already delivered: a task's result is taken by one wait")] - TaskConsumed { - /// The task whose result was taken. - task: TaskId, - }, - - /// The failure a wait delivers for a task its owner cancelled instead - /// of letting it end on its own: the member's `ok = false` error value, - /// kind `cancelled`, with a `task` field. An abandoned task is never - /// delivered - it lost its owner, and only the owner may wait - so - /// this is the one non-`Done` delivery. - #[error("task `{task}` was cancelled")] - TaskCancelled { - /// The task that was cancelled. - task: TaskId, - }, - - /// The model called a name outside both of the section entry's frozen - /// sets. - /// - /// This is the model tool loop's error alone: a script `tools.call` - /// resolves against the run's whole catalog and fails with - /// [`Error::UnboundToolCall`] instead. - #[error( - "{}", - out_of_scope_message(.name, *.global_exists, .static_aliases, *.has_dynamic_set) - )] - OutOfScopeToolCall { - /// The alias or identifier the model tried to use. - name: String, - /// Whether the name is a catalog tool's wire name. - global_exists: bool, - /// Whether the section has a Dynamic Set holding a tool, and so - /// offers the two discovery tools. - /// - /// The caller reads it from the frozen sets. The message must not - /// infer it from `static_aliases`: a catalog tool may take a - /// discovery tool's wire name while no Dynamic Set holds a tool. - has_dynamic_set: bool, - /// The Static Set's wire names: this section's own offers, with the - /// two discovery tools when a Dynamic Set holds a tool. - static_aliases: Vec, - }, - - /// A `tools.call` named no adhoc tool and no tool in the run's catalog. - /// - /// Script-initiated dispatch resolves a canonical id against the run's - /// whole catalog, not the section's frozen sets - the sets shape what - /// the model is offered, and the author's own code is not the model - - /// so this error means the name is no catalog tool's id at all; a wire - /// name is the model's, never a script's. - #[error("tool {name:?} is not a tool in this run; catalog tools: {ids:?}")] - UnboundToolCall { - /// The name the call tried to dispatch. - name: String, - /// The id of every catalog tool, in id order. - ids: Vec, - }, - - /// A `tools.call` named a catalog tool that only a model may call. - /// - /// A Pack marks a tool model-only when a render gives its arguments a - /// script cannot know, so the tool stays in the catalog and a section - /// offers it, while only a model's call can supply arguments that fit. - #[error( - "tool {name:?} ({id}) is model-only: a render shapes its arguments for the model that \ - receives them, so a script cannot call it; offer it with tools.offer and let the model \ - call it" - )] - ModelOnlyToolCall { - /// The name the call tried to dispatch. - name: String, - /// The catalog tool's id. - id: String, - }, - - /// A `models.infer` or `models.loop` round found no current model, - /// because the caller supplied none. - #[error("model binding required for section {section}")] - ModelRequired { - /// The name of the section whose round had no binding, or the - /// prompt's H1 title for a round in the H1 pass. - section: String, - }, - - /// The prompt declares a `runvil:` major this build does not support, - /// so it is refused rather than run under mismatched rules. - /// - /// Major 0 is the only supported major, and the `Display` names it - /// beside the major the prompt declared. - #[error("unsupported runvil version: {0} (this build supports major 0)")] - UnsupportedVersion(u32), +/// An internal invariant failure: a state the surrounding code has already +/// guaranteed cannot occur, surfaced rather than skipped so it cannot pass +/// for a successful fall-through. Its location is the Rust source position +/// of the call site, which the run's failure message ends with. The message +/// is owned when it names a value. +#[track_caller] +pub(crate) fn internal(message: impl Into>) -> Error { + runvil_lua::internal_at(message, std::panic::Location::caller()) +} - /// The environment cannot satisfy the prompt, for any cause - /// [`RunErrorKind::RequirementsUnmet`](crate::RunErrorKind::RequirementsUnmet) - /// lists. - /// - /// The notice, followed by the cause's text when there is one, is the - /// whole message, written to be read by a model. - #[error("{notice}{}", cause_suffix(.cause.as_ref()))] - RequirementsUnmet { - /// The model-readable refusal notice, one line per gap. - notice: String, - /// The failure the refusal stands in for, when one raised it. - cause: Option, - }, +/// An internal invariant failure that names the failure which broke the +/// invariant after its own text, capturing the call site as [`internal`] +/// does. +#[track_caller] +pub(crate) fn internal_with_cause( + message: impl Into>, + cause: &(dyn std::error::Error + 'static), +) -> Error { + internal(message).with_cause(cause) +} - /// 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. - #[error("tool call failure: {message}")] - Tool { - /// The tool's model-safe failure message. - message: String, - /// The originating tool error, whose text `message` holds. - cause: BoxedSource, - }, +/// A prompt that declares the `runvil:` major `version`, which this build +/// does not support, so it is refused rather than run under mismatched +/// rules. Major 0 is the only supported major. +pub(crate) fn unsupported_version(version: u32) -> Error { + failure( + ErrorCode::UnsupportedVersion, + format!("unsupported runvil version: {version} (this build supports major 0)"), + ) +} - /// An internal runtime invariant was violated (a state the surrounding code - /// has already guaranteed cannot occur). Surfaced as a concrete error rather - /// than silently skipping work, so an impossible state cannot masquerade as a - /// successful fall-through. - /// - /// The Rust source position of the construction site is captured (via - /// [`Error::internal`] or [`Error::internal_with_cause`], both - /// `#[track_caller]`) so the run's failure message can point at the - /// broken invariant. A Lua-crate internal failure brings its own - /// captured site. - #[error("internal invariant violated: {message}{}", cause_suffix(.cause.as_ref()))] - Internal { - /// The violated invariant, as a noun phrase. - message: std::borrow::Cow<'static, str>, - /// The Rust source file of the construction site. - file: &'static str, - /// The 1-based line of the construction site. - line: u32, - /// The failure that broke the invariant, when one did. - cause: Option, - }, +/// The environment's refusal of the prompt: `notice`, the model-readable +/// refusal with one line per gap, is the prose, and the message follows it +/// with the text of the failure the refusal stands in for, when one raised +/// it. +pub(crate) fn requirements_unmet( + notice: &str, + cause: Option<&(dyn std::error::Error + 'static)>, +) -> Error { + let refusal = Error::new(ErrorCode::RequirementsUnmet, notice, notice); + match cause { + Some(cause) => refusal.with_cause(cause), + None => refusal, + } +} - /// A Lua quota ran out: a `log()` event or byte budget. This stable - /// typed variant lets the caller tell quota exhaustion apart from an - /// authoring error. - #[error("lua {resource} quota exceeded")] - LuaQuota { - /// The exhausted resource. - resource: crate::lua::QuotaResource, - }, +/// A `models.infer` or `models.loop` round that found no current model, +/// because the caller supplied none. `section` is the section whose round +/// had no binding, or the prompt's H1 title for a round in the H1 pass. +pub(crate) fn model_required(section: &str) -> Error { + with_fact( + failure( + ErrorCode::ModelRequired, + format!("model binding required for section {section}"), + ), + FactKey::Section, + section, + ) +} - /// A `call`, `tasks.spawn`, or `tasks.fanout` arm would run deeper - /// than the call-depth cap allows. The dispatch's answer, so an - /// author `pcall` catches it, and a quota when it ends the run. - #[error("{via} recursion exceeded cap of {cap}")] - CallDepthExceeded { - /// The deepest nesting a chain may run at. - cap: usize, - /// The call that tripped the cap. - via: DepthVia, - }, +/// A `call`, `tasks.spawn`, or `tasks.fanout` arm that would run deeper +/// than `cap`, through `via`. The dispatch's answer, so an author `pcall` +/// catches it, and a quota when it ends the run. +pub(crate) fn call_depth_exceeded(cap: usize, via: DepthVia) -> Error { + let error = with_fact( + failure( + ErrorCode::CallDepthExceeded, + format!("{via} recursion exceeded cap of {cap}"), + ), + FactKey::Reason, + via.tag(), + ); + with_fact(error, FactKey::Limit, cap) +} - /// `compactors.fail` exhausted the model's context window: the request - /// overflowed on the pre-dispatch precheck or at the provider and the - /// selected compactor did not compact, or a caller raised it with its - /// own reason. - #[error("context exhausted: {}", crate::lua::exhaustion_detail(reason))] - ContextExhausted { - /// `precheck` or `provider` after an overflow, else the caller's reason. - reason: String, - }, +/// A chain that ended while the tasks it owned were still live, which it +/// names in spawn order. The `Tasks` fact holds their ids. +pub(crate) fn tasks_live(tasks: &[TaskId]) -> Error { + let ids: Vec = tasks.iter().map(ToString::to_string).collect(); + let message = format!( + "chain ended with tasks still live: {}; wait on or cancel every task a chain spawns \ + before it ends", + ids.join(", ") + ); + with_fact(failure(ErrorCode::TasksLive, message), FactKey::Tasks, ids) +} - /// A run-scoped store operation failed at the virtual filesystem layer, - /// keeping the concrete [`runvil_vfs::VfsError`] as the cause so a - /// backend failure survives as a typed error instead of being - /// flattened to a string. The message is the model-facing rendering for - /// the operation (or the probe text, which says when the run declares no - /// store), kept beside the error because the error alone does not name - /// the operation. - /// - /// The cause is the concrete `VfsError` rather than the `BoxedSource` - /// the other cause-bearing variants hold, because the Lua error table - /// carries the tag and fields this crate rebuilds that type from, so a - /// raise that crossed the Lua boundary can be turned back into the - /// variant it stands in for. - #[error("{message}")] - Store { - /// The rendered store failure message, from the Lua crate's - /// `store_error_message` or `store_probe_message`. - message: String, - /// The structured failure the message renders. - cause: runvil_vfs::VfsError, - }, +/// A store failure with no operation to render, such as a handle +/// acquisition or the declared-store probe: the store's code and facts, +/// and a message that tells a run that declares no store so and follows +/// the fixed probe text for any other failure. +pub(crate) fn store(cause: &runvil_vfs::VfsError) -> Error { + store_with_message(cause, crate::lua::store_probe_message(cause)) +} - /// Two execution identities claimed one store path: the claims - /// model's conflict, mapped from the store's write-race vocabulary at - /// the yield-answer boundary. Fatal to the run on the spot and never - /// resumed into Lua, so no author `pcall` can catch it; the message is - /// the claims model's whole diagnosis, naming the canonical path, both - /// identities, and both claim kinds. - #[error("store determinism violation: {0}")] - Determinism(String), +/// One store operation's failure: the store's code and facts, and the +/// model-facing message rendered with the operation for its wording. +pub(crate) fn store_op(op: &crate::lua::VfsOp, cause: &runvil_vfs::VfsError) -> Error { + store_with_message(cause, crate::lua::store_error_message(op, cause)) } -impl Error { - /// Returns the originating error a cause-bearing variant keeps, whose - /// text its `Display` already holds; `None` for every other variant. - #[cfg(test)] - pub(crate) fn originating_error( - &self, - ) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { - match self { - Error::Parse(error) => error.cause(), - Error::BindSchema { cause, .. } - | Error::LuaCompile { cause, .. } - | Error::Tool { cause, .. } => Some(cause.as_ref()), - Error::Lua { cause, .. } - | Error::RequirementsUnmet { cause, .. } - | Error::Internal { cause, .. } => cause.as_deref(), - Error::Store { cause, .. } => Some(cause), - Error::Completion(_) - | Error::Interrupted - | Error::ToolSets { .. } - | Error::Substitution(_) - | Error::ToolLoopExhausted - | Error::TasksLive { .. } - | Error::TaskNotOwned { .. } - | Error::TaskConsumed { .. } - | Error::TaskCancelled { .. } - | Error::OutOfScopeToolCall { .. } - | Error::UnboundToolCall { .. } - | Error::ModelOnlyToolCall { .. } - | Error::ModelRequired { .. } - | Error::UnsupportedVersion(_) - | Error::LuaQuota { .. } - | Error::CallDepthExceeded { .. } - | Error::ContextExhausted { .. } - | Error::Determinism(_) => None, - } - } +/// The store failure `cause` with `message` in place of its own text. +fn store_with_message(cause: &runvil_vfs::VfsError, message: String) -> Error { + let mut error = runvil_lua::store_error(cause); + error.message = message; + error } -/// Crate-internal result alias over [`Error`]. -pub(crate) type Result = std::result::Result; +/// Two execution identities that claimed one store path: `detail` is the +/// claims model's whole diagnosis, naming the canonical path, both +/// identities, and both claim kinds. Fatal to the run on the spot and +/// never resumed into Lua, so no author `pcall` can catch it. +pub(crate) fn determinism(detail: &str) -> Error { + failure( + ErrorCode::Determinism, + format!("store determinism violation: {detail}"), + ) +} diff --git a/crates/runvil-internal/engine/src/error/convert.rs b/crates/runvil-internal/engine/src/error/convert.rs deleted file mode 100644 index 78f1e59fa..000000000 --- a/crates/runvil-internal/engine/src/error/convert.rs +++ /dev/null @@ -1,355 +0,0 @@ -//! Building the internal error: its constructors, the `From` bridges that -//! map the model-client, parser, and Lua crates' errors onto it variant -//! for variant, and the rebuild of a raised Lua error table into the -//! variant it stands in for. - -use std::borrow::Cow; - -use runvil_lua::Error as LuaError; -use runvil_prompt::Error as ParserError; -use runvil_types::ids::TaskId; -use runvil_vfs::VfsError; - -use super::{BoxedSource, Error}; - -impl Error { - /// Builds a parse failure with a stable classification and no source span. - pub(crate) fn parse(kind: crate::prompt::ParseErrorKind, message: impl Into) -> Error { - Error::Parse(crate::prompt::detail::parse_error(kind, message)) - } - - /// Stamps the prompt's frontmatter name onto a parse failure raised by - /// `run` itself: the prompt is already parsed at that point, so the - /// name is known and the failure's message can name it. Any other variant passes - /// through unchanged. - pub(crate) fn with_prompt_name(self, name: &str) -> Error { - match self { - Error::Parse(error) => { - Error::Parse(crate::prompt::detail::with_prompt_name(error, name)) - } - other => other, - } - } - - /// Builds an internal-invariant failure, capturing the Rust source - /// position of the call site so the run's failure message can point at - /// the broken invariant. The message is owned when it names a value. - #[track_caller] - pub(crate) fn internal(message: impl Into>) -> Error { - let location = std::panic::Location::caller(); - Error::Internal { - message: message.into(), - file: location.file(), - line: location.line(), - cause: None, - } - } - - /// Builds an internal-invariant failure at a construction site the - /// caller already captured, such as the one a Lua-crate internal - /// failure brings. - pub(crate) fn internal_at( - message: impl Into>, - location: &'static std::panic::Location<'static>, - ) -> Error { - Error::Internal { - message: message.into(), - file: location.file(), - line: location.line(), - cause: None, - } - } - - /// Builds an internal-invariant failure that keeps the failure which - /// broke the invariant as its cause, capturing the call site as - /// [`Error::internal`] does. - #[track_caller] - pub(crate) fn internal_with_cause( - message: impl Into>, - cause: impl Into, - ) -> Error { - let location = std::panic::Location::caller(); - Error::Internal { - message: message.into(), - file: location.file(), - line: location.line(), - cause: Some(cause.into()), - } - } - - /// Wraps a store failure that has no operation to render - a handle - /// acquisition, the declared-store probe - as [`Error::Store`]: a run - /// that declares no store is told so, and any other failure follows the - /// fixed probe text. - pub(crate) fn store(cause: VfsError) -> Error { - Error::Store { - message: crate::lua::store_probe_message(&cause), - cause, - } - } - - /// Wraps one store operation's failure as [`Error::Store`], rendering - /// the model-facing message with the operation for its wording. - pub(crate) fn store_op(op: &runvil_lua::VfsOp, cause: VfsError) -> Error { - Error::Store { - message: crate::lua::store_error_message(op, &cause), - cause, - } - } - - /// Builds an [`Error::Lua`] that keeps only `message`, with no - /// originating `mlua` error. - pub(crate) fn lua_message(message: impl Into) -> Error { - Error::Lua { - message: message.into(), - cause: None, - } - } - - /// Wraps an `mlua` failure as [`Error::Lua`], keeping it as the cause - /// rather than flattening it to a string. - #[cfg(test)] - pub(super) fn lua(cause: mlua::Error) -> Error { - Error::Lua { - message: cause.to_string(), - cause: Some(Box::new(cause)), - } - } -} - -impl From for Error { - fn from(error: crate::subst::SubstitutionError) -> Error { - Error::Substitution(Box::new(error)) - } -} - -impl From for Error { - fn from(error: crate::model::CompletionError) -> Error { - Error::Completion(error) - } -} - -/// Maps a parse failure onto this internal type: a frontmatter or -/// structured failure stays a parse failure whole, and a Lua compile or -/// internal failure maps onto its own variant. The parser crate's error -/// type is not `#[non_exhaustive]` (the two crates version together), so -/// this match is total. -impl From for Error { - fn from(error: crate::prompt::ParseError) -> Self { - match crate::prompt::detail::parse_error_into_inner(error) { - inner - @ (ParserError::ParseFrontmatter { .. } | ParserError::ParseStructured { .. }) => { - Error::Parse(crate::prompt::detail::parse_error_from_inner(inner)) - } - ParserError::Lua(lua) => Error::from(lua), - ParserError::Internal(message) => Error::internal(message), - } - } -} - -/// Maps the Lua crate's internal type onto this one variant for variant, -/// and rebuilds a raised error table through [`Error::from_raised`]. The -/// Lua crate's error type is not `#[non_exhaustive]` (the two crates -/// version together), so this match is total. -impl From for Error { - fn from(error: LuaError) -> Error { - match error { - LuaError::Lua(message) => Error::lua_message(message), - LuaError::LuaRuntime { message, cause } => Error::Lua { - message, - cause: Some(cause), - }, - LuaError::LuaCompile { - location, - source_line, - lua_source, - message, - cause, - } => Error::LuaCompile { - location, - source_line, - lua_source, - message, - cause, - }, - LuaError::LuaQuota { resource } => Error::LuaQuota { resource }, - LuaError::ContextExhausted { reason } => Error::ContextExhausted { reason }, - LuaError::Interrupted => Error::Interrupted, - LuaError::Tool { message, cause } => Error::Tool { message, cause }, - LuaError::Store { message, cause } => Error::Store { message, cause }, - LuaError::Internal { message, location } => Error::internal_at(message, location), - LuaError::Raised(raised) => Error::from_raised(raised), - } - } -} - -/// The `task` field of a raised task-error table, when it parses. -fn raised_task(raised: &runvil_lua::Raised) -> Option { - raised - .fields - .get("task") - .and_then(runvil_lua::ErrorField::as_str) - .and_then(|task| task.parse().ok()) -} - -/// The field `name` of a raised error table, when it is a string. -fn raised_field<'a>(raised: &'a runvil_lua::Raised, name: &str) -> Option<&'a str> { - raised - .fields - .get(name) - .and_then(runvil_lua::ErrorField::as_str) -} - -/// Rebuilds the structured [`runvil_vfs::VfsError`] a raised store -/// error table stands in for, from its `reason`, its fields, and its -/// message. The table was built from the same variant by -/// [`store_error_value_fields`](runvil_lua::store_error_value_fields), so the -/// reconstruction is exact where the fields round-trip; a malformed table -/// degrades to the closest variant rather than panicking. -fn raised_store_error(raised: &runvil_lua::Raised) -> VfsError { - use runvil_vfs::PathReason; - let path = raised_field(raised, "path").unwrap_or_default().to_owned(); - match raised_field(raised, "reason") { - Some("not_found") => VfsError::NotFound { path }, - Some("anchor") => VfsError::Anchor { - path, - anchor: raised_field(raised, "anchor") - .unwrap_or_default() - .to_owned(), - count: raised - .fields - .get("count") - .and_then(runvil_lua::ErrorField::as_integer) - .and_then(|count| usize::try_from(count).ok()) - .unwrap_or(0), - }, - Some("invalid_path") => VfsError::InvalidPath { - path, - reason: raised_field(raised, "rule") - .and_then(PathReason::from_tag) - .unwrap_or(PathReason::Empty), - }, - Some("invalid_range") => VfsError::InvalidRange { - path, - reason: "the bounds are invalid", - }, - Some("not_utf8") => VfsError::NotUtf8 { - path, - valid_up_to: raised - .fields - .get("valid_up_to") - .and_then(runvil_lua::ErrorField::as_integer) - .and_then(|offset| usize::try_from(offset).ok()) - .unwrap_or(0), - }, - Some("is_a_directory") => VfsError::IsADirectory { path }, - Some("not_a_directory") => VfsError::NotADirectory { path }, - Some("directory_not_empty") => VfsError::DirectoryNotEmpty { path }, - Some("already_exists") => VfsError::AlreadyExists { path }, - Some("scope_ended") => VfsError::ScopeEnded { path }, - Some("read_only") => VfsError::ReadOnly { path }, - Some("root_protected") => VfsError::RootProtected { path }, - Some("no_store") => VfsError::NoStore, - // The variants below carry only `path` beside the message, so - // their text fields degrade to empty, and an I/O failure's kind - // to `Other` with no OS error code. - Some("permission_denied") => VfsError::PermissionDenied { - path, - reason: String::new(), - }, - Some("approval_required") => VfsError::ApprovalRequired { - path, - reason: String::new(), - }, - Some("outside_root") => VfsError::OutsideRoot { - path, - detail: String::new(), - }, - Some("cross_mount") => VfsError::CrossMount { - path, - detail: String::new(), - }, - Some("io") => VfsError::Io { - path, - kind: std::io::ErrorKind::Other, - raw_os_error: None, - message: String::new(), - }, - Some("unsupported") => VfsError::Unsupported { - path, - detail: String::new(), - }, - Some("conflict") => VfsError::Conflict { - path, - detail: raised.message.clone(), - }, - _ => VfsError::Backend { - message: raised.message.clone(), - }, - } -} - -impl Error { - /// Maps a structured error table that surfaced as a block's failure - /// onto the variant its kind names, so a Lua-side raise classifies as - /// the Rust-raised error it stands in for. A `task_not_owned` or - /// `task_consumed` table rebuilds its variant from its `task` field, and - /// every `cancelled` table maps to [`Error::Interrupted`]. A kind whose - /// variant needs structure the table does not hold (the tool-scope - /// errors, `model_only_tool`, `tasks_live`, `model_failure`, - /// `model_required`, `internal`), - /// or a `context_exhausted` or task table missing its field, keeps its - /// message as a Lua failure. - fn from_raised(raised: runvil_lua::Raised) -> Error { - match raised.kind { - runvil_lua::ErrorKind::ToolLoopExhausted => Error::ToolLoopExhausted, - runvil_lua::ErrorKind::ContextExhausted => match raised.exhaustion_reason() { - Some(reason) => Error::ContextExhausted { - reason: reason.to_owned(), - }, - None => Error::lua_message(raised.message), - }, - runvil_lua::ErrorKind::EmptyModelReply => { - let error = crate::model::CompletionError::new( - crate::model::CompletionErrorKind::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, - }, - ) - } - runvil_lua::ErrorKind::Cancelled => Error::Interrupted, - runvil_lua::ErrorKind::Tool => Error::Tool { - message: raised.message.clone(), - cause: Box::new(raised), - }, - runvil_lua::ErrorKind::TaskNotOwned => match raised_task(&raised) { - Some(task) => Error::TaskNotOwned { task }, - None => Error::lua_message(raised.message), - }, - runvil_lua::ErrorKind::TaskConsumed => match raised_task(&raised) { - Some(task) => Error::TaskConsumed { task }, - None => Error::lua_message(raised.message), - }, - runvil_lua::ErrorKind::Store => Error::Store { - message: raised.message.clone(), - cause: raised_store_error(&raised), - }, - runvil_lua::ErrorKind::OutOfScopeTool - | runvil_lua::ErrorKind::UnboundTool - | runvil_lua::ErrorKind::ModelOnlyTool - | runvil_lua::ErrorKind::TasksLive - | runvil_lua::ErrorKind::Lua - | runvil_lua::ErrorKind::ModelFailure - | runvil_lua::ErrorKind::ModelRequired - | runvil_lua::ErrorKind::Internal => Error::lua_message(raised.message), - } - } -} diff --git a/crates/runvil-internal/engine/src/error/sets.rs b/crates/runvil-internal/engine/src/error/sets.rs index f4197ef57..77a135d57 100644 --- a/crates/runvil-internal/engine/src/error/sets.rs +++ b/crates/runvil-internal/engine/src/error/sets.rs @@ -1,13 +1,15 @@ //! The rules of the merge that builds a section entry's frozen sets, the -//! evidence a broken rule carries, and the message -//! [`Error::ToolSets`](super::Error::ToolSets) renders when the sets break -//! one. +//! evidence a broken rule carries, and the `ToolSetsInvalid` failure the +//! sets fail with when they break one. +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::packs::PackId; use runvil_types::tools::ToolId; use crate::lua::{NonEmpty, OfferMode}; +use super::{Error, failure, with_fact}; + /// A merge rule a section entry's frozen sets broke, among the rules that /// name the tools that broke them. #[derive(Debug, Clone, PartialEq, Eq)] @@ -57,6 +59,36 @@ pub(crate) struct SetTool { pub(crate) set: OfferMode, } +impl SetRule { + /// The rule's tag, the failure's `Rule` fact. + fn tag(&self) -> &'static str { + match self { + SetRule::UnofferedSet(_) => "unoffered_set", + SetRule::ForeignTool(_) => "foreign_tool", + SetRule::UnofferedCatalogTool => "unoffered_catalog_tool", + SetRule::UniqueWireName(_) => "unique_wire_name", + } + } +} + +/// A section entry's frozen sets, or the render answer they are merged +/// from, that broke a rule of the merge that builds them, which fails the +/// entry's first loop round. The message names each tool that broke it, +/// with its Pack and its set, or the Packs a render answered, and the +/// rule; the `Rule` fact is the rule's tag, or `rendered_packs` for an +/// answer whose Packs differ from the ones asked. +pub(crate) fn tool_sets(evidence: &SetEvidence) -> Error { + let rule = match evidence { + SetEvidence::RenderedPacks { .. } => "rendered_packs", + SetEvidence::Tools { rule, .. } => rule.tag(), + }; + with_fact( + failure(ErrorCode::ToolSetsInvalid, sets_message(evidence)), + FactKey::Rule, + rule, + ) +} + /// `packs` as a bracketed list, such as `[web, gh]`. fn pack_list(packs: &[PackId]) -> String { let names: Vec = packs.iter().map(ToString::to_string).collect(); @@ -66,7 +98,7 @@ fn pack_list(packs: &[PackId]) -> String { /// The message for `evidence`, written for a model: each tool with its /// Pack and its set, or the Packs a render answered, and what the rule /// requires. -pub(super) fn sets_message(evidence: &SetEvidence) -> String { +fn sets_message(evidence: &SetEvidence) -> String { let (rule, tools) = match evidence { SetEvidence::RenderedPacks { asked, answered } => { return format!( diff --git a/crates/runvil-internal/engine/src/error/tests-chain.rs b/crates/runvil-internal/engine/src/error/tests-chain.rs index daa7902c6..1f6c05276 100644 --- a/crates/runvil-internal/engine/src/error/tests-chain.rs +++ b/crates/runvil-internal/engine/src/error/tests-chain.rs @@ -1,191 +1,200 @@ -//! 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. +//! The cause convention: a failure built with a cause holds the cause's +//! text once in its message, after its own, and keeps its prose free of +//! it; a model failure's table holds only its prose; the Lua crate's +//! failures end a run as they always did; 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_vfs::VfsError; use super::*; -use crate::prompt::Prompt; -/// The display text of each segment of `error`'s source chain, the error -/// itself first. -fn chain(error: &(dyn std::error::Error + 'static)) -> Vec { - let mut segments = Vec::new(); - let mut next = Some(error); - while let Some(segment) = next { - segments.push(segment.to_string()); - next = segment.source(); - } - segments -} - -/// One failure of each variant that keeps its cause in a plain field. -#[track_caller] -fn cause_bearing() -> Vec { - let frontmatter = Prompt::parse("---\nname: [unclosed\n---\n\n# T\n\n## S\n\np\n", "test") - .0 - .expect_err("an unclosed YAML list is refused"); - let syntax = mlua::Error::SyntaxError { - message: "unexpected symbol near ".to_owned(), - incomplete_input: true, - }; - let tool = ToolError::new(ToolErrorKind::Backend, "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![ - Error::from(frontmatter), - Error::BindSchema { - alias: "echo".to_owned(), - cause: Box::new(schema), - }, - Error::lua(mlua::Error::RuntimeError("boom".to_owned())), - Error::LuaCompile { - location: "section `S` prologue".to_owned(), - source_line: 1, - lua_source: "x =".to_owned(), - message: syntax.to_string(), - cause: Box::new(syntax), - }, - Error::Tool { - message: tool.to_string(), - cause: Box::new(tool), - }, - Error::store_op( - &VfsOp::Read { - path: "a.txt".to_owned(), - start: None, - end: None, - }, - VfsError::NotFound { - path: "a.txt".to_owned(), - }, +#[test] +fn an_internal_failure_built_with_a_cause_shows_its_text_once() { + let expected_line = line!() + 1; + let error = internal_with_cause("a test invariant", &std::io::Error::other("the cause")); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!( + error.message, + "internal invariant violated: a test invariant: the cause" + ); + assert_eq!(error.prose, ErrorCode::Internal.prose()); + assert_eq!( + failed(&error).1, + format!( + "internal invariant violated: a test invariant: the cause (at {}:{expected_line})", + file!() ), - Error::store(VfsError::Backend { - message: "the disk is gone".to_owned(), - }), - Error::internal_with_cause("a test invariant", std::io::Error::other("the cause")), - Error::RequirementsUnmet { - notice: "the environment cannot satisfy this prompt:\n- the H1 gate failed".to_owned(), - cause: Some(Box::new(std::io::Error::other("assertion failed"))), - }, - ] -} - -/// 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")), - )] -} - -/// Asserts that no segment of `error`'s source chain repeats inside an -/// earlier one, and returns how many segments the chain has. -#[track_caller] -fn assert_no_segment_repeats(error: &(dyn std::error::Error + 'static)) -> usize { - let segments = chain(error); - for (index, segment) in segments.iter().enumerate() { - for later in &segments[index + 1..] { - assert!( - !segment.contains(later.as_str()), - "required each chain segment once, actual {later:?} repeated inside {segment:?}" - ); - } - } - segments.len() + "the message ends with the construction site's file and line" + ); } #[test] -fn no_segment_of_an_error_chain_repeats() { - for error in cause_bearing() { - assert!( - error.originating_error().is_some(), - "the cause stays reachable: {error:?}" - ); - assert!( - std::error::Error::source(&error).is_none(), - "required no source, since Display already holds the cause, actual a source on \ - {error:?}" - ); - 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" - ); - } +fn a_requirements_refusal_appends_its_cause_once_after_the_notice() { + let notice = "the environment cannot satisfy this prompt:\n- the H1 gate failed"; + let error = requirements_unmet(notice, Some(&lua_runtime("assertion failed"))); + assert_eq!(error.message, format!("{notice}: assertion failed")); + assert_eq!(error.prose, notice, "the notice alone is the model's text"); + assert_eq!( + failed(&error), + ( + RunErrorKind::RequirementsUnmet, + format!("{notice}: assertion failed") + ) + ); } #[test] -fn an_internal_failure_built_with_a_cause_shows_its_text_once_and_keeps_it() { - let expected_line = line!() + 1; - let error = Error::internal_with_cause("a test invariant", std::io::Error::other("the cause")); +fn a_schema_build_failure_names_the_alias_and_ends_with_the_refusal() { + let refusal = runvil_model_client::detail::tool_schema_new("echo", "", serde_json::json!([])) + .expect_err("an array is not an object schema"); + let error = bind_schema("echo", &refusal); + assert_eq!(error.code, ErrorCode::ToolSchemaInvalid); assert_eq!( - error.to_string(), - "internal invariant violated: a test invariant: the cause" + error.message, + "model-facing schema build failure for tool alias \"echo\": tool \"echo\" parameters \ + schema must be a JSON object" ); + assert_eq!(error.prose, ErrorCode::ToolSchemaInvalid.prose()); + assert_eq!(error.facts.get(&FactKey::Name), Some(&Value::from("echo"))); assert_eq!( - error - .originating_error() - .map(ToString::to_string) - .as_deref(), - Some("the cause") + error.facts.get(&FactKey::Reason), + refusal.facts.get(&FactKey::Reason), + "the refusal's reason carries over" ); assert_eq!( - error.message(), - format!( - "internal invariant violated: a test invariant: the cause (at {}:{expected_line})", - file!() - ), - "the message ends with the construction site's file and line" + failed(&error), + (RunErrorKind::Binding, error.message.clone()) + ); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (ErrorKind::Internal, Vec::new()) ); +} - let bare = Error::internal("a test invariant"); - assert!(bare.originating_error().is_none()); +#[test] +fn a_model_failure_holds_its_cause_in_the_run_message_and_not_in_its_table() { + let transport = ErrorCode::Transport; + let error = crate::model::completion_error(transport, crate::model::phrase(transport)) + .with_cause(&std::io::Error::other("connection reset by peer")); + assert_eq!( + failed(&error), + ( + RunErrorKind::Completion, + "the connection to the model backend failed: connection reset by peer".to_owned() + ), + "the run's message holds the model failure's cause once" + ); + assert_eq!(error_kind(&error), ErrorKind::ModelFailure); assert_eq!( - bare.to_string(), - "internal invariant violated: a test invariant" + error.prose, "the connection to the model backend failed", + "the table's message, the prose, holds no cause" ); } #[test] -fn a_requirements_refusal_appends_its_cause_once_after_the_notice() { - let notice = "the environment cannot satisfy this prompt:\n- the H1 gate failed"; - let error = Error::RequirementsUnmet { - notice: notice.to_owned(), - cause: Some(Box::new(std::io::Error::other("assertion failed"))), - }; - assert_eq!(error.to_string(), format!("{notice}: assertion failed")); - let bare = Error::RequirementsUnmet { - notice: notice.to_owned(), - cause: None, - }; - assert_eq!(bare.to_string(), notice); +fn the_lua_crates_failures_end_a_run_with_the_kind_and_text_they_always_had() { + let site = std::panic::Location::caller(); + let emitter = crate::test_support::recording::null_emitter(); + let compiled = runvil_lua::LuaProgram::compile( + "x =", + "prompt", + "section `S` prologue", + NonZeroU32::new(7).expect("7 is non-zero"), + &emitter, + "S", + ) + .expect_err("the block must not compile"); + let tool = runvil_types::tools::tool_error(ErrorCode::ToolBackend, "the backend refused"); + let info = runvil_types::tools::ToolInfo::new( + runvil_types::tools::ToolId::parse("tools/echo").expect("a valid tool id"), + "echo tool", + serde_json::json!({ "type": "object" }), + ); + let dispatched = runvil_lua::prepare_dispatch( + &crate::lua::ToolBinding::from_info("echo", &info), + None, + Err(tool), + &runvil_types::untrusted::GuardNonce::from_seed(&crate::test_support::scenario::TEST_SEED), + &emitter, + "S", + crate::lua::ScriptReport { turn: 0 }, + ) + .expect_err("the tool's failure fails the dispatch"); + let mut not_found = runvil_lua::store_error(&VfsError::NotFound { + path: "a.md".to_owned(), + }); + "not found: a.md".clone_into(&mut not_found.message); + for (failure, kind, message) in [ + ( + compiled, + RunErrorKind::Lua, + "lua compilation error at section `S` prologue (line 7): syntax error: \ + [string \"line 7\"]:1: unexpected symbol near " + .to_owned(), + ), + ( + runvil_lua::internal_at("a test invariant", site), + RunErrorKind::Internal, + format!( + "internal invariant violated: a test invariant (at {}:{})", + site.file(), + site.line() + ), + ), + ( + dispatched, + RunErrorKind::Tool, + "tool call failure: the backend refused".to_owned(), + ), + ( + lua_quota(QuotaResource::LogEvent), + RunErrorKind::Quota, + "lua log event quota exceeded".to_owned(), + ), + ( + task_cancelled(&"0.1".parse().expect("the test task id parses")), + RunErrorKind::Lua, + "task `0.1` was cancelled".to_owned(), + ), + (not_found, RunErrorKind::Vfs, "not found: a.md".to_owned()), + ] { + assert_eq!(failed(&failure), (kind, message)); + } + assert_eq!( + crate::execute::outcome(&interrupted()), + RunOutcome::Failed { + kind: RunErrorKind::Internal, + message: interrupted().message, + }, + "an interruption, which the scheduler ends as cancelled before it reaches a failed \ + outcome" + ); } #[test] fn a_store_probe_failure_names_a_missing_store_and_keeps_the_fixed_text_otherwise() { + let missing = store(&VfsError::NoStore); + assert_eq!(missing.code, ErrorCode::NoStore); assert_eq!( - Error::store(VfsError::NoStore).to_string(), + missing.message, "this run declares no store, so no store operation can run" ); let backend = VfsError::Backend { message: "the disk is gone".to_owned(), }; - let expected = format!("store operation failed: {backend}"); - assert_eq!(Error::store(backend).to_string(), expected); + let failure = store(&backend); + assert_eq!(failure.code, ErrorCode::Backend); + assert_eq!( + failure.message, + format!("store operation failed: {backend}") + ); + assert_eq!( + failed(&failure), + (RunErrorKind::Vfs, failure.message.clone()) + ); + assert_eq!( + error_fields(&failure), + runvil_lua::store_error_value_fields(&backend) + ); } diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index b5e70d210..a71c59ed7 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -1,404 +1,404 @@ -//! Tests for the internal error: its source chains through the public -//! wrappers, its bridges from other crates' errors, and the kinds and -//! messages, locations included, it ends a run with. +//! Tests for the Engine's failure constructors: each one's code, message, +//! prose, location, and facts, the kind and message each ends a run with, +//! and the Lua error table each renders as. -use runvil_lua::Error as LuaError; +use std::num::NonZeroU32; + +use runvil_lua::{ErrorField, ErrorKind, QuotaResource, error_fields, error_kind}; +use runvil_types::error::{ErrorCode, FactKey, Location}; +use runvil_types::ids::TaskId; +use serde_json::Value; use super::*; -use crate::prompt::Prompt; +use crate::RunErrorKind; +use crate::execute::RunOutcome; #[path = "tests-chain.rs"] mod chain; -#[test] -fn context_exhaustion_maps_from_lua_and_classifies() { - // The compactor's typed exhaustion crosses the crate seam - // variant-for-variant and classifies as its own run-error kind, so a - // caller can distinguish context exhaustion from a transport failure. - for (reason, message) in [ - ( - "provider", - "context exhausted: the provider rejected the request as exceeding the model's context window", - ), - ("manual", "context exhausted: manual"), - ] { - let lua_error = LuaError::ContextExhausted { - reason: reason.to_owned(), - }; - let error = Error::from(lua_error); - assert!( - matches!(&error, Error::ContextExhausted { reason: kept, .. } if kept == reason), - "the mapping preserves the reason, got {error:?}" - ); - assert!( - error.originating_error().is_none(), - "a variant with no originating error has no cause" - ); - assert_eq!(error.to_string(), message); - assert_eq!(error.kind(), crate::RunErrorKind::ContextExhausted); - assert_eq!(error.message(), message); +/// The kind and message `error` ends a run with. +fn failed(error: &Error) -> (RunErrorKind, String) { + match crate::execute::outcome(error) { + RunOutcome::Failed { kind, message } => (kind, message), + other => panic!("a failure ends a run as failed, got {other:?}"), } } -#[test] -fn a_schema_build_failure_renders_its_refusal_and_keeps_it_as_the_cause() { - // The binding failure renders the schema refusal in its message once - // and keeps the typed refusal as its cause. - let schema_error = - runvil_model_client::detail::tool_schema_new("echo", "", serde_json::json!([])) - .expect_err("an array is not an object schema"); - let bind = Error::BindSchema { - alias: "echo".to_owned(), - cause: Box::new(schema_error), - }; - assert_eq!( - bind.to_string(), - "model-facing schema build failure for tool alias \"echo\": tool \"echo\" parameters \ - schema must be a JSON object" - ); - 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"); +/// The task id `path` names. +fn task(path: &str) -> TaskId { + path.parse().expect("the test task id parses") } -#[test] -fn lua_compile_keeps_the_originating_compiler_error_as_its_cause() { - // A compile failure keeps the concrete `mlua` error as its cause - // instead of flattening it into `message` alone. - let compile = Error::LuaCompile { - location: "section `S` prologue".to_owned(), - source_line: 7, - lua_source: "x =".to_owned(), - message: "syntax error near '='".to_owned(), - cause: Box::new(mlua::Error::SyntaxError { - message: "syntax error near '='".to_owned(), - incomplete_input: false, - }), - }; - let cause = compile - .originating_error() - .and_then(|cause| cause.downcast_ref::()) - .expect("the compiler error is the cause"); - assert!(matches!(cause, mlua::Error::SyntaxError { .. })); +/// The string field `name` with `value`. +fn field(name: &str, value: &str) -> (String, ErrorField) { + (name.to_owned(), ErrorField::String(value.to_owned())) } #[test] -fn typed_error_survives_the_lua_external_boundary() { - // Passing the typed error (not its `to_string()`) to - // `mlua::Error::external` keeps the original error as a downcastable - // source across the Lua boundary, rather than flattening it to text. - let original = Error::OutOfScopeToolCall { - name: "echo".to_owned(), - global_exists: false, - has_dynamic_set: false, - static_aliases: vec!["other".to_owned()], - }; - let display = original.to_string(); - let external = mlua::Error::external(original); - match &external { - mlua::Error::ExternalError(cause) => { - let recovered = cause - .downcast_ref::() - .expect("the original typed Error is preserved, not stringified"); - assert_eq!(recovered.to_string(), display); - } - other => panic!("expected an ExternalError holding the typed error, got {other:?}"), - } - // Re-wrapping through the crate's Lua boundary keeps the cause reachable. - let wrapped = Error::lua(external); - assert!(wrapped.originating_error().is_some()); -} - -#[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; - - let cause = std::io::Error::other("connection refused"); - let completion = CompletionError::new( - CompletionErrorKind::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" +fn an_internal_fault_is_located_at_its_rust_call_site_and_ends_its_message_there() { + let expected_line = line!() + 1; + let error = internal("a test invariant"); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!( + error.message, + "internal invariant violated: a test invariant" ); - let bridged = Error::from(completion); - assert!( - matches!(&bridged, Error::Completion(error) if error.kind() == CompletionErrorKind::Unavailable), - "the error type holds the completion error whole, got {bridged:?}" + assert_eq!(error.prose, ErrorCode::Internal.prose()); + assert_eq!( + error.at, + Some(Location::line( + file!(), + NonZeroU32::new(expected_line).expect("a line is non-zero") + )) ); - assert!( - std::error::Error::source(&bridged).is_some(), - "the cause must survive the bridge" + assert!(error.facts.is_empty()); + assert_eq!( + failed(&error), + ( + RunErrorKind::Internal, + format!( + "internal invariant violated: a test invariant (at {}:{expected_line})", + file!() + ) + ) ); + assert_eq!(error_kind(&error), ErrorKind::Internal); } #[test] -fn a_frontmatter_failure_message_ends_with_its_line_and_column() { - // The parser's surfaced YAML position crosses the error-type - // bridge and ends the run's failure message. A frontmatter failure - // predates the prompt's name, so the message names no prompt. - let source = concat!( - "---\n", - "name: x\n", - "description: d\n", - "uses:\n", - " - not a Pack id\n", - "---\n", - "\n# T\n\n## S\n\np\n", +fn a_lua_failure_keeps_its_text_and_reads_as_lua() { + let error = lua_runtime("messages must not be empty"); + assert_eq!(error.code, ErrorCode::LuaRuntime); + assert_eq!(error.message, "messages must not be empty"); + assert_eq!(error.prose, ErrorCode::LuaRuntime.prose()); + assert_eq!((error.at.clone(), error.facts.len()), (None, 0)); + assert_eq!( + failed(&error), + (RunErrorKind::Lua, "messages must not be empty".to_owned()) ); - let parse = Prompt::parse(source, "test") - .0 - .expect_err("a Pack id with spaces must be rejected"); - let error = Error::from(parse); - assert_eq!(error.kind(), crate::RunErrorKind::Parse); - assert_eq!(error.message(), format!("{error} (line 5, column 5)")); + assert_eq!(error_kind(&error), ErrorKind::Lua); } #[test] -fn a_structured_failure_message_ends_with_the_prompt_name_and_position() { - // A post-frontmatter parse failure ends its message with the - // prompt's frontmatter name and the offending span's line and - // column. - let source = "---\nname: dup\ndescription: d\n---\n\n# T\n\n## S\n\np\n\n## S\n\nq\n"; - let parse = Prompt::parse(source, "test") - .0 - .expect_err("duplicate sibling sections must be rejected"); - let error = Error::from(parse); +fn an_interruption_has_no_location_and_reads_as_cancelled() { + let error = interrupted(); + assert_eq!(error.code, ErrorCode::Interrupted); + assert_eq!( + error.message, + "interrupted: the run was cancelled or this call was stopped" + ); assert_eq!( - error.message(), - format!("{error} (prompt dup, line 12, column 1)") + failed(&error), + (RunErrorKind::Internal, error.message.clone()) ); + assert_eq!(error_kind(&error), ErrorKind::Cancelled); + assert_eq!(error_fields(&error), Vec::new()); } #[test] -fn an_internal_fault_message_ends_with_the_rust_file_and_line() { - // An internal invariant failure locates itself in the Rust - // source, captured at the construction site. - let expected_line = line!() + 1; - let error = Error::internal("a test invariant"); +fn an_unsupported_version_names_the_major_only_in_its_message() { + let error = unsupported_version(3); + assert_eq!(error.code, ErrorCode::UnsupportedVersion); assert_eq!( - error.message(), - format!( - "internal invariant violated: a test invariant (at {}:{expected_line})", - file!() - ) + error.message, + "unsupported runvil version: 3 (this build supports major 0)" ); + assert!(error.facts.is_empty()); + assert_eq!(failed(&error), (RunErrorKind::Parse, error.message.clone())); + assert_eq!(error_kind(&error), ErrorKind::Internal); } #[test] -fn a_failure_without_a_location_ends_its_message_with_its_own_text() { - // Cancellation and the other non-positional kinds have no source - // position to point at. - let error = Error::Interrupted; - assert_eq!(error.message(), error.to_string()); +fn a_refusal_notice_is_the_prose_and_the_whole_message_without_a_cause() { + let notice = "the environment cannot satisfy this prompt:\n- role 'strong': requires a \ + context of at least 200000 tokens; the model filling it provides 32000"; + let error = requirements_unmet(notice, None); + assert_eq!(error.code, ErrorCode::RequirementsUnmet); + assert_eq!( + (error.message.as_str(), error.prose.as_str()), + (notice, notice) + ); + assert_eq!( + failed(&error), + (RunErrorKind::RequirementsUnmet, notice.to_owned()) + ); + assert_eq!(error_kind(&error), ErrorKind::Internal); } #[test] -fn interrupted_and_substitution_failures_classify_under_kinds_a_finished_run_reports() { - // A finished run reports cancellation as `RunOutcome::Cancelled`, so an - // interruption that reaches a failed outcome is the Engine's fault, and - // a failed `{{ }}` substitution is the author's program failing, as any - // Lua fault is. - assert_eq!(Error::Interrupted.kind(), crate::RunErrorKind::Internal); - let (var, sys) = (serde_json::json!({}), serde_json::json!({})); - let unset = |_: &str| -> Result> { Ok(None) }; - let sources = crate::subst::Sources { - args: "", - argv: None, - item: None, - var: &var, - sys: &sys, - globals: &unset, - }; - let error = crate::subst::substitute("{{ var.missing }}", &sources) - .expect_err("a missing key fails the substitution"); - assert!(matches!(error, Error::Substitution(_)), "{error:?}"); - assert_eq!(error.kind(), crate::RunErrorKind::Lua); +fn a_missing_model_names_its_section() { + let error = model_required("Only"); + assert_eq!(error.code, ErrorCode::ModelRequired); + assert_eq!(error.message, "model binding required for section Only"); + assert_eq!(error.prose, ErrorCode::ModelRequired.prose()); + assert_eq!( + error.facts.get(&FactKey::Section), + Some(&Value::from("Only")) + ); + assert_eq!( + failed(&error), + (RunErrorKind::RequirementsUnmet, error.message.clone()) + ); + assert_eq!(error_kind(&error), ErrorKind::ModelRequired); + assert_eq!(error_fields(&error), Vec::new()); } #[test] -fn requirements_unmet_classifies_and_reports_the_notice_as_its_message() { - // The refusal notice is the whole message, and the kind classifies - // it for code. - let error = Error::RequirementsUnmet { - notice: "the environment cannot satisfy this prompt:\n- role 'strong': requires a context of at least 200000 tokens; the model filling it provides 32000".to_owned(), - cause: None, - }; - assert_eq!(error.kind(), crate::RunErrorKind::RequirementsUnmet); - assert_eq!(error.message(), error.to_string()); - assert!(error.message().contains("strong")); +fn context_exhaustion_holds_its_reason_and_renders_it() { + for (reason, message) in [ + ( + "provider", + "context exhausted: the provider rejected the request as exceeding the model's \ + context window", + ), + ("manual", "context exhausted: manual"), + ] { + let error = context_exhausted(reason); + assert_eq!(error.code, ErrorCode::ContextExhausted); + assert_eq!(error.message, message); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason)) + ); + assert_eq!( + failed(&error), + (RunErrorKind::ContextExhausted, message.to_owned()) + ); + assert_eq!(error_kind(&error), ErrorKind::ContextExhausted); + assert_eq!(error_fields(&error), vec![field("reason", reason)]); + } } #[test] -fn the_call_depth_cap_is_a_quota_that_lua_still_reads_as_lua() { - // The run classifies the cap as a quota, while an author's `pcall` - // keeps reading the `lua` kind it always branched on. - for (via, text) in [ - (DepthVia::Call, "call recursion exceeded cap of 8"), - (DepthVia::Fanout, "tasks.fanout recursion exceeded cap of 8"), +fn a_spent_quota_and_the_call_depth_cap_are_quotas_that_lua_reads_as_lua() { + for (resource, tag, text) in [ + ( + QuotaResource::LogEvent, + "log_event", + "lua log event quota exceeded", + ), + ( + QuotaResource::LogByte, + "log_byte", + "lua log byte quota exceeded", + ), + ] { + let error = lua_quota(resource); + assert_eq!(error.code, ErrorCode::LuaQuota); + assert_eq!(error.message, text); + assert_eq!(error.facts.get(&FactKey::Reason), Some(&Value::from(tag))); + assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (ErrorKind::Lua, Vec::new()) + ); + } + for (via, tag, text) in [ + (DepthVia::Call, "call", "call recursion exceeded cap of 8"), + ( + DepthVia::Fanout, + "fanout", + "tasks.fanout recursion exceeded cap of 8", + ), ] { - let error = Error::CallDepthExceeded { cap: 8, via }; - assert_eq!(error.to_string(), text); + let error = call_depth_exceeded(8, via); + assert_eq!(error.code, ErrorCode::CallDepthExceeded); + assert_eq!(error.message, text); + assert_eq!(error.facts.get(&FactKey::Reason), Some(&Value::from(tag))); + assert_eq!(error.facts.get(&FactKey::Limit), Some(&Value::from(8))); + assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); assert_eq!( - runvil_lua::ErrorValue::kind(&error), - runvil_lua::ErrorKind::Lua + (error_kind(&error), error_fields(&error)), + (ErrorKind::Lua, Vec::new()) ); - assert_eq!(error.kind(), crate::RunErrorKind::Quota); - assert_eq!(error.message(), text); - assert!(error.originating_error().is_none()); } } #[test] -fn an_owned_lua_internal_message_reaches_the_engine_error_whole() { - let message = format!("tool call counts: id {:?} could not be read", "web/fetch"); - let expected_line = line!() + 1; - let site = std::panic::Location::caller(); - let error = Error::from(LuaError::Internal { - message: std::borrow::Cow::Owned(message.clone()), - location: site, - }); - match &error { - Error::Internal { - message: carried, .. - } => assert_eq!(carried, &message), - other => panic!("expected an internal error, got {other:?}"), - } +fn leaked_tasks_are_named_in_spawn_order_and_joined_in_the_table() { + let error = tasks_live(&[task("1.2"), task("1.10")]); + assert_eq!(error.code, ErrorCode::TasksLive); assert_eq!( - error.to_string(), - format!("internal invariant violated: {message}") + error.message, + "chain ended with tasks still live: 1.2, 1.10; wait on or cancel every task a chain \ + spawns before it ends" ); - assert_eq!(error.kind(), crate::RunErrorKind::Internal); assert_eq!( - error.message(), - format!( - "internal invariant violated: {message} (at {}:{expected_line})", - file!() + error.facts.get(&FactKey::Tasks), + Some(&Value::from(vec!["1.2", "1.10"])) + ); + assert_eq!(failed(&error), (RunErrorKind::Lua, error.message.clone())); + assert_eq!(error_kind(&error), ErrorKind::TasksLive); + assert_eq!(error_fields(&error), vec![field("tasks", "1.2, 1.10")]); +} + +#[test] +fn a_task_failure_names_its_task_in_its_fact_and_its_table() { + let id = task("1.3"); + for (error, code, kind, message) in [ + ( + task_not_owned(&id), + ErrorCode::TaskNotOwned, + ErrorKind::TaskNotOwned, + "task `1.3` is not a task this chain owns", + ), + ( + task_consumed(&id), + ErrorCode::TaskConsumed, + ErrorKind::TaskConsumed, + "task `1.3` was already delivered: a task's result is taken by one wait", ), - "the message ends with the construction site's file and line" + ( + task_cancelled(&id), + ErrorCode::TaskCancelled, + ErrorKind::Cancelled, + "task `1.3` was cancelled", + ), + ] { + assert_eq!(error.code, code); + assert_eq!(error.message, message); + assert_eq!(error.prose, code.prose()); + assert_eq!(error.facts.get(&FactKey::Task), Some(&Value::from("1.3"))); + assert_eq!(failed(&error), (RunErrorKind::Lua, message.to_owned())); + assert_eq!(error_kind(&error), kind); + assert_eq!(error_fields(&error), vec![field("task", "1.3")]); + } +} + +#[test] +fn a_store_failure_takes_the_stores_code_and_facts_under_its_own_message() { + let cause = runvil_vfs::VfsError::Anchor { + path: "notes.md".to_owned(), + anchor: "TODO".to_owned(), + count: 2, + }; + let op = crate::lua::VfsOp::StrReplace { + path: "notes.md".to_owned(), + old: "TODO".to_owned(), + new: "DONE".to_owned(), + }; + let error = store_op(&op, &cause); + let expected = runvil_lua::store_error(&cause); + assert_eq!((error.code, &error.facts), (expected.code, &expected.facts)); + assert_eq!(error.message, crate::lua::store_error_message(&op, &cause)); + assert_eq!(error.prose, ErrorCode::Anchor.prose()); + assert_eq!(failed(&error), (RunErrorKind::Vfs, error.message.clone())); + assert_eq!(error_kind(&error), ErrorKind::Store); + assert_eq!( + error_fields(&error), + runvil_lua::store_error_value_fields(&cause) ); } -/// One failure of each store variant, with fields a table can carry. -fn one_failure_per_store_variant() -> Vec { - use runvil_vfs::{PathReason, VfsError}; - let path = || "notes.md".to_owned(); - vec![ - VfsError::NotFound { path: path() }, - VfsError::AlreadyExists { path: path() }, - VfsError::NotADirectory { path: path() }, - VfsError::IsADirectory { path: path() }, - VfsError::DirectoryNotEmpty { path: path() }, - VfsError::NotUtf8 { - path: path(), - valid_up_to: 7, - }, - VfsError::InvalidPath { - path: path(), - reason: PathReason::Traversal, - }, - VfsError::InvalidRange { - path: path(), - reason: "the bounds are invalid", - }, - VfsError::Anchor { - path: path(), - anchor: "TODO".to_owned(), - count: 2, - }, - VfsError::PermissionDenied { - path: path(), - reason: "denied".to_owned(), - }, - VfsError::ApprovalRequired { - path: path(), - reason: "ask".to_owned(), - }, - VfsError::ScopeEnded { path: path() }, - VfsError::ReadOnly { path: path() }, - VfsError::OutsideRoot { - path: path(), - detail: "escapes".to_owned(), - }, - VfsError::RootProtected { path: path() }, - VfsError::NoStore, - VfsError::CrossMount { - path: path(), - detail: "two mounts".to_owned(), - }, - VfsError::Io { - path: path(), - kind: std::io::ErrorKind::PermissionDenied, - raw_os_error: Some(13), - message: "denied".to_owned(), - }, - VfsError::Unsupported { - path: path(), - detail: "no".to_owned(), - }, - VfsError::Conflict { - path: path(), - detail: "raced".to_owned(), - }, - VfsError::Backend { - message: "gone".to_owned(), - }, - ] +#[test] +fn a_determinism_violation_is_a_store_kind_that_lua_reads_as_internal() { + let error = determinism("a.md: claimed by 1 and 2"); + assert_eq!(error.code, ErrorCode::Determinism); + assert_eq!( + error.message, + "store determinism violation: a.md: claimed by 1 and 2" + ); + assert_eq!(failed(&error), (RunErrorKind::Vfs, error.message.clone())); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (ErrorKind::Internal, Vec::new()) + ); } #[test] -fn every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag() { - use runvil_vfs::VfsError; - for failure in one_failure_per_store_variant() { - let raised = runvil_lua::Raised { - kind: runvil_lua::ErrorKind::Store, - message: failure.to_string(), - fields: runvil_lua::store_error_value_fields(&failure) - .into_iter() - .collect(), - }; - let Error::Store { cause: source, .. } = Error::from(LuaError::Raised(raised)) else { - panic!("a raised store table maps to a store error: {failure:?}"); - }; - assert_eq!(source.tag(), failure.tag(), "{failure:?}"); - match (&source, &failure) { - ( - _, - VfsError::NotFound { .. } - | VfsError::AlreadyExists { .. } - | VfsError::NotADirectory { .. } - | VfsError::IsADirectory { .. } - | VfsError::DirectoryNotEmpty { .. } - | VfsError::ScopeEnded { .. } - | VfsError::ReadOnly { .. } - | VfsError::RootProtected { .. } - | VfsError::NotUtf8 { .. } - | VfsError::InvalidPath { .. } - | VfsError::InvalidRange { .. } - | VfsError::Anchor { .. } - | VfsError::NoStore, - ) => assert_eq!(source, failure, "{failure:?}"), - ( - VfsError::Io { - kind, raw_os_error, .. - }, - _, - ) => assert_eq!( - (*kind, *raw_os_error), - (std::io::ErrorKind::Other, None), - "a table holds no I/O kind or OS code" - ), - _ => {} - } +fn a_refused_tool_call_names_the_tool_and_its_text_is_its_prose() { + let out_of_scope = out_of_scope_tool("echo", true, &["other".to_owned()], true); + let unbound = unbound_tool("web/gone", &["web/fetch".to_owned()]); + let model_only = model_only_tool("web/render", "web/render"); + for (error, code, kind, name) in [ + ( + &out_of_scope, + ErrorCode::OutOfScopeTool, + ErrorKind::OutOfScopeTool, + "echo", + ), + ( + &unbound, + ErrorCode::UnboundTool, + ErrorKind::UnboundTool, + "web/gone", + ), + ( + &model_only, + ErrorCode::ModelOnlyTool, + ErrorKind::ModelOnlyTool, + "web/render", + ), + ] { + assert_eq!(error.code, code); + assert_eq!(error.prose, error.message, "{code:?}"); + assert_eq!(error.facts.get(&FactKey::Name), Some(&Value::from(name))); + assert_eq!(failed(error), (RunErrorKind::Tool, error.message.clone())); + assert_eq!(error_kind(error), kind); + assert_eq!(error_fields(error), vec![field("name", name)]); } + assert_eq!( + out_of_scope.message, + "tool \"echo\" is not in this section's scope; aliases this section offers: \ + [\"other\"] (a catalog tool that was not offered in this section); a Dynamic Set tool \ + is reached through GetDynamicTools and CallDynamicTool" + ); + assert_eq!( + unbound.message, + "tool \"web/gone\" is not a tool in this run; catalog tools: [\"web/fetch\"]" + ); + assert!( + model_only + .message + .starts_with("tool \"web/render\" (web/render) is model-only") + ); +} + +#[test] +fn an_unconverged_tool_loop_is_a_tool_kind() { + let error = tool_loop_exhausted(); + assert_eq!(error.code, ErrorCode::ToolLoopExhausted); + assert_eq!(error.message, "tool-call loop did not converge"); + assert_eq!(failed(&error), (RunErrorKind::Tool, error.message.clone())); + assert_eq!(error_kind(&error), ErrorKind::ToolLoopExhausted); +} + +#[test] +fn broken_tool_sets_name_the_rule_they_broke() { + let packs = tool_sets(&SetEvidence::RenderedPacks { + asked: vec!["web".parse().expect("a Pack id")], + answered: Vec::new(), + }); + assert_eq!(packs.code, ErrorCode::ToolSetsInvalid); + assert_eq!( + packs.message, + "the render answered the Packs [] where the section offered the Packs [web]; a render \ + answers each offered Pack once, in offer order" + ); + assert_eq!( + packs.facts.get(&FactKey::Rule), + Some(&Value::from("rendered_packs")) + ); + assert_eq!( + failed(&packs), + (RunErrorKind::Binding, packs.message.clone()) + ); + let clash = tool_sets(&SetEvidence::Tools { + rule: SetRule::UniqueWireName("fetch".to_owned()), + tools: crate::lua::NonEmpty::new( + SetTool { + id: "web/fetch".parse().expect("a tool id"), + set: crate::lua::OfferMode::Static, + }, + Vec::new(), + ), + }); + assert_eq!( + clash.facts.get(&FactKey::Rule), + Some(&Value::from("unique_wire_name")) + ); + assert_eq!( + (error_kind(&clash), error_fields(&clash)), + (ErrorKind::Internal, Vec::new()) + ); } diff --git a/crates/runvil-internal/engine/src/error/tools.rs b/crates/runvil-internal/engine/src/error/tools.rs new file mode 100644 index 000000000..4db9bbbff --- /dev/null +++ b/crates/runvil-internal/engine/src/error/tools.rs @@ -0,0 +1,108 @@ +//! The failures of a tool's binding and of a refused tool call: a schema +//! that did not build, and a name the model or a script may not call. + +use runvil_types::error::{ErrorCode, FactKey}; + +use crate::lua::DiscoveryTool; + +use super::{Error, failure, with_fact}; + +/// A failure with `code` whose text, `text`, is written for the model, so +/// it is both the message and the prose, about the tool name `name`. +fn refusal(code: ErrorCode, text: String, name: &str) -> Error { + with_fact(Error::new(code, text.clone(), text), FactKey::Name, name) +} + +/// A model-facing schema that did not build for the bound tool `alias`: +/// the `Name` fact is the alias, the `Reason` fact is the schema refusal's +/// own, and the message ends with the refusal's text. Constructed only by +/// the tool-scope preparation. +pub(crate) fn bind_schema(alias: &str, cause: &Error) -> Error { + let mut error = with_fact( + failure( + ErrorCode::ToolSchemaInvalid, + format!("model-facing schema build failure for tool alias {alias:?}"), + ), + FactKey::Name, + alias, + ); + if let Some(reason) = cause.facts.get(&FactKey::Reason) { + error.facts.insert(FactKey::Reason, reason.clone()); + } + error.with_cause(cause) +} + +/// The model called `name`, outside both of the section entry's frozen +/// sets. The text names the Static Set's wire names, `static_aliases`, as +/// what they are; the offered-but-unscoped hint when `global_exists` says +/// the name is a catalog tool's wire name; and where a Dynamic Set tool is +/// reached from when `has_dynamic_set` says the section's Dynamic Set holds +/// a tool, which the caller reads from the frozen sets rather than from +/// `static_aliases`, since a catalog tool may take a discovery tool's wire +/// name while no Dynamic Set holds a tool. +/// +/// This is the model tool loop's refusal alone: a script `tools.call` +/// resolves against the run's whole catalog and fails with +/// [`unbound_tool`] instead. +pub(crate) fn out_of_scope_tool( + name: &str, + global_exists: bool, + static_aliases: &[String], + has_dynamic_set: bool, +) -> Error { + let hint = if global_exists { + " (a catalog tool that was not offered in this section)" + } else { + "" + }; + // A section whose Dynamic Set holds nothing freezes no discovery tool, + // so naming one would point the model at a tool it cannot call. + let dynamic_set = if has_dynamic_set { + format!( + "; a Dynamic Set tool is reached through {} and {}", + DiscoveryTool::Search.wire_name(), + DiscoveryTool::Call.wire_name() + ) + } else { + String::new() + }; + refusal( + ErrorCode::OutOfScopeTool, + format!( + "tool {name:?} is not in this section's scope; aliases this section offers: \ + {static_aliases:?}{hint}{dynamic_set}" + ), + name, + ) +} + +/// A `tools.call` that named `name`, which is no adhoc tool and no tool in +/// the run's catalog, whose tool ids, in id order, are `ids`. +/// +/// Script-initiated dispatch resolves a canonical id against the run's +/// whole catalog, not the section's frozen sets - the sets shape what the +/// model is offered, and the author's own code is not the model - so this +/// means the name is no catalog tool's id at all; a wire name is the +/// model's, never a script's. +pub(crate) fn unbound_tool(name: &str, ids: &[String]) -> Error { + refusal( + ErrorCode::UnboundTool, + format!("tool {name:?} is not a tool in this run; catalog tools: {ids:?}"), + name, + ) +} + +/// A `tools.call` that named `name`, the catalog tool `id`, which only a +/// model may call: a Pack marks a tool model-only when a render gives its +/// arguments a script cannot know. +pub(crate) fn model_only_tool(name: &str, id: &str) -> Error { + refusal( + ErrorCode::ModelOnlyTool, + format!( + "tool {name:?} ({id}) is model-only: a render shapes its arguments for the model \ + that receives them, so a script cannot call it; offer it with tools.offer and let \ + the model call it" + ), + name, + ) +} diff --git a/crates/runvil-internal/engine/src/error/value.rs b/crates/runvil-internal/engine/src/error/value.rs deleted file mode 100644 index 1c6b0a5ba..000000000 --- a/crates/runvil-internal/engine/src/error/value.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! The internal error as a Lua error value: the kind an author branches -//! on and the kind's fields. - -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 -/// `model_failure`, or `empty_model_reply` for an empty reply, and a round -/// with no model as `model_required`; other failures outside the prompt -/// (a failed tool freeze, configuration, input) render as `internal`; -/// every Lua-phase failure renders as `lua`; a store operation's own -/// failure renders as `store` with its `reason` and the structured -/// variant's fields. -impl runvil_lua::ErrorValue for Error { - fn kind(&self) -> runvil_lua::ErrorKind { - use runvil_lua::ErrorKind; - match self { - Error::Lua { .. } - | Error::LuaCompile { .. } - | Error::LuaQuota { .. } - | Error::CallDepthExceeded { .. } - | Error::Substitution(_) => ErrorKind::Lua, - Error::ContextExhausted { .. } => ErrorKind::ContextExhausted, - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { - ErrorKind::EmptyModelReply - } - Error::Completion(_) => ErrorKind::ModelFailure, - Error::ModelRequired { .. } => ErrorKind::ModelRequired, - Error::Interrupted | Error::TaskCancelled { .. } => ErrorKind::Cancelled, - Error::ToolLoopExhausted => ErrorKind::ToolLoopExhausted, - Error::TasksLive { .. } => ErrorKind::TasksLive, - Error::TaskNotOwned { .. } => ErrorKind::TaskNotOwned, - Error::TaskConsumed { .. } => ErrorKind::TaskConsumed, - Error::OutOfScopeToolCall { .. } => ErrorKind::OutOfScopeTool, - Error::UnboundToolCall { .. } => ErrorKind::UnboundTool, - Error::ModelOnlyToolCall { .. } => ErrorKind::ModelOnlyTool, - Error::Tool { .. } => ErrorKind::Tool, - Error::Store { .. } => ErrorKind::Store, - Error::Parse(_) - | Error::BindSchema { .. } - | Error::ToolSets { .. } - | Error::UnsupportedVersion(_) - | Error::RequirementsUnmet { .. } - | Error::Internal { .. } - | Error::Determinism(_) => ErrorKind::Internal, - } - } - - 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() - .map(|reason| { - vec![( - "finish_reason".to_owned(), - ErrorField::String(reason.to_owned()), - )] - }) - .unwrap_or_default(), - Error::OutOfScopeToolCall { name, .. } - | Error::UnboundToolCall { name, .. } - | Error::ModelOnlyToolCall { name, .. } => { - vec![("name".to_owned(), ErrorField::String(name.clone()))] - } - Error::TasksLive { tasks } => { - vec![("tasks".to_owned(), ErrorField::String(join_task_ids(tasks)))] - } - Error::TaskNotOwned { task } - | Error::TaskConsumed { task } - | Error::TaskCancelled { task } => { - vec![("task".to_owned(), ErrorField::String(task.to_string()))] - } - Error::Store { cause, .. } => runvil_lua::store_error_value_fields(cause), - Error::Parse(_) - | Error::Completion(_) - | Error::Interrupted - | Error::Lua { .. } - | Error::LuaCompile { .. } - | Error::BindSchema { .. } - | Error::ToolSets { .. } - | Error::Substitution(_) - | Error::ToolLoopExhausted - | Error::ModelRequired { .. } - | Error::UnsupportedVersion(_) - | Error::RequirementsUnmet { .. } - | Error::Tool { .. } - | Error::Internal { .. } - | Error::LuaQuota { .. } - | Error::CallDepthExceeded { .. } - | Error::Determinism(_) => Vec::new(), - } - } -} diff --git a/crates/runvil-internal/engine/src/execute.rs b/crates/runvil-internal/engine/src/execute.rs index 1158fcd50..5b8933d8f 100644 --- a/crates/runvil-internal/engine/src/execute.rs +++ b/crates/runvil-internal/engine/src/execute.rs @@ -15,7 +15,7 @@ //! - `context` - the ambient run state: the run-wide `SharedState` and //! each chain's `ChainState`. //! - `error` - the stable [`RunErrorKind`] of a failed outcome, and the -//! kind and message the Engine's own error ends a run with. +//! kind and message an Engine failure ends a run with. //! - `fill` - the fill function, which builds the model set from the //! caller's role map when the run is built. //! - `infer` - the nested-inference round's answer. @@ -57,6 +57,9 @@ mod walk_target; // Public API surface. pub use config::{RunConfig, RunLimits}; pub use error::RunErrorKind; +pub(crate) use error::outcome; +#[cfg(test)] +pub(crate) use error::run_error_kind; #[cfg(test)] pub(crate) use run::{AnswerRecord, ChatAnswerRecord, EffectRecord}; pub use run::{ @@ -121,7 +124,9 @@ pub enum RunOutcome { }, /// The caller cancelled the run or dropped an effect the run was waiting /// on and the cancelled error went uncaught, or a block raised a caught - /// `cancelled` error value again after another suspending call. + /// `cancelled` error value with no `task` again after another + /// suspending call. A raised value that names its `task` is that task's + /// cancellation, which fails the run as `Lua`. Cancelled, } diff --git a/crates/runvil-internal/engine/src/execute/context-tests.rs b/crates/runvil-internal/engine/src/execute/context-tests.rs index 08b95043d..96b5cc646 100644 --- a/crates/runvil-internal/engine/src/execute/context-tests.rs +++ b/crates/runvil-internal/engine/src/execute/context-tests.rs @@ -20,7 +20,7 @@ fn test_prompt() -> Prompt { "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n", "# Title\n\n## Only\n\ndone\n", ); - Prompt::parse(source, "run-context-test") + Prompt::parse(source, "prompt", "run-context-test") .0 .expect("the test prompt parses") } diff --git a/crates/runvil-internal/engine/src/execute/context.rs b/crates/runvil-internal/engine/src/execute/context.rs index cfb8aef9e..67cdf80bd 100644 --- a/crates/runvil-internal/engine/src/execute/context.rs +++ b/crates/runvil-internal/engine/src/execute/context.rs @@ -420,6 +420,7 @@ impl ChainState { seed, emitter: &self.reporting.emitter, section_name, + file: self.shared.prompt.file(), max_tool_iterations: self.max_tool_iterations(), ui: self.shared.ui.as_ref(), preludes: &self.shared.preludes, diff --git a/crates/runvil-internal/engine/src/execute/error-tests.rs b/crates/runvil-internal/engine/src/execute/error-tests.rs index b8b8d9eeb..25a034705 100644 --- a/crates/runvil-internal/engine/src/execute/error-tests.rs +++ b/crates/runvil-internal/engine/src/execute/error-tests.rs @@ -1,7 +1,15 @@ //! The stable tag of each run-error kind: the variant's name, which parses -//! back to the kind. +//! back to the kind. The kind each failure code classifies as, and the +//! location a failed run's message ends with. -use super::RunErrorKind; +use std::collections::BTreeSet; +use std::num::NonZeroU32; + +use runvil_types::error::{Error, ErrorCode, FactKey, Location}; + +use super::{RunErrorKind, failure_message, outcome, run_error_kind}; +use crate::execute::RunOutcome; +use crate::prompt::Prompt; /// Every kind, in declaration order. const KINDS: [RunErrorKind; 10] = [ @@ -45,3 +53,217 @@ fn a_tag_no_kind_has_parses_to_none() { assert_eq!(RunErrorKind::from_tag(tag), None, "{tag:?}"); } } + +/// Every code beside the kind a run it ends reports, which is the kind the +/// failure it stands for always classified as. +const CODE_KINDS: [(ErrorCode, RunErrorKind); 68] = [ + (ErrorCode::FrontmatterInvalid, RunErrorKind::Parse), + (ErrorCode::StructureInvalid, RunErrorKind::Parse), + (ErrorCode::FenceInvalid, RunErrorKind::Parse), + (ErrorCode::ListEmptyItem, RunErrorKind::Parse), + (ErrorCode::UnsupportedVersion, RunErrorKind::Parse), + (ErrorCode::InvalidValue, RunErrorKind::Parse), + (ErrorCode::LuaRuntime, RunErrorKind::Lua), + (ErrorCode::LuaCompile, RunErrorKind::Lua), + (ErrorCode::LuaQuota, RunErrorKind::Quota), + (ErrorCode::CallDepthExceeded, RunErrorKind::Quota), + (ErrorCode::Substitution, RunErrorKind::Lua), + (ErrorCode::TasksLive, RunErrorKind::Lua), + (ErrorCode::TaskNotOwned, RunErrorKind::Lua), + (ErrorCode::TaskConsumed, RunErrorKind::Lua), + (ErrorCode::TaskCancelled, RunErrorKind::Lua), + (ErrorCode::ContextOverflow, RunErrorKind::Completion), + (ErrorCode::RateLimited, RunErrorKind::Completion), + (ErrorCode::QuotaExhausted, RunErrorKind::Completion), + (ErrorCode::Overloaded, RunErrorKind::Completion), + (ErrorCode::Refused, RunErrorKind::Completion), + (ErrorCode::Timeout, RunErrorKind::Completion), + (ErrorCode::Transport, RunErrorKind::Completion), + (ErrorCode::ServerError, RunErrorKind::Completion), + (ErrorCode::Rejected, RunErrorKind::Completion), + (ErrorCode::MalformedResponse, RunErrorKind::Completion), + (ErrorCode::EmptyReply, RunErrorKind::Completion), + (ErrorCode::Unavailable, RunErrorKind::Completion), + (ErrorCode::InvalidParts, RunErrorKind::Completion), + (ErrorCode::ContextExhausted, RunErrorKind::ContextExhausted), + (ErrorCode::ModelRequired, RunErrorKind::RequirementsUnmet), + (ErrorCode::ToolInvalidArguments, RunErrorKind::Tool), + (ErrorCode::ToolBackend, RunErrorKind::Tool), + (ErrorCode::ToolTransport, RunErrorKind::Tool), + (ErrorCode::ToolOther, RunErrorKind::Tool), + (ErrorCode::OutOfScopeTool, RunErrorKind::Tool), + (ErrorCode::UnboundTool, RunErrorKind::Tool), + (ErrorCode::ModelOnlyTool, RunErrorKind::Tool), + (ErrorCode::ToolLoopExhausted, RunErrorKind::Tool), + (ErrorCode::ToolSchemaInvalid, RunErrorKind::Binding), + (ErrorCode::ToolSetsInvalid, RunErrorKind::Binding), + (ErrorCode::NotFound, RunErrorKind::Vfs), + (ErrorCode::AlreadyExists, RunErrorKind::Vfs), + (ErrorCode::NotADirectory, RunErrorKind::Vfs), + (ErrorCode::IsADirectory, RunErrorKind::Vfs), + (ErrorCode::DirectoryNotEmpty, RunErrorKind::Vfs), + (ErrorCode::NotUtf8, RunErrorKind::Vfs), + (ErrorCode::InvalidPath, RunErrorKind::Vfs), + (ErrorCode::InvalidRange, RunErrorKind::Vfs), + (ErrorCode::Anchor, RunErrorKind::Vfs), + (ErrorCode::PermissionDenied, RunErrorKind::Vfs), + (ErrorCode::ApprovalRequired, RunErrorKind::Vfs), + (ErrorCode::ScopeEnded, RunErrorKind::Vfs), + (ErrorCode::ReadOnly, RunErrorKind::Vfs), + (ErrorCode::OutsideRoot, RunErrorKind::Vfs), + (ErrorCode::RootProtected, RunErrorKind::Vfs), + (ErrorCode::NoStore, RunErrorKind::Vfs), + (ErrorCode::CrossMount, RunErrorKind::Vfs), + (ErrorCode::Io, RunErrorKind::Vfs), + (ErrorCode::Unsupported, RunErrorKind::Vfs), + (ErrorCode::Conflict, RunErrorKind::Vfs), + (ErrorCode::Backend, RunErrorKind::Vfs), + (ErrorCode::Determinism, RunErrorKind::Vfs), + ( + ErrorCode::RequirementsUnmet, + RunErrorKind::RequirementsUnmet, + ), + (ErrorCode::Interrupted, RunErrorKind::Internal), + (ErrorCode::Internal, RunErrorKind::Internal), + (ErrorCode::RecorderFailed, RunErrorKind::Internal), + (ErrorCode::PluginLoadFailed, RunErrorKind::Internal), + (ErrorCode::NoOutput, RunErrorKind::Internal), +]; + +#[test] +fn every_code_classifies_as_the_kind_its_failure_always_had() { + let numbers: BTreeSet = CODE_KINDS.iter().map(|(code, _)| code.number()).collect(); + assert_eq!(numbers.len(), CODE_KINDS.len(), "each code is listed once"); + for (code, kind) in CODE_KINDS { + assert_eq!(run_error_kind(code), kind, "{code:?}"); + assert_eq!(ErrorCode::from_tag(code.tag()), Some(code), "{code:?}"); + } +} + +/// A failure with `code` and `message`. +fn failure(code: ErrorCode, message: &str) -> Error { + Error::new(code, message, code.prose()) +} + +/// The line or column `n`. +fn at(n: u32) -> NonZeroU32 { + NonZeroU32::new(n).expect("tests pass non-zero positions") +} + +#[test] +fn a_parse_failure_message_ends_with_the_prompt_name_and_the_start_position() { + let mut error = failure(ErrorCode::StructureInvalid, "duplicate section"); + assert_eq!(failure_message(&error), "duplicate section"); + error.facts.insert(FactKey::Name, "greeter".into()); + assert_eq!( + failure_message(&error), + "duplicate section (prompt greeter)" + ); + error.at = Location::new("agents/greeter.md", at(3), at(7), at(4), at(1)); + assert_eq!( + failure_message(&error), + "duplicate section (prompt greeter, line 3, column 7)", + "the location names the start line and column, not the end" + ); + error.facts.clear(); + assert_eq!( + failure_message(&error), + "duplicate section (line 3, column 7)" + ); + assert_eq!( + outcome(&error), + RunOutcome::Failed { + kind: RunErrorKind::Parse, + message: "duplicate section (line 3, column 7)".to_owned(), + } + ); +} + +#[test] +fn a_parsed_prompts_failure_message_ends_with_its_name_and_position() { + let frontmatter = concat!( + "---\n", + "name: x\n", + "description: d\n", + "uses:\n", + " - not a Pack id\n", + "---\n", + "\n# T\n\n## S\n\np\n", + ); + let refused = Prompt::parse(frontmatter, "prompt", "test") + .0 + .expect_err("a Pack id with spaces must be rejected"); + assert_eq!(run_error_kind(refused.code), RunErrorKind::Parse); + assert_eq!( + failure_message(&refused), + format!("{} (line 5, column 5)", refused.message), + "a frontmatter failure predates the prompt's name" + ); + let duplicate = "---\nname: dup\ndescription: d\n---\n\n# T\n\n## S\n\np\n\n## S\n\nq\n"; + let refused = Prompt::parse(duplicate, "prompt", "test") + .0 + .expect_err("duplicate sibling sections must be rejected"); + assert_eq!( + failure_message(&refused), + format!("{} (prompt dup, line 12, column 1)", refused.message) + ); +} + +#[test] +fn a_parse_failure_run_raises_names_the_prompt_and_its_file() { + let code = ErrorCode::StructureInvalid; + let message = "not a runvil prompt: no runvil version"; + let stamped = crate::prompt::detail::with_prompt_context( + crate::prompt::detail::parse_error(code, message), + "greeter", + "agents/greeter.md", + ); + assert_eq!((stamped.code, stamped.message.as_str()), (code, message)); + assert_eq!(stamped.at, None); + assert_eq!( + stamped.facts.get(&FactKey::Name), + Some(&serde_json::Value::from("greeter")) + ); + assert_eq!( + stamped.prose, + format!("agents/greeter.md: {message}. {}", code.prose()) + ); + assert_eq!( + failure_message(&stamped), + format!("{message} (prompt greeter)") + ); +} + +#[test] +fn an_internal_failure_message_ends_with_its_rust_file_and_line() { + let mut error = failure(ErrorCode::Internal, "internal invariant violated: x"); + assert_eq!( + failure_message(&error), + "internal invariant violated: x", + "an internal failure with no location ends with its own text" + ); + error.at = Some(Location::line("src/a.rs", at(12))); + assert_eq!( + failure_message(&error), + "internal invariant violated: x (at src/a.rs:12)" + ); + error.facts.insert(FactKey::Name, "ignored".into()); + assert_eq!( + failure_message(&error), + "internal invariant violated: x (at src/a.rs:12)", + "an internal failure names no prompt" + ); +} + +#[test] +fn any_other_failure_ends_with_its_own_text_whatever_its_location() { + let mut error = failure( + ErrorCode::LuaCompile, + "lua compilation error at x (line 2): y", + ); + error.at = Some(Location::line("x", at(2))); + error.facts.insert(FactKey::Name, "p".into()); + assert_eq!(failure_message(&error), error.message); + let interrupted = crate::error::interrupted(); + assert_eq!(failure_message(&interrupted), interrupted.message); +} diff --git a/crates/runvil-internal/engine/src/execute/error.rs b/crates/runvil-internal/engine/src/execute/error.rs index 0c28ffd20..0cb24e80b 100644 --- a/crates/runvil-internal/engine/src/execute/error.rs +++ b/crates/runvil-internal/engine/src/execute/error.rs @@ -1,5 +1,7 @@ //! The stable [`RunErrorKind`] of a failed run, and the kind and message -//! the Engine's own error ends a run with. +//! an Engine failure ends a run with. + +use runvil_types::error::{ErrorCode, FactKey}; use crate::Error; @@ -115,122 +117,140 @@ impl RunErrorKind { } } -impl Error { - /// Returns the stable classification of this failure. - pub(crate) fn kind(&self) -> RunErrorKind { - match self { - Error::Parse(_) | Error::UnsupportedVersion(_) => RunErrorKind::Parse, - Error::LuaQuota { .. } | Error::CallDepthExceeded { .. } => RunErrorKind::Quota, - Error::ContextExhausted { .. } => RunErrorKind::ContextExhausted, - // A leaked task, a task reached for by a chain that does not - // own it, a result waited on twice, a wait's delivery of a - // cancelled task surfacing uncaught, and a failed `{{ }}` - // substitution are the author's program failing, as any Lua - // fault is. - Error::LuaCompile { .. } - | Error::Lua { .. } - | Error::TasksLive { .. } - | Error::TaskNotOwned { .. } - | Error::TaskConsumed { .. } - | Error::TaskCancelled { .. } - | Error::Substitution(_) => RunErrorKind::Lua, - Error::RequirementsUnmet { .. } | Error::ModelRequired { .. } => { - RunErrorKind::RequirementsUnmet - } - Error::Completion(_) => RunErrorKind::Completion, - Error::ToolLoopExhausted - | Error::OutOfScopeToolCall { .. } - | Error::UnboundToolCall { .. } - | Error::ModelOnlyToolCall { .. } - | Error::Tool { .. } => RunErrorKind::Tool, - // A cancelled run ends as `RunOutcome::Cancelled`, so an - // interruption that reaches a failure is an Engine fault. - Error::Internal { .. } | Error::Interrupted => RunErrorKind::Internal, - Error::Store { .. } | Error::Determinism(_) => RunErrorKind::Vfs, - Error::BindSchema { .. } | Error::ToolSets { .. } => RunErrorKind::Binding, +/// Returns the stable classification of a failure with `code`. +/// +/// A leaked task, a task reached for by a chain that does not own it, a +/// result waited on twice, a wait's delivery of a cancelled task surfacing +/// uncaught, and a failed `{{ }}` substitution are the author's program +/// failing, as any Lua fault is. A cancelled run ends as +/// `RunOutcome::Cancelled`, so an interruption that reaches a failure is an +/// Engine fault, as is a failure only the Harness raises. +pub(crate) fn run_error_kind(code: ErrorCode) -> RunErrorKind { + match code { + ErrorCode::FrontmatterInvalid + | ErrorCode::StructureInvalid + | ErrorCode::FenceInvalid + | ErrorCode::ListEmptyItem + | ErrorCode::UnsupportedVersion + | ErrorCode::InvalidValue => RunErrorKind::Parse, + ErrorCode::LuaQuota | ErrorCode::CallDepthExceeded => RunErrorKind::Quota, + ErrorCode::ContextExhausted => RunErrorKind::ContextExhausted, + ErrorCode::LuaRuntime + | ErrorCode::LuaCompile + | ErrorCode::TasksLive + | ErrorCode::TaskNotOwned + | ErrorCode::TaskConsumed + | ErrorCode::TaskCancelled + | ErrorCode::Substitution => RunErrorKind::Lua, + ErrorCode::RequirementsUnmet | ErrorCode::ModelRequired => RunErrorKind::RequirementsUnmet, + 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 => RunErrorKind::Completion, + ErrorCode::ToolLoopExhausted + | ErrorCode::OutOfScopeTool + | ErrorCode::UnboundTool + | ErrorCode::ModelOnlyTool + | ErrorCode::ToolInvalidArguments + | ErrorCode::ToolBackend + | ErrorCode::ToolTransport + | ErrorCode::ToolOther => RunErrorKind::Tool, + ErrorCode::Internal | ErrorCode::Interrupted => RunErrorKind::Internal, + 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 => RunErrorKind::Vfs, + ErrorCode::ToolSchemaInvalid | ErrorCode::ToolSetsInvalid => RunErrorKind::Binding, + ErrorCode::RecorderFailed | ErrorCode::PluginLoadFailed | ErrorCode::NoOutput => { + RunErrorKind::Internal } } +} - /// Returns the self-contained text a failed run reports: the error's - /// own text, then each cause down its `source` chain whose text the - /// message does not hold yet, after `: `, then where the failure - /// happened. - /// - /// A cause-bearing variant renders its cause in its own text and keeps - /// no `source`, so the chain adds only what a transparent variant's - /// error keeps there. A parse failure ends with its prompt name, line, - /// and column, each when known, as `(prompt {name}, line {line}, - /// column {column})`, and an internal fault with the Rust source file - /// and line of the broken invariant, as `(at {file}:{line})`. - pub(crate) fn message(&self) -> String { - let mut rendered = self.to_string(); - let mut cause = std::error::Error::source(self); - while let Some(current) = cause { - let text = current.to_string(); - if !text.is_empty() && !rendered.contains(&text) { - rendered.push_str(": "); - rendered.push_str(&text); - } - cause = current.source(); - } - if let Some(location) = self.location() { - rendered.push_str(" ("); - rendered.push_str(&location); - rendered.push(')'); - } - rendered +/// Returns the self-contained text a failed run reports: the error's +/// message, with its causes, then where the failure happened. +/// +/// A parse failure ends with its prompt name, line, and column, each when +/// known, as `(prompt {name}, line {line}, column {column})`, and an +/// internal fault with the Rust source file and line of the broken +/// invariant, as `(at {file}:{line})`. +pub(crate) fn failure_message(error: &Error) -> String { + let mut rendered = error.message.clone(); + if let Some(location) = location(error) { + rendered.push_str(" ("); + rendered.push_str(&location); + rendered.push(')'); } + rendered +} - /// Where the failure happened, for the end of its message, or `None` - /// when the failure has no location or the parser found none of its - /// parts. - fn location(&self) -> Option { - match self { - Error::Parse(error) => { - let parts: Vec = [ - error.name().map(|name| format!("prompt {name}")), - error.line().map(|line| format!("line {line}")), - error.column().map(|column| format!("column {column}")), - ] - .into_iter() - .flatten() - .collect(); - (!parts.is_empty()).then(|| parts.join(", ")) - } - Error::Internal { file, line, .. } => Some(format!("at {file}:{line}")), - Error::Completion(_) - | Error::Interrupted - | Error::Lua { .. } - | Error::LuaCompile { .. } - | Error::BindSchema { .. } - | Error::ToolSets { .. } - | Error::Substitution(_) - | Error::ToolLoopExhausted - | Error::TasksLive { .. } - | Error::TaskNotOwned { .. } - | Error::TaskConsumed { .. } - | Error::TaskCancelled { .. } - | Error::OutOfScopeToolCall { .. } - | Error::UnboundToolCall { .. } - | Error::ModelOnlyToolCall { .. } - | Error::ModelRequired { .. } - | Error::UnsupportedVersion(_) - | Error::RequirementsUnmet { .. } - | Error::Tool { .. } - | Error::LuaQuota { .. } - | Error::CallDepthExceeded { .. } - | Error::ContextExhausted { .. } - | Error::Store { .. } - | Error::Determinism(_) => None, +/// Where the failure happened, for the end of its message, or `None` +/// when the failure has no location or the parser found none of its +/// parts. +fn location(error: &Error) -> Option { + match run_error_kind(error.code) { + RunErrorKind::Parse => { + let parts: Vec = [ + error + .facts + .get(&FactKey::Name) + .and_then(serde_json::Value::as_str) + .map(|name| format!("prompt {name}")), + error + .at + .as_ref() + .map(|at| format!("line {}", at.start_line)), + error + .at + .as_ref() + .map(|at| format!("column {}", at.start_column)), + ] + .into_iter() + .flatten() + .collect(); + (!parts.is_empty()).then(|| parts.join(", ")) } + _ if error.code == ErrorCode::Internal => error + .at + .as_ref() + .map(|at| format!("at {}:{}", at.file, at.start_line)), + _ => None, } +} - /// Returns the failed outcome this error ends a run with. - pub(crate) fn to_outcome(&self) -> RunOutcome { - RunOutcome::Failed { - kind: self.kind(), - message: self.message(), - } +/// Returns the failed outcome `error` ends a run with: the kind its code +/// classifies, and its message. +pub(crate) fn outcome(error: &Error) -> RunOutcome { + RunOutcome::Failed { + kind: run_error_kind(error.code), + message: failure_message(error), } } diff --git a/crates/runvil-internal/engine/src/execute/infer-tests.rs b/crates/runvil-internal/engine/src/execute/infer-tests.rs index ac93b6b3a..536fbbb8b 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] @@ -27,12 +27,9 @@ fn an_infer_round_answered_with_tool_calls_fails_as_a_retryable_malformed_reply( "Infer", ) .expect_err("no tools were advertised, so tool calls are refused"); - let Error::Completion(failure) = &error else { - panic!("required a completion failure, actual {error:?}"); - }; assert_eq!( - failure.kind(), - CompletionErrorKind::MalformedResponse, + error.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..d2c083912 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,11 +60,8 @@ 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; + completion_error(code, format!("{}: {specific}", phrase(code))) } /// Applies one infer round's answer, the completion the caller obtained @@ -76,11 +74,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 a model failure 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, Error>, round: RoundId, reporting: &ReportingHandles, section: &str, @@ -91,7 +89,7 @@ pub(super) fn accept_infer( reporting .emitter .report(section, lifecycle::MODEL_TURN_FAILED); - return Err(Error::from(error)); + return Err(error); } }; accept_infer_completion(*completion, round, reporting, section) diff --git a/crates/runvil-internal/engine/src/execute/requirements-tests.rs b/crates/runvil-internal/engine/src/execute/requirements-tests.rs index 07043fb08..30d99e891 100644 --- a/crates/runvil-internal/engine/src/execute/requirements-tests.rs +++ b/crates/runvil-internal/engine/src/execute/requirements-tests.rs @@ -37,7 +37,7 @@ fn prompt(front: &str) -> Prompt { "---\nname: t\ndescription: d\nrunvil: 0\n{front}---\n\n\ # Run\n\n## Only\n\n```lua\nreturn 'done'\n```\n" ); - Prompt::parse(&source, "requirements-test") + Prompt::parse(&source, "prompt", "requirements-test") .0 .expect("the requirements test prompt parses") } 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/run/tests-drops.rs b/crates/runvil-internal/engine/src/execute/run/tests-drops.rs index 11fd17862..6cacdc1bc 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests-drops.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests-drops.rs @@ -43,7 +43,7 @@ fn bound_run(body: &str, cancel: &CancelHandle) -> Run { #[track_caller] fn bound_sections(sections: &str, cancel: &CancelHandle) -> Run { let source = format!("---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Run\n\n{sections}"); - let prompt = Prompt::parse(&source, "run-test") + let prompt = Prompt::parse(&source, "prompt", "run-test") .0 .expect("the drop test prompt parses"); let model = test_model_with("test-model", 4096, ThinkingMode::Never); diff --git a/crates/runvil-internal/engine/src/execute/run/tests-start.rs b/crates/runvil-internal/engine/src/execute/run/tests-start.rs index 00a3fbe94..71def5a7e 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests-start.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests-start.rs @@ -20,7 +20,7 @@ fn a_run_whose_only_section_returns_a_literal_is_done_at_its_first_step() { #[test] fn a_run_that_cannot_start_reports_its_failure_on_the_first_step() { let source = "---\nname: t\ndescription: d\nrunvil: 7\n---\n\n# Run\n\n## Only\n\ndone\n"; - let prompt = Prompt::parse(source, "run-test") + let prompt = Prompt::parse(source, "prompt", "run-test") .0 .expect("the prompt parses whatever version it declares"); let mut run = Run::new(Arc::new(prompt), "", run_config()); diff --git a/crates/runvil-internal/engine/src/execute/run/tests.rs b/crates/runvil-internal/engine/src/execute/run/tests.rs index f121dbc7e..2bbd158fb 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests.rs @@ -215,7 +215,7 @@ fn prompt_of(body: &str) -> Arc { let source = format!( "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Run\n\n## Only\n\n```lua\n{body}\n```\n" ); - let prompt = Prompt::parse(&source, "run-test") + let prompt = Prompt::parse(&source, "prompt", "run-test") .0 .expect("the run test prompt parses"); Arc::new(prompt) diff --git a/crates/runvil-internal/engine/src/execute/scheduler.rs b/crates/runvil-internal/engine/src/execute/scheduler.rs index 607448144..d23318d2a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler.rs @@ -23,8 +23,8 @@ //! Every `dispatch_*` arm with a fallible half resumes that half's error //! into the calling chain as the call's answer (`tool_calls` into the //! failing call's batch slot), so an author `pcall` catches it. An -//! `Error::Internal` from a scheduler method marks a state the scheduler's -//! own invariants rule out: a scheduler or shim bug. +//! `Internal` failure from a scheduler method marks a state the +//! scheduler's own invariants rule out: a scheduler or shim bug. //! //! [`ChainState`] stays the ambient shared read-mostly context, cloned into //! chains and callbacks; the scheduler owns the root chain's copy and is @@ -94,9 +94,9 @@ use runvil_types::cancel::CancelHandle; use runvil_vfs::Origin; use runvil_vfs::detail::ScopeHandle; +use crate::Result; use crate::lua::Answer; use crate::prompt::{Block, Prompt}; -use crate::{Error, Result}; use super::context::ChainState; use super::run::EffectAnswer; @@ -182,7 +182,7 @@ pub(crate) struct Scheduler { scope: Option, /// The error behind the failed outcome the last `Done` carried. #[cfg(test)] - failure: Option, + failure: Option, } impl Drop for Scheduler { @@ -251,9 +251,9 @@ impl Scheduler { /// end answering another chain. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no suspended block + /// Returns an `Internal` failure when the chain has no suspended block /// to take the answer, or one that already holds an answer. - fn answer_inline(&mut self, id: ChainIndex, answer: Result>) -> Result<()> { + fn answer_inline(&mut self, id: ChainIndex, answer: Result) -> Result<()> { self.chains[id].deliver(answer)?; self.ready.push_back(id); Ok(()) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs index bbba0b73d..47fd05832 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs @@ -8,13 +8,13 @@ use std::num::NonZeroUsize; +use runvil_types::error::ErrorCode; use runvil_types::ids::ChainId; use runvil_vfs::{Origin, VfsRef}; use super::super::chain_record::{Chain, ChainArena, ChainIndex, ChainKind}; use super::super::tasks::{TaskArena, TaskBacking}; use super::{Admission, Admit, ChainTree, SpawnRecord}; -use crate::Error; /// The arenas a [`ChainTree`] reads, built up one chain at a time. struct Tree { @@ -256,7 +256,7 @@ fn a_resumption_whose_slots_belong_to_no_admitted_task_is_an_internal_error() { assert!(admission.wake(queued, &tree.view())); assert!(matches!( admission.plan(&tree.view()), - Err(Error::Internal { .. }) + Err(error) if error.code == ErrorCode::Internal )); assert!(admission.resuming.contains(&queued)); } @@ -277,7 +277,7 @@ fn a_pass_that_fails_on_an_internal_error_leaves_admission_as_it_found_it() { ); assert!(matches!( admission.plan(&tree.view()), - Err(Error::Internal { .. }) + Err(error) if error.code == ErrorCode::Internal )); assert!(admission.is_queued(task)); assert!(!admission.holding.contains(&task)); @@ -303,7 +303,7 @@ fn a_pass_that_fails_after_admitting_a_resumption_leaves_its_slots_given_back() ); assert!(matches!( admission.plan(&tree.view()), - Err(Error::Internal { .. }) + Err(error) if error.code == ErrorCode::Internal )); assert_eq!(admission.gave_back.get(&call), Some(&task)); assert!(admission.resuming.contains(&call)); diff --git a/crates/runvil-internal/engine/src/execute/scheduler/admission.rs b/crates/runvil-internal/engine/src/execute/scheduler/admission.rs index 872807f39..8e16c063e 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/admission.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/admission.rs @@ -23,7 +23,7 @@ use std::sync::Arc; use runvil_types::event::Event; use crate::lua::Answer; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain_record::{ChainArena, ChainKind, Parked}; use super::tasks::TaskArena; @@ -155,7 +155,7 @@ impl Admission { /// next admission in the same pass. /// /// # Errors - /// Returns [`Error::Internal`] when a resumed chain's slots belong to + /// Returns an `Internal` failure when a resumed chain's slots belong to /// no admitted task, or a queued spawn is not a task chain. A failed /// pass leaves admission as it found it: both queues, the holders, and /// the given-back slots. @@ -175,7 +175,7 @@ impl Admission { continue; } if !matches!(tree.kind(holder), ChainKind::Task) || self.is_queued(holder) { - return Err(Error::internal( + return Err(error::internal( "a resumed chain's slots belong to an admitted task", )); } @@ -191,7 +191,7 @@ impl Admission { continue; } if !matches!(tree.kind(id), ChainKind::Task) { - return Err(Error::internal("a queued chain backs a task")); + return Err(error::internal("a queued chain backs a task")); } holding.insert(id); count_slot(&mut used, tree, id); @@ -328,7 +328,7 @@ impl Scheduler { /// admission [`Admission::plan`] made, in its order. /// /// # Errors - /// Returns [`Error::Internal`] when the plan does, or when a started + /// Returns an `Internal` failure when the plan does, or when a started /// task has no entry. pub(super) fn admit(&mut self) -> Result<()> { let tree = ChainTree { @@ -354,7 +354,7 @@ impl Scheduler { /// spawner moved on still reports where it was spawned). /// /// # Errors - /// Returns [`Error::Internal`] when its task has no entry: a spawn + /// Returns an `Internal` failure when its task has no entry: a spawn /// registers the entry before it queues the chain. fn admit_chain(&mut self, id: ChainIndex, record: SpawnRecord) -> Result<()> { let chain = &mut self.chains[id]; @@ -365,7 +365,7 @@ impl Scheduler { let var = chain.var.clone(); let (Some(owner), Some(target)) = (self.tasks.owner_of(&task), self.tasks.target_of(&task)) else { - return Err(Error::internal("a task chain has an entry")); + return Err(error::internal("a task chain has an entry")); }; let target = target.to_owned(); let emitter = Arc::clone(self.chains[owner].ctx.emitter()); @@ -394,13 +394,9 @@ impl Scheduler { /// its slots, else pushed onto the ready queue at once. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no suspended block + /// Returns an `Internal` failure when the chain has no suspended block /// to take the answer, or one that already holds an answer. - pub(super) fn wake_from_wait( - &mut self, - id: ChainIndex, - answer: Result>, - ) -> Result<()> { + pub(super) fn wake_from_wait(&mut self, id: ChainIndex, answer: Result) -> Result<()> { self.chains[id].deliver(answer)?; let tree = ChainTree { chains: &self.chains, diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index a697f134f..754319fab 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -17,9 +17,10 @@ //! call's identity: what the tool did is joined back into the chain, and //! the call's access refuses every later operation. +use runvil_types::error::ErrorCode; 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,8 +28,8 @@ use crate::lua::{ Answer, ModelReport, ToolCallOutcome, VfsOp, VfsOutcome, prepare_dispatch, prepare_model_dispatch, }; -use crate::model::{Completion, CompletionError}; -use crate::{Error, Result}; +use crate::model::Completion; +use crate::{Error, Result, error}; use super::tasks::Stop; use super::{ @@ -74,16 +75,16 @@ fn vfs_observations(op: &VfsOp) -> Option<(Lifecycle, Lifecycle)> { /// claims-model conflict becomes the fatal determinism violation: the /// driver intercepts it at the answer boundary and ends the run on the /// spot rather than resuming it into Lua, so no author `pcall` can catch -/// it. Every other failure returns as the call's answer as an -/// [`Error::Store`] holding the model-facing message rendered for the -/// operation and the structured cause, which ends the run as a +/// it. Every other failure returns as the call's answer as a store +/// failure with the model-facing message rendered for the operation and +/// the store's code and facts, which ends the run as a /// `RunErrorKind::Vfs` error if it aborts the chunk uncaught. -fn classify_vfs_failure(op: &VfsOp, error: VfsError) -> Error { - match error { - VfsError::Conflict { detail, .. } => Error::Determinism(detail), +fn classify_vfs_failure(op: &VfsOp, cause: &VfsError) -> Error { + match cause { + VfsError::Conflict { detail, .. } => error::determinism(detail), // `VfsError` is `#[non_exhaustive]` across the crate seam; every // other failure is the call's answer. - error => Error::store_op(op, error), + cause => error::store_op(op, cause), } } @@ -103,10 +104,10 @@ impl Scheduler { /// tries after is admitted. /// /// # Errors - /// Returns [`Error::Internal`] when the answer's kind does not match + /// Returns an `Internal` failure when the answer's kind does not match /// the effect's, when a batched call's answer finds no open slot to /// fill, or when the chain it resumes has no suspended block. - /// Returns [`Error::Determinism`] when a store answer reports a + /// Returns a `Determinism` failure when a store answer reports a /// claims-model conflict: the run ends on the spot rather than /// resuming the conflict into Lua, where an author `pcall` could catch /// it. @@ -128,14 +129,13 @@ impl Scheduler { }; let answer = match (resume, answer) { (Continuation::Timer(task), EffectAnswer::Dropped) => return self.drop_timer(&task), - (_, EffectAnswer::Dropped) => Err(Error::Interrupted), + (_, EffectAnswer::Dropped) => Err(error::interrupted()), (Continuation::Infer(round), EffectAnswer::Chat(result)) => self .with_chain(chain, |ops| ops.accept_infer(round, result)) .map(Answer::Infer), - (Continuation::Chat(round), EffectAnswer::Chat(result)) => self - .with_chain(chain, |ops| { - ops.accept_chat(round, result.map_err(Error::from)) - })?, + (Continuation::Chat(round), EffectAnswer::Chat(result)) => { + self.with_chain(chain, |ops| ops.accept_chat(round, result))? + } (Continuation::Render(binding), EffectAnswer::Render(rendered)) => { return self.with_chain(chain, |ops| ops.accept_render(binding, rendered)); } @@ -147,7 +147,7 @@ impl Scheduler { // A claims-model conflict is fatal: the suspended // chains drop unarmed in the run's teardown, exactly // as on the cancellation path. - Err(error @ Error::Determinism(_)) => return Err(error), + Err(error) if error.code == ErrorCode::Determinism => return Err(error), result => result.map(Answer::Store), } } @@ -187,7 +187,7 @@ impl Scheduler { | EffectAnswer::ToolCall(_) | EffectAnswer::Vfs(_), ) => { - return Err(Error::internal( + return Err(error::internal( "an effect's answer must be of the effect's own kind", )); } @@ -233,7 +233,7 @@ impl Scheduler { if self.ctx.cancel().is_cancelled() || !self.chains[owner].end_wait_for(task) { return Ok(()); } - self.wake_from_wait(owner, Err(Error::Interrupted)) + self.wake_from_wait(owner, Err(error::interrupted())) } } @@ -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 @@ -288,7 +288,6 @@ impl ChainOps<'_> { &report, ) .map(|outcome| ToolCallOutcome::Plain(outcome.into_content())) - .map_err(Error::from) } CallSite::Script => match prepare_dispatch( &call.binding, @@ -308,9 +307,8 @@ impl ChainOps<'_> { call.binding.alias(), outcome.into_content(), ) - .map_err(Error::from) } - Err(error) => Err(Error::from(error)), + Err(failure) => Err(failure), }, } } @@ -332,6 +330,6 @@ impl ChainOps<'_> { if result.is_ok() { succeeded } else { failed }, ); } - result.map_err(|error| classify_vfs_failure(op, error)) + result.map_err(|cause| classify_vfs_failure(op, &cause)) } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs index ae9d009ae..dbafb1606 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs @@ -7,6 +7,7 @@ use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; +use runvil_types::error::ErrorCode; use runvil_types::ids::{AbandonReason, ChainId, TaskId}; use runvil_vfs::Access; @@ -16,7 +17,7 @@ use crate::execute::section_context::TaskSeed; use crate::heading_address::SectionPos; use crate::lua::Answer; use crate::model::ModelBinding; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain_record::{CallDepth, ChainKind, Walk}; use super::{Chain, ChainIndex, ChatAnchor, Counters, GENERIC_COMPLETION, Scheduler}; @@ -38,12 +39,12 @@ impl Chain { /// supplied no model. /// /// # Errors - /// Returns [`Error::Internal`] when the selection's lock is poisoned. + /// Returns an `Internal` failure when the selection's lock is poisoned. pub(super) fn current_model(&self) -> Result> { self.selection .lock() .map(|selection| selection.clone()) - .map_err(|_| Error::internal("model selection lock was poisoned")) + .map_err(|_| error::internal("model selection lock was poisoned")) } } @@ -92,7 +93,7 @@ impl Scheduler { /// root walk and the H1 pass. /// /// # Errors - /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`. + /// Returns an `Internal` failure when the run's chain count exceeds `u32`. pub(super) fn start_chain(&mut self, start: ChainStart) -> Result { let task = match &start.kind { ChainKind::Root | ChainKind::H1 => TaskId::from(ChainId::root()), @@ -130,7 +131,7 @@ impl Scheduler { /// it again. /// /// # Errors - /// Returns [`Error::Internal`] when the run's chain count exceeds + /// Returns an `Internal` failure when the run's chain count exceeds /// `u32`, leaving the ready queue unchanged. #[expect( clippy::too_many_arguments, @@ -243,7 +244,9 @@ impl Scheduler { // the typed error, so the outcome ends the run here, exactly as // the answer boundary does. let outcome = match outcome { - Err(error @ Error::Determinism(_)) => return ChainStep::RunEnded(Err(error)), + Err(error) if error.code == ErrorCode::Determinism => { + return ChainStep::RunEnded(Err(error)); + } outcome => outcome, }; // A scheduler bug that strands the outcome - a missing task entry, diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs index c6e66b7b1..0e26d41f6 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs @@ -14,10 +14,10 @@ use std::sync::Arc; use runvil_types::event::ReplyOrigin; +use crate::Result; use crate::execute::run::{Effect, EffectId, Round}; use crate::lua::Answer; use crate::prompt::Prompt; -use crate::{Error, Result}; use super::{Chain, ChainIndex, Continuation, EffectTable, Scheduler}; @@ -75,9 +75,9 @@ impl ChainOps<'_> { /// [`Scheduler::with_chain`] to perform. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no suspended block + /// Returns an `Internal` failure when the chain has no suspended block /// to take the answer, or one that already holds an answer. - pub(super) fn answer(&mut self, answer: Result>) -> Result<()> { + pub(super) fn answer(&mut self, answer: Result) -> Result<()> { self.chain.deliver(answer)?; self.answered = true; Ok(()) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index ad2e2d3ee..e22b345ba 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -18,7 +18,7 @@ use crate::execute::walk_target::JumpTarget; use crate::heading_address::SectionPos; use crate::lua::{Answer, Blocked}; use crate::prompt::{Block, BodyText, Prompt, Section}; -use crate::{Error, Result}; +use crate::{Result, error}; mod anchor; mod arena; @@ -74,15 +74,12 @@ impl CallDepth { /// The depth a chain started one level deeper runs at. /// /// # Errors - /// Returns [`Error::CallDepthExceeded`], named after `via`, when that + /// Returns a `CallDepthExceeded` failure, named after `via`, when that /// depth passes the cap. pub(super) fn deeper(self, via: DepthVia) -> Result { let depth = self.0 + 1; if depth > MAX_CALL_DEPTH { - return Err(Error::CallDepthExceeded { - cap: MAX_CALL_DEPTH, - via, - }); + return Err(error::call_depth_exceeded(MAX_CALL_DEPTH, via)); } Ok(CallDepth(depth)) } @@ -143,7 +140,7 @@ pub(super) struct Running { /// The block's coroutine handle; a block that returns disposes of it. pub(super) coroutine: Thread, /// The answer delivered for the suspended coroutine, taken at resume. - pub(super) answer: Option>>, + pub(super) answer: Option>, } /// The section a chain has entered: built at each section entry, the H1 @@ -357,7 +354,7 @@ impl Chain { /// chain end. /// /// # Errors - /// Returns [`Error::Internal`] when the chain's capability is gone, + /// Returns an `Internal` failure when the chain's capability is gone, /// which only the chain-end paths do - a live chain always holds it. /// The two teardown probes skip rather than fail on a gone capability: /// `join_task` returns without a join, and `end_tool_call` ends the @@ -365,19 +362,19 @@ impl Chain { pub(super) fn access(&self) -> Result<&Arc> { self.access .as_ref() - .ok_or(Error::internal("a live chain holds its access capability")) + .ok_or(error::internal("a live chain holds its access capability")) } /// Hands out the chain's next child id, under [`Counters::next_child`]. /// /// # Errors - /// Returns [`Error::Internal`] when one chain has started `u32::MAX` + /// Returns an `Internal` failure when one chain has started `u32::MAX` /// children, which no reachable run does. pub(super) fn next_child_id(&mut self) -> Result { let index = self.counters.next_child; self.counters.next_child = index .checked_add(1) - .ok_or(Error::internal("a chain's child count cannot exceed u32"))?; + .ok_or(error::internal("a chain's child count cannot exceed u32"))?; Ok(self.lineage.child(index)) } @@ -385,13 +382,13 @@ impl Chain { /// [`Counters::next_entry`]. /// /// # Errors - /// Returns [`Error::Internal`] when one chain has entered `u32::MAX` + /// Returns an `Internal` failure when one chain has entered `u32::MAX` /// sections, which no reachable run does. pub(super) fn next_entry_id(&mut self) -> Result { let index = self.counters.next_entry; self.counters.next_entry = index .checked_add(1) - .ok_or(Error::internal("a chain's entry count cannot exceed u32"))?; + .ok_or(error::internal("a chain's entry count cannot exceed u32"))?; Ok(self.lineage.entry(index)) } @@ -418,22 +415,22 @@ impl Chain { /// the block resumes. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no suspended block, or + /// Returns an `Internal` failure when the chain has no suspended block, or /// when its block already holds an answer: every answer goes to a block /// that yielded for it, once, and the first answer stays. #[track_caller] - pub(super) fn deliver(&mut self, answer: Result>) -> Result<()> { + pub(super) fn deliver(&mut self, answer: Result) -> Result<()> { let Some(running) = self .entered .as_mut() .and_then(|entered| entered.running.as_mut()) else { - return Err(Error::internal( + return Err(error::internal( "a delivered answer implies a suspended block", )); }; if running.answer.is_some() { - return Err(Error::internal( + return Err(error::internal( "a suspended block holds at most one delivered answer", )); } @@ -445,7 +442,7 @@ impl Chain { /// reaches the chain by its arena index. /// /// # Errors - /// Returns [`Error::Internal`] when the chain is between sections, + /// Returns an `Internal` failure when the chain is between sections, /// which no caller that reached the chain through a suspended or /// running block can see. #[track_caller] @@ -453,7 +450,7 @@ impl Chain { self.entered .as_ref() .map(|entered| &entered.frame) - .ok_or(Error::internal("a live chain holds its frame")) + .ok_or(error::internal("a live chain holds its frame")) } /// The frame of the section the chain is running, mutably. @@ -465,7 +462,7 @@ impl Chain { self.entered .as_mut() .map(|entered| &mut entered.frame) - .ok_or(Error::internal("a live chain holds its frame")) + .ok_or(error::internal("a live chain holds its frame")) } /// The chain's current block sequence: the H1 pass's blocks, or the diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs index 84f578174..f34853c64 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs @@ -3,7 +3,7 @@ use std::ops::{Index, IndexMut}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::Chain; @@ -61,7 +61,7 @@ impl Chain { "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n", "# Title\n\n## Only\n\ndone\n", ); - let prompt = Prompt::parse(source, "chain-test") + let prompt = Prompt::parse(source, "prompt", "chain-test") .0 .expect("the test prompt parses"); let ctx = ChainState::root( @@ -117,18 +117,18 @@ impl ChainArena { /// Adds `chain` and returns the index that names it. /// /// # Errors - /// Returns [`Error::Internal`] when the arena already holds its bound, - /// leaving the arena unchanged. + /// Returns an `Internal` failure when the arena already holds its + /// bound, leaving the arena unchanged. pub(in crate::execute::scheduler) fn push(&mut self, chain: Chain) -> Result { #[cfg(test)] let max_chains = self.max_chains; #[cfg(not(test))] let max_chains = MAX_CHAINS; if self.chains.len() >= max_chains { - return Err(Error::internal("a run's chain count cannot exceed u32")); + return Err(error::internal("a run's chain count cannot exceed u32")); } - let id = ChainIndex(u32::try_from(self.chains.len()).map_err(|error| { - Error::internal_with_cause("a run's chain count cannot exceed u32", error) + let id = ChainIndex(u32::try_from(self.chains.len()).map_err(|cause| { + error::internal_with_cause("a run's chain count cannot exceed u32", &cause) })?); self.chains.push(chain); Ok(id) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index e7e9b3bde..3a735688c 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,10 +39,8 @@ 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::{Error, Result}; +use crate::model::{Completion, CompletionResult, ModelBinding, ToolCall, detail}; +use crate::{Error, Result, error}; use super::dispatch::Dispatched; use super::{ChainOps, Continuation}; @@ -99,7 +98,7 @@ impl ChainOps<'_> { ) -> Result { let messages = list.to_records(); if messages.is_empty() { - return Err(Error::lua_message("messages must not be empty")); + return Err(error::lua_runtime("messages must not be empty")); } let chain = &*self.chain; let section = chain.section_name().to_owned(); @@ -109,9 +108,7 @@ impl ChainOps<'_> { binding } else { let current = vm.current_model()?; - current.ok_or_else(|| Error::ModelRequired { - section: section.clone(), - })? + current.ok_or_else(|| error::model_required(§ion))? }; let handles = frame.reporting_handles().clone(); let emitter = handles.emitter; @@ -150,9 +147,9 @@ impl ChainOps<'_> { // error resumes into Lua, the loop's precedent. let conversation = match project_messages(&messages) { Ok(conversation) => conversation, - Err(error) => { + Err(failure) => { emitter.report(§ion, lifecycle::MODEL_TURN_FAILED); - return Err(Error::from(error)); + return Err(failure); } }; let context = binding.context(); @@ -210,7 +207,7 @@ impl ChainOps<'_> { /// round named. Returns that list. /// /// # Errors - /// Returns the merge's error, or [`Error::Internal`] when the parked + /// Returns the merge's error, or an `Internal` failure when the parked /// chain has lost its frame. fn merge_render(&self, rendered: Vec>) -> Result { let chain = &*self.chain; @@ -246,13 +243,13 @@ impl ChainOps<'_> { /// outcome; any other round only releases the messages it sent. /// /// # Errors - /// Returns [`Error::Internal`] when the parked chain has lost its frame - /// or its section's frozen sets. + /// Returns an `Internal` failure when the parked chain has lost its + /// frame or its section's frozen sets. pub(super) fn accept_chat( &mut self, round: RoundId, result: Result>, - ) -> Result>> { + ) -> Result> { let chain = &*self.chain; let round = ArrivedRound { id: round, @@ -278,9 +275,9 @@ impl ChainOps<'_> { // absence is a scheduler fault, never an author-visible // out-of-scope refusal. let vm = chain.frame()?.vm(); - let frozen = vm.rendered_tools().ok_or(Error::internal( - "a parked chat round's section holds its frozen sets", - ))?; + let frozen = vm.rendered_tools().ok_or_else(|| { + error::internal("a parked chat round's section holds its frozen sets") + })?; let scope = Scope { frozen: &frozen, tool_set: chain.ctx.tool_set(), @@ -290,7 +287,7 @@ impl ChainOps<'_> { // `CompletionResult` is `#[non_exhaustive]` across the crate // boundary: an outcome this build does not recognize can be // neither resumed nor promoted to an answer. - _ => Err(Error::internal("unrecognized completion outcome")), + _ => Err(error::internal("unrecognized completion outcome")), }; let usage = served .metrics @@ -341,7 +338,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 if error.code == ErrorCode::ContextOverflow => { self.reporting .emitter .report(&self.section, lifecycle::MODEL_TURN_FAILED); @@ -350,14 +347,18 @@ impl ArrivedRound { self.reporting.turns.load(Ordering::Relaxed), ))) } - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { + 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, })) } @@ -436,17 +437,18 @@ impl ArrivedRound { self.reporting .emitter .report(&self.section, lifecycle::TOOL_CALL_FAILED); - return Err(Error::OutOfScopeToolCall { - name: rogue.name().to_owned(), - global_exists: scope.global_exists(rogue.name()), - has_dynamic_set: !scope.frozen.dynamic_set.is_empty(), - static_aliases: scope - .frozen - .static_set - .iter() - .map(|binding| binding.alias().to_owned()) - .collect(), - }); + let static_aliases: Vec = scope + .frozen + .static_set + .iter() + .map(|binding| binding.alias().to_owned()) + .collect(); + return Err(error::out_of_scope_tool( + rogue.name(), + scope.global_exists(rogue.name()), + &static_aliases, + !scope.frozen.dynamic_set.is_empty(), + )); } Ok(ChatResult { outcome: ChatOutcome::ToolCalls(events), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index 0db98e142..36606705e 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -23,7 +23,7 @@ use crate::execute::run::Effect; use crate::execute::section_context::TaskSeed; use crate::lua::{Answer, Request, VfsOp}; use crate::model::{Message, ModelBinding}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain::ChainStart; use super::chain_record::{ChainKind, Parked}; @@ -36,7 +36,7 @@ use super::{ChainIndex, ChainOps, Continuation, Counters, Scheduler}; #[derive(Debug)] pub(super) enum Dispatched { Issued, - Answered(Answer), + Answered(Answer), } /// What a chain parks on when it yields `request`; `None` for the arms @@ -69,8 +69,8 @@ impl Scheduler { /// # Errors /// Returns the store arm's error when the chain's access capability is /// gone, the `adhoc_tool_done` arm's error when no adhoc tool call is - /// open, or [`Error::Internal`] when an arm's answer finds no suspended - /// block to take it. + /// open, or an `Internal` failure when an arm's answer finds no + /// suspended block to take it. pub(super) fn dispatch(&mut self, id: ChainIndex, request: Request) -> Result<()> { self.chains[id].parked = parked_on(&request); match request { @@ -214,9 +214,7 @@ impl ChainOps<'_> { .frame()? .vm() .current_model()? - .ok_or_else(|| Error::ModelRequired { - section: chain.section_name().to_owned(), - })? + .ok_or_else(|| error::model_required(chain.section_name()))? }; // A nested infer round consumes only the accumulated completion; // its `Infer` origin tells the caller its live deltas have no @@ -240,13 +238,15 @@ impl ChainOps<'_> { /// is pushed when the answer is applied, before the chain resumes. /// /// # Errors - /// Returns [`Error::Internal`] when the live chain's access capability - /// is gone, which only the chain-end paths take, or [`Error::Store`] - /// when the handle the chain's access came from declares no store, + /// Returns an `Internal` failure when the live chain's access + /// capability is gone, which only the chain-end paths take, or a store + /// failure when the handle the chain's access came from declares no store, /// which the run's start probe already ruled out. fn dispatch_vfs(&mut self, op: VfsOp) -> Result<()> { let access = Arc::clone(self.chain.access()?); - let view = Arc::new(runvil_vfs::detail::store_view(&access).map_err(Error::store)?); + let view = Arc::new( + runvil_vfs::detail::store_view(&access).map_err(|cause| error::store(&cause))?, + ); let effect = Effect::Vfs { access: view, op: op.clone(), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs index 927715ac1..743ce1d60 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs @@ -9,14 +9,14 @@ //! every issued effect has its answer, so every effect the caller was //! handed has exactly one answer. +use runvil_types::error::ErrorCode; use runvil_types::event::lifecycle; use runvil_types::ids::AbandonReason; use runvil_vfs::Origin; use crate::execute::RunOutcome; use crate::execute::run::{EffectAnswer, EffectId, Step}; -use crate::prompt::ParseErrorKind; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain::ChainStep; use super::effects::Arrival; @@ -35,7 +35,7 @@ impl Scheduler { if matches!(self.phase, Phase::Fresh) { if let Err(error) = self.check_start() { let error = if self.ctx.cancel().is_cancelled() { - Error::Interrupted + error::interrupted() } else { error }; @@ -72,7 +72,7 @@ impl Scheduler { && self.ready.is_empty() && !self.effects.has_awaited() { - self.end(Err(Error::internal( + self.end(Err(error::internal( "the scheduler stalled with no ready chain, no in-flight request, \ and no queued chain that can be admitted", ))); @@ -91,7 +91,7 @@ impl Scheduler { } } Phase::Done => Step::Done { - outcome: self.outcome(Err(Error::internal( + outcome: self.outcome(Err(error::internal( "a finished run cannot be stepped again", ))), events, @@ -119,9 +119,9 @@ impl Scheduler { fn outcome(&mut self, result: Result) -> RunOutcome { match result { Ok(final_text) => RunOutcome::Completed { final_text }, - Err(Error::Interrupted) => RunOutcome::Cancelled, + Err(error) if error.code == ErrorCode::Interrupted => RunOutcome::Cancelled, Err(error) => { - let outcome = error.to_outcome(); + let outcome = crate::execute::outcome(&error); #[cfg(test)] { self.failure = Some(error); @@ -145,7 +145,7 @@ impl Scheduler { Arrival::Awaited(parked) => parked, Arrival::Orphaned => return, Arrival::Unknown => { - self.end(Err(Error::internal( + self.end(Err(error::internal( "an answer arrived for an effect the run did not issue or already answered", ))); return; @@ -183,23 +183,27 @@ impl Scheduler { /// store, then what the prompt requires of the config. /// /// # Errors - /// Returns [`Error::UnsupportedVersion`] or a structural parse error for - /// a prompt that is not a supported runvil prompt, [`Error::Store`] - /// when the handle declares no store or the store's backend fails the - /// probe, or [`Error::RequirementsUnmet`] with the refusal notice when - /// the config lacks a declared Pack or a model the prompt's roles - /// require, or names a Pack id the Engine reserves. + /// Returns an `UnsupportedVersion` or a structural parse failure for a + /// prompt that is not a supported runvil prompt, a store failure when + /// the handle declares no store or the store's backend fails the probe, + /// or `RequirementsUnmet` with the refusal notice when the config lacks + /// a declared Pack or a model the prompt's roles require, or names a + /// Pack id the Engine reserves. fn check_start(&self) -> Result<()> { - let frontmatter = self.ctx.prompt().frontmatter(); + let prompt = self.ctx.prompt(); + let frontmatter = prompt.frontmatter(); match frontmatter.runvil() { Some(0) => {} - Some(other) => return Err(Error::UnsupportedVersion(other)), + Some(other) => return Err(error::unsupported_version(other)), None => { - return Err(Error::parse( - ParseErrorKind::Structure, - "not a runvil prompt: no runvil version", - ) - .with_prompt_name(frontmatter.name())); + return Err(crate::prompt::detail::with_prompt_context( + crate::prompt::detail::parse_error( + ErrorCode::StructureInvalid, + "not a runvil prompt: no runvil version", + ), + frontmatter.name(), + prompt.file(), + )); } } // Every run and every store call needs a declared store: the probe @@ -210,13 +214,10 @@ impl Scheduler { .ctx .vfs() .acquire(Origin::new("store mount probe")) - .map_err(Error::store)?; - runvil_vfs::detail::probe_store(&probe).map_err(Error::store)?; + .map_err(|cause| error::store(&cause))?; + runvil_vfs::detail::probe_store(&probe).map_err(|cause| error::store(&cause))?; if let Some(notice) = self.ctx.refusal() { - return Err(Error::RequirementsUnmet { - notice: notice.to_owned(), - cause: None, - }); + return Err(error::requirements_unmet(notice, None)); } Ok(()) } @@ -232,7 +233,7 @@ impl Scheduler { fn drain(&mut self) { loop { if self.ctx.cancel().is_cancelled() { - self.end(Err(Error::Interrupted)); + self.end(Err(error::interrupted())); return; } let Some(id) = self.ready.pop_front() else { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs index 775f95977..8b3c30f88 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs @@ -2,7 +2,7 @@ //! driver loop's first chain, under the walk's rules with these deltas: //! the frame takes the root chain's entry 0 and fires no section //! observations, a scalar return short-circuits the run, a Lua failure is -//! the prompt's failed hard gate, mapped to [`Error::RequirementsUnmet`], +//! the prompt's failed hard gate, mapped to a `RequirementsUnmet` failure, //! and the pass's end hands off to the root walk. The frame's own deltas, //! writable `argv` among them, are listed on `SectionContext::new_h1`. //! @@ -19,7 +19,7 @@ use std::sync::Arc; use crate::heading_address::{SectionIndex, SectionPos}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain::ChainStep; use super::chain_record::{ChainKind, Entered}; @@ -32,8 +32,8 @@ impl Scheduler { /// chain itself, so this returns no index and no caller pushes it. /// /// # Errors - /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`, - /// or [`Error::Store`] when the backend refuses acquisition. + /// Returns an `Internal` failure when the run's chain count exceeds `u32`, + /// or a store failure when the backend refuses acquisition. pub(super) fn start_h1(&mut self) -> Result<()> { // The H1 pass runs under the prompt's title, from its first // compiled H1 chunk. @@ -42,7 +42,11 @@ impl Scheduler { self.ctx.prompt().title(), crate::prompt::detail::h1_blocks(self.ctx.prompt()), ); - let access = self.ctx.vfs().acquire(origin).map_err(Error::store)?; + let access = self + .ctx + .vfs() + .acquire(origin) + .map_err(|cause| error::store(&cause))?; self.scope = Some(runvil_vfs::detail::scope_handle(&access)); // The pass's one frame takes the root lineage's entry 0, and the // walk that follows continues its counters (see `Chain::lineage`). @@ -69,16 +73,16 @@ impl Scheduler { /// result is the shared generic completion. /// /// # Errors - /// Returns [`Error::Lua`] when the final `var` read-back fails or H1 + /// Returns a `LuaRuntime` failure when the final `var` read-back fails or H1 /// left `argv` as non-JSON data, - /// or [`Error::Internal`] when the chain holds no frame, when the walk + /// or an `Internal` failure when the chain holds no frame, when the walk /// it hands off to cannot start because the run's chain count exceeds /// `u32`, or when the pass's capability is gone - the walk reuses it, so /// it acquires nothing and has nothing to fall back on. pub(super) fn end_h1(&mut self, id: ChainIndex, start: SectionPos) -> Result { let chain = &mut self.chains[id]; let Some(Entered { frame, .. }) = chain.entered.take() else { - return Err(Error::internal("the H1 pass ends with a live frame")); + return Err(error::internal("the H1 pass ends with a live frame")); }; let var = frame.read_var()?; // The freeze: whatever `argv` H1 leaves behind - the derived parse @@ -134,7 +138,7 @@ impl Scheduler { /// then the pass ends and the root walk starts at the target. /// /// # Errors - /// Returns [`Error::Lua`] when the heading is malformed, matches no + /// Returns a `LuaRuntime` failure when the heading is malformed, matches no /// top-level section, or matches more than one; the pass's own ending /// can fail as [`end_h1`](Self::end_h1) documents. pub(super) fn end_h1_at_jump(&mut self, id: ChainIndex, heading: &str) -> Result { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/step.rs b/crates/runvil-internal/engine/src/execute/scheduler/step.rs index fce158ebe..640651737 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/step.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/step.rs @@ -5,13 +5,14 @@ //! closing observation boundary. use runvil_types::emitter::Emitter; +use runvil_types::error::ErrorCode; use runvil_types::event::lifecycle; use crate::execute::requirements::NOTICE_HEADER; use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::{Answer, CoroStep, LuaBlockResult, Request, YieldParse}; use crate::prompt::{Block, BodyText}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain::ChainStep; use super::chain_record::{Entered, Running}; @@ -25,7 +26,7 @@ enum Settled { /// The block suspended on a request for its arm to dispatch. Dispatch(Request), /// The block suspended on a call answered on the spot. - Answer(Result>), + Answer(Result), /// The block jumped to the heading. Jump(String), /// The block returned a scalar. @@ -56,26 +57,24 @@ fn settle_block( // error remaps: the machinery around it (section setup, // the final `var` read-back, jump-target resolution) // keeps its own kind - a prompt bug under - // `Error::Lua`, not an unsatisfiable environment. Fatal + // `LuaRuntime`, not an unsatisfiable environment. Fatal // run conditions (cancellation, the claims violation) // keep their own classification either way. - return Err(match error { - error @ Error::Lua { .. } if is_h1 => Error::RequirementsUnmet { - notice: format!("{NOTICE_HEADER}\n- the H1 gate failed"), - cause: Some(Box::new(error)), - }, - other => other, - }); + if is_h1 && error.code == ErrorCode::LuaRuntime { + let notice = format!("{NOTICE_HEADER}\n- the H1 gate failed"); + return Err(error::requirements_unmet(¬ice, Some(&error))); + } + return Err(error); } }; match step { CoroStep::Yielded(coroutine, values) => { let settled = match entered.frame.vm().request_from_yield(&values) { YieldParse::Request(request) => Settled::Dispatch(request), - YieldParse::Refused(error) => Settled::Answer(Err(Error::from(error))), - YieldParse::Malformed(error) => { + YieldParse::Refused(refusal) => Settled::Answer(Err(refusal)), + YieldParse::Malformed(malformed) => { emitter.report(name, lifecycle::LUA_CHUNK_FAILED); - return Err(Error::from(error)); + return Err(malformed); } }; entered.running = Some(Running { @@ -137,14 +136,14 @@ impl Scheduler { answer: Some(answer), } = running else { - return Err(Error::internal( + return Err(error::internal( "a ready chain's suspended coroutine waits on its answer", )); }; // The answer ends whatever the chain was parked on. chain.parked = None; let Some(Block::Lua(program)) = entered.block_in(blocks) else { - return Err(Error::internal("a suspended coroutine's block is Lua")); + return Err(error::internal("a suspended coroutine's block is Lua")); }; entered .frame @@ -162,11 +161,7 @@ impl Scheduler { emitter.report(&name, lifecycle::LUA_CHUNK_FAILED); return Err(error); } - entered - .frame - .vm() - .start_block_coro(program) - .map_err(Error::from) + entered.frame.vm().start_block_coro(program) } Some(Block::Prose { text, .. }) => { // The parser emits one prose block per inter-fence gap, @@ -182,7 +177,7 @@ impl Scheduler { // `Block` is `#[non_exhaustive]` across the crate seam; an // unlisted variant has no advance rule. Some(_) => { - return Err(Error::internal("an unrecognized block kind cannot advance")); + return Err(error::internal("an unrecognized block kind cannot advance")); } // The section's blocks are exhausted: fall through. None if is_h1 => { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs index 0028364b2..c6a62b22c 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs @@ -13,11 +13,12 @@ use std::sync::Arc; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use runvil_types::ids::{AbandonReason, Provenance, TaskId}; use runvil_vfs::Access; -use crate::{Error, Result}; +use crate::{Result, error}; use super::tasks::{Stop, TaskBacking}; use super::{ChainIndex, Scheduler}; @@ -30,7 +31,7 @@ impl Scheduler { /// holds the outcome until a wait takes it. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no live entry - every + /// Returns an `Internal` failure when the chain has no live entry - every /// task chain registers its entry before it is enqueued, and a stopped /// task's chain is aborted before it can end - or when the woken owner /// has no suspended block. @@ -41,7 +42,7 @@ impl Scheduler { let emitter = Arc::clone(chain.ctx.emitter()); let succeeded = outcome.is_ok(); let Some(entry) = self.tasks.complete(&task, outcome) else { - return Err(Error::internal("a task chain's end implies its entry")); + return Err(error::internal("a task chain's end implies its entry")); }; let owner = entry.owner; emitter.emit(&entry.target, |execution, section, provenance| { @@ -69,7 +70,7 @@ impl Scheduler { /// live task the chain owns is abandoned (the reason names how the /// owner ended: the section ended, the tool loop was exhausted, or the /// owner failed some other way), and an `Ok` outcome that leaked - /// tasks becomes [`Error::TasksLive`] naming them in + /// tasks becomes a `TasksLive` failure naming them in /// spawn order. A failing chain keeps its own error - the leak is the /// lesser fault - but its tasks end all the same. pub(super) fn settle_owned_tasks( @@ -79,12 +80,14 @@ impl Scheduler { ) -> Result { let reason = match &outcome { Ok(_) => AbandonReason::OwnerReturned, - Err(Error::ToolLoopExhausted) => AbandonReason::ToolLoopExhausted, + Err(error) if error.code == ErrorCode::ToolLoopExhausted => { + AbandonReason::ToolLoopExhausted + } Err(_) => AbandonReason::OwnerFailed, }; let leaked = self.abandon_owned_tasks(owner, reason); match outcome { - Ok(_) if !leaked.is_empty() => Err(Error::TasksLive { tasks: leaked }), + Ok(_) if !leaked.is_empty() => Err(error::tasks_live(&leaked)), outcome => outcome, } } 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 2863d25ff..f99c7239d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -16,7 +16,7 @@ use runvil_vfs::detail; use crate::error::DepthVia; use crate::execute::section_context::TaskSeed; use crate::lua::{Answer, SpawnVia}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::super::admission::SpawnRecord; use super::super::chain::ChainStart; @@ -104,11 +104,11 @@ impl Scheduler { // target's slice by path, resolved against the shared tree. let prompt = self.prompt(); let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target))?; - let worker = resolved.pos.section(&prompt).ok_or(Error::internal( + let worker = resolved.pos.section(&prompt).ok_or(error::internal( "a resolved worker is absent from its slice", ))?; if worker.is_list() { - return Err(Error::lua_message(format!( + return Err(error::lua_runtime(format!( "section `{}` is a list section, not a worker template", worker.name() ))); @@ -122,7 +122,7 @@ impl Scheduler { &spawner_access, prompt_origin(&prompt, worker.name(), worker.blocks()), ) - .map_err(Error::store)?; + .map_err(|cause| error::store(&cause))?; // The task's happens-before identity, recorded before the access // moves into the chain: every delivery joins it, and the chain // end joins it last. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs index 8bc821518..9d4bfef0d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs @@ -4,12 +4,13 @@ //! running inside the task, and `live_author_tasks` lists an owner's live //! tasks in spawn order without internal timers. +use runvil_types::error::ErrorCode; use runvil_types::ids::{ChainId, TaskId}; use runvil_vfs::{Origin, VfsRef}; use super::super::ChainIndex; use super::{Stop, TaskArena, TaskBacking, TaskState}; -use crate::Error; +use crate::error; use crate::execute::run::EffectId; fn chain(raw: u32) -> ChainIndex { @@ -98,7 +99,7 @@ fn a_completed_task_is_done_and_its_outcome_is_taken_once() { ); assert!(matches!( arena.take_delivery(&task), - Err(Error::Internal { .. }) + Err(error) if error.code == ErrorCode::Internal )); assert_eq!( arena.state_of(&task), @@ -145,7 +146,7 @@ fn an_ended_or_delivered_task_keeps_its_entry() { assert!(arena.stop(&abandoned, Stop::Abandoned).is_some()); assert!( arena - .complete(&delivered, Err(Error::Interrupted)) + .complete(&delivered, Err(error::interrupted())) .is_some() ); assert!(arena.take_delivery(&delivered).is_err()); @@ -166,12 +167,12 @@ fn owned_admits_only_the_tasks_owner() { assert_eq!(entry.target, "worker 0"); assert!(matches!( arena.owned(chain(2), &task), - Err(Error::TaskNotOwned { task: refused }) if refused == task + Err(refused) if refused == error::task_not_owned(&task) )); let unknown = TaskId::from(ChainId::root().child(9)); assert!(matches!( arena.owned(chain(1), &unknown), - Err(Error::TaskNotOwned { task: refused }) if refused == unknown + Err(refused) if refused == error::task_not_owned(&unknown) )); } @@ -187,12 +188,12 @@ fn visible_also_admits_a_caller_running_inside_the_task() { ); assert!(matches!( arena.visible(chain(7), &outside, &task), - Err(Error::TaskNotOwned { task: refused }) if refused == task + Err(refused) if refused == error::task_not_owned(&task) )); let unknown = TaskId::from(ChainId::root().child(9)); assert!(matches!( arena.visible(chain(7), &unknown, &unknown), - Err(Error::TaskNotOwned { .. }) + Err(refused) if refused.code == ErrorCode::TaskNotOwned )); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs index 46efd468e..43c5ec100 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs @@ -27,7 +27,7 @@ use runvil_types::ids::TaskId; use runvil_vfs::ExecId; use crate::execute::run::EffectId; -use crate::{Error, Result}; +use crate::{Result, error}; use super::ChainIndex; @@ -177,13 +177,13 @@ impl TaskArena { self.entries.insert(task, entry); } - /// The entry of a task `caller` owns, or [`Error::TaskNotOwned`] - for + /// The entry of a task `caller` owns, or a `TaskNotOwned` failure - for /// an unknown id too, so a caller learns nothing about tasks it never /// started. pub(super) fn owned(&self, caller: ChainIndex, task: &TaskId) -> Result<&TaskEntry> { match self.entries.get(task) { Some(entry) if entry.owner == caller => Ok(entry), - _ => Err(Error::TaskNotOwned { task: task.clone() }), + _ => Err(error::task_not_owned(task)), } } @@ -198,7 +198,7 @@ impl TaskArena { ) -> Result<&TaskEntry> { match self.entries.get(task) { Some(entry) if entry.owner == caller || caller_task == task => Ok(entry), - _ => Err(Error::TaskNotOwned { task: task.clone() }), + _ => Err(error::task_not_owned(task)), } } @@ -239,7 +239,7 @@ impl TaskArena { for task in tasks { match self.owned(caller, task)?.state { EntryState::Delivered { .. } => { - return Err(Error::TaskConsumed { task: task.clone() }); + return Err(error::task_consumed(task)); } EntryState::Running => {} EntryState::Done(_) | EntryState::Cancelled | EntryState::Abandoned => { @@ -275,7 +275,7 @@ impl TaskArena { /// wait on it, so its delivery is a scheduler bug. pub(super) fn take_delivery(&mut self, task: &TaskId) -> Result { let Some(entry) = self.entries.get_mut(task) else { - return Err(Error::internal("a delivered task has an entry")); + return Err(error::internal("a delivered task has an entry")); }; match &mut entry.state { EntryState::Done(outcome) => { @@ -285,11 +285,11 @@ impl TaskArena { }; outcome } - EntryState::Cancelled => Err(Error::TaskCancelled { task: task.clone() }), - EntryState::Abandoned => Err(Error::internal( + EntryState::Cancelled => Err(error::task_cancelled(task)), + EntryState::Abandoned => Err(error::internal( "an abandoned task's owner ended, so no wait can deliver it", )), - EntryState::Running | EntryState::Delivered { .. } => Err(Error::internal( + EntryState::Running | EntryState::Delivered { .. } => Err(error::internal( "only a terminal, undelivered entry is delivered", )), } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs index 4258b197c..8444dfeef 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs @@ -115,10 +115,9 @@ impl Scheduler { /// chain reports before its first entry. /// /// # Errors - /// Returns [`Error::Store`](crate::Error::Store) when the backend - /// refuses the chain's access, or - /// [`Error::Internal`](crate::Error::Internal) when the run's chain - /// count exceeds its bound. + /// Returns a store failure when the backend refuses the chain's + /// access, or an `Internal` failure when the run's chain count exceeds + /// its bound. pub(crate) fn name_before_first_entry_for_test(&mut self, index: usize) -> Result { let id = self.start_probe_chain(index)?; Ok(self.chains[id].section_name().to_owned()) @@ -166,7 +165,7 @@ impl Scheduler { .ctx .vfs() .acquire(Origin::at("probe", self.ctx.prompt().title(), 1)) - .map_err(Error::store)?; + .map_err(|cause| crate::error::store(&cause))?; self.start_chain(ChainStart { lineage: ChainId::root(), counters: Counters::default(), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs index 64f2fdf03..6c0ee6b72 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs @@ -27,7 +27,7 @@ use runvil_types::ids::TaskId; use crate::execute::run::Effect; use crate::lua::Answer; -use crate::{Error, Result}; +use crate::{Result, error}; use super::tasks::TaskBacking; use super::{ChainIndex, Continuation, Scheduler}; @@ -68,13 +68,13 @@ impl Scheduler { /// waits when the timer fires. /// /// # Errors - /// Returns [`Error::Internal`] when `task`'s entry isn't live - a + /// Returns an `Internal` failure when `task`'s entry isn't live - a /// cancelled or abandoned timer's effect is aborted and its late firing /// discarded before it reaches here - or when the woken owner has no /// suspended block. pub(super) fn fire_timer(&mut self, task: &TaskId) -> Result<()> { let Some(entry) = self.tasks.complete(task, Ok(String::new())) else { - return Err(Error::internal("a fired timer has a live entry")); + return Err(error::internal("a fired timer has a live entry")); }; let owner = entry.owner; self.wake_waiter(owner, task) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs index 9d7ed56a5..cab5f3c0f 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs @@ -20,7 +20,7 @@ use runvil_types::tools::OutputTrust; use crate::execute::section_context::AdhocCall; use crate::lua::{AdhocToolOutcome, Answer, ScriptReport, ToolBinding, ToolCallOutcome}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::super::dispatch::Dispatched; use super::super::{CallSite, ChainOps}; @@ -72,7 +72,7 @@ impl ChainOps<'_> { /// the shim raises the handler's own value in its place. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no frame or no open + /// Returns an `Internal` failure when the chain has no frame or no open /// adhoc call, or no suspended block to take the answer. pub(in crate::execute::scheduler) fn dispatch_adhoc_tool_done( &mut self, @@ -81,7 +81,7 @@ impl ChainOps<'_> { let chain = &mut *self.chain; let emitter = Arc::clone(chain.ctx.emitter()); let section = chain.section_name().to_owned(); - let call = chain.frame_mut()?.pop_adhoc_call().ok_or(Error::internal( + let call = chain.frame_mut()?.pop_adhoc_call().ok_or(error::internal( "an adhoc tool's completion closes the call that opened it", ))?; let answer = match outcome { @@ -104,9 +104,9 @@ impl ChainOps<'_> { ); Ok(ToolCallOutcome::Plain(text)) } - AdhocToolOutcome::BadReturn(error) => { + AdhocToolOutcome::BadReturn(failure) => { emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); - Err(Error::from(error)) + Err(failure) } AdhocToolOutcome::Raised => { emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs index 94534ecf8..459c584f7 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs @@ -31,7 +31,7 @@ use serde_json::{Map, Value}; use crate::execute::scope::PATTERN_LIMIT; use crate::lua::{Answer, DiscoveryTool, OfferMode, RenderedTools, ToolBinding, ToolCallOutcome}; -use crate::{Error, Result}; +use crate::{Result, error}; use super::super::dispatch::Dispatched; use super::super::{CallSite, ChainOps}; @@ -72,7 +72,7 @@ impl ChainOps<'_> { /// call's id and `binding`, as a failed model call reports. /// /// # Errors - /// Returns [`Error::Internal`] when the chain's section holds no frozen + /// Returns an `Internal` failure when the chain's section holds no frozen /// sets, which a model call can only reach after the freeze, and the /// error of the counts install or of the target's preparer. pub(super) fn prepare_discovery_call( @@ -87,7 +87,7 @@ impl ChainOps<'_> { .frame()? .vm() .rendered_tools() - .ok_or(Error::internal( + .ok_or(error::internal( "a model call's section holds its frozen sets", ))?; let discovered = match tool { @@ -167,7 +167,7 @@ impl ChainOps<'_> { /// Dynamic Set. /// /// # Errors -/// Returns [`Error::Internal`] should a listing not serialize. +/// Returns an `Internal` failure should a listing not serialize. fn search(frozen: &RenderedTools, args: &Value) -> Result { let tool = DiscoveryTool::Search.wire_name(); let refused = |refusal| Ok(Discovered::Refused(refusal)); @@ -309,11 +309,11 @@ fn cut(description: &str) -> Cow<'_, str> { /// A listing's text: `listed` as compact JSON. /// /// # Errors -/// Returns [`Error::Internal`] should `listed` not serialize. +/// Returns an `Internal` failure should `listed` not serialize. fn listing(listed: &impl Serialize) -> Result { serde_json::to_string(listed) .map(Discovered::Listing) - .map_err(|_| Error::internal("a discovery listing serializes")) + .map_err(|_| error::internal("a discovery listing serializes")) } /// The JSON type of `value`, for a refusal. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs index 724dd4244..8780a99c9 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs @@ -51,7 +51,7 @@ use crate::lua::{ Answer, DiscoveryTool, ModelCall, OfferMode, ScriptReport, ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, current_dynamic_set, current_static_set, }; -use crate::{Error, Result}; +use crate::{Error, Result, error}; use super::chain_record::Parked; use super::dispatch::Dispatched; @@ -69,10 +69,8 @@ fn unbound_tool_call(tool_set: &ToolSet, name: &str) -> Error { .map(|info| &info.id) .collect(); ids.sort(); - Error::UnboundToolCall { - name: name.to_owned(), - ids: ids.into_iter().map(ToString::to_string).collect(), - } + let ids: Vec = ids.into_iter().map(ToString::to_string).collect(); + error::unbound_tool(name, &ids) } /// Where one tool call is answered, with the binding it resolved to, so a @@ -156,8 +154,8 @@ impl ChainOps<'_> { /// to the adhoc tool the id names, when it is one. /// /// # Errors - /// Returns [`Error::UnboundToolCall`] for a name that resolves to no - /// tool, and [`Error::ModelOnlyToolCall`] for a script's call of a + /// Returns an `UnboundTool` failure for a name that resolves to no + /// tool, and a `ModelOnlyTool` failure for a script's call of a /// catalog tool marked model-only. fn tool_route(&self, name: &str, site: &CallSite) -> Result { let chain = &*self.chain; @@ -188,10 +186,7 @@ impl ChainOps<'_> { // so a model-only tool refuses the call while the catalog keeps // naming it. if info.model_only_call { - return Err(crate::Error::ModelOnlyToolCall { - name: name.to_owned(), - id: info.id.to_string(), - }); + return Err(error::model_only_tool(name, &info.id.to_string())); } Ok(ToolRoute::Catalog(ToolBinding::from_info( &info.id.to_string(), @@ -247,14 +242,10 @@ impl ChainOps<'_> { /// answered. /// /// # Errors - /// Returns [`Error::Internal`] when the chain holds no such slot, + /// Returns an `Internal` failure when the chain holds no such slot, /// which only an answer the scheduler should have orphaned produces, /// or when the full batch finds no suspended block to answer. - pub(super) fn fill_batch_slot( - &mut self, - slot: usize, - answer: Result>, - ) -> Result<()> { + pub(super) fn fill_batch_slot(&mut self, slot: usize, answer: Result) -> Result<()> { let landed = match answer { Ok(Answer::ToolCallResult(ToolCallOutcome::Plain(text))) => Ok(text), Err(error) => Err(error), @@ -276,7 +267,7 @@ impl ChainOps<'_> { | Answer::Chat(_) | Answer::ToolCallsResult(_) | Answer::Store(_), - ) => Err(Error::internal( + ) => Err(error::internal( "a batched call lands as plain text or a failure", )), }; @@ -284,7 +275,7 @@ impl ChainOps<'_> { Some(Parked::Batch(batch)) => batch.get_mut(slot).filter(|open| open.is_none()), _ => None, } - .ok_or(Error::internal( + .ok_or(error::internal( "a batched call's answer fills an open slot of its chain's batch", ))?; *open = Some(landed); @@ -379,7 +370,7 @@ impl ChainOps<'_> { chain.access()?, prompt_origin(&prompt, &binding.id().to_string(), chain.blocks(&prompt)), ) - .map_err(Error::store)?; + .map_err(|cause| error::store(&cause))?; let exec = detail::access_id(&access); let effect = Effect::ToolCall { tool: binding.id().clone(), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs index 46e994765..15c8bf2c1 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs @@ -34,8 +34,8 @@ use std::sync::atomic::Ordering; use runvil_types::event::Event; use runvil_types::ids::TaskId; +use crate::Result; use crate::lua::{Answer, NonEmpty, TaskDelivery, TaskPhase, TaskStatus}; -use crate::{Error, Result}; use super::admission::ChainTree; use super::chain_record::Parked; @@ -90,7 +90,7 @@ impl Scheduler { /// The delivery is the join: everything the task did happens before the /// owner's next step, so its store writes - a cancelled task's partial /// writes included - are visible from the very next operation. - fn deliver(&mut self, task: &TaskId) -> TaskDelivery { + fn deliver(&mut self, task: &TaskId) -> TaskDelivery { if let Some(owner) = self.tasks.owner_of(task) { self.join_task(owner, task); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs index 6e7b35af9..da67c1bcd 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs @@ -18,7 +18,7 @@ use crate::execute::walk_target::{ }; use crate::heading_address::{self, SectionIndex, SectionPos, Visible}; use crate::prompt::Block; -use crate::{Error, Result}; +use crate::{Result, error}; use super::chain::ChainStep; use super::chain_record::{ChainKind, Entered}; @@ -32,8 +32,8 @@ impl Scheduler { /// [`Chain::counters`](super::Chain) for the normative statement. /// /// # Errors - /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`, - /// or [`Error::Store`] when the backend refuses acquisition. + /// Returns an `Internal` failure when the run's chain count exceeds `u32`, + /// or a store failure when the backend refuses acquisition. pub(super) fn start_root_walk(&mut self) -> Result<()> { // The pass the prompt does not have would have taken root entry 0 // and started no child, so the walk continues from the counters @@ -50,7 +50,11 @@ impl Scheduler { let blocks: &[Block] = crate::prompt::detail::entry(prompt).map_or(&[], |section| section.blocks()); let origin = prompt_origin(prompt, prompt.title(), blocks); - let access = self.ctx.vfs().acquire(origin).map_err(Error::store)?; + let access = self + .ctx + .vfs() + .acquire(origin) + .map_err(|cause| error::store(&cause))?; self.scope = Some(runvil_vfs::detail::scope_handle(&access)); self.start_root_chain( ChainKind::Root, @@ -69,7 +73,7 @@ impl Scheduler { /// chain when its slice is exhausted. /// /// # Errors - /// Returns the [`Error`] of frame construction, as documented on + /// Returns the failure of frame construction, as documented on /// [`SectionContext::new`]. pub(super) fn advance_entry(&mut self, id: ChainIndex) -> Result { if self.with_chain(id, ChainOps::enter_section)? @@ -96,7 +100,7 @@ impl ChainOps<'_> { /// slice is exhausted and the chain ends. /// /// # Errors - /// Returns the [`Error`] of frame construction, as documented on + /// Returns the failure of frame construction, as documented on /// [`SectionContext::new`]. fn enter_section(&mut self) -> Result { let prompt = self.prompt(); @@ -158,13 +162,13 @@ impl ChainOps<'_> { /// section; then the walk advances to the next section. /// /// # Errors - /// Returns [`Error::Lua`] when the final `var` read-back fails (the - /// frame drops unarmed), or [`Error::Internal`] when the chain holds no + /// Returns a `LuaRuntime` failure when the final `var` read-back fails (the + /// frame drops unarmed), or an `Internal` failure when the chain holds no /// frame. pub(super) fn end_section(&mut self) -> Result<()> { let chain = &mut *self.chain; let Some(Entered { mut frame, .. }) = chain.entered.take() else { - return Err(Error::internal("a section end implies a live frame")); + return Err(error::internal("a section end implies a live frame")); }; chain.var = frame.read_var()?; frame.mark_completed(); @@ -183,7 +187,7 @@ impl ChainOps<'_> { /// the target. /// /// # Errors - /// Returns [`Error::Lua`] when the `var` read-back fails (the + /// Returns a `LuaRuntime` failure when the `var` read-back fails (the /// frame drops unarmed) or when the heading matches no visible section /// or more than one - the jumper's frame has already closed as /// completed. @@ -191,7 +195,7 @@ impl ChainOps<'_> { { let chain = &mut *self.chain; let Some(Entered { mut frame, .. }) = chain.entered.take() else { - return Err(Error::internal("a jump implies a live frame")); + return Err(error::internal("a jump implies a live frame")); }; chain.var = frame.read_var()?; frame.mark_completed(); @@ -209,7 +213,7 @@ impl ChainOps<'_> { /// slice, so its targets never descend. /// /// # Errors - /// Returns [`Error::Lua`] when the heading is malformed, matches no + /// Returns a `LuaRuntime` failure when the heading is malformed, matches no /// visible section, or matches more than one (see /// [`heading_address::resolve_visible`]). pub(super) fn resolve_chain_target(&self, heading: &str) -> Result { @@ -218,7 +222,7 @@ impl ChainOps<'_> { if chain.kind.is_h1() { let sections = h1_visible_sections(&prompt); let target = heading_address::resolve_visible(heading, Visible::all(sections))?; - let index = section_position(sections, target).ok_or(Error::internal( + let index = section_position(sections, target).ok_or(error::internal( "a resolved H1 target is absent from the top-level slice", ))?; return Ok(JumpTarget { diff --git a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs index f79cc35f6..b227aeef2 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs @@ -5,28 +5,19 @@ use super::*; -/// A failed merge's rule and the tools it names, as a vector so a case can -/// write the expected list inline. -#[track_caller] -fn broken(error: Error) -> (SetRule, Vec) { - match error { - Error::ToolSets { - evidence: SetEvidence::Tools { rule, tools }, - } => (rule, tools.into_vec()), - other => panic!("expected a broken merge rule naming tools, got {other:?}"), - } +/// The failure of a merge that broke `rule` through `tool`, the one +/// rendered tool a render rule refuses. +fn broken(rule: SetRule, tool: SetTool) -> Error { + error::tool_sets(&SetEvidence::Tools { + rule, + tools: NonEmpty::new(tool, Vec::new()), + }) } -/// The Packs a failed render rule was asked about, and the Packs it was -/// answered with. -#[track_caller] -fn render_evidence(error: Error) -> (Vec, Vec) { - match error { - Error::ToolSets { - evidence: SetEvidence::RenderedPacks { asked, answered }, - } => (asked, answered), - other => panic!("expected a broken render rule naming Packs, got {other:?}"), - } +/// The failure of a render answered with the Packs `answered` where the +/// section offered the Packs `asked`. +fn render_evidence(asked: Vec, answered: Vec) -> Error { + error::tool_sets(&SetEvidence::RenderedPacks { asked, answered }) } /// The tool `id` in `set`, as a failed merge names it. @@ -74,8 +65,8 @@ fn rule_one_refuses_an_answer_whose_packs_are_not_the_ones_asked_in_offer_order( let answered_packs = answered.iter().map(|sets| sets.pack.clone()).collect(); let error = merge_tool_sets(&offers, answered, &tools).expect_err(case); assert_eq!( - render_evidence(error), - (vec![pack("gh"), pack("web")], answered_packs), + error, + render_evidence(vec![pack("gh"), pack("web")], answered_packs), "{case}" ); } @@ -138,7 +129,7 @@ fn rule_one_refuses_a_rendered_tool_outside_what_its_pack_was_asked() { let mut answered = asked.clone(); set_mut(&mut answered[index], set).push(info(id)); let error = merge_tool_sets(&offers, answered, &tools).expect_err(case); - assert_eq!(broken(error), (rule, vec![site(id, set)]), "{case}"); + assert_eq!(error, broken(rule, site(id, set)), "{case}"); } } diff --git a/crates/runvil-internal/engine/src/execute/scope-tests.rs b/crates/runvil-internal/engine/src/execute/scope-tests.rs index 55b089f0c..2679295ca 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests.rs @@ -5,10 +5,12 @@ //! tool, are in the `render` child. use runvil_lua::ToolOutputKind; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::tools::ToolId; use serde_json::json; use super::*; +use crate::Error; #[path = "scope-tests-render.rs"] mod render; @@ -285,34 +287,33 @@ fn the_search_tools_description_lists_at_most_one_hundred_names_then_counts_the_ #[test] fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { let tools = catalog([info("dup/a.b"), info("dup/a/b"), info("web/search")]); - let clash = |error: Error| match error { - Error::ToolSets { - evidence: - SetEvidence::Tools { - rule: SetRule::UniqueWireName(wire_name), - tools, - }, - } => ( - wire_name, - tools - .iter() - .map(|tool| (tool.id.to_string(), tool.set)) - .collect::>(), - ), - other => panic!("expected a wire-name clash, got {other:?}"), + // The failure a clash of `wire_name` between the two tools fails with. + let clash = |wire_name: &str, tools: [(&str, OfferMode); 2]| { + let [first, second] = tools.map(|(id, set)| SetTool { + id: ToolId::parse(id).expect("a fixture tool id"), + set, + }); + error::tool_sets(&SetEvidence::Tools { + rule: SetRule::UniqueWireName(wire_name.to_owned()), + tools: NonEmpty::new(first, vec![second]), + }) }; let within = merged(&[listed("dup/a/b"), listed("dup/a.b")], &[], &tools) .expect_err("two tools of one set clash"); assert_eq!( - clash(within), - ( - "dup_a_b".to_owned(), - vec![ - ("dup/a/b".to_owned(), OfferMode::Static), - ("dup/a.b".to_owned(), OfferMode::Static), + within, + clash( + "dup_a_b", + [ + ("dup/a/b", OfferMode::Static), + ("dup/a.b", OfferMode::Static) ] ) ); + assert_eq!( + within.facts.get(&FactKey::Rule), + Some(&Value::from("unique_wire_name")) + ); let across = merged(&[adhoc("web_search")], &[listed("web/search")], &tools) .expect_err("a static adhoc tool and a dynamic Pack tool of its name clash"); assert_eq!( @@ -323,12 +324,12 @@ fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { "the message names each tool's set" ); assert_eq!( - clash(across), - ( - "web_search".to_owned(), - vec![ - ("adhoc/web_search".to_owned(), OfferMode::Static), - ("web/search".to_owned(), OfferMode::Dynamic), + across, + clash( + "web_search", + [ + ("adhoc/web_search", OfferMode::Static), + ("web/search", OfferMode::Dynamic), ] ) ); @@ -339,12 +340,12 @@ fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { let discovery = merged(&offers, &[listed("web/search")], &tools) .expect_err("a Pack tool takes the search tool's wire name"); assert_eq!( - clash(discovery), - ( - "GetDynamicTools".to_owned(), - vec![ - ("web/discover".to_owned(), OfferMode::Static), - ("dynamic/search".to_owned(), OfferMode::Static), + discovery, + clash( + "GetDynamicTools", + [ + ("web/discover", OfferMode::Static), + ("dynamic/search", OfferMode::Static), ] ) ); @@ -365,8 +366,9 @@ fn rule_six_fails_a_bad_wire_name_or_a_non_object_schema_alike_in_either_set() { { let error = merged(&static_tools, &dynamic_tools, &tools) .expect_err("no round could advertise the tool"); - assert!( - matches!(&error, Error::BindSchema { alias: refused, .. } if refused == alias), + assert_eq!( + (error.code, error.facts.get(&FactKey::Name)), + (ErrorCode::ToolSchemaInvalid, Some(&Value::from(alias))), "{alias}: {error:?}" ); } diff --git a/crates/runvil-internal/engine/src/execute/scope.rs b/crates/runvil-internal/engine/src/execute/scope.rs index e5a5be646..ce316681c 100644 --- a/crates/runvil-internal/engine/src/execute/scope.rs +++ b/crates/runvil-internal/engine/src/execute/scope.rs @@ -21,7 +21,7 @@ use crate::lua::{ ToolSet, current_catalog_packs, current_dynamic_set, current_static_set, wire_name_of, }; use crate::model::{ToolSchema, detail}; -use crate::{Error, Result}; +use crate::{Result, error}; /// The most Dynamic Set wire names the search tool's description lists. const LISTED_NAMES: usize = 100; @@ -187,9 +187,9 @@ struct Laid { /// schema. /// /// # Errors -/// Returns [`Error::ToolSets`] naming the rule and the Packs or tools -/// that broke it, or [`Error::BindSchema`] for a tool no round could -/// advertise. +/// Returns a `ToolSetsInvalid` failure naming the rule and the Packs or +/// tools that broke it, or a `ToolSchemaInvalid` failure for a tool no +/// round could advertise. pub(super) fn merge_tool_sets( offers: &SectionOffers, rendered: Vec>, @@ -225,9 +225,9 @@ pub(super) fn merge_tool_sets( /// with each tool a set repeats kept at its first copy. /// /// # Errors -/// Returns [`Error::ToolSets`] for an answer whose Packs differ from the -/// ones asked, in order, or naming the first rendered tool a Pack's -/// answer may not hold. +/// Returns a `ToolSetsInvalid` failure for an answer whose Packs differ +/// from the ones asked, in order, or naming the first rendered tool a +/// Pack's answer may not hold. fn check_render( asked: &[PackToolSets], answered: Vec>, @@ -237,12 +237,10 @@ fn check_render( all.iter().map(|sets| sets.pack.clone()).collect() }; if packs(asked) != packs(&answered) { - return Err(Error::ToolSets { - evidence: SetEvidence::RenderedPacks { - asked: packs(asked), - answered: packs(&answered), - }, - }); + return Err(error::tool_sets(&SetEvidence::RenderedPacks { + asked: packs(asked), + answered: packs(&answered), + })); } answered .into_iter() @@ -263,9 +261,9 @@ fn check_render( /// dropped. /// /// # Errors -/// Returns [`Error::ToolSets`] naming the first tool that sits in a set -/// the Pack was not offered into, under another Pack's id, or is a run -/// catalog tool the section did not offer in `set`. +/// Returns a `ToolSetsInvalid` failure naming the first tool that sits in +/// a set the Pack was not offered into, under another Pack's id, or is a +/// run catalog tool the section did not offer in `set`. fn check_set( pack: &PackId, set: OfferMode, @@ -273,8 +271,8 @@ fn check_set( tools: Vec, catalog: &ToolCatalog, ) -> Result> { - let refuse = |rule: SetRule, tool: &ToolInfo| Error::ToolSets { - evidence: SetEvidence::Tools { + let refuse = |rule: SetRule, tool: &ToolInfo| { + error::tool_sets(&SetEvidence::Tools { rule, tools: NonEmpty::new( SetTool { @@ -283,7 +281,7 @@ fn check_set( }, Vec::new(), ), - }, + }) }; let mut kept: Vec = Vec::with_capacity(tools.len()); for tool in tools { @@ -352,7 +350,7 @@ fn bind(mut tool: ToolInfo, catalog: &ToolCatalog) -> ToolBinding { /// [`LISTED_NAMES`] of them and then how many more there are. /// /// # Errors -/// Returns [`Error::Internal`] should a discovery tool's id not parse. +/// Returns an `Internal` failure should a discovery tool's id not parse. fn discovery_tools(dynamic_set: &[Laid]) -> Result> { let mut names: Vec<&str> = dynamic_set .iter() @@ -389,7 +387,7 @@ fn discovery_tools(dynamic_set: &[Laid]) -> Result> { ), }; let id = ToolId::parse(tool.id()) - .map_err(|_| Error::internal("a discovery tool's id is a tool id"))?; + .map_err(|_| error::internal("a discovery tool's id is a tool id"))?; let info = ToolInfo::new(id, description, parameters); Ok(Laid { binding: ToolBinding::from_info(tool.wire_name(), &info), @@ -464,12 +462,10 @@ fn refuse_clash(static_set: &[Laid], dynamic_set: &[Laid]) -> Result<()> { set, }; if let Some(first) = taken.get(wire_name) { - return Err(Error::ToolSets { - evidence: SetEvidence::Tools { - rule: SetRule::UniqueWireName(wire_name.to_owned()), - tools: NonEmpty::new(first.clone(), vec![site]), - }, - }); + return Err(error::tool_sets(&SetEvidence::Tools { + rule: SetRule::UniqueWireName(wire_name.to_owned()), + tools: NonEmpty::new(first.clone(), vec![site]), + })); } taken.insert(wire_name, site); } @@ -482,17 +478,14 @@ fn refuse_clash(static_set: &[Laid], dynamic_set: &[Laid]) -> Result<()> { /// refused rather than sent to the model. /// /// # Errors -/// Returns [`Error::BindSchema`] holding the refusal. +/// Returns a `ToolSchemaInvalid` failure that names the refusal. pub(super) fn tool_schema(binding: &ToolBinding) -> Result { detail::tool_schema_new( binding.alias().to_owned(), binding.description().to_owned(), binding.schema().clone(), ) - .map_err(|error| Error::BindSchema { - alias: binding.alias().to_owned(), - cause: Box::new(error), - }) + .map_err(|refusal| error::bind_schema(binding.alias(), &refusal)) } #[cfg(test)] diff --git a/crates/runvil-internal/engine/src/execute/section_context-construct.rs b/crates/runvil-internal/engine/src/execute/section_context-construct.rs index c293fbb6e..64c79b956 100644 --- a/crates/runvil-internal/engine/src/execute/section_context-construct.rs +++ b/crates/runvil-internal/engine/src/execute/section_context-construct.rs @@ -20,7 +20,7 @@ use crate::heading_address::{SectionPos, Visible}; use crate::lua::{Library, LibraryKey, SectionVm, Uninjected}; use crate::model::ModelBinding; use crate::prompt::Prompt; -use crate::{Error, Result}; +use crate::{Result, error}; use super::{SectionContext, SectionSys, TaskSeed}; @@ -40,7 +40,7 @@ fn library_resolver( /// run's cancel flag and Lua limits applied. /// /// # Errors -/// Returns the [`Error`] of the VM construction or the limits install. +/// Returns the failure of the VM construction or the limits install. fn limited_vm(ctx: &ChainState, name: &str) -> Result> { let vm = SectionVm::new_for_section( ctx.nonce(), @@ -80,8 +80,8 @@ impl SectionContext { /// every other entry. /// /// # Errors - /// Returns [`Error::Internal`] when `pos` is past its slice, and - /// otherwise the [`Error`] of whichever step failed (see [`section_vm`] + /// Returns an `Internal` failure when `pos` is past its slice, and + /// otherwise the failure of whichever step failed (see [`section_vm`] /// for which failures tear the VM down). #[expect( clippy::too_many_arguments, @@ -97,7 +97,7 @@ impl SectionContext { var: &serde_json::Value, seed: TaskSeed, ) -> Result { - let section = pos.section(ctx.prompt()).ok_or(Error::internal( + let section = pos.section(ctx.prompt()).ok_or(error::internal( "an entered section is absent from its slice", ))?; let mut sys = ctx.section_sys(section_id, task_id, section.name()); @@ -160,7 +160,7 @@ impl SectionContext { /// top-level section, behind `require`. /// /// # Errors - /// Returns the [`Error`] of whichever step failed (see [`section_vm`] + /// Returns the failure of whichever step failed (see [`section_vm`] /// for which failures tear the VM down). pub(crate) fn new_h1( ctx: &ChainState, diff --git a/crates/runvil-internal/engine/src/execute/section_context.rs b/crates/runvil-internal/engine/src/execute/section_context.rs index ca86f2de4..60b037647 100644 --- a/crates/runvil-internal/engine/src/execute/section_context.rs +++ b/crates/runvil-internal/engine/src/execute/section_context.rs @@ -26,7 +26,7 @@ use runvil_types::ids::TaskId; use runvil_types::tools::ToolId; use crate::lua::{ProseState, ScriptReport, SectionVm, ToolCallCounts}; -use crate::{Error, Result, subst}; +use crate::{Result, subst}; use super::context::{ChainState, ReportingHandles}; @@ -152,12 +152,12 @@ impl SectionContext { /// goes through the live VM. /// /// # Errors - /// Returns [`Error::Lua`] when the section reassigned the `var` global, - /// which the walk relies on to fail the run, or when `var` cannot - /// convert back to JSON (the write guard keeps that second case from - /// happening in practice). + /// Returns a `LuaRuntime` failure when the section reassigned the `var` + /// global, which the walk relies on to fail the run, or when `var` + /// cannot convert back to JSON (the write guard keeps that second case + /// from happening in practice). pub(super) fn read_var(&self) -> Result { - Ok(self.vm().var()?) + self.vm().var() } /// Reads the H1 pass's `argv` back as JSON while the frame is live: the @@ -165,9 +165,9 @@ impl SectionContext { /// repair. `None` reads as nil. /// /// # Errors - /// Returns [`Error::Lua`] when H1 left `argv` as non-JSON data. + /// Returns a `LuaRuntime` failure when H1 left `argv` as non-JSON data. pub(super) fn read_argv(&self) -> Result> { - self.vm().argv_json().map_err(Error::from) + self.vm().argv_json() } /// Arms the completion flag: the block walk completed (a jump or @@ -199,13 +199,12 @@ impl SectionContext { /// renders. /// /// # Errors - /// Returns [`Error::Lua`] if the guard cannot be installed. + /// Returns a `LuaRuntime` failure if the guard cannot be installed. pub(super) fn install_lazy_prose(&self, ctx: &ChainState, template: &str) -> Result<()> { let template = template.to_owned(); let render = prose_renderer(ctx, self.item.clone()); self.vm() - .install_lazy_prose(move |state: ProseState<'_>| render(&template, state))?; - Ok(()) + .install_lazy_prose(move |state: ProseState<'_>| render(&template, state)) } /// The frame's effective reporting handles for the `chat` rounds the @@ -228,7 +227,7 @@ impl SectionContext { /// thread. /// /// # Errors - /// Returns the [`Error`] of the first-call install or the + /// Returns the failure of the first-call install or the /// id seeding. pub(super) fn tool_call_counts(&mut self, ids: &[ToolId]) -> Result { let Self { @@ -287,14 +286,13 @@ fn prose_renderer( let raw_args = Arc::clone(ctx.args()); let argv = ctx.argv().cloned(); move |template: &str, state: ProseState<'_>| -> mlua::Result { - let globals = |name: &str| (state.globals)(name).map_err(Error::from); let sources = subst::Sources { args: &raw_args, argv: argv.as_deref(), item: item.as_ref(), var: &state.var, sys: &state.sys, - globals: &globals, + globals: state.globals, }; subst::substitute(template, &sources).map_err(mlua::Error::external) } @@ -311,7 +309,7 @@ fn prose_renderer( /// call. `sys` changes only once the VM's re-seal succeeds. /// /// # Errors -/// Returns the [`Error`] of the counts install, the model resolution, or +/// Returns the failure of the counts install, the model resolution, or /// the `sys` re-seal. fn install_first_call_state( vm: &SectionVm, diff --git a/crates/runvil-internal/engine/src/execute/section_vm.rs b/crates/runvil-internal/engine/src/execute/section_vm.rs index 95e603a4d..c85ff8c62 100644 --- a/crates/runvil-internal/engine/src/execute/section_vm.rs +++ b/crates/runvil-internal/engine/src/execute/section_vm.rs @@ -25,7 +25,7 @@ use runvil_vfs::Access; use crate::lua::{Library, LibraryKey, ProseState, SectionText, SectionVm, Uninjected}; use crate::model::ModelBinding; -use crate::{Error, Result}; +use crate::{Error, Result, error}; /// What a section VM is seeded with beyond the shared Engine values. /// @@ -76,6 +76,9 @@ pub(crate) struct SectionVmSetup<'a> { pub(crate) emitter: &'a Emitter, /// The section name used in observations and error messages. pub(crate) section_name: &'a str, + /// The file the run's prompt came from, which the author prose of the + /// VM's Lua failures names. + pub(crate) file: &'a str, /// The run's tool-loop cap, as `ChainState::max_tool_iterations` /// resolves it, captured by the `models.loop` shim as its round cap. pub(crate) max_tool_iterations: usize, @@ -95,7 +98,8 @@ pub(crate) struct SectionVmSetup<'a> { /// Runs one section VM's setup sequence against a constructed, limited VM. /// -/// The sequence is fixed and shared: Engine injection with the frame +/// The sequence is fixed and shared: the prompt's file label, then Engine +/// injection with the frame /// constructor's [`VmSeed`], preceded by the raw-model-id opt-in when the run /// supplied a snapshot (Engine injection's `models` table install reads that /// flag, so the opt-in must land first), then @@ -132,6 +136,7 @@ where + 'static, P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { + vm.set_prompt_file(setup.file); // The raw-model-id fallback reads its flag during Engine injection, so // the snapshot's opt-in lands first. if setup.ui.is_some() { @@ -146,7 +151,7 @@ where setup.argv, setup.selection, ) - .map_err(|failure| Error::from(failure.teardown(setup.emitter, setup.section_name)))?; + .map_err(|failure| failure.teardown(setup.emitter, setup.section_name))?; match install_after_injection(&mut vm, setup, read, resolve, render) { Ok(()) => Ok(vm), Err(error) => { @@ -189,7 +194,7 @@ where // returns - caught by `pcall` or not - so the conflict wins over the // install's own outcome. if let Some(detail) = vm.take_store_conflict() { - return Err(Error::Determinism(detail)); + return Err(error::determinism(&detail)); } installed?; // `require` follows the preludes, so a prelude that calls it while it @@ -206,5 +211,5 @@ where // a prelude's top-level `store.write` yield from outside a coroutine. // The install also switches any store function a prelude captured, so // none of them runs directly after this point. - crate::lua::install_store_shims(vm.lua()).map_err(Error::from) + crate::lua::install_store_shims(vm.lua()) } diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index fdbe91fcc..da4de1cb5 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}; @@ -14,10 +15,8 @@ use serde_json::{Value, json}; use self::fixtures::untrusted_echo_tool; use super::turn::advance_turn; use super::*; -use crate::Error; use crate::lua::{SectionVm, current_static_set}; use crate::model::{ModelInfo, ModelSet}; -use crate::prompt::ParseErrorKind; use crate::prompt::Prompt; use crate::test_support::recording::{Observation, Observer, detail, null_emitter}; use crate::test_support::scenario::{Scenario, Trace}; @@ -25,7 +24,7 @@ use crate::test_support::scripted_chat::{ MOCK_MODEL, ScriptedCall, ScriptedChat, ScriptedReply, calls, fail, text, text_finish, }; use crate::test_support::seeded::Seeded; -use crate::test_support::{FnTool, fresh_access}; +use crate::test_support::{FnTool, authored, fresh_access, refused}; mod context; mod fixtures; diff --git a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs index d5d0705fb..4e0ef621f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs +++ b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs @@ -190,7 +190,7 @@ fn a_handlers_loop_during_a_loop_raises_loop_running_as_a_lua_error() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - format!("false|lua|{LOOP_RUNNING}|final answer") + format!("false|lua|{}|final answer", refused(LOOP_RUNNING)) ); assert_eq!( gateway.call_count(), @@ -209,7 +209,7 @@ fn an_overflow_compactors_loop_raises_loop_running() { return tostring(ok) .. '|' .. err.kind .. '|' .. tostring(err)", )); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), format!("false|lua|{LOOP_RUNNING}")); + assert_eq!(trace.text(), format!("false|lua|{}", refused(LOOP_RUNNING))); assert_eq!( gateway.call_count(), 0, diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs index 7ac632dc9..fcaf3cd6d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs @@ -221,19 +221,11 @@ fn an_out_of_scope_tool_name_fails_the_round_with_out_of_scope_tool() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - match trace.error() { - Error::OutOfScopeToolCall { - name, - global_exists, - static_aliases, - .. - } => { - assert_eq!(name, "rogue"); - assert!(!global_exists, "rogue is bound nowhere"); - assert_eq!(static_aliases, &["echo".to_owned()]); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // Rogue is bound nowhere, and the section offers only echo. + assert_eq!( + trace.error(), + &crate::error::out_of_scope_tool("rogue", false, &["echo".to_owned()], false) + ); } #[test] 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-rules.rs b/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs index 23b105b3b..603ea393f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs @@ -25,56 +25,59 @@ fn overflowing_with(body: &str) -> String { )) } -/// The precheck's exhaustion message. -const PRECHECK: &str = "context exhausted: the request precheck overflowed the model's context \ - window"; - #[test] fn each_broken_rule_raises_its_error_and_restores_the_records() { - let exhausted = format!("context_exhausted|{PRECHECK}"); + let exhausted = format!("context_exhausted|{}", ErrorCode::ContextExhausted.prose()); + let lua = |what: &str| format!("lua|{}", refused(what)); let cases = [ ( "an unanswered batch", "messages:replace(5, 5, { role = 'assistant', content = '', tool_calls = { \ { id = 'c2', name = 'echo', arguments = { value = 'v' } } } })", - "lua|messages[5] tool call \"c2\" has no tool result", + lua("messages[5] tool call \"c2\" has no tool result"), ), ( "a split batch", "messages:replace(4, 3, { role = 'user', content = 'between' })", - "lua|messages[3] tool call \"c1\" has no tool result", + lua("messages[3] tool call \"c1\" has no tool result"), ), ( "a duplicate call id", "messages:replace(5, 5, { role = 'assistant', content = '', tool_calls = { \ { id = 'c1', name = 'echo', arguments = { value = 'v' } } } }, \ { role = 'tool', content = 'r2', tool_call_id = 'c1' })", - "lua|messages[5] tool call id \"c1\" duplicates an earlier tool call", + lua("messages[5] tool call id \"c1\" duplicates an earlier tool call"), ), ( "an orphan tool record", "messages:replace(3, 3)", - "lua|messages[3] is an orphan tool record: no pending assistant tool call with id \ - \"c1\"", + lua( + "messages[3] is an orphan tool record: no pending assistant tool call with id \ + \"c1\"", + ), ), ( "a late system record", "messages:replace(5, 5, { role = 'user', content = 'short' }) messages:append({ role = 'system', content = 'late' })", - "lua|messages[6] is a system message after a non-system message; system messages \ - lead the list, and only a compactor can change the leading block", + lua( + "messages[6] is a system message after a non-system message; system messages \ + lead the list, and only a compactor can change the leading block", + ), ), ( "a non-text composed system block", "messages:replace(1, 0, { role = 'system', content = { { type = 'text', text = 'rules' } } })", - "lua|messages[1] is a system message with content parts; only plain text system \ - messages can be composed", + lua( + "messages[1] is a system message with content parts; only plain text system \ + messages can be composed", + ), ), - ("no shrink", "", exhausted.as_str()), + ("no shrink", "", exhausted.clone()), ( "a retried precheck still over", "messages:replace(5, 5, { role = 'user', content = string.rep('x', 50000) })", - exhausted.as_str(), + exhausted, ), ]; for (rule, body, refusal) in cases { diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction.rs b/crates/runvil-internal/engine/src/execute/tests/compaction.rs index 45de2489a..e87796072 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; @@ -156,7 +156,10 @@ fn a_retry_that_fails_another_way_keeps_the_compacted_records() { return tostring(ok) .. '|' .. err.kind .. ':' .. tostring(err) .. '|' .. #messages", ); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), "false|lua:messages must not be empty|0"); + assert_eq!( + trace.text(), + format!("false|lua:{}|0", refused("messages must not be empty")) + ); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -215,7 +218,7 @@ fn a_loop_inside_a_compactor_raises_loop_running() { return err.kind .. ':' .. tostring(err) .. '|' .. #messages", ); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), format!("lua:{LOOP_RUNNING}|1")); + assert_eq!(trace.text(), format!("lua:{}|1", refused(LOOP_RUNNING))); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -223,7 +226,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.", ), @@ -254,7 +257,10 @@ fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { .run(); assert_eq!( trace.text(), - format!("false|2|0|false|tools.offer: {FIXED}") + format!( + "false|2|0|false|{}", + refused(&format!("tools.offer: {FIXED}")) + ) ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 2, "the overflowed round and its retry"); diff --git a/crates/runvil-internal/engine/src/execute/tests/context.rs b/crates/runvil-internal/engine/src/execute/tests/context.rs index 6868307fa..27ec8c72b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/context.rs +++ b/crates/runvil-internal/engine/src/execute/tests/context.rs @@ -61,7 +61,7 @@ pub(super) fn parse(md: &str) -> Prompt { } else { md.replacen("---\n\n", "---\n\n# Test prompt\n\n", 1) }; - Prompt::parse(&source, EXECUTION).0.unwrap() + Prompt::parse(&source, "prompt", EXECUTION).0.unwrap() } /// An [`Observer`] that keeps every observation it is handed, in order, so a test diff --git a/crates/runvil-internal/engine/src/execute/tests/discovery.rs b/crates/runvil-internal/engine/src/execute/tests/discovery.rs index 7b1d4ee9a..30688e27b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/discovery.rs +++ b/crates/runvil-internal/engine/src/execute/tests/discovery.rs @@ -462,9 +462,14 @@ fn a_scripts_call_of_a_discovery_tools_id_fails_as_an_unknown_id() { Vec::new(), &[], ); - match rig.trace().error() { - Error::UnboundToolCall { name, .. } => assert_eq!(name, "dynamic/call"), - other => panic!("expected the unbound-tool error, got {other:?}"), - } + let error = rig.trace().error(); + assert_eq!( + ( + error.code, + error.facts.get(&runvil_types::error::FactKey::Name) + ), + (ErrorCode::UnboundTool, Some(&Value::from("dynamic/call"))), + "expected the unbound-tool error, got {error:?}" + ); assert!(issued_calls(&rig).is_empty(), "nothing was issued"); } 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..3e4af893c 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( @@ -88,10 +96,10 @@ fn unsupported_major_one_is_refused() { // Major 1 is unsupported: the gate refuses it and names the // declared version rather than silently degrading to major 0. let trace = Scenario::new(ran_under("runvil: 1\n")).run(); - assert!( - matches!(trace.error(), Error::UnsupportedVersion(1)), - "the gate refuses major 1 by name, got {:?}", - trace.error() + assert_eq!( + trace.error(), + &crate::error::unsupported_version(1), + "the gate refuses major 1 by name" ); } @@ -99,10 +107,10 @@ fn unsupported_major_one_is_refused() { fn unsupported_major_is_refused() { // Any major other than 0 is refused, never silently degraded to major 0. let trace = Scenario::new(ran_under("runvil: 2\n")).run(); - assert!( - matches!(trace.error(), Error::UnsupportedVersion(2)), - "the gate refuses major 2 by name, got {:?}", - trace.error() + assert_eq!( + trace.error(), + &crate::error::unsupported_version(2), + "the gate refuses major 2 by name" ); } @@ -111,21 +119,20 @@ fn missing_version_is_not_a_runvil_prompt() { // No `runvil:` key: not our prompt, so `run` declines with a Parse // error rather than executing it. let trace = Scenario::new(ran_under("")).run(); - match trace.error() { - Error::Parse(error) => { - assert_eq!(error.kind(), ParseErrorKind::Structure); - assert_eq!( - error.name(), - Some("t"), - "the error reports the parsed prompt's frontmatter name" - ); - assert!( - error.to_string().contains("not a runvil prompt"), - "the Parse message must name the missing version, got: {error}" - ); - } - other => panic!("expected Parse, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::StructureInvalid, "got {error:?}"); + assert_eq!( + error + .facts + .get(&FactKey::Name) + .and_then(serde_json::Value::as_str), + Some("t"), + "the error reports the parsed prompt's frontmatter name" + ); + assert!( + error.to_string().contains("not a runvil prompt"), + "the Parse message must name the missing version, got: {error}" + ); assert_eq!( trace.outcome(), &RunOutcome::Failed { @@ -270,18 +277,13 @@ fn empty_stop_turn_without_tool_calls_fails() { for offered in [None, Some(echo_tool())] { let trace = exit_scenario(vec![text_finish("", "stop")], offered).run(); 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.message(), - "the model replied with no text and no tool calls", - "the fixed phrase for the kind is the message" - ); - } - other => panic!("expected an EmptyReply failure, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::EmptyReply, "got {error:?}"); + assert_eq!(finish_reason(error), Some("stop")); + assert_eq!( + error.prose, "the model replied with no text and no tool calls", + "the fixed phrase for the code is the prose" + ); assert_eq!(trace.turns(), 1, "the empty round is a completed turn"); assert_eq!(events, vec![detail::MODEL_TURN_COMPLETED.to_string()]); } @@ -294,13 +296,9 @@ fn empty_truncated_final_text_fails_without_truncation_detail() { // truncated. let trace = exit_scenario(vec![text_finish("", "length")], None).run(); let events = exit_events(&trace); - match trace.error() { - Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), Some("length")); - } - other => panic!("expected an EmptyReply failure, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::EmptyReply, "got {error:?}"); + assert_eq!(finish_reason(error), Some("length")); assert_eq!(events, vec![detail::MODEL_TURN_COMPLETED.to_string()]); } @@ -314,13 +312,9 @@ fn empty_turn_without_finish_reason_after_tool_call_fails() { ) .run(); let events = exit_events(&trace); - match trace.error() { - Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), None); - } - other => panic!("expected an EmptyReply failure, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::EmptyReply, "got {error:?}"); + assert_eq!(finish_reason(error), None); assert_eq!( trace.turns(), 2, 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/full_id_calls.rs b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs index a74c2b8e9..f4b3b5cb2 100644 --- a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs +++ b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs @@ -163,7 +163,11 @@ fn a_script_call_naming_a_string_without_a_slash_raises_before_any_resolution() let rig = Rig::run(loop_scenario(source).tool(echo_tool())); assert_eq!( rig.trace().text(), - "lua:tools.call: \"tools_echo\" is not a tool id|tools.call: \"echo\" is not a tool id" + format!( + "lua:{}|{}", + refused("tools.call: \"tools_echo\" is not a tool id"), + refused("tools.call: \"echo\" is not a tool id") + ) ); assert!(rig.issued().is_empty(), "nothing was issued"); } @@ -210,17 +214,14 @@ fn a_script_call_by_id_and_by_tool_object_issue_the_same_effect() { fn a_name_that_is_no_catalog_id_is_unbound_and_lists_the_catalog_ids() { let rig = Rig::run(echo_and_concrete("return tools.call('tools/missing', {})")); let error = rig.trace().error(); - match error { - Error::UnboundToolCall { name, ids } => { - assert_eq!(name, "tools/missing"); - assert_eq!( - ids, - &["tools/concrete".to_owned(), ECHO_ID.to_owned()], - "every catalog tool's id is listed, in id order" - ); - } - other => panic!("expected the typed unbound-tool error, got {other:?}"), - } + assert_eq!( + error, + &crate::error::unbound_tool( + "tools/missing", + &["tools/concrete".to_owned(), ECHO_ID.to_owned()] + ), + "every catalog tool's id is listed, in id order" + ); assert_eq!( error.to_string(), "tool \"tools/missing\" is not a tool in this run; \ 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/live_infer.rs b/crates/runvil-internal/engine/src/execute/tests/live_infer.rs index 08cfcfd73..2b8c566f6 100644 --- a/crates/runvil-internal/engine/src/execute/tests/live_infer.rs +++ b/crates/runvil-internal/engine/src/execute/tests/live_infer.rs @@ -59,7 +59,7 @@ fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { let trace = Scenario::new(reading.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::RequirementsUnmet, "expected ModelRequired, got {error}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs index 8db8891e9..8bd8d9afc 100644 --- a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs +++ b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs @@ -330,7 +330,7 @@ fn model_required_when_infer_has_no_binding() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::RequirementsUnmet, "expected ModelRequired, got {error}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs index 540785fcd..1903d0b19 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs @@ -84,7 +84,7 @@ fn models_compact_refuses_while_a_loop_its_handler_or_a_compactor_runs() { let trace = loop_scenario(source).chat(&gateway).run(); let expected: Vec = ["handler", "compactor", "compact"] .iter() - .map(|label| format!("{label}:false:lua:{COMPACT_RUNNING}")) + .map(|label| format!("{label}:false:lua:{}", refused(COMPACT_RUNNING))) .chain(["5".to_owned()]) .collect(); assert_eq!(trace.text().lines().collect::>(), expected); @@ -114,7 +114,10 @@ fn models_compact_applies_the_structural_rules_but_not_the_size_rules() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - "lua|messages[2] tool call \"c1\" has no tool result|1:100000|gave up|1:100000" + format!( + "lua|{}|1:100000|gave up|1:100000", + refused("messages[2] tool call \"c1\" has no tool result") + ) ); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -147,7 +150,7 @@ fn models_compact_checks_the_compactor_then_the_reason() { "reason must be a string, got number", "models.compact takes (compactor, reason)", ] - .map(|message| format!("false:lua:{message}")); + .map(|message| format!("false:lua:{}", refused(message))); let lines: Vec<&str> = trace.text().lines().collect(); assert_eq!(lines[..6], refusals); assert_eq!(lines[6], "1", "a refused call leaves the list alone"); diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs index e4cf3a0d0..907332748 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs @@ -84,7 +84,10 @@ fn the_arity_error_counts_trailing_nils_in_both_shapes() { function() return models.loop(other, nil, nil) end, function() return models.loop(other, compactors.fail, nil) end,", ); - let arity = "lua:models.loop takes (compactor?) or (model, compactor?)"; + let arity = format!( + "lua:{}", + refused("models.loop takes (compactor?) or (model, compactor?)") + ); assert_eq!(out, format!("{arity}|{arity}|{arity}|{arity}|{arity}|1")); } @@ -98,12 +101,10 @@ fn the_compactor_is_checked_after_the_arity() { function() return models.loop(other, 42, nil) end, function() return models.loop(other, 42) end,", ); + let arity = refused("models.loop takes (compactor?) or (model, compactor?)"); + let compactor = refused("compactor must be a function, got integer"); assert_eq!( out, - "lua:models.loop takes (compactor?) or (model, compactor?)\ - |lua:compactor must be a function, got integer\ - |lua:models.loop takes (compactor?) or (model, compactor?)\ - |lua:compactor must be a function, got integer\ - |1" + format!("lua:{arity}|lua:{compactor}|lua:{arity}|lua:{compactor}|1") ); } 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.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs index 58a8c9715..1c171334c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs @@ -259,8 +259,10 @@ fn an_offer_after_the_first_loop_raises_and_the_second_loop_sends_the_same_empty .run(); assert_eq!( trace.text(), - "tools.offer: the section's tool list is fixed at its first models.loop round; \ - make every offer before the first loop, or offer in another section" + refused( + "tools.offer: the section's tool list is fixed at its first models.loop round; \ + make every offer before the first loop, or offer in another section" + ) ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 2, "one tool-free round per loop"); 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..5a4aacf9c 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 @@ -26,7 +26,7 @@ fn an_omitted_compactor_defaults_to_fail_with_typed_precheck_exhaustion() { let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); assert!( - matches!(error, Error::ContextExhausted { reason, .. } if reason == "precheck"), + error == &crate::error::context_exhausted("precheck"), "the omitted compactor defaults to compactors.fail with the precheck reason, got {error:?}" ); assert_eq!( @@ -47,17 +47,17 @@ fn models_loop_raises_context_exhaustion_at_the_call_site() { return tostring(err)", ); let trace = loop_scenario(source).chat(&gateway).run(); - let out = trace.text(); - assert!( - out.starts_with("context exhausted: "), - "the raised error is the typed exhaustion's message, got: {out}" + assert_eq!( + trace.text(), + runvil_types::error::ErrorCode::ContextExhausted.prose(), + "the raised error renders the typed exhaustion's prose" ); } #[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.", )]); @@ -69,7 +69,7 @@ fn an_explicit_compactors_fail_invocation_reports_the_provider_reason() { let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); assert!( - matches!(error, Error::ContextExhausted { reason, .. } if reason == "provider"), + error == &crate::error::context_exhausted("provider"), "the explicit compactors.fail invocation reports the provider reason, got {error:?}" ); assert_eq!(gateway.call_count(), 1, "the request left and was rejected"); @@ -85,13 +85,17 @@ fn an_uncaught_compactors_fail_with_a_callers_reason_ends_the_run_as_context_exh ); let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::ContextExhausted, "{error}"); + assert_eq!( + run_error_kind(error.code), + RunErrorKind::ContextExhausted, + "{error}" + ); assert!( error.to_string().contains("manual"), "the run's message holds the caller's reason, got: {error}" ); assert!( - matches!(error, Error::ContextExhausted { reason, .. } if reason == "manual"), + error == &crate::error::context_exhausted("manual"), "the reason survives as given, got {error:?}" ); assert_eq!( @@ -110,13 +114,17 @@ fn an_uncaught_direct_compactors_fail_ends_the_run_as_context_exhausted() { ); let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::ContextExhausted, "{error}"); + assert_eq!( + run_error_kind(error.code), + RunErrorKind::ContextExhausted, + "{error}" + ); assert!( error.to_string().contains("manual"), "the run's message holds the caller's reason, got: {error}" ); assert!( - matches!(error, Error::ContextExhausted { reason, .. } if reason == "manual"), + error == &crate::error::context_exhausted("manual"), "the reason survives as given, got {error:?}" ); assert_eq!(gateway.call_count(), 0, "no request leaves"); @@ -148,17 +156,14 @@ fn a_non_function_compactor_is_the_calls_error_in_the_engines_type_names() { return table.concat(out, '|')", ); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!( - trace.text(), - "compactor must be a function, got integer\ - |compactor must be a function, got integer\ - |compactor must be a function, got number\ - |compactor must be a function, got number\ - |compactor must be a function, got string\ - |compactor must be a function, got string\ - |compactor must be a function, got table\ - |compactor must be a function, got table" - ); + let expected: Vec = ["integer", "number", "string", "table"] + .iter() + .flat_map(|kind| { + let refusal = refused(&format!("compactor must be a function, got {kind}")); + [refusal.clone(), refusal] + }) + .collect(); + assert_eq!(trace.text(), expected.join("|")); assert_eq!( gateway.call_count(), 0, @@ -235,16 +240,17 @@ fn a_compactors_own_string_raise_reaches_the_caller_with_the_reason_tag() { return 'unreachable'", ); let trace = loop_scenario(source).chat_with(overflowing(&rounds)).run(); - match trace.error() { - Error::Lua { - message, - cause: Some(_), - } => assert!( - message.contains("custom failure: provider"), - "the compactor's own error survives with the reason tag, got: {message}" - ), - other => panic!("expected the compactor's own runtime error, got {other:?}"), - } + let error = trace.error(); + assert_eq!( + error.code, + ErrorCode::LuaRuntime, + "expected the compactor's own runtime error, got {error:?}" + ); + assert!( + error.message.contains("custom failure: provider"), + "the compactor's own error survives with the reason tag, got: {}", + error.message + ); assert_eq!( rounds.load(Ordering::SeqCst), 1, diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs index 4d1fd034a..015e58990 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs @@ -87,7 +87,10 @@ fn a_light_userdata_compactor_is_named_userdata_as_lua_names_it() { .run(); assert_eq!( trace.text(), - "userdata|lua|compactor must be a function, got userdata|1" + format!( + "userdata|lua|{}|1", + refused("compactor must be a function, got userdata") + ) ); assert_eq!(gateway.call_count(), 0, "no round runs"); } @@ -119,7 +122,7 @@ fn the_arity_error_still_raises_when_the_author_has_rebound_error() { ); assert_eq!( trace.text(), - "models.loop takes (compactor?) or (model, compactor?)" + refused("models.loop takes (compactor?) or (model, compactor?)") ); assert!(trace.effects().is_empty(), "no round runs"); } @@ -133,7 +136,10 @@ fn an_empty_list_raises_before_any_round() { return err.kind .. ':' .. tostring(err)", ); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), "lua:messages must not be empty"); + assert_eq!( + trace.text(), + format!("lua:{}", refused("messages must not be empty")) + ); assert_eq!(gateway.call_count(), 0, "the refused call sends no request"); } @@ -147,22 +153,21 @@ fn an_uncaught_loop_arity_error_fails_the_run_naming_the_authors_prompt_line() { return 'unreachable'", ); let trace = loop_scenario(source).chat(&gateway).run(); - match trace.error() { - Error::Lua { - message, - cause: Some(_), - } => { - assert!( - message.contains("models.loop takes (compactor?)"), - "the runtime error keeps the shim's message: {message}" - ); - assert!( - message.contains("section `Only` prologue:13: in"), - "the runtime error names the author's prompt line: {message}" - ); - } - other => panic!("expected the mapped runtime error, got {other:?}"), - } + let error = trace.error(); + assert_eq!( + error.code, + ErrorCode::LuaRuntime, + "expected the mapped runtime error, got {error:?}" + ); + let message = &error.message; + assert!( + message.contains("models.loop takes (compactor?)"), + "the runtime error keeps the shim's message: {message}" + ); + assert!( + message.contains("section `Only` prologue:13: in"), + "the runtime error names the author's prompt line: {message}" + ); assert_eq!(gateway.call_count(), 0, "no round runs"); } @@ -215,8 +220,10 @@ fn a_returning_compactor_that_shrank_nothing_raises_the_exact_exhaustion_text() )); assert_eq!( trace.text(), - "context_exhausted|context exhausted: the provider rejected the request as exceeding \ - the model's context window|1" + format!( + "context_exhausted|{}|1", + ErrorCode::ContextExhausted.prose() + ) ); assert_eq!(rounds, 1, "the one round left and overflowed"); } diff --git a/crates/runvil-internal/engine/src/execute/tests/observations.rs b/crates/runvil-internal/engine/src/execute/tests/observations.rs index c798a0705..b88d2437f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/observations.rs +++ b/crates/runvil-internal/engine/src/execute/tests/observations.rs @@ -177,7 +177,7 @@ fn a_failing_run_still_reports_run_finished() { // observation must report the run failure. let trace = Scenario::new(failing_prompt()).run(); assert!( - matches!(trace.error(), Error::Lua { .. }), + trace.error().code == ErrorCode::LuaRuntime, "the prologue fails as a Lua error, got {:?}", trace.error() ); diff --git a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs index 8b7f42ce6..e4310207d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs @@ -7,12 +7,12 @@ use super::*; use crate::execute::tests::context::Recorder; -/// The refusal of a section that offers both `dup/a/b` and `dup/a.b`, -/// whose default wire names are both `dup_a_b`. -const DUP_CLASH: &str = "the tools dup/a/b of the dup Pack in the Static Set and dup/a.b of the \ - dup Pack in the Static Set share the wire name \"dup_a_b\", and a wire \ - name names one tool across a section's Static Set and Dynamic Set; \ - offer only one of them"; +/// What a caught refusal of a section that offers both `dup/a/b` and +/// `dup/a.b`, whose default wire names are both `dup_a_b`, renders: its +/// kind and the prose of a broken tool-set rule. +fn dup_clash() -> String { + format!("internal:{}", ErrorCode::ToolSetsInvalid.prose()) +} /// One section's Lua that offers `offer`, then runs one loop round whose /// failure it catches, returning the failure's kind and text. @@ -55,7 +55,7 @@ fn two_catalog_tools_whose_default_names_clash_fail_only_the_section_that_offers .text() .split_once('|') .expect("the section joins its refusal and its catalog reads"); - assert_eq!(refusal, format!("internal:{DUP_CLASH}")); + assert_eq!(refusal, dup_clash()); assert_eq!(catalog, "truetrue", "both tools stay in the run catalog"); let (advertised, _) = rounds_and_calls(rig.issued()); assert_eq!( @@ -90,8 +90,10 @@ fn a_catalog_tool_whose_default_name_is_invalid_fails_only_the_sections_that_off ); assert_eq!( rig.trace().text(), - "internal:model-facing schema build failure for tool alias \"9lives_fetch\": invalid \ - tool wire name \"9lives_fetch\": expected [A-Za-z][A-Za-z0-9_-]{0,63}|9lives/fetch: x", + format!( + "internal:{}|9lives/fetch: x", + ErrorCode::ToolSchemaInvalid.prose() + ), "the section that offers the tool fails, and a script still calls it by id" ); let (advertised, _) = rounds_and_calls(rig.issued()); @@ -134,9 +136,14 @@ fn the_offer_hint_names_a_catalog_tool_by_its_own_wire_name_only() { .text() .split_once('|') .expect("the section joins both refusals"); - assert!( - own.ends_with("(did you mean 'tools/echo'?)"), - "the tool's own wire name hints its id: {own}" + assert_eq!( + own, + refused( + "tools.offer: no Pack named \"echo\" in this run; declare it under uses: to \ + require it, or offer it only when tools.packs.get(\"echo\") returns it (did you \ + mean 'tools/echo'?)" + ), + "the tool's own wire name hints its id" ); assert!( !default.contains("did you mean"), @@ -154,18 +161,18 @@ fn the_scope_gate_says_a_name_exists_only_for_a_catalog_tools_own_wire_name() { .tool(offered_tool("gh/search")) .chat(&gateway) .run(); - match trace.error() { - Error::OutOfScopeToolCall { - global_exists, - has_dynamic_set, - static_aliases, - .. - } => { - assert!(!has_dynamic_set, "the section holds no Dynamic Set tool"); - assert_eq!(static_aliases, &["gh_search".to_owned()]); - *global_exists - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), + // The section holds no Dynamic Set tool and offers only gh_search, + // so the refusal differs only in whether the name exists. + let offered = ["gh_search".to_owned()]; + let error = trace.error(); + if error == &crate::error::out_of_scope_tool(name, true, &offered, false) { + true + } else { + assert_eq!( + error, + &crate::error::out_of_scope_tool(name, false, &offered, false) + ); + false } }; assert!(refused("echo"), "the unoffered tool's own wire name exists"); @@ -193,7 +200,7 @@ fn the_scope_validation_events_bracket_the_merge_whether_it_holds_or_fails() { .tool(offered_tool("dup/a/b")) .chat(&gateway) .run(); - assert_eq!(trace.text(), format!("internal:{DUP_CLASH}")); + assert_eq!(trace.text(), dup_clash()); let recorder = Recorder::default(); trace.replay(&recorder); let validation = [ 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/preludes.rs b/crates/runvil-internal/engine/src/execute/tests/preludes.rs index 25e8e32cb..d2719ea42 100644 --- a/crates/runvil-internal/engine/src/execute/tests/preludes.rs +++ b/crates/runvil-internal/engine/src/execute/tests/preludes.rs @@ -71,7 +71,7 @@ fn assert_fails_before_any_effect(md: &str, preludes: Vec, fragments: & ); let trace = prelude_run(md, preludes); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::Lua, "{error:?}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Lua, "{error:?}"); let message = error.to_string(); for fragment in fragments { assert!( diff --git a/crates/runvil-internal/engine/src/execute/tests/render_effect.rs b/crates/runvil-internal/engine/src/execute/tests/render_effect.rs index 520ae4e4c..ecdfc167e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/render_effect.rs +++ b/crates/runvil-internal/engine/src/execute/tests/render_effect.rs @@ -414,8 +414,7 @@ fn a_render_answer_that_breaks_the_merge_fails_its_round_and_fixes_no_tools() { }); assert_eq!( text_of(result), - "internal:the tools Pack's render returned kit/sneak of the kit Pack in the Static Set; \ - a Pack renders only tools whose id starts with its own Pack id|done" + format!("internal:{}|done", ErrorCode::ToolSetsInvalid.prose()) ); assert_eq!( renders, 2, diff --git a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs index 40bb1075b..929673e63 100644 --- a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs @@ -60,7 +60,10 @@ fn a_first_round_the_precheck_refuses_still_fixes_the_tool_list() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - assert_eq!(trace.text(), format!("lua:tools.offer: {FIXED}")); + assert_eq!( + trace.text(), + format!("lua:{}", refused(&format!("tools.offer: {FIXED}"))) + ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "only the round on the wide model left"); assert_eq!( @@ -143,7 +146,7 @@ fn after_the_render_every_offer_raises_even_an_identical_one_while_defining_stil ); let trace = loop_scenario(source).tool(echo_tool()).chat(&gateway).run(); let lines: Vec<&str> = trace.text().lines().collect(); - let offer = format!("lua:tools.offer: {FIXED}"); + let offer = format!("lua:{}", refused(&format!("tools.offer: {FIXED}"))); assert_eq!( lines[..5], [ @@ -151,7 +154,7 @@ fn after_the_render_every_offer_raises_even_an_identical_one_while_defining_stil offer.clone(), offer.clone(), offer.clone(), - format!("lua:tools.offer_dynamic: {FIXED}"), + format!("lua:{}", refused(&format!("tools.offer_dynamic: {FIXED}"))), ], "a handler's offer during the loop, then an identical re-offer of a tool \ and of a Pack, an offer of a tool defined after the render, and a \ @@ -217,7 +220,7 @@ fn a_first_round_refused_over_an_empty_list_renders_nothing() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - assert_eq!(trace.text(), "messages must not be empty"); + assert_eq!(trace.text(), refused("messages must not be empty")); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "the refused round never left"); assert_eq!( @@ -249,7 +252,11 @@ fn a_first_round_refused_for_a_missing_model_renders_nothing() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - assert_eq!(trace.text(), "model binding required for section Only"); + assert_eq!( + trace.text(), + ErrorCode::ModelRequired.prose(), + "a missing model keeps its remedy prose" + ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "the refused round never left"); assert_eq!(bodies[0].tool_names(), ["echo"]); @@ -285,8 +292,7 @@ fn a_first_round_whose_tool_build_fails_renders_nothing() { .run(); assert_eq!( trace.text(), - "internal:model-facing schema build failure for tool alias \"shape\": tool \"shape\" \ - parameters schema must be a JSON object" + format!("internal:{}", ErrorCode::ToolSchemaInvalid.prose()) ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "the failed round never left"); diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs index abbab0930..482294b06 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs @@ -54,13 +54,10 @@ fn a_script_tools_call_with_an_unbound_name_lists_the_offered_ids() { ## Only\n\n\ ```lua\ntools.offer('tools/echo')\nreturn tools.call('tools/missing', {})\n```\n"; let trace = scheduler_scenario(md).offer("echo", echo_tool()).run(); - match trace.error() { - Error::UnboundToolCall { name, ids } => { - assert_eq!(name, "tools/missing"); - assert_eq!(ids, &["tools/echo".to_owned()]); - } - other => panic!("expected the typed unbound-tool error, got {other:?}"), - } + assert_eq!( + trace.error(), + &crate::error::unbound_tool("tools/missing", &["tools/echo".to_owned()]) + ); } #[test] @@ -97,13 +94,10 @@ fn a_script_tools_call_of_a_model_only_tool_is_refused_naming_it() { .offer("echo", echo_tool()) .offer("replace", model_only_tool()) .run(); - match trace.error() { - Error::ModelOnlyToolCall { name, id } => { - assert_eq!(name, "tools/replace"); - assert_eq!(id, "tools/replace"); - } - other => panic!("expected the typed model-only error, got {other:?}"), - } + assert_eq!( + trace.error(), + &crate::error::model_only_tool("tools/replace", "tools/replace") + ); } #[test] @@ -140,10 +134,12 @@ fn a_script_tools_call_of_a_model_only_tool_by_its_object_is_refused_the_same_wa let trace = scheduler_scenario(md) .offer("replace", model_only_tool()) .run(); - match trace.error() { - Error::ModelOnlyToolCall { id, .. } => assert_eq!(id, "tools/replace"), - other => panic!("expected the typed model-only error, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::ModelOnlyTool, "got {error:?}"); + assert!( + error.message.contains("(tools/replace) is model-only"), + "the refusal names the catalog id: {error}" + ); } #[test] @@ -247,15 +243,12 @@ fn invalid_json_from_a_structured_tool_is_a_tool_error() { let trace = scheduler_scenario(md) .offer("form", structured_tool("not json", true)) .run(); - match trace.error() { - Error::Tool { message, .. } => { - assert!( - message.contains("returned invalid JSON"), - "the tool error names the invalid JSON, got: {message}" - ); - } - other => panic!("expected the typed tool error, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::ToolOther, "got {error:?}"); + assert!( + error.message.contains("returned invalid JSON"), + "the tool error names the invalid JSON, got: {error}" + ); } #[test] @@ -271,15 +264,12 @@ fn an_untrusted_structured_output_is_wrapped_before_classification() { let trace = scheduler_scenario(md) .offer("form", structured_tool("{\"text\":\"typed\"}", false)) .run(); - match trace.error() { - Error::Tool { message, .. } => { - assert!( - message.contains("returned invalid JSON"), - "the wrap must precede the parse, got: {message}" - ); - } - other => panic!("expected the typed tool error, got {other:?}"), - } + let error = trace.error(); + assert_eq!(error.code, ErrorCode::ToolOther, "got {error:?}"); + assert!( + error.message.contains("returned invalid JSON"), + "the wrap must precede the parse, got: {error}" + ); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs index c66446b2e..8f952f603 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs @@ -28,12 +28,13 @@ fn two_arms_appending_one_path_boom_without_any_other_suspension() { ```\n"; let trace = scheduler_scenario(md).run(); - match trace.error() { - Error::Determinism(detail) => { - assert!(detail.contains("log.txt"), "error was: {detail}"); - } - other => panic!("expected the fatal determinism violation, got {other:?}"), - } + let error = trace.error(); + assert_eq!( + error.code, + ErrorCode::Determinism, + "expected the fatal determinism violation, got {error:?}" + ); + assert!(error.message.contains("log.txt"), "error was: {error}"); // The losing arm's append never reached the backend: exactly one arm's // append landed. let log = trace.read("log.txt").expect("one arm appended"); @@ -110,7 +111,7 @@ fn fatal_arm_aborts_queued_siblings() { let error = trace.error(); assert!( - !matches!(error, Error::Interrupted), + error.code != ErrorCode::Interrupted, "expected a fatal arm error, got {error}" ); let log = trace.read("log.txt").expect("the fatal arm wrote its item"); @@ -161,7 +162,7 @@ fn fatal_arm_aborts_an_in_flight_sibling() { let error = rig.trace().error(); assert!( - !matches!(error, Error::Interrupted), + error.code != ErrorCode::Interrupted, "expected a fatal arm error, got {error}" ); assert!( @@ -374,7 +375,7 @@ fn a_spawn_failure_mid_fanout_cancels_the_queued_arms() { fn an_answer_for_an_unknown_request_id_fails_loudly() { // An answer arriving for an id the run never issued (and that is not an // orphan) means the caller lost track of its effects: the run must fail - // with Error::Internal rather than silently discard the answer. Only an + // with an `Internal` failure rather than silently discard the answer. Only an // orphaned id (a fatal sibling's late I/O answer, covered by // `a_caught_fanout_failure_lets_the_caller_continue`) may be // discarded. @@ -391,7 +392,8 @@ fn an_answer_for_an_unknown_request_id_fails_loudly() { let rig = rig.finish(); assert!( - matches!(rig.trace().error(), Error::Internal { message, .. } if message.contains("did not issue")), + rig.trace().error().code == ErrorCode::Internal + && rig.trace().error().message.contains("did not issue"), "the unknown answer is a loud invariant failure: {:?}", rig.trace().error() ); @@ -418,7 +420,8 @@ fn aborting_an_effect_that_is_not_outstanding_records_no_orphan() { let rig = rig.finish(); assert!( - matches!(rig.trace().error(), Error::Internal { message, .. } if message.contains("did not issue")), + rig.trace().error().code == ErrorCode::Internal + && rig.trace().error().message.contains("did not issue"), "the answer for the never-issued id still fails loudly: {:?}", rig.trace().error() ); @@ -428,7 +431,7 @@ fn aborting_an_effect_that_is_not_outstanding_records_no_orphan() { fn an_answer_for_a_chain_with_no_suspended_block_fails_loudly() { // Every answer goes to a block that yielded for it, so an answer that // finds none is a scheduler bug: the inline answer and a wait's wake - // both raise Error::Internal rather than drop the answer and strand + // both raise an `Internal` failure rather than drop the answer and strand // the chain off every queue. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Title\n\n\ @@ -442,7 +445,7 @@ fn an_answer_for_a_chain_with_no_suspended_block_fails_loudly() { for delivery in deliveries { assert!( - matches!(&delivery, Err(Error::Internal { message, .. }) if message.contains("suspended block")), + matches!(&delivery, Err(error) if error.code == ErrorCode::Internal && error.message.contains("suspended block")), "an answer with no suspended block is a loud invariant failure: {delivery:?}" ); } @@ -464,7 +467,7 @@ fn a_second_delivery_before_resume_is_an_internal_error() { assert!(first.is_ok(), "the first delivery lands: {first:?}"); assert!( - matches!(&second, Err(Error::Internal { .. })), + matches!(&second, Err(error) if error.code == ErrorCode::Internal), "a second delivery before resume is a loud invariant failure: {second:?}" ); assert!(holds_first, "the block still holds the first answer"); diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/arms.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/arms.rs index 336661f02..e51164fed 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/arms.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/arms.rs @@ -8,7 +8,7 @@ use super::*; #[test] fn pre_cancelled_fanout_returns_interrupted() { // A fanout entered under an already-cancelled handle fails the run with - // Error::Interrupted instead of running the arms. + // an interruption instead of running the arms. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ @@ -31,7 +31,7 @@ fn pre_cancelled_fanout_returns_interrupted() { #[test] fn model_required_when_arm_infer_has_no_binding() { // An arm whose explicit infer of its prose has no model binding fails - // the fanout with Error::ModelRequired naming the worker section. The + // the fanout with `ModelRequired` naming the worker section. The // run has no role map and nothing filled in, so the model set stays // empty - the scheduler scenario pre-fills a default binding. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ @@ -46,7 +46,7 @@ fn model_required_when_arm_infer_has_no_binding() { let trace = Scenario::new(md).run(); let error = trace.error(); assert!( - matches!(error, Error::ModelRequired { .. }), + error.code == ErrorCode::ModelRequired, "expected ModelRequired, got {error}" ); assert!( diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs index 48ff05f50..f92abba65 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -89,17 +89,15 @@ fn fanout_depth_cap_reads_the_chain_field() { let trace = scheduler_scenario(md).run(); let error = trace.error(); - assert!( - matches!( - error, - Error::CallDepthExceeded { - cap: 8, - via: DepthVia::Fanout - } - ), - "expected the typed depth-cap error named after fanout, got {error:?}" + assert_eq!( + error, + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( + 8, + DepthVia::Fanout + )), + "expected the typed depth-cap error named after fanout" ); - assert_eq!(error.kind(), RunErrorKind::Quota, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); assert_eq!( error.to_string(), "tasks.fanout recursion exceeded cap of 8", @@ -218,21 +216,23 @@ fn two_arms_writing_one_path_terminate_the_run_with_a_determinism_violation() { ```\n"; let trace = scheduler_scenario(md).run(); - match trace.error() { - Error::Determinism(detail) => { - assert!(detail.contains("shared.txt"), "error was: {detail}"); - assert!( - detail.contains("conflicts with"), - "the conflict is named: {detail}" - ); - assert_eq!( - detail.matches("ExecId(").count(), - 2, - "both arms' identities are named: {detail}" - ); - } - other => panic!("expected the fatal determinism violation, got {other:?}"), - } + let error = trace.error(); + assert_eq!( + error.code, + ErrorCode::Determinism, + "expected the fatal determinism violation, got {error:?}" + ); + let detail = &error.message; + assert!(detail.contains("shared.txt"), "error was: {detail}"); + assert!( + detail.contains("conflicts with"), + "the conflict is named: {detail}" + ); + assert_eq!( + detail.matches("ExecId(").count(), + 2, + "both arms' identities are named: {detail}" + ); let recorder = recorded(&trace); assert_eq!( terminal_count(&trace, task_started), @@ -275,15 +275,17 @@ fn two_live_arms_appending_one_path_terminate_with_a_determinism_violation() { ```\n"; let trace = scheduler_scenario(md).run(); - match trace.error() { - Error::Determinism(detail) => { - assert!(detail.contains("evidence.md"), "error was: {detail}"); - assert_eq!( - detail.matches("ExecId(").count(), - 2, - "both arms' identities are named: {detail}" - ); - } - other => panic!("expected the fatal determinism violation, got {other:?}"), - } + let error = trace.error(); + assert_eq!( + error.code, + ErrorCode::Determinism, + "expected the fatal determinism violation, got {error:?}" + ); + let detail = &error.message; + assert!(detail.contains("evidence.md"), "error was: {detail}"); + assert_eq!( + detail.matches("ExecId(").count(), + 2, + "both arms' identities are named: {detail}" + ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs index bf547cf07..5b96a740e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs @@ -98,14 +98,16 @@ fn a_failed_h1_assertion_ends_the_run_as_requirements_unmet() { let trace = h1_scenario(md).run(); let error = trace.error(); - let notice = match error { - Error::RequirementsUnmet { notice, .. } => notice, - other => panic!("the failed assertion classifies as RequirementsUnmet: {other:?}"), - }; + assert_eq!( + error.code, + ErrorCode::RequirementsUnmet, + "the failed assertion classifies as RequirementsUnmet: {error:?}" + ); + let notice = &error.prose; let cause = error - .originating_error() - .expect("the remap keeps the chunk's own failure as the cause") - .to_string(); + .message + .strip_prefix(&format!("{notice}: ")) + .expect("the remap names the chunk's own failure after the notice"); assert!( cause.contains("the gate cannot hold") && !notice.contains("the gate cannot hold"), "the assertion's message is the cause, not the notice: {notice:?} / {cause:?}" @@ -134,8 +136,9 @@ fn an_uncaught_h1_assertion_reports_the_chunk_failed() { let trace = h1_scenario(md).run(); let error = trace.error(); - assert!( - matches!(error, Error::RequirementsUnmet { .. }), + assert_eq!( + error.code, + ErrorCode::RequirementsUnmet, "the failed gate classifies as RequirementsUnmet: {error}" ); let observed = sequence(&trace); @@ -160,8 +163,9 @@ fn an_h1_scalar_return_still_reads_var_back() { let trace = h1_scenario(md).run(); let error = trace.error(); - assert!( - matches!(error, Error::Lua { cause: None, .. }), + assert_eq!( + error.code, + ErrorCode::LuaRuntime, "the read-back failure is machinery around the prompt's chunk, \ not the failed gate, so it keeps the Lua kind: {error}" ); @@ -189,13 +193,14 @@ fn a_failing_library_required_from_h1_ends_the_run_as_requirements_unmet() { let trace = h1_scenario(md).run(); let error = trace.error(); - assert!( - matches!(error, Error::RequirementsUnmet { .. }), + assert_eq!( + error.code, + ErrorCode::RequirementsUnmet, "the library's raise is the failed H1 gate: {error}" ); let cause = error - .originating_error() - .map(ToString::to_string) + .message + .strip_prefix(&format!("{}: ", error.prose)) .unwrap_or_default(); assert!( cause.contains("library boom"), diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/returns_and_prose.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/returns_and_prose.rs index 0d9209d3f..2ddcf368e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/returns_and_prose.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/returns_and_prose.rs @@ -122,7 +122,7 @@ fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { let trace = Scenario::new(reading).run(); let error = trace.error(); assert!( - matches!(error, Error::ModelRequired { .. }), + error.code == ErrorCode::ModelRequired, "expected ModelRequired, got {error}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs index 4c0c94e29..a22e97c80 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs @@ -251,13 +251,11 @@ fn a_jump_descent_does_not_consume_call_depth() { let error = rig.trace().error(); assert!( - matches!( - error, - Error::CallDepthExceeded { - cap: 8, - via: DepthVia::Call - } - ), + error + == &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( + 8, + DepthVia::Call + )), "the depth cap must fail the run, got {error:?}" ); assert_eq!( diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs index 5b79ad0bd..419e50c26 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs @@ -88,17 +88,15 @@ fn call_depth_cap_reads_the_chain_field() { let trace = scheduler_scenario(md).run(); let error = trace.error(); - assert!( - matches!( - error, - Error::CallDepthExceeded { - cap: 8, - via: DepthVia::Call - } - ), - "expected the typed call-depth error, got {error:?}" + assert_eq!( + error, + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( + 8, + DepthVia::Call + )), + "expected the typed call-depth error" ); - assert_eq!(error.kind(), RunErrorKind::Quota, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); assert_eq!(error.to_string(), "call recursion exceeded cap of 8"); } diff --git a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs index 4abc3137f..c75d946fa 100644 --- a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs +++ b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs @@ -348,7 +348,7 @@ fn the_loop_entry_takes_its_two_shapes_and_checks_the_compactor_before_a_running compactor, LOOP_RUNNING, ] - .map(|message| format!("false:lua:{message}")); + .map(|message| format!("false:lua:{}", refused(message))); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[..7], expected); assert_eq!( diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs index c1f3df91d..7c19edf4d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs @@ -4,6 +4,7 @@ use super::*; use crate::RunErrorKind; +use crate::execute::run_error_kind; /// A jump transfers control and the jumper's remaining blocks never run. #[test] @@ -355,7 +356,7 @@ fn call_recursion_is_capped() { let trace = Scenario::new(source).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Quota, "the cap is a quota: {error}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs index f6dfb4149..965c82bab 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs @@ -4,6 +4,7 @@ use super::*; use crate::RunErrorKind; +use crate::execute::run_error_kind; use crate::execute::tests::context::arm_fanout; /// `prompt.list` inside a fanout arm reads a list section's items: the arm @@ -197,7 +198,7 @@ fn jump_inside_a_fanout_arm_to_a_non_visible_heading_errors() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Lua, "the jump raises in Lua: {error}" ); @@ -258,7 +259,7 @@ fn fanout_recursion_across_the_boundary_trips_the_depth_cap() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Quota, "the cap is a quota: {error}" ); @@ -297,7 +298,7 @@ fn call_inside_an_arm_spawned_near_the_cap_trips_the_depth_cap() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Quota, "the cap is a quota: {error}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs index adea15491..e1e640fb7 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs @@ -4,6 +4,7 @@ use super::*; use crate::RunErrorKind; +use crate::execute::run_error_kind; /// The `tasks` global is the task namespace (`tasks.spawn` and the /// waits), not a control-flow table: indexing it by a heading string reads @@ -149,7 +150,7 @@ fn var_write_of_a_function_fails_the_run() { let trace = Scenario::new(one_section("var.f = function() end")).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Lua, "the guard raises in Lua: {error}" ); @@ -202,7 +203,7 @@ fn missing_bare_global_in_prose_errors() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Lua, "the Lua read of `prose` raises the substitution failure: {error}" ); @@ -234,13 +235,13 @@ fn bare_global_holding_json_null_in_prose_is_the_null_value_failure() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Lua, "the Lua read of `prose` raises the substitution failure: {error}" ); let rendered = error.to_string(); assert!( - rendered.contains("missing {{ nothing }}") && rendered.contains("NullValue"), + rendered.contains("missing {{ nothing }}") && rendered.contains("[NullValue at byte "), "the error is the null-value failure naming the global: {rendered}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs index 999e9f108..b482e7157 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs @@ -7,6 +7,7 @@ use super::*; use crate::RunErrorKind; use crate::RunOutcome; +use crate::execute::run_error_kind; use crate::execute::tests::context::test_config; use crate::execute::tests::fixtures::echo_tool; use crate::test_support::scenario::EXECUTION; @@ -23,7 +24,7 @@ fn a_handle_without_a_declared_store_fails_the_run() { let trace = Scenario::new(source).vfs(vfs).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Vfs, "the run fails as a store error: {error}" ); @@ -54,7 +55,7 @@ impl runvil_vfs::Vfs for RefusingStore { #[test] fn a_store_backend_that_refuses_its_session_fails_the_run_at_the_first_step() { let source = one_section("return 'never runs'"); - let (prompt, _) = crate::prompt::Prompt::parse(&source, EXECUTION); + let (prompt, _) = crate::prompt::Prompt::parse(&source, "prompt", EXECUTION); let vfs = VfsRef::builder().store("/", RefusingStore).build(); let mut run = crate::execute::run::Run::new( Arc::new(prompt.expect("the fixture parses")), @@ -110,7 +111,7 @@ fn offering_a_tool_its_present_pack_does_not_offer_fails_the_section() { ); let trace = Scenario::new(source).tool(echo_tool()).run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::Lua, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Lua, "{error}"); assert!( error .to_string() diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs index bfec06beb..c48d9dc69 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs @@ -3,27 +3,29 @@ //! from library code. use runvil_lua::VfsOp; +use runvil_types::error::ErrorCode; use super::*; -use crate::Error; use crate::RunErrorKind; use crate::execute::EffectRecord; +use crate::execute::run_error_kind; /// A store failure the author caught with `pcall` is an error table of -/// kind `store`: its `reason`, `path`, and `anchor` fields and its -/// integer `count` read from Lua, and `tostring` returns the -/// model-facing message. +/// kind `store`: its `code`, `reason`, `path`, and `anchor` fields and its +/// integer `count` read from Lua, and `tostring` returns the store code's +/// remedy prose. #[test] -fn a_caught_store_error_is_a_store_table_with_reason_fields_and_message() { - let source = one_section( +fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { + let source = one_section(&format!( " local ok, err = pcall(function() return store.read('missing.txt') end) assert(ok == false, 'the missing read fails') assert(err.kind == 'store', 'kind is store, got ' .. tostring(err.kind)) + assert(err.code == 'not_found', 'code is not_found, got ' .. tostring(err.code)) assert(err.reason == 'not_found', 'reason is not_found, got ' .. tostring(err.reason)) assert(err.path == 'missing.txt', 'the path field names the file') - assert(tostring(err) == 'file not found in store: missing.txt', 'tostring is the message') + assert(tostring(err) == '{}', 'tostring is the prose, got ' .. tostring(err)) store.write('a.txt', 'na na na') local ok2, err2 = pcall(function() store.str_replace('a.txt', 'na', 'la') end) assert(ok2 == false, 'the ambiguous anchor fails') @@ -31,9 +33,11 @@ fn a_caught_store_error_is_a_store_table_with_reason_fields_and_message() { assert(err2.anchor == 'na', 'the anchor field names the anchor') assert(type(err2.count) == 'number' and err2.count % 1 == 0, 'count is an integer') assert(err2.count == 3, 'count is the match count') - assert(tostring(err2):find('include more surrounding text', 1, true) ~= nil, 'the message carries the hint') + assert(tostring(err2) == '{}', 'tostring is the prose, got ' .. tostring(err2)) return 'ok'", - ); + ErrorCode::NotFound.prose(), + ErrorCode::Anchor.prose() + )); assert_eq!(Scenario::new(source).run().text(), "ok"); } @@ -59,7 +63,7 @@ fn an_uncaught_store_failure_ends_the_run_as_a_store_error() { let trace = Scenario::new(one_section("return store.read('missing.txt')")).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Vfs, "the run fails as a store error: {error:?}" ); @@ -82,7 +86,7 @@ fn an_uncaught_store_failure_in_h1_ends_the_run_as_a_store_error() { let trace = Scenario::new(source.build()).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Vfs, "the H1 failure is a store error: {error:?}" ); @@ -110,7 +114,7 @@ fn a_caught_store_error_raised_again_after_a_suspending_call_keeps_store() { let trace = Scenario::new(source).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Vfs, "the re-raised error keeps its store classification: {error:?}" ); @@ -184,10 +188,10 @@ fn a_library_write_across_two_arms_ends_the_run_with_determinism() { let trace = Scenario::new(source).run(); let error = trace.error(); assert!( - matches!(error, Error::Determinism(_)), + error.code == ErrorCode::Determinism, "the load-time conflict is a determinism violation: {error:?}" ); - assert_eq!(error.kind(), RunErrorKind::Vfs, "{error:?}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Vfs, "{error:?}"); } /// A conflict a library caught with its own `pcall` while loading still @@ -200,10 +204,10 @@ fn a_library_conflict_caught_with_pcall_still_ends_the_run_with_determinism() { let trace = Scenario::new(source).run(); let error = trace.error(); assert!( - matches!(error, Error::Determinism(_)), + error.code == ErrorCode::Determinism, "a caught load-time conflict is still fatal: {error:?}" ); - assert_eq!(error.kind(), RunErrorKind::Vfs, "{error:?}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Vfs, "{error:?}"); } /// The library the captured-store cases require: `store.write` captured @@ -242,10 +246,10 @@ fn a_captured_store_function_conflict_caught_with_pcall_still_ends_the_run_with_ let trace = Scenario::new(source).run(); let error = trace.error(); assert!( - matches!(error, Error::Determinism(_)), + error.code == ErrorCode::Determinism, "a conflict through a captured store function is fatal: {error:?}" ); - assert_eq!(error.kind(), RunErrorKind::Vfs, "{error:?}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Vfs, "{error:?}"); } /// A store function a library captured at load time reaches the caller diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/execution.rs b/crates/runvil-internal/engine/src/execute/tests/suite/execution.rs index 74b9af264..23f244da1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/execution.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/execution.rs @@ -5,6 +5,7 @@ use std::collections::BTreeSet; use crate::RunErrorKind; +use crate::execute::run_error_kind; use crate::test_support::recording::detail; use crate::test_support::scenario::{Record, Scenario}; @@ -190,7 +191,7 @@ fn execution_error_fixtures_report_their_typed_contract() { let trace = Scenario::new(fixture.source).name(fixture.execution).run(); let error = trace.error(); assert_eq!( - error.kind(), + run_error_kind(error.code), fixture.kind, "fixture {} returned the wrong error kind: {error:?}", fixture.name diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/fanout.rs b/crates/runvil-internal/engine/src/execute/tests/suite/fanout.rs index 0ba487580..9a3427262 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/fanout.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/fanout.rs @@ -2,9 +2,10 @@ //! arms, the propagated arm-failure error contract, and encoding a result. use runvil_prompt::test_support::prompt; +use runvil_types::error::ErrorCode; -use crate::Error; use crate::RunErrorKind; +use crate::execute::run_error_kind; use crate::test_support::scenario::{Record, Scenario}; const FANOUT_BASIC_EXECUTION: &str = "fixture-fanout-basic"; @@ -122,11 +123,11 @@ fn a_cross_arm_append_terminates_the_run_with_a_determinism_violation() { .run(); let error = trace.error(); assert!( - matches!(error, Error::Determinism(_)), + error.code == ErrorCode::Determinism, "a conflicting access is a determinism violation: {error:?}" ); assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Vfs, "a determinism violation classifies as a store failure: {error:?}" ); @@ -161,7 +162,7 @@ fn fanout_arm_failure_propagates() { let error = trace.error(); // Assert the stable classification first, then the preserved source context. assert_eq!( - error.kind(), + run_error_kind(error.code), RunErrorKind::Lua, "a raised arm error must classify as a Lua failure: {error:?}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/parsing.rs b/crates/runvil-internal/engine/src/execute/tests/suite/parsing.rs index d57a2bfa4..11c9a981d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/parsing.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/parsing.rs @@ -39,7 +39,7 @@ const VALID_FIXTURES: &[ValidFixture] = &[ #[test] fn valid_prompt_files_parse_through_the_public_api() { for fixture in VALID_FIXTURES { - let prompt = Prompt::parse(fixture.source, fixture.name) + let prompt = Prompt::parse(fixture.source, fixture.name, fixture.name) .0 .unwrap_or_else(|error| panic!("fixture {} failed to parse: {error}", fixture.name)); // Call the verifier directly so its own assertion and source line remain @@ -168,7 +168,7 @@ fn every_prompt_under_parses(directory: &Path) { for path in files { let source = fs::read_to_string(&path).expect("read prompt fixture"); - Prompt::parse(&source, POSITIVE_PARSE) + Prompt::parse(&source, &path.display().to_string(), POSITIVE_PARSE) .0 .unwrap_or_else(|error| panic!("{} must parse: {error}", path.display())); } diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs b/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs index 291f28c05..128e14e9e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs @@ -8,9 +8,9 @@ use runvil_types::event::Event; use runvil_types::models::ThinkingMode; use super::{current_model, refusal, under_model}; -use crate::Error; use crate::RunErrorKind; use crate::execute::RunOutcome; +use crate::execute::run_error_kind; use crate::test_support::scenario::Scenario; use crate::test_support::scripted_chat::{ScriptedChat, text}; @@ -150,11 +150,12 @@ fn an_unsupported_version_is_reported_before_an_unmet_requirement() { let model = current_model(32_000, ThinkingMode::Switchable); let trace = under_model(&source, "version-first", model).run(); let error = trace.error(); - assert!( - matches!(error, Error::UnsupportedVersion(7)), - "the version gate refuses the run first: {error}" + assert_eq!( + error, + &crate::error::unsupported_version(7), + "the version gate refuses the run first" ); - assert_eq!(error.kind(), RunErrorKind::Parse, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Parse, "{error}"); assert!( trace.run_events().is_empty(), "nothing ran, nothing reported" diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/prepare.rs b/crates/runvil-internal/engine/src/execute/tests/suite/prepare.rs index 33e19e531..8bbb391a7 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/prepare.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/prepare.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; use crate::RunErrorKind; use crate::execute::context::ChainState; +use crate::execute::run_error_kind; use crate::execute::tests::context::test_config; use crate::execute::{Effect, EffectAnswer, RunConfig}; use crate::model::ModelBinding; @@ -111,7 +112,11 @@ fn state_of(prompt: &Prompt, config: RunConfig) -> ChainState { fn refusal(scenario: Scenario) -> String { let trace = scenario.run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet, "{error}"); + assert_eq!( + run_error_kind(error.code), + RunErrorKind::RequirementsUnmet, + "{error}" + ); assert!( trace.run_events().is_empty(), "a refused run reports no run events: {:?}", @@ -282,7 +287,11 @@ fn an_empty_map_leaves_the_model_set_empty_and_a_model_call_raises_model_require let asking = declaring("", "return models.infer('ping')"); let trace = Scenario::new(asking).run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet, "{error}"); + assert_eq!( + run_error_kind(error.code), + RunErrorKind::RequirementsUnmet, + "{error}" + ); assert!( error.to_string().contains("model binding required"), "{error}" diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/require/failures.rs b/crates/runvil-internal/engine/src/execute/tests/suite/require/failures.rs index 2885ef258..7d0089e83 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/require/failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/require/failures.rs @@ -7,6 +7,7 @@ use runvil_types::packs::Prelude; use crate::RunErrorKind; +use crate::execute::run_error_kind; use crate::execute::tests::preludes::prelude; use crate::test_support::scenario::Trace; @@ -24,7 +25,7 @@ fn run_with_preludes(source: &str, preludes: Vec) -> Trace { #[track_caller] fn lua_failure(trace: &Trace) -> String { let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::Lua, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Lua, "{error}"); error.to_string() } diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/require/walk.rs b/crates/runvil-internal/engine/src/execute/tests/suite/require/walk.rs index 754fafae6..6bad8cfa1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/require/walk.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/require/walk.rs @@ -3,6 +3,7 @@ //! keep a returning library away from the walk. use crate::RunErrorKind; +use crate::execute::run_error_kind; use super::*; @@ -43,7 +44,7 @@ fn the_walk_fails_the_run_on_a_library_that_returns_a_table() { ); let trace = require_case(source).run(); let error = trace.error(); - assert_eq!(error.kind(), RunErrorKind::Lua, "{error}"); + assert_eq!(run_error_kind(error.code), RunErrorKind::Lua, "{error}"); assert!( error .to_string() diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs index 38ee76246..7bca0e390 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs @@ -50,16 +50,11 @@ fn a_chain_ending_with_live_author_tasks_fails_as_tasks_live_naming_the_ids() { let mut rig = Rig::run(scheduler_scenario(md)); let error = rig.trace().error(); - match error { - Error::TasksLive { tasks } => { - assert_eq!( - tasks, - &[task("0.0"), task("0.1")], - "leaked ids in spawn order" - ); - } - other => panic!("expected tasks_live, got {other:?}"), - } + assert_eq!( + error, + &crate::error::tasks_live(&[task("0.0"), task("0.1")]), + "leaked ids in spawn order" + ); let text = error.to_string(); assert!( text.contains("0.0, 0.1"), @@ -95,7 +90,7 @@ fn a_chain_failing_with_a_live_task_keeps_its_own_error_and_abandons_the_task() let error = rig.trace().error(); assert!( - !matches!(error, Error::TasksLive { .. }), + error.code != ErrorCode::TasksLive, "a failing owner keeps its own error over the leak: {error:?}" ); assert!( @@ -160,10 +155,10 @@ fn a_returning_owner_abandons_its_running_task_as_owner_returned() { CHILD_PARKED_ON_HELD_ROUND, ); let rig = run_abandoning_a_running_task(scheduler_scenario(md), AbandonReason::OwnerReturned); - match rig.trace().error() { - Error::TasksLive { tasks } => assert_eq!(tasks, &[task("0.0")]), - other => panic!("expected tasks_live, got {other:?}"), - } + assert_eq!( + rig.trace().error(), + &crate::error::tasks_live(&[task("0.0")]) + ); } #[test] @@ -203,7 +198,7 @@ fn an_exhausted_tool_loop_abandons_its_running_task_as_tool_loop_exhausted() { let rig = run_abandoning_a_running_task(scenario, AbandonReason::ToolLoopExhausted); let error = rig.trace().error(); assert!( - matches!(error, Error::ToolLoopExhausted), + error == &crate::error::tool_loop_exhausted(), "the owner's own error stands: {error:?}" ); } @@ -238,14 +233,11 @@ fn moving_spawner_prompt(movement: &str) -> String { fn assert_task_outlives_movement(md: String) { let mut rig = Rig::run(scheduler_scenario(md)); - match rig.trace().error() { - Error::TasksLive { tasks } => assert_eq!( - tasks, - &[task("0.0")], - "the walk still owns the task when the sibling returns" - ), - other => panic!("expected tasks_live, got {other:?}"), - } + assert_eq!( + rig.trace().error(), + &crate::error::tasks_live(&[task("0.0")]), + "the walk still owns the task when the sibling returns" + ); assert_eq!( rig.task_state_for_test(&task("0.0")), Some(TaskState::Abandoned), @@ -341,14 +333,11 @@ fn a_task_spawned_in_h1_belongs_to_the_main_walk() { ```\n"; let mut rig = Rig::run(scheduler_scenario(md)); - match rig.trace().error() { - Error::TasksLive { tasks } => assert_eq!( - tasks, - &[task("0.0")], - "the walk owns H1's task and leaks it" - ), - other => panic!("expected tasks_live, got {other:?}"), - } + assert_eq!( + rig.trace().error(), + &crate::error::tasks_live(&[task("0.0")]), + "the walk owns H1's task and leaks it" + ); assert_eq!( rig.task_state_for_test(&task("0.0")), Some(TaskState::Abandoned) diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks.rs b/crates/runvil-internal/engine/src/execute/tests/tasks.rs index d5130bb92..dffa86b47 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks.rs @@ -284,16 +284,18 @@ fn spawn_shares_calls_depth_cap() { let outcome = rig.trace().outcome(); assert!( matches!(outcome, RunOutcome::Completed { .. }) - || matches!(rig.trace().error(), Error::TasksLive { .. }), + || rig.trace().error().code == ErrorCode::TasksLive, "unexpected outcome: {outcome:?}" ); let records = TaskRecorder::replayed(rig.trace()).records(); + let logged = authored( + ErrorCode::CallDepthExceeded, + "call recursion exceeded cap of 8", + ); let refusals = records .iter() - .filter(|(_, event)| { - *event == Observation::Lua("call recursion exceeded cap of 8".to_owned()) - }) + .filter(|(_, event)| *event == Observation::Lua(logged.clone())) .count(); assert_eq!(refusals, 1, "exactly one spawn is refused: {records:?}"); let starts = records @@ -319,7 +321,7 @@ fn spawn_rejects_a_list_section_target_with_the_worker_message() { let rig = Rig::run(scheduler_scenario(md)); assert_eq!( rig.trace().text(), - "section `Items` is a list section, not a worker template", + refused("section `Items` is a list section, not a worker template"), "the caught error ends the run normally" ); } 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-scope-gate.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs index 1f185fa2b..2e2c683ea 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs @@ -41,20 +41,12 @@ fn model_calling_an_offered_but_unscoped_tool_is_a_hard_error() { )])]); let trace = scoped_loop("").chat(&gateway).run(); let error = trace.error(); - match error { - Error::OutOfScopeToolCall { - name, - global_exists, - has_dynamic_set, - static_aliases, - } => { - assert_eq!(name, "global_tool"); - assert!(*global_exists, "the name is an offered tool's wire name"); - assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); - assert_eq!(static_aliases, &["scoped".to_owned()]); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // The name is an offered tool's wire name, and the section offers no + // Dynamic Set. + assert_eq!( + error, + &crate::error::out_of_scope_tool("global_tool", true, &["scoped".to_owned()], false) + ); let message = error.to_string(); assert!( message.ends_with( @@ -79,20 +71,12 @@ fn model_calling_an_advertised_tool_by_canonical_id_gets_no_not_offered_suffix() )])]); let trace = scoped_loop("").chat(&gateway).run(); let error = trace.error(); - match error { - Error::OutOfScopeToolCall { - name, - global_exists, - has_dynamic_set, - static_aliases, - } => { - assert_eq!(name, "tools/scoped"); - assert!(!*global_exists, "an id is no offered tool's wire name"); - assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); - assert_eq!(static_aliases, &["scoped".to_owned()]); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // An id is no offered tool's wire name, and the section offers no + // Dynamic Set. + assert_eq!( + error, + &crate::error::out_of_scope_tool("tools/scoped", false, &["scoped".to_owned()], false) + ); let message = error.to_string(); assert!( !message.contains("not offered in this section"), @@ -115,23 +99,12 @@ fn model_calling_a_defined_but_unoffered_adhoc_tool_gets_no_not_offered_suffix() .chat(&gateway) .run(); let error = trace.error(); - match error { - Error::OutOfScopeToolCall { - name, - global_exists, - has_dynamic_set, - static_aliases, - } => { - assert_eq!(name, "grab"); - assert!( - !*global_exists, - "an adhoc tool's name is no catalog tool's wire name" - ); - assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); - assert_eq!(static_aliases, &["scoped".to_owned()]); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // An adhoc tool's name is no catalog tool's wire name, and the section + // offers no Dynamic Set. + assert_eq!( + error, + &crate::error::out_of_scope_tool("grab", false, &["scoped".to_owned()], false) + ); assert!( !error.to_string().contains("not offered in this section"), "an unoffered adhoc tool must not hint a catalog tool: {error}" @@ -194,23 +167,12 @@ fn with_dynamic_offers_a_name_outside_both_sets_fails_listing_only_the_static_se // discovery tools because this section has them. let gateway = ScriptedChat::new(vec![calls(&[("call_1", "rogue", "{}")])]); let trace = scoped_loop(DYNAMIC_OFFERS).chat(&gateway).run(); - match trace.error() { - Error::OutOfScopeToolCall { - name, - global_exists, - has_dynamic_set, - static_aliases, - } => { - assert_eq!(name, "rogue"); - assert!(!*global_exists, "the name is no offered tool"); - assert!(*has_dynamic_set, "the section offers a Dynamic Set"); - assert_eq!( - static_aliases, - &["scoped", "GetDynamicTools", "CallDynamicTool"] - ); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // The name is no offered tool, and the section offers a Dynamic Set. + let aliases = ["scoped", "GetDynamicTools", "CallDynamicTool"].map(str::to_owned); + assert_eq!( + trace.error(), + &crate::error::out_of_scope_tool("rogue", false, &aliases, true) + ); let message = trace.error().to_string(); assert!( message.ends_with( @@ -255,21 +217,17 @@ fn a_static_tool_wearing_a_discovery_wire_name_does_not_claim_a_dynamic_set() { .chat(&gateway) .run(); let error = trace.error(); - match error { - Error::OutOfScopeToolCall { - has_dynamic_set, - static_aliases, - .. - } => { - assert!(!*has_dynamic_set, "the section holds no Dynamic Set tool"); - assert_eq!( - static_aliases, - &["GetDynamicTools".to_owned(), "CallDynamicTool".to_owned()], - "the discovery wire names are ordinary static aliases here" - ); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // The section holds no Dynamic Set tool, and the discovery wire names + // are ordinary static aliases here. + assert_eq!( + error, + &crate::error::out_of_scope_tool( + "rogue", + false, + &["GetDynamicTools".to_owned(), "CallDynamicTool".to_owned()], + false + ) + ); let message = error.to_string(); assert!( !message.contains("a Dynamic Set tool is reached"), @@ -291,20 +249,11 @@ fn model_calling_pure_unknown_tool_is_a_hard_error() { .chat(&gateway) .run(); let error = trace.error(); - match error { - Error::OutOfScopeToolCall { - name, - global_exists, - has_dynamic_set, - static_aliases, - } => { - assert_eq!(name, "nonexistent"); - assert!(!*global_exists, "the name is no offered tool"); - assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); - assert_eq!(static_aliases, &["echo".to_owned()]); - } - other => panic!("expected OutOfScopeToolCall, got {other:?}"), - } + // The name is no offered tool, and the section offers no Dynamic Set. + assert_eq!( + error, + &crate::error::out_of_scope_tool("nonexistent", false, &["echo".to_owned()], false) + ); assert!( !error.to_string().contains("not offered in this section"), "pure unknown must not hint offered-but-unscoped: {error}" 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..c2cd38198 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 @@ -47,7 +47,7 @@ fn tool_loop_gives_up_after_exactly_the_configured_cap() { .chat(&gateway) .run(); let error = trace.error(); - assert!(matches!(error, Error::ToolLoopExhausted), "got {error:?}"); + assert_eq!(error, &crate::error::tool_loop_exhausted()); assert_eq!( gateway.call_count(), cap, @@ -58,7 +58,7 @@ fn tool_loop_gives_up_after_exactly_the_configured_cap() { #[test] fn tool_loop_exhaustion_is_readable_at_the_call_site_after_whole_exchanges() { // The raise is pcall-able as the `tool_loop_exhausted` kind with the - // typed error's exact message, and every round before it appended a + // typed error's remedy prose, and every round before it appended a // complete exchange: the list holds no half-answered batch at the cap. let gateway = ScriptedChat::new(never_converging_script(2)); let source = capped( @@ -78,7 +78,10 @@ fn tool_loop_exhaustion_is_readable_at_the_call_site_after_whole_exchanges() { .run(); assert_eq!( trace.text(), - "tool_loop_exhausted|tool-call loop did not converge" + format!( + "tool_loop_exhausted|{}", + ErrorCode::ToolLoopExhausted.prose() + ) ); assert_eq!(gateway.call_count(), 2); } @@ -95,7 +98,7 @@ fn tool_loop_uses_the_default_cap_when_unspecified() { .chat(&gateway) .run(); let error = trace.error(); - assert!(matches!(error, Error::ToolLoopExhausted), "got {error:?}"); + assert_eq!(error, &crate::error::tool_loop_exhausted()); assert_eq!(gateway.call_count(), default_cap); } @@ -106,7 +109,7 @@ fn run_resolves_cap_from_frontmatter_else_default() { let default_cap = RunLimits::default().max_tool_iterations.get() as usize; let declared = "---\nname: t\ndescription: d\nmax_tool_iterations: 5\n---\n\n# T\n\n## S\n\np\n"; - let p = Prompt::parse(declared, EXECUTION).0.unwrap(); + let p = Prompt::parse(declared, "prompt", EXECUTION).0.unwrap(); assert_eq!( p.frontmatter() .max_tool_iterations() @@ -115,7 +118,7 @@ fn run_resolves_cap_from_frontmatter_else_default() { ); let absent = "---\nname: t\ndescription: d\n---\n\n# T\n\n## S\n\np\n"; - let p = Prompt::parse(absent, EXECUTION).0.unwrap(); + let p = Prompt::parse(absent, "prompt", EXECUTION).0.unwrap(); assert_eq!( p.frontmatter() .max_tool_iterations() @@ -153,8 +156,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\"}")]), @@ -224,7 +227,7 @@ fn repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap() { .chat(&gateway) .run(); let error = trace.error(); - assert!(matches!(error, Error::ToolLoopExhausted), "got {error:?}"); + assert_eq!(error, &crate::error::tool_loop_exhausted()); assert_eq!( gateway.call_count(), cap, @@ -235,7 +238,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 +250,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 +270,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) @@ -297,7 +296,7 @@ fn a_caught_missing_model_reads_as_the_model_required_kind() { let trace = loop_scenario(source).models(ModelSet::default()).run(); assert_eq!( trace.text(), - "model_required:model binding required for section Only" + format!("model_required:{}", ErrorCode::ModelRequired.prose()) ); } @@ -307,7 +306,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 +315,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/execute/tests/tool_scoping.rs b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs index ebe91daef..104cac8d0 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs @@ -125,6 +125,7 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { // snapshot over it includes the offered tool's wire name. let offer = LuaProgram::compile( "tools.offer('tools/section')", + "prompt", "prologue", NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), diff --git a/crates/runvil-internal/engine/src/execute/walk_target-tests.rs b/crates/runvil-internal/engine/src/execute/walk_target-tests.rs index cadf138b5..4024ec1a3 100644 --- a/crates/runvil-internal/engine/src/execute/walk_target-tests.rs +++ b/crates/runvil-internal/engine/src/execute/walk_target-tests.rs @@ -29,7 +29,7 @@ const LIBRARY_TREE: &str = concat!( ); fn library_tree() -> Prompt { - Prompt::parse(LIBRARY_TREE, "library-test") + Prompt::parse(LIBRARY_TREE, "prompt", "library-test") .0 .expect("the tree parses") } @@ -151,17 +151,20 @@ fn a_keyed_lookup_resolves_from_the_librarys_reach() { } #[test] -fn a_key_that_names_no_position_is_an_internal_error_that_keeps_its_cause() { +fn a_key_that_names_no_position_is_an_internal_error_that_names_its_cause() { + let invariant = "internal invariant violated: a library key names no section position: "; for identity in ["", "x", "1.", "1..2"] { let error = RequireOrigin::from_key(&LibraryKey::new(identity.to_owned())) .expect_err("a foreign key must not parse"); - assert!( - matches!(error, Error::Internal { .. }), + assert_eq!( + error.code, + runvil_types::error::ErrorCode::Internal, "{identity:?} gave {error}" ); assert!( - error.originating_error().is_some(), - "required the index parse failure as the cause, actual none for {identity:?}" + error.message.len() > invariant.len() && error.message.starts_with(invariant), + "required the index parse failure after the invariant, actual {:?} for {identity:?}", + error.message ); } } diff --git a/crates/runvil-internal/engine/src/execute/walk_target.rs b/crates/runvil-internal/engine/src/execute/walk_target.rs index bf74a562f..6148eee67 100644 --- a/crates/runvil-internal/engine/src/execute/walk_target.rs +++ b/crates/runvil-internal/engine/src/execute/walk_target.rs @@ -17,7 +17,7 @@ use crate::heading_address::{self, Reachable, SectionIndex, SectionPos, SlicePath, Visible}; use crate::lua::{Library, LibraryBlock, LibraryKey, SectionText}; use crate::prompt::{Block, Prompt, Section, detail}; -use crate::{Error, Result}; +use crate::{Result, error}; /// The position of `target` in `slice`, matched on the parser-unique /// `(level, name)` pair; `None` when the slice does not contain it. @@ -40,7 +40,7 @@ pub(super) fn h1_visible_sections(prompt: &Prompt) -> &[Section] { /// heading. /// /// # Errors -/// Returns [`Error::Lua`] when the heading is malformed or matches more +/// Returns a `LuaRuntime` failure when the heading is malformed or matches more /// than one visible section (see [`heading_address::find_visible`]). pub(super) fn read_visible(heading: &str, visible: Visible<'_>) -> Result> { Ok( @@ -79,7 +79,7 @@ pub(super) struct JumpTarget { /// first. /// /// # Errors -/// Returns [`Error::Lua`] when the heading is malformed, matches no visible +/// Returns a `LuaRuntime` failure when the heading is malformed, matches no visible /// section, or matches more than one. pub(super) fn resolve_jump_target( heading: &str, @@ -87,7 +87,7 @@ pub(super) fn resolve_jump_target( jumper: &SectionPos, ) -> Result { let siblings = jumper.siblings(prompt); - let jumper_section = jumper.section(prompt).ok_or(Error::internal( + let jumper_section = jumper.section(prompt).ok_or(error::internal( "a jumping section is absent from its sibling slice", ))?; let target = heading_address::resolve_visible(heading, Visible::of(siblings, jumper.index))?; @@ -100,7 +100,7 @@ pub(super) fn resolve_jump_target( // `target` was resolved out of the visible set read from exactly these // two slices, so a miss here is an internal invariant violation, not a // user-facing Lua error. - let index = section_position(siblings, target).ok_or(Error::internal( + let index = section_position(siblings, target).ok_or(error::internal( "resolved jump target is absent from the jumper's sibling slice", ))?; Ok(JumpTarget { @@ -125,7 +125,7 @@ impl RequireOrigin { /// The position `key` names: its library section's own. /// /// # Errors - /// Returns [`Error::Internal`] when `key` is not one [`library_key`] + /// Returns an `Internal` failure when `key` is not one [`library_key`] /// built. fn from_key(key: &LibraryKey) -> Result { let mut indices = key @@ -134,11 +134,11 @@ impl RequireOrigin { .map(str::parse) .collect::, _>>() .map_err(|cause| { - Error::internal_with_cause("a library key names no section position", cause) + error::internal_with_cause("a library key names no section position", &cause) })?; let index = indices .pop() - .ok_or(Error::internal("a library key names no section position"))?; + .ok_or(error::internal("a library key names no section position"))?; let slice = indices .into_iter() .fold(SlicePath::root(), |path, ancestor| { @@ -179,9 +179,9 @@ fn library_key(pos: &SectionPos) -> LibraryKey { /// `origin`. /// /// # Errors -/// Returns [`Error::Lua`] when the heading is malformed or names no +/// Returns a `LuaRuntime` failure when the heading is malformed or names no /// section in the reach, the miss listing the reach and nothing else, and -/// [`Error::Internal`] for a key [`library_key`] did not build or a block +/// an `Internal` failure for a key [`library_key`] did not build or a block /// kind the Engine cannot run. pub(super) fn resolve_library( prompt: &Prompt, @@ -201,7 +201,7 @@ pub(super) fn resolve_library( /// block pairs with nothing. /// /// # Errors -/// Returns [`Error::Internal`] for a block kind the Engine cannot run. +/// Returns an `Internal` failure for a block kind the Engine cannot run. fn build_library(section: &Section, key: LibraryKey) -> Result { let mut blocks = Vec::new(); let mut pending = ""; @@ -214,7 +214,7 @@ fn build_library(section: &Section, key: LibraryKey) -> Result { }), // `Block` is `#[non_exhaustive]` across the crate seam; an // unlisted variant has no library form. - _ => return Err(Error::internal("an unrecognized block kind cannot load")), + _ => return Err(error::internal("an unrecognized block kind cannot load")), } } Ok(Library { diff --git a/crates/runvil-internal/engine/src/heading_address-tests.rs b/crates/runvil-internal/engine/src/heading_address-tests.rs index 2dbcfab1e..e60bcd416 100644 --- a/crates/runvil-internal/engine/src/heading_address-tests.rs +++ b/crates/runvil-internal/engine/src/heading_address-tests.rs @@ -111,7 +111,7 @@ fn self_pos() -> SectionPos { } fn tree() -> crate::prompt::Prompt { - crate::prompt::Prompt::parse(TREE, "reach-test") + crate::prompt::Prompt::parse(TREE, "prompt", "reach-test") .0 .expect("the tree parses") } diff --git a/crates/runvil-internal/engine/src/heading_address.rs b/crates/runvil-internal/engine/src/heading_address.rs index d7d91657c..65ab97495 100644 --- a/crates/runvil-internal/engine/src/heading_address.rs +++ b/crates/runvil-internal/engine/src/heading_address.rs @@ -22,7 +22,7 @@ //! read it in place. use crate::prompt::{Prompt, Section, detail}; -use crate::{Error, Result}; +use crate::{Error, Result, error}; /// Where a sibling slice sits in the prompt tree: the index of each /// ancestor section from the top level down to the slice's parent. The @@ -282,13 +282,13 @@ pub(crate) fn section_heading(section: &Section) -> String { /// rejected, so a malformed heading can never be silently reinterpreted. /// /// # Errors -/// Returns [`Error::Lua`] when the marker run is absent, is not followed by +/// Returns a `LuaRuntime` failure when the marker run is absent, is not followed by /// whitespace, or the name is empty. fn parse_heading_address(heading: &str) -> Result<(usize, String)> { let stripped = heading.trim(); let level = stripped.chars().take_while(|&c| c == '#').count(); if level == 0 { - return Err(Error::lua_message(format!( + return Err(error::lua_runtime(format!( "section heading must include ### markers, got bare name: {stripped}" ))); } @@ -298,12 +298,12 @@ fn parse_heading_address(heading: &str) -> Result<(usize, String)> { // no name to parse whether or not whitespace followed the markers. let name = rest.trim(); if name.is_empty() { - return Err(Error::lua_message(format!( + return Err(error::lua_runtime(format!( "section heading has no name: {stripped}" ))); } if !rest.starts_with(|c: char| c.is_whitespace()) { - return Err(Error::lua_message(format!( + return Err(error::lua_runtime(format!( "section heading must have whitespace after the {} markers: {stripped}", "#".repeat(level) ))); @@ -317,7 +317,7 @@ fn parse_heading_address(heading: &str) -> Result<(usize, String)> { /// the same `None`, because only the visible set is ever searched. /// /// # Errors -/// Returns [`Error::Lua`] when the heading is malformed (see +/// Returns a `LuaRuntime` failure when the heading is malformed (see /// [`parse_heading_address`]) or when more than one visible section matches /// the exact address, so an ambiguous heading never resolves to an arbitrary /// first hit. @@ -329,7 +329,7 @@ pub(crate) fn find_visible<'a>(heading: &str, visible: Visible<'a>) -> Result( @@ -359,7 +359,7 @@ fn find_one<'a, T>( }); let found = matches.next(); if found.is_some() && matches.next().is_some() { - return Err(Error::lua_message(format!( + return Err(error::lua_runtime(format!( "section heading `{}` is ambiguous; more than one {set} section matches {} {name}", heading.trim(), "#".repeat(level) @@ -373,7 +373,7 @@ fn find_one<'a, T>( /// structure. fn not_found<'a>(heading: &str, available: impl Iterator) -> Error { let available: Vec = available.map(section_heading).collect(); - Error::lua_message(format!( + error::lua_runtime(format!( "section heading `{}` not found; available sections: {}", heading.trim(), available.join(", ") @@ -384,7 +384,7 @@ fn not_found<'a>(heading: &str, available: impl Iterator) -> /// the surfaces that must reach a section, such as `call` and `jump`. /// /// # Errors -/// Returns [`Error::Lua`] for every [`find_visible`] error, and, through +/// Returns a `LuaRuntime` failure for every [`find_visible`] error, and, through /// [`not_found`], when no visible section matches the exact address. pub(crate) fn resolve_visible<'a>(heading: &str, visible: Visible<'a>) -> Result<&'a Section> { find_visible(heading, visible)?.ok_or_else(|| not_found(heading, visible.iter())) @@ -394,7 +394,7 @@ pub(crate) fn resolve_visible<'a>(heading: &str, visible: Visible<'a>) -> Result /// for `require`. /// /// # Errors -/// Returns [`Error::Lua`] for every [`find_reachable`] error, and, through +/// Returns a `LuaRuntime` failure for every [`find_reachable`] error, and, through /// [`not_found`], when no reachable section matches the exact address. pub(crate) fn resolve_reachable<'a>(heading: &str, reach: Reachable<'a>) -> Result> { find_reachable(heading, reach)? diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 53a9b6a1c..84bb0bbc4 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -8,9 +8,9 @@ //! //! Three groups still name `runvil_lua` itself: //! -//! - The error bridge, `error::convert` and `error::value`, which maps the -//! Lua crate's error type variant for variant and implements its -//! [`ErrorValue`](runvil_lua::ErrorValue) trait on this crate's error. +//! - The error module, which re-exports the failure constructors the Lua +//! crate shares with the Engine, and whose store constructors build a +//! store failure through [`store_error`](runvil_lua::store_error). //! - Test-only code: the `#[cfg(test)]` shim install in `execute::section_vm`, //! which reaches `runvil_lua::test_support`, and this crate's test support, //! which takes `ToolOutputKind` from the crate root. @@ -19,13 +19,13 @@ pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, ModelReport, - NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, QuotaResource, RenderedTools, - Request, RosterEntry, ScriptReport, SectionText, SectionVm, SpawnVia, TaskDelivery, TaskPhase, + NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, RenderedTools, Request, + RosterEntry, ScriptReport, SectionText, SectionVm, SpawnVia, TaskDelivery, TaskPhase, TaskStatus, ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, - current_static_set, exhaustion_detail, install_preludes, install_store_shims, install_ui, - output_reserve, precheck, prepare_dispatch, prepare_model_dispatch, project_messages, - render_item, wire_name_of, + current_static_set, install_preludes, install_store_shims, install_ui, output_reserve, + precheck, prepare_dispatch, prepare_model_dispatch, project_messages, render_item, + wire_name_of, }; // The store operation behind `execute::perform_vfs_op`, the entry point diff --git a/crates/runvil-internal/engine/src/lua/tests.rs b/crates/runvil-internal/engine/src/lua/tests.rs index 787d47db4..f8f66291f 100644 --- a/crates/runvil-internal/engine/src/lua/tests.rs +++ b/crates/runvil-internal/engine/src/lua/tests.rs @@ -24,8 +24,8 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use mlua::{MultiValue, Thread}; +use runvil_lua::LuaProgram; use runvil_lua::test_support::answer_envelope; -use runvil_lua::{Error, LuaProgram}; use runvil_types::tools::{ToolId, ToolInfo}; use runvil_types::untrusted::GuardNonce; use serde_json::json; @@ -93,6 +93,10 @@ fn scheduler_vm_with_tools( }) } +/// The file every test VM's prompt comes from, which the author prose of +/// its Lua failures names. +const TEST_FILE: &str = "agents/test.md"; + /// What a test's section VM binds beyond an empty run's: its section, the /// run's tool set and model set, and the conditional Engine globals. struct VmBinds<'a> { @@ -131,6 +135,7 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { seed: binds.seed, emitter: &emitter, section_name: binds.section, + file: TEST_FILE, max_tool_iterations: 24, ui: binds.ui, preludes: &[], @@ -138,7 +143,7 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { }; let read = |_: String| -> std::result::Result, crate::Error> { Ok(None) }; let resolve = |heading: String, _| -> std::result::Result { - Err(crate::Error::lua_message(format!( + Err(crate::error::lua_runtime(format!( "no library `{heading}` in reach" ))) }; @@ -186,7 +191,7 @@ fn parse_request(vm: &SectionVm, yielded: MultiValue) -> Request { /// Renders the successful `answer` as the shim's envelope and resumes the /// thread with it. #[track_caller] -fn resume_with(vm: &SectionVm, thread: &Thread, answer: Answer) -> MultiValue { +fn resume_with(vm: &SectionVm, thread: &Thread, answer: Answer) -> MultiValue { let (envelope, _retained) = answer_envelope(vm.lua(), Ok(answer)).expect("the envelope renders"); thread @@ -200,6 +205,7 @@ fn resume_with(vm: &SectionVm, thread: &Thread, answer: Answer) -> MultiV fn compile_block(source: &str) -> LuaProgram { LuaProgram::compile( source, + "prompt", "section `Test` prologue", NonZeroU32::MIN, &null_emitter(), diff --git a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs index c1e986b4e..4b52b7517 100644 --- a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs +++ b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs @@ -4,7 +4,7 @@ //! `@`-named chunk errors rendering verbatim, and scalar returns and VM //! state rolling forward across block coroutines. -use runvil_lua::{Error, LuaProgram}; +use runvil_lua::LuaProgram; use runvil_types::cancel::CancelHandle; use crate::lua::{CoroStep, LuaBlockResult, Request, YieldParse}; @@ -26,7 +26,7 @@ fn the_cancellation_hook_fires_inside_a_resumed_coroutine() { let program = compile_block("while true do end"); match vm.start_block_coro(&program) { Err(error) => assert!( - matches!(error, Error::Interrupted), + error == crate::error::interrupted(), "the per-coroutine hook must observe cancellation: {error:?}" ), other => panic!("a cancelled infinite loop can only fail, got {other:?}"), @@ -51,7 +51,7 @@ fn every_block_coroutine_has_the_cancellation_hook() { let program = compile_block(source); match vm.start_block_coro(&program) { Err(error) => assert!( - matches!(error, Error::Interrupted), + error == crate::error::interrupted(), "block {source:?} must abort on the cancelled run: {error:?}" ), other => panic!("a cancelled block can only fail, got {other:?}"), diff --git a/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs b/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs new file mode 100644 index 000000000..1b12ac26e --- /dev/null +++ b/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs @@ -0,0 +1,114 @@ +//! The universal error at the coroutine boundary: an Engine failure +//! answered through the envelope reaches the author's `pcall` as the table +//! its code names. The Lua crate's own tests pin the way back, a kept +//! table mapping onto the failure it stands in for. + +use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::ids::TaskId; + +use crate::lua::{CoroStep, LuaBlockResult}; +use crate::model::ModelSet; + +use super::super::{TEST_FILE, compile_block, scheduler_vm}; + +/// What a block's `pcall` of `models.infer` sees when the Engine answers +/// the round with `error`: the table's `kind`, the fields `fields` names, +/// and its `tostring`, joined by `|`. +fn caught_engine_failure(error: crate::Error, fields: &[&str]) -> String { + let vm = scheduler_vm(&ModelSet::default(), None); + let names = fields + .iter() + .map(|name| format!("tostring(err.{name})")) + .collect::>() + .join(" .. '|' .. "); + let program = compile_block(&format!( + "local ok, err = pcall(models.infer, \"hi\")\n\ + assert(not ok, \"the failure envelope must raise\")\n\ + return err.kind .. '|' .. {names} .. '|' .. tostring(err)" + )); + let CoroStep::Yielded(thread, _values) = + vm.start_block_coro(&program).expect("the block suspends") + else { + panic!("the shim yield must suspend the block"); + }; + match vm + .resume_block_coro_answer(&program, &thread, Err(error)) + .expect("the pcall'd block resumes") + { + CoroStep::Done(LuaBlockResult::Returned(Some(text))) => text, + other => panic!("expected the caught failure's rendering, got {other:?}"), + } +} + +/// The task id `path` names. +fn task(path: &str) -> TaskId { + path.parse().expect("the test task id parses") +} + +#[test] +fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() { + assert_eq!( + caught_engine_failure( + crate::error::task_cancelled(&task("0.1")), + &["code", "task"] + ), + format!( + "cancelled|task_cancelled|0.1|{}", + ErrorCode::TaskCancelled.prose() + ) + ); + let leaked = crate::error::tasks_live(&[task("0.1"), task("0.2")]); + let leaked_prose = format!( + "{TEST_FILE}: {}. {}", + leaked.message, + ErrorCode::TasksLive.prose() + ); + assert_eq!( + caught_engine_failure(leaked, &["code", "tasks"]), + format!("tasks_live|tasks_live|0.1, 0.2|{leaked_prose}"), + "a leak the prompt's Lua caused gets its author prose" + ); + assert_eq!( + caught_engine_failure( + crate::error::context_exhausted("precheck"), + &["code", "reason"] + ), + format!( + "context_exhausted|context_exhausted|precheck|{}", + ErrorCode::ContextExhausted.prose() + ) + ); + let anchor = runvil_vfs::VfsError::Anchor { + path: "notes.md".to_owned(), + anchor: "TODO".to_owned(), + count: 2, + }; + let op = crate::lua::VfsOp::StrReplace { + path: "notes.md".to_owned(), + old: "TODO".to_owned(), + new: "DONE".to_owned(), + }; + assert_eq!( + caught_engine_failure( + crate::error::store_op(&op, &anchor), + &["code", "reason", "path", "anchor", "count", "rule"] + ), + format!( + "store|anchor|anchor|notes.md|TODO|2|nil|{}", + ErrorCode::Anchor.prose() + ), + "a store answer keeps its remedy prose" + ); + let mut empty = crate::model::completion_error( + ErrorCode::EmptyReply, + "the model replied with no text and no tool calls", + ) + .with_cause(&std::io::Error::other("a detail the table leaves out")); + empty + .facts + .insert(FactKey::FinishReason, serde_json::Value::from("length")); + assert_eq!( + caught_engine_failure(empty, &["code", "finish_reason"]), + "empty_model_reply|empty_reply|length|the model replied with no text and no tool calls" + ); +} diff --git a/crates/runvil-internal/engine/src/lua/tests/errors.rs b/crates/runvil-internal/engine/src/lua/tests/errors.rs index d90fef503..d81cd7f4d 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors.rs @@ -1,23 +1,28 @@ //! The failure contract at the coroutine boundary: every failure that //! reaches Lua is a `{ kind, message, ... }` table whose `tostring` is the //! message, a Rust-raised error answered through the envelope arrives in -//! the same shape, an author's own raise passes through untouched, a -//! typed error substituted at the boundary keeps its kind, a kept table -//! maps back onto the executor's typed variants, and tracebacks through -//! the shim render the impl frames verbatim. +//! the same shape, an Engine failure answered there renders the table its +//! code names, an author's own raise passes through untouched, a typed +//! error substituted at the boundary keeps its kind, a kept table maps +//! back onto the failure it stands in for, and tracebacks through the shim +//! render the impl frames verbatim. use std::num::NonZeroU32; use mlua::MultiValue; +use runvil_lua::LuaProgram; use runvil_lua::test_support::answer_envelope; -use runvil_lua::{Error, ErrorKind, LuaProgram}; +use runvil_types::error::ErrorCode; use crate::lua::{CoroStep, LuaBlockResult}; use crate::model::ModelSet; use crate::test_support::recording::null_emitter; -use super::{compile_block, scheduler_vm, start, test_models}; +use super::{TEST_FILE, compile_block, scheduler_vm, start, test_models}; + +#[path = "errors-tables.rs"] +mod tables; #[test] fn models_infer_takes_a_prompt_or_a_model_and_a_prompt() { @@ -100,7 +105,7 @@ fn a_failure_envelope_raises_a_table_holding_the_kind_and_fields() { // A Rust-raised error answered through the envelope reaches the // author's `pcall` in the same shape as a shim raise: `kind` names the // failure, the kind's fields appear beside it, and `tostring` is the - // typed error's display text unchanged. + // typed error's prose, a model failure's own remedy. for reason in ["provider", "manual"] { let vm = scheduler_vm(&ModelSet::default(), None); let program = compile_block( @@ -113,12 +118,11 @@ fn a_failure_envelope_raises_a_table_holding_the_kind_and_fields() { else { panic!("the shim yield must suspend the block"); }; - let error = Error::ContextExhausted { - reason: reason.to_owned(), - }; - let display = error.to_string(); + let error = crate::error::context_exhausted(reason); + let display = error.prose.clone(); + assert_eq!(display, ErrorCode::ContextExhausted.prose()); match vm - .resume_block_coro_answer::(&program, &thread, Err(error)) + .resume_block_coro_answer(&program, &thread, Err(error)) .expect("the pcall'd block resumes") { CoroStep::Done(LuaBlockResult::Returned(Some(text))) => { @@ -134,7 +138,8 @@ fn an_engine_callback_failure_caught_by_pcall_is_the_same_error_table() { // An Engine call that fails directly from Rust - no envelope, no shim // raise - reaches the author's `pcall` as the same `{ kind, message }` // table: `kind` is readable at every call site, and `tostring` is the - // message text (mlua's appended traceback is not part of it). The + // author prose of the failure's own text in the VM's file (mlua's + // label and appended traceback are not part of it). The // `sys` guard fails from a metamethod rather than a call, and // `xpcall`'s handler sees the same normalized value. let vm = scheduler_vm(&test_models(), None); @@ -151,13 +156,19 @@ fn an_engine_callback_failure_caught_by_pcall_is_the_same_error_table() { assert(not ok3, "the handler runs for the callback failure") return first .. "\n" .. second .. "\n" .. third"#, ); + let prose = |what: &str| format!("{TEST_FILE}: {what}. {}", ErrorCode::LuaRuntime.prose()); match vm.start_block_coro(&program).expect("the block runs") { CoroStep::Done(LuaBlockResult::Returned(Some(text))) => { assert_eq!( text, - "table|lua|models.get label \"missing\" is not a model role: expected default, strong, small, fast or creative\n\ - table|lua|runtime error: unknown sys field 'nothing'\n\ - table|lua" + format!( + "table|lua|{}\ntable|lua|{}\ntable|lua", + prose( + "models.get label \"missing\" is not a model role: expected default, \ + strong, small, fast or creative" + ), + prose("unknown sys field 'nothing'") + ) ); } other => panic!("expected the caught failures' rendering, got {other:?}"), @@ -197,21 +208,23 @@ fn an_authors_plain_table_with_a_kind_is_not_read_back_as_a_raise() { let vm = scheduler_vm(&ModelSet::default(), None); let program = compile_block(r#"error({ kind = "tool_loop_exhausted", message = "x" }, 0)"#); match vm.start_block_coro(&program) { - Err(Error::LuaRuntime { .. }) => {} + Err(error) if error.code == ErrorCode::LuaRuntime => {} other => panic!("an author's table is an ordinary runtime failure, got {other:?}"), } } #[test] fn an_uncaught_lua_kind_shim_raise_keeps_the_mapped_runtime_error() { - // A `lua`-kind table that propagates out of the block is not kept as a - // `Raised`: the mapped runtime error already holds the same message - // with its source and the prompt line the traceback maps to, which is - // what an authoring error needs. The block is compiled at prompt line - // 40 and fails at chunk line 2, so the mapped author frame is line 41. + // A `lua`-kind table that propagates out of the block is not kept as + // the error it reads back as: the mapped runtime error already holds + // the same message with its source and the prompt line the traceback + // maps to, which is what an authoring error needs. The block is + // compiled at prompt line 40 and fails at chunk line 2, so the mapped + // author frame is line 41, where its author prose locates it. let vm = scheduler_vm(&test_models(), None); let program = LuaProgram::compile( "local h = models.get(\"fast\")\nmodels.infer(h, \"a\", \"b\")", + "prompt", "section `Test` prologue", NonZeroU32::new(40).expect("40 is non-zero"), &null_emitter(), @@ -219,7 +232,8 @@ fn an_uncaught_lua_kind_shim_raise_keeps_the_mapped_runtime_error() { ) .expect("the driver block compiles"); match vm.start_block_coro(&program) { - Err(Error::LuaRuntime { message, .. }) => { + Err(error) if error.code == ErrorCode::LuaRuntime => { + let message = &error.message; assert!( message.contains("models.infer takes (prompt) or (model, prompt)"), "the mapped error keeps the shim's message: {message}" @@ -228,6 +242,21 @@ fn an_uncaught_lua_kind_shim_raise_keeps_the_mapped_runtime_error() { message.contains("section `Test` prologue:41: in"), "the author frame maps to the absolute prompt line: {message}" ); + assert_eq!( + error.prose, + format!( + "{TEST_FILE}:41: models.infer takes (prompt) or (model, prompt). {}", + ErrorCode::LuaRuntime.prose() + ) + ); + assert_eq!( + error.at, + Some(runvil_types::error::Location::line( + TEST_FILE, + NonZeroU32::new(41).expect("41 is non-zero") + )), + "the failure is located on its mapped line of the setup's file" + ); } other => panic!("a lua-kind raise must surface as the mapped runtime error, got {other:?}"), } @@ -251,14 +280,18 @@ fn a_typed_error_substituted_at_the_coroutine_boundary_keeps_its_kind() { else { panic!("the shim yield must suspend the block"); }; - let answer = Err(Error::LuaQuota { - resource: runvil_lua::QuotaResource::LogEvent, - }); - match vm.resume_block_coro_answer::(&program, &thread, answer) { - Err(Error::LuaQuota { - resource: runvil_lua::QuotaResource::LogEvent, - }) => {} - other => panic!("block {source:?} must surface the typed quota error, got {other:?}"), + let quota = crate::error::lua_quota(runvil_lua::QuotaResource::LogEvent); + let mut answered = quota.clone(); + answered.prose = format!( + "{TEST_FILE}: lua log event quota exceeded. {}", + ErrorCode::LuaQuota.prose() + ); + match vm.resume_block_coro_answer(&program, &thread, Err(quota)) { + Err(error) if error == answered => {} + other => panic!( + "block {source:?} must surface the typed quota error with its author prose, \ + got {other:?}" + ), } } } @@ -267,8 +300,8 @@ fn a_typed_error_substituted_at_the_coroutine_boundary_keeps_its_kind() { fn a_structured_raise_surfacing_as_the_coroutine_failure_keeps_its_table() { // Without a retained typed error to substitute (the envelope was // rendered ahead of time, as a Lua-side raise would be), the failure - // still arrives typed: the table the shim raised is kept as a - // `Raised` value holding its kind and fields, never flattened to the + // still arrives typed: the table the shim raised reads back as the + // universal error it was rendered from, never flattened to the // message string. let vm = scheduler_vm(&ModelSet::default(), None); let program = compile_block("models.infer(\"hi\")"); @@ -277,128 +310,16 @@ fn a_structured_raise_surfacing_as_the_coroutine_failure_keeps_its_table() { else { panic!("the shim yield must suspend the block"); }; - let (envelope, _retained) = answer_envelope::( - vm.lua(), - Err(Error::ContextExhausted { - reason: "precheck".to_owned(), - }), - ) - .expect("the envelope renders"); + let (envelope, _retained) = + answer_envelope(vm.lua(), Err(crate::error::context_exhausted("precheck"))) + .expect("the envelope renders"); match vm.resume_block_coro(&program, &thread, envelope) { - Err(Error::Raised(raised)) => { - assert_eq!(raised.kind, ErrorKind::ContextExhausted); - assert!( - raised.message.starts_with("context exhausted: "), - "the table keeps the display message: {}", - raised.message - ); - assert_eq!( - raised - .fields - .get("reason") - .and_then(runvil_lua::ErrorField::as_str), - Some("precheck") - ); - } - other => panic!("expected the kept table as a Raised failure, got {other:?}"), - } -} - -#[test] -fn a_raised_table_maps_onto_the_executor_error_type_by_kind() { - // The executor's `From` turns a kept table back - // into the variant its kind names, so a Lua-side raise classifies the - // same way as the Rust-raised error it replaces. - // A `context_exhausted` table keeps any reason string, and only a - // table with no reason falls back to a Lua failure. - for reason in ["provider", "manual"] { - let exhausted = runvil_lua::Raised { - kind: ErrorKind::ContextExhausted, - message: format!("context exhausted: {reason}"), - fields: [( - "reason".to_owned(), - runvil_lua::ErrorField::String(reason.to_owned()), - )] - .into_iter() - .collect(), - }; - match crate::Error::from(Error::Raised(exhausted)) { - crate::Error::ContextExhausted { reason: kept, .. } => assert_eq!(kept, reason), - other => panic!("expected context exhaustion holding {reason:?}, got {other:?}"), - } - } - let reasonless = runvil_lua::Raised { - kind: ErrorKind::ContextExhausted, - message: "context exhausted".to_owned(), - fields: std::collections::BTreeMap::new(), - }; - match crate::Error::from(Error::Raised(reasonless)) { - crate::Error::Lua { message, .. } => assert_eq!(message, "context exhausted"), - other => panic!("a reasonless table must keep its message as a Lua failure, got {other:?}"), - } - let loop_exhausted = runvil_lua::Raised { - kind: ErrorKind::ToolLoopExhausted, - message: "tool-call loop did not converge".to_owned(), - fields: std::collections::BTreeMap::new(), - }; - assert!(matches!( - crate::Error::from(Error::Raised(loop_exhausted)), - crate::Error::ToolLoopExhausted - )); - let cancelled = runvil_lua::Raised { - kind: ErrorKind::Cancelled, - message: "interrupted: the run was cancelled or this call was stopped".to_owned(), - fields: std::collections::BTreeMap::new(), - }; - assert!(matches!( - crate::Error::from(Error::Raised(cancelled)), - crate::Error::Interrupted - )); - // The empty-reply arm keeps the message the author saw as the message - // and copies the `finish_reason` field across. - let empty = runvil_lua::Raised { - kind: ErrorKind::EmptyModelReply, - message: "the model returned an empty turn".to_owned(), - fields: [( - "finish_reason".to_owned(), - runvil_lua::ErrorField::String("length".to_owned()), - )] - .into_iter() - .collect(), - }; - 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")); - } - other => panic!("expected the empty-reply failure, got {other:?}"), - } - // A model failure or a missing model holds none of the structure its - // variant needs, so the table keeps its message as a Lua failure with - // no cause rather than rebuilding a completion failure. - for (kind, message) in [ - ( - ErrorKind::ModelFailure, - "the model backend reported a fault of its own (status 500)", + Err(error) => assert_eq!( + error, + crate::error::context_exhausted("precheck"), + "the kept table reads back as its error, the message included" ), - ( - ErrorKind::ModelRequired, - "model binding required for section Only", - ), - ] { - let raised = runvil_lua::Raised { - kind, - message: message.to_owned(), - fields: std::collections::BTreeMap::new(), - }; - match crate::Error::from(Error::Raised(raised)) { - crate::Error::Lua { - message: kept, - cause: None, - } => assert_eq!(kept, message), - other => panic!("a {kind:?} table must stay a cause-less Lua failure, got {other:?}"), - } + other => panic!("expected the kept table's failure, got {other:?}"), } } @@ -427,6 +348,7 @@ fn a_traceback_through_a_shim_shows_unmapped_impl_frames() { // unexpected shim error, whose frames must render verbatim. let program = LuaProgram::compile( "var = 5\ncall(\"## Child\")", + "prompt", "section `Test` prologue", NonZeroU32::new(40).expect("40 is non-zero"), &null_emitter(), diff --git a/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs b/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs index d9ae13ee7..835658a8e 100644 --- a/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs +++ b/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs @@ -4,15 +4,13 @@ //! rendered through `test_support::answer_envelope`. The loop's other contract //! cases run end to end in `execute/tests/models_loop_contract.rs`. -use runvil_lua::Error; - use crate::lua::{Answer, ChatOutcome, ChatResult, Request}; use super::{parse_request, resume_with, scheduler_vm, start, test_models}; /// A completed round with no reply, no tool calls, and no empty detail, /// ending for `finish_reason`. -fn empty_round(finish_reason: Option<&str>) -> Answer { +fn empty_round(finish_reason: Option<&str>) -> Answer { Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::Empty(None), finish_reason: finish_reason.map(str::to_owned), diff --git a/crates/runvil-internal/engine/src/lua/tests/quota.rs b/crates/runvil-internal/engine/src/lua/tests/quota.rs index b65f7c3c9..493dc6c0f 100644 --- a/crates/runvil-internal/engine/src/lua/tests/quota.rs +++ b/crates/runvil-internal/engine/src/lua/tests/quota.rs @@ -11,7 +11,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use mlua::{HookTriggers, MultiValue, Value, VmState}; use serde_json::json; -use runvil_lua::Error; use runvil_types::metrics::ToolCallEvent; use crate::lua::{Answer, ChatOutcome, ChatResult, Request}; @@ -38,7 +37,7 @@ const ROUND_INSTRUCTION_FLOOR: u64 = 2; const MEASURED_ROUNDS: i64 = 3; /// A completed round that requested one `tools_echo` call. -fn tool_call_round() -> Answer { +fn tool_call_round() -> Answer { Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::ToolCalls(vec![ToolCallEvent::new( "call_1", @@ -51,7 +50,7 @@ fn tool_call_round() -> Answer { } /// A completed round that produced the terminal reply. -fn reply_round(text: &str) -> Answer { +fn reply_round(text: &str) -> Answer { Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::Reply(text.to_owned()), finish_reason: Some("stop".to_owned()), 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/model/tests.rs b/crates/runvil-internal/engine/src/model/tests.rs index d4f43b9f2..74ccdc15c 100644 --- a/crates/runvil-internal/engine/src/model/tests.rs +++ b/crates/runvil-internal/engine/src/model/tests.rs @@ -4,11 +4,11 @@ use std::num::NonZeroU32; use std::sync::Arc; use super::*; +use crate::Result; use crate::lua::{SectionVm, ToolSet}; use crate::test_support::fresh_access; use crate::test_support::recording::null_emitter; use crate::test_support::scenario::TEST_SEED; -use crate::{Error, Result}; use runvil_model_client::model::ModelInvocation; use runvil_types::emitter::Emitter; use runvil_types::untrusted::GuardNonce; @@ -19,6 +19,7 @@ use serde_json::json; fn chunk(source: &str) -> runvil_lua::LuaProgram { runvil_lua::LuaProgram::compile( source, + "prompt", "chunk", NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), @@ -81,12 +82,12 @@ fn section_vm_with_models( section, )? .inject_values("", &json!({}), &fresh_access()) - .map_err(|failure| Error::from(failure.teardown(emitter, section))) + .map_err(|failure| failure.teardown(emitter, section)) } /// Reads the section's current model the way the Engine's read path does. fn resolve_section_model(vm: &SectionVm) -> Result> { - vm.current_model().map_err(Error::from) + vm.current_model() } #[path = "tests-always.rs"] diff --git a/crates/runvil-internal/engine/src/prompt.rs b/crates/runvil-internal/engine/src/prompt.rs index beede9a97..07c488437 100644 --- a/crates/runvil-internal/engine/src/prompt.rs +++ b/crates/runvil-internal/engine/src/prompt.rs @@ -9,16 +9,12 @@ //! crate's own algorithms, called where the rule each one implements is //! documented. //! -//! Three groups still name `runvil_prompt` itself: +//! Two groups still name `runvil_prompt` itself: //! -//! - `error::convert`, the error bridge, which maps the loader's error type -//! variant for variant. //! - The test suites, which import the prompt fixtures under //! `runvil_prompt::test_support` and call a `detail` helper directly. //! - The crate's bench, `benches/models_loop.rs`, a separate crate that //! cannot see a `pub(crate)` surface. pub(crate) use runvil_prompt::detail; -pub(crate) use runvil_prompt::{ - Block, BodyText, ParseError, ParseErrorKind, Prompt, RoleRequirements, Section, -}; +pub(crate) use runvil_prompt::{Block, BodyText, Prompt, RoleRequirements, Section}; diff --git a/crates/runvil-internal/engine/src/subst-tests.rs b/crates/runvil-internal/engine/src/subst-tests.rs index 3acd04ae1..df04e0980 100644 --- a/crates/runvil-internal/engine/src/subst-tests.rs +++ b/crates/runvil-internal/engine/src/subst-tests.rs @@ -1,6 +1,8 @@ //! Regression coverage for `{{ }}` prose substitution: the resolution //! of the `args`, `argv`, `item`, `var`, `sys`, and bare-global sources, the -//! escape grammar, path segment validation, and the typed error kinds. +//! escape grammar, path segment validation, and the failure reasons. + +use std::collections::BTreeMap; use super::*; use serde_json::json; @@ -34,14 +36,19 @@ fn run(prose: &str) -> Result { substitute(prose, &test_sources(&var, &sys)) } -fn err_of(prose: &str) -> SubstitutionError { +fn err_of(prose: &str) -> Error { let var = json!({ "kind": "library", "row": { "a": 1 }, "arr": [1, 2] }); let sys = json!({ "id": 1 }); let sources = Sources { item: Some(&json!("i")), ..test_sources(&var, &sys) }; - substitute_inner(prose, &sources).expect_err("expected substitution failure") + substitute(prose, &sources).expect_err("expected substitution failure") +} + +/// The `Reason` fact of a substitution failure. +fn reason(error: &Error) -> Option<&str> { + error.facts.get(&FactKey::Reason)?.as_str() } #[test] @@ -114,8 +121,8 @@ fn nil_argv_is_an_error() { let var = json!({}); let sys = json!({}); for prose in ["{{ argv }}", "{{ argv.x }}"] { - let e = substitute_inner(prose, &test_sources(&var, &sys)).unwrap_err(); - assert_eq!(e.kind, SubstErrorKind::NilArgv, "path {prose:?}"); + let e = substitute(prose, &test_sources(&var, &sys)).unwrap_err(); + assert_eq!(reason(&e), Some("nil_argv"), "path {prose:?}"); assert!(e.to_string().contains("argv"), "names argv: {e}"); assert!( e.to_string().contains("is nil (argv holds no value)"), @@ -134,8 +141,8 @@ fn dotted_index_into_a_scalar_argv_is_an_error() { argv: Some(&argv), ..test_sources(&var, &sys) }; - let e = substitute_inner("{{ argv.x }}", &sources).unwrap_err(); - assert_eq!(e.kind, SubstErrorKind::MissingKey); + let e = substitute("{{ argv.x }}", &sources).unwrap_err(); + assert_eq!(reason(&e), Some("missing_key")); } #[test] @@ -173,7 +180,7 @@ fn no_placeholders_passthrough() { #[test] fn unclosed_is_error() { - assert_eq!(err_of("open {{ args").kind, SubstErrorKind::Unclosed); + assert_eq!(reason(&err_of("open {{ args")), Some("unclosed")); } // --- Escape grammar ------------------------------------------------------ @@ -222,10 +229,9 @@ fn empty_or_padded_segments_are_rejected() { let prose = format!("{{{{ {bad} }}}}"); let e = err_of(&prose); assert_eq!( - e.kind, - SubstErrorKind::EmptySegment, - "path {bad:?} must be an empty-segment error, got {:?}", - e.kind + reason(&e), + Some("empty_segment"), + "path {bad:?} must be an empty-segment error, got {e:?}" ); } } @@ -235,14 +241,47 @@ fn valid_nested_segment_still_resolves() { assert_eq!(run("{{ var.row.a }}").unwrap(), "1"); } -// --- Typed error kind/offset/source -------------------------------------- +// --- Failure reason/offset/cause ----------------------------------------- #[test] -fn error_has_kind_and_offset() { +fn error_has_reason_and_offset() { let e = err_of("prefix {{ ghost.x }}"); - assert_eq!(e.kind, SubstErrorKind::UnknownNamespace); - assert_eq!(e.offset, 7, "offset must point at the '{{{{'"); - assert!(e.to_string().contains("ghost.x")); + assert_eq!(reason(&e), Some("unknown_namespace")); + assert!( + e.message.ends_with("[UnknownNamespace at byte 7]"), + "offset must point at the '{{{{': {e}" + ); + assert!(e.message.contains("ghost.x")); +} + +#[test] +fn a_failure_is_a_substitution_error_with_its_reason_fact() { + let e = err_of("see {{ var.nope }}"); + let detail = "missing {{ var.nope }}"; + assert_eq!(e.code, ErrorCode::Substitution); + assert_eq!(e.message, format!("{detail} [MissingKey at byte 4]")); + assert_eq!( + e.prose, + format!("{detail}. {}", ErrorCode::Substitution.prose()) + ); + assert_eq!( + e.facts, + BTreeMap::from([(FactKey::Reason, Value::from("missing_key"))]) + ); + assert_eq!(e.at, None); +} + +#[test] +fn a_failure_classifies_as_the_authors_program_failing_and_lua_reads_it_as_lua() { + let error = run("{{ var.nope }}").expect_err("a missing key fails the substitution"); + assert_eq!(error.code, ErrorCode::Substitution); + assert_eq!(reason(&error), Some("missing_key")); + assert_eq!( + crate::execute::run_error_kind(error.code), + crate::RunErrorKind::Lua + ); + assert_eq!(runvil_lua::error_kind(&error), runvil_lua::ErrorKind::Lua); + assert_eq!(runvil_lua::error_fields(&error), Vec::new()); } // --- bare globals (section-local Lua globals in prose) ------------------- @@ -289,7 +328,7 @@ fn bare_global_table_renders_as_json() { #[test] fn missing_bare_global_is_unknown_namespace() { let e = err_of("{{ ghost }}"); - assert_eq!(e.kind, SubstErrorKind::UnknownNamespace); + assert_eq!(reason(&e), Some("unknown_namespace")); assert!(e.to_string().contains("global 'ghost'")); } @@ -299,28 +338,32 @@ fn non_json_bare_global_is_an_error() { let sys = json!({}); let globals = |name: &str| { assert_eq!(name, "f"); - Err(crate::Error::lua_message("global `f` is a function")) + Err(crate::error::lua_runtime("global `f` is a function")) }; let sources = Sources { globals: &globals, ..test_sources(&var, &sys) }; - let e = substitute_inner("{{ f }}", &sources).unwrap_err(); - assert_eq!(e.kind, SubstErrorKind::Serialize); - assert!(e.to_string().contains("not JSON data")); + let e = substitute("{{ f }}", &sources).unwrap_err(); + assert_eq!(reason(&e), Some("serialize")); + assert_eq!( + e.message, + "global 'f' in {{ f }} is not JSON data [Serialize at byte 0]: global `f` is a function", + "the lookup failure must be preserved in the message" + ); assert!( - std::error::Error::source(&e).is_some(), - "the lookup failure must be preserved as the source" + !e.prose.contains("is a function"), + "the lookup failure must stay out of the prose: {e:?}" ); } #[test] fn bare_namespaces_still_require_a_key() { - // `{{ var }}` and `{{ sys }}` stay BadPath; only bare globals render + // `{{ var }}` and `{{ sys }}` stay `bad_path`; only bare globals render // whole values. for prose in ["{{ var }}", "{{ sys }}"] { let e = err_of(prose); - assert_eq!(e.kind, SubstErrorKind::BadPath, "path {prose:?}"); + assert_eq!(reason(&e), Some("bad_path"), "path {prose:?}"); } } @@ -329,24 +372,24 @@ fn reply_is_no_longer_a_namespace() { // `{{ reply }}` resolves as a bare global like any other name, and is // unset here. let e = err_of("{{ reply }}"); - assert_eq!(e.kind, SubstErrorKind::UnknownNamespace); + assert_eq!(reason(&e), Some("unknown_namespace")); } #[test] -fn null_value_and_item_kinds() { +fn null_value_and_nil_item_reasons() { let var = json!({ "n": Value::Null }); let sys = json!({}); - let e = substitute_inner("{{ var.n }}", &test_sources(&var, &sys)).unwrap_err(); - assert_eq!(e.kind, SubstErrorKind::NullValue); + let e = substitute("{{ var.n }}", &test_sources(&var, &sys)).unwrap_err(); + assert_eq!(reason(&e), Some("null_value")); - let e = substitute_inner("{{ item }}", &test_sources(&var, &sys)).unwrap_err(); - assert_eq!(e.kind, SubstErrorKind::NilItem); + let e = substitute("{{ item }}", &test_sources(&var, &sys)).unwrap_err(); + assert_eq!(reason(&e), Some("nil_item")); } #[test] -fn not_a_table_kind() { +fn not_a_table_reason() { let e = err_of("{{ args.x }}"); - assert_eq!(e.kind, SubstErrorKind::NotATable); + assert_eq!(reason(&e), Some("not_a_table")); assert!(e.to_string().contains("not a table")); } diff --git a/crates/runvil-internal/engine/src/subst.rs b/crates/runvil-internal/engine/src/subst.rs index 2ac5c5532..9ac9f0d72 100644 --- a/crates/runvil-internal/engine/src/subst.rs +++ b/crates/runvil-internal/engine/src/subst.rs @@ -31,99 +31,58 @@ //! replacement that itself contains `{{ ... }}` is emitted verbatim and never //! triggers a second round of substitution. +use runvil_types::error::{Error, ErrorCode, FactKey}; use serde_json::Value; use crate::Result; -/// A stable classification of a [`SubstitutionError`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SubstErrorKind { - /// A `{{` was opened but never closed with `}}`. - Unclosed, - /// A bare namespace with no `.key` suffix where one is required. - BadPath, - /// A path segment was empty or whitespace-padded (`var.`, `var..x`). - EmptySegment, - /// The leading namespace is not one of the known roots and names no bare - /// global. - UnknownNamespace, - /// A key path after `args` or `item`, which render whole only. - NotATable, - /// A key path found no value: under `var`, `sys`, `argv`, or a bare - /// global. - MissingKey, - /// The resolved value was JSON null, for a `var`, `sys`, `argv`, or bare - /// global source. A null `item` is not this: `{{ item }}` renders it as - /// the text `null`. - NullValue, - /// `{{ item }}` was used with no item seeded into this section. - NilItem, - /// An `argv` placeholder, bare or dotted, was used while `argv` was nil. - NilArgv, - /// The bare-global lookup failed: the global cannot become JSON data, the - /// name is `prose` itself, or the lookup raised. - Serialize, -} - -/// A typed substitution failure. +/// Builds the failure of the placeholder that opens at byte `offset` of the +/// prose. `detail` says what failed, with a bounded, control-escaped preview +/// of the placeholder path; the message follows it with the documented +/// kind name, the reason in CamelCase, and the offset, as +/// `{detail} [MissingKey at byte 4]`, and the prose with the code's default +/// text. `reason` is the `Reason` fact, one of: /// -/// Holds a stable [`kind`](SubstitutionError::kind), the byte -/// [`offset`](SubstitutionError::offset) of the offending placeholder within -/// the prose, a `message` that embeds a bounded, control-escaped preview of the -/// placeholder path, and - for the serialization case - the preserved -/// underlying error as its [`source`](std::error::Error::source). -#[derive(Debug)] -pub(crate) struct SubstitutionError { - kind: SubstErrorKind, - offset: usize, - message: String, - source: Option>, -} - -impl SubstitutionError { - fn new(kind: SubstErrorKind, offset: usize, message: String) -> Self { - SubstitutionError { - kind, - offset, - message, - source: None, - } - } - - fn with_source( - kind: SubstErrorKind, - offset: usize, - message: String, - source: Box, - ) -> Self { - SubstitutionError { - kind, - offset, - message, - source: Some(source), - } - } -} - -impl std::fmt::Display for SubstitutionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{} [{:?} at byte {}]", - self.message, self.kind, self.offset - ) - } -} - -impl std::error::Error for SubstitutionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.source - .as_deref() - .map(|s| s as &(dyn std::error::Error + 'static)) - } +/// - `unclosed`: a `{{` was opened but never closed with `}}`. +/// - `bad_path`: a bare namespace with no `.key` suffix where one is +/// required. +/// - `empty_segment`: a path segment was empty or whitespace-padded +/// (`var.`, `var..x`). +/// - `unknown_namespace`: the leading namespace is not one of the known +/// roots and names no bare global. +/// - `not_a_table`: a key path after `args` or `item`, which render whole +/// only. +/// - `missing_key`: a key path found no value: under `var`, `sys`, `argv`, +/// or a bare global. +/// - `null_value`: the resolved value was JSON null, for a `var`, `sys`, +/// `argv`, or bare global source. A null `item` is not this: `{{ item }}` +/// renders it as the text `null`. +/// - `nil_item`: `{{ item }}` was used with no item seeded into this +/// section. +/// - `nil_argv`: an `argv` placeholder, bare or dotted, was used while +/// `argv` was nil. +/// - `serialize`: the bare-global lookup failed: the global cannot become +/// JSON data, the name is `prose` itself, or the lookup raised. +fn failure(reason: &str, offset: usize, detail: String) -> Error { + let code = ErrorCode::Substitution; + let kind: String = reason + .split('_') + .flat_map(|word| { + let mut letters = word.chars(); + letters + .next() + .map(|first| first.to_ascii_uppercase()) + .into_iter() + .chain(letters) + }) + .collect(); + let message = format!("{detail} [{kind} at byte {offset}]"); + let mut error = Error::new(code, message, detail + ". " + code.prose()); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error } -type SubstResult = std::result::Result; +type SubstResult = std::result::Result; /// The value sources `{{ }}` placeholders resolve against. /// @@ -145,7 +104,7 @@ pub(crate) struct Sources<'a> { pub(crate) sys: &'a Value, /// The bare-global lookup for `{{ name }}` resolution: `Ok(None)` when /// unset, `Ok(Some(_))` with its JSON form when set, and `Err` when the - /// lookup fails, which a placeholder reports as `Serialize`. + /// lookup fails, which a placeholder reports as `serialize`. pub(crate) globals: &'a dyn Fn(&str) -> Result>, } @@ -155,16 +114,13 @@ pub(crate) struct Sources<'a> { /// untouched. /// /// # Errors -/// Returns [`Error::Substitution`](crate::Error::Substitution) holding a -/// [`SubstitutionError`] whose kind is a [`SubstErrorKind`] variant, which is -/// the complete list of what a placeholder can fail with. The error carries -/// the byte offset of the offending placeholder and a message with its -/// bounded, control-escaped path preview. +/// Returns a `Substitution` failure whose `Reason` fact is one of the +/// reasons `failure` lists, which is the complete list of what a +/// placeholder can fail with. Its message names the reason and the byte +/// offset of the offending placeholder after a detail with its bounded, +/// control-escaped path preview, and a `serialize` message then holds the +/// lookup failure. pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { - Ok(substitute_inner(prose, sources)?) -} - -fn substitute_inner(prose: &str, sources: &Sources<'_>) -> SubstResult { let mut out = String::with_capacity(prose.len()); let bytes = prose.as_bytes(); let mut i = 0; @@ -182,13 +138,9 @@ fn substitute_inner(prose: &str, sources: &Sources<'_>) -> SubstResult { if bytes[i] == b'{' && i + 1 < prose.len() && bytes[i + 1] == b'{' { let start = i; let after = &prose[i + 2..]; - let end = after.find("}}").ok_or_else(|| { - SubstitutionError::new( - SubstErrorKind::Unclosed, - start, - "unclosed '{{' in prose".to_string(), - ) - })?; + let end = after + .find("}}") + .ok_or_else(|| failure("unclosed", start, "unclosed '{{' in prose".to_string()))?; let path = after[..end].trim(); out.push_str(&resolve(path, start, sources)?); i += 2 + end + 2; @@ -206,8 +158,8 @@ fn substitute_inner(prose: &str, sources: &Sources<'_>) -> SubstResult { /// The nil-`argv` failure shared by the bare and dotted `{{ argv }}` paths. fn nil_argv<'a>(argv: Option<&'a Value>, path: &str, offset: usize) -> SubstResult<&'a Value> { argv.ok_or_else(|| { - SubstitutionError::new( - SubstErrorKind::NilArgv, + failure( + "nil_argv", offset, format!( "{{{{ {} }}}} is nil (argv holds no value)", @@ -227,20 +179,20 @@ fn bare_global_root( ) -> SubstResult { globals(name) .map_err(|error| { - SubstitutionError::with_source( - SubstErrorKind::Serialize, + failure( + "serialize", offset, format!( "global '{}' in {{{{ {} }}}} is not JSON data", path_preview(name), path_preview(path) ), - Box::new(error), ) + .with_cause(&error) })? .ok_or_else(|| { - SubstitutionError::new( - SubstErrorKind::UnknownNamespace, + failure( + "unknown_namespace", offset, format!( "unknown namespace or global '{}' in {{{{ {} }}}}", @@ -258,8 +210,8 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult) -> SubstResult) -> SubstResult sources.sys, "argv" => nil_argv(sources.argv, path, offset)?, "args" => { - return Err(SubstitutionError::new( - SubstErrorKind::NotATable, + return Err(failure( + "not_a_table", offset, "args is a string, not a table".to_string(), )); } "item" => { - return Err(SubstitutionError::new( - SubstErrorKind::NotATable, + return Err(failure( + "not_a_table", offset, "item takes no key path; {{ item }} renders the whole item".to_string(), )); @@ -321,8 +273,8 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { for key in keys.split('.') { current = current.get(key).ok_or_else(|| { - SubstitutionError::new( - SubstErrorKind::MissingKey, + failure( + "missing_key", offset, format!("missing {{{{ {} }}}}", path_preview(path)), ) @@ -332,8 +284,8 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { - return Err(SubstitutionError::new( - SubstErrorKind::BadPath, + return Err(failure( + "bad_path", offset, format!("bad path: {{{{ {} }}}}", path_preview(path)), )); @@ -373,8 +325,8 @@ fn path_preview(path: &str) -> String { /// [`render_item`](crate::lua::render_item); a null is an error. fn render(value: &Value, path: &str, offset: usize) -> SubstResult { if value.is_null() { - return Err(SubstitutionError::new( - SubstErrorKind::NullValue, + return Err(failure( + "null_value", offset, format!("missing {{{{ {} }}}}", path_preview(path)), )); diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index e30c1f67d..8687d9130 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -26,10 +26,10 @@ //! 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}; use crate::lua::run_store_op; @@ -59,6 +59,29 @@ pub(crate) use recording::forward; #[cfg(test)] pub(crate) use tools::FnTool; +/// The author prose of a failure with `code` whose own text is `what`, +/// raised by a section of a prompt that came from no file, which is what +/// `tostring` gives for it once a `pcall` caught it. +#[cfg(test)] +pub(crate) fn authored(code: ErrorCode, what: &str) -> String { + format!("prompt: {what}. {}", code.prose()) +} + +/// [`authored`] for a `LuaRuntime` refusal: what a caught refusal's +/// `tostring` gives. +#[cfg(test)] +pub(crate) fn refused(what: &str) -> String { + authored(ErrorCode::LuaRuntime, what) +} + +/// `error` as it crossed into a section's Lua, of a prompt that came from +/// no file: with the [`authored`] prose of its message. +#[cfg(test)] +pub(crate) fn with_author_prose(mut error: crate::Error) -> crate::Error { + error.prose = authored(error.code, &error.message); + error +} + /// A fresh default handle's access capability, for tests that inject Engine /// values into a standalone VM. #[cfg(test)] @@ -88,9 +111,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, } @@ -170,10 +194,10 @@ pub(crate) fn drive_step( // is held, so a pending step that issued nothing while // holding nothing has nothing to wait on: an invariant // failure reported rather than a hang. - let error = Error::internal( + let error = crate::error::internal( "the serial driver was handed a pending run with no effect to answer", ); - return Some(error.to_outcome()); + return Some(crate::execute::outcome(&error)); } for IssuedEffect { id, effect, .. } in effects { issued(&effect); 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..5bc8b8e8a 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,15 +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) { - let Error::Completion(failure) = error else { - panic!("expected a model failure, got {error:?}"); - }; +pub(crate) fn assert_model_failure(error: &Error, code: ErrorCode, prose: &str) { assert_eq!( - (failure.kind(), failure.to_string().as_str()), - (kind, message) + (error.code, error.prose.as_str()), + (code, prose), + "{error:?}" ); } @@ -391,7 +388,7 @@ impl Scenario { /// events. #[track_caller] fn config_of(&mut self) -> (Prompt, Vec, RunConfig) { - let (parsed, events) = Prompt::parse(&self.source, &self.name); + let (parsed, events) = Prompt::parse(&self.source, "prompt", &self.name); let prompt = match parsed { Ok(prompt) => prompt, Err(error) => panic!("expected the scenario source to parse, got {error}"), 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/Cargo.toml b/crates/runvil-internal/lua/Cargo.toml index 65e0c83b1..fb6df1d29 100644 --- a/crates/runvil-internal/lua/Cargo.toml +++ b/crates/runvil-internal/lua/Cargo.toml @@ -14,7 +14,6 @@ runvil-types.workspace = true runvil-vfs.workspace = true serde.workspace = true serde_json.workspace = true -thiserror.workspace = true [features] # Test-only installs and helpers for companion crates' suites and this diff --git a/crates/runvil-internal/lua/benches/surface.rs b/crates/runvil-internal/lua/benches/surface.rs index a591a1ae9..994910572 100644 --- a/crates/runvil-internal/lua/benches/surface.rs +++ b/crates/runvil-internal/lua/benches/surface.rs @@ -70,6 +70,7 @@ fn message_building(c: &mut Criterion) { messages:assistant('a summary')\n\ messages:user('now shorter')\n\ return #messages", + "prompt", "bench", NonZeroU32::MIN, &emitter(), diff --git a/crates/runvil-internal/lua/src/alias.rs b/crates/runvil-internal/lua/src/alias.rs index b3c2e0887..816c13dfb 100644 --- a/crates/runvil-internal/lua/src/alias.rs +++ b/crates/runvil-internal/lua/src/alias.rs @@ -3,7 +3,7 @@ use runvil_types::names::is_alias; -use crate::error::{Error, Result}; +use crate::error::{Result, lua_runtime}; /// A prompt-local alias that passed the supported wire grammar. /// @@ -15,14 +15,14 @@ impl Alias { /// Validates `alias` against the wire grammar and wraps it. /// /// # Errors - /// Returns [`Error::Lua`] when `alias` is empty, exceeds 64 bytes, starts + /// Returns a `LuaRuntime` failure when `alias` is empty, exceeds 64 bytes, starts /// 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 { if is_alias(&alias) { Ok(Alias(alias)) } else { - Err(Error::Lua(format!( + Err(lua_runtime(format!( "invalid alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" ))) } diff --git a/crates/runvil-internal/lua/src/argv.rs b/crates/runvil-internal/lua/src/argv.rs index 380bcf893..0764dd624 100644 --- a/crates/runvil-internal/lua/src/argv.rs +++ b/crates/runvil-internal/lua/src/argv.rs @@ -17,7 +17,7 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_proxy}; /// How a section VM installs the `argv` global at Engine injection. `None` @@ -40,20 +40,20 @@ pub(crate) const ASSIGNMENT_REFUSAL: &str = "argv is frozen outside H1: assign i /// (malformed args, or JSON null) installs nil. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the value cannot be bridged or installed. +/// Returns a `LuaRuntime` failure if the value cannot be bridged or installed. pub(crate) fn install_writable(lua: &Lua, argv: Option<&Json>) -> Result<()> { let value = match argv { None | Some(Json::Null) => Value::Nil, - Some(json) => lua.to_value(json).map_err(Error::lua)?, + Some(json) => lua.to_value(json).map_err(error::lua)?, }; - lua.globals().raw_set("argv", value).map_err(Error::lua) + lua.globals().raw_set("argv", value).map_err(error::lua) } /// Installs `argv` frozen, the mode of every section but H1: the value H1 /// left behind at the freeze. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the value cannot be bridged or the guard +/// Returns a `LuaRuntime` failure if the value cannot be bridged or the guard /// cannot record it. pub(crate) fn install_frozen(lua: &Lua, argv: Option<&Json>) -> Result<()> { let frozen = match argv { @@ -75,26 +75,26 @@ fn frozen_json_value(lua: &Lua, value: &Json) -> Result { Json::Array(values) => { let data = lua .create_table_with_capacity(values.len(), 0) - .map_err(Error::lua)?; + .map_err(error::lua)?; data.set_metatable(Some(lua.array_metatable())) - .map_err(Error::lua)?; + .map_err(error::lua)?; for (index, value) in values.iter().enumerate() { data.raw_set(index + 1, frozen_json_value(lua, value)?) - .map_err(Error::lua)?; + .map_err(error::lua)?; } freeze_table(lua, data).map(Value::Table) } Json::Object(values) => { let data = lua .create_table_with_capacity(0, values.len()) - .map_err(Error::lua)?; + .map_err(error::lua)?; for (key, value) in values { data.raw_set(key.as_str(), frozen_json_value(lua, value)?) - .map_err(Error::lua)?; + .map_err(error::lua)?; } freeze_table(lua, data).map(Value::Table) } - scalar => lua.to_value(scalar).map_err(Error::lua), + scalar => lua.to_value(scalar).map_err(error::lua), } } @@ -114,5 +114,5 @@ fn freeze_table(lua: &Lua, data: mlua::Table) -> Result { }, "argv is frozen", ) - .map_err(Error::lua) + .map_err(error::lua) } diff --git a/crates/runvil-internal/lua/src/collection.rs b/crates/runvil-internal/lua/src/collection.rs index ddcd77be3..8736f1f33 100644 --- a/crates/runvil-internal/lua/src/collection.rs +++ b/crates/runvil-internal/lua/src/collection.rs @@ -11,7 +11,7 @@ use mlua::{Lua, LuaSerdeExt, Table, Value}; -use crate::error::{Error, Result}; +use crate::error::{self, Result, lua_runtime}; use crate::order::{KeyError, SortKey, sort_key}; /// Enumerates fanout's collection argument as the sequence of members the @@ -20,28 +20,28 @@ use crate::order::{KeyError, SortKey, sort_key}; /// Lua values; the `spawn` request converts each one at its own boundary. /// /// # Errors -/// Returns [`Error::Lua`] when the value is not a table (the message points +/// Returns a `LuaRuntime` failure when the value is not a table (the message points /// at `prompt.list` for the list-section case), when a member is a /// function, userdata, or thread (the error names the member's index), or /// when a hash key is not a string, number, or boolean. pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result { let Value::Table(table) = collection else { - return Err(Error::Lua( + return Err(lua_runtime( "tasks.fanout's second parameter is a collection; for a list section use prompt.list(heading), which returns nil when no visible section has that heading".to_owned(), )); }; let border = table.raw_len(); let members = lua .create_table_with_capacity(border, 0) - .map_err(Error::lua)?; + .map_err(error::lua)?; for index in 1..=border { - let member = table.raw_get::(index).map_err(Error::lua)?; + let member = table.raw_get::(index).map_err(error::lua)?; check_member(&member, &index.to_string())?; - members.raw_set(index, member).map_err(Error::lua)?; + members.raw_set(index, member).map_err(error::lua)?; } let mut pairs: Vec<(SortKey, Value, Value)> = Vec::new(); for pair in table.pairs::() { - let (key, member) = pair.map_err(Error::lua)?; + let (key, member) = pair.map_err(error::lua)?; // The array part was already emitted above, in order. if let Value::Integer(index) = &key && usize::try_from(*index).is_ok_and(|index| (1..=border).contains(&index)) @@ -53,10 +53,10 @@ pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result
// here, so a non-scalar key is rejected before any later code path. let (position, key_label) = sort_key(&key).map_err(|error| match error { KeyError::NotFinite => { - Error::Lua("fanout collection key is not a finite number".to_owned()) + lua_runtime("fanout collection key is not a finite number".to_owned()) } - KeyError::NotUtf8(source) => Error::lua(source), - KeyError::Unsortable(type_name) => Error::Lua(format!( + KeyError::NotUtf8(source) => error::lua(source), + KeyError::Unsortable(type_name) => lua_runtime(format!( "fanout collection key must be a string, number, or boolean, got {type_name}" )), })?; @@ -65,12 +65,12 @@ pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result
} pairs.sort_by(|left, right| left.0.compare(&right.0)); for (position, (_, key, member)) in pairs.into_iter().enumerate() { - let entry = lua.create_table_with_capacity(0, 2).map_err(Error::lua)?; - entry.raw_set("key", key).map_err(Error::lua)?; - entry.raw_set("value", member).map_err(Error::lua)?; + let entry = lua.create_table_with_capacity(0, 2).map_err(error::lua)?; + entry.raw_set("key", key).map_err(error::lua)?; + entry.raw_set("value", member).map_err(error::lua)?; members .raw_set(border + position + 1, entry) - .map_err(Error::lua)?; + .map_err(error::lua)?; } Ok(members) } @@ -82,7 +82,7 @@ pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result
/// spawn's own type error. fn check_member(member: &Value, index: &str) -> Result<()> { match member { - Value::Function(_) | Value::UserData(_) | Value::Thread(_) => Err(Error::Lua(format!( + Value::Function(_) | Value::UserData(_) | Value::Thread(_) => Err(lua_runtime(format!( "fanout collection member at index {index} is a {}; members must be data", member.type_name() ))), @@ -111,9 +111,9 @@ pub fn render_item(item: &serde_json::Value) -> String { /// heading reads exactly as `{{ item }}` would render the same member. /// /// # Errors -/// Returns [`Error::LuaRuntime`] when the value has no JSON form. +/// Returns a `LuaRuntime` failure when the value has no JSON form. pub(crate) fn render_item_value(lua: &Lua, item: Value) -> Result { - let json: serde_json::Value = lua.from_value(item).map_err(Error::lua)?; + let json: serde_json::Value = lua.from_value(item).map_err(error::lua)?; Ok(render_item(&json)) } diff --git a/crates/runvil-internal/lua/src/compactors-tests.rs b/crates/runvil-internal/lua/src/compactors-tests.rs index 5b0d52668..508a3fb29 100644 --- a/crates/runvil-internal/lua/src/compactors-tests.rs +++ b/crates/runvil-internal/lua/src/compactors-tests.rs @@ -5,6 +5,7 @@ use std::num::NonZeroU32; use mlua::Lua; use runvil_model_client::client::{Message, Role}; use runvil_model_client::detail::message_from_validated_parts; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::ids::RoundId; use runvil_types::metrics::Usage; use serde_json::{Value, json}; @@ -13,7 +14,7 @@ use super::{ Compactor, OverflowReason, UsageAnchor, exhaustion_detail, install_compactors, output_reserve, precheck, }; -use crate::Error; +use crate::error::{Error, context_exhausted, lua_runtime}; use crate::messages::MessageList; use crate::scope::RenderedTools; use crate::tests::user; @@ -37,7 +38,7 @@ fn fail_invoked(reason: OverflowReason) -> Error { .expect_err("the fail policy always fails") } -/// Extracts the typed crate error a `compactors.fail` raise brought across +/// Extracts the universal error a `compactors.fail` raise brought across /// the Lua boundary (the typed error, never its flattened text). /// mlua wraps a callback's error in `CallbackError` for the traceback; the /// original external error is kept as its cause. @@ -49,42 +50,44 @@ fn raised_crate_error_ref(error: &mlua::Error) -> &Error { match cause { mlua::Error::ExternalError(cause) => cause .downcast_ref::() - .expect("the raise must hold the typed crate error, not text"), + .expect("the raise must hold the universal error, not text"), other => panic!("expected an ExternalError holding the typed error, got {other:?}"), } } /// The reason a raise held, when it was a context exhaustion. fn raised_reason(error: &mlua::Error) -> &str { - match raised_crate_error_ref(error) { - Error::ContextExhausted { reason } => reason, - other => panic!("expected ContextExhausted, got {other:?}"), - } + let raised = raised_crate_error_ref(error); + assert_eq!( + raised.code, + ErrorCode::ContextExhausted, + "expected ContextExhausted, got {raised:?}" + ); + raised + .facts + .get(&FactKey::Reason) + .and_then(Value::as_str) + .expect("a context exhaustion names its reason") } #[test] fn fail_raises_typed_exhaustion_for_a_provider_overflow() { let error = fail_invoked(OverflowReason::Provider); - assert!( - matches!(&error, Error::ContextExhausted { reason } if reason == "provider"), - "the default policy must raise typed context exhaustion, got {error:?}" + assert_eq!( + error, + context_exhausted("provider"), + "the default policy must raise typed context exhaustion" ); } #[test] fn fail_invocation_raises_typed_exhaustion_holding_the_reason() { for reason in [OverflowReason::Precheck, OverflowReason::Provider] { - let error = fail_invoked(reason); - match error { - Error::ContextExhausted { reason: raised } => { - assert_eq!( - raised, - reason.tag(), - "the exhaustion must hold the invoking reason's tag" - ); - } - other => panic!("expected ContextExhausted, got {other:?}"), - } + assert_eq!( + fail_invoked(reason), + context_exhausted(reason.tag()), + "the exhaustion must hold the invoking reason's tag" + ); } let precheck = fail_invoked(OverflowReason::Precheck).to_string(); let provider = fail_invoked(OverflowReason::Provider).to_string(); @@ -116,12 +119,7 @@ fn compactors_fail_raises_typed_exhaustion_with_any_reason_from_lua() { #[test] fn the_exhaustion_message_details_an_overflow_tag_and_otherwise_gives_the_reason() { - let message = |reason: &str| { - Error::ContextExhausted { - reason: reason.to_owned(), - } - .to_string() - }; + let message = |reason: &str| context_exhausted(reason).message; assert_eq!( message("precheck"), "context exhausted: the request precheck overflowed the model's context window" @@ -150,12 +148,13 @@ fn exhaustion_detail_is_an_overflow_tags_display_and_any_other_reason_itself() { #[test] fn the_fail_policy_invokes_as_typed_exhaustion_for_either_reason() { for reason in [OverflowReason::Precheck, OverflowReason::Provider] { - match fail_invoked(reason) { - Error::ContextExhausted { reason: raised } => { - assert_eq!(raised, reason.tag(), "the policy holds the invoking reason"); - } - other => panic!("expected ContextExhausted, got {other:?}"), - } + let error = fail_invoked(reason); + assert_eq!(error.code, ErrorCode::ContextExhausted); + assert_eq!( + error.facts.get(&FactKey::Reason), + Some(&Value::from(reason.tag())), + "the policy holds the invoking reason" + ); } } @@ -197,14 +196,16 @@ fn compactors_clear_raises_outside_a_compactor_and_leaves_the_records() { .load("compactors.clear('manual')") .exec() .expect_err("compactors.clear refuses outside a compactor"); - assert!( - matches!(raised_crate_error_ref(&error), Error::Lua(message) if message == outside), - "the Lua raise holds the lua-kind refusal: {error:?}" + assert_eq!( + raised_crate_error_ref(&error), + &lua_runtime(outside), + "the Lua raise holds the lua-kind refusal" ); let refused = Compactor::Clear.invoke(&list, "precheck"); - assert!( - matches!(&refused, Err(Error::Lua(message)) if message == outside), - "the policy refuses the same way from Rust: {refused:?}" + assert_eq!( + refused, + Err(lua_runtime(outside)), + "the policy refuses the same way from Rust" ); assert_eq!(list.to_records(), vec![user("one")], "no record is removed"); } diff --git a/crates/runvil-internal/lua/src/compactors.rs b/crates/runvil-internal/lua/src/compactors.rs index 7a9648144..ec6c5c6db 100644 --- a/crates/runvil-internal/lua/src/compactors.rs +++ b/crates/runvil-internal/lua/src/compactors.rs @@ -11,7 +11,7 @@ //! `messages` list in place; the loop then checks that the list shrank and //! retries the round. The shipped [`Compactor`] policies are //! `compactors.fail`, the omitted-compactor default, which always raises -//! typed context exhaustion ([`Error::ContextExhausted`]), and +//! typed context exhaustion (a `ContextExhausted` failure), and //! `compactors.clear`, which removes every record and works only inside a //! compactor. //! @@ -29,7 +29,7 @@ use runvil_model_client::detail::{message_content_value, message_raw_tool_calls} use runvil_types::metrics::Usage; use serde_json::Value; -use crate::error::{Error, Result}; +use crate::error::{self, Result, context_exhausted, lua_runtime}; use crate::messages::MessageList; /// Why a request cannot fit the model's context window. @@ -99,7 +99,7 @@ pub fn exhaustion_detail(reason: &str) -> Cow<'_, str> { /// the loop reads when the compactor argument is omitted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Compactor { - /// `compactors.fail`: always raises [`Error::ContextExhausted`]. + /// `compactors.fail`: always raises a `ContextExhausted` failure. Fail, /// `compactors.clear`: removes every record from the list, leaving its /// last send and its frozen sets. It works only inside a compactor. @@ -111,15 +111,13 @@ impl Compactor { /// caller's own. /// /// # Errors - /// `Fail` always returns [`Error::ContextExhausted`] holding `reason`; - /// `Clear` returns [`Error::Lua`] when no compactor is running over - /// `list`, and leaves it unchanged. + /// `Fail` always returns a `ContextExhausted` failure holding `reason`; + /// `Clear` returns a `LuaRuntime` failure when no compactor is running + /// over `list`, and leaves it unchanged. pub(crate) fn invoke(self, list: &MessageList, reason: &str) -> Result<()> { match self { - Compactor::Fail => Err(Error::ContextExhausted { - reason: reason.to_owned(), - }), - Compactor::Clear if !list.compacting() => Err(Error::Lua( + Compactor::Fail => Err(context_exhausted(reason)), + Compactor::Clear if !list.compacting() => Err(lua_runtime( "compactors.clear works only inside a compactor; empty messages with \ models.compact(compactors.clear, reason)" .to_owned(), @@ -283,7 +281,7 @@ fn message_chars(message: &Message) -> u64 { /// Each policy is a Rust-backed function taking one reason string, the /// overflow's tag or the caller's own. `compactors.fail` raises typed /// context exhaustion holding that reason as an external error, so -/// the [`Error::ContextExhausted`] value crosses the Lua boundary +/// the `ContextExhausted` failure crosses the Lua boundary /// downcastable rather than flattened to text. The loop, which calls the /// selected compactor on an overflow round, normalizes that raise into /// the structured error table before re-raising it, so the kind reaches @@ -294,21 +292,21 @@ fn message_chars(message: &Message) -> u64 { /// section's `messages` list. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if a function or the global install fails. +/// Returns a `LuaRuntime` failure if a function or the global install fails. pub(crate) fn install_compactors(lua: &Lua, globals: &Table, list: &MessageList) -> Result<()> { - let compactors = lua.create_table().map_err(Error::lua)?; + let compactors = lua.create_table().map_err(error::lua)?; for (name, policy) in [("fail", Compactor::Fail), ("clear", Compactor::Clear)] { let list = list.clone(); let function: Function = lua .create_function(move |_lua, reason: String| { policy.invoke(&list, &reason).map_err(mlua::Error::external) }) - .map_err(Error::lua)?; - compactors.raw_set(name, function).map_err(Error::lua)?; + .map_err(error::lua)?; + compactors.raw_set(name, function).map_err(error::lua)?; } globals .raw_set("compactors", compactors) - .map_err(Error::lua) + .map_err(error::lua) } #[cfg(test)] diff --git a/crates/runvil-internal/lua/src/coro-failure.rs b/crates/runvil-internal/lua/src/coro-failure.rs index b95f252df..b49ad049c 100644 --- a/crates/runvil-internal/lua/src/coro-failure.rs +++ b/crates/runvil-internal/lua/src/coro-failure.rs @@ -4,8 +4,8 @@ use mlua::{Function, Lua, Table, Value}; -use crate::error::{Error, Result}; -use crate::error_value::{Raised, raised_from}; +use crate::error::{self, Error, Result}; +use crate::error_value::{raised_from, retained_error}; /// The registry key of the last value a guarded block raised, written by /// the guard's `stash_failure` capture from the message handler at the @@ -31,16 +31,18 @@ pub(super) fn stash_failure_capture(lua: &Lua) -> Result { lua.set_named_registry_value(FAILURE_TRACEBACK_REGISTRY, traceback)?; lua.set_named_registry_value(FAILURE_REGISTRY, failure) }) - .map_err(Error::lua) + .map_err(error::lua) } /// What the guard stashed for the last failure of a guarded block. #[derive(Debug, Default)] pub(crate) struct StashedFailure { - /// The raised value read back as a [`Raised`] when it is a structured - /// error table built on this VM; `None` when it was a plain string, an - /// author's own table, or a Rust callback's wrapped failure. - pub(crate) raised: Option, + /// The raised value read back as the universal error it stands for + /// when it is a structured error table built on this VM: the error a + /// table was rendered from, or the one its fields hold; `None` when it + /// was a plain string, an author's own table, or a Rust callback's + /// wrapped failure. + pub(crate) raised: Option, /// The raised value itself when it was a table, which identifies the /// raise of one particular table. table: Option
, @@ -58,25 +60,39 @@ impl StashedFailure { .is_some_and(|raised| raised.to_pointer() == table.to_pointer()) } - /// Restores the raise-point traceback onto a Lua-raised failure. + /// Restores the raise as the line mapper should read it onto a + /// Lua-raised failure. /// /// mlua appends the coroutine's traceback to a runtime error when the /// coroutine dies, but the guard's re-raise is what kills it, so that /// traceback shows the guard's frame and nothing of the block's. When /// this stash recorded the real one, the appended tail is replaced with - /// it, so the line mapper sees the author's frames. A Rust callback's + /// it, so the line mapper sees the author's frames. A raised error + /// table renders as its prose, so the text before the tail becomes the + /// message of the error the table stands for. A Rust callback's /// wrapped failure has its own traceback and is left untouched. pub(crate) fn restore_traceback<'e>( &self, error: &'e mlua::Error, ) -> std::borrow::Cow<'e, mlua::Error> { - let (mlua::Error::RuntimeError(message), Some(traceback)) = (error, &self.traceback) else { + let mlua::Error::RuntimeError(message) = error else { return std::borrow::Cow::Borrowed(error); }; - let head = message + if self.traceback.is_none() && self.raised.is_none() { + return std::borrow::Cow::Borrowed(error); + } + let (head, tail) = message .rfind("\nstack traceback:") - .map_or(message.as_str(), |at| &message[..at]); - std::borrow::Cow::Owned(mlua::Error::RuntimeError(format!("{head}\n{traceback}"))) + .map_or((message.as_str(), ""), |at| message.split_at(at)); + let head = self + .raised + .as_ref() + .map_or(head, |raised| raised.message.as_str()); + let restored = match &self.traceback { + Some(traceback) => format!("{head}\n{traceback}"), + None => format!("{head}{tail}"), + }; + std::borrow::Cow::Owned(mlua::Error::RuntimeError(restored)) } } @@ -86,27 +102,30 @@ impl StashedFailure { /// was stashed. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if a registry slot cannot be read or +/// Returns a `LuaRuntime` failure if a registry slot cannot be read or /// cleared. pub(crate) fn take_failure(lua: &Lua) -> Result { let failure: Value = lua .named_registry_value(FAILURE_REGISTRY) - .map_err(Error::lua)?; + .map_err(error::lua)?; let traceback: Value = lua .named_registry_value(FAILURE_TRACEBACK_REGISTRY) - .map_err(Error::lua)?; + .map_err(error::lua)?; if matches!(failure, Value::Nil) { return Ok(StashedFailure::default()); } lua.set_named_registry_value(FAILURE_REGISTRY, Value::Nil) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.set_named_registry_value(FAILURE_TRACEBACK_REGISTRY, Value::Nil) - .map_err(Error::lua)?; + .map_err(error::lua)?; let traceback = match traceback { - Value::String(text) => Some(text.to_str().map_err(Error::lua)?.to_owned()), + Value::String(text) => Some(text.to_str().map_err(error::lua)?.to_owned()), _ => None, }; - let raised = raised_from(lua, &failure).map_err(Error::lua)?; + let raised = match retained_error(lua, &failure).map_err(error::lua)? { + Some(retained) => Some(retained), + None => raised_from(lua, &failure).map_err(error::lua)?, + }; let table = match failure { Value::Table(table) => Some(table), _ => None, diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index 28024067a..ab6e2257f 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -31,8 +31,7 @@ use mlua::{Function, Lua, StdLib, Table, Value}; use runvil_model_client::model::ModelBinding; use crate::engine_globals::route_store_to_shims; -use crate::error::SharedSource; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::error_value::{install_error_value, install_normalize_failure}; use crate::hardening::CancelHook; use crate::models::{LuaModelHandle, selection_poisoned}; @@ -40,7 +39,7 @@ use crate::program::LuaProgram; use crate::var::var_snapshot_table; use failure::stash_failure_capture; -pub(crate) use failure::take_failure; +pub(crate) use failure::{StashedFailure, take_failure}; /// The shim chunk's name: `@`-prefixed so PUC renders it verbatim as a file /// path, making unexpected shim errors clickable `file:line:` references. @@ -84,30 +83,20 @@ const STORE_REGISTRY: &str = "runvil.impl_coro.store"; const GUARD_REGISTRY: &str = "runvil.impl_coro.guard"; /// The shim program, compiled once and loaded per VM. Compilation of the -/// bundled source fails only on a crate bug, so the payload is a shareable -/// [`SharedSource`] cause (the crate `Error` is not `Clone`), re-wrapped as -/// a typed error at each install. -static SHIM_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(SHIM_SOURCE, SHIM_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +/// bundled source fails only on a crate bug, and each install clones the +/// failure. +static SHIM_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(SHIM_SOURCE, SHIM_CHUNK_NAME)); /// The `tasks` namespace program, compiled once and loaded per VM after the /// prelude, under the same failure contract. -static TASKS_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(TASKS_SOURCE, TASKS_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +static TASKS_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(TASKS_SOURCE, TASKS_CHUNK_NAME)); /// The `fanout` program, compiled once and loaded per VM after the prelude, /// under the same failure contract. -static FANOUT_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(FANOUT_SOURCE, FANOUT_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +static FANOUT_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(FANOUT_SOURCE, FANOUT_CHUNK_NAME)); /// Installs the yield shims on a VM whose Engine globals already exist. /// @@ -160,7 +149,7 @@ static FANOUT_PROGRAM: LazyLock> = /// call for the trampoline. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the coroutine library, the shim chunk, or any +/// Returns a `LuaRuntime` failure if the coroutine library, the shim chunk, or any /// install step fails. pub(crate) fn install_shim_prelude( lua: &Lua, @@ -170,28 +159,28 @@ pub(crate) fn install_shim_prelude( loop_running: &Arc, cancel_hook: &CancelHook, ) -> Result<()> { - lua.load_std_libs(StdLib::COROUTINE).map_err(Error::lua)?; + lua.load_std_libs(StdLib::COROUTINE).map_err(error::lua)?; let globals = lua.globals(); - let coroutine: Table = globals.raw_get("coroutine").map_err(Error::lua)?; - let yield_fn: Function = coroutine.raw_get("yield").map_err(Error::lua)?; + let coroutine: Table = globals.raw_get("coroutine").map_err(error::lua)?; + let yield_fn: Function = coroutine.raw_get("yield").map_err(error::lua)?; let var_snapshot = lua .create_function(|lua, ()| var_snapshot_table(lua).map_err(mlua::Error::external)) - .map_err(Error::lua)?; - let models: Table = globals.raw_get("models").map_err(Error::lua)?; - let tools: Table = globals.raw_get("tools").map_err(Error::lua)?; - let compactors: Table = globals.raw_get("compactors").map_err(Error::lua)?; - let error_value = install_error_value(lua).map_err(Error::lua)?; + .map_err(error::lua)?; + let models: Table = globals.raw_get("models").map_err(error::lua)?; + let tools: Table = globals.raw_get("tools").map_err(error::lua)?; + let compactors: Table = globals.raw_get("compactors").map_err(error::lua)?; + let error_value = install_error_value(lua).map_err(error::lua)?; let stash_failure = stash_failure_capture(lua)?; - let normalize_failure = install_normalize_failure(lua).map_err(Error::lua)?; + let normalize_failure = install_normalize_failure(lua).map_err(error::lua)?; let (enter_adhoc_handler, leave_adhoc_handler) = adhoc_handler_captures(lua, adhoc_handler_depth)?; let hook = cancel_hook.clone(); let cancel_requested = lua .create_function(move |_, ()| Ok(hook.is_cancelled())) - .map_err(Error::lua)?; + .map_err(error::lua)?; let is_model_handle = lua .create_function(|_, value: Value| Ok(crate::models::is_handle(&value))) - .map_err(Error::lua)?; + .map_err(error::lua)?; let loop_begin = crate::models_loop::loop_begin( lua, max_tool_iterations, @@ -200,7 +189,7 @@ pub(crate) fn install_shim_prelude( cancel_hook, )?; let compact_begin = crate::models_loop::compact_begin(lua, loop_running, cancel_hook)?; - let program = SHIM_PROGRAM.as_ref().map_err(Error::shared)?; + let program = SHIM_PROGRAM.as_ref().map_err(Clone::clone)?; let shims: Table = program .load(lua)? .call(( @@ -218,24 +207,24 @@ pub(crate) fn install_shim_prelude( loop_begin, compact_begin, )) - .map_err(Error::lua)?; - let guard: Function = shims.raw_get("guard").map_err(Error::lua)?; + .map_err(error::lua)?; + let guard: Function = shims.raw_get("guard").map_err(error::lua)?; lua.set_named_registry_value(GUARD_REGISTRY, guard) - .map_err(Error::lua)?; + .map_err(error::lua)?; for name in ["pcall", "xpcall"] { - let protected: Function = shims.raw_get(name).map_err(Error::lua)?; - globals.raw_set(name, protected).map_err(Error::lua)?; + let protected: Function = shims.raw_get(name).map_err(error::lua)?; + globals.raw_set(name, protected).map_err(error::lua)?; } - let call: Function = shims.raw_get("call").map_err(Error::lua)?; - globals.raw_set("call", call).map_err(Error::lua)?; - let helpers: Table = shims.raw_get("helpers").map_err(Error::lua)?; + let call: Function = shims.raw_get("call").map_err(error::lua)?; + globals.raw_set("call", call).map_err(error::lua)?; + let helpers: Table = shims.raw_get("helpers").map_err(error::lua)?; let tasks: Table = TASKS_PROGRAM .as_ref() - .map_err(Error::shared)? + .map_err(Clone::clone)? .load(lua)? .call((yield_fn.clone(), var_snapshot.clone(), helpers.clone())) - .map_err(Error::lua)?; - globals.raw_set("tasks", &tasks).map_err(Error::lua)?; + .map_err(error::lua)?; + globals.raw_set("tasks", &tasks).map_err(error::lua)?; let fanout = fanout_shim( lua, yield_fn, @@ -244,15 +233,15 @@ pub(crate) fn install_shim_prelude( selection, is_model_handle, )?; - tasks.raw_set("fanout", fanout).map_err(Error::lua)?; + tasks.raw_set("fanout", fanout).map_err(error::lua)?; stash_shims(lua, &shims)?; for name in ["loop", "compact"] { - let entry: Function = shims.raw_get(name).map_err(Error::lua)?; - models.raw_set(name, entry).map_err(Error::lua)?; + let entry: Function = shims.raw_get(name).map_err(error::lua)?; + models.raw_set(name, entry).map_err(error::lua)?; } globals .raw_set("coroutine", Value::Nil) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok(()) } @@ -282,16 +271,16 @@ fn fanout_shim( )), } }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let render_item = lua .create_function(|lua, item: Value| { crate::collection::render_item_value(lua, item).map_err(mlua::Error::external) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let (get_selection, set_selection) = selection_captures(lua, selection)?; FANOUT_PROGRAM .as_ref() - .map_err(Error::shared)? + .map_err(Clone::clone)? .load(lua)? .call(( yield_fn, @@ -303,7 +292,7 @@ fn fanout_shim( set_selection, is_model_handle, )) - .map_err(Error::lua) + .map_err(error::lua) } /// Builds the `fanout` chunk's `get_selection()` and `set_selection(value)` @@ -328,7 +317,7 @@ fn selection_captures( None => Ok(Value::Nil), } }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let write = Arc::clone(selection); let set = lua .create_function(move |_, value: Value| -> mlua::Result<()> { @@ -347,7 +336,7 @@ fn selection_captures( *write.lock().map_err(|_| selection_poisoned())? = binding; Ok(()) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok((get, set)) } @@ -356,13 +345,13 @@ fn selection_captures( fn stash_shims(lua: &Lua, shims: &Table) -> Result<()> { #[cfg(any(test, feature = "test-support"))] { - let model_tool_call: Function = shims.raw_get("model_tool_call").map_err(Error::lua)?; + let model_tool_call: Function = shims.raw_get("model_tool_call").map_err(error::lua)?; lua.set_named_registry_value(MODEL_TOOL_CALL_REGISTRY, model_tool_call) - .map_err(Error::lua)?; + .map_err(error::lua)?; } - let store: Table = shims.raw_get("store").map_err(Error::lua)?; + let store: Table = shims.raw_get("store").map_err(error::lua)?; lua.set_named_registry_value(STORE_REGISTRY, store) - .map_err(Error::lua) + .map_err(error::lua) } /// Builds the prelude's `enter_adhoc_handler()` and `leave_adhoc_handler()` @@ -375,14 +364,14 @@ fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function entered.fetch_add(1, Ordering::Relaxed); Ok(()) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let left = Arc::clone(depth); let leave = lua .create_function(move |_, ()| { left.fetch_sub(1, Ordering::Relaxed); Ok(()) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok((enter, leave)) } @@ -396,9 +385,9 @@ fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function /// intact instead of only as mlua's stringification. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the shim prelude never ran on this VM. +/// Returns a `LuaRuntime` failure if the shim prelude never ran on this VM. pub(crate) fn block_guard(lua: &Lua) -> Result { - lua.named_registry_value(GUARD_REGISTRY).map_err(Error::lua) + lua.named_registry_value(GUARD_REGISTRY).map_err(error::lua) } /// Installs the store yield shims onto a VM's `store` table, replacing the @@ -415,18 +404,18 @@ pub(crate) fn block_guard(lua: &Lua) -> Result { /// The executor's section setup and live H1 setup are the only callers. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the shim prelude never ran on this VM, the +/// Returns a `LuaRuntime` failure if the shim prelude never ran on this VM, the /// `store` table is absent, or the install fails. pub fn install_store_shims(lua: &Lua) -> Result<()> { let shims: Table = lua .named_registry_value(STORE_REGISTRY) - .map_err(Error::lua)?; - let store: Table = lua.globals().raw_get("store").map_err(Error::lua)?; + .map_err(error::lua)?; + let store: Table = lua.globals().raw_get("store").map_err(error::lua)?; // `pairs` order is unspecified; every iteration only assigns one named // function into `store`, so the resulting table does not depend on it. for pair in shims.pairs::() { - let (name, function) = pair.map_err(Error::lua)?; - store.raw_set(name, function).map_err(Error::lua)?; + let (name, function) = pair.map_err(error::lua)?; + store.raw_set(name, function).map_err(error::lua)?; } route_store_to_shims(lua, &shims) } diff --git a/crates/runvil-internal/lua/src/detail.rs b/crates/runvil-internal/lua/src/detail.rs deleted file mode 100644 index 899cea422..000000000 --- a/crates/runvil-internal/lua/src/detail.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! The constructor the compiled-program statics wrap their failure with. - -use crate::error::SharedSource; - -/// Wraps a concrete error as a shareable cause. -#[must_use] -pub(crate) fn shared_source_new( - source: impl std::error::Error + Send + Sync + 'static, -) -> SharedSource { - SharedSource(std::sync::Arc::new(source)) -} diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index a7e73f7a4..a69a2769d 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,16 +73,17 @@ 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, None, ) .expect_err("a canned failure fails the preparation"); - assert!( - matches!(error, Error::Tool { .. }), - "the canned failure is the typed tool error, got {error:?}" + assert_eq!( + error, + tool(tool_error(ErrorCode::ToolBackend, "canned failure")), + "the canned failure is the typed tool error" ); assert_eq!( recorder.kinds(), @@ -105,7 +107,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,15 +222,19 @@ 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, ScriptReport { turn: 1 }, ); - assert!( - matches!(failed, Err(Error::Tool { .. })), - "a failed listing is the tool's error: {failed:?}" + assert_eq!( + failed, + Err(tool(tool_error( + ErrorCode::ToolOther, + "the servers are down" + ))), + "a failed listing is the tool's error" ); } diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 312a4ef18..7f3ecd617 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -14,11 +14,12 @@ 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; -use crate::error::{Error, Result}; +use crate::error::{Result, tool}; +use crate::error_value::{ErrorKind, error_kind}; /// The run coordinates a script-initiated dispatch reports under. /// @@ -103,12 +104,12 @@ impl ToolDispatch { /// `call_result` is the tool's own answer, however the caller obtained it. /// /// # Errors -/// Returns [`Error::Tool`] when `call_result` is the tool's failure (its -/// typed error retained as the cause). +/// Returns the tool's failure when `call_result` is one: the tool's error +/// under a message that names the call, with the tool's own prose. 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, @@ -130,12 +131,12 @@ pub fn prepare_dispatch( /// [`ToolDispatch`] under the model's call id. /// /// # Errors -/// Returns [`Error::Tool`] when `call_result` is the tool's failure (its -/// typed error retained as the cause). +/// Returns the tool's failure when `call_result` is one, as +/// [`prepare_dispatch`] does. 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, @@ -149,7 +150,7 @@ pub(crate) fn dispatch_body( lifecycle::TOOL_CALL_FAILED }, ); - let output = call_result.map_err(Error::tool)?; + let output = call_result.map_err(tool)?; // Trust travels with the output: an untrusted result is nonce-wrapped // before it can reach the next model turn or the calling script, except // a listing a script reads, which passes verbatim. Its `listing` flag @@ -195,18 +196,18 @@ pub(crate) fn dispatch_body( /// under the model's call id. /// /// A model-issued call always resumes with content: the tool's own failure -/// ([`Error::Tool`]) becomes the call's result - the error message -/// nonce-wrapped as untrusted - so the model reads the failure and the -/// round continues; the shared rules have already fired the failed -/// observation. Every other dispatch failure still propagates. +/// becomes the call's result - its prose nonce-wrapped as untrusted - so +/// the model reads the failure and the round continues; the shared rules +/// have already fired the failed observation. Every other dispatch failure +/// still propagates. /// /// # Errors /// Propagates any error of [`prepare_dispatch`] other than the tool's own -/// failure ([`Error::Tool`]), which becomes the call's result. +/// failure, which becomes the call's result. 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, @@ -214,8 +215,8 @@ pub fn prepare_model_dispatch( ) -> Result { let outcome = match dispatch_body(binding, vendor, call_result, nonce, emitter, section, None) { Ok(outcome) => outcome, - Err(Error::Tool { message, .. }) => ToolDispatch { - content: nonce.wrap(&message), + Err(failure) if error_kind(&failure) == ErrorKind::Tool => ToolDispatch { + content: nonce.wrap(&failure.prose), trust: OutputTrust::Untrusted, listing: false, }, diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index 6634d6a4e..8412f21a2 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -12,8 +12,8 @@ use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_vfs::{Access, VfsError}; -use crate::error::SharedSource; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result, authored_at, prompt_file}; +use crate::error_value::store_error; use crate::program::LuaProgram; use crate::protocol::{VfsOp, VfsOutcome}; use read::{read_store, read_store_numbered}; @@ -34,11 +34,15 @@ const STORE_PHASE_REGISTRY: &str = "runvil.engine.store_phase"; /// The store dispatcher program, compiled once and loaded per VM under the /// shim programs' failure contract: compiling the bundled source fails /// only on a crate bug. -static STORE_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(STORE_SOURCE, STORE_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +static STORE_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(STORE_SOURCE, STORE_CHUNK_NAME)); + +/// The store failure `cause` with `message` in place of its own text. +fn store_failure(cause: &VfsError, message: String) -> Error { + let mut error = store_error(cause); + error.message = message; + error +} /// Owned reporting context captured by the persistent `store` closures. struct StoreReporter { @@ -76,9 +80,10 @@ struct StoreShared { impl StoreShared { /// Builds one direct store closure taking the Lua arguments `A`: it runs /// `operation` over the store view, records a conflict, reports - /// `outcomes` (success first), and raises a failure as - /// [`Error::store`] under the operation `op` rebuilds from the - /// arguments. + /// `outcomes` (success first), and raises a failure as the store's + /// failure under the message rendered for the operation `op` rebuilds + /// from the arguments, with the author prose of that message in the + /// VM's prompt file. fn closure( &self, lua: &Lua, @@ -94,13 +99,17 @@ impl StoreShared { let reporter = Arc::clone(&self.reporter); let conflict = Arc::clone(&self.conflict); let (success, failure) = outcomes; - lua.create_function(move |_, arguments: A| { + lua.create_function(move |lua, arguments: A| { let result = operation(&view, &arguments); record_store_conflict(&conflict, &result); reporter.report(&result, success, failure); - result.map_err(|source| mlua::Error::external(Error::store(&op(arguments), source))) + result.map_err(|source| { + let message = store_error_message(&op(arguments), &source); + let failure = store_failure(&source, message.clone()); + mlua::Error::external(authored_at(failure, &prompt_file(lua), None, &message)) + }) }) - .map_err(Error::lua) + .map_err(error::lua) } } @@ -224,9 +233,9 @@ fn record_store_conflict( /// `read(path, start, end)` slices); `read_numbered` returns it with /// absolute line numbers under the same optional bounds; `glob` returns an /// array table of matching paths. A [`VfsError`] from any op is raised as -/// a structured error value of kind `store` via -/// [`Error::store`], so it aborts the chunk and a `pcall` caller reads -/// `err.kind`, `err.reason`, and the variant's fields. +/// its store failure, a structured error value of kind `store`, so it +/// aborts the chunk and a `pcall` caller reads `err.kind`, `err.reason`, +/// and the variant's fields. /// /// Every closure captures an `Arc` clone of the section's store view. /// The view locks the backend per call and is synchronous, so nothing is @@ -246,9 +255,10 @@ fn record_store_conflict( /// the install and yields like `store.*` afterward. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the `store` table or any of its +/// Returns a `LuaRuntime` failure if the `store` table or any of its /// functions cannot be created or installed into the sandbox globals, or -/// [`Error::Store`] if the handle the access came from declares no store. +/// the store's failure if the handle the access came from declares no +/// store. pub(crate) fn install_store_table( lua: &Lua, globals: &mlua::Table, @@ -257,16 +267,14 @@ pub(crate) fn install_store_table( section: &str, conflict: &Arc>>, ) -> Result<()> { - let table = lua.create_table().map_err(Error::lua)?; + let table = lua.create_table().map_err(error::lua)?; // One store view for the section's whole life: derived once from the // chain's access, so the closures share it and the strict path rules // run on every operation through it. let shared = StoreShared { view: Arc::new( - runvil_vfs::detail::store_view(access).map_err(|cause| Error::Store { - message: store_probe_message(&cause), - cause, - })?, + runvil_vfs::detail::store_view(access) + .map_err(|cause| store_failure(&cause, store_probe_message(&cause)))?, ), reporter: Arc::new(StoreReporter { emitter: emitter.clone(), @@ -274,13 +282,13 @@ pub(crate) fn install_store_table( }), conflict: Arc::clone(conflict), }; - let program = STORE_PROGRAM.as_ref().map_err(Error::shared)?; - let (dispatcher, phase): (Function, Table) = program.load(lua)?.call(()).map_err(Error::lua)?; + let program = STORE_PROGRAM.as_ref().map_err(Error::clone)?; + let (dispatcher, phase): (Function, Table) = program.load(lua)?.call(()).map_err(error::lua)?; lua.set_named_registry_value(STORE_PHASE_REGISTRY, phase) - .map_err(Error::lua)?; + .map_err(error::lua)?; let install = |name: &str, direct: Function| -> Result<()> { - let function: Function = dispatcher.call((name, direct)).map_err(Error::lua)?; - table.raw_set(name, function).map_err(Error::lua) + let function: Function = dispatcher.call((name, direct)).map_err(error::lua)?; + table.raw_set(name, function).map_err(error::lua) }; let write = shared.closure( @@ -355,7 +363,7 @@ pub(crate) fn install_store_table( )?; install("exists", exists)?; - globals.raw_set("store", table).map_err(Error::lua)?; + globals.raw_set("store", table).map_err(error::lua)?; Ok(()) } @@ -365,13 +373,13 @@ pub(crate) fn install_store_table( /// from here on exactly as `store.*` does. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the VM's store table was never installed or -/// the switch fails. +/// Returns a `LuaRuntime` failure if the VM's store table was never +/// installed or the switch fails. pub(crate) fn route_store_to_shims(lua: &Lua, shims: &Table) -> Result<()> { let phase: Table = lua .named_registry_value(STORE_PHASE_REGISTRY) - .map_err(Error::lua)?; - phase.raw_set("shims", shims.clone()).map_err(Error::lua) + .map_err(error::lua)?; + phase.raw_set("shims", shims.clone()).map_err(error::lua) } /// Executes one validated store operation against the store view: the diff --git a/crates/runvil-internal/lua/src/engine_globals.rs b/crates/runvil-internal/lua/src/engine_globals.rs index 8d8a7b7be..67e10b583 100644 --- a/crates/runvil-internal/lua/src/engine_globals.rs +++ b/crates/runvil-internal/lua/src/engine_globals.rs @@ -13,7 +13,7 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; use runvil_types::emitter::Emitter; use runvil_types::untrusted::GuardNonce; -use crate::error::{Error, QuotaExceeded, QuotaResource, Result}; +use crate::error::{self, QuotaResource, Result, lua_quota}; pub(crate) use store::{install_store_table, route_store_to_shims}; pub use store::{run_store_op, store_error_message, store_probe_message}; @@ -38,9 +38,7 @@ fn log_checkpoint( .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1)) .is_err() { - return Err(mlua::Error::external(QuotaExceeded( - QuotaResource::LogEvent, - ))); + return Err(mlua::Error::external(lua_quota(QuotaResource::LogEvent))); } let Some(Value::String(message)) = arguments.into_iter().next() else { return Err(mlua::Error::external("log message must be a UTF-8 string")); @@ -67,7 +65,7 @@ fn log_checkpoint( }) .is_err() { - return Err(mlua::Error::external(QuotaExceeded(QuotaResource::LogByte))); + return Err(mlua::Error::external(lua_quota(QuotaResource::LogByte))); } emitter.lua(section, &message); Ok(()) @@ -90,8 +88,8 @@ pub(crate) fn install_log( .create_function(move |_, arguments: MultiValue| { log_checkpoint(&emitter, §ion, &log_budget, &log_byte_budget, arguments) }) - .map_err(Error::lua)?; - lua.globals().raw_set("log", log).map_err(Error::lua) + .map_err(error::lua)?; + lua.globals().raw_set("log", log).map_err(error::lua) } fn is_log_line_break_or_control(character: char) -> bool { @@ -105,10 +103,10 @@ pub(crate) fn install_untrusted(lua: &Lua, nonce: &GuardNonce) -> Result<()> { let nonce = nonce.clone(); let untrusted = lua .create_function(move |_, s: String| Ok(nonce.wrap(&s))) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.globals() .raw_set("untrusted", untrusted) - .map_err(Error::lua) + .map_err(error::lua) } /// `ui()` snapshot conversion: a JSON null field reads as nil in author @@ -131,10 +129,11 @@ const UI_SNAPSHOT_OPTIONS: mlua::serde::SerializeOptions = mlua::serde::Serializ /// its own copy of the JSON tree. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the function or the global cannot be created. +/// Returns a `LuaRuntime` failure if the function or the global cannot be +/// created. pub fn install_ui(lua: &Lua, snapshot: Arc) -> Result<()> { let snapshot = lua .create_function(move |lua, ()| lua.to_value_with(snapshot.as_ref(), UI_SNAPSHOT_OPTIONS)) - .map_err(Error::lua)?; - lua.globals().raw_set("ui", snapshot).map_err(Error::lua) + .map_err(error::lua)?; + lua.globals().raw_set("ui", snapshot).map_err(error::lua) } diff --git a/crates/runvil-internal/lua/src/error-author-tests.rs b/crates/runvil-internal/lua/src/error-author-tests.rs new file mode 100644 index 000000000..82c6df624 --- /dev/null +++ b/crates/runvil-internal/lua/src/error-author-tests.rs @@ -0,0 +1,103 @@ +//! Tests for author prose: its shapes with and without a line, the failure +//! text it names, the file a VM names, and which failures crossing into a +//! VM's Lua get it. + +use std::num::NonZeroU32; + +use mlua::Lua; +use runvil_types::error::{ErrorCode, Location}; +use runvil_types::ids::TaskId; + +use super::{authored, authored_at, failure_text, prompt_file, set_prompt_file}; +use crate::error::{internal, lua_runtime, task_not_owned, tool_loop_exhausted}; + +/// The line `n`. +fn line(n: u32) -> NonZeroU32 { + NonZeroU32::new(n).expect("tests pass non-zero lines") +} + +#[test] +fn author_prose_names_the_file_and_the_line_when_it_is_known() { + let located = authored_at( + lua_runtime("the message"), + "agents/a.md", + Some(line(12)), + "boom", + ); + assert_eq!( + located.prose, + format!("agents/a.md:12: boom. {}", ErrorCode::LuaRuntime.prose()) + ); + assert_eq!(located.at, Some(Location::line("agents/a.md", line(12)))); + assert_eq!(located.message, "the message"); + let unlocated = authored_at(lua_runtime("the message"), "agents/a.md", None, "boom"); + assert_eq!( + unlocated.prose, + format!("agents/a.md: boom. {}", ErrorCode::LuaRuntime.prose()) + ); + assert_eq!(unlocated.at, None); +} + +#[test] +fn the_failure_text_drops_the_positions_in_front_and_the_traceback() { + assert_eq!( + failure_text("[string \"line 3\"]:2: boom\nstack traceback:\n\t[C]: in ?"), + "boom" + ); + assert_eq!( + failure_text("[string \"line 3\"]:2: [string \"line 9\"]:1: nested"), + "nested" + ); + assert_eq!(failure_text("a plain refusal"), "a plain refusal"); + assert_eq!( + failure_text("[string \"line 3\"]: no line"), + "[string \"line 3\"]: no line", + "a marker with no line is no position" + ); + assert_eq!(failure_text("a refusal\n"), "a refusal"); +} + +#[test] +fn a_vm_names_the_file_it_was_given_or_prompt() { + let lua = Lua::new(); + assert_eq!(prompt_file(&lua), "prompt"); + set_prompt_file(&lua, "agents/a.md"); + assert_eq!(prompt_file(&lua), "agents/a.md"); +} + +#[test] +fn a_failure_crossing_into_lua_gets_author_prose_only_when_the_prompts_lua_caused_it() { + let lua = Lua::new(); + set_prompt_file(&lua, "agents/a.md"); + let refusal = authored(&lua, lua_runtime("messages must not be empty")); + assert_eq!( + refusal.prose, + format!( + "agents/a.md: messages must not be empty. {}", + ErrorCode::LuaRuntime.prose() + ) + ); + assert_eq!(refusal.message, "messages must not be empty"); + let task: TaskId = "0.1".parse().expect("the test task id parses"); + assert_eq!( + authored(&lua, task_not_owned(&task)).prose, + format!( + "agents/a.md: task `0.1` is not a task this chain owns. {}", + ErrorCode::TaskNotOwned.prose() + ) + ); + assert_eq!( + authored(&lua, tool_loop_exhausted()), + tool_loop_exhausted(), + "a tool failure keeps its remedy prose" + ); + let located = authored_at(lua_runtime("boom"), "agents/a.md", Some(line(4)), "boom"); + assert_eq!( + authored(&lua, located.clone()), + located, + "a located failure keeps its prose" + ); + let fault = internal("a test invariant broke"); + assert_eq!(authored(&lua, fault.clone()), fault); + assert_eq!(fault.prose, ErrorCode::Internal.prose()); +} diff --git a/crates/runvil-internal/lua/src/error-author.rs b/crates/runvil-internal/lua/src/error-author.rs new file mode 100644 index 000000000..7fbafceee --- /dev/null +++ b/crates/runvil-internal/lua/src/error-author.rs @@ -0,0 +1,109 @@ +//! Author prose: the text the model reads for a failure the prompt's own +//! Lua caused, which names the prompt's file, and the line when it is +//! known, before what failed and the code's default prose. The file is the +//! label the VM holds for the prompt its Lua came from. + +use std::num::NonZeroU32; + +use mlua::Lua; +use runvil_types::error::{Error, ErrorCode, Location}; + +/// The file label of a VM that was given none: the label a prompt that +/// came from no file has. +pub(crate) const UNNAMED_FILE: &str = "prompt"; + +/// The file the prompt a VM runs came from, held as the VM's app data. +struct PromptFile(String); + +/// Records `file` as the file the prompt this VM runs came from, which +/// the author prose of its failures names. +pub(crate) fn set_prompt_file(lua: &Lua, file: &str) { + lua.set_app_data(PromptFile(file.to_owned())); +} + +/// The file the prompt this VM runs came from, or `prompt` when the VM +/// was given none. +pub(crate) fn prompt_file(lua: &Lua) -> String { + lua.app_data_ref::() + .map_or_else(|| UNNAMED_FILE.to_owned(), |file| file.0.clone()) +} + +/// Whether a failure with `code` is one the prompt's Lua causes as it +/// runs, whose prose names where: a Lua failure, an exhausted quota, the +/// call-depth cap, a failed substitution, or a misused task. A store +/// failure is one only when a direct `store` closure raised it, which +/// gives it its prose itself. +fn is_authored(code: ErrorCode) -> bool { + matches!( + code, + ErrorCode::LuaRuntime + | ErrorCode::LuaCompile + | ErrorCode::LuaQuota + | ErrorCode::CallDepthExceeded + | ErrorCode::Substitution + | ErrorCode::TasksLive + | ErrorCode::TaskNotOwned + | ErrorCode::TaskConsumed + ) +} + +/// The prose of a failure with `code` in `file`: `{file}:{line}: {what}. +/// {default}` when the line is known, and `{file}: {what}. {default}` +/// otherwise, where `what` is what failed and `default` the code's prose. +fn author_prose(file: &str, line: Option, what: &str, code: ErrorCode) -> String { + match line { + Some(line) => format!("{file}:{line}: {what}. {}", code.prose()), + None => format!("{file}: {what}. {}", code.prose()), + } +} + +/// Gives `error` the author prose for `what` in `file`, and, when `line` +/// is known, the location of that line of `file`. +pub(crate) fn authored_at( + mut error: Error, + file: &str, + line: Option, + what: &str, +) -> Error { + error.at = line.map(|line| Location::line(file, line)); + error.prose = author_prose(file, line, what, error.code); + error +} + +/// `error` as it crosses into this VM's Lua: a failure the prompt's Lua +/// caused that has no location yet gets the author prose of its message's +/// own text in the VM's file; a located one already has its prose, and +/// every other failure keeps its own. +pub(crate) fn authored(lua: &Lua, error: Error) -> Error { + if error.at.is_some() || !is_authored(error.code) { + return error; + } + let what = failure_text(&error.message).to_owned(); + authored_at(error, &prompt_file(lua), None, &what) +} + +/// The failure's own text in `message`: the text before the traceback, +/// without the `[string "..."]:N: ` positions Lua puts in front of it. +pub(crate) fn failure_text(message: &str) -> &str { + let mut text = message + .find("\nstack traceback:") + .map_or(message, |at| &message[..at]); + while let Some(rest) = text.strip_prefix("[string \"") { + let Some(close) = rest.find("\"]:") else { + break; + }; + let after = &rest[close + 3..]; + let digits = after + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(after.len()); + match after[digits..].strip_prefix(": ") { + Some(tail) if digits > 0 => text = tail, + _ => break, + } + } + text.trim_end() +} + +#[cfg(test)] +#[path = "error-author-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/lua/src/error-tests.rs b/crates/runvil-internal/lua/src/error-tests.rs index eb53001d2..5066d80d5 100644 --- a/crates/runvil-internal/lua/src/error-tests.rs +++ b/crates/runvil-internal/lua/src/error-tests.rs @@ -1,83 +1,59 @@ -//! Tests for the internal error: the cause-bearing variants' chains and the -//! quota variant's message. +//! Tests for the crate's failure helpers: an `mlua` failure's text, a +//! refusal that crosses the `mlua` boundary as its typed value, and a tool +//! failure's message and code. -use runvil_types::tools::{ToolError, ToolErrorKind}; +use std::sync::Arc; + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use runvil_types::tools::tool_error; use runvil_vfs::VfsError; +use serde_json::Value; -use super::{Error, QuotaResource}; -use crate::protocol::VfsOp; +use super::{author_refusal, lua, lua_runtime, tool}; -/// The display text of each segment of `error`'s source chain, the error -/// itself first. -fn chain(error: &(dyn std::error::Error + 'static)) -> Vec { - let mut segments = Vec::new(); - let mut next = Some(error); - while let Some(segment) = next { - segments.push(segment.to_string()); - next = segment.source(); - } - segments +#[test] +fn an_mlua_failure_is_a_lua_runtime_failure_with_its_text() { + let error = lua(mlua::Error::RuntimeError("boom".to_owned())); + assert_eq!(error, lua_runtime("runtime error: boom")); + assert_eq!(error.prose, ErrorCode::LuaRuntime.prose()); } #[test] -fn no_segment_of_a_cause_bearing_error_chain_repeats() { - let syntax = mlua::Error::SyntaxError { - message: "unexpected symbol near ".to_owned(), - incomplete_input: true, +fn a_refusal_crosses_the_mlua_boundary_as_its_typed_failure() { + let raised = author_refusal("a refused edit".to_owned()); + let expected = lua_runtime("a refused edit"); + assert_eq!(raised.downcast_ref::(), Some(&expected)); + let framed = mlua::Error::CallbackError { + traceback: "stack traceback:".to_owned(), + cause: Arc::new(raised), }; - let errors = [ - Error::lua(mlua::Error::RuntimeError("boom".to_owned())), - Error::LuaCompile { - location: "section `S` prologue".to_owned(), - source_line: 1, - lua_source: "x =".to_owned(), - message: syntax.to_string(), - cause: Box::new(syntax), - }, - Error::tool(ToolError::new( - ToolErrorKind::Backend, - "the backend refused", - )), - Error::store( - &VfsOp::Read { - path: "a.txt".to_owned(), - start: None, - end: None, - }, - VfsError::NotFound { - path: "a.txt".to_owned(), - }, - ), - ]; - for error in &errors { - assert!( - error.cause().is_some(), - "the cause stays reachable: {error:?}" - ); - assert!( - std::error::Error::source(error).is_none(), - "required no source, since Display already holds the cause, actual a source on \ - {error:?}" - ); - let segments = chain(error); - for (index, segment) in segments.iter().enumerate() { - for later in &segments[index + 1..] { - assert!( - !segment.contains(later.as_str()), - "required each chain segment once, actual {later:?} repeated inside \ - {segment:?}" - ); - } - } - } + assert_eq!( + framed.downcast_ref::(), + Some(&expected), + "a callback frame keeps the typed failure" + ); +} + +#[test] +fn a_tool_failure_keeps_the_tools_error_under_a_message_that_names_the_call() { + let mut failure = tool_error(ErrorCode::ToolBackend, "the backend refused"); + failure.facts.insert(FactKey::Path, Value::from("a.md")); + let error = tool(failure.clone()); + assert_eq!(error.code, ErrorCode::ToolBackend); + assert_eq!( + error.message, + format!("tool call failure: {}", failure.prose) + ); + assert_eq!( + (&error.prose, &error.facts), + (&failure.prose, &failure.facts) + ); } #[test] -fn a_quota_error_names_its_exhausted_resource() { - for (resource, expected) in [ - (QuotaResource::LogEvent, "lua log event quota exceeded"), - (QuotaResource::LogByte, "lua log byte quota exceeded"), - ] { - assert_eq!(Error::LuaQuota { resource }.to_string(), expected); - } +fn a_tool_failure_outside_the_tool_codes_is_tool_other() { + let foreign = crate::store_error(&VfsError::NotFound { + path: "a.md".to_owned(), + }); + assert_eq!(tool(foreign).code, ErrorCode::ToolOther); } diff --git a/crates/runvil-internal/lua/src/error-universal-tests.rs b/crates/runvil-internal/lua/src/error-universal-tests.rs new file mode 100644 index 000000000..bdab95ba3 --- /dev/null +++ b/crates/runvil-internal/lua/src/error-universal-tests.rs @@ -0,0 +1,125 @@ +//! Tests for the failures this crate shares with the Engine: each one's +//! text, its facts, and the table kind and fields it renders. + +use std::borrow::Cow; +use std::num::NonZeroU32; + +use runvil_types::error::{ErrorCode, FactKey, Location}; +use runvil_types::ids::TaskId; +use serde_json::Value; + +use super::*; +use crate::error_value::{ErrorField, ErrorKind, error_fields, error_kind}; + +/// The string field `name` with `value`. +fn string(name: &str, value: &str) -> (String, ErrorField) { + (name.to_owned(), ErrorField::String(value.to_owned())) +} + +/// The task id `path` names. +fn task(path: &str) -> TaskId { + path.parse().expect("the test task id parses") +} + +#[test] +fn a_lua_message_is_a_lua_runtime_failure_with_the_default_prose() { + let error = lua_runtime("a refused edit"); + assert_eq!(error.code, ErrorCode::LuaRuntime); + assert_eq!(error.message, "a refused edit"); + assert_eq!(error.prose, ErrorCode::LuaRuntime.prose()); + assert_eq!(error_kind(&error), ErrorKind::Lua); +} + +#[test] +fn a_compile_failure_names_its_region_as_its_section() { + let region = "section `S` prologue"; + let error = lua_compile(region, 7, "syntax error near '='"); + assert_eq!(error.code, ErrorCode::LuaCompile); + assert_eq!( + error.message, + "lua compilation error at section `S` prologue (line 7): syntax error near '='" + ); + assert_eq!( + error.facts.get(&FactKey::Section), + Some(&Value::from(region)) + ); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (ErrorKind::Lua, Vec::new()) + ); +} + +#[test] +fn the_typed_failures_keep_their_text_and_name_their_facts() { + for (resource, text, tag) in [ + ( + QuotaResource::LogEvent, + "lua log event quota exceeded", + "log_event", + ), + ( + QuotaResource::LogByte, + "lua log byte quota exceeded", + "log_byte", + ), + ] { + let quota = lua_quota(resource); + assert_eq!(quota.message, text); + assert_eq!(quota.facts.get(&FactKey::Reason), Some(&Value::from(tag))); + assert_eq!(error_kind("a), ErrorKind::Lua); + } + let exhausted = context_exhausted("manual"); + assert_eq!(exhausted.message, "context exhausted: manual"); + assert_eq!(error_fields(&exhausted), vec![string("reason", "manual")]); + let interruption = interrupted(); + assert_eq!( + interruption.message, + "interrupted: the run was cancelled or this call was stopped" + ); + assert_eq!(error_kind(&interruption), ErrorKind::Cancelled); + assert_eq!( + tool_loop_exhausted().message, + "tool-call loop did not converge" + ); +} + +#[test] +fn a_task_failure_names_its_task() { + for (error, kind) in [ + (task_not_owned(&task("0.1")), ErrorKind::TaskNotOwned), + (task_consumed(&task("0.1")), ErrorKind::TaskConsumed), + (task_cancelled(&task("0.1")), ErrorKind::Cancelled), + ] { + assert_eq!(error.facts.get(&FactKey::Task), Some(&Value::from("0.1"))); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (kind, vec![string("task", "0.1")]), + "{error:?}" + ); + } + assert_eq!( + task_cancelled(&task("0.1")).message, + "task `0.1` was cancelled" + ); +} + +#[test] +fn an_internal_fault_keeps_its_message_whole_and_its_site() { + let message = format!("tool call counts: id {:?} could not be read", "web/fetch"); + let site = std::panic::Location::caller(); + let error = internal_at(Cow::Owned(message.clone()), site); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!( + error.message, + format!("internal invariant violated: {message}") + ); + assert_eq!(error.prose, ErrorCode::Internal.prose()); + assert_eq!( + error.at, + Some(Location::line( + site.file(), + NonZeroU32::new(site.line()).expect("a line is non-zero") + )) + ); + assert_eq!(error_kind(&error), ErrorKind::Internal); +} diff --git a/crates/runvil-internal/lua/src/error-universal.rs b/crates/runvil-internal/lua/src/error-universal.rs new file mode 100644 index 000000000..6422cf893 --- /dev/null +++ b/crates/runvil-internal/lua/src/error-universal.rs @@ -0,0 +1,176 @@ +//! The failures this crate raises that the Engine raises too, through the +//! same constructors, so each failure has one text. + +use std::borrow::Cow; +use std::num::NonZeroU32; + +use runvil_types::error::{Error, ErrorCode, FactKey, Location}; +use runvil_types::ids::TaskId; +use serde_json::Value; + +use super::QuotaResource; + +#[cfg(test)] +#[path = "error-universal-tests.rs"] +mod tests; + +/// Builds a failure with `code`, `message`, and the code's default prose. +fn failure(code: ErrorCode, message: impl Into) -> Error { + Error::new(code, message, code.prose()) +} + +/// Gives `error` the fact `key` with `value`. +fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { + error.facts.insert(key, value.into()); + error +} + +/// A condition the prompt author caused and can fix in Lua, such as an +/// empty `messages` list or a heading that names no section, as a +/// `LuaRuntime` failure. The message is the specific failure as a noun +/// phrase, with no `lua error:` label, since a failed run classifies it as +/// a Lua failure. +#[must_use] +pub fn lua_runtime(message: impl Into) -> Error { + failure(ErrorCode::LuaRuntime, message) +} + +/// The failure of a run that was cancelled, of a call whose effect the +/// caller dropped, or of a block that raised a caught `cancelled` error +/// value with no `task` again after another suspending call. +#[must_use] +pub fn interrupted() -> Error { + failure( + ErrorCode::Interrupted, + "interrupted: the run was cancelled or this call was stopped", + ) +} + +/// `compactors.fail` exhausted the model's context window, with `reason`: +/// `precheck` or `provider` after an overflow, else the caller's own. The +/// `Reason` fact is `reason`. +#[must_use] +pub fn context_exhausted(reason: &str) -> Error { + let text = format!( + "context exhausted: {}", + crate::compactors::exhaustion_detail(reason) + ); + with_fact( + failure(ErrorCode::ContextExhausted, text), + FactKey::Reason, + reason, + ) +} + +/// An exhausted Lua quota on `resource`, whose tag (`log_event` or +/// `log_byte`) is the `Reason` fact. +#[must_use] +pub fn lua_quota(resource: QuotaResource) -> Error { + let tag = match resource { + QuotaResource::LogEvent => "log_event", + QuotaResource::LogByte => "log_byte", + }; + with_fact( + failure( + ErrorCode::LuaQuota, + format!("lua {resource} quota exceeded"), + ), + FactKey::Reason, + tag, + ) +} + +/// A task operation that named `task`, which the caller does not own. An +/// id that names no task at all is refused the same way, so a caller +/// learns nothing about tasks it never started. +#[must_use] +pub fn task_not_owned(task: &TaskId) -> Error { + task_failure( + ErrorCode::TaskNotOwned, + format!("task `{task}` is not a task this chain owns"), + task, + ) +} + +/// A wait that named `task`, whose result was already delivered once. +#[must_use] +pub fn task_consumed(task: &TaskId) -> Error { + task_failure( + ErrorCode::TaskConsumed, + format!("task `{task}` was already delivered: a task's result is taken by one wait"), + task, + ) +} + +/// The failure a wait delivers for `task`, which its owner cancelled +/// instead of letting it end on its own. +#[must_use] +pub fn task_cancelled(task: &TaskId) -> Error { + task_failure( + ErrorCode::TaskCancelled, + format!("task `{task}` was cancelled"), + task, + ) +} + +/// A failure with `code` and `message` about `task`, its `Task` fact. +fn task_failure(code: ErrorCode, message: String, task: &TaskId) -> Error { + with_fact(failure(code, message), FactKey::Task, task.to_string()) +} + +/// The tool-call loop ran its iteration cap without a final text reply. +#[must_use] +pub fn tool_loop_exhausted() -> Error { + failure( + ErrorCode::ToolLoopExhausted, + "tool-call loop did not converge", + ) +} + +/// An internal invariant failure constructed at `site`: a state the +/// surrounding code has already guaranteed cannot occur, surfaced rather +/// than skipped. Its location is that Rust source position, which a failed +/// run's message ends with. The message is owned when it names a value. +#[must_use] +pub fn internal_at( + message: impl Into>, + site: &'static std::panic::Location<'static>, +) -> Error { + let text = format!("internal invariant violated: {}", message.into()); + let mut error = failure(ErrorCode::Internal, text); + error.at = NonZeroU32::new(site.line()).map(|line| Location::line(site.file(), line)); + error +} + +/// Lua source at the prompt region `region`, which starts at prompt line +/// `source_line`, that did not compile, with the compiler's `diagnostic`. +/// The `Section` fact names the region; the compile path gives it its +/// location and prose. +pub(crate) fn lua_compile(region: &str, source_line: u32, diagnostic: &str) -> Error { + with_fact( + failure( + ErrorCode::LuaCompile, + format!("lua compilation error at {region} (line {source_line}): {diagnostic}"), + ), + FactKey::Section, + region, + ) +} + +/// A dispatched tool's own failure, `error`, under a message that names +/// the tool call and its model-safe prose; the prose, location, and facts +/// stay the tool's. A code outside the tool codes is `ToolOther`, so a +/// tool's failure always classifies as one. +pub(crate) fn tool(mut error: Error) -> Error { + if !matches!( + error.code, + ErrorCode::ToolInvalidArguments + | ErrorCode::ToolBackend + | ErrorCode::ToolTransport + | ErrorCode::ToolOther + ) { + error.code = ErrorCode::ToolOther; + } + error.message = format!("tool call failure: {}", error.prose); + error +} diff --git a/crates/runvil-internal/lua/src/error-value-classify.rs b/crates/runvil-internal/lua/src/error-value-classify.rs index 7aca82d78..bfce35890 100644 --- a/crates/runvil-internal/lua/src/error-value-classify.rs +++ b/crates/runvil-internal/lua/src/error-value-classify.rs @@ -2,82 +2,54 @@ //! author code, and the `normalize_failure` capture built on it. use mlua::{Function, Lua, Value}; +use runvil_types::error::{Error, ErrorCode}; -use super::{ErrorField, ErrorKind, ErrorValue, error_table}; -use crate::error::Error; +use super::error_table; +use crate::error::{ + authored, authored_at, failure_text, lua_runtime, prompt_file, root_cause, root_text, +}; -/// A Rust callback's failure classified for the error table: the kind, the -/// message `tostring` rendered before (the root cause's display, without -/// the traceback mlua appends), and the kind's fields. +/// A Rust callback's failure as the universal error its table renders. /// /// mlua raises a callback's `Err` into Lua as an opaque userdata whose /// `tostring` is the error's display; author code cannot index it, so /// `err.kind` fails at exactly the call sites that fail directly from -/// Rust. The classifier reads the typed [`Error`] back out of the mlua +/// Rust. The classifier reads the universal error back out of the mlua /// wrapper when the callback raised one, and otherwise sorts mlua's own /// variants: an authoring or argument failure (a runtime or syntax error, /// a bad argument, a value conversion, an external error of another type, -/// an exhausted memory quota) is `lua`; anything else is mlua's own -/// machinery failing, which the author cannot act on, so it is `internal`. -struct Classified { - kind: ErrorKind, - message: String, - fields: Vec<(String, ErrorField)>, -} - -impl Classified { - fn from_mlua(error: &mlua::Error) -> Classified { - if let Some(typed) = error.downcast_ref::() { - return Classified { - kind: typed.kind(), - message: typed.to_string(), - fields: typed.fields(), - }; - } - let root = root_cause(error); - let kind = match root { - mlua::Error::RuntimeError(_) - | mlua::Error::SyntaxError { .. } - | mlua::Error::MemoryError(_) - | mlua::Error::BadArgument { .. } - | mlua::Error::FromLuaConversionError { .. } - | mlua::Error::SerializeError(_) - | mlua::Error::DeserializeError(_) - | mlua::Error::ExternalError(_) => ErrorKind::Lua, - _ => ErrorKind::Internal, - }; - Classified { - kind, - message: root.to_string(), - fields: Vec::new(), - } +/// an exhausted memory quota) is `LuaRuntime`, with the root error's text +/// as its message and the author prose of what failed; anything else is +/// mlua's own machinery failing, which the author cannot act on, so it is +/// `Internal`. A universal failure the prompt's Lua caused gets its author +/// prose. +fn classified(lua: &Lua, error: &mlua::Error) -> Error { + if let Some(typed) = error.downcast_ref::() { + return authored(lua, typed.clone()); } -} - -/// Strips mlua's wrapping layers (the callback frame and any context) down -/// to the error a callback returned. -fn root_cause(error: &mlua::Error) -> &mlua::Error { - match error { - mlua::Error::CallbackError { cause, .. } | mlua::Error::WithContext { cause, .. } => { - root_cause(cause) + let root = root_cause(error); + match root { + mlua::Error::RuntimeError(_) + | mlua::Error::SyntaxError { .. } + | mlua::Error::MemoryError(_) + | mlua::Error::BadArgument { .. } + | mlua::Error::FromLuaConversionError { .. } + | mlua::Error::SerializeError(_) + | mlua::Error::DeserializeError(_) + | mlua::Error::ExternalError(_) => { + let what = root_text(error); + authored_at( + lua_runtime(root.to_string()), + &prompt_file(lua), + None, + failure_text(&what), + ) } - other => other, - } -} - -impl std::fmt::Display for Classified { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) - } -} - -impl ErrorValue for Classified { - fn kind(&self) -> ErrorKind { - self.kind - } - - fn fields(&self) -> Vec<(String, ErrorField)> { - self.fields.clone() + _ => Error::new( + ErrorCode::Internal, + root.to_string(), + ErrorCode::Internal.prose(), + ), } } @@ -98,10 +70,7 @@ pub(crate) fn install_normalize_failure(lua: &Lua) -> mlua::Result { /// unchanged, for the capture and the loop's compactor rule alike. pub(crate) fn normalized(lua: &Lua, value: Value) -> mlua::Result { match value { - Value::Error(error) => Ok(Value::Table(error_table( - lua, - &Classified::from_mlua(&error), - )?)), + Value::Error(error) => Ok(Value::Table(error_table(lua, &classified(lua, &error))?)), other => Ok(other), } } diff --git a/crates/runvil-internal/lua/src/error-value-raised-tests.rs b/crates/runvil-internal/lua/src/error-value-raised-tests.rs new file mode 100644 index 000000000..03c13cc5f --- /dev/null +++ b/crates/runvil-internal/lua/src/error-value-raised-tests.rs @@ -0,0 +1,221 @@ +//! Tests for error tables in both directions: every table's `code`, a +//! table read back as the universal error it stands for, the code a +//! shim-built table and a table without a code get from their kind, and +//! the error a rendered table keeps beside it. + +use mlua::{Function, Lua, Table, Value}; +use runvil_types::error::{Error, ErrorCode, FactKey}; +use runvil_types::ids::TaskId; + +use super::super::tests::every_store_failure; +use super::super::{error_table, install_error_value, retained_error}; +use super::raised_from; +use crate::error::{ + QuotaResource, context_exhausted, internal, lua_quota, lua_runtime, task_not_owned, tool, + tool_loop_exhausted, +}; + +/// The task id `path` names. +fn task(path: &str) -> TaskId { + path.parse().expect("the test task id parses") +} + +/// An error with `code`, `message`, a prose of its own, and `facts`. +fn failure(code: ErrorCode, facts: &[(FactKey, serde_json::Value)]) -> Error { + let mut error = Error::new(code, "the message", "the prose"); + error.facts.extend(facts.iter().cloned()); + error +} + +/// Errors across the families whose facts their tables' fields hold. +fn located_by_their_fields() -> Vec { + let mut errors = vec![ + context_exhausted("manual"), + failure( + ErrorCode::EmptyReply, + &[(FactKey::FinishReason, "length".into())], + ), + failure(ErrorCode::OutOfScopeTool, &[(FactKey::Name, "echo".into())]), + failure( + ErrorCode::TasksLive, + &[(FactKey::Tasks, serde_json::Value::from(vec!["1", "1.2"]))], + ), + task_not_owned(&task("0.4")), + lua_runtime("a refused edit"), + tool_loop_exhausted(), + ]; + errors.extend( + every_store_failure() + .iter() + .map(|(failure, _)| crate::store_error(failure)), + ); + errors +} + +/// The string field `name` of `table`. +fn field(table: &Table, name: &str) -> Option { + table.raw_get(name).expect("the field reads") +} + +/// The shim's `error_value(kind, fields)` capture on `lua`. +fn error_value(lua: &Lua) -> Function { + install_error_value(lua).expect("the capture builds") +} + +/// The table `error_value(kind, fields)` builds, with `fields` as strings. +fn shim_table(lua: &Lua, kind: &str, fields: &[(&str, &str)]) -> Table { + let given = lua.create_table().expect("the fields build"); + for (name, value) in fields { + given.raw_set(*name, *value).expect("a field sets"); + } + error_value(lua) + .call((kind, given)) + .expect("the shim builds the table") +} + +/// What `table` reads back as. +fn read_back(lua: &Lua, table: &Table) -> Error { + raised_from(lua, &Value::Table(table.clone())) + .expect("the table reads") + .expect("the table is an error table") +} + +#[test] +fn every_table_carries_its_errors_code_beside_its_kind() { + let lua = Lua::new(); + let mut errors = located_by_their_fields(); + errors.extend([ + lua_quota(QuotaResource::LogByte), + tool(failure(ErrorCode::ToolBackend, &[])), + failure(ErrorCode::Transport, &[]), + failure(ErrorCode::CallDepthExceeded, &[]), + internal("a test invariant broke"), + ]); + for error in errors { + let table = error_table(&lua, &error).expect("the table renders"); + assert_eq!( + field(&table, "code").as_deref(), + Some(error.code.tag()), + "{error:?}" + ); + assert_eq!( + field(&table, "message").as_deref(), + Some(error.prose.as_str()), + "the table's message is the prose: {error:?}" + ); + } +} + +#[test] +fn a_table_read_back_keeps_its_code_prose_and_facts() { + let lua = Lua::new(); + for error in located_by_their_fields() { + let table = error_table(&lua, &error).expect("the table renders"); + let back = read_back(&lua, &table); + assert_eq!( + (back.code, &back.prose, &back.facts), + (error.code, &error.prose, &error.facts), + "{error:?}" + ); + assert_eq!(back.message, error.prose, "the message is the table's"); + } +} + +#[test] +fn a_rendered_table_keeps_its_error_whole_and_a_shim_table_none() { + let lua = Lua::new(); + let error = context_exhausted("precheck"); + let table = Value::Table(error_table(&lua, &error).expect("the table renders")); + assert_eq!( + retained_error(&lua, &table).expect("the table reads"), + Some(error) + ); + let shim = Value::Table(shim_table(&lua, "lua", &[("message", "x")])); + assert_eq!(retained_error(&lua, &shim).expect("the table reads"), None); + assert_eq!( + retained_error(&lua, &Value::Integer(1)).expect("a value reads"), + None + ); +} + +#[test] +fn a_shim_built_table_gets_the_code_its_kind_gives_unless_its_fields_name_one() { + let lua = Lua::new(); + for (kind, fields, code) in [ + ("lua", vec![("message", "x")], ErrorCode::LuaRuntime), + ("store", vec![("reason", "not_found")], ErrorCode::NotFound), + ("cancelled", vec![("task", "0.1")], ErrorCode::TaskCancelled), + ("cancelled", vec![], ErrorCode::Interrupted), + ("tool_loop_exhausted", vec![], ErrorCode::ToolLoopExhausted), + ("lua", vec![("code", "lua_quota")], ErrorCode::LuaQuota), + ] { + let table = shim_table(&lua, kind, &fields); + assert_eq!( + field(&table, "code").as_deref(), + Some(code.tag()), + "{kind} {fields:?}" + ); + assert_eq!(read_back(&lua, &table).code, code, "{kind} {fields:?}"); + } + let table = shim_table(&lua, "lua", &[("message", "the shim's own text")]); + let back = read_back(&lua, &table); + assert_eq!( + (back.message.as_str(), back.prose.as_str()), + ("the shim's own text", "the shim's own text") + ); +} + +#[test] +fn a_table_with_no_usable_code_reads_back_as_the_failure_its_kind_names() { + let lua = Lua::new(); + for (kind, fields, code) in [ + ( + "context_exhausted", + vec![("reason", "manual")], + ErrorCode::ContextExhausted, + ), + ("context_exhausted", vec![], ErrorCode::LuaRuntime), + ( + "cancelled", + vec![("task", "not a task")], + ErrorCode::Interrupted, + ), + ( + "task_consumed", + vec![("task", "0.4")], + ErrorCode::TaskConsumed, + ), + ("task_consumed", vec![], ErrorCode::LuaRuntime), + ("empty_model_reply", vec![], ErrorCode::EmptyReply), + ("tool", vec![], ErrorCode::ToolOther), + ("store", vec![("reason", "determinism")], ErrorCode::Backend), + ("store", vec![], ErrorCode::Backend), + ("model_failure", vec![], ErrorCode::LuaRuntime), + ("internal", vec![], ErrorCode::LuaRuntime), + ] { + let table = shim_table(&lua, kind, &fields); + table + .raw_set("code", "no such code") + .expect("the code field sets"); + assert_eq!(read_back(&lua, &table).code, code, "{kind} {fields:?}"); + } + let table = shim_table(&lua, "cancelled", &[("task", "0.1"), ("message", "gone")]); + table + .raw_set("code", Value::Nil) + .expect("the code field clears"); + let back = read_back(&lua, &table); + assert_eq!(back.code, ErrorCode::TaskCancelled); + assert_eq!(back.facts.get(&FactKey::Task), Some(&"0.1".into())); + assert_eq!(back.message, "gone"); +} + +#[test] +fn an_authors_own_table_is_no_error_table() { + let lua = Lua::new(); + let own = lua.create_table().expect("the table builds"); + own.raw_set("kind", "lua").expect("a field sets"); + assert_eq!( + raised_from(&lua, &Value::Table(own)).expect("the table reads"), + None + ); +} diff --git a/crates/runvil-internal/lua/src/error-value-raised.rs b/crates/runvil-internal/lua/src/error-value-raised.rs new file mode 100644 index 000000000..870ab08c7 --- /dev/null +++ b/crates/runvil-internal/lua/src/error-value-raised.rs @@ -0,0 +1,115 @@ +//! A raised error table read back as the universal error it stands for: +//! its code, its message as both message and prose, and the facts its +//! fields hold. + +use std::collections::BTreeMap; + +use mlua::{Lua, Table, Value}; +use runvil_types::error::{Error, ErrorCode}; +use runvil_types::ids::TaskId; + +use super::universal::field_facts; +use super::{ErrorField, ErrorKind, is_error_table}; + +/// The code a table of `kind` stands for when it names none, where +/// `field` reads one of its string fields: a `cancelled` table with a +/// `task` field that parses is that task's cancellation and without one +/// an interruption; a `task_not_owned` or `task_consumed` table is its +/// task refusal only with such a field; a `context_exhausted` table is +/// context exhaustion only with a `reason`; a `store` table is the store +/// code its `reason` tag names, or `Backend`; and a kind whose failure +/// needs structure the table does not hold is `LuaRuntime`. +fn kind_code(kind: ErrorKind, field: impl Fn(&str) -> Option) -> ErrorCode { + let task = || field("task").is_some_and(|task| task.parse::().is_ok()); + match kind { + ErrorKind::ToolLoopExhausted => ErrorCode::ToolLoopExhausted, + ErrorKind::ContextExhausted if field("reason").is_some() => ErrorCode::ContextExhausted, + ErrorKind::EmptyModelReply => ErrorCode::EmptyReply, + ErrorKind::Cancelled if task() => ErrorCode::TaskCancelled, + ErrorKind::Cancelled => ErrorCode::Interrupted, + ErrorKind::Tool => ErrorCode::ToolOther, + ErrorKind::TaskNotOwned if task() => ErrorCode::TaskNotOwned, + ErrorKind::TaskConsumed if task() => ErrorCode::TaskConsumed, + ErrorKind::Store => { + let store = ErrorCode::NotFound.number()..=ErrorCode::Backend.number(); + field("reason") + .and_then(|reason| ErrorCode::from_tag(&reason)) + .filter(|code| store.contains(&code.number())) + .unwrap_or(ErrorCode::Backend) + } + ErrorKind::ContextExhausted + | ErrorKind::TaskNotOwned + | ErrorKind::TaskConsumed + | ErrorKind::OutOfScopeTool + | ErrorKind::UnboundTool + | ErrorKind::ModelOnlyTool + | ErrorKind::TasksLive + | ErrorKind::Lua + | ErrorKind::ModelFailure + | ErrorKind::ModelRequired + | ErrorKind::Internal => ErrorCode::LuaRuntime, + } +} + +/// The code an error table of `kind` holding `fields` names: its `code` +/// field's, when that is a code's tag, and else the one its kind gives. A +/// field that cannot be read as a string names nothing. +pub(super) fn table_code(kind: ErrorKind, fields: &Table) -> ErrorCode { + let string = |name: &str| match fields.raw_get::(name) { + Ok(Value::String(text)) => text.to_str().ok().map(|text| text.to_owned()), + _ => None, + }; + string("code") + .and_then(|tag| ErrorCode::from_tag(&tag)) + .unwrap_or_else(|| kind_code(kind, string)) +} + +/// Reads a raised Lua value back as the universal error it stands for, +/// when it is an error table built on this VM. The code is the one +/// [`table_code`] names; the message and the prose are both the table's +/// `message`; and the facts are its fields under the fact names the +/// table's fields hold for that code. Any other value is `None`. +/// +/// # Errors +/// Returns an `mlua` error if the table's fields cannot be read. +pub(crate) fn raised_from(lua: &Lua, value: &Value) -> mlua::Result> { + let Value::Table(table) = value else { + return Ok(None); + }; + if !is_error_table(lua, table)? { + return Ok(None); + } + let Value::String(tag) = table.raw_get::("kind")? else { + return Ok(None); + }; + let Some(kind) = ErrorKind::from_tag(&tag.to_str()?) else { + return Ok(None); + }; + let message = match table.raw_get::("message")? { + Value::String(message) => message.to_str()?.to_owned(), + _ => kind.tag().to_owned(), + }; + // `pairs` order is unspecified, but `fields` is a `BTreeMap`, so the + // facts never depend on it. + let mut fields = BTreeMap::new(); + for pair in table.pairs::() { + let (name, value) = pair?; + if matches!(name.as_str(), "kind" | "code" | "message") { + continue; + } + let field = match value { + Value::String(value) => ErrorField::String(value.to_str()?.to_owned()), + Value::Integer(value) => ErrorField::Integer(value), + _ => continue, + }; + fields.insert(name, field); + } + let code = table_code(kind, table); + let mut error = Error::new(code, message.clone(), message); + error.facts = field_facts(code, &fields); + Ok(Some(error)) +} + +#[cfg(test)] +#[path = "error-value-raised-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/lua/src/error-value-store.rs b/crates/runvil-internal/lua/src/error-value-store.rs index 44f670dfe..b93c37398 100644 --- a/crates/runvil-internal/lua/src/error-value-store.rs +++ b/crates/runvil-internal/lua/src/error-value-store.rs @@ -1,8 +1,24 @@ //! 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 std::collections::BTreeMap; + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; use super::ErrorField; +/// Each store field's name in a Lua error table beside the fact that holds +/// it in a universal error, in the order a table lists them. +const STORE_FACTS: [(&str, FactKey); 5] = [ + ("path", FactKey::Path), + ("valid_up_to", FactKey::ValidUpTo), + ("rule", FactKey::Rule), + ("anchor", FactKey::Anchor), + ("count", FactKey::Count), +]; + /// Returns the variant's own fields, without `reason`, in the shape /// `store_error_value_fields` documents. fn store_error_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> { @@ -74,3 +90,95 @@ 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 Some(&(_, key)) = STORE_FACTS.iter().find(|(known, _)| *known == name) else { + continue; + }; + let value = match field { + ErrorField::String(text) => Value::from(text), + ErrorField::Integer(number) => Value::from(number), + }; + converted.facts.insert(key, value); + } + converted +} + +/// Returns the fields a store failure's universal error renders into its +/// Lua error table, the inverse of [`store_error`]: the code's tag as +/// `reason`, then each store fact under the field name +/// [`store_error_value_fields`] gives it, a string fact as a string and a +/// count or an offset as an integer. +pub(super) fn store_fact_fields(error: &Error) -> Vec<(String, ErrorField)> { + let mut fields = vec![( + "reason".to_owned(), + ErrorField::String(error.code.tag().to_owned()), + )]; + for (name, key) in STORE_FACTS { + let field = match error.facts.get(&key) { + Some(Value::String(text)) => ErrorField::String(text.clone()), + Some(value) => match value.as_i64() { + Some(number) => ErrorField::Integer(number), + None => continue, + }, + None => continue, + }; + fields.push((name.to_owned(), field)); + } + fields +} + +/// Returns the store facts a store failure's Lua error table `fields` +/// hold, the inverse of [`store_fact_fields`]: each store field under its +/// fact, a string field as a string and an integer field as a number. +/// `reason` is the code's tag, not a fact. +pub(super) fn store_field_facts(fields: &BTreeMap) -> BTreeMap { + STORE_FACTS + .iter() + .filter_map(|&(name, key)| { + let value = match fields.get(name)? { + ErrorField::String(text) => Value::from(text.as_str()), + ErrorField::Integer(number) => Value::from(*number), + }; + Some((key, value)) + }) + .collect() +} + +/// 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..41fafb772 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" +)] +pub(super) 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-universal-tests.rs b/crates/runvil-internal/lua/src/error-value-universal-tests.rs new file mode 100644 index 000000000..f5119e255 --- /dev/null +++ b/crates/runvil-internal/lua/src/error-value-universal-tests.rs @@ -0,0 +1,160 @@ +//! Tests for the universal error's Lua error value: the kind each code +//! renders as, the text its table holds, and the fields its facts give. + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; + +use super::super::tests::every_store_failure; +use super::super::{ErrorField, ErrorKind, store_error, store_error_value_fields}; +use super::{error_fields, error_kind}; + +/// An error with `code` and `facts`, whose message and prose say which +/// they are. +fn error(code: ErrorCode, facts: &[(FactKey, Value)]) -> Error { + let mut error = Error::new(code, "the message", "the prose"); + error.facts.extend(facts.iter().cloned()); + error +} + +/// What `error`'s table holds: its kind, its message, which is the error's +/// prose, and its fields. +fn rendered(error: &Error) -> (ErrorKind, String, Vec<(String, ErrorField)>) { + (error_kind(error), error.prose.clone(), error_fields(error)) +} + +/// The string field `name` with `value`. +fn string(name: &str, value: &str) -> (String, ErrorField) { + (name.to_owned(), ErrorField::String(value.to_owned())) +} + +#[test] +fn a_task_refusal_renders_its_kind_and_its_task_field() { + for (code, kind) in [ + (ErrorCode::TaskNotOwned, ErrorKind::TaskNotOwned), + (ErrorCode::TaskConsumed, ErrorKind::TaskConsumed), + (ErrorCode::TaskCancelled, ErrorKind::Cancelled), + ] { + let refusal = error(code, &[(FactKey::Task, Value::from("1.2"))]); + assert_eq!( + rendered(&refusal), + (kind, "the prose".to_owned(), vec![string("task", "1.2")]), + "{code:?}" + ); + } + let interrupted = error(ErrorCode::Interrupted, &[]); + assert_eq!( + rendered(&interrupted), + (ErrorKind::Cancelled, "the prose".to_owned(), Vec::new()) + ); +} + +#[test] +fn leaked_tasks_render_as_one_string_joined_by_commas() { + let leaked = error( + ErrorCode::TasksLive, + &[(FactKey::Tasks, Value::from(vec!["1", "1.2"]))], + ); + assert_eq!(error_kind(&leaked), ErrorKind::TasksLive); + assert_eq!(error_fields(&leaked), vec![string("tasks", "1, 1.2")]); +} + +#[test] +fn every_store_failure_renders_the_fields_its_variant_gives() { + for (failure, code) in every_store_failure() { + let converted = store_error(&failure); + assert_eq!( + rendered(&converted), + ( + ErrorKind::Store, + converted.prose.clone(), + store_error_value_fields(&failure) + ), + "{code:?}" + ); + } +} + +#[test] +fn an_empty_reply_holds_its_prose_and_its_finish_reason() { + let empty = error( + ErrorCode::EmptyReply, + &[(FactKey::FinishReason, Value::from("length"))], + ); + assert_eq!( + rendered(&empty), + ( + ErrorKind::EmptyModelReply, + "the prose".to_owned(), + vec![string("finish_reason", "length")] + ) + ); + let bare = error(ErrorCode::EmptyReply, &[]); + assert_eq!(error_fields(&bare), Vec::new()); +} + +#[test] +fn a_model_failure_holds_its_prose_and_no_fields() { + let failure = error(ErrorCode::Transport, &[(FactKey::Count, Value::from(3))]); + assert_eq!( + rendered(&failure), + (ErrorKind::ModelFailure, "the prose".to_owned(), Vec::new()) + ); +} + +#[test] +fn context_exhaustion_renders_its_reason() { + let exhausted = error( + ErrorCode::ContextExhausted, + &[(FactKey::Reason, Value::from("precheck"))], + ); + assert_eq!( + rendered(&exhausted), + ( + ErrorKind::ContextExhausted, + "the prose".to_owned(), + vec![string("reason", "precheck")] + ) + ); +} + +#[test] +fn a_refused_tool_call_renders_the_name_it_tried() { + for (code, kind) in [ + (ErrorCode::OutOfScopeTool, ErrorKind::OutOfScopeTool), + (ErrorCode::UnboundTool, ErrorKind::UnboundTool), + (ErrorCode::ModelOnlyTool, ErrorKind::ModelOnlyTool), + ] { + let refusal = error(code, &[(FactKey::Name, Value::from("echo"))]); + assert_eq!(error_kind(&refusal), kind, "{code:?}"); + assert_eq!( + error_fields(&refusal), + vec![string("name", "echo")], + "{code:?}" + ); + } +} + +#[test] +fn a_code_without_table_fields_renders_none_whatever_facts_it_holds() { + let facts = [ + (FactKey::Name, Value::from("echo")), + (FactKey::Reason, Value::from("why")), + (FactKey::Path, Value::from("a.md")), + ]; + for (code, kind) in [ + (ErrorCode::ToolSchemaInvalid, ErrorKind::Internal), + (ErrorCode::Substitution, ErrorKind::Lua), + (ErrorCode::LuaQuota, ErrorKind::Lua), + (ErrorCode::LuaCompile, ErrorKind::Lua), + (ErrorCode::ToolOther, ErrorKind::Tool), + (ErrorCode::ModelRequired, ErrorKind::ModelRequired), + (ErrorCode::Determinism, ErrorKind::Internal), + (ErrorCode::StructureInvalid, ErrorKind::Internal), + ] { + assert_eq!( + rendered(&error(code, &facts)), + (kind, "the prose".to_owned(), Vec::new()), + "{code:?}" + ); + } +} diff --git a/crates/runvil-internal/lua/src/error-value-universal.rs b/crates/runvil-internal/lua/src/error-value-universal.rs new file mode 100644 index 000000000..353a8a8cb --- /dev/null +++ b/crates/runvil-internal/lua/src/error-value-universal.rs @@ -0,0 +1,191 @@ +//! The universal error as a Lua error value: the kind an author branches +//! on, which its code names, and the kind's fields, which its facts hold, +//! in both directions. + +use std::collections::BTreeMap; + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; + +use super::store::{store_fact_fields, store_field_facts}; +use super::{ErrorField, ErrorKind}; + +/// The kind a failure with `code` renders as. A model round's failure is +/// `model_failure`, or `empty_model_reply` for an empty reply, and a round +/// with no model is `model_required`; every Lua-phase failure, an exhausted +/// quota and the call-depth cap included, is `lua`; a store operation's own +/// failure is `store`; a cancelled task's delivery is `cancelled`, as an +/// interruption is; and a failure outside the prompt the author cannot act +/// on (a parse failure, a failed tool freeze, a refused requirement, a +/// determinism violation, an internal fault) is `internal`. +fn kind_of(code: ErrorCode) -> ErrorKind { + match code { + ErrorCode::LuaRuntime + | ErrorCode::LuaCompile + | ErrorCode::LuaQuota + | ErrorCode::CallDepthExceeded + | ErrorCode::Substitution => ErrorKind::Lua, + ErrorCode::ContextExhausted => ErrorKind::ContextExhausted, + ErrorCode::EmptyReply => ErrorKind::EmptyModelReply, + ErrorCode::ContextOverflow + | ErrorCode::RateLimited + | ErrorCode::QuotaExhausted + | ErrorCode::Overloaded + | ErrorCode::Refused + | ErrorCode::Timeout + | ErrorCode::Transport + | ErrorCode::ServerError + | ErrorCode::Rejected + | ErrorCode::MalformedResponse + | ErrorCode::Unavailable + | ErrorCode::InvalidParts => ErrorKind::ModelFailure, + ErrorCode::ModelRequired => ErrorKind::ModelRequired, + ErrorCode::Interrupted | ErrorCode::TaskCancelled => ErrorKind::Cancelled, + ErrorCode::ToolLoopExhausted => ErrorKind::ToolLoopExhausted, + ErrorCode::TasksLive => ErrorKind::TasksLive, + ErrorCode::TaskNotOwned => ErrorKind::TaskNotOwned, + ErrorCode::TaskConsumed => ErrorKind::TaskConsumed, + ErrorCode::OutOfScopeTool => ErrorKind::OutOfScopeTool, + ErrorCode::UnboundTool => ErrorKind::UnboundTool, + ErrorCode::ModelOnlyTool => ErrorKind::ModelOnlyTool, + ErrorCode::ToolInvalidArguments + | ErrorCode::ToolBackend + | ErrorCode::ToolTransport + | ErrorCode::ToolOther => ErrorKind::Tool, + 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 => ErrorKind::Store, + ErrorCode::FrontmatterInvalid + | ErrorCode::StructureInvalid + | ErrorCode::FenceInvalid + | ErrorCode::ListEmptyItem + | ErrorCode::UnsupportedVersion + | ErrorCode::InvalidValue + | ErrorCode::ToolSchemaInvalid + | ErrorCode::ToolSetsInvalid + | ErrorCode::Determinism + | ErrorCode::RequirementsUnmet + | ErrorCode::Internal + | ErrorCode::RecorderFailed + | ErrorCode::PluginLoadFailed + | ErrorCode::NoOutput => ErrorKind::Internal, + } +} + +/// The fact `key` of `error` as the string field `name`, or nothing when +/// the fact is absent or not a string. +fn string_field(error: &Error, name: &str, key: FactKey) -> Vec<(String, ErrorField)> { + error + .facts + .get(&key) + .and_then(Value::as_str) + .map(|value| vec![(name.to_owned(), ErrorField::String(value.to_owned()))]) + .unwrap_or_default() +} + +/// The `Tasks` fact's ids as one string joined by `, `, the `tasks` field +/// of a `tasks_live` table. +fn joined_tasks(error: &Error) -> Vec<(String, ErrorField)> { + let Some(Value::Array(tasks)) = error.facts.get(&FactKey::Tasks) else { + return Vec::new(); + }; + let joined = tasks + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", "); + vec![("tasks".to_owned(), ErrorField::String(joined))] +} + +/// Returns the kind a universal error's Lua error table names, which its +/// code classifies as. +#[must_use] +pub fn error_kind(error: &Error) -> ErrorKind { + kind_of(error.code) +} + +/// Returns the fields a universal error's Lua error table holds beside +/// `kind`, `code`, and `message`, from its facts: `reason` for context exhaustion, +/// `finish_reason` for an empty reply when the backend gave one, `name` for +/// a refused tool call, `tasks` for leaked tasks, `task` for a task +/// operation's refusal or a cancelled task's delivery, and a store +/// failure's `reason` and fields. Every other code's table has no fields, +/// whatever facts it holds. +#[must_use] +pub fn error_fields(error: &Error) -> Vec<(String, ErrorField)> { + match error.code { + ErrorCode::ContextExhausted => string_field(error, "reason", FactKey::Reason), + ErrorCode::EmptyReply => string_field(error, "finish_reason", FactKey::FinishReason), + ErrorCode::OutOfScopeTool | ErrorCode::UnboundTool | ErrorCode::ModelOnlyTool => { + string_field(error, "name", FactKey::Name) + } + ErrorCode::TasksLive => joined_tasks(error), + ErrorCode::TaskNotOwned | ErrorCode::TaskConsumed | ErrorCode::TaskCancelled => { + string_field(error, "task", FactKey::Task) + } + code if kind_of(code) == ErrorKind::Store => store_fact_fields(error), + _ => Vec::new(), + } +} + +/// Returns the facts an error table's `fields` hold for a failure with +/// `code`, the inverse of [`error_fields`]: each field that code's table +/// gives, under its fact, with `tasks` split back into the ids it joins. +/// A field of another shape, or one the code's table never gives, holds +/// no fact. +pub(super) fn field_facts( + code: ErrorCode, + fields: &BTreeMap, +) -> BTreeMap { + let string = |name: &str, key: FactKey| { + fields + .get(name) + .and_then(ErrorField::as_str) + .map(|value| (key, Value::from(value))) + .into_iter() + .collect() + }; + match code { + ErrorCode::ContextExhausted => string("reason", FactKey::Reason), + ErrorCode::EmptyReply => string("finish_reason", FactKey::FinishReason), + ErrorCode::OutOfScopeTool | ErrorCode::UnboundTool | ErrorCode::ModelOnlyTool => { + string("name", FactKey::Name) + } + ErrorCode::TasksLive => fields + .get("tasks") + .and_then(ErrorField::as_str) + .map(|joined| { + let tasks: Vec<&str> = joined.split(", ").filter(|id| !id.is_empty()).collect(); + (FactKey::Tasks, Value::from(tasks)) + }) + .into_iter() + .collect(), + ErrorCode::TaskNotOwned | ErrorCode::TaskConsumed | ErrorCode::TaskCancelled => { + string("task", FactKey::Task) + } + code if kind_of(code) == ErrorKind::Store => store_field_facts(fields), + _ => BTreeMap::new(), + } +} + +#[cfg(test)] +#[path = "error-value-universal-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index ebb5c9606..b5f4b659a 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -4,41 +4,55 @@ //! a Rust-raised error answered through the `(ok, result)` envelope, a //! Engine function's own failure (`tools.offer`, `models.get`, a `sys` or `var` //! guard) caught by `pcall`, or a shim raise such as -//! `tool_loop_exhausted` - is one Lua table `{ kind, message, ... }` under -//! a shared metatable whose `__tostring` returns `message`. A `pcall` -//! caller that prints the error sees exactly the text it saw before; a -//! caller that branches reads `kind` and the kind's own fields (`reason` -//! for `context_exhausted`, `finish_reason` for `empty_model_reply`). +//! `tool_loop_exhausted` - is one Lua table `{ kind, code, message, ... }` +//! under a shared metatable whose `__tostring` returns `message`. The +//! message is the failure's prose, the text an author may hand a model; a +//! caller that branches reads `kind`, `code`, the stable tag of the +//! failure's [`ErrorCode`], and the kind's own fields (`reason` for +//! `context_exhausted`, `finish_reason` for `empty_model_reply`). //! -//! [`ErrorKind`] names the closed vocabulary of kinds, [`ErrorValue`] is -//! what a Rust error implements to render itself into the shape, +//! [`ErrorKind`] names the closed vocabulary of kinds; [`error_kind`] and +//! [`error_fields`] render a universal error into the shape; //! [`install_normalize_failure`] is the capture the shim's `pcall` and //! `xpcall` replacements run a caught value through (so a Rust callback's -//! failure, which mlua raises as an opaque userdata, takes the same shape), -//! and [`Raised`] is the table read back into Rust when a shim raise -//! surfaces as a block coroutine's failure, so the kind survives the -//! boundary in both directions. +//! failure, which mlua raises as an opaque userdata, takes the same shape); +//! and [`raised_from`] reads a table back into a universal error when a +//! raise surfaces as a block coroutine's failure, so the failure survives +//! the boundary in both directions. A table rendered from a universal +//! error keeps that error beside it, which [`retained_error`] reads back +//! whole. #[path = "error-value-classify.rs"] mod classify; +#[path = "error-value-raised.rs"] +mod raised; #[path = "error-value-store.rs"] mod store; +#[path = "error-value-universal.rs"] +mod universal; -use std::collections::BTreeMap; use std::str::FromStr; -use mlua::{Function, Lua, Table, Value}; +use mlua::{AnyUserData, Function, Lua, Table, Value}; -use crate::error::{Error, accepted_tags}; +use runvil_types::error::{Error, ErrorCode, FactKey}; + +use crate::error::accepted_tags; pub(crate) use classify::{install_normalize_failure, normalized}; -pub use store::store_error_value_fields; +pub(crate) use raised::raised_from; +pub use store::{store_error, store_error_value_fields}; +pub use universal::{error_fields, error_kind}; /// 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 /// the same identity and the read-back can recognize it. const METATABLE_REGISTRY: &str = "runvil.error_value.metatable"; +/// The registry key of the VM's weak-keyed table from each error table +/// rendered from a universal error to that error, created on first use. +const RETAINED_REGISTRY: &str = "runvil.error_value.retained"; + /// The closed vocabulary of failure kinds an author can branch on. /// /// The tag is the `kind` string the Lua table holds; the set is fixed by @@ -166,36 +180,26 @@ 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 = 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 = 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,98 +232,6 @@ impl ErrorField { } } -/// A Rust error's rendering into the Lua error table: its kind and the -/// kind's own fields. `Display` supplies `message`. -/// -/// 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 -/// variant classifies it here. -pub trait ErrorValue: std::fmt::Display { - /// The kind the table's `kind` field names. - fn kind(&self) -> ErrorKind; - - /// 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)> { - Vec::new() - } -} - -impl ErrorValue for Error { - fn kind(&self) -> ErrorKind { - match self { - Error::Lua(_) - | Error::LuaRuntime { .. } - | Error::LuaCompile { .. } - | Error::LuaQuota { .. } => ErrorKind::Lua, - Error::ContextExhausted { .. } => ErrorKind::ContextExhausted, - Error::Interrupted => ErrorKind::Cancelled, - Error::Tool { .. } => ErrorKind::Tool, - Error::Store { .. } => ErrorKind::Store, - Error::Internal { .. } => ErrorKind::Internal, - Error::Raised(raised) => raised.kind, - } - } - - fn fields(&self) -> Vec<(String, ErrorField)> { - match self { - Error::ContextExhausted { reason } => { - vec![("reason".to_owned(), ErrorField::String(reason.clone()))] - } - Error::Store { cause, .. } => store_error_value_fields(cause), - Error::Raised(raised) => raised - .fields - .iter() - .map(|(name, value)| (name.clone(), value.clone())) - .collect(), - Error::Lua(_) - | Error::LuaRuntime { .. } - | Error::LuaCompile { .. } - | Error::LuaQuota { .. } - | Error::Interrupted - | Error::Tool { .. } - | Error::Internal { .. } => Vec::new(), - } - } -} - -/// A structured error table read back into Rust: the kind, the message -/// `tostring` rendered, and the kind's fields. -/// -/// This is the shape a block coroutine's failure takes when the raised -/// value was an error table (built by the shim's `raise` or by -/// `error_table`) and no retained typed error was substituted for it - -/// the case for a Lua-side raise. The executor maps it back onto its own -/// internal type by kind. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Raised { - /// The kind the table named. - pub kind: ErrorKind, - /// The message `tostring` renders. - pub message: String, - /// The kind's own fields (`reason`, `finish_reason`, ...), string- or - /// integer-valued (a store anchor's `count`, a UTF-8 failure's - /// `valid_up_to`). - pub fields: BTreeMap, -} - -impl Raised { - /// The reason a `context_exhausted` table held, when it holds a - /// string one. - #[must_use] - pub fn exhaustion_reason(&self) -> Option<&str> { - self.fields.get("reason").and_then(ErrorField::as_str) - } -} - -impl std::fmt::Display for Raised { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for Raised {} - /// Returns the VM's shared error metatable, creating it on first use. /// /// `__tostring` returns the table's `message`, so an author's `tostring` @@ -341,10 +253,26 @@ fn metatable(lua: &Lua) -> mlua::Result
{ Ok(metatable) } -/// Sets `kind` on `fields`, fills a missing `message` from the kind, and -/// attaches the shared metatable. -fn finish_table(lua: &Lua, kind: ErrorKind, fields: Table) -> mlua::Result
{ +/// Returns the VM's table from each error table rendered from a universal +/// error to that error, creating it on first use. Its keys are weak, so a +/// retained error lives exactly as long as its table. +fn retained(lua: &Lua) -> mlua::Result
{ + if let Value::Table(existing) = lua.named_registry_value::(RETAINED_REGISTRY)? { + return Ok(existing); + } + let retained = lua.create_table()?; + let weak = lua.create_table()?; + weak.raw_set("__mode", "k")?; + retained.set_metatable(Some(weak))?; + lua.set_named_registry_value(RETAINED_REGISTRY, &retained)?; + Ok(retained) +} + +/// Sets `kind` and `code` on `fields`, fills a missing `message` from the +/// kind, and attaches the shared metatable. +fn finish_table(lua: &Lua, kind: ErrorKind, code: ErrorCode, fields: Table) -> mlua::Result
{ fields.raw_set("kind", kind.tag())?; + fields.raw_set("code", code.tag())?; if matches!(fields.raw_get::("message")?, Value::Nil) { fields.raw_set("message", kind.tag())?; } @@ -352,91 +280,86 @@ fn finish_table(lua: &Lua, kind: ErrorKind, fields: Table) -> mlua::Result
mlua::Result
{ +pub(crate) fn error_table(lua: &Lua, error: &Error) -> mlua::Result
{ let table = lua.create_table()?; - table.raw_set("message", error.to_string())?; - for (name, value) in error.fields() { + table.raw_set("message", error.prose.as_str())?; + for (name, value) in error_fields(error) { let value = match value { ErrorField::String(value) => Value::String(lua.create_string(value)?), ErrorField::Integer(value) => Value::Integer(value), }; table.raw_set(name, value)?; } - finish_table(lua, error.kind(), table) + let table = finish_table(lua, error_kind(error), error.code, table)?; + retained(lua)?.raw_set(table.clone(), lua.create_any_userdata(error.clone())?)?; + Ok(table) +} + +/// Reads back the universal error a raised value was rendered from, when +/// it is an error table [`error_table`] built on this VM. Any other value +/// is `None`. +/// +/// # Errors +/// Returns an `mlua` error if the retained error cannot be read. +pub(crate) fn retained_error(lua: &Lua, value: &Value) -> mlua::Result> { + let Value::Table(table) = value else { + return Ok(None); + }; + match retained(lua)?.raw_get::>(table.clone())? { + Some(error) => Ok(Some(error.borrow::()?.clone())), + None => Ok(None), + } +} + +/// Builds the table the shim's `error_value(kind, { message = message })` +/// builds: `message` as it is, under the code `kind` gives. +/// +/// # Errors +/// Returns an `mlua` error if the table cannot be created on `lua`. +pub(crate) fn message_table(lua: &Lua, kind: ErrorKind, message: &str) -> mlua::Result
{ + let fields = lua.create_table()?; + fields.raw_set("message", message)?; + let code = raised::table_code(kind, &fields); + finish_table(lua, kind, code, fields) } /// Builds the `error_value(kind, fields)` chunk capture for the shim: the /// Lua-side constructor of the same table shape, so a shim raise and a -/// Rust-raised error are indistinguishable to author code. An unknown kind -/// is a shim bug and fails the call. +/// Rust-raised error are indistinguishable to author code. The table's +/// code is the one its fields name, or else the one its kind gives. An +/// unknown kind is a shim bug and fails the call. /// /// # Errors /// Returns an `mlua` error if the function cannot be created. pub(crate) fn install_error_value(lua: &Lua) -> mlua::Result { lua.create_function(|lua, (tag, fields): (String, Option
)| { let kind = tag.parse::().map_err(|error| { - mlua::Error::external(Error::internal(format!("the shim raised {error}"))) + mlua::Error::external(crate::error::internal(format!("the shim raised {error}"))) })?; let fields = match fields { Some(fields) => fields, None => lua.create_table()?, }; - finish_table(lua, kind, fields) + let code = raised::table_code(kind, &fields); + finish_table(lua, kind, code, fields) }) } -/// Reads a raised Lua value back as a [`Raised`] when it is an error table -/// built on this VM (recognized by the shared metatable, so an author's own -/// table with a `kind` field is not mistaken for one). Any other value is -/// `None`. -/// -/// # Errors -/// Returns an `mlua` error if the table's fields cannot be read. -pub(crate) fn raised_from(lua: &Lua, value: &Value) -> mlua::Result> { - let Value::Table(table) = value else { - return Ok(None); - }; +/// Whether `table` is an error table built on this VM, recognized by the +/// shared metatable, so an author's own table with a `kind` field is not +/// mistaken for one. +fn is_error_table(lua: &Lua, table: &Table) -> mlua::Result { let Some(attached) = table.metatable() else { - return Ok(None); + return Ok(false); }; - if attached.to_pointer() != metatable(lua)?.to_pointer() { - return Ok(None); - } - let Value::String(tag) = table.raw_get::("kind")? else { - return Ok(None); - }; - let Some(kind) = ErrorKind::from_tag(&tag.to_str()?) else { - return Ok(None); - }; - let message = match table.raw_get::("message")? { - Value::String(message) => message.to_str()?.to_owned(), - _ => kind.tag().to_owned(), - }; - // `pairs` order is unspecified, but `fields` is a `BTreeMap`, so the - // raised value's field order never depends on it. - let mut fields = BTreeMap::new(); - for pair in table.pairs::() { - let (name, value) = pair?; - if name == "kind" || name == "message" { - continue; - } - let field = match value { - Value::String(value) => ErrorField::String(value.to_str()?.to_owned()), - Value::Integer(value) => ErrorField::Integer(value), - _ => continue, - }; - fields.insert(name, field); - } - Ok(Some(Raised { - kind, - message, - fields, - })) + Ok(attached.to_pointer() == metatable(lua)?.to_pointer()) } #[cfg(test)] diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index b2e4ccc29..dab8e1b0d 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -1,46 +1,30 @@ -//! The crate's internal error type. +//! The crate's failures, each the universal [`Error`]. //! -//! [`Error`] mirrors `runvil-engine`'s own internal error type: it -//! is never part of the documented API. A failed run's public outcome -//! (`runvil_engine::RunOutcome`) carries the kind and message of -//! `runvil-engine`'s internal error type, which maps this one back -//! variant-for-variant through `From`. The internal type is public only so -//! `runvil-engine` can perform that mapping verbatim; the facade does -//! not re-export it, and it is not marked `#[non_exhaustive]`, so the -//! mapping stays total. -//! -//! A variant that keeps an originating error renders that error's text in -//! its own `Display` and holds the error as a plain `cause` field behind -//! [`Error::cause`], never as a `#[source]`, so a printer that walks the -//! source chain shows the cause's text once. +//! A site builds its failure where it raises it, with the constructors +//! here, which the Engine shares for the failures it raises itself, so +//! each failure has one text. A failure raised through Lua crosses the +//! `mlua` boundary as a typed value, `mlua::Error::external(error)`, and +//! the boundary that reads it back downcasts to [`Error`]. A failure the +//! prompt's own Lua caused gets author prose that names the prompt's file, +//! which the VM holds. use std::borrow::Cow; -/// A type-erased owned error cause used by the internal error type. -type BoxedSource = Box; - -/// A cloneable, shareable error cause. -/// -/// Some compiled-program statics re-produce a typed [`Error`] on every -/// lookup, so a non-`Clone` dependency error cannot be moved into a fresh -/// [`Error`] each time. Wrapping it in a reference-counted [`SharedSource`] -/// keeps the typed cause and clones it cheaply per lookup instead of -/// flattening it to a string. Every embedded chunk's compiled-program static -/// is a caller, through `detail::shared_source_new`. -#[derive(Debug, Clone)] -pub(crate) struct SharedSource(pub(crate) std::sync::Arc); +pub(crate) use runvil_types::error::Error; -impl std::fmt::Display for SharedSource { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(&self.0, formatter) - } -} +#[path = "error-author.rs"] +mod author; +#[path = "error-universal.rs"] +mod universal; -impl std::error::Error for SharedSource { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.0.source() - } -} +#[cfg(any(test, feature = "test-support"))] +pub(crate) use author::UNNAMED_FILE; +pub(crate) use author::{authored, authored_at, failure_text, prompt_file, set_prompt_file}; +pub use universal::{ + context_exhausted, internal_at, interrupted, lua_quota, lua_runtime, task_cancelled, + task_consumed, task_not_owned, tool_loop_exhausted, +}; +pub(crate) use universal::{lua_compile, tool}; /// An Engine quota on Lua that a block can exhaust. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -60,221 +44,54 @@ impl std::fmt::Display for QuotaResource { } } -/// The crate's internal error type: every failure a section VM's setup, Lua -/// code, or Engine calls can end in. -#[derive(Debug, thiserror::Error)] -pub enum Error { - /// A condition the prompt author caused and can fix in Lua, with no - /// originating `mlua` error to keep (for example a fanout collection - /// member that is a function, or a refused message-list edit). - /// - /// Failures that *do* have an `mlua` cause use [`Error::LuaRuntime`]. - /// The message is the specific failure as a noun phrase; the public - /// wrapper classifies this as a Lua failure, so no redundant - /// `lua error:` type label is prepended. - #[error("{0}")] - Lua(String), - - /// A section's Lua phase failed at runtime or while bridging Engine - /// values: the mapped prompt-location message, beside the originating - /// `mlua` error kept as the cause. - /// - /// This is the cause-bearing counterpart to [`Error::Lua`]: it is built - /// from a concrete `mlua::Error` (see `Error::lua` and - /// `LuaProgram::map_runtime_error_among`), so the typed failure - /// survives instead of being flattened to a string. - #[error("{message}")] - LuaRuntime { - /// The mapped, location-tagged diagnostic (no redundant type label). - message: String, - /// The originating Lua error, whose text `message` already holds. - cause: BoxedSource, - }, - - /// Lua source was not syntactically valid at its prompt location. - /// - /// Keeps the originating `mlua` compile error as the cause beside the - /// location metadata, so the typed compiler diagnostic survives instead - /// of being flattened into `message` alone. - #[error("lua compilation error at {location} (line {source_line}): {message}")] - LuaCompile { - /// The prompt region supplied by the parser, such as a section prologue. - location: String, - /// 1-based line number in the prompt source where this Lua region starts. - source_line: u32, - /// The retained source that failed to compile. - lua_source: String, - /// The Lua 5.5 compiler diagnostic. - message: String, - /// The originating `mlua` compile error, whose text `message` holds. - cause: BoxedSource, - }, - - /// An Engine quota on Lua (log events or log bytes) was exhausted. A - /// stable typed error rather than a bare `Lua(String)` so the caller can - /// distinguish quota exhaustion from an authoring error. - #[error("lua {resource} quota exceeded")] - LuaQuota { - /// The exhausted resource. - resource: QuotaResource, - }, - - /// `compactors.fail` exhausted the model's context: a request - /// overflowed the context window (the pre-dispatch precheck or a - /// provider rejection) and the selected compactor did not compact, or - /// a caller raised it with its own reason. A stable typed error rather - /// than a bare [`Error::Lua`] so the caller and `pcall` sites can - /// distinguish context exhaustion from an authoring error. - #[error("context exhausted: {}", crate::compactors::exhaustion_detail(reason))] - ContextExhausted { - /// `precheck` or `provider` after an overflow, else the caller's reason. - reason: String, - }, - - /// The run was cancelled, or the caller dropped a call the run was - /// waiting on. - #[error("interrupted: the run was cancelled or this call was stopped")] - Interrupted, - - /// A dispatched tool's own failure: the tool error's message, beside the - /// tool's typed error kept as the cause rather than flattened. - #[error("{message}")] - Tool { - /// The tool failure's rendered message. - message: String, - /// The tool's typed error, whose text `message` holds. - cause: BoxedSource, - }, - - /// A store operation's own failure: the model-facing message rendered - /// for the operation, beside the structured - /// [`VfsError`](runvil_vfs::VfsError) it renders. The direct store - /// closures raise this, so a failure caught by `pcall` classifies as - /// kind `store` with the `reason` tag and the variant's fields. - #[error("{message}")] - Store { - /// The model-facing message, from `store_error_message`. - message: String, - /// The structured failure the message renders. - cause: runvil_vfs::VfsError, - }, - - /// Engine misuse that no prompt author can cause: a broken internal - /// invariant, a lifecycle step taken out of order, or a poisoned lock. - /// Surfaced as a concrete error rather than silently skipping work, so an - /// impossible state cannot masquerade as a successful fall-through. The - /// message is owned when it names a value, and `location` is the - /// construction site. - #[error("internal invariant violated: {message}")] - Internal { - /// The violated invariant, as a noun phrase. - message: Cow<'static, str>, - /// The Rust source position of the construction site. - location: &'static std::panic::Location<'static>, - }, - - /// A structured error table raised in Lua surfaced as a block - /// coroutine's failure with no retained typed error to substitute: the - /// table's kind, message, and fields, kept rather than flattened to the - /// message string. The executor maps it back onto its own error type by - /// kind, so a shim raise classifies as the Rust-raised error it stands - /// in for. - #[error("{0}")] - Raised(crate::error_value::Raised), +/// The `LuaRuntime` failure an `mlua` failure is, with the `mlua` error's +/// text as its message. +#[expect( + clippy::needless_pass_by_value, + reason = "the adapter every VM call's `map_err` passes its error to" +)] +pub(crate) fn lua(source: mlua::Error) -> Error { + lua_runtime(source.to_string()) } -/// The typed value an Engine quota refusal raises through Lua. -/// -/// The runtime-error boundary recognizes a refusal by downcasting to this -/// type and maps it to [`Error::LuaQuota`], so an author who raises the -/// same text fails as an ordinary runtime error. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct QuotaExceeded(pub(crate) QuotaResource); - -impl std::fmt::Display for QuotaExceeded { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self.0 { - QuotaResource::LogEvent => "lua log event budget exceeded", - QuotaResource::LogByte => "lua log cumulative byte budget exceeded", - }) - } +/// An internal invariant failure, at the call site as its construction +/// site. The message is owned when it names a value. +#[track_caller] +pub(crate) fn internal(message: impl Into>) -> Error { + internal_at(message, std::panic::Location::caller()) } -impl std::error::Error for QuotaExceeded {} - -impl Error { - /// Wraps an `mlua` failure as [`Error::LuaRuntime`], keeping it as the - /// cause rather than flattening it to a string. - pub(crate) fn lua(source: mlua::Error) -> Error { - Error::LuaRuntime { - message: source.to_string(), - cause: Box::new(source), - } - } - - /// Re-produces a typed [`Error`] from a [`SharedSource`] a - /// compiled-program static captured once, cloning the `Arc` rather than - /// flattening the cause to a string. - pub(crate) fn shared(source: &SharedSource) -> Error { - Error::LuaRuntime { - message: source.to_string(), - cause: Box::new(source.clone()), - } - } - - /// 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 { - Error::Tool { - message: source.to_string(), - cause: Box::new(source), - } - } - - /// Wraps a store operation's failure as [`Error::Store`], rendering - /// the model-facing message with the operation for its wording. - pub(crate) fn store(op: &crate::protocol::VfsOp, source: runvil_vfs::VfsError) -> Error { - Error::Store { - message: crate::engine_globals::store_error_message(op, &source), - cause: source, - } - } +/// An author error raised across the Lua boundary: a `LuaRuntime` +/// failure with `message`, which `pcall` reports as kind `lua`. +pub(crate) fn author_refusal(message: String) -> mlua::Error { + mlua::Error::external(lua_runtime(message)) +} - /// Builds an [`Error::Internal`], capturing the call site as its - /// construction site. The message is owned when it names a value. - #[track_caller] - pub(crate) fn internal(message: impl Into>) -> Error { - Error::Internal { - message: message.into(), - location: std::panic::Location::caller(), +/// Strips `mlua`'s wrapping layers (the callback frame and any context) +/// down to the error a callback returned. +pub(crate) fn root_cause(error: &mlua::Error) -> &mlua::Error { + match error { + mlua::Error::CallbackError { cause, .. } | mlua::Error::WithContext { cause, .. } => { + root_cause(cause) } + other => other, } +} - /// Returns the originating error a cause-bearing variant keeps, whose - /// text its `Display` already holds; `None` for every other variant. - #[must_use] - pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { - match self { - Error::LuaRuntime { cause, .. } - | Error::LuaCompile { cause, .. } - | Error::Tool { cause, .. } => Some(cause.as_ref()), - Error::Store { cause, .. } => Some(cause), - Error::Lua(_) - | Error::LuaQuota { .. } - | Error::ContextExhausted { .. } - | Error::Interrupted - | Error::Internal { .. } - | Error::Raised(_) => None, +/// The text of the failure at the root of `error`, without `mlua`'s label +/// for its kind: what Lua raised, or the message of the universal failure +/// a callback raised. +pub(crate) fn root_text(error: &mlua::Error) -> String { + match root_cause(error) { + mlua::Error::RuntimeError(message) | mlua::Error::SyntaxError { message, .. } => { + message.clone() } + other => other + .downcast_ref::() + .map_or_else(|| other.to_string(), |typed| typed.message.clone()), } } -/// An author error raised across the Lua boundary: an external -/// [`Error::Lua`], which `pcall` reports as kind `lua`. -pub(crate) fn author_refusal(message: String) -> mlua::Error { - mlua::Error::external(Error::Lua(message)) -} - /// Crate-internal result alias over [`Error`]. pub(crate) type Result = std::result::Result; diff --git a/crates/runvil-internal/lua/src/globals.rs b/crates/runvil-internal/lua/src/globals.rs index 9f363ac8a..bf2004faf 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -39,8 +39,7 @@ use std::sync::LazyLock; use mlua::{AnyUserData, Function, Lua, Table, Value}; -use crate::error::SharedSource; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result}; use crate::program::LuaProgram; /// The guard chunk's name, `@`-prefixed so PUC renders it verbatim as a @@ -56,11 +55,8 @@ const STATE_REGISTRY: &str = "runvil.globals.state"; /// The guard program, compiled once and loaded per VM, under the shim /// programs' failure contract. -static GLOBALS_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(GLOBALS_SOURCE, GLOBALS_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +static GLOBALS_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(GLOBALS_SOURCE, GLOBALS_CHUNK_NAME)); /// Installs the `_G` guard and the `setmetatable` and `getmetatable` /// replacements on a fresh VM. @@ -70,17 +66,17 @@ static GLOBALS_PROGRAM: LazyLock> /// `setmetatable` or `getmetatable`. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the chunk cannot load or run, or the guard +/// Returns a `LuaRuntime` failure if the chunk cannot load or run, or the guard /// and the replacements cannot be installed. pub(crate) fn install(lua: &Lua) -> Result<()> { let globals = lua.globals(); - let state = lua.create_table().map_err(Error::lua)?; + let state = lua.create_table().map_err(error::lua)?; let refuse_argv = refusal(lua, crate::argv::ASSIGNMENT_REFUSAL)?; let refuse_prose = refusal(lua, crate::prose::ASSIGNMENT_REFUSAL)?; let refuse_messages = refusal(lua, crate::messages::ASSIGNMENT_REFUSAL)?; let (guard, replace_metatable, read_metatable): (Table, Function, Function) = GLOBALS_PROGRAM .as_ref() - .map_err(Error::shared)? + .map_err(Error::clone)? .load(lua)? .call(( globals.clone(), @@ -89,40 +85,40 @@ pub(crate) fn install(lua: &Lua) -> Result<()> { refuse_prose, refuse_messages, )) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.set_named_registry_value(STATE_REGISTRY, state) - .map_err(Error::lua)?; + .map_err(error::lua)?; globals .raw_set("setmetatable", replace_metatable) - .map_err(Error::lua)?; + .map_err(error::lua)?; globals .raw_set("getmetatable", read_metatable) - .map_err(Error::lua)?; - globals.set_metatable(Some(guard)).map_err(Error::lua) + .map_err(error::lua)?; + globals.set_metatable(Some(guard)).map_err(error::lua) } /// Freezes `argv` on the guard: reads return `frozen`, and every /// assignment of the global raises the freeze refusal. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot +/// Returns a `LuaRuntime` failure if the guard's slot table is missing or cannot /// be written. pub(crate) fn freeze_argv(lua: &Lua, frozen: Value) -> Result<()> { let state = state(lua)?; - state.raw_set("argv", frozen).map_err(Error::lua)?; - state.raw_set("argv_frozen", true).map_err(Error::lua) + state.raw_set("argv", frozen).map_err(error::lua)?; + state.raw_set("argv_frozen", true).map_err(error::lua) } /// Puts `render` in the `prose` slot, nil leaving `prose` reading nil, and /// returns the render it replaced, nil before the first install. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot +/// Returns a `LuaRuntime` failure if the guard's slot table is missing or cannot /// be read or written. pub(crate) fn replace_prose(lua: &Lua, render: Value) -> Result { let state = state(lua)?; - let replaced = state.raw_get("prose").map_err(Error::lua)?; - state.raw_set("prose", render).map_err(Error::lua)?; + let replaced = state.raw_get("prose").map_err(error::lua)?; + state.raw_set("prose", render).map_err(error::lua)?; Ok(replaced) } @@ -130,24 +126,24 @@ pub(crate) fn replace_prose(lua: &Lua, render: Value) -> Result { /// `messages` slot, which every later read of the global returns. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot +/// Returns a `LuaRuntime` failure if the guard's slot table is missing or cannot /// be written. pub(crate) fn set_messages(lua: &Lua, list: AnyUserData) -> Result<()> { - state(lua)?.raw_set("messages", list).map_err(Error::lua) + state(lua)?.raw_set("messages", list).map_err(error::lua) } /// The section's list, as the `messages` slot holds it. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot -/// be read, and [`Error::Internal`] if no list is installed. +/// Returns a `LuaRuntime` failure if the guard's slot table is missing or cannot +/// be read, and an `Internal` failure if no list is installed. pub(crate) fn messages(lua: &Lua) -> Result { match state(lua)? .raw_get::("messages") - .map_err(Error::lua)? + .map_err(error::lua)? { Value::UserData(list) => Ok(list), - _ => Err(Error::internal( + _ => Err(error::internal( "the section's messages list is not installed", )), } @@ -155,13 +151,13 @@ pub(crate) fn messages(lua: &Lua) -> Result { /// The guard's slot table. fn state(lua: &Lua) -> Result
{ - lua.named_registry_value(STATE_REGISTRY).map_err(Error::lua) + lua.named_registry_value(STATE_REGISTRY).map_err(error::lua) } /// An Engine function that raises `message` as a runtime error. fn refusal(lua: &Lua, message: &'static str) -> Result { lua.create_function(move |_, ()| -> mlua::Result<()> { Err(mlua::Error::runtime(message)) }) - .map_err(Error::lua) + .map_err(error::lua) } /// Why a name is reserved in a section VM's global namespace. diff --git a/crates/runvil-internal/lua/src/hardening.rs b/crates/runvil-internal/lua/src/hardening.rs index 8a5e5e099..dce634e17 100644 --- a/crates/runvil-internal/lua/src/hardening.rs +++ b/crates/runvil-internal/lua/src/hardening.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, OnceLock}; use mlua::{HookTriggers, Lua, Thread, Value, VmState}; use runvil_types::cancel::CancelHandle; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; /// How many instructions between hook firings. const HOOK_INTERVAL: u32 = 10_000; @@ -44,7 +44,7 @@ pub(crate) fn harden(lua: &Lua) -> Result<()> { "print", "warn", ] { - globals.set(name, Value::Nil).map_err(Error::lua)?; + globals.set(name, Value::Nil).map_err(error::lua)?; } lua.load( r#" @@ -81,7 +81,7 @@ end "#, ) .exec() - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok(()) } @@ -108,14 +108,14 @@ impl CancelHook { /// Installs the cancellation hook on a block coroutine. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the VM rejects the hook callback. + /// Returns a `LuaRuntime` failure if the VM rejects the hook callback. pub(crate) fn install_on_thread(&self, thread: &Thread) -> Result<()> { thread .set_hook( HookTriggers::new().every_nth_instruction(HOOK_INTERVAL), cancel_poll(Arc::clone(&self.cancel)), ) - .map_err(Error::lua) + .map_err(error::lua) } /// Installs the run's cancel flag. The first install wins: a VM serves @@ -146,7 +146,7 @@ fn cancel_poll( move |_lua, _debug| { // Cooperative cancellation: abort a long-running Lua block promptly // when the run's CancelHandle is signaled (mapped to - // Error::Interrupted at the VM's failure boundary). + // an `Interrupted` failure at the VM's failure boundary). if cancelled(&cancel) { return Err(mlua::Error::RuntimeError( "lua execution cancelled".to_string(), @@ -162,13 +162,13 @@ fn cancel_poll( /// [`CancelHook::install_on_thread`] with the returned hook. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the VM rejects the hook callback. +/// Returns a `LuaRuntime` failure if the VM rejects the hook callback. pub(crate) fn install_cancel_hook(lua: &Lua) -> Result { let hook = CancelHook::default(); lua.set_hook( HookTriggers::new().every_nth_instruction(HOOK_INTERVAL), cancel_poll(Arc::clone(&hook.cancel)), ) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok(hook) } diff --git a/crates/runvil-internal/lua/src/iteration.rs b/crates/runvil-internal/lua/src/iteration.rs index c7f363a2f..74c542e1b 100644 --- a/crates/runvil-internal/lua/src/iteration.rs +++ b/crates/runvil-internal/lua/src/iteration.rs @@ -40,22 +40,23 @@ use std::cmp::Ordering; use mlua::{Lua, MultiValue, Table, Value}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::order::{SortKey, sort_key}; /// Installs the deterministic `next` and `pairs` globals, replacing the base /// library's hash-order versions. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if either global cannot be created or installed. +/// Returns a `LuaRuntime` failure if either global cannot be created or +/// installed. pub(crate) fn install_deterministic_iteration(lua: &Lua) -> Result<()> { let globals = lua.globals(); let next_fn = lua .create_function(deterministic_next) - .map_err(Error::lua)?; - globals.raw_set("next", &next_fn).map_err(Error::lua)?; - let pairs_fn = lua.create_function(pairs).map_err(Error::lua)?; - globals.raw_set("pairs", pairs_fn).map_err(Error::lua) + .map_err(error::lua)?; + globals.raw_set("next", &next_fn).map_err(error::lua)?; + let pairs_fn = lua.create_function(pairs).map_err(error::lua)?; + globals.raw_set("pairs", pairs_fn).map_err(error::lua) } /// One table key and its position in the deterministic walk order. diff --git a/crates/runvil-internal/lua/src/json.rs b/crates/runvil-internal/lua/src/json.rs index 9f0ac0d2a..cb650c029 100644 --- a/crates/runvil-internal/lua/src/json.rs +++ b/crates/runvil-internal/lua/src/json.rs @@ -18,41 +18,41 @@ pub(crate) use encode::to_json; use mlua::{Lua, LuaSerdeExt, Table, Value}; -use crate::error::{Error, Result, author_refusal}; +use crate::error::{self, Result, author_refusal}; /// Installs the `json` table as a global. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the table, its functions, or the +/// Returns a `LuaRuntime` failure if the table, its functions, or the /// global cannot be created. pub(crate) fn install(lua: &Lua) -> Result<()> { - let json = lua.create_table().map_err(Error::lua)?; - json.raw_set("null", lua.null()).map_err(Error::lua)?; - json.raw_set("array", lua.create_function(array).map_err(Error::lua)?) - .map_err(Error::lua)?; + let json = lua.create_table().map_err(error::lua)?; + json.raw_set("null", lua.null()).map_err(error::lua)?; + json.raw_set("array", lua.create_function(array).map_err(error::lua)?) + .map_err(error::lua)?; json.raw_set( "decode", - lua.create_function(decode::decode).map_err(Error::lua)?, + lua.create_function(decode::decode).map_err(error::lua)?, ) - .map_err(Error::lua)?; + .map_err(error::lua)?; json.raw_set( "decode_lines", lua.create_function(decode::decode_lines) - .map_err(Error::lua)?, + .map_err(error::lua)?, ) - .map_err(Error::lua)?; + .map_err(error::lua)?; json.raw_set( "encode", - lua.create_function(encode::encode).map_err(Error::lua)?, + lua.create_function(encode::encode).map_err(error::lua)?, ) - .map_err(Error::lua)?; + .map_err(error::lua)?; json.raw_set( "encode_lines", lua.create_function(encode::encode_lines) - .map_err(Error::lua)?, + .map_err(error::lua)?, ) - .map_err(Error::lua)?; - lua.globals().raw_set("json", json).map_err(Error::lua) + .map_err(error::lua)?; + lua.globals().raw_set("json", json).map_err(error::lua) } /// `json.array(t)`: marks `t` as a list in place and returns it, or returns diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index 9efa6296a..457f34856 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -19,7 +19,7 @@ use std::ffi::c_void; use mlua::{Lua, LuaSerdeExt, LuaString, Table, Value}; use serde_json::{Map, Number, Value as Json}; -use super::keys::{Entries, Rule, Shape, entries, shape}; +use super::keys::{Entries, Shape, entries, shape}; use crate::error::author_refusal; use crate::proxy::{key_path, key_text}; use crate::walk_budget::{HELD_KEY_LIMIT, TooLarge, WalkBudget}; @@ -114,37 +114,37 @@ fn too_large(prefix: &str) -> mlua::Error { author_refusal(format!("{prefix}: value is too large to encode")) } -/// Why a value cannot be encoded, before the prefix and path are added. -enum Failure { - NotData(&'static str), - NotUtf8, - NotFinite, - Unreadable(Option), - Keys(Rule), - Cycle, - TooDeep, +/// Why a string or a key that is not UTF-8 cannot be encoded, the text +/// after the path. +const NOT_UTF8: &str = "is not valid UTF-8"; + +/// Why NaN or an infinity cannot be encoded. +const NOT_FINITE: &str = "is not a finite number"; + +/// Why a table on its own path cannot be encoded. +const CYCLE: &str = "contains itself"; + +/// Why a value of the Lua type `kind` cannot be encoded. +fn not_data(kind: &str) -> String { + format!("must be JSON data, got {kind}") } -impl Failure { - /// The message's text after the path. - fn text(&self) -> String { - match self { - Failure::NotData(kind) => format!("must be JSON data, got {kind}"), - Failure::NotUtf8 => "is not valid UTF-8".to_owned(), - Failure::NotFinite => "is not a finite number".to_owned(), - Failure::Unreadable(Some(label)) => { - format!("cannot be read ({label}); copy the fields you need into a plain table") - } - Failure::Unreadable(None) => { - "cannot be read; copy the fields you need into a plain table".to_owned() - } - Failure::Keys(rule) => rule.text(), - Failure::Cycle => "contains itself".to_owned(), - Failure::TooDeep => format!("is nested more than {DEPTH_LIMIT} levels deep"), +/// Why a sealed view whose data cannot be read, labelled by `label`, +/// cannot be encoded. +fn unreadable(label: Option<&str>) -> String { + match label { + Some(label) => { + format!("cannot be read ({label}); copy the fields you need into a plain table") } + None => "cannot be read; copy the fields you need into a plain table".to_owned(), } } +/// Why a table nested past the depth limit cannot be encoded. +fn too_deep() -> String { + format!("is nested more than {DEPTH_LIMIT} levels deep") +} + /// What a table stands for once sealed views are read through. enum Resolved { Data(Table), @@ -201,17 +201,18 @@ impl Encoder { .map_err(|TooLarge| self.too_large()) } - /// The error for `failure` at the current path. - fn error(&self, failure: &Failure) -> mlua::Error { + /// The refusal of the value at the current path, which `failure` says + /// why. + fn error(&self, failure: &str) -> mlua::Error { let path = self .keys .iter() .fold("value".to_owned(), |path, key| key_path(&path, key)); - author_refusal(format!("{}: {path} {}", self.prefix, failure.text())) + author_refusal(format!("{}: {path} {failure}", self.prefix)) } /// `placeholder` in debug mode, or the error for `failure`. - fn fail(&self, failure: &Failure, placeholder: String) -> mlua::Result { + fn fail(&self, failure: &str, placeholder: String) -> mlua::Result { if self.debug { Ok(Json::String(placeholder)) } else { @@ -230,14 +231,14 @@ impl Encoder { Value::Number(number) => match finite(*number) { Some(json) => Ok(json), None if self.debug => Ok(Json::String(non_finite_text(*number))), - None => Err(self.error(&Failure::NotFinite)), + None => Err(self.error(NOT_FINITE)), }, Value::String(text) => { self.charge_bytes(text.as_bytes().len())?; match text.to_str() { Ok(text) => Ok(Json::String(text.to_owned())), Err(_) if self.debug => Ok(Json::String(text.to_string_lossy())), - Err(_) => Err(self.error(&Failure::NotUtf8)), + Err(_) => Err(self.error(NOT_UTF8)), } } Value::Table(table) => self.table(table, level), @@ -247,7 +248,7 @@ impl Encoder { Value::Function(_) | Value::Thread(_) => format!("[{kind}]"), _ => "[userdata]".to_owned(), }; - self.fail(&Failure::NotData(kind), placeholder) + self.fail(¬_data(kind), placeholder) } } } @@ -262,16 +263,16 @@ impl Encoder { Some(label) => format!("[cannot read: {label}]"), None => "[cannot read]".to_owned(), }; - return self.fail(&Failure::Unreadable(label), placeholder); + return self.fail(&unreadable(label.as_deref()), placeholder); } - Resolved::Loop => return self.fail(&Failure::Cycle, "[cycle]".to_owned()), + Resolved::Loop => return self.fail(CYCLE, "[cycle]".to_owned()), }; let pointer = data.to_pointer(); if self.tables.contains(&pointer) { - return self.fail(&Failure::Cycle, "[cycle]".to_owned()); + return self.fail(CYCLE, "[cycle]".to_owned()); } if level > DEPTH_LIMIT { - return self.fail(&Failure::TooDeep, "[too deep]".to_owned()); + return self.fail(&too_deep(), "[too deep]".to_owned()); } let Entries { keys, held } = self.keys_of(&data)?; let shape = shape(&keys, self.is_marked(&data)); @@ -281,7 +282,7 @@ impl Encoder { Shape::List => self.list(&data, keys, level).map(Json::Array), Shape::Object => self.object(&data, keys, level), Shape::Broken(_) if self.debug => self.fallback(&data, keys, level), - Shape::Broken(rule) => Err(self.error(&Failure::Keys(rule))), + Shape::Broken(rule) => Err(self.error(&rule.text())), }; self.held -= held; self.tables.pop(); @@ -330,7 +331,7 @@ impl Encoder { Err(_) if self.debug => names.push(name.to_string_lossy()), Err(_) => { self.keys.push(key.clone()); - return Err(self.error(&Failure::NotUtf8)); + return Err(self.error(NOT_UTF8)); } } } @@ -373,8 +374,10 @@ impl Encoder { fn items(&mut self, list: &Table) -> mlua::Result> { let data = match self.resolve(list)? { Resolved::Data(data) => data, - Resolved::Unreadable(label) => return Err(self.error(&Failure::Unreadable(label))), - Resolved::Loop => return Err(self.error(&Failure::Cycle)), + Resolved::Unreadable(label) => { + return Err(self.error(&unreadable(label.as_deref()))); + } + Resolved::Loop => return Err(self.error(CYCLE)), }; // Only integer keys reach the list below, and they hold no // reference, so `held` has nothing to add for this table. @@ -389,7 +392,7 @@ impl Encoder { ))); } if let Shape::Broken(rule) = shape(&keys, marked) { - return Err(self.error(&Failure::Keys(rule))); + return Err(self.error(&rule.text())); } self.tables.push(data.to_pointer()); let items = self.list(&data, keys, 0); diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index a5982e632..73ec5a623 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -73,7 +73,6 @@ mod bindings; mod collection; mod compactors; mod coro; -mod detail; mod dispatch; mod engine_globals; mod error; @@ -125,9 +124,12 @@ pub use crate::dispatch::{ pub use crate::engine_globals::{ install_ui, run_store_op, store_error_message, store_probe_message, }; -pub use crate::error::{Error, QuotaResource}; +pub use crate::error::{ + QuotaResource, context_exhausted, internal_at, interrupted, lua_quota, lua_runtime, + task_cancelled, task_consumed, task_not_owned, tool_loop_exhausted, +}; pub use crate::error_value::{ - ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error_value_fields, + ErrorField, ErrorKind, error_fields, error_kind, 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/messages-tests-list.rs b/crates/runvil-internal/lua/src/messages-tests-list.rs index 52d114467..c2da95678 100644 --- a/crates/runvil-internal/lua/src/messages-tests-list.rs +++ b/crates/runvil-internal/lua/src/messages-tests-list.rs @@ -4,30 +4,25 @@ use runvil_model_client::client::{Message, Role}; use serde_json::json; -use crate::messages::{ListEditError, MessageList}; +use runvil_types::error::{Error, ErrorCode}; + +use crate::messages::{MessageList, late_system}; +use crate::project_messages; use crate::protocol::{MessageContent, MessageRecord, ToolCallRecord}; use crate::test_vm::{BuiltVm, TestVm}; use crate::tests::{assistant, system, tool_result, user}; -use crate::{Error, project_messages}; #[path = "messages-tests-list-commit.rs"] mod commit; -/// The message of the crate error a list method raised, not its +/// The message of the `LuaRuntime` refusal a list method raised, not its /// flattened traceback. #[track_caller] pub(super) fn refusal(vm: &BuiltVm, source: &str) -> String { let error = vm.eval_err(source); - let cause = match &error { - mlua::Error::CallbackError { cause, .. } => cause.as_ref(), - other => other, - }; - match cause { - mlua::Error::ExternalError(cause) => match cause.downcast_ref::() { - Some(Error::Lua(message)) => message.clone(), - other => panic!("expected an Error::Lua refusal, got {other:?}"), - }, - other => panic!("expected the list's refusal, got {other:?}"), + match error.downcast_ref::() { + Some(refused) if refused.code == ErrorCode::LuaRuntime => refused.message.clone(), + other => panic!("expected the list's LuaRuntime refusal, got {other:?} from {error:?}"), } } @@ -96,7 +91,7 @@ fn push_refuses_a_late_system_record_as_a_typed_edit_error_naming_its_position() let refusal = list .push(system("late")) .expect_err("a system record behind a user record"); - assert_eq!(refusal, ListEditError::LateSystem { index: 3 }); + assert_eq!(refusal, late_system(3)); assert!( refusal .to_string() diff --git a/crates/runvil-internal/lua/src/messages-tests.rs b/crates/runvil-internal/lua/src/messages-tests.rs index f7017e134..15b91659b 100644 --- a/crates/runvil-internal/lua/src/messages-tests.rs +++ b/crates/runvil-internal/lua/src/messages-tests.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use mlua::{Lua, LuaSerdeExt, Value}; +use runvil_types::error::ErrorCode; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::json; @@ -11,7 +12,7 @@ use super::MessageList; use crate::protocol::{Request, YieldParse}; use crate::test_vm::TestVm; use crate::tests::{system, user}; -use crate::{Error, OfferMode, RenderedTools, ToolBinding}; +use crate::{OfferMode, RenderedTools, ToolBinding}; #[path = "messages-tests-list.rs"] mod list; @@ -147,7 +148,7 @@ fn a_list_or_a_record_view_is_refused_by_the_var_and_prose_guards() { let vm = TestVm::new().build(); vm.install_lazy_prose(|state| { let refusals = ["messages", "record"].map(|name| match (state.globals)(name) { - Err(Error::Lua(message)) => message, + Err(error) if error.code == ErrorCode::LuaRuntime => error.message, other => format!("{name} was not refused: {other:?}"), }); Ok(refusals.join("|")) diff --git a/crates/runvil-internal/lua/src/messages.rs b/crates/runvil-internal/lua/src/messages.rs index cc1dd20a2..31c279528 100644 --- a/crates/runvil-internal/lua/src/messages.rs +++ b/crates/runvil-internal/lua/src/messages.rs @@ -40,7 +40,7 @@ use runvil_types::ids::RoundId; use serde_json::{Map, Value as Json}; use crate::bindings::ToolBinding; -use crate::error::{Error, Result, author_refusal}; +use crate::error::{self, Error, Result, author_refusal, lua_runtime}; use crate::protocol::{MessageRecord, RecordRole, parse_record}; use crate::scope::{OfferMode, RenderedTools}; @@ -60,10 +60,10 @@ pub(crate) const ASSIGNMENT_REFUSAL: &str = "messages is the section's conversat /// Installs `list` as the `messages` global, served from the guard's slot. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the userdata cannot be built or the guard's -/// slot table cannot be written. +/// Returns a `LuaRuntime` failure if the userdata cannot be built or the +/// guard's slot table cannot be written. pub(crate) fn install_messages(lua: &Lua, list: &MessageList) -> Result<()> { - let userdata = lua.create_userdata(list.clone()).map_err(Error::lua)?; + let userdata = lua.create_userdata(list.clone()).map_err(error::lua)?; crate::globals::set_messages(lua, userdata) } @@ -73,33 +73,13 @@ pub(crate) fn install_messages(lua: &Lua, list: &MessageList) -> Result<()> { #[derive(Clone, Debug, Default)] pub struct MessageList(Arc>); -/// Why a [`MessageList`] refused an edit, which leaves the list unchanged. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub(crate) enum ListEditError { - /// A system record at the 1-based `index` would sit behind a - /// non-system record. - #[error( +/// The refusal of an edit that would put the system record at the 1-based +/// `index` behind a non-system record, which leaves the list unchanged. +pub(crate) fn late_system(index: usize) -> Error { + lua_runtime(format!( "messages[{index}] is a system message after a non-system message; system messages \ lead the list, and only a compactor can change the leading block" - )] - LateSystem { - /// The late record's 1-based position. - index: usize, - }, - /// A `replace(first, last)` range falls outside a list of `len` records. - #[error( - "replace({first}, {last}) is out of bounds on a list of {len} records: it needs \ - 1 <= first <= last + 1 <= {}", - len.saturating_add(1) - )] - OutOfBounds { - /// The range's first position, as given. - first: i64, - /// The range's last position, as given. - last: i64, - /// The list's record count. - len: usize, - }, + )) } /// What a list holds. @@ -147,9 +127,9 @@ impl MessageList { /// refused. /// /// # Errors - /// Returns [`ListEditError::LateSystem`] naming a late system record's + /// Returns the [`late_system`] refusal naming a late system record's /// position. - pub(crate) fn push(&self, record: MessageRecord) -> std::result::Result<(), ListEditError> { + pub(crate) fn push(&self, record: MessageRecord) -> Result<()> { let mut state = self.state(); if record.role == RecordRole::System && state @@ -157,9 +137,7 @@ impl MessageList { .iter() .any(|held| held.role != RecordRole::System) { - return Err(ListEditError::LateSystem { - index: state.records.len() + 1, - }); + return Err(late_system(state.records.len() + 1)); } state.records.push(Arc::new(record)); Ok(()) @@ -168,12 +146,7 @@ impl MessageList { /// Replaces the 1-based inclusive range `first..=last` with /// `records`, where `1 <= first <= last + 1 <= len + 1`, then checks /// the leading-system rule. A refused edit leaves the list unchanged. - fn replace( - &self, - first: usize, - last: usize, - records: Vec, - ) -> std::result::Result<(), ListEditError> { + fn replace(&self, first: usize, last: usize, records: Vec) -> Result<()> { let mut state = self.state(); let len = state.records.len(); if !in_bounds(first, last, len) { @@ -245,18 +218,20 @@ fn in_bounds(first: usize, last: usize, len: usize) -> bool { } /// Returns the refusal for a `replace` range outside the list's bounds, -/// from the range's positions as given. -fn out_of_bounds(first: impl TryInto, last: impl TryInto, len: usize) -> ListEditError { - ListEditError::OutOfBounds { - first: first.try_into().unwrap_or(i64::MAX), - last: last.try_into().unwrap_or(i64::MAX), - len, - } +/// from the range's positions as given, which leaves the list unchanged. +fn out_of_bounds(first: impl TryInto, last: impl TryInto, len: usize) -> Error { + let first = first.try_into().unwrap_or(i64::MAX); + let last = last.try_into().unwrap_or(i64::MAX); + lua_runtime(format!( + "replace({first}, {last}) is out of bounds on a list of {len} records: it needs \ + 1 <= first <= last + 1 <= {}", + len.saturating_add(1) + )) } /// Refuses records whose system records do not all lead them, naming the /// first late one. -fn check_leading_system(records: &[Arc]) -> std::result::Result<(), ListEditError> { +fn check_leading_system(records: &[Arc]) -> Result<()> { let lead = records .iter() .take_while(|record| record.role == RecordRole::System) @@ -265,9 +240,7 @@ fn check_leading_system(records: &[Arc]) -> std::result::Result<( .iter() .position(|record| record.role == RecordRole::System) { - Some(offset) => Err(ListEditError::LateSystem { - index: lead + offset + 1, - }), + Some(offset) => Err(late_system(lead + offset + 1)), None => Ok(()), } } @@ -299,8 +272,7 @@ fn record_json(lua: &Lua, index: usize, value: Value) -> mlua::Result { /// appends it. fn add(list: &MessageList, index: usize, entry: &Json) -> mlua::Result<()> { let record = parse_record(index, entry).map_err(author_refusal)?; - list.push(record) - .map_err(|refused| author_refusal(refused.to_string())) + list.push(record).map_err(mlua::Error::external) } /// Appends the record a builder makes from `role` and `fields`, leaving @@ -437,7 +409,7 @@ impl UserData for MessageList { let len = target.len(); let (first, last) = match (usize::try_from(first), usize::try_from(last)) { (Ok(first), Ok(last)) if in_bounds(first, last, len) => (first, last), - _ => return Err(author_refusal(out_of_bounds(first, last, len).to_string())), + _ => return Err(mlua::Error::external(out_of_bounds(first, last, len))), }; let records = records .into_iter() @@ -450,7 +422,7 @@ impl UserData for MessageList { .collect::>>()?; target .replace(first, last, records) - .map_err(|refused| author_refusal(refused.to_string()))?; + .map_err(mlua::Error::external)?; Ok(this) }, ); diff --git a/crates/runvil-internal/lua/src/models-tests.rs b/crates/runvil-internal/lua/src/models-tests.rs index 5500460d6..d09d9f422 100644 --- a/crates/runvil-internal/lua/src/models-tests.rs +++ b/crates/runvil-internal/lua/src/models-tests.rs @@ -472,7 +472,8 @@ fn a_poisoned_selection_reaches_the_caller_as_an_internal_error() { .current_model() .expect_err("a poisoned selection cannot be read"); assert!( - matches!(&error, crate::Error::Internal { message, .. } if message == "model selection lock was poisoned"), + error.code == runvil_types::error::ErrorCode::Internal + && error.message == "internal invariant violated: model selection lock was poisoned", "the lock failure is Engine misuse, neither an authoring nor a model failure: {error:?}" ); } diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index fc8f06ee7..791cce1c5 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -14,12 +14,10 @@ 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}; +use crate::error::{self, Result}; #[path = "models-userdata.rs"] mod userdata; @@ -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) + } }) } @@ -261,7 +262,7 @@ fn parse_use_options(options: Value, rest: &MultiValue) -> mlua::Result>>, raw_ids: RawModelIds, ) -> Result<()> { - let models = lua.create_table().map_err(Error::lua)?; + let models = lua.create_table().map_err(error::lua)?; let frozen = Arc::clone(set); let current = Arc::clone(selection); @@ -302,8 +303,8 @@ pub(crate) fn install_models( Ok(LuaModelHandle::from_binding(binding)) }, ) - .map_err(Error::lua)?; - models.set("use", use_fn).map_err(Error::lua)?; + .map_err(error::lua)?; + models.set("use", use_fn).map_err(error::lua)?; let frozen = Arc::clone(set); let get_fn = lua @@ -319,8 +320,8 @@ pub(crate) fn install_models( }; Ok(LuaModelHandle::from_binding(binding)) }) - .map_err(Error::lua)?; - models.set("get", get_fn).map_err(Error::lua)?; + .map_err(error::lua)?; + models.set("get", get_fn).map_err(error::lua)?; let current = Arc::clone(selection); let current_fn = lua @@ -330,10 +331,10 @@ pub(crate) fn install_models( .map(LuaModelHandle::from_binding) .ok_or_else(|| mlua::Error::external("the caller supplied no model")) }) - .map_err(Error::lua)?; - models.set("current", current_fn).map_err(Error::lua)?; + .map_err(error::lua)?; + models.set("current", current_fn).map_err(error::lua)?; - globals.raw_set("models", models).map_err(Error::lua) + globals.raw_set("models", models).map_err(error::lua) } #[cfg(test)] #[path = "models-tests.rs"] diff --git a/crates/runvil-internal/lua/src/models_loop.rs b/crates/runvil-internal/lua/src/models_loop.rs index b53f3e8ff..6d5b0585f 100644 --- a/crates/runvil-internal/lua/src/models_loop.rs +++ b/crates/runvil-internal/lua/src/models_loop.rs @@ -26,15 +26,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use mlua::{AnyUserData, Function, Lua, LuaSerdeExt, MultiValue, Table, Value}; use runvil_types::metrics::ToolCallEvent; -use crate::error::{Error, Result}; -use crate::error_value::{ErrorKind, error_table, normalized}; +use crate::error::{self, Result, authored, lua_runtime}; +use crate::error_value::{ErrorKind, error_table, message_table, normalized}; use crate::hardening::CancelHook; use crate::messages::MessageList; use crate::protocol::ChatResult; mod machine; -use machine::{Input, Machine, Phase, Then, raised, settle}; +use machine::{Input, Machine, Phase, Then, settle}; /// `models.loop`'s refusal past its two argument shapes. const LOOP_ARITY: &str = "models.loop takes (compactor?) or (model, compactor?)"; @@ -76,7 +76,7 @@ const COMPACT_RUNNING: &str = /// called `cancel_requested()`. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the function cannot be created. +/// Returns a `LuaRuntime` failure if the function cannot be created. pub(crate) fn loop_begin( lua: &Lua, max_tool_iterations: usize, @@ -89,7 +89,7 @@ pub(crate) fn loop_begin( lua.create_function(move |lua, args: MultiValue| { begin(lua, args, max_tool_iterations, &compactors, &running, &hook) }) - .map_err(Error::lua) + .map_err(error::lua) } /// `loop_begin`'s body: the leading handle, then the arity; the @@ -180,7 +180,7 @@ fn begin( /// VM's cancellation hook, whose cancel flag the step reads. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the function cannot be created. +/// Returns a `LuaRuntime` failure if the function cannot be created. pub(crate) fn compact_begin( lua: &Lua, loop_running: &Arc, @@ -189,7 +189,7 @@ pub(crate) fn compact_begin( let running = Arc::clone(loop_running); let hook = hook.clone(); lua.create_function(move |lua, args: MultiValue| compact(lua, args, &running, &hook)) - .map_err(Error::lua) + .map_err(error::lua) } /// `compact_begin`'s body. The flag is set only once nothing here can @@ -242,9 +242,9 @@ fn compact( } /// `loop_begin`'s answer to a refused argument: no step closure, then the -/// `"raise"` action with a new `lua`-kind error. +/// `"raise"` action with a new `LuaRuntime` failure. fn refuse(lua: &Lua, message: &str) -> mlua::Result { - let mut action = act(lua, Then::RaiseNew(raised(ErrorKind::Lua, message)))?; + let mut action = act(lua, Then::RaiseNew(lua_runtime(message)))?; action.push_front(Value::Nil); Ok(action) } @@ -308,8 +308,9 @@ fn answer( /// Turns the machine's next step into the trampoline's tag and values: /// each request table built as the shim builds it, a call's arguments /// converted with `to_value`, the overflow reason as its `tag()`, a -/// normalized failure through `error_value::normalized`, and a new error -/// as `error_table` over `Error::Raised`. +/// normalized failure through `error_value::normalized`, and a new +/// failure as its error table, with author prose when the prompt's Lua +/// caused it. fn act(lua: &Lua, then: Then) -> mlua::Result { let (tag, values) = match then { Then::Chat { messages, handle } => { @@ -355,8 +356,8 @@ fn act(lua: &Lua, then: Then) -> mlua::Result { Then::Return => ("return", Vec::new()), Then::Raise(value) => ("raise", vec![value]), Then::RaiseNormalized(failure) => ("raise", vec![normalized(lua, failure)?]), - Then::RaiseNew(raised) => { - let table = error_table(lua, &Error::Raised(raised))?; + Then::RaiseNew(failure) => { + let table = error_table(lua, &authored(lua, failure))?; ("raise", vec![Value::Table(table)]) } }; @@ -388,16 +389,16 @@ fn request(lua: &Lua, op: &str, fields: [(&str, Value); N]) -> m /// The value `fail` raises for an envelope's failure: an error table /// unchanged; anything else a new `lua`-kind table whose message is what -/// the `tostring` global returns for it. The Engine renders every failure -/// as an error table, so only a hand-built envelope reaches the second -/// branch. +/// the `tostring` global returns for it, as `fail` builds it. The Engine +/// renders every failure as an error table, so only a hand-built envelope +/// reaches the second branch. fn envelope_failure(lua: &Lua, value: Value) -> mlua::Result { if let Value::Table(_) = value { return Ok(value); } let tostring: Function = lua.globals().get("tostring")?; let message: String = tostring.call(value)?; - let table = error_table(lua, &Error::Raised(raised(ErrorKind::Lua, &message)))?; + let table = message_table(lua, ErrorKind::Lua, &message)?; Ok(Value::Table(table)) } @@ -429,7 +430,7 @@ fn truthy(value: &Value) -> bool { /// produces: a crate bug, raised as an mlua callback error. #[track_caller] fn malformed(what: &'static str) -> mlua::Error { - mlua::Error::external(Error::internal(what)) + mlua::Error::external(error::internal(what)) } /// Lua's `type()` name for `value`, with an integer named `integer`, as diff --git a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs index ca5b6896a..0c408cca5 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs @@ -2,9 +2,11 @@ //! place of the Lua values it passes along, and a plain list read back //! through `records()`. +use runvil_types::error::FactKey; + use super::{Input, Machine, Phase, Then}; use crate::compactors::OverflowReason; -use crate::error_value::ErrorField; +use crate::error_value::error_kind; use crate::messages::MessageList; use crate::models_loop::tests::{echo_call, round}; use crate::protocol::{ChatOutcome, ChatResult, MessageContent, MessageRecord}; @@ -34,15 +36,16 @@ fn show(then: &Then) -> String { Then::Return => "return".to_owned(), Then::Raise(value) => format!("raise {value}"), Then::RaiseNormalized(value) => format!("normalize {value}"), - Then::RaiseNew(raised) => match raised.fields.get("finish_reason") { - Some(ErrorField::String(reason)) => { - format!("new {}|{}|{reason}", raised.kind, raised.message) - } - Some(ErrorField::Integer(reason)) => { - panic!("finish_reason must be a string field, actual the integer {reason}") + Then::RaiseNew(raised) => { + let kind = error_kind(raised); + match raised.facts.get(&FactKey::FinishReason) { + Some(serde_json::Value::String(reason)) => { + format!("new {kind}|{}|{reason}", raised.message) + } + Some(other) => panic!("finish_reason must be a string fact, actual {other}"), + None => format!("new {kind}|{}", raised.message), } - None => format!("new {}|{}", raised.kind, raised.message), - }, + } } } diff --git a/crates/runvil-internal/lua/src/models_loop/machine.rs b/crates/runvil-internal/lua/src/models_loop/machine.rs index a4a948640..5ad889336 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine.rs @@ -18,13 +18,12 @@ //! round compacts at most once, so a retry that overflows again puts the //! snapshot back and raises `context_exhausted`. -use std::collections::BTreeMap; - +use runvil_model_client::model::completion_error; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::metrics::ToolCallEvent; use crate::compactors::{OverflowReason, estimate_tokens}; -use crate::error::Error; -use crate::error_value::{ErrorField, ErrorKind, ErrorValue, Raised}; +use crate::error::{self, Error, context_exhausted, tool_loop_exhausted}; use crate::messages::{MessageList, Snapshot}; use crate::projection::project_messages; use crate::protocol::{ @@ -35,12 +34,8 @@ use crate::protocol::{ /// empty detail. const EMPTY_MODEL_REPLY: &str = "empty model reply"; -/// The message the round cap raises. -const NOT_CONVERGED: &str = "tool-call loop did not converge"; - -/// The message raised for an input the machine's phase does not await. -const UNEXPECTED_INPUT: &str = - "internal invariant violated: the models.loop step received an input its phase does not await"; +/// The invariant an input the machine's phase does not await breaks. +const UNEXPECTED_INPUT: &str = "the models.loop step received an input its phase does not await"; /// One loop call: its rules' state and the values it passes along. #[derive(Debug)] @@ -154,8 +149,8 @@ pub(super) enum Then { Raise(V), /// Raise a compactor's failure through `normalize_failure`. RaiseNormalized(V), - /// Raise a new error table. - RaiseNew(Raised), + /// Raise a new failure, as its error table. + RaiseNew(Error), } impl Machine { @@ -233,7 +228,7 @@ impl Machine { | (Phase::Batching, Input::Batched(Err(failure))) | (Phase::Calling, Input::Dispatched(Err(failure))) | (Phase::Reporting(None), Input::Reported(Err(failure))) => Then::Raise(failure), - _ => Then::RaiseNew(raised(ErrorKind::Internal, UNEXPECTED_INPUT)), + _ => Then::RaiseNew(error::internal(UNEXPECTED_INPUT)), } } @@ -241,7 +236,7 @@ impl Machine { /// `tool_loop_exhausted` once the cap's rounds have all started. fn next_round(&mut self) -> Then { if self.rounds >= self.max_rounds { - return Then::RaiseNew(raised(ErrorKind::ToolLoopExhausted, NOT_CONVERGED)); + return Then::RaiseNew(tool_loop_exhausted()); } self.rounds += 1; self.chat(None) @@ -283,14 +278,12 @@ impl Machine { let record = text_record(RecordRole::Assistant, String::new()); return self.push(vec![record], |_| Then::Return); } - let mut empty = raised(ErrorKind::EmptyModelReply, EMPTY_MODEL_REPLY); - if let Some(detail) = detail { - empty.message = detail; - } + let mut empty = completion_error( + ErrorCode::EmptyReply, + detail.unwrap_or_else(|| EMPTY_MODEL_REPLY.to_owned()), + ); if let Some(reason) = round.finish_reason { - empty - .fields - .insert("finish_reason".to_owned(), ErrorField::String(reason)); + empty.facts.insert(FactKey::FinishReason, reason.into()); } Then::RaiseNew(empty) } @@ -319,7 +312,7 @@ impl Machine { fn take_batch(&mut self, texts: Vec) -> Then { let unanswered = self.calls.len() - self.results.len(); if texts.len() > unanswered { - return Then::RaiseNew(raised(ErrorKind::Internal, UNEXPECTED_INPUT)); + return Then::RaiseNew(error::internal(UNEXPECTED_INPUT)); } let short = texts.len() < unanswered; self.results.extend(texts); @@ -381,7 +374,7 @@ impl Machine { for record in records { if let Err(refused) = self.list.push(record) { self.phase = Phase::Done; - return Then::RaiseNew(raised(ErrorKind::Internal, &refused.to_string())); + return Then::RaiseNew(error::internal(refused.message)); } } next(self) @@ -422,9 +415,8 @@ fn check( list: &MessageList, snapshot: &Snapshot, overflow: Option, -) -> std::result::Result<(), Raised> { - let projected = project_messages(&list.to_records()) - .map_err(|refused| raised(refused.kind(), &refused.to_string()))?; +) -> std::result::Result<(), Error> { + let projected = project_messages(&list.to_records())?; let Some(reason) = overflow else { return Ok(()); }; @@ -441,15 +433,8 @@ fn check( /// A new `context_exhausted` error for `reason`, as `compactors.fail` /// raises it. -fn exhausted(reason: OverflowReason) -> Raised { - let error = Error::ContextExhausted { - reason: reason.tag().to_owned(), - }; - Raised { - kind: error.kind(), - message: error.to_string(), - fields: error.fields().into_iter().collect(), - } +fn exhausted(reason: OverflowReason) -> Error { + context_exhausted(reason.tag()) } /// A record of `role` holding the plain text `content` and no `meta`. @@ -462,15 +447,6 @@ fn text_record(role: RecordRole, content: String) -> MessageRecord { } } -/// A new error of `kind` with `message` and no fields. -pub(super) fn raised(kind: ErrorKind, message: &str) -> Raised { - Raised { - kind, - message: message.to_owned(), - fields: BTreeMap::new(), - } -} - #[cfg(test)] #[path = "machine-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/models_loop/tests.rs b/crates/runvil-internal/lua/src/models_loop/tests.rs index ea0133010..bc23c8097 100644 --- a/crates/runvil-internal/lua/src/models_loop/tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/tests.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use mlua::{Function, Lua, MultiValue, Value}; use runvil_model_client::model::{ModelBinding, ModelId, ModelInvocation}; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::metrics::ToolCallEvent; use runvil_types::tools::ToolId; use serde_json::json; @@ -14,13 +15,13 @@ use serde_json::json; use super::machine::{Input, Phase, Then}; use super::{LOOP_RUNNING, act, envelope_failure, loop_begin, lua_type_name, read_input}; use crate::compactors::OverflowReason; -use crate::error::Error; -use crate::error_value::{ErrorField, ErrorKind, Raised, error_table, raised_from}; +use crate::error_value::{ErrorKind, error_kind, error_table, raised_from}; use crate::hardening::CancelHook; use crate::messages::MessageList; use crate::models::LuaModelHandle; use crate::protocol::{AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, YieldParse}; use crate::test_support::answer_envelope; +use crate::tests::refused; /// A requested call named `echo`, its arguments naming its id. pub(super) fn echo_call(id: &str) -> ToolCallEvent { @@ -119,33 +120,35 @@ fn the_entry_checks_its_arguments_in_order_with_the_shims_texts() { ) .call((begin, handle(&lua), Value::NULL)) .unwrap(); - let arity = "nil|raise|models.loop takes (compactor?) or (model, compactor?)"; - let integer = "nil|raise|compactor must be a function, got integer"; - let number = "nil|raise|compactor must be a function, got number"; - let started = "function|yield|chat"; + let raise = |message: &str| format!("nil|raise|{}", refused(message)); + let arity = raise("models.loop takes (compactor?) or (model, compactor?)"); + let integer = raise("compactor must be a function, got integer"); + let number = raise("compactor must be a function, got number"); + let userdata = raise("compactor must be a function, got userdata"); + let started = "function|yield|chat".to_owned(); assert_eq!( outcomes, [ // Three arguments, whichever shape the first one starts. - arity, + arity.clone(), // A handle then one argument is the handle shape. - started, + started.clone(), // A handle then a non-function compactor. number, - arity, + arity.clone(), // A non-handle first argument takes the section-model shape, so // it may carry only one argument. - arity, - arity, - arity, + arity.clone(), + arity.clone(), + arity.clone(), arity, // That one argument is the compactor, checked after the arity. - integer, - "nil|raise|compactor must be a function, got userdata", + integer.clone(), + userdata, // No arguments, a nil argument, a handle alone, and a handle // with nil all take a documented shape. - started, - started, + started.clone(), + started.clone(), started, integer, ] @@ -217,7 +220,7 @@ fn a_running_loop_refuses_another_until_its_step_ends_or_fails() { ) .call((begin, capped, is_running, handle(&lua))) .unwrap(); - let refused = format!("nil|raise|{LOOP_RUNNING}=true"); + let refused = format!("nil|raise|{}=true", refused(LOOP_RUNNING)); assert_eq!( seen, format!( @@ -245,8 +248,7 @@ fn a_step_failing_on_a_full_heap_after_an_overflow_leaves_no_flag_set() { outcome: ChatOutcome::Overflow(OverflowReason::Precheck), ..round() }; - let (envelope, _) = - answer_envelope(&lua, Ok(Answer::::Chat(Box::new(overflowed)))).unwrap(); + let (envelope, _) = answer_envelope(&lua, Ok(Answer::Chat(Box::new(overflowed)))).unwrap(); lua.gc_collect().unwrap(); lua.set_memory_limit(lua.used_memory() + headroom).unwrap(); let stepped = step.call::(envelope); @@ -274,7 +276,7 @@ fn a_step_failing_on_a_full_heap_after_an_overflow_leaves_no_flag_set() { #[test] fn envelope_failure_keeps_an_error_table_and_wraps_anything_else() { let lua = Lua::new(); - let table = error_table(&lua, &Error::Interrupted).unwrap(); + let table = error_table(&lua, &crate::interrupted()).unwrap(); let kept = envelope_failure(&lua, Value::Table(table.clone())).unwrap(); assert_eq!(kept.to_pointer(), table.to_pointer()); let boom = Value::String(lua.create_string("boom").unwrap()); @@ -283,8 +285,8 @@ fn envelope_failure_keeps_an_error_table_and_wraps_anything_else() { .unwrap() .expect("an error table"); assert_eq!( - (raised.kind, raised.message.as_str()), - (ErrorKind::Lua, "boom") + (error_kind(&raised), raised.code, raised.message.as_str()), + (ErrorKind::Lua, ErrorCode::LuaRuntime, "boom") ); } @@ -299,7 +301,7 @@ fn a_chat_answer_resumes_as_a_chat_result_userdata_the_step_takes_whole() { ..round() }; let (envelope, retained) = - answer_envelope(&lua, Ok(Answer::::Chat(Box::new(served.clone())))).unwrap(); + answer_envelope(&lua, Ok(Answer::Chat(Box::new(served.clone())))).unwrap(); assert!(retained.is_none()); let values = envelope.into_vec(); let Some(Value::UserData(resumed)) = values.get(1).cloned() else { @@ -408,18 +410,9 @@ fn act_names_each_call_return_and_raise_with_its_values() { ); let plain = Then::RaiseNormalized(value("plain")); assert_eq!(action(&lua, plain), tagged("raise", vec![value("plain")])); - let typed = mlua::Error::external(Error::ContextExhausted { - reason: "precheck".to_owned(), - }); - let new = Raised { - kind: ErrorKind::EmptyModelReply, - message: "empty".to_owned(), - fields: [( - "finish_reason".to_owned(), - ErrorField::String("stop".to_owned()), - )] - .into(), - }; + let typed = mlua::Error::external(crate::context_exhausted("precheck")); + let mut new = runvil_model_client::model::completion_error(ErrorCode::EmptyReply, "empty"); + new.facts.insert(FactKey::FinishReason, "stop".into()); let raised = [ ( Then::RaiseNormalized(Value::Error(Box::new(typed))), @@ -434,12 +427,13 @@ fn act_names_each_call_return_and_raise_with_its_values() { panic!("an error table"); }; let read = |name| table.raw_get::(name).unwrap(); - let field_name = if kind == "context_exhausted" { - "reason" + let (field_name, code) = if kind == "context_exhausted" { + ("reason", ErrorCode::ContextExhausted) } else { - "finish_reason" + ("finish_reason", ErrorCode::EmptyReply) }; assert_eq!((tag.as_str(), read("kind").as_str()), ("raise", kind)); + assert_eq!(read("code"), code.tag()); assert_eq!(read(field_name), field); } } diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index 3d26f4ff4..6443386b7 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -27,7 +27,7 @@ use runvil_types::packs::PackId; use runvil_types::tools::ToolId; use crate::bindings::{RosterEntry, ToolSet}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::proxy::read_only_walk; use crate::tools::tool_object; use crate::vm::AdhocTools; @@ -132,13 +132,13 @@ pub(crate) struct PackNames { /// Builds one Pack object per Pack `pack_tools` lists and returns their names. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if an object cannot be created or stored. +/// Returns a `LuaRuntime` failure if an object cannot be created or stored. pub(crate) fn install_pack_objects( lua: &Lua, set: &ToolSet, adhoc: &AdhocTools, ) -> Result { - let objects = lua.create_table().map_err(Error::lua)?; + let objects = lua.create_table().map_err(error::lua)?; let mut names = PackNames { all: Vec::new(), extras: Vec::new(), @@ -156,19 +156,19 @@ pub(crate) fn install_pack_objects( required, tools, }) - .map_err(Error::lua)?; - objects.raw_set(key, object).map_err(Error::lua)?; + .map_err(error::lua)?; + objects.raw_set(key, object).map_err(error::lua)?; } lua.set_named_registry_value(PACK_OBJECTS_REGISTRY, objects) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok(names) } /// Installs `tools.packs` on `tools` over the Pack objects `names` lists. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if a function or table cannot be created -/// or installed, and [`Error::Internal`] if the `tools.packs` proxy comes +/// Returns a `LuaRuntime` failure if a function or table cannot be created +/// or installed, and an `Internal` failure if the `tools.packs` proxy comes /// back without the metatable `__len` is set on. pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> Result<()> { let PackNames { all, extras } = names; @@ -180,10 +180,10 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R other.type_name() ))), }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let extras = lua .create_function(move |lua, ()| pack_array(lua, &extras)) - .map_err(Error::lua)?; + .map_err(error::lua)?; let indexed = all.clone(); let index = lua @@ -195,11 +195,11 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R None => Ok(Value::Nil), }, }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let count = all.len(); let len = lua .create_function(move |_, _: MultiValue| Ok(count)) - .map_err(Error::lua)?; + .map_err(error::lua)?; let step = lua .create_function(move |lua, (_, key): (Value, Value)| { let next = match key { @@ -214,13 +214,13 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R None => Ok((Value::Nil, Value::Nil)), } }) - .map_err(Error::lua)?; - let proxy = read_only_walk(lua, index, step, "tools.packs").map_err(Error::lua)?; + .map_err(error::lua)?; + let proxy = read_only_walk(lua, index, step, "tools.packs").map_err(error::lua)?; let meta = proxy .metatable() - .ok_or_else(|| Error::internal("tools.packs was built without a metatable"))?; - meta.raw_set("__len", len).map_err(Error::lua)?; - tools.raw_set("packs", proxy).map_err(Error::lua) + .ok_or_else(|| error::internal("tools.packs was built without a metatable"))?; + meta.raw_set("__len", len).map_err(error::lua)?; + tools.raw_set("packs", proxy).map_err(error::lua) } /// The 0-based position an integer key of `tools.packs` names, or `None` diff --git a/crates/runvil-internal/lua/src/prelude-tests-environment.rs b/crates/runvil-internal/lua/src/prelude-tests-environment.rs index e1a8a27cf..87d4e1103 100644 --- a/crates/runvil-internal/lua/src/prelude-tests-environment.rs +++ b/crates/runvil-internal/lua/src/prelude-tests-environment.rs @@ -145,6 +145,7 @@ fn a_prelude_function_called_from_a_block_yields_its_tool_call() { .expect("the prelude installs"); let block = LuaProgram::compile( "return kit.run('ls')", + "prompt", "prelude test block", NonZeroU32::new(1).expect("a non-zero line"), &null_emitter(), diff --git a/crates/runvil-internal/lua/src/prelude-tests.rs b/crates/runvil-internal/lua/src/prelude-tests.rs index 2a959c4e4..8dd1a834d 100644 --- a/crates/runvil-internal/lua/src/prelude-tests.rs +++ b/crates/runvil-internal/lua/src/prelude-tests.rs @@ -217,11 +217,13 @@ fn json_encode_of_var_inside_a_prelude_function_cannot_read_the_view() { ); assert!(!ok, "encoding the prelude's var view raises"); assert_eq!( - (kind.as_str(), message.as_str()), + (kind.as_str(), message), ( "lua", - "json.encode: value cannot be read (var is read-only); \ - copy the fields you need into a plain table" + crate::tests::refused( + "json.encode: value cannot be read (var is read-only); \ + copy the fields you need into a plain table" + ) ) ); let section_text: String = vm.eval("return json.encode(var)"); diff --git a/crates/runvil-internal/lua/src/prelude.rs b/crates/runvil-internal/lua/src/prelude.rs index 00ba549c8..cedf428f2 100644 --- a/crates/runvil-internal/lua/src/prelude.rs +++ b/crates/runvil-internal/lua/src/prelude.rs @@ -25,7 +25,7 @@ use mlua::chunk::ChunkMode; use mlua::{Lua, Table, Value}; use runvil_types::packs::{PackId, Prelude}; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result, lua_runtime}; use crate::proxy::{key_path, key_text, read_only_proxy}; /// The `_G` names a prelude's environment reads, as bound when it installs. @@ -70,10 +70,9 @@ const VISIBLE_GLOBALS: [&str; 21] = [ /// prelude that calls `tools.call` while loading fails. /// /// # Errors -/// Returns [`Error::LuaRuntime`] naming the Pack when a prelude -/// fails to load, and [`Error::Lua`] naming the Pack, the global, -/// and what it collides with when a global collides or is not named by a -/// string. +/// Returns a `LuaRuntime` failure naming the Pack when a prelude fails to +/// load, or naming the Pack, the global, and what it collides with when a +/// global collides or is not named by a string. pub fn install_preludes(lua: &Lua, preludes: &[Prelude]) -> Result<()> { let globals = lua.globals(); let mut installed: BTreeMap = BTreeMap::new(); @@ -85,7 +84,7 @@ pub fn install_preludes(lua: &Lua, preludes: &[Prelude]) -> Result<()> { .set_mode(ChunkMode::Text) .set_environment(env.clone()) .call::<()>(pack.to_string()) - .map_err(|error| load_failure(pack, error))?; + .map_err(|error| load_failure(pack, &error))?; let defined = defined_globals(pack, &env)?; for name in defined.keys() { check_collision(&globals, pack, name, &installed)?; @@ -95,7 +94,7 @@ pub fn install_preludes(lua: &Lua, preludes: &[Prelude]) -> Result<()> { Value::Table(table) => Value::Table(seal(lua, pack, &name, table)?), other => other, }; - globals.raw_set(name.as_str(), value).map_err(Error::lua)?; + globals.raw_set(name.as_str(), value).map_err(error::lua)?; installed.insert(name, pack); } } @@ -107,19 +106,19 @@ pub fn install_preludes(lua: &Lua, preludes: &[Prelude]) -> Result<()> { /// globals, and the `string`, `table`, `math`, `utf8`, `tools`, `store`, and /// `json` tables it reads through that lookup are shared with the rest of the VM. fn environment(lua: &Lua, globals: &Table) -> Result
{ - let lookup = lua.create_table().map_err(Error::lua)?; + let lookup = lua.create_table().map_err(error::lua)?; for name in VISIBLE_GLOBALS { - let value: Value = globals.raw_get(name).map_err(Error::lua)?; - lookup.raw_set(name, value).map_err(Error::lua)?; + let value: Value = globals.raw_get(name).map_err(error::lua)?; + lookup.raw_set(name, value).map_err(error::lua)?; } - if let Value::Table(var) = globals.raw_get::("var").map_err(Error::lua)? { - let view = read_only_var(lua, var, "var".to_owned()).map_err(Error::lua)?; - lookup.raw_set("var", view).map_err(Error::lua)?; + if let Value::Table(var) = globals.raw_get::("var").map_err(error::lua)? { + let view = read_only_var(lua, var, "var".to_owned()).map_err(error::lua)?; + lookup.raw_set("var", view).map_err(error::lua)?; } - let metatable = lua.create_table().map_err(Error::lua)?; - metatable.raw_set("__index", lookup).map_err(Error::lua)?; - let env = lua.create_table().map_err(Error::lua)?; - env.set_metatable(Some(metatable)).map_err(Error::lua)?; + let metatable = lua.create_table().map_err(error::lua)?; + metatable.raw_set("__index", lookup).map_err(error::lua)?; + let env = lua.create_table().map_err(error::lua)?; + env.set_metatable(Some(metatable)).map_err(error::lua)?; Ok(env) } @@ -149,7 +148,7 @@ fn read_only_var(lua: &Lua, target: Table, path: String) -> mlua::Result
fn defined_globals(pack: &PackId, env: &Table) -> Result> { let mut defined = BTreeMap::new(); for pair in env.pairs::() { - let (key, value) = pair.map_err(Error::lua)?; + let (key, value) = pair.map_err(error::lua)?; if let Value::String(name) = &key && let Ok(name) = name.to_str() { @@ -160,7 +159,7 @@ fn defined_globals(pack: &PackId, env: &Table) -> Result Value::String(_) => "a string key that is not valid UTF-8".to_owned(), other => format!("a key of type {}", other.type_name()), }; - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "Pack `{pack}`: its prelude defines a global under {key}; a global's \ name must be a UTF-8 string" ))); @@ -187,14 +186,14 @@ fn check_collision( } else if let Some(kind) = crate::reserved_name(name) { format!("which is reserved as {kind}") } else if !matches!( - globals.raw_get::(name).map_err(Error::lua)?, + globals.raw_get::(name).map_err(error::lua)?, Value::Nil ) { "which is already an Engine global".to_owned() } else { return Ok(()); }; - Err(Error::Lua(format!( + Err(lua_runtime(format!( "Pack `{pack}`: its prelude defines the global `{name}`, {collides_with}" ))) } @@ -209,13 +208,13 @@ fn seal(lua: &Lua, pack: &PackId, name: &str, hidden: Table) -> Result
{ move |key| format!("{refusal} '{}'", key_text(key)), &format!("{name} is sealed"), ) - .map_err(Error::lua) + .map_err(error::lua) } /// Maps a prelude's load or run failure to the model-facing message, -/// keeping the Lua traceback after it and the `mlua` error as the cause. -fn load_failure(pack: &PackId, error: mlua::Error) -> Error { - let rendered = match &error { +/// keeping the Lua traceback after it. +fn load_failure(pack: &PackId, error: &mlua::Error) -> Error { + let rendered = match error { mlua::Error::RuntimeError(message) => message.clone(), other => other.to_string(), }; @@ -231,10 +230,7 @@ fn load_failure(pack: &PackId, error: mlua::Error) -> Error { message.push_str("\nstack traceback:"); message.push_str(traceback); } - Error::LuaRuntime { - message, - cause: Box::new(error), - } + lua_runtime(message) } #[cfg(test)] diff --git a/crates/runvil-internal/lua/src/program-tests.rs b/crates/runvil-internal/lua/src/program-tests.rs new file mode 100644 index 000000000..57e31349f --- /dev/null +++ b/crates/runvil-internal/lua/src/program-tests.rs @@ -0,0 +1,145 @@ +//! Tests for the compile path's location and the runtime mapping's +//! location and author prose, a quota refusal's among them. + +use std::num::NonZeroU32; + +use mlua::Lua; +use runvil_types::emitter::{Emitter, EventSink}; +use runvil_types::error::{ErrorCode, FactKey, Location}; +use serde_json::Value; + +use super::{LuaProgram, compile_line, map_chunk_line_to_absolute}; +use crate::error::{QuotaResource, lua_quota}; + +/// The file every program here comes from. +const FILE: &str = "agents/a.md"; + +/// The region every program here is. +const REGION: &str = "section `S` prologue"; + +/// The line `n`. +fn line(n: u32) -> NonZeroU32 { + NonZeroU32::new(n).expect("tests pass non-zero lines") +} + +/// `source` compiled as the block of [`REGION`] in [`FILE`] that starts on +/// `start`. +fn compiled(source: &str, start: u32) -> LuaProgram { + let emitter = Emitter::root(EventSink::default(), "test", false); + LuaProgram::compile(source, FILE, REGION, line(start), &emitter, "S") + .expect("the block compiles") +} + +/// The failure running `program` in `lua` raises. +fn raised(program: &LuaProgram, lua: &Lua) -> mlua::Error { + program + .load(lua) + .expect("the bytecode loads") + .call::<()>(()) + .expect_err("the block fails") +} + +#[test] +fn a_compile_line_maps_the_chunk_line_or_keeps_the_block_line() { + let diagnostic = "[string \"line 40\"]:3: unexpected symbol near "; + assert_eq!(compile_line("line 40", line(40), diagnostic), line(42)); + assert_eq!( + compile_line("line 41", line(41), diagnostic), + line(41), + "another chunk's line is not this block's" + ); + assert_eq!(compile_line("line 40", line(40), "no chunk line"), line(40)); + let chunk = format!("line {}", u32::MAX); + assert_eq!( + compile_line( + &chunk, + NonZeroU32::MAX, + &format!("[string \"{chunk}\"]:2: x") + ), + NonZeroU32::MAX, + "an overflowing mapping keeps the block line" + ); +} + +#[test] +fn a_compile_failure_is_located_on_its_diagnostics_line_with_author_prose() { + let emitter = Emitter::root(EventSink::default(), "test", false); + let error = LuaProgram::compile( + "local a = 1\nlocal b =", + FILE, + REGION, + line(11), + &emitter, + "S", + ) + .expect_err("the block must not compile"); + let diagnostic = "[string \"line 11\"]:2: unexpected symbol near "; + assert_eq!(error.code, ErrorCode::LuaCompile); + assert_eq!( + error.message, + format!("lua compilation error at {REGION} (line 11): syntax error: {diagnostic}") + ); + assert_eq!(error.at, Some(Location::line(FILE, line(12)))); + assert_eq!( + error.facts.get(&FactKey::Section), + Some(&Value::from(REGION)) + ); + assert_eq!( + error.prose, + format!( + "{FILE}:12: unexpected symbol near . {}", + ErrorCode::LuaCompile.prose() + ) + ); +} + +#[test] +fn a_runtime_failure_is_located_on_its_mapped_line_with_author_prose() { + let program = compiled("local x = 1\nerror('boom')", 50); + let lua = Lua::new(); + let raw = raised(&program, &lua); + let mapped = program.map_runtime_error_among(&raw, &[], FILE); + let (message, _) = + map_chunk_line_to_absolute(&raw.to_string(), &[("line 50", REGION, line(50))]); + assert_eq!(mapped.code, ErrorCode::LuaRuntime); + assert_eq!(mapped.message, message, "the message keeps its mapped text"); + assert_eq!(mapped.at, Some(Location::line(FILE, line(51)))); + assert_eq!( + mapped.prose, + format!("{FILE}:51: boom. {}", ErrorCode::LuaRuntime.prose()) + ); +} + +#[test] +fn a_quota_refusal_raised_through_lua_is_recognized_at_the_boundary() { + let lua = Lua::new(); + let log = lua + .create_function(|_, ()| -> mlua::Result<()> { + Err(mlua::Error::external(lua_quota(QuotaResource::LogEvent))) + }) + .expect("the callback builds"); + lua.globals().set("log", log).expect("the global installs"); + let program = compiled("local x = 1\nlog()", 7); + let mapped = program.map_runtime_error_among(&raised(&program, &lua), &[], FILE); + let quota = lua_quota(QuotaResource::LogEvent); + assert_eq!( + (mapped.code, &mapped.message, &mapped.facts), + (ErrorCode::LuaQuota, "a.message, "a.facts) + ); + assert_eq!(mapped.at, Some(Location::line(FILE, line(8)))); + assert_eq!( + mapped.prose, + format!( + "{FILE}:8: lua log event quota exceeded. {}", + ErrorCode::LuaQuota.prose() + ) + ); + let mimic = compiled("error('lua log event quota exceeded')", 7); + assert_eq!( + mimic + .map_runtime_error_among(&raised(&mimic, &lua), &[], FILE) + .code, + ErrorCode::LuaRuntime, + "an author who raises the same text fails as a runtime error" + ); +} diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index fa0cd419a..91e4671d6 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -4,28 +4,24 @@ use std::num::NonZeroU32; use mlua::{Function, Lua, LuaOptions, StdLib}; use runvil_types::emitter::Emitter; +use runvil_types::error::ErrorCode; use runvil_types::event::lifecycle; -use crate::error::{Error, QuotaExceeded, Result}; +use crate::error::{ + self, Error, Result, authored_at, failure_text, lua_compile, lua_runtime, root_text, +}; -/// Identifies whether temporary compiler setup or chunk compilation failed. -enum CompilerError { - Vm(mlua::Error), - Chunk(mlua::Error), -} - -/// Compiles a named chunk in the fixed temporary VM and keeps debug info. -fn compile_chunk(source: &str, chunk_name: &str) -> std::result::Result, CompilerError> { - let lua = Lua::new_with( +/// The fixed temporary VM a chunk compiles in. +fn compiler() -> mlua::Result { + Lua::new_with( StdLib::STRING | StdLib::TABLE | StdLib::MATH | StdLib::UTF8, LuaOptions::default(), ) - .map_err(CompilerError::Vm)?; - let function = lua - .load(source) - .set_name(chunk_name) - .into_function() - .map_err(CompilerError::Chunk)?; +} + +/// Compiles a named chunk in `lua` and keeps debug info. +fn compile_chunk(lua: &Lua, source: &str, chunk_name: &str) -> mlua::Result> { + let function = lua.load(source).set_name(chunk_name).into_function()?; // Keep debug info so runtime errors report the chunk name and line // (`dump(true)` strips them and leaves `?:` in the traceback). Ok(function.dump(false)) @@ -75,7 +71,8 @@ pub struct LuaProgram { impl LuaProgram { /// Compiles `source` as Lua 5.5 bytecode without executing it. /// - /// `location` identifies the source region in diagnostics. The chunk + /// `location` identifies the source region in diagnostics, and `file` + /// is the prompt file the region is in. The chunk /// itself compiles under the name `line {source_line}`, which Lua /// renders as `[string "line 120"]`: no two blocks of one prompt start /// on the same line, and the name never reaches the length at which Lua @@ -84,11 +81,15 @@ impl LuaProgram { /// coordinates. /// /// # Errors - /// Returns [`Error::LuaCompile`] when `source` is not syntactically valid, - /// retaining the source, location, and Lua diagnostic. Returns - /// [`Error::LuaRuntime`] if the temporary compiler VM cannot be created. + /// Returns a `LuaCompile` failure when `source` is not syntactically + /// valid: its message names the region and holds the Lua diagnostic, + /// its `Section` fact names the region, and it is located on the line + /// of `file` the diagnostic names, which its author prose names too. + /// Returns an `Internal` failure, with the `mlua` error's text, if the + /// temporary compiler VM cannot be created. pub fn compile( source: &str, + file: &str, location: &str, source_line: NonZeroU32, emitter: &Emitter, @@ -97,21 +98,31 @@ impl LuaProgram { emitter.report(section, lifecycle::LUA_COMPILATION_STARTED); let chunk_name = format!("line {source_line}"); - let bytecode = match compile_chunk(source, &chunk_name) { + let compiled = compiler() + .map_err(|error| { + let code = ErrorCode::Internal; + Error::new(code, error.to_string(), code.prose()) + }) + .and_then(|lua| { + compile_chunk(&lua, source, &chunk_name).map_err(|error| { + let diagnostic = match &error { + mlua::Error::SyntaxError { message, .. } => message.clone(), + other => other.to_string(), + }; + let line = compile_line(&chunk_name, source_line, &diagnostic); + authored_at( + lua_compile(location, source_line.get(), &error.to_string()), + file, + Some(line), + failure_text(&diagnostic), + ) + }) + }); + let bytecode = match compiled { Ok(bytecode) => bytecode, - Err(CompilerError::Vm(error)) => { - emitter.report(section, lifecycle::LUA_COMPILATION_FAILED); - return Err(Error::lua(error)); - } - Err(CompilerError::Chunk(error)) => { + Err(error) => { emitter.report(section, lifecycle::LUA_COMPILATION_FAILED); - return Err(Error::LuaCompile { - location: location.to_owned(), - source_line: source_line.get(), - lua_source: source.to_owned(), - message: error.to_string(), - cause: Box::new(error), - }); + return Err(error); } }; @@ -133,12 +144,12 @@ impl LuaProgram { /// file path in errors, which the line mapper never rewrites. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the temporary compiler VM cannot be + /// Returns a `LuaRuntime` failure if the temporary compiler VM cannot be /// created or the source fails to compile. pub fn compile_internal(source: &str, chunk_name: &str) -> Result { - let bytecode = compile_chunk(source, chunk_name).map_err(|error| match error { - CompilerError::Vm(error) | CompilerError::Chunk(error) => Error::lua(error), - })?; + let bytecode = compiler() + .and_then(|lua| compile_chunk(&lua, source, chunk_name)) + .map_err(error::lua)?; Ok(Self { source: source.to_owned(), bytecode, @@ -166,40 +177,40 @@ impl LuaProgram { /// exposed as a persistence format. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the VM rejects the internally + /// Returns a `LuaRuntime` failure if the VM rejects the internally /// compiled bytecode. pub fn load(&self, lua: &Lua) -> Result { lua.load(self.bytecode.as_slice()) .into_function() - .map_err(Error::lua) + .map_err(error::lua) } /// Maps a Lua runtime failure to its ordered core outcome, for a chunk /// that ran beside `loaded`, the programs of the libraries loaded into - /// its VM. + /// its VM, from the prompt file `file`. /// - /// An Engine quota refusal, recognized by its typed value, returns - /// [`Error::LuaQuota`], and a typed context exhaustion, such as an - /// uncaught direct `compactors.fail` call's, returns - /// [`Error::ContextExhausted`] with its reason. All remaining failures, - /// other typed external errors included, return - /// [`Error::LuaRuntime`] with each chunk-relative line + /// A typed context exhaustion, such as an uncaught direct + /// `compactors.fail` call's, returns as it was raised. An Engine quota + /// refusal, recognized by its typed value, returns its `LuaQuota` + /// failure, and every other failure, other typed failures included, + /// returns a `LuaRuntime` failure with each chunk-relative line /// rewritten to an absolute prompt-source line by the program that owns /// that chunk, this one first when two share a chunk name. Nested errors /// from other chunks (for example a fanout arm) are left unchanged. + /// Either is located on the first line the mapping rewrote, of `file`, + /// and its author prose names that line and the failure's own text. /// Cancellation is the VM's to classify: a chunk the instruction hook - /// aborted under the run's cancel flag is [`Error::Interrupted`] before - /// the raw error reaches here. - pub(crate) fn map_runtime_error_among(&self, error: &mlua::Error, loaded: &[Self]) -> Error { - if let Some(QuotaExceeded(resource)) = error.downcast_ref::() { - return Error::LuaQuota { - resource: *resource, - }; - } - if let Some(Error::ContextExhausted { reason }) = error.downcast_ref::() { - return Error::ContextExhausted { - reason: reason.clone(), - }; + /// aborted under the run's cancel flag is an `Interrupted` failure + /// before the raw error reaches here. + pub(crate) fn map_runtime_error_among( + &self, + error: &mlua::Error, + loaded: &[Self], + file: &str, + ) -> Error { + let typed = error.downcast_ref::(); + if let Some(exhausted) = typed.filter(|typed| typed.code == ErrorCode::ContextExhausted) { + return exhausted.clone(); } let raw = error.to_string(); let owners: Vec<(&str, &str, NonZeroU32)> = std::iter::once(self) @@ -212,13 +223,12 @@ impl LuaProgram { ) }) .collect(); - let mapped = map_chunk_line_to_absolute(&raw, &owners); - // Retain the originating `mlua` error as the private source with the - // mapped prompt-location message, instead of flattening it to a string. - Error::LuaRuntime { - message: mapped, - cause: Box::new(error.clone()), - } + let (mapped, line) = map_chunk_line_to_absolute(&raw, &owners); + let (failure, what) = match typed.filter(|typed| typed.code == ErrorCode::LuaQuota) { + Some(quota) => (quota.clone(), quota.message.clone()), + None => (lua_runtime(mapped), root_text(error)), + }; + authored_at(failure, file, line, failure_text(&what)) } /// Display location recorded at compile time (parser location string), @@ -229,9 +239,29 @@ impl LuaProgram { } } +/// The prompt-source line a compiler `diagnostic` names, for the chunk +/// named `chunk_name` that starts on `source_line`: the chunk-relative +/// line after the chunk's marker, mapped to its absolute line, or +/// `source_line` when the diagnostic names none or the mapping overflows. +fn compile_line(chunk_name: &str, source_line: NonZeroU32, diagnostic: &str) -> NonZeroU32 { + let marker = format!("[string \"{chunk_name}\"]:"); + diagnostic + .find(&marker) + .map(|at| &diagnostic[at + marker.len()..]) + .and_then(|rest| { + let digits = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + absolute_line(source_line, &rest[..digits]) + }) + .and_then(NonZeroU32::new) + .unwrap_or(source_line) +} + /// Rewrites chunk-relative line numbers for the named chunks in `owners`, /// each a chunk name, the display location it maps to, and the -/// prompt-source line its chunk starts at, to absolute prompt-source lines. +/// prompt-source line its chunk starts at, to absolute prompt-source lines, +/// and returns the rewritten message beside the first line it rewrote. /// /// Only `[string "{chunk name}"]:N:` occurrences of an owner's chunk name /// are rewritten, each through the first owner whose chunk name it names, to @@ -240,12 +270,13 @@ impl LuaProgram { /// child's mapped error leaves the child's absolute lines alone, even for a /// library both VMs loaded. The message gains a single leading /// `{location}:{line}:` tag for the first occurrence rewritten; a message -/// with none passes through unchanged. A chunk line whose absolute mapping -/// would overflow `u32` keeps its original marker and digits. +/// with none passes through unchanged, with no line. A chunk line whose +/// absolute mapping would overflow `u32` keeps its original marker and +/// digits. pub(crate) fn map_chunk_line_to_absolute( message: &str, owners: &[(&str, &str, NonZeroU32)], -) -> String { +) -> (String, Option) { const OPENING: &str = "[string \""; let markers: Vec<(String, &str, NonZeroU32)> = owners .iter() @@ -288,9 +319,12 @@ pub(crate) fn map_chunk_line_to_absolute( if let Some((location, absolute)) = tag { // Leading `location:line:` tag naming the prompt file and its // absolute line: `briefer.md:51: ...` - format!("{location}:{absolute}: {result}") + ( + format!("{location}:{absolute}: {result}"), + NonZeroU32::new(absolute), + ) } else { - result + (result, None) } } @@ -307,3 +341,7 @@ fn absolute_line(source_line: NonZeroU32, chunk_line: &str) -> Option { None => None, } } + +#[cfg(test)] +#[path = "program-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/lua/src/projection-tests.rs b/crates/runvil-internal/lua/src/projection-tests.rs index 5c3cd9e66..539bca28d 100644 --- a/crates/runvil-internal/lua/src/projection-tests.rs +++ b/crates/runvil-internal/lua/src/projection-tests.rs @@ -3,8 +3,9 @@ use mlua::{Lua, Value}; use serde_json::json; +use runvil_types::error::ErrorCode; + use super::project_messages; -use crate::Error; use crate::protocol::{ ContentPart, MessageContent, MessageRecord, NonEmpty, RecordRole, Request, ToolCallRecord, YieldParse, @@ -35,7 +36,7 @@ fn wire(records: &[MessageRecord]) -> serde_json::Value { fn projection_error(records: &[MessageRecord]) -> String { match project_messages(records) { - Err(Error::Lua(message)) => message, + Err(error) if error.code == ErrorCode::LuaRuntime => error.message, other => panic!("expected a projection Lua error, got {other:?}"), } } diff --git a/crates/runvil-internal/lua/src/projection.rs b/crates/runvil-internal/lua/src/projection.rs index 2e936bcad..685307d26 100644 --- a/crates/runvil-internal/lua/src/projection.rs +++ b/crates/runvil-internal/lua/src/projection.rs @@ -36,14 +36,14 @@ use runvil_model_client::client::Message; use runvil_model_client::detail::message_from_validated_parts; use serde_json::Value; -use crate::error::{Error, Result}; +use crate::error::{Result, lua_runtime}; use crate::protocol::{ContentPart, MessageContent, MessageRecord, NonEmpty, RecordRole}; /// Validates and projects one author-built message list into the wire /// messages for one model dispatch. /// /// # Errors -/// Returns [`Error::Lua`] naming the offending 1-based index when the list +/// Returns a `LuaRuntime` failure naming the offending 1-based index when the list /// violates a cross-record rule: a malformed record (tool calls on a /// non-assistant, a system message outside the leading block, content /// parts in a system block that must be composed), a duplicate tool-call @@ -60,7 +60,7 @@ fn validate(messages: &[MessageRecord]) -> Result<()> { if system_len > 1 { for (position, record) in messages[..system_len].iter().enumerate() { if matches!(record.content, MessageContent::Parts(_)) { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{}] is a system message with content parts; only plain \ text system messages can be composed", position + 1 @@ -77,13 +77,13 @@ fn validate(messages: &[MessageRecord]) -> Result<()> { for (position, record) in messages.iter().enumerate() { let index = position + 1; if record.role != RecordRole::Assistant && !record.tool_calls.is_empty() { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{index}] sets tool_calls but is not an assistant message" ))); } match &record.role { RecordRole::System if position >= system_len => { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{index}] is a system message outside the leading system block" ))); } @@ -91,7 +91,7 @@ fn validate(messages: &[MessageRecord]) -> Result<()> { reject_unanswered(pending.as_ref())?; for call in &record.tool_calls { if !seen.insert(call.id.as_str()) { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{index}] tool call id {:?} duplicates an earlier \ tool call", call.id @@ -117,7 +117,7 @@ fn validate(messages: &[MessageRecord]) -> Result<()> { } } Some(_) | None => { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{index}] is an orphan tool record: no pending \ assistant tool call with id {id:?}" ))); @@ -139,7 +139,7 @@ fn reject_unanswered(pending: Option<&(usize, Vec<&str>)>) -> Result<()> { if let Some((index, unanswered)) = pending && let Some(id) = unanswered.first() { - return Err(Error::Lua(format!( + return Err(lua_runtime(format!( "messages[{index}] tool call {id:?} has no tool result" ))); } diff --git a/crates/runvil-internal/lua/src/prose.rs b/crates/runvil-internal/lua/src/prose.rs index cdbf0c115..ea0062b68 100644 --- a/crates/runvil-internal/lua/src/prose.rs +++ b/crates/runvil-internal/lua/src/prose.rs @@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex}; use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use crate::error::{Error, Result}; +use crate::error::{self, Result, lua_runtime}; use crate::var::var_to_json; use crate::vm::SYS_LIVE_POISONED; @@ -68,7 +68,7 @@ impl fmt::Debug for ProseState<'_> { /// install and first read is visible to the render. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the read cannot be built or the guard cannot +/// Returns a `LuaRuntime` failure if the read cannot be built or the guard cannot /// record it. pub(crate) fn install(lua: &Lua, sys_live: &Arc>, render: F) -> Result<()> where @@ -84,7 +84,7 @@ where /// hands back text the caller already read without rendering again. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the read cannot be built or the guard cannot +/// Returns a `LuaRuntime` failure if the read cannot be built or the guard cannot /// record it. pub(crate) fn swap(lua: &Lua, sys_live: &Arc>, render: F) -> Result where @@ -97,7 +97,7 @@ where /// Puts back the read [`swap`] replaced. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the guard cannot record it. +/// Returns a `LuaRuntime` failure if the guard cannot record it. pub(crate) fn restore(lua: &Lua, saved: Value) -> Result<()> { crate::globals::replace_prose(lua, saved).map(drop) } @@ -106,7 +106,7 @@ pub(crate) fn restore(lua: &Lua, saved: Value) -> Result<()> { /// the Engine callback and memoizes, and every later call returns the memo. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the closure cannot be created. +/// Returns a `LuaRuntime` failure if the closure cannot be created. fn lazy_read(lua: &Lua, sys_live: &Arc>, render: F) -> Result where F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, @@ -125,24 +125,24 @@ where let var = var_to_json(lua).map_err(mlua::Error::external)?; let sys = sys_live .lock() - .map_err(|_| mlua::Error::external(Error::internal(SYS_LIVE_POISONED)))? + .map_err(|_| mlua::Error::external(error::internal(SYS_LIVE_POISONED)))? .clone(); let globals_lookup = |name: &str| -> Result> { if name == "prose" { - return Err(Error::Lua( + return Err(lua_runtime( "recursive {{ prose }}: prose cannot reference itself".to_owned(), )); } - let value: Value = lua.globals().get(name).map_err(Error::lua)?; + let value: Value = lua.globals().get(name).map_err(error::lua)?; match value { Value::Nil => Ok(None), Value::Function(_) | Value::UserData(_) | Value::Thread(_) => { - Err(Error::Lua(format!( + Err(lua_runtime(format!( "global `{name}` is a {}; bare globals in prose must be JSON data", value.type_name() ))) } - other => Ok(Some(lua.from_value(other).map_err(Error::lua)?)), + other => Ok(Some(lua.from_value(other).map_err(error::lua)?)), } }; let rendered = render(ProseState { @@ -155,11 +155,11 @@ where drop(guard); lua.create_string(&rendered) }) - .map_err(Error::lua) + .map_err(error::lua) } /// Returns the failure of a poisoned memo slot. #[track_caller] fn memo_poisoned() -> mlua::Error { - mlua::Error::external(Error::internal("prose memo slot was poisoned")) + mlua::Error::external(error::internal("prose memo slot was poisoned")) } diff --git a/crates/runvil-internal/lua/src/protocol/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 9c3e9db19..993dc036d 100644 --- a/crates/runvil-internal/lua/src/protocol/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/answer.rs @@ -1,12 +1,13 @@ //! The answer vocabulary: one dispatched request's successful outcome and //! the payload types its variants hold. +use runvil_types::error::ErrorCode; use runvil_types::ids::TaskId; use runvil_types::metrics::ToolCallEvent; use crate::bindings::ToolOutputKind; use crate::compactors::OverflowReason; -use crate::error::{Error, Result}; +use crate::error::{Error, Result, tool}; /// The result of one [`VfsOp`](super::VfsOp) run against the run's store /// view. @@ -69,17 +70,17 @@ impl ToolCallOutcome { /// structured output effectively restricted to trusted tools. /// /// # Errors - /// Returns [`Error::Tool`] when a structured binding's output is not - /// valid JSON, retaining the parse failure as the cause. + /// Returns a `ToolOther` tool failure when a structured binding's + /// output is not valid JSON, whose prose names the parse failure. pub fn from_dispatch(kind: ToolOutputKind, alias: &str, text: String) -> Result { match kind { ToolOutputKind::Plain => Ok(ToolCallOutcome::Plain(text)), ToolOutputKind::Structured => match serde_json::from_str(&text) { Ok(json) => Ok(ToolCallOutcome::Structured(json)), - Err(error) => Err(Error::Tool { - message: format!("structured tool {alias:?} returned invalid JSON: {error}"), - cause: Box::new(error), - }), + Err(error) => { + let text = format!("structured tool {alias:?} returned invalid JSON: {error}"); + Err(tool(Error::new(ErrorCode::ToolOther, text.clone(), text))) + } }, } } @@ -142,11 +143,11 @@ impl mlua::UserData for ChatResult {} /// once (the `fanout` shim's fatal-arm path) hands the driver the member's /// own typed error rather than its rendering. #[derive(Debug)] -pub struct TaskDelivery { +pub struct TaskDelivery { /// The member that ended. pub task: TaskId, /// The member's final text or failure. - pub outcome: std::result::Result, + pub outcome: std::result::Result, } /// A task's lifecycle phase, as `tasks.status` reports it in `state`. @@ -252,13 +253,12 @@ pub struct TaskStatus { /// `Result` owns the typed error until the envelope is rendered, so a /// `call` or `join_any` failure round-trips with its structure intact. /// -/// The error type is the driver's, and only a [`TaskDelivery`] holds one: -/// a `join_any` member's failure is part of a successful wait. The -/// executor's scheduler drives `Answer` over its own error type so a -/// member's dispatch failure (a gateway completion error, a binding -/// failure) round-trips typed. +/// The error is the universal [`Error`], and +/// only a [`TaskDelivery`] holds one: a `join_any` member's failure is part +/// of a successful wait, so a member's dispatch failure (a gateway +/// completion error, a binding failure) round-trips typed. #[derive(Debug)] -pub enum Answer { +pub enum Answer { /// The completion text for an `infer` request. Infer(String), /// The contained chain's final text for a `call` request. @@ -270,7 +270,7 @@ pub enum Answer { /// path text; the wait shim keeps it to wait on and cancel. Timer(TaskId), /// The member delivered for a `join_any` request. - JoinAny(TaskDelivery), + JoinAny(TaskDelivery), /// Whether the task has ended, for a `ready` request. Ready(bool), /// The task's status table for a `status` request. Boxed so the diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index e07332646..e6b6a3e0a 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -22,7 +22,7 @@ use tasks::{ }; use tools::{parse_adhoc_tool_done, parse_tool_call, parse_tool_calls}; -use crate::error::Error; +use crate::error::{self, Error, lua_runtime}; use crate::models::LuaModelHandle; use crate::section::resolve_section_target; @@ -38,7 +38,7 @@ const DIRECT_YIELD: &str = "scripts may not yield directly"; /// The fixed direct-yield failure. fn direct_yield_error() -> Error { - Error::Lua(DIRECT_YIELD.to_owned()) + lua_runtime(DIRECT_YIELD) } /// How reading one request field failed. @@ -54,7 +54,7 @@ enum FieldFailure { /// Frames one author-argument failure as the call's error. fn call_error(message: String) -> FieldFailure { - FieldFailure::Argument(Error::Lua(message)) + FieldFailure::Argument(lua_runtime(message)) } /// Reads one author-supplied string value as UTF-8 text; invalid UTF-8 is @@ -149,7 +149,7 @@ fn shim_var( fn call_target(table: &mlua::Table) -> std::result::Result { match table.raw_get::("target") { Ok(value) => { - resolve_section_target(value).map_err(|error| FieldFailure::Argument(Error::lua(error))) + resolve_section_target(value).map_err(|error| FieldFailure::Argument(error::lua(error))) } Err(_) => Err(FieldFailure::Malformed), } diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index f901d431e..f2dd7599e 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -66,8 +66,8 @@ fn read_tool_call( }; let alias = match table.raw_get::("alias") { // Flatten to the call-error string so the answer frames exactly as - // the other author-argument failures (`Error::Lua`, not a runtime - // wrapper). + // the other author-argument failures (the bare message, not a + // runtime wrapper). Ok(value) => decode(&value).map_err(|error| call_error(error.to_string()))?, Err(_) => return Err(FieldFailure::Malformed), }; diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 2230e89ab..290822a72 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -2,8 +2,10 @@ //! resume envelope. use mlua::{Lua, LuaSerdeExt, MultiValue, Table, Value}; +use runvil_types::error::Error; -use crate::error_value::{ErrorValue, error_table}; +use crate::error::authored; +use crate::error_value::error_table; use crate::tools::adhoc_handler; use super::answer::{Answer, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome, VfsOutcome}; @@ -12,9 +14,9 @@ use super::answer::{Answer, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome /// error table, beside that table: the block's raise of this very table, /// and no other value, is the retained error's. #[derive(Debug)] -pub(crate) struct Retained { +pub(crate) struct Retained { /// The typed error the answer owned. - pub(crate) error: E, + pub(crate) error: Error, /// The error table the envelope holds. pub(crate) table: Table, } @@ -76,14 +78,15 @@ fn store_value(lua: &Lua, outcome: VfsOutcome) -> mlua::Result { /// the typed error to retain: a shim that re-raises the member's failure at /// once (`tasks.fanout` on a fatal arm) surfaces the member's own typed /// error. -fn delivery_values( +fn delivery_values( lua: &Lua, - delivery: TaskDelivery, -) -> mlua::Result<(Vec, Option>)> { + delivery: TaskDelivery, +) -> mlua::Result<(Vec, Option)> { let id = Value::String(lua.create_string(delivery.task.to_string())?); let (ok, result, retained) = match delivery.outcome { Ok(text) => (true, Value::String(lua.create_string(&text)?), None), Err(error) => { + let error = authored(lua, error); let table = error_table(lua, &error)?; ( false, @@ -100,10 +103,12 @@ fn delivery_values( /// /// On success the envelope is `(true, value...)`. On failure it is /// `(false, table)`, where `table` is the error's structured value -/// (`kind`, `message` as the error's display string, and the kind's -/// fields, with `tostring` returning the message) - the shim raises it -/// with `error(result, 0)`, so a printing author sees exactly the Engine's -/// message and a branching one reads `kind` - and the typed error is +/// (`kind`, `code`, `message`, and the kind's fields, as +/// [`error_table`] renders them, with `tostring` returning the message) - +/// the shim raises it +/// with `error(result, 0)`, so a printing author sees the error's prose, +/// author prose for a failure the prompt's Lua caused, and a branching +/// one reads `kind` - and the typed error is /// retained beside that table, which identifies its raise. A successful /// `join_any` whose member failed retains the member's error the same /// way, since a shim may re-raise it at once. @@ -111,13 +116,14 @@ fn delivery_values( /// # Errors /// Returns an `mlua` error if a Lua string, userdata, or table cannot be /// created on `lua`. -pub(crate) fn render_envelope( +pub(crate) fn render_envelope( lua: &Lua, - answer: std::result::Result, E>, -) -> mlua::Result<(MultiValue, Option>)> { + answer: std::result::Result, +) -> mlua::Result<(MultiValue, Option)> { let answer = match answer { Ok(answer) => answer, Err(error) => { + let error = authored(lua, error); let table = error_table(lua, &error)?; return Ok(( MultiValue::from_vec(vec![Value::Boolean(false), Value::Table(table.clone())]), diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 5e2d3cfcc..459c81ed4 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -17,7 +17,7 @@ use runvil_types::ids::TaskId; use super::non_empty::NonEmpty; use super::record::ToolCallRecord; -use crate::Error; +use crate::error::Error; use crate::messages::MessageList; /// Which author-facing call started a task chain. The Engine reports a diff --git a/crates/runvil-internal/lua/src/protocol/tests.rs b/crates/runvil-internal/lua/src/protocol/tests.rs index 099207fba..548d841e3 100644 --- a/crates/runvil-internal/lua/src/protocol/tests.rs +++ b/crates/runvil-internal/lua/src/protocol/tests.rs @@ -13,9 +13,10 @@ use mlua::{AnyUserData, Function, IntoLuaMulti, Lua, LuaSerdeExt, MultiValue, Va use serde_json::json; use runvil_model_client::model::{ModelBinding, ModelId, ModelInvocation}; +use runvil_types::error::ErrorCode; use runvil_types::ids::TaskId; -use crate::Error; +use crate::error::Error; use crate::models::LuaModelHandle; use crate::test_support::answer_envelope; @@ -44,7 +45,11 @@ fn handle_userdata(lua: &Lua) -> AnyUserData { fn assert_direct_yield(parse: YieldParse) { match parse { - YieldParse::Malformed(Error::Lua(message)) => { + YieldParse::Malformed(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "scripts may not yield directly"); } other => panic!("expected the direct-yield Lua error, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index e7aab58af..8cf072584 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -8,9 +8,8 @@ use super::*; #[test] fn an_ok_infer_answer_round_trips_through_lua() { let lua = Lua::new(); - let (envelope, retained) = - answer_envelope(&lua, Ok(Answer::::Infer("completion".to_owned()))) - .expect("the envelope renders"); + let (envelope, retained) = answer_envelope(&lua, Ok(Answer::Infer("completion".to_owned()))) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -23,9 +22,8 @@ fn an_ok_infer_answer_round_trips_through_lua() { #[test] fn an_ok_call_answer_round_trips_through_lua() { let lua = Lua::new(); - let (envelope, retained) = - answer_envelope(&lua, Ok(Answer::::Call("chain text".to_owned()))) - .expect("the envelope renders"); + let (envelope, retained) = answer_envelope(&lua, Ok(Answer::Call("chain text".to_owned()))) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -40,7 +38,7 @@ fn an_ok_spawn_answer_resumes_the_task_id_as_its_path_text() { let lua = Lua::new(); let task: TaskId = "0.2".parse().expect("a task id parses"); let (envelope, retained) = - answer_envelope(&lua, Ok(Answer::::Spawn(task))).expect("the envelope renders"); + answer_envelope(&lua, Ok(Answer::Spawn(task))).expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -50,27 +48,58 @@ fn an_ok_spawn_answer_resumes_the_task_id_as_its_path_text() { assert_eq!(text.to_str().expect("the text is UTF-8"), "0.2"); } +/// The quota failure every failed answer here holds, and the same failure +/// as it crossed into a VM given no file, with its author prose. +fn quota_and_answered() -> (Error, Error) { + let quota = crate::lua_quota(crate::QuotaResource::LogEvent); + let mut answered = quota.clone(); + answered.prose = format!( + "prompt: lua log event quota exceeded. {}", + ErrorCode::LuaQuota.prose() + ); + (quota, answered) +} + #[test] fn a_failed_answer_round_trips_and_retains_the_typed_error() { let lua = Lua::new(); - let (envelope, retained) = answer_envelope::( - &lua, - Err(Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - }), - ) - .expect("the envelope renders"); - match retained { - Some(Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - }) => {} - other => panic!("expected the retained LuaQuota error, got {other:?}"), - } + let (quota, answered) = quota_and_answered(); + let (envelope, retained) = answer_envelope(&lua, Err(quota)).expect("the envelope renders"); + assert_eq!( + retained.as_ref(), + Some(&answered), + "the typed error is retained" + ); let (ok, result) = echo_through_lua(&lua, envelope); assert!(!ok); let (kind, message) = failure_parts(&lua, result); assert_eq!(kind, "lua"); - assert_eq!(message, "lua log event quota exceeded"); + assert_eq!(message, answered.prose); +} + +#[test] +fn a_failed_tool_answer_renders_the_tools_remedy_prose_with_no_file_or_line() { + let lua = Lua::new(); + crate::error::set_prompt_file(&lua, "agents/a.md"); + let failure = crate::error::tool(runvil_types::tools::tool_error( + ErrorCode::ToolBackend, + "the backend refused; try another source", + )); + let (envelope, retained) = + answer_envelope(&lua, Err(failure.clone())).expect("the envelope renders"); + assert_eq!( + retained.as_ref(), + Some(&failure), + "a tool failure crosses into the VM unchanged" + ); + let (ok, result) = echo_through_lua(&lua, envelope); + assert!(!ok); + let (kind, message) = failure_parts(&lua, result); + assert_eq!( + (kind.as_str(), message.as_str()), + ("tool", "the backend refused; try another source"), + "the table's message is the tool's own prose, naming no file or line" + ); } #[test] @@ -81,22 +110,20 @@ fn a_join_any_delivery_of_a_failed_member_retains_the_members_typed_error() { // substitute the member's own error for the raised table. let lua = Lua::new(); let task: TaskId = "0.1".parse().expect("a task id parses"); + let (quota, answered) = quota_and_answered(); let (envelope, retained) = answer_envelope( &lua, - Ok(Answer::::JoinAny(TaskDelivery { + Ok(Answer::JoinAny(TaskDelivery { task, - outcome: Err(Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - }), + outcome: Err(quota), })), ) .expect("the envelope renders"); - match retained { - Some(Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - }) => {} - other => panic!("expected the member's retained LuaQuota error, got {other:?}"), - } + assert_eq!( + retained.as_ref(), + Some(&answered), + "the member's typed error is retained" + ); let (ok, id, member_ok, kind, message): (bool, String, bool, String, String) = lua .load( "local ok, id, member_ok, err = ...; \ @@ -108,7 +135,7 @@ fn a_join_any_delivery_of_a_failed_member_retains_the_members_typed_error() { assert_eq!(id, "0.1"); assert!(!member_ok, "the member failed"); assert_eq!(kind, "lua"); - assert_eq!(message, "lua log event quota exceeded"); + assert_eq!(message, answered.prose); } #[test] @@ -117,7 +144,7 @@ fn a_join_any_delivery_of_a_finished_member_retains_nothing() { let task: TaskId = "0.1".parse().expect("a task id parses"); let (envelope, retained) = answer_envelope( &lua, - Ok(Answer::::JoinAny(TaskDelivery { + Ok(Answer::JoinAny(TaskDelivery { task, outcome: Ok("done".to_owned()), })), @@ -155,9 +182,8 @@ fn a_status_answer_derives_ok_from_the_phase() { depth: 0, note: None, }; - let (envelope, retained) = - answer_envelope(&lua, Ok(Answer::::Status(Box::new(status)))) - .expect("the envelope renders"); + let (envelope, retained) = answer_envelope(&lua, Ok(Answer::Status(Box::new(status)))) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, phase, status_ok): (bool, String, String) = lua .load("local ok, status = ...; return ok, status.state, tostring(status.ok)") @@ -174,7 +200,7 @@ fn an_ok_plain_tool_call_answer_round_trips_as_a_string() { let lua = Lua::new(); let (envelope, retained) = answer_envelope( &lua, - Ok(Answer::::ToolCallResult(ToolCallOutcome::Plain( + Ok(Answer::ToolCallResult(ToolCallOutcome::Plain( "echoed: hi".to_owned(), ))), ) @@ -192,8 +218,8 @@ fn an_ok_plain_tool_call_answer_round_trips_as_a_string() { fn an_ok_structured_tool_call_answer_round_trips_as_a_table() { let lua = Lua::new(); let outcome = ToolCallOutcome::Structured(json!({ "text": "typed", "images": [] })); - let (envelope, retained) = answer_envelope(&lua, Ok(Answer::::ToolCallResult(outcome))) - .expect("the envelope renders"); + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::ToolCallResult(outcome))).expect("the envelope renders"); assert!(retained.is_none()); let (ok, text, images_len): (bool, String, i64) = lua .load("local ok, result = ...; return ok, result.text, #result.images") @@ -231,7 +257,7 @@ fn an_adhoc_tool_call_answer_resumes_with_its_handler_and_args() { let lua = lua_with_adhoc_grab(); let (envelope, retained) = answer_envelope( &lua, - Ok(Answer::::ToolCallResult(ToolCallOutcome::Adhoc { + Ok(Answer::ToolCallResult(ToolCallOutcome::Adhoc { alias: "grab".to_owned(), args: json!({ "value": "hi" }), })), @@ -259,7 +285,7 @@ fn an_adhoc_tool_call_answer_for_an_unregistered_alias_is_an_error() { let lua = lua_with_adhoc_grab(); let error = answer_envelope( &lua, - Ok(Answer::::ToolCallResult(ToolCallOutcome::Adhoc { + Ok(Answer::ToolCallResult(ToolCallOutcome::Adhoc { alias: "missing".to_owned(), args: json!({}), })), @@ -277,18 +303,20 @@ fn an_adhoc_tool_call_answer_for_an_unregistered_alias_is_an_error() { fn a_cancelled_answer_round_trips_as_the_cancelled_kind_and_retains_the_typed_error() { let lua = Lua::new(); let (envelope, retained) = - answer_envelope::(&lua, Err(Error::Interrupted)).expect("the envelope renders"); - match retained { - Some(Error::Interrupted) => {} - other => panic!("expected the retained Interrupted error, got {other:?}"), - } + answer_envelope(&lua, Err(crate::interrupted())).expect("the envelope renders"); + assert_eq!( + retained, + Some(crate::interrupted()), + "the typed error is retained" + ); let (ok, result) = echo_through_lua(&lua, envelope); assert!(!ok); let (kind, message) = failure_parts(&lua, result); assert_eq!(kind, "cancelled"); assert_eq!( message, - "interrupted: the run was cancelled or this call was stopped" + ErrorCode::Interrupted.prose(), + "an interruption keeps its default prose" ); } @@ -313,14 +341,18 @@ fn from_dispatch_classifies_by_the_declared_output_kind() { // Invalid JSON from a structured binding is the tool's error. match ToolCallOutcome::from_dispatch(ToolOutputKind::Structured, "form", "not json".to_owned()) { - Err(Error::Tool { message, cause }) => { - let detail = cause - .downcast_ref::() - .expect("the parse failure must survive as the cause"); + Err(error) => { + let detail = serde_json::from_str::("not json") + .expect_err("the output is not JSON"); + let text = format!("structured tool \"form\" returned invalid JSON: {detail}"); assert_eq!( - message, - format!("structured tool \"form\" returned invalid JSON: {detail}"), - "the message holds the cause's text, which no chain segment repeats" + (error.code, &error.message, &error.prose), + ( + ErrorCode::ToolOther, + &format!("tool call failure: {text}"), + &text + ), + "the prose names the parse failure, under a message that names the call" ); } other => panic!("expected the typed tool error, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs index 0fe4be56f..52a1e279a 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs @@ -7,18 +7,29 @@ use crate::tests::recording::null_emitter; use crate::{CoroStep, LuaProgram}; /// The typed error every case answers the block's `models.infer` with. -fn quota() -> Error { - Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - } +fn quota() -> runvil_types::error::Error { + crate::lua_quota(crate::QuotaResource::LogEvent) +} + +/// [`quota`] as it crossed into the block's VM, which names no file: with +/// the author prose of its message. +fn answered_quota() -> runvil_types::error::Error { + let mut quota = quota(); + quota.prose = format!( + "prompt: {}. {}", + quota.message, + runvil_types::error::ErrorCode::LuaQuota.prose() + ); + quota } /// Runs `source` on a shim VM to its `models.infer` yield, answers the /// yield with [`quota`], and returns how the block ended. -fn answered(source: &str) -> std::result::Result { +fn answered(source: &str) -> std::result::Result { let vm = TestVm::new().shims().build(); let program = LuaProgram::compile( source, + "prompt", "test program", NonZeroU32::MIN, &null_emitter(), @@ -30,7 +41,7 @@ fn answered(source: &str) -> std::result::Result { else { panic!("the infer shim must suspend the block"); }; - let ended = vm.resume_block_coro_answer::(&program, &thread, Err(quota())); + let ended = vm.resume_block_coro_answer(&program, &thread, Err(quota())); drop(thread); vm.teardown(); ended @@ -43,9 +54,7 @@ fn a_block_raising_the_answered_error_table_surfaces_the_typed_error() { "local ok, err = pcall(models.infer, 'hi')\nerror(err, 0)", ] { match answered(source) { - Err(Error::LuaQuota { - resource: crate::QuotaResource::LogEvent, - }) => {} + Err(error) if error == answered_quota() => {} other => panic!("block {source:?} must surface the typed quota error, got {other:?}"), } } @@ -56,9 +65,10 @@ fn a_block_raising_the_answered_errors_text_as_its_own_keeps_its_own_error() { // The raise carries the same text as the answered error, but it is the // block's own string, not the answer's table. match answered("local ok, err = pcall(models.infer, 'hi')\nerror(tostring(err), 0)") { - Err(Error::LuaRuntime { message, .. }) => assert!( - message.contains("lua log event quota exceeded"), - "the block's own raise keeps its message: {message}" + Err(error) if error.code == runvil_types::error::ErrorCode::LuaRuntime => assert!( + error.message.contains("lua log event quota exceeded"), + "the block's own raise keeps its message: {}", + error.message ), other => panic!("the block's own raise must stay its own error, got {other:?}"), } diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse.rs b/crates/runvil-internal/lua/src/protocol/tests/parse.rs index 093781a30..03afef829 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse.rs @@ -145,7 +145,11 @@ fn an_adhoc_tool_done_return_becomes_its_scalar_text() { fn an_adhoc_tool_done_table_return_is_a_bad_return() { let lua = Lua::new(); match adhoc_tool_done_returning(&lua, "{ 1, 2 }") { - AdhocToolOutcome::BadReturn(Error::Lua(message)) => { + AdhocToolOutcome::BadReturn(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "cannot return a table as a result"); } other => panic!("a table return must be rejected, got {other:?}"), @@ -218,7 +222,11 @@ fn an_infer_with_a_missing_or_non_string_prompt_is_the_calls_error() { ] { let table = lua_table(&lua, source); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, expected, "{source}"); } other => panic!("expected the prompt call error for {source}, got {other:?}"), @@ -238,7 +246,11 @@ fn an_infer_with_a_wrong_handle_type_is_the_calls_error() { "{ op = 'infer', prompt = 'hi', handle = 'not a handle' }", ); match Request::from_yield(&lua, &Value::Table(as_string)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!( message, "models.infer handle must be a model handle, got string" @@ -255,7 +267,11 @@ fn an_infer_with_a_wrong_handle_type_is_the_calls_error() { wrong, ); match Request::from_yield(&lua, &Value::Table(as_other_userdata)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "models.infer handle must be a model handle"); } other => panic!("expected the handle call error, got {other:?}"), @@ -267,7 +283,11 @@ fn a_call_with_a_non_string_target_keeps_the_resolve_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'call', target = 42, var = { k = 1 } }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::LuaRuntime { message, .. }) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert!( message.contains("section target must be a string, got integer"), "unexpected message: {message}" diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs index 3aecc72ab..d600f0a07 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs @@ -40,7 +40,11 @@ fn chat_request_with_handle(lua: &Lua, records: &str, handle: impl IntoLua) -> m fn expect_chat_call_error(parse: YieldParse, expected: &str) { match parse { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, expected); } other => panic!("expected the chat call error {expected:?}, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs index 49e23a75b..684b1c891 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs @@ -109,7 +109,11 @@ fn spawn_seed_shape_errors_are_the_calls_error() { var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "item must be JSON data, got function"); } other => panic!("expected the item call error, got {other:?}"), @@ -120,7 +124,11 @@ fn spawn_seed_shape_errors_are_the_calls_error() { "{ op = 'spawn', target = '## Child', index = -1, var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "index must be a non-negative integer, got -1"); } other => panic!("expected the index call error, got {other:?}"), @@ -142,7 +150,11 @@ fn an_unconvertible_spawn_item_is_the_calls_error_naming_the_conversion_failure( .from_value::(item) .expect_err("a table holding a function has no JSON form"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!( message, format!("item must be a JSON-representable value: {cause}") @@ -158,7 +170,11 @@ fn a_malformed_task_id_is_the_calls_error_naming_the_parse_failure() { let cause = "0.x".parse::().expect_err("`0.x` is not a task id"); let table = lua_table(&lua, "{ op = 'ready', task = '0.x' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!( message, format!("`0.x` is not a task id such as `0.1`: {cause}") @@ -181,7 +197,11 @@ fn a_spawn_with_a_non_string_target_keeps_the_resolve_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'spawn', target = 42, var = { k = 1 } }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::LuaRuntime { message, .. }) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert!( message.contains("section target must be a string, got integer"), "unexpected message: {message}" @@ -224,7 +244,11 @@ fn timer_seconds_out_of_domain_are_the_calls_error() { for (seconds, needle) in cases { let table = lua_table(&lua, &format!("{{ op = 'timer', seconds = {seconds} }}")); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert!( message.contains("timeout") && message.contains(needle), "the message names the option and the value: {message}" @@ -235,7 +259,11 @@ fn timer_seconds_out_of_domain_are_the_calls_error() { } let table = lua_table(&lua, "{ op = 'timer', seconds = 'soon' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "timeout must be a number, got string"); } other => panic!("expected the timeout type error, got {other:?}"), @@ -288,7 +316,11 @@ fn concurrency_converts_a_whole_number_float_and_refuses_an_out_of_range_one() { } let table = lua_table(&lua, "{ op = 'concurrency', limit = 1e20 }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => assert!( + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => assert!( message.contains("positive whole number") && message.contains("64-bit"), "the message names the requirement and the range: {message}" ), @@ -300,7 +332,7 @@ fn concurrency_converts_a_whole_number_float_and_refuses_an_out_of_range_one() { fn a_timer_answer_resumes_the_task_id_as_its_path_text() { let lua = Lua::new(); let task: TaskId = "0.3".parse().expect("`0.3` is a valid task id"); - let answer: Answer = Answer::Timer(task); + let answer = Answer::Timer(task); let (envelope, retained) = answer_envelope(&lua, Ok(answer)).expect("a timer answer renders its envelope"); assert!(retained.is_none()); diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs index 93623ab28..a5994a165 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs @@ -177,7 +177,11 @@ fn a_tool_call_with_a_non_alias_alias_is_the_calls_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'tool_call', alias = 42 }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!( message, "tools.call takes a tool id or a tool object, got integer" @@ -194,7 +198,11 @@ fn a_script_call_naming_a_string_without_a_slash_is_the_calls_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'tool_call', alias = 'grab' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "tools.call: \"grab\" is not a tool id"); } other => panic!("expected the id call error, got {other:?}"), @@ -209,7 +217,11 @@ fn a_tool_call_with_a_non_table_args_is_the_calls_error() { "{ op = 'tool_call', alias = 'tools/echo', args = 42 }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!(message, "args must be a table, got integer"); } other => panic!("expected the args call error, got {other:?}"), @@ -230,7 +242,11 @@ fn a_tool_call_with_an_unrepresentable_args_table_is_the_calls_error() { .from_value::(args) .expect_err("a table holding a function has no JSON form"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Refused(Error::Lua(message)) => { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => { assert_eq!( message, format!("args must be a JSON-representable table: {cause}") diff --git a/crates/runvil-internal/lua/src/require.rs b/crates/runvil-internal/lua/src/require.rs index 22af4183b..165444979 100644 --- a/crates/runvil-internal/lua/src/require.rs +++ b/crates/runvil-internal/lua/src/require.rs @@ -21,8 +21,7 @@ use mlua::{AnyUserData, Function, Lua, Value}; use runvil_types::emitter::{Emitter, LibraryLoad}; use serde_json::Value as Json; -use crate::error::SharedSource; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result}; use crate::error_value::install_error_value; use crate::program::LuaProgram; use crate::prose::ProseState; @@ -36,11 +35,8 @@ const REQUIRE_SOURCE: &str = include_str!("__impl_require.lua"); /// The loader program, compiled once and loaded per VM, under the shim /// programs' failure contract. -static REQUIRE_PROGRAM: LazyLock> = - LazyLock::new(|| { - LuaProgram::compile_internal(REQUIRE_SOURCE, REQUIRE_CHUNK_NAME) - .map_err(crate::detail::shared_source_new) - }); +static REQUIRE_PROGRAM: LazyLock> = + LazyLock::new(|| LuaProgram::compile_internal(REQUIRE_SOURCE, REQUIRE_CHUNK_NAME)); /// The loads under way on one VM, outermost first: each library's key, /// which a `require` made during its load passes to the resolver, and its @@ -63,7 +59,7 @@ pub(crate) struct VmShare<'a> { /// through a clone of `emitter` under `section`, and working on `vm`. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the loader chunk or its helpers cannot be +/// Returns a `LuaRuntime` failure if the loader chunk or its helpers cannot be /// built or installed. pub(crate) fn install( lua: &Lua, @@ -83,10 +79,10 @@ where let begin_load = begin_load_capture(lua, &loading, vm, emitter, section)?; let end_load = end_load_capture(lua, &loading, vm.depth, emitter, section)?; let (swap_prose, restore_prose) = prose_captures(lua, render, vm.sys_live)?; - let error_value = install_error_value(lua).map_err(Error::lua)?; + let error_value = install_error_value(lua).map_err(error::lua)?; let require: Function = REQUIRE_PROGRAM .as_ref() - .map_err(Error::shared)? + .map_err(Error::clone)? .load(lua)? .call(( resolve, @@ -96,10 +92,10 @@ where restore_prose, error_value, )) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.globals() .raw_set("require", require) - .map_err(Error::lua) + .map_err(error::lua) } /// Builds `resolve(heading)`: the resolver's library for `heading`, asked @@ -129,7 +125,7 @@ where let library_heading = library.heading.clone(); Ok((identity, library_heading, lua.create_any_userdata(library)?)) }) - .map_err(Error::lua) + .map_err(error::lua) } /// Builds `begin_load(library)`: reports the load's start, loads the @@ -184,7 +180,7 @@ fn begin_load_capture( depth.fetch_add(1, Ordering::Relaxed); Ok((blocks, templates)) }) - .map_err(Error::lua) + .map_err(error::lua) } /// Builds `swap_prose(template)`, which installs a library block's own @@ -206,12 +202,12 @@ where crate::prose::swap(lua, &sys_live, move |state| render(&template, state)) .map_err(mlua::Error::external) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let restore = lua .create_function(|lua, saved: Value| { crate::prose::restore(lua, saved).map_err(mlua::Error::external) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok((swap, restore)) } @@ -230,7 +226,7 @@ fn end_load_capture( let section = section.to_owned(); lua.create_function(move |_, finished: bool| { let Some((_, heading)) = lock(&loading, "library-load state")?.pop() else { - return Err(mlua::Error::external(Error::internal( + return Err(mlua::Error::external(error::internal( "require ended a library load that never began", ))); }; @@ -243,14 +239,14 @@ fn end_load_capture( emitter.library_load(§ion, &heading, phase); Ok(()) }) - .map_err(Error::lua) + .map_err(error::lua) } /// Locks one of `require`'s shared slots, `what`, mapping a poisoned lock /// to an internal error. fn lock<'a, T>(slot: &'a Mutex, what: &str) -> mlua::Result> { slot.lock().map_err(|_| { - mlua::Error::external(Error::internal(format!("require's {what} was poisoned"))) + mlua::Error::external(error::internal(format!("require's {what} was poisoned"))) }) } diff --git a/crates/runvil-internal/lua/src/scope.rs b/crates/runvil-internal/lua/src/scope.rs index d335e3387..91d3eb758 100644 --- a/crates/runvil-internal/lua/src/scope.rs +++ b/crates/runvil-internal/lua/src/scope.rs @@ -9,7 +9,7 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolId, ToolInfo}; use crate::bindings::ToolBinding; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; /// Shared per-VM tool-call counts, keyed by tool id and seeded at 0 for /// every id the installer was given. `increment` seeds a missing id at 0, @@ -37,13 +37,13 @@ impl ToolCallCounts { fn lock(&self) -> Result>> { self.inner .lock() - .map_err(|_| Error::internal("tool call counts mutex was poisoned")) + .map_err(|_| error::internal("tool call counts mutex was poisoned")) } /// Ensures `id` is present in the map, seeding it at 0 when new. /// /// # Errors - /// Returns [`Error::Internal`] if the mutex is poisoned. + /// Returns an `Internal` failure if the mutex is poisoned. pub fn ensure(&self, id: &str) -> Result<()> { let mut map = self.lock()?; map.entry(id.to_owned()).or_insert(0); @@ -53,7 +53,7 @@ impl ToolCallCounts { /// Increments the count for `id`, seeding it at 0 first when new. /// /// # Errors - /// Returns [`Error::Internal`] if the mutex is poisoned. + /// Returns an `Internal` failure if the mutex is poisoned. pub fn increment(&self, id: &str) -> Result<()> { let mut map = self.lock()?; *map.entry(id.to_owned()).or_insert(0) += 1; @@ -63,7 +63,7 @@ impl ToolCallCounts { /// Returns every seeded id with its count, read under one lock. /// /// # Errors - /// Returns [`Error::Internal`] if the mutex is poisoned. + /// Returns an `Internal` failure if the mutex is poisoned. pub(crate) fn snapshot(&self) -> Result> { Ok(self.lock()?.clone()) } diff --git a/crates/runvil-internal/lua/src/sys.rs b/crates/runvil-internal/lua/src/sys.rs index 224f2fdf2..6a67a3ba1 100644 --- a/crates/runvil-internal/lua/src/sys.rs +++ b/crates/runvil-internal/lua/src/sys.rs @@ -3,7 +3,7 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_proxy}; /// Builds a sealed Lua `sys` table from runtime metadata. @@ -14,7 +14,7 @@ pub(crate) fn seal_sys(lua: &Lua, sys: &Json) -> Result { let data = match sys { Json::Object(map) => map.clone(), other => { - return Err(Error::internal(format!("sys must be a table, got {other}"))); + return Err(error::internal(format!("sys must be a table, got {other}"))); } }; @@ -32,7 +32,7 @@ pub(crate) fn seal_sys(lua: &Lua, sys: &Json) -> Result { Some(value) => lua.to_value(value), } }) - .map_err(Error::lua)?; + .map_err(error::lua)?; read_only_proxy( lua, @@ -40,5 +40,5 @@ pub(crate) fn seal_sys(lua: &Lua, sys: &Json) -> Result { |key| format!("sys is read-only; cannot set '{}'", key_text(key)), "sys is sealed", ) - .map_err(Error::lua) + .map_err(error::lua) } diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index a3c4b0674..0f6e3bcd5 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -16,8 +16,7 @@ use runvil_vfs::Access; use crate::argv::Argv; use crate::bindings::{RosterEntry, ToolBinding, ToolSet}; use crate::coro::MODEL_TOOL_CALL_REGISTRY; -use crate::error::{Error, Result}; -use crate::error_value::ErrorValue; +use crate::error::{self, Error, Result, UNNAMED_FILE}; use crate::program::LuaProgram; use crate::protocol::{Answer, render_envelope}; use crate::scope::Offers; @@ -90,16 +89,16 @@ pub fn section_vm_peak() -> usize { /// simply absent. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the shim prelude was never installed +/// Returns a `LuaRuntime` failure if the shim prelude was never installed /// on this VM, the `tools` table is absent, or the install fails. pub fn install_model_tool_call_shim(lua: &Lua) -> Result<()> { let model_tool_call: Function = lua .named_registry_value(MODEL_TOOL_CALL_REGISTRY) - .map_err(Error::lua)?; - let tools: Table = lua.globals().raw_get("tools").map_err(Error::lua)?; + .map_err(error::lua)?; + let tools: Table = lua.globals().raw_get("tools").map_err(error::lua)?; tools .raw_set("call_as_model", model_tool_call) - .map_err(Error::lua) + .map_err(error::lua) } impl ToolBinding { @@ -132,21 +131,21 @@ impl ToolSet { /// # Errors /// Returns an `mlua` error if a Lua string, userdata, or table cannot be /// created on `lua`. -pub fn answer_envelope( +pub fn answer_envelope( lua: &Lua, - answer: std::result::Result, E>, -) -> mlua::Result<(MultiValue, Option)> { + answer: std::result::Result, +) -> mlua::Result<(MultiValue, Option)> { let (envelope, retained) = render_envelope(lua, answer)?; Ok((envelope, retained.map(|retained| retained.error))) } impl LuaProgram { /// Maps a Lua runtime failure of a chunk that ran with no library - /// programs loaded beside it: `map_runtime_error_among` with none - /// loaded. + /// programs loaded beside it, in a VM given no file: + /// `map_runtime_error_among` with none loaded, in `prompt`. #[must_use] pub fn map_runtime_error(&self, error: &mlua::Error) -> Error { - self.map_runtime_error_among(error, &[]) + self.map_runtime_error_among(error, &[], UNNAMED_FILE) } } @@ -185,7 +184,7 @@ impl SectionVm { /// /// # Errors /// Returns the chunk's own failure when execution fails or the program - /// returns a non-scalar value, or [`Error::Internal`] if the jump slot + /// returns a non-scalar value, or an `Internal` failure if the jump slot /// is poisoned. pub fn run_chunk( &self, diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index 76c3b1ffa..fec9d7f10 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -18,7 +18,7 @@ use runvil_types::untrusted::GuardNonce; use runvil_vfs::{Access, Origin, VfsRef}; use serde_json::{Value as Json, json}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::tests::recording::null_emitter; use crate::vm::scalar_return; use crate::{Argv, OfferedTool, RosterEntry, SectionText, SectionVm, ToolSet}; @@ -87,7 +87,7 @@ pub(crate) fn run_chunk( .inject_values(args, sys, access) .map_err(|failure| failure.teardown(emitter, section))?; vm.install_engine_globals(emitter, section)?; - let returned: MultiValue = vm.lua().load(source).eval().map_err(Error::lua)?; + let returned: MultiValue = vm.lua().load(source).eval().map_err(error::lua)?; let returned = scalar_return(returned)?; let var = vm.var()?; Ok(LuaOutcome { returned, var }) diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 4b9d90d20..197f34331 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -19,8 +19,10 @@ use serde_json::{Value as Json, json}; use self::recording::{Observation, Recorder, boundary, null_emitter}; use super::*; +use runvil_types::error::ErrorCode; + use crate::engine_globals::LUA_LOG_CHARACTER_LIMIT; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::hardening::install_cancel_hook; use crate::section::resolve_section_target; use crate::test_vm::{ @@ -77,12 +79,22 @@ fn assert_identity_only(recorder: &Recorder) { } } -/// Returns the message held by either Lua-category error representation. +/// The author prose of a `LuaRuntime` refusal whose own text is +/// `message`, raised in a VM given no file: what `tostring` gives for it +/// once a `pcall` caught it. +pub(crate) fn refused(message: &str) -> String { + format!("prompt: {message}. {}", ErrorCode::LuaRuntime.prose()) +} + +/// Returns the message of a `LuaRuntime` failure. +#[track_caller] fn lua_error_message(error: &Error) -> &str { - match error { - Error::Lua(message) | Error::LuaRuntime { message, .. } => message, - other => panic!("expected a Lua-category error, got {other:?}"), - } + assert_eq!( + error.code, + ErrorCode::LuaRuntime, + "expected a Lua runtime failure, got {error:?}" + ); + &error.message } /// A backend whose every operation fails. The error is `Backend` rather @@ -211,13 +223,16 @@ fn run_scalar( ) -> Result> { match vm.run_chunk(program, emitter, section)? { LuaBlockResult::Returned(value) => Ok(value), - LuaBlockResult::Jump(heading) => Err(Error::Lua(format!("unexpected jump to {heading}"))), + LuaBlockResult::Jump(heading) => Err(crate::error::lua_runtime(format!( + "unexpected jump to {heading}" + ))), } } fn program(source: &str) -> LuaProgram { LuaProgram::compile( source, + "prompt", "test program", NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), @@ -331,8 +346,13 @@ pub(crate) fn assert_chunk_name_resolves(constant: &str, chunk_name: &str, embed ); } -/// The refusal a `pcall` caught, from the caught error's `tostring`: its -/// first line without the `runtime error: ` prefix mlua renders. +/// The refusal a `pcall` caught, from the caught error's `tostring`: the +/// text its author prose names when the shim's `pcall` caught it in a VM +/// given no file, or its first line without the `runtime error: ` prefix +/// mlua renders when the base `pcall` did. pub(crate) fn refusal_line(caught: &str) -> Option<&str> { + if let Some(text) = caught.strip_prefix("prompt: ") { + return text.strip_suffix(format!(". {}", ErrorCode::LuaRuntime.prose()).as_str()); + } caught.strip_prefix("runtime error: ")?.lines().next() } diff --git a/crates/runvil-internal/lua/src/tests/budgets.rs b/crates/runvil-internal/lua/src/tests/budgets.rs index 3e79fdd35..4639d913a 100644 --- a/crates/runvil-internal/lua/src/tests/budgets.rs +++ b/crates/runvil-internal/lua/src/tests/budgets.rs @@ -29,13 +29,9 @@ fn the_first_chunk_consumes_the_configured_log_budget() { "Budget", ) .expect_err("the second log must exhaust the configured budget"); + let quota = crate::lua_quota(crate::QuotaResource::LogEvent); assert!( - matches!( - error, - Error::LuaQuota { - resource: crate::QuotaResource::LogEvent - } - ), + (error.code, &error.message, &error.facts) == (quota.code, "a.message, "a.facts), "log-budget exhaustion must surface as a typed LuaQuota: {error:?}" ); vm.teardown(); @@ -58,7 +54,7 @@ fn an_author_raise_of_a_quota_refusals_text_is_an_ordinary_runtime_error() { ) .expect_err("the author's raise must fail the block"); assert!( - matches!(error, Error::LuaRuntime { .. }), + error.code == ErrorCode::LuaRuntime, "an author raise of {refusal:?} must stay a runtime error: {error:?}" ); } diff --git a/crates/runvil-internal/lua/src/tests/cancellation.rs b/crates/runvil-internal/lua/src/tests/cancellation.rs index 4eef2910a..24d0b5324 100644 --- a/crates/runvil-internal/lua/src/tests/cancellation.rs +++ b/crates/runvil-internal/lua/src/tests/cancellation.rs @@ -11,10 +11,11 @@ fn long_running_lua_block_cancels_cooperatively() { // An unbounded loop that, without cooperative cancellation, would run // forever: no instruction ceiling ends it. With the cancel flag set, the // very first instruction-hook firing aborts it; the hook's error is the - // raw cancellation message, which the VM classifies as - // `Error::Interrupted` once the flag is observed set. + // raw cancellation message, which the VM classifies as an + // `Interrupted` failure once the flag is observed set. let program = LuaProgram::compile( "local n = 0\nwhile true do n = n + 1 end", + "prompt", "cancel loop", NonZeroU32::MIN, &null_emitter(), @@ -63,7 +64,7 @@ fn a_pre_cancelled_run_aborts_a_tight_loop_promptly() { vm.teardown(); assert!( - matches!(outcome, Err(Error::Interrupted)), + outcome.as_ref().err() == Some(&crate::interrupted()), "expected Interrupted, got {outcome:?}" ); } @@ -84,7 +85,7 @@ fn a_cancelled_run_unwinds_through_an_author_pcall_loop() { let outcome = start_cancelled_block("while true do pcall(function() while true do end end) end"); assert!( - matches!(outcome, Err(Error::Interrupted)), + outcome.as_ref().err() == Some(&crate::interrupted()), "an author pcall must not swallow cancellation, got {outcome:?}" ); } @@ -95,7 +96,7 @@ fn a_cancelled_run_unwinds_through_an_author_xpcall_loop() { "while true do xpcall(function() while true do end end, function(e) return e end) end", ); assert!( - matches!(outcome, Err(Error::Interrupted)), + outcome.as_ref().err() == Some(&crate::interrupted()), "an author xpcall with a message handler must not swallow cancellation, got {outcome:?}" ); } @@ -121,7 +122,7 @@ fn a_cancelled_run_skips_a_looping_xpcall_message_handler() { "xpcall(function() while true do end end, function() while true do end end)", ); assert!( - matches!(outcome, Err(Error::Interrupted)), + outcome.as_ref().err() == Some(&crate::interrupted()), "a message handler must not run under cancellation, got {outcome:?}" ); } diff --git a/crates/runvil-internal/lua/src/tests/diagnostics.rs b/crates/runvil-internal/lua/src/tests/diagnostics.rs index 1291221d7..1fb818707 100644 --- a/crates/runvil-internal/lua/src/tests/diagnostics.rs +++ b/crates/runvil-internal/lua/src/tests/diagnostics.rs @@ -11,6 +11,7 @@ fn lua_program_retains_source_and_round_trips_bytecode() { let source = "return greeting .. ' world'"; let program = LuaProgram::compile( source, + "prompt", "section Gather prologue", NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), @@ -35,6 +36,7 @@ fn runtime_assert_failure_reports_chunk_name_and_line() { let location = "section `Web Search` epilog"; let program = LuaProgram::compile( "local x = 1\nassert(false)\nreturn x", + "prompt", location, NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), @@ -109,7 +111,11 @@ fn map_chunk_line_to_absolute_rewrites_line_numbers() { ); assert_eq!( result, - "section `Web Search` epilog:51: section `Web Search` epilog:51: assertion failed!" + ( + "section `Web Search` epilog:51: section `Web Search` epilog:51: assertion failed!" + .to_owned(), + NonZeroU32::new(51) + ) ); } @@ -118,7 +124,7 @@ fn map_chunk_line_to_absolute_only_rewrites_matching_chunk() { let msg = r#"[string "line 50"]:51: assertion failed! stack traceback: [string "line 22"]:3: in main chunk"#; - let result = map_chunk_line_to_absolute( + let (result, line) = map_chunk_line_to_absolute( msg, &[( "line 22", @@ -126,6 +132,11 @@ stack traceback: NonZeroU32::new(22).expect("22 is non-zero"), )], ); + assert_eq!( + line, + NonZeroU32::new(24), + "the tag's line is the first rewritten" + ); assert!( result.contains("[string \"line 50\"]:51:"), "child absolute line must stay intact: {result}" @@ -148,14 +159,15 @@ fn map_chunk_line_to_absolute_leaves_an_already_mapped_marker_alone() { "section `lib` prologue", NonZeroU32::new(50).expect("50 is non-zero"), ); - let child = map_chunk_line_to_absolute(r#"[string "line 50"]:2: boom"#, &[lib]); + let (child, _) = map_chunk_line_to_absolute(r#"[string "line 50"]:2: boom"#, &[lib]); assert_eq!( child, "section `lib` prologue:51: section `lib` prologue:51: boom" ); let parent = map_chunk_line_to_absolute(&child, &[lib]); assert_eq!( - parent, child, + parent, + (child, None), "a second pass through the same owner must not rewrite line 51 again" ); } @@ -165,7 +177,9 @@ fn map_chunk_line_to_absolute_keeps_original_digits_on_overflow() { // source_line + chunk_line - 1 must not wrap; on overflow the original // chunk-relative digits are preserved rather than a wrong absolute line. let msg = r#"[string "x"]:5: boom"#; - let result = map_chunk_line_to_absolute(msg, &[("x", "section `x` prologue", NonZeroU32::MAX)]); + let (result, line) = + map_chunk_line_to_absolute(msg, &[("x", "section `x` prologue", NonZeroU32::MAX)]); + assert_eq!(line, None, "an overflowing mapping names no line"); assert!( result.contains(r#"[string "x"]:5:"#), "overflowing mapping must keep the original line 5: {result}" @@ -187,7 +201,7 @@ fn map_chunk_line_to_absolute_no_match_passthrough() { NonZeroU32::new(10).expect("10 is non-zero"), )], ); - assert_eq!(result, msg); + assert_eq!(result, (msg.to_owned(), None)); } #[test] @@ -210,10 +224,14 @@ stack traceback: ]; assert_eq!( map_chunk_line_to_absolute(msg, &owners), - "lib:51: lib:51: boom + ( + "lib:51: lib:51: boom stack traceback: lib:51: in function 'boom' caller:32: in main chunk" + .to_owned(), + NonZeroU32::new(51) + ) ); } @@ -223,6 +241,7 @@ fn runtime_error_maps_to_absolute_prompt_line() { let source_line = NonZeroU32::new(50).expect("50 is non-zero"); let program = LuaProgram::compile( "local x = 1\nassert(false)\nreturn x", + "prompt", location, source_line, &null_emitter(), @@ -256,6 +275,7 @@ fn a_location_longer_than_lua_keeps_in_a_chunk_name_still_maps() { assert!(location.len() >= 60, "the fixture must exceed Lua's cut"); let program = LuaProgram::compile( "assert(false)", + "prompt", location, NonZeroU32::new(40).expect("40 is non-zero"), &null_emitter(), @@ -276,11 +296,42 @@ fn a_location_longer_than_lua_keeps_in_a_chunk_name_still_maps() { } #[test] -fn malformed_lua_reports_location_and_retains_source_diagnostic() { +fn a_block_failure_names_the_vms_file_and_its_mapped_line() { + let vm = TestVm::new().shims().build(); + crate::error::set_prompt_file(vm.lua(), "agents/a.md"); + let error = match vm.start_block_coro(&program("local x = 1\nerror('boom')")) { + Err(error) => error, + other => panic!("the block must fail, got {other:?}"), + }; + assert_eq!(error.code, ErrorCode::LuaRuntime); + assert!( + error + .message + .starts_with("test program:2: runtime error: test program:2: boom"), + "the message keeps its mapped text: {}", + error.message + ); + assert_eq!( + error.at, + Some(runvil_types::error::Location::line( + "agents/a.md", + NonZeroU32::new(2).expect("2 is non-zero") + )) + ); + assert_eq!( + error.prose, + format!("agents/a.md:2: boom. {}", ErrorCode::LuaRuntime.prose()) + ); + vm.teardown(); +} + +#[test] +fn malformed_lua_reports_its_region_and_the_diagnostic() { let source = "local secret =\nreturn secret"; let location = "section Gather prologue"; let error = LuaProgram::compile( source, + "prompt", location, NonZeroU32::new(1).expect("compile source line is non-zero"), &null_emitter(), @@ -288,22 +339,15 @@ fn malformed_lua_reports_location_and_retains_source_diagnostic() { ) .expect_err("malformed Lua must not compile"); - match &error { - Error::LuaCompile { - location: actual_location, - lua_source: actual_source, - message, - .. - } => { - assert_eq!(actual_location, location); - assert_eq!(actual_source, source); - assert!( - message.contains(r#"[string "line 1"]"#), - "the Lua diagnostic must name the chunk by its first prompt line: {message}" - ); - } - other => panic!("expected Error::LuaCompile, got {other:?}"), - } + assert_eq!(error.code, ErrorCode::LuaCompile); + assert_eq!( + error.facts.get(&runvil_types::error::FactKey::Section), + Some(&Json::from(location)) + ); + assert!( + error.message.contains(r#"[string "line 1"]"#), + "the Lua diagnostic must name the chunk by its first prompt line: {error}" + ); assert!( error.to_string().contains(location), "the displayed error must identify its source region" @@ -317,6 +361,7 @@ fn lua_compilation_reports_are_ordered_exact_and_payload_free() { let location = "private/location"; LuaProgram::compile( source, + "prompt", location, NonZeroU32::new(1).expect("compile source line is non-zero"), recorder.emitter(), @@ -341,6 +386,7 @@ fn lua_compilation_reports_are_ordered_exact_and_payload_free() { let recorder = Recorder::default(); LuaProgram::compile( "local private =", + "prompt", location, NonZeroU32::new(1).expect("compile source line is non-zero"), recorder.emitter(), @@ -362,23 +408,3 @@ fn lua_compilation_reports_are_ordered_exact_and_payload_free() { ); assert_identity_only(&recorder); } - -#[test] -fn lua_runtime_error_keeps_its_mlua_cause_out_of_the_source_chain() { - // A Lua runtime failure is the cause-bearing `LuaRuntime` variant: its - // message already holds the `mlua` error's text, so the originating - // error is kept behind `cause()` and a chain printer shows it once. - let err = run("error('boom')", "").expect_err("an explicit error() must raise"); - assert!( - matches!(err, Error::LuaRuntime { .. }), - "a Lua runtime failure must use the cause-bearing variant, got {err:?}" - ); - assert!( - matches!(err.cause(), Some(cause) if cause.is::()), - "the originating mlua error must stay reachable as the cause" - ); - assert!( - std::error::Error::source(&err).is_none(), - "the cause must not repeat as a chain segment" - ); -} diff --git a/crates/runvil-internal/lua/src/tests/json.rs b/crates/runvil-internal/lua/src/tests/json.rs index 3c6da739f..5887693ba 100644 --- a/crates/runvil-internal/lua/src/tests/json.rs +++ b/crates/runvil-internal/lua/src/tests/json.rs @@ -37,9 +37,10 @@ fn caught(vm: &BuiltVm, call: &str) -> (String, String) { (kind, message) } -/// The `(kind, message)` pair of a caught `lua` error. +/// The `(kind, message)` pair of a caught `lua` error whose own text is +/// `message`: its kind and the author prose `tostring` gives for it. fn lua_error(message: &str) -> (String, String) { - ("lua".to_owned(), message.to_owned()) + ("lua".to_owned(), refused(message)) } /// Evaluates the Lua expression `call` on `vm` and returns the string it diff --git a/crates/runvil-internal/lua/src/tests/json/errors.rs b/crates/runvil-internal/lua/src/tests/json/errors.rs index 7cce88c23..9d01c821b 100644 --- a/crates/runvil-internal/lua/src/tests/json/errors.rs +++ b/crates/runvil-internal/lua/src/tests/json/errors.rs @@ -289,7 +289,7 @@ fn an_encode_error_under_pcall_has_kind_lua() { return err.kind, tostring(err)", ); assert_eq!( - (kind.as_str(), message.as_str()), - ("lua", "json.encode: value must be JSON data, got function") + (kind, message), + lua_error("json.encode: value must be JSON data, got function") ); } diff --git a/crates/runvil-internal/lua/src/tests/json/lines.rs b/crates/runvil-internal/lua/src/tests/json/lines.rs index 2e3950ac7..3e3847cd0 100644 --- a/crates/runvil-internal/lua/src/tests/json/lines.rs +++ b/crates/runvil-internal/lua/src/tests/json/lines.rs @@ -47,7 +47,7 @@ fn an_error_names_its_line_and_column_across_the_whole_text() { ); assert_eq!( message, - "json.decode_lines: expected value at line 3 column 6" + refused("json.decode_lines: expected value at line 3 column 6") ); } diff --git a/crates/runvil-internal/lua/src/tests/logging.rs b/crates/runvil-internal/lua/src/tests/logging.rs index 47cd6f33d..295375efc 100644 --- a/crates/runvil-internal/lua/src/tests/logging.rs +++ b/crates/runvil-internal/lua/src/tests/logging.rs @@ -218,13 +218,9 @@ fn log_cumulative_byte_budget_is_enforced_before_the_event_budget() { .expect_err("the cumulative byte budget must refuse the third message"); // The refusal is the stable typed quota error, not an opaque // Lua authoring string. + let quota = crate::lua_quota(crate::QuotaResource::LogByte); assert!( - matches!( - error, - Error::LuaQuota { - resource: crate::QuotaResource::LogByte - } - ), + (error.code, &error.message, &error.facts) == (quota.code, "a.message, "a.facts), "the byte ceiling must surface as a typed LuaQuota: {error:?}" ); let logged = recorder diff --git a/crates/runvil-internal/lua/src/tests/require.rs b/crates/runvil-internal/lua/src/tests/require.rs index 0b4db930a..810bac81c 100644 --- a/crates/runvil-internal/lua/src/tests/require.rs +++ b/crates/runvil-internal/lua/src/tests/require.rs @@ -31,6 +31,7 @@ fn library(identity: &str, heading: &str, sources: &[&str]) -> Library { .map(|source| LibraryBlock { program: LuaProgram::compile( source, + "prompt", &format!("library {heading}"), NonZeroU32::new(100).expect("100 is non-zero"), &null_emitter(), @@ -66,7 +67,9 @@ fn resolver( .find(|(written, at, _)| *written == heading && at.is_none()) }) .map(|(_, _, library)| library.clone()) - .ok_or_else(|| Error::Lua(format!("no section `{heading}` within reach of require"))) + .ok_or_else(|| { + crate::lua_runtime(format!("no section `{heading}` within reach of require")) + }) } } diff --git a/crates/runvil-internal/lua/src/tests/require/errors.rs b/crates/runvil-internal/lua/src/tests/require/errors.rs index 421e8489a..c141c05ae 100644 --- a/crates/runvil-internal/lua/src/tests/require/errors.rs +++ b/crates/runvil-internal/lua/src/tests/require/errors.rs @@ -10,6 +10,7 @@ use super::*; fn at(source: &str, location: &str, line: u32) -> LuaProgram { LuaProgram::compile( source, + "prompt", location, NonZeroU32::new(line).expect("prompt lines start at 1"), &null_emitter(), diff --git a/crates/runvil-internal/lua/src/tests/sandbox.rs b/crates/runvil-internal/lua/src/tests/sandbox.rs index a785c1fe4..625c4e73c 100644 --- a/crates/runvil-internal/lua/src/tests/sandbox.rs +++ b/crates/runvil-internal/lua/src/tests/sandbox.rs @@ -72,7 +72,7 @@ fn an_installed_require_is_runvils_and_loads_no_module() { let vm = TestVm::new().shims().build(); vm.install_require( |heading, _| { - Err::(Error::Lua(format!( + Err::(crate::lua_runtime(format!( "no section `{heading}` within reach of require" ))) }, @@ -93,7 +93,10 @@ fn an_installed_require_is_runvils_and_loads_no_module() { }; assert_eq!( returned, - "function|false|no section `os` within reach of require|nil|nil" + format!( + "function|false|prompt: no section `os` within reach of require. {}|nil|nil", + ErrorCode::LuaRuntime.prose() + ) ); } @@ -171,7 +174,7 @@ fn untrusted_global_wraps_every_call_under_the_run_nonce() { fn untrusted_global_rejects_a_non_string_argument() { let error = run("return untrusted({})", "").expect_err("a table is not a string"); assert!( - matches!(error, Error::Lua(_) | Error::LuaRuntime { .. }), + error.code == ErrorCode::LuaRuntime, "a non-string argument must surface as a Lua error, got {error:?}" ); } diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index d6c2a16a0..7e486877c 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -113,12 +113,13 @@ fn a_refused_injection_hands_back_the_vm_for_its_teardown() { ) .expect_err("a non-object var seed is refused"); assert!( - matches!(failure.error(), Error::Internal { message, .. } if message.contains("JSON object")), + failure.error().code == ErrorCode::Internal + && failure.error().message.contains("JSON object"), "the refusal names the seed's shape: {:?}", failure.error() ); let error = failure.teardown(recorder.emitter(), "Seed"); - assert!(matches!(error, Error::Internal { .. })); + assert_eq!(error.code, ErrorCode::Internal); assert_eq!( recorder.observations(), [ @@ -350,8 +351,8 @@ fn call_with_a_non_string_target_errors() { // heading, and the error says so. let vm = TestVm::new().build(); vm.install_control_globals( - |target, _, _| resolve_section_target(target).map_err(Error::lua), - |_| Err(Error::Lua("prompt reads are not needed here".to_owned())), + |target, _, _| resolve_section_target(target).map_err(crate::error::lua), + |_| Err(crate::lua_runtime("prompt reads are not needed here")), ) .expect("control globals must install"); let out = run_scalar( diff --git a/crates/runvil-internal/lua/src/tests/store.rs b/crates/runvil-internal/lua/src/tests/store.rs index b32a88347..68689c85c 100644 --- a/crates/runvil-internal/lua/src/tests/store.rs +++ b/crates/runvil-internal/lua/src/tests/store.rs @@ -349,8 +349,8 @@ fn store_glob_returns_a_sorted_array() { #[test] fn store_error_surfaces_as_lua_error() { - // An ambiguous `str_replace` anchor is a `VfsError::Anchor`, wrapped as - // `Error::Store` (mapped through `mlua::Error::external`). + // An ambiguous `str_replace` anchor is a `VfsError::Anchor`, raised as + // its store failure (through `mlua::Error::external`). let err = run( "store.write('a.txt', 'na na na')\nstore.str_replace('a.txt', 'na', 'la')", "", diff --git a/crates/runvil-internal/lua/src/tests/store_errors.rs b/crates/runvil-internal/lua/src/tests/store_errors.rs index e11b39ccb..fb102f7ee 100644 --- a/crates/runvil-internal/lua/src/tests/store_errors.rs +++ b/crates/runvil-internal/lua/src/tests/store_errors.rs @@ -15,14 +15,9 @@ fn a_store_install_without_a_store_names_why_in_its_message() { let error = vm .install_engine_globals(&null_emitter(), "Test") .expect_err("a handle without a store cannot install the store table"); - assert!( - matches!( - &error, - Error::Store { - cause: VfsError::NoStore, - .. - } - ), + assert_eq!( + error.code, + ErrorCode::NoStore, "required a store error over NoStore, actual {error:?}" ); assert_eq!( @@ -55,9 +50,12 @@ fn direct_store_failure_caught_by_pcall_carries_the_reason_tag() { ) .expect("the section chunk must run"); assert_eq!( - out.as_deref(), - Some("store|not_found|missing.txt|file not found in store: missing.txt"), - "the direct store failure must carry the documented shape" + out, + Some(format!( + "store|not_found|missing.txt|prompt: file not found in store: missing.txt. {}", + ErrorCode::NotFound.prose() + )), + "the direct store failure must carry the documented shape and its author prose" ); } @@ -343,38 +341,47 @@ fn store_error_messages_use_the_operations_wording() { /// A store failure raised through the Lua boundary renders as an error /// table of kind `store` whose `count` field is a Lua integer, and -/// `tostring` returns the model-facing message. +/// `tostring` returns the failure's prose. #[test] -fn a_raised_store_error_renders_an_integer_count_and_the_message() { +fn a_raised_store_error_renders_an_integer_count_and_the_prose() { let anchor = runvil_vfs::VfsError::Anchor { path: "notes.md".to_owned(), anchor: "TODO".to_owned(), count: 3, }; - let error = Error::store( - &crate::VfsOp::StrReplace { - path: "notes.md".to_owned(), - old: "TODO".to_owned(), - new: "DONE".to_owned(), - }, - anchor, + let op = crate::VfsOp::StrReplace { + path: "notes.md".to_owned(), + old: "TODO".to_owned(), + new: "DONE".to_owned(), + }; + let mut error = crate::store_error(&anchor); + error.message = crate::store_error_message(&op, &anchor); + assert!( + error.message.contains("include more surrounding text"), + "the message carries the recovery hint: {error}" ); - assert_eq!(error.kind(), crate::ErrorKind::Store); - let fields = error.fields(); - assert!(fields.contains(&("count".to_owned(), crate::ErrorField::Integer(3),))); let lua = mlua::Lua::new(); let table = crate::error_value::error_table(&lua, &error).expect("the table builds"); + assert_eq!( + table.raw_get::("kind").expect("the kind reads"), + "store" + ); + assert_eq!( + table.raw_get::("count").expect("the count reads"), + Value::Integer(3) + ); + assert_eq!( + table + .raw_get::("message") + .expect("the message reads"), + error.prose + ); let raised = crate::error_value::raised_from(&lua, &Value::Table(table)) .expect("the table reads back") .expect("the table is a structured error table"); - assert_eq!(raised.kind, crate::ErrorKind::Store); assert_eq!( - raised.fields.get("count"), - Some(&crate::ErrorField::Integer(3)), - "the count field stays an integer through the boundary" - ); - assert!( - error.to_string().contains("include more surrounding text"), - "the message carries the recovery hint: {error}" + (raised.code, &raised.facts), + (error.code, &error.facts), + "the count stays an integer through the boundary" ); } diff --git a/crates/runvil-internal/lua/src/tests/store_reports.rs b/crates/runvil-internal/lua/src/tests/store_reports.rs index 8b94770c6..28bb34107 100644 --- a/crates/runvil-internal/lua/src/tests/store_reports.rs +++ b/crates/runvil-internal/lua/src/tests/store_reports.rs @@ -80,7 +80,7 @@ fn store_reports_are_ordered_exact_and_payload_free_on_failure() { "Gather", ) .expect_err("the missing anchor must fail"); - assert!(matches!(error, Error::Lua(_) | Error::LuaRuntime { .. })); + assert_eq!(error.code, ErrorCode::LuaRuntime); assert_eq!( recorder.observations(), @@ -200,7 +200,7 @@ fn every_store_operation_reports_its_exact_success_and_failure() { let error = run_chunk(case.source, "", &json!({}), &access, &observer, "Store") .expect_err("the failing backend rejects every operation"); assert!( - matches!(error, Error::Lua(_) | Error::LuaRuntime { .. }), + error.code == ErrorCode::LuaRuntime, "a store failure surfaces through the Lua boundary, got {error:?}" ); assert_eq!( diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index 427ef0080..2356082ed 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -27,7 +27,7 @@ use serde_json::{Value as Json, json}; use crate::alias::Alias; use crate::bindings::{ToolBinding, ToolSet}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::json::to_json; use crate::messages::MessageList; use crate::packs::{ADHOC_PACK, install_pack_list, install_pack_objects}; @@ -88,7 +88,7 @@ pub(crate) fn adhoc_handler(lua: &Lua, name: &str) -> mlua::Result { }; match handler { Value::Function(handler) => Ok(handler), - _ => Err(mlua::Error::external(Error::internal( + _ => Err(mlua::Error::external(error::internal( "an adhoc tool answer names a registered handler", ))), } @@ -103,7 +103,7 @@ pub(crate) fn adhoc_handler(lua: &Lua, name: &str) -> mlua::Result { /// the section's frozen sets. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if a Lua table or callback cannot be created or +/// Returns a `LuaRuntime` failure if a Lua table or callback cannot be created or /// installed. pub(crate) fn install_tools( lua: &Lua, @@ -113,7 +113,7 @@ pub(crate) fn install_tools( adhoc_tools: &AdhocTools, list: &MessageList, ) -> Result<()> { - let tools = lua.create_table().map_err(Error::lua)?; + let tools = lua.create_table().map_err(error::lua)?; install_tool_objects(lua, set)?; let names = install_pack_objects(lua, set, adhoc_tools)?; install_pack_list(lua, &tools, names)?; @@ -128,7 +128,7 @@ pub(crate) fn install_tools( install_adhoc(lua, &tools, adhoc_tools)?; install_tool_get(lua, &tools)?; - globals.raw_set("tools", tools).map_err(Error::lua) + globals.raw_set("tools", tools).map_err(error::lua) } /// Installs `tools.adhoc(attrs, f)` over a fresh handler table: `f` is @@ -143,9 +143,9 @@ pub(crate) fn install_tools( fn install_adhoc(lua: &Lua, tools: &Table, adhoc_tools: &AdhocTools) -> Result<()> { lua.set_named_registry_value( ADHOC_HANDLERS_REGISTRY, - lua.create_table().map_err(Error::lua)?, + lua.create_table().map_err(error::lua)?, ) - .map_err(Error::lua)?; + .map_err(error::lua)?; let adhoc = adhoc_tools.clone(); let define = lua .create_function(move |lua, args: MultiValue| { @@ -187,8 +187,8 @@ fn install_adhoc(lua: &Lua, tools: &Table, adhoc_tools: &AdhocTools) -> Result<( } Ok(object) }) - .map_err(Error::lua)?; - tools.set("adhoc", define).map_err(Error::lua) + .map_err(error::lua)?; + tools.set("adhoc", define).map_err(error::lua) } /// Reads `tools.adhoc`'s attrs as the tool's wire name and info, checking @@ -255,7 +255,7 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str tool_schema_new(name.clone(), description.clone(), parameters.clone()) .map_err(|error| mlua::Error::external(format!("tools.adhoc: {error}")))?; let id = ToolId::parse(&id).map_err(|error| { - mlua::Error::external(Error::internal(format!( + mlua::Error::external(error::internal(format!( "an adhoc tool's id is a tool id: {error}" ))) })?; diff --git a/crates/runvil-internal/lua/src/tools/objects.rs b/crates/runvil-internal/lua/src/tools/objects.rs index e34f4f52e..311049acd 100644 --- a/crates/runvil-internal/lua/src/tools/objects.rs +++ b/crates/runvil-internal/lua/src/tools/objects.rs @@ -10,7 +10,7 @@ use mlua::{AnyUserData, Lua, Table, Value}; use super::userdata::LuaToolHandle; use crate::bindings::{ToolBinding, ToolSet}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; /// The registry key of the VM's tool-object table: one object per tool, /// keyed by id. @@ -35,20 +35,20 @@ pub(crate) fn tool_object(lua: &Lua, id: &str) -> mlua::Result Result<()> { - let objects = lua.create_table().map_err(Error::lua)?; + let objects = lua.create_table().map_err(error::lua)?; for info in set.catalog().tools() { let object = lua .create_userdata(LuaToolHandle::from_info(info)) - .map_err(Error::lua)?; + .map_err(error::lua)?; objects .raw_set(info.id.to_string(), object) - .map_err(Error::lua)?; + .map_err(error::lua)?; } lua.set_named_registry_value(TOOL_OBJECTS_REGISTRY, objects) - .map_err(Error::lua) + .map_err(error::lua) } /// Adds the tool object of the adhoc tool `binding` binds and returns it. @@ -75,7 +75,7 @@ pub(super) fn remove_tool_object(lua: &Lua, id: &str) -> mlua::Result<()> { /// Installs `tools.get(id)`. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the function cannot be created or installed. +/// Returns a `LuaRuntime` failure if the function cannot be created or installed. pub(super) fn install_tool_get(lua: &Lua, tools: &Table) -> Result<()> { let get = lua .create_function(|lua, id: Value| match id { @@ -85,6 +85,6 @@ pub(super) fn install_tool_get(lua: &Lua, tools: &Table) -> Result<()> { other.type_name() ))), }) - .map_err(Error::lua)?; - tools.set("get", get).map_err(Error::lua) + .map_err(error::lua)?; + tools.set("get", get).map_err(error::lua) } diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index 600b2cf4c..cc04c0f25 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -18,7 +18,7 @@ use runvil_types::tools::ToolId; use super::refuse_after_render; use super::userdata::LuaToolHandle; use crate::bindings::{RosterEntry, ToolSet, wire_name_of}; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::messages::MessageList; use crate::packs::{LuaPackHandle, is_adhoc, pack_object}; use crate::proxy::key_text; @@ -41,7 +41,7 @@ pub(super) struct Offering { /// `mode`. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if the function cannot be created or +/// Returns a `LuaRuntime` failure if the function cannot be created or /// installed. pub(super) fn install_offer( lua: &Lua, @@ -68,8 +68,8 @@ pub(super) fn install_offer( } Ok(()) }) - .map_err(Error::lua)?; - tools.set(key, offer).map_err(Error::lua) + .map_err(error::lua)?; + tools.set(key, offer).map_err(error::lua) } impl Offering { diff --git a/crates/runvil-internal/lua/src/tools/stats.rs b/crates/runvil-internal/lua/src/tools/stats.rs index 0e7d7fcf1..cd31c9cb2 100644 --- a/crates/runvil-internal/lua/src/tools/stats.rs +++ b/crates/runvil-internal/lua/src/tools/stats.rs @@ -19,7 +19,7 @@ use mlua::{Lua, Table, Value}; use runvil_types::tools::ToolId; use crate::bindings::ToolSet; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_walk}; use crate::scope::ToolCallCounts; use crate::vm::AdhocTools; @@ -141,7 +141,7 @@ impl Members { /// `adhoc`, and returns the counts so the scheduler can increment them. /// /// # Errors -/// Returns [`Error::LuaRuntime`] when `tools.stats` cannot be built or +/// Returns a `LuaRuntime` failure when `tools.stats` cannot be built or /// installed. pub(crate) fn install_tool_call_counts( lua: &Lua, @@ -162,9 +162,9 @@ pub(crate) fn install_tool_call_counts( ), adhoc: adhoc.clone(), }; - let tools: Table = lua.globals().raw_get("tools").map_err(Error::lua)?; - let proxy = stats_proxy(lua, &stats).map_err(Error::lua)?; - tools.raw_set("stats", proxy).map_err(Error::lua)?; + let tools: Table = lua.globals().raw_get("tools").map_err(error::lua)?; + let proxy = stats_proxy(lua, &stats).map_err(error::lua)?; + tools.raw_set("stats", proxy).map_err(error::lua)?; Ok(counts) } diff --git a/crates/runvil-internal/lua/src/var.rs b/crates/runvil-internal/lua/src/var.rs index e6c33ff61..2c35b5ce0 100644 --- a/crates/runvil-internal/lua/src/var.rs +++ b/crates/runvil-internal/lua/src/var.rs @@ -3,7 +3,7 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use crate::error::{Error, Result}; +use crate::error::{self, Result, lua_runtime}; use crate::proxy::key_path; use crate::walk_budget::WalkBudget; @@ -160,34 +160,34 @@ pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result Json::Object(values.clone()), Some(other) => { - return Err(Error::internal(format!( + return Err(error::internal(format!( "var must seed from a JSON object, got {}", - lua.to_value(other).map_err(Error::lua)?.type_name() + lua.to_value(other).map_err(error::lua)?.type_name() ))); } None => Json::Object(serde_json::Map::new()), }; - let guarded_data = lua.create_table().map_err(Error::lua)?; + let guarded_data = lua.create_table().map_err(error::lua)?; let Value::Table(proxy) = - guarded_json_value(lua, &initial, "var", &guarded_data).map_err(Error::lua)? + guarded_json_value(lua, &initial, "var", &guarded_data).map_err(error::lua)? else { - return Err(Error::internal("guarded var root was not a table")); + return Err(error::internal("guarded var root was not a table")); }; let Value::Table(data) = guarded_data .raw_get::(proxy.clone()) - .map_err(Error::lua)? + .map_err(error::lua)? else { - return Err(Error::internal("guarded var data table was missing")); + return Err(error::internal("guarded var data table was missing")); }; // The named registry entries keep the data table alive and reachable for // Engine read-back, and the proxy reachable for the reassignment check in // `var_to_json`. lua.set_named_registry_value(VAR_DATA_REGISTRY, data) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.set_named_registry_value(VAR_PROXY_REGISTRY, proxy.clone()) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.set_named_registry_value(VAR_GUARDED_DATA_REGISTRY, guarded_data) - .map_err(Error::lua)?; + .map_err(error::lua)?; Ok(proxy) } @@ -199,42 +199,42 @@ pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result Result { let proxy: mlua::Table = lua .named_registry_value(VAR_PROXY_REGISTRY) - .map_err(Error::lua)?; - let current: Value = lua.globals().get("var").map_err(Error::lua)?; + .map_err(error::lua)?; + let current: Value = lua.globals().get("var").map_err(error::lua)?; match current { - Value::Table(ref table) if table.equals(&proxy).map_err(Error::lua)? => {} + Value::Table(ref table) if table.equals(&proxy).map_err(error::lua)? => {} _ => { - return Err(Error::Lua( + return Err(lua_runtime( "the `var` global was reassigned; write `var.` instead".to_owned(), )); } } let data: mlua::Table = lua .named_registry_value(VAR_DATA_REGISTRY) - .map_err(Error::lua)?; + .map_err(error::lua)?; let guarded_data: mlua::Table = lua .named_registry_value(VAR_GUARDED_DATA_REGISTRY) - .map_err(Error::lua)?; - match materialize_guarded_value(lua, Value::Table(data), &guarded_data).map_err(Error::lua)? { + .map_err(error::lua)?; + match materialize_guarded_value(lua, Value::Table(data), &guarded_data).map_err(error::lua)? { Value::Table(plain) => Ok(plain), // The root data value is always a table; `guarded_var` builds it. - _ => Err(Error::internal("guarded var data table was not a table")), + _ => Err(error::internal("guarded var data table was not a table")), } } /// Reads the hidden `var` data table back as JSON. /// /// # Errors -/// Fails as [`var_snapshot_table`] does, and with [`Error::LuaRuntime`] if +/// Fails as [`var_snapshot_table`] does, and with a `LuaRuntime` failure if /// the data cannot be represented as JSON. pub(crate) fn var_to_json(lua: &Lua) -> Result { lua.from_value(Value::Table(var_snapshot_table(lua)?)) - .map_err(Error::lua) + .map_err(error::lua) } diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 9a2064297..8cd053745 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -33,7 +33,7 @@ use serde_json::Value as Json; use crate::bindings::ToolSet; use crate::engine_globals::{LUA_LOG_CHARACTER_LIMIT, install_untrusted}; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result}; use crate::hardening::{CancelHook, harden, install_cancel_hook}; use crate::iteration::install_deterministic_iteration; use crate::messages::MessageList; @@ -188,20 +188,20 @@ impl SectionVm { /// every id. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the VM cannot be built or hardened. + /// Returns a `LuaRuntime` failure if the VM cannot be built or hardened. pub(crate) fn new(nonce: &GuardNonce, emitter: &Emitter, section: &str) -> Result { let lua = Lua::new_with( StdLib::STRING | StdLib::TABLE | StdLib::MATH | StdLib::UTF8, LuaOptions::default(), ) - .map_err(Error::lua)?; + .map_err(error::lua)?; #[cfg(any(test, feature = "test-support"))] crate::test_support::count_live_vm(&lua); // Bound the VM heap by default; `apply_lua_limits` may tighten or relax // it to the caller's `RunLimits`. A default keeps every VM // bounded even when the run installs no explicit limits. lua.set_memory_limit(DEFAULT_LUA_MEMORY_BYTES) - .map_err(Error::lua)?; + .map_err(error::lua)?; lua.set_app_data(HeapCeiling(DEFAULT_LUA_MEMORY_BYTES)); let mut vm = Self { lua, @@ -247,7 +247,7 @@ impl SectionVm { /// path. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the VM cannot be built or hardened. + /// Returns a `LuaRuntime` failure if the VM cannot be built or hardened. pub fn new_for_section( nonce: &GuardNonce, tools: &Arc, @@ -271,6 +271,13 @@ impl SectionVm { self.raw_model_ids = RawModelIds::Resolved; } + /// Records `file`, the file the run's prompt came from, as the file + /// the author prose of this VM's Lua failures names; a VM given none + /// names `prompt`. + pub fn set_prompt_file(&self, file: &str) { + crate::error::set_prompt_file(&self.lua, file); + } + /// Applies the run's Lua resource limits to this VM. /// /// Sets the heap ceiling (`lua_memory_bytes`), records it for the @@ -281,12 +288,12 @@ impl SectionVm { /// `SectionVm::new` installs. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the underlying VM rejects the memory + /// Returns a `LuaRuntime` failure if the underlying VM rejects the memory /// limit. pub fn apply_lua_limits(&self, memory_bytes: usize, log_events: u32) -> Result<()> { self.lua .set_memory_limit(memory_bytes) - .map_err(Error::lua)?; + .map_err(error::lua)?; self.lua.set_app_data(HeapCeiling(memory_bytes)); self.log_budget.store(log_events, Ordering::Relaxed); self.log_byte_budget @@ -310,7 +317,7 @@ impl SectionVm { impl SectionVm { /// Installs the run's cancel flag on this VM's instruction hook: every /// block coroutine the VM starts polls it, and a set flag aborts the - /// running chunk as [`Error::Interrupted`]. A VM without one is never + /// running chunk as an `Interrupted` failure. A VM without one is never /// cancelled. The first install wins. pub fn set_cancel(&self, cancel: runvil_types::cancel::CancelHandle) { self.cancel_hook.set_cancel(cancel); diff --git a/crates/runvil-internal/lua/src/vm/adhoc.rs b/crates/runvil-internal/lua/src/vm/adhoc.rs index 749e93490..8f262db1a 100644 --- a/crates/runvil-internal/lua/src/vm/adhoc.rs +++ b/crates/runvil-internal/lua/src/vm/adhoc.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use crate::bindings::ToolBinding; -use crate::error::{Error, Result}; +use crate::error::{self, Result}; /// The adhoc tools `tools.adhoc` defined on a section VM, in definition /// order. @@ -23,17 +23,17 @@ impl AdhocTools { /// Locks the registry. /// /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. + /// Returns an `Internal` failure if the entries lock was poisoned. fn entries(&self) -> Result>> { self.entries .lock() - .map_err(|_| Error::internal("adhoc tools registry was poisoned")) + .map_err(|_| error::internal("adhoc tools registry was poisoned")) } /// Registers the adhoc tool `binding` binds. /// /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. + /// Returns an `Internal` failure if the entries lock was poisoned. pub(crate) fn register(&self, binding: ToolBinding) -> Result<()> { self.entries()?.push(binding); Ok(()) @@ -43,7 +43,7 @@ impl AdhocTools { /// holds a `/`, else whose wire name is `name`. /// /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. + /// Returns an `Internal` failure if the entries lock was poisoned. pub fn binding(&self, name: &str) -> Result> { let by_id = name.contains('/'); Ok(self @@ -62,7 +62,7 @@ impl AdhocTools { /// Returns every adhoc tool's binding, in id order. /// /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. + /// Returns an `Internal` failure if the entries lock was poisoned. pub(crate) fn bindings(&self) -> Result> { let mut bindings = self.entries()?.clone(); bindings.sort_by(|left, right| left.id().cmp(right.id())); diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index ab9b81c9f..92d4a34d8 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -12,7 +12,7 @@ use serde_json::Value as Json; use super::{Injected, SectionVm, Uninjected}; use crate::argv::Argv; use crate::compactors::install_compactors; -use crate::error::{Error, Result}; +use crate::error::{self, Error, Result}; use crate::messages::install_messages; use crate::models::install_models; use crate::sys::seal_sys; @@ -24,7 +24,7 @@ use crate::var::guarded_var; #[derive(Debug)] pub struct InjectFailure { vm: Box>, - error: Error, + error: Box, } impl InjectFailure { @@ -40,7 +40,7 @@ impl InjectFailure { #[must_use] pub fn teardown(self, emitter: &Emitter, section: &str) -> Error { self.vm.teardown(emitter, section); - self.error + *self.error } } @@ -89,7 +89,7 @@ impl SectionVm { if let Err(error) = self.install_values(args, sys, initial_var, argv, selection) { return Err(InjectFailure { vm: Box::new(self), - error, + error: Box::new(error), }); } Ok(self.injected(Injected { @@ -112,15 +112,15 @@ impl SectionVm { selection: &Arc>>, ) -> Result<()> { let globals = self.lua.globals(); - globals.raw_set("args", args).map_err(Error::lua)?; + globals.raw_set("args", args).map_err(error::lua)?; match argv { Argv::Writable(value) => crate::argv::install_writable(&self.lua, value)?, Argv::Frozen(value) => crate::argv::install_frozen(&self.lua, value)?, } let sys_table = seal_sys(&self.lua, sys)?; - globals.raw_set("sys", sys_table).map_err(Error::lua)?; + globals.raw_set("sys", sys_table).map_err(error::lua)?; let var = guarded_var(&self.lua, initial_var)?; - globals.raw_set("var", var).map_err(Error::lua)?; + globals.raw_set("var", var).map_err(error::lua)?; install_tools( &self.lua, &globals, diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 7579576ad..886eed2a5 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -12,7 +12,9 @@ use serde_json::Value as Json; use super::{SYS_LIVE_POISONED, SectionVm, pack_sequence}; use crate::coro::install_shim_prelude; use crate::engine_globals::{install_log, install_store_table}; -use crate::error::{Error, Result}; +#[cfg(test)] +use crate::error::Error; +use crate::error::{self, Result}; use crate::prose::ProseState; use crate::section::{Library, LibraryKey, SectionText, resolve_section_target}; use crate::sys::seal_sys; @@ -32,8 +34,8 @@ impl SectionVm { /// [`take_store_conflict`](Self::take_store_conflict). /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the globals cannot be installed, or - /// [`Error::Store`] if the handle declares no store. + /// Returns a `LuaRuntime` failure if the globals cannot be installed, or + /// the store's failure if the handle declares no store. pub fn install_engine_globals(&self, emitter: &Emitter, section: &str) -> Result<()> { install_log( &self.lua, @@ -90,7 +92,7 @@ impl SectionVm { /// never reach this VM. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if any global cannot be installed. + /// Returns a `LuaRuntime` failure if any global cannot be installed. #[cfg(test)] pub(crate) fn install_control_globals(&self, call_callback: E, read: R) -> Result<()> where @@ -104,8 +106,8 @@ impl SectionVm { let var = var_to_json(lua).map_err(mlua::Error::external)?; call_callback(target, input, var).map_err(mlua::Error::external) }) - .map_err(Error::lua)?; - globals.raw_set("call", call_fn).map_err(Error::lua)?; + .map_err(error::lua)?; + globals.raw_set("call", call_fn).map_err(error::lua)?; self.install_jump_global(&globals)?; self.install_prompt_table(&globals, read) } @@ -118,7 +120,7 @@ impl SectionVm { /// [`install_coro_shims`](Self::install_coro_shims). /// /// # Errors - /// Returns [`Error::LuaRuntime`] if any global cannot be installed. + /// Returns a `LuaRuntime` failure if any global cannot be installed. pub fn install_scheduler_control_globals(&self, read: R) -> Result<()> where R: Fn(String) -> std::result::Result, E> + Send + Sync + 'static, @@ -139,7 +141,7 @@ impl SectionVm { /// through captures over the selection injection received. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the shim prelude cannot install. + /// Returns a `LuaRuntime` failure if the shim prelude cannot install. pub fn install_coro_shims(&mut self, max_tool_iterations: usize) -> Result<()> { install_shim_prelude( &self.lua, @@ -171,7 +173,7 @@ impl SectionVm { /// line. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the loader cannot install. + /// Returns a `LuaRuntime` failure if the loader cannot install. pub fn install_require( &self, resolver: R, @@ -227,8 +229,8 @@ impl SectionVm { *slot = Some(heading); Err(mlua::Error::external("jump transfer")) }) - .map_err(Error::lua)?; - globals.raw_set("jump", jump_fn).map_err(Error::lua) + .map_err(error::lua)?; + globals.raw_set("jump", jump_fn).map_err(error::lua) } fn install_prompt_table(&self, globals: &mlua::Table, read: R) -> Result<()> @@ -245,7 +247,7 @@ impl SectionVm { .map_err(mlua::Error::external)?; Ok(text.map(|text| text.markdown)) }) - .map_err(Error::lua)?; + .map_err(error::lua)?; let list = self .lua .create_function(move |lua, target: Value| { @@ -259,28 +261,28 @@ impl SectionVm { Some(text) => pack_sequence(lua, text.items).map(Value::Table), } }) - .map_err(Error::lua)?; - let prompt = self.lua.create_table().map_err(Error::lua)?; - prompt.raw_set("section", section).map_err(Error::lua)?; - prompt.raw_set("list", list).map_err(Error::lua)?; - globals.raw_set("prompt", prompt).map_err(Error::lua) + .map_err(error::lua)?; + let prompt = self.lua.create_table().map_err(error::lua)?; + prompt.raw_set("section", section).map_err(error::lua)?; + prompt.raw_set("list", list).map_err(error::lua)?; + globals.raw_set("prompt", prompt).map_err(error::lua) } /// Replaces the sealed Lua `sys` global after scope close, to expose /// `sys.model` once the section's model binding is fixed. /// /// # Errors - /// Returns [`Error::Internal`] if the live slot is poisoned, or the + /// Returns an `Internal` failure if the live slot is poisoned, or the /// install failure if the sealed table cannot be installed. pub fn re_seal_sys(&self, sys: &Json) -> Result<()> { let globals = self.lua.globals(); let sys_table = seal_sys(&self.lua, sys)?; - globals.raw_set("sys", sys_table).map_err(Error::lua)?; + globals.raw_set("sys", sys_table).map_err(error::lua)?; let mut live = self .state .sys_live .lock() - .map_err(|_| Error::internal(SYS_LIVE_POISONED))?; + .map_err(|_| error::internal(SYS_LIVE_POISONED))?; *live = sys.clone(); Ok(()) } @@ -295,7 +297,7 @@ impl SectionVm { /// the template is rejected as recursive. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the read cannot be built or the `_G` guard + /// Returns a `LuaRuntime` failure if the read cannot be built or the `_G` guard /// cannot record it. pub fn install_lazy_prose(&self, render: F) -> Result<()> where diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 0f73dfdca..40eb22133 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -3,11 +3,12 @@ use mlua::thread::ThreadStatus; use mlua::{MultiValue, Thread, Value}; +use runvil_types::error::ErrorCode; use super::SectionVm; -use crate::coro::{block_guard, take_failure}; -use crate::error::{Error, Result}; -use crate::error_value::ErrorKind; +use crate::coro::{StashedFailure, block_guard, take_failure}; +use crate::error::{self, Error, Result, interrupted, lua_runtime, prompt_file}; +use crate::error_value::{ErrorKind, error_kind}; use crate::program::LuaProgram; use crate::protocol::{Answer, Request, Retained, YieldParse, render_envelope}; use crate::section::LuaBlockResult; @@ -17,19 +18,19 @@ impl SectionVm { /// rather than silently coercing the failure into "no jump". /// /// # Errors - /// Returns [`Error::Internal`] when the jump-slot mutex is poisoned. + /// Returns an `Internal` failure when the jump-slot mutex is poisoned. pub(crate) fn take_jump(&self) -> Result> { let mut slot = self .jump_slot .lock() - .map_err(|_| Error::internal(JUMP_SLOT_POISONED))?; + .map_err(|_| error::internal(JUMP_SLOT_POISONED))?; Ok(slot.take()) } /// Clears any jump target an earlier chunk recorded. /// /// # Errors - /// Returns [`Error::Internal`] when the jump-slot mutex is poisoned. + /// Returns an `Internal` failure when the jump-slot mutex is poisoned. pub(crate) fn clear_jump(&self) -> Result<()> { self.take_jump().map(drop) } @@ -59,23 +60,26 @@ impl SectionVm { /// the block function: the guard runs the block under `xpcall`, whose /// handler stashes a failure and its raise-point traceback, and /// re-raises the same value, so a structured error table a shim raised - /// reaches the Engine as a typed [`Error::Raised`] rather than only as - /// mlua's stringification, and a Lua-raised error keeps the author's - /// frames for the prompt-line mapping. + /// reaches the Engine as the universal error it stands for rather than + /// only as mlua's stringification, and a Lua-raised error keeps the + /// author's frames for the prompt-line mapping. /// /// # Errors - /// Returns [`Error::Internal`] if the jump slot is poisoned, or - /// [`Error::LuaRuntime`] if the program cannot load, the shim prelude + /// Returns an `Internal` failure if the jump slot is poisoned, or a + /// `LuaRuntime` failure if the program cannot load, the shim prelude /// never ran on this VM, or the thread cannot be created or hooked; a /// block failure returns the mapped runtime error. pub fn start_block_coro(&self, program: &LuaProgram) -> Result { self.clear_jump()?; let function = program.load(&self.lua)?; let guard = block_guard(&self.lua)?; - let thread = self.lua.create_thread(guard).map_err(Error::lua)?; + let thread = self.lua.create_thread(guard).map_err(error::lua)?; self.cancel_hook.install_on_thread(&thread)?; let result = thread.resume::(function); - self.step_block_coro::(program, thread, result, None) + match self.step_block_coro(thread, result)? { + Ok(step) => Ok(step), + Err(error) => Err(self.block_failure(program, &error)?), + } } /// Resumes a suspended block coroutine with the driver's answer values. @@ -90,7 +94,10 @@ impl SectionVm { args: impl mlua::IntoLuaMulti, ) -> Result { let result = thread.resume::(args); - self.step_block_coro::(program, thread.clone(), result, None) + match self.step_block_coro(thread.clone(), result)? { + Ok(step) => Ok(step), + Err(error) => Err(self.block_failure(program, &error)?), + } } /// Validates a suspended block coroutine's yielded values into the @@ -112,62 +119,57 @@ impl SectionVm { /// /// The answer renders to its `(ok, result)` envelope on this VM. On a /// failure answer the envelope includes the error's structured table - /// (`kind`, `message`, fields) for the shim to raise. When the block's - /// failure is a raise of that very table - the shim's own raise, or the - /// block's re-raise of the value it caught - the typed error the answer - /// owned is substituted back, so the Rust caller receives the + /// (`kind`, `code`, `message`, fields) for the shim to raise. When the + /// block's failure is a raise of that very table - the shim's own raise, + /// or the block's re-raise of the value it caught - the typed error the + /// answer owned is substituted back, so the Rust caller receives the /// structured error rather than a string. A block that caught the /// table and failed with anything else, its message text included, /// keeps its own error. /// - /// The error type is the driver's own (`E`); this crate's internal - /// failures convert into it through [`From`], and its - /// [`ErrorValue`](crate::ErrorValue) rendering supplies the table's - /// kind. - /// /// # Errors /// Same contract as [`start_block_coro`](Self::start_block_coro), plus - /// the driver's `E` if the envelope cannot be rendered on this VM. - pub fn resume_block_coro_answer( + /// a `LuaRuntime` failure if the envelope cannot be rendered on this VM. + pub fn resume_block_coro_answer( &self, program: &LuaProgram, thread: &Thread, - answer: std::result::Result, E>, - ) -> std::result::Result - where - E: crate::ErrorValue + From, - { - let (envelope, retained) = render_envelope(&self.lua, answer).map_err(Error::lua)?; + answer: std::result::Result, + ) -> Result { + let (envelope, retained) = render_envelope(&self.lua, answer).map_err(error::lua)?; let result = thread.resume::(envelope); - self.step_block_coro(program, thread.clone(), result, retained) + match self.step_block_coro(thread.clone(), result)? { + Ok(step) => Ok(step), + Err(error) => Err(self.answered_block_failure(program, &error, retained)), + } } - fn step_block_coro>( + /// Steps a resumed block coroutine: a yield suspends it with the + /// shim's request table, a recorded jump takes precedence over the + /// chunk's own error, which is the jump's own transfer marker, and a + /// return applies the scalar-return rule. Any other end is the block's + /// failure, left for the caller to classify. + fn step_block_coro( &self, - program: &LuaProgram, thread: Thread, result: mlua::Result, - retained: Option>, - ) -> std::result::Result { + ) -> Result> { match result { // A resumed thread that is still resumable suspended on a yield; // the yielded values are the shim's request table. Ok(values) if thread.status() == ThreadStatus::Resumable => { - Ok(CoroStep::Yielded(thread, values)) + Ok(Ok(CoroStep::Yielded(thread, values))) } result => { - // A recorded jump takes precedence over the chunk's error, - // which is the jump's own transfer marker. if let Some(heading) = self.take_jump()? { - return Ok(CoroStep::Done(LuaBlockResult::Jump(heading))); + return Ok(Ok(CoroStep::Done(LuaBlockResult::Jump(heading)))); + } + match result { + Ok(returned) => Ok(Ok(CoroStep::Done(LuaBlockResult::Returned( + scalar_return(returned)?, + )))), + Err(error) => Ok(Err(error)), } - let returned = match result { - Ok(returned) => returned, - Err(error) => return Err(self.block_failure(program, &error, retained)?), - }; - Ok(CoroStep::Done(LuaBlockResult::Returned(scalar_return( - returned, - )?))) } } } @@ -177,66 +179,64 @@ impl SectionVm { /// re-raise is what killed the coroutine, so mlua's own traceback shows /// only the guard's frame), and the failure maps through /// [`map_chunk_failure`](Self::map_chunk_failure) to the mapped prompt - /// line. Cancellation and Engine quotas keep that mapping. Otherwise a - /// raise of the answer's retained error table is the retained typed - /// error, and any other structured error table the guard stashed is - /// kept as [`Error::Raised`], except a `lua`-kind table, whose mapped - /// runtime error keeps the same message with its source and the - /// mapped prompt line. A raise that is no error table keeps its - /// mapping, so an uncaught direct `compactors.fail` call is - /// [`Error::ContextExhausted`]. The stash is taken on every failure so - /// it never goes stale. - fn block_failure>( + /// line, which [`kept_failure`] then keeps or replaces with the + /// structured table the guard stashed. The stash is taken on every + /// failure so it never goes stale. + fn block_failure(&self, program: &LuaProgram, error: &mlua::Error) -> Result { + let (stashed, mapped) = self.mapped_failure(program, error)?; + Ok(kept_failure(stashed, mapped)) + } + + /// Classifies the failure of a block resumed with an answer, as + /// [`block_failure`](Self::block_failure) does, except that a raise of + /// `retained`'s error table, the answer's own, is the answer's typed + /// error, unless the block was cancelled or spent an Engine quota. + fn answered_block_failure( &self, program: &LuaProgram, error: &mlua::Error, - retained: Option>, - ) -> Result { - let stashed = take_failure(&self.lua)?; - let mapped = self.map_chunk_failure(program, &stashed.restore_traceback(error)); - if let Error::Interrupted | Error::LuaQuota { .. } = mapped { - return Ok(E::from(mapped)); - } - if let Some(retained) = retained + retained: Option, + ) -> Error { + let (stashed, mapped) = match self.mapped_failure(program, error) { + Ok(parts) => parts, + Err(failure) => return failure, + }; + if !is_fatal(&mapped) + && let Some(retained) = retained && stashed.raised_table_is(&retained.table) { - return Ok(retained.error); + return retained.error; } - Ok(E::from(match stashed.raised { - None => mapped, - Some(raised) => match raised.kind { - ErrorKind::Lua => mapped, - ErrorKind::ToolLoopExhausted - | ErrorKind::ContextExhausted - | ErrorKind::EmptyModelReply - | ErrorKind::ModelFailure - | ErrorKind::ModelRequired - | ErrorKind::OutOfScopeTool - | ErrorKind::UnboundTool - | ErrorKind::ModelOnlyTool - | ErrorKind::Tool - | ErrorKind::TaskNotOwned - | ErrorKind::TaskConsumed - | ErrorKind::TasksLive - | ErrorKind::Cancelled - | ErrorKind::Store - | ErrorKind::Internal => Error::Raised(raised), - }, - })) + kept_failure(stashed, mapped) + } + + /// Takes the guard's stash for a block's failure and maps the failure, + /// its raise-point traceback restored, to its prompt line. + fn mapped_failure( + &self, + program: &LuaProgram, + error: &mlua::Error, + ) -> Result<(StashedFailure, Error)> { + let stashed = take_failure(&self.lua)?; + let mapped = self.map_chunk_failure(program, &stashed.restore_traceback(error)); + Ok((stashed, mapped)) } /// Maps one chunk's Lua failure to its typed outcome: a chunk the - /// instruction hook aborted under the run's cancel flag is - /// [`Error::Interrupted`], whatever the raw error says; everything + /// instruction hook aborted under the run's cancel flag is an + /// `Interrupted` failure, whatever the raw error says; everything /// else maps through `LuaProgram::map_runtime_error_among` of `program` - /// and of every library block `require` loaded into this VM. + /// and of every library block `require` loaded into this VM, in the + /// VM's prompt file. pub(crate) fn map_chunk_failure(&self, program: &LuaProgram, error: &mlua::Error) -> Error { if self.cancel_hook.is_cancelled() { - return Error::Interrupted; + return interrupted(); } match self.library_programs.lock() { - Ok(libraries) => program.map_runtime_error_among(error, &libraries), - Err(_) => Error::internal("require's loaded library programs were poisoned"), + Ok(libraries) => { + program.map_runtime_error_among(error, &libraries, &prompt_file(&self.lua)) + } + Err(_) => error::internal("require's loaded library programs were poisoned"), } } } @@ -244,6 +244,30 @@ impl SectionVm { /// The failure of a poisoned jump slot. const JUMP_SLOT_POISONED: &str = "jump slot poisoned"; +/// Whether `mapped` is a cancellation or an exhausted Engine quota, which +/// keep their mapping whatever the block raised. +fn is_fatal(mapped: &Error) -> bool { + matches!(mapped.code, ErrorCode::Interrupted | ErrorCode::LuaQuota) +} + +/// The failure a block keeps, given what the guard `stashed` and the +/// failure `mapped` to its prompt line: the mapping for a cancellation, an +/// Engine quota, a raise that is no error table, or a `lua`-kind table, +/// whose mapped runtime error keeps the same message with its source and +/// the mapped prompt line; any other structured table as the universal +/// error it stands for. A raise that is no error table keeps its mapping, +/// so an uncaught direct `compactors.fail` call is its context +/// exhaustion. +fn kept_failure(stashed: StashedFailure, mapped: Error) -> Error { + if is_fatal(&mapped) { + return mapped; + } + match stashed.raised { + Some(raised) if error_kind(&raised) != ErrorKind::Lua => raised, + _ => mapped, + } +} + /// Renders a returned Lua scalar as the section's result string. Tables and other /// non-scalar returns are errors. fn value_to_string(value: &Value) -> Result { @@ -252,7 +276,7 @@ fn value_to_string(value: &Value) -> Result { Value::Integer(i) => Ok(i.to_string()), Value::Number(n) => Ok(n.to_string()), Value::Boolean(b) => Ok(b.to_string()), - other => Err(Error::Lua(format!( + other => Err(lua_runtime(format!( "cannot return a {} as a result", other.type_name() ))), @@ -264,7 +288,8 @@ fn value_to_string(value: &Value) -> Result { /// Later values are ignored. A missing or nil first value returns `None`. /// /// # Errors -/// Returns [`Error::Lua`] when the first value is not a supported scalar. +/// Returns a `LuaRuntime` failure when the first value is not a supported +/// scalar. pub(crate) fn scalar_return(returned: MultiValue) -> Result> { match returned.into_iter().next() { None | Some(Value::Nil) => Ok(None), diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 0c74bc323..7100ff9a7 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -12,7 +12,7 @@ use serde_json::Value as Json; use super::{AdhocTools, SectionVm}; use crate::bindings::{ToolBinding, ToolSet}; -use crate::error::{Error, Result}; +use crate::error::{self, Result, lua_runtime}; use crate::packs::is_adhoc; use crate::scope::{OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, ToolCallCounts}; use crate::tools::install_tool_call_counts as install_tool_call_counts_impl; @@ -23,8 +23,8 @@ impl SectionVm { /// table behind the guarded proxy (not the proxy, which stays empty). /// /// # Errors - /// Returns [`Error::Lua`] if the author reassigned the `var` global, or - /// [`Error::LuaRuntime`] if `var` cannot be represented as JSON. + /// Returns a `LuaRuntime` failure if the author reassigned the `var` + /// global, or if `var` cannot be represented as JSON. pub fn var(&self) -> Result { var_to_json(&self.lua) } @@ -35,18 +35,17 @@ impl SectionVm { /// read-back path. /// /// # Errors - /// Returns [`Error::Lua`] when H1 left `argv` as a function, userdata, - /// or thread, and [`Error::LuaRuntime`] when its value cannot be - /// represented as JSON. + /// Returns a `LuaRuntime` failure when H1 left `argv` as a function, + /// userdata, or thread, or when its value cannot be represented as + /// JSON. pub fn argv_json(&self) -> Result> { - let value: Value = self.lua.globals().get("argv").map_err(Error::lua)?; + let value: Value = self.lua.globals().get("argv").map_err(error::lua)?; match value { Value::Nil => Ok(None), - Value::Function(_) | Value::UserData(_) | Value::Thread(_) => Err(Error::Lua(format!( - "argv must be JSON data, got {}", - value.type_name() - ))), - other => Ok(Some(self.lua.from_value(other).map_err(Error::lua)?)), + Value::Function(_) | Value::UserData(_) | Value::Thread(_) => Err(lua_runtime( + format!("argv must be JSON data, got {}", value.type_name()), + )), + other => Ok(Some(self.lua.from_value(other).map_err(error::lua)?)), } } @@ -57,11 +56,11 @@ impl SectionVm { /// is the same `LuaSerdeExt` bridge that seeds `var`. /// /// # Errors - /// Returns [`Error::LuaRuntime`] if the value cannot convert or the global + /// Returns a `LuaRuntime` failure if the value cannot convert or the global /// cannot be set. pub fn set_global_json(&self, name: &str, value: &Json) -> Result<()> { - let value = self.lua.to_value(value).map_err(Error::lua)?; - self.lua.globals().raw_set(name, value).map_err(Error::lua) + let value = self.lua.to_value(value).map_err(error::lua)?; + self.lua.globals().raw_set(name, value).map_err(error::lua) } /// Installs `tools.stats`, the read-only Lua view of a fresh @@ -75,7 +74,7 @@ impl SectionVm { /// it. /// /// # Errors - /// Returns [`Error::LuaRuntime`] when installing `tools.stats` fails. + /// Returns a `LuaRuntime` failure when installing `tools.stats` fails. pub fn install_tool_call_counts(&self, ids: &[ToolId]) -> Result { install_tool_call_counts_impl(&self.lua, &self.tool_set, &self.adhoc_tools, ids) } @@ -115,13 +114,13 @@ impl SectionVm { /// supplied no model. /// /// # Errors - /// Returns [`Error::Internal`] if the selection's mutex is poisoned. + /// Returns an `Internal` failure if the selection's mutex is poisoned. pub fn current_model(&self) -> Result> { self.state .selection .lock() .map(|selection| selection.clone()) - .map_err(|_| Error::internal("model selection lock was poisoned")) + .map_err(|_| error::internal("model selection lock was poisoned")) } } @@ -137,7 +136,7 @@ impl SectionVm { /// sets instead. /// /// # Errors -/// Returns [`Error::Internal`] if the offers' mutex or the adhoc +/// Returns an `Internal` failure if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. pub fn current_static_set( set: &ToolSet, @@ -158,7 +157,7 @@ pub fn current_static_set( /// holds its id. /// /// # Errors -/// Returns [`Error::Internal`] if the offers' mutex or the adhoc +/// Returns an `Internal` failure if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. pub fn current_dynamic_set( set: &ToolSet, @@ -185,7 +184,7 @@ pub fn current_dynamic_set( /// section entry's render asks its Packs in. /// /// # Errors -/// Returns [`Error::Internal`] if the offers' mutex or the adhoc +/// Returns an `Internal` failure if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. pub fn current_catalog_packs( set: &ToolSet, @@ -216,7 +215,7 @@ fn expanded( ) -> Result> { let runtime = runtime .lock() - .map_err(|_| Error::internal("section offers lock was poisoned"))?; + .map_err(|_| error::internal("section offers lock was poisoned"))?; let mut tools = Vec::new(); let offers = runtime .offers @@ -232,7 +231,7 @@ fn expanded( }; tools.push( tool.ok_or_else(|| { - Error::internal(format!("offered tool {id} names no tool")) + error::internal(format!("offered tool {id} names no tool")) })?, ); } diff --git a/crates/runvil-internal/model-client/Cargo.toml b/crates/runvil-internal/model-client/Cargo.toml index c3d397644..68dab8641 100644 --- a/crates/runvil-internal/model-client/Cargo.toml +++ b/crates/runvil-internal/model-client/Cargo.toml @@ -13,7 +13,6 @@ publish = false runvil-types.workspace = true serde.workspace = true serde_json.workspace = true -thiserror.workspace = true [build-dependencies] build-ceiling.workspace = true 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 d135bce2a..9a50dd566 100644 --- a/crates/runvil-internal/model-client/src/detail.rs +++ b/crates/runvil-internal/model-client/src/detail.rs @@ -6,12 +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. /// @@ -58,25 +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(); if !is_alias(&name) { - return Err(ToolSchemaError::InvalidName { - name, - reason: "expected [A-Za-z][A-Za-z0-9_-]{0,63}", - }); + 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, @@ -85,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/Cargo.toml b/crates/runvil-internal/prompt/Cargo.toml index 6344209f9..ca624d9c9 100644 --- a/crates/runvil-internal/prompt/Cargo.toml +++ b/crates/runvil-internal/prompt/Cargo.toml @@ -13,7 +13,6 @@ runvil-types.workspace = true pulldown-cmark.workspace = true serde.workspace = true serde_yaml_ng.workspace = true -thiserror.workspace = true [dev-dependencies] mlua.workspace = true diff --git a/crates/runvil-internal/prompt/src/contract.rs b/crates/runvil-internal/prompt/src/contract.rs index 93b08e6b4..80f94600e 100644 --- a/crates/runvil-internal/prompt/src/contract.rs +++ b/crates/runvil-internal/prompt/src/contract.rs @@ -20,10 +20,12 @@ use std::marker::PhantomData; use runvil_types::models::ModelRole; use runvil_types::names::is_alias; -use runvil_types::packs::{PackId, PackRef}; +use runvil_types::packs::PackRef; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; +use crate::error; + mod args; mod models; @@ -132,28 +134,22 @@ where } } -/// A refused `uses:` list. -#[derive(Debug, thiserror::Error)] -pub(crate) enum PackListError { - /// The list names this Pack more than once. - #[error("invalid frontmatter: Pack id {0} is declared more than once under uses")] - Duplicate(PackId), - /// The list names this reserved Pack id, from any vendor. - #[error( - "invalid frontmatter: Pack id {0} is reserved by the Engine and is never declared \ - under uses" - )] - Reserved(PackId), -} - /// Refuses a `uses:` list that names one Pack id twice, whatever the /// vendors, because a run holds one Pack per Pack id. /// -/// The refusal names the first Pack id declared again. -pub(crate) fn check_distinct_packs(packs: &[PackRef]) -> Result<(), PackListError> { +/// The refusal names the first Pack id declared again, with the `Reason` +/// fact `pack_duplicate`. +pub(crate) fn check_distinct_packs(packs: &[PackRef]) -> crate::Result<()> { let mut seen = BTreeSet::new(); match packs.iter().find(|pack| !seen.insert(pack.id())) { - Some(pack) => Err(PackListError::Duplicate(pack.id().clone())), + Some(pack) => Err(error::pack_refusal( + "pack_duplicate", + pack.id(), + format!( + "invalid frontmatter: Pack id {} is declared more than once under uses", + pack.id() + ), + )), None => Ok(()), } } @@ -161,10 +157,19 @@ pub(crate) fn check_distinct_packs(packs: &[PackRef]) -> Result<(), PackListErro /// Refuses a `uses:` list that names a reserved Pack id, such as `adhoc`, /// whatever the vendor, because the Engine holds the tools under it. /// -/// The refusal names the first reserved Pack id declared. -pub(crate) fn check_no_reserved_pack(packs: &[PackRef]) -> Result<(), PackListError> { +/// The refusal names the first reserved Pack id declared, with the +/// `Reason` fact `pack_reserved`. +pub(crate) fn check_no_reserved_pack(packs: &[PackRef]) -> crate::Result<()> { match packs.iter().find(|pack| pack.id().is_reserved()) { - Some(pack) => Err(PackListError::Reserved(pack.id().clone())), + Some(pack) => Err(error::pack_refusal( + "pack_reserved", + pack.id(), + format!( + "invalid frontmatter: Pack id {} is reserved by the Engine and is never declared \ + under uses", + pack.id() + ), + )), None => Ok(()), } } diff --git a/crates/runvil-internal/prompt/src/contract/tests-packs.rs b/crates/runvil-internal/prompt/src/contract/tests-packs.rs index 5a386cb74..5069a0bce 100644 --- a/crates/runvil-internal/prompt/src/contract/tests-packs.rs +++ b/crates/runvil-internal/prompt/src/contract/tests-packs.rs @@ -31,7 +31,7 @@ fn an_entry_of_three_or_more_segments_is_refused_with_the_uses_entry_reason() { for entry in ["a/b/c", "acme/web/extra/more"] { assert_parse_error( &prompt(&format!("uses: [{entry}]"), &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, &format!( "invalid uses entry `{entry}`: a uses entry is a Pack id, such as `web`, \ or vendor/name, such as `acme/web`" @@ -45,7 +45,7 @@ fn an_entry_with_an_empty_segment_keeps_the_pack_id_reason() { for entry in ["web/", "runvil//web"] { assert_parse_error( &prompt(&format!("uses: [\"{entry}\"]"), &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, &format!("invalid uses entry `{entry}`: segments must not be empty"), ); } @@ -56,7 +56,7 @@ fn an_at_sign_in_an_entry_is_rejected_with_the_charset_reason() { // A Pack id carries no version, so `@` is a parse error. assert_parse_error( &prompt("uses: [web@1]", &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, "invalid uses entry `web@1`: segments may contain only lowercase ASCII letters, \ digits, '-', '_', '.'", ); @@ -67,8 +67,8 @@ fn an_entry_with_uppercase_is_rejected() { for entry in ["Web", "Acme/web"] { let error = parse_err(&prompt(&format!("uses: [{entry}]"), &[])); assert_eq!( - error.kind(), - ParseErrorKind::Frontmatter, + error.code, + ErrorCode::FrontmatterInvalid, "{entry}: {error}" ); } @@ -78,7 +78,7 @@ fn an_entry_with_uppercase_is_rejected() { fn an_entry_that_is_not_a_string_is_refused_naming_both_entry_forms() { assert_parse_error( &prompt("uses: [42]", &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, "expected a Pack id, such as `web`, or vendor/name, such as `acme/web`", ); } @@ -93,8 +93,8 @@ fn the_map_form_of_an_entry_is_refused_naming_both_entry_forms() { let src = format!("---\nname: x\ndescription: d\nuses:\n{entry}---\n\n# T\n\n## S\n\np\n"); let error = parse_err(&src); assert_eq!( - error.kind(), - ParseErrorKind::Frontmatter, + error.code, + ErrorCode::FrontmatterInvalid, "{entry}: {error}" ); assert!( @@ -104,8 +104,8 @@ fn the_map_form_of_an_entry_is_refused_naming_both_entry_forms() { "the refusal names both entry forms: {error}" ); assert_eq!( - error.line(), - NonZeroU32::new(5), + location(&error).map(|(_, line, ..)| line), + Some(5), "the entry's own line: {error}" ); } @@ -121,8 +121,8 @@ fn a_pack_id_declared_twice_is_refused() { ] { let error = parse_err(&prompt(&format!("uses: {entries}"), &[])); assert_eq!( - error.kind(), - ParseErrorKind::Frontmatter, + error.code, + ErrorCode::FrontmatterInvalid, "{entries}: {error}" ); assert_eq!( @@ -146,8 +146,8 @@ fn a_reserved_pack_id_is_refused_from_any_vendor_naming_the_first_one_declared() ] { let error = parse_err(&prompt(&format!("uses: {entries}"), &[])); assert_eq!( - error.kind(), - ParseErrorKind::Frontmatter, + error.code, + ErrorCode::FrontmatterInvalid, "{entries}: {error}" ); assert_eq!( @@ -183,7 +183,7 @@ fn entries_keep_their_declaration_order() { fn the_old_plugins_key_is_refused_as_an_unknown_field() { let old_key = "plugins"; let error = parse_err(&prompt(&format!("{old_key}: [web]"), &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{error}"); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{error}"); assert!( error.to_string().contains("unknown field `plugins`"), "the parser names the key it does not know: {error}" diff --git a/crates/runvil-internal/prompt/src/contract/tests.rs b/crates/runvil-internal/prompt/src/contract/tests.rs index 4da448975..19ef84dd6 100644 --- a/crates/runvil-internal/prompt/src/contract/tests.rs +++ b/crates/runvil-internal/prompt/src/contract/tests.rs @@ -3,11 +3,12 @@ use std::num::NonZeroU32; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::models::ModelRole; use super::{ArgType, ArgsDeclIter}; -use crate::ParseErrorKind; use crate::test_support::{assert_parse_error, parse_err, parse_ok, prompt}; +use crate::tests::location; #[path = "tests-packs.rs"] mod packs; @@ -91,7 +92,7 @@ fn an_unknown_key_inside_a_contract_entry_is_rejected() { "models: { strong: { tier: 1 } }", ] { let error = parse_err(&prompt(front, &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{error}"); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{error}"); } } @@ -132,7 +133,7 @@ fn an_arg_default_must_match_the_declared_type() { let front = format!("args: {{ flag: {{ type: {kind}, default: {default} }} }}"); assert_parse_error( &prompt(&front, &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, &format!( "the default has type `{actual}`, which does not match the declared type `{kind}`" ), @@ -156,7 +157,7 @@ fn an_arg_default_must_match_the_declared_type() { #[test] fn an_arg_name_must_match_the_alias_grammar() { let error = parse_err(&prompt("args: { '1bad': { type: string } }", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); } #[test] @@ -192,7 +193,7 @@ fn an_absent_args_key_marks_the_default_declaration() { #[test] fn an_unknown_arg_type_is_rejected() { let error = parse_err(&prompt("args: { flag: { type: text } }", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); } #[test] @@ -253,7 +254,7 @@ fn args_iterate_in_name_order_through_a_named_iterator() { fn an_unknown_role_name_is_a_parse_error_that_lists_the_five_roles() { assert_parse_error( &prompt("models: { reviewer: {} }", &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, "expected one of `default`, `strong`, `small`, `fast`, `creative`", ); } @@ -262,7 +263,7 @@ fn an_unknown_role_name_is_a_parse_error_that_lists_the_five_roles() { fn an_unknown_field_in_a_role_is_a_parse_error_that_names_the_two_fields() { assert_parse_error( &prompt("models: { strong: { tier: 1 } }", &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, "unknown field `tier`, expected `min_context` or `thinking`", ); } @@ -273,13 +274,13 @@ fn a_duplicate_role_key_is_rejected() { "models: { strong: { min_context: 1 }, strong: { thinking: true } }", &[], )); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{error}"); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{error}"); } #[test] fn a_zero_min_context_is_rejected() { let error = parse_err(&prompt("models: { strong: { min_context: 0 } }", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); } #[test] @@ -297,19 +298,14 @@ fn contract_errors_report_their_frontmatter_line_and_column() { "\n# T\n\n## S\n\np\n", ); let error = parse_err(src); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); assert_eq!( - error.line(), - NonZeroU32::new(5), - "the offending scalar's line: {error}" + location(&error), + Some(("prompt", 5, 5, 5, 5)), + "the offending scalar's line and column: {error}" ); assert_eq!( - error.column(), - NonZeroU32::new(5), - "the offending scalar's column: {error}" - ); - assert_eq!( - error.name(), + error.facts.get(&FactKey::Name), None, "a frontmatter failure predates the prompt's name" ); diff --git a/crates/runvil-internal/prompt/src/detail.rs b/crates/runvil-internal/prompt/src/detail.rs index 369bd6b24..95857a99a 100644 --- a/crates/runvil-internal/prompt/src/detail.rs +++ b/crates/runvil-internal/prompt/src/detail.rs @@ -4,38 +4,25 @@ //! The `runvil` facade never re-exports this module, so only Engine //! crates reach it. -use crate::{Block, Error, ParseError, ParseErrorKind, Prompt, Section}; +use runvil_types::error::{Error, ErrorCode}; -/// Unwraps a parse failure into the internal error it classifies, so the -/// Engine can map it onto its own internal error type variant for variant. -#[must_use] -pub fn parse_error_into_inner(error: ParseError) -> Error { - error.into_inner() -} - -/// Classifies an internal error back into the parse failure that wraps -/// it, the inverse of [`parse_error_into_inner`]. -#[must_use] -pub fn parse_error_from_inner(inner: Error) -> ParseError { - ParseError::from_inner(inner) -} +use crate::{Block, Prompt, Section}; -/// Builds a parse failure with a stable classification and no source span, -/// for a failure the Engine raises about a parsed prompt. +/// Builds a parse failure with no location, for a failure the Engine +/// raises about a parsed prompt. +/// +/// Its prose is the code's default until [`with_prompt_context`] writes it. #[must_use] -pub fn parse_error(kind: ParseErrorKind, message: impl Into) -> ParseError { - ParseError::from_inner(Error::parse(kind, message)) +pub fn parse_error(code: ErrorCode, message: impl Into) -> Error { + crate::error::failure(code, message) } -/// Stamps the prompt's frontmatter name onto a spanless structured parse -/// failure. A failure with a span passes through unchanged, because -/// locating a span needs the prompt body. +/// Gives a parse failure the Engine raises about a parsed prompt the +/// prompt's frontmatter `name` as its `Name` fact, the prompt's `file` as +/// its location's file, and the prose a failure the parse returns has. #[must_use] -pub fn with_prompt_name(error: ParseError, name: &str) -> ParseError { - if error.span().is_some() { - return error; - } - ParseError::from_inner(error.into_inner().with_prompt_context(name, "", 0)) +pub fn with_prompt_context(error: Error, name: &str, file: &str) -> Error { + crate::error::with_prompt_context(error, Some(name), file) } /// Returns the ordered live Lua and prose blocks from the H1. diff --git a/crates/runvil-internal/prompt/src/error-tests.rs b/crates/runvil-internal/prompt/src/error-tests.rs index ee1ca2999..9997be44b 100644 --- a/crates/runvil-internal/prompt/src/error-tests.rs +++ b/crates/runvil-internal/prompt/src/error-tests.rs @@ -1,79 +1,230 @@ -//! Tests for the error classification and the cause-bearing error chains. +//! Tests for the failures a parse returns: each code's message, location, +//! facts, and prose. -use runvil_lua::Error as LuaError; +use std::collections::BTreeMap; +use std::num::NonZeroU32; -use super::{Error, ParseError, ParseErrorKind}; -use crate::test_support::{one_section, parse_err, prompt}; +use runvil_lua::LuaProgram; +use runvil_types::emitter::{Emitter, EventSink}; +use runvil_types::error::{Error, ErrorCode, FactKey}; -/// The display text of each segment of `error`'s source chain, the error -/// itself first. -fn chain(error: &(dyn std::error::Error + 'static)) -> Vec { - let mut segments = Vec::new(); - let mut next = Some(error); - while let Some(segment) = next { - segments.push(segment.to_string()); - next = segment.source(); - } - segments -} +use super::{internal, lua, with_prompt_context}; +use crate::tests::location; +use crate::{Frontmatter, Prompt}; + +/// The file label every source here is parsed from. +const FILE: &str = "agents/demo.md"; +/// The failure parsing `src` from [`FILE`] returns. #[track_caller] -fn assert_no_segment_repeats(error: &(dyn std::error::Error + 'static)) { - let segments = chain(error); - for (index, segment) in segments.iter().enumerate() { - for later in &segments[index + 1..] { - assert!( - !segment.contains(later.as_str()), - "required each chain segment once, actual {later:?} repeated inside {segment:?}" - ); - } +fn failure_of(src: &str) -> Error { + match Prompt::parse(src, FILE, "test").0 { + Ok(prompt) => panic!("expected a failure, parsed `{}`", prompt.title()), + Err(error) => error, } } +/// The prompt `x` with the section `S` holding `content`, which starts on +/// file line 10. +fn in_section(content: &str) -> String { + format!("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n{content}") +} + +/// The prose of a failure with `code` and `message` located at `line`. +fn prose_at(code: ErrorCode, line: u32, message: &str) -> String { + format!("{FILE}:{line}: {message}. {}", code.prose()) +} + +#[test] +fn a_yaml_failure_is_frontmatter_invalid_at_its_line_and_column() { + let yaml = "name: x\ndescription: d\nuses:\n - not a Pack id\n"; + let error = failure_of(&format!("---\n{yaml}---\n\n# T\n\n## S\n\np\n")); + let Err(cause) = serde_yaml_ng::from_str::(yaml) else { + panic!("the frontmatter must not decode"); + }; + let message = format!("invalid frontmatter: {cause}"); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); + assert_eq!(error.message, message); + assert_eq!(location(&error), Some((FILE, 5, 5, 5, 5))); + assert_eq!(error.facts, BTreeMap::new()); + assert_eq!( + error.prose, + prose_at(ErrorCode::FrontmatterInvalid, 5, &message) + ); +} + +#[test] +fn a_failure_with_no_line_names_only_the_file_in_its_prose() { + let error = failure_of("# T\n\n## S\n\np\n"); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); + assert_eq!( + error.message, + "file must begin with a --- frontmatter delimiter" + ); + assert_eq!(error.at, None); + assert_eq!(error.facts, BTreeMap::new()); + assert_eq!( + error.prose, + "agents/demo.md: file must begin with a --- frontmatter delimiter. The frontmatter is \ + invalid. Fix the YAML between the `---` lines so it matches the frontmatter schema." + ); +} + #[test] -fn no_segment_of_a_cause_bearing_parse_error_chain_repeats() { - for (src, kind) in [ - (prompt(": : :", &[]), ParseErrorKind::Frontmatter), - (one_section("x ="), ParseErrorKind::Lua), +fn a_refused_uses_list_names_its_reason_and_the_pack_id() { + for (entries, reason, pack, message) in [ + ( + "[web, acme/web]", + "pack_duplicate", + "web", + "invalid frontmatter: Pack id web is declared more than once under uses", + ), + ( + "[web, acme/adhoc]", + "pack_reserved", + "adhoc", + "invalid frontmatter: Pack id adhoc is reserved by the Engine and is never declared \ + under uses", + ), ] { - let error = parse_err(&src); - assert_eq!(error.kind(), kind, "{error}"); - assert!( - error.cause().is_some(), - "the cause stays reachable: {error:?}" + let error = failure_of(&format!( + "---\nname: x\ndescription: d\nuses: {entries}\n---\n\n# T\n\n## S\n\np\n" + )); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{entries}"); + assert_eq!(error.message, message); + assert_eq!(error.at, None, "{entries}"); + assert_eq!( + error.facts, + BTreeMap::from([ + (FactKey::Reason, reason.into()), + (FactKey::Name, pack.into()) + ]), + "the Pack id is the name, not the prompt's: {entries}" ); - assert!( - std::error::Error::source(&error).is_none(), - "required no source, since Display already holds the cause, actual a source on \ - {error:?}" + assert_eq!( + error.prose, + format!( + "{FILE}: {message}. {}", + ErrorCode::FrontmatterInvalid.prose() + ) ); - assert_no_segment_repeats(&error); + } +} + +#[test] +fn a_duplicate_sibling_is_structure_invalid_at_its_heading() { + let error = failure_of(&in_section("a\n\n## S\n\nb\n")); + let message = "duplicate sibling section name `S`: first declared at line 8, again at line \ + 12; sibling section names must be unique"; + assert_eq!(error.code, ErrorCode::StructureInvalid); + assert_eq!(error.message, message); + assert_eq!(location(&error), Some((FILE, 12, 1, 13, 1))); + assert_eq!(error.facts, BTreeMap::from([(FactKey::Name, "x".into())])); + assert_eq!( + error.prose, + prose_at(ErrorCode::StructureInvalid, 12, message) + ); +} - let inner = error.into_inner(); - assert!(inner.cause().is_some(), "{inner:?}"); - assert!(std::error::Error::source(&inner).is_none(), "{inner:?}"); - assert_no_segment_repeats(&inner); +#[test] +fn a_fence_violation_names_the_rule_it_breaks() { + let opener = "`lua` fence opening line must be exactly ```lua"; + let label = "unrecognized fence label `Lua`; the only Lua fence label is `lua`"; + for (content, rule, message, line, end_column) in [ + ("```Lua\nreturn 1\n```\n", "label", label, 10, 7), + ("~~~lua\nreturn 1\n~~~\n", "opener", opener, 10, 7), + ( + "```lua\nvar.a = 1\n ```\n", + "closer", + "`lua` fence closing line must be exactly ```", + 12, + 6, + ), + ( + "Prose.\n\n ```lua\n return 1\n ```\n", + "lookalike", + opener, + 12, + 11, + ), + ] { + let error = failure_of(&in_section(content)); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{rule}"); + assert_eq!(error.message, message, "{rule}"); + assert_eq!( + location(&error), + Some((FILE, line, 1, line, end_column)), + "{rule}" + ); + assert_eq!( + error.facts, + BTreeMap::from([(FactKey::Name, "x".into()), (FactKey::Reason, rule.into())]) + ); + assert_eq!( + error.prose, + prose_at(ErrorCode::FenceInvalid, line, message) + ); } } #[test] -fn a_broken_parser_invariant_classifies_as_internal() { - let error = ParseError::from_inner(Error::Internal("parser: a test invariant broke")); - assert_eq!(error.kind(), ParseErrorKind::Internal); +fn an_empty_list_item_is_list_empty_item_with_no_line() { + let error = failure_of(&in_section("- alpha\n1.\n- beta\n")); + let message = "empty bullet item in list section `S`"; + assert_eq!(error.code, ErrorCode::ListEmptyItem); + assert_eq!(error.message, message); + assert_eq!(error.at, None); + assert_eq!(error.facts, BTreeMap::from([(FactKey::Name, "x".into())])); + assert_eq!( + error.prose, + format!("{FILE}: {message}. {}", ErrorCode::ListEmptyItem.prose()) + ); +} + +#[test] +fn a_lua_compile_failure_is_located_at_the_line_its_diagnostic_names() { + // The block's source starts on file line 11, and the compiler reports + // its second line, file line 12. + let source = "local a = 1\nlocal b ="; + let error = failure_of(&in_section(&format!("```lua\n{source}\n```\n"))); + let emitter = Emitter::root(EventSink::default(), "test", false); + let start = NonZeroU32::new(11).expect("11 is non-zero"); + let Err(compiled) = + LuaProgram::compile(source, FILE, "section `S` prologue", start, &emitter, "S") + else { + panic!("the block must not compile"); + }; + let message = compiled.to_string(); + assert!( + message.contains("[string \"line 11\"]:2:"), + "the diagnostic names the chunk line: {message}" + ); + assert_eq!(error.code, ErrorCode::LuaCompile); + assert_eq!(error.message, message); + assert_eq!(location(&error), Some((FILE, 12, 1, 13, 1))); + assert_eq!(error.facts, BTreeMap::from([(FactKey::Name, "x".into())])); + assert_eq!(error.prose, prose_at(ErrorCode::LuaCompile, 12, &message)); +} + +#[test] +fn an_internal_failure_has_the_default_prose_and_no_name() { + let error = with_prompt_context(internal("parser: a test invariant broke"), Some("x"), FILE); + assert_eq!(error.code, ErrorCode::Internal); assert_eq!( - error.to_string(), + error.message, "internal invariant violated: parser: a test invariant broke" ); - assert_eq!(error.span(), None); - assert_eq!(error.line(), None); + assert_eq!(error.at, None); + assert_eq!(error.facts, BTreeMap::new()); + assert_eq!(error.prose, ErrorCode::Internal.prose()); } #[test] -fn a_lua_compiler_that_cannot_start_classifies_as_internal() { - let vm_failure = mlua::Error::MemoryError("not enough memory".to_owned()); - let error = ParseError::from_inner(Error::Lua(LuaError::LuaRuntime { - message: vm_failure.to_string(), - cause: Box::new(vm_failure), - })); - assert_eq!(error.kind(), ParseErrorKind::Internal); +fn a_lua_compiler_that_cannot_start_is_internal() { + let message = mlua::Error::MemoryError("not enough memory".to_owned()).to_string(); + let code = ErrorCode::Internal; + let error = lua(&Error::new(code, message.clone(), code.prose())); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!(error.message, message); + assert_eq!(error.at, None); } diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index 958907185..1230270ed 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -1,371 +1,176 @@ -//! Defines the parser's internal error type and its public classification. +//! Builds the parser's failures as the universal [`Error`]. //! -//! [`Error`] is the internal error type every parsing module returns -//! through [`Result`]. [`ParseError`] is the public wrapper returned -//! by [`Prompt::parse`](crate::Prompt::parse): it classifies the internal -//! type into a stable [`ParseErrorKind`] and surfaces the failure's -//! location fields. -//! -//! A variant that keeps an originating error renders that error's text in -//! its own `Display` and holds the error as a plain `cause` field behind -//! [`Error::cause`], never as a `#[source]`, so a printer that walks the -//! source chain shows the cause's text once. +//! Every parsing module returns its failure through [`Result`], built by +//! the constructors here: the code names what failed and the message is +//! the full diagnostic. [`with_prompt_context`] runs once on every failure +//! [`Prompt::parse`](crate::Prompt::parse) returns: it names the file the +//! failure is in, the prompt a failure in the body belongs to, and writes +//! the prose. use std::num::NonZeroU32; use std::ops::Range; -/// A type-erased owned error cause used by the internal error type. -type BoxedSource = Box; +use runvil_types::error::{Error, ErrorCode, FactKey, Location}; +use runvil_types::packs::PackId; -/// The parser's internal error type, classified into [`ParseError`] at -/// the public boundary. -/// -/// Public only so `runvil-engine` can convert it back onto its own -/// internal type variant-for-variant; the facade does not re-export it. -#[derive(Debug, thiserror::Error)] -pub enum Error { - /// 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 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, counted in characters - /// as the YAML parser counts them, when known. - column: Option, - }, +/// The parser's result type over the universal [`Error`]. +pub(crate) type Result = std::result::Result; - /// A structurally-classified parse failure with a stable kind and an - /// optional source byte span. - /// - /// [`ParseError`] exposes the classification and location from these - /// stored fields instead of inferring them from message text. - #[error("{message}")] - ParseStructured { - /// The stable classification of this parse failure. - kind: ParseErrorKind, - /// The byte span of the offending region within the body (after the - /// frontmatter and a leading BOM, CRLF normalized to LF), when known. - span: Option>, - /// The human-readable diagnostic. - message: String, - /// 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. - line: Option, - /// The 1-based byte column of the span's start, when a span is known. - column: Option, - }, +/// Builds a failure with no location, whose prose is the code's default +/// until [`with_prompt_context`] writes it. +pub(crate) fn failure(code: ErrorCode, message: impl Into) -> Error { + Error::new(code, message, code.prose()) +} - /// 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), +/// Builds a failure at `span`, the byte range of the offending region +/// within `body` (after the frontmatter and a leading BOM, CRLF normalized +/// to LF), which starts in the file after `frontmatter_lines` lines. +/// +/// The location runs from the span's start to its end, each a 1-based file +/// line and byte column. It is `None` when either end is out of bounds or +/// its arithmetic overflows - an invariant break that must not replace the +/// original failure. +pub(crate) fn failure_at( + code: ErrorCode, + message: impl Into, + body: &str, + span: Range, + frontmatter_lines: u32, +) -> Error { + let mut error = failure(code, message); + error.at = span_location(body, span, frontmatter_lines); + error +} - /// An internal parser invariant was violated (a state the surrounding code - /// has already guaranteed cannot occur). - #[error("internal invariant violated: {0}")] - Internal(&'static str), +/// Builds the failure for a broken parser invariant: a state the +/// surrounding code has already guaranteed cannot occur. +pub(crate) fn internal(message: &str) -> Error { + failure( + ErrorCode::Internal, + format!("internal invariant violated: {message}"), + ) } -/// The parser's internal result type over [`Error`]. -pub(crate) type Result = std::result::Result; +/// Builds the frontmatter failure for a refused `uses:` list: `reason` is +/// its `Reason` fact and the refused Pack id its `Name` fact. +pub(crate) fn pack_refusal(reason: &str, pack: &PackId, message: String) -> Error { + let mut error = failure(ErrorCode::FrontmatterInvalid, message); + error.facts.insert(FactKey::Reason, reason.into()); + error.facts.insert(FactKey::Name, pack.to_string().into()); + error +} -impl Error { - /// Builds a parse failure with a stable classification and no source span. - pub(crate) fn parse(kind: ParseErrorKind, message: impl Into) -> Error { - Error::ParseStructured { - kind, - span: None, - message: message.into(), - name: None, - line: None, - column: None, +/// Builds the failure for frontmatter that did not decode: it was not +/// valid YAML, or a key or value broke the frontmatter schema. +/// +/// The location is the file position the YAML parser reported: its line +/// and column when it reported both, or the whole line when the column is +/// unknown. +pub(crate) fn frontmatter_decode(cause: &serde_yaml_ng::Error) -> Error { + let mut error = failure( + ErrorCode::FrontmatterInvalid, + format!("invalid frontmatter: {cause}"), + ); + error.at = cause.location().and_then(|location| { + // The location is relative to the frontmatter block, which starts + // on file line 2 (line 1 is the opening `---` delimiter). + let line = u32::try_from(location.line()) + .ok() + .and_then(|line| line.checked_add(1)) + .and_then(NonZeroU32::new)?; + match u32::try_from(location.column()) + .ok() + .and_then(NonZeroU32::new) + { + Some(column) => Location::new(String::new(), line, column, line, column), + None => Some(Location::line(String::new(), line)), } - } + }); + error +} - /// Builds a parse failure with a stable classification and the byte span - /// of the offending region within the body. - /// - /// [`with_prompt_context`](Error::with_prompt_context) locates the span. - pub(crate) fn parse_at( - kind: ParseErrorKind, - span: Range, - message: impl Into, - ) -> Error { - Error::ParseStructured { - kind, - span: Some(span), - message: message.into(), - name: None, - line: None, - column: None, - } - } +/// Builds the failure for a Lua block that did not compile, or for a +/// compiler that could not start, which is internal: the compiler's code +/// and message. +/// +/// A compile failure is located on the file line its diagnostic names, as +/// the compiler located it. +pub(crate) fn lua(cause: &Error) -> Error { + let mut error = failure(cause.code, cause.message.clone()); + error.at.clone_from(&cause.at); + error +} - /// Returns the originating error a cause-bearing failure keeps. - /// - /// The failure's `Display` already holds the cause's text. Every other - /// failure returns `None`. - #[must_use] - pub(crate) fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { - match self { - Error::ParseFrontmatter { cause, .. } => Some(cause.as_ref()), - Error::Lua(lua) => lua.cause(), - Error::ParseStructured { .. } | Error::Internal(_) => None, - } +/// Gives a failure the parse returns its file, its prompt name, and its +/// prose; the Engine reaches it through [`crate::detail::with_prompt_context`] +/// for a failure it raises about a parsed prompt. +/// +/// `file` becomes the file of the failure's location. `name`, the prompt's +/// frontmatter name, is `None` for a failure found before the name is +/// known; otherwise it becomes the `Name` fact of every failure but an +/// internal one. The prose of an internal failure is the code's default. +/// Every other prose names the file, the start line when the failure has +/// a location, the message, and the default: +/// `{file}:{line}: {message}. {default}`, or without a location +/// `{file}: {message}. {default}`. +pub(crate) fn with_prompt_context(mut error: Error, name: Option<&str>, file: &str) -> Error { + let code = error.code; + if let Some(name) = name + && code != ErrorCode::Internal + { + error.facts.insert(FactKey::Name, name.into()); } - - /// Stamps a structured parse failure with the prompt's frontmatter name - /// and its span's position. - /// - /// 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 failure reports its own - /// position, if it has one, and an internal failure has none. - pub(crate) fn with_prompt_context( - self, - name: &str, - body: &str, - frontmatter_lines: u32, - ) -> Error { - match self { - Error::ParseStructured { - kind, - span, - message, - .. - } => { - let (line, column) = match &span { - Some(span) => body_line_column(body, span.start, frontmatter_lines), - None => (None, None), - }; - Error::ParseStructured { - kind, - span, - message, - name: Some(name.to_owned()), - line, - column, - } - } - other @ (Error::ParseFrontmatter { .. } | Error::Lua(_) | Error::Internal(_)) => other, - } + if let Some(at) = &mut error.at { + file.clone_into(&mut at.file); } + error.prose = match (code, &error.at) { + (ErrorCode::Internal, _) => code.prose().to_owned(), + (_, Some(at)) => format!( + "{file}:{}: {}. {}", + at.start_line, + error.message, + code.prose() + ), + (_, None) => format!("{file}: {}. {}", error.message, code.prose()), + }; + error +} + +/// Returns the location of `span` within `body`, offset past the +/// frontmatter lines, or `None` when either end cannot be located. +fn span_location(body: &str, span: Range, frontmatter_lines: u32) -> Option { + let (start_line, start_column) = body_line_column(body, span.start, frontmatter_lines)?; + let (end_line, end_column) = body_line_column(body, span.end, frontmatter_lines)?; + Location::new( + String::new(), + start_line, + start_column, + end_line, + end_column, + ) } /// 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, and either is `None` -/// alone when its own arithmetic overflows - an invariant break that must not -/// replace the original parse failure. +/// `None` when the offset is out of bounds or either value's arithmetic +/// overflows. fn body_line_column( body: &str, byte_offset: usize, frontmatter_lines: u32, -) -> (Option, Option) { - let Some(prefix) = body.get(..byte_offset) else { - return (None, None); - }; +) -> Option<(NonZeroU32, NonZeroU32)> { + let prefix = body.get(..byte_offset)?; let line = u32::try_from(prefix.matches('\n').count()) .ok() .and_then(|newlines| NonZeroU32::MIN.checked_add(newlines)) - .and_then(|line| line.checked_add(frontmatter_lines)); + .and_then(|line| line.checked_add(frontmatter_lines))?; let column = u32::try_from(prefix.len() - prefix.rfind('\n').map_or(0, |index| index + 1)) .ok() - .and_then(|offset| NonZeroU32::MIN.checked_add(offset)); - (line, column) -} - -/// A stable, matchable classification of a [`ParseError`]. -/// -/// Adding a new kind is a compatible change, so a `match` on this type -/// needs a wildcard arm. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ParseErrorKind { - /// The YAML frontmatter block was missing, left open, or invalid. - Frontmatter, - /// The document structure was invalid, for example a missing or duplicate - /// H1 title, or two sibling sections with the same name. - Structure, - /// A reserved `lua` code fence was left open, or a fence or line broke the - /// strict Lua fence rule. - /// - /// The rule breaks are: a fence's label started with `lua` in any letter - /// case without being exactly `lua` (Label), a `lua` fence's opening line - /// was not exactly three backticks then `lua` (Opener), its closing line - /// was not exactly three backticks (Closer), or a line outside every - /// fence looked like a `lua` opening line (Lookalike). Lines inside the - /// body of another fenced code block are never inspected. - Fence, - /// A list section had an empty item. - List, - /// A Lua region failed to compile because of a syntax error. - Lua, - /// The parser failed through no fault of the prompt: one of its own - /// invariants broke, or the Lua compiler could not start. - Internal, -} - -/// The error returned by [`Prompt::parse`](crate::Prompt::parse). -/// -/// It holds a stable [`kind`](ParseError::kind) and, when known, where the -/// failure happened. It keeps the originating error, when there is one, -/// which [`cause`](ParseError::cause) returns. -#[derive(Debug)] -pub struct ParseError { - inner: Box, + .and_then(|offset| NonZeroU32::MIN.checked_add(offset))?; + Some((line, column)) } -/// The classified parts of an internal error: the stable kind plus the -/// location fields the internal error holds. -/// -/// The location fields are the source span and the 1-based line/column - -/// surfaced from the retained YAML failure, or computed from the span. -struct Classification { - kind: ParseErrorKind, - span: Option>, - line: Option, - column: Option, -} - -/// Classifies an internal error into its stable kind and location fields. -fn classify_parse_error(inner: &Error) -> Classification { - let unlocated = |kind| Classification { - kind, - span: None, - line: None, - column: None, - }; - match inner { - Error::ParseStructured { - kind, - span, - line, - column, - .. - } => Classification { - kind: *kind, - span: span.clone(), - line: *line, - column: *column, - }, - Error::ParseFrontmatter { line, column, .. } => Classification { - line: *line, - column: *column, - ..unlocated(ParseErrorKind::Frontmatter) - }, - Error::Lua(lua) => unlocated(match lua { - runvil_lua::Error::LuaCompile { .. } => ParseErrorKind::Lua, - // Compiling a block fails otherwise only when the compiler VM - // cannot start. - _ => ParseErrorKind::Internal, - }), - Error::Internal(_) => unlocated(ParseErrorKind::Internal), - } -} - -impl ParseError { - /// Returns the stable classification of this failure. - #[must_use] - pub fn kind(&self) -> ParseErrorKind { - classify_parse_error(&self.inner).kind - } - - /// Returns the byte span of the offending region, when one is available. - /// - /// A failure that can locate the offending region, such as a duplicate - /// sibling section, has a byte span. Other failures return `None`. The - /// offsets are relative to the document body after the frontmatter and a - /// leading BOM, with CRLF normalized to LF. To locate the failure in the - /// original file, use [`line`](ParseError::line) and - /// [`column`](ParseError::column). - #[must_use] - pub fn span(&self) -> Option> { - classify_parse_error(&self.inner).span - } - - /// Returns the prompt's frontmatter name for a `Structure`, `Fence`, or - /// `List` failure found past the frontmatter. - /// - /// 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 { - Error::ParseStructured { name, .. } => name.as_deref(), - _ => None, - } - } - - /// Returns the 1-based file line of the failure, when known. - /// - /// For a frontmatter YAML failure, this is the line the YAML parser - /// reported. For a failure with a byte span, it is the line where the - /// span starts. - #[must_use] - pub fn line(&self) -> Option { - classify_parse_error(&self.inner).line - } - - /// 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 - } - - /// Returns the originating error this failure keeps, when it has one. - /// - /// A frontmatter failure keeps the YAML decode failure, and a Lua compile - /// failure keeps the compiler's own error. The failure's message already - /// holds the cause's text, so [`std::error::Error::source`] returns - /// `None` and a printer that walks the source chain shows the text once. - #[must_use] - pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { - self.inner.cause() - } - - /// Unwraps the internal error; the Engine reaches it through - /// [`crate::detail::parse_error_into_inner`]. - #[must_use] - pub(crate) fn into_inner(self) -> Error { - *self.inner - } - - /// Wraps the internal error in its public form. - pub(crate) fn from_inner(inner: Error) -> ParseError { - ParseError { - inner: Box::new(inner), - } - } -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.inner) - } -} - -impl std::error::Error for ParseError {} - #[cfg(test)] #[path = "error-tests.rs"] mod tests; diff --git a/crates/runvil-internal/prompt/src/fence.rs b/crates/runvil-internal/prompt/src/fence.rs index dd4d49a1d..aa7b1d402 100644 --- a/crates/runvil-internal/prompt/src/fence.rs +++ b/crates/runvil-internal/prompt/src/fence.rs @@ -10,8 +10,13 @@ use std::ops::Range; use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; +use runvil_types::error::{Error, ErrorCode, FactKey}; -use crate::{Error, ParseErrorKind, Result, position}; +use crate::{Result, error, position}; + +/// The message for a `lua` fence whose opening line breaks the Opener rule, +/// and for a Lookalike line. +const OPENER_MESSAGE: &str = "`lua` fence opening line must be exactly ```lua"; /// Uncompiled block produced while scanning section content. pub(crate) enum RawBlock<'a> { @@ -61,7 +66,7 @@ fn extract_exact_fence<'a>(content: &'a str, location: &str) -> Result<(String, let text = text.strip_suffix('\r').unwrap_or(text); if closes_three_backtick_fence(text) { if text != "```" { - return Err(Error::Internal( + return Err(error::internal( "parser: fence split met a closing line the validator refuses", )); } @@ -70,8 +75,8 @@ fn extract_exact_fence<'a>(content: &'a str, location: &str) -> Result<(String, } offset += line.len(); } - Err(Error::parse( - ParseErrorKind::Fence, + Err(error::failure( + ErrorCode::FenceInvalid, format!("{location} fence is not closed"), )) } @@ -114,21 +119,9 @@ fn exact_lua_openings(content: &str) -> Vec { .collect() } -/// The rule a fence or line breaks, as found by [`first_fence_violation`]. -pub(crate) enum FenceViolation { - /// Label: the fence's label starts with `lua` in any letter case without - /// being exactly `lua`. - Label(String), - /// Opener: a fence labeled exactly `lua` whose opening line is not exactly - /// three backticks then `lua`, or a Lookalike line. - Opener, - /// Closer: a fence whose opening line is exact and whose closing line is - /// not exactly three backticks. - Closer, -} - -/// Returns the line's byte range, without its terminator, and the rule -/// broken by the first violation in `body` in source order. +/// Returns the first violation in `body` in source order, as a fence +/// failure located at its line without the terminator, for a body that +/// starts in the file after `frontmatter_lines` lines. /// /// Each fence the Markdown parser reports is checked against the Label rule, /// then the Opener rule, then the Closer rule. A fence the parser leaves @@ -136,10 +129,12 @@ pub(crate) enum FenceViolation { /// other line is checked against the Lookalike rule: after leading spaces and /// tabs, three or more backticks or tildes, optional spaces or tabs, then /// `lua` in any letter case. Such a line sits where the parser reports no -/// 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(crate) fn first_fence_violation(body: &str) -> Option<(Range, FenceViolation)> { +/// fence, such as in an indented code block or a raw HTML block, and fails +/// with the Opener rule's message. Lines inside a fence's extent are never +/// checked against the Lookalike rule, so a fence nested in another fence's +/// body never fails. The failure's `Reason` fact names the rule: `label`, +/// `opener`, `closer`, or `lookalike`. +pub(crate) fn first_fence_violation(body: &str, frontmatter_lines: u32) -> Option { let mut extents = Vec::new(); let mut fence = None; for (extent, label) in fence_openings(body) { @@ -156,29 +151,46 @@ pub(crate) fn first_fence_violation(body: &str) -> Option<(Range, FenceVi .filter(|start| !extents.iter().any(|extent| extent.contains(start))) .map(|start| line_at(body, start)) .find(|line| is_lua_lookalike(&body[line.clone()])) - .map(|line| (line, FenceViolation::Opener)); - [fence, lookalike] + .map(|line| (line, "lookalike", OPENER_MESSAGE.to_owned())); + let (line, rule, message) = [fence, lookalike] .into_iter() .flatten() - .min_by_key(|(line, _)| line.start) + .min_by_key(|(line, ..)| line.start)?; + let mut violation = error::failure_at( + ErrorCode::FenceInvalid, + message, + body, + line, + frontmatter_lines, + ); + violation.facts.insert(FactKey::Reason, rule.into()); + Some(violation) } /// Checks one reported fence against the Label, Opener, and Closer rules, in -/// that order. +/// that order, and returns the offending line's byte range, without its +/// terminator, the rule it breaks, and the message. fn fence_violation( body: &str, extent: Range, label: &str, -) -> Option<(Range, FenceViolation)> { +) -> Option<(Range, &'static str, String)> { let opening = line_at(body, extent.start); if label != "lua" { - return is_lua_like(label).then(|| (opening, FenceViolation::Label(label.to_owned()))); + return is_lua_like(label).then(|| { + let message = + format!("unrecognized fence label `{label}`; the only Lua fence label is `lua`"); + (opening, "label", message) + }); } if &body[opening.clone()] != "```lua" { - return Some((opening, FenceViolation::Opener)); + return Some((opening, "opener", OPENER_MESSAGE.to_owned())); } let closing = closing_line(body, extent)?; - (&body[closing.clone()] != "```").then_some((closing, FenceViolation::Closer)) + (&body[closing.clone()] != "```").then(|| { + let message = "`lua` fence closing line must be exactly ```".to_owned(); + (closing, "closer", message) + }) } /// Byte range of the line starting at `start`, without its terminator. @@ -277,8 +289,8 @@ fn pending_prose<'a>(content: &'a str, segment: Range, rules: &[Range( content: &'a str, @@ -302,7 +314,7 @@ pub(crate) fn split_section_blocks<'a>( strip_exact_lua_opening(&content[opening..]) }; let Some(after_open) = after_open else { - return Err(Error::Internal( + return Err(error::internal( "parser: section fence split disagrees with the Markdown parser", )); }; diff --git a/crates/runvil-internal/prompt/src/frontmatter.rs b/crates/runvil-internal/prompt/src/frontmatter.rs index 34ba41bab..96ac3ce1c 100644 --- a/crates/runvil-internal/prompt/src/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/frontmatter.rs @@ -2,11 +2,12 @@ use std::num::NonZeroU32; +use runvil_types::error::ErrorCode; use runvil_types::models::ModelRole; use runvil_types::packs::PackRef; use crate::contract::RoleMap; -use crate::{ArgsDecl, Error, ParseErrorKind, Result, RoleRequirements}; +use crate::{ArgsDecl, Result, RoleRequirements, error}; /// A declared input or output file in a prompt's frontmatter. /// @@ -208,8 +209,8 @@ pub(crate) fn split_frontmatter(input: &str) -> Result { match lines.next() { Some(l) if l.trim() == "---" => {} _ => { - return Err(Error::parse( - ParseErrorKind::Frontmatter, + return Err(error::failure( + ErrorCode::FrontmatterInvalid, "file must begin with a --- frontmatter delimiter", )); } @@ -227,8 +228,8 @@ pub(crate) fn split_frontmatter(input: &str) -> Result { yaml.push('\n'); } if !closed { - return Err(Error::parse( - ParseErrorKind::Frontmatter, + return Err(error::failure( + ErrorCode::FrontmatterInvalid, "frontmatter was not closed with ---", )); } diff --git a/crates/runvil-internal/prompt/src/lib.rs b/crates/runvil-internal/prompt/src/lib.rs index 782703d43..9328bacec 100644 --- a/crates/runvil-internal/prompt/src/lib.rs +++ b/crates/runvil-internal/prompt/src/lib.rs @@ -43,7 +43,6 @@ pub mod test_support; pub use body_text::BodyText; pub use contract::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter, RoleRequirements}; use error::Result; -pub use error::{Error, ParseError, ParseErrorKind}; #[cfg(test)] use frontmatter::MAX_TOOL_ITERATIONS; pub use frontmatter::{FileDecl, Frontmatter}; diff --git a/crates/runvil-internal/prompt/src/list.rs b/crates/runvil-internal/prompt/src/list.rs index b4a1302c1..332e3c7fc 100644 --- a/crates/runvil-internal/prompt/src/list.rs +++ b/crates/runvil-internal/prompt/src/list.rs @@ -1,6 +1,8 @@ //! Detects list-only sections and extracts their bullet items. -use crate::{Error, ParseErrorKind, Result}; +use runvil_types::error::ErrorCode; + +use crate::{Result, error}; /// 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 @@ -8,8 +10,8 @@ use crate::{Error, ParseErrorKind, Result}; /// prose. /// /// # Errors -/// Returns a list-classified parse error when the prose is a list and a -/// marker has no content. +/// Returns a `ListEmptyItem` failure 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; @@ -25,8 +27,8 @@ pub(crate) fn bullet_items<'a>(prose: &'a str, section: &str) -> Result (std::result::Result, Vec) { + ) -> (std::result::Result, Vec) { let sink = EventSink::default(); let emitter = Emitter::root(sink.clone(), execution, false); emitter.report("Prompt", lifecycle::PARSE_STARTED); - let result = Self::parse_inner(input, &emitter); + // Everything past the frontmatter postdates the prompt's name, so a + // failure from there on names it. + let (name, result) = match Self::parse_frontmatter(input) { + Ok((frontmatter, split)) => ( + Some(frontmatter.name().to_owned()), + Self::parse_body(frontmatter, split, file, &emitter), + ), + Err(error) => (None, Err(error)), + }; emitter.report( "Prompt", if result.is_ok() { @@ -73,55 +92,31 @@ impl Prompt { lifecycle::PARSE_FAILED }, ); - (result.map_err(ParseError::from_inner), sink.take()) + let result = + result.map_err(|error| error::with_prompt_context(error, name.as_deref(), file)); + (result, sink.take()) } - fn parse_inner(input: &str, emitter: &Emitter) -> Result { + /// Splits the frontmatter from the body, decodes it, and checks its + /// `uses:` list. + fn parse_frontmatter(input: &str) -> Result<(Frontmatter, SplitSource)> { 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| { - ( - u32::try_from(location.line()) - .ok() - .and_then(|line| line.checked_add(1)) - .and_then(NonZeroU32::new), - u32::try_from(location.column()) - .ok() - .and_then(NonZeroU32::new), - ) - }); - Error::ParseFrontmatter { - message: e.to_string(), - cause: Box::new(e), - line, - column, - } - })?; - contract::check_distinct_packs(frontmatter.uses()) - .and_then(|()| contract::check_no_reserved_pack(frontmatter.uses())) - .map_err(|refusal| match refusal { - PackListError::Duplicate(_) | PackListError::Reserved(_) => { - Error::parse(ParseErrorKind::Frontmatter, refusal.to_string()) - } - })?; - // 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, &shared, frontmatter_lines, emitter) - .map_err(|error| error.with_prompt_context(&name, &shared, frontmatter_lines)) + let frontmatter: Frontmatter = serde_yaml_ng::from_str(&split.yaml) + .map_err(|cause| error::frontmatter_decode(&cause))?; + contract::check_distinct_packs(frontmatter.uses())?; + contract::check_no_reserved_pack(frontmatter.uses())?; + Ok((frontmatter, split)) } fn parse_body( frontmatter: Frontmatter, - shared: &Arc, - frontmatter_lines: u32, + split: SplitSource, + file: &str, emitter: &Emitter, ) -> Result { - let body: &str = shared; + let frontmatter_lines = split.frontmatter_lines; + let shared: Arc = Arc::from(split.body); + let body: &str = &shared; let headings = sections::collect_headings(body)?; let h1_positions: Vec = headings @@ -131,8 +126,8 @@ impl Prompt { .collect(); let [h1_index] = h1_positions.as_slice() else { if h1_positions.is_empty() { - return Err(Error::parse( - ParseErrorKind::Structure, + return Err(error::failure( + ErrorCode::StructureInvalid, "prompt requires an H1 title", )); } @@ -143,8 +138,8 @@ impl Prompt { .map(|line| line.to_string()) }) .collect::>>()?; - return Err(Error::parse( - ParseErrorKind::Structure, + return Err(error::failure( + ErrorCode::StructureInvalid, format!( "prompt must contain exactly one H1 title, found {} at lines {}", lines.len(), @@ -154,25 +149,16 @@ impl Prompt { }; let h1 = &headings[*h1_index]; if h1.title.is_empty() { - return Err(Error::parse( - ParseErrorKind::Structure, + return Err(error::failure( + ErrorCode::StructureInvalid, "prompt H1 title must not be empty", )); } let title = h1.title.clone(); 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) => { - format!("unrecognized fence label `{label}`; the only Lua fence label is `lua`") - } - FenceViolation::Opener => { - "`lua` fence opening line must be exactly ```lua".to_owned() - } - FenceViolation::Closer => "`lua` fence closing line must be exactly ```".to_owned(), - }; - return Err(Error::parse_at(ParseErrorKind::Fence, line, message)); + if let Some(violation) = fence::first_fence_violation(body, frontmatter_lines) { + return Err(violation); } // Everything before the H1 is preface and has no prompt semantics. @@ -182,7 +168,8 @@ impl Prompt { .skip(*h1_index + 1) .filter(|h| h.level >= 2) .collect(); - let mut walker = SectionWalker::new(shared, §ion_headings, frontmatter_lines, emitter); + let mut walker = + SectionWalker::new(&shared, §ion_headings, frontmatter_lines, file, emitter); let mut h1_blocks = walker.compile_blocks( fence::split_section_blocks(h1_content, &title)?, h1_content_abs_line, @@ -198,6 +185,7 @@ impl Prompt { let sections = walker.build_sections(1)?; Ok(Prompt { + file: file.to_owned(), frontmatter, title, h1_blocks, diff --git a/crates/runvil-internal/prompt/src/position.rs b/crates/runvil-internal/prompt/src/position.rs index 633bb1f1a..e428e68a4 100644 --- a/crates/runvil-internal/prompt/src/position.rs +++ b/crates/runvil-internal/prompt/src/position.rs @@ -5,7 +5,7 @@ use std::num::NonZeroU32; use std::sync::Arc; -use crate::{BodyText, Error, Result}; +use crate::{BodyText, Result, error}; /// Wraps a computed 1-based source line for the Lua compiler. /// @@ -13,45 +13,43 @@ use crate::{BodyText, Error, Result}; /// a broken source-position invariant. /// /// # Errors -/// Returns [`Error::Internal`] when `line` is zero. +/// Returns an `Internal` failure when `line` is zero. pub(crate) fn nz_source_line(line: u32) -> Result { - NonZeroU32::new(line).ok_or(Error::Internal( - "parser: computed 1-based source line was zero", - )) + NonZeroU32::new(line) + .ok_or_else(|| error::internal("parser: computed 1-based source line was zero")) } /// 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 +/// Returns an `Internal` failure when the sum overflows `u32`, rather than /// saturating and hiding a broken position. pub(crate) fn line_add(a: u32, b: u32) -> Result { a.checked_add(b) - .ok_or(Error::Internal("parser: source line arithmetic overflowed")) + .ok_or_else(|| error::internal("parser: source line arithmetic overflowed")) } /// Locates `part` inside `shared`, the text it was sliced from. /// /// # Errors -/// Returns [`Error::Internal`] when `part` is not a slice of `shared`. +/// Returns an `Internal` failure 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", - )) + BodyText::within(shared, part) + .ok_or_else(|| error::internal("parser: section text is not a slice of the prompt body")) } /// Counts the number of `\n` characters in `text[..byte_offset]`. /// /// # Errors -/// Returns [`Error::Internal`] when `byte_offset` is out of bounds or not a -/// UTF-8 boundary, or when the newline count exceeds `u32`. +/// Returns an `Internal` failure when `byte_offset` is out of bounds or not +/// a UTF-8 boundary, or when the newline count exceeds `u32`. pub(crate) fn newlines_before(text: &str, byte_offset: usize) -> Result { - let prefix = text.get(..byte_offset).ok_or(Error::Internal( - "parser: source byte offset invariant broken", - ))?; + let prefix = text + .get(..byte_offset) + .ok_or_else(|| error::internal("parser: source byte offset invariant broken"))?; u32::try_from(prefix.matches('\n').count()) - .map_err(|_| Error::Internal("parser: newline count exceeded u32 range")) + .map_err(|_| error::internal("parser: newline count exceeded u32 range")) } #[cfg(test)] diff --git a/crates/runvil-internal/prompt/src/position/tests.rs b/crates/runvil-internal/prompt/src/position/tests.rs index 4d4e974ba..21418bd01 100644 --- a/crates/runvil-internal/prompt/src/position/tests.rs +++ b/crates/runvil-internal/prompt/src/position/tests.rs @@ -1,16 +1,18 @@ //! Tests for the source-position helpers. +use runvil_types::error::ErrorCode; + use super::newlines_before; -use crate::Error; #[test] fn newlines_before_reports_broken_byte_offset_invariants() { for offset in [1, 4] { let error = newlines_before("é\n", offset) .expect_err("a non-boundary or out-of-range offset must return an error"); - assert!(matches!( - error, - Error::Internal("parser: source byte offset invariant broken") - )); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!( + error.message, + "internal invariant violated: parser: source byte offset invariant broken" + ); } } diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index 436a77e32..43e44953b 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -128,6 +128,9 @@ impl Section { /// A fully parsed prompt file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Prompt { + /// The file the source came from, as the caller of + /// [`Prompt::parse`](crate::Prompt::parse) named it. + pub(crate) file: String, /// The parsed YAML frontmatter. pub(crate) frontmatter: Frontmatter, /// The required H1 title. @@ -139,6 +142,13 @@ pub struct Prompt { } impl Prompt { + /// Returns the file the source came from, such as + /// `agents/research.md`, or `prompt` when it came from no file. + #[must_use] + pub fn file(&self) -> &str { + &self.file + } + /// Returns the parsed frontmatter. #[must_use] pub fn frontmatter(&self) -> &Frontmatter { diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index 0b218ad82..d546f1c87 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -5,10 +5,11 @@ use std::sync::Arc; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use runvil_types::emitter::Emitter; +use runvil_types::error::{Error, ErrorCode}; 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 crate::{Block, BodyText, Result, Section, error, list}; use runvil_lua::LuaProgram; /// A heading with its title and the prose/Lua that follows it (before the next @@ -93,67 +94,6 @@ pub(crate) fn collect_headings(body: &str) -> Result>> { Ok(headings) } -/// 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 -/// top-level section with no parent H2. Such a heading has no well-defined -/// parent, so reject it rather than silently reparenting it to a shallower -/// ancestor. -fn check_child_level(heading: &Heading<'_>, parent_level: u8) -> Result<()> { - let level = heading.level; - if level > parent_level + 1 { - return Err(Error::parse_at( - ParseErrorKind::Structure, - heading.span.clone(), - format!( - "section `{}` is an orphan H{level} heading with no parent H{}", - heading.title, - parent_level + 1 - ), - )); - } - Ok(()) -} - -/// Returns the heading's section name. -/// -/// 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<'_>) -> Result { - let name = heading.title.clone(); - if name.is_empty() { - return Err(Error::parse_at( - ParseErrorKind::Structure, - heading.span.clone(), - format!("an H{} section heading must not be empty", heading.level), - )); - } - Ok(name) -} - -/// Rejects a section whose name an earlier sibling already took. -/// -/// Sections are addressed by name (jumps, lookups), so two siblings sharing a -/// name would make the target ambiguous. The error names BOTH heading -/// locations so the author can find each one. -fn check_unique_sibling( - siblings: &[(String, u32)], - name: &str, - heading: &Heading<'_>, - heading_abs_line: u32, -) -> Result<()> { - if let Some((_, first_line)) = siblings.iter().find(|(n, _)| n == name) { - return Err(Error::parse_at( - ParseErrorKind::Structure, - heading.span.clone(), - format!( - "duplicate sibling section name `{name}`: first declared at line {first_line}, again at line {heading_abs_line}; sibling section names must be unique" - ), - )); - } - Ok(()) -} - /// Compiles a prompt body's H1 and section blocks, and builds its section /// tree. /// @@ -168,6 +108,8 @@ pub(crate) struct SectionWalker<'a> { /// The file lines before the body, added to a body line to give its file /// line. frontmatter_lines: u32, + /// The file the prompt came from, which a block's compile failure names. + file: &'a str, /// Receives each Lua block's compile events. emitter: &'a Emitter, /// Index in `headings` of the next heading to build. @@ -180,12 +122,14 @@ impl<'a> SectionWalker<'a> { shared: &'a Arc, headings: &'a [Heading<'a>], frontmatter_lines: u32, + file: &'a str, emitter: &'a Emitter, ) -> Self { Self { shared, headings, frontmatter_lines, + file, emitter, pos: 0, } @@ -205,12 +149,13 @@ impl<'a> SectionWalker<'a> { /// 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`. + /// Returns a `StructureInvalid` failure at the heading for an orphan + /// heading, an empty heading, or duplicate sibling names, and the + /// failure for malformed section content. Propagates a Lua compile + /// failure, and an `Internal` failure 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(); @@ -223,15 +168,15 @@ impl<'a> SectionWalker<'a> { if level <= parent_level { break; } - check_child_level(h, parent_level)?; - let name = section_name(h)?; + self.check_child_level(h, parent_level)?; + let name = self.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)?; + self.check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; sibling_lines.push((name.clone(), heading_abs_line)); result.push(Section { @@ -249,6 +194,76 @@ impl<'a> SectionWalker<'a> { Ok(result) } + /// 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 + /// top-level section with no parent H2. Such a heading has no well-defined + /// parent, so reject it rather than silently reparenting it to a shallower + /// ancestor. + fn check_child_level(&self, heading: &Heading<'_>, parent_level: u8) -> Result<()> { + let level = heading.level; + if level > parent_level + 1 { + return Err(self.structure_failure( + heading, + format!( + "section `{}` is an orphan H{level} heading with no parent H{}", + heading.title, + parent_level + 1 + ), + )); + } + Ok(()) + } + + /// Returns the heading's section name. + /// + /// 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(&self, heading: &Heading<'_>) -> Result { + let name = heading.title.clone(); + if name.is_empty() { + return Err(self.structure_failure( + heading, + format!("an H{} section heading must not be empty", heading.level), + )); + } + Ok(name) + } + + /// Rejects a section whose name an earlier sibling already took. + /// + /// Sections are addressed by name (jumps, lookups), so two siblings sharing a + /// name would make the target ambiguous. The error names BOTH heading + /// locations so the author can find each one. + fn check_unique_sibling( + &self, + siblings: &[(String, u32)], + name: &str, + heading: &Heading<'_>, + heading_abs_line: u32, + ) -> Result<()> { + if let Some((_, first_line)) = siblings.iter().find(|(n, _)| n == name) { + return Err(self.structure_failure( + heading, + format!( + "duplicate sibling section name `{name}`: first declared at line {first_line}, again at line {heading_abs_line}; sibling section names must be unique" + ), + )); + } + Ok(()) + } + + /// Builds a structure failure located at `heading`. + fn structure_failure(&self, heading: &Heading<'_>, message: String) -> Error { + error::failure_at( + ErrorCode::StructureInvalid, + message, + self.shared, + heading.span.clone(), + self.frontmatter_lines, + ) + } + /// Builds one heading's executable blocks while preserving source positions. /// /// `location` names the section in each Lua block's location. @@ -273,9 +288,10 @@ impl<'a> SectionWalker<'a> { /// 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`. + /// Returns a Lua compile failure for a block that does not compile, and + /// an `Internal` failure for a compiler that cannot start, 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>, @@ -297,11 +313,13 @@ impl<'a> SectionWalker<'a> { let abs_line = line_add(content_abs_line, line_offset)?; let program = LuaProgram::compile( &source, + self.file, &location(&role), nz_source_line(abs_line)?, self.emitter, name, - )?; + ) + .map_err(|cause| error::lua(&cause))?; blocks.push(Block::Lua(program)); } } diff --git a/crates/runvil-internal/prompt/src/test_support.rs b/crates/runvil-internal/prompt/src/test_support.rs index 6e08d5962..5af4ebb4e 100644 --- a/crates/runvil-internal/prompt/src/test_support.rs +++ b/crates/runvil-internal/prompt/src/test_support.rs @@ -8,7 +8,9 @@ //! parse assertions show the source and the actual outcome when a parse goes //! the other way. -use crate::{Block, BodyText, ParseError, ParseErrorKind, Prompt, Section}; +use runvil_types::error::{Error, ErrorCode}; + +use crate::{Block, BodyText, Prompt, Section}; /// Builds a synthetic section with the given blocks, so each test fixture /// states only its own deltas (a prose block) instead of restating the @@ -161,7 +163,7 @@ fn trimmed(text: &str) -> String { reason = "a test may call it only to assert that the source parses" )] pub fn parse_ok(src: &str) -> Prompt { - match Prompt::parse(src, "test").0 { + match Prompt::parse(src, "prompt", "test").0 { Ok(prompt) => prompt, Err(error) => panic!("expected a parse, got {error:?}\n{}", excerpt(src)), } @@ -174,8 +176,8 @@ pub fn parse_ok(src: &str) -> Prompt { /// When `src` parses, naming its title and showing the source's first lines. #[track_caller] #[must_use] -pub fn parse_err(src: &str) -> ParseError { - match Prompt::parse(src, "test").0 { +pub fn parse_err(src: &str) -> Error { + match Prompt::parse(src, "prompt", "test").0 { Ok(prompt) => panic!( "expected an error, parsed `{}`\n{}", prompt.title, @@ -185,18 +187,18 @@ pub fn parse_err(src: &str) -> ParseError { } } -/// Asserts that `src` fails to parse with a `kind` error whose message +/// Asserts that `src` fails to parse with a `code` error whose message /// contains `fragment`. /// /// # Panics /// -/// When `src` parses, or fails with another kind or message, naming the +/// When `src` parses, or fails with another code or message, naming the /// actual error and showing the source's first lines. #[track_caller] -pub fn assert_parse_error(src: &str, kind: ParseErrorKind, fragment: &str) { +pub fn assert_parse_error(src: &str, code: ErrorCode, fragment: &str) { let error = parse_err(src); - let expected = format!("expected a {kind:?} error containing {fragment:?}"); - let matched = error.kind() == kind && error.to_string().contains(fragment); + let expected = format!("expected a {code:?} error containing {fragment:?}"); + let matched = error.code == code && error.message.contains(fragment); assert!(matched, "{expected}, got {error:?}\n{}", excerpt(src)); } diff --git a/crates/runvil-internal/prompt/src/tests.rs b/crates/runvil-internal/prompt/src/tests.rs index cea54838a..939fb9aca 100644 --- a/crates/runvil-internal/prompt/src/tests.rs +++ b/crates/runvil-internal/prompt/src/tests.rs @@ -1,8 +1,7 @@ //! Crate-wide parser tests: frontmatter, headings, fences, lists, breaks, and line mapping. -use std::num::NonZeroU32; - use runvil_lua::LuaProgram; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::event::Event; use super::*; @@ -35,81 +34,69 @@ const NON_EXACT_LUA_OPENERS: [&str; 9] = [ const OPENER_MESSAGE: &str = "`lua` fence opening line must be exactly ```lua"; const CLOSER_MESSAGE: &str = "`lua` fence closing line must be exactly ```"; -#[test] -fn invalid_frontmatter_keeps_the_yaml_failure_as_its_cause() { - let error = parse_err(&prompt(": : :", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); - let cause = error - .cause() - .expect("the YAML decode failure stays reachable as the cause"); - assert!( - cause.is::(), - "required a YAML decode failure, actual {cause:?}" - ); +/// The failure's location as its file, start line, start column, end +/// line, and end column, when it has one. +pub(crate) fn location(error: &Error) -> Option<(&str, u32, u32, u32, u32)> { + error.at.as_ref().map(|at| { + ( + at.file.as_str(), + at.start_line.get(), + at.start_column.get(), + at.end_line.get(), + at.end_column.get(), + ) + }) } #[test] fn frontmatter_syntax_errors_report_a_position_without_a_name() { - // A malformed-YAML frontmatter surfaces the retained - // serde_yaml_ng position (1-based, file-absolute); the failure predates - // the prompt's name, so none is reported. + // A malformed-YAML frontmatter is located at the serde_yaml_ng position + // (1-based, file-absolute), a point at the line and column; the failure + // predates the prompt's name, so none is reported. let error = parse_err("---\nname: p\ndescription: d\n: : :\n---\n\n# T\n\n## S\n\nhi\n"); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); - assert_eq!( - error.line(), - NonZeroU32::new(4), - "the malformed line: {error}" - ); - assert!( - error.column().is_some(), - "a column accompanies the line: {error}" - ); - assert_eq!(error.name(), None); + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); + let Some((file, line, column, end_line, end_column)) = location(&error) else { + panic!("the malformed line is located: {error:?}"); + }; + assert_eq!((file, line), ("prompt", 4), "the malformed line: {error}"); + assert_eq!((end_line, end_column), (line, column), "{error:?}"); + assert_eq!(error.facts.get(&FactKey::Name), None); } #[test] fn structured_errors_report_the_prompt_name_and_source_position() { // A structured failure postdates the frontmatter, so the parse // error reports the prompt's frontmatter name plus the offending - // span's 1-based line and column, computed against the source. + // span's 1-based start and end lines and columns, computed against the + // source. let src = concat!( "---\nname: dup\ndescription: d\n---\n", // lines 1-4 "\n# T\n\n## S\n\np\n\n## S\n\nq\n", // the second `## S` heads line 12 ); let error = parse_err(src); - assert_eq!(error.kind(), ParseErrorKind::Structure); - assert_eq!(error.name(), Some("dup")); + assert_eq!(error.code, ErrorCode::StructureInvalid); + assert_eq!(error.facts.get(&FactKey::Name), Some(&"dup".into())); assert_eq!( - error.line(), - NonZeroU32::new(12), - "the duplicate heading's line: {error}" - ); - assert_eq!( - error.column(), - NonZeroU32::new(1), - "the duplicate heading's column: {error}" - ); - assert!( - error.span().is_some(), - "the byte span is preserved alongside the line/column" + location(&error), + Some(("prompt", 12, 1, 13, 1)), + "the duplicate heading's line, through its newline: {error}" ); } #[test] fn orphan_and_empty_heading_errors_report_a_line() { // The frontmatter takes lines 1-4, `# T` is line 6, and the body starts - // on line 8. - for (body, line, kind) in [ - ("## A\n\na\n\n#### D\n\nd\n", 12, ParseErrorKind::Structure), - ("## \n\na\n", 8, ParseErrorKind::Structure), - ] { + // on line 8. Each span is the heading line through its newline. + for (body, line) in [("## A\n\na\n\n#### D\n\nd\n", 12), ("## \n\na\n", 8)] { let error = parse_err(&format!( "---\nname: x\ndescription: d\n---\n\n# T\n\n{body}" )); - assert_eq!(error.kind(), kind, "{error}"); - assert_eq!(error.line(), NonZeroU32::new(line), "{error}"); - assert_eq!(error.column(), NonZeroU32::new(1), "{error}"); - assert!(error.span().is_some(), "{error}"); + assert_eq!(error.code, ErrorCode::StructureInvalid, "{error}"); + assert_eq!( + location(&error), + Some(("prompt", line, 1, line + 1, 1)), + "{error}" + ); } } @@ -230,3 +217,12 @@ fn parses_single_minimal_section() { assert_eq!(p.sections[0].name, "Greet"); assert_eq!(p.sections[0].prose(), "Say hi"); } + +#[test] +fn parse_records_the_file_the_source_came_from() { + let src = PromptSource::new().prose("Greet", "Say hi").build(); + let prompt = Prompt::parse(&src, "agents/research.md", "test") + .0 + .expect("the minimal prompt parses"); + assert_eq!(prompt.file(), "agents/research.md"); +} diff --git a/crates/runvil-internal/prompt/src/tests/frontmatter.rs b/crates/runvil-internal/prompt/src/tests/frontmatter.rs index 96d98ff90..540af5907 100644 --- a/crates/runvil-internal/prompt/src/tests/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/tests/frontmatter.rs @@ -12,8 +12,8 @@ fn name_and_description_are_sufficient_frontmatter_for_parsing() { #[test] fn missing_frontmatter_delimiter_errors() { assert_eq!( - parse_err("# T\n\n## S\n\np\n").kind(), - ParseErrorKind::Frontmatter + parse_err("# T\n\n## S\n\np\n").code, + ErrorCode::FrontmatterInvalid ); } @@ -27,7 +27,7 @@ fn an_indented_delimiter_in_a_block_scalar_does_not_close_the_frontmatter() { #[test] fn unknown_frontmatter_field_is_rejected() { let src = prompt("not_a_real_field: 1", &[]); - assert_parse_error(&src, ParseErrorKind::Frontmatter, "not_a_real_field"); + assert_parse_error(&src, ErrorCode::FrontmatterInvalid, "not_a_real_field"); // A known-field-only frontmatter still parses. parse_ok(&prompt("", &[])); } @@ -49,8 +49,8 @@ fn max_tool_iterations_rejects_zero_negative_and_overflow() { for bad in ["0", "-1", "1001", "100000000000"] { let error = parse_err(&prompt(&format!("max_tool_iterations: {bad}"), &[])); assert_eq!( - error.kind(), - ParseErrorKind::Frontmatter, + error.code, + ErrorCode::FrontmatterInvalid, "value {bad}: {error}" ); } @@ -61,14 +61,14 @@ fn max_tool_iterations_reports_the_guides_messages() { for raw in ["0", "-1"] { assert_parse_error( &prompt(&format!("max_tool_iterations: {raw}"), &[]), - ParseErrorKind::Frontmatter, + ErrorCode::FrontmatterInvalid, &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, + ErrorCode::FrontmatterInvalid, &format!("max_tool_iterations must be <= 1000, got {raw}"), ); } diff --git a/crates/runvil-internal/prompt/src/tests/h1_fences.rs b/crates/runvil-internal/prompt/src/tests/h1_fences.rs index efe446bee..72e1413fe 100644 --- a/crates/runvil-internal/prompt/src/tests/h1_fences.rs +++ b/crates/runvil-internal/prompt/src/tests/h1_fences.rs @@ -7,7 +7,7 @@ const LABEL_NEAR_MISSES: [&str; 4] = ["lua extra", "Lua", "LUA", "luau"]; #[track_caller] fn assert_unrecognized_label(src: &str, label: &str) { let error = parse_err(src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!( error.to_string(), format!("unrecognized fence label `{label}`; the only Lua fence label is `lua`") @@ -95,7 +95,7 @@ fn h1_label_near_misses_fail_whatever_the_opener_shape() { #[track_caller] fn assert_opener_violation(src: &str) { let error = parse_err(src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!(error.to_string(), OPENER_MESSAGE, "{src:?}"); } @@ -136,10 +136,14 @@ fn unrecognized_lua_label_fails_before_any_lua_compiles() { let source = "private_payload ="; let body = format!("```lua extra\n{source}\n```"); let src = PromptSource::new().h1("local ok = 1").prose("S", &body); - let (error, events) = Prompt::parse(&src.title("Private title").build(), "parse-failure"); + let (error, events) = Prompt::parse( + &src.title("Private title").build(), + "prompt", + "parse-failure", + ); let recorder = Recorder(events); let error = error.expect_err("the label near-miss must fail"); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert!(!error.to_string().contains(source), "{error}"); let observations = recorder.observations(); assert_eq!( @@ -156,10 +160,14 @@ fn non_exact_lua_opener_fails_before_any_lua_compiles() { let source = "private_payload ="; let body = format!("~~~lua\n{source}\n~~~"); let src = PromptSource::new().h1("local ok = 1").prose("S", &body); - let (error, events) = Prompt::parse(&src.title("Private title").build(), "parse-failure"); + let (error, events) = Prompt::parse( + &src.title("Private title").build(), + "prompt", + "parse-failure", + ); let recorder = Recorder(events); let error = error.expect_err("the non-exact opener must fail"); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert!(!error.to_string().contains(source), "{error}"); assert_eq!( recorder.observations(), @@ -173,7 +181,7 @@ fn non_exact_lua_opener_fails_before_any_lua_compiles() { #[test] fn successful_parse_reports_only_fixed_boundaries() { let source = PromptSource::new().h1("local secret = 42").prose("S", "p"); - let (prompt, events) = Prompt::parse(&source.title("T").build(), "parse-success"); + let (prompt, events) = Prompt::parse(&source.title("T").build(), "prompt", "parse-success"); prompt.expect("prompt must parse"); let recorder = Recorder(events); assert!( diff --git a/crates/runvil-internal/prompt/src/tests/headings.rs b/crates/runvil-internal/prompt/src/tests/headings.rs index da94b1266..e994a8878 100644 --- a/crates/runvil-internal/prompt/src/tests/headings.rs +++ b/crates/runvil-internal/prompt/src/tests/headings.rs @@ -12,7 +12,7 @@ fn h1_only_prompt_parses_with_empty_sections() { #[test] fn empty_h1_title_errors() { let src = "---\nname: x\ndescription: d\n---\n\n#\n\n## S\n\np\n"; - assert_parse_error(src, ParseErrorKind::Structure, "title must not be empty"); + assert_parse_error(src, ErrorCode::StructureInvalid, "title must not be empty"); } #[test] @@ -21,7 +21,7 @@ fn more_than_one_h1_is_refused_naming_the_count_and_lines() { let src = "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n\n# U\n\nq\n\n# V\n"; assert_parse_error( src, - ParseErrorKind::Structure, + ErrorCode::StructureInvalid, "prompt must contain exactly one H1 title, found 3 at lines 6, 12, 16", ); } @@ -86,24 +86,24 @@ fn skipped_heading_level_is_rejected_as_orphan() { .prose("A", "a") .prose("#### D", "d") .build(); - assert_parse_error(&src, ParseErrorKind::Structure, "orphan"); + assert_parse_error(&src, ErrorCode::StructureInvalid, "orphan"); } #[test] fn orphan_top_level_deep_heading_is_rejected() { // The first section heading is an H3 with no parent H2: an orphan. let src = PromptSource::new().prose("### A", "a").build(); - assert_parse_error(&src, ParseErrorKind::Structure, "orphan"); + assert_parse_error(&src, ErrorCode::StructureInvalid, "orphan"); // An H4 top-level section (double skip) is likewise rejected. let src = PromptSource::new().prose("#### A", "a").build(); - assert_parse_error(&src, ParseErrorKind::Structure, "orphan"); + assert_parse_error(&src, ErrorCode::StructureInvalid, "orphan"); } #[test] fn empty_section_heading_is_rejected() { let src = PromptSource::new().prose("", "a").build(); - assert_parse_error(&src, ParseErrorKind::Structure, "must not be empty"); + assert_parse_error(&src, ErrorCode::StructureInvalid, "must not be empty"); } #[test] @@ -122,13 +122,13 @@ fn duplicate_sibling_section_names_are_rejected() { message.contains("first declared at line 8") && message.contains("again at line 12"), "both duplicate locations must be reported, got: {message}" ); - // A structured parse error reports a stable kind and a byte span rather - // than inferring them from the message. - assert_eq!(err.kind(), ParseErrorKind::Structure); - let span = err.span().expect("duplicate section has a span"); - assert!( - !span.is_empty(), - "span must be a non-empty range, got {span:?}" + // A structured parse error reports a stable code and the location of + // the second heading rather than inferring them from the message. + assert_eq!(err.code, ErrorCode::StructureInvalid); + assert_eq!( + location(&err), + Some(("prompt", 12, 1, 13, 1)), + "the second `## S` heading line, through its newline: {err:?}" ); // The same name under DIFFERENT parents (not siblings) is allowed. diff --git a/crates/runvil-internal/prompt/src/tests/line_mapping.rs b/crates/runvil-internal/prompt/src/tests/line_mapping.rs index 13645b3a9..c42fac0e9 100644 --- a/crates/runvil-internal/prompt/src/tests/line_mapping.rs +++ b/crates/runvil-internal/prompt/src/tests/line_mapping.rs @@ -305,8 +305,10 @@ fn unrecognized_lua_label_error_reports_the_fence_opening_line() { let src = "---\nname: x\ndescription: d\n---\n\n# T\n\n\ ## S\n\nProse.\n\n```lua extra\nfunction f()\nend\n```\n\n```Lua\nreturn 1\n```\n"; let error = parse_err(src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); - assert_eq!(error.line(), NonZeroU32::new(12), "{error}"); - assert_eq!(error.column(), NonZeroU32::new(1), "{error}"); - assert!(error.span().is_some(), "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); + assert_eq!( + location(&error), + Some(("prompt", 12, 1, 12, 13)), + "the opening line without its newline: {error}" + ); } diff --git a/crates/runvil-internal/prompt/src/tests/lists.rs b/crates/runvil-internal/prompt/src/tests/lists.rs index f1e4d05a2..c2209b218 100644 --- a/crates/runvil-internal/prompt/src/tests/lists.rs +++ b/crates/runvil-internal/prompt/src/tests/lists.rs @@ -30,7 +30,7 @@ fn all_marker_list_with_empty_item_is_rejected() { // then a hard error rather than a detector miss. let body = "- alpha\n1.\n- beta"; let error = parse_err(&PromptSource::new().prose("S", body).build()); - assert_eq!(error.kind(), ParseErrorKind::List); + assert_eq!(error.code, ErrorCode::ListEmptyItem); } #[test] @@ -45,7 +45,7 @@ fn an_empty_marker_before_a_prose_line_stays_prose() { fn list_error_kind_does_not_depend_on_the_section_name() { for section in ["frontmatter", "fence"] { let error = parse_err(&PromptSource::new().prose(section, "- alpha\n1.").build()); - assert_eq!(error.kind(), ParseErrorKind::List); + assert_eq!(error.code, ErrorCode::ListEmptyItem); } } diff --git a/crates/runvil-internal/prompt/src/tests/section_fences.rs b/crates/runvil-internal/prompt/src/tests/section_fences.rs index 78c5db492..9980b0d0b 100644 --- a/crates/runvil-internal/prompt/src/tests/section_fences.rs +++ b/crates/runvil-internal/prompt/src/tests/section_fences.rs @@ -75,7 +75,7 @@ fn exact_middle_lua_fences_become_compiled_blocks() { fn invalid_middle_lua_fence_fails_parse() { let body = "Before.\n\n```lua\nnot valid lua =\n```\n\nAfter."; let err = parse_err(&PromptSource::new().prose("S", body).build()); - assert_eq!(err.kind(), ParseErrorKind::Lua); + assert_eq!(err.code, ErrorCode::LuaCompile); } #[test] @@ -100,7 +100,7 @@ fn non_exact_lua_openers_fail_in_a_section() { let src = format!("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n{intro}{fence}\n"); let error = parse_err(&src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!(error.to_string(), OPENER_MESSAGE, "{src:?}"); } } @@ -118,7 +118,7 @@ fn lua_fence_lookalikes_outside_every_fence_are_parse_errors() { ] { let src = format!("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n{content}\n"); let error = parse_err(&src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!(error.to_string(), OPENER_MESSAGE, "{src:?}"); } } @@ -128,7 +128,7 @@ fn section_fence_label_near_misses_are_parse_errors() { for label in ["Lua", "lua extra", "LUA", "luau"] { let body = format!("```{label}\nreturn 1\n```"); let error = parse_err(&PromptSource::new().prose("S", &body).build()); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!( error.to_string(), format!("unrecognized fence label `{label}`; the only Lua fence label is `lua`") @@ -162,7 +162,7 @@ fn non_exact_section_closing_lines_fail_the_parse() { "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n```lua\nvar.a = 1\n{bad_close}\n\n{after}\n" ); let error = parse_err(&src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error} in {src:?}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error} in {src:?}"); assert_eq!(error.to_string(), CLOSER_MESSAGE, "{src:?}"); } } @@ -178,29 +178,39 @@ fn a_lookalike_line_and_a_bad_closing_line_report_whichever_comes_first() { ] { let src = format!("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n{content}\n"); let error = parse_err(&src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!(error.to_string(), message, "{src:?}"); } } #[test] fn closer_and_lookalike_errors_report_the_offending_line() { - // `## S` is on line 8 and the content starts on line 10. - for (content, message, line) in [ - ("```lua\nvar.a = 1\nvar.b = 2\n ```\n", CLOSER_MESSAGE, 13), + // `## S` is on line 8 and the content starts on line 10. Each span is + // the offending line without its newline. + for (content, message, line, end_column) in [ + ( + "```lua\nvar.a = 1\nvar.b = 2\n ```\n", + CLOSER_MESSAGE, + 13, + 6, + ), ( "Prose.\n\n ```lua\n return 1\n ```\n", OPENER_MESSAGE, 12, + 11, ), ] { let error = parse_err(&format!( "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n{content}" )); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!(error.to_string(), message); - assert_eq!(error.line(), NonZeroU32::new(line), "{error}"); - assert_eq!(error.column(), NonZeroU32::new(1), "{error}"); + assert_eq!( + location(&error), + Some(("prompt", line, 1, line, end_column)), + "{error}" + ); } } @@ -212,10 +222,12 @@ fn a_fence_split_that_disagrees_with_the_markdown_parser_is_an_internal_error() let Err(error) = crate::fence::split_section_blocks(content, "S") else { panic!("required a split failure, actual a split of {content:?}"); }; - assert!(matches!(error, Error::Internal(_)), "{error:?}"); - assert_eq!( - ParseError::from_inner(error).kind(), - ParseErrorKind::Internal + assert_eq!(error.code, ErrorCode::Internal, "{error:?}"); + assert!( + error + .message + .starts_with("internal invariant violated: parser: "), + "{error:?}" ); } @@ -275,22 +287,18 @@ fn malformed_section_phases_report_locations_and_safe_boundaries() { let src = PromptSource::new() .prose("Private section", content) .build(); - let (outcome, events) = Prompt::parse(&src, "test"); + let (outcome, events) = Prompt::parse(&src, "prompt", "test"); let recorder = Recorder(events); let Err(error) = outcome else { panic!("malformed {phase} unexpectedly parsed"); }; - match error.into_inner() { - Error::Lua(runvil_lua::Error::LuaCompile { - location, - lua_source, - .. - }) => { - assert_eq!(location, expected_location); - assert_eq!(lua_source, "private_payload ="); - } - other => panic!("expected LuaCompile, got {other:?}"), - } + assert_eq!(error.code, ErrorCode::LuaCompile, "{error:?}"); + assert!( + error.message.starts_with(&format!( + "lua compilation error at {expected_location} (line " + )), + "{error:?}" + ); let observations = recorder.observations(); assert_eq!( @@ -330,7 +338,7 @@ fn backtick_lines_that_cannot_close_a_lua_fence_leave_it_unclosed() { "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\n```lua\nreturn 1\n{last_line}\n" ); let error = parse_err(&src); - assert_eq!(error.kind(), ParseErrorKind::Fence, "{error}"); + assert_eq!(error.code, ErrorCode::FenceInvalid, "{error}"); assert_eq!( error.to_string(), "section `S` prologue `lua` fence is not closed", @@ -343,7 +351,7 @@ fn backtick_lines_that_cannot_close_a_lua_fence_leave_it_unclosed() { fn successful_section_compilation_reports_fixed_ordered_boundaries() { let body = "```lua\nvar.secret = 1\n```\n\nProse.\n\n```lua\nreturn reply\n```"; let src = PromptSource::new().prose("S", body).build(); - let (prompt, events) = Prompt::parse(&src, "section-programs"); + let (prompt, events) = Prompt::parse(&src, "prompt", "section-programs"); prompt.expect("section programs must compile"); let recorder = Recorder(events); 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..a6dea5ec0 --- /dev/null +++ b/crates/runvil-internal/types/src/error-tag.rs @@ -0,0 +1,160 @@ +//! 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", + } + } + + /// Parses a tag from [`ErrorCode::tag`] back into its code, so the two + /// round-trip. Returns `None` for any other string. + #[must_use] + pub fn from_tag(tag: &str) -> Option { + Some(match tag { + "frontmatter_invalid" => ErrorCode::FrontmatterInvalid, + "structure_invalid" => ErrorCode::StructureInvalid, + "fence_invalid" => ErrorCode::FenceInvalid, + "list_empty_item" => ErrorCode::ListEmptyItem, + "unsupported_version" => ErrorCode::UnsupportedVersion, + "invalid_value" => ErrorCode::InvalidValue, + "lua_runtime" => ErrorCode::LuaRuntime, + "lua_compile" => ErrorCode::LuaCompile, + "lua_quota" => ErrorCode::LuaQuota, + "call_depth_exceeded" => ErrorCode::CallDepthExceeded, + "substitution" => ErrorCode::Substitution, + "tasks_live" => ErrorCode::TasksLive, + "task_not_owned" => ErrorCode::TaskNotOwned, + "task_consumed" => ErrorCode::TaskConsumed, + "task_cancelled" => ErrorCode::TaskCancelled, + "context_overflow" => ErrorCode::ContextOverflow, + "rate_limited" => ErrorCode::RateLimited, + "quota_exhausted" => ErrorCode::QuotaExhausted, + "overloaded" => ErrorCode::Overloaded, + "refused" => ErrorCode::Refused, + "timeout" => ErrorCode::Timeout, + "transport" => ErrorCode::Transport, + "server_error" => ErrorCode::ServerError, + "rejected" => ErrorCode::Rejected, + "malformed_response" => ErrorCode::MalformedResponse, + "empty_reply" => ErrorCode::EmptyReply, + "unavailable" => ErrorCode::Unavailable, + "invalid_parts" => ErrorCode::InvalidParts, + "context_exhausted" => ErrorCode::ContextExhausted, + "model_required" => ErrorCode::ModelRequired, + "tool_invalid_arguments" => ErrorCode::ToolInvalidArguments, + "tool_backend" => ErrorCode::ToolBackend, + "tool_transport" => ErrorCode::ToolTransport, + "tool_other" => ErrorCode::ToolOther, + "out_of_scope_tool" => ErrorCode::OutOfScopeTool, + "unbound_tool" => ErrorCode::UnboundTool, + "model_only_tool" => ErrorCode::ModelOnlyTool, + "tool_loop_exhausted" => ErrorCode::ToolLoopExhausted, + "tool_schema_invalid" => ErrorCode::ToolSchemaInvalid, + "tool_sets_invalid" => ErrorCode::ToolSetsInvalid, + "not_found" => ErrorCode::NotFound, + "already_exists" => ErrorCode::AlreadyExists, + "not_a_directory" => ErrorCode::NotADirectory, + "is_a_directory" => ErrorCode::IsADirectory, + "directory_not_empty" => ErrorCode::DirectoryNotEmpty, + "not_utf8" => ErrorCode::NotUtf8, + "invalid_path" => ErrorCode::InvalidPath, + "invalid_range" => ErrorCode::InvalidRange, + "anchor" => ErrorCode::Anchor, + "permission_denied" => ErrorCode::PermissionDenied, + "approval_required" => ErrorCode::ApprovalRequired, + "scope_ended" => ErrorCode::ScopeEnded, + "read_only" => ErrorCode::ReadOnly, + "outside_root" => ErrorCode::OutsideRoot, + "root_protected" => ErrorCode::RootProtected, + "no_store" => ErrorCode::NoStore, + "cross_mount" => ErrorCode::CrossMount, + "io" => ErrorCode::Io, + "unsupported" => ErrorCode::Unsupported, + "conflict" => ErrorCode::Conflict, + "backend" => ErrorCode::Backend, + "determinism" => ErrorCode::Determinism, + "requirements_unmet" => ErrorCode::RequirementsUnmet, + "interrupted" => ErrorCode::Interrupted, + "internal" => ErrorCode::Internal, + "recorder_failed" => ErrorCode::RecorderFailed, + "plugin_load_failed" => ErrorCode::PluginLoadFailed, + "no_output" => ErrorCode::NoOutput, + _ => return None, + }) + } +} 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..c1c0a4e62 --- /dev/null +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -0,0 +1,319 @@ +//! 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 every_tag_parses_back_to_its_code_and_no_other_text_parses() { + for code in ALL { + assert_eq!(ErrorCode::from_tag(code.tag()), Some(code), "{code:?}"); + } + for tag in ["", "Not_Found", "not_found ", "tool", "store"] { + assert_eq!(ErrorCode::from_tag(tag), None, "{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..4b72d3c63 --- /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 +/// caller. 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 1e514982e..943001435 100644 --- a/crates/runvil-internal/types/src/names-tests.rs +++ b/crates/runvil-internal/types/src/names-tests.rs @@ -1,7 +1,96 @@ -//! Tests for `GlobalName` parsing and its rejection messages, and for the -//! alias grammar. +//! Tests for `GlobalName` parsing and its rejection messages, for the alias +//! grammar, and for the error every id type and catalog rejects with. + +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"; diff --git a/crates/runvil-internal/types/src/names.rs b/crates/runvil-internal/types/src/names.rs index b9641886c..2c235f597 100644 --- a/crates/runvil-internal/types/src/names.rs +++ b/crates/runvil-internal/types/src/names.rs @@ -11,12 +11,17 @@ //! [`is_alias`] holds a second grammar, for prompt-local aliases, which are //! never global names. //! -//! [`IdError`] is the error every id type and every catalog of ids returns, -//! whether the id is built on a name or not. +//! 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; @@ -46,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 @@ -96,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) } } @@ -151,38 +156,16 @@ pub fn is_alias(name: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) } -/// 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, -} - -impl IdError { - /// Builds an error that renders as `message`. - pub(crate) fn new(message: String) -> IdError { - IdError { 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 fmt::Display for IdError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.message) - } -} - -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/examples/greeter.rs b/crates/runvil/examples/greeter.rs index 5bcec8984..3a46d0a58 100644 --- a/crates/runvil/examples/greeter.rs +++ b/crates/runvil/examples/greeter.rs @@ -68,7 +68,7 @@ pub fn answer(effect: Effect) -> EffectAnswer { pub fn greet() -> Result> { // 1. Parse the greeter, and start the log with its parse events before // checking the result. - let (parsed, parse_events) = Prompt::parse(GREETER, "greeter"); + let (parsed, parse_events) = Prompt::parse(GREETER, "prompt", "greeter"); let mut log: Vec = parse_events.clone(); let prompt = parsed?; diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index b0054fb23..b7c1ad975 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -1,18 +1,14 @@ -#[non_exhaustive] pub enum runvil::ParseErrorKind #[non_exhaustive] pub enum runvil::RunErrorKind #[non_exhaustive] pub enum runvil::effect::ToolCaller #[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,7 +18,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::RunLimits { .. } #[non_exhaustive] pub struct runvil::effect::IssuedEffect { .. } #[non_exhaustive] pub struct runvil::effect::PackToolSets { .. } @@ -36,7 +31,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 */ } @@ -49,7 +43,6 @@ #[non_exhaustive] pub struct runvil::model::ToolSchema { /* private fields */ } #[non_exhaustive] pub struct runvil::packs::PackId(/* 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 */ } @@ -58,8 +51,6 @@ #[non_exhaustive] pub struct runvil::vfs::Origin { .. } #[non_exhaustive] pub struct runvil::vfs::RealBackend { /* private fields */ } #[non_exhaustive] pub struct runvil::vfs::Stat { .. } -auto runvil::ParseError: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::ParseErrorKind: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::Prompt: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::Run: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::RunConfig: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe @@ -71,17 +62,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 @@ -93,8 +87,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 @@ -104,11 +96,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 @@ -128,8 +118,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 @@ -159,8 +147,6 @@ auto runvil::vfs::VfsPathBuf: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSa auto runvil::vfs::VfsRef: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::vfs::VfsRefBuilder: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::vfs::Writability: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -derives runvil::ParseError: Debug -derives runvil::ParseErrorKind: Clone, Copy, Debug, Eq, Hash, PartialEq derives runvil::Prompt: Clone, Debug, Eq, PartialEq derives runvil::Run: Debug derives runvil::RunConfig: Debug @@ -178,11 +164,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 @@ -194,8 +183,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 @@ -205,11 +192,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 @@ -229,8 +214,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 @@ -262,29 +245,19 @@ derives runvil::vfs::VfsRefBuilder: Debug 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 @@ -296,17 +269,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 @@ -339,6 +312,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 @@ -352,15 +328,12 @@ 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 -pub fn runvil::ParseError::kind(&self) -> runvil::ParseErrorKind -pub fn runvil::ParseError::line(&self) -> core::option::Option -pub fn runvil::ParseError::name(&self) -> core::option::Option<&str> -pub fn runvil::ParseError::span(&self) -> core::option::Option> +pub fn runvil::Prompt::file(&self) -> &str pub fn runvil::Prompt::frontmatter(&self) -> &runvil::prompt::Frontmatter -pub fn runvil::Prompt::parse(input: &str, execution: &str) -> (core::result::Result, alloc::vec::Vec) +pub fn runvil::Prompt::parse(input: &str, file: &str, execution: &str) -> (core::result::Result, alloc::vec::Vec) pub fn runvil::Prompt::title(&self) -> &str pub fn runvil::Run::new(prompt: alloc::sync::Arc, args: &str, config: runvil::RunConfig) -> runvil::Run pub fn runvil::Run::resume(&mut self, id: runvil::effect::EffectId, answer: runvil::effect::EffectAnswer) @@ -390,6 +363,11 @@ 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::ErrorCode::from_tag(tag: &str) -> core::option::Option +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 @@ -422,7 +400,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 @@ -435,19 +413,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 @@ -457,7 +422,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 @@ -480,10 +445,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 @@ -495,28 +460,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 @@ -546,17 +514,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 @@ -567,6 +529,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> @@ -641,8 +604,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 @@ -652,12 +617,6 @@ pub mod runvil::prompt pub mod runvil::timestamp pub mod runvil::tools pub mod runvil::vfs -pub runvil::ParseErrorKind::Fence -pub runvil::ParseErrorKind::Frontmatter -pub runvil::ParseErrorKind::Internal -pub runvil::ParseErrorKind::List -pub runvil::ParseErrorKind::Lua -pub runvil::ParseErrorKind::Structure pub runvil::RunErrorKind::Binding pub runvil::RunErrorKind::Completion pub runvil::RunErrorKind::ContextExhausted @@ -708,13 +667,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 @@ -732,6 +691,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 @@ -1040,19 +1091,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(..) @@ -1069,8 +1107,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 @@ -1084,10 +1120,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 @@ -1235,7 +1267,6 @@ 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 */ } @@ -1243,13 +1274,13 @@ 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 */ } diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 83ce27526..de6a9623d 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -20,8 +20,6 @@ pub use runvil_engine::RunErrorKind; pub use runvil_engine::RunLimits; pub use runvil_engine::RunOutcome; pub use runvil_engine::Step; -pub use runvil_prompt::ParseError; -pub use runvil_prompt::ParseErrorKind; pub use runvil_prompt::Prompt; pub use runvil_types::seed::RunSeed; @@ -46,17 +44,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 +73,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 +99,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 +137,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/greeter.rs b/crates/runvil/tests/suite/greeter.rs index 90bd69daf..bbd563313 100644 --- a/crates/runvil/tests/suite/greeter.rs +++ b/crates/runvil/tests/suite/greeter.rs @@ -24,7 +24,7 @@ const REPLY: &str = " #[test] fn the_smallest_greeter_parses_and_its_title_is_the_h1_text() { - let (parsed, _events) = Prompt::parse(¬e("'hello'"), "greeter"); + let (parsed, _events) = Prompt::parse(¬e("'hello'"), "prompt", "greeter"); assert!(parsed.is_ok_and(|prompt| prompt.title() == "Greeter")); } 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/parsing.rs b/crates/runvil/tests/suite/parsing.rs index bb45f6149..08f4151c4 100644 --- a/crates/runvil/tests/suite/parsing.rs +++ b/crates/runvil/tests/suite/parsing.rs @@ -1,38 +1,39 @@ //! Public-parser contracts: invalid fixtures report their exact -//! [`ParseErrorKind`] and message. +//! [`ErrorCode`] and message. use std::fs; use std::path::Path; -use runvil::{ParseErrorKind, Prompt}; +use runvil::Prompt; +use runvil::error::ErrorCode; use super::support::TestResult; -const INVALID_FIXTURES: [(&str, ParseErrorKind, &str); 6] = [ - ("missing-h1", ParseErrorKind::Structure, "requires an H1"), +const INVALID_FIXTURES: [(&str, ErrorCode, &str); 6] = [ + ("missing-h1", ErrorCode::StructureInvalid, "requires an H1"), ( "unrecognized-lua-label", - ParseErrorKind::Fence, + ErrorCode::FenceInvalid, "unrecognized fence label", ), ( "lua-fence-opener", - ParseErrorKind::Fence, + ErrorCode::FenceInvalid, "fence opening line must be exactly", ), ( "lua-fence-closer", - ParseErrorKind::Fence, + ErrorCode::FenceInvalid, "fence closing line must be exactly", ), ( "malformed-epilog", - ParseErrorKind::Lua, + ErrorCode::LuaCompile, "section `Transform` epilog", ), ( "list-h3-non-list-content", - ParseErrorKind::List, + ErrorCode::ListEmptyItem, "empty bullet item", ), ]; @@ -40,19 +41,18 @@ const INVALID_FIXTURES: [(&str, ParseErrorKind, &str); 6] = [ #[test] fn invalid_prompt_files_report_public_error_contracts() -> TestResult { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/prompts/invalid"); - for (stem, kind, message_fragment) in INVALID_FIXTURES { + for (stem, code, message_fragment) in INVALID_FIXTURES { let name = format!("invalid/{stem}.md"); let source = fs::read_to_string(dir.join(format!("{stem}.md")))?; - let Err(error) = Prompt::parse(&source, &name).0 else { + let Err(error) = Prompt::parse(&source, &name, &name).0 else { return Err(format!("fixture {name} unexpectedly parsed").into()); }; assert_eq!( - error.kind(), - kind, - "fixture {name} returned the wrong error kind: {error:?}" + error.code, code, + "fixture {name} returned the wrong error code: {error:?}" ); assert!( - error.to_string().contains(message_fragment), + error.message.contains(message_fragment), "fixture {name} error did not contain {message_fragment:?}: {error}" ); } diff --git a/crates/runvil/tests/suite/prompt.rs b/crates/runvil/tests/suite/prompt.rs index 3626a9d74..a4056b614 100644 --- a/crates/runvil/tests/suite/prompt.rs +++ b/crates/runvil/tests/suite/prompt.rs @@ -1,7 +1,8 @@ //! Reading what a prompt declares before it runs. +use runvil::Prompt; +use runvil::error::ErrorCode; use runvil::model::{ModelRole, ThinkingMode}; -use runvil::{ParseErrorKind, Prompt}; use super::prepare::refusal; use super::support::{Built, Scenario, TestResult, model, prompt}; @@ -17,7 +18,7 @@ const CONTRACT: &str = " #[test] fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> TestResult { let source = prompt(CONTRACT, &[("Greet", "return 'done'")]); - let parsed = Prompt::parse(&source, "greeter").0?; + let parsed = Prompt::parse(&source, "prompt", "greeter").0?; let frontmatter = parsed.frontmatter(); assert_eq!(frontmatter.name(), "greeter"); assert_eq!( @@ -65,14 +66,22 @@ fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> Te ); let misspelled = source.replace("uses:", "use:"); - let parsed = Prompt::parse(&misspelled, "greeter").0; + let parsed = Prompt::parse(&misspelled, "prompt", "greeter").0; assert_eq!( - parsed.err().map(|error| error.kind()), - Some(ParseErrorKind::Frontmatter) + parsed.err().map(|error| error.code), + Some(ErrorCode::FrontmatterInvalid) ); Ok(()) } +#[test] +fn the_prompt_names_the_file_it_was_parsed_from() -> TestResult { + let source = prompt(CONTRACT, &[("Greet", "return 'done'")]); + let parsed = Prompt::parse(&source, "prompt", "greeter").0?; + assert_eq!(parsed.file(), "prompt"); + Ok(()) +} + #[test] fn a_role_check_by_hand_finds_the_same_shortfall_the_run_reports() -> TestResult { let source = prompt( 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/runvil/tests/suite/support/scenario.rs b/crates/runvil/tests/suite/support/scenario.rs index 3d13ffc6b..5bb25eb5c 100644 --- a/crates/runvil/tests/suite/support/scenario.rs +++ b/crates/runvil/tests/suite/support/scenario.rs @@ -116,7 +116,7 @@ impl Scenario { /// Parses `source` as the run `greeter` and builds its config. pub(crate) fn build(&self, source: &str) -> TestResult { - let (parsed, events) = Prompt::parse(source, "greeter"); + let (parsed, events) = Prompt::parse(source, "prompt", "greeter"); let prompt = parsed?; let roles = match (&self.roles, &self.model) { (Some(roles), _) => roles.clone(), diff --git a/crates/workshop/agents/src/conversation-run.rs b/crates/workshop/agents/src/conversation-run.rs index 6505367e9..8df5cf6ef 100644 --- a/crates/workshop/agents/src/conversation-run.rs +++ b/crates/workshop/agents/src/conversation-run.rs @@ -7,8 +7,9 @@ use std::sync::Arc; use harness::pack::HostServices; use harness::record::{RunOutcome, RunRecorder}; -use harness::{Harness, HarnessError, RunReport, RunRequest, display_chain}; +use harness::{Harness, RunReport, RunRequest, display_chain}; use plugin_user_input::{INPUT_BROKER, InputBroker}; +use runvil::error::Error; use runvil::ids::RoundId; use tokio::sync::broadcast; @@ -72,7 +73,7 @@ impl Conversation { /// Notes the report's run and reports a run that failed or was cut /// short; a completed run reports nothing. - fn take_report(&self, result: Result) { + fn take_report(&self, result: Result) { let message = match result { Ok(report) => { self.note_run(report.run_id); diff --git a/crates/workshop/agents/src/discovery-tests.rs b/crates/workshop/agents/src/discovery-tests.rs index 452785ec0..7d0a58848 100644 --- a/crates/workshop/agents/src/discovery-tests.rs +++ b/crates/workshop/agents/src/discovery-tests.rs @@ -33,8 +33,8 @@ fn the_built_in_chat_is_always_listed_and_a_dir_file_shadows_its_source() { ); assert_eq!( agent_source(dir.path(), "chat").expect("the built-in serves"), - BUILTIN_CHAT_SOURCE, - "with no directory file, the embedded source is what launches" + (BUILTIN_CHAT_FILE.to_owned(), BUILTIN_CHAT_SOURCE.to_owned()), + "with no directory file, the embedded source is what launches, named by its own file" ); std::fs::write(dir.path().join("chat.md"), "# shadowed").expect("seed the shadow"); @@ -45,8 +45,11 @@ fn the_built_in_chat_is_always_listed_and_a_dir_file_shadows_its_source() { ); assert_eq!( agent_source(dir.path(), "chat").expect("the shadow reads"), - "# shadowed", - "a directory chat.md shadows the embedded source" + ( + dir.path().join("chat.md").display().to_string(), + "# shadowed".to_owned() + ), + "a directory chat.md shadows the embedded source and names its own path" ); assert_eq!( @@ -78,7 +81,10 @@ fn a_launch_loads_only_a_name_the_agent_menu_lists() { std::fs::write(dir.path().join("hidden.txt"), "# hidden").expect("seed a non-agent"); assert_eq!( load_agent(dir.path(), "echo").expect("a listed agent loads"), - "# echo" + ( + dir.path().join("echo.md").display().to_string(), + "# echo".to_owned() + ) ); for refused in ["ghost", "hidden", "../echo", "echo.md"] { let error = load_agent(dir.path(), refused) diff --git a/crates/workshop/agents/src/discovery.rs b/crates/workshop/agents/src/discovery.rs index 06e3d6a5b..35cb658b5 100644 --- a/crates/workshop/agents/src/discovery.rs +++ b/crates/workshop/agents/src/discovery.rs @@ -9,6 +9,10 @@ use std::path::Path; /// fresh install has a working chat with no agents folder at all. pub const BUILTIN_CHAT_SOURCE: &str = include_str!("../agents/chat.md"); +/// The file the built-in chat agent's source comes from, as its run names +/// it: the file in this crate that [`BUILTIN_CHAT_SOURCE`] embeds. +const BUILTIN_CHAT_FILE: &str = "agents/chat.md"; + /// The built-in default agent's name: discovery always offers it, and a /// folder file named `chat.md` shadows the embedded source. const BUILTIN_CHAT_NAME: &str = "chat"; @@ -59,16 +63,17 @@ pub fn discover_agents(dir: &Path) -> Vec { names } -/// Reads the source of the agent `name`, accepting only a name the agent -/// menu lists: [`discover_agents`] over `dir` is the trust boundary, so a -/// client-sent name reaches the filesystem only as the bare stem of a -/// real `.md` file in `dir`, or as the built-in `chat`. +/// Reads the agent `name` as the pair of the file its source came from and +/// the source, accepting only a name the agent menu lists: +/// [`discover_agents`] over `dir` is the trust boundary, so a client-sent +/// name reaches the filesystem only as the bare stem of a real `.md` file +/// in `dir`, or as the built-in `chat`. /// /// # Errors /// Returns [`LaunchError::UnknownAgent`] for a name discovery does not /// list, paths included, and [`LaunchError::Unreadable`] when a listed /// agent's source cannot be read. -pub fn load_agent(dir: &Path, name: &str) -> Result { +pub fn load_agent(dir: &Path, name: &str) -> Result<(String, String), LaunchError> { if !discover_agents(dir).iter().any(|known| known == name) { return Err(LaunchError::UnknownAgent { name: name.to_owned(), @@ -77,17 +82,18 @@ pub fn load_agent(dir: &Path, name: &str) -> Result { agent_source(dir, name).map_err(|source| LaunchError::Unreadable { source }) } -/// Reads the agent's program source: the folder file when it exists - a -/// folder `chat.md` shadows the built-in - else the embedded built-in for -/// the `chat` name alone. A missing file for any other name is a real -/// filesystem race, surfaced as the error it is; so is an existing -/// `chat.md` that cannot be read, because silently serving the built-in -/// would mask the operator's own file. -fn agent_source(dir: &Path, name: &str) -> io::Result { - match std::fs::read_to_string(dir.join(format!("{name}.md"))) { - Ok(source) => Ok(source), +/// Reads the agent's file and program source: the folder file's path and +/// text when it exists - a folder `chat.md` shadows the built-in - else +/// the embedded built-in for the `chat` name alone. A missing file for any +/// other name is a real filesystem race, surfaced as the error it is; so is +/// an existing `chat.md` that cannot be read, because silently serving the +/// built-in would mask the operator's own file. +fn agent_source(dir: &Path, name: &str) -> io::Result<(String, String)> { + let path = dir.join(format!("{name}.md")); + match std::fs::read_to_string(&path) { + Ok(source) => Ok((path.display().to_string(), source)), Err(error) if name == BUILTIN_CHAT_NAME && error.kind() == io::ErrorKind::NotFound => { - Ok(BUILTIN_CHAT_SOURCE.to_owned()) + Ok((BUILTIN_CHAT_FILE.to_owned(), BUILTIN_CHAT_SOURCE.to_owned())) } Err(error) => Err(error), } diff --git a/crates/workshop/agents/src/input-tests.rs b/crates/workshop/agents/src/input-tests.rs index a09ec692b..e970b32a8 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") } } @@ -289,6 +289,7 @@ async fn an_ask_is_answered_when_the_registry_receives_the_text() { let request = RunRequest { name: "conversation-1".to_owned(), source: source.to_owned(), + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), 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..bf66c8d3d 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))) }) } } @@ -74,6 +75,7 @@ pub(crate) fn harness_for( let request = RunRequest { name: conversation.id().to_string(), source, + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs: VfsRef::default(), 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.rs b/crates/workshop/server/src/agents.rs index 0f44b709b..98c8d99cc 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -298,7 +298,7 @@ impl AgentSessions { // the same unreadable state as an unreadable file. let agents_path = self.inner.agents_path.clone(); let agent = name.to_owned(); - let source = tokio::task::spawn_blocking(move || load_agent(&agents_path, &agent)) + let (file, source) = tokio::task::spawn_blocking(move || load_agent(&agents_path, &agent)) .await .unwrap_or_else(|join| { Err(LaunchError::Unreadable { @@ -311,15 +311,15 @@ impl AgentSessions { self.inner.registry.push(), self.inner.backoff.clone(), ); - tokio::spawn(self.clone().drive(conversation.clone(), source)); + tokio::spawn(self.clone().drive(conversation.clone(), file, source)); Ok(conversation) } - /// Runs `conversation`'s one run over `source`. The request's Host + /// Runs `conversation`'s one run over `source`, read from `file`. The request's Host /// snapshot is read once the menu's catalog holds a chat-capable /// model, so a pick made while the catalog was empty is the one the /// run binds; a close during that wait ends the run before it begins. - async fn drive(self, conversation: Conversation, source: String) { + async fn drive(self, conversation: Conversation, file: String, source: String) { tokio::select! { () = conversation.closing() => {} () = self.inner.broker.chat_model_ready() => {} @@ -327,6 +327,7 @@ impl AgentSessions { let request = RunRequest { name: conversation.id().to_string(), source, + file, args: String::new(), input_text: None, vfs: language_guide::run_vfs(), 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..b5c761753 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))) }) } } @@ -94,6 +95,7 @@ pub(super) async fn run_prompt_over( let request = RunRequest { name: conversation.id().to_string(), source, + file: "prompt".to_owned(), args: String::new(), input_text: None, vfs, diff --git a/crates/workshop/server/src/error.rs b/crates/workshop/server/src/error.rs index a3a6d6b44..53bdaca6b 100644 --- a/crates/workshop/server/src/error.rs +++ b/crates/workshop/server/src/error.rs @@ -18,7 +18,7 @@ use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; -use runvil::{ParseError, ParseErrorKind}; +use runvil::error::{Error, ErrorCode}; use workshop_gateway::GatewayError; use workshop_protocol::ErrorEnvelope; use workshop_support::{LEAK_DETAIL, envelope_response, render_message}; @@ -56,36 +56,37 @@ pub(crate) enum AppError { #[error("ui asset not found: {0}")] AssetMissing(String), - /// A posted prompt failed to parse; the code names the failure kind + /// A posted prompt failed to parse; the code names the failure's code /// and the message carries the parser's `line N: ` prefix. #[error("{message}")] PromptParse { /// The pre-rendered message, including the `line N: ` prefix when /// the parser located the failure. message: String, - /// The wire code for the parse-failure kind. + /// The wire code for the parse failure's code. code: &'static str, }, } impl AppError { /// Builds the wire error for a prompt that failed to parse: the code - /// names the failure kind and the message carries the parser's - /// `line N: ` prefix when it located the failure. - pub(crate) fn prompt_parse(error: &ParseError) -> Self { - let code = match error.kind() { - ParseErrorKind::Frontmatter => "parse_frontmatter", - ParseErrorKind::Structure => "parse_structure", - ParseErrorKind::Fence => "parse_fence", - ParseErrorKind::List => "parse_list", - ParseErrorKind::Lua => "parse_lua", - ParseErrorKind::Internal => "parse_internal", - // Any other kind has no wire code of its own. + /// names the failure's code and the message carries the parser's + /// `line N: ` prefix, the line the failure starts on, when it located + /// the failure. + pub(crate) fn prompt_parse(error: &Error) -> Self { + let code = match error.code { + ErrorCode::FrontmatterInvalid => "parse_frontmatter", + ErrorCode::StructureInvalid => "parse_structure", + ErrorCode::FenceInvalid => "parse_fence", + ErrorCode::ListEmptyItem => "parse_list", + ErrorCode::LuaCompile => "parse_lua", + ErrorCode::Internal => "parse_internal", + // Any other code has no wire code of its own. _ => "parse_error", }; - let message = match error.line() { - Some(line) => format!("line {line}: {error}"), - None => error.to_string(), + let message = match &error.at { + Some(at) => format!("line {}: {}", at.start_line, error.message), + None => error.message.clone(), }; Self::PromptParse { message, code } } diff --git a/crates/workshop/server/src/routes/prompts.rs b/crates/workshop/server/src/routes/prompts.rs index b66338876..66a365fd2 100644 --- a/crates/workshop/server/src/routes/prompts.rs +++ b/crates/workshop/server/src/routes/prompts.rs @@ -199,7 +199,7 @@ impl From<&Frontmatter> for ContractResponse { async fn contract(Json(body): Json) -> Response { // The contract needs the tree alone; the parse-time events are not // this route's to log. - match Prompt::parse(&body.text, &body.name).0 { + match Prompt::parse(&body.text, &body.name, &body.name).0 { Ok(prompt) => ( StatusCode::OK, Json(ContractResponse::from(prompt.frontmatter())),