From 047d27518be8be43d2b8c888d3802da0816ce56f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 02:27:53 -0800 Subject: [PATCH 01/23] Merge duplicate prompt parser paths and fix their docs This change gives each prompt parse rule one implementation and makes the doc comments state what the parser does. The alias grammar moves to `runvil_types::names`, the H1 and the sections share one compile path, and list detection uses one pass. Accepted prompts and error messages do not change. - `runvil_types::names::is_alias` is now the only alias grammar, and the `ContractKey` check, `Alias::parse`, and `tool_schema_new` call it but keep their own messages. The accept and reject cases move to `is_alias_follows_the_alias_grammar`, so the grammar cannot drift between callers while each caller's tests pass. - `SectionWalker` holds the values that the parse passes down, and its `compile_blocks` compiles both the H1 blocks and the section blocks, so `split_h1` is gone. - `split_section_blocks` gives each `RawBlock::Lua` a `LuaRole` once, and `lua_block_location` takes that role in place of four position arguments. - `#[non_exhaustive]` drops from `Prompt`, `ParseError`, `Section`, `Frontmatter`, `FileDecl`, `ArgDecl`, `ArgsDecl`, and `RoleRequirements`, whose fields are all private or crate-private, so it restricted nothing. `public-api.txt` changes to match. - `is_list` on `Section` replaces the test-only `is_list_only` as a public method, and the worker check in `tasks-spawn.rs` calls it. - `list::bullet_items` replaces `is_all_list_markers` and `parse_bullet_items` with one pass that returns `None` for prose and reports an empty item only after the full scan. `an_empty_marker_before_a_prose_line_stays_prose` pins that order, and the two unreachable errors and their tests are gone. - `level as u8` replaces `level_num`, so the heading level now comes from the `HeadingLevel` discriminant. - `max_tool_iterations` now validates in one chain, and `max_tool_iterations_reports_the_guides_messages` pins both messages, also for a value above the `u32` range. - No new test covers the Lua block locations or the shared compile path. They rely on the existing parse tests. - The Language Guide does not change in this commit. --- .../src/execute/scheduler/tasks-spawn.rs | 4 +- crates/runvil-internal/lua/src/alias.rs | 47 +-- .../model-client/src/detail.rs | 9 +- crates/runvil-internal/prompt/README.md | 2 +- crates/runvil-internal/prompt/src/contract.rs | 33 +- .../prompt/src/contract/args.rs | 27 +- .../prompt/src/contract/models.rs | 1 - crates/runvil-internal/prompt/src/error.rs | 47 ++- crates/runvil-internal/prompt/src/fence.rs | 140 +++---- .../runvil-internal/prompt/src/frontmatter.rs | 70 ++-- crates/runvil-internal/prompt/src/lib.rs | 8 +- crates/runvil-internal/prompt/src/list.rs | 81 +--- crates/runvil-internal/prompt/src/parse.rs | 47 ++- crates/runvil-internal/prompt/src/position.rs | 19 +- .../{sections-tests.rs => position/tests.rs} | 2 +- crates/runvil-internal/prompt/src/prompt.rs | 28 +- crates/runvil-internal/prompt/src/sections.rs | 377 ++++++++++-------- .../prompt/src/test_support.rs | 11 +- crates/runvil-internal/prompt/src/tests.rs | 4 +- .../prompt/src/tests/breaks.rs | 2 +- .../prompt/src/tests/frontmatter.rs | 30 +- .../runvil-internal/prompt/src/tests/lists.rs | 45 +-- .../runvil-internal/types/src/names-tests.rs | 33 +- crates/runvil-internal/types/src/names.rs | 17 + crates/runvil/public-api.txt | 14 +- 25 files changed, 533 insertions(+), 565 deletions(-) rename crates/runvil-internal/prompt/src/{sections-tests.rs => position/tests.rs} (92%) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs index a46305bce..2863d25ff 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -107,9 +107,7 @@ impl Scheduler { let worker = resolved.pos.section(&prompt).ok_or(Error::internal( "a resolved worker is absent from its slice", ))?; - // A section is list-only exactly when it parsed into list items, which - // the parser refuses for any section holding a Lua block. - if !worker.items().is_empty() { + if worker.is_list() { return Err(Error::lua_message(format!( "section `{}` is a list section, not a worker template", worker.name() diff --git a/crates/runvil-internal/lua/src/alias.rs b/crates/runvil-internal/lua/src/alias.rs index 2abcc5947..b3c2e0887 100644 --- a/crates/runvil-internal/lua/src/alias.rs +++ b/crates/runvil-internal/lua/src/alias.rs @@ -1,12 +1,13 @@ -//! The one prompt-local alias grammar, shared by the `tools` and `models` -//! Engine globals. +//! The [`Alias`] type: an adhoc tool's name, checked against the +//! prompt-local alias grammar of [`runvil_types::names::is_alias`]. + +use runvil_types::names::is_alias; use crate::error::{Error, Result}; /// A prompt-local alias that passed the supported wire grammar. /// -/// An adhoc tool's name and a model label share the one rule. An alias -/// never contains `/`, so it never reads as a tool id. +/// An alias never contains `/`, so it never reads as a tool id. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Alias(String); @@ -18,13 +19,7 @@ impl Alias { /// with a non-letter, or contains a character other than a letter, /// digit, `_`, or `-` after its first byte. pub(crate) fn parse(alias: String) -> Result { - let bytes = alias.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if valid { + if is_alias(&alias) { Ok(Alias(alias)) } else { Err(Error::Lua(format!( @@ -44,38 +39,8 @@ impl From for String { mod tests { use super::*; - #[test] - fn the_alias_grammar_accepts_letters_digits_underscores_and_dashes() { - for alias in [ - "a", - "A", - "search", - "web_fetch", - "web-fetch2", - &"z".repeat(64), - ] { - let parsed = Alias::parse(alias.to_owned()).expect("the alias validates"); - assert_eq!(String::from(parsed), alias, "{alias:?} keeps its text"); - } - } - #[test] fn the_alias_grammar_rejects_every_other_shape() { - for alias in [ - "", - "1search", - "_search", - "-search", - "web fetch", - "web.fetch", - "web/fetch", - &"a".repeat(65), - ] { - assert!( - Alias::parse(alias.to_owned()).is_err(), - "{alias:?} must not validate" - ); - } let error = Alias::parse("1search".to_owned()).expect_err("the message names the grammar"); assert!( error diff --git a/crates/runvil-internal/model-client/src/detail.rs b/crates/runvil-internal/model-client/src/detail.rs index 392b39aae..d135bce2a 100644 --- a/crates/runvil-internal/model-client/src/detail.rs +++ b/crates/runvil-internal/model-client/src/detail.rs @@ -6,6 +6,7 @@ //! one that splits a completion into the result the tool loop consumes and //! the metadata the round reports. +use runvil_types::names::is_alias; use serde_json::Value; use crate::client::{ @@ -68,13 +69,7 @@ pub fn tool_schema_new( parameters: Value, ) -> Result { let name = name.into(); - let bytes = name.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if !valid { + if !is_alias(&name) { return Err(ToolSchemaError::InvalidName { name, reason: "expected [A-Za-z][A-Za-z0-9_-]{0,63}", diff --git a/crates/runvil-internal/prompt/README.md b/crates/runvil-internal/prompt/README.md index ece6bf035..8b3227e8b 100644 --- a/crates/runvil-internal/prompt/README.md +++ b/crates/runvil-internal/prompt/README.md @@ -1,3 +1,3 @@ # Prompt loader -This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. It does no execution. +This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence in the H1 body and the sections compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. It does no execution. diff --git a/crates/runvil-internal/prompt/src/contract.rs b/crates/runvil-internal/prompt/src/contract.rs index ff6a68e06..93b08e6b4 100644 --- a/crates/runvil-internal/prompt/src/contract.rs +++ b/crates/runvil-internal/prompt/src/contract.rs @@ -2,14 +2,16 @@ //! //! The YAML is the whole contract: Packs install, models declare, args //! type. Parsing validates the static shape - each `uses:` entry a bare -//! Pack id or vendor/name, no Pack id declared twice, the alias grammar -//! on arg names, the five model role names, arg name and type sanity - and -//! exposes the FULL declaration on the parsed [`Prompt`](crate::Prompt); -//! checking the declaration against the caller's environment is the job of -//! the run's first step, never the parser's. +//! Pack id or vendor/name, no Pack id declared twice, no reserved Pack id, +//! the alias grammar on arg names, the five model role names, and each arg +//! default against its declared type - and exposes the FULL declaration on +//! the parsed [`Prompt`](crate::Prompt); checking the declaration against +//! the caller's environment is the job of the run's first step, never the +//! parser's. //! -//! `args` and `models` are defined in submodules; this root owns the -//! duplicate-Pack check and the map deserializer the two map keys share. +//! `args` and `models` are defined in submodules; this root owns the `uses:` +//! checks, the arg-name check, and the map deserializer the two map keys +//! share. use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -17,6 +19,7 @@ use std::fmt; use std::marker::PhantomData; use runvil_types::models::ModelRole; +use runvil_types::names::is_alias; use runvil_types::packs::{PackId, PackRef}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; @@ -31,20 +34,6 @@ pub use args::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter}; pub(crate) use models::RoleMap; pub use models::RoleRequirements; -/// The prompt-local alias grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. -/// -/// Args field names are prompt-local and never global names. (The same rule -/// sits in `runvil-lua`'s `alias` module, the run-time counterpart to this -/// parse-time check.) -fn is_valid_alias(alias: &str) -> bool { - let bytes = alias.as_bytes(); - (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - /// A key of a contract map: how it is checked and how an error names it. trait ContractKey: Ord { /// Refuses a key the map's grammar does not allow. @@ -56,7 +45,7 @@ trait ContractKey: Ord { impl ContractKey for String { fn check(&self, what: &str) -> Result<(), String> { - if is_valid_alias(self) { + if is_alias(self) { Ok(()) } else { Err(format!( diff --git a/crates/runvil-internal/prompt/src/contract/args.rs b/crates/runvil-internal/prompt/src/contract/args.rs index 8dd5c634e..852ad8c23 100644 --- a/crates/runvil-internal/prompt/src/contract/args.rs +++ b/crates/runvil-internal/prompt/src/contract/args.rs @@ -1,9 +1,8 @@ //! Parses the `args` frontmatter key into typed arg declarations. //! -//! The declaration advertises, documents, and derives the tool schema; it -//! does not enforce - enforcement belongs to the prompt's H1. There are no -//! freeform prompts: a prompt with no `args:` key gets the default -//! declaration of one optional string field named `prose`. +//! The declaration advertises and documents the prompt's args; it does not +//! enforce - enforcement belongs to the prompt's H1. There are no freeform +//! prompts: a prompt with no `args:` key gets the default [`ArgsDecl`]. use std::collections::{BTreeMap, btree_map}; @@ -47,7 +46,6 @@ impl std::fmt::Display for ArgType { /// The declaration of one prompt arg: its type, whether it is optional, its /// default, and its description. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct ArgDecl { kind: ArgType, optional: bool, @@ -84,7 +82,8 @@ impl ArgDecl { } } -/// The map form of an arg declaration (`type` is a Rust keyword). +/// The unchecked map form of an arg declaration: [`ArgDecl`]'s `Deserialize` +/// reads it, then refuses a `default` whose YAML type does not match `type`. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ArgDeclWire { @@ -155,18 +154,12 @@ impl<'de> Deserialize<'de> for ArgDecl { /// /// Every prompt has an args declaration. A prompt whose frontmatter omits /// the `args` key gets the default declaration: one optional string arg -/// named `prose`. The input text of such a prompt arrives wrapped as +/// named `prose`. The argument string of such a prompt arrives wrapped as /// `argv = { prose = "" }`. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct ArgsDecl { fields: BTreeMap, - /// True only when the declaration is the implicit default (the `args:` - /// key was absent). - /// - /// Interface prose then wraps into `argv.prose`. An explicit `args:` - /// key - even one identical to the default shape - is a structured - /// declaration and never wraps. + /// Whether the `args:` key was absent; see [`ArgsDecl::is_default`]. implicit: bool, } @@ -174,9 +167,8 @@ impl ArgsDecl { /// Returns whether this is the default declaration, which a prompt gets /// when its frontmatter omits the `args` key. /// - /// The input text of such a prompt arrives wrapped as `argv.prose`. This - /// returns `false` for every explicit `args` key, even one that declares - /// the same single `prose` arg. + /// This returns `false` for every explicit `args` key, even one that + /// declares the same single `prose` arg. #[must_use] pub fn is_default(&self) -> bool { self.implicit @@ -242,7 +234,6 @@ impl<'a> Iterator for ArgsDeclIter<'a> { impl ExactSizeIterator for ArgsDeclIter<'_> {} impl Default for ArgsDecl { - /// The default declaration: one optional string field named `prose`. fn default() -> Self { let mut fields = BTreeMap::new(); fields.insert( diff --git a/crates/runvil-internal/prompt/src/contract/models.rs b/crates/runvil-internal/prompt/src/contract/models.rs index 7634bdd3c..1f1a9a326 100644 --- a/crates/runvil-internal/prompt/src/contract/models.rs +++ b/crates/runvil-internal/prompt/src/contract/models.rs @@ -24,7 +24,6 @@ use crate::contract; /// requires a model that can run without thinking and turns it off. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct RoleRequirements { /// The minimum context window in tokens. #[serde(default)] diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index 15a7f24ea..958907185 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -24,19 +24,22 @@ type BoxedSource = Box; /// internal type variant-for-variant; the facade does not re-export it. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The prompt frontmatter was not valid YAML. + /// The prompt frontmatter did not decode: it was not valid YAML, or a key + /// or value broke the frontmatter schema, such as an unknown key or an arg + /// default of the wrong type. /// /// Keeps the decode failure as the cause beside the location it reports. #[error("invalid frontmatter: {message}")] ParseFrontmatter { /// The human-readable diagnostic (no raw source dump). message: String, - /// The originating YAML parse failure, whose text `message` holds. + /// The originating YAML decode failure, whose text `message` holds. cause: BoxedSource, /// The 1-based file line of the YAML failure, surfaced from the /// cause's location when it has one. line: Option, - /// The 1-based file column of the YAML failure, when known. + /// The 1-based file column of the YAML failure, counted in characters + /// as the YAML parser counts them, when known. column: Option, }, @@ -54,8 +57,8 @@ pub enum Error { span: Option>, /// The human-readable diagnostic. message: String, - /// The prompt's frontmatter name, stamped when the failure postdates - /// the frontmatter (a frontmatter failure predates the name). + /// The prompt's frontmatter name, stamped on a failure found in the + /// body or raised by the Engine. name: Option, /// The 1-based file line of the span's start, computed against the /// source when a span is known. @@ -64,9 +67,9 @@ pub enum Error { column: Option, }, - /// A Lua region failed to compile at parse time, preserved as the - /// `runvil-lua` internal error type so the compiler diagnostic - /// chain survives unchanged. + /// A Lua region failed to compile at parse time, or the compiler VM could + /// not start, preserved as the `runvil-lua` internal error type so the + /// compiler diagnostic chain survives unchanged. #[error(transparent)] Lua(#[from] runvil_lua::Error), @@ -129,8 +132,8 @@ impl Error { /// /// The position is the span's 1-based file line and byte column, when /// the failure has a span. Every other variant passes through unchanged: - /// a frontmatter failure predates the name, a Lua compile failure already - /// reports its own position, and an internal failure has none. + /// a frontmatter failure predates the name, a Lua failure reports its own + /// position, if it has one, and an internal failure has none. pub(crate) fn with_prompt_context( self, name: &str, @@ -165,9 +168,9 @@ impl Error { /// Returns the 1-based file line and byte column of `byte_offset` within /// `body`, offset past the frontmatter lines. /// -/// Both are `None` when the offset is out of bounds or the arithmetic -/// overflows - an invariant break that must not replace the original parse -/// failure. +/// Both are `None` when the offset is out of bounds, and either is `None` +/// alone when its own arithmetic overflows - an invariant break that must not +/// replace the original parse failure. fn body_line_column( body: &str, byte_offset: usize, @@ -208,7 +211,7 @@ pub enum ParseErrorKind { /// fence looked like a `lua` opening line (Lookalike). Lines inside the /// body of another fenced code block are never inspected. Fence, - /// A list-only section contained prose lines or empty items. + /// A list section had an empty item. List, /// A Lua region failed to compile because of a syntax error. Lua, @@ -223,7 +226,6 @@ pub enum ParseErrorKind { /// failure happened. It keeps the originating error, when there is one, /// which [`cause`](ParseError::cause) returns. #[derive(Debug)] -#[non_exhaustive] pub struct ParseError { inner: Box, } @@ -296,13 +298,12 @@ impl ParseError { classify_parse_error(&self.inner).span } - /// Returns the prompt's frontmatter name, when the failure came after the - /// frontmatter was read. + /// Returns the prompt's frontmatter name for a `Structure`, `Fence`, or + /// `List` failure found past the frontmatter. /// - /// A frontmatter failure happens before the parser learns the name, which - /// comes from the frontmatter itself. It returns `None`, and the caller - /// can use its own label for the source. A Lua compile failure - /// also returns `None`. + /// Every other failure returns `None`, including every `Frontmatter` + /// failure, a refused `uses:` list among them, and every `Lua` and + /// `Internal` failure. The caller can use its own label for the source. #[must_use] pub fn name(&self) -> Option<&str> { match &*self.inner { @@ -322,6 +323,10 @@ impl ParseError { } /// Returns the 1-based column of the failure, when known. + /// + /// For a frontmatter YAML failure, this is the column the YAML parser + /// reported, which counts characters. For a failure with a byte span, it + /// is the byte column where the span starts. #[must_use] pub fn column(&self) -> Option { classify_parse_error(&self.inner).column diff --git a/crates/runvil-internal/prompt/src/fence.rs b/crates/runvil-internal/prompt/src/fence.rs index 62395932f..dd4d49a1d 100644 --- a/crates/runvil-internal/prompt/src/fence.rs +++ b/crates/runvil-internal/prompt/src/fence.rs @@ -1,78 +1,40 @@ //! Checks the strict `lua` fence rule and splits content into Lua and prose blocks. //! //! H1 and section content is an alternating sequence of `lua` fences and -//! prose. A `lua` fence opens with a line that is exactly three backticks -//! then `lua` and closes with a line that is exactly three backticks. This -//! module finds the first fence in the body that breaks the Label, Opener, or -//! Closer rule, or the first Lookalike line, so the parse can reject it, and -//! splits content into [`RawBlock`]s that the facade compiles into -//! [`Block`]s. Lines inside the body of another fenced code block are never -//! inspected, which is how a document shows a Lua fence as an example. +//! prose. [`Prompt::parse`](crate::Prompt::parse) states the strict fence +//! rule. This module finds the first fence or line in the body that breaks +//! its Label, Opener, Closer, or Lookalike case, so the parse can reject it, +//! and splits content into [`RawBlock`]s, which this crate compiles into +//! [`Block`](crate::Block)s. use std::ops::Range; -use std::sync::Arc; use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; -use runvil_types::emitter::Emitter; -use crate::{Block, Error, ParseErrorKind, Result, position}; -use runvil_lua::LuaProgram; +use crate::{Error, ParseErrorKind, Result, position}; /// Uncompiled block produced while scanning section content. -pub(super) enum RawBlock<'a> { +pub(crate) enum RawBlock<'a> { Lua { source: String, /// Lines before the first line of Lua source within section content. line_offset: u32, + /// The block's place in its section, which names it in its compile + /// location. + role: LuaRole, }, Prose(&'a str), } -/// Compiles H1 content as alternating live Lua and prose blocks. -/// -/// `content` is the H1's stretch of `body`, and `content_abs_line` is the -/// 1-based line number in the full input where `content` begins. -/// -/// # Errors -/// Returns a fence-classified parse error for an unclosed fence, and a Lua -/// compilation error for invalid Lua. -pub(super) fn split_h1( - body: &Arc, - content: &str, - title: &str, - content_abs_line: u32, - emitter: &Emitter, -) -> Result> { - let raw_blocks = split_section_blocks(content, title)?; - let mut blocks = Vec::with_capacity(raw_blocks.len()); - for raw in raw_blocks { - match raw { - RawBlock::Prose(text) => blocks.push(Block::Prose { - text: position::body_text(body, text)?, - }), - RawBlock::Lua { - source, - line_offset, - } => { - let location = format!("H1 `{title}` lua"); - let line = position::line_add(content_abs_line, line_offset)?; - blocks.push(Block::Lua(LuaProgram::compile( - &source, - &location, - position::nz_source_line(line)?, - emitter, - title, - )?)); - } - } - } - if matches!( - blocks.as_slice(), - [Block::Prose { text }] if text.as_str().is_empty() - ) { - blocks.clear(); - } - Ok(blocks) +/// A Lua block's place among its section's blocks. +pub(crate) enum LuaRole { + /// The first block. + Prologue, + /// Neither first nor last, with its 1-based place among the section's + /// Lua blocks. + Middle(usize), + /// The last block, when it is not also the first. + Epilog, } /// Removes complete leading lines that contain only whitespace. @@ -92,7 +54,7 @@ pub(crate) fn trim_leading_blank_lines(content: &str) -> &str { /// closes the fence on. The validator refuses any closing line that is not /// exactly three backticks before a split runs, so a split that meets one has /// broken an invariant, and it reports an internal error. -fn extract_exact_fence<'a>(content: &'a str, label: &str) -> Result<(String, &'a str)> { +fn extract_exact_fence<'a>(content: &'a str, location: &str) -> Result<(String, &'a str)> { let mut offset = 0; for line in content.split_inclusive('\n') { let text = line.strip_suffix('\n').unwrap_or(line); @@ -110,7 +72,7 @@ fn extract_exact_fence<'a>(content: &'a str, label: &str) -> Result<(String, &'a } Err(Error::parse( ParseErrorKind::Fence, - format!("{label} fence is not closed"), + format!("{location} fence is not closed"), )) } @@ -153,7 +115,7 @@ fn exact_lua_openings(content: &str) -> Vec { } /// The rule a fence or line breaks, as found by [`first_fence_violation`]. -pub(super) enum FenceViolation { +pub(crate) enum FenceViolation { /// Label: the fence's label starts with `lua` in any letter case without /// being exactly `lua`. Label(String), @@ -177,7 +139,7 @@ pub(super) enum FenceViolation { /// fence, such as in an indented code block or a raw HTML block, and breaks /// the Opener rule. Lines inside a fence's extent are never checked against /// the Lookalike rule, so a fence nested in another fence's body never fails. -pub(super) fn first_fence_violation(body: &str) -> Option<(Range, FenceViolation)> { +pub(crate) fn first_fence_violation(body: &str) -> Option<(Range, FenceViolation)> { let mut extents = Vec::new(); let mut fence = None; for (extent, label) in fence_openings(body) { @@ -304,7 +266,7 @@ fn pending_prose<'a>(content: &'a str, segment: Range, rules: &[Range(content: &'a str, segment: Range, rules: &[Range( +pub(crate) fn split_section_blocks<'a>( content: &'a str, section: &str, ) -> Result>> { @@ -344,15 +306,13 @@ pub(super) fn split_section_blocks<'a>( "parser: section fence split disagrees with the Markdown parser", )); }; - // Preserve classic location wording for unclosed leading/trailing fences. - let label = if index == 0 && opening == leading_start { + // An unclosed fence runs to the end of its content, so it is always the last block. + let location = if index == 0 && opening == leading_start { format!("section `{section}` prologue `lua`") - } else if index + 1 == openings.len() { - format!("section `{section}` epilog `lua`") } else { - format!("section `{section}` `lua`") + format!("section `{section}` epilog `lua`") }; - let (source, rest) = extract_exact_fence(after_open, &label)?; + let (source, rest) = extract_exact_fence(after_open, &location)?; let fence_end = content.len() - rest.len(); if !(pos == 0 && opening == leading_start) { @@ -364,6 +324,7 @@ pub(super) fn split_section_blocks<'a>( blocks.push(RawBlock::Lua { source, line_offset, + role: LuaRole::Middle(index + 1), }); pos = fence_end; } @@ -373,30 +334,29 @@ pub(super) fn split_section_blocks<'a>( blocks.push(RawBlock::Prose(trailing)); } + if let Some(RawBlock::Lua { role, .. }) = blocks.first_mut() { + *role = LuaRole::Prologue; + } + if blocks.len() > 1 + && let Some(RawBlock::Lua { role, .. }) = blocks.last_mut() + { + *role = LuaRole::Epilog; + } + Ok(blocks) } -/// Compile label for one Lua block of the section named by `label`, -/// preserving classic prologue/epilog names. +/// The compile location of one Lua block, from its section's location and +/// its role. /// -/// Every other block is named by `ordinal`, its 1-based place among the -/// section's Lua blocks. The label is the display name mapped runtime -/// errors give the block; mapping itself matches the block's line-based -/// chunk name, so the label need not be unique or short. -pub(super) fn lua_block_location( - label: &str, - index: usize, - total: usize, - has_prose: bool, - ordinal: usize, -) -> String { - let is_first = index == 0; - let is_last = index + 1 == total; - if is_first { - return format!("{label} prologue"); - } - if is_last && has_prose { - return format!("{label} epilog"); +/// A middle block is named by its 1-based place among the section's Lua +/// blocks. The location is the display name mapped runtime errors give the +/// block; mapping itself matches the block's line-based chunk name, so the +/// location need not be unique or short. +pub(crate) fn lua_block_location(section_location: &str, role: &LuaRole) -> String { + match role { + LuaRole::Prologue => format!("{section_location} prologue"), + LuaRole::Epilog => format!("{section_location} epilog"), + LuaRole::Middle(n) => format!("{section_location} lua {n}"), } - format!("{label} lua {ordinal}") } diff --git a/crates/runvil-internal/prompt/src/frontmatter.rs b/crates/runvil-internal/prompt/src/frontmatter.rs index 0ed15e91f..34ba41bab 100644 --- a/crates/runvil-internal/prompt/src/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/frontmatter.rs @@ -11,11 +11,9 @@ use crate::{ArgsDecl, Error, ParseErrorKind, Result, RoleRequirements}; /// A declared input or output file in a prompt's frontmatter. /// /// The `path` is the file's name in the store, which the prompt reads or -/// writes. The `description` documents the file's purpose and also feeds MCP -/// schema generation. +/// writes. The `description` documents the file's purpose for the caller. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct FileDecl { /// The store-internal path (e.g. `"paper.md"`). path: String, @@ -43,18 +41,17 @@ impl FileDecl { /// a misspelled or extra one, is a prompt authoring error and fails the parse. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields)] -#[non_exhaustive] pub struct Frontmatter { - /// The prompt's identifier, supplied explicitly by a caller. - pub(crate) name: String, - /// One-line description shown in prompt listings and name retrieval. - pub(crate) description: String, + /// The prompt's identifier, from the `name:` key. + name: String, + /// The prompt's one-line summary, from the `description:` key. + description: String, /// The runvil Engine major this file targets, when declared. /// /// Its presence marks the file as a runvil prompt; `None` means the /// file is not one. #[serde(default)] - pub(crate) runvil: Option, + runvil: Option, /// Maximum model round trips a section's tool-call loop may take. /// /// An absent value leaves the runtime's own cap in force. A declared @@ -62,7 +59,7 @@ pub struct Frontmatter { /// negatives, and values above [`MAX_TOOL_ITERATIONS`], so no invalid /// cap can reach execution. #[serde(default, deserialize_with = "max_tool_iterations")] - pub(crate) max_tool_iterations: Option, + max_tool_iterations: Option, /// A file the prompt expects to find in the store when it starts. #[serde(default)] input: Option, @@ -75,7 +72,7 @@ pub struct Frontmatter { #[serde(default)] uses: Vec, /// The typed args declaration; an absent `args:` key yields the default - /// declaration (one optional string field named `prose`). + /// [`ArgsDecl`]. #[serde(default)] args: ArgsDecl, /// The requirements declared per model role. @@ -104,15 +101,16 @@ where "max_tool_iterations must be a positive integer (>= 1), got {raw}" ))); } - if raw > i64::from(MAX_TOOL_ITERATIONS) { - return Err(serde::de::Error::custom(format!( - "max_tool_iterations must be <= {MAX_TOOL_ITERATIONS}, got {raw}" - ))); - } - // 1 <= raw <= MAX_TOOL_ITERATIONS, so both conversions are infallible. - let value = u32::try_from(raw) - .map_err(|_| serde::de::Error::custom("max_tool_iterations is out of range"))?; - Ok(NonZeroU32::new(value)) + u32::try_from(raw) + .ok() + .and_then(NonZeroU32::new) + .filter(|v| v.get() <= MAX_TOOL_ITERATIONS) + .map(Some) + .ok_or_else(|| { + serde::de::Error::custom(format!( + "max_tool_iterations must be <= {MAX_TOOL_ITERATIONS}, got {raw}" + )) + }) } impl Frontmatter { @@ -122,7 +120,7 @@ impl Frontmatter { &self.name } - /// Returns the one-line description shown in listings. + /// Returns the prompt's one-line summary. #[must_use] pub fn description(&self) -> &str { &self.description @@ -172,8 +170,7 @@ impl Frontmatter { /// Returns the typed args declaration. /// - /// A prompt that omits the `args:` key yields the default declaration - /// (one optional string field named `prose`). + /// A prompt that omits the `args:` key yields the default [`ArgsDecl`]. #[must_use] pub fn args(&self) -> &ArgsDecl { &self.args @@ -187,14 +184,25 @@ impl Frontmatter { } } -/// Splits a file into its YAML frontmatter, its markdown body, and the -/// number of lines consumed by the frontmatter block (both `---` delimiters -/// and everything between them). +/// A prompt file split at its frontmatter by [`split_frontmatter`]. +#[derive(Debug)] +pub(crate) struct SplitSource { + /// The YAML text between the two `---` delimiters. + pub(crate) yaml: String, + /// The markdown body after the closing `---` line. + pub(crate) body: String, + /// The number of lines consumed by the frontmatter block (both `---` + /// delimiters and everything between them). + pub(crate) frontmatter_lines: u32, +} + +/// Splits a file into its YAML frontmatter and its markdown body. /// /// The file must open with a `---` line and close the frontmatter with another /// `---` line starting at column 0; an indented `---` is YAML block scalar -/// text. `str::lines` handles both `\n` and `\r\n`. -pub(crate) fn split_frontmatter(input: &str) -> Result<(String, String, u32)> { +/// text. `str::lines` handles both `\n` and `\r\n`. Whitespace before or after +/// the dashes of the opening line is ignored. +pub(crate) fn split_frontmatter(input: &str) -> Result { let input = input.strip_prefix('\u{feff}').unwrap_or(input); // drop BOM let mut lines = input.lines(); match lines.next() { @@ -225,5 +233,9 @@ pub(crate) fn split_frontmatter(input: &str) -> Result<(String, String, u32)> { )); } let body = lines.collect::>().join("\n"); - Ok((yaml, body, line_count)) + Ok(SplitSource { + yaml, + body, + frontmatter_lines: line_count, + }) } diff --git a/crates/runvil-internal/prompt/src/lib.rs b/crates/runvil-internal/prompt/src/lib.rs index 1a40cd632..782703d43 100644 --- a/crates/runvil-internal/prompt/src/lib.rs +++ b/crates/runvil-internal/prompt/src/lib.rs @@ -4,7 +4,8 @@ //! blocks, then H2 sections. //! H1 and section content are alternating sequences of exact `lua` fences and //! prose ([`Block`]). Sections nest recursively (H3 under H2, H4 under H3, and -//! so on through H6). Classic prologue/prose/epilog is exactly `[Lua, Prose, Lua]`. +//! so on through H6). A section with a prologue, prose, and an epilog is +//! exactly `[Lua, Prose, Lua]`. //! //! Prose capture follows the pending-Markdown model: Markdown accumulates //! after each heading or ordinary `lua` fence, and each prose block is the @@ -14,8 +15,9 @@ //! normally. Markdown left after the final `lua` fence is inert trailing //! commentary, never an error. //! -//! Every `lua` fence compiles at load time, so a loaded [`Prompt`] is ready to -//! run: a syntax error surfaces here, never partway through a run. +//! Every `lua` fence in the H1 body and the sections compiles at load time, so +//! a loaded [`Prompt`] is ready to run: a syntax error surfaces here, never +//! partway through a run. //! //! ## Invariants //! diff --git a/crates/runvil-internal/prompt/src/list.rs b/crates/runvil-internal/prompt/src/list.rs index c844e945a..b4a1302c1 100644 --- a/crates/runvil-internal/prompt/src/list.rs +++ b/crates/runvil-internal/prompt/src/list.rs @@ -1,24 +1,18 @@ //! Detects list-only sections and extracts their bullet items. -//! -//! A no-Lua section whose every nonblank line is a bullet marker is a list; one -//! marker classifier (`classify_list_line`) feeds both detection -//! ([`is_all_list_markers`]) and extraction ([`parse_bullet_items`]) so the two -//! can never disagree about what counts as a marker. use crate::{Error, ParseErrorKind, Result}; -/// Parses bullet items from prose in a list-only section. -/// -/// A list-only section has no Lua blocks. Its prose must contain only unordered -/// (`- ` or `* `) or ordered (`N. ` or `N) `) bullet lines, with blank lines -/// ignored. Returns an error if the section contains non-list content or if the -/// items list is empty. +/// Returns the items of a list section's prose, or `None` when the prose is +/// not a list. Prose is a list when every nonblank line is a bullet marker +/// and at least one is, so one incidental bullet in ordinary prose leaves it +/// prose. /// /// # Errors -/// Returns a list-classified parse error when a line is a non-list line, an -/// empty marker, or when no items were found. -pub(super) fn parse_bullet_items<'a>(prose: &'a str, section: &str) -> Result> { +/// Returns a list-classified parse error when the prose is a list and a +/// marker has no content. +pub(crate) fn bullet_items<'a>(prose: &'a str, section: &str) -> Result>> { let mut items = Vec::new(); + let mut has_empty_marker = false; for line in prose.lines() { let trimmed = line.trim(); if trimmed.is_empty() { @@ -26,60 +20,20 @@ pub(super) fn parse_bullet_items<'a>(prose: &'a str, section: &str) -> Result items.push(content), - ListLine::EmptyMarker => { - return Err(Error::parse( - ParseErrorKind::List, - format!("empty bullet item in list section `{section}`"), - )); - } - ListLine::NotAMarker => { - return Err(Error::parse( - ParseErrorKind::List, - format!( - "section `{section}` is a list section but contains non-list content: {trimmed}" - ), - )); - } + ListLine::EmptyMarker => has_empty_marker = true, + ListLine::NotAMarker => return Ok(None), } } - if items.is_empty() { + if has_empty_marker { return Err(Error::parse( ParseErrorKind::List, - format!("section `{section}` is a list section but has no items"), + format!("empty bullet item in list section `{section}`"), )); } - Ok(items) -} - -/// Returns true when `prose` is entirely list markers. -/// -/// Every nonblank line must be a list marker (well-formed or empty), and at -/// least one such line must exist. This is the list-only invariant: a single -/// incidental bullet line in otherwise ordinary prose leaves a non-marker -/// line present, so the section is classified as prose rather than forced -/// through strict list parsing. A section whose lines are all markers but -/// include an empty one is still a list, so [`parse_bullet_items`] reports -/// the empty item. -pub(super) fn is_all_list_markers(prose: &str) -> bool { - let mut saw_marker = false; - for line in prose.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match classify_list_line(trimmed) { - ListLine::NotAMarker => return false, - ListLine::Item(_) | ListLine::EmptyMarker => saw_marker = true, - } - } - saw_marker + Ok((!items.is_empty()).then_some(items)) } /// The classification of a single nonblank prose line as a list marker. -/// -/// One classifier feeds both list *detection* (is this section a list?) and -/// *extraction* (parse its items), so the two can never disagree about what -/// counts as a marker. enum ListLine<'a> { /// A well-formed marker with nonblank content (the returned item text). Item(&'a str), @@ -165,8 +119,11 @@ mod tests { #[test] fn all_markers_predicate() { - assert!(is_all_list_markers("- a\n1. b")); - assert!(!is_all_list_markers("- a\nplain line")); - assert!(!is_all_list_markers(" \n ")); + assert_eq!( + bullet_items("- a\n1. b", "test").unwrap(), + Some(vec!["a", "b"]) + ); + assert_eq!(bullet_items("- a\nplain line", "test").unwrap(), None); + assert_eq!(bullet_items(" \n ", "test").unwrap(), None); } } diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 52d408db2..72085e3c9 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -9,8 +9,8 @@ use runvil_types::event::{Event, lifecycle}; use crate::contract::{self, PackListError}; use crate::fence::{self, FenceViolation}; use crate::position; -use crate::sections::{self, Heading}; -use crate::{Error, Frontmatter, ParseError, ParseErrorKind, Prompt, Result, frontmatter}; +use crate::sections::{self, Heading, SectionWalker}; +use crate::{Block, Error, Frontmatter, ParseError, ParseErrorKind, Prompt, Result, frontmatter}; impl Prompt { /// Parses the full source text of a prompt file into a [`Prompt`] and @@ -32,7 +32,8 @@ impl Prompt { /// /// - `Frontmatter` when the `---` delimiters are missing, the frontmatter /// fails to decode, a `uses:` entry is neither a Pack id nor - /// vendor/name, or two entries share a Pack id. + /// vendor/name, two entries share a Pack id, or an entry names a Pack id + /// the Engine reserves. /// - `Structure` when the H1 is missing, there is more than one H1, the H1 /// title is empty, a section heading skips a level (such as an H4 /// directly under an H2), a section heading is empty, or two sibling @@ -76,9 +77,10 @@ impl Prompt { } fn parse_inner(input: &str, emitter: &Emitter) -> Result { - let (yaml, body, frontmatter_lines) = frontmatter::split_frontmatter(input)?; - let body: Arc = Arc::from(body); - let frontmatter: Frontmatter = serde_yaml_ng::from_str(&yaml).map_err(|e| { + let split = frontmatter::split_frontmatter(input)?; + let frontmatter_lines = split.frontmatter_lines; + let shared: Arc = Arc::from(split.body); + let frontmatter: Frontmatter = serde_yaml_ng::from_str(&split.yaml).map_err(|e| { // The location is relative to the frontmatter block, which starts // on file line 2 (line 1 is the opening `---` delimiter). let (line, column) = e.location().map_or((None, None), |location| { @@ -109,8 +111,8 @@ impl Prompt { // Everything past the frontmatter postdates the prompt's name, so a // failure from here on is stamped with it (and its span's position). let name = frontmatter.name().to_owned(); - Self::parse_body(frontmatter, &body, frontmatter_lines, emitter) - .map_err(|error| error.with_prompt_context(&name, &body, frontmatter_lines)) + Self::parse_body(frontmatter, &shared, frontmatter_lines, emitter) + .map_err(|error| error.with_prompt_context(&name, &shared, frontmatter_lines)) } fn parse_body( @@ -137,7 +139,7 @@ impl Prompt { let lines = h1_positions .iter() .map(|&index| { - position::line_add(frontmatter_lines, headings[index].source_line) + position::line_add(frontmatter_lines, headings[index].body_line) .map(|line| line.to_string()) }) .collect::>>()?; @@ -151,14 +153,15 @@ impl Prompt { )); }; let h1 = &headings[*h1_index]; - if h1.title.trim().is_empty() { + if h1.title.is_empty() { return Err(Error::parse( ParseErrorKind::Structure, "prompt H1 title must not be empty", )); } let title = h1.title.clone(); - let h1_content_abs_line = position::line_add(frontmatter_lines, h1.content_start_line)?; + let h1_content = h1.content; + let h1_content_abs_line = position::line_add(frontmatter_lines, h1.content_body_line)?; if let Some((line, violation)) = fence::first_fence_violation(body) { let message = match violation { FenceViolation::Label(label) => { @@ -171,7 +174,6 @@ impl Prompt { }; return Err(Error::parse_at(ParseErrorKind::Fence, line, message)); } - let h1_blocks = fence::split_h1(shared, h1.content, &title, h1_content_abs_line, emitter)?; // Everything before the H1 is preface and has no prompt semantics. // Sections are headings after the H1 at level 2 or deeper. @@ -180,15 +182,20 @@ impl Prompt { .skip(*h1_index + 1) .filter(|h| h.level >= 2) .collect(); - let mut pos = 0; - let sections = sections::build_sections( - shared, - §ion_headings, - &mut pos, - 1, - frontmatter_lines, - emitter, + let mut walker = SectionWalker::new(shared, §ion_headings, frontmatter_lines, emitter); + let mut h1_blocks = walker.compile_blocks( + fence::split_section_blocks(h1_content, &title)?, + h1_content_abs_line, + &title, + |_| format!("H1 `{title}` lua"), )?; + if matches!( + h1_blocks.as_slice(), + [Block::Prose { text }] if text.as_str().is_empty() + ) { + h1_blocks.clear(); + } + let sections = walker.build_sections(1)?; Ok(Prompt { frontmatter, diff --git a/crates/runvil-internal/prompt/src/position.rs b/crates/runvil-internal/prompt/src/position.rs index db0d83b85..633bb1f1a 100644 --- a/crates/runvil-internal/prompt/src/position.rs +++ b/crates/runvil-internal/prompt/src/position.rs @@ -9,9 +9,8 @@ use crate::{BodyText, Error, Result}; /// Wraps a computed 1-based source line for the Lua compiler. /// -/// Line numbers computed during parsing are always at least 1. A zero here means -/// a broken source-position invariant; rather than silently coercing it to line -/// one, this reports a structured internal parse error. +/// Line numbers computed during parsing are always at least 1, so a zero means +/// a broken source-position invariant. /// /// # Errors /// Returns [`Error::Internal`] when `line` is zero. @@ -21,7 +20,8 @@ pub(crate) fn nz_source_line(line: u32) -> Result { )) } -/// Adds two 1-based line components with overflow checking. +/// Adds two line components, at most one of them 1-based, with overflow +/// checking. /// /// # Errors /// Returns [`Error::Internal`] when the sum overflows `u32`, rather than @@ -31,12 +31,12 @@ pub(crate) fn line_add(a: u32, b: u32) -> Result { .ok_or(Error::Internal("parser: source line arithmetic overflowed")) } -/// Locates `part` inside the shared `text` it was sliced from. +/// Locates `part` inside `shared`, the text it was sliced from. /// /// # Errors -/// Returns [`Error::Internal`] when `part` is not a slice of `text`. -pub(crate) fn body_text(text: &Arc, part: &str) -> Result { - BodyText::within(text, part).ok_or(Error::Internal( +/// Returns [`Error::Internal`] when `part` is not a slice of `shared`. +pub(crate) fn body_text(shared: &Arc, part: &str) -> Result { + BodyText::within(shared, part).ok_or(Error::Internal( "parser: section text is not a slice of the prompt body", )) } @@ -53,3 +53,6 @@ pub(crate) fn newlines_before(text: &str, byte_offset: usize) -> Result { u32::try_from(prefix.matches('\n').count()) .map_err(|_| Error::Internal("parser: newline count exceeded u32 range")) } + +#[cfg(test)] +mod tests; diff --git a/crates/runvil-internal/prompt/src/sections-tests.rs b/crates/runvil-internal/prompt/src/position/tests.rs similarity index 92% rename from crates/runvil-internal/prompt/src/sections-tests.rs rename to crates/runvil-internal/prompt/src/position/tests.rs index 9d9648576..4d4e974ba 100644 --- a/crates/runvil-internal/prompt/src/sections-tests.rs +++ b/crates/runvil-internal/prompt/src/position/tests.rs @@ -1,7 +1,7 @@ //! Tests for the source-position helpers. +use super::newlines_before; use crate::Error; -use crate::position::newlines_before; #[test] fn newlines_before_reports_broken_byte_offset_invariants() { diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index 0ac9e1781..436a77e32 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -4,7 +4,7 @@ //! below them is the Engine's, reached through [`crate::detail`]. //! //! Construction happens in the parsing modules; this module holds the value -//! types and the invariant-preserving operations on them. +//! types and their accessors. use crate::{BodyText, Frontmatter}; use runvil_lua::LuaProgram; @@ -22,14 +22,13 @@ pub enum Block { /// template. #[non_exhaustive] Prose { - /// Captured Markdown, trimmed of surrounding blank lines. + /// Captured Markdown, trimmed of leading and trailing whitespace. text: BodyText, }, } /// One section of a prompt: a heading, ordered blocks, and children. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct Section { /// The heading text (the section's address). pub(crate) name: String, @@ -39,9 +38,8 @@ pub struct Section { pub(crate) blocks: Vec, /// Child sections nested under this one (deeper heading levels). pub(crate) children: Vec
, - /// Pre-parsed bullet items for list-only sections (no lua blocks). - /// - /// Empty for non-list sections. + /// The list items of a list section, which has at least one; empty for + /// every other section. pub(crate) items: Vec, /// The section's own raw Markdown, without its heading line or children. pub(crate) markdown: BodyText, @@ -72,7 +70,7 @@ impl Section { &self.children } - /// Returns the pre-parsed bullet items for a list-only section. + /// Returns the list items of a list section; empty for every other section. #[must_use] pub fn items(&self) -> &[BodyText] { &self.items @@ -85,7 +83,7 @@ impl Section { self.markdown.as_str() } - /// Classic leading Lua fence when the first block is Lua. + /// The prologue: the first block, when it is Lua. #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn prologue(&self) -> Option<&LuaProgram> { @@ -109,8 +107,7 @@ impl Section { .unwrap_or("") } - /// Classic trailing Lua fence when the last block is Lua and not the sole - /// leading prologue (a section that is only one Lua block has no epilog). + /// The epilog: the last block, when it is Lua and not the only block. #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn epilog(&self) -> Option<&LuaProgram> { @@ -121,22 +118,15 @@ impl Section { } } - /// True when this section is a validated bullet list. - /// - /// A section is list-only exactly when it parsed into non-empty - /// [`items`](Self::items) - i.e. it had no Lua blocks and every nonblank - /// prose line was a valid list item. Ordinary prose (even prose that - /// happens to contain a single bullet line) is not list-only. - #[cfg(test)] + /// Returns whether this is a list section, which has at least one item. #[must_use] - pub(crate) fn is_list_only(&self) -> bool { + pub fn is_list(&self) -> bool { !self.items.is_empty() } } /// A fully parsed prompt file. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct Prompt { /// The parsed YAML frontmatter. pub(crate) frontmatter: Frontmatter, diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index 301a3e825..0b218ad82 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -3,10 +3,10 @@ use std::ops::Range; use std::sync::Arc; -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use runvil_types::emitter::Emitter; -use crate::fence::{self, RawBlock}; +use crate::fence::{self, LuaRole, RawBlock}; use crate::position::{body_text, line_add, newlines_before, nz_source_line}; use crate::{Block, BodyText, Error, ParseErrorKind, Result, Section, list}; use runvil_lua::LuaProgram; @@ -16,28 +16,17 @@ use runvil_lua::LuaProgram; #[derive(Debug, Clone)] pub(crate) struct Heading<'a> { pub(crate) level: u8, + /// The heading's text, trimmed. pub(crate) title: String, pub(crate) content: &'a str, /// 1-based line number within `body` where `content` begins. - pub(crate) content_start_line: u32, + pub(crate) content_body_line: u32, /// 1-based line number within `body` of the heading line itself. - pub(crate) source_line: u32, + pub(crate) body_line: u32, /// Byte range of the heading within `body`, kept for span diagnostics. span: Range, } -/// Converts a `HeadingLevel` to its numeric level. -fn level_num(level: HeadingLevel) -> u8 { - match level { - HeadingLevel::H1 => 1, - HeadingLevel::H2 => 2, - HeadingLevel::H3 => 3, - HeadingLevel::H4 => 4, - HeadingLevel::H5 => 5, - HeadingLevel::H6 => 6, - } -} - /// Walks the markdown body and collects every heading with the content that /// follows it, up to the next heading of any level. /// @@ -61,7 +50,7 @@ pub(crate) fn collect_headings(body: &str) -> Result>> { container_depth = container_depth.saturating_sub(1); } Event::Start(Tag::Heading { level, .. }) if container_depth == 0 => { - current = Some((level_num(level), range.clone(), String::new())); + current = Some((level as u8, range.clone(), String::new())); } Event::End(TagEnd::Heading(_)) => { if let Some((level, range, title)) = current.take() { @@ -90,66 +79,20 @@ pub(crate) fn collect_headings(body: &str) -> Result>> { let end = raws.peek().map_or(body.len(), |next| next.range.start); // +1 because newlines_before gives 0-based offset, and we want // the 1-based line number of the first content line. - let content_start_line = line_add(newlines_before(body, start)?, 1)?; - let source_line = line_add(newlines_before(body, raw.range.start)?, 1)?; + let content_body_line = line_add(newlines_before(body, start)?, 1)?; + let body_line = line_add(newlines_before(body, raw.range.start)?, 1)?; headings.push(Heading { level: raw.level, title: raw.title, content: &body[start..end], - content_start_line, - source_line, + content_body_line, + body_line, span: raw.range, }); } Ok(headings) } -/// Builds one heading's executable blocks while preserving source positions. -/// -/// Prose reads in place from `body`, the shared text `heading` was sliced from. -/// `label` names the section in each Lua block's location. -fn build_heading_blocks( - body: &Arc, - heading: &Heading<'_>, - name: &str, - label: &str, - frontmatter_lines: u32, - emitter: &Emitter, -) -> Result> { - let content_abs_line = line_add(frontmatter_lines, heading.content_start_line)?; - let raw_blocks = fence::split_section_blocks(heading.content, name)?; - let has_prose = raw_blocks - .iter() - .any(|block| matches!(block, RawBlock::Prose(_))); - let total = raw_blocks.len(); - let mut blocks = Vec::with_capacity(total); - let mut ordinal = 0; - for (index, raw) in raw_blocks.into_iter().enumerate() { - match raw { - RawBlock::Prose(text) => blocks.push(Block::Prose { - text: body_text(body, text)?, - }), - RawBlock::Lua { - source, - line_offset, - } => { - ordinal += 1; - let abs_line = line_add(content_abs_line, line_offset)?; - let location = fence::lua_block_location(label, index, total, has_prose, ordinal); - let program = LuaProgram::compile( - &source, - &location, - nz_source_line(abs_line)?, - emitter, - name, - )?; - blocks.push(Block::Lua(program)); - } - } - } - Ok(blocks) -} - /// Rejects an orphan deep heading, one that skips a level. /// /// Examples are an H4 under an H2 with no intervening H3, or an H3/H4 @@ -164,7 +107,7 @@ fn check_child_level(heading: &Heading<'_>, parent_level: u8) -> Result<()> { heading.span.clone(), format!( "section `{}` is an orphan H{level} heading with no parent H{}", - heading.title.trim(), + heading.title, parent_level + 1 ), )); @@ -176,64 +119,18 @@ fn check_child_level(heading: &Heading<'_>, parent_level: u8) -> Result<()> { /// /// A section's name is its runtime address (jumps, lookups, fanout), so an /// empty/whitespace heading is unaddressable and must be rejected at parse. -fn section_name(heading: &Heading<'_>, level: u8) -> Result { +fn section_name(heading: &Heading<'_>) -> Result { let name = heading.title.clone(); - if name.trim().is_empty() { + if name.is_empty() { return Err(Error::parse_at( ParseErrorKind::Structure, heading.span.clone(), - format!("an H{level} section heading must not be empty"), + format!("an H{} section heading must not be empty", heading.level), )); } Ok(name) } -/// Returns the section's list items, or none when it is not a list. -/// -/// A section is a list only when it has no Lua and every nonblank prose line -/// is a list marker; one incidental bullet in mixed prose leaves a non-marker -/// line, so the section stays prose. -fn list_items(body: &Arc, blocks: &[Block], name: &str) -> Result> { - let has_no_lua = blocks - .iter() - .all(|block| matches!(block, Block::Prose { .. })); - let prose_for_items = blocks.iter().find_map(|block| match block { - Block::Prose { text, .. } => Some(text.as_str()), - Block::Lua(_) => None, - }); - if has_no_lua - && let Some(prose) = prose_for_items - && list::is_all_list_markers(prose) - { - list::parse_bullet_items(prose, name)? - .into_iter() - .map(|item| body_text(body, item)) - .collect() - } else { - Ok(Vec::new()) - } -} - -/// Names a section in its Lua block locations. -/// -/// The name is the title alone, or the title and its heading line when -/// another section in `headings` shares the title, so a reader can tell the -/// two sections' errors apart. The locations are display names; each block's -/// line-based chunk name, not its location, decides which program maps an -/// error. -fn block_label(headings: &[Heading<'_>], name: &str, heading_abs_line: u32) -> String { - if headings - .iter() - .filter(|other| other.title == name) - .nth(1) - .is_some() - { - format!("section `{name}` (line {heading_abs_line})") - } else { - format!("section `{name}`") - } -} - /// Rejects a section whose name an earlier sibling already took. /// /// Sections are addressed by name (jumps, lookups), so two siblings sharing a @@ -257,62 +154,204 @@ fn check_unique_sibling( Ok(()) } -/// Builds a section tree from a flat, document-ordered list of headings. -/// -/// Recursion consumes headings whose level is deeper than `parent_level`; a -/// heading at or above `parent_level` belongs to an ancestor and stops the -/// current level. A heading that skips a level (an orphan deep heading, such as -/// an H4 directly under an H2, or an H3 top-level section with no parent H2) is -/// rejected: every heading must be exactly one level deeper than its parent. +/// Compiles a prompt body's H1 and section blocks, and builds its section +/// tree. /// -/// # Errors -/// Returns [`Error::ParseStructured`] for an orphan heading, an empty heading, -/// duplicate sibling names, or malformed section content. Propagates -/// [`Error::Lua`] from Lua compilation and [`Error::Internal`] from invalid -/// or overflowing source-line calculations, from a fence split that disagrees -/// with the Markdown parser, or from section text that is not a slice of -/// `body`. -pub(crate) fn build_sections( - body: &Arc, - headings: &[Heading<'_>], - pos: &mut usize, - parent_level: u8, +/// [`SectionWalker::build_sections`] consumes `headings` from `pos` onward, so +/// one walker builds one tree. +pub(crate) struct SectionWalker<'a> { + /// The prompt body that every heading and block is sliced from. + shared: &'a Arc, + /// The section headings, every heading after the H1 at level 2 or deeper, + /// in document order. + headings: &'a [Heading<'a>], + /// The file lines before the body, added to a body line to give its file + /// line. frontmatter_lines: u32, - emitter: &Emitter, -) -> Result> { - let mut result = Vec::new(); - // Parallel to `result`: each sibling's name and its 1-based heading line, so - // a duplicate can name both the first and the offending location. - let mut sibling_lines: Vec<(String, u32)> = Vec::new(); - while *pos < headings.len() { - let h = &headings[*pos]; - let level = h.level; - if level <= parent_level { - break; + /// Receives each Lua block's compile events. + emitter: &'a Emitter, + /// Index in `headings` of the next heading to build. + pos: usize, +} + +impl<'a> SectionWalker<'a> { + /// Returns a walker positioned at the first of `headings`. + pub(crate) fn new( + shared: &'a Arc, + headings: &'a [Heading<'a>], + frontmatter_lines: u32, + emitter: &'a Emitter, + ) -> Self { + Self { + shared, + headings, + frontmatter_lines, + emitter, + pos: 0, + } + } + + /// Builds a section tree from the walker's flat, document-ordered headings. + /// + /// Recursion consumes headings whose level is deeper than `parent_level`; a + /// heading at or above `parent_level` belongs to an ancestor and stops the + /// current level. A heading that skips a level (an orphan deep heading, such + /// as an H4 directly under an H2, or an H3 top-level section with no parent + /// H2) is rejected: every heading must be exactly one level deeper than its + /// parent. + /// + /// Each section's checks run in a fixed order that decides which error a + /// malformed prompt reports: its level, its name, its blocks, its children, + /// its list items, and last its name against earlier siblings. + /// + /// # Errors + /// Returns [`Error::ParseStructured`] for an orphan heading, an empty + /// heading, duplicate sibling names, or malformed section content. + /// Propagates [`Error::Lua`] from Lua compilation and [`Error::Internal`] + /// from invalid or overflowing source-line calculations, from a fence split + /// that disagrees with the Markdown parser, or from section text that is + /// not a slice of `shared`. + pub(crate) fn build_sections(&mut self, parent_level: u8) -> Result> { + let headings = self.headings; + let mut result = Vec::new(); + // Parallel to `result`: each sibling's name and its 1-based heading line, so + // a duplicate can name both the first and the offending location. + let mut sibling_lines: Vec<(String, u32)> = Vec::new(); + while self.pos < headings.len() { + let h = &headings[self.pos]; + let level = h.level; + if level <= parent_level { + break; + } + check_child_level(h, parent_level)?; + let name = section_name(h)?; + let heading_abs_line = line_add(self.frontmatter_lines, h.body_line)?; + let location = self.section_location(&name, heading_abs_line); + let blocks = self.build_heading_blocks(h, &name, &location)?; + self.pos += 1; + let children = self.build_sections(level)?; + let items = self.list_items(&blocks, &name)?; + check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; + sibling_lines.push((name.clone(), heading_abs_line)); + + result.push(Section { + name, + level, + blocks, + children, + items, + markdown: body_text( + self.shared, + fence::trim_leading_blank_lines(h.content).trim_end(), + )?, + }); + } + Ok(result) + } + + /// Builds one heading's executable blocks while preserving source positions. + /// + /// `location` names the section in each Lua block's location. + fn build_heading_blocks( + &self, + heading: &Heading<'_>, + name: &str, + location: &str, + ) -> Result> { + let content_abs_line = line_add(self.frontmatter_lines, heading.content_body_line)?; + let raw_blocks = fence::split_section_blocks(heading.content, name)?; + self.compile_blocks(raw_blocks, content_abs_line, name, |role| { + fence::lua_block_location(location, role) + }) + } + + /// Compiles split content into blocks, in source order. + /// + /// Every prose block must be a slice of `shared`, which it reads from in + /// place. `content_abs_line` is the 1-based file line where the content + /// begins, and `location` gives each Lua block's compile location from its + /// role. + /// + /// # Errors + /// Propagates [`Error::Lua`] from Lua compilation, and returns + /// [`Error::Internal`] for an overflowing or zero source line or for prose + /// that is not a slice of `shared`. + pub(crate) fn compile_blocks( + &self, + raw_blocks: Vec>, + content_abs_line: u32, + name: &str, + location: impl Fn(&LuaRole) -> String, + ) -> Result> { + let mut blocks = Vec::with_capacity(raw_blocks.len()); + for raw in raw_blocks { + match raw { + RawBlock::Prose(text) => blocks.push(Block::Prose { + text: body_text(self.shared, text)?, + }), + RawBlock::Lua { + source, + line_offset, + role, + } => { + let abs_line = line_add(content_abs_line, line_offset)?; + let program = LuaProgram::compile( + &source, + &location(&role), + nz_source_line(abs_line)?, + self.emitter, + name, + )?; + blocks.push(Block::Lua(program)); + } + } } - check_child_level(h, parent_level)?; - let name = section_name(h, level)?; - let heading_abs_line = line_add(frontmatter_lines, h.source_line)?; - let label = block_label(headings, &name, heading_abs_line); - let blocks = build_heading_blocks(body, h, &name, &label, frontmatter_lines, emitter)?; - *pos += 1; - let children = build_sections(body, headings, pos, level, frontmatter_lines, emitter)?; - let items = list_items(body, &blocks, &name)?; - check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; - sibling_lines.push((name.clone(), heading_abs_line)); + Ok(blocks) + } - result.push(Section { - name, - level, - blocks, - children, - items, - markdown: body_text(body, fence::trim_leading_blank_lines(h.content).trim_end())?, + /// Returns the section's list items, or none when it is not a list. + /// + /// A section is a list when it has no Lua and [`list::bullet_items`] returns + /// items. + fn list_items(&self, blocks: &[Block], name: &str) -> Result> { + let has_no_lua = blocks + .iter() + .all(|block| matches!(block, Block::Prose { .. })); + let prose_for_items = blocks.iter().find_map(|block| match block { + Block::Prose { text, .. } => Some(text.as_str()), + Block::Lua(_) => None, }); + if has_no_lua + && let Some(prose) = prose_for_items + && let Some(items) = list::bullet_items(prose, name)? + { + items + .into_iter() + .map(|item| body_text(self.shared, item)) + .collect() + } else { + Ok(Vec::new()) + } } - Ok(result) -} -#[cfg(test)] -#[path = "sections-tests.rs"] -mod position_tests; + /// Names a section in its Lua block locations. + /// + /// The name is the title alone, or the title and its heading line when + /// another section in `headings` shares the title, so a reader can tell the + /// two sections' errors apart. The locations are display names; each block's + /// line-based chunk name, not its location, decides which program maps an + /// error. + fn section_location(&self, name: &str, heading_abs_line: u32) -> String { + if self + .headings + .iter() + .filter(|other| other.title == name) + .nth(1) + .is_some() + { + format!("section `{name}` (line {heading_abs_line})") + } else { + format!("section `{name}`") + } + } +} diff --git a/crates/runvil-internal/prompt/src/test_support.rs b/crates/runvil-internal/prompt/src/test_support.rs index 00abfcc2b..6e08d5962 100644 --- a/crates/runvil-internal/prompt/src/test_support.rs +++ b/crates/runvil-internal/prompt/src/test_support.rs @@ -1,11 +1,12 @@ //! Test support for the parser's own tests and, through the `test-support` //! feature, for companion crates' tests. //! -//! `Section` and `Block::Prose` are `#[non_exhaustive]`, so a companion -//! crate's tests cannot construct them literally; these constructors are the -//! cross-crate seam for synthetic section trees. The source builder writes a -//! prompt from its parts, and the parse assertions show the source and the -//! actual outcome when a parse goes the other way. +//! `Section`'s fields are crate-private and `Block::Prose` is +//! `#[non_exhaustive]`, so a companion crate's tests cannot construct them +//! literally; these constructors are the cross-crate seam for synthetic +//! section trees. The source builder writes a prompt from its parts, and the +//! parse assertions show the source and the actual outcome when a parse goes +//! the other way. use crate::{Block, BodyText, ParseError, ParseErrorKind, Prompt, Section}; diff --git a/crates/runvil-internal/prompt/src/tests.rs b/crates/runvil-internal/prompt/src/tests.rs index fc8ea5e62..cea54838a 100644 --- a/crates/runvil-internal/prompt/src/tests.rs +++ b/crates/runvil-internal/prompt/src/tests.rs @@ -193,8 +193,8 @@ fn parses_multi_section_with_all_features() { .build(); let p = parse_ok(&src); - assert_eq!(p.frontmatter.name, "demo"); - assert_eq!(p.frontmatter.description, "d"); + assert_eq!(p.frontmatter.name(), "demo"); + assert_eq!(p.frontmatter.description(), "d"); assert_eq!(p.title, "Demo Title"); assert_eq!( p.h1_blocks, diff --git a/crates/runvil-internal/prompt/src/tests/breaks.rs b/crates/runvil-internal/prompt/src/tests/breaks.rs index 58ab9cb60..70537f793 100644 --- a/crates/runvil-internal/prompt/src/tests/breaks.rs +++ b/crates/runvil-internal/prompt/src/tests/breaks.rs @@ -61,7 +61,7 @@ fn list_items_below_a_leading_break_parse() { let body = "---\n\n- alpha\n- beta"; let prompt = parse_ok(&PromptSource::new().prose("Items", body).build()); let section = &prompt.sections[0]; - assert!(section.is_list_only()); + assert!(section.is_list()); assert_eq!(section.items(), ["alpha", "beta"]); } diff --git a/crates/runvil-internal/prompt/src/tests/frontmatter.rs b/crates/runvil-internal/prompt/src/tests/frontmatter.rs index e497890a3..96d98ff90 100644 --- a/crates/runvil-internal/prompt/src/tests/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/tests/frontmatter.rs @@ -6,7 +6,7 @@ use super::*; #[test] fn name_and_description_are_sufficient_frontmatter_for_parsing() { let prompt = parse_ok("---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n"); - assert_eq!(prompt.frontmatter.name, "x"); + assert_eq!(prompt.frontmatter.name(), "x"); } #[test] @@ -36,12 +36,12 @@ fn unknown_frontmatter_field_is_rejected() { fn max_tool_iterations_parses_positive_and_is_absent_when_unset() { let p = parse_ok(&prompt("max_tool_iterations: 20", &[])); assert_eq!( - p.frontmatter.max_tool_iterations, + p.frontmatter.max_tool_iterations(), Some(std::num::NonZeroU32::new(20).unwrap()) ); let p = parse_ok(&prompt("", &[])); - assert_eq!(p.frontmatter.max_tool_iterations, None); + assert_eq!(p.frontmatter.max_tool_iterations(), None); } #[test] @@ -56,6 +56,24 @@ fn max_tool_iterations_rejects_zero_negative_and_overflow() { } } +#[test] +fn max_tool_iterations_reports_the_guides_messages() { + for raw in ["0", "-1"] { + assert_parse_error( + &prompt(&format!("max_tool_iterations: {raw}"), &[]), + ParseErrorKind::Frontmatter, + &format!("max_tool_iterations must be a positive integer (>= 1), got {raw}"), + ); + } + for raw in ["1001", "100000000000"] { + assert_parse_error( + &prompt(&format!("max_tool_iterations: {raw}"), &[]), + ParseErrorKind::Frontmatter, + &format!("max_tool_iterations must be <= 1000, got {raw}"), + ); + } +} + #[test] fn max_tool_iterations_accepts_the_upper_boundary() { let p = parse_ok(&prompt( @@ -63,7 +81,7 @@ fn max_tool_iterations_accepts_the_upper_boundary() { &[], )); assert_eq!( - p.frontmatter.max_tool_iterations, + p.frontmatter.max_tool_iterations(), Some(std::num::NonZeroU32::new(MAX_TOOL_ITERATIONS).unwrap()) ); } @@ -71,10 +89,10 @@ fn max_tool_iterations_accepts_the_upper_boundary() { #[test] fn frontmatter_exposes_runvil_field() { let with = "---\nname: x\ndescription: d\nrunvil: 0\n---\n\n# T\n\n## S\n\np\n"; - assert_eq!(parse_ok(with).frontmatter.runvil, Some(0)); + assert_eq!(parse_ok(with).frontmatter.runvil(), Some(0)); let without = "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n"; - assert_eq!(parse_ok(without).frontmatter.runvil, None); + assert_eq!(parse_ok(without).frontmatter.runvil(), None); } #[test] diff --git a/crates/runvil-internal/prompt/src/tests/lists.rs b/crates/runvil-internal/prompt/src/tests/lists.rs index 9bb19d804..f1e4d05a2 100644 --- a/crates/runvil-internal/prompt/src/tests/lists.rs +++ b/crates/runvil-internal/prompt/src/tests/lists.rs @@ -1,7 +1,7 @@ //! List section tests: list detection and the bullet parser. use super::*; -use crate::list::parse_bullet_items; +use crate::list::bullet_items; #[test] fn mixed_prose_with_one_bullet_is_not_a_list() { @@ -10,7 +10,7 @@ fn mixed_prose_with_one_bullet_is_not_a_list() { let body = "Here is context.\n- one incidental bullet\nMore prose follows."; let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); let section = &prompt.sections[0]; - assert!(!section.is_list_only(), "mixed prose is not a list"); + assert!(!section.is_list(), "mixed prose is not a list"); assert!(section.items().is_empty()); assert!(section.prose().contains("incidental bullet")); } @@ -20,7 +20,7 @@ fn pure_list_section_parses_items() { let body = "- alpha\n- beta\n3. gamma"; let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); let section = &prompt.sections[0]; - assert!(section.is_list_only()); + assert!(section.is_list()); assert_eq!(section.items(), ["alpha", "beta", "gamma"]); } @@ -33,6 +33,14 @@ fn all_marker_list_with_empty_item_is_rejected() { assert_eq!(error.kind(), ParseErrorKind::List); } +#[test] +fn an_empty_marker_before_a_prose_line_stays_prose() { + let prompt = parse_ok(&PromptSource::new().prose("S", "- alpha\n-\nmore").build()); + let section = &prompt.sections[0]; + assert!(section.items().is_empty()); + assert_eq!(section.prose(), "- alpha\n-\nmore"); +} + #[test] fn list_error_kind_does_not_depend_on_the_section_name() { for section in ["frontmatter", "fence"] { @@ -43,42 +51,25 @@ fn list_error_kind_does_not_depend_on_the_section_name() { #[test] fn bullet_parser_strips_unordered_markers() { - let items = parse_bullet_items("- alpha\n* beta\n- gamma", "test").unwrap(); - assert_eq!(items, vec!["alpha", "beta", "gamma"]); + let items = bullet_items("- alpha\n* beta\n- gamma", "test").unwrap(); + assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); } #[test] fn bullet_parser_strips_ordered_markers() { - let items = parse_bullet_items("1. first\n2. second\n3) third", "test").unwrap(); - assert_eq!(items, vec!["first", "second", "third"]); + let items = bullet_items("1. first\n2. second\n3) third", "test").unwrap(); + assert_eq!(items, Some(vec!["first", "second", "third"])); } #[test] fn bullet_parser_ignores_blank_lines() { - let items = parse_bullet_items("- alpha\n\n- beta\n \n- gamma", "test").unwrap(); - assert_eq!(items, vec!["alpha", "beta", "gamma"]); -} - -#[test] -fn bullet_parser_rejects_non_list_content() { - let err = parse_bullet_items("- alpha\nnot a bullet\n- gamma", "test") - .expect_err("non-list content must error"); - assert!( - err.to_string().contains("non-list content"), - "error was: {err}" - ); -} - -#[test] -fn bullet_parser_rejects_empty_list() { - let err = parse_bullet_items("", "test").expect_err("empty list must error"); - assert!(err.to_string().contains("no items"), "error was: {err}"); + let items = bullet_items("- alpha\n\n- beta\n \n- gamma", "test").unwrap(); + assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); } #[test] fn bullet_parser_rejects_empty_item() { - let err = - parse_bullet_items("- alpha\n- \n- gamma", "test").expect_err("empty item must error"); + let err = bullet_items("- alpha\n- \n- gamma", "test").expect_err("empty item must error"); assert!( err.to_string().contains("empty bullet item"), "error was: {err}" diff --git a/crates/runvil-internal/types/src/names-tests.rs b/crates/runvil-internal/types/src/names-tests.rs index 8b3ce5387..1e514982e 100644 --- a/crates/runvil-internal/types/src/names-tests.rs +++ b/crates/runvil-internal/types/src/names-tests.rs @@ -1,6 +1,7 @@ -//! Tests for `GlobalName` parsing and its rejection messages. +//! Tests for `GlobalName` parsing and its rejection messages, and for the +//! alias grammar. -use super::GlobalName; +use super::{GlobalName, is_alias}; const EMPTY: &str = "invalid global name: segments must not be empty"; const CONTROL: &str = "invalid global name: segments must not contain a control character"; @@ -114,3 +115,31 @@ fn from_str_parses_like_parse() { ); assert_eq!(error.to_string(), CHARSET); } + +#[test] +fn is_alias_follows_the_alias_grammar() { + for name in [ + "a", + "A", + "search", + "web_fetch", + "web-fetch2", + "a_b-1", + &"z".repeat(64), + ] { + assert!(is_alias(name), "{name:?} must be an alias"); + } + for name in [ + "", + "1search", + "_search", + "-search", + "web fetch", + "web.fetch", + "web/fetch", + "\u{e9}", + &"a".repeat(65), + ] { + assert!(!is_alias(name), "{name:?} must not be an alias"); + } +} diff --git a/crates/runvil-internal/types/src/names.rs b/crates/runvil-internal/types/src/names.rs index 41ba0ac3d..b9641886c 100644 --- a/crates/runvil-internal/types/src/names.rs +++ b/crates/runvil-internal/types/src/names.rs @@ -8,6 +8,9 @@ //! alphanumeric plus `-`, `_`, `.`, and comparison is case-sensitive. A //! name carries no version, so a `@` is a parse error. //! +//! [`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. @@ -134,6 +137,20 @@ fn validate_segment(segment: &str) -> Result<(), &'static str> { Ok(()) } +/// Returns whether `name` follows the prompt-local alias grammar +/// `[A-Za-z][A-Za-z0-9_-]{0,63}`: 1 to 64 bytes, an ASCII letter first, then +/// ASCII letters, digits, `_`, or `-`. An alias, such as an arg name or an +/// adhoc tool name, is never a global name. +#[must_use] +pub fn is_alias(name: &str) -> bool { + let bytes = name.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphabetic() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + /// The error returned when an id, or a catalog of ids, is invalid. /// /// Parsing a [`GlobalName`], a [`PackId`], a [`ToolId`], a [`ChainId`] or a diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 150521ec0..b0054fb23 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -23,8 +23,6 @@ #[non_exhaustive] pub enum runvil::vfs::VfsOutcome #[non_exhaustive] pub enum runvil::vfs::Writability #[non_exhaustive] pub runvil::model::TemperatureError::OutOfRange { .. } -#[non_exhaustive] pub struct runvil::ParseError { /* private fields */ } -#[non_exhaustive] pub struct runvil::Prompt { /* private fields */ } #[non_exhaustive] pub struct runvil::RunLimits { .. } #[non_exhaustive] pub struct runvil::effect::IssuedEffect { .. } #[non_exhaustive] pub struct runvil::effect::PackToolSets { .. } @@ -50,11 +48,6 @@ #[non_exhaustive] pub struct runvil::model::ToolCall { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ToolSchema { /* private fields */ } #[non_exhaustive] pub struct runvil::packs::PackId(/* private fields */) -#[non_exhaustive] pub struct runvil::prompt::ArgDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::ArgsDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::FileDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::Frontmatter { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::RoleRequirements { /* private fields */ } #[non_exhaustive] pub struct runvil::tools::ToolCatalog { /* private fields */ } #[non_exhaustive] pub struct runvil::tools::ToolError { /* private fields */ } #[non_exhaustive] pub struct runvil::tools::ToolId(/* private fields */) @@ -1242,6 +1235,8 @@ pub runvil::vfs::VfsOutcome::Text::0: alloc::string::String pub runvil::vfs::VfsOutcome::Unit pub runvil::vfs::Writability::ReadOnly pub runvil::vfs::Writability::ReadWrite +pub struct runvil::ParseError { /* private fields */ } +pub struct runvil::Prompt { /* private fields */ } pub struct runvil::Run { /* private fields */ } pub struct runvil::RunConfig { /* private fields */ } pub struct runvil::RunSeed(/* private fields */) @@ -1259,7 +1254,12 @@ pub struct runvil::model::Temperature(/* private fields */) pub struct runvil::packs::GlobalName { /* private fields */ } pub struct runvil::packs::PackRef { /* private fields */ } pub struct runvil::packs::Prelude { /* private fields */ } +pub struct runvil::prompt::ArgDecl { /* private fields */ } +pub struct runvil::prompt::ArgsDecl { /* private fields */ } pub struct runvil::prompt::ArgsDeclIter<'a> { /* private fields */ } +pub struct runvil::prompt::FileDecl { /* private fields */ } +pub struct runvil::prompt::Frontmatter { /* private fields */ } +pub struct runvil::prompt::RoleRequirements { /* private fields */ } pub struct runvil::timestamp::Timestamp(/* private fields */) pub struct runvil::vfs::Access { /* private fields */ } pub struct runvil::vfs::AcquireContext { /* private fields */ } From bfac884005e954c8518351263521731d336027c0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 09:18:30 -0800 Subject: [PATCH 02/23] Name the stub Gateway before it is launched The desktop app spawned its sibling Gateway without checking whether that file was the compile-only stub the fetch script writes, so Windows answered a text file with an image-format error that named no file. Boot now reads the stub's prefix and fails with the path and the command that stages the real Gateway. - sidecar_is_stub reads the .pinned marker with a substring test and keeps uild.rs free of a JSON dependency; a malformed marker reads as not-a-stub, so an unreadable marker cannot silence the check. - cargo:rerun-if-changed watches the marker, which only the fetch script writes, so re-staging a release over a stub re-runs the build script and clears the warning. - The stub test is a byte-prefix compare against the exact text --stub writes, so an empty or missing file reads as not-a-stub and falls through to the spawn. --- crates/workshop/desktop/build.rs | 36 +++++++++- crates/workshop/desktop/src/gateway/boot.rs | 38 +++++++++++ .../desktop/src/gateway/tests/boot.rs | 65 ++++++++++++++++++- 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/workshop/desktop/build.rs b/crates/workshop/desktop/build.rs index dbd381493..74ce82a71 100644 --- a/crates/workshop/desktop/build.rs +++ b/crates/workshop/desktop/build.rs @@ -57,6 +57,10 @@ fn install_gateway_sidecar() -> Result<(), Box> { .join(format!("runvil-gateway-{target}{exe_suffix}")); println!("cargo:rerun-if-env-changed={GATEWAY_SIDECAR_VARIABLE}"); + // The marker is written only by `tools/fetch-gateway-sidecar.mjs`, never + // here, so watching it cannot loop. Watching it means re-staging a + // release over a stub re-runs this script and the warning below clears. + println!("cargo:rerun-if-changed={}", pin_marker(&sidecar).display()); if let Some(named) = env::var_os(GATEWAY_SIDECAR_VARIABLE) { let named = PathBuf::from(named); if named.as_os_str().is_empty() { @@ -86,6 +90,19 @@ fn install_gateway_sidecar() -> Result<(), Box> { // `target//runvil-gateway` from an earlier day must not // silently replace a downloaded release. if sidecar_is_pinned(&sidecar) { + if sidecar_is_stub(&sidecar) { + // CI stages a stub for compile-only jobs, so a build that copies + // one is expected there. A person building locally gets the same + // warning, which is the last chance to notice before an app that + // cannot start exists on disk. + println!( + "cargo:warning={} is a compile-only stub Gateway and cannot run; \ + run `node tools/fetch-gateway-sidecar.mjs --target {}` and rebuild for a \ + Gateway that starts", + sidecar.display(), + target + ); + } return Ok(()); } if !built.is_file() || sidecar_is_current(&built, &sidecar) { @@ -119,9 +136,26 @@ fn profile_dir_from_out_dir(out_dir: &Path) -> Option { /// Whether `tools/fetch-gateway-sidecar.mjs` staged the sidecar and said /// so: the file is then the authority, whatever a workspace build wrote. fn sidecar_is_pinned(sidecar: &Path) -> bool { + pin_marker(sidecar).is_file() +} + +/// The marker beside the sidecar: `.pinned`. +fn pin_marker(sidecar: &Path) -> PathBuf { let mut marker = sidecar.as_os_str().to_owned(); marker.push(".pinned"); - Path::new(&marker).is_file() + PathBuf::from(marker) +} + +/// Whether the marker says the staged sidecar is the compile-only stub +/// rather than the pinned release. The marker is the fetch script's JSON, +/// so a substring check keeps this script free of a JSON dependency. A +/// malformed marker reads as unknown, and unknown is not a stub: the app's +/// own boot check still refuses to run one. +fn sidecar_is_stub(sidecar: &Path) -> bool { + let Ok(text) = fs::read_to_string(pin_marker(sidecar)) else { + return false; + }; + text.contains("\"kind\": \"stub\"") || text.contains("\"kind\":\"stub\"") } /// The sidecar is current when it exists with the same length and a diff --git a/crates/workshop/desktop/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs index cd46247f5..8a0c4f14b 100644 --- a/crates/workshop/desktop/src/gateway/boot.rs +++ b/crates/workshop/desktop/src/gateway/boot.rs @@ -88,6 +88,9 @@ pub(crate) fn ensure_gateway(config: &Config) -> anyhow::Result Ok(GatewayAttachment::Config), GatewayPlan::Fail => Err(no_gateway_error()), GatewayPlan::Launch(exe) => { + if sibling_is_placeholder(&exe) { + return Err(placeholder_gateway_error(&exe)); + } launch_and_attach_cancellable(&run_dir, &exe, &CancellationToken::new()) .and_then(validated_recovery_attachment) .context("launch the sidecar gateway") @@ -145,6 +148,41 @@ pub(super) fn sibling_gateway(exe_dir: &Path) -> Option { candidate.is_file().then_some(candidate) } +/// The text `tools/fetch-gateway-sidecar.mjs --stub` writes, and the only +/// content a file may hold and still be called a Gateway. Checked before a +/// launch because Windows answers a text file with "This version of %1 is +/// not compatible with the version of Windows you're running" and no clue +/// which file was at fault. +const STUB_PREFIX: &str = "This is a placeholder staged by"; + +/// Whether the sibling Gateway is a compile-only stub rather than an +/// executable. A real Gateway starts with `MZ` on Windows and something +/// other than this text everywhere else, so a byte-prefix compare is the +/// whole test. +pub(super) fn sibling_is_placeholder(exe: &Path) -> bool { + use std::io::Read as _; + let Ok(mut file) = std::fs::File::open(exe) else { + return false; + }; + let mut prefix = vec![0_u8; STUB_PREFIX.len()]; + let Ok(read) = file.read(&mut prefix) else { + return false; + }; + prefix.truncate(read); + prefix == STUB_PREFIX.as_bytes() +} + +/// The failure for a stub sidecar, naming the file and the one command that +/// replaces it with a runnable Gateway. +pub(super) fn placeholder_gateway_error(exe: &Path) -> anyhow::Error { + anyhow::anyhow!( + "the gateway beside this program is a compile-only stub, not an executable: {}; \ + run `node tools/fetch-gateway-sidecar.mjs` from the repository root to stage the \ + pinned Gateway release, then rebuild", + exe.display() + ) +} + /// Builds the loud boot failure naming both supported remedies. pub(super) fn no_gateway_error() -> anyhow::Error { anyhow::anyhow!( diff --git a/crates/workshop/desktop/src/gateway/tests/boot.rs b/crates/workshop/desktop/src/gateway/tests/boot.rs index 246bd302b..d2f5d9585 100644 --- a/crates/workshop/desktop/src/gateway/tests/boot.rs +++ b/crates/workshop/desktop/src/gateway/tests/boot.rs @@ -13,7 +13,8 @@ use super::{ #[cfg(windows)] use crate::gateway::boot::spawn_detached_windows_with; use crate::gateway::boot::{ - GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, plan_gateway, sibling_gateway, spawn_detached, + GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, placeholder_gateway_error, plan_gateway, + sibling_gateway, sibling_is_placeholder, spawn_detached, }; use crate::gateway::identity::GatewayAttachment; use crate::gateway::supervisor::RECOVERY_POLL_INTERVAL; @@ -265,6 +266,68 @@ fn the_no_gateway_error_names_both_remedies() { ); } +/// The stub `tools/fetch-gateway-sidecar.mjs --stub` writes, which CI stages +/// so compile-only jobs need no Gateway release. +const STUB_TEXT: &str = "This is a placeholder staged by tools/fetch-gateway-sidecar.mjs --stub.\n\ + It stands in for the Gateway sidecar so compile-only jobs need no release.\n"; + +fn sibling_with(content: &[u8]) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let exe = dir.path().join(GATEWAY_EXE_NAME); + std::fs::write(&exe, content).expect("write the sibling"); + (dir, exe) +} + +#[test] +fn a_stub_sibling_is_recognized_before_a_launch_attempt() { + let (_dir, exe) = sibling_with(STUB_TEXT.as_bytes()); + assert!( + sibling_is_placeholder(&exe), + "the compile-only stub must be caught before spawning it" + ); +} + +#[test] +fn a_real_executable_sibling_is_not_a_placeholder() { + let (_dir, exe) = sibling_with(b"MZ\x90\x00\x03\x00\x00\x00"); + assert!( + !sibling_is_placeholder(&exe), + "an executable is left to the spawn to judge" + ); +} + +#[test] +fn an_empty_sibling_is_not_treated_as_a_placeholder() { + let (_dir, exe) = sibling_with(b""); + assert!( + !sibling_is_placeholder(&exe), + "an unreadable prefix must not read as a stub" + ); +} + +#[test] +fn a_missing_sibling_is_not_treated_as_a_placeholder() { + let dir = tempfile::TempDir::new().expect("tempdir"); + assert!( + !sibling_is_placeholder(&dir.path().join(GATEWAY_EXE_NAME)), + "a file that is not there cannot be a stub" + ); +} + +#[test] +fn the_placeholder_error_names_the_file_and_the_fetch_command() { + let exe = PathBuf::from("/install/runvil-gateway"); + let message = placeholder_gateway_error(&exe).to_string(); + assert!( + message.contains("runvil-gateway"), + "the error names the file that is not runnable: {message}" + ); + assert!( + message.contains("node tools/fetch-gateway-sidecar.mjs"), + "the error names the one command that stages a real Gateway: {message}" + ); +} + #[test] fn matching_boot_publication_survives_closure_and_server_teardown() { let mut launched = super::validated_gateway("launched-key"); From be093c7c856a317301bab55c63e1f4ecfa0cde17 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 09:46:22 -0800 Subject: [PATCH 03/23] Rename the vibelight alias to outline The alias is named `vibelight`, and cargo resolves an alias before an installed subcommand of the same name and warns that the alias shadows it. That warning says cargo plans to make shadowing a hard error. `cargo install vibelight` now installs a `cargo-vibelight` shim, so the alias would shadow it on every machine that has it. The alias becomes `outline`, which no subcommand name can collide with. It still builds the sibling checkout in release mode, so `cargo outline` is the moving copy and `cargo vibelight` is the installed one. --- .cargo/config.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 3e836ce66..ce4f8c60d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,7 +12,13 @@ xtask = "run -p build-xtask --" # Outlines the workspace with the vibelight checkout next to this repo. Cargo # builds it in release mode on first use and rebuilds it when its source # changes. Run it from the repo root: the path is relative to the cwd. -vibelight = "run --release --manifest-path ../vibelight/Cargo.toml --" +# +# The name is `outline`, not `vibelight`, on purpose. Cargo resolves an alias +# before an installed subcommand, warns that the alias shadows it, and plans to +# make that a hard error, so an alias named `vibelight` would break every +# `cargo install vibelight` user. With this name the checkout stays the default +# here, and `cargo vibelight` runs whichever version is installed. +outline = "run --release --bin vibelight --manifest-path ../vibelight/Cargo.toml --" # Clippy appends a docs-link line to every diagnostic; the lint name already # identifies it. From 7e5a6e070566170d405730ec3034959d49f3e1ba Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:02:08 -0800 Subject: [PATCH 04/23] Add the universal error record to runvil-types The new `error` module gives one record that can describe a failure from any Engine crate. `Error` holds an `ErrorCode`, a `message` for logs, a `prose` text for the model, an optional `Location`, and a `facts` map from `FactKey` to `serde_json::Value`. - `ErrorCode` has 65 unit variants with explicit `#[repr(u16)]` numbers grouped in family hundreds, and `number` returns the number. - The four types have public fields and derive no serde traits. - `Location::new` returns `None` when the end comes before the start. `Location::line` covers one whole line, and the range is empty on line `u32::MAX`. - No code outside the module uses it yet. - The tests check that each code is in `ALL` once and that each number is in its `family` hundred. `ALL` is a hand-written list. --- crates/runvil-internal/types/README.md | 2 +- .../runvil-internal/types/src/error-tests.rs | 187 +++++++++++ crates/runvil-internal/types/src/error.rs | 291 ++++++++++++++++++ crates/runvil-internal/types/src/lib.rs | 6 +- 4 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 crates/runvil-internal/types/src/error-tests.rs create mode 100644 crates/runvil-internal/types/src/error.rs 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-tests.rs b/crates/runvil-internal/types/src/error-tests.rs new file mode 100644 index 000000000..90cc077de --- /dev/null +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -0,0 +1,187 @@ +//! Tests for `ErrorCode` numbering and `Location` ranges. + +use std::collections::BTreeSet; +use std::num::NonZeroU32; + +use super::{ErrorCode, Location}; + +/// Every code, in declaration order. +const ALL: [ErrorCode; 65] = [ + 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, +]; + +/// 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, + } +} + +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 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..da9b7c49d --- /dev/null +++ b/crates/runvil-internal/types/src/error.rs @@ -0,0 +1,291 @@ +//! 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; + +#[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(), + } + } +} + +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`, and `Chain`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum FactKey { + /// A store path or 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, +} + +/// A stable failure code. Numbers group by family in hundreds: 1xx parse, +/// 2xx Lua, 3xx tasks, 4xx model, 5xx tools, 6xx store, 7xx run. A number is +/// never reused. Variants named after a `VfsError` or `CompletionErrorKind` +/// variant keep its name, so their tags will match. +#[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 failed to parse or validate, such as an id, a tag, or a + /// temperature. + 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, +} + +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/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; From 8993c932d66e4636550eea1f1055eac4b91bb030 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:37:03 -0800 Subject: [PATCH 05/23] Add harness codes, a run fact, and default prose The universal error gains the codes and the fact key that harness failures need, and a default model-facing text for each code. `prose` returns one sentence per code from the new `error-prose.rs`, and the sentences for failures in the prompt itself speak to a model that fixes the prompt. - `RecorderFailed`, `PluginLoadFailed`, and `NoOutput` open the 8xx family, and the new `Run` fact holds a recorder run id. - The docs of the `Path` fact and of `InvalidValue` now cover file paths and missing values. - `prose` is one `match` with one arm per code, under an `#[expect]` of `clippy::too_many_lines`. - A new test checks that every default text is a whole sentence, and `ALL` now lists 68 codes. --- .../runvil-internal/types/src/error-prose.rs | 216 ++++++++++++++++++ .../runvil-internal/types/src/error-tests.rs | 15 +- crates/runvil-internal/types/src/error.rs | 24 +- 3 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 crates/runvil-internal/types/src/error-prose.rs 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-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index 90cc077de..baf46452c 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -6,7 +6,7 @@ use std::num::NonZeroU32; use super::{ErrorCode, Location}; /// Every code, in declaration order. -const ALL: [ErrorCode; 65] = [ +const ALL: [ErrorCode; 68] = [ ErrorCode::FrontmatterInvalid, ErrorCode::StructureInvalid, ErrorCode::FenceInvalid, @@ -72,6 +72,9 @@ const ALL: [ErrorCode; 65] = [ 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 @@ -141,6 +144,7 @@ fn family(code: ErrorCode) -> u16 { | ErrorCode::Backend | ErrorCode::Determinism => 6, ErrorCode::RequirementsUnmet | ErrorCode::Interrupted | ErrorCode::Internal => 7, + ErrorCode::RecorderFailed | ErrorCode::PluginLoadFailed | ErrorCode::NoOutput => 8, } } @@ -161,6 +165,15 @@ fn every_code_number_falls_in_its_family_hundred() { } } +#[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 = diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index da9b7c49d..4d1e0de2e 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -6,6 +6,8 @@ use std::collections::BTreeMap; use std::fmt; use std::num::NonZeroU32; +#[path = "error-prose.rs"] +mod prose; #[cfg(test)] #[path = "error-tests.rs"] mod tests; @@ -100,10 +102,10 @@ impl Location { } /// The name of a fact. Each mirrors a field name in today's Lua error tables, -/// apart from `Limit`, `Section`, and `Chain`. +/// apart from `Limit`, `Section`, `Chain`, and `Run`. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum FactKey { - /// A store path or glob pattern, as a string. + /// 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. @@ -131,11 +133,13 @@ pub enum FactKey { 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. A number is -/// never reused. Variants named after a `VfsError` or `CompletionErrorKind` +/// 2xx Lua, 3xx tasks, 4xx model, 5xx tools, 6xx store, 7xx run, 8xx +/// harness. A number is never reused. Variants named after a `VfsError` or `CompletionErrorKind` /// variant keep its name, so their tags will match. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[repr(u16)] @@ -152,8 +156,8 @@ pub enum ErrorCode { ListEmptyItem = 104, /// The prompt declares a `runvil:` major this build does not support. UnsupportedVersion = 105, - /// A value failed to parse or validate, such as an id, a tag, or a - /// temperature. + /// 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. @@ -280,6 +284,14 @@ pub enum ErrorCode { 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 { From c79098519f3fca3d9098528f35cbef39eabe621e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:38:32 -0800 Subject: [PATCH 06/23] Re-export the universal error from runvil and runvil-plugin Harness and Plugin crates cannot depend on `runvil-types`, so the universal error now reaches them through the two public crates. The `runvil` facade gains an `error` module, and `runvil-plugin` re-exports the same four types at its root. - The facade module holds `Error`, `ErrorCode`, `FactKey`, and `Location`, one `pub use` line each. - `public-api.txt` now lists the four types, every `ErrorCode` variant with its number, and every `FactKey` variant. - No code uses the re-exports yet. --- crates/runvil-plugin/src/lib.rs | 1 + crates/runvil/public-api.txt | 112 ++++++++++++++++++++++++++++++++ crates/runvil/src/lib.rs | 10 +++ 3 files changed, 123 insertions(+) diff --git a/crates/runvil-plugin/src/lib.rs b/crates/runvil-plugin/src/lib.rs index f4cb3c4d7..df639ec9b 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -100,6 +100,7 @@ pub use pack::{Pack, PackFuture, PackInfo}; pub use registry::{InstallError, PackOrigin, PackRegistry}; pub use service::{HostServices, ServiceError, 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; diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index b0054fb23..950774b39 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -77,6 +77,10 @@ auto runvil::effect::IssuedEffect: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !U 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 @@ -178,6 +182,10 @@ 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 @@ -263,6 +271,7 @@ 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::error::Error impl core::error::Error for runvil::ids::IdError impl core::error::Error for runvil::model::CompletionError impl core::error::Error for runvil::model::ParseRoleError @@ -273,6 +282,7 @@ 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 @@ -339,6 +349,8 @@ 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::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,6 +364,8 @@ pub enum runvil::RunOutcome pub enum runvil::Step pub enum runvil::effect::Effect pub enum runvil::effect::EffectAnswer +pub enum runvil::error::ErrorCode +pub enum runvil::error::FactKey pub enum runvil::packs::RosterEntry pub fn runvil::ParseError::cause(&self) -> core::option::Option<&dyn core::error::Error + core::marker::Send + core::marker::Sync + 'static> pub fn runvil::ParseError::column(&self) -> core::option::Option @@ -390,6 +404,9 @@ 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::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 @@ -643,6 +660,7 @@ pub fn runvil::vfs::VfsRefBuilder::store(self, root: &str, backend: impl runvil: pub fn runvil::vfs::perform_vfs_op(access: &runvil::vfs::Access, op: runvil::vfs::VfsOp) -> core::result::Result pub mod runvil::cancel pub mod runvil::effect +pub mod runvil::error pub mod runvil::event pub mod runvil::ids pub mod runvil::metrics @@ -732,6 +750,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 @@ -1243,6 +1353,8 @@ 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 */) diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 83ce27526..e274bd327 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -46,6 +46,16 @@ 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, From b34889fc686bb6e04718ac4aa5031133651abacc Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:43:00 -0800 Subject: [PATCH 07/23] Convert a store failure into the universal error `store_error` gives the one mapping from a `VfsError` to the universal error, for the places where a store failure leaves the VFS. `VfsError` stays the VFS's own error, so the std-only VFS crate does not change. - The code is named after the variant. A variant added to `VfsError` later becomes `Backend` until it is named. - The facts reuse `store_error_fields`, so they hold the same `path`, `valid_up_to`, `rule`, `anchor`, and `count` values a Lua error table holds. - The message is the failure's display text, the prose is the code's default, and the error has no location. - The `runvil` facade re-exports the function as `runvil::vfs::store_error`. No code calls it yet. - New tests cover all 21 variants and the facts each one keeps. --- .../lua/src/error-value-store.rs | 62 +++++- .../lua/src/error-value-tests.rs | 180 +++++++++++++++++- crates/runvil-internal/lua/src/error-value.rs | 2 +- crates/runvil-internal/lua/src/lib.rs | 3 +- crates/runvil/public-api.txt | 1 + crates/runvil/src/lib.rs | 1 + 6 files changed, 244 insertions(+), 5 deletions(-) diff --git a/crates/runvil-internal/lua/src/error-value-store.rs b/crates/runvil-internal/lua/src/error-value-store.rs index 44f670dfe..3dd34c4c6 100644 --- a/crates/runvil-internal/lua/src/error-value-store.rs +++ b/crates/runvil-internal/lua/src/error-value-store.rs @@ -1,5 +1,9 @@ //! The field shape a store failure's error value carries: its `reason` -//! tag and the [`VfsError`](runvil_vfs::VfsError) variant's fields. +//! tag and the [`VfsError`](runvil_vfs::VfsError) variant's fields, and the +//! universal error the failure becomes where it leaves the VFS. + +use runvil_types::error::{Error, ErrorCode, FactKey}; +use serde_json::Value; use super::ErrorField; @@ -74,3 +78,59 @@ pub fn store_error_value_fields(error: &runvil_vfs::VfsError) -> Vec<(String, Er fields.extend(store_error_fields(error)); fields } + +/// Returns the universal error a store failure becomes where it leaves the +/// VFS: the code named after the variant, the variant's fields as facts under +/// the names a Lua error table gives them, the failure's display text as the +/// message, and the code's default prose. +#[must_use] +pub fn store_error(error: &runvil_vfs::VfsError) -> Error { + let code = store_error_code(error); + let mut converted = Error::new(code, error.to_string(), code.prose()); + for (name, field) in store_error_fields(error) { + let key = match name.as_str() { + "path" => FactKey::Path, + "valid_up_to" => FactKey::ValidUpTo, + "rule" => FactKey::Rule, + "anchor" => FactKey::Anchor, + "count" => FactKey::Count, + _ => continue, + }; + let value = match field { + ErrorField::String(text) => Value::from(text), + ErrorField::Integer(number) => Value::from(number), + }; + converted.facts.insert(key, value); + } + converted +} + +/// The code named after the variant. +fn store_error_code(error: &runvil_vfs::VfsError) -> ErrorCode { + use runvil_vfs::VfsError; + match error { + VfsError::NotFound { .. } => ErrorCode::NotFound, + VfsError::AlreadyExists { .. } => ErrorCode::AlreadyExists, + VfsError::NotADirectory { .. } => ErrorCode::NotADirectory, + VfsError::IsADirectory { .. } => ErrorCode::IsADirectory, + VfsError::DirectoryNotEmpty { .. } => ErrorCode::DirectoryNotEmpty, + VfsError::NotUtf8 { .. } => ErrorCode::NotUtf8, + VfsError::InvalidPath { .. } => ErrorCode::InvalidPath, + VfsError::InvalidRange { .. } => ErrorCode::InvalidRange, + VfsError::Anchor { .. } => ErrorCode::Anchor, + VfsError::PermissionDenied { .. } => ErrorCode::PermissionDenied, + VfsError::ApprovalRequired { .. } => ErrorCode::ApprovalRequired, + VfsError::ScopeEnded { .. } => ErrorCode::ScopeEnded, + VfsError::ReadOnly { .. } => ErrorCode::ReadOnly, + VfsError::OutsideRoot { .. } => ErrorCode::OutsideRoot, + VfsError::RootProtected { .. } => ErrorCode::RootProtected, + VfsError::NoStore => ErrorCode::NoStore, + VfsError::CrossMount { .. } => ErrorCode::CrossMount, + VfsError::Io { .. } => ErrorCode::Io, + VfsError::Unsupported { .. } => ErrorCode::Unsupported, + VfsError::Conflict { .. } => ErrorCode::Conflict, + // `VfsError` is `#[non_exhaustive]`: a variant added later is a + // backend failure until it is named here. + _ => ErrorCode::Backend, + } +} diff --git a/crates/runvil-internal/lua/src/error-value-tests.rs b/crates/runvil-internal/lua/src/error-value-tests.rs index 36ec9e2af..0441ac809 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, ParseErrorKindError, store_error, store_error_value_fields}; #[test] fn every_error_kind_tag_parses_back_to_its_kind() { @@ -26,3 +31,174 @@ fn an_unknown_error_kind_names_the_text_and_every_accepted_tag() { ); } } + +/// Every store failure beside the code named after its variant. +#[expect( + clippy::too_many_lines, + reason = "one table holds every variant beside its code" +)] +fn every_store_failure() -> Vec<(VfsError, ErrorCode)> { + let path = || "a.md".to_owned(); + let text = || "why".to_owned(); + vec![ + (VfsError::NotFound { path: path() }, ErrorCode::NotFound), + ( + VfsError::AlreadyExists { path: path() }, + ErrorCode::AlreadyExists, + ), + ( + VfsError::NotADirectory { path: path() }, + ErrorCode::NotADirectory, + ), + ( + VfsError::IsADirectory { path: path() }, + ErrorCode::IsADirectory, + ), + ( + VfsError::DirectoryNotEmpty { path: path() }, + ErrorCode::DirectoryNotEmpty, + ), + ( + VfsError::NotUtf8 { + path: path(), + valid_up_to: 3, + }, + ErrorCode::NotUtf8, + ), + ( + VfsError::InvalidPath { + path: path(), + reason: PathReason::Traversal, + }, + ErrorCode::InvalidPath, + ), + ( + VfsError::InvalidRange { + path: path(), + reason: "backwards", + }, + ErrorCode::InvalidRange, + ), + ( + VfsError::Anchor { + path: path(), + anchor: text(), + count: 2, + }, + ErrorCode::Anchor, + ), + ( + VfsError::PermissionDenied { + path: path(), + reason: text(), + }, + ErrorCode::PermissionDenied, + ), + ( + VfsError::ApprovalRequired { + path: path(), + reason: text(), + }, + ErrorCode::ApprovalRequired, + ), + (VfsError::ScopeEnded { path: path() }, ErrorCode::ScopeEnded), + (VfsError::ReadOnly { path: path() }, ErrorCode::ReadOnly), + ( + VfsError::OutsideRoot { + path: path(), + detail: text(), + }, + ErrorCode::OutsideRoot, + ), + ( + VfsError::RootProtected { path: path() }, + ErrorCode::RootProtected, + ), + (VfsError::NoStore, ErrorCode::NoStore), + ( + VfsError::CrossMount { + path: path(), + detail: text(), + }, + ErrorCode::CrossMount, + ), + ( + VfsError::Io { + path: path(), + kind: std::io::ErrorKind::Other, + raw_os_error: None, + message: text(), + }, + ErrorCode::Io, + ), + ( + VfsError::Unsupported { + path: path(), + detail: text(), + }, + ErrorCode::Unsupported, + ), + ( + VfsError::Conflict { + path: path(), + detail: text(), + }, + ErrorCode::Conflict, + ), + (VfsError::Backend { message: text() }, ErrorCode::Backend), + ] +} + +#[test] +fn a_store_failure_takes_the_code_named_after_its_variant() { + for (error, code) in every_store_failure() { + let converted = store_error(&error); + assert_eq!(converted.code, code, "{error:?}"); + assert_eq!(converted.message, error.to_string(), "{error:?}"); + assert_eq!(converted.prose, code.prose(), "{error:?}"); + assert_eq!(converted.at, None, "{error:?}"); + } +} + +#[test] +fn a_store_failure_keeps_the_lua_table_fields_as_facts() { + let anchor = store_error(&VfsError::Anchor { + path: "a.md".to_owned(), + anchor: "x".to_owned(), + count: 2, + }); + assert_eq!(anchor.facts.get(&FactKey::Path), Some(&Value::from("a.md"))); + assert_eq!(anchor.facts.get(&FactKey::Anchor), Some(&Value::from("x"))); + assert_eq!(anchor.facts.get(&FactKey::Count), Some(&Value::from(2))); + let invalid = store_error(&VfsError::InvalidPath { + path: "../a".to_owned(), + reason: PathReason::Traversal, + }); + assert_eq!( + invalid.facts.get(&FactKey::Rule), + Some(&Value::from("traversal")) + ); + let not_utf8 = store_error(&VfsError::NotUtf8 { + path: "a.bin".to_owned(), + valid_up_to: 3, + }); + assert_eq!( + not_utf8.facts.get(&FactKey::ValidUpTo), + Some(&Value::from(3)) + ); + for error in [ + VfsError::Anchor { + path: "a.md".to_owned(), + anchor: "x".to_owned(), + count: 2, + }, + VfsError::NoStore, + VfsError::Backend { + message: "down".to_owned(), + }, + ] { + // Every Lua table field but `reason`, which the code names. + let fields = store_error_value_fields(&error).len() - 1; + assert_eq!(store_error(&error).facts.len(), fields, "{error:?}"); + } +} diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index ebb5c9606..40ae09248 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -32,7 +32,7 @@ use mlua::{Function, Lua, Table, Value}; use crate::error::{Error, accepted_tags}; pub(crate) use classify::{install_normalize_failure, normalized}; -pub use store::store_error_value_fields; +pub use store::{store_error, store_error_value_fields}; /// The registry key of the shared error metatable, created on first use per /// VM so every error table on that VM - Lua-built or Rust-built - shares diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index a5982e632..e0e90c3b5 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -127,7 +127,8 @@ pub use crate::engine_globals::{ }; pub use crate::error::{Error, QuotaResource}; pub use crate::error_value::{ - ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error_value_fields, + ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error, + store_error_value_fields, }; pub use crate::globals::{RESERVED_NAMES, Reserved, reserved_name}; pub use crate::messages::MessageList; diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 950774b39..ee1b286fc 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -658,6 +658,7 @@ 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 diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index e274bd327..0d38672fc 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -142,6 +142,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; From dded64840c571ed4bf87e12cd0efa4b107d257c2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:49:54 -0800 Subject: [PATCH 08/23] Replace IdError with the universal error Every id type and both catalogs now reject an id with an `InvalidValue` error instead of the bespoke `IdError`. The message text stays the same, and the rejected text becomes the `Name` fact. - `invalid_id` in `names.rs` builds the error with the code's default prose and no location. It is crate-private. - `FromStr` for `GlobalName`, `ChainId`, `TaskId`, `PackId`, and `ToolId` now has `type Err = Error`. The `parse` functions, `ModelId::new`, `ModelId::gateway`, `ToolCatalog::new`, and `ModelCatalog::new` return the same `Error`. - `runvil::ids` and `runvil-plugin` no longer re-export `IdError`, and the run-log fidelity test names `runvil::error::Error`. - New tests pin the code, the `Name` fact, the message, and the prose for every id type and for a repeated id in each catalog. --- crates/runvil-internal/types/src/ids.rs | 19 ++-- crates/runvil-internal/types/src/models.rs | 43 +++++---- .../runvil-internal/types/src/names-tests.rs | 93 ++++++++++++++++++- crates/runvil-internal/types/src/names.rs | 65 +++++-------- crates/runvil-internal/types/src/packs.rs | 21 +++-- crates/runvil-internal/types/src/tools/ids.rs | 20 ++-- .../types/src/tools/registry.rs | 16 ++-- crates/runvil-plugin/src/lib.rs | 1 - crates/runvil/public-api.txt | 29 +++--- crates/runvil/src/lib.rs | 6 +- crates/workshop/run-log/tests/it/fidelity.rs | 5 +- 11 files changed, 200 insertions(+), 118 deletions(-) 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/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/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/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-plugin/src/lib.rs b/crates/runvil-plugin/src/lib.rs index df639ec9b..9ec85c7f4 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -102,7 +102,6 @@ pub use service::{HostServices, ServiceError, ServiceId, ServiceKey, TOKIO_RUNTI 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, diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index ee1b286fc..f969f3227 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -85,7 +85,6 @@ 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 @@ -190,7 +189,6 @@ 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 @@ -272,7 +270,6 @@ 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::error::Error -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 @@ -284,7 +281,6 @@ 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 @@ -306,17 +302,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::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 @@ -497,10 +493,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 @@ -530,10 +526,10 @@ 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::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 @@ -563,7 +559,7 @@ 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 @@ -573,7 +569,7 @@ pub fn runvil::tools::ToolError::with_source(self, source: impl core::convert::I 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 @@ -1357,7 +1353,6 @@ 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 */ } diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 0d38672fc..ddef11279 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -57,16 +57,14 @@ pub mod error { } 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 { 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)) } From 00c252d5af5418076cc64ef214a894967855c082 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:51:50 -0800 Subject: [PATCH 09/23] Replace ParseRoleError with the universal error `Role::from_str` now rejects unknown text with an `InvalidValue` error instead of the bespoke `ParseRoleError`. The error keeps the old text, names the input, and lists every role. - The message and the prose are both the old `Display` text, because the list of roles tells the reader what to use instead. - The rejected text is the `Name` fact, which replaces the `input` accessor. - `runvil::model` and `client` no longer re-export `ParseRoleError`. - The role test now pins the code, the `Name` fact, the message, the prose, and the missing location. --- .../model-client/src/client.rs | 2 +- .../model-client/src/client/role.rs | 59 ++++++------------- .../model-client/src/client/tests.rs | 10 +++- crates/runvil/public-api.txt | 8 +-- crates/runvil/src/lib.rs | 1 - 5 files changed, 28 insertions(+), 52 deletions(-) diff --git a/crates/runvil-internal/model-client/src/client.rs b/crates/runvil-internal/model-client/src/client.rs index 72bfa3791..ba577964e 100644 --- a/crates/runvil-internal/model-client/src/client.rs +++ b/crates/runvil-internal/model-client/src/client.rs @@ -14,7 +14,7 @@ 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, 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..de50ee7c5 100644 --- a/crates/runvil-internal/model-client/src/client/tests.rs +++ b/crates/runvil-internal/model-client/src/client/tests.rs @@ -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] diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index f969f3227..8e550c661 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -107,7 +107,6 @@ 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 @@ -211,7 +210,6 @@ 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 @@ -271,7 +269,6 @@ impl core::convert::From for runvil::vfs::VfsPathBuf impl core::error::Error for runvil::ParseError impl core::error::Error for runvil::error::Error 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::vfs::ParsePathReasonError @@ -283,7 +280,6 @@ impl core::fmt::Display for runvil::error::Error impl core::fmt::Display for runvil::ids::ChainId 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 @@ -306,7 +302,7 @@ impl core::str::traits::FromStr for runvil::ids::ChainId { type Err = runvil::er impl core::str::traits::FromStr for runvil::ids::TaskId 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::error::Error } impl core::str::traits::FromStr for runvil::packs::PackId @@ -508,7 +504,6 @@ 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 @@ -1357,7 +1352,6 @@ 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 ddef11279..b7305b9c2 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -75,7 +75,6 @@ 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; From 074b1f548ed57a69de38c118f879b2d4bf9ac610 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:53:41 -0800 Subject: [PATCH 10/23] Replace ParseErrorKindError with the universal error `ErrorKind::from_str` now rejects text that is no error-kind tag with an `InvalidValue` error instead of the bespoke `ParseErrorKindError`. The error keeps the old text, names the input, and lists every tag. - The message and the prose are both the old text, because the list of tags tells the reader what to use instead. - The rejected text is the `Name` fact, which replaces the test-only `input` accessor. - `runvil-lua` no longer re-exports `ParseErrorKindError`. The shim's unknown-kind refusal still renders the same text, because it formats the error's `Display`. - The kind test now pins the code, the `Name` fact, and the prose. --- .../lua/src/error-value-tests.rs | 8 ++-- crates/runvil-internal/lua/src/error-value.rs | 45 ++++++++----------- crates/runvil-internal/lua/src/lib.rs | 3 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/crates/runvil-internal/lua/src/error-value-tests.rs b/crates/runvil-internal/lua/src/error-value-tests.rs index 0441ac809..21a400320 100644 --- a/crates/runvil-internal/lua/src/error-value-tests.rs +++ b/crates/runvil-internal/lua/src/error-value-tests.rs @@ -6,7 +6,7 @@ use runvil_types::error::{ErrorCode, FactKey}; use runvil_vfs::{PathReason, VfsError}; use serde_json::Value; -use super::{ErrorKind, ParseErrorKindError, store_error, store_error_value_fields}; +use super::{ErrorKind, store_error, store_error_value_fields}; #[test] fn every_error_kind_tag_parses_back_to_its_kind() { @@ -17,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 { diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index 40ae09248..ace1105d1 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -29,6 +29,8 @@ use std::str::FromStr; use mlua::{Function, Lua, Table, Value}; +use runvil_types::error::{ErrorCode, FactKey}; + use crate::error::{Error, accepted_tags}; pub(crate) use classify::{install_normalize_failure, normalized}; @@ -166,36 +168,27 @@ impl std::fmt::Display for ErrorKind { } impl FromStr for ErrorKind { - type Err = ParseErrorKindError; - - /// Parses a kind from its exact tag, as `ErrorKind::from_tag` does. - fn from_str(tag: &str) -> Result { - ErrorKind::from_tag(tag).ok_or_else(|| ParseErrorKindError { - input: tag.to_owned(), + type Err = runvil_types::error::Error; + + /// Parses a kind from its exact tag, as `ErrorKind::from_tag` does. Other + /// text is an [`InvalidValue`](ErrorCode::InvalidValue) error that names + /// it and lists every tag. + fn from_str(tag: &str) -> Result { + ErrorKind::from_tag(tag).ok_or_else(|| { + let text = format!( + "invalid error kind `{tag}`: required one of {}", + accepted_tags(ErrorKind::ALL.iter().map(|kind| kind.tag())) + ); + let mut error = + runvil_types::error::Error::new(ErrorCode::InvalidValue, text.clone(), text); + error + .facts + .insert(FactKey::Name, serde_json::Value::from(tag)); + error }) } } -/// The error returned when text is not an [`ErrorKind`] tag. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error( - "invalid error kind `{input}`: required one of {}", - accepted_tags(ErrorKind::ALL.iter().map(|kind| kind.tag())) -)] -pub struct ParseErrorKindError { - /// The rejected text. - input: String, -} - -impl ParseErrorKindError { - /// Returns the rejected text. - #[cfg(test)] - #[must_use] - pub(crate) fn input(&self) -> &str { - &self.input - } -} - /// One field of an error table: a string or an integer, so a field like /// a store anchor's `count` stays numeric in Lua instead of being /// stringified. diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index e0e90c3b5..03c0b9227 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -127,8 +127,7 @@ pub use crate::engine_globals::{ }; pub use crate::error::{Error, QuotaResource}; pub use crate::error_value::{ - ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error, - store_error_value_fields, + ErrorField, ErrorKind, ErrorValue, Raised, store_error, store_error_value_fields, }; pub use crate::globals::{RESERVED_NAMES, Reserved, reserved_name}; pub use crate::messages::MessageList; From c944666f03edd7bbd16b59cfeb834a7f4b50aa4b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:56:08 -0800 Subject: [PATCH 11/23] Replace TemperatureError with the universal error `Temperature::new` and `CompletionOptions::with_temperature` now reject a temperature with an `InvalidValue` error instead of the bespoke `TemperatureError`. The message text stays the same. - The error names the `temperature` setting in its `Name` fact, and its `Reason` fact is `not_finite` or `out_of_range` in place of the two variants. - The prose gives the valid range. It does not repeat the message. - `models.use` in the Lua crate still picks its finite-number refusal for a value that is not finite, now through `is_finite` instead of a match on the variant. - `runvil::model` and `model` no longer re-export `TemperatureError`. - The temperature test now pins the code, the `Name` fact, both reasons, the message, and the prose. --- crates/runvil-internal/lua/src/models.rs | 13 ++--- .../runvil-internal/model-client/src/model.rs | 4 +- .../model-client/src/model/options-tests.rs | 35 +++++++----- .../model-client/src/model/options.rs | 53 +++++++++++-------- crates/runvil/public-api.txt | 12 +---- crates/runvil/src/lib.rs | 1 - 6 files changed, 62 insertions(+), 56 deletions(-) diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index fc8f06ee7..34ea3acb8 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -14,9 +14,7 @@ use std::sync::Mutex; use mlua::{Lua, MultiValue, Table, Value}; -use runvil_model_client::model::{ - ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature, TemperatureError, -}; +use runvil_model_client::model::{ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature}; use runvil_types::models::ModelRole; use crate::error::{Error, Result}; @@ -149,9 +147,12 @@ fn decode_temperature(value: &Value) -> mlua::Result { } other => Err(invalid_option("temperature", "a number", other.type_name())), }?; - Temperature::new(number).map_err(|error| match error { - TemperatureError::NotFinite => invalid_option("temperature", "finite", number), - other => mlua::Error::external(format!("models.use option {other}")), + Temperature::new(number).map_err(|error| { + if number.is_finite() { + mlua::Error::external(format!("models.use option {error}")) + } else { + invalid_option("temperature", "finite", number) + } }) } diff --git a/crates/runvil-internal/model-client/src/model.rs b/crates/runvil-internal/model-client/src/model.rs index 1fbb6ef5e..cfb2d609d 100644 --- a/crates/runvil-internal/model-client/src/model.rs +++ b/crates/runvil-internal/model-client/src/model.rs @@ -13,9 +13,7 @@ mod error; mod options; pub use error::{CompletionError, CompletionErrorKind}; -pub use options::{ - CompletionOptions, ModelBinding, ModelInvocation, ModelSet, Temperature, TemperatureError, -}; +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/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/public-api.txt b/crates/runvil/public-api.txt index 8e550c661..1d2b2a1ac 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -8,7 +8,6 @@ #[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 @@ -22,7 +21,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 { .. } @@ -110,7 +108,6 @@ auto runvil::model::ModelRole: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindS 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 @@ -213,7 +210,6 @@ derives runvil::model::ModelRole: Clone, Copy, Debug, Deserialize, Eq, Hash, Ord 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 @@ -269,7 +265,6 @@ impl core::convert::From for runvil::vfs::VfsPathBuf impl core::error::Error for runvil::ParseError impl core::error::Error for runvil::error::Error impl core::error::Error for runvil::model::CompletionError -impl core::error::Error for runvil::model::TemperatureError impl core::error::Error for runvil::tools::ToolError impl core::error::Error for runvil::vfs::ParsePathReasonError impl core::error::Error for runvil::vfs::VfsError @@ -281,7 +276,6 @@ impl core::fmt::Display for runvil::ids::ChainId impl core::fmt::Display for runvil::ids::TaskId impl core::fmt::Display for runvil::model::CompletionError 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 @@ -466,7 +460,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 @@ -509,7 +503,7 @@ 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<'_> @@ -1171,8 +1165,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 diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index b7305b9c2..2bbba1c2b 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -86,7 +86,6 @@ pub mod model { 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_types::models::ModelCatalog; pub use runvil_types::models::ModelId; pub use runvil_types::models::ModelInfo; From bc8530871198244e510b1732fa87ec9e72d4fe6d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 17:59:09 -0800 Subject: [PATCH 12/23] Replace ToolSchemaError with the universal error `tool_schema_new` now refuses a tool schema with a `ToolSchemaInvalid` error instead of the bespoke `ToolSchemaError`. The message text stays the same. - The error names the tool in its `Name` fact. Its `Reason` fact is `invalid_name` or `non_object_schema` in place of the two variants. - The prose is the code's default, which speaks to whoever fixes the tool. - The engine's `BindSchema` variant still boxes the refusal as its cause, now as the universal `Error`, and its test downcasts to that type and checks the code. - `client` no longer re-exports `ToolSchemaError`, and the engine's `model` module doc no longer names it. - The schema test now pins the code, the `Name` fact, both reasons, the prose, and the tool name in the message. --- .../runvil-internal/engine/src/error/tests.rs | 8 +++- crates/runvil-internal/engine/src/model.rs | 2 +- .../model-client/src/client.rs | 2 +- .../model-client/src/client/tests.rs | 43 +++++++++++------- .../model-client/src/client/wire.rs | 27 ----------- .../model-client/src/detail.rs | 45 +++++++++++++------ 6 files changed, 67 insertions(+), 60 deletions(-) diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index b5e70d210..34f497735 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -59,8 +59,12 @@ fn a_schema_build_failure_renders_its_refusal_and_keeps_it_as_the_cause() { assert_eq!(bind.message(), bind.to_string()); let cause = bind .originating_error() - .and_then(|cause| cause.downcast_ref::()); - assert!(cause.is_some(), "the schema refusal is the cause"); + .and_then(|cause| cause.downcast_ref::()) + .expect("the schema refusal is the cause"); + assert_eq!( + cause.code, + runvil_types::error::ErrorCode::ToolSchemaInvalid + ); } #[test] diff --git a/crates/runvil-internal/engine/src/model.rs b/crates/runvil-internal/engine/src/model.rs index 252de57ea..a58baa16a 100644 --- a/crates/runvil-internal/engine/src/model.rs +++ b/crates/runvil-internal/engine/src/model.rs @@ -15,7 +15,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. //! diff --git a/crates/runvil-internal/model-client/src/client.rs b/crates/runvil-internal/model-client/src/client.rs index ba577964e..4340bb700 100644 --- a/crates/runvil-internal/model-client/src/client.rs +++ b/crates/runvil-internal/model-client/src/client.rs @@ -17,7 +17,7 @@ mod wire; 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/tests.rs b/crates/runvil-internal/model-client/src/client/tests.rs index de50ee7c5..c3c8e0387 100644 --- a/crates/runvil-internal/model-client/src/client/tests.rs +++ b/crates/runvil-internal/model-client/src/client/tests.rs @@ -270,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.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. /// From 751986085988a53c6fc101b114fc35c4a8ec6b08 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:01:50 -0800 Subject: [PATCH 13/23] Replace SecretError with the universal error `SecretString::new` now refuses an empty credential with an `InvalidValue` error instead of the bespoke `SecretError`. The message stays `secret must not be empty`. - The `Reason` fact is `empty_secret`. The error holds no part of the secret. - The prose tells the reader to supply a non-empty secret. - `From for GatewayConfigError` is removed. No production code used it, because the environment loader folds the refusal into `None` with `Result::ok`. - The test that pinned the `SecretError` cause of a config error is removed with the conversion, and the source check on the empty key is removed from the leak test. - The empty-credential test now pins the code, the `Reason` fact, the message, and the prose. --- crates/harness-gateway-client/src/config.rs | 42 +++++++------------ crates/harness-gateway-client/src/lib.rs | 1 - .../src/transport/tests/env.rs | 35 ++++++---------- 3 files changed, 29 insertions(+), 49 deletions(-) diff --git a/crates/harness-gateway-client/src/config.rs b/crates/harness-gateway-client/src/config.rs index efd60dc62..d6ce7ab78 100644 --- a/crates/harness-gateway-client/src/config.rs +++ b/crates/harness-gateway-client/src/config.rs @@ -3,6 +3,8 @@ use std::fmt; +use runvil::error::{Error, ErrorCode, FactKey}; + /// 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. @@ -30,7 +32,7 @@ pub enum GatewayConfigError { /// 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. + /// The cause is, for example, a URL parse failure. #[error("{message}")] Config { /// A human-readable description of the problem. The text of the @@ -57,12 +59,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, serde_json::Value::from("empty_secret")); + return Err(error); } Ok(SecretString(secret)) } @@ -74,27 +85,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()") diff --git a/crates/harness-gateway-client/src/lib.rs b/crates/harness-gateway-client/src/lib.rs index 11a4660a9..2f6c84288 100644 --- a/crates/harness-gateway-client/src/lib.rs +++ b/crates/harness-gateway-client/src/lib.rs @@ -78,7 +78,6 @@ 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; diff --git a/crates/harness-gateway-client/src/transport/tests/env.rs b/crates/harness-gateway-client/src/transport/tests/env.rs index bd5ebca90..4a9374e0e 100644 --- a/crates/harness-gateway-client/src/transport/tests/env.rs +++ b/crates/harness-gateway-client/src/transport/tests/env.rs @@ -3,8 +3,9 @@ use runvil::model::Message; +use runvil::error::{ErrorCode, FactKey}; + use super::*; -use crate::config::SecretError; #[test] fn from_env_surfaces_non_unicode_value_instead_of_dropping_it() { @@ -221,22 +222,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] @@ -285,12 +282,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] From bfd41776ab262d77a384d4d4913bf9f1d23f14b3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:04:16 -0800 Subject: [PATCH 14/23] Replace GatewayConfigError with the universal error The gateway client's setup errors are now `InvalidValue` errors instead of the bespoke `GatewayConfigError`. The environment and endpoint messages keep their text, and every message still omits any key or URL. - `missing_env` and `invalid_env` name the variable in the `Name` fact. Their `Reason` facts are `missing_env` and `invalid_env`, and an endpoint refusal has `invalid_config`. - The prose of a setup error is its message, because the message names the variable or the rule to fix. - An unparsable URL's message now ends with the `url::ParseError` text instead of keeping it as the source. That text names the rule, never the input. - `GatewayChat::from_env`, `GatewayEndpoint::new`, and its `TryFrom` return the universal `Error`. The crate no longer exports `GatewayConfigError`. - The tests check the reason, the variable, and the prose. The source-chain checks are replaced by a check on the parse text in the message, and the no-leak loop stays. --- crates/harness-gateway-client/src/config.rs | 93 +++++++++---------- crates/harness-gateway-client/src/lib.rs | 1 - .../harness-gateway-client/src/transport.rs | 30 +++--- .../src/transport/tests.rs | 2 +- .../src/transport/tests/env.rs | 59 ++++++------ 5 files changed, 91 insertions(+), 94 deletions(-) diff --git a/crates/harness-gateway-client/src/config.rs b/crates/harness-gateway-client/src/config.rs index d6ce7ab78..0ff23188a 100644 --- a/crates/harness-gateway-client/src/config.rs +++ b/crates/harness-gateway-client/src/config.rs @@ -1,47 +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; 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 +} -/// 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), - - /// 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. - #[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` @@ -72,7 +68,7 @@ impl SecretString { ); error .facts - .insert(FactKey::Reason, serde_json::Value::from("empty_secret")); + .insert(FactKey::Reason, Value::from("empty_secret")); return Err(error); } Ok(SecretString(secret)) @@ -118,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(), @@ -187,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/lib.rs b/crates/harness-gateway-client/src/lib.rs index 2f6c84288..90b611bd8 100644 --- a/crates/harness-gateway-client/src/lib.rs +++ b/crates/harness-gateway-client/src/lib.rs @@ -76,7 +76,6 @@ mod wire; pub use broker::GatewayBroker; pub use catalog::fetch_model_catalog; -pub use config::GatewayConfigError; pub use config::GatewayEndpoint; pub use config::SecretString; pub use runvil::model::CompletionError; diff --git a/crates/harness-gateway-client/src/transport.rs b/crates/harness-gateway-client/src/transport.rs index 690b593db..6e35df1f5 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::error::Error; use runvil::model::{Completion, CompletionError, 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; @@ -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)), }) } @@ -319,10 +319,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 +330,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 4a9374e0e..4fc4656b8 100644 --- a/crates/harness-gateway-client/src/transport/tests/env.rs +++ b/crates/harness-gateway-client/src/transport/tests/env.rs @@ -4,23 +4,40 @@ use runvil::model::Message; use runvil::error::{ErrorCode, FactKey}; +use serde_json::Value; use super::*; +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" @@ -35,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] @@ -58,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")); } } @@ -249,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", From 058b6ec80fbeab077cbcfbe7ea25475587b225bb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:05:54 -0800 Subject: [PATCH 15/23] Replace ServiceError with the universal error `HostServices::provide` now refuses a provider with an `InvalidValue` error instead of the bespoke `ServiceError`. The refusal messages keep their text. - The error names the id literal in its `Name` fact. Its `Reason` fact is `invalid_id` or `duplicate_id` in place of the two variants. - The prose is the message, which names the id and the rule. - The map still stays as it was on either refusal, and `runvil-plugin` no longer exports `ServiceError`. - The service tests now check the code, the reason, the literal, and the prose through one helper, in place of the matches on the variants. --- crates/runvil-plugin/src/lib.rs | 2 +- crates/runvil-plugin/src/service-tests.rs | 53 +++++++++---------- crates/runvil-plugin/src/service.rs | 64 ++++++++++------------- 3 files changed, 54 insertions(+), 65 deletions(-) diff --git a/crates/runvil-plugin/src/lib.rs b/crates/runvil-plugin/src/lib.rs index 9ec85c7f4..f051fcebd 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -98,7 +98,7 @@ 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 service::{HostServices, ServiceId, ServiceKey, TOKIO_RUNTIME}; pub use runvil_types::error::{Error, ErrorCode, FactKey, Location}; pub use runvil_types::models::{ModelId, ModelInfo, ThinkingMode}; 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 {} From 4fa7c20007d858cc59625304ed9ab3878388d232 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:10:05 -0800 Subject: [PATCH 16/23] Replace InstallError with the universal error `PackRegistry::insert` and every Plugin's `install` now refuse a Pack with an `InvalidValue` error instead of the bespoke `InstallError`. The refusal messages keep their text. - The error names the FQN in its `Name` fact. Its `Reason` fact is `invalid_fqn`, `duplicate_pack`, `reserved_pack_id`, or `reserved_name` in place of the four variants. - The prose is the message, which names the Pack and the rule. - The `install` functions of `plugin-std`, `plugin-mcp`, `plugin-user-input`, and `plugin-web` now return `Result<(), Error>`, and their docs name the `duplicate_pack` refusal. - `runvil-plugin` and the `harness` facade no longer export `InstallError`, and `runvil-plugin` drops its `thiserror` dependency. - The registry tests check the code, the reason, the FQN, and the prose through one helper, and the refusal-message test builds each refusal through `insert`. The two Plugin tests check the `duplicate_pack` refusal the same way. --- Cargo.lock | 1 - crates/harness/src/lib.rs | 1 - crates/plugin-mcp/src/lib.rs | 11 +- crates/plugin-std/src/pack.rs | 12 +-- crates/plugin-std/tests/it/tools.rs | 14 ++- crates/plugin-user-input/src/lib.rs | 10 +- crates/plugin-web/src/web-tests.rs | 14 ++- crates/plugin-web/src/web.rs | 12 +-- crates/runvil-plugin/Cargo.toml | 2 - crates/runvil-plugin/src/lib.rs | 4 +- crates/runvil-plugin/src/pack.rs | 2 +- crates/runvil-plugin/src/registry-tests.rs | 87 +++++++++------- crates/runvil-plugin/src/registry.rs | 115 ++++++++++----------- 13 files changed, 147 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08a967d28..f36d3af95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4793,7 +4793,6 @@ dependencies = [ "runvil-types", "runvil-vfs", "serde_json", - "thiserror 2.0.19", ] [[package]] diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index d06f3beec..f18726c63 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -38,7 +38,6 @@ pub mod pack { pub use harness_runner::HostContext; pub use runvil_plugin::HostServices; - pub use runvil_plugin::InstallError; pub use runvil_plugin::PackId; pub use runvil_plugin::PackOrigin; pub use runvil_plugin::PackRegistry; diff --git a/crates/plugin-mcp/src/lib.rs b/crates/plugin-mcp/src/lib.rs index 36827b890..87a8020c4 100644 --- a/crates/plugin-mcp/src/lib.rs +++ b/crates/plugin-mcp/src/lib.rs @@ -107,8 +107,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use runvil_plugin::{ - HostServices, InstallError, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME, - ToolError, + Error, HostServices, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME, ToolError, }; use serde_json::{Map, Value}; use tokio::runtime::Handle; @@ -131,14 +130,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()) } diff --git a/crates/plugin-std/src/pack.rs b/crates/plugin-std/src/pack.rs index 47dce1e51..1c91aceae 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, HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ToolContext, ToolError, + ToolId, ToolInfo, ToolOutput, }; 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()) } diff --git a/crates/plugin-std/tests/it/tools.rs b/crates/plugin-std/tests/it/tools.rs index d6d895fc2..1d0335466 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, + ErrorCode, FactKey, HostServices, OutputTrust, Pack, PackId, PackRegistry, ToolError, + ToolErrorKind, ToolId, ToolOutput, }; use serde_json::{Value, json}; @@ -120,9 +120,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")) ); } diff --git a/crates/plugin-user-input/src/lib.rs b/crates/plugin-user-input/src/lib.rs index d934518cc..aca899b5d 100644 --- a/crates/plugin-user-input/src/lib.rs +++ b/crates/plugin-user-input/src/lib.rs @@ -77,7 +77,7 @@ mod ask; -use runvil_plugin::{InstallError, PackInfo, PackRegistry, ServiceId, ServiceKey, ToolError}; +use runvil_plugin::{Error, PackInfo, PackRegistry, ServiceId, ServiceKey, ToolError}; 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()) } diff --git a/crates/plugin-web/src/web-tests.rs b/crates/plugin-web/src/web-tests.rs index 9af3c11aa..5bbdbfc7c 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, + ErrorCode, FactKey, HostServices, Pack, PackId, PackRegistry, ServiceKey, TOKIO_RUNTIME, + ToolError, ToolErrorKind, ToolId, }; use serde_json::{Value, json}; use tokio::runtime::Handle; @@ -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")) ); } diff --git a/crates/plugin-web/src/web.rs b/crates/plugin-web/src/web.rs index 6221961af..87100c077 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, HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceKey, ToolContext, + ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput, }; 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()) } 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 f051fcebd..13af4cf58 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,7 +97,7 @@ pub mod testing; pub use context::{RenderContext, ToolContext}; pub use pack::{Pack, PackFuture, PackInfo}; -pub use registry::{InstallError, PackOrigin, PackRegistry}; +pub use registry::{PackOrigin, PackRegistry}; pub use service::{HostServices, ServiceId, ServiceKey, TOKIO_RUNTIME}; pub use runvil_types::error::{Error, ErrorCode, FactKey, Location}; diff --git a/crates/runvil-plugin/src/pack.rs b/crates/runvil-plugin/src/pack.rs index 3529b7ab0..75a45c6b0 100644 --- a/crates/runvil-plugin/src/pack.rs +++ b/crates/runvil-plugin/src/pack.rs @@ -147,7 +147,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()) /// } /// ``` 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..bee50ad22 100644 --- a/crates/runvil-plugin/src/registry.rs +++ b/crates/runvil-plugin/src/registry.rs @@ -1,12 +1,12 @@ //! 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 serde_json::Value; @@ -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. From ef0c59477bcfc3f4d64a8165a334c45497e7dbb7 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:12:31 -0800 Subject: [PATCH 17/23] Add with_cause to the universal error `Error::with_cause` appends a cause, and each cause under it, to the message after `: `, and leaves the prose as it was. A conversion that kept a typed source now keeps its text in the message, where a log reads it, while the model reads only the prose. - A cause whose text is empty, or already in the message, is skipped. - The facade listing in `public-api.txt` gains the method. - Two new tests pin the appended chain, the unchanged prose, and the skip. --- .../runvil-internal/types/src/error-tests.rs | 40 ++++++++++++++++++- crates/runvil-internal/types/src/error.rs | 17 ++++++++ crates/runvil/public-api.txt | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index baf46452c..ee323d428 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -1,9 +1,45 @@ -//! Tests for `ErrorCode` numbering and `Location` ranges. +//! 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::{ErrorCode, Location}; +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"); +} /// Every code, in declaration order. const ALL: [ErrorCode; 68] = [ diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 4d1e0de2e..f996ebe3d 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -41,6 +41,23 @@ impl Error { facts: BTreeMap::new(), } } + + /// Appends `cause`, and each cause under it, to the message after `: `, + /// skipping any text the message already holds. 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) { + self.message.push_str(": "); + self.message.push_str(&text); + } + next = current.source(); + } + self + } } impl fmt::Display for Error { diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 1d2b2a1ac..a7827a5b5 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -391,6 +391,7 @@ pub fn runvil::effect::ToolCallOrigin::new(execution: impl core::convert::Into) -> runvil::effect::ToolCallOrigin pub fn runvil::effect::ToolCallOrigin::with_model(self, model: impl core::convert::Into) -> runvil::effect::ToolCallOrigin pub fn runvil::error::Error::new(code: runvil::error::ErrorCode, message: impl core::convert::Into, prose: impl core::convert::Into) -> runvil::error::Error +pub fn runvil::error::Error::with_cause(self, cause: &dyn core::error::Error + 'static) -> runvil::error::Error pub fn runvil::error::Location::line(file: impl core::convert::Into, line: core::num::nonzero::NonZeroU32) -> runvil::error::Location pub fn runvil::error::Location::new(file: impl core::convert::Into, start_line: core::num::nonzero::NonZeroU32, start_column: core::num::nonzero::NonZeroU32, end_line: core::num::nonzero::NonZeroU32, end_column: core::num::nonzero::NonZeroU32) -> core::option::Option pub fn runvil::event::Event::execution(&self) -> &str From 1a63a8da401c26a7571718831dbb232ed058f88c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 18:39:07 -0800 Subject: [PATCH 18/23] Replace ToolError and ToolErrorKind with the universal error A tool call now fails with the universal `Error` instead of the bespoke `ToolError`, and its four kinds become the codes `ToolInvalidArguments`, `ToolBackend`, `ToolTransport`, and `ToolOther`. `tool_error` builds a failure whose message and prose are both the tool's model-safe text, and `with_cause` puts a cause in the message only. - The `Pack` trait, `PackInfo` construct, every Plugin, the Harness performers, the Engine's effect answer, and the Lua dispatch now carry `Error`. `runvil`, `runvil-plugin`, and `runvil-types` export `tool_error` in place of the two types. - The Lua crate's `Error::tool` and the Engine's failure record take the tool's `prose`, so a cause stays out of the text the model reads and out of the stored record. - `tool_failure_tag` keeps the tags a run log stored before: `invalid_arguments`, `backend`, `transport`, and `other` for any other code. - Tests that pinned the typed source or the kind tags are removed. Tests that hid a source now check the cause text in the message and its absence from the prose. A new test pins every tool code's record tag, and one new types test pins `tool_error`. - The two store-answer tests move unchanged to `effect-tests-store.rs`, which keeps `effect-tests.rs` under the 500-line limit. --- .../harness-internal/runner/src/host-run.rs | 13 +- .../runner/src/host-tests-render.rs | 6 +- .../harness-internal/runner/src/host-tests.rs | 29 ++--- .../harness-internal/runner/src/performers.rs | 5 +- .../harness-internal/runner/tests/it/asker.rs | 20 ++-- .../runner/tests/it/effect_loop-cap.rs | 5 +- .../runner/tests/it/performers.rs | 5 +- .../runner/tests/it/prepare-host-services.rs | 6 +- .../runner/tests/it/prepare-input.rs | 14 +-- .../runner/tests/it/prepare-ready.rs | 22 ++-- .../runner/tests/it/prepare.rs | 11 +- .../runner/tests/it/render.rs | 17 ++- .../runner/tests/it/scripted.rs | 14 ++- .../runner/tests/it/support.rs | 17 +-- .../runner/tests/it/tool_context.rs | 11 +- crates/harness/tests/suite/asker.rs | 26 ++-- crates/harness/tests/suite/host.rs | 3 +- crates/harness/tests/suite/vfs.rs | 4 +- crates/plugin-mcp/src/dynamic.rs | 52 ++++---- crates/plugin-mcp/src/entry.rs | 23 ++-- crates/plugin-mcp/src/lib.rs | 13 +- crates/plugin-mcp/src/result-tests.rs | 20 ++-- crates/plugin-mcp/src/result.rs | 26 ++-- crates/plugin-mcp/src/server.rs | 17 ++- crates/plugin-mcp/tests/it/calls.rs | 14 +-- crates/plugin-mcp/tests/it/listing.rs | 12 +- crates/plugin-mcp/tests/it/support.rs | 19 +-- crates/plugin-std/src/check-tests.rs | 24 ++-- crates/plugin-std/src/check.rs | 31 ++--- crates/plugin-std/src/dir.rs | 17 +-- crates/plugin-std/src/dir/args.rs | 11 +- crates/plugin-std/src/dir/tests.rs | 10 +- crates/plugin-std/src/dir/tests/arguments.rs | 6 +- crates/plugin-std/src/dir/tests/ignore.rs | 4 +- crates/plugin-std/src/grep.rs | 10 +- crates/plugin-std/src/grep/args.rs | 21 ++-- crates/plugin-std/src/grep/tests.rs | 12 +- crates/plugin-std/src/grep/tests/arguments.rs | 6 +- crates/plugin-std/src/grep/tests/patterns.rs | 4 +- crates/plugin-std/src/lib.rs | 6 +- crates/plugin-std/src/pack.rs | 30 +++-- crates/plugin-std/src/read.rs | 19 +-- crates/plugin-std/src/read/args.rs | 15 +-- crates/plugin-std/src/read/tests.rs | 10 +- crates/plugin-std/src/read/tests/arguments.rs | 30 ++--- crates/plugin-std/src/spec.rs | 19 +-- crates/plugin-std/tests/it/grep.rs | 4 +- crates/plugin-std/tests/it/tools.rs | 22 ++-- crates/plugin-user-input/src/ask.rs | 25 ++-- crates/plugin-user-input/src/lib.rs | 8 +- crates/plugin-user-input/tests/it/ask.rs | 28 +++-- crates/plugin-web/src/error.rs | 18 +-- crates/plugin-web/src/fetch.rs | 46 +++---- crates/plugin-web/src/fetch/tests-policy.rs | 6 +- crates/plugin-web/src/provider.rs | 4 +- crates/plugin-web/src/search-request.rs | 16 ++- crates/plugin-web/src/search-tests.rs | 75 ++++++------ crates/plugin-web/src/search.rs | 28 ++--- crates/plugin-web/src/web-tests.rs | 8 +- crates/plugin-web/src/web.rs | 33 +++-- crates/runvil-internal/engine/src/error.rs | 8 +- .../engine/src/error/tests-chain.rs | 7 +- .../engine/src/execute/run/effect-record.rs | 8 +- .../src/execute/run/effect-tests-store.rs | 61 ++++++++++ .../engine/src/execute/run/effect-tests.rs | 101 ++++++---------- .../engine/src/execute/run/effect.rs | 22 +++- .../engine/src/execute/scheduler/apply.rs | 4 +- .../engine/src/execute/tests.rs | 3 +- .../engine/src/execute/tests/fixtures.rs | 22 ++-- .../engine/src/execute/tests/offering.rs | 2 +- .../src/execute/tests/tool_call_access.rs | 12 +- .../engine/src/execute/tests/tool_listings.rs | 2 +- .../engine/src/execute/tests/tool_loop.rs | 4 +- .../engine/src/test_support.rs | 10 +- .../src/test_support/scenario-driving.rs | 6 +- .../engine/src/test_support/tools.rs | 10 +- .../runvil-internal/lua/src/dispatch-tests.rs | 12 +- crates/runvil-internal/lua/src/dispatch.rs | 8 +- crates/runvil-internal/lua/src/error-tests.rs | 8 +- crates/runvil-internal/lua/src/error.rs | 9 +- crates/runvil-internal/types/src/tools.rs | 6 +- .../runvil-internal/types/src/tools/output.rs | 113 ++---------------- .../runvil-internal/types/src/tools/tests.rs | 63 +++------- crates/runvil-plugin/src/lib.rs | 2 +- crates/runvil-plugin/src/pack.rs | 22 ++-- crates/runvil-plugin/src/registry.rs | 8 +- crates/runvil-plugin/tests/it/pack_info.rs | 35 +++--- crates/runvil/public-api.txt | 21 +--- crates/runvil/src/lib.rs | 3 +- crates/runvil/tests/suite/support.rs | 12 +- crates/workshop/agents/src/input-tests.rs | 4 +- crates/workshop/agents/src/input-tool.rs | 16 ++- 92 files changed, 808 insertions(+), 846 deletions(-) create mode 100644 crates/runvil-internal/engine/src/execute/run/effect-tests-store.rs diff --git a/crates/harness-internal/runner/src/host-run.rs b/crates/harness-internal/runner/src/host-run.rs index 5c6c05454..10bfbdcbf 100644 --- a/crates/harness-internal/runner/src/host-run.rs +++ b/crates/harness-internal/runner/src/host-run.rs @@ -18,9 +18,10 @@ use std::sync::Arc; use runvil::Prompt; use runvil::effect::{PackToolSets, ToolCallOrigin}; +use runvil::error::{Error, ErrorCode}; use runvil::model::ModelInfo; use runvil::packs::{Prelude, RosterEntry}; -use runvil::tools::{ToolCatalog, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput, tool_error}; use runvil::vfs::Access; use runvil_plugin::{HostServices, Pack, PackId, PackInfo, RenderContext, ServiceId, ToolContext}; use serde_json::Value; @@ -187,7 +188,7 @@ impl ToolPerformer for HostRunContext { access: Arc, origin: ToolCallOrigin, args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let pack = self.usable(&tool.pack()).cloned(); let services = self.services.clone(); Box::pin(async move { @@ -197,10 +198,10 @@ impl ToolPerformer for HostRunContext { alias = %alias, "a ToolCall names a tool of no usable Pack of the run" ); - return Err(ToolError::message(format!( - "tool `{alias}` ({tool}) belongs to no usable Pack of this run" - )) - .with_kind(ToolErrorKind::Other)); + return Err(tool_error( + ErrorCode::ToolOther, + format!("tool `{alias}` ({tool}) belongs to no usable Pack of this run"), + )); }; let cx = ToolContext::new(&tool, &access, &origin, &services); pack.call(cx, args).await diff --git a/crates/harness-internal/runner/src/host-tests-render.rs b/crates/harness-internal/runner/src/host-tests-render.rs index 1002a3909..284be00dd 100644 --- a/crates/harness-internal/runner/src/host-tests-render.rs +++ b/crates/harness-internal/runner/src/host-tests-render.rs @@ -37,10 +37,10 @@ fn rendering( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let renders = services .get(&RENDERS) - .ok_or_else(|| ToolError::message("rendering needs tests/renders"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "rendering needs tests/renders"))?; Ok(Arc::new(Rendering { name: name.clone(), listed: listed(name, config, services)?, @@ -57,7 +57,7 @@ impl Pack for Rendering { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.listed.call(cx, args) } diff --git a/crates/harness-internal/runner/src/host-tests.rs b/crates/harness-internal/runner/src/host-tests.rs index 381f41188..eaa850933 100644 --- a/crates/harness-internal/runner/src/host-tests.rs +++ b/crates/harness-internal/runner/src/host-tests.rs @@ -10,13 +10,15 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use runvil::effect::{ToolCallOrigin, ToolCaller}; +use runvil::error::{Error, ErrorCode}; use runvil::packs::RosterEntry; use runvil::timestamp::Timestamp; +use runvil::tools::tool_error; use runvil::vfs::{Origin, VfsRef}; use runvil::{Prompt, Run, RunConfig, RunOutcome, RunSeed, Step}; use runvil_plugin::{ HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceId, ServiceKey, - ToolContext, ToolError, ToolId, ToolInfo, ToolOutput, + ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -55,11 +57,11 @@ impl Pack for Listed { self.tools.clone() } - fn ready(&self) -> PackFuture<'_, Result<(), ToolError>> { + fn ready(&self) -> PackFuture<'_, Result<(), Error>> { match self.ready.as_deref() { Some("fails") => { let reason = format!("{} will not be ready", self.name); - Box::pin(async move { Err(ToolError::message(reason)) }) + Box::pin(async move { Err(tool_error(ErrorCode::ToolOther, reason)) }) } Some("pends") => Box::pin(std::future::pending()), _ => Box::pin(async { Ok(()) }), @@ -70,7 +72,7 @@ impl Pack for Listed { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { Ok(ToolOutput::trusted(format!("{}:{}", self.name, cx.tool()))) }) } } @@ -83,11 +85,7 @@ impl Pack for Listed { clippy::unnecessary_wraps, reason = "the PackInfo construct signature fixes the argument and return types" )] -fn listed( - name: &PackId, - config: Value, - services: &HostServices, -) -> Result, ToolError> { +fn listed(name: &PackId, config: Value, services: &HostServices) -> Result, Error> { if let Some(built) = services.get(&BUILT) { built.lock().unwrap().push(name.to_string()); } @@ -115,13 +113,10 @@ fn listed_tool(id: &str) -> ToolInfo { /// Builds a [`Listed`] with one `/use` tool, failing when the Host /// provides no [`BACKEND`]. -fn backed( - name: &PackId, - _config: Value, - services: &HostServices, -) -> Result, ToolError> { +fn backed(name: &PackId, _config: Value, services: &HostServices) -> Result, Error> { if services.get(&BACKEND).is_none() { - return Err(ToolError::message( + return Err(tool_error( + ErrorCode::ToolOther, "backed needs tests/backend, and this host provides none", )); } @@ -447,7 +442,7 @@ fn preludes_follow_the_prompts_declaration_order_and_carry_the_pack_id() { } /// Calls `tool` through `run` from a script and returns its result. -async fn call(run: &dyn ToolPerformer, tool: &str) -> Result { +async fn call(run: &dyn ToolPerformer, tool: &str) -> Result { let access = VfsRef::default().acquire(Origin::new("host test")).unwrap(); let origin = ToolCallOrigin::new("host-test", "Only", ToolCaller::Script); let tool = ToolId::parse(tool).unwrap(); @@ -469,7 +464,7 @@ async fn a_call_goes_to_the_pack_its_tool_ids_first_segment_names() { ]); let run = begin(&host, HostServices::new(), BTreeMap::new(), &prompt("")); - let answer = |result: Result| result.unwrap().text().to_owned(); + let answer = |result: Result| result.unwrap().text().to_owned(); assert_eq!(answer(call(&run, "one/echo").await), "one:one/echo"); assert_eq!(answer(call(&run, "two/echo").await), "two:two/echo"); let error = call(&run, "three/echo") diff --git a/crates/harness-internal/runner/src/performers.rs b/crates/harness-internal/runner/src/performers.rs index b72d48fd3..51e5b73cb 100644 --- a/crates/harness-internal/runner/src/performers.rs +++ b/crates/harness-internal/runner/src/performers.rs @@ -33,11 +33,12 @@ 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, }; -use runvil::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; use serde_json::Value; @@ -99,7 +100,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/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-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index cdbb88770..34a74c9ef 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,13 @@ 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, }; use runvil::timestamp::Timestamp; -use runvil::tools::{ToolCatalog, ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; use runvil::{Prompt, Run, RunConfig}; use serde_json::{Value, json}; @@ -138,7 +139,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); diff --git a/crates/harness-internal/runner/tests/it/performers.rs b/crates/harness-internal/runner/tests/it/performers.rs index 83543d197..ceff1f74d 100644 --- a/crates/harness-internal/runner/tests/it/performers.rs +++ b/crates/harness-internal/runner/tests/it/performers.rs @@ -14,7 +14,8 @@ use harness_runner::recorder::{ }; use runvil::cancel::CancelHandle; use runvil::effect::ToolCallOrigin; -use runvil::tools::{ToolError, ToolId, ToolOutput}; +use runvil::error::Error; +use runvil::tools::{ToolId, ToolOutput}; use runvil::vfs::Access; use serde_json::{Value, json}; @@ -112,7 +113,7 @@ impl ToolPerformer for DelayedTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let delay = self.delay; let text = self.text; Box::pin(async move { diff --git a/crates/harness-internal/runner/tests/it/prepare-host-services.rs b/crates/harness-internal/runner/tests/it/prepare-host-services.rs index c92daad21..6f8f6a491 100644 --- a/crates/harness-internal/runner/tests/it/prepare-host-services.rs +++ b/crates/harness-internal/runner/tests/it/prepare-host-services.rs @@ -33,7 +33,7 @@ fn construct_greeter( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let greet = ToolInfo::new( ToolId::parse(&format!("{name}/greet")).unwrap(), "Answer with the run's greeting.", @@ -51,11 +51,11 @@ impl Pack for Greeter { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let greeting = cx .service(&GREETING) - .ok_or_else(|| ToolError::message("no greeting in this run"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "no greeting in this run"))?; Ok(ToolOutput::trusted(greeting.to_string())) }) } diff --git a/crates/harness-internal/runner/tests/it/prepare-input.rs b/crates/harness-internal/runner/tests/it/prepare-input.rs index 0013a9b51..34708f38c 100644 --- a/crates/harness-internal/runner/tests/it/prepare-input.rs +++ b/crates/harness-internal/runner/tests/it/prepare-input.rs @@ -20,21 +20,21 @@ struct Scripted(&'static str); #[async_trait::async_trait] impl AskBroker for Scripted { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Ok(self.0.to_owned()) } } /// A broker whose every wait fails, with a hidden cause behind the -/// message. +/// prose. struct Failing; #[async_trait::async_trait] impl AskBroker for Failing { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Err( - ToolError::new(ToolErrorKind::Backend, "the operator's window closed") - .with_source(std::io::Error::other("socket reset")), + tool_error(ErrorCode::ToolBackend, "the operator's window closed") + .with_cause(&std::io::Error::other("socket reset")), ) } } @@ -56,7 +56,7 @@ fn construct_probe( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let saw = ToolInfo::new( ToolId::parse(&format!("{name}/saw")).unwrap(), "Answer whether the call was lent a broker.", @@ -74,7 +74,7 @@ impl Pack for Probe { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { let saw = cx.service(&BROKER).is_some(); Box::pin(async move { Ok(ToolOutput::trusted(saw.to_string())) }) } diff --git a/crates/harness-internal/runner/tests/it/prepare-ready.rs b/crates/harness-internal/runner/tests/it/prepare-ready.rs index c6dcc259c..fe0fdacc9 100644 --- a/crates/harness-internal/runner/tests/it/prepare-ready.rs +++ b/crates/harness-internal/runner/tests/it/prepare-ready.rs @@ -89,14 +89,15 @@ fn construct_starting( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let startup = services .get(&STARTUPS) .and_then(|startups| startups.get(&name.to_string()).cloned()) .ok_or_else(|| { - ToolError::message(format!( - "{name} needs its startup in tests/startups, and this host provides none" - )) + tool_error( + ErrorCode::ToolOther, + format!("{name} needs its startup in tests/startups, and this host provides none"), + ) })?; Ok(Arc::new(Starting { echo: construct_echo(name, config, services)?, @@ -112,16 +113,21 @@ impl Pack for Starting { } } - fn ready(&self) -> PackFuture<'_, Result<(), ToolError>> { + fn ready(&self) -> PackFuture<'_, Result<(), Error>> { let mut phase = self.startup.phase.clone(); Box::pin(async move { self.startup.asked.notify_one(); let settled = *phase .wait_for(|phase| !matches!(phase, Phase::Starting)) .await - .map_err(|_closed| ToolError::message("the test dropped the fixture's startup"))?; + .map_err(|_closed| { + tool_error( + ErrorCode::ToolOther, + "the test dropped the fixture's startup", + ) + })?; match settled { - Phase::Failed(reason) => Err(ToolError::message(reason)), + Phase::Failed(reason) => Err(tool_error(ErrorCode::ToolOther, reason)), Phase::Starting | Phase::Ready => Ok(()), } }) @@ -131,7 +137,7 @@ impl Pack for Starting { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.echo.call(cx, args) } } diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index ff3c8ec71..a476be954 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -21,10 +21,11 @@ use harness_runner::prepare::{PrepareError, Prepared, Services, begin, prepare}; use harness_runner::recorder::{MemoryRecorder, RecordKind, RunErrorKind, RunId, RunOutcome}; use harness_runner::{HostContext, display_chain}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode}; use runvil::event::Event; +use runvil::tools::tool_error; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolError, ToolErrorKind, - ToolId, ToolInfo, ToolOutput, + HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -151,7 +152,7 @@ fn construct_echo( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let echo = ToolInfo::new( ToolId::parse(&format!("{name}/echo")).unwrap(), "Echo the value argument.", @@ -169,12 +170,12 @@ impl Pack for Echo { &'a self, _cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let value = args .get("value") .and_then(Value::as_str) - .ok_or_else(|| ToolError::message("echo: missing `value`"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "echo: missing `value`"))?; Ok(ToolOutput::trusted(value.to_owned())) }) } diff --git a/crates/harness-internal/runner/tests/it/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..d55370a9a 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, }; +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; @@ -221,10 +223,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 +248,7 @@ impl Pack for HoldPack { &'a self, _cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { self.held.hold() } } @@ -303,12 +305,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..52b1c9c7a 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -14,11 +14,12 @@ use harness_runner::recorder::{ }; use runvil::cancel::CancelHandle; use runvil::effect::{Round, ToolCallOrigin}; +use runvil::error::Error; use runvil::model::{ Completion, CompletionError, 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, @@ -151,7 +152,7 @@ impl ToolPerformer for Unused { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { unreachable!("this test issues no ToolCall effect") } } @@ -184,7 +185,7 @@ impl ToolPerformer for TextTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let text = self.0; Box::pin(async move { Ok(ToolOutput::trusted(text)) }) } @@ -201,7 +202,7 @@ impl ToolPerformer for PendingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { Box::pin(std::future::pending()) } } @@ -218,9 +219,9 @@ impl ToolPerformer for PanickingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { Box::pin(std::future::poll_fn( - |_cx| -> Poll> { + |_cx| -> Poll> { panic!("the tool performer panics instead of answering") }, )) @@ -242,7 +243,7 @@ impl ToolPerformer for ClosingTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let recorder = Arc::clone(&self.recorder); let run_id = self.run_id; Box::pin(async move { @@ -377,7 +378,7 @@ impl ToolPerformer for GatedTool { _access: Arc, _origin: ToolCallOrigin, _args: Value, - ) -> BoxFuture> { + ) -> BoxFuture> { let gate = Arc::clone(&self.gate); Box::pin(async move { gate.acquire() diff --git a/crates/harness-internal/runner/tests/it/tool_context.rs b/crates/harness-internal/runner/tests/it/tool_context.rs index b4a9372e3..d582e3f52 100644 --- a/crates/harness-internal/runner/tests/it/tool_context.rs +++ b/crates/harness-internal/runner/tests/it/tool_context.rs @@ -8,9 +8,10 @@ use std::sync::Arc; use harness_runner::effect_loop::drive_run; use harness_runner::recorder::{MemoryRecorder, RunOutcome}; use runvil::cancel::CancelHandle; +use runvil::error::{Error, ErrorCode}; +use runvil::tools::tool_error; use runvil_plugin::{ - HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolError, ToolId, ToolInfo, - ToolOutput, + HostServices, Pack, PackFuture, PackId, PackInfo, ToolContext, ToolId, ToolInfo, ToolOutput, }; use serde_json::{Value, json}; @@ -43,7 +44,7 @@ fn construct( name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { let record = ToolInfo::new( ToolId::parse(&format!("{name}/record")).unwrap(), "Write a file through the call's access and name the caller.", @@ -63,11 +64,11 @@ impl Pack for Record { &'a self, cx: ToolContext<'a>, _args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { cx.access() .write("/from-tool.md", WRITTEN.as_bytes()) - .map_err(|error| ToolError::message(format!("record: {error}")))?; + .map_err(|error| tool_error(ErrorCode::ToolOther, format!("record: {error}")))?; Ok(ToolOutput::trusted(format!("{:?}", cx.origin().caller))) }) } diff --git a/crates/harness/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/host.rs b/crates/harness/tests/suite/host.rs index 08ecc7c99..afec43244 100644 --- a/crates/harness/tests/suite/host.rs +++ b/crates/harness/tests/suite/host.rs @@ -20,7 +20,6 @@ use runvil::model::{ Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, ModelInfo, ThinkingMode, ToolSchema, }; -use runvil_plugin::ToolError; use crate::asker::{AskBroker, asker_host, broker_services}; use crate::support::{Clock, Offline}; @@ -85,7 +84,7 @@ struct Operator(&'static str); #[async_trait] impl AskBroker for Operator { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { Ok(self.0.to_owned()) } } diff --git a/crates/harness/tests/suite/vfs.rs b/crates/harness/tests/suite/vfs.rs index 0c21664d2..3533391db 100644 --- a/crates/harness/tests/suite/vfs.rs +++ b/crates/harness/tests/suite/vfs.rs @@ -9,8 +9,8 @@ use async_trait::async_trait; use harness::record::MemoryRecorder; use harness::vfs::{Origin, VfsError, VfsRef}; use harness::{Harness, HostSnapshot, RunRequest}; +use runvil::error::Error; use runvil::vfs::{MemoryBackend, Op, Policy, Verdict, VfsPath}; -use runvil_plugin::ToolError; use crate::asker::{AskBroker, asker_host, broker_services}; use crate::support::{Clock, Offline}; @@ -89,7 +89,7 @@ struct Edit { #[async_trait] impl AskBroker for Edit { - async fn wait(&self) -> Result { + async fn wait(&self) -> Result { let edit = self .vfs .acquire_store(Origin::new("desk edit")) diff --git a/crates/plugin-mcp/src/dynamic.rs b/crates/plugin-mcp/src/dynamic.rs index a1f558639..75615f5fe 100644 --- a/crates/plugin-mcp/src/dynamic.rs +++ b/crates/plugin-mcp/src/dynamic.rs @@ -7,8 +7,8 @@ use std::collections::BTreeMap; use rmcp::model::JsonObject; use rmcp::service::{Peer, RoleClient}; use runvil_plugin::{ - Pack, PackFuture, PackId, ToolCaller, ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, - ToolOutput, + Error, ErrorCode, Pack, PackFuture, PackId, ToolCaller, ToolContext, ToolId, ToolInfo, + ToolOutput, tool_error, }; use serde_json::{Map, Value, json}; use tokio::runtime::Handle; @@ -81,13 +81,13 @@ fn marked(caller: ToolCaller, text: String) -> ToolOutput { } } -fn invalid(text: impl Into) -> ToolError { - ToolError::message(text).with_kind(ToolErrorKind::InvalidArguments) +fn invalid(text: impl Into) -> Error { + tool_error(ErrorCode::ToolInvalidArguments, text) } /// The members of a tool's arguments, which must be a JSON object or /// `null`. -fn members(args: Value, refusal: &str) -> Result, ToolError> { +fn members(args: Value, refusal: &str) -> Result, Error> { match args { Value::Null => Ok(Map::new()), Value::Object(members) => Ok(members), @@ -96,7 +96,7 @@ fn members(args: Value, refusal: &str) -> Result, ToolError> } /// The string at `key`, or `None` when it is absent or `null`. -fn string<'a>(members: &'a Map, key: &str) -> Result, ToolError> { +fn string<'a>(members: &'a Map, key: &str) -> Result, Error> { match members.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(text)) => Ok(Some(text)), @@ -138,11 +138,10 @@ impl Mcp { name: &PackId, namespaces: BTreeMap, runtime: Handle, - ) -> Result { + ) -> Result { let id = |tool: &str| { ToolId::parse(&format!("{name}/{tool}")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "MCP could not name its tools") - .with_source(e) + tool_error(ErrorCode::ToolBackend, "MCP could not name its tools").with_cause(&e) }) }; let get = ToolInfo::new( @@ -194,7 +193,7 @@ impl Mcp { } /// The server under `name`, or an error that says how to list them. - fn namespace(&self, name: &str) -> Result<&Namespace, ToolError> { + fn namespace(&self, name: &str) -> Result<&Namespace, Error> { self.namespaces.get(name).ok_or_else(|| { invalid(format!( "no configured MCP server has the namespace `{name}`; call {} with no arguments \ @@ -204,7 +203,7 @@ impl Mcp { }) } - fn unknown_tool(&self, namespace: &str, tool: &str) -> ToolError { + fn unknown_tool(&self, namespace: &str, tool: &str) -> Error { invalid(format!( "the MCP server `{namespace}` has no tool `{tool}`; call {} with namespace \ `{namespace}` to list its tools", @@ -213,7 +212,7 @@ impl Mcp { } /// Answers `get_dynamic_tools` as JSON text. - fn list(&self, args: Value) -> Result { + fn list(&self, args: Value) -> Result { let members = members( args, "get_dynamic_tools takes a JSON object with the optional strings `namespace` and `toolName`", @@ -259,7 +258,7 @@ impl Mcp { /// The peer and tool name a `call_dynamic_tool` goes to, or why it /// cannot go out. - fn target(&self, namespace: &str, tool: &str) -> Result<(Peer, String), ToolError> { + fn target(&self, namespace: &str, tool: &str) -> Result<(Peer, String), Error> { self.namespace(namespace)?.with_status(|status| match status { Status::Ready(ready) => ready .tools @@ -267,19 +266,23 @@ impl Mcp { .find(|listed| listed.name == tool) .map(|listed| (ready.peer.clone(), listed.name.clone())) .ok_or_else(|| self.unknown_tool(namespace, tool)), - Status::Loading => Err(ToolError::message(format!( - "the MCP server `{namespace}` is still loading; call {} later to see when it is ready", - self.get.id - ))), - Status::Error(reason) => Err(ToolError::message(format!( - "the MCP server `{namespace}` is unavailable: {reason}" - ))), + Status::Loading => Err(tool_error( + ErrorCode::ToolOther, + format!( + "the MCP server `{namespace}` is still loading; call {} later to see when it is ready", + self.get.id + ), + )), + Status::Error(reason) => Err(tool_error( + ErrorCode::ToolOther, + format!("the MCP server `{namespace}` is unavailable: {reason}"), + )), }) } /// Performs `call_dynamic_tool` and returns the result's text. Every /// argument is checked before anything is sent. - async fn call_tool(&self, args: Value) -> Result { + async fn call_tool(&self, args: Value) -> Result { let mut members = members( args, "call_dynamic_tool takes a JSON object with the strings `namespace` and `toolName` \ @@ -312,7 +315,7 @@ impl Pack for Mcp { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { let tool = cx.tool(); let caller = cx.origin().caller; @@ -321,7 +324,10 @@ impl Pack for Mcp { } else if *tool == self.call.id { self.call_tool(args).await.map(|text| marked(caller, text)) } else { - Err(ToolError::message(format!("MCP has no tool {tool}"))) + Err(tool_error( + ErrorCode::ToolOther, + format!("MCP has no tool {tool}"), + )) } }) } diff --git a/crates/plugin-mcp/src/entry.rs b/crates/plugin-mcp/src/entry.rs index 5d6ed4465..d5c1c3407 100644 --- a/crates/plugin-mcp/src/entry.rs +++ b/crates/plugin-mcp/src/entry.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt; use reqwest::header::{HeaderName, HeaderValue}; -use runvil_plugin::ToolError; +use runvil_plugin::{Error, ErrorCode, tool_error}; use serde_json::{Map, Value}; /// The reason every local entry is refused with, until local servers @@ -41,8 +41,8 @@ pub(crate) struct Vars<'a> { pub(crate) home: Option, } -fn refuse(text: impl Into) -> ToolError { - ToolError::message(text) +fn refuse(text: impl Into) -> Error { + tool_error(ErrorCode::ToolOther, text) } /// Parses `config` into a remote entry. @@ -52,7 +52,7 @@ fn refuse(text: impl Into) -> ToolError { /// both shapes or neither, a wrong value type, the legacy `sse` type, an /// unset `${env:NAME}`, and a header rmcp owns or that is not a valid /// header. No message holds an entry value. -pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result { +pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result { let entry = config.as_object().ok_or_else(|| { refuse("an MCP entry must be a JSON object with `url`, or with `command`") })?; @@ -73,7 +73,7 @@ pub(crate) fn parse(config: &Value, vars: &Vars<'_>) -> Result) -> Result<(), ToolError> { +fn check_local_shape(entry: &Map) -> Result<(), Error> { string_field(entry, "command")?; if let Some(args) = entry.get("args") { let all_strings = args @@ -90,7 +90,7 @@ fn check_local_shape(entry: &Map) -> Result<(), ToolError> { Ok(()) } -fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result { +fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result { if entry.get("type").and_then(Value::as_str) == Some("sse") { return Err(refuse( "type `sse` is the legacy MCP transport, and only Streamable HTTP is supported", @@ -118,7 +118,7 @@ fn parse_remote(entry: &Map, vars: &Vars<'_>) -> Result) -> Result<(HeaderName, HeaderValue), ToolError> { +fn header(key: &str, raw: &str, vars: &Vars<'_>) -> Result<(HeaderName, HeaderValue), Error> { let name = HeaderName::from_bytes(key.as_bytes()) .map_err(|_| refuse(format!("header `{key}` is not a valid header name")))?; if RESERVED_HEADERS.contains(&name.as_str()) { @@ -136,15 +136,12 @@ fn header(key: &str, raw: &str, vars: &Vars<'_>) -> Result<(HeaderName, HeaderVa Ok((name, value)) } -fn wrong_type(field: &str, wanted: &str) -> ToolError { +fn wrong_type(field: &str, wanted: &str) -> Error { refuse(format!("`{field}` must be {wanted}")) } /// The string at `field`, or `None` when it is absent. -fn string_field<'a>( - entry: &'a Map, - field: &str, -) -> Result, ToolError> { +fn string_field<'a>(entry: &'a Map, field: &str) -> Result, Error> { match entry.get(field) { None => Ok(None), Some(Value::String(text)) => Ok(Some(text)), @@ -153,7 +150,7 @@ fn string_field<'a>( } /// The key and string value of each member of the object at `value`. -fn string_map<'a>(value: &'a Value, field: &str) -> Result, ToolError> { +fn string_map<'a>(value: &'a Value, field: &str) -> Result, Error> { let members = value .as_object() .ok_or_else(|| wrong_type(field, "an object of strings"))?; diff --git a/crates/plugin-mcp/src/lib.rs b/crates/plugin-mcp/src/lib.rs index 87a8020c4..a7140f0b3 100644 --- a/crates/plugin-mcp/src/lib.rs +++ b/crates/plugin-mcp/src/lib.rs @@ -107,7 +107,8 @@ use std::collections::BTreeMap; use std::sync::Arc; use runvil_plugin::{ - Error, HostServices, 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; @@ -150,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 1c91aceae..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::{ - Error, HostServices, 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}; @@ -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 1d0335466..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::{ - ErrorCode, FactKey, HostServices, 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 { @@ -246,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] @@ -256,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] @@ -268,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] @@ -381,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 aca899b5d..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::{Error, PackInfo, PackRegistry, ServiceId, ServiceKey, ToolError}; +use runvil_plugin::{Error, PackInfo, PackRegistry, ServiceId, ServiceKey}; use serde_json::Value; /// The Pack's `PackInfo`. @@ -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 5bbdbfc7c..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::{ - ErrorCode, FactKey, HostServices, 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)) } @@ -200,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 87100c077..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::{ - Error, HostServices, 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; @@ -76,15 +76,24 @@ fn construct( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !(config.is_null() || config == json!({})) { - return Err(ToolError::message("web takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "web takes no configuration", + )); } let provider = services.get(&SEARCH_PROVIDER).ok_or_else(|| { - ToolError::message("web needs runvil/search-provider, and this host provides none") + tool_error( + ErrorCode::ToolOther, + "web needs runvil/search-provider, and this host provides none", + ) })?; let runtime = services.get(&TOKIO_RUNTIME).ok_or_else(|| { - ToolError::message("web needs runvil/tokio-runtime, and this host provides none") + tool_error( + ErrorCode::ToolOther, + "web needs runvil/tokio-runtime, and this host provides none", + ) })?; let web = Web::new( name, @@ -96,11 +105,10 @@ fn construct( impl Web { /// The Pack over `fetch` and `search`, its tools named under `name`. - fn new(name: &PackId, fetch: WebFetch, search: WebSearch) -> Result { + fn new(name: &PackId, fetch: WebFetch, search: WebSearch) -> Result { let id = |tool: &str| { ToolId::parse(&format!("{name}/{tool}")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "web could not name its tools") - .with_source(e) + tool_error(ErrorCode::ToolBackend, "web could not name its tools").with_cause(&e) }) }; let tools = vec![ @@ -141,12 +149,15 @@ impl Pack for Web { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { match cx.tool().name() { "fetch" => self.fetch.call(args).await, "search" => self.search.call(args).await, - other => Err(ToolError::message(format!("web has no tool named {other}"))), + other => Err(tool_error( + ErrorCode::ToolOther, + format!("web has no tool named {other}"), + )), } }) } diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 3a0c5b316..505debdea 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -5,7 +5,7 @@ //! [`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 +//! a tool's [`runvil_types::error::Error`] 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`. //! @@ -359,9 +359,9 @@ pub(crate) enum Error { /// A dispatched tool returned a model-safe failure. /// - /// The tool's own [`runvil_types::tools::ToolError`] is kept as the - /// cause, so the typed failure (and any transport/parse error the tool - /// wrapped) survives instead of being flattened to a string. + /// The tool's own [`runvil_types::error::Error`] is kept as the cause, + /// so its code and facts survive instead of being flattened to a + /// string. #[error("tool call failure: {message}")] Tool { /// The tool's model-safe failure message. diff --git a/crates/runvil-internal/engine/src/error/tests-chain.rs b/crates/runvil-internal/engine/src/error/tests-chain.rs index daa7902c6..6869dea39 100644 --- a/crates/runvil-internal/engine/src/error/tests-chain.rs +++ b/crates/runvil-internal/engine/src/error/tests-chain.rs @@ -6,7 +6,8 @@ //! the store probe names a missing store. use runvil_lua::VfsOp; -use runvil_types::tools::{ToolError, ToolErrorKind}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::tool_error; use runvil_vfs::VfsError; use super::*; @@ -34,7 +35,7 @@ fn cause_bearing() -> Vec { message: "unexpected symbol near ".to_owned(), incomplete_input: true, }; - let tool = ToolError::new(ToolErrorKind::Backend, "the backend refused"); + let tool = tool_error(ErrorCode::ToolBackend, "the backend refused"); let schema = runvil_model_client::detail::tool_schema_new("echo", "", serde_json::json!([])) .expect_err("an array is not an object schema"); vec![ @@ -52,7 +53,7 @@ fn cause_bearing() -> Vec { cause: Box::new(syntax), }, Error::Tool { - message: tool.to_string(), + message: tool.prose.clone(), cause: Box::new(tool), }, Error::store_op( 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..581c544b3 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect-record.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect-record.rs @@ -149,10 +149,10 @@ pub(crate) enum AnswerRecord { /// `{"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. +/// 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 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..3fa35396e 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 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_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 { @@ -312,18 +316,40 @@ 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 = [ @@ -335,8 +361,8 @@ fn a_failure_record_of_each_kind_round_trips_through_json() { 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 +384,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] diff --git a/crates/runvil-internal/engine/src/execute/run/effect.rs b/crates/runvil-internal/engine/src/execute/run/effect.rs index a3dde60a8..a57603b20 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect.rs @@ -29,10 +29,11 @@ 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; @@ -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), @@ -376,7 +377,10 @@ impl EffectAnswer { 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 +392,18 @@ impl EffectAnswer { } } +/// The tag a failed tool call's record keeps. Stored run logs hold these +/// tags, so each tool code keeps the tag its kind had, and any other code is +/// `other`. +fn tool_failure_tag(code: ErrorCode) -> &'static str { + match code { + ErrorCode::ToolInvalidArguments => "invalid_arguments", + ErrorCode::ToolBackend => "backend", + ErrorCode::ToolTransport => "transport", + _ => "other", + } +} + #[cfg(test)] #[path = "effect-tests.rs"] mod tests; diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index a697f134f..abb048c0b 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -19,7 +19,7 @@ use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_types::ids::{RoundId, TaskId}; -use runvil_types::tools::{ToolError, ToolOutput}; +use runvil_types::tools::ToolOutput; use runvil_vfs::{ExecId, VfsError, detail}; use crate::execute::infer::accept_infer; @@ -260,7 +260,7 @@ impl ChainOps<'_> { fn accept_tool_call( &mut self, call: &ToolCallContinuation, - result: std::result::Result, + result: std::result::Result, ) -> Result { let chain = &mut *self.chain; // The shared dispatch body reports through this chain's emitter, so diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index fdbe91fcc..e3db706de 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex}; use runvil_lua::LuaProgram; use runvil_model_client::client::Role; -use runvil_types::tools::{ToolError, ToolErrorKind, ToolId, ToolOutput}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::{ToolId, ToolOutput, tool_error}; use runvil_types::untrusted::GuardNonce; use runvil_vfs::{Access, VfsError}; use serde_json::{Value, json}; diff --git a/crates/runvil-internal/engine/src/execute/tests/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/offering.rs b/crates/runvil-internal/engine/src/execute/tests/offering.rs index da5c15456..ca3d71b2b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering.rs @@ -21,7 +21,7 @@ fn tool_at(id: &str, schema: Value) -> FnTool { let tool = FnTool::new(id, schema, move |args| { let value = args["value"] .as_str() - .ok_or_else(|| ToolError::message("the value argument is required"))?; + .ok_or_else(|| tool_error(ErrorCode::ToolOther, "the value argument is required"))?; Ok(ToolOutput::trusted(format!("{label}: {value}"))) }); tool.description(id) diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs index 304098168..6a14b8f02 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_access.rs @@ -29,15 +29,21 @@ const FILES: &str = "test/tools/files"; #[track_caller] fn files_tool() -> FnTool { FnTool::new(FILES, json!({ "type": "object" }), |_| { - Err(ToolError::message("the suite's driver performs this tool")) + Err(tool_error( + ErrorCode::ToolOther, + "the suite's driver performs this tool", + )) }) .description("reads or writes one of the run's files") } /// Performs one files call through `access`: `read` names a file whose /// text is the output, and `write` names a file that receives `text`. -fn use_access(access: &Access, args: &Value) -> std::result::Result { - let failed = |error: VfsError| ToolError::message(error.to_string()); +fn use_access( + access: &Access, + args: &Value, +) -> std::result::Result { + let failed = |error: VfsError| tool_error(ErrorCode::ToolOther, error.to_string()); if let Some(path) = args["read"].as_str() { return access .read_string(path) diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs index 9cec22a24..61ba09825 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs @@ -43,7 +43,7 @@ fn trusted_listing_tool() -> FnTool { /// `tools/failing_listing`, which describes tools and always fails. fn failing_listing_tool() -> FnTool { FnTool::new("tools/failing_listing", no_arguments(), |_| { - Err(ToolError::message("the servers are down")) + Err(tool_error(ErrorCode::ToolOther, "the servers are down")) }) .description("List the tools of servers that are down.") .describes_tools() diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs index 2466407e0..f7fe76c83 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs @@ -153,8 +153,8 @@ fn tool_loop_dispatches_then_returns_text() { #[test] fn a_failing_tool_becomes_an_untrusted_error_result_and_the_loop_continues() { - // A bound tool's own failure is the call's result record - the ToolError - // message, nonce-wrapped as untrusted - with TOOL_CALL_FAILED firing + // A bound tool's own failure is the call's result record - the tool + // error's prose, nonce-wrapped as untrusted - with TOOL_CALL_FAILED firing // alongside it, and the loop continues to the terminal reply. let gateway = ScriptedChat::new(vec![ calls(&[("call_x", "echo", "{\"value\":\"x\"}")]), diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index e30c1f67d..40739fbf4 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -26,8 +26,9 @@ //! suites hold without rewriting their assertions. `FnTool` is the //! fixture tool, a closure answered at once. +use runvil_types::error::ErrorCode; use runvil_types::event::Event; -use runvil_types::tools::ToolError; +use runvil_types::tools::tool_error; use crate::Error; use crate::execute::{Effect, EffectAnswer, EffectId, IssuedEffect, Run, RunOutcome, Step}; @@ -88,9 +89,10 @@ pub fn perform_locally( match effect { Effect::Chat { .. } => chat(effect), Effect::Render { offered, .. } => EffectAnswer::Render(offered.clone()), - Effect::ToolCall { alias, .. } => EffectAnswer::ToolCall(Err(ToolError::message(format!( - "no tool is bound as {alias} in this test" - )))), + Effect::ToolCall { alias, .. } => EffectAnswer::ToolCall(Err(tool_error( + ErrorCode::ToolOther, + format!("no tool is bound as {alias} in this test"), + ))), Effect::Vfs { access, op } => EffectAnswer::Vfs(run_store_op(access, op.clone())), Effect::Timer { .. } => EffectAnswer::Timer, } diff --git a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs index ad0e8dbc2..6a419a72d 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; @@ -81,7 +82,8 @@ impl Answers { 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/tools.rs b/crates/runvil-internal/engine/src/test_support/tools.rs index c06ee7e44..101d2f69a 100644 --- a/crates/runvil-internal/engine/src/test_support/tools.rs +++ b/crates/runvil-internal/engine/src/test_support/tools.rs @@ -10,11 +10,11 @@ use std::sync::Arc; -use runvil_types::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil_types::error::Error; +use runvil_types::tools::{ToolId, ToolInfo, ToolOutput}; /// The closure an [`FnTool`] answers its calls with. -type FnToolHandler = - dyn Fn(serde_json::Value) -> Result + Send + Sync + 'static; +type FnToolHandler = dyn Fn(serde_json::Value) -> Result + Send + Sync + 'static; /// A fixture tool whose call is a closure, answered at once by the serial /// test drivers. @@ -37,7 +37,7 @@ impl FnTool { pub(crate) fn new( id: &str, schema: serde_json::Value, - handler: impl Fn(serde_json::Value) -> Result + Send + Sync + 'static, + handler: impl Fn(serde_json::Value) -> Result + Send + Sync + 'static, ) -> FnTool { let id = ToolId::parse(id).unwrap_or_else(|error| panic!("tool id {id:?}: {error}")); FnTool { @@ -106,7 +106,7 @@ impl FnTool { } /// Answers one call with `args` at once. - pub(crate) fn call_now(&self, args: serde_json::Value) -> Result { + pub(crate) fn call_now(&self, args: serde_json::Value) -> Result { (self.handler)(args) } } diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index a7e73f7a4..7f8ed675e 100644 --- a/crates/runvil-internal/lua/src/dispatch-tests.rs +++ b/crates/runvil-internal/lua/src/dispatch-tests.rs @@ -2,7 +2,8 @@ //! `prepare_dispatch` and `prepare_model_dispatch` over fixture tools as //! data and canned outputs, reported to a recorder. -use runvil_types::tools::{OutputTrust, ToolError, ToolErrorKind, ToolInfo, ToolOutput}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::{OutputTrust, ToolInfo, ToolOutput, tool_error}; use super::*; use crate::test_vm::{TEST_SECTION, echo_tool, test_nonce, tool_at}; @@ -72,7 +73,7 @@ fn prepare_dispatch_turns_a_canned_tool_error_into_the_typed_error() { let error = dispatch_body( &failing, None, - Err(ToolError::message("canned failure").with_kind(ToolErrorKind::Backend)), + Err(tool_error(ErrorCode::ToolBackend, "canned failure")), &test_nonce(), recorder.emitter(), TEST_SECTION, @@ -105,7 +106,10 @@ fn a_model_issued_tool_failure_becomes_untrusted_failure_text_under_its_call_id( let outcome = prepare_model_dispatch( &failing, None, - Err(ToolError::message("the tool's own backend failed").with_kind(ToolErrorKind::Backend)), + Err(tool_error( + ErrorCode::ToolBackend, + "the tool's own backend failed", + )), &nonce, recorder.emitter(), TEST_SECTION, @@ -217,7 +221,7 @@ fn the_listing_rule_needs_the_flag_and_untrusted_output() { let failed = prepare_dispatch( &binding("tool", &listing_tool()), None, - Err(ToolError::message("the servers are down")), + Err(tool_error(ErrorCode::ToolOther, "the servers are down")), &nonce, Recorder::default().emitter(), TEST_SECTION, diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 312a4ef18..30f97a813 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -14,7 +14,7 @@ use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; -use runvil_types::tools::{OutputTrust, ToolError, ToolOutput}; +use runvil_types::tools::{OutputTrust, ToolOutput}; use runvil_types::untrusted::GuardNonce; use crate::bindings::ToolBinding; @@ -108,7 +108,7 @@ impl ToolDispatch { pub fn prepare_dispatch( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, @@ -135,7 +135,7 @@ pub fn prepare_dispatch( pub(crate) fn dispatch_body( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, @@ -206,7 +206,7 @@ pub(crate) fn dispatch_body( pub fn prepare_model_dispatch( binding: &ToolBinding, vendor: Option<&str>, - call_result: std::result::Result, + call_result: std::result::Result, nonce: &GuardNonce, emitter: &Emitter, section: &str, diff --git a/crates/runvil-internal/lua/src/error-tests.rs b/crates/runvil-internal/lua/src/error-tests.rs index eb53001d2..1f19d180c 100644 --- a/crates/runvil-internal/lua/src/error-tests.rs +++ b/crates/runvil-internal/lua/src/error-tests.rs @@ -1,7 +1,8 @@ //! Tests for the internal error: the cause-bearing variants' chains and the //! quota variant's message. -use runvil_types::tools::{ToolError, ToolErrorKind}; +use runvil_types::error::ErrorCode; +use runvil_types::tools::tool_error; use runvil_vfs::VfsError; use super::{Error, QuotaResource}; @@ -34,10 +35,7 @@ fn no_segment_of_a_cause_bearing_error_chain_repeats() { message: syntax.to_string(), cause: Box::new(syntax), }, - Error::tool(ToolError::new( - ToolErrorKind::Backend, - "the backend refused", - )), + Error::tool(tool_error(ErrorCode::ToolBackend, "the backend refused")), Error::store( &VfsOp::Read { path: "a.txt".to_owned(), diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index b2e4ccc29..5474d660f 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -222,11 +222,12 @@ impl Error { } } - /// Wraps a tool failure as [`Error::Tool`], keeping the tool's own - /// error as the cause rather than discarding it. - pub(crate) fn tool(source: runvil_types::tools::ToolError) -> Error { + /// Wraps a tool failure as [`Error::Tool`]: the message is the tool's + /// prose, the text that is safe for the model, and the tool's own error + /// is kept as the cause rather than discarded. + pub(crate) fn tool(source: runvil_types::error::Error) -> Error { Error::Tool { - message: source.to_string(), + message: source.prose.clone(), cause: Box::new(source), } } diff --git a/crates/runvil-internal/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/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/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/src/lib.rs b/crates/runvil-plugin/src/lib.rs index 13af4cf58..ca4fcd45b 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -104,6 +104,6 @@ pub use runvil_types::error::{Error, ErrorCode, FactKey, Location}; pub use runvil_types::models::{ModelId, ModelInfo, ThinkingMode}; 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 75a45c6b0..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. @@ -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.rs b/crates/runvil-plugin/src/registry.rs index bee50ad22..c992878c8 100644 --- a/crates/runvil-plugin/src/registry.rs +++ b/crates/runvil-plugin/src/registry.rs @@ -8,7 +8,7 @@ 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}; @@ -220,7 +220,7 @@ fn builtin( _name: &PackId, _config: Value, _services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { Ok(Arc::new(Builtin)) } @@ -236,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/tests/it/pack_info.rs b/crates/runvil-plugin/tests/it/pack_info.rs index 594e12775..cd275ce89 100644 --- a/crates/runvil-plugin/tests/it/pack_info.rs +++ b/crates/runvil-plugin/tests/it/pack_info.rs @@ -12,9 +12,9 @@ use std::task::{Context, Poll, Waker}; use runvil_plugin::testing::TestCall; use runvil_plugin::{ - HostServices, ModelId, ModelInfo, OutputTrust, Pack, PackFuture, PackId, PackInfo, - RenderContext, ServiceId, ServiceKey, ThinkingMode, ToolContext, ToolError, ToolErrorKind, - ToolId, ToolInfo, ToolOutput, + Error, ErrorCode, HostServices, ModelId, ModelInfo, OutputTrust, Pack, PackFuture, PackId, + PackInfo, RenderContext, ServiceId, ServiceKey, ThinkingMode, ToolContext, ToolId, ToolInfo, + ToolOutput, tool_error, }; use serde_json::{Value, json}; @@ -44,15 +44,21 @@ fn construct( name: &PackId, config: Value, services: &HostServices, -) -> Result, ToolError> { +) -> Result, Error> { if !config.is_null() { - return Err(ToolError::message("greeter takes no configuration")); + return Err(tool_error( + ErrorCode::ToolOther, + "greeter takes no configuration", + )); } let greeting = services.get(&GREETING).ok_or_else(|| { - ToolError::message("greeter needs acme/greeting, and this Host provides none") + tool_error( + ErrorCode::ToolOther, + "greeter needs acme/greeting, and this Host provides none", + ) })?; let id = ToolId::parse(&format!("{name}/greet")).map_err(|e| { - ToolError::new(ToolErrorKind::Backend, "greeter could not name its tool").with_source(e) + tool_error(ErrorCode::ToolBackend, "greeter could not name its tool").with_cause(&e) })?; let tools = vec![ToolInfo::new( id, @@ -71,22 +77,23 @@ impl Pack for Greeter { &'a self, cx: ToolContext<'a>, args: Value, - ) -> PackFuture<'a, Result> { + ) -> PackFuture<'a, Result> { Box::pin(async move { match cx.tool().name() { "greet" => { - let visitor = cx - .service(&VISITOR) - .ok_or_else(|| ToolError::message("no visitor is in this run"))?; + let visitor = cx.service(&VISITOR).ok_or_else(|| { + tool_error(ErrorCode::ToolOther, "no visitor is in this run") + })?; let mark = args.get("mark").and_then(Value::as_str).unwrap_or("."); Ok(ToolOutput::untrusted(format!( "{}, {visitor}{mark}", self.greeting ))) } - other => Err(ToolError::message(format!( - "greeter has no tool named {other}" - ))), + other => Err(tool_error( + ErrorCode::ToolOther, + format!("greeter has no tool named {other}"), + )), } }) } diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index a7827a5b5..aaa17b789 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -11,7 +11,6 @@ #[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 @@ -47,7 +46,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 */ } @@ -127,8 +125,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 @@ -229,8 +225,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 @@ -265,7 +259,6 @@ impl core::convert::From for runvil::vfs::VfsPathBuf impl core::error::Error for runvil::ParseError impl core::error::Error for runvil::error::Error impl core::error::Error for runvil::model::CompletionError -impl core::error::Error for runvil::tools::ToolError impl core::error::Error for runvil::vfs::ParsePathReasonError impl core::error::Error for runvil::vfs::VfsError impl core::fmt::Display for runvil::ParseError @@ -280,7 +273,6 @@ 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 @@ -551,12 +543,6 @@ 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::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 @@ -570,6 +556,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> @@ -719,7 +706,7 @@ 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 @@ -1179,10 +1166,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 diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 2bbba1c2b..49d4669a3 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -99,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 { diff --git a/crates/runvil/tests/suite/support.rs b/crates/runvil/tests/suite/support.rs index 6cdb21dcf..8f6067f9a 100644 --- a/crates/runvil/tests/suite/support.rs +++ b/crates/runvil/tests/suite/support.rs @@ -7,11 +7,12 @@ 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, }; -use runvil::tools::{ToolError, ToolId, ToolInfo, ToolOutput}; +use runvil::tools::{ToolId, ToolInfo, ToolOutput, tool_error}; use serde_json::Value; mod scenario; @@ -20,7 +21,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 { @@ -100,9 +101,10 @@ pub(super) fn shout() -> Tool { id: "example/text/shout", handler: Box::new(|args| match args.get("text").and_then(Value::as_str) { Some(text) => Ok(ToolOutput::trusted(text.to_uppercase())), - None => Err(ToolError::message(format!( - "shout needs a string `text` argument; got {args}" - ))), + None => Err(tool_error( + ErrorCode::ToolOther, + format!("shout needs a string `text` argument; got {args}"), + )), }), } } diff --git a/crates/workshop/agents/src/input-tests.rs b/crates/workshop/agents/src/input-tests.rs index a09ec692b..735e94448 100644 --- a/crates/workshop/agents/src/input-tests.rs +++ b/crates/workshop/agents/src/input-tests.rs @@ -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"); 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", + )), } } } From b24268ae407938ff39b06343de37ff1e415d6080 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 19:14:35 -0800 Subject: [PATCH 19/23] Replace CompletionError with the universal error A model round and a catalog fetch now fail with the universal `Error` instead of the bespoke `CompletionError`, and the 13 `CompletionErrorKind` kinds become model codes of the same names. `completion_error`, `empty_reply`, and `context_overflow` build the failures, and `phrase` gives each model code its fixed phrase. - `ErrorCode` gains `tag`, which gives each code its name in snake_case. The store and model codes keep the tags a run log already holds, and the chat `FailureRecord` uses `code.tag()`. - The prompt token count and the window move to the `Count` and `Limit` facts, and the finish reason moves to the `FinishReason` fact. - A cause goes through `with_cause`, and the Gateway client's `with_detail` appends the escaped provider body. Both go in the message only, so the prose stays free of provider text. - The Lua `ErrorValue` trait gains `table_message`, and the Engine returns the prose of a model failure through it. The Lua table's `message` stays free of provider text and causes. - The chat `FailureRecord` and the `ChatOutcome::Empty` text take the prose. The display text of the Engine's `Error::Completion` now includes the cause and the provider body. - `runvil` exports the four functions in place of the two types, and `harness-gateway-client` drops its re-exports. - Tests that pinned a typed source or the `detail()` accessor are removed. Tests that hid a source or a body now check its text in the message and its absence from the prose. New tests pin every tag, every phrase, and each builder. --- crates/build-xtask/src/facade_shape-tests.rs | 2 +- crates/harness-gateway-client/Cargo.toml | 6 +- crates/harness-gateway-client/src/broker.rs | 11 +- .../src/catalog-tests.rs | 107 +++--- crates/harness-gateway-client/src/catalog.rs | 55 +-- crates/harness-gateway-client/src/failure.rs | 102 +++-- crates/harness-gateway-client/src/lib.rs | 13 +- .../harness-gateway-client/src/transport.rs | 25 +- .../src/transport/tests/limits.rs | 78 ++-- .../src/transport/tests/streaming.rs | 26 +- .../src/wire/classify-tests.rs | 229 +++++------- .../src/wire/classify.rs | 93 +++-- .../src/wire/parse-tests.rs | 28 +- .../harness-gateway-client/src/wire/parse.rs | 23 +- .../src/wire/read-tests.rs | 37 +- .../harness-gateway-client/src/wire/read.rs | 28 +- .../src/wire/stream-tests.rs | 63 ++-- .../harness-gateway-client/src/wire/stream.rs | 19 +- .../runner/src/environment-tests.rs | 13 +- .../runner/src/environment.rs | 5 +- .../harness-internal/runner/src/performers.rs | 7 +- .../runner/tests/it/effect_loop-broker.rs | 9 +- .../runner/tests/it/effect_loop-cap.rs | 7 +- .../runner/tests/it/harness-errors.rs | 11 +- .../runner/tests/it/scripted.rs | 25 +- .../runner/tests/it/support.rs | 6 +- crates/harness/examples/run-prompt.rs | 13 +- crates/harness/tests/suite/broker.rs | 9 +- crates/harness/tests/suite/host.rs | 12 +- crates/harness/tests/suite/support.rs | 13 +- crates/runvil-internal/engine/src/error.rs | 18 +- .../engine/src/error/convert.rs | 29 +- .../engine/src/error/tests-chain.rs | 42 +-- .../runvil-internal/engine/src/error/tests.rs | 35 +- .../runvil-internal/engine/src/error/value.rs | 21 +- .../engine/src/execute/infer-tests.rs | 6 +- .../engine/src/execute/infer.rs | 15 +- .../engine/src/execute/run/effect-record.rs | 11 +- .../engine/src/execute/run/effect-tests.rs | 28 +- .../engine/src/execute/run/effect.rs | 11 +- .../engine/src/execute/scheduler/apply.rs | 4 +- .../engine/src/execute/scheduler/chat.rs | 17 +- .../src/execute/tests/chat_record_rebuild.rs | 8 +- .../engine/src/execute/tests/compaction.rs | 4 +- .../engine/src/execute/tests/exit_rules.rs | 27 +- .../engine/src/execute/tests/gateway.rs | 4 +- .../tests/models_loop-author-shapes.rs | 2 +- .../execute/tests/models_loop_compactors.rs | 2 +- .../engine/src/execute/tests/tool_loop.rs | 16 +- .../engine/src/lua/tests/errors.rs | 12 +- crates/runvil-internal/engine/src/model.rs | 7 +- .../src/test_support/scenario-driving.rs | 6 +- .../engine/src/test_support/scenario.rs | 14 +- .../engine/src/test_support/scripted_chat.rs | 46 +-- crates/runvil-internal/lua/src/error-value.rs | 16 +- .../model-client/src/client/tests.rs | 12 +- .../model-client/src/client/wire-canned.rs | 20 +- .../runvil-internal/model-client/src/lib.rs | 7 +- .../runvil-internal/model-client/src/model.rs | 4 +- .../model-client/src/model/error-tests.rs | 240 ++++++------ .../model-client/src/model/error.rs | 351 ++++-------------- .../model-client/src/normalize-tests.rs | 20 +- .../model-client/src/normalize.rs | 18 +- crates/runvil-internal/types/src/error-tag.rs | 83 +++++ .../runvil-internal/types/src/error-tests.rs | 56 +++ crates/runvil-internal/types/src/error.rs | 6 +- crates/runvil/public-api.txt | 47 +-- crates/runvil/src/lib.rs | 6 +- crates/runvil/tests/suite/model.rs | 5 +- crates/runvil/tests/suite/support.rs | 9 +- crates/workshop/agents/src/input-tests.rs | 6 +- crates/workshop/agents/tests/it/support.rs | 13 +- .../server/src/agents/broker-tests.rs | 23 +- crates/workshop/server/src/agents/broker.rs | 22 +- crates/workshop/server/src/agents/tests.rs | 11 +- 75 files changed, 1193 insertions(+), 1242 deletions(-) create mode 100644 crates/runvil-internal/types/src/error-tag.rs 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..0fd137cc0 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 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/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 90b611bd8..b13e82900 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 the `GatewaySearchError` message. //! - 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 @@ -78,8 +77,6 @@ pub use broker::GatewayBroker; pub use catalog::fetch_model_catalog; pub use config::GatewayEndpoint; 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; diff --git a/crates/harness-gateway-client/src/transport.rs b/crates/harness-gateway-client/src/transport.rs index 6e35df1f5..7b68cfc10 100644 --- a/crates/harness-gateway-client/src/transport.rs +++ b/crates/harness-gateway-client/src/transport.rs @@ -12,7 +12,7 @@ use std::num::NonZeroU64; use std::time::{Duration, Instant}; use runvil::error::Error; -use runvil::model::{Completion, CompletionError, CompletionOptions, Message, ToolSchema}; +use runvil::model::{Completion, CompletionOptions, Message, ToolSchema}; use crate::config::{GatewayEndpoint, SecretString, invalid_env, missing_env}; use crate::failure::{elapsed, transport_failure, unavailable}; @@ -63,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)? @@ -127,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 { @@ -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)); diff --git a/crates/harness-gateway-client/src/transport/tests/limits.rs b/crates/harness-gateway-client/src/transport/tests/limits.rs index 83e11c169..9054b0003 100644 --- a/crates/harness-gateway-client/src/transport/tests/limits.rs +++ b/crates/harness-gateway-client/src/transport/tests/limits.rs @@ -4,10 +4,10 @@ use std::num::NonZeroU64; use std::time::Duration; +use runvil::error::ErrorCode; use runvil::model::{CompletionResult, Message}; use super::*; -use crate::CompletionErrorKind; #[tokio::test] async fn complete_on_a_disabled_client_is_an_unavailable_error() { @@ -17,7 +17,7 @@ async fn complete_on_a_disabled_client_is_an_unavailable_error() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("a disabled client cannot complete"); - assert_eq!(err.kind(), CompletionErrorKind::Unavailable); + assert_eq!(err.code, ErrorCode::Unavailable); assert_eq!( err.to_string(), "model access is turned off or not configured" @@ -25,7 +25,7 @@ async fn complete_on_a_disabled_client_is_an_unavailable_error() { } #[tokio::test] -async fn backend_error_display_is_body_free_and_body_is_opt_in_and_escaped() { +async fn backend_error_prose_is_body_free_and_the_message_holds_the_escaped_body() { use axum::Router; use axum::routing::post; @@ -43,27 +43,30 @@ async fn backend_error_display_is_body_free_and_body_is_opt_in_and_escaped() { .await .expect_err("a 502 must surface as a classified failure"); - // F5: the public Display names only the kind and the status, never the - // raw body. - assert_eq!(err.kind(), CompletionErrorKind::ServerError); - let shown = err.to_string(); + // F5: the prose the model reads names only the code and the status, + // never the raw body. + assert_eq!(err.code, ErrorCode::ServerError); + let shown = &err.prose; assert_eq!( shown, "the model backend reported a fault of its own (status 502)" ); assert!( !shown.contains("super-secret") && !shown.contains('\n'), - "the raw body must not appear in Display, got {shown}" + "the raw body must not appear in the prose, got {shown}" ); - // The bounded, control-escaped body is available only via the opt-in. - let body = err.detail().expect("the body is available opt-in"); + // The bounded, control-escaped body is in the message only. + let body = err + .message + .strip_prefix(&format!("{shown}: ")) + .expect("the message holds the body"); assert!( body.contains("\\n"), "control chars must be escaped, got {body}" ); assert!( - !body.contains('\n'), - "no raw newline in the diagnostic body" + !err.message.contains('\n'), + "no raw newline in the diagnostic message" ); } @@ -84,7 +87,7 @@ async fn complete_refuses_a_success_stream_over_the_size_cap() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an oversize stream must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -109,8 +112,8 @@ async fn complete_refuses_a_backend_error_body_over_the_size_cap() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an oversize error body must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert_eq!(err.detail(), None, "the refused body is never kept"); + assert_eq!(err.code, ErrorCode::MalformedResponse); + assert_eq!(err.message, err.prose, "the refused body is never kept"); } #[tokio::test] @@ -133,8 +136,8 @@ async fn a_request_past_the_timeout_is_a_timeout_failure() { .await .expect_err("a stalled gateway must time out"); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "the timeout must be recognizable: {err:?}" ); } @@ -264,8 +267,8 @@ async fn a_stream_that_stalls_after_the_headers_is_a_timeout_failure() { .await .expect_err("a stream that stops arriving must time out"); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "the timeout must be recognizable: {err:?}" ); } @@ -305,17 +308,17 @@ async fn a_body_read_timeout_is_a_timeout_failure() { .expect_err("the body read stalls past the timeout"); assert!(read.is_timeout(), "reqwest reports the read as a timeout"); + let read_text = read.to_string(); let err = transport_failure(read); assert_eq!( - err.kind(), - CompletionErrorKind::Timeout, + err.code, + ErrorCode::Timeout, "a timed-out body read is a Timeout: {err:?}" ); - assert_eq!(err.to_string(), "the model backend did not answer in time"); + assert_eq!(err.prose, "the model backend did not answer in time"); assert!( - std::error::Error::source(&err) - .is_some_and(|source| source.downcast_ref::().is_some()), - "the reqwest error is kept as the source" + err.message.contains(&read_text) && !err.prose.contains(&read_text), + "the reqwest error is kept in the message only: {err:?}" ); } @@ -329,31 +332,28 @@ async fn a_refused_connection_is_a_transport_failure() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("nothing listens on the port"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); - assert_eq!( - err.to_string(), - "the connection to the model backend failed" + assert_eq!(err.code, ErrorCode::Transport); + assert_eq!(err.prose, "the connection to the model backend failed"); + assert!( + err.message.starts_with(&format!("{}: ", err.prose)), + "the connection error joins the message: {err:?}" ); - assert!(std::error::Error::source(&err).is_some()); } #[tokio::test] async fn complete_refuses_a_malformed_stream_chunk() { // F14: a 200 whose stream holds an undecodable chunk is - // MalformedResponse, and the decode failure is preserved as the - // error-chain source. + // MalformedResponse, and the decode failure is kept in the message. let base = spawn_raw_gateway(axum::http::StatusCode::OK, "data: { not json\n\n").await; let client = keyed_client(&base); let err = client .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("undecodable chunk must fail"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - let source = - std::error::Error::source(&err).expect("the decode error must be a preserved source"); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the JSON decode error, got {source}" + err.message.starts_with(&format!("{}: ", err.prose)), + "the decode error joins the message: {err:?}" ); } @@ -372,7 +372,7 @@ async fn complete_refuses_malformed_tool_call_fragments_at_the_boundary() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("malformed tool arguments must be rejected"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); } #[tokio::test] @@ -389,7 +389,7 @@ async fn stream_without_done_sentinel_is_malformed() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("a truncated stream must fail"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ diff --git a/crates/harness-gateway-client/src/transport/tests/streaming.rs b/crates/harness-gateway-client/src/transport/tests/streaming.rs index ccf7ac8d6..0534ed9a7 100644 --- a/crates/harness-gateway-client/src/transport/tests/streaming.rs +++ b/crates/harness-gateway-client/src/transport/tests/streaming.rs @@ -1,10 +1,11 @@ //! The streamed round end to end: what goes on the wire, and how the //! stream comes back as one completion. +use runvil::error::{ErrorCode, FactKey}; use runvil::model::{CompletionResult, Message}; use super::*; -use crate::{CompletionErrorKind, StreamDelta}; +use crate::StreamDelta; #[tokio::test] async fn complete_sends_completion_options_and_stream_flags_on_the_wire() { @@ -65,11 +66,13 @@ async fn complete_hard_fails_on_empty_model_reply() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("empty product must fail"); - assert_eq!(err.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(err.code, ErrorCode::EmptyReply); assert_eq!( - err.finish_reason(), + err.facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str), Some("stop"), - "the finish_reason must survive the conversion into CompletionError" + "the finish_reason must survive the conversion into the error" ); assert_eq!( err.to_string(), @@ -77,8 +80,7 @@ async fn complete_hard_fails_on_empty_model_reply() { reasoning content was present but ignored" ); assert_eq!( - err.detail(), - None, + err.message, err.prose, "the specific is ours, not provider text" ); } @@ -260,8 +262,8 @@ async fn truncated_tool_call_batch_fails_the_completion() { .await .expect_err("a truncated tool-call batch must fail"); assert_eq!( - err.kind(), - CompletionErrorKind::MalformedResponse, + err.code, + ErrorCode::MalformedResponse, "finish_reason {reason:?}" ); assert!( @@ -309,10 +311,10 @@ async fn mid_stream_error_envelope_is_a_transport_failure() { .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) .await .expect_err("an error envelope must fail the completion"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); + assert_eq!(err.code, ErrorCode::Transport); + assert_eq!(err.prose, "the connection to the model backend failed"); assert_eq!( - err.to_string(), - "the connection to the model backend failed" + err.message, + "the connection to the model backend failed: upstream died" ); - assert_eq!(err.detail(), Some("upstream died")); } diff --git a/crates/harness-gateway-client/src/wire/classify-tests.rs b/crates/harness-gateway-client/src/wire/classify-tests.rs index 90c941f8b..b042d8805 100644 --- a/crates/harness-gateway-client/src/wire/classify-tests.rs +++ b/crates/harness-gateway-client/src/wire/classify-tests.rs @@ -2,12 +2,26 @@ //! phrase, per token-count form, the in-stream envelope, and a body that //! matches nothing. -use runvil::model::CompletionErrorKind; +use runvil::error::{Error, ErrorCode, FactKey}; +use runvil::model::phrase; use super::{classify_http_failure, classify_stream_error}; -fn kind_of(status: u16, body: &str) -> CompletionErrorKind { - classify_http_failure(status, body).kind() +fn code_of(status: u16, body: &str) -> ErrorCode { + classify_http_failure(status, body).code +} + +/// The `(prompt_tokens, window)` counts an overflow names as its `Count` +/// and `Limit` facts. +fn overflow(error: &Error) -> (Option, Option) { + let count = |key: FactKey| { + error + .facts + .get(&key) + .and_then(serde_json::Value::as_u64) + .and_then(|count| u32::try_from(count).ok()) + }; + (count(FactKey::Count), count(FactKey::Limit)) } #[test] @@ -30,13 +44,13 @@ fn every_overflow_phrase_classifies_a_400_as_context_overflow() { for phrase in phrases { let body = format!("{{\"error\":\"the request hit a limit: {phrase} reached\"}}"); assert_eq!( - kind_of(400, &body), - CompletionErrorKind::ContextOverflow, + code_of(400, &body), + ErrorCode::ContextOverflow, "400 with {phrase:?}" ); assert_eq!( - kind_of(413, &body), - CompletionErrorKind::ContextOverflow, + code_of(413, &body), + ErrorCode::ContextOverflow, "413 with {phrase:?}" ); } @@ -45,68 +59,52 @@ fn every_overflow_phrase_classifies_a_400_as_context_overflow() { #[test] fn overflow_phrases_match_case_insensitively() { assert_eq!( - kind_of(400, "CONTEXT WINDOW exceeded"), - CompletionErrorKind::ContextOverflow + code_of(400, "CONTEXT WINDOW exceeded"), + ErrorCode::ContextOverflow ); assert_eq!( - kind_of(413, "Prompt Is Too Long"), - CompletionErrorKind::ContextOverflow + code_of(413, "Prompt Is Too Long"), + ErrorCode::ContextOverflow ); } #[test] fn provider_overflow_detection_matches_known_signatures() { - let cases: &[(u16, &str, CompletionErrorKind)] = &[ + let cases: &[(u16, &str, ErrorCode)] = &[ ( 400, "This model's maximum context length is 4096 tokens.", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "context_length_exceeded", - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, ), + (400, "context_length_exceeded", ErrorCode::ContextOverflow), ( 400, "the request exceeds the available context size", - CompletionErrorKind::ContextOverflow, - ), - ( - 413, - "prompt is too long", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "CONTEXT WINDOW exceeded", - CompletionErrorKind::ContextOverflow, - ), - ( - 400, - "too many tokens in prompt", - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, ), + (413, "prompt is too long", ErrorCode::ContextOverflow), + (400, "CONTEXT WINDOW exceeded", ErrorCode::ContextOverflow), + (400, "too many tokens in prompt", ErrorCode::ContextOverflow), // A server fault never classifies as an overflow, even with // overflow wording. ( 500, "maximum context length is 4096 tokens", - CompletionErrorKind::ServerError, + ErrorCode::ServerError, ), // A client rejection without overflow wording stays a plain // rejection. ( 400, "invalid request: unknown field `stream`", - CompletionErrorKind::Rejected, + ErrorCode::Rejected, ), // Credentials win over overflow wording outside 400 and 413. - (401, "context length", CompletionErrorKind::Unavailable), + (401, "context length", ErrorCode::Unavailable), ]; for (status, body, expected) in cases { assert_eq!( - kind_of(*status, body), + code_of(*status, body), *expected, "status {status} with body {body:?}" ); @@ -115,14 +113,8 @@ fn provider_overflow_detection_matches_known_signatures() { #[test] fn an_overflow_phrase_on_another_status_is_not_an_overflow() { - assert_eq!( - kind_of(422, "context length exceeded"), - CompletionErrorKind::Rejected - ); - assert_eq!( - kind_of(429, "context length"), - CompletionErrorKind::RateLimited - ); + assert_eq!(code_of(422, "context length exceeded"), ErrorCode::Rejected); + assert_eq!(code_of(429, "context length"), ErrorCode::RateLimited); } #[test] @@ -132,22 +124,22 @@ fn the_openai_token_counts_are_read_from_the_message() { "This model's maximum context length is 4096 tokens. However, your messages \ resulted in 5120 tokens. Please reduce the length of the messages.", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5120), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5120), Some(4096))); } #[test] fn the_greater_than_maximum_token_counts_are_read_from_the_message() { let error = classify_http_failure(400, "prompt is too long: 205000 tokens > 200000 maximum"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(205_000), Some(200_000))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(205_000), Some(200_000))); } #[test] fn an_overflow_that_states_no_counts_leaves_them_unset() { let error = classify_http_failure(400, "context_length_exceeded"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (None, None)); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (None, None)); } #[test] @@ -156,8 +148,8 @@ fn a_count_too_large_for_u32_is_left_unset() { 400, "maximum context length is 99999999999 tokens, however you sent 5 tokens", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5), None)); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5), None)); } #[test] @@ -165,8 +157,8 @@ fn a_429_naming_quota_or_billing_is_quota_exhausted() { for word in ["quota", "billing", "insufficient_quota", "credit", "QUOTA"] { let body = format!("{{\"error\":\"your {word} has run out\"}}"); assert_eq!( - kind_of(429, &body), - CompletionErrorKind::QuotaExhausted, + code_of(429, &body), + ErrorCode::QuotaExhausted, "429 with {word:?}" ); } @@ -175,33 +167,27 @@ fn a_429_naming_quota_or_billing_is_quota_exhausted() { #[test] fn any_other_429_is_rate_limited() { assert_eq!( - kind_of(429, "slow down: too many requests"), - CompletionErrorKind::RateLimited - ); - assert_eq!( - kind_of(429, "(empty body)"), - CompletionErrorKind::RateLimited + code_of(429, "slow down: too many requests"), + ErrorCode::RateLimited ); + assert_eq!(code_of(429, "(empty body)"), ErrorCode::RateLimited); } #[test] fn a_503_or_529_is_overloaded() { - assert_eq!( - kind_of(503, "service unavailable"), - CompletionErrorKind::Overloaded - ); - assert_eq!(kind_of(529, "busy"), CompletionErrorKind::Overloaded); + assert_eq!(code_of(503, "service unavailable"), ErrorCode::Overloaded); + assert_eq!(code_of(529, "busy"), ErrorCode::Overloaded); } #[test] fn a_5xx_naming_overloaded_is_overloaded() { assert_eq!( - kind_of(500, "{\"type\":\"overloaded_error\"}"), - CompletionErrorKind::Overloaded + code_of(500, "{\"type\":\"overloaded_error\"}"), + ErrorCode::Overloaded ); assert_eq!( - kind_of(502, "The server is OVERLOADED"), - CompletionErrorKind::Overloaded + code_of(502, "The server is OVERLOADED"), + ErrorCode::Overloaded ); } @@ -209,8 +195,8 @@ fn a_5xx_naming_overloaded_is_overloaded() { fn every_other_5xx_is_a_server_error() { for status in [500, 501, 502, 504, 599] { assert_eq!( - kind_of(status, "internal failure"), - CompletionErrorKind::ServerError, + code_of(status, "internal failure"), + ErrorCode::ServerError, "status {status}" ); } @@ -220,9 +206,9 @@ fn every_other_5xx_is_a_server_error() { fn a_401_or_403_is_unavailable_with_the_credentials_phrase() { for status in [401, 403] { let error = classify_http_failure(status, "bad key"); - assert_eq!(error.kind(), CompletionErrorKind::Unavailable); + assert_eq!(error.code, ErrorCode::Unavailable); assert_eq!( - error.to_string(), + error.prose, format!("the model backend did not accept the credentials (status {status})") ); } @@ -232,28 +218,21 @@ fn a_401_or_403_is_unavailable_with_the_credentials_phrase() { fn a_400_naming_a_content_policy_term_is_refused() { for word in ["content_filter", "content policy", "safety", "refus"] { let body = format!("{{\"error\":\"blocked by {word} rules\"}}"); - assert_eq!( - kind_of(400, &body), - CompletionErrorKind::Refused, - "400 with {word:?}" - ); + assert_eq!(code_of(400, &body), ErrorCode::Refused, "400 with {word:?}"); } } #[test] fn a_400_naming_both_a_context_limit_and_a_safety_term_is_an_overflow() { assert_eq!( - kind_of(400, "context length exceeded; safety settings unchanged"), - CompletionErrorKind::ContextOverflow + code_of(400, "context length exceeded; safety settings unchanged"), + ErrorCode::ContextOverflow ); } #[test] fn a_content_policy_term_on_another_status_is_rejected() { - assert_eq!( - kind_of(422, "content_filter"), - CompletionErrorKind::Rejected - ); + assert_eq!(code_of(422, "content_filter"), ErrorCode::Rejected); } #[test] @@ -267,33 +246,29 @@ fn a_body_that_matches_no_rule_is_rejected_never_a_success() { (200, "ok"), ] { assert_eq!( - kind_of(status, body), - CompletionErrorKind::Rejected, + code_of(status, body), + ErrorCode::Rejected, "status {status} with body {body:?}" ); } } #[test] -fn the_message_names_the_kind_and_the_status_and_keeps_the_body_as_detail() { +fn the_prose_names_the_code_and_the_status_and_the_message_adds_the_body() { let error = classify_http_failure(503, "upstream "); + assert_eq!(error.prose, "the model backend is overloaded (status 503)"); assert_eq!( - error.to_string(), - "the model backend is overloaded (status 503)" - ); - assert_eq!( - error.message(), - "the model backend is overloaded (status 503)" + error.message, + "the model backend is overloaded (status 503): upstream " ); - assert_eq!(error.detail(), Some("upstream ")); assert!( - !error.to_string().contains("upstream"), - "Display never carries the body" + !error.prose.contains("upstream"), + "the prose never carries the body" ); } #[test] -fn each_http_kind_carries_its_fixed_phrase_and_the_status_suffix() { +fn each_http_code_carries_its_fixed_phrase_and_the_status_suffix() { let cases: &[(u16, &str, &str)] = &[ ( 400, @@ -332,42 +307,38 @@ fn each_http_kind_carries_its_fixed_phrase_and_the_status_suffix() { "the model backend did not accept the credentials (status 401)", ), ]; - for (status, body, message) in cases { + for (status, body, prose) in cases { let error = classify_http_failure(*status, body); - assert_eq!(error.to_string(), *message, "status {status}, {body:?}"); - assert_eq!(error.message(), *message); + assert_eq!(error.prose, *prose, "status {status}, {body:?}"); + assert_eq!(error.message, format!("{prose}: {body}")); } } #[test] fn a_429_is_rate_limited_unless_its_body_names_a_spent_quota() { assert_eq!( - classify_http_failure(429, "slow down").kind(), - CompletionErrorKind::RateLimited + classify_http_failure(429, "slow down").code, + ErrorCode::RateLimited ); assert_eq!( - classify_http_failure(429, "insufficient_quota").kind(), - CompletionErrorKind::QuotaExhausted + classify_http_failure(429, "insufficient_quota").code, + ErrorCode::QuotaExhausted ); } #[test] -fn provider_text_stays_in_the_detail_and_out_of_the_message() { +fn provider_text_stays_in_the_message_and_out_of_the_prose() { let error = classify_http_failure(503, "upstream "); - assert!( - error - .to_string() - .starts_with(CompletionErrorKind::Overloaded.phrase()) - ); - assert!(!error.to_string().contains("upstream")); - assert_eq!(error.detail(), Some("upstream ")); + assert!(error.prose.starts_with(phrase(ErrorCode::Overloaded))); + assert!(!error.prose.contains("upstream")); + assert!(error.message.ends_with(": upstream ")); } #[test] fn the_classifier_keeps_only_the_body_it_was_given() { let body = "x".repeat(2000); let error = classify_http_failure(400, &body); - assert_eq!(error.detail().map(str::len), Some(2000)); + assert_eq!(error.message, format!("{}: {body}", error.prose)); } #[test] @@ -375,14 +346,14 @@ fn an_in_stream_envelope_naming_a_context_limit_is_an_overflow_without_a_status( let error = classify_stream_error( "This model's maximum context length is 4096 tokens, however you sent 5000 tokens", ); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5000), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!(overflow(&error), (Some(5000), Some(4096))); assert_eq!( - error.to_string(), + error.prose, "the request is larger than the model's context window" ); assert!( - !error.to_string().contains("status"), + !error.prose.contains("status"), "an in-stream envelope carries no status" ); } @@ -390,26 +361,26 @@ fn an_in_stream_envelope_naming_a_context_limit_is_an_overflow_without_a_status( #[test] fn an_in_stream_envelope_applies_the_other_body_text_rules() { assert_eq!( - classify_stream_error("overloaded_error: try again").kind(), - CompletionErrorKind::Overloaded + classify_stream_error("overloaded_error: try again").code, + ErrorCode::Overloaded ); assert_eq!( - classify_stream_error("insufficient_quota").kind(), - CompletionErrorKind::QuotaExhausted + classify_stream_error("insufficient_quota").code, + ErrorCode::QuotaExhausted ); assert_eq!( - classify_stream_error("blocked by content_filter").kind(), - CompletionErrorKind::Refused + classify_stream_error("blocked by content_filter").code, + ErrorCode::Refused ); } #[test] fn an_in_stream_envelope_that_matches_no_rule_stays_a_transport_failure() { let error = classify_stream_error("upstream\\ndied"); - assert_eq!(error.kind(), CompletionErrorKind::Transport); + assert_eq!(error.code, ErrorCode::Transport); + assert_eq!(error.prose, "the connection to the model backend failed"); assert_eq!( - error.to_string(), - "the connection to the model backend failed" + error.message, + "the connection to the model backend failed: upstream\\ndied" ); - assert_eq!(error.detail(), Some("upstream\\ndied")); } diff --git a/crates/harness-gateway-client/src/wire/classify.rs b/crates/harness-gateway-client/src/wire/classify.rs index 2073871b9..fb13de68a 100644 --- a/crates/harness-gateway-client/src/wire/classify.rs +++ b/crates/harness-gateway-client/src/wire/classify.rs @@ -1,11 +1,11 @@ //! The HTTP failure classifier: the one place that reads a status code and -//! response text and turns them into a [`CompletionErrorKind`]. +//! response text and turns them into a model [`ErrorCode`]. //! //! [`classify_http_failure`] takes a non-success status and its bounded, //! control-escaped body. [`classify_stream_error`] applies the same body-text //! rules to an error envelope that arrives inside a 200 stream, where there -//! is no status. Both keep the body as the error's opt-in -//! [`detail`](CompletionError::detail) and never put it in the message. +//! is no status. Both append the body to the error's message and never put +//! it in the prose. //! //! The rules run in this order, and the first match wins: //! @@ -18,7 +18,10 @@ //! 5. A 400 whose body names a content policy term is `Refused`. //! 6. Every other status is `Rejected`. -use runvil::model::{CompletionError, CompletionErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::model::{completion_error, context_overflow, phrase}; + +use crate::failure::with_detail; /// Body phrases the known backends emit when a request exceeds the model's /// context window (OpenAI and compatible gateways, Anthropic, llama.cpp, @@ -48,50 +51,46 @@ const REFUSAL_WORDS: &[&str] = &["content_filter", "content policy", "safety", " /// unavailable phrase. const CREDENTIALS_PHRASE: &str = "the model backend did not accept the credentials"; -/// Classifies a failed HTTP response into a [`CompletionError`]. +/// Classifies a failed HTTP response into an [`Error`] under a model code. /// /// `body` must already be bounded and passed through -/// [`escape_controls`](crate::escape_controls). The error keeps exactly that -/// text as its [`detail`](CompletionError::detail). The message never -/// includes it. +/// [`escape_controls`](crate::escape_controls). The error's message ends +/// with exactly that text after `: `. The prose never includes it. /// -/// The message is the kind's fixed phrase with ` (status N)` appended. A 401 -/// or 403 is `Unavailable`, and its message uses +/// The prose is the code's fixed phrase with ` (status N)` appended. A 401 +/// or 403 is `Unavailable`, and its prose uses /// `the model backend did not accept the credentials` in place of the /// phrase. /// /// The result is always an error. When the body matches a rule, that rule -/// sets the kind. Otherwise a 429 is `RateLimited`, a 503 or 529 is +/// sets the code. Otherwise a 429 is `RateLimited`, a 503 or 529 is /// `Overloaded`, any other 5xx is `ServerError`, and any other status except /// 401 and 403 is `Rejected`. #[must_use] -pub fn classify_http_failure(status: u16, body: &str) -> CompletionError { +pub fn classify_http_failure(status: u16, body: &str) -> Error { let lower = body.to_lowercase(); - let http = |kind: CompletionErrorKind| { - http_error(kind, kind.phrase(), status).with_detail(body.to_owned()) - }; + let http = |code: ErrorCode| with_detail(http_error(code, phrase(code), status), body); if matches!(status, 400 | 413) && names_any(&lower, OVERFLOW_PHRASES) { let (prompt_tokens, window) = overflow_counts(&lower); - return CompletionError::context_overflow( + let overflow = context_overflow( prompt_tokens, window, - format!( - "{} (status {status})", - CompletionErrorKind::ContextOverflow.phrase() - ), - ) - .with_detail(body.to_owned()); + format!("{} (status {status})", phrase(ErrorCode::ContextOverflow)), + ); + return with_detail(overflow, body); } match status { - 429 if names_any(&lower, QUOTA_WORDS) => http(CompletionErrorKind::QuotaExhausted), - 429 => http(CompletionErrorKind::RateLimited), - 503 | 529 => http(CompletionErrorKind::Overloaded), - 500..=599 if lower.contains("overloaded") => http(CompletionErrorKind::Overloaded), - 500..=599 => http(CompletionErrorKind::ServerError), - 401 | 403 => http_error(CompletionErrorKind::Unavailable, CREDENTIALS_PHRASE, status) - .with_detail(body.to_owned()), - 400 if names_any(&lower, REFUSAL_WORDS) => http(CompletionErrorKind::Refused), - _ => http(CompletionErrorKind::Rejected), + 429 if names_any(&lower, QUOTA_WORDS) => http(ErrorCode::QuotaExhausted), + 429 => http(ErrorCode::RateLimited), + 503 | 529 => http(ErrorCode::Overloaded), + 500..=599 if lower.contains("overloaded") => http(ErrorCode::Overloaded), + 500..=599 => http(ErrorCode::ServerError), + 401 | 403 => with_detail( + http_error(ErrorCode::Unavailable, CREDENTIALS_PHRASE, status), + body, + ), + 400 if names_any(&lower, REFUSAL_WORDS) => http(ErrorCode::Refused), + _ => http(ErrorCode::Rejected), } } @@ -104,37 +103,33 @@ pub fn classify_http_failure(status: u16, body: &str) -> CompletionError { /// `Refused`. Any other text is `Transport`, because the stream died in /// flight. /// -/// The message is the kind's fixed phrase alone. `body` must already be -/// bounded and control-escaped. The error keeps it as its -/// [`detail`](CompletionError::detail). +/// The prose is the code's fixed phrase alone. `body` must already be +/// bounded and control-escaped. The error's message ends with it after +/// `: `. #[must_use] -pub fn classify_stream_error(body: &str) -> CompletionError { +pub fn classify_stream_error(body: &str) -> Error { let lower = body.to_lowercase(); let error = if names_any(&lower, OVERFLOW_PHRASES) { let (prompt_tokens, window) = overflow_counts(&lower); - CompletionError::context_overflow( - prompt_tokens, - window, - CompletionErrorKind::ContextOverflow.phrase(), - ) + context_overflow(prompt_tokens, window, phrase(ErrorCode::ContextOverflow)) } else { - let kind = if names_any(&lower, QUOTA_WORDS) { - CompletionErrorKind::QuotaExhausted + let code = if names_any(&lower, QUOTA_WORDS) { + ErrorCode::QuotaExhausted } else if lower.contains("overloaded") { - CompletionErrorKind::Overloaded + ErrorCode::Overloaded } else if names_any(&lower, REFUSAL_WORDS) { - CompletionErrorKind::Refused + ErrorCode::Refused } else { - CompletionErrorKind::Transport + ErrorCode::Transport }; - CompletionError::new(kind, kind.phrase()) + completion_error(code, phrase(code)) }; - error.with_detail(body.to_owned()) + with_detail(error, body) } /// Builds an HTTP failure: `phrase` with the status appended. -fn http_error(kind: CompletionErrorKind, phrase: &str, status: u16) -> CompletionError { - CompletionError::new(kind, format!("{phrase} (status {status})")) +fn http_error(code: ErrorCode, phrase: &str, status: u16) -> Error { + completion_error(code, format!("{phrase} (status {status})")) } fn names_any(lower: &str, words: &[&str]) -> bool { diff --git a/crates/harness-gateway-client/src/wire/parse-tests.rs b/crates/harness-gateway-client/src/wire/parse-tests.rs index c8e1b928b..10f2b9e3c 100644 --- a/crates/harness-gateway-client/src/wire/parse-tests.rs +++ b/crates/harness-gateway-client/src/wire/parse-tests.rs @@ -2,18 +2,26 @@ //! reasoning side channel. use super::*; -use runvil::model::CompletionErrorKind; +use runvil::error::ErrorCode; const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; /// Whether `result` failed as a `MalformedResponse`. -fn is_malformed(result: Result) -> bool { - result.is_err_and(|error| error.kind() == CompletionErrorKind::MalformedResponse) +fn is_malformed(result: Result) -> bool { + result.is_err_and(|error| error.code == ErrorCode::MalformedResponse) } /// Whether `result` failed as an `EmptyReply`. -fn is_empty_reply(result: Result) -> bool { - result.is_err_and(|error| error.kind() == CompletionErrorKind::EmptyReply) +fn is_empty_reply(result: Result) -> bool { + result.is_err_and(|error| error.code == ErrorCode::EmptyReply) +} + +/// The backend's finish reason an empty reply names, when it names one. +fn finish_reason(error: &Error) -> Option<&str> { + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str) } /// Wraps one assistant message in the gateway's one-choice envelope. @@ -249,13 +257,13 @@ fn empty_content_with_reasoning_is_error() { }); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!( error.to_string(), format!("{EMPTY_REPLY_PHRASE}: reasoning content was present but ignored") ); assert_eq!( - error.finish_reason(), + finish_reason(&error), Some("stop"), "the choice's finish_reason must survive on the error" ); @@ -269,9 +277,9 @@ fn empty_string_content_without_tools_is_error() { })); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); - assert_eq!(error.finish_reason(), None, "no finish_reason on the wire"); + assert_eq!(finish_reason(&error), None, "no finish_reason on the wire"); } #[test] @@ -282,7 +290,7 @@ fn null_content_without_tools_is_error() { })); let error = normalize(&body).expect_err("an empty turn must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!(error.to_string(), EMPTY_REPLY_PHRASE); } diff --git a/crates/harness-gateway-client/src/wire/parse.rs b/crates/harness-gateway-client/src/wire/parse.rs index 8a34df1b2..fe20f2c94 100644 --- a/crates/harness-gateway-client/src/wire/parse.rs +++ b/crates/harness-gateway-client/src/wire/parse.rs @@ -23,8 +23,9 @@ //! completion: a malformed section degrades to `None` with a returned //! diagnostic naming it. +use runvil::error::{Error, FactKey}; use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics}; -use runvil::model::{CompletionError, CompletionResult, ToolCall}; +use runvil::model::{CompletionResult, ToolCall, empty_reply}; use serde::Deserialize; use serde_json::Value; @@ -59,7 +60,7 @@ struct TurnContext<'a> { /// non-empty array of objects, `finish_reason` is a present non-string, /// `message` is missing or not an object, or a reasoning field has the wrong /// type. -fn turn_context(body: &Value) -> Result, CompletionError> { +fn turn_context(body: &Value) -> Result, Error> { let choices = match body.get("choices") { None => return Err(malformed("no choices in response")), Some(Value::Array(choices)) => choices, @@ -96,7 +97,7 @@ fn turn_context(body: &Value) -> Result, CompletionError> { /// Returns a `MalformedResponse`-kind failure when the body has no usable /// choice shape, and an `EmptyReply`-kind one when the choice has neither /// non-empty tool calls nor non-empty text. -pub(super) fn normalize(body: &Value) -> Result { +pub(super) fn normalize(body: &Value) -> Result { let TurnContext { message, finish_reason, @@ -136,11 +137,13 @@ pub(super) fn normalize(body: &Value) -> Result }); } - let error = CompletionError::empty_reply(reasoning_content.is_some()); - Err(match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - }) + let mut error = empty_reply(reasoning_content.is_some()); + if let Some(reason) = finish_reason { + error + .facts + .insert(FactKey::FinishReason, Value::from(reason)); + } + Err(error) } /// Parses the OpenAI `message.tool_calls` array into [`ToolCall`]s. @@ -151,7 +154,7 @@ pub(super) fn normalize(body: &Value) -> Result /// null arguments are rejected rather than coerced. [`ToolCall::from_parts`] /// then refuses a blank id, a blank name, and arguments that do not decode /// to an object. -fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, CompletionError> { +fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, Error> { let mut calls = Vec::with_capacity(raw_calls.len()); for raw in raw_calls { if !raw.is_object() { @@ -211,7 +214,7 @@ fn parse_openai_tool_calls(raw_calls: &[Value]) -> Result, Complet /// # Errors /// Returns a `MalformedResponse`-kind failure when a present reasoning field is not a /// string or null. -fn extract_reasoning(message: &Value) -> Result, CompletionError> { +fn extract_reasoning(message: &Value) -> Result, Error> { for key in ["reasoning_content", "reasoning", "thinking"] { match message.get(key) { None | Some(Value::Null) => {} diff --git a/crates/harness-gateway-client/src/wire/read-tests.rs b/crates/harness-gateway-client/src/wire/read-tests.rs index 49b6f4c86..0c50bddc0 100644 --- a/crates/harness-gateway-client/src/wire/read-tests.rs +++ b/crates/harness-gateway-client/src/wire/read-tests.rs @@ -7,14 +7,15 @@ use std::pin::pin; use std::task::{Context, Poll, Waker}; use std::time::{Duration, Instant}; -use runvil::model::{CompletionErrorKind, CompletionResult, RawExchange}; +use runvil::error::ErrorCode; +use runvil::model::{CompletionResult, RawExchange, completion_error}; use serde_json::json; use super::*; /// A chunk source over canned chunks; it never pends, so the tests need /// no executor. -struct Canned(VecDeque, CompletionError>>); +struct Canned(VecDeque, Error>>); impl Canned { fn of(chunks: &[&str]) -> Canned { @@ -30,9 +31,7 @@ impl Canned { impl ChunkSource for Canned { type Chunk = Vec; - fn next_chunk( - &mut self, - ) -> impl Future, CompletionError>> + Send { + fn next_chunk(&mut self) -> impl Future, Error>> + Send { std::future::ready(self.0.pop_front().transpose()) } } @@ -67,13 +66,13 @@ fn read_body_capped_refuses_an_advertised_oversize_length_before_reading() { let mut source = Canned::of(&["never read"]); let err = block_on(read_body_capped(&mut source, Some(100), 8)) .expect_err("an advertised length over the cap is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ response body of 100 bytes exceeds the 8-byte limit" ); - assert_eq!(err.detail(), None); + assert_eq!(err.message, err.prose); assert_eq!(source.0.len(), 1, "nothing was read"); } @@ -82,7 +81,7 @@ fn read_body_capped_refuses_streamed_chunks_over_the_cap() { let mut source = Canned::of(&["12345", "6789"]); let err = block_on(read_body_capped(&mut source, None, 8)) .expect_err("chunks past the cap are refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -255,7 +254,7 @@ fn read_completion_stream_refuses_a_stream_over_the_byte_cap() { || started, )) .expect_err("an oversize stream is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ @@ -277,24 +276,23 @@ fn read_completion_stream_refuses_a_stream_without_the_sentinel() { || started, )) .expect_err("a cut-off stream is refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(err.code, ErrorCode::MalformedResponse); assert_eq!( err.to_string(), "the model backend sent a reply that could not be understood: \ completion stream ended without the [DONE] sentinel" ); - assert_eq!(err.detail(), None); + assert_eq!(err.message, err.prose); } #[test] fn read_completion_stream_returns_the_source_failure_as_is() { - let timed_out = - CompletionError::new(CompletionErrorKind::Timeout, "no chunk in time").with_source( - std::io::Error::new(std::io::ErrorKind::TimedOut, "deadline"), - ); + let timed_out = completion_error(ErrorCode::Timeout, "no chunk in time").with_cause( + &std::io::Error::new(std::io::ErrorKind::TimedOut, "deadline"), + ); let mut source = Canned(VecDeque::from([ Ok(sse(&[text_chunk("par")]).into_bytes()), - Err(timed_out), + Err(timed_out.clone()), ])); let started = Instant::now(); let err = block_on(read_completion_stream( @@ -306,7 +304,8 @@ fn read_completion_stream_returns_the_source_failure_as_is() { || started, )) .expect_err("a read failure fails the round"); - assert_eq!(err.kind(), CompletionErrorKind::Timeout); - assert_eq!(err.to_string(), "no chunk in time"); - assert!(std::error::Error::source(&err).is_some()); + assert_eq!(err, timed_out); + assert_eq!(err.code, ErrorCode::Timeout); + assert_eq!(err.prose, "no chunk in time"); + assert_eq!(err.message, "no chunk in time: deadline"); } diff --git a/crates/harness-gateway-client/src/wire/read.rs b/crates/harness-gateway-client/src/wire/read.rs index 3273facd1..3f44627b3 100644 --- a/crates/harness-gateway-client/src/wire/read.rs +++ b/crates/harness-gateway-client/src/wire/read.rs @@ -11,8 +11,9 @@ use std::future::Future; use std::time::{Duration, Instant}; +use runvil::error::Error; use runvil::metrics::ClientTiming; -use runvil::model::{Completion, CompletionError}; +use runvil::model::Completion; use serde_json::Value; use super::delta::StreamDelta; @@ -46,15 +47,12 @@ pub trait ChunkSource { /// Returns the next chunk, or `None` once the body is exhausted. /// - /// When a read fails, the implementation returns the - /// [`CompletionError`] that the round fails with. It is a - /// `Timeout`-kind error when the read ran out of time and a - /// `Transport`-kind error otherwise. Build it with - /// [`CompletionError::new`] and attach the transport's own error with - /// [`CompletionError::with_source`]. - fn next_chunk( - &mut self, - ) -> impl Future, CompletionError>> + Send; + /// When a read fails, the implementation returns the [`Error`] that + /// the round fails with. Its code is `Timeout` when the read ran out of + /// time and `Transport` otherwise. Build it with + /// [`completion_error`](runvil::model::completion_error) and attach the + /// transport's own error with [`Error::with_cause`]. + fn next_chunk(&mut self) -> impl Future, Error>> + Send; } /// Reads a whole response body from `source`, refusing it once it would @@ -71,13 +69,13 @@ pub trait ChunkSource { /// force an unbounded allocation before the body is decoded. /// /// # Errors -/// Returns a `MalformedResponse`-kind [`CompletionError`] when the body -/// would exceed `cap`, and the source's own error when a read fails. +/// Returns an [`Error`] with code `MalformedResponse` when the body would +/// exceed `cap`, and the source's own error when a read fails. pub async fn read_body_capped( source: &mut S, content_length: Option, cap: u64, -) -> Result, CompletionError> { +) -> Result, Error> { if let Some(len) = content_length && len > cap { @@ -120,7 +118,7 @@ pub async fn read_body_capped( /// every clock reading from `started` and `now`. /// /// # Errors -/// Returns a `MalformedResponse`-kind [`CompletionError`] when the stream +/// Returns an [`Error`] with code `MalformedResponse` when the stream /// exceeds `max_bytes` or ends before the sentinel, and the source's own /// error when a read fails. Also returns the error that reassembling the /// reply raises for a malformed chunk, a mid-stream error envelope, a @@ -132,7 +130,7 @@ pub async fn read_completion_stream( on_delta: impl Fn(StreamDelta), started: Instant, now: impl Fn() -> Instant, -) -> Result { +) -> Result { let mut scanner = SseScanner::new(); let mut accumulator = StreamAccumulator::new(); let mut received: u64 = 0; diff --git a/crates/harness-gateway-client/src/wire/stream-tests.rs b/crates/harness-gateway-client/src/wire/stream-tests.rs index dd6d136a1..1e71d56b0 100644 --- a/crates/harness-gateway-client/src/wire/stream-tests.rs +++ b/crates/harness-gateway-client/src/wire/stream-tests.rs @@ -1,6 +1,7 @@ //! Tests for the streaming accumulator and the SSE data-line scanner. -use runvil::model::{CompletionErrorKind, CompletionResult}; +use runvil::error::{ErrorCode, FactKey}; +use runvil::model::CompletionResult; use serde_json::Value; use super::*; @@ -178,15 +179,15 @@ fn finish_fails_a_tool_call_batch_truncated_by_length_or_content_filter() { .finish(Value::Null, None) .expect_err("a truncated tool-call batch must fail"); assert_eq!( - error.kind(), - CompletionErrorKind::MalformedResponse, + error.code, + ErrorCode::MalformedResponse, "finish_reason {reason:?}" ); assert!( error.to_string().contains("truncated"), "the error's message names the truncation: {error:?}" ); - assert_eq!(error.detail(), None); + assert_eq!(error.message, error.prose); } } @@ -208,7 +209,7 @@ fn finish_fails_a_tool_call_batch_sharing_an_id_as_a_retryable_malformed_reply() ]) .finish(Value::Null, None) .expect_err("two calls sharing an id must fail"); - assert_eq!(error.kind(), CompletionErrorKind::MalformedResponse); + assert_eq!(error.code, ErrorCode::MalformedResponse); } #[test] @@ -242,11 +243,14 @@ fn finish_hard_fails_on_an_empty_model_reply() { ]) .finish(Value::Null, None) .expect_err("empty product must fail"); - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(error.code, ErrorCode::EmptyReply); assert_eq!( - error.finish_reason(), + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str), Some("stop"), - "the finish_reason must survive the conversion into CompletionError" + "the finish_reason must survive the conversion into the error" ); assert_eq!( error.to_string(), @@ -254,7 +258,7 @@ fn finish_hard_fails_on_an_empty_model_reply() { reasoning content was present but ignored", "the ignored-reasoning note extends the message" ); - assert_eq!(error.detail(), None); + assert_eq!(error.message, error.prose); } #[test] @@ -351,7 +355,7 @@ fn empty_choices_usage_chunk_is_metadata_not_a_turn() { } #[test] -fn error_envelope_fails_the_stream_with_the_escaped_message_as_detail() { +fn error_envelope_fails_the_stream_with_the_escaped_message_in_its_message_only() { let mut accumulator = StreamAccumulator::new(); let error = accumulator .apply( @@ -360,12 +364,12 @@ fn error_envelope_fails_the_stream_with_the_escaped_message_as_detail() { &no_delta, ) .expect_err("an error envelope must fail the stream"); - assert_eq!(error.kind(), CompletionErrorKind::Transport); + assert_eq!(error.code, ErrorCode::Transport); + assert_eq!(error.prose, "the connection to the model backend failed"); assert_eq!( - error.to_string(), - "the connection to the model backend failed" + error.message, + "the connection to the model backend failed: upstream\\ndied" ); - assert_eq!(error.detail(), Some("upstream\\ndied")); } #[test] @@ -382,8 +386,14 @@ fn error_envelope_naming_a_context_limit_is_a_context_overflow() { &no_delta, ) .expect_err("an error envelope must fail the stream"); - assert_eq!(error.kind(), CompletionErrorKind::ContextOverflow); - assert_eq!(error.overflow(), (Some(5000), Some(4096))); + assert_eq!(error.code, ErrorCode::ContextOverflow); + assert_eq!( + ( + error.facts.get(&FactKey::Count), + error.facts.get(&FactKey::Limit) + ), + (Some(&Value::from(5000)), Some(&Value::from(4096))) + ); } #[test] @@ -404,32 +414,33 @@ fn malformed_chunks_are_rejected_not_skipped() { let mut accumulator = StreamAccumulator::new(); let error = accumulator.apply(payload, &no_delta).expect_err(label); assert_eq!( - error.kind(), - CompletionErrorKind::MalformedResponse, + error.code, + ErrorCode::MalformedResponse, "{label}: {error:?}" ); assert!( error - .to_string() + .prose .starts_with("the model backend sent a reply that could not be understood: "), - "{label}: the message extends the fixed phrase: {error}" + "{label}: the prose extends the fixed phrase: {error:?}" ); - assert_eq!(error.detail(), None, "{label}"); } } #[test] -fn a_malformed_chunk_preserves_the_decode_source() { +fn a_malformed_chunk_keeps_the_decode_error_in_its_message_only() { + let decode = serde_json::from_str::("{ not json") + .expect_err("the payload is not JSON") + .to_string(); let mut accumulator = StreamAccumulator::new(); let error = accumulator .apply("{ not json", &no_delta) .expect_err("undecodable chunk must fail"); - let source = - std::error::Error::source(&error).expect("the decode error must be a preserved source"); assert!( - source.downcast_ref::().is_some(), - "the preserved source must be the JSON decode error, got {source}" + error.message.contains(&decode), + "the message holds the JSON decode error: {error:?}" ); + assert!(!error.prose.contains(&decode), "{error:?}"); } #[test] diff --git a/crates/harness-gateway-client/src/wire/stream.rs b/crates/harness-gateway-client/src/wire/stream.rs index c4a57526b..392d70ab6 100644 --- a/crates/harness-gateway-client/src/wire/stream.rs +++ b/crates/harness-gateway-client/src/wire/stream.rs @@ -21,8 +21,9 @@ use std::collections::BTreeMap; +use runvil::error::Error; use runvil::metrics::ClientTiming; -use runvil::model::{Completion, CompletionError, RawExchange}; +use runvil::model::{Completion, RawExchange}; use serde_json::{Map, Value}; use super::classify::classify_stream_error; @@ -145,7 +146,7 @@ impl StreamAccumulator { /// reasoning fragment it contains. /// /// # Errors - /// Returns a `MalformedResponse`-kind [`CompletionError`] when the + /// Returns an [`Error`] with code `MalformedResponse` when the /// payload is not valid JSON or a recognized field has the wrong /// shape, and the [`classify_stream_error`] result when the payload is /// a mid-stream error envelope (`Transport` unless its text names a @@ -154,12 +155,12 @@ impl StreamAccumulator { &mut self, data: &str, on_delta: &impl Fn(StreamDelta), - ) -> Result { + ) -> Result { if data == "[DONE]" { return Ok(Applied::Done); } let chunk: Value = serde_json::from_str(data) - .map_err(|error| malformed("stream chunk was not valid JSON").with_source(error))?; + .map_err(|error| malformed("stream chunk was not valid JSON").with_cause(&error))?; // A mid-stream `error` envelope is how the gateway (and llama.cpp) // report a failure after the 200 has already been sent: the // completion died in flight, so it is a transport failure unless @@ -208,7 +209,7 @@ impl StreamAccumulator { &mut self, choice: &Value, on_delta: &impl Fn(StreamDelta), - ) -> Result { + ) -> Result { let Some(index) = choice.get("index").and_then(Value::as_u64) else { return Err(malformed("stream choice had no integer index")); }; @@ -270,7 +271,7 @@ impl StreamAccumulator { } /// Merges one tool-call fragment into its index-keyed buffer. - fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), CompletionError> { + fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), Error> { let Some(index) = fragment.get("index").and_then(Value::as_u64) else { return Err(malformed("stream tool-call fragment had no integer index")); }; @@ -321,7 +322,7 @@ impl StreamAccumulator { /// absent when nothing was measured. /// /// # Errors - /// Returns a `MalformedResponse`-kind [`CompletionError`] when a + /// Returns an [`Error`] with code `MalformedResponse` when a /// tool-call batch was cut short by a `length` or `content_filter` /// finish (partial arguments must not execute), the normalizer's own /// errors (`EmptyReply` for a turn with neither non-empty tool calls nor @@ -331,7 +332,7 @@ impl StreamAccumulator { self, request_body: Value, client_timing: Option, - ) -> Result { + ) -> Result { // The truncation rule runs before normalization: a tool-call batch // cut short by `length` or `content_filter` may hold partial JSON // arguments, and partial arguments must not execute. @@ -434,7 +435,7 @@ fn append_string_fragment( key: &str, slot: &mut Option, label: &str, -) -> Result, CompletionError> { +) -> Result, Error> { match delta.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(text)) => { diff --git a/crates/harness-internal/runner/src/environment-tests.rs b/crates/harness-internal/runner/src/environment-tests.rs index c6b9addad..cea04d0f4 100644 --- a/crates/harness-internal/runner/src/environment-tests.rs +++ b/crates/harness-internal/runner/src/environment-tests.rs @@ -2,9 +2,10 @@ //! resolve. use runvil::effect::Round; +use runvil::error::ErrorCode; use runvil::model::{ - Completion, CompletionErrorKind, CompletionOptions, Message, ModelBinding, ModelCatalog, - ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use super::*; @@ -14,7 +15,7 @@ use crate::performers::BoxFuture; struct EmptyBroker; impl InferenceBroker for EmptyBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -25,9 +26,9 @@ impl InferenceBroker for EmptyBroker { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/harness-internal/runner/src/environment.rs b/crates/harness-internal/runner/src/environment.rs index bfe7df895..ecf0c1849 100644 --- a/crates/harness-internal/runner/src/environment.rs +++ b/crates/harness-internal/runner/src/environment.rs @@ -9,7 +9,8 @@ use std::path::PathBuf; -use runvil::model::{CompletionError, ModelId, ModelInfo}; +use runvil::error::Error; +use runvil::model::{ModelId, ModelInfo}; use crate::performers::InferenceBroker; use crate::recorder::RunErrorKind; @@ -52,7 +53,7 @@ pub enum CurrentModelError { /// The inference broker failed to fetch its model list. The broker's /// error is the source. #[error("the model catalog could not be fetched")] - CatalogFetchFailed(#[source] CompletionError), + CatalogFetchFailed(#[source] Error), /// The selected id is absent from the fetched catalog. #[error("the selected model `{0}` is absent from the fetched catalog")] SelectionAbsent(String), diff --git a/crates/harness-internal/runner/src/performers.rs b/crates/harness-internal/runner/src/performers.rs index 51e5b73cb..0b874fabe 100644 --- a/crates/harness-internal/runner/src/performers.rs +++ b/crates/harness-internal/runner/src/performers.rs @@ -35,8 +35,7 @@ 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::{ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; @@ -58,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. /// @@ -76,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 diff --git a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs index 32285b74d..bbf61efd2 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs @@ -12,11 +12,12 @@ use harness_runner::performers::{BoxFuture, InferenceBroker, Performers}; use harness_runner::recorder::{RecordKind, RunOutcome}; use runvil::cancel::CancelHandle; use runvil::effect::Round; +use runvil::error::Error; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, - ModelCatalog, ModelId, ModelInfo, ModelRole, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ModelRole, ThinkingMode, ToolSchema, }; use runvil::timestamp::Timestamp; use runvil::{Prompt, Run, RunConfig}; @@ -64,7 +65,7 @@ struct RecordingBroker { } impl InferenceBroker for RecordingBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { unreachable!("the effect loop never lists models") } @@ -75,7 +76,7 @@ impl InferenceBroker for RecordingBroker { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { let asked = messages .last() .map(|message| message.content().to_owned()) diff --git a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index 34a74c9ef..9177ba70e 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs @@ -17,8 +17,7 @@ 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, ToolId, ToolInfo, ToolOutput}; @@ -174,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 @@ -187,7 +186,7 @@ fn answering( /// A completion asking for [`TOOL`] once per entry of `names`, each /// call's id and `name` argument the entry. -fn parks(names: &[&str]) -> Result, CompletionError> { +fn parks(names: &[&str]) -> Result, Error> { let calls = names .iter() .map(|name| ToolCall::from_parts(*name, WIRE, json!({ "name": name }))) diff --git a/crates/harness-internal/runner/tests/it/harness-errors.rs b/crates/harness-internal/runner/tests/it/harness-errors.rs index 38b9abf06..5761b6dec 100644 --- a/crates/harness-internal/runner/tests/it/harness-errors.rs +++ b/crates/harness-internal/runner/tests/it/harness-errors.rs @@ -15,7 +15,8 @@ use harness_runner::display_chain; use harness_runner::environment::{CurrentModelError, HostSnapshot}; use harness_runner::recorder::{MemoryRecorder, RunErrorKind}; use harness_runner::{Harness, HarnessError, HostContext, RunReport, RunRequest}; -use runvil::model::{Completion, CompletionError, CompletionErrorKind}; +use runvil::error::{Error, ErrorCode}; +use runvil::model::{Completion, completion_error, phrase}; use runvil_plugin::{HostServices, PackRegistry}; use super::{ @@ -74,14 +75,14 @@ fn assert_ended_before_the_parse(recorder: &FailingRecorder, report: &RunReport) #[tokio::test] async fn a_model_list_the_broker_cannot_fetch_ends_the_recorded_run_as_a_completion_failure() { let recorder = Arc::new(FailingRecorder::never_failing()); - let kind = CompletionErrorKind::Transport; - let broker = ScriptedBroker::replying().listing(Listing::Fails(kind)); + let code = ErrorCode::Transport; + let broker = ScriptedBroker::replying().listing(Listing::Fails(code)); let report = harness_over(&recorder, broker) .run(request(PLAIN)) .await .expect("a failed listing is the run's outcome"); - let lookup = CurrentModelError::CatalogFetchFailed(CompletionError::new(kind, kind.phrase())); + let lookup = CurrentModelError::CatalogFetchFailed(completion_error(code, phrase(code))); assert_eq!( report.outcome, RunOutcome::Failed { @@ -230,7 +231,7 @@ async fn a_panicking_broker_answers_its_round_dropped() { }); let in_the_round = ScriptedBroker::new(|_round, _messages| { Box::pin(std::future::poll_fn( - |_cx| -> Poll, CompletionError>> { + |_cx| -> Poll, Error>> { panic!("the broker panics in its round") }, )) diff --git a/crates/harness-internal/runner/tests/it/scripted.rs b/crates/harness-internal/runner/tests/it/scripted.rs index d55370a9a..765b53f1b 100644 --- a/crates/harness-internal/runner/tests/it/scripted.rs +++ b/crates/harness-internal/runner/tests/it/scripted.rs @@ -14,8 +14,8 @@ 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::{ @@ -34,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, } @@ -62,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, @@ -104,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()), } @@ -122,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) } @@ -144,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) } diff --git a/crates/harness-internal/runner/tests/it/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 52b1c9c7a..9c612f6ec 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -16,7 +16,7 @@ use runvil::cancel::CancelHandle; use runvil::effect::{Round, ToolCallOrigin}; use runvil::error::Error; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, }; use runvil::timestamp::Timestamp; use runvil::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; @@ -128,7 +128,7 @@ pub(crate) const TIMED_MAIN: &str = "local t = tasks.spawn('## Child')\n\ pub(crate) struct Unused; impl InferenceBroker for Unused { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { unreachable!("the effect loop never lists models") } @@ -139,7 +139,7 @@ impl InferenceBroker for Unused { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { unreachable!("no test issues a Chat effect") } } diff --git a/crates/harness/examples/run-prompt.rs b/crates/harness/examples/run-prompt.rs index 0b62376c0..ed90394a1 100644 --- a/crates/harness/examples/run-prompt.rs +++ b/crates/harness/examples/run-prompt.rs @@ -12,9 +12,10 @@ use harness::record::{MemoryRecorder, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest, Timer}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; /// Reads the line in `name.txt` and writes its greeting to `reply.txt`. @@ -31,7 +32,7 @@ const GREET: &str = concat!( struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -42,9 +43,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/harness/tests/suite/broker.rs b/crates/harness/tests/suite/broker.rs index 81cf5ee64..654935d7c 100644 --- a/crates/harness/tests/suite/broker.rs +++ b/crates/harness/tests/suite/broker.rs @@ -10,11 +10,12 @@ use harness::record::{MemoryRecorder, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use runvil::effect::Round; +use runvil::error::Error; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - Completion, CompletionError, CompletionOptions, CompletionResult, Message, ModelBinding, - ModelCatalog, ModelId, ModelInfo, ThinkingMode, ToolSchema, + Completion, CompletionOptions, CompletionResult, Message, ModelBinding, ModelCatalog, ModelId, + ModelInfo, ThinkingMode, ToolSchema, }; use crate::support::Clock; @@ -42,7 +43,7 @@ struct ScriptedBroker { } impl InferenceBroker for ScriptedBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -54,7 +55,7 @@ impl InferenceBroker for ScriptedBroker { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { self.rounds .lock() .unwrap_or_else(PoisonError::into_inner) diff --git a/crates/harness/tests/suite/host.rs b/crates/harness/tests/suite/host.rs index afec43244..134106ce1 100644 --- a/crates/harness/tests/suite/host.rs +++ b/crates/harness/tests/suite/host.rs @@ -17,8 +17,8 @@ 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 crate::asker::{AskBroker, asker_host, broker_services}; @@ -121,7 +121,7 @@ struct Stuck { } impl InferenceBroker for Stuck { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -133,7 +133,7 @@ impl InferenceBroker for Stuck { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { self.started.raise(); Box::pin(std::future::pending()) } @@ -166,7 +166,7 @@ struct Streaming { } impl InferenceBroker for Streaming { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let catalog = self.catalog.clone(); Box::pin(async move { Ok(catalog) }) } @@ -178,7 +178,7 @@ impl InferenceBroker for Streaming { _tools: Vec, _options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { let window = (round.origin == ReplyOrigin::Chat).then(|| self.window.clone()); let said = messages .last() diff --git a/crates/harness/tests/suite/support.rs b/crates/harness/tests/suite/support.rs index 40a1469de..26dc1f956 100644 --- a/crates/harness/tests/suite/support.rs +++ b/crates/harness/tests/suite/support.rs @@ -5,16 +5,17 @@ use std::time::Duration; use harness::{BoxFuture, InferenceBroker, Timer}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; /// Lists no model and refuses every round as `Unavailable`. pub(crate) struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -25,9 +26,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 505debdea..6a4fe317d 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -3,10 +3,10 @@ //! [`Error`] is a `pub(crate)` internal error type, never part of the public API. //! A run it ends reports its kind and message in //! [`RunOutcome::Failed`](crate::RunOutcome::Failed), and -//! [`ParseError`](crate::prompt::ParseError), -//! [`CompletionError`](crate::model::CompletionError), and -//! a tool's [`runvil_types::error::Error`] each reach that outcome through this -//! type, which classifies them and keeps their causes. +//! [`ParseError`](crate::prompt::ParseError), and the +//! [`runvil_types::error::Error`] a model round or a tool fails with, each +//! reach that outcome through this type, which classifies them and keeps +//! their causes. //! The `From` bridges that let internal `?` reach this type live in `convert`. //! //! A variant that keeps an originating error renders that error's text in @@ -125,14 +125,14 @@ pub(crate) enum Error { /// A model round or a catalog fetch failed, as the broker that performed /// it reported. /// - /// Holds the broker's [`CompletionError`](crate::model::CompletionError) - /// whole, so the closed kind, the fixed message, the opt-in detail, and - /// the cause chain survive until the run's failure message is built + /// Holds the broker's [`runvil_types::error::Error`] whole, so its model + /// code, its message with the provider detail and the cause chain, its + /// prose, and its facts survive until the run's failure message is built /// from them. The Engine branches on - /// its kind only: a context overflow takes the provider overflow path + /// its code only: a context overflow takes the provider overflow path /// and an empty reply takes the empty-answer path. #[error(transparent)] - Completion(crate::model::CompletionError), + Completion(runvil_types::error::Error), /// The run was cancelled, the caller dropped an effect the run was /// waiting on, or a block raised a caught `cancelled` error value again diff --git a/crates/runvil-internal/engine/src/error/convert.rs b/crates/runvil-internal/engine/src/error/convert.rs index 78f1e59fa..caca53734 100644 --- a/crates/runvil-internal/engine/src/error/convert.rs +++ b/crates/runvil-internal/engine/src/error/convert.rs @@ -123,8 +123,8 @@ impl From for Error { } } -impl From for Error { - fn from(error: crate::model::CompletionError) -> Error { +impl From for Error { + fn from(error: runvil_types::error::Error) -> Error { Error::Completion(error) } } @@ -310,20 +310,21 @@ impl Error { None => Error::lua_message(raised.message), }, runvil_lua::ErrorKind::EmptyModelReply => { - let error = crate::model::CompletionError::new( - crate::model::CompletionErrorKind::EmptyReply, + let mut error = crate::model::completion_error( + runvil_types::error::ErrorCode::EmptyReply, raised.message, ); - Error::Completion( - match raised - .fields - .get("finish_reason") - .and_then(runvil_lua::ErrorField::as_str) - { - Some(reason) => error.with_finish_reason(reason), - None => error, - }, - ) + if let Some(reason) = raised + .fields + .get("finish_reason") + .and_then(runvil_lua::ErrorField::as_str) + { + error.facts.insert( + runvil_types::error::FactKey::FinishReason, + serde_json::Value::from(reason), + ); + } + Error::Completion(error) } runvil_lua::ErrorKind::Cancelled => Error::Interrupted, runvil_lua::ErrorKind::Tool => Error::Tool { diff --git a/crates/runvil-internal/engine/src/error/tests-chain.rs b/crates/runvil-internal/engine/src/error/tests-chain.rs index 6869dea39..d0fbe810b 100644 --- a/crates/runvil-internal/engine/src/error/tests-chain.rs +++ b/crates/runvil-internal/engine/src/error/tests-chain.rs @@ -1,9 +1,9 @@ //! The error-chain convention: a cause-bearing failure's message holds the //! cause's text, its source chain shows that text once, and its cause stays -//! reachable through `originating_error()`; a variant that still keeps a `source()` -//! shows each segment of its chain once, and so does the message a run -//! ends with; an internal failure built with a cause appends it once; and -//! the store probe names a missing store. +//! reachable through `originating_error()`; a model failure's message holds +//! its cause, so the message a run ends with shows it once; an internal +//! failure built with a cause appends it once; and the store probe names a +//! missing store. use runvil_lua::VfsOp; use runvil_types::error::ErrorCode; @@ -77,15 +77,13 @@ fn cause_bearing() -> Vec { ] } -/// One failure of each variant that still keeps its cause as a `source()`, -/// so a chain printer walks more than one segment. -#[track_caller] -fn source_bearing() -> Vec { - let transport = crate::model::CompletionErrorKind::Transport; - vec![Error::Completion( - crate::model::CompletionError::new(transport, transport.phrase()) - .with_source(std::io::Error::other("connection reset by peer")), - )] +/// A model failure built with a cause, which its message holds. +fn caused_completion() -> Error { + let transport = ErrorCode::Transport; + Error::Completion( + crate::model::completion_error(transport, crate::model::phrase(transport)) + .with_cause(&std::io::Error::other("connection reset by peer")), + ) } /// Asserts that no segment of `error`'s source chain repeats inside an @@ -118,17 +116,13 @@ fn no_segment_of_an_error_chain_repeats() { ); assert_eq!(assert_no_segment_repeats(&error), 1); } - for error in source_bearing() { - assert!( - assert_no_segment_repeats(&error) > 1, - "required a chain of at least two segments to walk, actual one for {error:?}" - ); - assert_eq!( - error.message(), - chain(&error).join(": "), - "the run's message holds each segment of the chain once" - ); - } + let completion = caused_completion(); + assert_eq!(assert_no_segment_repeats(&completion), 1); + assert_eq!( + completion.message(), + "the connection to the model backend failed: connection reset by peer", + "the run's message holds the model failure's cause once" + ); } #[test] diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index 34f497735..a7d049628 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -117,33 +117,28 @@ fn typed_error_survives_the_lua_external_boundary() { #[test] fn completion_errors_preserve_their_causes_across_the_error_type_bridge() { - // A broker's failure arrives as a `CompletionError` with its concrete - // cause attached; the cause survives both the public - // `CompletionError::source` and the mapping onto this crate's error - // type. - use crate::model::CompletionError; - use runvil_model_client::model::CompletionErrorKind; + // A broker's failure arrives with its concrete cause in its message; + // the mapping onto this crate's error type keeps the failure whole, so + // the cause stays in the message and out of the prose. + use runvil_types::error::ErrorCode; let cause = std::io::Error::other("connection refused"); - let completion = CompletionError::new( - CompletionErrorKind::Unavailable, + let completion = crate::model::completion_error( + ErrorCode::Unavailable, "model access is turned off or not configured", ) - .with_source(cause); - assert_eq!(completion.kind(), CompletionErrorKind::Unavailable); - assert!( - std::error::Error::source(&completion).is_some(), - "the configuration cause must survive the public wrapper" - ); + .with_cause(&cause); let bridged = Error::from(completion); + let Error::Completion(error) = &bridged else { + panic!("the error type holds the completion error whole, got {bridged:?}"); + }; + assert_eq!(error.code, ErrorCode::Unavailable); assert!( - matches!(&bridged, Error::Completion(error) if error.kind() == CompletionErrorKind::Unavailable), - "the error type holds the completion error whole, got {bridged:?}" - ); - assert!( - std::error::Error::source(&bridged).is_some(), - "the cause must survive the bridge" + error.message.contains("connection refused"), + "the cause must survive the bridge: {error:?}" ); + assert_eq!(error.prose, "model access is turned off or not configured"); + assert!(bridged.to_string().contains("connection refused")); } #[test] diff --git a/crates/runvil-internal/engine/src/error/value.rs b/crates/runvil-internal/engine/src/error/value.rs index 1c6b0a5ba..a611c11af 100644 --- a/crates/runvil-internal/engine/src/error/value.rs +++ b/crates/runvil-internal/engine/src/error/value.rs @@ -1,8 +1,9 @@ //! The internal error as a Lua error value: the kind an author branches //! on and the kind's fields. +use runvil_types::error::{ErrorCode, FactKey}; + use super::{Error, join_task_ids}; -use crate::model::CompletionErrorKind; /// The internal type's rendering into the Lua error table: the kind an author /// branches on and the kind's fields. A failed model round renders as @@ -22,7 +23,7 @@ impl runvil_lua::ErrorValue for Error { | Error::CallDepthExceeded { .. } | Error::Substitution(_) => ErrorKind::Lua, Error::ContextExhausted { .. } => ErrorKind::ContextExhausted, - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { + Error::Completion(error) if error.code == ErrorCode::EmptyReply => { ErrorKind::EmptyModelReply } Error::Completion(_) => ErrorKind::ModelFailure, @@ -47,14 +48,26 @@ impl runvil_lua::ErrorValue for Error { } } + /// A model failure's table holds its prose, so the provider detail and + /// the causes its message keeps stay out of the text an author may hand + /// a model. + fn table_message(&self) -> String { + match self { + Error::Completion(error) => error.prose.clone(), + other => other.to_string(), + } + } + fn fields(&self) -> Vec<(String, runvil_lua::ErrorField)> { use runvil_lua::ErrorField; match self { Error::ContextExhausted { reason } => { vec![("reason".to_owned(), ErrorField::String(reason.clone()))] } - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => error - .finish_reason() + Error::Completion(error) if error.code == ErrorCode::EmptyReply => error + .facts + .get(&FactKey::FinishReason) + .and_then(serde_json::Value::as_str) .map(|reason| { vec![( "finish_reason".to_owned(), diff --git a/crates/runvil-internal/engine/src/execute/infer-tests.rs b/crates/runvil-internal/engine/src/execute/infer-tests.rs index ac93b6b3a..f1568a57f 100644 --- a/crates/runvil-internal/engine/src/execute/infer-tests.rs +++ b/crates/runvil-internal/engine/src/execute/infer-tests.rs @@ -8,7 +8,7 @@ use runvil_types::ids::RoundId; use serde_json::json; use super::*; -use crate::model::{CompletionErrorKind, ToolCall}; +use crate::model::ToolCall; use crate::test_support::recording::null_emitter; #[test] @@ -31,8 +31,8 @@ fn an_infer_round_answered_with_tool_calls_fails_as_a_retryable_malformed_reply( panic!("required a completion failure, actual {error:?}"); }; assert_eq!( - failure.kind(), - CompletionErrorKind::MalformedResponse, + failure.code, + ErrorCode::MalformedResponse, "a reply that broke protocol is malformed, so it may come back well formed" ); } diff --git a/crates/runvil-internal/engine/src/execute/infer.rs b/crates/runvil-internal/engine/src/execute/infer.rs index 56c6b70bf..bcc103550 100644 --- a/crates/runvil-internal/engine/src/execute/infer.rs +++ b/crates/runvil-internal/engine/src/execute/infer.rs @@ -12,12 +12,13 @@ //! reports the round and renders its text, and the yielding chain resumes //! with the outcome. +use runvil_types::error::ErrorCode; use runvil_types::event::ReplyOrigin; use runvil_types::event::lifecycle; use runvil_types::ids::RoundId; use crate::Error; -use crate::model::{Completion, CompletionError, CompletionErrorKind, CompletionResult}; +use crate::model::{Completion, CompletionResult, completion_error, phrase}; use super::context::ReportingHandles; use super::turn; @@ -59,10 +60,10 @@ fn accept_infer_completion( /// The malformed-reply failure for an infer round whose outcome breaks the /// round's protocol, with `specific` naming how. fn malformed_reply(specific: &str) -> Error { - let kind = CompletionErrorKind::MalformedResponse; - Error::Completion(CompletionError::new( - kind, - format!("{}: {specific}", kind.phrase()), + let code = ErrorCode::MalformedResponse; + Error::Completion(completion_error( + code, + format!("{}: {specific}", phrase(code)), )) } @@ -76,11 +77,11 @@ fn malformed_reply(specific: &str) -> Error { /// cancellation, so no `MODEL_TURN_FAILED` fires for it. /// /// # Errors -/// Returns the completion's failure, or an [`Error::Completion`] of kind +/// Returns the completion's failure, or an [`Error::Completion`] with code /// `MalformedResponse` when the round produced tool calls (none were /// advertised) or an unrecognized outcome. pub(super) fn accept_infer( - result: std::result::Result, CompletionError>, + result: std::result::Result, runvil_types::error::Error>, round: RoundId, reporting: &ReportingHandles, section: &str, diff --git a/crates/runvil-internal/engine/src/execute/run/effect-record.rs b/crates/runvil-internal/engine/src/execute/run/effect-record.rs index 581c544b3..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 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 tool call is the prose the model read. +/// `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.rs b/crates/runvil-internal/engine/src/execute/run/effect-tests.rs index 3fa35396e..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,7 +6,7 @@ //! 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, 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 +//! 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. The store answer's records are in the `store` @@ -15,7 +15,7 @@ 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, tool_error}; use serde::de::DeserializeOwned; @@ -273,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}" @@ -354,8 +358,8 @@ fn a_failed_tool_call_answer_records_each_tool_codes_tag_and_any_other_code_as_o 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"}}}"#, @@ -451,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 a57603b20..573a5af55 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect.rs @@ -39,7 +39,7 @@ 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; @@ -319,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 @@ -355,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 @@ -372,7 +373,7 @@ 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 { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index abb048c0b..d7db2eb23 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -27,7 +27,7 @@ use crate::lua::{ Answer, ModelReport, ToolCallOutcome, VfsOp, VfsOutcome, prepare_dispatch, prepare_model_dispatch, }; -use crate::model::{Completion, CompletionError}; +use crate::model::Completion; use crate::{Error, Result}; use super::tasks::Stop; @@ -244,7 +244,7 @@ impl ChainOps<'_> { fn accept_infer( &self, round: RoundId, - result: std::result::Result, CompletionError>, + result: std::result::Result, runvil_types::error::Error>, ) -> Result { let chain = &*self.chain; accept_infer(result, round, chain.ctx.reporting(), chain.section_name()) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index e7e9b3bde..d750511c3 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs @@ -25,6 +25,7 @@ use std::sync::atomic::Ordering; +use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::event::{ReplyOrigin, lifecycle}; use runvil_types::ids::RoundId; use runvil_types::metrics::ToolCallEvent; @@ -38,9 +39,7 @@ use crate::lua::{ Answer, ChatOutcome, ChatResult, MessageList, OverflowReason, RenderedTools, ToolSet, output_reserve, precheck, project_messages, wire_name_of, }; -use crate::model::{ - Completion, CompletionErrorKind, CompletionResult, ModelBinding, ToolCall, detail, -}; +use crate::model::{Completion, CompletionResult, ModelBinding, ToolCall, detail}; use crate::{Error, Result}; use super::dispatch::Dispatched; @@ -341,7 +340,7 @@ impl ArrivedRound { /// other failure is a failed turn and the call's error. fn failed(&self, error: Error) -> std::result::Result, Error> { match error { - Error::Completion(error) if error.kind() == CompletionErrorKind::ContextOverflow => { + Error::Completion(error) if error.code == ErrorCode::ContextOverflow => { self.reporting .emitter .report(&self.section, lifecycle::MODEL_TURN_FAILED); @@ -350,14 +349,18 @@ impl ArrivedRound { self.reporting.turns.load(Ordering::Relaxed), ))) } - Error::Completion(error) if error.kind() == CompletionErrorKind::EmptyReply => { + Error::Completion(error) if error.code == ErrorCode::EmptyReply => { let turn = advance_turn(&self.reporting.turns); self.reporting .emitter .report(&self.section, lifecycle::MODEL_TURN_COMPLETED); Ok(Box::new(ChatResult { - outcome: ChatOutcome::Empty(Some(error.message().to_owned())), - finish_reason: error.finish_reason().map(str::to_owned), + outcome: ChatOutcome::Empty(Some(error.prose.clone())), + finish_reason: error + .facts + .get(&FactKey::FinishReason) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), turn, })) } diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs b/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs index a49e230bf..60e62e4ab 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_record_rebuild.rs @@ -8,14 +8,16 @@ use std::cell::RefCell; use std::collections::BTreeMap; use std::rc::Rc; +use runvil_model_client::model::context_overflow; use runvil_prompt::test_support::one_section; +use runvil_types::error::ErrorCode; use runvil_types::ids::RoundId; use super::fixtures::echo_tool; use super::models_loop::loop_scenario; use super::*; use crate::execute::run::{Effect, EffectAnswer, EffectRecord}; -use crate::model::{CompletionError, CompletionErrorKind}; +use crate::model::completion_error; use crate::test_support::scenario::chat_answer; /// One issued round as the driver saw it: its record, and the wire @@ -230,7 +232,7 @@ fn an_add_then_a_remove_between_two_sends_adds_nothing_to_the_log() { #[test] fn a_resend_with_no_changes_logs_no_messages_and_keeps_the_whole_request() { - let failure = CompletionError::new(CompletionErrorKind::MalformedResponse, "garbled reply"); + let failure = completion_error(ErrorCode::MalformedResponse, "garbled reply"); let (scenario, log) = echo_rounds( "messages:user('one')\n\ assert(not pcall(models.loop), 'the first round fails')\n\ @@ -285,7 +287,7 @@ fn re_inserting_an_identical_record_adds_nothing_to_the_log() { #[test] fn a_run_that_compacted_after_an_overflow_rebuilds_every_request_from_its_records() { - let overflow = CompletionError::context_overflow(Some(5000), Some(4096), "too large"); + let overflow = context_overflow(Some(5000), Some(4096), "too large"); let (scenario, log) = echo_rounds( "messages:system('be brief')\n\ messages:user('one')\n\ diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction.rs b/crates/runvil-internal/engine/src/execute/tests/compaction.rs index 45de2489a..50db999c1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction.rs @@ -6,12 +6,12 @@ //! `models_compact`. use runvil_prompt::test_support::{one_section, prompt}; +use runvil_types::error::ErrorCode; use super::fixtures::echo_tool; use super::gateway::overflowing; use super::models_loop::loop_scenario; use super::*; -use crate::model::CompletionErrorKind; #[path = "compaction-rules.rs"] mod rules; @@ -223,7 +223,7 @@ fn a_loop_inside_a_compactor_raises_loop_running() { fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { let gateway = ScriptedChat::new(vec![ fail( - CompletionErrorKind::ContextOverflow, + ErrorCode::ContextOverflow, 400, "This model's maximum context length is 4096 tokens.", ), diff --git a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs index 703597438..4922f9651 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -5,14 +5,22 @@ //! raise `empty_model_reply`). use runvil_prompt::test_support::{PromptSource, one_section, prompt}; +use runvil_types::error::{ErrorCode, FactKey}; use super::context::Recorder; use super::fixtures::echo_tool; use super::models_loop::{LOOP_TO_TEXT, loop_events, loop_scenario}; use super::*; -use crate::model::CompletionErrorKind; use crate::test_support::scenario::Trace; +/// The backend's finish reason a model failure names, when it names one. +fn finish_reason(error: &runvil_types::error::Error) -> Option<&str> { + error + .facts + .get(&FactKey::FinishReason) + .and_then(Value::as_str) +} + #[test] fn falls_through_to_next_section() { let source = prompt( @@ -272,12 +280,11 @@ fn empty_stop_turn_without_tool_calls_fails() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), Some("stop")); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), Some("stop")); assert_eq!( - error.message(), - "the model replied with no text and no tool calls", - "the fixed phrase for the kind is the message" + error.prose, "the model replied with no text and no tool calls", + "the fixed phrase for the code is the prose" ); } other => panic!("expected an EmptyReply failure, got {other:?}"), @@ -296,8 +303,8 @@ fn empty_truncated_final_text_fails_without_truncation_detail() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), Some("length")); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), Some("length")); } other => panic!("expected an EmptyReply failure, got {other:?}"), } @@ -316,8 +323,8 @@ fn empty_turn_without_finish_reason_after_tool_call_fails() { let events = exit_events(&trace); match trace.error() { Error::Completion(error) => { - assert_eq!(error.kind(), CompletionErrorKind::EmptyReply); - assert_eq!(error.finish_reason(), None); + assert_eq!(error.code, ErrorCode::EmptyReply); + assert_eq!(finish_reason(error), None); } other => panic!("expected an EmptyReply failure, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/execute/tests/gateway.rs b/crates/runvil-internal/engine/src/execute/tests/gateway.rs index f330ee41c..faad41bc1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/gateway.rs +++ b/crates/runvil-internal/engine/src/execute/tests/gateway.rs @@ -5,8 +5,8 @@ use super::*; /// The broker-reported context overflow every round of the overflowing /// model fails with. -fn context_overflow() -> crate::model::CompletionError { - crate::model::CompletionError::context_overflow( +fn context_overflow() -> runvil_types::error::Error { + runvil_model_client::model::context_overflow( Some(5000), Some(4096), "the request is larger than the model's context window", diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs index 80e44d1fa..d6b6a0de3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs @@ -209,7 +209,7 @@ fn the_chat_agent_shape_leaves_the_oldest_records_out_of_a_summary_past_its_budg let mut replies = vec![text("ok"); long_turns]; replies.extend([ fail( - crate::model::CompletionErrorKind::ContextOverflow, + runvil_types::error::ErrorCode::ContextOverflow, 400, "This model's maximum context length is 131072 tokens.", ), diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs index 0060adc8f..a15d209de 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs @@ -57,7 +57,7 @@ fn models_loop_raises_context_exhaustion_at_the_call_site() { #[test] fn an_explicit_compactors_fail_invocation_reports_the_provider_reason() { let gateway = ScriptedChat::new(vec![fail( - crate::model::CompletionErrorKind::ContextOverflow, + runvil_types::error::ErrorCode::ContextOverflow, 400, "This model's maximum context length is 4096 tokens.", )]); diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs index f7fe76c83..32c83bced 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs @@ -9,13 +9,13 @@ //! and `compaction`. use runvil_prompt::test_support::{one_section, prompt}; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use super::context::Recorder; use super::fixtures::{echo_tool, failing_tool, untrusted_echo_tool}; use super::models_loop::{LOOP_TO_TEXT, loop_events, loop_scenario}; use super::*; -use crate::model::CompletionErrorKind; use crate::test_support::scenario::{EXECUTION, assert_model_failure}; /// The one-section loop prompt running `lua` under an explicit @@ -235,7 +235,7 @@ fn repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap() { #[test] fn a_failing_model_turn_is_reported_before_the_error_propagates() { let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::ServerError, + ErrorCode::ServerError, 500, "private backend response", )]); @@ -247,7 +247,7 @@ fn a_failing_model_turn_is_reported_before_the_error_propagates() { let trace = loop_scenario(source).chat(&gateway).run(); assert_model_failure( trace.error(), - CompletionErrorKind::ServerError, + ErrorCode::ServerError, "the model backend reported a fault of its own (status 500)", ); let recorder = Recorder::default(); @@ -267,11 +267,7 @@ fn a_failing_model_turn_is_reported_before_the_error_propagates() { #[test] fn a_caught_model_failure_reads_as_the_model_failure_kind() { - let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::ServerError, - 500, - "backend fault", - )]); + let gateway = ScriptedChat::new(vec![fail(ErrorCode::ServerError, 500, "backend fault")]); let source = one_section( "messages:user('go') local ok, err = pcall(models.loop) @@ -307,7 +303,7 @@ fn a_client_rejection_without_overflow_signatures_stays_a_rejection() { // overflow, so the bare rejection propagates and no compactor is // invoked. let gateway = ScriptedChat::new(vec![fail( - CompletionErrorKind::Rejected, + ErrorCode::Rejected, 400, "invalid request: unknown field", )]); @@ -316,7 +312,7 @@ fn a_client_rejection_without_overflow_signatures_stays_a_rejection() { .run(); assert_model_failure( trace.error(), - CompletionErrorKind::Rejected, + ErrorCode::Rejected, "the model backend rejected the request (status 400)", ); } diff --git a/crates/runvil-internal/engine/src/lua/tests/errors.rs b/crates/runvil-internal/engine/src/lua/tests/errors.rs index d90fef503..c6a895c4c 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors.rs @@ -368,9 +368,15 @@ fn a_raised_table_maps_onto_the_executor_error_type_by_kind() { }; match crate::Error::from(Error::Raised(empty)) { crate::Error::Completion(error) => { - assert_eq!(error.kind(), crate::model::CompletionErrorKind::EmptyReply); - assert_eq!(error.message(), "the model returned an empty turn"); - assert_eq!(error.finish_reason(), Some("length")); + assert_eq!(error.code, runvil_types::error::ErrorCode::EmptyReply); + assert_eq!(error.prose, "the model returned an empty turn"); + assert_eq!( + error + .facts + .get(&runvil_types::error::FactKey::FinishReason) + .and_then(serde_json::Value::as_str), + Some("length") + ); } other => panic!("expected the empty-reply failure, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/model.rs b/crates/runvil-internal/engine/src/model.rs index a58baa16a..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`]). //! @@ -30,7 +31,7 @@ pub(crate) use runvil_model_client::client::{ // documented. pub(crate) use runvil_model_client::detail; pub(crate) use runvil_model_client::model::{ - CompletionError, CompletionErrorKind, ModelBinding, ModelInvocation, ModelSet, Temperature, + ModelBinding, ModelInvocation, ModelSet, Temperature, completion_error, phrase, }; pub(crate) use runvil_types::models::{ModelId, ModelInfo, ModelRole, ThinkingMode}; diff --git a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs index 6a419a72d..405473428 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs @@ -24,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 @@ -74,8 +74,8 @@ 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", ))), }, diff --git a/crates/runvil-internal/engine/src/test_support/scenario.rs b/crates/runvil-internal/engine/src/test_support/scenario.rs index 3b4fd372c..d51815813 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario.rs @@ -22,6 +22,7 @@ use std::thread; use runvil_lua::ToolOutputKind; use runvil_model_client::model::ModelInvocation; use runvil_types::cancel::CancelHandle; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use runvil_types::seed::RunSeed; use runvil_types::timestamp::Timestamp; @@ -33,9 +34,7 @@ use super::scripted_chat::ScriptedChat; use crate::Error; use crate::execute::{Effect, EffectAnswer, Run, RunConfig, RunLimits}; use crate::lua::ToolBinding; -use crate::model::{ - CompletionErrorKind, ModelBinding, ModelId, ModelInfo, ModelRole, ModelSet, ThinkingMode, -}; +use crate::model::{ModelBinding, ModelId, ModelInfo, ModelRole, ModelSet, ThinkingMode}; use crate::prompt::Prompt; #[path = "scenario-driving.rs"] @@ -95,16 +94,13 @@ pub(crate) fn test_binding(alias: &str, description: &str, model: &str) -> Model ) } -/// Asserts `error` is a model failure of `kind` whose message is `message`. +/// Asserts `error` is a model failure of `code` whose prose is `prose`. #[track_caller] -pub(crate) fn assert_model_failure(error: &Error, kind: CompletionErrorKind, message: &str) { +pub(crate) fn assert_model_failure(error: &Error, code: ErrorCode, prose: &str) { let Error::Completion(failure) = error else { panic!("expected a model failure, got {error:?}"); }; - assert_eq!( - (failure.kind(), failure.to_string().as_str()), - (kind, message) - ); + assert_eq!((failure.code, failure.prose.as_str()), (code, prose)); } /// The edit a scenario applies to its run's config before the run is diff --git a/crates/runvil-internal/engine/src/test_support/scripted_chat.rs b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs index 25bdcb4c5..9482befaa 100644 --- a/crates/runvil-internal/engine/src/test_support/scripted_chat.rs +++ b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs @@ -13,7 +13,8 @@ use std::sync::{Arc, Mutex, PoisonError}; use runvil_model_client::client::{Completion, CompletionResult, Message, ToolCall, ToolSchema}; -use runvil_model_client::model::{CompletionError, CompletionErrorKind, CompletionOptions}; +use runvil_model_client::model::{CompletionOptions, completion_error, empty_reply, phrase}; +use runvil_types::error::{Error, ErrorCode, FactKey}; use runvil_types::metrics::CallMetrics; use serde_json::Value; @@ -69,15 +70,15 @@ pub(crate) fn calls(calls: &[(&str, &str, &str)]) -> ScriptedReply { } } -/// A failed round of `kind`, with HTTP `status` and response `body`. +/// A failed round of `code`, with HTTP `status` and response `body`. /// -/// For every kind it builds the kind's phrase with the status appended and -/// the body as the detail, so each test names its kind directly. +/// For every code it builds the code's phrase with the status appended and +/// the body as the detail, so each test names its code directly. #[must_use] -pub(crate) fn fail(kind: CompletionErrorKind, status: u16, body: &str) -> ScriptedReply { +pub(crate) fn fail(code: ErrorCode, status: u16, body: &str) -> ScriptedReply { ScriptedReply::Failure { - kind, - message: format!("{} (status {status})", kind.phrase()), + code, + message: format!("{} (status {status})", phrase(code)), detail: Some(body.to_owned()), } } @@ -109,11 +110,11 @@ pub enum ScriptedReply { }, /// A failed round. /// - /// A fresh [`CompletionError`] is built for every answer, because the - /// error is not `Clone`. + /// A fresh [`Error`] is built for every answer, and the detail joins its + /// message only, never its prose. Failure { - /// The failure's kind. - kind: CompletionErrorKind, + /// The failure's code. + code: ErrorCode, /// The failure's message. message: String, /// The provider text behind the failure, when there is some. @@ -124,7 +125,7 @@ pub enum ScriptedReply { impl ScriptedReply { /// Answers a round with this reply: the answer /// [`ScriptedChat::complete_now`] gives. - pub(crate) fn answer(&self) -> Result { + pub(crate) fn answer(&self) -> Result { match self { ScriptedReply::Text { model, @@ -135,11 +136,13 @@ impl ScriptedReply { } => { let reasoning = reasoning.as_deref().filter(|text| !text.trim().is_empty()); if content.trim().is_empty() { - let error = CompletionError::empty_reply(reasoning.is_some()); - return Err(match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - }); + let mut error = empty_reply(reasoning.is_some()); + if let Some(reason) = finish_reason { + error + .facts + .insert(FactKey::FinishReason, Value::from(reason.as_str())); + } + return Err(error); } let mut completion = Completion::from_result(CompletionResult::Text(content.clone()), model)?; @@ -162,13 +165,14 @@ impl ScriptedReply { Completion::from_result(CompletionResult::ToolCalls(calls), model) } ScriptedReply::Failure { - kind, + code, message, detail, } => { - let mut error = CompletionError::new(*kind, message); + let mut error = completion_error(*code, message); if let Some(detail) = detail { - error = error.with_detail(detail); + error.message.push_str(": "); + error.message.push_str(detail); } Err(error) } @@ -285,7 +289,7 @@ impl ScriptedChat { messages: &[Message], tools: &[ToolSchema], options: &CompletionOptions, - ) -> Result { + ) -> Result { let index = { let mut requests = self.requests.lock().unwrap_or_else(PoisonError::into_inner); let index = requests.len(); diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index ace1105d1..f6ab27636 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -221,8 +221,8 @@ impl ErrorField { } } -/// A Rust error's rendering into the Lua error table: its kind and the -/// kind's own fields. `Display` supplies `message`. +/// A Rust error's rendering into the Lua error table: its kind, its +/// message, and the kind's own fields. /// /// The envelope renderer requires this of the driver's error type, so a /// failure answered to Lua always has a kind; an internal type that gains a @@ -231,6 +231,13 @@ pub trait ErrorValue: std::fmt::Display { /// The kind the table's `kind` field names. fn kind(&self) -> ErrorKind; + /// The text the table's `message` field holds, which an author may hand + /// a model. `Display` supplies it unless a variant keeps a model-safe + /// text apart from its diagnostic. + fn table_message(&self) -> String { + self.to_string() + } + /// The kind's own fields, as `(name, value)` pairs set beside /// `kind` and `message`. Empty for kinds without fields. fn fields(&self) -> Vec<(String, ErrorField)> { @@ -346,13 +353,14 @@ fn finish_table(lua: &Lua, kind: ErrorKind, fields: Table) -> mlua::Result mlua::Result
{ let table = lua.create_table()?; - table.raw_set("message", error.to_string())?; + table.raw_set("message", error.table_message())?; for (name, value) in error.fields() { let value = match value { ErrorField::String(value) => Value::String(lua.create_string(value)?), diff --git a/crates/runvil-internal/model-client/src/client/tests.rs b/crates/runvil-internal/model-client/src/client/tests.rs index c3c8e0387..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] @@ -150,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" ); } @@ -163,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}" 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/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 cfb2d609d..5d168b3ae 100644 --- a/crates/runvil-internal/model-client/src/model.rs +++ b/crates/runvil-internal/model-client/src/model.rs @@ -9,10 +9,10 @@ //! 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 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. 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/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/types/src/error-tag.rs b/crates/runvil-internal/types/src/error-tag.rs new file mode 100644 index 000000000..6c7f43705 --- /dev/null +++ b/crates/runvil-internal/types/src/error-tag.rs @@ -0,0 +1,83 @@ +//! The stable tag of each code. + +use super::ErrorCode; + +impl ErrorCode { + /// Returns the code's stable tag: its name in snake_case, such as + /// `not_found` or `rate_limited`. No two codes share a tag. Each store + /// and model code keeps the tag a run log already records for it, so + /// stored records keep their meaning. + #[must_use] + pub const fn tag(self) -> &'static str { + match self { + ErrorCode::FrontmatterInvalid => "frontmatter_invalid", + ErrorCode::StructureInvalid => "structure_invalid", + ErrorCode::FenceInvalid => "fence_invalid", + ErrorCode::ListEmptyItem => "list_empty_item", + ErrorCode::UnsupportedVersion => "unsupported_version", + ErrorCode::InvalidValue => "invalid_value", + ErrorCode::LuaRuntime => "lua_runtime", + ErrorCode::LuaCompile => "lua_compile", + ErrorCode::LuaQuota => "lua_quota", + ErrorCode::CallDepthExceeded => "call_depth_exceeded", + ErrorCode::Substitution => "substitution", + ErrorCode::TasksLive => "tasks_live", + ErrorCode::TaskNotOwned => "task_not_owned", + ErrorCode::TaskConsumed => "task_consumed", + ErrorCode::TaskCancelled => "task_cancelled", + ErrorCode::ContextOverflow => "context_overflow", + ErrorCode::RateLimited => "rate_limited", + ErrorCode::QuotaExhausted => "quota_exhausted", + ErrorCode::Overloaded => "overloaded", + ErrorCode::Refused => "refused", + ErrorCode::Timeout => "timeout", + ErrorCode::Transport => "transport", + ErrorCode::ServerError => "server_error", + ErrorCode::Rejected => "rejected", + ErrorCode::MalformedResponse => "malformed_response", + ErrorCode::EmptyReply => "empty_reply", + ErrorCode::Unavailable => "unavailable", + ErrorCode::InvalidParts => "invalid_parts", + ErrorCode::ContextExhausted => "context_exhausted", + ErrorCode::ModelRequired => "model_required", + ErrorCode::ToolInvalidArguments => "tool_invalid_arguments", + ErrorCode::ToolBackend => "tool_backend", + ErrorCode::ToolTransport => "tool_transport", + ErrorCode::ToolOther => "tool_other", + ErrorCode::OutOfScopeTool => "out_of_scope_tool", + ErrorCode::UnboundTool => "unbound_tool", + ErrorCode::ModelOnlyTool => "model_only_tool", + ErrorCode::ToolLoopExhausted => "tool_loop_exhausted", + ErrorCode::ToolSchemaInvalid => "tool_schema_invalid", + ErrorCode::ToolSetsInvalid => "tool_sets_invalid", + ErrorCode::NotFound => "not_found", + ErrorCode::AlreadyExists => "already_exists", + ErrorCode::NotADirectory => "not_a_directory", + ErrorCode::IsADirectory => "is_a_directory", + ErrorCode::DirectoryNotEmpty => "directory_not_empty", + ErrorCode::NotUtf8 => "not_utf8", + ErrorCode::InvalidPath => "invalid_path", + ErrorCode::InvalidRange => "invalid_range", + ErrorCode::Anchor => "anchor", + ErrorCode::PermissionDenied => "permission_denied", + ErrorCode::ApprovalRequired => "approval_required", + ErrorCode::ScopeEnded => "scope_ended", + ErrorCode::ReadOnly => "read_only", + ErrorCode::OutsideRoot => "outside_root", + ErrorCode::RootProtected => "root_protected", + ErrorCode::NoStore => "no_store", + ErrorCode::CrossMount => "cross_mount", + ErrorCode::Io => "io", + ErrorCode::Unsupported => "unsupported", + ErrorCode::Conflict => "conflict", + ErrorCode::Backend => "backend", + ErrorCode::Determinism => "determinism", + ErrorCode::RequirementsUnmet => "requirements_unmet", + ErrorCode::Interrupted => "interrupted", + ErrorCode::Internal => "internal", + ErrorCode::RecorderFailed => "recorder_failed", + ErrorCode::PluginLoadFailed => "plugin_load_failed", + ErrorCode::NoOutput => "no_output", + } + } +} diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index ee323d428..327b5c8f1 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -201,6 +201,62 @@ fn every_code_number_falls_in_its_family_hundred() { } } +#[test] +fn every_code_has_its_own_snake_case_tag() { + let tags: BTreeSet<&str> = ALL.into_iter().map(ErrorCode::tag).collect(); + assert_eq!(tags.len(), ALL.len(), "no two codes share a tag"); + for tag in tags { + assert!( + tag.bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'), + "{tag}" + ); + } +} + +#[test] +fn store_and_model_codes_keep_the_tags_stored_records_hold() { + // The store and model tags a run log already holds. + for (code, tag) in [ + (ErrorCode::NotFound, "not_found"), + (ErrorCode::AlreadyExists, "already_exists"), + (ErrorCode::NotADirectory, "not_a_directory"), + (ErrorCode::IsADirectory, "is_a_directory"), + (ErrorCode::DirectoryNotEmpty, "directory_not_empty"), + (ErrorCode::NotUtf8, "not_utf8"), + (ErrorCode::InvalidPath, "invalid_path"), + (ErrorCode::InvalidRange, "invalid_range"), + (ErrorCode::Anchor, "anchor"), + (ErrorCode::PermissionDenied, "permission_denied"), + (ErrorCode::ApprovalRequired, "approval_required"), + (ErrorCode::ScopeEnded, "scope_ended"), + (ErrorCode::ReadOnly, "read_only"), + (ErrorCode::OutsideRoot, "outside_root"), + (ErrorCode::RootProtected, "root_protected"), + (ErrorCode::NoStore, "no_store"), + (ErrorCode::CrossMount, "cross_mount"), + (ErrorCode::Io, "io"), + (ErrorCode::Unsupported, "unsupported"), + (ErrorCode::Conflict, "conflict"), + (ErrorCode::Backend, "backend"), + (ErrorCode::ContextOverflow, "context_overflow"), + (ErrorCode::RateLimited, "rate_limited"), + (ErrorCode::QuotaExhausted, "quota_exhausted"), + (ErrorCode::Overloaded, "overloaded"), + (ErrorCode::Refused, "refused"), + (ErrorCode::Timeout, "timeout"), + (ErrorCode::Transport, "transport"), + (ErrorCode::ServerError, "server_error"), + (ErrorCode::Rejected, "rejected"), + (ErrorCode::MalformedResponse, "malformed_response"), + (ErrorCode::EmptyReply, "empty_reply"), + (ErrorCode::Unavailable, "unavailable"), + (ErrorCode::InvalidParts, "invalid_parts"), + ] { + assert_eq!(code.tag(), tag); + } +} + #[test] fn every_code_has_default_prose_in_whole_sentences() { for code in ALL { diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index f996ebe3d..6ac3e6cb1 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -8,6 +8,8 @@ use std::num::NonZeroU32; #[path = "error-prose.rs"] mod prose; +#[path = "error-tag.rs"] +mod tag; #[cfg(test)] #[path = "error-tests.rs"] mod tests; @@ -156,8 +158,8 @@ pub enum FactKey { /// A stable failure code. Numbers group by family in hundreds: 1xx parse, /// 2xx Lua, 3xx tasks, 4xx model, 5xx tools, 6xx store, 7xx run, 8xx -/// harness. A number is never reused. Variants named after a `VfsError` or `CompletionErrorKind` -/// variant keep its name, so their tags will match. +/// harness. A number is never reused. Store and model codes keep the names +/// of the store and model failure kinds a run log already records. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[repr(u16)] pub enum ErrorCode { diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index aaa17b789..56e4b4938 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -4,7 +4,6 @@ #[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 @@ -33,7 +32,6 @@ #[non_exhaustive] pub struct runvil::metrics::Usage { .. } #[non_exhaustive] pub struct runvil::metrics::VllmMetrics { .. } #[non_exhaustive] pub struct runvil::model::Completion { /* private fields */ } -#[non_exhaustive] pub struct runvil::model::CompletionError { /* private fields */ } #[non_exhaustive] pub struct runvil::model::CompletionOptions { /* private fields */ } #[non_exhaustive] pub struct runvil::model::Message { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ModelCatalog { /* private fields */ } @@ -67,7 +65,7 @@ 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 @@ -92,8 +90,6 @@ auto runvil::metrics::Usage: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSaf auto runvil::metrics::VllmMetrics: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::ArgumentNames: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Completion: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::model::CompletionError: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::model::CompletionErrorKind: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::CompletionOptions: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::CompletionResult: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::model::Message: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -192,8 +188,6 @@ derives runvil::metrics::Usage: Clone, Debug, Deserialize, Eq, PartialEq, Serial derives runvil::metrics::VllmMetrics: Clone, Debug, Default, Deserialize, PartialEq, Serialize derives runvil::model::ArgumentNames: Clone, Debug derives runvil::model::Completion: Debug -derives runvil::model::CompletionError: Debug -derives runvil::model::CompletionErrorKind: Clone, Copy, Debug, Eq, Hash, PartialEq derives runvil::model::CompletionOptions: Clone, Debug, Eq, PartialEq derives runvil::model::CompletionResult: Clone, Debug, Eq, PartialEq derives runvil::model::Message: Clone, Debug, Eq, PartialEq, Serialize @@ -258,7 +252,6 @@ 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::error::Error -impl core::error::Error for runvil::model::CompletionError impl core::error::Error for runvil::vfs::ParsePathReasonError impl core::error::Error for runvil::vfs::VfsError impl core::fmt::Display for runvil::ParseError @@ -267,7 +260,6 @@ 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::TaskId -impl core::fmt::Display for runvil::model::CompletionError impl core::fmt::Display for runvil::model::Role impl core::fmt::Display for runvil::packs::GlobalName impl core::fmt::Display for runvil::packs::PackId @@ -329,6 +321,7 @@ 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 @@ -418,7 +411,7 @@ pub fn runvil::metrics::VllmMetrics::with_queue_time_ms(self, ms: core::option:: pub fn runvil::metrics::VllmMetrics::with_time_to_first_token_ms(self, ms: core::option::Option) -> runvil::metrics::VllmMetrics pub fn runvil::metrics::VllmMetrics::with_tokens_per_second(self, rate: core::option::Option) -> runvil::metrics::VllmMetrics pub fn runvil::model::Completion::finish_reason(&self) -> core::option::Option<&str> -pub fn runvil::model::Completion::from_result(result: runvil::model::CompletionResult, model: impl core::convert::Into) -> core::result::Result +pub fn runvil::model::Completion::from_result(result: runvil::model::CompletionResult, model: impl core::convert::Into) -> core::result::Result pub fn runvil::model::Completion::metadata_diagnostics(&self) -> &[alloc::string::String] pub fn runvil::model::Completion::metrics(&self) -> core::option::Option<&runvil::metrics::CallMetrics> pub fn runvil::model::Completion::model(&self) -> &str @@ -431,19 +424,6 @@ pub fn runvil::model::Completion::with_metrics(self, metrics: runvil::metrics::C pub fn runvil::model::Completion::with_model(self, model: impl core::convert::Into) -> runvil::model::Completion pub fn runvil::model::Completion::with_raw(self, raw: runvil::model::RawExchange) -> runvil::model::Completion pub fn runvil::model::Completion::with_reasoning_content(self, reasoning_content: impl core::convert::Into) -> runvil::model::Completion -pub fn runvil::model::CompletionError::context_overflow(prompt_tokens: core::option::Option, window: core::option::Option, message: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::detail(&self) -> core::option::Option<&str> -pub fn runvil::model::CompletionError::empty_reply(reasoning_present: bool) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::finish_reason(&self) -> core::option::Option<&str> -pub fn runvil::model::CompletionError::kind(&self) -> runvil::model::CompletionErrorKind -pub fn runvil::model::CompletionError::message(&self) -> &str -pub fn runvil::model::CompletionError::new(kind: runvil::model::CompletionErrorKind, message: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::overflow(&self) -> (core::option::Option, core::option::Option) -pub fn runvil::model::CompletionError::with_detail(self, text: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::with_finish_reason(self, reason: impl core::convert::Into) -> runvil::model::CompletionError -pub fn runvil::model::CompletionError::with_source(self, source: impl core::convert::Into>) -> runvil::model::CompletionError -pub fn runvil::model::CompletionErrorKind::phrase(self) -> &'static str -pub fn runvil::model::CompletionErrorKind::tag(self) -> &'static str pub fn runvil::model::CompletionOptions::invocation(&self) -> &runvil::model::ModelInvocation pub fn runvil::model::CompletionOptions::max_tokens(&self) -> core::option::Option pub fn runvil::model::CompletionOptions::model(&self) -> &str @@ -502,12 +482,16 @@ 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::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 @@ -700,7 +684,7 @@ 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> @@ -1124,19 +1108,6 @@ pub runvil::metrics::VllmMetrics::mean_itl_ms: core::option::Option pub runvil::metrics::VllmMetrics::queue_time_ms: core::option::Option pub runvil::metrics::VllmMetrics::time_to_first_token_ms: core::option::Option pub runvil::metrics::VllmMetrics::tokens_per_second: core::option::Option -pub runvil::model::CompletionErrorKind::ContextOverflow -pub runvil::model::CompletionErrorKind::EmptyReply -pub runvil::model::CompletionErrorKind::InvalidParts -pub runvil::model::CompletionErrorKind::MalformedResponse -pub runvil::model::CompletionErrorKind::Overloaded -pub runvil::model::CompletionErrorKind::QuotaExhausted -pub runvil::model::CompletionErrorKind::RateLimited -pub runvil::model::CompletionErrorKind::Refused -pub runvil::model::CompletionErrorKind::Rejected -pub runvil::model::CompletionErrorKind::ServerError -pub runvil::model::CompletionErrorKind::Timeout -pub runvil::model::CompletionErrorKind::Transport -pub runvil::model::CompletionErrorKind::Unavailable pub runvil::model::CompletionResult::Text(..) pub runvil::model::CompletionResult::Text::0: alloc::string::String pub runvil::model::CompletionResult::ToolCalls(..) diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 49d4669a3..128ad5f53 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -80,12 +80,14 @@ pub mod model { 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::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; diff --git a/crates/runvil/tests/suite/model.rs b/crates/runvil/tests/suite/model.rs index 847de95e8..c6db214f4 100644 --- a/crates/runvil/tests/suite/model.rs +++ b/crates/runvil/tests/suite/model.rs @@ -1,6 +1,7 @@ //! Describing models and answering their rounds. -use runvil::model::{CompletionErrorKind, ModelCatalog, ModelId, ThinkingMode}; +use runvil::error::ErrorCode; +use runvil::model::{ModelCatalog, ModelId, ThinkingMode}; use runvil::{RunErrorKind, RunOutcome}; use super::support::{Reply, Scenario, TestResult, fail, model, prompt}; @@ -43,7 +44,7 @@ fn a_catalog_finds_a_model_by_id_and_refuses_a_repeated_id_as_a_whole() -> TestR #[test] fn a_failed_model_round_fails_the_run_as_a_completion_failure() -> TestResult { - let RunOutcome::Failed { kind, .. } = run_asking(fail(CompletionErrorKind::Overloaded))? else { + let RunOutcome::Failed { kind, .. } = run_asking(fail(ErrorCode::Overloaded))? else { return Err("a failed round fails the run".into()); }; assert_eq!(kind, RunErrorKind::Completion); diff --git a/crates/runvil/tests/suite/support.rs b/crates/runvil/tests/suite/support.rs index 8f6067f9a..bcdec57cf 100644 --- a/crates/runvil/tests/suite/support.rs +++ b/crates/runvil/tests/suite/support.rs @@ -9,8 +9,7 @@ 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::{ToolId, ToolInfo, ToolOutput, tool_error}; use serde_json::Value; @@ -79,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 { @@ -92,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 { diff --git a/crates/workshop/agents/src/input-tests.rs b/crates/workshop/agents/src/input-tests.rs index 735e94448..08d907a8f 100644 --- a/crates/workshop/agents/src/input-tests.rs +++ b/crates/workshop/agents/src/input-tests.rs @@ -11,7 +11,7 @@ use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use plugin_user_input::{INPUT_BROKER, InputBroker}; use runvil::effect::Round; use runvil::model::{ - Completion, CompletionError, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, }; use tokio::sync::broadcast; @@ -242,7 +242,7 @@ async fn a_registry_cancel_fails_the_broker_call_and_emits_cancelled() { struct NoChat; impl InferenceBroker for NoChat { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -253,7 +253,7 @@ impl InferenceBroker for NoChat { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, runvil::error::Error>> { unreachable!("the prompt makes no model round") } } diff --git a/crates/workshop/agents/tests/it/support.rs b/crates/workshop/agents/tests/it/support.rs index 29b7a65d3..9ea6a41d6 100644 --- a/crates/workshop/agents/tests/it/support.rs +++ b/crates/workshop/agents/tests/it/support.rs @@ -16,9 +16,10 @@ use harness::record::MemoryRecorder; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ - Completion, CompletionError, CompletionErrorKind, CompletionOptions, Message, ModelBinding, - ModelCatalog, ToolSchema, + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use workshop_agents::{Conversation, TokioTimer}; @@ -26,7 +27,7 @@ use workshop_agents::{Conversation, TokioTimer}; struct Offline; impl InferenceBroker for Offline { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -37,9 +38,9 @@ impl InferenceBroker for Offline { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } diff --git a/crates/workshop/server/src/agents/broker-tests.rs b/crates/workshop/server/src/agents/broker-tests.rs index ce1e7488d..513f876e8 100644 --- a/crates/workshop/server/src/agents/broker-tests.rs +++ b/crates/workshop/server/src/agents/broker-tests.rs @@ -17,12 +17,13 @@ use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use harness::InferenceBroker; -use harness_gateway_client::{CompletionError, CompletionErrorKind, StreamDelta}; +use harness_gateway_client::StreamDelta; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::event::ReplyOrigin; use runvil::ids::RoundId; use runvil::model::{ - CompletionOptions, CompletionResult, Message, ModelBinding, ModelId, ModelInvocation, + CompletionOptions, CompletionResult, Message, ModelBinding, ModelId, ModelInvocation, phrase, }; use serde_json::json; use workshop_agents::{Conversations, DeltaKind}; @@ -112,7 +113,7 @@ fn binding() -> ModelBinding { } /// Runs one round on `broker` and returns its reply text. -async fn round(broker: &WorkshopBroker) -> Result { +async fn round(broker: &WorkshopBroker) -> Result { let completion = broker .chat( binding(), @@ -130,9 +131,7 @@ async fn round(broker: &WorkshopBroker) -> Result { /// Runs one streaming round on `broker` and returns its reply text beside /// every piece its callback was handed. -async fn streamed_round( - broker: &WorkshopBroker, -) -> (Result, Vec) { +async fn streamed_round(broker: &WorkshopBroker) -> (Result, Vec) { let kept: Arc>> = Arc::default(); let sink = Arc::clone(&kept); let completion = broker @@ -157,7 +156,7 @@ async fn streamed_round( } /// Lists the models on `broker` and returns their names. -async fn listed(broker: &WorkshopBroker) -> Result, CompletionError> { +async fn listed(broker: &WorkshopBroker) -> Result, Error> { let catalog = broker.models().await?; Ok(catalog .models() @@ -166,14 +165,14 @@ async fn listed(broker: &WorkshopBroker) -> Result, CompletionError> .collect()) } -/// Asserts `error` is `Unavailable` with exactly the kind's fixed phrase, +/// Asserts `error` is `Unavailable` with exactly the code's fixed phrase, /// so no URL or key rides in its message. -fn assert_unavailable(error: &CompletionError) { - let kind = CompletionErrorKind::Unavailable; - assert_eq!(error.kind(), kind); +fn assert_unavailable(error: &Error) { + let code = ErrorCode::Unavailable; + assert_eq!(error.code, code); assert_eq!( error.to_string(), - kind.phrase(), + phrase(code), "the message is the fixed phrase alone" ); } diff --git a/crates/workshop/server/src/agents/broker.rs b/crates/workshop/server/src/agents/broker.rs index a7ad88989..7ab7dded9 100644 --- a/crates/workshop/server/src/agents/broker.rs +++ b/crates/workshop/server/src/agents/broker.rs @@ -19,11 +19,13 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex, PoisonError}; use harness::{BoxFuture, InferenceBroker}; -use harness_gateway_client::{CompletionError, CompletionErrorKind, GatewayBroker, StreamDelta}; +use harness_gateway_client::{GatewayBroker, StreamDelta}; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::event::ReplyOrigin; use runvil::model::{ Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use workshop_agents::{Conversation, DeltaKind}; use workshop_menu::{CatalogBus, MenuHandles}; @@ -54,11 +56,11 @@ impl WorkshopBroker { /// The Gateway broker for the current generation, built on the first /// call under it. No usable gateway fails as `Unavailable` with the - /// kind's fixed phrase, so no URL or key reaches the message. - fn current(&self) -> Result { - let unavailable = CompletionErrorKind::Unavailable; + /// code's fixed phrase, so no URL or key reaches the message. + fn current(&self) -> Result { + let unavailable = ErrorCode::Unavailable; let gateway = usable_gateway(&self.registry) - .ok_or_else(|| CompletionError::new(unavailable, unavailable.phrase()))?; + .ok_or_else(|| completion_error(unavailable, phrase(unavailable)))?; // A poisoned lock holds a pair written whole by one store. let mut cached = self.cached.lock().unwrap_or_else(PoisonError::into_inner); if let Some((generation, broker)) = cached.as_ref() @@ -124,7 +126,7 @@ impl WorkshopBroker { tools: Vec, options: CompletionOptions, on_piece: Arc, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { match self.current() { Ok(broker) => { let options = self.routed(&binding, options); @@ -136,7 +138,7 @@ impl WorkshopBroker { } impl InferenceBroker for WorkshopBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { let broker = self.clone(); Box::pin(async move { broker.chat_model_ready().await; @@ -151,7 +153,7 @@ impl InferenceBroker for WorkshopBroker { tools: Vec, options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { match self.current() { Ok(broker) => { let options = self.routed(&binding, options); @@ -198,7 +200,7 @@ impl RunBroker { } impl InferenceBroker for RunBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { self.inner.models() } @@ -209,7 +211,7 @@ impl InferenceBroker for RunBroker { tools: Vec, options: CompletionOptions, round: Round, - ) -> BoxFuture, CompletionError>> { + ) -> BoxFuture, Error>> { // Only a section's own round has a live reader; a nested // `models.infer` round is read whole from its answer. if round.origin != ReplyOrigin::Chat { diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index 0373bce34..59b88648b 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -18,11 +18,12 @@ use harness::pack::{HostContext, HostServices}; use harness::record::{MemoryRecorder, RunErrorKind, RunOutcome}; use harness::vfs::VfsRef; use harness::{BoxFuture, Harness, HostSnapshot, InferenceBroker, RunRequest}; -use harness_gateway_client::{CompletionError, CompletionErrorKind}; use plugin_web::SEARCH_PROVIDER; use runvil::effect::Round; +use runvil::error::{Error, ErrorCode}; use runvil::model::{ Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + completion_error, phrase, }; use runvil::tools::ToolId; use runvil_plugin::{ServiceKey, TOKIO_RUNTIME}; @@ -51,7 +52,7 @@ fn requiring(pack: &str) -> String { struct OfflineBroker; impl InferenceBroker for OfflineBroker { - fn models(&self) -> BoxFuture> { + fn models(&self) -> BoxFuture> { Box::pin(async { Ok(ModelCatalog::empty()) }) } @@ -62,9 +63,9 @@ impl InferenceBroker for OfflineBroker { _tools: Vec, _options: CompletionOptions, _round: Round, - ) -> BoxFuture, CompletionError>> { - let kind = CompletionErrorKind::Unavailable; - Box::pin(async move { Err(CompletionError::new(kind, kind.phrase())) }) + ) -> BoxFuture, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(completion_error(code, phrase(code))) }) } } From c75d678aa6006a28043c7bf89efedba8fe259ad5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 19:20:13 -0800 Subject: [PATCH 20/23] Replace GatewaySearchError with the universal error A Gateway web search now fails with the universal `Error` instead of the bespoke `GatewaySearchError`. A transport failure is `ToolTransport` and a backend failure is `ToolBackend`, and `tool_error` builds each one with the existing text as its prose. - `search_error` gives the `SearchError` the prose as its text and the universal `Error` as its source, so the text of the search failure does not change. - A send, read, decode, or parse cause goes in the message through `with_cause` and stays out of the prose. - `harness-gateway-client` no longer exports `GatewaySearchError` and `GatewaySearchErrorKind`, and it drops its `thiserror` dependency. - The response tests check the code, the prose, and the cause in the message through `assert_caused`, and the provider tests check the universal cause through `assert_gateway_cause`. The source downcasts to `reqwest::Error` and `FromUtf8Error` are removed. --- Cargo.lock | 1 - crates/harness-gateway-client/Cargo.toml | 1 - crates/harness-gateway-client/src/lib.rs | 4 +- .../src/search-tests-provider.rs | 27 ++-- .../src/search-tests-responses.rs | 96 ++++++------ crates/harness-gateway-client/src/search.rs | 145 ++++-------------- 6 files changed, 95 insertions(+), 179 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f36d3af95..62d21aa64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,7 +2269,6 @@ dependencies = [ "runvil", "serde", "serde_json", - "thiserror 2.0.19", "tokio", "url", ] diff --git a/crates/harness-gateway-client/Cargo.toml b/crates/harness-gateway-client/Cargo.toml index 0fd137cc0..56814f46c 100644 --- a/crates/harness-gateway-client/Cargo.toml +++ b/crates/harness-gateway-client/Cargo.toml @@ -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/lib.rs b/crates/harness-gateway-client/src/lib.rs index b13e82900..2d6bcdfda 100644 --- a/crates/harness-gateway-client/src/lib.rs +++ b/crates/harness-gateway-client/src/lib.rs @@ -58,7 +58,7 @@ //! error text. //! - A backend error body is bounded and control-escaped before it is //! kept. A chat round keeps it only in the error's message, never in its -//! prose, and a search keeps it in the `GatewaySearchError` message. +//! 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 @@ -78,8 +78,6 @@ pub use catalog::fetch_model_catalog; pub use config::GatewayEndpoint; pub use config::SecretString; 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) }) } From ef07d6eb631c25b8b652e65c064fcc51bd5df09d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 19:27:38 -0800 Subject: [PATCH 21/23] Replace RecorderError and MemoryError with the universal error A recorder now fails with the universal `Error` under `RecorderFailed` instead of the bespoke `RecorderError`, and the in-memory recorder drops its private `MemoryError`. `recorder_error` builds the failure from an error or a message, so each `map_err` call site keeps its shape. - The message is `the run recorder failed` followed by the cause chain, and the prose is the code's default. `display_chain` renders the same text as before. - The memory recorder's refusals add the `Reason` fact `unknown_run` or `run_ended` and the run id as the `Run` fact. - `HarnessError::Recorder` and `PrepareError::Recorder` hold the error in a `Box`, which keeps both `Err` types under the `result_large_err` limit. - `harness-runner` and the `harness` facade export `recorder_error` in place of `RecorderError`. The workshop's `TursoRecorder` and the tee test build their failures through it. - The source downcast tests are removed. The runner tests check the code, the message, the prose, and the memory recorder's facts through `assert_refused`, and the run-log tests compare the message after the prefix with the `LogError` text. --- .../runner/src/effect_loop.rs | 37 ++++++------- crates/harness-internal/runner/src/harness.rs | 16 +++--- crates/harness-internal/runner/src/lib.rs | 4 +- crates/harness-internal/runner/src/prepare.rs | 21 ++++---- .../runner/src/recorder-memory.rs | 53 +++++++++++-------- .../harness-internal/runner/src/recorder.rs | 41 ++++++-------- .../runner/tests/it/recorder.rs | 31 +++++++---- .../runner/tests/it/support.rs | 6 +-- crates/harness/src/lib.rs | 2 +- crates/workshop/agents/src/tee-tests.rs | 6 +-- crates/workshop/run-log/src/recorder.rs | 18 +++---- crates/workshop/run-log/tests/it/recorder.rs | 26 ++++----- 12 files changed, 131 insertions(+), 130 deletions(-) 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/harness.rs b/crates/harness-internal/runner/src/harness.rs index f3eff245a..85e76375e 100644 --- a/crates/harness-internal/runner/src/harness.rs +++ b/crates/harness-internal/runner/src/harness.rs @@ -27,7 +27,7 @@ use crate::files::{OutputError, report_output}; use crate::host::HostContext; use crate::performers::{InferenceBroker, Timer}; use crate::prepare::{Services, begin, prepare}; -use crate::recorder::{RecorderError, RunId, RunOutcome, RunRecorder}; +use crate::recorder::{RunId, RunOutcome, RunRecorder}; #[path = "harness-control.rs"] mod control; @@ -113,7 +113,7 @@ pub enum HarnessError { run: Option, /// The error the recorder returned. #[source] - source: RecorderError, + source: Box, }, } @@ -205,13 +205,17 @@ impl Harness { host, } = request; - let begun = begin(&*recorder, &name, &source) - .await - .map_err(|source| HarnessError::Recorder { run: None, source })?; + let begun = + begin(&*recorder, &name, &source) + .await + .map_err(|source| HarnessError::Recorder { + run: None, + source: Box::new(source), + })?; let run_id = begun.run_id; let recorded = |source| HarnessError::Recorder { run: Some(run_id), - source, + source: Box::new(source), }; let model = match resolve_model(&host, &*broker, control.cancel_handle()).await { diff --git a/crates/harness-internal/runner/src/lib.rs b/crates/harness-internal/runner/src/lib.rs index b1cee226e..4a86ad8b4 100644 --- a/crates/harness-internal/runner/src/lib.rs +++ b/crates/harness-internal/runner/src/lib.rs @@ -49,6 +49,6 @@ pub use display_chain::display_chain; pub use harness::{Harness, HarnessError, RunControl, RunReport, RunRequest}; pub use host::HostContext; pub use recorder::{ - MemoryRecorder, Record, RecordKind, RecorderError, RecorderFuture, RunErrorKind, RunId, - RunMeta, RunOutcome, RunRecorder, + MemoryRecorder, Record, RecordKind, RecorderFuture, RunErrorKind, RunId, RunMeta, RunOutcome, + RunRecorder, recorder_error, }; diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index fdb7cb994..3e2f0bd51 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use runvil::cancel::CancelHandle; +use runvil::error::Error; use runvil::event::Event; use runvil::model::{ModelInfo, ModelRole}; use runvil::prompt::FileDecl; @@ -49,7 +50,7 @@ use crate::files::{InputFileError, stage_input}; use crate::host::HostContext; use crate::performers::{InferenceBroker, Performers, Timer}; use crate::recorder::{ - Record, RecordKind, RecorderError, RunErrorKind, RunId, RunMeta, RunOutcome, RunRecorder, + Record, RecordKind, RunErrorKind, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error, }; /// What the caller owns and preparation borrows: the Host's built @@ -168,18 +169,18 @@ pub enum PrepareError { run: RunId, /// The recorder's refusal. #[source] - source: RecorderError, + source: Box, }, } impl PrepareError { /// The failed outcome this failure ended the run with, or, for a /// recorder refusal, which ends nothing, the refusal. - pub(crate) fn ended(self) -> Result { + pub(crate) fn ended(self) -> Result { match self { PrepareError::Parse { source, .. } => Ok(failed(RunErrorKind::Parse, &source)), PrepareError::Input { source, .. } => Ok(failed(source.kind(), &source)), - PrepareError::Recorder { source, .. } => Err(source), + PrepareError::Recorder { source, .. } => Err(*source), } } } @@ -190,11 +191,7 @@ impl PrepareError { /// /// # Errors /// Returns the recorder's refusal to begin the run. -pub async fn begin( - recorder: &dyn RunRecorder, - name: &str, - source: &str, -) -> Result { +pub async fn begin(recorder: &dyn RunRecorder, name: &str, source: &str) -> Result { let seed = RunSeed::from_bytes(rand::random()); let started_at = now_timestamp(); let run_id = recorder @@ -259,7 +256,7 @@ pub async fn prepare( } = begun; let recorded = |source| PrepareError::Recorder { run: run_id, - source, + source: Box::new(source), }; // Parse-time events are the run's first records, whether or not the @@ -353,14 +350,14 @@ fn failed(kind: RunErrorKind, source: &dyn std::error::Error) -> RunOutcome { } /// One event's record under its own provenance. -fn event_record(event: &Event) -> Result { +fn event_record(event: &Event) -> Result { let provenance = event.provenance(); Ok(Record { task_id: provenance.task.to_string(), task_seq: provenance.seq, kind: RecordKind::Event, effect_id: None, - payload: serde_json::to_value(event).map_err(RecorderError::new)?, + payload: serde_json::to_value(event).map_err(recorder_error)?, }) } diff --git a/crates/harness-internal/runner/src/recorder-memory.rs b/crates/harness-internal/runner/src/recorder-memory.rs index 967914a7f..2230bc5b5 100644 --- a/crates/harness-internal/runner/src/recorder-memory.rs +++ b/crates/harness-internal/runner/src/recorder-memory.rs @@ -2,7 +2,10 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; -use super::{Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder}; +use runvil::error::{Error, FactKey}; +use serde_json::Value; + +use super::{Record, RecorderFuture, RunId, RunMeta, RunOutcome, RunRecorder, recorder_error}; /// A recorder that keeps every run in memory, for tests and doc examples. /// @@ -10,7 +13,8 @@ use super::{Record, RecorderError, RecorderFuture, RunId, RunMeta, RunOutcome, R /// in the order runs begin. A write to an id outside the runs this recorder /// began, a write to a run that has ended, and a second /// [`end_run`](RunRecorder::end_run) each fail, so a test notices a Harness -/// that breaks the call order. +/// that breaks the call order. Each such failure has the `Reason` fact +/// `unknown_run` or `run_ended` and the run id as the `Run` fact. #[derive(Debug, Default)] pub struct MemoryRecorder { runs: Mutex>, @@ -23,14 +27,6 @@ struct MemoryRun { outcome: Option, } -#[derive(Debug, thiserror::Error)] -enum MemoryError { - #[error("memory recorder: unknown run {0}")] - UnknownRun(RunId), - #[error("memory recorder: run {0} has ended")] - RunEnded(RunId), -} - impl MemoryRecorder { /// An empty recorder. #[must_use] @@ -85,21 +81,38 @@ fn slot(run: RunId) -> usize { } /// The run `run`, if it exists and still takes writes. -fn open_run(runs: &mut [MemoryRun], run: RunId) -> Result<&mut MemoryRun, MemoryError> { - let found = runs - .get_mut(slot(run)) - .ok_or(MemoryError::UnknownRun(run))?; +fn open_run(runs: &mut [MemoryRun], run: RunId) -> Result<&mut MemoryRun, Error> { + let found = runs.get_mut(slot(run)).ok_or_else(|| { + refused( + run, + "unknown_run", + format!("memory recorder: unknown run {run}"), + ) + })?; if found.outcome.is_some() { - return Err(MemoryError::RunEnded(run)); + return Err(refused( + run, + "run_ended", + format!("memory recorder: run {run} has ended"), + )); } Ok(found) } +/// A write to `run` the recorder refuses for `reason`, with `text` as its +/// cause. +fn refused(run: RunId, reason: &str, text: String) -> Error { + let mut error = recorder_error(text); + error.facts.insert(FactKey::Reason, Value::from(reason)); + error.facts.insert(FactKey::Run, Value::from(run.get())); + error +} + impl RunRecorder for MemoryRecorder { fn begin_run(&self, meta: RunMeta) -> RecorderFuture<'_, RunId> { Box::pin(async move { let mut runs = self.lock(); - let id = i64::try_from(runs.len() + 1).map_err(RecorderError::new)?; + let id = i64::try_from(runs.len() + 1).map_err(recorder_error)?; runs.push(MemoryRun { meta, records: Vec::new(), @@ -112,18 +125,14 @@ impl RunRecorder for MemoryRecorder { fn append(&self, run: RunId, record: Record) -> RecorderFuture<'_, ()> { Box::pin(async move { let mut runs = self.lock(); - open_run(&mut runs, run) - .map(|open| open.records.push(record)) - .map_err(RecorderError::new) + open_run(&mut runs, run).map(|open| open.records.push(record)) }) } fn end_run(&self, run: RunId, outcome: RunOutcome) -> RecorderFuture<'_, ()> { Box::pin(async move { let mut runs = self.lock(); - open_run(&mut runs, run) - .map(|open| open.outcome = Some(outcome)) - .map_err(RecorderError::new) + open_run(&mut runs, run).map(|open| open.outcome = Some(outcome)) }) } } diff --git a/crates/harness-internal/runner/src/recorder.rs b/crates/harness-internal/runner/src/recorder.rs index 1a4145ba9..c7ae70253 100644 --- a/crates/harness-internal/runner/src/recorder.rs +++ b/crates/harness-internal/runner/src/recorder.rs @@ -22,12 +22,12 @@ //! input cannot be put in place. A run the effect loop loses track of //! ends as failed with the kind `Internal`. -use std::error::Error; use std::fmt; use std::future::Future; use std::pin::Pin; use runvil::RunSeed; +use runvil::error::{Error, ErrorCode}; pub use runvil::{RunErrorKind, RunOutcome}; #[path = "recorder-memory.rs"] @@ -38,30 +38,19 @@ pub use memory::MemoryRecorder; /// The future that each [`RunRecorder`] method returns. /// /// It is boxed, can be sent between threads, and borrows the recorder. -pub type RecorderFuture<'a, T> = - Pin> + Send + 'a>>; +pub type RecorderFuture<'a, T> = Pin> + Send + 'a>>; -/// The error a recorder returns when it fails to store a write. +/// Builds the `RecorderFailed` error a recorder returns when it fails to +/// store a write, from the error or the message that made it fail. /// -/// It wraps the recorder's own error as its source. The Harness stops the -/// run that received it. Its message says only that the run recorder -/// failed, so read the source chain for the reason. -#[derive(Debug, thiserror::Error)] -#[error("the run recorder failed")] -pub struct RecorderError { - /// The recorder's own error. - #[source] - source: Box, -} - -impl RecorderError { - /// Wraps the error, or the message, that made the recorder fail. - #[must_use] - pub fn new(source: impl Into>) -> Self { - Self { - source: source.into(), - } - } +/// The Harness stops the run that received it. Its message is `the run +/// recorder failed: ` followed by the cause chain, and its prose is the +/// code's default. +#[must_use] +pub fn recorder_error(cause: impl Into>) -> Error { + let cause = cause.into(); + let code = ErrorCode::RecorderFailed; + Error::new(code, "the run recorder failed", code.prose()).with_cause(&*cause) } /// The storage that the Harness writes each run's history to. @@ -76,14 +65,14 @@ pub trait RunRecorder: Send + Sync { /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn begin_run(&self, meta: RunMeta) -> RecorderFuture<'_, RunId>; /// Adds one record to an open run. /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn append(&self, run: RunId, record: Record) -> RecorderFuture<'_, ()>; /// Closes a run with its outcome. The Harness calls it once for each @@ -91,7 +80,7 @@ pub trait RunRecorder: Send + Sync { /// /// # Errors /// - /// Returns the recorder's own failure, wrapped in a [`RecorderError`]. + /// Returns the recorder's own failure, built with [`recorder_error`]. fn end_run(&self, run: RunId, outcome: RunOutcome) -> RecorderFuture<'_, ()>; } diff --git a/crates/harness-internal/runner/tests/it/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/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 9c612f6ec..f5f042a84 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -10,7 +10,7 @@ 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}; @@ -301,10 +301,10 @@ impl FailingRecorder { &self.inner } - fn count(&self) -> Result<(), RecorderError> { + fn count(&self) -> Result<(), runvil::error::Error> { let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call == self.fail_on { - return Err(RecorderError::new(format!( + return Err(recorder_error(format!( "the fixture recorder refuses call {call}" ))); } diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index f18726c63..e1623572b 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -64,13 +64,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/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/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/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:?}" ); } From 7406dfd6ce3d2cb04290cee77b0ffac2471ce7ef Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 23:33:18 -0800 Subject: [PATCH 22/23] Stop with_cause repeating text the message ends with When a cause is itself a universal `Error`, its message starts with its prose, and the outer message often already ends with that same text. `with_cause` now finds the longest head of the cause's text, cut at a `: `, that the message ends with, and appends only what follows it. - A cause with no such head still goes after `: ` whole, and a cause the message already holds is still skipped. - The search is from the right through `rmatch_indices`, so the longest repeated head wins, and an empty head never matches. - A new test pins a universal cause that adds only its own cause, and an unrelated cause with `: ` in its text that goes in whole. --- crates/runvil-internal/types/src/error-tests.rs | 17 +++++++++++++++++ crates/runvil-internal/types/src/error.rs | 17 +++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index 327b5c8f1..f6cccbe8b 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -41,6 +41,23 @@ fn with_cause_skips_text_the_message_already_holds() { 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, diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 6ac3e6cb1..1ebf2f3ac 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -45,16 +45,25 @@ impl Error { } /// Appends `cause`, and each cause under it, to the message after `: `, - /// skipping any text the message already holds. The prose stays as it - /// was. + /// 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) { - self.message.push_str(": "); - self.message.push_str(&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(); } From ee6318a23c13fa4eceb515149c58d402ad8172d0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Nov 2026 23:37:43 -0800 Subject: [PATCH 23/23] Replace PluginFailure and LoadError with the universal error A Plugin that does not open or load now fails with the universal `Error` under `PluginLoadFailed` instead of the public `PluginFailure` and the private `LoadError`. `load_error` builds each failure with the old message text and the `Reason` fact `open`, `missing_entry`, `refused`, `misuse`, `status`, `undescribed`, or `duplicate`. - The prose is the code's default, because the message holds operating system text and library paths. - `with_path` puts a library's path in the `Path` fact, and a repeat puts the repeated Plugin name in the `Name` fact. A failure of a linked Plugin has no `Path` fact. - `insert_plugin` returns `Result, Error>`, and `PluginReport::failures` returns `&[Error]`. `harness-plugin-loader` and the `harness` facade no longer export `PluginFailure`, and the loader drops its `thiserror` dependency. - The tests read `message` in place of `reason()`. New assertions check the code, the default prose, the `Reason` fact of each failure kind through the `reason` helper, the absent `Path` of a linked Plugin, and the `Path` and `Name` facts of a library failure. --- Cargo.lock | 1 - .../harness-internal/plugin-loader/Cargo.toml | 5 +- .../plugin-loader/src/fakes-tests.rs | 13 ++- .../plugin-loader/src/host-tests.rs | 15 +-- .../plugin-loader/src/host.rs | 26 ++++-- .../harness-internal/plugin-loader/src/lib.rs | 4 +- .../plugin-loader/src/load-tests.rs | 7 +- .../plugin-loader/src/load.rs | 41 ++++----- .../plugin-loader/src/plugin-tests.rs | 40 +++++--- .../plugin-loader/src/plugin.rs | 91 +++++++------------ .../test-plugin/tests/it/insert.rs | 32 ++++++- crates/harness/src/lib.rs | 1 - 12 files changed, 149 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62d21aa64..33bca01a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2279,7 +2279,6 @@ version = "0.3.0" dependencies = [ "libloading 0.8.9", "runvil-plugin", - "thiserror 2.0.19", ] [[package]] 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/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/src/lib.rs b/crates/harness/src/lib.rs index e1623572b..8af777121 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -49,7 +49,6 @@ pub mod plugin { //! loaded. pub use harness_plugin_loader::Plugin; - pub use harness_plugin_loader::PluginFailure; pub use harness_plugin_loader::PluginReport; pub use harness_plugin_loader::PluginSource; pub use harness_plugin_loader::insert_plugin;