From 9dca71204dba79639e973f08099e25cc2f4dbe03 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 08:32:19 -0800 Subject: [PATCH 01/36] Remove SetRule, SetEvidence, and SetTool Each tool-set failure now builds its message at the check that refuses the sets, and `error::tool_sets` takes that message as a `String`. This removes `SetRule`, `SetEvidence`, `SetTool`, and `error/sets.rs`, and adds `tool_in_set` for the messages that name a tool and its set. The message text does not change. - `tool_sets` and `tool_in_set` are in `error/tools.rs`, beside `bind_schema`. - The failures no longer set the `FactKey::Rule` fact. - `check_render` compares the Pack lists as names, not as `PackId` values. - The scope tests now assert the literal message and `ErrorCode::ToolSetsInvalid` for each case. They no longer build the expected error through `tool_sets`. --- crates/runvil-internal/engine/src/error.rs | 6 +- .../runvil-internal/engine/src/error/sets.rs | 144 ------------------ .../runvil-internal/engine/src/error/tests.rs | 48 +++--- .../runvil-internal/engine/src/error/tools.rs | 20 ++- .../engine/src/execute/scope-tests-render.rs | 73 ++++----- .../engine/src/execute/scope-tests.rs | 67 +++----- .../engine/src/execute/scope.rs | 73 ++++----- 7 files changed, 131 insertions(+), 300 deletions(-) delete mode 100644 crates/runvil-internal/engine/src/error/sets.rs diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 6e58cefcb..c385e18e3 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -27,13 +27,13 @@ use runvil_types::error::{ErrorCode, FactKey}; use runvil_types::ids::TaskId; use serde_json::Value; -mod sets; #[cfg(test)] mod tests; mod tools; -pub(crate) use sets::{SetEvidence, SetRule, SetTool, tool_sets}; -pub(crate) use tools::{bind_schema, model_only_tool, out_of_scope_tool, unbound_tool}; +pub(crate) use tools::{ + bind_schema, model_only_tool, out_of_scope_tool, tool_in_set, tool_sets, unbound_tool, +}; /// Crate-internal result alias over [`Error`]. pub(crate) type Result = std::result::Result; diff --git a/crates/runvil-internal/engine/src/error/sets.rs b/crates/runvil-internal/engine/src/error/sets.rs deleted file mode 100644 index 77a135d57..000000000 --- a/crates/runvil-internal/engine/src/error/sets.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! The rules of the merge that builds a section entry's frozen sets, the -//! evidence a broken rule carries, and the `ToolSetsInvalid` failure the -//! sets fail with when they break one. - -use runvil_types::error::{ErrorCode, FactKey}; -use runvil_types::packs::PackId; -use runvil_types::tools::ToolId; - -use crate::lua::{NonEmpty, OfferMode}; - -use super::{Error, failure, with_fact}; - -/// A merge rule a section entry's frozen sets broke, among the rules that -/// name the tools that broke them. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SetRule { - /// A render returns tools only for the sets the section offered a Pack - /// into; this Pack's render returned some for another set. - UnofferedSet(PackId), - /// A Pack renders only tools under its own id; this Pack rendered - /// another Pack's tool. - ForeignTool(PackId), - /// A render returns a run catalog tool only into a set the section - /// offered it in; this one returned it into another set. - UnofferedCatalogTool, - /// A wire name names one tool across both sets; this one names two. - UniqueWireName(String), -} - -/// What a broken merge rule is evidenced by, which is what its message -/// renders: the Packs a render answered, or the tools that broke one of -/// the rules that name them. -/// -/// The two are separate variants because the rules' cardinalities differ, -/// so a rule that names tools can never render with no tool to name. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SetEvidence { - /// The Packs the render was asked about, in order, and the Packs it - /// answered, in order. - RenderedPacks { - asked: Vec, - answered: Vec, - }, - /// The rule that names tools, and the tools that broke it. - Tools { - /// The rule the tools broke. - rule: SetRule, - /// The tools that broke it: both tools of a clash, or the one - /// rendered tool a render rule refuses. - tools: NonEmpty, - }, -} - -/// One tool that broke a merge rule: its id, whose first segment names its -/// Pack, and the set it sits in. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SetTool { - pub(crate) id: ToolId, - pub(crate) set: OfferMode, -} - -impl SetRule { - /// The rule's tag, the failure's `Rule` fact. - fn tag(&self) -> &'static str { - match self { - SetRule::UnofferedSet(_) => "unoffered_set", - SetRule::ForeignTool(_) => "foreign_tool", - SetRule::UnofferedCatalogTool => "unoffered_catalog_tool", - SetRule::UniqueWireName(_) => "unique_wire_name", - } - } -} - -/// A section entry's frozen sets, or the render answer they are merged -/// from, that broke a rule of the merge that builds them, which fails the -/// entry's first loop round. The message names each tool that broke it, -/// with its Pack and its set, or the Packs a render answered, and the -/// rule; the `Rule` fact is the rule's tag, or `rendered_packs` for an -/// answer whose Packs differ from the ones asked. -pub(crate) fn tool_sets(evidence: &SetEvidence) -> Error { - let rule = match evidence { - SetEvidence::RenderedPacks { .. } => "rendered_packs", - SetEvidence::Tools { rule, .. } => rule.tag(), - }; - with_fact( - failure(ErrorCode::ToolSetsInvalid, sets_message(evidence)), - FactKey::Rule, - rule, - ) -} - -/// `packs` as a bracketed list, such as `[web, gh]`. -fn pack_list(packs: &[PackId]) -> String { - let names: Vec = packs.iter().map(ToString::to_string).collect(); - format!("[{}]", names.join(", ")) -} - -/// The message for `evidence`, written for a model: each tool with its -/// Pack and its set, or the Packs a render answered, and what the rule -/// requires. -fn sets_message(evidence: &SetEvidence) -> String { - let (rule, tools) = match evidence { - SetEvidence::RenderedPacks { asked, answered } => { - return format!( - "the render answered the Packs {} where the section offered the Packs {}; a \ - render answers each offered Pack once, in offer order", - pack_list(answered), - pack_list(asked) - ); - } - SetEvidence::Tools { rule, tools } => (rule, tools), - }; - let tools = tools - .iter() - .map(|tool| { - format!( - "{} of the {} Pack in the {}", - tool.id, - tool.id.pack(), - tool.set - ) - }) - .collect::>() - .join(" and "); - match rule { - SetRule::UnofferedSet(pack) => format!( - "the {pack} Pack's render returned {tools}, a set the section did not offer the \ - {pack} Pack into; a render returns tools only for the sets the section offered its \ - Pack into" - ), - SetRule::ForeignTool(pack) => format!( - "the {pack} Pack's render returned {tools}; a Pack renders only tools whose id starts \ - with its own Pack id" - ), - SetRule::UnofferedCatalogTool => format!( - "the render returned {tools}, a run catalog tool the section did not offer in that \ - set; a render returns a catalog tool only into a set the section offered it in" - ), - SetRule::UniqueWireName(wire_name) => format!( - "the tools {tools} share the wire name {wire_name:?}, and a wire name names one tool \ - across a section's Static Set and Dynamic Set; offer only one of them" - ), - } -} diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index a71c59ed7..86ab05a4a 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -364,41 +364,27 @@ fn an_unconverged_tool_loop_is_a_tool_kind() { } #[test] -fn broken_tool_sets_name_the_rule_they_broke() { - let packs = tool_sets(&SetEvidence::RenderedPacks { - asked: vec!["web".parse().expect("a Pack id")], - answered: Vec::new(), - }); - assert_eq!(packs.code, ErrorCode::ToolSetsInvalid); +fn broken_tool_sets_end_a_run_as_a_binding_failure_with_an_internal_table() { + let error = tool_sets("the sets broke a rule".to_owned()); + assert_eq!(error.code, ErrorCode::ToolSetsInvalid); + assert_eq!(error.message, "the sets broke a rule"); + assert_eq!(error.prose, ErrorCode::ToolSetsInvalid.prose()); + assert!(error.facts.is_empty(), "{:?}", error.facts); assert_eq!( - packs.message, - "the render answered the Packs [] where the section offered the Packs [web]; a render \ - answers each offered Pack once, in offer order" - ); - assert_eq!( - packs.facts.get(&FactKey::Rule), - Some(&Value::from("rendered_packs")) - ); - assert_eq!( - failed(&packs), - (RunErrorKind::Binding, packs.message.clone()) + failed(&error), + (RunErrorKind::Binding, error.message.clone()) ); - let clash = tool_sets(&SetEvidence::Tools { - rule: SetRule::UniqueWireName("fetch".to_owned()), - tools: crate::lua::NonEmpty::new( - SetTool { - id: "web/fetch".parse().expect("a tool id"), - set: crate::lua::OfferMode::Static, - }, - Vec::new(), - ), - }); assert_eq!( - clash.facts.get(&FactKey::Rule), - Some(&Value::from("unique_wire_name")) + (error_kind(&error), error_fields(&error)), + (ErrorKind::Internal, Vec::new()) ); +} + +#[test] +fn a_tool_in_a_set_names_its_id_its_pack_and_its_set() { + let id = "web/fetch".parse().expect("a tool id"); assert_eq!( - (error_kind(&clash), error_fields(&clash)), - (ErrorKind::Internal, Vec::new()) + tool_in_set(&id, crate::lua::OfferMode::Dynamic), + "web/fetch of the web Pack in the Dynamic Set" ); } diff --git a/crates/runvil-internal/engine/src/error/tools.rs b/crates/runvil-internal/engine/src/error/tools.rs index 4db9bbbff..5f3441c0b 100644 --- a/crates/runvil-internal/engine/src/error/tools.rs +++ b/crates/runvil-internal/engine/src/error/tools.rs @@ -1,9 +1,11 @@ //! The failures of a tool's binding and of a refused tool call: a schema -//! that did not build, and a name the model or a script may not call. +//! that did not build, frozen sets that broke a rule of the merge that +//! builds them, and a name the model or a script may not call. use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::tools::ToolId; -use crate::lua::DiscoveryTool; +use crate::lua::{DiscoveryTool, OfferMode}; use super::{Error, failure, with_fact}; @@ -32,6 +34,20 @@ pub(crate) fn bind_schema(alias: &str, cause: &Error) -> Error { error.with_cause(cause) } +/// A section entry's frozen sets, or the render answer they are merged +/// from, that broke a rule of the merge that builds them, which fails the +/// entry's first loop round. `message` names what broke the rule and what +/// the rule requires. +pub(crate) fn tool_sets(message: String) -> Error { + failure(ErrorCode::ToolSetsInvalid, message) +} + +/// The tool `id` in `set`, as a broken merge rule's message names it, such +/// as `web/fetch of the web Pack in the Static Set`. +pub(crate) fn tool_in_set(id: &ToolId, set: OfferMode) -> String { + format!("{id} of the {} Pack in the {set}", id.pack()) +} + /// The model called `name`, outside both of the section entry's frozen /// sets. The text names the Static Set's wire names, `static_aliases`, as /// what they are; the offered-but-unscoped hint when `global_exists` says diff --git a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs index b227aeef2..27ca4e228 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs @@ -5,30 +5,6 @@ use super::*; -/// The failure of a merge that broke `rule` through `tool`, the one -/// rendered tool a render rule refuses. -fn broken(rule: SetRule, tool: SetTool) -> Error { - error::tool_sets(&SetEvidence::Tools { - rule, - tools: NonEmpty::new(tool, Vec::new()), - }) -} - -/// The failure of a render answered with the Packs `answered` where the -/// section offered the Packs `asked`. -fn render_evidence(asked: Vec, answered: Vec) -> Error { - error::tool_sets(&SetEvidence::RenderedPacks { asked, answered }) -} - -/// The tool `id` in `set`, as a failed merge names it. -#[track_caller] -fn site(id: &str, set: OfferMode) -> SetTool { - SetTool { - id: ToolId::parse(id).expect("a fixture tool id"), - set, - } -} - /// The Pack id `id`. #[track_caller] fn pack(id: &str) -> PackId { @@ -62,11 +38,19 @@ fn rule_one_refuses_an_answer_whose_packs_are_not_the_ones_asked_in_offer_order( vec![gh.clone(), sets("kit", Vec::new(), Vec::new())], ), ] { - let answered_packs = answered.iter().map(|sets| sets.pack.clone()).collect(); + let answered_packs: Vec = + answered.iter().map(|sets| sets.pack.to_string()).collect(); let error = merge_tool_sets(&offers, answered, &tools).expect_err(case); assert_eq!( - error, - render_evidence(vec![pack("gh"), pack("web")], answered_packs), + (error.code, error.to_string()), + ( + ErrorCode::ToolSetsInvalid, + format!( + "the render answered the Packs [{}] where the section offered the Packs \ + [gh, web]; a render answers each offered Pack once, in offer order", + answered_packs.join(", ") + ) + ), "{case}" ); } @@ -87,49 +71,63 @@ fn rule_one_refuses_a_rendered_tool_outside_what_its_pack_was_asked() { gh, OfferMode::Static, "gh/new", - SetRule::UnofferedSet(pack("gh")), + "the gh Pack's render returned gh/new of the gh Pack in the Static Set, a set the \ + section did not offer the gh Pack into; a render returns tools only for the sets \ + the section offered its Pack into", ), ( "a model-only tool under another Pack's id", web, OfferMode::Static, "gh/new", - SetRule::ForeignTool(pack("web")), + "the web Pack's render returned gh/new of the gh Pack in the Static Set; a Pack \ + renders only tools whose id starts with its own Pack id", ), ( "another Pack's catalog tool", web, OfferMode::Dynamic, "gh/one", - SetRule::ForeignTool(pack("web")), + "the web Pack's render returned gh/one of the gh Pack in the Dynamic Set; a Pack \ + renders only tools whose id starts with its own Pack id", ), ( "a catalog tool the section never offered", web, OfferMode::Static, "web/other", - SetRule::UnofferedCatalogTool, + "the render returned web/other of the web Pack in the Static Set, a run catalog \ + tool the section did not offer in that set; a render returns a catalog tool only \ + into a set the section offered it in", ), ( "a catalog tool the section offered only into the Dynamic Set", web, OfferMode::Static, "web/search", - SetRule::UnofferedCatalogTool, + "the render returned web/search of the web Pack in the Static Set, a run catalog \ + tool the section did not offer in that set; a render returns a catalog tool only \ + into a set the section offered it in", ), ( "a catalog tool the section offered only into the Static Set", web, OfferMode::Dynamic, "web/fetch", - SetRule::UnofferedCatalogTool, + "the render returned web/fetch of the web Pack in the Dynamic Set, a run catalog \ + tool the section did not offer in that set; a render returns a catalog tool only \ + into a set the section offered it in", ), ]; - for (case, index, set, id, rule) in cases { + for (case, index, set, id, message) in cases { let mut answered = asked.clone(); set_mut(&mut answered[index], set).push(info(id)); let error = merge_tool_sets(&offers, answered, &tools).expect_err(case); - assert_eq!(error, broken(rule, site(id, set)), "{case}"); + assert_eq!( + (error.code, error.to_string()), + (ErrorCode::ToolSetsInvalid, message.to_owned()), + "{case}" + ); } } @@ -210,11 +208,6 @@ fn the_stored_pack_order_is_the_whole_of_what_a_render_is_asked() { #[test] fn a_render_rules_message_names_both_asked_and_answered_packs_and_never_renders_subjectless() { - // The rule that names Packs carries `asked` and `answered` as its own - // shape, so its message can never render with no subject: the counts - // and both lists are the evidence, not tools it was handed. A - // `RenderedPacks` rule that could arrive with no tool list is the - // shape this variant replaces. let tools = catalog([info("web/fetch"), info("gh/one")]); let offers = SectionOffers { static_tools: vec![listed("web/fetch")], diff --git a/crates/runvil-internal/engine/src/execute/scope-tests.rs b/crates/runvil-internal/engine/src/execute/scope-tests.rs index 2679295ca..39d54d2b6 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests.rs @@ -10,7 +10,6 @@ use runvil_types::tools::ToolId; use serde_json::json; use super::*; -use crate::Error; #[path = "scope-tests-render.rs"] mod render; @@ -287,52 +286,31 @@ fn the_search_tools_description_lists_at_most_one_hundred_names_then_counts_the_ #[test] fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { let tools = catalog([info("dup/a.b"), info("dup/a/b"), info("web/search")]); - // The failure a clash of `wire_name` between the two tools fails with. - let clash = |wire_name: &str, tools: [(&str, OfferMode); 2]| { - let [first, second] = tools.map(|(id, set)| SetTool { - id: ToolId::parse(id).expect("a fixture tool id"), - set, - }); - error::tool_sets(&SetEvidence::Tools { - rule: SetRule::UniqueWireName(wire_name.to_owned()), - tools: NonEmpty::new(first, vec![second]), - }) - }; let within = merged(&[listed("dup/a/b"), listed("dup/a.b")], &[], &tools) .expect_err("two tools of one set clash"); assert_eq!( - within, - clash( - "dup_a_b", - [ - ("dup/a/b", OfferMode::Static), - ("dup/a.b", OfferMode::Static) - ] + (within.code, within.to_string()), + ( + ErrorCode::ToolSetsInvalid, + "the tools dup/a/b of the dup Pack in the Static Set and dup/a.b of the dup Pack in \ + the Static Set share the wire name \"dup_a_b\", and a wire name names one tool \ + across a section's Static Set and Dynamic Set; offer only one of them" + .to_owned() ) ); - assert_eq!( - within.facts.get(&FactKey::Rule), - Some(&Value::from("unique_wire_name")) - ); let across = merged(&[adhoc("web_search")], &[listed("web/search")], &tools) .expect_err("a static adhoc tool and a dynamic Pack tool of its name clash"); assert_eq!( - across.to_string(), - "the tools adhoc/web_search of the adhoc Pack in the Static Set and web/search of the \ - web Pack in the Dynamic Set share the wire name \"web_search\", and a wire name names \ - one tool across a section's Static Set and Dynamic Set; offer only one of them", + (across.code, across.to_string()), + ( + ErrorCode::ToolSetsInvalid, + "the tools adhoc/web_search of the adhoc Pack in the Static Set and web/search of \ + the web Pack in the Dynamic Set share the wire name \"web_search\", and a wire name \ + names one tool across a section's Static Set and Dynamic Set; offer only one of them" + .to_owned() + ), "the message names each tool's set" ); - assert_eq!( - across, - clash( - "web_search", - [ - ("adhoc/web_search", OfferMode::Static), - ("web/search", OfferMode::Dynamic), - ] - ) - ); let taker = info("web/discover").wire_name("GetDynamicTools"); let tools = catalog([taker.clone(), info("web/search")]); let offers = [OfferedTool::Catalog(taker)]; @@ -340,13 +318,14 @@ fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { let discovery = merged(&offers, &[listed("web/search")], &tools) .expect_err("a Pack tool takes the search tool's wire name"); assert_eq!( - discovery, - clash( - "GetDynamicTools", - [ - ("web/discover", OfferMode::Static), - ("dynamic/search", OfferMode::Static), - ] + (discovery.code, discovery.to_string()), + ( + ErrorCode::ToolSetsInvalid, + "the tools web/discover of the web Pack in the Static Set and dynamic/search of the \ + dynamic Pack in the Static Set share the wire name \"GetDynamicTools\", and a wire \ + name names one tool across a section's Static Set and Dynamic Set; offer only one \ + of them" + .to_owned() ) ); } diff --git a/crates/runvil-internal/engine/src/execute/scope.rs b/crates/runvil-internal/engine/src/execute/scope.rs index ce316681c..aaf0cbfbe 100644 --- a/crates/runvil-internal/engine/src/execute/scope.rs +++ b/crates/runvil-internal/engine/src/execute/scope.rs @@ -14,11 +14,10 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::{Value, json}; -use crate::error::{SetEvidence, SetRule, SetTool}; use crate::execute::PackToolSets; use crate::lua::{ - DiscoveryTool, NonEmpty, OfferMode, OfferedTool, RenderedTools, SectionVm, ToolBinding, - ToolSet, current_catalog_packs, current_dynamic_set, current_static_set, wire_name_of, + DiscoveryTool, OfferMode, OfferedTool, RenderedTools, SectionVm, ToolBinding, ToolSet, + current_catalog_packs, current_dynamic_set, current_static_set, wire_name_of, }; use crate::model::{ToolSchema, detail}; use crate::{Result, error}; @@ -233,14 +232,16 @@ fn check_render( answered: Vec>, catalog: &ToolCatalog, ) -> Result>> { - let packs = |all: &[PackToolSets]| -> Vec { - all.iter().map(|sets| sets.pack.clone()).collect() + let names = |all: &[PackToolSets]| -> Vec { + all.iter().map(|sets| sets.pack.to_string()).collect() }; - if packs(asked) != packs(&answered) { - return Err(error::tool_sets(&SetEvidence::RenderedPacks { - asked: packs(asked), - answered: packs(&answered), - })); + if names(asked) != names(&answered) { + return Err(error::tool_sets(format!( + "the render answered the Packs [{}] where the section offered the Packs [{}]; a \ + render answers each offered Pack once, in offer order", + names(&answered).join(", "), + names(asked).join(", ") + ))); } answered .into_iter() @@ -271,29 +272,30 @@ fn check_set( tools: Vec, catalog: &ToolCatalog, ) -> Result> { - let refuse = |rule: SetRule, tool: &ToolInfo| { - error::tool_sets(&SetEvidence::Tools { - rule, - tools: NonEmpty::new( - SetTool { - id: tool.id.clone(), - set, - }, - Vec::new(), - ), - }) - }; let mut kept: Vec = Vec::with_capacity(tools.len()); for tool in tools { if offered.is_empty() { - return Err(refuse(SetRule::UnofferedSet(pack.clone()), &tool)); + return Err(error::tool_sets(format!( + "the {pack} Pack's render returned {}, a set the section did not offer the \ + {pack} Pack into; a render returns tools only for the sets the section offered \ + its Pack into", + error::tool_in_set(&tool.id, set) + ))); } if !pack.contains(&tool.id) { - return Err(refuse(SetRule::ForeignTool(pack.clone()), &tool)); + return Err(error::tool_sets(format!( + "the {pack} Pack's render returned {}; a Pack renders only tools whose id \ + starts with its own Pack id", + error::tool_in_set(&tool.id, set) + ))); } let unoffered = !offered.iter().any(|held| held.id == tool.id); if unoffered && catalog.get(&tool.id).is_some() { - return Err(refuse(SetRule::UnofferedCatalogTool, &tool)); + return Err(error::tool_sets(format!( + "the render returned {}, a run catalog tool the section did not offer in that \ + set; a render returns a catalog tool only into a set the section offered it in", + error::tool_in_set(&tool.id, set) + ))); } if !kept.iter().any(|held| held.id == tool.id) { kept.push(tool); @@ -454,20 +456,19 @@ fn refuse_clash(static_set: &[Laid], dynamic_set: &[Laid]) -> Result<()> { .iter() .map(|tool| (tool, OfferMode::Static)) .chain(dynamic_set.iter().map(|tool| (tool, OfferMode::Dynamic))); - let mut taken: BTreeMap<&str, SetTool> = BTreeMap::new(); + let mut taken: BTreeMap<&str, (&ToolId, OfferMode)> = BTreeMap::new(); for (tool, set) in sited { let wire_name = tool.binding.alias(); - let site = SetTool { - id: tool.binding.id().clone(), - set, - }; - if let Some(first) = taken.get(wire_name) { - return Err(error::tool_sets(&SetEvidence::Tools { - rule: SetRule::UniqueWireName(wire_name.to_owned()), - tools: NonEmpty::new(first.clone(), vec![site]), - })); + let id = tool.binding.id(); + if let Some(&(first_id, first_set)) = taken.get(wire_name) { + return Err(error::tool_sets(format!( + "the tools {} and {} share the wire name {wire_name:?}, and a wire name names \ + one tool across a section's Static Set and Dynamic Set; offer only one of them", + error::tool_in_set(first_id, first_set), + error::tool_in_set(id, set) + ))); } - taken.insert(wire_name, site); + taken.insert(wire_name, (id, set)); } Ok(()) } From dd86a205273ebe4fa6274b76e4d4d5ec7ec2ab48 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 08:38:05 -0800 Subject: [PATCH 02/36] Remove DepthVia from the call-depth failure `call_depth_exceeded` and `CallDepth::deeper` now take the cap's name as a `&str` instead of a `DepthVia`. The call dispatch passes `"call"`, and the spawn maps `SpawnVia::Fanout` to `"tasks.fanout"` and `SpawnVia::Spawn` to `"call"`. The message text does not change. - The failure no longer sets the `FactKey::Reason` fact. It keeps the `FactKey::Limit` fact. - The error test asserts that the `FactKey::Reason` fact is absent. --- crates/runvil-internal/engine/src/error.rs | 50 ++++--------------- .../runvil-internal/engine/src/error/tests.rs | 14 ++---- .../src/execute/scheduler/chain_record.rs | 9 ++-- .../engine/src/execute/scheduler/dispatch.rs | 3 +- .../src/execute/scheduler/tasks-spawn.rs | 5 +- .../engine/src/execute/tests/scheduler.rs | 1 - .../scheduler/fanout/failure_semantics.rs | 2 +- .../execute/tests/scheduler/walk/chains.rs | 3 +- .../execute/tests/scheduler/walk/driving.rs | 5 +- 9 files changed, 25 insertions(+), 67 deletions(-) diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index c385e18e3..77c205e6e 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -49,37 +49,6 @@ fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { error } -/// The call that passed the call-depth cap, which a `CallDepthExceeded` -/// failure names. The name is the cap's, not the author's construct: -/// `tasks.fanout` for a fanout arm, and `call` for a `call` or a -/// `tasks.spawn`, which shares its cap. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum DepthVia { - /// A `call`, or a `tasks.spawn`. - Call, - /// One arm of a `tasks.fanout`. - Fanout, -} - -impl DepthVia { - /// The failure's `Reason` fact: `call` or `fanout`. - fn tag(self) -> &'static str { - match self { - DepthVia::Call => "call", - DepthVia::Fanout => "fanout", - } - } -} - -impl std::fmt::Display for DepthVia { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - DepthVia::Call => "call", - DepthVia::Fanout => "tasks.fanout", - }) - } -} - /// An internal invariant failure: a state the surrounding code has already /// guaranteed cannot occur, surfaced rather than skipped so it cannot pass /// for a successful fall-through. Its location is the Rust source position @@ -141,18 +110,19 @@ pub(crate) fn model_required(section: &str) -> Error { } /// A `call`, `tasks.spawn`, or `tasks.fanout` arm that would run deeper -/// than `cap`, through `via`. The dispatch's answer, so an author `pcall` -/// catches it, and a quota when it ends the run. -pub(crate) fn call_depth_exceeded(cap: usize, via: DepthVia) -> Error { - let error = with_fact( +/// than `cap`. `cap_name` is the cap's name, not the author's construct: +/// `tasks.fanout` for a fanout arm, and `call` for a `call` or a +/// `tasks.spawn`, which shares its cap. The dispatch's answer, so an +/// author `pcall` catches it, and a quota when it ends the run. +pub(crate) fn call_depth_exceeded(cap: usize, cap_name: &str) -> Error { + with_fact( failure( ErrorCode::CallDepthExceeded, - format!("{via} recursion exceeded cap of {cap}"), + format!("{cap_name} recursion exceeded cap of {cap}"), ), - FactKey::Reason, - via.tag(), - ); - with_fact(error, FactKey::Limit, cap) + FactKey::Limit, + cap, + ) } /// A chain that ended while the tasks it owned were still live, which it diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index 86ab05a4a..3e6cd981d 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -193,18 +193,14 @@ fn a_spent_quota_and_the_call_depth_cap_are_quotas_that_lua_reads_as_lua() { (ErrorKind::Lua, Vec::new()) ); } - for (via, tag, text) in [ - (DepthVia::Call, "call", "call recursion exceeded cap of 8"), - ( - DepthVia::Fanout, - "fanout", - "tasks.fanout recursion exceeded cap of 8", - ), + for (cap_name, text) in [ + ("call", "call recursion exceeded cap of 8"), + ("tasks.fanout", "tasks.fanout recursion exceeded cap of 8"), ] { - let error = call_depth_exceeded(8, via); + let error = call_depth_exceeded(8, cap_name); assert_eq!(error.code, ErrorCode::CallDepthExceeded); assert_eq!(error.message, text); - assert_eq!(error.facts.get(&FactKey::Reason), Some(&Value::from(tag))); + assert_eq!(error.facts.get(&FactKey::Reason), None); assert_eq!(error.facts.get(&FactKey::Limit), Some(&Value::from(8))); assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); assert_eq!( diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index e22b345ba..503361687 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -11,7 +11,6 @@ use mlua::Thread; use runvil_types::ids::{ChainId, TaskId}; use runvil_vfs::Access; -use crate::error::DepthVia; use crate::execute::context::ChainState; use crate::execute::section_context::{SectionContext, TaskSeed}; use crate::execute::walk_target::JumpTarget; @@ -74,12 +73,12 @@ impl CallDepth { /// The depth a chain started one level deeper runs at. /// /// # Errors - /// Returns a `CallDepthExceeded` failure, named after `via`, when that - /// depth passes the cap. - pub(super) fn deeper(self, via: DepthVia) -> Result { + /// Returns a `CallDepthExceeded` failure that names the cap `cap_name` + /// when that depth passes the cap. + pub(super) fn deeper(self, cap_name: &str) -> Result { let depth = self.0 + 1; if depth > MAX_CALL_DEPTH { - return Err(error::call_depth_exceeded(MAX_CALL_DEPTH, via)); + return Err(error::call_depth_exceeded(MAX_CALL_DEPTH, cap_name)); } Ok(CallDepth(depth)) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index 36606705e..a13e7b575 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -18,7 +18,6 @@ use std::sync::Arc; use runvil_types::event::ReplyOrigin; -use crate::error::DepthVia; use crate::execute::run::Effect; use crate::execute::section_context::TaskSeed; use crate::lua::{Answer, Request, VfsOp}; @@ -151,7 +150,7 @@ impl Scheduler { var: &serde_json::Value, ) -> Result { let chain = &self.chains[id]; - let depth = chain.call_depth.deeper(DepthVia::Call)?; + let depth = chain.call_depth.deeper("call")?; // An explicit input forks the chain's args (and `argv` re-derives // from them); a no-input call inherits the caller's context whole, // so the run's frozen `argv` - H1's repair included - reaches the 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 f99c7239d..4a10178d9 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -13,7 +13,6 @@ use std::sync::atomic::AtomicU32; use runvil_types::ids::TaskId; use runvil_vfs::detail; -use crate::error::DepthVia; use crate::execute::section_context::TaskSeed; use crate::lua::{Answer, SpawnVia}; use crate::{Result, error}; @@ -77,8 +76,8 @@ impl Scheduler { ) -> Result<(TaskId, ChainIndex, SpawnRecord)> { let chain = &self.chains[id]; let depth = chain.call_depth.deeper(match via { - SpawnVia::Fanout => DepthVia::Fanout, - SpawnVia::Spawn => DepthVia::Call, + SpawnVia::Fanout => "tasks.fanout", + SpawnVia::Spawn => "call", })?; // The spawning section, captured now: the start event reports it // at admission, which may come after the spawner's walk moved to diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler.rs index 9fbfadad5..b4c6aa25e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler.rs @@ -15,7 +15,6 @@ use runvil_types::ids::AbandonReason; use super::context::Recorder; use super::*; -use crate::error::DepthVia; use crate::execute::EffectRecord; use crate::test_support::rig::RigTrace; use crate::test_support::scenario::{infer_prompt, test_binding}; diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs index f92abba65..d57d25248 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -93,7 +93,7 @@ fn fanout_depth_cap_reads_the_chain_field() { error, &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( 8, - DepthVia::Fanout + "tasks.fanout" )), "expected the typed depth-cap error named after fanout" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs index a22e97c80..87f78d92c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs @@ -253,8 +253,7 @@ fn a_jump_descent_does_not_consume_call_depth() { assert!( error == &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( - 8, - DepthVia::Call + 8, "call" )), "the depth cap must fail the run, got {error:?}" ); diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs index 419e50c26..634c04223 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs @@ -90,10 +90,7 @@ fn call_depth_cap_reads_the_chain_field() { let error = trace.error(); assert_eq!( error, - &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( - 8, - DepthVia::Call - )), + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8, "call")), "expected the typed call-depth error" ); assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); From b682c1b8e5e0a5019cc447d5080c2ca008a343e6 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 14:16:30 -0800 Subject: [PATCH 03/36] Add `place` and `coordinates` for error locations Error locations need one writer and one formatter, so this commit adds `place` and `coordinates` to `runvil_types::error`. `place` writes the prompt location of an `Error`, and `coordinates` formats a `Location` as `{file}:{line}`. `with_prompt_context` and the `ErrorCode::Internal` arm of `location` now call `coordinates`, and six new tests check both functions. - `place` keeps the location an error already has and only fills in an empty file, so the first placement wins and the innermost line stays. It never changes an `ErrorCode::Internal` error, so that error keeps its Rust location. - Both callers of `coordinates` keep their output text. `with_prompt_context` copies `file` into `at.file` before it writes `error.prose`, so `coordinates` gets the same file that the old code took from `file`. - Only the new tests call `place`. Its doc comment calls it the only writer of a prompt location, but `with_prompt_context` still writes `at.file` on every error with a location, `ErrorCode::Internal` included. - No new test checks the text that `with_prompt_context` or `location` builds. --- .../engine/src/execute/error.rs | 4 +- crates/runvil-internal/prompt/src/error.rs | 9 +-- .../runvil-internal/types/src/error-tests.rs | 66 ++++++++++++++++++- crates/runvil-internal/types/src/error.rs | 29 ++++++++ 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/crates/runvil-internal/engine/src/execute/error.rs b/crates/runvil-internal/engine/src/execute/error.rs index 0cb24e80b..3b8d36254 100644 --- a/crates/runvil-internal/engine/src/execute/error.rs +++ b/crates/runvil-internal/engine/src/execute/error.rs @@ -1,7 +1,7 @@ //! The stable [`RunErrorKind`] of a failed run, and the kind and message //! an Engine failure ends a run with. -use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::error::{ErrorCode, FactKey, coordinates}; use crate::Error; @@ -240,7 +240,7 @@ fn location(error: &Error) -> Option { _ if error.code == ErrorCode::Internal => error .at .as_ref() - .map(|at| format!("at {}:{}", at.file, at.start_line)), + .map(|at| format!("at {}", coordinates(at))), _ => None, } } diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index 1230270ed..1c89ab6dd 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -10,7 +10,7 @@ use std::num::NonZeroU32; use std::ops::Range; -use runvil_types::error::{Error, ErrorCode, FactKey, Location}; +use runvil_types::error::{Error, ErrorCode, FactKey, Location, coordinates}; use runvil_types::packs::PackId; /// The parser's result type over the universal [`Error`]. @@ -125,12 +125,7 @@ pub(crate) fn with_prompt_context(mut error: Error, name: Option<&str>, file: &s } error.prose = match (code, &error.at) { (ErrorCode::Internal, _) => code.prose().to_owned(), - (_, Some(at)) => format!( - "{file}:{}: {}. {}", - at.start_line, - error.message, - code.prose() - ), + (_, Some(at)) => format!("{}: {}. {}", coordinates(at), error.message, code.prose()), (_, None) => format!("{file}: {}. {}", error.message, code.prose()), }; error diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index c1c0a4e62..8c4c51ed5 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -1,11 +1,12 @@ -//! Tests for `ErrorCode` numbering, `Location` ranges, and the cause chain -//! `with_cause` appends. +//! Tests for `ErrorCode` numbering, `Location` ranges, the cause chain +//! `with_cause` appends, and how `place` locates an error and +//! `coordinates` formats its location. use std::collections::BTreeSet; use std::fmt; use std::num::NonZeroU32; -use super::{Error, ErrorCode, Location}; +use super::{Error, ErrorCode, Location, coordinates, place}; /// A cause with an optional cause of its own. #[derive(Debug)] @@ -317,3 +318,62 @@ fn a_line_location_covers_the_whole_line() { (NonZeroU32::MAX, nonzero(1)) ); } + +/// A runtime failure with no location. +fn runtime() -> Error { + Error::new(ErrorCode::LuaRuntime, "boom", "Fix the code.") +} + +/// The range in `file` from line 2, column 5, up to line 4, column 7. +fn span(file: &str) -> Location { + Location::new(file, nonzero(2), nonzero(5), nonzero(4), nonzero(7)) + .expect("the range ends after it starts") +} + +#[test] +fn place_keeps_an_existing_location() { + let mut error = runtime(); + error.at = Some(span("a.md")); + assert_eq!(place(error.clone(), "b.md", Some(nonzero(9))), error); +} + +#[test] +fn place_fills_an_empty_file_and_keeps_the_lines_and_columns() { + let mut error = runtime(); + error.at = Some(span("")); + let placed = place(error, "a.md", Some(nonzero(9))); + assert_eq!(placed.at, Some(span("a.md"))); +} + +#[test] +fn place_sets_a_line_location_when_there_is_none() { + let placed = place(runtime(), "a.md", Some(nonzero(7))); + assert_eq!(placed.at, Some(Location::line("a.md", nonzero(7)))); +} + +#[test] +fn place_with_no_line_and_no_location_changes_nothing() { + assert_eq!(place(runtime(), "a.md", None), runtime()); +} + +#[test] +fn place_leaves_an_internal_error_unchanged() { + let code = ErrorCode::Internal; + let unlocated = Error::new(code, "internal invariant violated: x", code.prose()); + let mut in_rust = unlocated.clone(); + in_rust.at = Some(Location::line("src/a.rs", nonzero(12))); + let mut unfiled = unlocated.clone(); + unfiled.at = Some(span("")); + for error in [unlocated, in_rust, unfiled] { + assert_eq!(place(error.clone(), "a.md", Some(nonzero(7))), error); + } +} + +#[test] +fn coordinates_formats_the_file_and_start_line() { + assert_eq!(coordinates(&span("a.md")), "a.md:2"); + assert_eq!( + coordinates(&Location::line("src/a.rs", nonzero(12))), + "src/a.rs:12" + ); +} diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 4b72d3c63..2143fea23 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -129,6 +129,35 @@ impl Location { } } +/// Places `error` in the prompt `file`. This is the only code that writes a +/// prompt location. +/// +/// An [`ErrorCode::Internal`] error comes back unchanged, so it keeps its +/// Rust location. Any other error that has a location keeps it, so the +/// first placement wins; only an empty file in it becomes `file`, since the +/// parser locates a failure before it knows the file. Otherwise a known +/// `line` becomes [`Location::line`] in `file`, and with no `line` the +/// error comes back unchanged. +#[must_use] +pub fn place(mut error: Error, file: &str, line: Option) -> Error { + if error.code == ErrorCode::Internal { + return error; + } + match &mut error.at { + Some(at) if at.file.is_empty() => file.clone_into(&mut at.file), + Some(_) => {} + None => error.at = line.map(|line| Location::line(file, line)), + } + error +} + +/// Formats `at` as `{file}:{line}`: its file and start line, with no +/// column. This is the only code that formats a location as `file:line`. +#[must_use] +pub fn coordinates(at: &Location) -> String { + format!("{}:{}", at.file, at.start_line) +} + /// The name of a fact. Each mirrors a field name in today's Lua error tables, /// apart from `Limit`, `Section`, `Chain`, and `Run`. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] From 126a8eaa7650f1e35166458986518e0eac23659d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 15:29:10 -0800 Subject: [PATCH 04/36] Locate failures that reach Lua on the author's prompt line A failure that crosses into a section's Lua is now located on the author's line that caused it, when that line is known. The VM records the author's line at each shim yield and reads the innermost author frame from the running stack, and `place` writes the location. The error text that Lua sees then starts with the prompt file and line, and an internal failure keeps its Rust location. - The call line is per-VM state, a field of the `PromptFile` app data beside the file. `start_block_coro` clears it, and the Lua wrapper that `recording_yield` builds over the coroutine `yield` sets it at each shim yield, `None` included. - A crossing failure takes the line of the innermost author frame, or else the call line, and `classified` tries the `mlua` traceback first. `place` keeps a location that a failure already has, so the first line found wins. - Each prompt block now compiles with `local __runvil_site = nil;` in front of its first line, so a main-chunk `return f()` is not a tail call. The author's frame stays on the stack, no line moves, and `source()` still returns the source as given. - `error_table` now puts `coordinates` in front of the prose in the table's `message` when the failure is located and not `Internal`, and author prose becomes `{what}. {default}`. An uncaught Lua failure's `prose` thus names no file or line, and its line is only in `at`. - `authored` now locates a crossing failure of any code but `Internal`, so a caught tool, model, store, or context failure also shows its file and line. Their prose does not change. - `map_runtime_error_among` now returns a located `Internal` or `Substitution` failure as raised, and turns every other typed failure into `LuaRuntime` except `ContextExhausted` and `LuaQuota`. `ContextExhausted` now goes through the mapping too, so both keep their own failure and take the mapped line when they have no location. - `kept_failure` and `answered_block_failure` now put the typed failure on the mapped line through `placed_like`, where they used to drop that line. An `Internal` mapping gives no line. - `LuaProgram::compile` and `classified` now build `Internal` with `error::internal` instead of `Error::new`, so it has a Rust location. `with_prompt_context` now writes `file` only into a location with no file, and `AppError::prompt_parse` adds no `line N: ` prefix for `Internal`. - Most test edits add the expected line, through the new `placed` helper and a new line argument on the `engine` helpers `refused` and `with_author_prose`. `spawn_shares_calls_target_resolution_and_raises_at_the_call_site` now puts its two calls on one line, so `tostring` still gives the same text for both. - No production code in this change calls the new public `call_line` method of `SectionVm`. Only a test reads it. - No test in this change covers the `Substitution` pass-through in `map_runtime_error_among`. The new mapping tests cover `Internal` and `ContextExhausted`. --- .../engine/src/execute/section_vm.rs | 4 +- .../engine/src/execute/tests.rs | 2 +- .../engine/src/execute/tests/batch_turn.rs | 8 +- .../engine/src/execute/tests/chat_arm.rs | 10 +- .../src/execute/tests/compaction-rules.rs | 22 ++- .../engine/src/execute/tests/compaction.rs | 6 +- .../engine/src/execute/tests/exit_rules.rs | 11 +- .../engine/src/execute/tests/full_id_calls.rs | 15 +- .../src/execute/tests/models_compact.rs | 6 +- .../execute/tests/models_loop-arguments.rs | 9 +- .../engine/src/execute/tests/models_loop.rs | 1 + .../execute/tests/models_loop_compactors.rs | 22 +-- .../src/execute/tests/models_loop_contract.rs | 8 +- .../engine/src/execute/tests/offering-sets.rs | 23 ++- .../engine/src/execute/tests/render_effect.rs | 5 +- .../src/execute/tests/rendered_tools.rs | 28 ++-- .../tests/scheduler/failures-script-tools.rs | 10 +- .../scheduler/fanout/failure_semantics.rs | 73 +++++---- .../execute/tests/scheduler/walk/chains.rs | 9 +- .../execute/tests/scheduler/walk/driving.rs | 7 +- .../src/execute/tests/section_messages.rs | 8 +- .../tests/suite/exec_flow/store_failures.rs | 24 ++- .../src/execute/tests/tasks-chain-end.rs | 4 +- .../engine/src/execute/tests/tasks.rs | 17 +- .../src/execute/tests/tool_loop-scope-gate.rs | 40 +++-- .../engine/src/execute/tests/tool_loop.rs | 15 +- .../runvil-internal/engine/src/lua/tests.rs | 4 +- .../engine/src/lua/tests/errors-tables.rs | 31 ++-- .../engine/src/lua/tests/errors.rs | 79 ++++++--- .../engine/src/test_support.rs | 30 ++-- crates/runvil-internal/lua/src/coro.rs | 36 ++++- .../lua/src/engine_globals-store.rs | 12 +- .../lua/src/error-author-tests.rs | 152 +++++++++++++++--- .../runvil-internal/lua/src/error-author.rs | 124 +++++++++----- .../lua/src/error-value-classify.rs | 69 ++++---- .../lua/src/error-value-tests.rs | 35 +++- crates/runvil-internal/lua/src/error-value.rs | 26 ++- crates/runvil-internal/lua/src/error.rs | 12 +- .../runvil-internal/lua/src/program-tests.rs | 105 +++++++++++- crates/runvil-internal/lua/src/program.rs | 133 ++++++++++----- .../lua/src/protocol/render.rs | 7 +- .../lua/src/protocol/tests/answer.rs | 28 +++- .../lua/src/protocol/tests/answer_retained.rs | 45 ++++-- crates/runvil-internal/lua/src/tests.rs | 17 +- .../lua/src/tests/diagnostics.rs | 2 +- .../runvil-internal/lua/src/tests/sandbox.rs | 2 +- .../lua/src/tests/store_errors.rs | 7 +- crates/runvil-internal/lua/src/vm.rs | 14 +- crates/runvil-internal/lua/src/vm/run.rs | 37 +++-- crates/runvil-internal/prompt/src/detail.rs | 4 +- .../runvil-internal/prompt/src/error-tests.rs | 13 ++ crates/runvil-internal/prompt/src/error.rs | 17 +- .../runvil-internal/types/src/error-prose.rs | 3 +- crates/runvil-internal/types/src/error.rs | 6 +- crates/workshop/server/src/error.rs | 11 +- .../server/src/routes/prompts-tests.rs | 24 ++- 56 files changed, 1066 insertions(+), 406 deletions(-) diff --git a/crates/runvil-internal/engine/src/execute/section_vm.rs b/crates/runvil-internal/engine/src/execute/section_vm.rs index c85ff8c62..9e35e57b4 100644 --- a/crates/runvil-internal/engine/src/execute/section_vm.rs +++ b/crates/runvil-internal/engine/src/execute/section_vm.rs @@ -76,8 +76,8 @@ pub(crate) struct SectionVmSetup<'a> { pub(crate) emitter: &'a Emitter, /// The section name used in observations and error messages. pub(crate) section_name: &'a str, - /// The file the run's prompt came from, which the author prose of the - /// VM's Lua failures names. + /// The file the run's prompt came from, which the VM's Lua failures + /// are placed in. pub(crate) file: &'a str, /// The run's tool-loop cap, as `ChainState::max_tool_iterations` /// resolves it, captured by the `models.loop` shim as its round cap. diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index da4de1cb5..8ab6cbf0b 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -24,7 +24,7 @@ use crate::test_support::scripted_chat::{ MOCK_MODEL, ScriptedCall, ScriptedChat, ScriptedReply, calls, fail, text, text_finish, }; use crate::test_support::seeded::Seeded; -use crate::test_support::{FnTool, authored, fresh_access, refused}; +use crate::test_support::{FnTool, authored, fresh_access, placed, refused}; mod context; mod fixtures; diff --git a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs index 4e0ef621f..d2ec4e70d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs +++ b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs @@ -190,7 +190,7 @@ fn a_handlers_loop_during_a_loop_raises_loop_running_as_a_lua_error() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - format!("false|lua|{}|final answer", refused(LOOP_RUNNING)) + format!("false|lua|{}|final answer", refused(19, LOOP_RUNNING)) ); assert_eq!( gateway.call_count(), @@ -209,7 +209,11 @@ fn an_overflow_compactors_loop_raises_loop_running() { return tostring(ok) .. '|' .. err.kind .. '|' .. tostring(err)", )); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), format!("false|lua|{}", refused(LOOP_RUNNING))); + assert_eq!( + trace.text(), + format!("false|lua|{}", refused(18, LOOP_RUNNING)), + "the refusal is placed on the compactor's own call" + ); assert_eq!( gateway.call_count(), 0, diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs index fcaf3cd6d..29382c733 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs @@ -199,7 +199,7 @@ fn an_out_of_scope_tool_name_fails_the_round_with_out_of_scope_tool() { .run(); assert_eq!( trace.text(), - "out_of_scope_tool|rogue|tool \"rogue\" is not in this section's scope; \ + "out_of_scope_tool|rogue|prompt:14: tool \"rogue\" is not in this section's scope; \ aliases this section offers: [\"echo\"]" ); let events = trace.events(); @@ -221,10 +221,14 @@ fn an_out_of_scope_tool_name_fails_the_round_with_out_of_scope_tool() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - // Rogue is bound nowhere, and the section offers only echo. + // Rogue is bound nowhere, and the section offers only echo. The + // refusal is placed on the loop's line. assert_eq!( trace.error(), - &crate::error::out_of_scope_tool("rogue", false, &["echo".to_owned()], false) + &placed( + crate::error::out_of_scope_tool("rogue", false, &["echo".to_owned()], false), + 14 + ) ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs b/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs index 603ea393f..74514453e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction-rules.rs @@ -27,31 +27,41 @@ fn overflowing_with(body: &str) -> String { #[test] fn each_broken_rule_raises_its_error_and_restores_the_records() { - let exhausted = format!("context_exhausted|{}", ErrorCode::ContextExhausted.prose()); - let lua = |what: &str| format!("lua|{}", refused(what)); + // A refusal of the compacted list is placed on the loop's call, prompt + // line 17, and one the compactor's own `append` raises on that call's + // line, 19. + let exhausted = format!( + "context_exhausted|prompt:17: {}", + ErrorCode::ContextExhausted.prose() + ); + let lua = |line: u32, what: &str| format!("lua|{}", refused(line, what)); let cases = [ ( "an unanswered batch", "messages:replace(5, 5, { role = 'assistant', content = '', tool_calls = { \ { id = 'c2', name = 'echo', arguments = { value = 'v' } } } })", - lua("messages[5] tool call \"c2\" has no tool result"), + lua(17, "messages[5] tool call \"c2\" has no tool result"), ), ( "a split batch", "messages:replace(4, 3, { role = 'user', content = 'between' })", - lua("messages[3] tool call \"c1\" has no tool result"), + lua(17, "messages[3] tool call \"c1\" has no tool result"), ), ( "a duplicate call id", "messages:replace(5, 5, { role = 'assistant', content = '', tool_calls = { \ { id = 'c1', name = 'echo', arguments = { value = 'v' } } } }, \ { role = 'tool', content = 'r2', tool_call_id = 'c1' })", - lua("messages[5] tool call id \"c1\" duplicates an earlier tool call"), + lua( + 17, + "messages[5] tool call id \"c1\" duplicates an earlier tool call", + ), ), ( "an orphan tool record", "messages:replace(3, 3)", lua( + 17, "messages[3] is an orphan tool record: no pending assistant tool call with id \ \"c1\"", ), @@ -61,6 +71,7 @@ fn each_broken_rule_raises_its_error_and_restores_the_records() { "messages:replace(5, 5, { role = 'user', content = 'short' }) messages:append({ role = 'system', content = 'late' })", lua( + 19, "messages[6] is a system message after a non-system message; system messages \ lead the list, and only a compactor can change the leading block", ), @@ -69,6 +80,7 @@ fn each_broken_rule_raises_its_error_and_restores_the_records() { "a non-text composed system block", "messages:replace(1, 0, { role = 'system', content = { { type = 'text', text = 'rules' } } })", lua( + 17, "messages[1] is a system message with content parts; only plain text system \ messages can be composed", ), diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction.rs b/crates/runvil-internal/engine/src/execute/tests/compaction.rs index e87796072..e3646354d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction.rs @@ -158,7 +158,7 @@ fn a_retry_that_fails_another_way_keeps_the_compacted_records() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - format!("false|lua:{}|0", refused("messages must not be empty")) + format!("false|lua:{}|0", refused(13, "messages must not be empty")) ); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -218,7 +218,7 @@ fn a_loop_inside_a_compactor_raises_loop_running() { return err.kind .. ':' .. tostring(err) .. '|' .. #messages", ); let trace = loop_scenario(source).chat(&gateway).run(); - assert_eq!(trace.text(), format!("lua:{}|1", refused(LOOP_RUNNING))); + assert_eq!(trace.text(), format!("lua:{}|1", refused(14, LOOP_RUNNING))); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -259,7 +259,7 @@ fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { trace.text(), format!( "false|2|0|false|{}", - refused(&format!("tools.offer: {FIXED}")) + refused(23, &format!("tools.offer: {FIXED}")) ) ); let bodies = gateway.requests(); 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 3e4af893c..8b8bf0e14 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -333,8 +333,9 @@ fn empty_turn_without_finish_reason_after_tool_call_fails() { #[test] fn an_empty_reply_is_readable_at_the_call_site_and_appends_nothing() { // The raise is pcall-able as the `empty_model_reply` kind holding the - // finish reason as its field and the client's phrase as its message, - // and the rejected round leaves the author's list untouched. + // finish reason as its field and the client's phrase, after the loop + // call's line, as its message, and the rejected round leaves the + // author's list untouched. let gateway = ScriptedChat::new(vec![text_finish("", "stop")]); let source = one_section( "messages:user('say nothing') @@ -346,7 +347,7 @@ fn an_empty_reply_is_readable_at_the_call_site_and_appends_nothing() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - "empty_model_reply|stop|the model replied with no text and no tool calls" + "empty_model_reply|stop|prompt:13: the model replied with no text and no tool calls" ); } @@ -369,7 +370,7 @@ fn an_empty_reply_that_ignored_reasoning_says_so_at_the_call_site() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - "empty_model_reply|the model replied with no text and no tool calls: \ + "empty_model_reply|prompt:13: the model replied with no text and no tool calls: \ reasoning content was present but ignored" ); } @@ -393,6 +394,6 @@ fn an_empty_reply_with_whitespace_only_reasoning_keeps_the_plain_phrase() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - "empty_model_reply|the model replied with no text and no tool calls" + "empty_model_reply|prompt:13: the model replied with no text and no tool calls" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs index f4b3b5cb2..60ef8390c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs +++ b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs @@ -165,8 +165,8 @@ fn a_script_call_naming_a_string_without_a_slash_raises_before_any_resolution() rig.trace().text(), format!( "lua:{}|{}", - refused("tools.call: \"tools_echo\" is not a tool id"), - refused("tools.call: \"echo\" is not a tool id") + refused(12, "tools.call: \"tools_echo\" is not a tool id"), + refused(13, "tools.call: \"echo\" is not a tool id") ) ); assert!(rig.issued().is_empty(), "nothing was issued"); @@ -216,11 +216,14 @@ fn a_name_that_is_no_catalog_id_is_unbound_and_lists_the_catalog_ids() { let error = rig.trace().error(); assert_eq!( error, - &crate::error::unbound_tool( - "tools/missing", - &["tools/concrete".to_owned(), ECHO_ID.to_owned()] + &placed( + crate::error::unbound_tool( + "tools/missing", + &["tools/concrete".to_owned(), ECHO_ID.to_owned()] + ), + 12 ), - "every catalog tool's id is listed, in id order" + "every catalog tool's id is listed, in id order, on the returning call's line" ); assert_eq!( error.to_string(), diff --git a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs index 1903d0b19..2c8bb04c8 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs @@ -84,7 +84,7 @@ fn models_compact_refuses_while_a_loop_its_handler_or_a_compactor_runs() { let trace = loop_scenario(source).chat(&gateway).run(); let expected: Vec = ["handler", "compactor", "compact"] .iter() - .map(|label| format!("{label}:false:lua:{}", refused(COMPACT_RUNNING))) + .map(|label| format!("{label}:false:lua:{}", refused(14, COMPACT_RUNNING))) .chain(["5".to_owned()]) .collect(); assert_eq!(trace.text().lines().collect::>(), expected); @@ -116,7 +116,7 @@ fn models_compact_applies_the_structural_rules_but_not_the_size_rules() { trace.text(), format!( "lua|{}|1:100000|gave up|1:100000", - refused("messages[2] tool call \"c1\" has no tool result") + refused(14, "messages[2] tool call \"c1\" has no tool result") ) ); assert_eq!(gateway.call_count(), 0, "no request leaves"); @@ -150,7 +150,7 @@ fn models_compact_checks_the_compactor_then_the_reason() { "reason must be a string, got number", "models.compact takes (compactor, reason)", ] - .map(|message| format!("false:lua:{}", refused(message))); + .map(|message| format!("false:lua:{}", refused(14, message))); let lines: Vec<&str> = trace.text().lines().collect(); assert_eq!(lines[..6], refusals); assert_eq!(lines[6], "1", "a refused call leaves the list alone"); diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs index 907332748..436d20316 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop-arguments.rs @@ -10,7 +10,8 @@ use super::*; /// refused loop call, under `pcall`, and returns the section's result: /// every error's `kind:message`, joined by `|`, then the list's size. No /// gateway reply is scripted, so a call that sends a request fails the -/// run. +/// run. Each function tail-calls the loop, which drops its frame, so every +/// refusal is placed on the line of the `pcall`. #[track_caller] fn refusals(calls: &str) -> String { let gateway = ScriptedChat::new(vec![text("unreachable")]); @@ -86,7 +87,7 @@ fn the_arity_error_counts_trailing_nils_in_both_shapes() { ); let arity = format!( "lua:{}", - refused("models.loop takes (compactor?) or (model, compactor?)") + refused(20, "models.loop takes (compactor?) or (model, compactor?)") ); assert_eq!(out, format!("{arity}|{arity}|{arity}|{arity}|{arity}|1")); } @@ -101,8 +102,8 @@ fn the_compactor_is_checked_after_the_arity() { function() return models.loop(other, 42, nil) end, function() return models.loop(other, 42) end,", ); - let arity = refused("models.loop takes (compactor?) or (model, compactor?)"); - let compactor = refused("compactor must be a function, got integer"); + let arity = refused(19, "models.loop takes (compactor?) or (model, compactor?)"); + let compactor = refused(19, "compactor must be a function, got integer"); assert_eq!( out, format!("lua:{arity}|lua:{compactor}|lua:{arity}|lua:{compactor}|1") diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs index 1c171334c..60749bdb1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs @@ -260,6 +260,7 @@ fn an_offer_after_the_first_loop_raises_and_the_second_loop_sends_the_same_empty assert_eq!( trace.text(), refused( + 14, "tools.offer: the section's tool list is fixed at its first models.loop round; \ make every offer before the first loop, or offer in another section" ) 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 5a4aacf9c..003fe36d6 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_compactors.rs @@ -26,8 +26,9 @@ fn an_omitted_compactor_defaults_to_fail_with_typed_precheck_exhaustion() { let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); assert!( - error == &crate::error::context_exhausted("precheck"), - "the omitted compactor defaults to compactors.fail with the precheck reason, got {error:?}" + error == &placed(crate::error::context_exhausted("precheck"), 13), + "the omitted compactor defaults to compactors.fail with the precheck reason, on the \ + loop's line, got {error:?}" ); assert_eq!( gateway.call_count(), @@ -49,8 +50,11 @@ fn models_loop_raises_context_exhaustion_at_the_call_site() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - runvil_types::error::ErrorCode::ContextExhausted.prose(), - "the raised error renders the typed exhaustion's prose" + format!( + "prompt:13: {}", + runvil_types::error::ErrorCode::ContextExhausted.prose() + ), + "the raised error renders the typed exhaustion's prose after the call's line" ); } @@ -69,7 +73,7 @@ fn an_explicit_compactors_fail_invocation_reports_the_provider_reason() { let trace = loop_scenario(source).chat(&gateway).run(); let error = trace.error(); assert!( - error == &crate::error::context_exhausted("provider"), + error == &placed(crate::error::context_exhausted("provider"), 13), "the explicit compactors.fail invocation reports the provider reason, got {error:?}" ); assert_eq!(gateway.call_count(), 1, "the request left and was rejected"); @@ -95,7 +99,7 @@ fn an_uncaught_compactors_fail_with_a_callers_reason_ends_the_run_as_context_exh "the run's message holds the caller's reason, got: {error}" ); assert!( - error == &crate::error::context_exhausted("manual"), + error == &placed(crate::error::context_exhausted("manual"), 13), "the reason survives as given, got {error:?}" ); assert_eq!( @@ -124,8 +128,8 @@ fn an_uncaught_direct_compactors_fail_ends_the_run_as_context_exhausted() { "the run's message holds the caller's reason, got: {error}" ); assert!( - error == &crate::error::context_exhausted("manual"), - "the reason survives as given, got {error:?}" + error == &placed(crate::error::context_exhausted("manual"), 12), + "the reason survives as given, on the call's mapped line, got {error:?}" ); assert_eq!(gateway.call_count(), 0, "no request leaves"); } @@ -159,7 +163,7 @@ fn a_non_function_compactor_is_the_calls_error_in_the_engines_type_names() { let expected: Vec = ["integer", "number", "string", "table"] .iter() .flat_map(|kind| { - let refusal = refused(&format!("compactor must be a function, got {kind}")); + let refusal = refused(15, &format!("compactor must be a function, got {kind}")); [refusal.clone(), refusal] }) .collect(); diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs index 015e58990..ad0c1bbea 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract.rs @@ -89,7 +89,7 @@ fn a_light_userdata_compactor_is_named_userdata_as_lua_names_it() { trace.text(), format!( "userdata|lua|{}|1", - refused("compactor must be a function, got userdata") + refused(16, "compactor must be a function, got userdata") ) ); assert_eq!(gateway.call_count(), 0, "no round runs"); @@ -122,7 +122,7 @@ fn the_arity_error_still_raises_when_the_author_has_rebound_error() { ); assert_eq!( trace.text(), - refused("models.loop takes (compactor?) or (model, compactor?)") + refused(14, "models.loop takes (compactor?) or (model, compactor?)") ); assert!(trace.effects().is_empty(), "no round runs"); } @@ -138,7 +138,7 @@ fn an_empty_list_raises_before_any_round() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - format!("lua:{}", refused("messages must not be empty")) + format!("lua:{}", refused(12, "messages must not be empty")) ); assert_eq!(gateway.call_count(), 0, "the refused call sends no request"); } @@ -221,7 +221,7 @@ fn a_returning_compactor_that_shrank_nothing_raises_the_exact_exhaustion_text() assert_eq!( trace.text(), format!( - "context_exhausted|{}|1", + "context_exhausted|prompt:13: {}|1", ErrorCode::ContextExhausted.prose() ) ); diff --git a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs index e4310207d..6661fff12 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs @@ -9,9 +9,10 @@ use crate::execute::tests::context::Recorder; /// What a caught refusal of a section that offers both `dup/a/b` and /// `dup/a.b`, whose default wire names are both `dup_a_b`, renders: its -/// kind and the prose of a broken tool-set rule. +/// kind and the prose of a broken tool-set rule, placed on the line of the +/// [`failing_round`] loop after a three-line section, prompt line 22. fn dup_clash() -> String { - format!("internal:{}", ErrorCode::ToolSetsInvalid.prose()) + format!("internal:prompt:22: {}", ErrorCode::ToolSetsInvalid.prose()) } /// One section's Lua that offers `offer`, then runs one loop round whose @@ -91,7 +92,7 @@ fn a_catalog_tool_whose_default_name_is_invalid_fails_only_the_sections_that_off assert_eq!( rig.trace().text(), format!( - "internal:{}|9lives/fetch: x", + "internal:prompt:22: {}|9lives/fetch: x", ErrorCode::ToolSchemaInvalid.prose() ), "the section that offers the tool fails, and a script still calls it by id" @@ -139,6 +140,7 @@ fn the_offer_hint_names_a_catalog_tool_by_its_own_wire_name_only() { assert_eq!( own, refused( + 12, "tools.offer: no Pack named \"echo\" in this run; declare it under uses: to \ require it, or offer it only when tools.packs.get(\"echo\") returns it (did you \ mean 'tools/echo'?)" @@ -162,15 +164,24 @@ fn the_scope_gate_says_a_name_exists_only_for_a_catalog_tools_own_wire_name() { .chat(&gateway) .run(); // The section holds no Dynamic Set tool and offers only gh_search, - // so the refusal differs only in whether the name exists. + // so the refusal differs only in whether the name exists. It is + // placed on the loop's line. let offered = ["gh_search".to_owned()]; let error = trace.error(); - if error == &crate::error::out_of_scope_tool(name, true, &offered, false) { + if error + == &placed( + crate::error::out_of_scope_tool(name, true, &offered, false), + 14, + ) + { true } else { assert_eq!( error, - &crate::error::out_of_scope_tool(name, false, &offered, false) + &placed( + crate::error::out_of_scope_tool(name, false, &offered, false), + 14 + ) ); false } diff --git a/crates/runvil-internal/engine/src/execute/tests/render_effect.rs b/crates/runvil-internal/engine/src/execute/tests/render_effect.rs index ecdfc167e..c24359d57 100644 --- a/crates/runvil-internal/engine/src/execute/tests/render_effect.rs +++ b/crates/runvil-internal/engine/src/execute/tests/render_effect.rs @@ -414,7 +414,10 @@ fn a_render_answer_that_breaks_the_merge_fails_its_round_and_fixes_no_tools() { }); assert_eq!( text_of(result), - format!("internal:{}|done", ErrorCode::ToolSetsInvalid.prose()) + format!( + "internal:prompt:14: {}|done", + ErrorCode::ToolSetsInvalid.prose() + ) ); assert_eq!( renders, 2, diff --git a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs index 929673e63..1f61d23f0 100644 --- a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs @@ -62,7 +62,7 @@ fn a_first_round_the_precheck_refuses_still_fixes_the_tool_list() { .run(); assert_eq!( trace.text(), - format!("lua:{}", refused(&format!("tools.offer: {FIXED}"))) + format!("lua:{}", refused(16, &format!("tools.offer: {FIXED}"))) ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "only the round on the wide model left"); @@ -146,19 +146,22 @@ fn after_the_render_every_offer_raises_even_an_identical_one_while_defining_stil ); let trace = loop_scenario(source).tool(echo_tool()).chat(&gateway).run(); let lines: Vec<&str> = trace.text().lines().collect(); - let offer = format!("lua:{}", refused(&format!("tools.offer: {FIXED}"))); + let offer = |line| format!("lua:{}", refused(line, &format!("tools.offer: {FIXED}"))); assert_eq!( lines[..5], [ - offer.clone(), - offer.clone(), - offer.clone(), - offer.clone(), - format!("lua:{}", refused(&format!("tools.offer_dynamic: {FIXED}"))), + offer(16), + offer(23), + offer(24), + offer(25), + format!( + "lua:{}", + refused(26, &format!("tools.offer_dynamic: {FIXED}")) + ), ], "a handler's offer during the loop, then an identical re-offer of a tool \ and of a Pack, an offer of a tool defined after the render, and a \ - dynamic offer" + dynamic offer, each on its own call's line" ); assert_eq!(lines.len(), 7, "{lines:?}"); assert!( @@ -220,7 +223,7 @@ fn a_first_round_refused_over_an_empty_list_renders_nothing() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - assert_eq!(trace.text(), refused("messages must not be empty")); + assert_eq!(trace.text(), refused(12, "messages must not be empty")); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "the refused round never left"); assert_eq!( @@ -254,7 +257,7 @@ fn a_first_round_refused_for_a_missing_model_renders_nothing() { .run(); assert_eq!( trace.text(), - ErrorCode::ModelRequired.prose(), + format!("prompt:13: {}", ErrorCode::ModelRequired.prose()), "a missing model keeps its remedy prose" ); let bodies = gateway.requests(); @@ -292,7 +295,10 @@ fn a_first_round_whose_tool_build_fails_renders_nothing() { .run(); assert_eq!( trace.text(), - format!("internal:{}", ErrorCode::ToolSchemaInvalid.prose()) + format!( + "internal:prompt:14: {}", + ErrorCode::ToolSchemaInvalid.prose() + ) ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "the failed round never left"); diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs index 482294b06..6fa661d42 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs @@ -56,7 +56,10 @@ fn a_script_tools_call_with_an_unbound_name_lists_the_offered_ids() { let trace = scheduler_scenario(md).offer("echo", echo_tool()).run(); assert_eq!( trace.error(), - &crate::error::unbound_tool("tools/missing", &["tools/echo".to_owned()]) + &crate::test_support::placed( + crate::error::unbound_tool("tools/missing", &["tools/echo".to_owned()]), + 13 + ) ); } @@ -96,7 +99,10 @@ fn a_script_tools_call_of_a_model_only_tool_is_refused_naming_it() { .run(); assert_eq!( trace.error(), - &crate::error::model_only_tool("tools/replace", "tools/replace") + &crate::test_support::placed( + crate::error::model_only_tool("tools/replace", "tools/replace"), + 13 + ) ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs index d57d25248..7b76776b5 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -60,41 +60,43 @@ fn fanout_worker_that_is_a_list_section_errors() { ); } +/// Alpha and Beta ping-pong calls through nested call chains, and the +/// chain that lands at depth 8 calls fanout, on prompt line 13, where each +/// arm would run one level deeper than the cap allows. +const DEPTH_FANOUT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Depth\n\n\ + ## Alpha\n\n\ + ```lua\n\ + var.n = (var.n or 0) + 1\n\ + if var.n >= 9 then return tasks.fanout('### Worker', {'x'}) end\n\ + return call('## Beta')\n\ + ```\n\n\ + ### Worker\n\n\ + ```lua\nreturn item\n```\n\n\ + ## Beta\n\n\ + ```lua\n\ + var.n = var.n + 1\n\ + return call('## Alpha')\n\ + ```\n"; + #[test] fn fanout_depth_cap_reads_the_chain_field() { - // Pin of the fanout depth-cap guard: Alpha and Beta ping-pong calls - // through nested call chains, and the chain that lands at depth 8 calls - // fanout - each arm would run one level deeper, so the cap fires from - // the requesting chain's call-depth field. The arm's spawn sets the - // fanout mark, so the spawn arm names the cap after `tasks.fanout` in the - // typed error itself; the shim re-raises that table and the retained - // typed error is substituted back at every `call` level, so the run's - // error is the typed error itself: the exact variant, the exact text, - // no runtime-error prefix or traceback. - let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ - # Depth\n\n\ - ## Alpha\n\n\ - ```lua\n\ - var.n = (var.n or 0) + 1\n\ - if var.n >= 9 then return tasks.fanout('### Worker', {'x'}) end\n\ - return call('## Beta')\n\ - ```\n\n\ - ### Worker\n\n\ - ```lua\nreturn item\n```\n\n\ - ## Beta\n\n\ - ```lua\n\ - var.n = var.n + 1\n\ - return call('## Alpha')\n\ - ```\n"; - let trace = scheduler_scenario(md).run(); + // Pin of the fanout depth-cap guard: the cap fires from the requesting + // chain's call-depth field. The arm's spawn sets the fanout mark, so + // the spawn arm names the cap after `tasks.fanout` in the typed error + // itself; the shim re-raises that table and the retained typed error + // is substituted back at every `call` level, so the run's error is the + // typed error itself: the exact variant, the exact text, no + // runtime-error prefix or traceback. + let trace = scheduler_scenario(DEPTH_FANOUT).run(); let error = trace.error(); assert_eq!( error, - &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( - 8, - "tasks.fanout" - )), + &crate::test_support::with_author_prose( + crate::error::call_depth_exceeded(8, "tasks.fanout"), + 13 + ), "expected the typed depth-cap error named after fanout" ); assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); @@ -105,6 +107,19 @@ fn fanout_depth_cap_reads_the_chain_field() { ); } +#[test] +fn an_uncaught_fanout_depth_error_lands_on_the_authors_fanout_line() { + // The refused spawn answers the fanout's yield, so the error is placed + // on that yield's call line, and every `call` level above keeps it. + let trace = scheduler_scenario(DEPTH_FANOUT).run(); + assert_eq!( + trace.error().at, + std::num::NonZeroU32::new(13) + .map(|line| runvil_types::error::Location::line("prompt", line)), + "the run's error points at Alpha's `tasks.fanout(...)` line" + ); +} + #[test] fn an_exhausted_arm_becomes_the_incomplete_stub_and_its_sibling_still_lands() { // The `tool_loop_exhausted` arm rule: one arm's `models.loop` runs its diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs index 87f78d92c..010caa7d4 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs @@ -252,10 +252,11 @@ fn a_jump_descent_does_not_consume_call_depth() { let error = rig.trace().error(); assert!( error - == &crate::test_support::with_author_prose(crate::error::call_depth_exceeded( - 8, "call" - )), - "the depth cap must fail the run, got {error:?}" + == &crate::test_support::with_author_prose( + crate::error::call_depth_exceeded(8, "call"), + 19 + ), + "the depth cap must fail the run on X's call line, got {error:?}" ); assert_eq!( rig.trace().read("depth.txt").expect("depth log"), diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs index 634c04223..87683fdb7 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs @@ -78,7 +78,8 @@ fn call_depth_cap_reads_the_chain_field() { // Two sections calling each other ping-pong through nested call // chains; the cap must fire from the requesting chain's call-depth // field. The typed error then round-trips through every parent's - // answer envelope without flattening, and classifies as a quota. + // answer envelope without flattening, keeping the innermost call's + // line, and classifies as a quota. let md = "---\nname: depth\ndescription: d\nrunvil: 0\n---\n\n\ # Depth\n\n\ ## Alpha\n\n\ @@ -90,8 +91,8 @@ fn call_depth_cap_reads_the_chain_field() { let error = trace.error(); assert_eq!( error, - &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8, "call")), - "expected the typed call-depth error" + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8, "call"), 12), + "expected the typed call-depth error on Alpha's call line" ); assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); assert_eq!(error.to_string(), "call recursion exceeded cap of 8"); diff --git a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs index c75d946fa..b09daa1bc 100644 --- a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs +++ b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs @@ -348,9 +348,13 @@ fn the_loop_entry_takes_its_two_shapes_and_checks_the_compactor_before_a_running compactor, LOOP_RUNNING, ] - .map(|message| format!("false:lua:{}", refused(message))); + .map(|message| format!("false:lua:{}", refused(15, message))); let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines[..7], expected); + assert_eq!( + lines[..7], + expected, + "each function tail-calls the loop, so every refusal lands on the `pcall` line" + ); assert_eq!( lines[7], "4", "the user message, the call, its result, and the reply" diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs index c48d9dc69..d966c2886 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs @@ -14,7 +14,7 @@ use crate::execute::run_error_kind; /// A store failure the author caught with `pcall` is an error table of /// kind `store`: its `code`, `reason`, `path`, and `anchor` fields and its /// integer `count` read from Lua, and `tostring` returns the store code's -/// remedy prose. +/// remedy prose after the failing call's `file:line`. #[test] fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { let source = one_section(&format!( @@ -25,7 +25,7 @@ fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { assert(err.code == 'not_found', 'code is not_found, got ' .. tostring(err.code)) assert(err.reason == 'not_found', 'reason is not_found, got ' .. tostring(err.reason)) assert(err.path == 'missing.txt', 'the path field names the file') - assert(tostring(err) == '{}', 'tostring is the prose, got ' .. tostring(err)) + assert(tostring(err) == 'prompt:12: {}', 'tostring is the prose, got ' .. tostring(err)) store.write('a.txt', 'na na na') local ok2, err2 = pcall(function() store.str_replace('a.txt', 'na', 'la') end) assert(ok2 == false, 'the ambiguous anchor fails') @@ -33,7 +33,7 @@ fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { assert(err2.anchor == 'na', 'the anchor field names the anchor') assert(type(err2.count) == 'number' and err2.count % 1 == 0, 'count is an integer') assert(err2.count == 3, 'count is the match count') - assert(tostring(err2) == '{}', 'tostring is the prose, got ' .. tostring(err2)) + assert(tostring(err2) == 'prompt:20: {}', 'tostring is the prose, got ' .. tostring(err2)) return 'ok'", ErrorCode::NotFound.prose(), ErrorCode::Anchor.prose() @@ -41,6 +41,24 @@ fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { assert_eq!(Scenario::new(source).run().text(), "ok"); } +/// A caught `store.read` failure names the read's own line, which the read's +/// yield recorded, not the line of the `pcall` that caught it. +#[test] +fn a_caught_store_read_failure_names_its_calls_file_and_line() { + let source = one_section( + " + local ok, err = pcall(function() + local text = store.read('missing.txt') + return text + end) + return tostring(err)", + ); + assert_eq!( + Scenario::new(source).run().text(), + format!("prompt:13: {}", ErrorCode::NotFound.prose()) + ); +} + /// A store argument type error stays kind `lua`, like every other Engine /// function's argument errors: only what the VFS reports is kind `store`. #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs index 7bca0e390..93bd5d11a 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs @@ -198,8 +198,8 @@ fn an_exhausted_tool_loop_abandons_its_running_task_as_tool_loop_exhausted() { let rig = run_abandoning_a_running_task(scenario, AbandonReason::ToolLoopExhausted); let error = rig.trace().error(); assert!( - error == &crate::error::tool_loop_exhausted(), - "the owner's own error stands: {error:?}" + error == &crate::test_support::placed(crate::error::tool_loop_exhausted(), 17), + "the owner's own error stands, on its loop's line: {error:?}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks.rs b/crates/runvil-internal/engine/src/execute/tests/tasks.rs index dffa86b47..07450a18e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks.rs @@ -236,8 +236,9 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { fn spawn_shares_calls_target_resolution_and_raises_at_the_call_site() { // An unresolvable target is the call's answer: `pcall` catches it, and // the message is exactly the one `call` produces for the same heading. + // Both calls share a line, so both are placed on it. let md = spawner_prompt( - "local ok_s, err_s = pcall(tasks.spawn, '## Missing')\n\ + "local ok_s, err_s = pcall(tasks.spawn, '## Missing'); \ local ok_c, err_c = pcall(call, '## Missing')\n\ assert(not ok_s and not ok_c, 'both resolutions fail')\n\ assert(err_s.kind == 'lua', err_s.kind)\n\ @@ -289,9 +290,12 @@ fn spawn_shares_calls_depth_cap() { ); let records = TaskRecorder::replayed(rig.trace()).records(); - let logged = authored( - ErrorCode::CallDepthExceeded, - "call recursion exceeded cap of 8", + let logged = format!( + "prompt:12: {}", + authored( + ErrorCode::CallDepthExceeded, + "call recursion exceeded cap of 8", + ) ); let refusals = records .iter() @@ -321,7 +325,10 @@ fn spawn_rejects_a_list_section_target_with_the_worker_message() { let rig = Rig::run(scheduler_scenario(md)); assert_eq!( rig.trace().text(), - refused("section `Items` is a list section, not a worker template"), + refused( + 12, + "section `Items` is a list section, not a worker template" + ), "the caught error ends the run normally" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs index 2e2c683ea..e331f3700 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs @@ -42,10 +42,13 @@ fn model_calling_an_offered_but_unscoped_tool_is_a_hard_error() { let trace = scoped_loop("").chat(&gateway).run(); let error = trace.error(); // The name is an offered tool's wire name, and the section offers no - // Dynamic Set. + // Dynamic Set. The refusal is placed on the loop's line. assert_eq!( error, - &crate::error::out_of_scope_tool("global_tool", true, &["scoped".to_owned()], false) + &placed( + crate::error::out_of_scope_tool("global_tool", true, &["scoped".to_owned()], false), + 14 + ) ); let message = error.to_string(); assert!( @@ -75,7 +78,10 @@ fn model_calling_an_advertised_tool_by_canonical_id_gets_no_not_offered_suffix() // Dynamic Set. assert_eq!( error, - &crate::error::out_of_scope_tool("tools/scoped", false, &["scoped".to_owned()], false) + &placed( + crate::error::out_of_scope_tool("tools/scoped", false, &["scoped".to_owned()], false), + 14 + ) ); let message = error.to_string(); assert!( @@ -103,7 +109,10 @@ fn model_calling_a_defined_but_unoffered_adhoc_tool_gets_no_not_offered_suffix() // offers no Dynamic Set. assert_eq!( error, - &crate::error::out_of_scope_tool("grab", false, &["scoped".to_owned()], false) + &placed( + crate::error::out_of_scope_tool("grab", false, &["scoped".to_owned()], false), + 15 + ) ); assert!( !error.to_string().contains("not offered in this section"), @@ -171,7 +180,10 @@ fn with_dynamic_offers_a_name_outside_both_sets_fails_listing_only_the_static_se let aliases = ["scoped", "GetDynamicTools", "CallDynamicTool"].map(str::to_owned); assert_eq!( trace.error(), - &crate::error::out_of_scope_tool("rogue", false, &aliases, true) + &placed( + crate::error::out_of_scope_tool("rogue", false, &aliases, true), + 18 + ) ); let message = trace.error().to_string(); assert!( @@ -221,11 +233,14 @@ fn a_static_tool_wearing_a_discovery_wire_name_does_not_claim_a_dynamic_set() { // are ordinary static aliases here. assert_eq!( error, - &crate::error::out_of_scope_tool( - "rogue", - false, - &["GetDynamicTools".to_owned(), "CallDynamicTool".to_owned()], - false + &placed( + crate::error::out_of_scope_tool( + "rogue", + false, + &["GetDynamicTools".to_owned(), "CallDynamicTool".to_owned()], + false + ), + 15 ) ); let message = error.to_string(); @@ -252,7 +267,10 @@ fn model_calling_pure_unknown_tool_is_a_hard_error() { // The name is no offered tool, and the section offers no Dynamic Set. assert_eq!( error, - &crate::error::out_of_scope_tool("nonexistent", false, &["echo".to_owned()], false) + &placed( + crate::error::out_of_scope_tool("nonexistent", false, &["echo".to_owned()], false), + 14 + ) ); assert!( !error.to_string().contains("not offered in this section"), 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 c2cd38198..bb67e6ecb 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop.rs @@ -47,7 +47,7 @@ fn tool_loop_gives_up_after_exactly_the_configured_cap() { .chat(&gateway) .run(); let error = trace.error(); - assert_eq!(error, &crate::error::tool_loop_exhausted()); + assert_eq!(error, &placed(crate::error::tool_loop_exhausted(), 15)); assert_eq!( gateway.call_count(), cap, @@ -79,7 +79,7 @@ fn tool_loop_exhaustion_is_readable_at_the_call_site_after_whole_exchanges() { assert_eq!( trace.text(), format!( - "tool_loop_exhausted|{}", + "tool_loop_exhausted|prompt:15: {}", ErrorCode::ToolLoopExhausted.prose() ) ); @@ -98,7 +98,7 @@ fn tool_loop_uses_the_default_cap_when_unspecified() { .chat(&gateway) .run(); let error = trace.error(); - assert_eq!(error, &crate::error::tool_loop_exhausted()); + assert_eq!(error, &placed(crate::error::tool_loop_exhausted(), 14)); assert_eq!(gateway.call_count(), default_cap); } @@ -227,7 +227,7 @@ fn repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap() { .chat(&gateway) .run(); let error = trace.error(); - assert_eq!(error, &crate::error::tool_loop_exhausted()); + assert_eq!(error, &placed(crate::error::tool_loop_exhausted(), 15)); assert_eq!( gateway.call_count(), cap, @@ -281,7 +281,7 @@ fn a_caught_model_failure_reads_as_the_model_failure_kind() { let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!( trace.text(), - "model_failure:the model backend reported a fault of its own (status 500)" + "model_failure:prompt:13: the model backend reported a fault of its own (status 500)" ); } @@ -296,7 +296,10 @@ fn a_caught_missing_model_reads_as_the_model_required_kind() { let trace = loop_scenario(source).models(ModelSet::default()).run(); assert_eq!( trace.text(), - format!("model_required:{}", ErrorCode::ModelRequired.prose()) + format!( + "model_required:prompt:13: {}", + ErrorCode::ModelRequired.prose() + ) ); } diff --git a/crates/runvil-internal/engine/src/lua/tests.rs b/crates/runvil-internal/engine/src/lua/tests.rs index f8f66291f..2b4410671 100644 --- a/crates/runvil-internal/engine/src/lua/tests.rs +++ b/crates/runvil-internal/engine/src/lua/tests.rs @@ -93,8 +93,8 @@ fn scheduler_vm_with_tools( }) } -/// The file every test VM's prompt comes from, which the author prose of -/// its Lua failures names. +/// The file every test VM's prompt comes from, which its Lua failures are +/// placed in. const TEST_FILE: &str = "agents/test.md"; /// What a test's section VM binds beyond an empty run's: its section, the diff --git a/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs b/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs index 1b12ac26e..2e0125560 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors-tables.rs @@ -11,9 +11,9 @@ use crate::model::ModelSet; use super::super::{TEST_FILE, compile_block, scheduler_vm}; -/// What a block's `pcall` of `models.infer` sees when the Engine answers -/// the round with `error`: the table's `kind`, the fields `fields` names, -/// and its `tostring`, joined by `|`. +/// What a block's `pcall` of `models.infer` on its first line sees when the +/// Engine answers the round with `error`: the table's `kind`, the fields +/// `fields` names, and its `tostring`, joined by `|`. fn caught_engine_failure(error: crate::Error, fields: &[&str]) -> String { let vm = scheduler_vm(&ModelSet::default(), None); let names = fields @@ -45,6 +45,12 @@ fn task(path: &str) -> TaskId { path.parse().expect("the test task id parses") } +/// `text` as a caught table's `tostring` renders it, after the line of the +/// block's `pcall`. +fn at_call(text: &str) -> String { + format!("{TEST_FILE}:1: {text}") +} + #[test] fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() { assert_eq!( @@ -54,18 +60,14 @@ fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() ), format!( "cancelled|task_cancelled|0.1|{}", - ErrorCode::TaskCancelled.prose() + at_call(ErrorCode::TaskCancelled.prose()) ) ); let leaked = crate::error::tasks_live(&[task("0.1"), task("0.2")]); - let leaked_prose = format!( - "{TEST_FILE}: {}. {}", - leaked.message, - ErrorCode::TasksLive.prose() - ); + let leaked_prose = format!("{}. {}", leaked.message, ErrorCode::TasksLive.prose()); assert_eq!( caught_engine_failure(leaked, &["code", "tasks"]), - format!("tasks_live|tasks_live|0.1, 0.2|{leaked_prose}"), + format!("tasks_live|tasks_live|0.1, 0.2|{}", at_call(&leaked_prose)), "a leak the prompt's Lua caused gets its author prose" ); assert_eq!( @@ -75,7 +77,7 @@ fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() ), format!( "context_exhausted|context_exhausted|precheck|{}", - ErrorCode::ContextExhausted.prose() + at_call(ErrorCode::ContextExhausted.prose()) ) ); let anchor = runvil_vfs::VfsError::Anchor { @@ -95,7 +97,7 @@ fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() ), format!( "store|anchor|anchor|notes.md|TODO|2|nil|{}", - ErrorCode::Anchor.prose() + at_call(ErrorCode::Anchor.prose()) ), "a store answer keeps its remedy prose" ); @@ -109,6 +111,9 @@ fn an_engine_failure_answered_through_the_envelope_is_the_table_its_code_names() .insert(FactKey::FinishReason, serde_json::Value::from("length")); assert_eq!( caught_engine_failure(empty, &["code", "finish_reason"]), - "empty_model_reply|empty_reply|length|the model replied with no text and no tool calls" + format!( + "empty_model_reply|empty_reply|length|{}", + at_call("the model replied with no text and no tool calls") + ) ); } diff --git a/crates/runvil-internal/engine/src/lua/tests/errors.rs b/crates/runvil-internal/engine/src/lua/tests/errors.rs index d81cd7f4d..15cb9f261 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors.rs @@ -13,7 +13,7 @@ use mlua::MultiValue; use runvil_lua::LuaProgram; use runvil_lua::test_support::answer_envelope; -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use crate::lua::{CoroStep, LuaBlockResult}; use crate::model::ModelSet; @@ -105,7 +105,8 @@ fn a_failure_envelope_raises_a_table_holding_the_kind_and_fields() { // A Rust-raised error answered through the envelope reaches the // author's `pcall` in the same shape as a shim raise: `kind` names the // failure, the kind's fields appear beside it, and `tostring` is the - // typed error's prose, a model failure's own remedy. + // typed error's prose, a model failure's own remedy, after the line of + // the call the answer resumed. for reason in ["provider", "manual"] { let vm = scheduler_vm(&ModelSet::default(), None); let program = compile_block( @@ -126,7 +127,10 @@ fn a_failure_envelope_raises_a_table_holding_the_kind_and_fields() { .expect("the pcall'd block resumes") { CoroStep::Done(LuaBlockResult::Returned(Some(text))) => { - assert_eq!(text, format!("table|context_exhausted|{reason}|{display}")); + assert_eq!( + text, + format!("table|context_exhausted|{reason}|{TEST_FILE}:1: {display}") + ); } other => panic!("expected the caught failure's rendering, got {other:?}"), } @@ -138,9 +142,9 @@ fn an_engine_callback_failure_caught_by_pcall_is_the_same_error_table() { // An Engine call that fails directly from Rust - no envelope, no shim // raise - reaches the author's `pcall` as the same `{ kind, message }` // table: `kind` is readable at every call site, and `tostring` is the - // author prose of the failure's own text in the VM's file (mlua's - // label and appended traceback are not part of it). The - // `sys` guard fails from a metamethod rather than a call, and + // author prose of the failure's own text after the failing line of the + // VM's file (mlua's label and appended traceback are not part of it). + // The `sys` guard fails from a metamethod rather than a call, and // `xpcall`'s handler sees the same normalized value. let vm = scheduler_vm(&test_models(), None); let program = compile_block( @@ -156,18 +160,20 @@ fn an_engine_callback_failure_caught_by_pcall_is_the_same_error_table() { assert(not ok3, "the handler runs for the callback failure") return first .. "\n" .. second .. "\n" .. third"#, ); - let prose = |what: &str| format!("{TEST_FILE}: {what}. {}", ErrorCode::LuaRuntime.prose()); + let prose = |line: u32, what: &str| { + format!( + "{TEST_FILE}:{line}: {what}. {}", + ErrorCode::LuaRuntime.prose() + ) + }; match vm.start_block_coro(&program).expect("the block runs") { CoroStep::Done(LuaBlockResult::Returned(Some(text))) => { assert_eq!( text, format!( "table|lua|{}\ntable|lua|{}\ntable|lua", - prose( - "models.get label \"missing\" is not a model role: expected default, \ - strong, small, fast or creative" - ), - prose("unknown sys field 'nothing'") + prose(1, MISSING_ROLE), + prose(4, "unknown sys field 'nothing'") ) ); } @@ -175,6 +181,37 @@ fn an_engine_callback_failure_caught_by_pcall_is_the_same_error_table() { } } +/// The refusal of `models.get("missing")`. +const MISSING_ROLE: &str = "models.get label \"missing\" is not a model role: expected default, \ + strong, small, fast or creative"; + +#[test] +fn a_callback_failure_caught_by_pcall_names_the_failing_line_not_the_pcall() { + // The line comes from the traceback mlua took as the callback failed, + // whose innermost author frame is the failing call inside the pcall'd + // function. + let vm = scheduler_vm(&test_models(), None); + let program = compile_block( + r#"local ok, err = pcall(function() + local label = "missing" + local role = models.get(label) + return role + end) + assert(not ok, "a label outside the five roles must fail") + return tostring(err)"#, + ); + match vm.start_block_coro(&program).expect("the block runs") { + CoroStep::Done(LuaBlockResult::Returned(Some(text))) => assert_eq!( + text, + format!( + "{TEST_FILE}:3: {MISSING_ROLE}. {}", + ErrorCode::LuaRuntime.prose() + ) + ), + other => panic!("expected the caught failure's rendering, got {other:?}"), + } +} + #[test] fn the_normalizing_pcall_leaves_lua_values_and_returns_unchanged() { // Only a Rust-raised failure is rewritten: an author's string error and @@ -220,7 +257,7 @@ fn an_uncaught_lua_kind_shim_raise_keeps_the_mapped_runtime_error() { // the same message with its source and the prompt line the traceback // maps to, which is what an authoring error needs. The block is // compiled at prompt line 40 and fails at chunk line 2, so the mapped - // author frame is line 41, where its author prose locates it. + // author frame is line 41, where the failure is located. let vm = scheduler_vm(&test_models(), None); let program = LuaProgram::compile( "local h = models.get(\"fast\")\nmodels.infer(h, \"a\", \"b\")", @@ -245,7 +282,7 @@ fn an_uncaught_lua_kind_shim_raise_keeps_the_mapped_runtime_error() { assert_eq!( error.prose, format!( - "{TEST_FILE}:41: models.infer takes (prompt) or (model, prompt). {}", + "models.infer takes (prompt) or (model, prompt). {}", ErrorCode::LuaRuntime.prose() ) ); @@ -281,9 +318,9 @@ fn a_typed_error_substituted_at_the_coroutine_boundary_keeps_its_kind() { panic!("the shim yield must suspend the block"); }; let quota = crate::error::lua_quota(runvil_lua::QuotaResource::LogEvent); - let mut answered = quota.clone(); + let mut answered = place(quota.clone(), TEST_FILE, NonZeroU32::new(1)); answered.prose = format!( - "{TEST_FILE}: lua log event quota exceeded. {}", + "lua log event quota exceeded. {}", ErrorCode::LuaQuota.prose() ); match vm.resume_block_coro_answer(&program, &thread, Err(quota)) { @@ -301,8 +338,8 @@ fn a_structured_raise_surfacing_as_the_coroutine_failure_keeps_its_table() { // Without a retained typed error to substitute (the envelope was // rendered ahead of time, as a Lua-side raise would be), the failure // still arrives typed: the table the shim raised reads back as the - // universal error it was rendered from, never flattened to the - // message string. + // universal error it was rendered from, placed on the call line the + // block was suspended on, never flattened to the message string. let vm = scheduler_vm(&ModelSet::default(), None); let program = compile_block("models.infer(\"hi\")"); let CoroStep::Yielded(thread, _values) = @@ -316,7 +353,11 @@ fn a_structured_raise_surfacing_as_the_coroutine_failure_keeps_its_table() { match vm.resume_block_coro(&program, &thread, envelope) { Err(error) => assert_eq!( error, - crate::error::context_exhausted("precheck"), + place( + crate::error::context_exhausted("precheck"), + TEST_FILE, + NonZeroU32::new(1) + ), "the kept table reads back as its error, the message included" ), other => panic!("expected the kept table's failure, got {other:?}"), diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index 8687d9130..80a252943 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -59,25 +59,33 @@ pub(crate) use recording::forward; #[cfg(test)] pub(crate) use tools::FnTool; -/// The author prose of a failure with `code` whose own text is `what`, -/// raised by a section of a prompt that came from no file, which is what -/// `tostring` gives for it once a `pcall` caught it. +/// The author prose of a failure with `code` whose own text is `what`. #[cfg(test)] pub(crate) fn authored(code: ErrorCode, what: &str) -> String { - format!("prompt: {what}. {}", code.prose()) + format!("{what}. {}", code.prose()) } -/// [`authored`] for a `LuaRuntime` refusal: what a caught refusal's -/// `tostring` gives. +/// What `tostring` gives for a caught `LuaRuntime` refusal whose own text +/// is `what`, placed on `line` of a prompt that came from no file: the +/// line's coordinates, then the [`authored`] prose. #[cfg(test)] -pub(crate) fn refused(what: &str) -> String { - authored(ErrorCode::LuaRuntime, what) +pub(crate) fn refused(line: u32, what: &str) -> String { + format!("prompt:{line}: {}", authored(ErrorCode::LuaRuntime, what)) } -/// `error` as it crossed into a section's Lua, of a prompt that came from -/// no file: with the [`authored`] prose of its message. +/// `error` placed on `line` of a prompt that came from no file, as it +/// arrives once it crossed into a section's Lua there. #[cfg(test)] -pub(crate) fn with_author_prose(mut error: crate::Error) -> crate::Error { +pub(crate) fn placed(error: crate::Error, line: u32) -> crate::Error { + runvil_types::error::place(error, "prompt", std::num::NonZeroU32::new(line)) +} + +/// `error` as it crossed into a section's Lua on `line` of a prompt that +/// came from no file: [`placed`] there, with the [`authored`] prose of its +/// message. +#[cfg(test)] +pub(crate) fn with_author_prose(error: crate::Error, line: u32) -> crate::Error { + let mut error = placed(error, line); error.prose = authored(error.code, &error.message); error } diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index ab6e2257f..5d7d65de3 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -6,7 +6,9 @@ //! (source in the `__impl_*.lua` files beside this one) that //! `coroutine.yield` a request table and interpret the resume values as the //! `(ok, result)` envelope; coroutine driving itself -//! (`Thread::create`/`resume`) is pure Rust in the scheduler. The source is +//! (`Thread::create`/`resume`) is pure Rust in the scheduler. Every shim +//! yields through one wrapper, which first records the author's line as the +//! VM's call line. The source is //! pulled in with `include_str!` so chunk line 1 is file line 1, compiled //! once through the usual [`LuaProgram`] machinery, and loaded per VM. The //! chunk is named with an `@` prefix, so PUC's `luaO_chunkid` renders shim @@ -66,6 +68,15 @@ const FANOUT_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_fanout.l /// a captured cap, bounds how many arms run at once. const FANOUT_SOURCE: &str = include_str!("__impl_fanout.lua"); +/// The name of the chunk that builds the shims' recording `yield`, +/// `@`-prefixed as the shim chunks' names are. +const YIELD_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/coro.rs"; + +/// The source of the shims' recording `yield`, over the `record` capture +/// and the coroutine library's own `yield`. +const YIELD_SOURCE: &str = + "local record, raw_yield = ...\nreturn function(...) record() return raw_yield(...) end"; + /// The registry key for the shim's model-issued `tool_call` form, which /// test builds stash for `install_model_tool_call_shim` to install as /// `tools.call_as_model`, so a fixture section can drive the driver's @@ -104,7 +115,9 @@ static FANOUT_PROGRAM: LazyLock> = /// test VM that never installs the shims keeps exactly /// `STRING | TABLE | MATH | UTF8`); the `coroutine` global is stripped again /// before returning, so author code cannot yield directly and a hand-rolled -/// yield fails the driver's strict validation. The `models` and `tools` +/// yield fails the driver's strict validation. All three chunks receive the +/// [`recording_yield`] over it, so every yield records its call line. The +/// `models` and `tools` /// tables are passed to the shim chunk as arguments, so the chunk never /// reads a global; the chunk shims `models.infer` and installs /// `tools.call`, and the `call` shim comes back for the Engine to install as @@ -162,7 +175,7 @@ pub(crate) fn install_shim_prelude( lua.load_std_libs(StdLib::COROUTINE).map_err(error::lua)?; let globals = lua.globals(); let coroutine: Table = globals.raw_get("coroutine").map_err(error::lua)?; - let yield_fn: Function = coroutine.raw_get("yield").map_err(error::lua)?; + let yield_fn = recording_yield(lua, coroutine.raw_get("yield").map_err(error::lua)?)?; let var_snapshot = lua .create_function(|lua, ()| var_snapshot_table(lua).map_err(mlua::Error::external)) .map_err(error::lua)?; @@ -245,6 +258,23 @@ pub(crate) fn install_shim_prelude( Ok(()) } +/// Builds the `yield` every shim chunk receives: a Lua function that +/// records the author's line as the VM's call line, the line of the +/// innermost author frame or `None`, and then yields through `raw_yield`. +/// A Rust callback cannot yield, so only the recording runs in Rust. +fn recording_yield(lua: &Lua, raw_yield: Function) -> Result { + let record = lua + .create_function(|lua, ()| { + error::set_call_line(lua, error::author_line(lua)); + Ok(()) + }) + .map_err(error::lua)?; + lua.load(YIELD_SOURCE) + .set_name(YIELD_CHUNK_NAME) + .call((record, raw_yield)) + .map_err(error::lua) +} + /// Runs the `fanout` chunk over the prelude's `yield` and `var_snapshot` /// captures and failure `helpers`, plus the collection enumerator, the item /// renderer, the two captures over the chain's current model `selection`, diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index 8412f21a2..80cb7f6e1 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -12,7 +12,7 @@ use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_vfs::{Access, VfsError}; -use crate::error::{self, Error, Result, authored_at, prompt_file}; +use crate::error::{self, Error, Result, author_prose}; use crate::error_value::store_error; use crate::program::LuaProgram; use crate::protocol::{VfsOp, VfsOutcome}; @@ -82,8 +82,7 @@ impl StoreShared { /// `operation` over the store view, records a conflict, reports /// `outcomes` (success first), and raises a failure as the store's /// failure under the message rendered for the operation `op` rebuilds - /// from the arguments, with the author prose of that message in the - /// VM's prompt file. + /// from the arguments, with the author prose of that message. fn closure( &self, lua: &Lua, @@ -99,14 +98,15 @@ impl StoreShared { let reporter = Arc::clone(&self.reporter); let conflict = Arc::clone(&self.conflict); let (success, failure) = outcomes; - lua.create_function(move |lua, arguments: A| { + lua.create_function(move |_, arguments: A| { let result = operation(&view, &arguments); record_store_conflict(&conflict, &result); reporter.report(&result, success, failure); result.map_err(|source| { let message = store_error_message(&op(arguments), &source); - let failure = store_failure(&source, message.clone()); - mlua::Error::external(authored_at(failure, &prompt_file(lua), None, &message)) + let mut failure = store_failure(&source, message); + failure.prose = author_prose(&failure.message, failure.code); + mlua::Error::external(failure) }) }) .map_err(error::lua) diff --git a/crates/runvil-internal/lua/src/error-author-tests.rs b/crates/runvil-internal/lua/src/error-author-tests.rs index 82c6df624..390761cf8 100644 --- a/crates/runvil-internal/lua/src/error-author-tests.rs +++ b/crates/runvil-internal/lua/src/error-author-tests.rs @@ -1,14 +1,18 @@ -//! Tests for author prose: its shapes with and without a line, the failure -//! text it names, the file a VM names, and which failures crossing into a -//! VM's Lua get it. +//! Tests for where a failure crossing into a VM's Lua is placed and for its +//! author prose: the prose's shape, the failure text it names, the file +//! and the call line a VM holds, the author's line on the running stack, +//! and which failures crossing into a VM's Lua get the prose. use std::num::NonZeroU32; -use mlua::Lua; -use runvil_types::error::{ErrorCode, Location}; +use mlua::{Function, Lua}; +use runvil_types::error::{ErrorCode, Location, place}; use runvil_types::ids::TaskId; -use super::{authored, authored_at, failure_text, prompt_file, set_prompt_file}; +use super::{ + author_line, authored, authored_at, call_line, failure_text, prompt_file, set_call_line, + set_prompt_file, +}; use crate::error::{internal, lua_runtime, task_not_owned, tool_loop_exhausted}; /// The line `n`. @@ -17,7 +21,7 @@ fn line(n: u32) -> NonZeroU32 { } #[test] -fn author_prose_names_the_file_and_the_line_when_it_is_known() { +fn author_prose_names_what_failed_and_the_line_becomes_the_location() { let located = authored_at( lua_runtime("the message"), "agents/a.md", @@ -26,14 +30,14 @@ fn author_prose_names_the_file_and_the_line_when_it_is_known() { ); assert_eq!( located.prose, - format!("agents/a.md:12: boom. {}", ErrorCode::LuaRuntime.prose()) + format!("boom. {}", ErrorCode::LuaRuntime.prose()) ); assert_eq!(located.at, Some(Location::line("agents/a.md", line(12)))); assert_eq!(located.message, "the message"); let unlocated = authored_at(lua_runtime("the message"), "agents/a.md", None, "boom"); assert_eq!( - unlocated.prose, - format!("agents/a.md: boom. {}", ErrorCode::LuaRuntime.prose()) + unlocated.prose, located.prose, + "the prose never names a place" ); assert_eq!(unlocated.at, None); } @@ -66,38 +70,144 @@ fn a_vm_names_the_file_it_was_given_or_prompt() { } #[test] -fn a_failure_crossing_into_lua_gets_author_prose_only_when_the_prompts_lua_caused_it() { +fn the_call_line_is_the_latest_recorded_and_none_clears_it() { let lua = Lua::new(); + assert_eq!(call_line(&lua), None); + set_call_line(&lua, Some(line(7))); + assert_eq!(call_line(&lua), Some(line(7))); + assert_eq!(prompt_file(&lua), "prompt", "a call line names no file"); set_prompt_file(&lua, "agents/a.md"); + assert_eq!(call_line(&lua), Some(line(7)), "naming the file keeps it"); + set_call_line(&lua, None); + assert_eq!(call_line(&lua), None); + assert_eq!(prompt_file(&lua), "agents/a.md"); +} + +#[test] +fn the_author_line_is_the_innermost_prompt_block_frames() { + let lua = Lua::new(); + let probe = lua + .create_function(|lua, ()| Ok(author_line(lua).map(NonZeroU32::get))) + .expect("the probe builds"); + lua.globals() + .set("probe", probe) + .expect("the probe installs"); + let helper: Function = lua + .load("return function() return probe() end") + .set_name("@helper.lua") + .eval() + .expect("the helper builds"); + lua.globals() + .set("helper", helper) + .expect("the helper installs"); + let (direct, through_helper, nested): (u32, u32, u32) = lua + .load("local x = 1\nreturn probe(), helper(), (function()\n return probe()\nend)()") + .set_name("line 40") + .eval() + .expect("the block runs"); + assert_eq!( + (direct, through_helper, nested), + (41, 41, 42), + "a frame of another chunk is skipped, and a nested function reports its own line" + ); + let unnamed: Option = lua + .load("return probe()") + .set_name("probe") + .eval() + .expect("the probe chunk runs"); + assert_eq!(unnamed, None, "a chunk that is no prompt block has no line"); + assert_eq!(author_line(&lua), None, "no Lua frame runs outside a call"); +} + +#[test] +fn a_failure_crossing_into_lua_is_placed_and_gets_author_prose_when_the_prompts_lua_caused_it() { + let lua = Lua::new(); + set_prompt_file(&lua, "agents/a.md"); + set_call_line(&lua, Some(line(9))); + let call = Some(Location::line("agents/a.md", line(9))); let refusal = authored(&lua, lua_runtime("messages must not be empty")); assert_eq!( refusal.prose, format!( - "agents/a.md: messages must not be empty. {}", + "messages must not be empty. {}", ErrorCode::LuaRuntime.prose() ) ); + assert_eq!( + refusal.at, call, + "outside any Lua call, the call line places it" + ); assert_eq!(refusal.message, "messages must not be empty"); let task: TaskId = "0.1".parse().expect("the test task id parses"); assert_eq!( authored(&lua, task_not_owned(&task)).prose, format!( - "agents/a.md: task `0.1` is not a task this chain owns. {}", + "task `0.1` is not a task this chain owns. {}", ErrorCode::TaskNotOwned.prose() ) ); + let exhausted = authored(&lua, tool_loop_exhausted()); assert_eq!( - authored(&lua, tool_loop_exhausted()), - tool_loop_exhausted(), + exhausted.prose, + tool_loop_exhausted().prose, "a tool failure keeps its remedy prose" ); - let located = authored_at(lua_runtime("boom"), "agents/a.md", Some(line(4)), "boom"); + assert_eq!(exhausted.at, call, "and is placed all the same"); + let fault = internal("a test invariant broke"); assert_eq!( - authored(&lua, located.clone()), - located, - "a located failure keeps its prose" + authored(&lua, fault.clone()), + fault, + "an internal failure keeps its Rust location" ); - let fault = internal("a test invariant broke"); - assert_eq!(authored(&lua, fault.clone()), fault); assert_eq!(fault.prose, ErrorCode::Internal.prose()); } + +#[test] +fn an_authored_failure_placed_before_it_crosses_keeps_its_place_and_gets_author_prose() { + let lua = Lua::new(); + set_prompt_file(&lua, "agents/a.md"); + set_call_line(&lua, Some(line(9))); + let placed = place(lua_runtime("boom"), "agents/a.md", Some(line(4))); + assert_eq!(placed.prose, ErrorCode::LuaRuntime.prose()); + let crossed = authored(&lua, placed); + assert_eq!( + crossed.at, + Some(Location::line("agents/a.md", line(4))), + "the first placement wins" + ); + assert_eq!( + crossed.prose, + format!("boom. {}", ErrorCode::LuaRuntime.prose()) + ); + assert_eq!( + authored(&lua, crossed.clone()), + crossed, + "crossing again changes nothing" + ); +} + +#[test] +fn a_failure_crossing_inside_a_callback_is_placed_on_the_author_frames_line() { + let lua = Lua::new(); + set_call_line(&lua, Some(line(9))); + let cross = lua + .create_function(|lua, ()| { + Ok(authored(lua, lua_runtime("boom")) + .at + .map(|at| at.start_line.get())) + }) + .expect("the callback builds"); + lua.globals() + .set("cross", cross) + .expect("the callback installs"); + let at: Option = lua + .load("local x = 1\nreturn (cross())") + .set_name("line 20") + .eval() + .expect("the block runs"); + assert_eq!( + at, + Some(21), + "the running author frame wins over the call line" + ); +} diff --git a/crates/runvil-internal/lua/src/error-author.rs b/crates/runvil-internal/lua/src/error-author.rs index 7fbafceee..8de4a2e4d 100644 --- a/crates/runvil-internal/lua/src/error-author.rs +++ b/crates/runvil-internal/lua/src/error-author.rs @@ -1,38 +1,90 @@ -//! Author prose: the text the model reads for a failure the prompt's own -//! Lua caused, which names the prompt's file, and the line when it is -//! known, before what failed and the code's default prose. The file is the -//! label the VM holds for the prompt its Lua came from. +//! Where a failure crossing into a VM's Lua is placed, and its author +//! prose. The place is a line of the file the VM holds for the prompt its +//! Lua came from: the line of the innermost author frame running, or else +//! the call line, the author's line at the block's latest yield. Author +//! prose is the text the model reads for a failure the prompt's own Lua +//! caused: what failed, then the code's default prose. use std::num::NonZeroU32; use mlua::Lua; -use runvil_types::error::{Error, ErrorCode, Location}; +use runvil_types::error::{Error, ErrorCode, place}; + +use crate::program::prompt_line; /// The file label of a VM that was given none: the label a prompt that /// came from no file has. pub(crate) const UNNAMED_FILE: &str = "prompt"; -/// The file the prompt a VM runs came from, held as the VM's app data. -struct PromptFile(String); +/// Where a VM's failures are placed, held as the VM's app data. +struct PromptFile { + /// The file the prompt the VM runs came from. + file: String, + /// The author's line at the running block's latest yield. + call_line: Option, +} /// Records `file` as the file the prompt this VM runs came from, which -/// the author prose of its failures names. +/// its failures are placed in. pub(crate) fn set_prompt_file(lua: &Lua, file: &str) { - lua.set_app_data(PromptFile(file.to_owned())); + if let Some(mut held) = lua.app_data_mut::() { + file.clone_into(&mut held.file); + return; + } + lua.set_app_data(PromptFile { + file: file.to_owned(), + call_line: None, + }); } /// The file the prompt this VM runs came from, or `prompt` when the VM /// was given none. pub(crate) fn prompt_file(lua: &Lua) -> String { lua.app_data_ref::() - .map_or_else(|| UNNAMED_FILE.to_owned(), |file| file.0.clone()) + .map_or_else(|| UNNAMED_FILE.to_owned(), |held| held.file.clone()) +} + +/// Records `line` as this VM's call line, `None` included, so the line of +/// an earlier yield never outlives the one it belongs to. +pub(crate) fn set_call_line(lua: &Lua, line: Option) { + if let Some(mut held) = lua.app_data_mut::() { + held.call_line = line; + return; + } + lua.set_app_data(PromptFile { + file: UNNAMED_FILE.to_owned(), + call_line: line, + }); +} + +/// The author's line at the latest yield of the block this VM runs, which +/// holds while the block is suspended on that yield. +pub(crate) fn call_line(lua: &Lua) -> Option { + lua.app_data_ref::() + .and_then(|held| held.call_line) +} + +/// The prompt line of the innermost frame on the running Lua stack whose +/// chunk is a prompt block, `line N`, skipping every other frame: a +/// shim's, a Pack prelude's, a Rust callback's. `None` when no such frame +/// runs, as when the caller renders an answer outside any Lua call. +pub(crate) fn author_line(lua: &Lua) -> Option { + (0..) + .map_while(|level| { + lua.inspect_stack(level, |frame| { + let chunk_name = frame.source().source?; + let line = u32::try_from(frame.current_line()?).ok()?; + prompt_line(&chunk_name, line) + }) + }) + .flatten() + .next() } /// Whether a failure with `code` is one the prompt's Lua causes as it -/// runs, whose prose names where: a Lua failure, an exhausted quota, the -/// call-depth cap, a failed substitution, or a misused task. A store -/// failure is one only when a direct `store` closure raised it, which -/// gives it its prose itself. +/// runs, whose prose names what failed: a Lua failure, an exhausted quota, +/// the call-depth cap, a failed substitution, or a misused task. A direct +/// `store` closure gives its own failure that prose itself. fn is_authored(code: ErrorCode) -> bool { matches!( code, @@ -47,39 +99,31 @@ fn is_authored(code: ErrorCode) -> bool { ) } -/// The prose of a failure with `code` in `file`: `{file}:{line}: {what}. -/// {default}` when the line is known, and `{file}: {what}. {default}` -/// otherwise, where `what` is what failed and `default` the code's prose. -fn author_prose(file: &str, line: Option, what: &str, code: ErrorCode) -> String { - match line { - Some(line) => format!("{file}:{line}: {what}. {}", code.prose()), - None => format!("{file}: {what}. {}", code.prose()), - } +/// The author prose of a failure with `code`: `{what}. {default}`, where +/// `what` is what failed and `default` the code's prose. +pub(crate) fn author_prose(what: &str, code: ErrorCode) -> String { + format!("{what}. {}", code.prose()) } -/// Gives `error` the author prose for `what` in `file`, and, when `line` -/// is known, the location of that line of `file`. -pub(crate) fn authored_at( - mut error: Error, - file: &str, - line: Option, - what: &str, -) -> Error { - error.at = line.map(|line| Location::line(file, line)); - error.prose = author_prose(file, line, what, error.code); +/// Places `error` on `line` of `file`, and gives a failure the prompt's +/// Lua caused whose prose is still its code's default the author prose +/// for `what`. +pub(crate) fn authored_at(error: Error, file: &str, line: Option, what: &str) -> Error { + let mut error = place(error, file, line); + if is_authored(error.code) && error.prose == error.code.prose() { + error.prose = author_prose(what, error.code); + } error } -/// `error` as it crosses into this VM's Lua: a failure the prompt's Lua -/// caused that has no location yet gets the author prose of its message's -/// own text in the VM's file; a located one already has its prose, and -/// every other failure keeps its own. +/// `error` as it crosses into this VM's Lua: placed in the VM's file on +/// the line of the innermost author frame running, or else on the call +/// line, with the author prose of its message's own text when the +/// prompt's Lua caused it. pub(crate) fn authored(lua: &Lua, error: Error) -> Error { - if error.at.is_some() || !is_authored(error.code) { - return error; - } + let line = author_line(lua).or_else(|| call_line(lua)); let what = failure_text(&error.message).to_owned(); - authored_at(error, &prompt_file(lua), None, &what) + authored_at(error, &prompt_file(lua), line, &what) } /// The failure's own text in `message`: the text before the traceback, diff --git a/crates/runvil-internal/lua/src/error-value-classify.rs b/crates/runvil-internal/lua/src/error-value-classify.rs index bfce35890..563b36123 100644 --- a/crates/runvil-internal/lua/src/error-value-classify.rs +++ b/crates/runvil-internal/lua/src/error-value-classify.rs @@ -1,13 +1,17 @@ //! The classifier a Rust callback's failure runs through on its way to //! author code, and the `normalize_failure` capture built on it. +use std::num::NonZeroU32; + use mlua::{Function, Lua, Value}; -use runvil_types::error::{Error, ErrorCode}; +use runvil_types::error::Error; use super::error_table; use crate::error::{ - authored, authored_at, failure_text, lua_runtime, prompt_file, root_cause, root_text, + author_line, authored_at, call_line, failure_text, internal, lua_runtime, prompt_file, + root_cause, root_text, }; +use crate::program::first_prompt_line; /// A Rust callback's failure as the universal error its table renders. /// @@ -21,35 +25,42 @@ use crate::error::{ /// an exhausted memory quota) is `LuaRuntime`, with the root error's text /// as its message and the author prose of what failed; anything else is /// mlua's own machinery failing, which the author cannot act on, so it is -/// `Internal`. A universal failure the prompt's Lua caused gets its author -/// prose. +/// `Internal`, at its Rust line. Any other failure is placed on the line of +/// the failing call, which the traceback mlua took as the callback failed +/// names; when that names no author frame, on the line of the innermost +/// author frame running, or else on the call line. A universal failure the +/// prompt's Lua caused gets its author prose. fn classified(lua: &Lua, error: &mlua::Error) -> Error { - if let Some(typed) = error.downcast_ref::() { - return authored(lua, typed.clone()); - } - let root = root_cause(error); - match root { - mlua::Error::RuntimeError(_) - | mlua::Error::SyntaxError { .. } - | mlua::Error::MemoryError(_) - | mlua::Error::BadArgument { .. } - | mlua::Error::FromLuaConversionError { .. } - | mlua::Error::SerializeError(_) - | mlua::Error::DeserializeError(_) - | mlua::Error::ExternalError(_) => { - let what = root_text(error); - authored_at( - lua_runtime(root.to_string()), - &prompt_file(lua), - None, - failure_text(&what), - ) + let (failure, what) = match error.downcast_ref::() { + Some(typed) => (typed.clone(), typed.message.clone()), + None => match root_cause(error) { + root @ (mlua::Error::RuntimeError(_) + | mlua::Error::SyntaxError { .. } + | mlua::Error::MemoryError(_) + | mlua::Error::BadArgument { .. } + | mlua::Error::FromLuaConversionError { .. } + | mlua::Error::SerializeError(_) + | mlua::Error::DeserializeError(_) + | mlua::Error::ExternalError(_)) => (lua_runtime(root.to_string()), root_text(error)), + root => return internal(root.to_string()), + }, + }; + let line = traceback_line(error) + .or_else(|| author_line(lua)) + .or_else(|| call_line(lua)); + authored_at(failure, &prompt_file(lua), line, failure_text(&what)) +} + +/// The prompt line of the innermost author frame in the traceback mlua +/// took as a callback in `error` failed: the innermost callback's that +/// names one. Read before [`root_cause`] drops the tracebacks. +fn traceback_line(error: &mlua::Error) -> Option { + match error { + mlua::Error::CallbackError { traceback, cause } => { + traceback_line(cause).or_else(|| first_prompt_line(traceback)) } - _ => Error::new( - ErrorCode::Internal, - root.to_string(), - ErrorCode::Internal.prose(), - ), + mlua::Error::WithContext { cause, .. } => traceback_line(cause), + _ => None, } } diff --git a/crates/runvil-internal/lua/src/error-value-tests.rs b/crates/runvil-internal/lua/src/error-value-tests.rs index 41fafb772..30ad09502 100644 --- a/crates/runvil-internal/lua/src/error-value-tests.rs +++ b/crates/runvil-internal/lua/src/error-value-tests.rs @@ -1,12 +1,41 @@ //! Tests for the error value's kind vocabulary: its tags parse back, and a //! rejected tag's error names every accepted one. Also the universal error a -//! store failure becomes: its code, its facts, and its texts. +//! store failure becomes: its code, its facts, and its texts; and the +//! message a table rendered from a universal error holds. -use runvil_types::error::{ErrorCode, FactKey}; +use std::num::NonZeroU32; + +use runvil_types::error::{Error, ErrorCode, FactKey, place}; use runvil_vfs::{PathReason, VfsError}; use serde_json::Value; -use super::{ErrorKind, store_error, store_error_value_fields}; +use super::{ErrorKind, error_table, store_error, store_error_value_fields}; +use crate::error::{internal, lua_runtime}; + +#[test] +fn a_tables_message_puts_a_prompt_location_and_never_a_rust_one_before_the_prose() { + let lua = mlua::Lua::new(); + let message = |error: &Error| -> String { + error_table(&lua, error) + .expect("the table builds") + .raw_get("message") + .expect("the message reads") + }; + let unlocated = lua_runtime("boom"); + assert_eq!(message(&unlocated), unlocated.prose); + let located = place(unlocated.clone(), "agents/a.md", NonZeroU32::new(7)); + assert_eq!( + message(&located), + format!("agents/a.md:7: {}", located.prose) + ); + let fault = internal("a test invariant broke"); + assert!(fault.at.is_some(), "an internal failure is located in Rust"); + assert_eq!( + message(&fault), + fault.prose, + "an internal failure's text shows no coordinates" + ); +} #[test] fn every_error_kind_tag_parses_back_to_its_kind() { diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index b5f4b659a..10056eca4 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -6,10 +6,12 @@ //! guard) caught by `pcall`, or a shim raise such as //! `tool_loop_exhausted` - is one Lua table `{ kind, code, message, ... }` //! under a shared metatable whose `__tostring` returns `message`. The -//! message is the failure's prose, the text an author may hand a model; a -//! caller that branches reads `kind`, `code`, the stable tag of the -//! failure's [`ErrorCode`], and the kind's own fields (`reason` for -//! `context_exhausted`, `finish_reason` for `empty_model_reply`). +//! message is the failure's prose, the text an author may hand a model, +//! which a table rendered from a universal error placed on a prompt line +//! puts after that line's `file:line`; a caller that branches reads +//! `kind`, `code`, the stable tag of the failure's [`ErrorCode`], and the +//! kind's own fields (`reason` for `context_exhausted`, `finish_reason` +//! for `empty_model_reply`). //! //! [`ErrorKind`] names the closed vocabulary of kinds; [`error_kind`] and //! [`error_fields`] render a universal error into the shape; @@ -35,7 +37,7 @@ use std::str::FromStr; use mlua::{AnyUserData, Function, Lua, Table, Value}; -use runvil_types::error::{Error, ErrorCode, FactKey}; +use runvil_types::error::{Error, ErrorCode, FactKey, coordinates}; use crate::error::accepted_tags; @@ -281,15 +283,23 @@ fn finish_table(lua: &Lua, kind: ErrorKind, code: ErrorCode, fields: Table) -> m } /// Renders a universal error as the Lua error table: the `kind` -/// [`error_kind`] names, the error's code, its prose as the `message`, and -/// the [`error_fields`] beside them, under the shared metatable. The table +/// [`error_kind`] names, the error's code, its `message`, and the +/// [`error_fields`] beside them, under the shared metatable. The `message` +/// is the prose, after `{file}:{line}: ` when the error is located and not +/// internal, since an internal failure's location is in Rust. The table /// keeps `error` beside it, for [`retained_error`]. /// /// # Errors /// Returns an `mlua` error if the table cannot be created on `lua`. pub(crate) fn error_table(lua: &Lua, error: &Error) -> mlua::Result { let table = lua.create_table()?; - table.raw_set("message", error.prose.as_str())?; + let message = match &error.at { + Some(at) if error.code != ErrorCode::Internal => { + format!("{}: {}", coordinates(at), error.prose) + } + _ => error.prose.clone(), + }; + table.raw_set("message", message)?; for (name, value) in error_fields(error) { let value = match value { ErrorField::String(value) => Value::String(lua.create_string(value)?), diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index dab8e1b0d..ff8cc80bf 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -4,9 +4,10 @@ //! here, which the Engine shares for the failures it raises itself, so //! each failure has one text. A failure raised through Lua crosses the //! `mlua` boundary as a typed value, `mlua::Error::external(error)`, and -//! the boundary that reads it back downcasts to [`Error`]. A failure the -//! prompt's own Lua caused gets author prose that names the prompt's file, -//! which the VM holds. +//! the boundary that reads it back downcasts to [`Error`]. A failure that +//! crosses into Lua is placed on the author's line of the prompt's file, +//! which the VM holds, and one the prompt's own Lua caused gets author +//! prose that names what failed. use std::borrow::Cow; @@ -19,7 +20,10 @@ mod universal; #[cfg(any(test, feature = "test-support"))] pub(crate) use author::UNNAMED_FILE; -pub(crate) use author::{authored, authored_at, failure_text, prompt_file, set_prompt_file}; +pub(crate) use author::{ + author_line, author_prose, authored, authored_at, call_line, failure_text, prompt_file, + set_call_line, set_prompt_file, +}; pub use universal::{ context_exhausted, internal_at, interrupted, lua_quota, lua_runtime, task_cancelled, task_consumed, task_not_owned, tool_loop_exhausted, diff --git a/crates/runvil-internal/lua/src/program-tests.rs b/crates/runvil-internal/lua/src/program-tests.rs index 57e31349f..96a600cd7 100644 --- a/crates/runvil-internal/lua/src/program-tests.rs +++ b/crates/runvil-internal/lua/src/program-tests.rs @@ -1,5 +1,6 @@ -//! Tests for the compile path's location and the runtime mapping's -//! location and author prose, a quota refusal's among them. +//! Tests for the compile path's location, the runtime mapping's location +//! and author prose, a quota refusal's among them, the typed failures the +//! mapping keeps, and the prompt line a block's chunk position names. use std::num::NonZeroU32; @@ -8,8 +9,8 @@ use runvil_types::emitter::{Emitter, EventSink}; use runvil_types::error::{ErrorCode, FactKey, Location}; use serde_json::Value; -use super::{LuaProgram, compile_line, map_chunk_line_to_absolute}; -use crate::error::{QuotaResource, lua_quota}; +use super::{LuaProgram, compile_line, first_prompt_line, map_chunk_line_to_absolute, prompt_line}; +use crate::error::{QuotaResource, context_exhausted, internal, lua_quota}; /// The file every program here comes from. const FILE: &str = "agents/a.md"; @@ -87,12 +88,25 @@ fn a_compile_failure_is_located_on_its_diagnostics_line_with_author_prose() { assert_eq!( error.prose, format!( - "{FILE}:12: unexpected symbol near . {}", + "unexpected symbol near . {}", ErrorCode::LuaCompile.prose() ) ); } +#[test] +fn a_block_keeps_its_source_and_its_lines_behind_the_frame_guard() { + let source = "local x = 1\nreturn x"; + let program = compiled(source, 30); + assert_eq!(program.source(), source); + let lua = Lua::new(); + let raw = raised(&compiled("local x = 1\nerror('boom')", 30), &lua); + assert!( + raw.to_string().contains("[string \"line 30\"]:2: boom"), + "the guard adds no line: {raw}" + ); +} + #[test] fn a_runtime_failure_is_located_on_its_mapped_line_with_author_prose() { let program = compiled("local x = 1\nerror('boom')", 50); @@ -106,8 +120,85 @@ fn a_runtime_failure_is_located_on_its_mapped_line_with_author_prose() { assert_eq!(mapped.at, Some(Location::line(FILE, line(51)))); assert_eq!( mapped.prose, - format!("{FILE}:51: boom. {}", ErrorCode::LuaRuntime.prose()) + format!("boom. {}", ErrorCode::LuaRuntime.prose()) + ); +} + +/// The failure running `program` raises in a Lua VM whose global `fail` +/// raises `error` from Rust. +fn raised_through(program: &LuaProgram, error: &runvil_types::error::Error) -> mlua::Error { + let lua = Lua::new(); + let raise = error.clone(); + let fail = lua + .create_function(move |_, ()| -> mlua::Result<()> { + Err(mlua::Error::external(raise.clone())) + }) + .expect("the callback builds"); + lua.globals() + .set("fail", fail) + .expect("the global installs"); + raised(program, &lua) +} + +#[test] +fn a_raised_internal_failure_keeps_its_rust_location_through_the_mapping() { + let fault = internal("a test invariant broke"); + assert!(fault.at.is_some(), "an internal failure is located in Rust"); + let program = compiled("local x = 1\nfail()", 5); + let mapped = program.map_runtime_error_among(&raised_through(&program, &fault), &[], FILE); + assert_eq!( + mapped, fault, + "it is not recoded at the line that raised it" + ); +} + +#[test] +fn a_raised_context_exhaustion_is_placed_on_its_mapped_line() { + let exhausted = context_exhausted("precheck"); + let program = compiled("local x = 1\nfail()", 5); + let mapped = program.map_runtime_error_among(&raised_through(&program, &exhausted), &[], FILE); + assert_eq!( + (mapped.code, &mapped.message, &mapped.prose), + (exhausted.code, &exhausted.message, &exhausted.prose), + "it keeps its own failure and its prose" + ); + assert_eq!(mapped.at, Some(Location::line(FILE, line(6)))); +} + +#[test] +fn a_prompt_line_is_named_only_by_a_prompt_blocks_chunk() { + assert_eq!(prompt_line("line 40", 3), Some(line(42))); + assert_eq!(prompt_line("line 40", 0), Some(line(39))); + for other in [ + "line 0", + "line +4", + "line ", + "@pack:web", + "probe", + "lines 4", + ] { + assert_eq!(prompt_line(other, 3), None, "{other:?}"); + } + assert_eq!( + prompt_line(&format!("line {}", u32::MAX), 2), + None, + "an overflowing line is none" + ); +} + +#[test] +fn the_first_prompt_position_skips_function_markers_and_other_chunks() { + let traceback = "stack traceback:\n\t[C]: in ?\n\ + \t@crates/runvil-internal/lua/src/__impl_coro.lua:91: in function 'pcall'\n\ + \t[string \"probe\"]:3: in function <[string \"line 7\"]:2>\n\ + \t[string \"line 7\"]:4: in main chunk"; + assert_eq!(first_prompt_line(traceback), Some(line(10))); + assert_eq!( + first_prompt_line("in function <[string \"line 7\"]:2>"), + None, + "a function marker is no position" ); + assert_eq!(first_prompt_line("[string \"line 7\"]:: x"), None); } #[test] @@ -130,7 +221,7 @@ fn a_quota_refusal_raised_through_lua_is_recognized_at_the_boundary() { assert_eq!( mapped.prose, format!( - "{FILE}:8: lua log event quota exceeded. {}", + "lua log event quota exceeded. {}", ErrorCode::LuaQuota.prose() ) ); diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index 91e4671d6..65499ba56 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -11,6 +11,13 @@ use crate::error::{ self, Error, Result, authored_at, failure_text, lua_compile, lua_runtime, root_text, }; +/// The statement every prompt block compiles behind, on its first line so +/// no line moves. Lua compiles no tail call inside a to-be-closed +/// variable's scope, so a main chunk's `return f()` keeps the author's +/// frame on the stack for the call line to be read from; a nil +/// to-be-closed value is never closed. +const FRAME_GUARD: &str = "local __runvil_site = nil; "; + /// The fixed temporary VM a chunk compiles in. fn compiler() -> mlua::Result { Lua::new_with( @@ -76,17 +83,20 @@ impl LuaProgram { /// itself compiles under the name `line {source_line}`, which Lua /// renders as `[string "line 120"]`: no two blocks of one prompt start /// on the same line, and the name never reaches the length at which Lua - /// cuts it. Compilation reports contain only fixed strings and never - /// include `source` or `location`; each is stamped with the emitter's - /// coordinates. + /// cuts it. The chunk compiles behind a statement on its first line + /// that keeps a main-chunk tail call from dropping the author's frame; + /// [`source`](Self::source) is the source as given. Compilation reports + /// contain only fixed strings and never include `source` or `location`; + /// each is stamped with the emitter's coordinates. /// /// # Errors /// Returns a `LuaCompile` failure when `source` is not syntactically /// valid: its message names the region and holds the Lua diagnostic, - /// its `Section` fact names the region, and it is located on the line - /// of `file` the diagnostic names, which its author prose names too. - /// Returns an `Internal` failure, with the `mlua` error's text, if the - /// temporary compiler VM cannot be created. + /// its `Section` fact names the region, it is located on the line of + /// `file` the diagnostic names, and its author prose names what failed. + /// Returns an `Internal` failure located at this crate's Rust source, + /// whose message holds the `mlua` error's text, if the temporary + /// compiler VM cannot be created. pub fn compile( source: &str, file: &str, @@ -98,13 +108,11 @@ impl LuaProgram { emitter.report(section, lifecycle::LUA_COMPILATION_STARTED); let chunk_name = format!("line {source_line}"); + let guarded = format!("{FRAME_GUARD}{source}"); let compiled = compiler() - .map_err(|error| { - let code = ErrorCode::Internal; - Error::new(code, error.to_string(), code.prose()) - }) + .map_err(|error| error::internal(error.to_string())) .and_then(|lua| { - compile_chunk(&lua, source, &chunk_name).map_err(|error| { + compile_chunk(&lua, &guarded, &chunk_name).map_err(|error| { let diagnostic = match &error { mlua::Error::SyntaxError { message, .. } => message.clone(), other => other.to_string(), @@ -189,19 +197,21 @@ impl LuaProgram { /// that ran beside `loaded`, the programs of the libraries loaded into /// its VM, from the prompt file `file`. /// - /// A typed context exhaustion, such as an uncaught direct - /// `compactors.fail` call's, returns as it was raised. An Engine quota - /// refusal, recognized by its typed value, returns its `LuaQuota` - /// failure, and every other failure, other typed failures included, - /// returns a `LuaRuntime` failure with each chunk-relative line - /// rewritten to an absolute prompt-source line by the program that owns - /// that chunk, this one first when two share a chunk name. Nested errors - /// from other chunks (for example a fanout arm) are left unchanged. - /// Either is located on the first line the mapping rewrote, of `file`, - /// and its author prose names that line and the failure's own text. - /// Cancellation is the VM's to classify: a chunk the instruction hook - /// aborted under the run's cancel flag is an `Interrupted` failure - /// before the raw error reaches here. + /// A typed internal or substitution failure that already has a + /// location returns as it was raised, so an internal failure keeps its + /// Rust line. A typed context exhaustion, such as an uncaught direct + /// `compactors.fail` call's, and an Engine quota refusal, recognized by + /// its typed value, keep their own failure, and every other failure, + /// other typed failures included, returns a `LuaRuntime` failure with + /// each chunk-relative line rewritten to an absolute prompt-source line + /// by the program that owns that chunk, this one first when two share a + /// chunk name. Nested errors from other chunks (for example a fanout + /// arm) are left unchanged. Each is placed on the first line the + /// mapping rewrote, of `file`, when it has no location yet, and the + /// quota and the runtime failure get the author prose of the failure's + /// own text. Cancellation is the VM's to classify: a chunk the + /// instruction hook aborted under the run's cancel flag is an + /// `Interrupted` failure before the raw error reaches here. pub(crate) fn map_runtime_error_among( &self, error: &mlua::Error, @@ -209,8 +219,11 @@ impl LuaProgram { file: &str, ) -> Error { let typed = error.downcast_ref::(); - if let Some(exhausted) = typed.filter(|typed| typed.code == ErrorCode::ContextExhausted) { - return exhausted.clone(); + if let Some(located) = typed.filter(|typed| { + matches!(typed.code, ErrorCode::Internal | ErrorCode::Substitution) + && typed.at.is_some() + }) { + return located.clone(); } let raw = error.to_string(); let owners: Vec<(&str, &str, NonZeroU32)> = std::iter::once(self) @@ -224,8 +237,14 @@ impl LuaProgram { }) .collect(); let (mapped, line) = map_chunk_line_to_absolute(&raw, &owners); - let (failure, what) = match typed.filter(|typed| typed.code == ErrorCode::LuaQuota) { - Some(quota) => (quota.clone(), quota.message.clone()), + let kept = typed.filter(|typed| { + matches!( + typed.code, + ErrorCode::ContextExhausted | ErrorCode::LuaQuota + ) + }); + let (failure, what) = match kept { + Some(kept) => (kept.clone(), kept.message.clone()), None => (lua_runtime(mapped), root_text(error)), }; authored_at(failure, file, line, failure_text(&what)) @@ -252,12 +271,42 @@ fn compile_line(chunk_name: &str, source_line: NonZeroU32, diagnostic: &str) -> let digits = rest .find(|c: char| !c.is_ascii_digit()) .unwrap_or(rest.len()); - absolute_line(source_line, &rest[..digits]) + absolute_line(source_line, rest[..digits].parse().ok()?) }) .and_then(NonZeroU32::new) .unwrap_or(source_line) } +/// The prompt line of line `chunk_line` of the chunk named `chunk_name`, +/// when that is a prompt block's name, `line N`: the block's line `N` +/// mapped as [`absolute_line`] maps it. `None` for any other name, or when +/// the line overflows `u32`. +pub(crate) fn prompt_line(chunk_name: &str, chunk_line: u32) -> Option { + let start = chunk_name.strip_prefix("line ")?; + if !start.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + absolute_line(start.parse().ok()?, chunk_line).and_then(NonZeroU32::new) +} + +/// The prompt line of the first position in `text` that Lua writes for a +/// prompt block's line, `[string "line N"]:M:`, as a traceback names its +/// innermost author frame. The `<[string "line N"]:K>` that names where a +/// function is defined has no colon after its line and is skipped. +pub(crate) fn first_prompt_line(text: &str) -> Option { + const OPENING: &str = "[string \""; + text.match_indices(OPENING).find_map(|(at, _)| { + let (chunk_name, rest) = text[at + OPENING.len()..].split_once("\"]:")?; + let digits = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + if !rest[digits..].starts_with(':') { + return None; + } + prompt_line(chunk_name, rest[..digits].parse().ok()?) + }) +} + /// Rewrites chunk-relative line numbers for the named chunks in `owners`, /// each a chunk name, the display location it maps to, and the /// prompt-source line its chunk starts at, to absolute prompt-source lines, @@ -303,7 +352,11 @@ pub(crate) fn map_chunk_line_to_absolute( let digit_end = after .find(|c: char| !c.is_ascii_digit()) .unwrap_or(after.len()); - if let Some(absolute) = absolute_line(*source_line, &after[..digit_end]) { + let absolute = after[..digit_end] + .parse() + .ok() + .and_then(|chunk_line| absolute_line(*source_line, chunk_line)); + if let Some(absolute) = absolute { tag.get_or_insert((location, absolute)); result.push_str(location); result.push(':'); @@ -328,18 +381,12 @@ pub(crate) fn map_chunk_line_to_absolute( } } -/// The absolute prompt-source line of `chunk_line`, a chunk-relative line's -/// digits, in a chunk starting at `source_line`: the two summed, less one. -/// `None` when the digits or the sum overflow `u32`, so a pathological line -/// count cannot wrap into a wrong number. -fn absolute_line(source_line: NonZeroU32, chunk_line: &str) -> Option { - let Ok(chunk_line) = chunk_line.parse::() else { - return None; - }; - match source_line.get().checked_add(chunk_line) { - Some(sum) => sum.checked_sub(1), - None => None, - } +/// The absolute prompt-source line of `chunk_line`, a chunk-relative line, +/// in a chunk starting at `source_line`: the two summed, less one. `None` +/// when the sum overflows `u32`, so a pathological line count cannot wrap +/// into a wrong number. +fn absolute_line(source_line: NonZeroU32, chunk_line: u32) -> Option { + source_line.get().checked_add(chunk_line)?.checked_sub(1) } #[cfg(test)] diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 290822a72..fe58ccd4f 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -106,9 +106,10 @@ fn delivery_values( /// (`kind`, `code`, `message`, and the kind's fields, as /// [`error_table`] renders them, with `tostring` returning the message) - /// the shim raises it -/// with `error(result, 0)`, so a printing author sees the error's prose, -/// author prose for a failure the prompt's Lua caused, and a branching -/// one reads `kind` - and the typed error is +/// with `error(result, 0)`, so a printing author sees the error's prose +/// after the prompt line it was placed on, the call line of the yield it +/// answers unless it had a location, and a branching one reads `kind` - +/// and the typed error, so placed, is /// retained beside that table, which identifies its raise. A successful /// `join_any` whose member failed retains the member's error the same /// way, since a shim may re-raise it at once. diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index 8cf072584..d0720a752 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -49,12 +49,12 @@ fn an_ok_spawn_answer_resumes_the_task_id_as_its_path_text() { } /// The quota failure every failed answer here holds, and the same failure -/// as it crossed into a VM given no file, with its author prose. +/// as it crossed into a VM with no call line, with its author prose. fn quota_and_answered() -> (Error, Error) { let quota = crate::lua_quota(crate::QuotaResource::LogEvent); let mut answered = quota.clone(); answered.prose = format!( - "prompt: lua log event quota exceeded. {}", + "lua log event quota exceeded. {}", ErrorCode::LuaQuota.prose() ); (quota, answered) @@ -78,28 +78,42 @@ fn a_failed_answer_round_trips_and_retains_the_typed_error() { } #[test] -fn a_failed_tool_answer_renders_the_tools_remedy_prose_with_no_file_or_line() { +fn a_failed_tool_answer_keeps_the_tools_prose_and_names_coordinates_only_when_located() { let lua = Lua::new(); crate::error::set_prompt_file(&lua, "agents/a.md"); + let prose = "the backend refused; try another source"; let failure = crate::error::tool(runvil_types::tools::tool_error( ErrorCode::ToolBackend, - "the backend refused; try another source", + prose, )); let (envelope, retained) = answer_envelope(&lua, Err(failure.clone())).expect("the envelope renders"); assert_eq!( retained.as_ref(), Some(&failure), - "a tool failure crosses into the VM unchanged" + "with no call line, a tool failure crosses into the VM unchanged" ); let (ok, result) = echo_through_lua(&lua, envelope); assert!(!ok); let (kind, message) = failure_parts(&lua, result); assert_eq!( (kind.as_str(), message.as_str()), - ("tool", "the backend refused; try another source"), - "the table's message is the tool's own prose, naming no file or line" + ("tool", prose), + "an unlocated table's message is the tool's own prose" ); + crate::error::set_call_line(&lua, NonZeroU32::new(5)); + let (envelope, retained) = + answer_envelope(&lua, Err(failure.clone())).expect("the envelope renders"); + let retained = retained.expect("the failure is retained"); + assert_eq!(retained.prose, prose, "the tool's prose is untouched"); + assert_eq!( + retained.at, + NonZeroU32::new(5).map(|line| runvil_types::error::Location::line("agents/a.md", line)), + "the failure is placed on the call line" + ); + let (_, result) = echo_through_lua(&lua, envelope); + let (_, message) = failure_parts(&lua, result); + assert_eq!(message, format!("agents/a.md:5: {prose}")); } #[test] diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs index 52a1e279a..55b7cc622 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs @@ -1,5 +1,8 @@ //! The retained typed error: a block coroutine's failure becomes the -//! answer's own error only when the block raised that answer's error table. +//! answer's own error only when the block raised that answer's error table, +//! placed on the line of the call the answer resumed. + +use runvil_types::error::Location; use super::*; use crate::test_vm::{TEST_SECTION, TestVm}; @@ -11,21 +14,28 @@ fn quota() -> runvil_types::error::Error { crate::lua_quota(crate::QuotaResource::LogEvent) } -/// [`quota`] as it crossed into the block's VM, which names no file: with -/// the author prose of its message. -fn answered_quota() -> runvil_types::error::Error { +/// [`quota`] as it crossed into the block's VM, which names no file, at +/// the call on `line`: placed there, with the author prose of its message. +fn answered_quota(line: u32) -> runvil_types::error::Error { let mut quota = quota(); quota.prose = format!( - "prompt: {}. {}", + "{}. {}", quota.message, runvil_types::error::ErrorCode::LuaQuota.prose() ); + quota.at = NonZeroU32::new(line).map(|line| Location::line("prompt", line)); quota } /// Runs `source` on a shim VM to its `models.infer` yield, answers the -/// yield with [`quota`], and returns how the block ended. -fn answered(source: &str) -> std::result::Result { +/// yield with [`quota`], and returns the call line the yield recorded +/// beside how the block ended. +fn answered( + source: &str, +) -> ( + Option, + std::result::Result, +) { let vm = TestVm::new().shims().build(); let program = LuaProgram::compile( source, @@ -41,10 +51,11 @@ fn answered(source: &str) -> std::result::Result {} + (_, Err(error)) if error == answered_quota(1) => {} other => panic!("block {source:?} must surface the typed quota error, got {other:?}"), } } @@ -65,7 +76,7 @@ fn a_block_raising_the_answered_errors_text_as_its_own_keeps_its_own_error() { // The raise carries the same text as the answered error, but it is the // block's own string, not the answer's table. match answered("local ok, err = pcall(models.infer, 'hi')\nerror(tostring(err), 0)") { - Err(error) if error.code == runvil_types::error::ErrorCode::LuaRuntime => assert!( + (_, Err(error)) if error.code == runvil_types::error::ErrorCode::LuaRuntime => assert!( error.message.contains("lua log event quota exceeded"), "the block's own raise keeps its message: {}", error.message @@ -73,3 +84,17 @@ fn a_block_raising_the_answered_errors_text_as_its_own_keeps_its_own_error() { other => panic!("the block's own raise must stay its own error, got {other:?}"), } } + +#[test] +fn a_main_chunk_tail_call_keeps_its_line_for_the_call_and_its_answer() { + let (call_line, ended) = answered("local prompt = 'hi'\nreturn models.infer(prompt)"); + assert_eq!( + call_line, + NonZeroU32::new(2), + "the yield finds the returning line" + ); + match ended { + Err(error) => assert_eq!(error, answered_quota(2)), + other => panic!("the block must fail with the answered error, got {other:?}"), + } +} diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 197f34331..76fff3f3d 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -80,10 +80,10 @@ fn assert_identity_only(recorder: &Recorder) { } /// The author prose of a `LuaRuntime` refusal whose own text is -/// `message`, raised in a VM given no file: what `tostring` gives for it -/// once a `pcall` caught it. +/// `message`: what `tostring` gives for it once a `pcall` caught it where +/// no prompt block runs, as in a probe chunk, which places it on no line. pub(crate) fn refused(message: &str) -> String { - format!("prompt: {message}. {}", ErrorCode::LuaRuntime.prose()) + format!("{message}. {}", ErrorCode::LuaRuntime.prose()) } /// Returns the message of a `LuaRuntime` failure. @@ -347,12 +347,13 @@ pub(crate) fn assert_chunk_name_resolves(constant: &str, chunk_name: &str, embed } /// The refusal a `pcall` caught, from the caught error's `tostring`: the -/// text its author prose names when the shim's `pcall` caught it in a VM -/// given no file, or its first line without the `runtime error: ` prefix -/// mlua renders when the base `pcall` did. +/// text its author prose names when the shim's `pcall` caught it where no +/// prompt block runs, or its first line without the `runtime error: ` +/// prefix mlua renders when the base `pcall` did. pub(crate) fn refusal_line(caught: &str) -> Option<&str> { - if let Some(text) = caught.strip_prefix("prompt: ") { - return text.strip_suffix(format!(". {}", ErrorCode::LuaRuntime.prose()).as_str()); + if let Some(text) = caught.strip_suffix(format!(". {}", ErrorCode::LuaRuntime.prose()).as_str()) + { + return Some(text); } caught.strip_prefix("runtime error: ")?.lines().next() } diff --git a/crates/runvil-internal/lua/src/tests/diagnostics.rs b/crates/runvil-internal/lua/src/tests/diagnostics.rs index 1fb818707..f3198be4a 100644 --- a/crates/runvil-internal/lua/src/tests/diagnostics.rs +++ b/crates/runvil-internal/lua/src/tests/diagnostics.rs @@ -320,7 +320,7 @@ fn a_block_failure_names_the_vms_file_and_its_mapped_line() { ); assert_eq!( error.prose, - format!("agents/a.md:2: boom. {}", ErrorCode::LuaRuntime.prose()) + format!("boom. {}", ErrorCode::LuaRuntime.prose()) ); vm.teardown(); } diff --git a/crates/runvil-internal/lua/src/tests/sandbox.rs b/crates/runvil-internal/lua/src/tests/sandbox.rs index 625c4e73c..63b8c885c 100644 --- a/crates/runvil-internal/lua/src/tests/sandbox.rs +++ b/crates/runvil-internal/lua/src/tests/sandbox.rs @@ -94,7 +94,7 @@ fn an_installed_require_is_runvils_and_loads_no_module() { assert_eq!( returned, format!( - "function|false|prompt: no section `os` within reach of require. {}|nil|nil", + "function|false|prompt:1: no section `os` within reach of require. {}|nil|nil", ErrorCode::LuaRuntime.prose() ) ); diff --git a/crates/runvil-internal/lua/src/tests/store_errors.rs b/crates/runvil-internal/lua/src/tests/store_errors.rs index fb102f7ee..b867a8758 100644 --- a/crates/runvil-internal/lua/src/tests/store_errors.rs +++ b/crates/runvil-internal/lua/src/tests/store_errors.rs @@ -52,10 +52,11 @@ fn direct_store_failure_caught_by_pcall_carries_the_reason_tag() { assert_eq!( out, Some(format!( - "store|not_found|missing.txt|prompt: file not found in store: missing.txt. {}", + "store|not_found|missing.txt|prompt:1: file not found in store: missing.txt. {}", ErrorCode::NotFound.prose() )), - "the direct store failure must carry the documented shape and its author prose" + "the direct store failure must carry the documented shape, its call's line, and its \ + author prose" ); } @@ -341,7 +342,7 @@ fn store_error_messages_use_the_operations_wording() { /// A store failure raised through the Lua boundary renders as an error /// table of kind `store` whose `count` field is a Lua integer, and -/// `tostring` returns the failure's prose. +/// `tostring` of the unlocated failure returns its prose. #[test] fn a_raised_store_error_renders_an_integer_count_and_the_prose() { let anchor = runvil_vfs::VfsError::Anchor { diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 8cd053745..7b9c72098 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -20,6 +20,7 @@ mod install; mod run; mod state; +use std::num::NonZeroU32; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -272,8 +273,8 @@ impl SectionVm { } /// Records `file`, the file the run's prompt came from, as the file - /// the author prose of this VM's Lua failures names; a VM given none - /// names `prompt`. + /// this VM's Lua failures are placed in; a VM given none places them + /// in `prompt`. pub fn set_prompt_file(&self, file: &str) { crate::error::set_prompt_file(&self.lua, file); } @@ -361,4 +362,13 @@ impl SectionVm { pub fn lua(&self) -> &Lua { &self.lua } + + /// The prompt line of the author's call the running block last yielded + /// on, which holds while the block is suspended on that yield: `None` + /// before the block's first yield, or when that yield found no author + /// frame running. + #[must_use] + pub fn call_line(&self) -> Option { + crate::error::call_line(&self.lua) + } } diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 40eb22133..269aebad5 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -3,11 +3,11 @@ use mlua::thread::ThreadStatus; use mlua::{MultiValue, Thread, Value}; -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use super::SectionVm; use crate::coro::{StashedFailure, block_guard, take_failure}; -use crate::error::{self, Error, Result, interrupted, lua_runtime, prompt_file}; +use crate::error::{self, Error, Result, interrupted, lua_runtime, prompt_file, set_call_line}; use crate::error_value::{ErrorKind, error_kind}; use crate::program::LuaProgram; use crate::protocol::{Answer, Request, Retained, YieldParse, render_envelope}; @@ -52,9 +52,10 @@ impl SectionVm { /// programs, and the scalar-return rule applies to the return values. /// A coroutine that yields suspends with the shim's request table; the /// driver resumes it with - /// [`resume_block_coro_answer`](Self::resume_block_coro_answer). No - /// observation events fire here; the driver owns the chunk observation - /// boundaries. + /// [`resume_block_coro_answer`](Self::resume_block_coro_answer). The + /// start clears the VM's [`call_line`](Self::call_line), which each of + /// the block's yields then sets. No observation events fire here; the + /// driver owns the chunk observation boundaries. /// /// The coroutine body is the shim's block guard, resumed first with /// the block function: the guard runs the block under `xpcall`, whose @@ -71,6 +72,7 @@ impl SectionVm { /// block failure returns the mapped runtime error. pub fn start_block_coro(&self, program: &LuaProgram) -> Result { self.clear_jump()?; + set_call_line(&self.lua, None); let function = program.load(&self.lua)?; let guard = block_guard(&self.lua)?; let thread = self.lua.create_thread(guard).map_err(error::lua)?; @@ -190,7 +192,8 @@ impl SectionVm { /// Classifies the failure of a block resumed with an answer, as /// [`block_failure`](Self::block_failure) does, except that a raise of /// `retained`'s error table, the answer's own, is the answer's typed - /// error, unless the block was cancelled or spent an Engine quota. + /// error, placed as [`kept_failure`] places a table's, unless the block + /// was cancelled or spent an Engine quota. fn answered_block_failure( &self, program: &LuaProgram, @@ -205,7 +208,7 @@ impl SectionVm { && let Some(retained) = retained && stashed.raised_table_is(&retained.table) { - return retained.error; + return placed_like(retained.error, &mapped); } kept_failure(stashed, mapped) } @@ -255,19 +258,31 @@ fn is_fatal(mapped: &Error) -> bool { /// Engine quota, a raise that is no error table, or a `lua`-kind table, /// whose mapped runtime error keeps the same message with its source and /// the mapped prompt line; any other structured table as the universal -/// error it stands for. A raise that is no error table keeps its mapping, -/// so an uncaught direct `compactors.fail` call is its context -/// exhaustion. +/// error it stands for, placed on the mapped line when it has no location +/// yet. A raise that is no error table keeps its mapping, so an uncaught +/// direct `compactors.fail` call is its context exhaustion. fn kept_failure(stashed: StashedFailure, mapped: Error) -> Error { if is_fatal(&mapped) { return mapped; } match stashed.raised { - Some(raised) if error_kind(&raised) != ErrorKind::Lua => raised, + Some(raised) if error_kind(&raised) != ErrorKind::Lua => placed_like(raised, &mapped), _ => mapped, } } +/// `error` placed on the prompt line `mapped` was located on, when the +/// mapping found one; an internal mapping's location is in Rust and +/// places nothing. +fn placed_like(error: Error, mapped: &Error) -> Error { + match &mapped.at { + Some(at) if mapped.code != ErrorCode::Internal => { + place(error, &at.file, Some(at.start_line)) + } + _ => error, + } +} + /// Renders a returned Lua scalar as the section's result string. Tables and other /// non-scalar returns are errors. fn value_to_string(value: &Value) -> Result { diff --git a/crates/runvil-internal/prompt/src/detail.rs b/crates/runvil-internal/prompt/src/detail.rs index 95857a99a..eeb76f1b3 100644 --- a/crates/runvil-internal/prompt/src/detail.rs +++ b/crates/runvil-internal/prompt/src/detail.rs @@ -18,8 +18,8 @@ pub fn parse_error(code: ErrorCode, message: impl Into) -> Error { } /// Gives a parse failure the Engine raises about a parsed prompt the -/// prompt's frontmatter `name` as its `Name` fact, the prompt's `file` as -/// its location's file, and the prose a failure the parse returns has. +/// prompt's frontmatter `name` as its `Name` fact, its place in the +/// prompt's `file`, and the prose a failure the parse returns has. #[must_use] pub fn with_prompt_context(error: Error, name: &str, file: &str) -> Error { crate::error::with_prompt_context(error, Some(name), file) diff --git a/crates/runvil-internal/prompt/src/error-tests.rs b/crates/runvil-internal/prompt/src/error-tests.rs index 9997be44b..34574754f 100644 --- a/crates/runvil-internal/prompt/src/error-tests.rs +++ b/crates/runvil-internal/prompt/src/error-tests.rs @@ -219,6 +219,19 @@ fn an_internal_failure_has_the_default_prose_and_no_name() { assert_eq!(error.prose, ErrorCode::Internal.prose()); } +#[test] +fn an_internal_failure_keeps_its_rust_location_in_the_prompt_context() { + let fault = runvil_lua::internal_at("a test invariant broke", std::panic::Location::caller()); + assert!(fault.at.is_some(), "an internal failure is located in Rust"); + let error = with_prompt_context(fault.clone(), Some("x"), FILE); + assert_eq!( + error.at, fault.at, + "the prompt's file never replaces the Rust file" + ); + assert_eq!(error.facts, BTreeMap::new()); + assert_eq!(error.prose, ErrorCode::Internal.prose()); +} + #[test] fn a_lua_compiler_that_cannot_start_is_internal() { let message = mlua::Error::MemoryError("not enough memory".to_owned()).to_string(); diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index 1c89ab6dd..cb7bfc0c5 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -10,7 +10,7 @@ use std::num::NonZeroU32; use std::ops::Range; -use runvil_types::error::{Error, ErrorCode, FactKey, Location, coordinates}; +use runvil_types::error::{Error, ErrorCode, FactKey, Location, coordinates, place}; use runvil_types::packs::PackId; /// The parser's result type over the universal [`Error`]. @@ -105,24 +105,23 @@ pub(crate) fn lua(cause: &Error) -> Error { /// prose; the Engine reaches it through [`crate::detail::with_prompt_context`] /// for a failure it raises about a parsed prompt. /// -/// `file` becomes the file of the failure's location. `name`, the prompt's +/// The failure is placed in `file`: a location with no file yet gets it, +/// and an internal failure keeps its Rust location. `name`, the prompt's /// frontmatter name, is `None` for a failure found before the name is /// known; otherwise it becomes the `Name` fact of every failure but an /// internal one. The prose of an internal failure is the code's default. -/// Every other prose names the file, the start line when the failure has -/// a location, the message, and the default: -/// `{file}:{line}: {message}. {default}`, or without a location +/// Every other prose names the location's file and start line when the +/// failure has a location, and `file` otherwise, then the message and the +/// default: `{file}:{line}: {message}. {default}`, or without a location /// `{file}: {message}. {default}`. -pub(crate) fn with_prompt_context(mut error: Error, name: Option<&str>, file: &str) -> Error { +pub(crate) fn with_prompt_context(error: Error, name: Option<&str>, file: &str) -> Error { + let mut error = place(error, file, None); let code = error.code; if let Some(name) = name && code != ErrorCode::Internal { error.facts.insert(FactKey::Name, name.into()); } - if let Some(at) = &mut error.at { - file.clone_into(&mut at.file); - } error.prose = match (code, &error.at) { (ErrorCode::Internal, _) => code.prose().to_owned(), (_, Some(at)) => format!("{}: {}. {}", coordinates(at), error.message, code.prose()), diff --git a/crates/runvil-internal/types/src/error-prose.rs b/crates/runvil-internal/types/src/error-prose.rs index 94548b841..16ddb0f91 100644 --- a/crates/runvil-internal/types/src/error-prose.rs +++ b/crates/runvil-internal/types/src/error-prose.rs @@ -7,7 +7,8 @@ impl ErrorCode { /// 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. + /// a model fixing the prompt, and its site puts what failed in front, + /// after the location only for a parse failure. #[must_use] #[expect( clippy::too_many_lines, diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 2143fea23..07914f4fa 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -129,8 +129,10 @@ impl Location { } } -/// Places `error` in the prompt `file`. This is the only code that writes a -/// prompt location. +/// Places `error` in the prompt `file`. This is the only code that gives a +/// location its prompt file or turns a known line into a prompt location: +/// the parser's span constructors build spans with no file, which this +/// completes, and the Lua crate's `internal_at` writes Rust locations. /// /// An [`ErrorCode::Internal`] error comes back unchanged, so it keeps its /// Rust location. Any other error that has a location keeps it, so the diff --git a/crates/workshop/server/src/error.rs b/crates/workshop/server/src/error.rs index 53bdaca6b..603cd3726 100644 --- a/crates/workshop/server/src/error.rs +++ b/crates/workshop/server/src/error.rs @@ -61,7 +61,7 @@ pub(crate) enum AppError { #[error("{message}")] PromptParse { /// The pre-rendered message, including the `line N: ` prefix when - /// the parser located the failure. + /// the parser located the failure in the prompt. message: String, /// The wire code for the parse failure's code. code: &'static str, @@ -72,7 +72,8 @@ impl AppError { /// Builds the wire error for a prompt that failed to parse: the code /// names the failure's code and the message carries the parser's /// `line N: ` prefix, the line the failure starts on, when it located - /// the failure. + /// the failure in the prompt. An internal failure's location is a Rust + /// line, so its message has no prefix. pub(crate) fn prompt_parse(error: &Error) -> Self { let code = match error.code { ErrorCode::FrontmatterInvalid => "parse_frontmatter", @@ -85,8 +86,10 @@ impl AppError { _ => "parse_error", }; let message = match &error.at { - Some(at) => format!("line {}: {}", at.start_line, error.message), - None => error.message.clone(), + Some(at) if error.code != ErrorCode::Internal => { + format!("line {}: {}", at.start_line, error.message) + } + _ => error.message.clone(), }; Self::PromptParse { message, code } } diff --git a/crates/workshop/server/src/routes/prompts-tests.rs b/crates/workshop/server/src/routes/prompts-tests.rs index 474aefeb3..7452bcd77 100644 --- a/crates/workshop/server/src/routes/prompts-tests.rs +++ b/crates/workshop/server/src/routes/prompts-tests.rs @@ -1,12 +1,17 @@ //! Tests for the `POST /prompts/contract` route: the wire contract the -//! Run window renders, and the `422` parse-failure envelope. +//! Run window renders, and the `422` parse-failure envelope and its +//! message. + +use std::num::NonZeroU32; use axum::body::Body; use axum::http::{Request, StatusCode}; +use runvil::error::{Error, ErrorCode, Location}; use tower::ServiceExt as _; use crate::app::router; use crate::app::test_helpers::{body_bytes, state_for}; +use crate::error::AppError; /// Posts `body` to `/prompts/contract` on the assembled server router and /// returns the status with the decoded JSON body. @@ -126,6 +131,23 @@ async fn a_broken_yaml_key_answers_parse_frontmatter_with_the_line() { ); } +#[test] +fn an_internal_parse_failure_names_no_prompt_line() { + // An internal failure's location is a Rust line, never a prompt line. + let internal = ErrorCode::Internal; + let mut error = Error::new( + internal, + "internal invariant violated: a test fault", + internal.prose(), + ); + error.at = NonZeroU32::new(9).map(|line| Location::line("crates/x/src/lib.rs", line)); + let AppError::PromptParse { message, code } = AppError::prompt_parse(&error) else { + panic!("a parse failure answers as a prompt-parse wire error"); + }; + assert_eq!(code, "parse_internal"); + assert_eq!(message, "internal invariant violated: a test fault"); +} + #[tokio::test] async fn a_prompt_without_args_yields_the_implicit_prose_field() { let text = "---\nname: plain\ndescription: no args\n---\n\n# Plain\n\n## S\n\np\n"; From c5c876da4b33bfc87d5650e22471346fc4024dd2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 16:29:06 -0800 Subject: [PATCH 05/36] Give Engine failures the prompt line that caused them Many Engine failures did not record the prompt line that caused them. Each such site now calls `place` with the prompt file and the line of the call, the block, or the `{{ }}` placeholder that caused the failure. `failure_message` now ends every failure that has a location with `(at {file}:{line})`, and keeps the `(prompt {name}, line {line}, column {column})` form only for the parser's own codes. - `lua_line` sets one rule for a failed `var` or `argv` read-back: the start line of the last Lua block at or before the block index. A fall-through gets the last Lua block, and a jump or a return gets its own block, because neither moves the index. - The `jump` closure records `author_line` beside the heading, and the line goes through `take_jump`, `LuaBlockResult::Jump`, and `Settled::Jump`. Only `apply_jump` and `end_h1_at_jump` put that line on the failure when the heading does not resolve, because `call` and `prepare_spawn` also use `resolve_chain_target`. - `TaskEntry` has a new `spawn_line`, which `prepare_spawn` reads from the `call_line()` of the chain that spawns the task. `settle_owned_tasks` puts `tasks_live` on the line of the first leaked task, and timers record `None`. - The parser records the start line of each trimmed prose block in the new `line` field of `Block::Prose`, through the new `file_line_of`. `pending_prose` keeps that line beside the text, and library prose gets it through `LibraryBlock`, `begin_load`, and `swap_prose(template, line)`. - `substitute` puts each failure on the prose line plus the newlines before the placeholder, because only `substitute` has both the text and the byte offset. `Sources` has the new fields `file` and `line` for this. - `Running` and `Entered` move to the new `chain_record/entered.rs` to keep `chain_record.rs` under 500 lines. The moved code differs only in the type and doc of `pending_prose`, and in `pub(in crate::execute::scheduler)`, which keeps the reach of the old `pub(super)`. - The two `jump` tests in `lua/src/tests/require.rs` move to the new `require/jump.rs` for the same limit, and they now also check the `jump` line. - The test hook `dispatch_store_without_a_store_for_test` gives the chain at arena index 0 an access with no store and dispatches its store request again, because the start probe of the run prevents a store-view failure. - In `settle_block`, the `RequirementsUnmet` refusal of the H1 gate takes the file and start line of its cause, or the first line of the block when the cause has no location. A failed `{{ }}` substitution in H1 keeps its `Substitution` code and its template line. - `InvalidValue` and every other code of kind `Parse` outside the four parser codes keep that kind, but now end with `(at {file}:{line})`, or with nothing when they have no location. A run-time `InvalidValue` holds the bad value in its `Name` fact, which the parse form showed as a prompt name. - A `LuaCompile` message already names its line, so its run message now names the line again, as in `(line 7)` and `(at prompt:7)`. - `prepare_spawn` reads the line through `chain.frame()?`, so a spawn from a chain with no frame now fails with an `Internal` error. The store paths use `chain.frame().ok()` and give no line in that case. - New tests pin each placement above except the block-line fallback of the H1 gate, a failed `install_lazy_prose`, the read-backs in `end_h1` other than `argv` at a fall-through, the other codes of kind `Parse`, and the new `Internal` exit in `prepare_spawn`. --- .../engine/src/error/tests-chain.rs | 9 +- crates/runvil-internal/engine/src/execute.rs | 6 +- .../engine/src/execute/error-tests.rs | 35 ++++++- .../engine/src/execute/error.rs | 20 ++-- .../src/execute/scheduler/admission-tests.rs | 3 +- .../engine/src/execute/scheduler/apply.rs | 15 ++- .../engine/src/execute/scheduler/chain.rs | 13 ++- .../src/execute/scheduler/chain_record.rs | 74 ++++----------- .../execute/scheduler/chain_record/entered.rs | 70 ++++++++++++++ .../engine/src/execute/scheduler/dispatch.rs | 12 ++- .../engine/src/execute/scheduler/h1.rs | 44 ++++++--- .../engine/src/execute/scheduler/step.rs | 92 +++++++++++++------ .../engine/src/execute/scheduler/task_end.rs | 18 +++- .../src/execute/scheduler/tasks-spawn.rs | 12 ++- .../src/execute/scheduler/tasks-tests.rs | 4 +- .../engine/src/execute/scheduler/tasks.rs | 15 ++- .../src/execute/scheduler/test_hooks.rs | 30 +++++- .../engine/src/execute/scheduler/timer.rs | 1 + .../engine/src/execute/scheduler/walk.rs | 42 ++++++--- .../engine/src/execute/section_context.rs | 26 ++++-- .../engine/src/execute/section_vm.rs | 5 +- .../engine/src/execute/tests.rs | 2 +- .../scheduler/fanout/failure_semantics.rs | 6 ++ .../tests/scheduler/h1/control_flow.rs | 23 +++++ .../src/execute/tests/scheduler/h1/pass.rs | 76 ++++++++++++++- .../src/execute/tests/scheduler/walk/jumps.rs | 24 ++++- .../execute/tests/suite/exec_flow/globals.rs | 87 +++++++++++++++++- .../tests/suite/exec_flow/store_failures.rs | 32 ++++++- .../src/execute/tests/suite/lazy_prose.rs | 40 ++++++-- .../src/execute/tests/suite/require/prose.rs | 31 ++++++- .../src/execute/tests/tasks-chain-end.rs | 55 +++++++++-- .../engine/src/execute/tests/tool_call_arm.rs | 19 +++- .../engine/src/execute/walk_target-tests.rs | 20 ++++ .../engine/src/execute/walk_target.rs | 23 +++-- .../runvil-internal/engine/src/lua/tests.rs | 2 +- .../engine/src/lua/tests/coroutine.rs | 2 +- .../runvil-internal/engine/src/subst-tests.rs | 45 ++++++++- crates/runvil-internal/engine/src/subst.rs | 38 ++++++-- .../engine/src/test_support.rs | 7 ++ .../lua/src/__impl_require.lua | 22 +++-- crates/runvil-internal/lua/src/require.rs | 32 ++++--- crates/runvil-internal/lua/src/section.rs | 11 ++- .../runvil-internal/lua/src/test_support.rs | 4 +- crates/runvil-internal/lua/src/tests.rs | 2 +- .../runvil-internal/lua/src/tests/require.rs | 45 +-------- .../lua/src/tests/require/errors.rs | 1 + .../lua/src/tests/require/jump.rs | 53 +++++++++++ .../lua/src/tests/require/prose.rs | 34 ++++++- .../runvil-internal/lua/src/tests/sandbox.rs | 2 +- crates/runvil-internal/lua/src/vm.rs | 6 +- crates/runvil-internal/lua/src/vm/install.rs | 14 ++- crates/runvil-internal/lua/src/vm/run.rs | 13 +-- crates/runvil-internal/prompt/src/parse.rs | 2 +- crates/runvil-internal/prompt/src/position.rs | 18 ++++ crates/runvil-internal/prompt/src/prompt.rs | 6 +- crates/runvil-internal/prompt/src/sections.rs | 9 +- .../prompt/src/test_support.rs | 7 +- crates/runvil-internal/prompt/src/tests.rs | 1 + .../prompt/src/tests/breaks.rs | 14 +-- .../prompt/src/tests/h1_fences.rs | 4 +- .../prompt/src/tests/headings.rs | 2 +- .../prompt/src/tests/line_mapping.rs | 33 ++++++- .../prompt/src/tests/section_fences.rs | 4 +- 63 files changed, 1103 insertions(+), 314 deletions(-) create mode 100644 crates/runvil-internal/engine/src/execute/scheduler/chain_record/entered.rs create mode 100644 crates/runvil-internal/lua/src/tests/require/jump.rs diff --git a/crates/runvil-internal/engine/src/error/tests-chain.rs b/crates/runvil-internal/engine/src/error/tests-chain.rs index 1f6c05276..e4bb3e35c 100644 --- a/crates/runvil-internal/engine/src/error/tests-chain.rs +++ b/crates/runvil-internal/engine/src/error/tests-chain.rs @@ -1,8 +1,9 @@ //! The cause convention: a failure built with a cause holds the cause's //! text once in its message, after its own, and keeps its prose free of //! it; a model failure's table holds only its prose; the Lua crate's -//! failures end a run as they always did; and the store probe names a -//! missing store. +//! failures end a run with their own kind and text, a located one's +//! ending with where it failed; and the store probe names a missing +//! store. use runvil_types::error::ErrorCode; use runvil_vfs::VfsError; @@ -93,7 +94,7 @@ fn a_model_failure_holds_its_cause_in_the_run_message_and_not_in_its_table() { } #[test] -fn the_lua_crates_failures_end_a_run_with_the_kind_and_text_they_always_had() { +fn the_lua_crates_failures_end_a_run_with_their_own_kind_and_text() { let site = std::panic::Location::caller(); let emitter = crate::test_support::recording::null_emitter(); let compiled = runvil_lua::LuaProgram::compile( @@ -130,7 +131,7 @@ fn the_lua_crates_failures_end_a_run_with_the_kind_and_text_they_always_had() { compiled, RunErrorKind::Lua, "lua compilation error at section `S` prologue (line 7): syntax error: \ - [string \"line 7\"]:1: unexpected symbol near " + [string \"line 7\"]:1: unexpected symbol near (at prompt:7)" .to_owned(), ), ( diff --git a/crates/runvil-internal/engine/src/execute.rs b/crates/runvil-internal/engine/src/execute.rs index 5b8933d8f..e72827025 100644 --- a/crates/runvil-internal/engine/src/execute.rs +++ b/crates/runvil-internal/engine/src/execute.rs @@ -118,8 +118,10 @@ pub enum RunOutcome { /// each kind. kind: RunErrorKind, /// The failure's self-contained message, with the text of each - /// cause it keeps and, for a parse or internal failure, where it - /// happened. + /// cause it keeps and then where it happened, when known: the + /// prompt name, line, and column for a failure in the prompt's + /// frontmatter, structure, fences, or lists, and + /// `(at {file}:{line})` for any other. message: String, }, /// The caller cancelled the run or dropped an effect the run was waiting diff --git a/crates/runvil-internal/engine/src/execute/error-tests.rs b/crates/runvil-internal/engine/src/execute/error-tests.rs index 25a034705..13c323a28 100644 --- a/crates/runvil-internal/engine/src/execute/error-tests.rs +++ b/crates/runvil-internal/engine/src/execute/error-tests.rs @@ -256,14 +256,41 @@ fn an_internal_failure_message_ends_with_its_rust_file_and_line() { } #[test] -fn any_other_failure_ends_with_its_own_text_whatever_its_location() { +fn any_other_located_failure_ends_with_its_file_and_line() { let mut error = failure( ErrorCode::LuaCompile, "lua compilation error at x (line 2): y", ); - error.at = Some(Location::line("x", at(2))); + error.at = Some(Location::line("agents/a.md", at(2))); error.facts.insert(FactKey::Name, "p".into()); - assert_eq!(failure_message(&error), error.message); + assert_eq!( + failure_message(&error), + "lua compilation error at x (line 2): y (at agents/a.md:2)", + "the suffix adds the file to the line the message names" + ); let interrupted = crate::error::interrupted(); - assert_eq!(failure_message(&interrupted), interrupted.message); + assert_eq!( + failure_message(&interrupted), + interrupted.message, + "a failure with no location ends with its own text" + ); +} + +#[test] +fn a_run_time_invalid_value_takes_the_located_form_not_the_parse_form() { + let mut error = failure(ErrorCode::InvalidValue, "unknown model role `bogus`"); + error.facts.insert(FactKey::Name, "bogus".into()); + assert_eq!( + failure_message(&error), + "unknown model role `bogus`", + "the value its `Name` fact holds is no prompt name" + ); + error.at = Some(Location::line("agents/a.md", at(9))); + assert_eq!( + outcome(&error), + RunOutcome::Failed { + kind: RunErrorKind::Parse, + message: "unknown model role `bogus` (at agents/a.md:9)".to_owned(), + } + ); } diff --git a/crates/runvil-internal/engine/src/execute/error.rs b/crates/runvil-internal/engine/src/execute/error.rs index 3b8d36254..035823ea0 100644 --- a/crates/runvil-internal/engine/src/execute/error.rs +++ b/crates/runvil-internal/engine/src/execute/error.rs @@ -197,10 +197,12 @@ pub(crate) fn run_error_kind(code: ErrorCode) -> RunErrorKind { /// Returns the self-contained text a failed run reports: the error's /// message, with its causes, then where the failure happened. /// -/// A parse failure ends with its prompt name, line, and column, each when -/// known, as `(prompt {name}, line {line}, column {column})`, and an -/// internal fault with the Rust source file and line of the broken -/// invariant, as `(at {file}:{line})`. +/// A frontmatter, structure, fence, or list failure, the parser's own, +/// ends with its prompt name, line, and column, each when known, as +/// `(prompt {name}, line {line}, column {column})`. Any other failure with +/// a location ends with it as `(at {file}:{line})`: its prompt file and +/// line, or for an internal fault the Rust source file and line of the +/// broken invariant. pub(crate) fn failure_message(error: &Error) -> String { let mut rendered = error.message.clone(); if let Some(location) = location(error) { @@ -215,8 +217,11 @@ pub(crate) fn failure_message(error: &Error) -> String { /// when the failure has no location or the parser found none of its /// parts. fn location(error: &Error) -> Option { - match run_error_kind(error.code) { - RunErrorKind::Parse => { + match error.code { + ErrorCode::FrontmatterInvalid + | ErrorCode::StructureInvalid + | ErrorCode::FenceInvalid + | ErrorCode::ListEmptyItem => { let parts: Vec = [ error .facts @@ -237,11 +242,10 @@ fn location(error: &Error) -> Option { .collect(); (!parts.is_empty()).then(|| parts.join(", ")) } - _ if error.code == ErrorCode::Internal => error + _ => error .at .as_ref() .map(|at| format!("at {}", coordinates(at))), - _ => None, } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs index 47fd05832..85f7ca9fb 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs @@ -60,7 +60,8 @@ impl Tree { exec: runvil_vfs::detail::access_id(&access), }; let task = self.chains[id].task.clone(); - self.tasks.insert(task, backing, owner, "worker".to_owned()); + self.tasks + .insert(task, backing, owner, "worker".to_owned(), None); id } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index 754319fab..17b267571 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -17,7 +17,7 @@ //! call's identity: what the tool did is joined back into the chain, and //! the call's access refuses every later operation. -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_types::ids::{RoundId, TaskId}; use runvil_types::tools::ToolOutput; @@ -317,7 +317,9 @@ impl ChainOps<'_> { /// failed observation (pushed before the chain resumes, so the op's /// outcome precedes the chunk's closing boundary), then the outcome, /// with a failure classified for the answer channel under the - /// operation's own wording. + /// operation's own wording and placed on the call line of the block + /// parked on it, so a claims-model conflict, which never reaches Lua, + /// is located too. fn accept_vfs( &self, op: &VfsOp, @@ -330,6 +332,13 @@ impl ChainOps<'_> { if result.is_ok() { succeeded } else { failed }, ); } - result.map_err(|cause| classify_vfs_failure(op, &cause)) + result.map_err(|cause| { + let line = chain.frame().ok().and_then(|frame| frame.vm().call_line()); + place( + classify_vfs_failure(op, &cause), + chain.ctx.prompt().file(), + line, + ) + }) } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs index dbafb1606..2e82d10bc 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs @@ -7,7 +7,7 @@ use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use runvil_types::ids::{AbandonReason, ChainId, TaskId}; use runvil_vfs::Access; @@ -180,7 +180,9 @@ impl Scheduler { /// `outcome` is the chain's end: a scalar return's value, `None` for a /// walk that ran off its slice's last section, or the chain's failure. pub(super) fn finish(&mut self, id: ChainIndex, outcome: Result>) -> ChainStep { + let prompt = self.prompt(); let chain = &mut self.chains[id]; + let blocks = chain.blocks(&prompt); let parent = chain.kind.parent(); let is_task = matches!(chain.kind, ChainKind::Task); // `None` when the chain ended by exhausting its slice: the last @@ -203,9 +205,14 @@ impl Scheduler { // at fall-through (the walk rolls it forward; a call chain // or a task chain discards its clone), and arms the completion // flag so the frame's drop fires SECTION_FINISHED. A failure - - // the read-back's included - drops the frame unarmed. + // the read-back's included, placed on the returning block - + // drops the frame unarmed. if let Some(entered) = entered.as_mut() { - entered.frame.read_var()?; + let line = entered.block.lua_line(blocks); + entered + .frame + .read_var() + .map_err(|error| place(error, prompt.file(), line))?; if !is_h1 { entered.frame.mark_completed(); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index 503361687..c2d66492d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -4,10 +4,9 @@ //! started, indexed by [`ChainIndex`]. use std::fmt; -use std::num::NonZeroUsize; +use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::{Arc, Mutex}; -use mlua::Thread; use runvil_types::ids::{ChainId, TaskId}; use runvil_vfs::Access; @@ -16,15 +15,17 @@ use crate::execute::section_context::{SectionContext, TaskSeed}; use crate::execute::walk_target::JumpTarget; use crate::heading_address::SectionPos; use crate::lua::{Answer, Blocked}; -use crate::prompt::{Block, BodyText, Prompt, Section}; +use crate::prompt::{Block, Prompt, Section}; use crate::{Result, error}; mod anchor; mod arena; +mod entered; mod kind; pub(super) use anchor::ChatAnchor; pub(super) use arena::{ChainArena, ChainIndex}; +pub(super) use entered::{Entered, Running}; pub(super) use kind::ChainKind; /// A chain's local id counters: the indices its next child chain and its @@ -54,6 +55,18 @@ impl BlockIndex { pub(super) fn next(self) -> BlockIndex { BlockIndex(self.0 + 1) } + + /// The line the last Lua block in `blocks` at or before this one + /// starts on, which a failure of the whole block is placed on: the + /// section's last Lua block once this index has moved past its end. + /// `None` when no Lua block comes at or before it. + pub(super) fn lua_line(self, blocks: &[Block]) -> Option { + let last = self.0.min(blocks.len().checked_sub(1)?); + blocks[..=last].iter().rev().find_map(|block| match block { + Block::Lua(program) => Some(program.source_line()), + _ => None, + }) + } } /// The deepest nesting a chain may run at, counting `call` children and @@ -132,61 +145,6 @@ impl Parked { } } -/// A Lua block that is running or suspended: its coroutine and the answer -/// delivered for it. -#[derive(Debug)] -pub(super) struct Running { - /// The block's coroutine handle; a block that returns disposes of it. - pub(super) coroutine: Thread, - /// The answer delivered for the suspended coroutine, taken at resume. - pub(super) answer: Option>, -} - -/// The section a chain has entered: built at each section entry, the H1 -/// pass's included, and taken at the section's end, at chain end, and at -/// an abort. The frame's drop is the teardown boundary on every path that -/// takes the `Entered`, and it reports `SECTION_FINISHED` only where the -/// section completed: `end_section`, `apply_jump`, and `finish` arm the -/// frame first, while the H1 pass's fall-through and scalar return, an -/// abort, and the run's teardown take it unarmed and are deliberately -/// silent. -pub(super) struct Entered { - /// The section's running or suspended Lua block, `None` between - /// blocks. Declared first: a suspended coroutine drops before the - /// frame it runs in. - pub(super) running: Option, - /// The per-section frame (VM, `sys`, conversation, counts). - pub(super) frame: SectionContext, - /// The section's in-flight or next Lua/prose block: the running - /// block's index while `running` is `Some`, otherwise the next block - /// to start. - pub(super) block: BlockIndex, - /// The pending Markdown buffer: the prose block the next Lua fence - /// consumes, installed as that block's lazy `prose` template when the - /// coroutine starts. An unconsumed buffer drops with the section, - /// never evaluated. - pub(super) pending_prose: Option, -} - -impl Entered { - /// A fresh entry into `frame`'s section: no block running, the first - /// block next, and no pending prose. - pub(super) fn new(frame: SectionContext) -> Entered { - Entered { - running: None, - frame, - block: BlockIndex::FIRST, - pending_prose: None, - } - } - - /// The block at the section's block position in `blocks`, or `None` - /// once they are exhausted. - pub(super) fn block_in<'p>(&self, blocks: &'p [Block]) -> Option<&'p Block> { - blocks.get(self.block.0) - } -} - /// A chain's walk: a stack of positions that is never empty, with the /// current position on top. Each level below the top is a suspended parent /// walk, held at the section whose jump descended from it. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/entered.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/entered.rs new file mode 100644 index 000000000..a9ca56584 --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/entered.rs @@ -0,0 +1,70 @@ +//! The chain record's [`Entered`] section and its [`Running`] block. + +use std::num::NonZeroU32; + +use mlua::Thread; + +use crate::Result; +use crate::execute::section_context::SectionContext; +use crate::lua::Answer; +use crate::prompt::{Block, BodyText}; + +use super::BlockIndex; + +/// A Lua block that is running or suspended: its coroutine and the answer +/// delivered for it. +#[derive(Debug)] +pub(in crate::execute::scheduler) struct Running { + /// The block's coroutine handle; a block that returns disposes of it. + pub(in crate::execute::scheduler) coroutine: Thread, + /// The answer delivered for the suspended coroutine, taken at resume. + pub(in crate::execute::scheduler) answer: Option>, +} + +/// The section a chain has entered: built at each section entry, the H1 +/// pass's included, and taken at the section's end, at chain end, and at +/// an abort. The frame's drop is the teardown boundary on every path that +/// takes the `Entered`, and it reports `SECTION_FINISHED` only where the +/// section completed: `end_section`, `apply_jump`, and `finish` arm the +/// frame first, while the H1 pass's fall-through and scalar return, an +/// abort, and the run's teardown take it unarmed and are deliberately +/// silent. +pub(in crate::execute::scheduler) struct Entered { + /// The section's running or suspended Lua block, `None` between + /// blocks. Declared first: a suspended coroutine drops before the + /// frame it runs in. + pub(in crate::execute::scheduler) running: Option, + /// The per-section frame (VM, `sys`, conversation, counts). + pub(in crate::execute::scheduler) frame: SectionContext, + /// The section's in-flight or next Lua/prose block: the running + /// block's index while `running` is `Some`, otherwise the next block + /// to start. + pub(in crate::execute::scheduler) block: BlockIndex, + /// The pending Markdown buffer: the prose block the next Lua fence + /// consumes, beside the prompt line it starts on, installed as that + /// block's lazy `prose` template when the coroutine starts. An + /// unconsumed buffer drops with the section, never evaluated. + pub(in crate::execute::scheduler) pending_prose: Option<(BodyText, NonZeroU32)>, +} + +impl Entered { + /// A fresh entry into `frame`'s section: no block running, the first + /// block next, and no pending prose. + pub(in crate::execute::scheduler) fn new(frame: SectionContext) -> Entered { + Entered { + running: None, + frame, + block: BlockIndex::FIRST, + pending_prose: None, + } + } + + /// The block at the section's block position in `blocks`, or `None` + /// once they are exhausted. + pub(in crate::execute::scheduler) fn block_in<'p>( + &self, + blocks: &'p [Block], + ) -> Option<&'p Block> { + blocks.get(self.block.0) + } +} diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index a13e7b575..23a2e023c 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -16,6 +16,7 @@ use std::sync::Arc; +use runvil_types::error::place; use runvil_types::event::ReplyOrigin; use crate::execute::run::Effect; @@ -240,12 +241,15 @@ impl ChainOps<'_> { /// Returns an `Internal` failure when the live chain's access /// capability is gone, which only the chain-end paths take, or a store /// failure when the handle the chain's access came from declares no store, - /// which the run's start probe already ruled out. + /// which the run's start probe already ruled out, placed on the store + /// call's line. fn dispatch_vfs(&mut self, op: VfsOp) -> Result<()> { let access = Arc::clone(self.chain.access()?); - let view = Arc::new( - runvil_vfs::detail::store_view(&access).map_err(|cause| error::store(&cause))?, - ); + let view = Arc::new(runvil_vfs::detail::store_view(&access).map_err(|cause| { + let chain = &*self.chain; + let line = chain.frame().ok().and_then(|frame| frame.vm().call_line()); + place(error::store(&cause), chain.ctx.prompt().file(), line) + })?); let effect = Effect::Vfs { access: view, op: op.clone(), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs index 8b3c30f88..18a5eed2b 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs @@ -1,10 +1,11 @@ //! The H1 pass: the prompt's H1 blocks run first as section 0, the //! driver loop's first chain, under the walk's rules with these deltas: //! the frame takes the root chain's entry 0 and fires no section -//! observations, a scalar return short-circuits the run, a Lua failure is -//! the prompt's failed hard gate, mapped to a `RequirementsUnmet` failure, -//! and the pass's end hands off to the root walk. The frame's own deltas, -//! writable `argv` among them, are listed on `SectionContext::new_h1`. +//! observations, a scalar return short-circuits the run, a block's +//! `LuaRuntime` failure is the prompt's failed hard gate, mapped to a +//! `RequirementsUnmet` failure where it failed, and the pass's end hands +//! off to the root walk. The frame's own deltas, writable `argv` among +//! them, are listed on `SectionContext::new_h1`. //! //! The pass and the walk that follows it share the root lineage `0` and //! are two arena records: the hand-off starts the walk from the pass's @@ -16,8 +17,11 @@ //! //! [`Chain::counters`]: super::Chain::counters +use std::num::NonZeroU32; use std::sync::Arc; +use runvil_types::error::place; + use crate::heading_address::{SectionIndex, SectionPos}; use crate::{Result, error}; @@ -74,20 +78,27 @@ impl Scheduler { /// /// # Errors /// Returns a `LuaRuntime` failure when the final `var` read-back fails or H1 - /// left `argv` as non-JSON data, + /// left `argv` as non-JSON data, placed on the pass's last Lua block at + /// a fall-through and on the jumping block for a jump out, /// or an `Internal` failure when the chain holds no frame, when the walk /// it hands off to cannot start because the run's chain count exceeds /// `u32`, or when the pass's capability is gone - the walk reuses it, so /// it acquires nothing and has nothing to fall back on. pub(super) fn end_h1(&mut self, id: ChainIndex, start: SectionPos) -> Result { + let prompt = self.prompt(); let chain = &mut self.chains[id]; - let Some(Entered { frame, .. }) = chain.entered.take() else { + let Some(Entered { frame, block, .. }) = chain.entered.take() else { return Err(error::internal("the H1 pass ends with a live frame")); }; - let var = frame.read_var()?; + let line = block.lua_line(chain.blocks(&prompt)); + let var = frame + .read_var() + .map_err(|error| place(error, prompt.file(), line))?; // The freeze: whatever `argv` H1 leaves behind - the derived parse // or its repair - is what every walked section inherits, frozen. - let argv = frame.read_argv()?; + let argv = frame + .read_argv() + .map_err(|error| place(error, prompt.file(), line))?; drop(frame); // The root identity spans the pass and the walk: the walk reuses // the pass's capability, so nothing the pass wrote false-conflicts @@ -139,10 +150,19 @@ impl Scheduler { /// /// # Errors /// Returns a `LuaRuntime` failure when the heading is malformed, matches no - /// top-level section, or matches more than one; the pass's own ending - /// can fail as [`end_h1`](Self::end_h1) documents. - pub(super) fn end_h1_at_jump(&mut self, id: ChainIndex, heading: &str) -> Result { - let target = self.with_chain(id, |ops| ops.resolve_chain_target(heading))?; + /// top-level section, or matches more than one, placed on `line`, the + /// `jump` call's; the pass's own ending can fail as + /// [`end_h1`](Self::end_h1) documents. + pub(super) fn end_h1_at_jump( + &mut self, + id: ChainIndex, + heading: &str, + line: Option, + ) -> Result { + let prompt = self.prompt(); + let target = self + .with_chain(id, |ops| ops.resolve_chain_target(heading)) + .map_err(|error| place(error, prompt.file(), line))?; self.end_h1(id, target.pos) } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/step.rs b/crates/runvil-internal/engine/src/execute/scheduler/step.rs index 640651737..03a8b91a3 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/step.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/step.rs @@ -4,14 +4,16 @@ //! dispatch, advance or finish a completed block, and report the chunk's //! closing observation boundary. +use std::num::NonZeroU32; + use runvil_types::emitter::Emitter; -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use runvil_types::event::lifecycle; use crate::execute::requirements::NOTICE_HEADER; use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::{Answer, CoroStep, LuaBlockResult, Request, YieldParse}; -use crate::prompt::{Block, BodyText}; +use crate::prompt::Block; use crate::{Result, error}; use super::chain::ChainStep; @@ -27,8 +29,9 @@ enum Settled { Dispatch(Request), /// The block suspended on a call answered on the spot. Answer(Result), - /// The block jumped to the heading. - Jump(String), + /// The block jumped to the heading, from the `jump` call on the + /// prompt line, when known. + Jump(String, Option), /// The block returned a scalar. Returned(String), /// The block returned no value; the section's next block is next. @@ -38,10 +41,14 @@ enum Settled { /// Settles one Lua block coroutine step in `entered`, the block's /// section, and reports the chunk's closing observation boundary under /// `name`: a yield parses into its request and leaves the block -/// suspended, and a completion moves past the block. +/// suspended, and a completion moves past the block. A failure settled +/// here is placed in the prompt `file`; `block_line` is the line the +/// block starts on. fn settle_block( entered: &mut Entered, result: Result, + file: &str, + block_line: NonZeroU32, is_h1: bool, emitter: &Emitter, name: &str, @@ -53,16 +60,23 @@ fn settle_block( // A failed H1 assertion ends the run before the walk: // H1's remaining job is the prompt's hard gates, so the // prompt chunk's own Lua failure IS the failed assertion, - // kept whole as the refusal's cause. Only the chunk's - // error remaps: the machinery around it (section setup, - // the final `var` read-back, jump-target resolution) - // keeps its own kind - a prompt bug under - // `LuaRuntime`, not an unsatisfiable environment. Fatal - // run conditions (cancellation, the claims violation) - // keep their own classification either way. + // kept whole as the refusal's cause, and the refusal is + // placed where the cause failed, else on the block's first + // line. Only the chunk's error remaps: the machinery around + // it (section setup, the final `var` read-back, jump-target + // resolution) keeps its own kind - a prompt bug under + // `LuaRuntime`, not an unsatisfiable environment. Fatal run + // conditions (cancellation, the claims violation) keep their + // own classification either way, and so does a failed `{{ }}` + // substitution, which arrives placed on its template's line. if is_h1 && error.code == ErrorCode::LuaRuntime { let notice = format!("{NOTICE_HEADER}\n- the H1 gate failed"); - return Err(error::requirements_unmet(¬ice, Some(&error))); + let (file, line) = error + .at + .as_ref() + .map_or((file, block_line), |at| (at.file.as_str(), at.start_line)); + let refusal = error::requirements_unmet(¬ice, Some(&error)); + return Err(place(refusal, file, Some(line))); } return Err(error); } @@ -74,7 +88,7 @@ fn settle_block( YieldParse::Refused(refusal) => Settled::Answer(Err(refusal)), YieldParse::Malformed(malformed) => { emitter.report(name, lifecycle::LUA_CHUNK_FAILED); - return Err(malformed); + return Err(place(malformed, file, entered.frame.vm().call_line())); } }; entered.running = Some(Running { @@ -89,14 +103,17 @@ fn settle_block( // resolved target. emitter.report(name, lifecycle::LUA_CHUNK_SUCCEEDED); match done { - LuaBlockResult::Jump(heading) => Ok(Settled::Jump(heading)), + LuaBlockResult::Jump(heading, line) => Ok(Settled::Jump(heading, line)), LuaBlockResult::Returned(Some(value)) => { // The H1 pass's final `var` read-back runs here // exactly as the walk reads it on every exit, so a // reassigned `var` global fails the run instead of // returning the value. if is_h1 { - entered.frame.read_var()?; + entered + .frame + .read_var() + .map_err(|error| place(error, file, Some(block_line)))?; } Ok(Settled::Returned(value)) } @@ -130,7 +147,7 @@ impl Scheduler { // when the slice is exhausted. return self.advance_entry(id); }; - let result = if let Some(running) = entered.running.take() { + let (block_line, result) = if let Some(running) = entered.running.take() { let Running { coroutine, answer: Some(answer), @@ -145,10 +162,11 @@ impl Scheduler { let Some(Block::Lua(program)) = entered.block_in(blocks) else { return Err(error::internal("a suspended coroutine's block is Lua")); }; - entered + let result = entered .frame .vm() - .resume_block_coro_answer(program, &coroutine, answer) + .resume_block_coro_answer(program, &coroutine, answer); + (program.source_line(), result) } else { match entered.block_in(blocks) { Some(Block::Lua(program)) => { @@ -156,20 +174,26 @@ impl Scheduler { // fresh read-only lazy `prose` template first. emitter.report(&name, lifecycle::LUA_CHUNK_STARTED); let pending = entered.pending_prose.take(); - let template = pending.as_ref().map_or("", BodyText::as_str); - if let Err(error) = entered.frame.install_lazy_prose(&chain.ctx, template) { + let (template, line) = pending + .as_ref() + .map_or(("", None), |(text, line)| (text.as_str(), Some(*line))); + if let Err(error) = entered.frame.install_lazy_prose(&chain.ctx, template, line) + { emitter.report(&name, lifecycle::LUA_CHUNK_FAILED); - return Err(error); + return Err(place(error, prompt.file(), Some(program.source_line()))); } - entered.frame.vm().start_block_coro(program) + ( + program.source_line(), + entered.frame.vm().start_block_coro(program), + ) } - Some(Block::Prose { text, .. }) => { + Some(Block::Prose { text, line, .. }) => { // The parser emits one prose block per inter-fence gap, // already accumulated and reset at thematic breaks, so the // block IS the pending buffer the next Lua fence consumes. // Prose never infers: the buffer waits for the following // block's lazy `prose` install, unevaluated until read. - entered.pending_prose = Some(text.clone()); + entered.pending_prose = Some((text.clone(), *line)); entered.block = entered.block.next(); self.ready.push_back(id); return Ok(ChainStep::Continue); @@ -190,7 +214,15 @@ impl Scheduler { } } }; - let settled = settle_block(entered, result, is_h1, emitter, &name)?; + let settled = settle_block( + entered, + result, + prompt.file(), + block_line, + is_h1, + emitter, + &name, + )?; self.apply_settled(id, settled, is_h1) } @@ -209,9 +241,11 @@ impl Scheduler { Settled::Answer(answer) => self.answer_inline(id, answer)?, // A jump out of H1 ends the pass and starts the walk at the // target. - Settled::Jump(heading) if is_h1 => return self.end_h1_at_jump(id, &heading), - Settled::Jump(heading) => { - self.with_chain(id, |ops| ops.apply_jump(&heading))?; + Settled::Jump(heading, line) if is_h1 => { + return self.end_h1_at_jump(id, &heading, line); + } + Settled::Jump(heading, line) => { + self.with_chain(id, |ops| ops.apply_jump(&heading, line))?; self.ready.push_back(id); } Settled::Returned(value) if is_h1 => { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs index c6a62b22c..fcbf789ff 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs @@ -13,7 +13,7 @@ use std::sync::Arc; -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use runvil_types::event::Event; use runvil_types::ids::{AbandonReason, Provenance, TaskId}; use runvil_vfs::Access; @@ -71,8 +71,9 @@ impl Scheduler { /// owner ended: the section ended, the tool loop was exhausted, or the /// owner failed some other way), and an `Ok` outcome that leaked /// tasks becomes a `TasksLive` failure naming them in - /// spawn order. A failing chain keeps its own error - the leak is the - /// lesser fault - but its tasks end all the same. + /// spawn order, placed on the line that spawned the first of them. A + /// failing chain keeps its own error - the leak is the lesser fault - + /// but its tasks end all the same. pub(super) fn settle_owned_tasks( &mut self, owner: ChainIndex, @@ -87,7 +88,16 @@ impl Scheduler { }; let leaked = self.abandon_owned_tasks(owner, reason); match outcome { - Ok(_) if !leaked.is_empty() => Err(error::tasks_live(&leaked)), + Ok(_) if !leaked.is_empty() => { + let line = leaked + .first() + .and_then(|task| self.tasks.spawn_line_of(task)); + Err(place( + error::tasks_live(&leaked), + self.ctx.prompt().file(), + line, + )) + } outcome => outcome, } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs index 4a10178d9..8da78a842 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -61,10 +61,12 @@ impl Scheduler { /// `args` and `var` snapshot, with its own access capability (a /// concurrent thread of execution, forked from the spawner's so the /// spawn is the happens-before edge; every delivery of the task joins - /// it back) and a fresh turn counter. The child leaves this call - /// holding no Lua VM yet, with its `var` snapshot moved into the chain, - /// and its spawn record comes back for the admission queue; the drain - /// admits it when an admission slot frees up. + /// it back) and a fresh turn counter. The task's entry keeps the + /// spawner's call line, the prompt line it spawned from. The child + /// leaves this call holding no Lua VM yet, with its `var` snapshot + /// moved into the chain, and its spawn record comes back for the + /// admission queue; the drain admits it when an admission slot frees + /// up. fn prepare_spawn( &mut self, id: ChainIndex, @@ -83,6 +85,7 @@ impl Scheduler { // at admission, which may come after the spawner's walk moved to // another section. let spawner_section = chain.section_name().to_owned(); + let spawn_line = chain.frame()?.vm().call_line(); // An explicit input forks the chain's args (and `argv` re-derives // from them), as a `call` with input does; otherwise the chain // inherits the spawner's context whole, but for the turn counter, @@ -156,6 +159,7 @@ impl Scheduler { TaskBacking::Chain { chain: child, exec }, id, worker.name().to_owned(), + spawn_line, ); // The task waits for admission from here, and the spawn record // holds what the start event reports once the task first runs. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs index 9d4bfef0d..6069d5e6a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs @@ -37,7 +37,7 @@ fn chain_backing(raw: u32) -> TaskBacking { /// backed by the chain at arena index `100 + index`. fn spawn(arena: &mut TaskArena, owner: ChainIndex, index: u32) -> (TaskId, TaskBacking) { let backing = chain_backing(100 + index); - arena.insert(task(index), backing, owner, format!("worker {index}")); + arena.insert(task(index), backing, owner, format!("worker {index}"), None); (task(index), backing) } @@ -45,7 +45,7 @@ fn spawn(arena: &mut TaskArena, owner: ChainIndex, index: u32) -> (TaskId, TaskB /// owned by `owner`. fn timer(arena: &mut TaskArena, owner: ChainIndex, index: u32) -> TaskId { let backing = TaskBacking::Effect(EffectId(u64::from(index))); - arena.insert(task(index), backing, owner, "timer".to_owned()); + arena.insert(task(index), backing, owner, "timer".to_owned(), None); task(index) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs index 43c5ec100..2df3a2da7 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs @@ -22,6 +22,7 @@ mod spawn; use std::collections::BTreeMap; +use std::num::NonZeroU32; use runvil_types::ids::TaskId; use runvil_vfs::ExecId; @@ -126,6 +127,11 @@ pub(super) struct TaskEntry { /// The latest progress note published through `tasks.note` from inside /// the task, reported by `tasks.status`. pub(super) note: Option, + /// The prompt line of the author's call that spawned the task, which a + /// `tasks_live` failure that names it first is placed on: `None` for + /// an internal timer, and for a spawn whose yield found no author + /// frame. + pub(super) spawn_line: Option, } impl TaskEntry { @@ -159,13 +165,14 @@ pub(super) struct TaskArena { impl TaskArena { /// Registers a running task that `owner` started over `backing`, - /// reporting under `target`. + /// reporting under `target`, by the call on `spawn_line`. pub(super) fn insert( &mut self, task: TaskId, backing: TaskBacking, owner: ChainIndex, target: String, + spawn_line: Option, ) { let entry = TaskEntry { backing, @@ -173,6 +180,7 @@ impl TaskArena { target, state: EntryState::Running, note: None, + spawn_line, }; self.entries.insert(task, entry); } @@ -373,6 +381,11 @@ impl TaskArena { self.entries.get(task).map(|entry| entry.target.as_str()) } + /// The prompt line of the call that spawned `task`, when known. + pub(super) fn spawn_line_of(&self, task: &TaskId) -> Option { + self.entries.get(task)?.spawn_line + } + /// `task`'s state, or `None` when no task with that id was started. #[cfg(test)] pub(super) fn state_of(&self, task: &TaskId) -> Option { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs index 8444dfeef..5ccfc2bc6 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs @@ -1,7 +1,8 @@ //! Test-only hooks over the scheduler's private state: the seams the //! `execute::tests` suites drive the scheduler's edge paths through -//! (overflow bounds, the effect table, task entries, and answer delivery) -//! and the turn counter and tool set they read. Compiled only under test; nothing here +//! (overflow bounds, the effect table, task entries, answer delivery, and +//! a store dispatch with no store) and the turn counter and tool set they +//! read. Compiled only under test; nothing here //! exists in a shipped Engine. use std::num::NonZeroUsize; @@ -9,11 +10,11 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use runvil_types::ids::{ChainId, TaskId}; -use runvil_vfs::{Origin, detail}; +use runvil_vfs::{MemoryBackend, Origin, VfsRef, detail}; use crate::execute::run::EffectId; use crate::heading_address::{SectionIndex, SectionPos}; -use crate::lua::{Answer, ToolSet}; +use crate::lua::{Answer, Request, ToolSet, VfsOp}; use crate::{Error, Result}; use super::chain::ChainStart; @@ -70,6 +71,7 @@ impl Scheduler { TaskBacking::Chain { chain: wedge, exec }, owner, "wedge".to_owned(), + None, ); self.admission.hold_for_test(wedge); } @@ -158,6 +160,26 @@ impl Scheduler { ([first, second], holds_first) } + /// Dispatches `op` again for the chain at arena index 0, whose block + /// is suspended on a store request, through an access from a handle + /// that declares no store: the store-view failure the run's start + /// probe otherwise rules out. + /// + /// # Errors + /// Returns the dispatch's store failure. + pub(crate) fn dispatch_store_without_a_store_for_test(&mut self, op: VfsOp) -> Result<()> { + let (id, _) = self + .chains + .iter() + .next() + .expect("the run has started a chain"); + let access = VfsRef::new(MemoryBackend::new()) + .acquire(Origin::new("no store")) + .expect("a memory backend acquires"); + self.chains[id].access = Some(Arc::new(access)); + self.dispatch(id, Request::Store { op }) + } + /// Starts a root chain over the top-level slice at `index` under a /// fresh access, entering no section. fn start_probe_chain(&mut self, index: usize) -> Result { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs index 6c0ee6b72..81ccd3b9a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs @@ -57,6 +57,7 @@ impl Scheduler { TaskBacking::Effect(effect), id, "timer".to_owned(), + None, ); Ok(task) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs index da67c1bcd..d2188ba69 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs @@ -10,8 +10,11 @@ //! ([`Chain::lineage`](super::Chain::lineage)); a prompt without H1 blocks //! starts the root walk directly. +use std::num::NonZeroU32; use std::sync::Arc; +use runvil_types::error::place; + use crate::execute::section_context::SectionContext; use crate::execute::walk_target::{ JumpTarget, h1_visible_sections, resolve_jump_target, section_position, @@ -163,14 +166,21 @@ impl ChainOps<'_> { /// /// # Errors /// Returns a `LuaRuntime` failure when the final `var` read-back fails (the - /// frame drops unarmed), or an `Internal` failure when the chain holds no - /// frame. + /// frame drops unarmed), placed on the section's last Lua block, or an + /// `Internal` failure when the chain holds no frame. pub(super) fn end_section(&mut self) -> Result<()> { + let prompt = self.prompt(); let chain = &mut *self.chain; - let Some(Entered { mut frame, .. }) = chain.entered.take() else { + let blocks = chain.blocks(&prompt); + let Some(Entered { + mut frame, block, .. + }) = chain.entered.take() + else { return Err(error::internal("a section end implies a live frame")); }; - chain.var = frame.read_var()?; + chain.var = frame + .read_var() + .map_err(|error| place(error, prompt.file(), block.lua_line(blocks)))?; frame.mark_completed(); drop(frame); chain.walk.advance(); @@ -188,20 +198,30 @@ impl ChainOps<'_> { /// /// # Errors /// Returns a `LuaRuntime` failure when the `var` read-back fails (the - /// frame drops unarmed) or when the heading matches no visible section - /// or more than one - the jumper's frame has already closed as - /// completed. - pub(super) fn apply_jump(&mut self, heading: &str) -> Result<()> { + /// frame drops unarmed), placed on the jumping block's line, or when + /// the heading matches no visible section or more than one, placed on + /// `line`, the `jump` call's - the jumper's frame has already closed + /// as completed. + pub(super) fn apply_jump(&mut self, heading: &str, line: Option) -> Result<()> { + let prompt = self.prompt(); { let chain = &mut *self.chain; - let Some(Entered { mut frame, .. }) = chain.entered.take() else { + let blocks = chain.blocks(&prompt); + let Some(Entered { + mut frame, block, .. + }) = chain.entered.take() + else { return Err(error::internal("a jump implies a live frame")); }; - chain.var = frame.read_var()?; + chain.var = frame + .read_var() + .map_err(|error| place(error, prompt.file(), block.lua_line(blocks)))?; frame.mark_completed(); drop(frame); } - let target = self.resolve_chain_target(heading)?; + let target = self + .resolve_chain_target(heading) + .map_err(|error| place(error, prompt.file(), line))?; self.chain.walk.jump(target); Ok(()) } diff --git a/crates/runvil-internal/engine/src/execute/section_context.rs b/crates/runvil-internal/engine/src/execute/section_context.rs index 60b037647..0ea048fc6 100644 --- a/crates/runvil-internal/engine/src/execute/section_context.rs +++ b/crates/runvil-internal/engine/src/execute/section_context.rs @@ -19,6 +19,7 @@ #[path = "section_context-construct.rs"] mod construct; +use std::num::NonZeroU32; use std::sync::Arc; use runvil_types::event::lifecycle; @@ -195,16 +196,21 @@ impl SectionContext { /// Installs the scheduler's pending Markdown buffer, empty when no /// Markdown accumulated, as the VM's lazy `prose` template before one /// Lua coroutine starts, rendered through this frame's - /// [`prose_renderer`]; [`SectionVm::install_lazy_prose`] states when it - /// renders. + /// [`prose_renderer`] with `line`, the prompt line the buffer starts + /// on; [`SectionVm::install_lazy_prose`] states when it renders. /// /// # Errors /// Returns a `LuaRuntime` failure if the guard cannot be installed. - pub(super) fn install_lazy_prose(&self, ctx: &ChainState, template: &str) -> Result<()> { + pub(super) fn install_lazy_prose( + &self, + ctx: &ChainState, + template: &str, + line: Option, + ) -> Result<()> { let template = template.to_owned(); let render = prose_renderer(ctx, self.item.clone()); self.vm() - .install_lazy_prose(move |state: ProseState<'_>| render(&template, state)) + .install_lazy_prose(move |state: ProseState<'_>| render(&template, line, state)) } /// The frame's effective reporting handles for the `chat` rounds the @@ -278,14 +284,18 @@ fn live_vm(vm: Option<&SectionVm>) -> &SectionVm { /// The `{{ }}` renderer for one frame's prose templates, its own blocks' /// and the libraries `require` loads alike: each render substitutes from /// the run's `args` and `argv`, shared with the run rather than copied, -/// the frame's `item`, and the section state the first read snapshots. +/// the frame's `item`, and the section state the first read snapshots, +/// and places a failure in the run's prompt file from the line the +/// template starts on. fn prose_renderer( ctx: &ChainState, item: Option, -) -> impl Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static { +) -> impl Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static +{ let raw_args = Arc::clone(ctx.args()); let argv = ctx.argv().cloned(); - move |template: &str, state: ProseState<'_>| -> mlua::Result { + let prompt = Arc::clone(ctx.prompt()); + move |template: &str, line: Option, state: ProseState<'_>| -> mlua::Result { let sources = subst::Sources { args: &raw_args, argv: argv.as_deref(), @@ -293,6 +303,8 @@ fn prose_renderer( var: &state.var, sys: &state.sys, globals: state.globals, + file: prompt.file(), + line, }; subst::substitute(template, &sources).map_err(mlua::Error::external) } diff --git a/crates/runvil-internal/engine/src/execute/section_vm.rs b/crates/runvil-internal/engine/src/execute/section_vm.rs index 9e35e57b4..f555a9dfe 100644 --- a/crates/runvil-internal/engine/src/execute/section_vm.rs +++ b/crates/runvil-internal/engine/src/execute/section_vm.rs @@ -17,6 +17,7 @@ //! consumes the uninjected VM, so a setup failure tears it down here, the //! one place a VM that never reached its frame is torn down. +use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use runvil_types::emitter::Emitter; @@ -134,7 +135,7 @@ where + Send + Sync + 'static, - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { vm.set_prompt_file(setup.file); // The raw-model-id fallback reads its flag during Engine injection, so @@ -175,7 +176,7 @@ where + Send + Sync + 'static, - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { vm.install_engine_globals(setup.emitter, setup.section_name)?; if let Some(snapshot) = setup.ui { diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index 8ab6cbf0b..02998e4d8 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -24,7 +24,7 @@ use crate::test_support::scripted_chat::{ MOCK_MODEL, ScriptedCall, ScriptedChat, ScriptedReply, calls, fail, text, text_finish, }; use crate::test_support::seeded::Seeded; -use crate::test_support::{FnTool, authored, fresh_access, placed, refused}; +use crate::test_support::{FnTool, at_line, authored, fresh_access, placed, refused}; mod context; mod fixtures; diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs index 7b76776b5..cc68e8060 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -248,6 +248,12 @@ fn two_arms_writing_one_path_terminate_the_run_with_a_determinism_violation() { 2, "both arms' identities are named: {detail}" ); + assert_eq!( + error.at, + at_line(19), + "the violation, which never reaches Lua, is placed on the losing arm's \ + `store.write` line: {error:?}" + ); let recorder = recorded(&trace); assert_eq!( terminal_count(&trace, task_started), diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs index 2a2a5f101..100e7061e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs @@ -94,6 +94,29 @@ fn jump_from_h1_to_an_unknown_section_fails_the_run() { ); } +#[test] +fn a_jump_from_h1_to_a_missing_heading_fails_on_the_jump_line() { + // Jump-target resolution is the machinery around the chunk, not the + // failed gate, so it keeps its own kind on the `jump` call's line. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Test prompt\n\n\ + ```lua\n\ + var.before = true\n\ + jump('## Nope')\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn 'unexpected'\n```\n"; + let trace = h1_scenario(md).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::LuaRuntime, "{error:?}"); + assert_eq!( + error.at, + at_line(11), + "the `jump` call's line, not the block's first: {error:?}" + ); +} + #[test] fn fanout_from_h1_runs_the_worker_over_the_collection() { // `fanout` works in H1 as in any section: the worker resolves against diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs index 5b96a740e..d5b33a629 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/pass.rs @@ -1,8 +1,9 @@ //! The H1 pass itself: its infer and default-model selection, the //! root entry ids of the chunk and the first walked section, H1 assertion -//! failures and their reports, `var` read back after a scalar return, a -//! failing H1 library as the failed gate, and the section boundaries the -//! pass does not fire. +//! failures and their reports and lines, `var` and `argv` read back after a +//! scalar return and at the pass's end, a failed substitution that keeps +//! its own code, a failing H1 library as the failed gate, and the section +//! boundaries the pass does not fire. use super::*; use crate::execute::tests::context::sequence; @@ -173,6 +174,75 @@ fn an_h1_scalar_return_still_reads_var_back() { error.to_string().contains("global was reassigned"), "the read-back failure must name the cause: {error}" ); + assert_eq!( + error.at, + at_line(10), + "the read-back fails on the returning block's line: {error:?}" + ); +} + +#[test] +fn an_h1_argv_read_back_failure_is_placed_on_the_pass_last_lua_block() { + // The pass falls through, so its block index is past its last block: + // the failure lands on the last Lua block's first line. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Repair\n\n\ + ```lua\n\ + local broken = true\n\ + argv = function() end\n\ + ```\n\n\ + Trailing commentary.\n\n\ + ## Result\n\n\ + ```lua\nreturn 'unexpected'\n```\n"; + let trace = h1_scenario(md).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::LuaRuntime, "{error:?}"); + assert_eq!(error.at, at_line(10), "{error:?}"); +} + +#[test] +fn a_failed_h1_gate_is_placed_on_its_causes_line() { + let md = "---\nname: h1-gate\ndescription: d\nrunvil: 0\n---\n\n\ + # Gate\n\n\ + ```lua\n\ + local ready = false\n\ + assert(ready, 'the gate cannot hold')\n\ + ```\n"; + let trace = h1_scenario(md).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::RequirementsUnmet, "{error:?}"); + assert_eq!( + error.at, + at_line(11), + "the refusal is where its cause failed, not the block's first line: {error:?}" + ); + assert!( + matches!( + trace.outcome(), + RunOutcome::Failed { message, .. } if message.ends_with(" (at prompt:11)") + ), + "{:?}", + trace.outcome() + ); +} + +#[test] +fn a_failed_substitution_in_h1_keeps_its_code_on_its_template_line() { + // A `{{ }}` failure is no failed gate: it arrives placed on its + // template's line with its own code, which the gate never remaps. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Prose Gate\n\n\ + Needs {{ var.missing }}.\n\n\ + ```lua\n\ + return prose\n\ + ```\n"; + let trace = h1_scenario(md).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::Substitution, "{error:?}"); + assert_eq!(error.at, at_line(9), "{error:?}"); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/jumps.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/jumps.rs index cb57d0a47..6bc6305ff 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/jumps.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/jumps.rs @@ -1,8 +1,8 @@ //! Jumps and observation boundaries: a jump skips the jumper's remaining //! blocks, cannot target its own section, runs an off-walk target, and //! keeps `var`; the jumper finishes before its target starts, even when -//! the target does not resolve, and an erroring section reports started -//! but not finished. +//! the target does not resolve, which fails on the `jump` call's line, and +//! an erroring section reports started but not finished. use super::*; use crate::execute::tests::context::sequence; @@ -175,3 +175,23 @@ fn a_failed_jump_resolution_still_finishes_the_jumper() { "the jumper completed before the resolution failed: {observed:?}" ); } + +#[test] +fn a_jump_to_a_missing_heading_fails_on_the_jump_line() { + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Unresolved\n\n\ + ## A\n\n\ + ```lua\n\ + var.before = true\n\ + jump('## Missing')\n\ + ```\n"; + let trace = scheduler_scenario(md).run(); + + let error = trace.error(); + assert!(error.to_string().contains("not found"), "{error}"); + assert_eq!( + error.at, + at_line(13), + "the `jump` call's line, not the block's first: {error:?}" + ); +} diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs index e1e640fb7..413ca6118 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/globals.rs @@ -1,10 +1,11 @@ //! The globals a section reads: the `tasks` namespace, `item`, `var` across -//! the walk, `call`, and fanout arms, bare globals in prose, and the `sys` -//! metadata. +//! the walk, `call`, and fanout arms and the line its failed read-back is +//! placed on, bare globals in prose, and the `sys` metadata. use super::*; use crate::RunErrorKind; use crate::execute::run_error_kind; +use crate::test_support::at_line; /// The `tasks` global is the task namespace (`tasks.spawn` and the /// waits), not a control-flow table: indexing it by a heading string reads @@ -162,6 +163,88 @@ fn var_write_of_a_function_fails_the_run() { ); } +/// The `var` read-back at a section's fall-through fails on the section's +/// last Lua block, the walk having moved past it and its trailing prose. +#[test] +fn a_var_read_back_failure_at_fall_through_is_placed_on_the_last_lua_block() { + let source = PromptSource::new().prose( + "Only", + " + ```lua + var.first = true + ``` + + ```lua + local later = true + var = 5 + ``` + + Trailing commentary.", + ); + let error = Scenario::new(source.build()).run().error().clone(); + assert!( + error.to_string().contains("global was reassigned"), + "{error}" + ); + assert_eq!(error.at, at_line(16), "{error:?}"); +} + +/// The `var` read-back at a jump fails on the jumping block, not on the +/// section's last one. +#[test] +fn a_var_read_back_failure_at_a_jump_is_placed_on_the_jumping_block() { + let source = PromptSource::new() + .prose( + "A", + " + ```lua + local leaving = true + var = 5 + jump('## B') + ``` + + ```lua + error('the jump leaves this block unrun') + ```", + ) + .section("B", "return 'unreached'"); + let error = Scenario::new(source.build()).run().error().clone(); + assert!( + error.to_string().contains("global was reassigned"), + "{error}" + ); + assert_eq!(error.at, at_line(12), "{error:?}"); +} + +/// The `var` read-back at a scalar return fails on the returning block, +/// not on the section's last one. +#[test] +fn a_var_read_back_failure_at_a_return_is_placed_on_the_returning_block() { + let source = PromptSource::new().prose( + "Only", + " + ```lua + var.first = true + ``` + + ```lua + local done = true + var = 5 + return 'early' + ``` + + ```lua + error('the return leaves this block unrun') + ```", + ); + let error = Scenario::new(source.build()).run().error().clone(); + assert!( + error.to_string().contains("global was reassigned"), + "{error}" + ); + assert_eq!(error.at, at_line(16), "{error:?}"); +} + /// A bare global (`x = 42` without `local`) resolves in prose read through /// the lazy `prose` value, with dotted paths indexing into a table global /// and a whole table rendering as JSON. diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs index d966c2886..da1a1cb14 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/store_failures.rs @@ -1,6 +1,6 @@ //! How store failures reach the author and the run: caught error tables, -//! uncaught run errors, the store view's confinement, and write conflicts -//! from library code. +//! uncaught run errors, the store view's confinement and the line its +//! failure is placed on, and write conflicts from library code. use runvil_lua::VfsOp; use runvil_types::error::ErrorCode; @@ -8,8 +8,10 @@ use runvil_types::error::ErrorCode; use super::*; use crate::RunErrorKind; -use crate::execute::EffectRecord; use crate::execute::run_error_kind; +use crate::execute::{Effect, EffectRecord}; +use crate::test_support::at_line; +use crate::test_support::rig::Rig; /// A store failure the author caught with `pcall` is an error table of /// kind `store`: its `code`, `reason`, `path`, and `anchor` fields and its @@ -41,6 +43,30 @@ fn a_caught_store_error_is_a_store_table_with_reason_fields_and_prose() { assert_eq!(Scenario::new(source).run().text(), "ok"); } +/// A store view the chain cannot derive ends the chain without reaching +/// Lua, so it is placed on the store call's line. The run's start probe +/// rules out a missing store, so the hook re-dispatches the parked write +/// through an access whose handle declares none. +#[test] +fn a_store_view_failure_is_placed_on_the_store_calls_line() { + let source = one_section( + "local ready = true + store.write('a.txt', 'x')", + ); + let mut rig = + Rig::start(Scenario::new(source).hold(|effect| matches!(effect, Effect::Vfs { .. }))); + rig.settle(); + let error = rig + .scheduler_for_test() + .dispatch_store_without_a_store_for_test(VfsOp::Write { + path: "a.txt".to_owned(), + contents: "x".to_owned(), + }) + .expect_err("a handle that declares no store has no store view"); + assert_eq!(error.code, ErrorCode::NoStore, "{error:?}"); + assert_eq!(error.at, at_line(13), "{error:?}"); +} + /// A caught `store.read` failure names the read's own line, which the read's /// yield recorded, not the line of the `pcall` that caught it. #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/lazy_prose.rs b/crates/runvil-internal/engine/src/execute/tests/suite/lazy_prose.rs index 73ef1034d..8579baf7d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/lazy_prose.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/lazy_prose.rs @@ -1,13 +1,17 @@ //! Lazy `prose` behavior and section-boundary handoff: the pending //! Markdown buffer installs as a fresh read-only lazy `prose` template //! before each Lua coroutine starts, every `{{ }}` substitution evaluates -//! once on the first runtime read and memoizes, unconsumed buffers discard -//! at section end, and no `reply` global or automatic result handoff -//! crosses a section boundary. +//! once on the first runtime read and memoizes, a failed one is placed on +//! its placeholder's line, unconsumed buffers discard at section end, and +//! no `reply` global or automatic result handoff crosses a section +//! boundary. use runvil_prompt::test_support::{PromptSource, one_section, prompt}; +use runvil_types::error::ErrorCode; +use crate::test_support::at_line; use crate::test_support::scenario::Scenario; +use crate::{RunErrorKind, RunOutcome}; /// The prompt whose one section `Only` holds the Markdown `body`. fn only(body: &str) -> String { @@ -125,6 +129,7 @@ fn recursive_prose_reference_is_rejected_at_the_read_site() { #[test] fn pcall_catches_a_read_site_substitution_error() { + // The caught error names the template's line, not the read's. let source = only( " Missing: {{ var.missing }}. @@ -132,17 +137,23 @@ fn pcall_catches_a_read_site_substitution_error() { ```lua local ok, err = pcall(function() return prose end) assert(not ok, 'the read must fail') - assert(tostring(err):match('missing'), 'the substitution error surfaces: ' .. tostring(err)) - return 'caught' + return tostring(err) ```", ); - assert_eq!(Scenario::new(source).run().text(), "caught"); + let caught = Scenario::new(source).run().text().to_owned(); + assert!( + caught.starts_with("prompt:11: missing {{ var.missing }}"), + "the substitution error surfaces on its template's line: {caught}" + ); } #[test] fn an_uncaught_read_error_fails_the_run() { + // The placeholder is on the template's second line, and the run fails + // with the substitution's own code there. let source = only( " + Intro. Missing: {{ var.missing }}. ```lua @@ -150,11 +161,26 @@ fn an_uncaught_read_error_fails_the_run() { ```", ); let trace = Scenario::new(source).run(); - let rendered = trace.error().to_string(); + let error = trace.error(); + let rendered = error.to_string(); assert!( rendered.contains("missing {{ var.missing }}"), "the substitution error reaches the run failure: {rendered}" ); + assert_eq!(error.code, ErrorCode::Substitution, "{error:?}"); + assert_eq!( + error.at, + at_line(12), + "the placeholder's line, not the read's: {error:?}" + ); + assert!( + matches!( + trace.outcome(), + RunOutcome::Failed { kind: RunErrorKind::Lua, message } if message.ends_with(" (at prompt:12)") + ), + "{:?}", + trace.outcome() + ); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/require/prose.rs b/crates/runvil-internal/engine/src/execute/tests/suite/require/prose.rs index 615d797ef..70dfcfe7f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/require/prose.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/require/prose.rs @@ -1,9 +1,38 @@ //! Library prose: a library block reads its own `prose`, rendered from //! the caller's environment, and the caller's `prose` comes back //! unchanged, whether the caller read it first or not and whether the -//! library finished or raised. +//! library finished or raised; a failed substitution in it is placed on +//! the library's own template line. + +use runvil_types::error::ErrorCode; use super::*; +use crate::test_support::at_line; + +#[test] +fn a_failed_substitution_in_library_prose_is_placed_on_the_librarys_template_line() { + let source = PromptSource::new() + .section("Main", "return require '## lib'") + .prose( + "lib", + " + Intro. + Needs {{ var.missing }}. + + ```lua + return prose + ```", + ); + let trace = require_case(source.build()).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::Substitution, "{error:?}"); + assert_eq!( + error.at, + at_line(18), + "the placeholder's line in the library, not the caller's `require` line: {error:?}" + ); +} #[test] fn a_library_hands_its_prose_to_the_caller_and_leaves_the_callers_prose_alone() { diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs index 93bd5d11a..c2e0ddd5a 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks-chain-end.rs @@ -52,8 +52,8 @@ fn a_chain_ending_with_live_author_tasks_fails_as_tasks_live_naming_the_ids() { assert_eq!( error, - &crate::error::tasks_live(&[task("0.0"), task("0.1")]), - "leaked ids in spawn order" + &crate::test_support::placed(crate::error::tasks_live(&[task("0.0"), task("0.1")]), 12), + "leaked ids in spawn order, on the line that spawned the first" ); let text = error.to_string(); assert!( @@ -157,7 +157,7 @@ fn a_returning_owner_abandons_its_running_task_as_owner_returned() { let rig = run_abandoning_a_running_task(scheduler_scenario(md), AbandonReason::OwnerReturned); assert_eq!( rig.trace().error(), - &crate::error::tasks_live(&[task("0.0")]) + &crate::test_support::placed(crate::error::tasks_live(&[task("0.0")]), 12) ); } @@ -235,8 +235,8 @@ fn assert_task_outlives_movement(md: String) { assert_eq!( rig.trace().error(), - &crate::error::tasks_live(&[task("0.0")]), - "the walk still owns the task when the sibling returns" + &crate::test_support::placed(crate::error::tasks_live(&[task("0.0")]), 12), + "the walk still owns the task when the sibling returns, spawned on the spawner's line" ); assert_eq!( rig.task_state_for_test(&task("0.0")), @@ -308,6 +308,47 @@ fn a_call_chain_ending_with_a_live_task_answers_tasks_live_to_its_caller() { ); } +#[test] +fn a_fanout_arm_leaking_a_task_fails_on_the_arms_own_spawn_line() { + // The arm's `tasks_live` is placed in the arm, on its spawn's line, + // before the fanout delivers it, so the parent's re-raise and the run + // keep that line rather than the parent's `tasks.fanout` line. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Tasks\n\n\ + ## Main\n\n\ + ```lua\n\ + return tasks.fanout('## Worker', {'only'})[1].text\n\ + ```\n\n\ + ## Worker\n\n\ + ```lua\n\ + local note = item\n\ + tasks.spawn('## Child')\n\ + return note\n\ + ```\n\n\ + ## Child\n\n\ + ```lua\n\ + store.write('park', 'x')\n\ + return 'never'\n\ + ```\n"; + let trace = scheduler_scenario(md).run(); + + let error = trace.error(); + assert_eq!(error.code, ErrorCode::TasksLive, "{error:?}"); + assert_eq!( + error.at, + at_line(19), + "the Worker's `tasks.spawn` line, not Main's fanout line: {error:?}" + ); + assert!( + matches!( + trace.outcome(), + RunOutcome::Failed { message, .. } if message.ends_with(" (at prompt:19)") + ), + "the run's message ends with where it failed: {:?}", + trace.outcome() + ); +} + #[test] fn a_task_spawned_in_h1_belongs_to_the_main_walk() { // H1 spawns a child that parks; the walk's first section returns at @@ -335,8 +376,8 @@ fn a_task_spawned_in_h1_belongs_to_the_main_walk() { assert_eq!( rig.trace().error(), - &crate::error::tasks_live(&[task("0.0")]), - "the walk owns H1's task and leaks it" + &crate::test_support::placed(crate::error::tasks_live(&[task("0.0")]), 10), + "the walk owns H1's task and leaks it, placed on the H1's spawn" ); assert_eq!( rig.task_state_for_test(&task("0.0")), diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs index 3d86d329d..f8bac2be2 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs @@ -1,7 +1,8 @@ //! Tests for the scheduler's `tool_call` arm: the model-issued form //! (`model: Some`), which always resumes with content and reports its //! `ToolResult` under the model's call id; the script form (`model: -//! None`), which keeps the raise-at-call-site behavior; adhoc tools, +//! None`), which keeps the raise-at-call-site behavior; a malformed +//! model-issued yield, which fails the block on its call line; adhoc tools, //! whose handlers run inside the calling block coroutine - store calls, //! nested adhoc calls, and bound calls included - with no leaf work of //! their own. @@ -171,6 +172,22 @@ fn a_bound_alias_with_no_implementation_in_the_test_driver_table_resumes_as_a_to ); } +#[test] +fn a_malformed_yield_fails_the_block_on_the_line_that_yielded() { + // A `call_id` with no `turn` is a shim-internal field of the wrong + // shape, which only a broken shim yields: the block fails before any + // dispatch, on the call line its yield recorded. + let source = one_section( + "local ready = true + return tools.call_as_model('call_1', 'tools/echo', {}, nil)", + ); + let trace = arm_scenario(source).run(); + let error = trace.error(); + assert_eq!(error.code, ErrorCode::LuaRuntime, "{error:?}"); + assert!(error.message.contains("yield directly"), "{error:?}"); + assert_eq!(error.at, at_line(13), "{error:?}"); +} + /// The `tool_result` lines `recorder` saw, in order. pub(super) fn tool_result_lines(recorder: &ToolRecorder) -> Vec { recorder diff --git a/crates/runvil-internal/engine/src/execute/walk_target-tests.rs b/crates/runvil-internal/engine/src/execute/walk_target-tests.rs index 4024ec1a3..ef63caccd 100644 --- a/crates/runvil-internal/engine/src/execute/walk_target-tests.rs +++ b/crates/runvil-internal/engine/src/execute/walk_target-tests.rs @@ -71,6 +71,15 @@ fn templates(library: &Library) -> Vec<&str> { .collect() } +/// The prompt line each block's template starts on. +fn template_lines(library: &Library) -> Vec> { + library + .blocks + .iter() + .map(|block| block.line.map(std::num::NonZeroU32::get)) + .collect() +} + #[test] fn a_library_pairs_each_program_with_the_prose_right_before_it() { let section = synthetic_section( @@ -90,6 +99,11 @@ fn a_library_pairs_each_program_with_the_prose_right_before_it() { assert_eq!(library.heading, "## Lib"); assert_eq!(sources(&library), ["first = 1", "second = 2", "third = 3"]); assert_eq!(templates(&library), ["Intro.", "", "Later."]); + assert_eq!( + template_lines(&library), + [Some(1), None, Some(1)], + "a block with no prose right before it has no template line" + ); } #[test] @@ -100,6 +114,12 @@ fn resolve_library_builds_a_parsed_section_without_its_trailing_prose() { assert_eq!(library.heading, "## Lib"); assert_eq!(sources(&library), ["first = 1", "second = 2", "third = 3"]); assert_eq!(templates(&library), ["Intro.", "", "Later."]); + let lines = template_lines(&library); + assert_eq!( + (lines[0], lines[2]), + (Some(43), Some(53)), + "each template keeps the file line it starts on: {lines:?}" + ); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/walk_target.rs b/crates/runvil-internal/engine/src/execute/walk_target.rs index 6148eee67..21c04bfbe 100644 --- a/crates/runvil-internal/engine/src/execute/walk_target.rs +++ b/crates/runvil-internal/engine/src/execute/walk_target.rs @@ -196,22 +196,27 @@ pub(super) fn resolve_library( } /// The library `section` makes under `key`: each Lua block in order, -/// paired with the prose block right before it, or with an empty template -/// when no prose block comes right before it. Prose after the last Lua -/// block pairs with nothing. +/// paired with the prose block right before it and the line that prose +/// starts on, or with an empty template and no line when no prose block +/// comes right before it. Prose after the last Lua block pairs with +/// nothing. /// /// # Errors /// Returns an `Internal` failure for a block kind the Engine cannot run. fn build_library(section: &Section, key: LibraryKey) -> Result { let mut blocks = Vec::new(); - let mut pending = ""; + let mut pending = None; for block in section.blocks() { match block { - Block::Prose { text, .. } => pending = text.as_str(), - Block::Lua(program) => blocks.push(LibraryBlock { - program: program.clone(), - prose: std::mem::take(&mut pending).to_owned(), - }), + Block::Prose { text, line, .. } => pending = Some((text.as_str(), *line)), + Block::Lua(program) => { + let (prose, line) = pending.take().unzip(); + blocks.push(LibraryBlock { + program: program.clone(), + prose: prose.unwrap_or_default().to_owned(), + line, + }); + } // `Block` is `#[non_exhaustive]` across the crate seam; an // unlisted variant has no library form. _ => return Err(error::internal("an unrecognized block kind cannot load")), diff --git a/crates/runvil-internal/engine/src/lua/tests.rs b/crates/runvil-internal/engine/src/lua/tests.rs index 2b4410671..03d0220b8 100644 --- a/crates/runvil-internal/engine/src/lua/tests.rs +++ b/crates/runvil-internal/engine/src/lua/tests.rs @@ -147,7 +147,7 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { "no library `{heading}` in reach" ))) }; - setup_section_vm(vm, &setup, read, resolve, |template, _| { + setup_section_vm(vm, &setup, read, resolve, |template, _, _| { Ok(template.to_owned()) }) .expect("the setup installs") diff --git a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs index 4b52b7517..e06800ac4 100644 --- a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs +++ b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs @@ -104,7 +104,7 @@ fn jump_transfers_through_thread_resume_unchanged() { .start_block_coro(&program) .expect("a jump is not a failure") { - CoroStep::Done(LuaBlockResult::Jump(heading)) => assert_eq!(heading, "## Target"), + CoroStep::Done(LuaBlockResult::Jump(heading, _)) => assert_eq!(heading, "## Target"), other => panic!("expected the jump transfer, got {other:?}"), } } diff --git a/crates/runvil-internal/engine/src/subst-tests.rs b/crates/runvil-internal/engine/src/subst-tests.rs index df04e0980..842278739 100644 --- a/crates/runvil-internal/engine/src/subst-tests.rs +++ b/crates/runvil-internal/engine/src/subst-tests.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; +use runvil_types::error::Location; + use super::*; use serde_json::json; @@ -17,8 +19,12 @@ fn no_globals(_: &str) -> Result> { Ok(None) } +/// The prompt line the stock sources' prose starts on. +const PROSE_LINE: NonZeroU32 = NonZeroU32::new(7).expect("7 is non-zero"); + /// Sources with the stock args string and no argv/item, over the given -/// `var`/`sys`; tests override fields with struct-update syntax. +/// `var`/`sys`, for prose that starts on [`PROSE_LINE`] of `prompt`; tests +/// override fields with struct-update syntax. fn test_sources<'a>(var: &'a Value, sys: &'a Value) -> Sources<'a> { Sources { args: "Acme Corp", @@ -27,6 +33,8 @@ fn test_sources<'a>(var: &'a Value, sys: &'a Value) -> Sources<'a> { var, sys, globals: &no_globals, + file: "prompt", + line: Some(PROSE_LINE), } } @@ -268,7 +276,40 @@ fn a_failure_is_a_substitution_error_with_its_reason_fact() { e.facts, BTreeMap::from([(FactKey::Reason, Value::from("missing_key"))]) ); - assert_eq!(e.at, None); + assert_eq!( + e.at, + Some(Location::line("prompt", PROSE_LINE)), + "a placeholder on the prose's first line fails there" + ); +} + +#[test] +fn a_failure_is_placed_on_its_placeholders_line_of_the_prose() { + let var = json!({}); + let sys = json!({}); + let sources = test_sources(&var, &sys); + for (prose, newlines) in [ + ("one\ntwo {{ var.nope }}", 1), + ("one\n\nthree\n{{ unclosed", 3), + ] { + let e = substitute(prose, &sources).expect_err("the placeholder fails"); + assert_eq!( + e.at.map(|at| at.start_line.get()), + Some(PROSE_LINE.get() + newlines), + "{prose:?}" + ); + } + let unplaced = Sources { + line: None, + ..test_sources(&var, &sys) + }; + assert_eq!( + substitute("{{ var.nope }}", &unplaced) + .expect_err("the placeholder fails") + .at, + None, + "prose whose line is unknown places nothing" + ); } #[test] diff --git a/crates/runvil-internal/engine/src/subst.rs b/crates/runvil-internal/engine/src/subst.rs index 9ac9f0d72..97ecc8cab 100644 --- a/crates/runvil-internal/engine/src/subst.rs +++ b/crates/runvil-internal/engine/src/subst.rs @@ -31,7 +31,9 @@ //! replacement that itself contains `{{ ... }}` is emitted verbatim and never //! triggers a second round of substitution. -use runvil_types::error::{Error, ErrorCode, FactKey}; +use std::num::NonZeroU32; + +use runvil_types::error::{Error, ErrorCode, FactKey, place}; use serde_json::Value; use crate::Result; @@ -106,6 +108,10 @@ pub(crate) struct Sources<'a> { /// unset, `Ok(Some(_))` with its JSON form when set, and `Err` when the /// lookup fails, which a placeholder reports as `serialize`. pub(crate) globals: &'a dyn Fn(&str) -> Result>, + /// The prompt file the prose comes from, which a failure is placed in. + pub(crate) file: &'a str, + /// The prompt line the prose starts on, when known. + pub(crate) line: Option, } /// Resolves every `{{ path }}` in `prose` against the [`Sources`]. @@ -119,7 +125,8 @@ pub(crate) struct Sources<'a> { /// placeholder can fail with. Its message names the reason and the byte /// offset of the offending placeholder after a detail with its bounded, /// control-escaped path preview, and a `serialize` message then holds the -/// lookup failure. +/// lookup failure. It is placed in the sources' file on the offending +/// placeholder's line, when the line the prose starts on is known. pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { let mut out = String::with_capacity(prose.len()); let bytes = prose.as_bytes(); @@ -137,12 +144,23 @@ pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { } if bytes[i] == b'{' && i + 1 < prose.len() && bytes[i + 1] == b'{' { let start = i; + let placed = |error| { + place( + error, + sources.file, + placeholder_line(sources.line, &prose[..start]), + ) + }; let after = &prose[i + 2..]; - let end = after - .find("}}") - .ok_or_else(|| failure("unclosed", start, "unclosed '{{' in prose".to_string()))?; + let end = after.find("}}").ok_or_else(|| { + placed(failure( + "unclosed", + start, + "unclosed '{{' in prose".to_string(), + )) + })?; let path = after[..end].trim(); - out.push_str(&resolve(path, start, sources)?); + out.push_str(&resolve(path, start, sources).map_err(placed)?); i += 2 + end + 2; continue; } @@ -155,6 +173,14 @@ pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { Ok(out) } +/// The prompt line of a placeholder that `before` comes ahead of, in prose +/// that starts on `line`; `None` when `line` is unknown or the sum +/// overflows. +fn placeholder_line(line: Option, before: &str) -> Option { + let newlines = u32::try_from(before.matches('\n').count()).ok()?; + line?.checked_add(newlines) +} + /// The nil-`argv` failure shared by the bare and dotted `{{ argv }}` paths. fn nil_argv<'a>(argv: Option<&'a Value>, path: &str, offset: usize) -> SubstResult<&'a Value> { argv.ok_or_else(|| { diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index 80a252943..22b8ffbe8 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -80,6 +80,13 @@ pub(crate) fn placed(error: crate::Error, line: u32) -> crate::Error { runvil_types::error::place(error, "prompt", std::num::NonZeroU32::new(line)) } +/// The location of all of `line` of a prompt that came from no file, the +/// `at` of a failure [`placed`] there. +#[cfg(test)] +pub(crate) fn at_line(line: u32) -> Option { + std::num::NonZeroU32::new(line).map(|line| runvil_types::error::Location::line("prompt", line)) +} + /// `error` as it crossed into a section's Lua on `line` of a prompt that /// came from no file: [`placed`] there, with the [`authored`] prose of its /// message. diff --git a/crates/runvil-internal/lua/src/__impl_require.lua b/crates/runvil-internal/lua/src/__impl_require.lua index 0e4870fe8..df6bda8a7 100644 --- a/crates/runvil-internal/lua/src/__impl_require.lua +++ b/crates/runvil-internal/lua/src/__impl_require.lua @@ -6,12 +6,13 @@ -- none is, of the section, and returns the library's identity, its -- heading, and the library itself; `begin_load(library)` reports the -- load's start, loads the library's blocks, marks the library as the one --- loading, which makes `jump` refuse, and returns the blocks and their --- prose templates in order; `end_load(finished)` takes that mark back and --- reports how the load ended; `swap_prose(template)` installs a block's --- own lazy `prose` over its template and returns the read it replaced; --- `restore_prose(saved)` puts that read back; and `error_value` builds the --- structured error table. +-- loading, which makes `jump` refuse, and returns the blocks, their prose +-- templates, and the prompt lines the templates start on, in order; +-- `end_load(finished)` takes that mark back and reports how the load +-- ended; `swap_prose(template, line)` installs a block's own lazy `prose` +-- over its template, which starts on `line`, and returns the read it +-- replaced; `restore_prose(saved)` puts that read back; and `error_value` +-- builds the structured error table. -- -- `require` is Lua rather than an Engine function, so the blocks run -- inside the calling block's coroutine and a block's suspending call is an @@ -72,9 +73,10 @@ local restoring = { -- The first block that returns a value other than nil ends the library -- with it; a library whose blocks all fall through hands back `true`. -- Each block reads its own `prose` while it runs. -local function run(blocks, templates) +local function run(blocks, templates, lines) for index = 1, #blocks do - local scope = setmetatable({ saved = swap_prose(templates[index]) }, restoring) + local scope = + setmetatable({ saved = swap_prose(templates[index], lines[index]) }, restoring) local value = blocks[index]() if value ~= nil then return value end end @@ -92,9 +94,9 @@ local function require(heading) ) cache[identity] = LOADING loading[#loading + 1] = entry - local blocks, templates = begin_load(library) + local blocks, templates, lines = begin_load(library) entry.begun = true - local value = run(blocks, templates) + local value = run(blocks, templates, lines) cache[identity] = value entry.finished = true return value diff --git a/crates/runvil-internal/lua/src/require.rs b/crates/runvil-internal/lua/src/require.rs index 165444979..3d6271ecc 100644 --- a/crates/runvil-internal/lua/src/require.rs +++ b/crates/runvil-internal/lua/src/require.rs @@ -14,6 +14,7 @@ //! The standard package library stays unloaded: `require` reaches //! sections only, through the resolver, never files or native modules. +use std::num::NonZeroU32; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; @@ -55,8 +56,9 @@ pub(crate) struct VmShare<'a> { } /// Installs `require` as a global over `resolver`, rendering each library -/// block's `prose` template through `render`, reporting each first load -/// through a clone of `emitter` under `section`, and working on `vm`. +/// block's `prose` template through `render` with the prompt line it +/// starts on, reporting each first load through a clone of `emitter` under +/// `section`, and working on `vm`. /// /// # Errors /// Returns a `LuaRuntime` failure if the loader chunk or its helpers cannot be @@ -72,7 +74,7 @@ pub(crate) fn install( where R: Fn(String, Option) -> std::result::Result + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { let loading = Loading::default(); let resolve = resolve_capture(lua, resolver, &loading)?; @@ -130,9 +132,9 @@ where /// Builds `begin_load(library)`: reports the load's start, loads the /// library's blocks, keeps their programs on the VM, and marks the library -/// as the one loading, returning the blocks and their prose templates in -/// order. A block that fails to load reports the load as failed and leaves -/// nothing marked. +/// as the one loading, returning the blocks, their prose templates, and +/// the prompt lines the templates start on, in order. A block that fails +/// to load reports the load as failed and leaves nothing marked. fn begin_load_capture( lua: &Lua, loading: &Loading, @@ -162,8 +164,10 @@ fn begin_load_capture( }; let mut programs = lock(&programs, "loaded library programs")?; let mut templates = Vec::with_capacity(library.blocks.len()); + let mut lines = Vec::with_capacity(library.blocks.len()); for block in library.blocks { templates.push(block.prose); + lines.push(block.line.map(NonZeroU32::get)); if !programs.contains(&block.program) { programs.push(block.program); } @@ -178,28 +182,30 @@ fn begin_load_capture( }; loading.push((library.key, library.heading)); depth.fetch_add(1, Ordering::Relaxed); - Ok((blocks, templates)) + Ok((blocks, templates, lines)) }) .map_err(error::lua) } -/// Builds `swap_prose(template)`, which installs a library block's own -/// lazy `prose` over `template`, rendered through `render`, and returns the -/// read it replaced, and `restore_prose(saved)`, which puts that read back. +/// Builds `swap_prose(template, line)`, which installs a library block's +/// own lazy `prose` over `template`, starting on prompt `line` when one is +/// given, rendered through `render`, and returns the read it replaced, and +/// `restore_prose(saved)`, which puts that read back. fn prose_captures

( lua: &Lua, render: P, sys_live: &Arc>, ) -> Result<(Function, Function)> where - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { let render = Arc::new(render); let sys_live = Arc::clone(sys_live); let swap = lua - .create_function(move |lua, template: String| { + .create_function(move |lua, (template, line): (String, Option)| { let render = Arc::clone(&render); - crate::prose::swap(lua, &sys_live, move |state| render(&template, state)) + let line = line.and_then(NonZeroU32::new); + crate::prose::swap(lua, &sys_live, move |state| render(&template, line, state)) .map_err(mlua::Error::external) }) .map_err(error::lua)?; diff --git a/crates/runvil-internal/lua/src/section.rs b/crates/runvil-internal/lua/src/section.rs index df3da86b8..504b82f8a 100644 --- a/crates/runvil-internal/lua/src/section.rs +++ b/crates/runvil-internal/lua/src/section.rs @@ -2,6 +2,8 @@ //! `call` or `jump` target, the text a prompt read sees, and the library //! sections `require` loads. +use std::num::NonZeroU32; + use mlua::Value; /// Outcome of a Lua block that may invoke `jump`. @@ -9,8 +11,10 @@ use mlua::Value; pub enum LuaBlockResult { /// Normal completion with an optional scalar return. Returned(Option), - /// `jump` transferred control to this heading (`## Name`). - Jump(String), + /// `jump` transferred control to this heading (`## Name`), called on + /// this prompt line, which is `None` when the call found no author + /// frame running. + Jump(String, Option), } /// Resolves a `call` / `jump` target from a heading string. @@ -78,4 +82,7 @@ pub struct LibraryBlock { pub program: crate::LuaProgram, /// The block's prose template, the Markdown before it. pub prose: String, + /// The prompt line the prose template starts on, which its render + /// receives; `None` when no prose comes right before the block. + pub line: Option, } diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index 0f6e3bcd5..1dd3cb9e6 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -208,8 +208,8 @@ impl SectionVm { fn run_loaded(&self, program: &LuaProgram) -> Result { self.clear_jump()?; let result = program.load(self.lua())?.call(()); - if let Some(heading) = self.take_jump()? { - return Ok(LuaBlockResult::Jump(heading)); + if let Some((heading, line)) = self.take_jump()? { + return Ok(LuaBlockResult::Jump(heading, line)); } let returned = result.map_err(|error| self.map_chunk_failure(program, &error))?; Ok(LuaBlockResult::Returned(scalar_return(returned)?)) diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 76fff3f3d..4ca634687 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -223,7 +223,7 @@ fn run_scalar( ) -> Result> { match vm.run_chunk(program, emitter, section)? { LuaBlockResult::Returned(value) => Ok(value), - LuaBlockResult::Jump(heading) => Err(crate::error::lua_runtime(format!( + LuaBlockResult::Jump(heading, _) => Err(crate::error::lua_runtime(format!( "unexpected jump to {heading}" ))), } diff --git a/crates/runvil-internal/lua/src/tests/require.rs b/crates/runvil-internal/lua/src/tests/require.rs index 810bac81c..1afa84f22 100644 --- a/crates/runvil-internal/lua/src/tests/require.rs +++ b/crates/runvil-internal/lua/src/tests/require.rs @@ -7,6 +7,7 @@ use super::*; mod errors; +mod jump; mod prose; mod reports; @@ -39,6 +40,7 @@ fn library(identity: &str, heading: &str, sources: &[&str]) -> Library { ) .expect("library Lua must compile"), prose: String::new(), + line: None, }) .collect(), } @@ -78,14 +80,14 @@ fn resolver( /// resolver calls. #[track_caller] fn require_vm(entries: Vec, emitter: &Emitter) -> (BuiltVm, Calls) { - require_vm_rendering(entries, |template, _| Ok(template.to_owned()), emitter) + require_vm_rendering(entries, |template, _, _| Ok(template.to_owned()), emitter) } /// [`require_vm`] with library prose rendered through `render`. #[track_caller] fn require_vm_rendering

(entries: Vec, render: P, emitter: &Emitter) -> (BuiltVm, Calls) where - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { let calls = Calls::default(); let vm = TestVm::new().shims().build(); @@ -429,45 +431,6 @@ fn a_non_string_heading_raises_without_reaching_the_resolver() { ); } -#[test] -fn jump_during_a_load_raises_and_the_refusal_ends_with_the_load() { - let (vm, _) = require_vm( - vec![("## a", None, library("a", "## a", &["jump '## elsewhere'"]))], - &null_emitter(), - ); - let error = failure(&vm, "require '## a'"); - assert!( - error.contains("jump is unavailable while a library loads"), - "jump must refuse during a load, got {error}" - ); - let step = vm - .start_block_coro(&program("pcall(require, '## a')\njump '## after'")) - .expect("the jump after a failed load must transfer"); - assert!( - matches!(step, CoroStep::Done(LuaBlockResult::Jump(ref heading)) if heading == "## after"), - "a jump after the load must transfer, got {step:?}" - ); -} - -#[test] -fn a_library_function_that_jumps_after_the_load_jumps_for_the_caller() { - let (vm, _) = require_vm( - vec![( - "## a", - None, - library("a", "## a", &["function go() jump '## next' end"]), - )], - &null_emitter(), - ); - let step = vm - .start_block_coro(&program("require '## a'\ngo()")) - .expect("the jump must transfer"); - assert!( - matches!(step, CoroStep::Done(LuaBlockResult::Jump(ref heading)) if heading == "## next"), - "the library function's jump must transfer the caller, got {step:?}" - ); -} - #[test] fn a_suspending_library_call_yields_from_the_callers_coroutine() { let (vm, _) = require_vm( diff --git a/crates/runvil-internal/lua/src/tests/require/errors.rs b/crates/runvil-internal/lua/src/tests/require/errors.rs index c141c05ae..371021609 100644 --- a/crates/runvil-internal/lua/src/tests/require/errors.rs +++ b/crates/runvil-internal/lua/src/tests/require/errors.rs @@ -30,6 +30,7 @@ fn placed(identity: &str, heading: &str, blocks: &[(&str, &str, u32)]) -> Librar .map(|(source, location, line)| LibraryBlock { program: at(source, location, *line), prose: String::new(), + line: None, }) .collect(), } diff --git a/crates/runvil-internal/lua/src/tests/require/jump.rs b/crates/runvil-internal/lua/src/tests/require/jump.rs new file mode 100644 index 000000000..b2e083898 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/require/jump.rs @@ -0,0 +1,53 @@ +//! `jump` and `require`: a `jump` refuses while a library loads, and a +//! library function's `jump` after the load transfers its caller. Each +//! transfer names the line of its `jump` call. + +use super::*; + +#[test] +fn jump_during_a_load_raises_and_the_refusal_ends_with_the_load() { + let (vm, _) = require_vm( + vec![("## a", None, library("a", "## a", &["jump '## elsewhere'"]))], + &null_emitter(), + ); + let error = failure(&vm, "require '## a'"); + assert!( + error.contains("jump is unavailable while a library loads"), + "jump must refuse during a load, got {error}" + ); + let step = vm + .start_block_coro(&program("pcall(require, '## a')\njump '## after'")) + .expect("the jump after a failed load must transfer"); + assert!( + matches!( + step, + CoroStep::Done(LuaBlockResult::Jump(ref heading, line)) + if heading == "## after" && line == NonZeroU32::new(2) + ), + "a jump after the load must transfer from its own line, got {step:?}" + ); +} + +#[test] +fn a_library_function_that_jumps_after_the_load_jumps_for_the_caller() { + let (vm, _) = require_vm( + vec![( + "## a", + None, + library("a", "## a", &["function go() jump '## next' end"]), + )], + &null_emitter(), + ); + let step = vm + .start_block_coro(&program("require '## a'\ngo()")) + .expect("the jump must transfer"); + assert!( + matches!( + step, + CoroStep::Done(LuaBlockResult::Jump(ref heading, line)) + if heading == "## next" && line == NonZeroU32::new(100) + ), + "the library function's jump must transfer the caller from the library's line, got \ + {step:?}" + ); +} diff --git a/crates/runvil-internal/lua/src/tests/require/prose.rs b/crates/runvil-internal/lua/src/tests/require/prose.rs index bc0d43b8e..20f258563 100644 --- a/crates/runvil-internal/lua/src/tests/require/prose.rs +++ b/crates/runvil-internal/lua/src/tests/require/prose.rs @@ -1,6 +1,7 @@ //! Library prose: each library block reads its own lazy `prose`, filled -//! from the caller's environment, and the caller's `prose` comes back -//! exactly as it was after every load, a failed one included. +//! from the caller's environment and rendered with the prompt line its +//! template starts on, and the caller's `prose` comes back exactly as it +//! was after every load, a failed one included. use super::*; @@ -26,7 +27,11 @@ fn var_x(var: &Json) -> String { clippy::unnecessary_wraps, reason = "a library prose renderer returns a Result" )] -fn fill_var_x(template: &str, state: ProseState<'_>) -> mlua::Result { +fn fill_var_x( + template: &str, + _: Option, + state: ProseState<'_>, +) -> mlua::Result { let ProseState { var, .. } = state; Ok(template.replace("{{ var.x }}", &var_x(&var))) } @@ -59,6 +64,29 @@ fn a_library_block_reads_its_own_prose_filled_from_the_callers_var() { assert_eq!(value.as_deref(), Some("lib sees seven|false|caller text")); } +#[test] +fn a_library_blocks_prose_renders_with_the_line_its_template_starts_on() { + let mut lib = with_prose( + library( + "lib", + "## lib", + &["first = prose", "return first .. '|' .. prose"], + ), + &["one", "two"], + ); + lib.blocks[0].line = NonZeroU32::new(40); + let (vm, _) = require_vm_rendering( + vec![("## lib", None, lib)], + |template, line, _| Ok(format!("{template}@{line:?}")), + &null_emitter(), + ); + assert_eq!( + returned(&vm, "return require '## lib'").as_deref(), + Some("one@Some(40)|two@None"), + "a block with no prose before it renders with no line" + ); +} + #[test] fn the_callers_read_prose_keeps_its_memo_across_a_require() { let renders = Arc::new(AtomicUsize::new(0)); diff --git a/crates/runvil-internal/lua/src/tests/sandbox.rs b/crates/runvil-internal/lua/src/tests/sandbox.rs index 63b8c885c..6e7e6a0d3 100644 --- a/crates/runvil-internal/lua/src/tests/sandbox.rs +++ b/crates/runvil-internal/lua/src/tests/sandbox.rs @@ -76,7 +76,7 @@ fn an_installed_require_is_runvils_and_loads_no_module() { "no section `{heading}` within reach of require" ))) }, - |template, _| Ok(template.to_owned()), + |template, _, _| Ok(template.to_owned()), &null_emitter(), "Section", ) diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 7b9c72098..73f6f90c3 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -67,6 +67,10 @@ fn log_byte_budget(log_events: u32) -> usize { (log_events as usize).saturating_mul(LUA_LOG_CHARACTER_LIMIT) } +/// A `jump` the running chunk recorded: the target heading, and the prompt +/// line of the author's `jump` call. +type Jump = (String, Option); + /// Collects owned values into a 1-based Lua sequence table. fn pack_sequence(lua: &Lua, values: Vec) -> mlua::Result { let table = lua.create_table_with_capacity(values.len(), 0)?; @@ -129,7 +133,7 @@ pub struct SectionVm { /// The section's offers, read by the executor's scope path. offers: Arc>, /// Set by Lua `jump` before it aborts the current chunk. - jump_slot: Arc>>, + jump_slot: Arc>>, /// How many adhoc tool handlers are running on this VM; `jump` refuses /// while it is above zero. adhoc_handler_depth: Arc, diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 886eed2a5..a24bc2ddd 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -2,6 +2,7 @@ //! the control globals and yield shims, `require`, and the `sys` and //! `prose` globals. +use std::num::NonZeroU32; use std::sync::atomic::Ordering; use std::sync::{Arc, PoisonError}; @@ -165,7 +166,8 @@ impl SectionVm { /// /// While a library block runs, its `prose` is its own: a lazy read of /// the block's prose template, rendered at most once through `render` - /// with the template and the same [`ProseState`] snapshot + /// with the template, the prompt line it starts on, and the same + /// [`ProseState`] snapshot that /// [`install_lazy_prose`](Self::install_lazy_prose)'s render takes, read /// from the caller's environment. The caller's `prose`, memo included, /// comes back when the block ends, however it ends. A failure anywhere @@ -187,7 +189,10 @@ impl SectionVm { + Sync + 'static, E: std::error::Error + Send + Sync + 'static, - P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, + P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + + Send + + Sync + + 'static, { crate::require::install( &self.lua, @@ -209,7 +214,7 @@ impl SectionVm { let library_load_depth = Arc::clone(&self.library_load_depth); let jump_fn = self .lua - .create_function(move |_, target: Value| -> mlua::Result<()> { + .create_function(move |lua, target: Value| -> mlua::Result<()> { if adhoc_handler_depth.load(Ordering::Relaxed) > 0 { return Err(mlua::Error::external( "jump is unavailable inside an adhoc tool handler: return a value from \ @@ -223,10 +228,11 @@ impl SectionVm { )); } let heading = resolve_section_target(target)?; + let line = error::author_line(lua); let mut slot = jump_slot .lock() .map_err(|_| mlua::Error::external("jump slot poisoned"))?; - *slot = Some(heading); + *slot = Some((heading, line)); Err(mlua::Error::external("jump transfer")) }) .map_err(error::lua)?; diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 269aebad5..0a37a314a 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -5,7 +5,7 @@ use mlua::thread::ThreadStatus; use mlua::{MultiValue, Thread, Value}; use runvil_types::error::{ErrorCode, place}; -use super::SectionVm; +use super::{Jump, SectionVm}; use crate::coro::{StashedFailure, block_guard, take_failure}; use crate::error::{self, Error, Result, interrupted, lua_runtime, prompt_file, set_call_line}; use crate::error_value::{ErrorKind, error_kind}; @@ -14,12 +14,13 @@ use crate::protocol::{Answer, Request, Retained, YieldParse, render_envelope}; use crate::section::LuaBlockResult; impl SectionVm { - /// Takes any recorded jump target, propagating a poisoned jump-slot lock - /// rather than silently coercing the failure into "no jump". + /// Takes any recorded jump: its target and the prompt line of the + /// `jump` call, propagating a poisoned jump-slot lock rather than + /// silently coercing the failure into "no jump". /// /// # Errors /// Returns an `Internal` failure when the jump-slot mutex is poisoned. - pub(crate) fn take_jump(&self) -> Result> { + pub(crate) fn take_jump(&self) -> Result> { let mut slot = self .jump_slot .lock() @@ -163,8 +164,8 @@ impl SectionVm { Ok(Ok(CoroStep::Yielded(thread, values))) } result => { - if let Some(heading) = self.take_jump()? { - return Ok(Ok(CoroStep::Done(LuaBlockResult::Jump(heading)))); + if let Some((heading, line)) = self.take_jump()? { + return Ok(Ok(CoroStep::Done(LuaBlockResult::Jump(heading, line)))); } match result { Ok(returned) => Ok(Ok(CoroStep::Done(LuaBlockResult::Returned( diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 0124231b1..0dc95b640 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -178,7 +178,7 @@ impl Prompt { )?; if matches!( h1_blocks.as_slice(), - [Block::Prose { text }] if text.as_str().is_empty() + [Block::Prose { text, .. }] if text.as_str().is_empty() ) { h1_blocks.clear(); } diff --git a/crates/runvil-internal/prompt/src/position.rs b/crates/runvil-internal/prompt/src/position.rs index e428e68a4..4f7a5f746 100644 --- a/crates/runvil-internal/prompt/src/position.rs +++ b/crates/runvil-internal/prompt/src/position.rs @@ -39,6 +39,24 @@ pub(crate) fn body_text(shared: &Arc, part: &str) -> Result { .ok_or_else(|| error::internal("parser: section text is not a slice of the prompt body")) } +/// The 1-based file line that `part`, a slice of `body`, starts on, for a +/// body that starts in the file after `frontmatter_lines` lines. +/// +/// # Errors +/// Returns an `Internal` failure when `part` is not a slice of `body`, or +/// when the line overflows `u32`. +pub(crate) fn file_line_of(body: &str, part: &str, frontmatter_lines: u32) -> Result { + let start = part + .as_ptr() + .addr() + .checked_sub(body.as_ptr().addr()) + .ok_or_else(|| error::internal("parser: section text is not a slice of the prompt body"))?; + nz_source_line(line_add( + line_add(frontmatter_lines, newlines_before(body, start)?)?, + 1, + )?) +} + /// Counts the number of `\n` characters in `text[..byte_offset]`. /// /// # Errors diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index 43e44953b..c43cb789b 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -6,6 +6,8 @@ //! Construction happens in the parsing modules; this module holds the value //! types and their accessors. +use std::num::NonZeroU32; + use crate::{BodyText, Frontmatter}; use runvil_lua::LuaProgram; @@ -24,6 +26,8 @@ pub enum Block { Prose { /// Captured Markdown, trimmed of leading and trailing whitespace. text: BodyText, + /// The 1-based line of the prompt file that `text` starts on. + line: NonZeroU32, }, } @@ -101,7 +105,7 @@ impl Section { .iter() .rev() .find_map(|block| match block { - Block::Prose { text } => Some(text.as_str()), + Block::Prose { text, .. } => Some(text.as_str()), _ => None, }) .unwrap_or("") diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index d546f1c87..42d5d31ea 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -8,7 +8,7 @@ use runvil_types::emitter::Emitter; use runvil_types::error::{Error, ErrorCode}; use crate::fence::{self, LuaRole, RawBlock}; -use crate::position::{body_text, line_add, newlines_before, nz_source_line}; +use crate::position::{body_text, file_line_of, line_add, newlines_before, nz_source_line}; use crate::{Block, BodyText, Result, Section, error, list}; use runvil_lua::LuaProgram; @@ -283,9 +283,9 @@ impl<'a> SectionWalker<'a> { /// 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. + /// place, and its file line is where that slice starts. + /// `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 /// Returns a Lua compile failure for a block that does not compile, and @@ -304,6 +304,7 @@ impl<'a> SectionWalker<'a> { match raw { RawBlock::Prose(text) => blocks.push(Block::Prose { text: body_text(self.shared, text)?, + line: file_line_of(self.shared, text, self.frontmatter_lines)?, }), RawBlock::Lua { source, diff --git a/crates/runvil-internal/prompt/src/test_support.rs b/crates/runvil-internal/prompt/src/test_support.rs index 5af4ebb4e..000b4594d 100644 --- a/crates/runvil-internal/prompt/src/test_support.rs +++ b/crates/runvil-internal/prompt/src/test_support.rs @@ -8,6 +8,8 @@ //! parse assertions show the source and the actual outcome when a parse goes //! the other way. +use std::num::NonZeroU32; + use runvil_types::error::{Error, ErrorCode}; use crate::{Block, BodyText, Prompt, Section}; @@ -27,12 +29,13 @@ pub fn synthetic_section(name: &str, level: u8, blocks: Vec) -> Section { } } -/// Builds a prose block, the one `Block` variant the executor's test -/// fixtures need to construct directly. +/// Builds a prose block on line 1, the one `Block` variant the executor's +/// test fixtures need to construct directly. #[must_use] pub fn prose_block(text: String) -> Block { Block::Prose { text: BodyText::from_owned(text), + line: NonZeroU32::MIN, } } diff --git a/crates/runvil-internal/prompt/src/tests.rs b/crates/runvil-internal/prompt/src/tests.rs index 939fb9aca..98db5ba3a 100644 --- a/crates/runvil-internal/prompt/src/tests.rs +++ b/crates/runvil-internal/prompt/src/tests.rs @@ -187,6 +187,7 @@ fn parses_multi_section_with_all_features() { p.h1_blocks, vec![Block::Prose { text: BodyText::from_owned("Human-readable intro text.".to_owned()), + line: std::num::NonZeroU32::new(9).expect("9 is non-zero"), }] ); diff --git a/crates/runvil-internal/prompt/src/tests/breaks.rs b/crates/runvil-internal/prompt/src/tests/breaks.rs index 70537f793..d4ce3ef5f 100644 --- a/crates/runvil-internal/prompt/src/tests/breaks.rs +++ b/crates/runvil-internal/prompt/src/tests/breaks.rs @@ -14,7 +14,7 @@ fn leading_break_resets_prose_and_content_below_parses() { assert_eq!(section.blocks().len(), 3); assert!(matches!( §ion.blocks()[0], - Block::Prose { text } if text.as_str().is_empty() + Block::Prose { text, .. } if text.as_str().is_empty() )); assert!(matches!( §ion.blocks()[1], @@ -84,7 +84,7 @@ fn h1_break_resets_prose_and_lua_fence_below_stays_live() { let prompt = parse_ok(&PromptSource::new().h1_prose(h1).prose("S", "p").build()); assert!(matches!( prompt.h1_blocks.as_slice(), - [Block::Prose { text: above }, Block::Lua(program), Block::Prose { text: below }] + [Block::Prose { text: above, .. }, Block::Lua(program), Block::Prose { text: below, .. }] if above.as_str().is_empty() && program.source() == "local live = 1" && below == "Below prose." @@ -133,7 +133,7 @@ fn pending_markdown_binds_to_the_following_lua_fence() { assert_eq!(section.blocks().len(), 2); assert!(matches!( §ion.blocks()[0], - Block::Prose { text } if text == "Gather the facts." + Block::Prose { text, .. } if text == "Gather the facts." )); assert!(matches!( §ion.blocks()[1], @@ -164,7 +164,7 @@ fn a_lua_fence_consumes_the_pending_buffer() { assert_eq!(section.blocks().len(), 4); assert!(matches!( §ion.blocks()[0], - Block::Prose { text } if text == "First." + Block::Prose { text, .. } if text == "First." )); assert!(matches!( §ion.blocks()[1], @@ -172,7 +172,7 @@ fn a_lua_fence_consumes_the_pending_buffer() { )); assert!(matches!( §ion.blocks()[2], - Block::Prose { text } if text == "Second." + Block::Prose { text, .. } if text == "Second." )); assert!(matches!( §ion.blocks()[3], @@ -189,7 +189,7 @@ fn a_thematic_break_resets_the_pending_buffer() { let section = &prompt.sections[0]; assert_eq!(section.blocks().len(), 2); match §ion.blocks()[0] { - Block::Prose { text } => { + Block::Prose { text, .. } => { assert_eq!(text, "Ask the question."); assert!(!text.as_str().contains("Draft notes.")); assert!(!text.as_str().contains("---")); @@ -213,7 +213,7 @@ fn per_fence_commentary_is_excluded_by_a_break() { )); assert!(matches!( §ion.blocks()[1], - Block::Prose { text } if text == "Pending for step two." + Block::Prose { text, .. } if text == "Pending for step two." )); assert!(matches!( §ion.blocks()[2], diff --git a/crates/runvil-internal/prompt/src/tests/h1_fences.rs b/crates/runvil-internal/prompt/src/tests/h1_fences.rs index 72e1413fe..61f548b3c 100644 --- a/crates/runvil-internal/prompt/src/tests/h1_fences.rs +++ b/crates/runvil-internal/prompt/src/tests/h1_fences.rs @@ -50,7 +50,7 @@ fn h1_plain_lua_and_prose_are_live_blocks() { )); assert!(matches!( &prompt.h1_blocks[1], - Block::Prose { text } if text == "Plan {{ args }}." + Block::Prose { text, .. } if text == "Plan {{ args }}." )); assert!(matches!( &prompt.h1_blocks[2], @@ -125,7 +125,7 @@ fn label_near_misses_inside_longer_fences_remain_prose() { .all(|block| matches!(block, Block::Prose { .. })) ); assert!(prompt.h1_blocks.iter().any( - |block| matches!(block, Block::Prose { text } if text.as_str().contains("```lua extra")) + |block| matches!(block, Block::Prose { text, .. } if text.as_str().contains("```lua extra")) )); assert!(prompt.sections[0].prologue().is_none()); assert!(prompt.sections[0].prose().contains("```lua extra")); diff --git a/crates/runvil-internal/prompt/src/tests/headings.rs b/crates/runvil-internal/prompt/src/tests/headings.rs index e994a8878..4cb1195dd 100644 --- a/crates/runvil-internal/prompt/src/tests/headings.rs +++ b/crates/runvil-internal/prompt/src/tests/headings.rs @@ -33,7 +33,7 @@ fn preface_before_h1_is_ignored() { assert_eq!(prompt.title, "T"); assert!(matches!( prompt.h1_blocks.as_slice(), - [Block::Prose { text }] if text == "Description." + [Block::Prose { text, .. }] if text == "Description." )); assert_eq!(prompt.entry().expect("has sections").name, "S"); } diff --git a/crates/runvil-internal/prompt/src/tests/line_mapping.rs b/crates/runvil-internal/prompt/src/tests/line_mapping.rs index c42fac0e9..c3a842543 100644 --- a/crates/runvil-internal/prompt/src/tests/line_mapping.rs +++ b/crates/runvil-internal/prompt/src/tests/line_mapping.rs @@ -1,9 +1,40 @@ //! Line-mapping tests: a runtime error in each Lua block reports its -//! file-absolute line. +//! file-absolute line, and each prose block records the file line its +//! text starts on. use super::*; use runvil_lua::LuaProgram; +#[test] +fn each_prose_block_records_the_file_line_its_trimmed_text_starts_on() { + // 8: Plan. <- the H1's prose + // 12: Notes above. <- dropped by the break below it + // 14: --- + // 16: Pending. <- the prose the fence on line 19 consumes + // 17: Second line. + // 23: Trailing. <- after the last fence + let src = "---\nname: x\ndescription: d\n---\n\n# T\n\nPlan.\n\n## S\n\nNotes above.\n\n---\n\n\ + Pending.\nSecond line.\n\n```lua\nx = 1\n```\n\nTrailing.\n"; + let prompt = parse_ok(src); + let prose = |blocks: &[Block]| -> Vec<(String, u32)> { + blocks + .iter() + .filter_map(|block| match block { + Block::Prose { text, line } => Some((text.as_str().to_owned(), line.get())), + Block::Lua(_) => None, + }) + .collect() + }; + assert_eq!(prose(&prompt.h1_blocks), [("Plan.".to_owned(), 8)]); + assert_eq!( + prose(prompt.sections[0].blocks()), + [ + ("Pending.\nSecond line.".to_owned(), 16), + ("Trailing.".to_owned(), 23) + ] + ); +} + fn assert_runtime_error_line(program: &LuaProgram, absolute_line: u32) { let lua = mlua::Lua::new(); let function = program.load(&lua).expect("bytecode must load"); diff --git a/crates/runvil-internal/prompt/src/tests/section_fences.rs b/crates/runvil-internal/prompt/src/tests/section_fences.rs index 9980b0d0b..ba5142bf0 100644 --- a/crates/runvil-internal/prompt/src/tests/section_fences.rs +++ b/crates/runvil-internal/prompt/src/tests/section_fences.rs @@ -58,7 +58,7 @@ fn exact_middle_lua_fences_become_compiled_blocks() { assert_eq!(section.prose(), "After."); assert_eq!(section.blocks.len(), 3); match §ion.blocks[0] { - Block::Prose { text } => assert_eq!(text, "Before."), + Block::Prose { text, .. } => assert_eq!(text, "Before."), other => panic!("expected leading prose, got {other:?}"), } match §ion.blocks[1] { @@ -66,7 +66,7 @@ fn exact_middle_lua_fences_become_compiled_blocks() { other => panic!("expected lua block, got {other:?}"), } match §ion.blocks[2] { - Block::Prose { text } => assert_eq!(text, "After."), + Block::Prose { text, .. } => assert_eq!(text, "After."), other => panic!("expected trailing prose, got {other:?}"), } } From f2c5fc50e546d777a75b1ad633eb00dbeebf11fd Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 17:22:51 -0800 Subject: [PATCH 06/36] Give parse and run-start failures their prompt line A failed parse or a refused run start now tells the author which prompt line caused it, when that line is known. The parser now puts each failure on the line it knows, and `internal` records the Rust file and line of its call through `#[track_caller]`. With the new frontmatter key lines, the Engine puts `UnsupportedVersion` on the `runvil:` line and the `RequirementsUnmet` refusal on a `uses:` or `models:` line. - `split_frontmatter` finds key lines with a text scan, not from the YAML parse, so `key_line` is meaningful only for a key that decoded to a value. A key gets a line only when exactly one line declares it at column 0, so a repeated key or a flow mapping gives no line, and a failure on that key has no location. - `key_lines` is a field of `SplitSource` and `Prompt`, not of `Frontmatter`, so two `Frontmatter` values with the same content still compare equal. `Prompt` equality now also compares `key_lines`, and the Engine reads them through `key_line`, a new `pub fn` in `detail.rs`. - `ChainState` builds the refusal once, with its location: `SharedState` holds it as `Option` in place of the notice text, and `check_start` returns a clone. - `refusal` in `requirements.rs` puts the failure on the key of the first gap the prompt caused: `uses:` for a missing or unavailable Pack, else `models:` for a context or thinking shortfall. A reserved Pack id and a role map with no `default` entry are the caller's gaps, so a refusal made only of those has no location. - `internal` now takes `&'static str` in place of `&str` and passes the message to `runvil_lua::internal_at` with no prefix. Its test pins one `internal invariant violated:` prefix and reads the expected site from `std::panic::Location::caller()`, so path separators do not matter. - `bullet_items` now takes a closure that builds the failure from the first empty marker, so `sections.rs`, which holds the prompt body, builds and places it. There `empty_item` finds the marker's byte offset from its address relative to `shared`, and gives no location when the marker is not a slice of `shared`. - A file with no opening frontmatter delimiter, or with frontmatter that never closes, fails on line 1, as an unclosed `lua` fence fails on its opening line. More than one H1 fails on the line of the second, and an empty list item fails on its marker, past the indent. - A `FrontmatterInvalid` run message now ends with its line and column alone, such as `(line 4, column 1)`. For that code, `location` drops the `Name` fact, which, when present, is a refused Pack's id, so the message never names the Pack as the prompt. - `check_start` now passes `UnsupportedVersion` through `with_prompt_context` as well, so the failure gets the prompt's `Name` fact and the prose that `with_prompt_context` writes. A prompt with no `runvil:` key still fails with `StructureInvalid` and no location. - The `RequirementsUnmet` run message puts its `(at {file}:{line})` suffix after the whole notice, on the last gap's line, even when the suffix gives the line of the first gap's key. - Three version tests that compared the whole error now compare the outcome or single fields, so none of them pins the prose that `UnsupportedVersion` now gets. --- .../runner/src/host-tests-pick.rs | 7 +- .../harness-internal/runner/src/host-tests.rs | 3 +- .../runner/tests/it/prepare.rs | 6 +- crates/runvil-internal/engine/src/execute.rs | 4 +- .../engine/src/execute/context.rs | 17 +-- .../engine/src/execute/error-tests.rs | 22 ++++ .../engine/src/execute/error.rs | 15 ++- .../engine/src/execute/requirements-tests.rs | 56 ++++++++- .../engine/src/execute/requirements.rs | 37 +++++- .../engine/src/execute/run/tests-start.rs | 3 +- .../engine/src/execute/scheduler/drive.rs | 50 ++++---- .../engine/src/execute/tests/exit_rules.rs | 32 ++++- .../execute/tests/suite/prepare-refusals.rs | 13 +- crates/runvil-internal/prompt/src/detail.rs | 12 ++ .../runvil-internal/prompt/src/error-tests.rs | 113 ++++++++++++++---- crates/runvil-internal/prompt/src/error.rs | 17 ++- crates/runvil-internal/prompt/src/fence.rs | 39 ++++-- .../runvil-internal/prompt/src/frontmatter.rs | 66 ++++++++-- crates/runvil-internal/prompt/src/list.rs | 39 +++--- crates/runvil-internal/prompt/src/parse.rs | 53 ++++---- crates/runvil-internal/prompt/src/prompt.rs | 5 + crates/runvil-internal/prompt/src/sections.rs | 28 ++++- .../prompt/src/tests/frontmatter.rs | 52 +++++++- .../runvil-internal/prompt/src/tests/lists.rs | 44 +++++-- .../prompt/src/tests/section_fences.rs | 2 +- crates/runvil/tests/suite/packs.rs | 2 +- crates/runvil/tests/suite/prepare.rs | 6 +- crates/runvil/tests/suite/prompt.rs | 2 +- 28 files changed, 578 insertions(+), 167 deletions(-) diff --git a/crates/harness-internal/runner/src/host-tests-pick.rs b/crates/harness-internal/runner/src/host-tests-pick.rs index 9ed03c14d..e7b73f52a 100644 --- a/crates/harness-internal/runner/src/host-tests-pick.rs +++ b/crates/harness-internal/runner/src/host-tests-pick.rs @@ -79,7 +79,7 @@ fn a_bare_entry_that_only_another_vendor_has_is_unavailable_naming_each_fqn() { refusal(&run, &declares), "the environment cannot satisfy this prompt:\n\ - poop is unavailable: poop means runvil/poop, which is not installed; \ - write acme/poop to use that Pack", + write acme/poop to use that Pack (at prompt:5)", "the run names the reason and does not call the Pack missing" ); @@ -116,7 +116,7 @@ fn an_entry_no_vendor_has_is_missing_and_an_absent_vendor_name_is_unavailable() refusal(&run, &declares), "the environment cannot satisfy this prompt:\n\ - missing required Pack: nope\n\ - - gone is unavailable: acme/gone is not installed" + - gone is unavailable: acme/gone is not installed (at prompt:5)" ); } @@ -152,7 +152,8 @@ fn the_roster_covers_every_declared_pack_the_run_cannot_serve() { "the environment cannot satisfy this prompt:\n\ - missing required Pack: absent\n\ - needy is unavailable: the run lacks the services it needs: tests/session\n\ - - broken is unavailable: backed needs tests/backend, and this host provides none" + - broken is unavailable: backed needs tests/backend, and this host provides none \ + (at prompt:5)" ); } diff --git a/crates/harness-internal/runner/src/host-tests.rs b/crates/harness-internal/runner/src/host-tests.rs index a2716dfec..182da5454 100644 --- a/crates/harness-internal/runner/src/host-tests.rs +++ b/crates/harness-internal/runner/src/host-tests.rs @@ -313,7 +313,8 @@ fn a_construct_failure_is_stored_and_reported_as_unavailable_with_its_reason() { assert_eq!( refusal(&run, &declares), "the environment cannot satisfy this prompt:\n\ - - backed is unavailable: backed needs tests/backend, and this host provides none", + - backed is unavailable: backed needs tests/backend, and this host provides none \ + (at prompt:5)", "the run names the reason, and never calls the built Pack missing" ); diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index 21d576f99..d95a6817f 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -214,14 +214,14 @@ async fn an_unmet_requirement_ends_the_prepared_run_as_failed_with_the_engines_n let (run_id, outcome) = prepare_and_drive(NEEDS_WEB, services(&recorder, bare())).await; let notice = "the environment cannot satisfy this prompt:\n\ - - missing required Pack: web"; + - missing required Pack: web (at prompt:5)"; assert_eq!( outcome, RunOutcome::Failed { kind: RunErrorKind::RequirementsUnmet, message: notice.to_owned(), }, - "the failure is the engine's notice, verbatim" + "the failure is the engine's notice, verbatim, on the `uses:` line" ); assert_eq!( recorder.outcome(run_id), @@ -460,7 +460,7 @@ async fn a_bare_entry_another_vendor_answers_is_refused_naming_its_fqn() { refusal(drive_over(declares_bare, building(TOOLS)).await), "the environment cannot satisfy this prompt:\n\ - tools is unavailable: tools means runvil/tools, which is not installed; \ - write tests/tools to use that Pack", + write tests/tools to use that Pack (at prompt:5)", "a bare id means the runvil Pack, which the Host lacks" ); } diff --git a/crates/runvil-internal/engine/src/execute.rs b/crates/runvil-internal/engine/src/execute.rs index e72827025..ca14ebc3a 100644 --- a/crates/runvil-internal/engine/src/execute.rs +++ b/crates/runvil-internal/engine/src/execute.rs @@ -120,8 +120,8 @@ pub enum RunOutcome { /// The failure's self-contained message, with the text of each /// cause it keeps and then where it happened, when known: the /// prompt name, line, and column for a failure in the prompt's - /// frontmatter, structure, fences, or lists, and - /// `(at {file}:{line})` for any other. + /// structure, fences, or lists, the line and column for one in its + /// frontmatter, and `(at {file}:{line})` for any other. message: String, }, /// The caller cancelled the run or dropped an effect the run was waiting diff --git a/crates/runvil-internal/engine/src/execute/context.rs b/crates/runvil-internal/engine/src/execute/context.rs index 67cdf80bd..977560e1d 100644 --- a/crates/runvil-internal/engine/src/execute/context.rs +++ b/crates/runvil-internal/engine/src/execute/context.rs @@ -21,6 +21,7 @@ use runvil_types::packs::Prelude; use runvil_types::untrusted::GuardNonce; use runvil_vfs::{Access, VfsRef}; +use crate::Error; use crate::lua::{Argv, ToolSet}; use crate::model::{ModelBinding, ModelRole, ModelSet}; use crate::prompt::Prompt; @@ -83,9 +84,9 @@ struct SharedState { /// by nothing during the run, so every reader shares it with no lock and /// no copy. model_set: Arc, - /// The refusal notice the run's first step fails with: what the prompt + /// The refusal the run's first step fails with: what the prompt /// requires that the config lacks, or `None` when it lacks nothing. - refusal: Option>, + refusal: Option, /// The run's `started_at` rendered as RFC 3339, stamped into every /// section's `sys.when`, the H1 pass included. when: Arc, @@ -137,7 +138,7 @@ impl ChainState { /// is minted here (starting at zero), as are the run's shared tool and /// model sets - the tool set built from the catalog and the roster, /// the model set filled from the role map (it stays empty for an empty - /// map) - and the refusal notice of what the prompt requires that + /// map) - and the refusal for what the prompt requires that /// `config` lacks; the nonce derives from `config`'s seed and `when` /// renders `config`'s `started_at`, so two states over the same inputs /// agree on both. @@ -161,7 +162,7 @@ impl ChainState { let mut requirements = Requirements::default(); let model_set = Arc::new(fill::fill_model_set(&prompt, models, &mut requirements)); requirements.check_packs(&prompt, &tools, packs.as_ref()); - let refusal = requirements.notice().map(Arc::from); + let refusal = requirements.refusal(&prompt); let tool_set = Arc::new(bound::run_tool_set( &prompt, tools, @@ -323,10 +324,10 @@ impl ChainState { Arc::clone(&self.shared.model_set) } - /// The refusal notice the run's first step fails with, or `None` when - /// the config lacks nothing the prompt requires. - pub(super) fn refusal(&self) -> Option<&str> { - self.shared.refusal.as_deref() + /// The refusal the run's first step fails with, or `None` when the + /// config lacks nothing the prompt requires. + pub(super) fn refusal(&self) -> Option<&Error> { + self.shared.refusal.as_ref() } /// The resolved tool-loop cap per `models.loop` or model handle `loop` diff --git a/crates/runvil-internal/engine/src/execute/error-tests.rs b/crates/runvil-internal/engine/src/execute/error-tests.rs index 13c323a28..c030d8736 100644 --- a/crates/runvil-internal/engine/src/execute/error-tests.rs +++ b/crates/runvil-internal/engine/src/execute/error-tests.rs @@ -209,6 +209,28 @@ fn a_parsed_prompts_failure_message_ends_with_its_name_and_position() { ); } +#[test] +fn a_refused_uses_list_ends_with_its_line_and_never_names_the_pack_as_the_prompt() { + let source = "---\nname: x\ndescription: d\nuses: [web, web]\n---\n\n# T\n"; + let refused = Prompt::parse(source, "prompt", "test") + .0 + .expect_err("a Pack id declared twice must be refused"); + assert_eq!( + refused.facts.get(&FactKey::Name), + Some(&serde_json::Value::from("web")), + "the refused Pack's id is the failure's name" + ); + assert_eq!( + outcome(&refused), + RunOutcome::Failed { + kind: RunErrorKind::Parse, + message: "invalid frontmatter: Pack id web is declared more than once under uses \ + (line 4, column 1)" + .to_owned(), + } + ); +} + #[test] fn a_parse_failure_run_raises_names_the_prompt_and_its_file() { let code = ErrorCode::StructureInvalid; diff --git a/crates/runvil-internal/engine/src/execute/error.rs b/crates/runvil-internal/engine/src/execute/error.rs index 035823ea0..8dd524648 100644 --- a/crates/runvil-internal/engine/src/execute/error.rs +++ b/crates/runvil-internal/engine/src/execute/error.rs @@ -197,12 +197,14 @@ pub(crate) fn run_error_kind(code: ErrorCode) -> RunErrorKind { /// Returns the self-contained text a failed run reports: the error's /// message, with its causes, then where the failure happened. /// -/// A frontmatter, structure, fence, or list failure, the parser's own, -/// ends with its prompt name, line, and column, each when known, as -/// `(prompt {name}, line {line}, column {column})`. Any other failure with -/// a location ends with it as `(at {file}:{line})`: its prompt file and -/// line, or for an internal fault the Rust source file and line of the -/// broken invariant. +/// A structure, fence, or list failure, the parser's own, ends with its +/// prompt name, line, and column, each when known, as +/// `(prompt {name}, line {line}, column {column})`. A frontmatter failure +/// ends with its line and column alone, each when known: it predates the +/// prompt's name, and its `Name` fact, when it has one, is a refused +/// Pack's id. Any other failure with a location ends with it as +/// `(at {file}:{line})`: its prompt file and line, or for an internal +/// fault the Rust source file and line of the broken invariant. pub(crate) fn failure_message(error: &Error) -> String { let mut rendered = error.message.clone(); if let Some(location) = location(error) { @@ -226,6 +228,7 @@ fn location(error: &Error) -> Option { error .facts .get(&FactKey::Name) + .filter(|_| error.code != ErrorCode::FrontmatterInvalid) .and_then(serde_json::Value::as_str) .map(|name| format!("prompt {name}")), error diff --git a/crates/runvil-internal/engine/src/execute/requirements-tests.rs b/crates/runvil-internal/engine/src/execute/requirements-tests.rs index 30d99e891..b0db2bf76 100644 --- a/crates/runvil-internal/engine/src/execute/requirements-tests.rs +++ b/crates/runvil-internal/engine/src/execute/requirements-tests.rs @@ -1,7 +1,8 @@ //! Tests for the run's start checks against its config: each declared //! Pack against the roster, the reserved Pack ids `adhoc` and `dynamic`, -//! and the order of the refusal notice's lines. The model role checks sit -//! in the execution suite's `prepare` module. +//! the order of the refusal notice's lines, and the frontmatter key the +//! refusal is placed on. The model role checks sit in the execution +//! suite's `prepare` module. use std::collections::BTreeMap; use std::sync::Arc; @@ -170,7 +171,7 @@ fn a_declared_pack_the_roster_lists_as_unavailable_is_refused_with_its_reason_no refusal(prompt("uses: [web]\n"), ctx), Some( "the environment cannot satisfy this prompt:\n\ - - web is unavailable: the search provider is missing" + - web is unavailable: the search provider is missing (at prompt:5)" .to_owned() ) ); @@ -190,7 +191,7 @@ fn declared_packs_the_roster_lacks_are_missing_and_list_before_the_unavailable_o - missing required Pack: other\n\ - missing required Pack: gone\n\ - web is unavailable: the search provider is missing\n\ - - mcp is unavailable: the server is down" + - mcp is unavailable: the server is down (at prompt:5)" .to_owned() ), "each kind of gap lists in declaration order" @@ -207,7 +208,8 @@ fn a_run_given_no_roster_checks_no_declared_pack_and_an_empty_roster_lacks_every assert_eq!( refusal(prompt("uses: [web]\n"), config().with_packs([])), Some( - "the environment cannot satisfy this prompt:\n- missing required Pack: web".to_owned() + "the environment cannot satisfy this prompt:\n- missing required Pack: web (at prompt:5)" + .to_owned() ), "an empty roster is a roster" ); @@ -229,8 +231,50 @@ fn the_notice_lists_missing_then_unavailable_packs_then_reserved_ids_then_model_ "- kit is unavailable: the kit server is down", &reserved, "- role 'strong': requires a context of at least 200000 tokens; \ - the model filling it provides 32000", + the model filling it provides 32000 (at prompt:5)", ]; let source = "uses: [kit, web]\nmodels: { strong: { min_context: 200000 } }\n"; assert_eq!(refusal(prompt(source), ctx), Some(expected.join("\n"))); } + +#[test] +fn the_refusal_is_placed_on_the_key_of_the_first_gap_the_prompt_caused() { + // `models:` is file line 5 and `uses:` file line 6. + let source = "models: { strong: { min_context: 200000 } }\nuses: [web]\n"; + let small = BTreeMap::from([( + ModelRole::Default, + test_model_with("small", 32_000, ThinkingMode::Switchable), + )]); + let no_default = BTreeMap::from([( + ModelRole::Strong, + test_model_with("big", 200_000, ThinkingMode::Switchable), + )]); + for (ctx, suffix, why) in [ + ( + config().with_packs([]).with_models(small.clone()), + Some(" (at prompt:6)"), + "a Pack line lists before a model line", + ), + ( + config() + .with_tools(catalog(&["adhoc/grab"])) + .with_models(small), + Some(" (at prompt:5)"), + "the reserved id lists first but is the caller's gap", + ), + ( + config() + .with_tools(catalog(&["adhoc/grab"])) + .with_models(no_default), + None, + "a reserved id and a role map with no default are both the caller's", + ), + ] { + let message = refusal(prompt(source), ctx).expect("the run is refused"); + assert_eq!( + message.find(" (at ").map(|at| &message[at..]), + suffix, + "{why}: {message}" + ); + } +} diff --git a/crates/runvil-internal/engine/src/execute/requirements.rs b/crates/runvil-internal/engine/src/execute/requirements.rs index 0bb2661d7..b31ca8e1d 100644 --- a/crates/runvil-internal/engine/src/execute/requirements.rs +++ b/crates/runvil-internal/engine/src/execute/requirements.rs @@ -3,12 +3,14 @@ use std::collections::{BTreeMap, BTreeSet}; +use runvil_types::error::place; use runvil_types::models::ModelRole; use runvil_types::packs::PackId; use runvil_types::tools::ToolCatalog; use crate::lua::RosterEntry; use crate::prompt::Prompt; +use crate::{Error, error}; #[cfg(test)] #[path = "requirements-tests.rs"] @@ -66,6 +68,39 @@ impl Requirements { .collect(); } + /// Returns the `RequirementsUnmet` failure the run's first step ends + /// with, whose message and prose are the refusal notice, or `None` when + /// nothing blocks the run. + /// + /// The failure is placed on the frontmatter key of the first gap in the + /// notice that the prompt caused: `uses:` for a missing or unavailable + /// Pack, and `models:` for a model that falls short of a role's + /// requirement. A reserved Pack id and a role map with no `default` + /// entry are the caller's gaps and point at no key, so a refusal made + /// only of those has no location. + pub(super) fn refusal(&self, prompt: &Prompt) -> Option { + let notice = self.notice()?; + let key = if self.missing_packs.is_empty() && self.unavailable_packs.is_empty() { + self.unmet_models + .iter() + .any(|unmet| { + matches!( + unmet.check, + RequirementCheck::ContextMinimum | RequirementCheck::Thinking + ) + }) + .then_some("models") + } else { + Some("uses") + }; + let line = key.and_then(|key| crate::prompt::detail::key_line(prompt, key)); + Some(place( + error::requirements_unmet(¬ice, None), + prompt.file(), + line, + )) + } + /// Returns the refusal notice, which explains what blocks the run, or /// `None` when nothing does. /// @@ -73,7 +108,7 @@ impl Requirements { /// carry required versus actual; the Pack lines name the Pack and, for /// an unavailable Pack, the reason. The notice is written for a model /// to read: concise, factual, and self-contained. - pub(super) fn notice(&self) -> Option { + fn notice(&self) -> Option { // Writing to a String is infallible, so each `write!` result is // discarded under the denied `unwrap_used`/`expect_used` lints. use std::fmt::Write as _; diff --git a/crates/runvil-internal/engine/src/execute/run/tests-start.rs b/crates/runvil-internal/engine/src/execute/run/tests-start.rs index 71def5a7e..fb86a89cb 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests-start.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests-start.rs @@ -32,7 +32,8 @@ fn a_run_that_cannot_start_reports_its_failure_on_the_first_step() { outcome, RunOutcome::Failed { kind: crate::execute::RunErrorKind::Parse, - message: "unsupported runvil version: 7 (this build supports major 0)".to_owned(), + message: "unsupported runvil version: 7 (this build supports major 0) (at prompt:4)" + .to_owned(), } ); assert!( diff --git a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs index 743ce1d60..7088634f0 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs @@ -9,7 +9,7 @@ //! every issued effect has its answer, so every effect the caller was //! handed has exactly one answer. -use runvil_types::error::ErrorCode; +use runvil_types::error::{ErrorCode, place}; use runvil_types::event::lifecycle; use runvil_types::ids::AbandonReason; use runvil_vfs::Origin; @@ -183,28 +183,34 @@ impl Scheduler { /// store, then what the prompt requires of the config. /// /// # Errors - /// Returns an `UnsupportedVersion` or a structural parse failure for a - /// prompt that is not a supported runvil prompt, a store failure when - /// the handle declares no store or the store's backend fails the probe, - /// or `RequirementsUnmet` with the refusal notice when the config lacks - /// a declared Pack or a model the prompt's roles require, or names a - /// Pack id the Engine reserves. + /// Returns an `UnsupportedVersion` failure on the `runvil:` line, or a + /// structural parse failure for a prompt with no `runvil:` key, either + /// naming the prompt; a store failure when the handle declares no store + /// or the store's backend fails the probe; or `RequirementsUnmet` with + /// the refusal notice when the config lacks a declared Pack or a model + /// the prompt's roles require, or names a Pack id the Engine reserves, + /// placed on the frontmatter key of the first gap the prompt caused. fn check_start(&self) -> Result<()> { let prompt = self.ctx.prompt(); let frontmatter = prompt.frontmatter(); - match frontmatter.runvil() { - Some(0) => {} - Some(other) => return Err(error::unsupported_version(other)), - None => { - return Err(crate::prompt::detail::with_prompt_context( - crate::prompt::detail::parse_error( - ErrorCode::StructureInvalid, - "not a runvil prompt: no runvil version", - ), - frontmatter.name(), - prompt.file(), - )); - } + let declined = match frontmatter.runvil() { + Some(0) => None, + Some(other) => Some(place( + error::unsupported_version(other), + prompt.file(), + crate::prompt::detail::key_line(prompt, "runvil"), + )), + None => Some(crate::prompt::detail::parse_error( + ErrorCode::StructureInvalid, + "not a runvil prompt: no runvil version", + )), + }; + if let Some(error) = declined { + return Err(crate::prompt::detail::with_prompt_context( + error, + frontmatter.name(), + prompt.file(), + )); } // Every run and every store call needs a declared store: the probe // stats the store root through a throwaway capability, so a handle @@ -216,8 +222,8 @@ impl Scheduler { .acquire(Origin::new("store mount probe")) .map_err(|cause| error::store(&cause))?; runvil_vfs::detail::probe_store(&probe).map_err(|cause| error::store(&cause))?; - if let Some(notice) = self.ctx.refusal() { - return Err(error::requirements_unmet(notice, None)); + if let Some(refusal) = self.ctx.refusal() { + return Err(refusal.clone()); } Ok(()) } 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 8b8bf0e14..906033738 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -5,7 +5,7 @@ //! raise `empty_model_reply`). use runvil_prompt::test_support::{PromptSource, one_section, prompt}; -use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::error::{ErrorCode, FactKey, Location}; use super::context::Recorder; use super::fixtures::echo_tool; @@ -97,9 +97,13 @@ fn unsupported_major_one_is_refused() { // declared version rather than silently degrading to major 0. let trace = Scenario::new(ran_under("runvil: 1\n")).run(); assert_eq!( - trace.error(), - &crate::error::unsupported_version(1), - "the gate refuses major 1 by name" + trace.outcome(), + &RunOutcome::Failed { + kind: RunErrorKind::Parse, + message: "unsupported runvil version: 1 (this build supports major 0) (at prompt:4)" + .to_owned(), + }, + "the gate refuses major 1 by name, on its `runvil:` line" ); } @@ -107,11 +111,26 @@ fn unsupported_major_one_is_refused() { fn unsupported_major_is_refused() { // Any major other than 0 is refused, never silently degraded to major 0. let trace = Scenario::new(ran_under("runvil: 2\n")).run(); + let error = trace.error(); assert_eq!( - trace.error(), - &crate::error::unsupported_version(2), + (error.code, error.message.as_str()), + ( + ErrorCode::UnsupportedVersion, + "unsupported runvil version: 2 (this build supports major 0)" + ), "the gate refuses major 2 by name" ); + let line = std::num::NonZeroU32::new(4).expect("4 is non-zero"); + assert_eq!( + error.at, + Some(Location::line("prompt", line)), + "the refusal points at the `runvil:` line" + ); + assert_eq!( + error.facts.get(&FactKey::Name).and_then(Value::as_str), + Some("t"), + "the refusal names the prompt" + ); } #[test] @@ -121,6 +140,7 @@ fn missing_version_is_not_a_runvil_prompt() { let trace = Scenario::new(ran_under("")).run(); let error = trace.error(); assert_eq!(error.code, ErrorCode::StructureInvalid, "got {error:?}"); + assert_eq!(error.at, None, "a key the prompt lacks has no line"); assert_eq!( error .facts diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs b/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs index 128e14e9e..fe33489c7 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/prepare-refusals.rs @@ -4,6 +4,7 @@ //! fired ending the run cancelled. use runvil_types::cancel::CancelHandle; +use runvil_types::error::ErrorCode; use runvil_types::event::Event; use runvil_types::models::ThinkingMode; @@ -151,10 +152,18 @@ fn an_unsupported_version_is_reported_before_an_unmet_requirement() { let trace = under_model(&source, "version-first", model).run(); let error = trace.error(); assert_eq!( - error, - &crate::error::unsupported_version(7), + (error.code, error.message.as_str()), + ( + ErrorCode::UnsupportedVersion, + "unsupported runvil version: 7 (this build supports major 0)" + ), "the version gate refuses the run first" ); + assert_eq!( + error.at.as_ref().map(|at| at.start_line.get()), + Some(4), + "on the `runvil:` line, not the `models:` line of the unmet requirement" + ); assert_eq!(run_error_kind(error.code), RunErrorKind::Parse, "{error}"); assert!( trace.run_events().is_empty(), diff --git a/crates/runvil-internal/prompt/src/detail.rs b/crates/runvil-internal/prompt/src/detail.rs index eeb76f1b3..cd8470592 100644 --- a/crates/runvil-internal/prompt/src/detail.rs +++ b/crates/runvil-internal/prompt/src/detail.rs @@ -4,6 +4,8 @@ //! The `runvil` facade never re-exports this module, so only Engine //! crates reach it. +use std::num::NonZeroU32; + use runvil_types::error::{Error, ErrorCode}; use crate::{Block, Prompt, Section}; @@ -42,3 +44,13 @@ pub fn sections(prompt: &Prompt) -> &[Section] { pub fn entry(prompt: &Prompt) -> Option<&Section> { prompt.entry() } + +/// Returns the file line of the frontmatter's top-level `key`, such as +/// `uses`, when exactly one line declares it at column 0. +/// +/// The line comes from a scan of the text, not the YAML parse, so it is +/// meaningful only for a key that decoded to a value. +#[must_use] +pub fn key_line(prompt: &Prompt, key: &str) -> Option { + prompt.key_lines.get(key).copied() +} diff --git a/crates/runvil-internal/prompt/src/error-tests.rs b/crates/runvil-internal/prompt/src/error-tests.rs index 34574754f..44bd27841 100644 --- a/crates/runvil-internal/prompt/src/error-tests.rs +++ b/crates/runvil-internal/prompt/src/error-tests.rs @@ -6,7 +6,7 @@ use std::num::NonZeroU32; use runvil_lua::LuaProgram; use runvil_types::emitter::{Emitter, EventSink}; -use runvil_types::error::{Error, ErrorCode, FactKey}; +use runvil_types::error::{Error, ErrorCode, FactKey, Location}; use super::{internal, lua, with_prompt_context}; use crate::tests::location; @@ -55,21 +55,83 @@ fn a_yaml_failure_is_frontmatter_invalid_at_its_line_and_column() { #[test] fn a_failure_with_no_line_names_only_the_file_in_its_prose() { - let error = failure_of("# T\n\n## S\n\np\n"); + // A flow mapping declares `uses` on no line of its own, so the refusal + // has no line to point at. + let error = failure_of("---\n{ name: x, description: d, uses: [web, web] }\n---\n\n# T\n"); assert_eq!(error.code, ErrorCode::FrontmatterInvalid); assert_eq!( error.message, - "file must begin with a --- frontmatter delimiter" + "invalid frontmatter: Pack id web is declared more than once under uses" ); assert_eq!(error.at, None); - assert_eq!(error.facts, BTreeMap::new()); assert_eq!( error.prose, - "agents/demo.md: file must begin with a --- frontmatter delimiter. The frontmatter is \ - invalid. Fix the YAML between the `---` lines so it matches the frontmatter schema." + "agents/demo.md: invalid frontmatter: Pack id web is declared more than once under uses. \ + The frontmatter is invalid. Fix the YAML between the `---` lines so it matches the \ + frontmatter schema." ); } +#[test] +fn a_failure_that_knows_its_line_is_located_on_it() { + let prologue = in_section("```lua\nreturn 1\n"); + let epilog = in_section("Prose.\n\n```lua\nreturn 1\n"); + let h1 = "---\nname: x\ndescription: d\n---\n\n# T\n\n```lua\nreturn 1\n"; + for (src, code, message, line) in [ + ( + "# T\n\n## S\n\np\n", + ErrorCode::FrontmatterInvalid, + "file must begin with a --- frontmatter delimiter", + 1, + ), + ( + "---\nname: x\ndescription: d\n\n# T\n", + ErrorCode::FrontmatterInvalid, + "frontmatter was not closed with ---", + 1, + ), + ( + "---\nname: x\ndescription: d\n---\n\n# T\n\n## S\n\np\n\n# U\n", + ErrorCode::StructureInvalid, + "prompt must contain exactly one H1 title, found 2 at lines 6, 12", + 12, + ), + ( + "---\nname: x\ndescription: d\n---\n\n#\n\n## S\n\np\n", + ErrorCode::StructureInvalid, + "prompt H1 title must not be empty", + 6, + ), + ( + prologue.as_str(), + ErrorCode::FenceInvalid, + "section `S` prologue `lua` fence is not closed", + 10, + ), + ( + epilog.as_str(), + ErrorCode::FenceInvalid, + "section `S` epilog `lua` fence is not closed", + 12, + ), + ( + h1, + ErrorCode::FenceInvalid, + "section `T` prologue `lua` fence is not closed", + 8, + ), + ] { + let error = failure_of(src); + assert_eq!((error.code, error.message.as_str()), (code, message)); + assert_eq!( + location(&error), + Some((FILE, line, 1, line + 1, 1)), + "{message}" + ); + assert_eq!(error.prose, prose_at(code, line, message)); + } +} + #[test] fn a_refused_uses_list_names_its_reason_and_the_pack_id() { for (entries, reason, pack, message) in [ @@ -92,7 +154,11 @@ fn a_refused_uses_list_names_its_reason_and_the_pack_id() { )); assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{entries}"); assert_eq!(error.message, message); - assert_eq!(error.at, None, "{entries}"); + assert_eq!( + location(&error), + Some((FILE, 4, 1, 5, 1)), + "the `uses:` line: {entries}" + ); assert_eq!( error.facts, BTreeMap::from([ @@ -103,10 +169,7 @@ fn a_refused_uses_list_names_its_reason_and_the_pack_id() { ); assert_eq!( error.prose, - format!( - "{FILE}: {message}. {}", - ErrorCode::FrontmatterInvalid.prose() - ) + prose_at(ErrorCode::FrontmatterInvalid, 4, message) ); } } @@ -168,17 +231,18 @@ fn a_fence_violation_names_the_rule_it_breaks() { } #[test] -fn an_empty_list_item_is_list_empty_item_with_no_line() { - let error = failure_of(&in_section("- alpha\n1.\n- beta\n")); +fn an_empty_list_item_is_list_empty_item_at_its_marker() { + let error = failure_of(&in_section("- alpha\n 1.\n- beta\n")); let message = "empty bullet item in list section `S`"; assert_eq!(error.code, ErrorCode::ListEmptyItem); assert_eq!(error.message, message); - assert_eq!(error.at, None); - assert_eq!(error.facts, BTreeMap::from([(FactKey::Name, "x".into())])); assert_eq!( - error.prose, - format!("{FILE}: {message}. {}", ErrorCode::ListEmptyItem.prose()) + location(&error), + Some((FILE, 11, 3, 11, 5)), + "the marker itself, past its indent" ); + assert_eq!(error.facts, BTreeMap::from([(FactKey::Name, "x".into())])); + assert_eq!(error.prose, prose_at(ErrorCode::ListEmptyItem, 11, message)); } #[test] @@ -207,14 +271,19 @@ fn a_lua_compile_failure_is_located_at_the_line_its_diagnostic_names() { } #[test] -fn an_internal_failure_has_the_default_prose_and_no_name() { - let error = with_prompt_context(internal("parser: a test invariant broke"), Some("x"), FILE); +fn a_parser_invariant_failure_is_located_at_its_rust_call_site_with_the_default_prose() { + let (fault, site) = (internal("parser: test"), std::panic::Location::caller()); + let error = with_prompt_context(fault, Some("x"), FILE); assert_eq!(error.code, ErrorCode::Internal); + assert_eq!(error.message, "internal invariant violated: parser: test"); assert_eq!( - error.message, - "internal invariant violated: parser: a test invariant broke" + error.at, + Some(Location::line( + site.file(), + NonZeroU32::new(site.line()).expect("a line is non-zero") + )), + "the call site's Rust file and line survive the prompt context" ); - assert_eq!(error.at, None); assert_eq!(error.facts, BTreeMap::new()); assert_eq!(error.prose, ErrorCode::Internal.prose()); } diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index cb7bfc0c5..f6083732d 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -22,6 +22,12 @@ pub(crate) fn failure(code: ErrorCode, message: impl Into) -> Error { Error::new(code, message, code.prose()) } +/// Places `error` on the whole file `line`, when known, as [`place`] does, +/// leaving the location's file for [`with_prompt_context`] to fill. +pub(crate) fn on_line(error: Error, line: Option) -> Error { + place(error, "", line) +} + /// Builds a failure at `span`, the byte range of the offending region /// within `body` (after the frontmatter and a leading BOM, CRLF normalized /// to LF), which starts in the file after `frontmatter_lines` lines. @@ -43,12 +49,11 @@ pub(crate) fn failure_at( } /// Builds the failure for a broken parser invariant: a state the -/// surrounding code has already guaranteed cannot occur. -pub(crate) fn internal(message: &str) -> Error { - failure( - ErrorCode::Internal, - format!("internal invariant violated: {message}"), - ) +/// surrounding code has already guaranteed cannot occur. Its location is +/// the Rust source position of the call site. +#[track_caller] +pub(crate) fn internal(message: &'static str) -> Error { + runvil_lua::internal_at(message, std::panic::Location::caller()) } /// Builds the frontmatter failure for a refused `uses:` list: `reason` is diff --git a/crates/runvil-internal/prompt/src/fence.rs b/crates/runvil-internal/prompt/src/fence.rs index aa7b1d402..5a9126f47 100644 --- a/crates/runvil-internal/prompt/src/fence.rs +++ b/crates/runvil-internal/prompt/src/fence.rs @@ -7,6 +7,7 @@ //! and splits content into [`RawBlock`]s, which this crate compiles into //! [`Block`](crate::Block)s. +use std::num::NonZeroU32; use std::ops::Range; use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; @@ -58,8 +59,13 @@ pub(crate) fn trim_leading_blank_lines(content: &str) -> &str { /// Extracts source through the first closing line, the line the Markdown parser /// 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, location: &str) -> Result<(String, &'a str)> { +/// broken an invariant, and it reports an internal error. A fence with no +/// closing line fails on `opening_line`, the file line of its opening line. +fn extract_exact_fence<'a>( + content: &'a str, + location: &str, + opening_line: Option, +) -> Result<(String, &'a str)> { let mut offset = 0; for line in content.split_inclusive('\n') { let text = line.strip_suffix('\n').unwrap_or(line); @@ -75,9 +81,12 @@ fn extract_exact_fence<'a>(content: &'a str, location: &str) -> Result<(String, } offset += line.len(); } - Err(error::failure( - ErrorCode::FenceInvalid, - format!("{location} fence is not closed"), + Err(error::on_line( + error::failure( + ErrorCode::FenceInvalid, + format!("{location} fence is not closed"), + ), + opening_line, )) } @@ -288,13 +297,17 @@ fn pending_prose<'a>(content: &'a str, segment: Range, rules: &[Range( content: &'a str, section: &str, + content_abs_line: u32, ) -> Result>> { let openings = exact_lua_openings(content); let rules = rule_ranges(content); @@ -324,7 +337,13 @@ pub(crate) fn split_section_blocks<'a>( } else { format!("section `{section}` epilog `lua`") }; - let (source, rest) = extract_exact_fence(after_open, &location)?; + let before = position::newlines_before(content, opening); + let opening_line = before + .as_ref() + .ok() + .and_then(|&newlines| content_abs_line.checked_add(newlines)) + .and_then(NonZeroU32::new); + let (source, rest) = extract_exact_fence(after_open, &location, opening_line)?; let fence_end = content.len() - rest.len(); if !(pos == 0 && opening == leading_start) { @@ -332,7 +351,7 @@ pub(crate) fn split_section_blocks<'a>( blocks.push(RawBlock::Prose(pending.trim())); } - let line_offset = position::line_add(position::newlines_before(content, opening)?, 1)?; + let line_offset = position::line_add(before?, 1)?; blocks.push(RawBlock::Lua { source, line_offset, diff --git a/crates/runvil-internal/prompt/src/frontmatter.rs b/crates/runvil-internal/prompt/src/frontmatter.rs index 96ac3ce1c..1ee1b02e8 100644 --- a/crates/runvil-internal/prompt/src/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/frontmatter.rs @@ -1,5 +1,6 @@ //! Splits a prompt's YAML frontmatter from its body and models what it declares. +use std::collections::BTreeMap; use std::num::NonZeroU32; use runvil_types::error::ErrorCode; @@ -195,42 +196,70 @@ pub(crate) struct SplitSource { /// The number of lines consumed by the frontmatter block (both `---` /// delimiters and everything between them). pub(crate) frontmatter_lines: u32, + /// The file line of each top-level key that exactly one YAML line + /// declares, by the key's name. + pub(crate) key_lines: BTreeMap, } -/// Splits a file into its YAML frontmatter and its markdown body. +/// Splits a file into its YAML frontmatter and its markdown body, and finds +/// the line of each top-level key. /// /// 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`. Whitespace before or after /// the dashes of the opening line is ignored. +/// +/// A YAML line declares a key when it starts at column 0 with the key, plain +/// or in double or single quotes, then optional spaces or tabs, a colon, and +/// the end of the line, a space, or a tab. A plain key runs to the first such +/// colon and never starts with whitespace or a YAML indicator such as `#` or +/// `-`, so a comment, an indented line, block scalar text, and a sequence +/// entry declare none. A key two lines declare has no line. +/// +/// # Errors +/// Returns a `FrontmatterInvalid` failure on line 1, the opening delimiter, +/// when the file does not open with `---` or the frontmatter is never closed. 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() { Some(l) if l.trim() == "---" => {} _ => { - return Err(error::failure( - ErrorCode::FrontmatterInvalid, - "file must begin with a --- frontmatter delimiter", + return Err(error::on_line( + error::failure( + ErrorCode::FrontmatterInvalid, + "file must begin with a --- frontmatter delimiter", + ), + Some(NonZeroU32::MIN), )); } } let mut yaml = String::new(); let mut closed = false; let mut line_count: u32 = 1; // opening --- + let mut declared: BTreeMap<&str, Option> = BTreeMap::new(); for line in lines.by_ref() { line_count += 1; if line.trim_end() == "---" { closed = true; break; } + if let Some(key) = declared_key(line) { + declared + .entry(key) + .and_modify(|first| *first = None) + .or_insert(NonZeroU32::new(line_count)); + } yaml.push_str(line); yaml.push('\n'); } if !closed { - return Err(error::failure( - ErrorCode::FrontmatterInvalid, - "frontmatter was not closed with ---", + return Err(error::on_line( + error::failure( + ErrorCode::FrontmatterInvalid, + "frontmatter was not closed with ---", + ), + Some(NonZeroU32::MIN), )); } let body = lines.collect::>().join("\n"); @@ -238,5 +267,28 @@ pub(crate) fn split_frontmatter(input: &str) -> Result { yaml, body, frontmatter_lines: line_count, + key_lines: declared + .into_iter() + .filter_map(|(key, line)| Some((key.to_owned(), line?))) + .collect(), }) } + +/// Returns the top-level key `line` declares, as [`split_frontmatter`] reads +/// a declaration. +fn declared_key(line: &str) -> Option<&str> { + let ends = |rest: &str| rest.is_empty() || rest.starts_with([' ', '\t']); + let first = line.chars().next()?; + let (key, rest) = match first { + '"' | '\'' => line[1..].split_once(first)?, + _ if first.is_whitespace() || "#-?:,[]{}&*!|>%@`".contains(first) => return None, + _ => { + let (colon, _) = line + .match_indices(':') + .find(|&(at, _)| ends(&line[at + 1..]))?; + (line[..colon].trim_end_matches([' ', '\t']), &line[colon..]) + } + }; + let rest = rest.trim_start_matches([' ', '\t']).strip_prefix(':')?; + ends(rest).then_some(key) +} diff --git a/crates/runvil-internal/prompt/src/list.rs b/crates/runvil-internal/prompt/src/list.rs index 332e3c7fc..5b37e5967 100644 --- a/crates/runvil-internal/prompt/src/list.rs +++ b/crates/runvil-internal/prompt/src/list.rs @@ -1,8 +1,8 @@ //! Detects list-only sections and extracts their bullet items. -use runvil_types::error::ErrorCode; +use runvil_types::error::Error; -use crate::{Result, error}; +use crate::Result; /// 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 @@ -10,11 +10,14 @@ use crate::{Result, error}; /// prose. /// /// # Errors -/// Returns a `ListEmptyItem` failure when the prose is a list and a marker -/// has no content. -pub(crate) fn bullet_items<'a>(prose: &'a str, section: &str) -> Result>> { +/// Returns the failure `empty_item` builds from the first marker with no +/// content, trimmed and sliced from `prose`, when the prose is a list. +pub(crate) fn bullet_items<'a>( + prose: &'a str, + empty_item: impl FnOnce(&'a str) -> Error, +) -> Result>> { let mut items = Vec::new(); - let mut has_empty_marker = false; + let mut empty_marker = None; for line in prose.lines() { let trimmed = line.trim(); if trimmed.is_empty() { @@ -22,17 +25,14 @@ pub(crate) fn bullet_items<'a>(prose: &'a str, section: &str) -> Result items.push(content), - ListLine::EmptyMarker => has_empty_marker = true, + ListLine::EmptyMarker => empty_marker = empty_marker.or(Some(trimmed)), ListLine::NotAMarker => return Ok(None), } } - if has_empty_marker { - return Err(error::failure( - ErrorCode::ListEmptyItem, - format!("empty bullet item in list section `{section}`"), - )); + match empty_marker { + Some(marker) => Err(empty_item(marker)), + None => Ok((!items.is_empty()).then_some(items)), } - Ok((!items.is_empty()).then_some(items)) } /// The classification of a single nonblank prose line as a list marker. @@ -121,11 +121,12 @@ mod tests { #[test] fn all_markers_predicate() { - 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); + fn items(prose: &str) -> Option> { + bullet_items(prose, |marker| panic!("no empty marker, got {marker:?}")) + .expect("a prose with no empty marker is never refused") + } + assert_eq!(items("- a\n1. b"), Some(vec!["a", "b"])); + assert_eq!(items("- a\nplain line"), None); + assert_eq!(items(" \n "), None); } } diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 0dc95b640..b904b492e 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -1,5 +1,6 @@ //! Parses a prompt's source into a [`Prompt`]: frontmatter, H1, and sections. +use std::num::NonZeroU32; use std::sync::Arc; use runvil_types::emitter::{Emitter, EventSink}; @@ -36,7 +37,8 @@ impl Prompt { /// nor vendor/name, two entries share a Pack id, or an entry names a /// Pack id the Engine reserves. A refused `uses:` list has the /// `Reason` fact `pack_duplicate` or `pack_reserved` and the Pack id - /// as its `Name` fact. + /// as its `Name` fact, and is located on the `uses:` line when one + /// line declares the key. /// - `StructureInvalid` 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 @@ -64,9 +66,10 @@ impl Prompt { /// - `Internal` when the parser fails through no fault of the prompt: /// one of its own invariants breaks, or the Lua compiler cannot start. /// - /// The failure's location, when known, is in `file`. A failure found - /// past the frontmatter, other than an `Internal` one, has the prompt's - /// frontmatter name as its `Name` fact. + /// The failure's location, when known, is in `file`, except that an + /// `Internal` failure is located at the Rust source position of the + /// fault. A failure found past the frontmatter, other than an + /// `Internal` one, has the prompt's frontmatter name as its `Name` fact. pub fn parse( input: &str, file: &str, @@ -98,13 +101,14 @@ impl Prompt { } /// Splits the frontmatter from the body, decodes it, and checks its - /// `uses:` list. + /// `uses:` list, placing a refused list on the `uses:` line. fn parse_frontmatter(input: &str) -> Result<(Frontmatter, SplitSource)> { let split = frontmatter::split_frontmatter(input)?; let frontmatter: Frontmatter = serde_yaml_ng::from_str(&split.yaml) .map_err(|cause| error::frontmatter_decode(&cause))?; - contract::check_distinct_packs(frontmatter.uses())?; - contract::check_no_reserved_pack(frontmatter.uses())?; + contract::check_distinct_packs(frontmatter.uses()) + .and_then(|()| contract::check_no_reserved_pack(frontmatter.uses())) + .map_err(|refusal| error::on_line(refusal, split.key_lines.get("uses").copied()))?; Ok((frontmatter, split)) } @@ -133,25 +137,29 @@ impl Prompt { } let lines = h1_positions .iter() - .map(|&index| { - position::line_add(frontmatter_lines, headings[index].body_line) - .map(|line| line.to_string()) - }) + .map(|&index| position::line_add(frontmatter_lines, headings[index].body_line)) .collect::>>()?; - return Err(error::failure( - ErrorCode::StructureInvalid, - format!( - "prompt must contain exactly one H1 title, found {} at lines {}", - lines.len(), - lines.join(", ") - ), + let listed: Vec = lines.iter().map(u32::to_string).collect(); + let message = format!( + "prompt must contain exactly one H1 title, found {} at lines {}", + lines.len(), + listed.join(", ") + ); + return Err(error::on_line( + error::failure(ErrorCode::StructureInvalid, message), + lines.get(1).copied().and_then(NonZeroU32::new), )); }; let h1 = &headings[*h1_index]; if h1.title.is_empty() { - return Err(error::failure( - ErrorCode::StructureInvalid, - "prompt H1 title must not be empty", + return Err(error::on_line( + error::failure( + ErrorCode::StructureInvalid, + "prompt H1 title must not be empty", + ), + frontmatter_lines + .checked_add(h1.body_line) + .and_then(NonZeroU32::new), )); } let title = h1.title.clone(); @@ -171,7 +179,7 @@ impl Prompt { let mut walker = SectionWalker::new(&shared, §ion_headings, frontmatter_lines, file, emitter); let mut h1_blocks = walker.compile_blocks( - fence::split_section_blocks(h1_content, &title)?, + fence::split_section_blocks(h1_content, &title, h1_content_abs_line)?, h1_content_abs_line, &title, |_| format!("H1 `{title}` lua"), @@ -190,6 +198,7 @@ impl Prompt { title, h1_blocks, sections, + key_lines: split.key_lines, }) } } diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index c43cb789b..67fba9756 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -6,6 +6,7 @@ //! Construction happens in the parsing modules; this module holds the value //! types and their accessors. +use std::collections::BTreeMap; use std::num::NonZeroU32; use crate::{BodyText, Frontmatter}; @@ -143,6 +144,10 @@ pub struct Prompt { pub(crate) h1_blocks: Vec, /// Top-level sections (H2s) in file order. pub(crate) sections: Vec

, + /// The file line of each top-level frontmatter key that exactly one + /// line declares, by the key's name; the Engine reaches it through + /// [`crate::detail::key_line`]. + pub(crate) key_lines: BTreeMap, } impl Prompt { diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index 42d5d31ea..52cbc5de3 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -274,7 +274,7 @@ impl<'a> SectionWalker<'a> { 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)?; + let raw_blocks = fence::split_section_blocks(heading.content, name, content_abs_line)?; self.compile_blocks(raw_blocks, content_abs_line, name, |role| { fence::lua_block_location(location, role) }) @@ -332,6 +332,9 @@ impl<'a> SectionWalker<'a> { /// /// A section is a list when it has no Lua and [`list::bullet_items`] returns /// items. + /// + /// # Errors + /// Returns a `ListEmptyItem` failure at the first marker with no content. fn list_items(&self, blocks: &[Block], name: &str) -> Result> { let has_no_lua = blocks .iter() @@ -342,7 +345,7 @@ impl<'a> SectionWalker<'a> { }); if has_no_lua && let Some(prose) = prose_for_items - && let Some(items) = list::bullet_items(prose, name)? + && let Some(items) = list::bullet_items(prose, |marker| self.empty_item(marker, name))? { items .into_iter() @@ -353,6 +356,27 @@ impl<'a> SectionWalker<'a> { } } + /// Builds the failure for a list item with no content at `marker`, its + /// marker sliced from `shared`, or with no location when `marker` is not + /// a slice of it. + fn empty_item(&self, marker: &str, name: &str) -> Error { + let message = format!("empty bullet item in list section `{name}`"); + match marker + .as_ptr() + .addr() + .checked_sub(self.shared.as_ptr().addr()) + { + Some(start) => error::failure_at( + ErrorCode::ListEmptyItem, + message, + self.shared, + start..start + marker.len(), + self.frontmatter_lines, + ), + None => error::failure(ErrorCode::ListEmptyItem, message), + } + } + /// Names a section in its Lua block locations. /// /// The name is the title alone, or the title and its heading line when diff --git a/crates/runvil-internal/prompt/src/tests/frontmatter.rs b/crates/runvil-internal/prompt/src/tests/frontmatter.rs index 540af5907..627885db7 100644 --- a/crates/runvil-internal/prompt/src/tests/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/tests/frontmatter.rs @@ -1,5 +1,8 @@ //! Frontmatter tests: required keys and delimiters, the tool-loop cap, -//! the `runvil:` key, and declared files. +//! the `runvil:` key, declared files, and the line of each key. + +use std::collections::BTreeMap; +use std::num::NonZeroU32; use super::*; @@ -127,3 +130,50 @@ fn runvil_zero_is_accepted() { let src = "---\nname: x\ndescription: d\nrunvil: 0\n---\n\n# T\n\n## S\n\np\n"; assert_eq!(parse_ok(src).frontmatter().runvil(), Some(0)); } + +#[test] +fn a_key_line_is_the_one_column_0_line_that_declares_the_key() { + // The scan reads lines, not YAML, so this frontmatter need not decode. + let src = concat!( + "---\n", + "name: x\n", // 2: plain + "\"description\": |\n", // 3: double-quoted + " uses: [block scalar]\n", // 4: indented block scalar text + " runvil: 1\n", // 5: indented block scalar text + "'runvil' \t: 0\n", // 6: single-quoted, spaces and a tab + "# output: x\n", // 7: a comment + "args:\n", // 8: the colon ends the line + "input:x\n", // 9: no space after the colon + "x-team.owner: a: b\n", // 10: any key, up to the first such colon + "models: {}\n", // 11: declared twice + "models: {}\n", // 12: declared twice + "max_tool_iterations : 3\n", // 13: a space before the colon + "- uses: [web]\n", // 14: a sequence entry + "---\n\n# T\n", + ); + let split = crate::frontmatter::split_frontmatter(src).expect("the frontmatter splits"); + let lines: BTreeMap<&str, u32> = split + .key_lines + .iter() + .map(|(key, line)| (key.as_str(), line.get())) + .collect(); + assert_eq!( + lines, + BTreeMap::from([ + ("args", 8), + ("description", 3), + ("max_tool_iterations", 13), + ("name", 2), + ("runvil", 6), + ("x-team.owner", 10), + ]) + ); +} + +#[test] +fn a_parsed_prompt_gives_the_engine_the_line_of_each_key_it_declares() { + let prompt = parse_ok("---\nname: x\ndescription: d\nrunvil: 0\nuses: [web]\n---\n\n# T\n"); + let line = |key| crate::detail::key_line(&prompt, key).map(NonZeroU32::get); + assert_eq!((line("runvil"), line("uses")), (Some(4), Some(5))); + assert_eq!(line("models"), None, "an absent key has no line"); +} diff --git a/crates/runvil-internal/prompt/src/tests/lists.rs b/crates/runvil-internal/prompt/src/tests/lists.rs index c2209b218..873b60d04 100644 --- a/crates/runvil-internal/prompt/src/tests/lists.rs +++ b/crates/runvil-internal/prompt/src/tests/lists.rs @@ -49,30 +49,52 @@ fn list_error_kind_does_not_depend_on_the_section_name() { } } +/// The items of `prose`, which has no empty marker. +#[track_caller] +fn items(prose: &str) -> Option> { + bullet_items(prose, |marker| panic!("no empty marker, got {marker:?}")) + .expect("a prose with no empty marker is never refused") +} + #[test] fn bullet_parser_strips_unordered_markers() { - let items = bullet_items("- alpha\n* beta\n- gamma", "test").unwrap(); - assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); + assert_eq!( + items("- alpha\n* beta\n- gamma"), + Some(vec!["alpha", "beta", "gamma"]) + ); } #[test] fn bullet_parser_strips_ordered_markers() { - let items = bullet_items("1. first\n2. second\n3) third", "test").unwrap(); - assert_eq!(items, Some(vec!["first", "second", "third"])); + assert_eq!( + items("1. first\n2. second\n3) third"), + Some(vec!["first", "second", "third"]) + ); } #[test] fn bullet_parser_ignores_blank_lines() { - let items = bullet_items("- alpha\n\n- beta\n \n- gamma", "test").unwrap(); - assert_eq!(items, Some(vec!["alpha", "beta", "gamma"])); + assert_eq!( + items("- alpha\n\n- beta\n \n- gamma"), + Some(vec!["alpha", "beta", "gamma"]) + ); } #[test] -fn bullet_parser_rejects_empty_item() { - 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}" +fn bullet_parser_fails_with_the_failure_built_from_the_first_empty_marker() { + let prose = "- alpha\n - \n1.\n- gamma"; + let mut seen = None; + let error = bullet_items(prose, |marker| { + seen = Some(marker); + Error::new(ErrorCode::ListEmptyItem, "empty item", "empty item") + }) + .expect_err("an empty item must be refused"); + assert_eq!(error.code, ErrorCode::ListEmptyItem, "the caller's failure"); + assert_eq!(seen, Some("-")); + assert_eq!( + seen.map(str::as_ptr), + Some(prose[10..].as_ptr()), + "the trimmed marker of the first empty item, sliced from the prose" ); } diff --git a/crates/runvil-internal/prompt/src/tests/section_fences.rs b/crates/runvil-internal/prompt/src/tests/section_fences.rs index ba5142bf0..dd52fa723 100644 --- a/crates/runvil-internal/prompt/src/tests/section_fences.rs +++ b/crates/runvil-internal/prompt/src/tests/section_fences.rs @@ -219,7 +219,7 @@ fn a_fence_split_that_disagrees_with_the_markdown_parser_is_an_internal_error() // The parse refuses the four-backtick closing line before any split, so // only a direct split can reach the disagreement. let content = "```lua\nvar.a = 1\n````\n\n```lua\nvar.b = 2\n```\n"; - let Err(error) = crate::fence::split_section_blocks(content, "S") else { + let Err(error) = crate::fence::split_section_blocks(content, "S", 10) else { panic!("required a split failure, actual a split of {content:?}"); }; assert_eq!(error.code, ErrorCode::Internal, "{error:?}"); diff --git a/crates/runvil/tests/suite/packs.rs b/crates/runvil/tests/suite/packs.rs index bd96a19a2..f9ceffec0 100644 --- a/crates/runvil/tests/suite/packs.rs +++ b/crates/runvil/tests/suite/packs.rs @@ -51,7 +51,7 @@ fn a_declared_pack_the_roster_lacks_or_cannot_use_refuses_the_run() -> TestResul refusal(scenario, &source)?, "the environment cannot satisfy this prompt:\n\ - missing required Pack: web\n\ - - kit is unavailable: the kit server is down" + - kit is unavailable: the kit server is down (at prompt:5)" ); Ok(()) } diff --git a/crates/runvil/tests/suite/prepare.rs b/crates/runvil/tests/suite/prepare.rs index 565d9ebb5..65879c98d 100644 --- a/crates/runvil/tests/suite/prepare.rs +++ b/crates/runvil/tests/suite/prepare.rs @@ -147,7 +147,7 @@ fn a_context_minimum_above_the_filling_models_is_refused() -> TestResult { refusal_under(STRONG, 32_000, ThinkingMode::Always)?, "the environment cannot satisfy this prompt:\n\ - role 'strong': requires a context of at least 200000 tokens; \ - the model filling it provides 32000" + the model filling it provides 32000 (at prompt:5)" ); Ok(()) } @@ -159,14 +159,14 @@ fn a_thinking_requirement_the_filling_model_fails_is_refused() -> TestResult { refusal_under(STRONG, 200_000, ThinkingMode::Never)?, "the environment cannot satisfy this prompt:\n\ - role 'strong': requires 'thinking: true'; \ - the thinking capability of the model filling it is Never" + the thinking capability of the model filling it is Never (at prompt:5)" ); let small = "models: { small: { thinking: false } }"; assert_eq!( refusal_under(small, 32_000, ThinkingMode::Always)?, "the environment cannot satisfy this prompt:\n\ - role 'small': requires 'thinking: false'; \ - the thinking capability of the model filling it is Always" + the thinking capability of the model filling it is Always (at prompt:5)" ); Ok(()) } diff --git a/crates/runvil/tests/suite/prompt.rs b/crates/runvil/tests/suite/prompt.rs index a4056b614..5d46f0764 100644 --- a/crates/runvil/tests/suite/prompt.rs +++ b/crates/runvil/tests/suite/prompt.rs @@ -115,7 +115,7 @@ fn a_role_check_by_hand_finds_the_same_shortfall_the_run_reports() -> TestResult refusal(scenario, &source)?, "the environment cannot satisfy this prompt:\n\ - role 'small': requires 'thinking: false'; \ - the thinking capability of the model filling it is Always", + the thinking capability of the model filling it is Always (at prompt:5)", "only the small role falls short, on its thinking requirement" ); Ok(()) From fc9438875ca433553197ccaecc68f5473c745d0b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 18:03:47 -0800 Subject: [PATCH 07/36] Add error location invariants and correct stale comments The types, Lua, and Engine crates now record the error location rules as invariants, and the change corrects stale comments to agree with the code. The three Lua shim chunks now say that their `yield` wraps `coroutine.yield`, and `program.rs` now says that its leading tag names the block's display location. A new test in `exit_rules.rs` compares the version gate's whole refusal error, including the prose that `unsupported_major_is_refused` does not check. - The types invariant names `coordinates` as the only code that formats a location as `file:line`. `map_chunk_line_to_absolute` in `program.rs` writes its own leading tag with `format!("{location}:{absolute}: {result}")`, where `location` is the block's display location. - The Lua invariant on error tables puts `file:line` only in the table's `message`, never in the error's prose. The new test expects the version gate's refusal to start its prose with `prompt:4:`, because the refusal goes through the parser's prompt context. - The `coro.rs` changes move line breaks only; no words change. --- .../engine/src/execute/tests/exit_rules.rs | 24 +++++++++++++++++++ crates/runvil-internal/engine/src/lib.rs | 4 ++++ .../runvil-internal/lua/src/__impl_coro.lua | 2 +- .../runvil-internal/lua/src/__impl_fanout.lua | 2 +- .../runvil-internal/lua/src/__impl_tasks.lua | 2 +- crates/runvil-internal/lua/src/coro.rs | 17 +++++++------ crates/runvil-internal/lua/src/lib.rs | 9 +++++++ crates/runvil-internal/lua/src/program.rs | 4 ++-- crates/runvil-internal/types/src/lib.rs | 6 +++++ 9 files changed, 56 insertions(+), 14 deletions(-) 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 906033738..949a57b7b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -133,6 +133,30 @@ fn unsupported_major_is_refused() { ); } +#[test] +fn unsupported_major_refusal_gets_the_prompt_context() { + // The gate's refusal goes through the parser's prompt context, so its + // prose leads with the `runvil:` line, as a parse failure's does. + let trace = Scenario::new(ran_under("runvil: 3\n")).run(); + let message = "unsupported runvil version: 3 (this build supports major 0)"; + let mut expected = runvil_types::error::Error::new( + ErrorCode::UnsupportedVersion, + message, + format!( + "prompt:4: {message}. {}", + ErrorCode::UnsupportedVersion.prose() + ), + ); + let line = std::num::NonZeroU32::new(4).expect("4 is non-zero"); + expected.at = Some(Location::line("prompt", line)); + expected.facts.insert(FactKey::Name, Value::from("t")); + assert_eq!( + trace.error(), + &expected, + "the whole refusal: its prose, its line, and the prompt's name" + ); +} + #[test] fn missing_version_is_not_a_runvil_prompt() { // No `runvil:` key: not our prompt, so `run` declines with a Parse diff --git a/crates/runvil-internal/engine/src/lib.rs b/crates/runvil-internal/engine/src/lib.rs index 26f60e076..865837f62 100644 --- a/crates/runvil-internal/engine/src/lib.rs +++ b/crates/runvil-internal/engine/src/lib.rs @@ -14,6 +14,10 @@ //! `runvil-prompt`, `runvil-vfs`, and //! `runvil-model-client`. `cargo test -p build-xtask` enforces the //! product and container boundaries. +//! - A failed run's message ends with ` (at file:line)` when its failure +//! is located, except that a structure, fence, or list failure, the +//! parser's own, ends with ` (prompt ..., line ..., column ...)`, and a +//! frontmatter failure with ` (line ..., column ...)`. mod error; mod execute; diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index d7dffe23c..192dee2f7 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -2,7 +2,7 @@ -- -- The Engine installs this after the Engine globals exist and before the -- Pack preludes. The chunk arguments are privileged captures, never --- globals: `yield` is coroutine.yield (the coroutine global is stripped +-- globals: `yield` wraps coroutine.yield (the coroutine global is stripped -- after install, so author code cannot yield directly), `var_snapshot` is -- the Engine helper returning the hidden `var` data table as a plain deep -- copy, `models`/`tools` are the section's namespace tables, passed in so diff --git a/crates/runvil-internal/lua/src/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index c73fd4188..2f1e6e6fe 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -4,7 +4,7 @@ -- -- The Engine installs this after the coroutine prelude (`__impl_coro.lua`) -- and installs the returned function as `tasks.fanout`. The chunk --- arguments are privileged captures, never globals: `yield` is +-- arguments are privileged captures, never globals: `yield` wraps -- coroutine.yield, `var_snapshot` is the Engine helper returning the hidden -- `var` data table as a plain deep copy, `helpers` is the prelude's shared -- trio (`raise(kind, fields)` builds and raises the structured error diff --git a/crates/runvil-internal/lua/src/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index ae05f553f..13aed9c68 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -4,7 +4,7 @@ -- -- The Engine installs this after the coroutine prelude (`__impl_coro.lua`) -- and installs the returned table as the `tasks` global. The chunk --- arguments are privileged captures, never globals: `yield` is +-- arguments are privileged captures, never globals: `yield` wraps -- coroutine.yield, `var_snapshot` is the Engine helper returning the hidden -- `var` data table as a plain deep copy, and `helpers` is the prelude's -- shared trio - `raise(kind, fields)` builds and raises the structured diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index 5d7d65de3..c6be27712 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -8,12 +8,12 @@ //! `(ok, result)` envelope; coroutine driving itself //! (`Thread::create`/`resume`) is pure Rust in the scheduler. Every shim //! yields through one wrapper, which first records the author's line as the -//! VM's call line. The source is -//! pulled in with `include_str!` so chunk line 1 is file line 1, compiled -//! once through the usual [`LuaProgram`] machinery, and loaded per VM. The -//! chunk is named with an `@` prefix, so PUC's `luaO_chunkid` renders shim -//! frames as verbatim `file:line:` references with no `[string "..."]` -//! wrapper, and the line mapper (`program.rs`) never touches them. +//! VM's call line. The source is pulled in with `include_str!` so chunk +//! line 1 is file line 1, compiled once through the usual [`LuaProgram`] +//! machinery, and loaded per VM. The chunk is named with an `@` prefix, so +//! PUC's `luaO_chunkid` renders shim frames as verbatim `file:line:` +//! references with no `[string "..."]` wrapper, and the line mapper +//! (`program.rs`) never touches them. //! //! An adhoc tool call is a two-yield handshake inside the calling block //! coroutine. The `tools.call` shim yields `tool_call`; for an adhoc tool @@ -117,9 +117,8 @@ static FANOUT_PROGRAM: LazyLock> = /// before returning, so author code cannot yield directly and a hand-rolled /// yield fails the driver's strict validation. All three chunks receive the /// [`recording_yield`] over it, so every yield records its call line. The -/// `models` and `tools` -/// tables are passed to the shim chunk as arguments, so the chunk never -/// reads a global; the chunk shims `models.infer` and installs +/// `models` and `tools` tables are passed to the shim chunk as arguments, so +/// the chunk never reads a global; the chunk shims `models.infer` and installs /// `tools.call`, and the `call` shim comes back for the Engine to install as /// a global. The `tasks` namespace is a second chunk, run over the same /// `yield` and `var_snapshot` captures plus the prelude's returned failure diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 73ec5a623..842f55a1f 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -66,6 +66,15 @@ //! - Scheduler state changes only through a typed `Request` yielded by the //! installed shim; a direct or malformed yield fails the block without //! changing scheduler state. +//! - Every Engine-call yield goes through the recording `yield` that a +//! VM's shim chunks share, which first sets the VM's call line to the +//! author's line, or clears it when no author frame runs. +//! - Every universal error that crosses into a VM's Lua as an error table +//! is placed through `authored_at`, except an internal one, which keeps +//! its Rust location. The table's `message`, which `tostring` returns, +//! is the error's prose after its `file:line` when it is placed on a +//! prompt line; the `file:line` goes into that `message` only, never +//! into the prose. mod alias; mod argv; diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index 65499ba56..fe7561866 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -370,8 +370,8 @@ pub(crate) fn map_chunk_line_to_absolute( result.push_str(rest); if let Some((location, absolute)) = tag { - // Leading `location:line:` tag naming the prompt file and its - // absolute line: `briefer.md:51: ...` + // Leading `location:line:` tag naming the block's display location + // and its absolute line: ``section `Web Search` epilog:51: ...`` ( format!("{location}:{absolute}: {result}"), NonZeroU32::new(absolute), diff --git a/crates/runvil-internal/types/src/lib.rs b/crates/runvil-internal/types/src/lib.rs index 12923bf6f..ab8bd942d 100644 --- a/crates/runvil-internal/types/src/lib.rs +++ b/crates/runvil-internal/types/src/lib.rs @@ -33,6 +33,12 @@ //! - [`untrusted`] neutralizes chat-template control delimiters in //! untrusted tool and Lua text, and is never applied to assistant //! replay or tool-call wire payloads. +//! - [`place`](error::place) is the only code that gives a location its +//! prompt file or turns a known line into a prompt location, and +//! [`coordinates`](error::coordinates) is the only code that formats a +//! location as `file:line`. The parser's span constructors build +//! file-less spans that `place` completes, and the Lua crate's +//! `internal_at` writes Rust locations. pub mod cancel; pub mod emitter; From 65c49c3cdc8cc4f6c30c4ae399dc73bb2eefe01e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 3 Nov 2026 18:32:00 -0800 Subject: [PATCH 08/36] Remove `SpawnVia` and the cap name from the depth-cap error The depth-cap error gives the prompt line where the cap stops a `call`, `tasks.spawn`, or `tasks.fanout`, so its message does not have to include the cap name. The change removes the `cap_name` argument of `call_depth_exceeded` and `deeper`, and the `SpawnVia` enum that chose the name for a spawn. The fanout shim no longer sets the `fanout = true` mark, and `Request::Spawn` loses its `via` field. - The message of every depth-cap refusal is now `recursion exceeded cap of {cap}`, without `call` or `tasks.fanout` before it. The error code is still `ErrorCode::CallDepthExceeded`, and the `FactKey::Limit` fact still gives the cap. - `parse_spawn` no longer reads a `fanout` field. The change removes the `FieldFailure::Malformed` result that `shim_via` gave for a non-boolean value, and its test `spawn_reads_the_fanout_mark_and_rejects_a_non_boolean_one`. - In `fanout_arms.rs`, the text alone no longer shows if the cap stopped a `tasks.fanout` or a `call`. Two tests now check `error.at` instead: `at_line(16)` for the `tasks.fanout` line, and `at_line(33)` for the `call` line in an arm. --- crates/runvil-internal/engine/src/error.rs | 10 ++--- .../runvil-internal/engine/src/error/tests.rs | 26 +++++------- .../src/execute/scheduler/chain_record.rs | 7 ++-- .../engine/src/execute/scheduler/dispatch.rs | 12 +----- .../src/execute/scheduler/tasks-spawn.rs | 25 ++++------- .../scheduler/fanout/failure_semantics.rs | 22 ++++------ .../execute/tests/scheduler/walk/chains.rs | 6 +-- .../execute/tests/scheduler/walk/driving.rs | 4 +- .../execute/tests/suite/exec_flow/chains.rs | 4 +- .../tests/suite/exec_flow/fanout_arms.rs | 19 +++++++-- .../engine/src/execute/tests/tasks.rs | 9 ++-- crates/runvil-internal/engine/src/lua.rs | 11 +++-- .../engine/src/lua/tests/shims.rs | 12 ++---- .../runvil-internal/lua/src/__impl_fanout.lua | 8 ++-- crates/runvil-internal/lua/src/lib.rs | 4 +- crates/runvil-internal/lua/src/protocol.rs | 2 +- .../lua/src/protocol/parse/tasks.rs | 16 +------ .../lua/src/protocol/request.rs | 15 ------- .../lua/src/protocol/tests/parse_tasks.rs | 42 ++----------------- 19 files changed, 81 insertions(+), 173 deletions(-) diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 77c205e6e..b57bacab0 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -110,15 +110,13 @@ pub(crate) fn model_required(section: &str) -> Error { } /// A `call`, `tasks.spawn`, or `tasks.fanout` arm that would run deeper -/// than `cap`. `cap_name` is the cap's name, not the author's construct: -/// `tasks.fanout` for a fanout arm, and `call` for a `call` or a -/// `tasks.spawn`, which shares its cap. The dispatch's answer, so an -/// author `pcall` catches it, and a quota when it ends the run. -pub(crate) fn call_depth_exceeded(cap: usize, cap_name: &str) -> Error { +/// than `cap`. The dispatch's answer, so an author `pcall` catches it, and +/// a quota when it ends the run. +pub(crate) fn call_depth_exceeded(cap: usize) -> Error { with_fact( failure( ErrorCode::CallDepthExceeded, - format!("{cap_name} recursion exceeded cap of {cap}"), + format!("recursion exceeded cap of {cap}"), ), FactKey::Limit, cap, diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index 3e6cd981d..5f890de1a 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -193,21 +193,17 @@ fn a_spent_quota_and_the_call_depth_cap_are_quotas_that_lua_reads_as_lua() { (ErrorKind::Lua, Vec::new()) ); } - for (cap_name, text) in [ - ("call", "call recursion exceeded cap of 8"), - ("tasks.fanout", "tasks.fanout recursion exceeded cap of 8"), - ] { - let error = call_depth_exceeded(8, cap_name); - assert_eq!(error.code, ErrorCode::CallDepthExceeded); - assert_eq!(error.message, text); - assert_eq!(error.facts.get(&FactKey::Reason), None); - assert_eq!(error.facts.get(&FactKey::Limit), Some(&Value::from(8))); - assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); - assert_eq!( - (error_kind(&error), error_fields(&error)), - (ErrorKind::Lua, Vec::new()) - ); - } + let text = "recursion exceeded cap of 8"; + let error = call_depth_exceeded(8); + assert_eq!(error.code, ErrorCode::CallDepthExceeded); + assert_eq!(error.message, text); + assert_eq!(error.facts.get(&FactKey::Reason), None); + assert_eq!(error.facts.get(&FactKey::Limit), Some(&Value::from(8))); + assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); + assert_eq!( + (error_kind(&error), error_fields(&error)), + (ErrorKind::Lua, Vec::new()) + ); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index c2d66492d..7606d953d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -86,12 +86,11 @@ impl CallDepth { /// The depth a chain started one level deeper runs at. /// /// # Errors - /// Returns a `CallDepthExceeded` failure that names the cap `cap_name` - /// when that depth passes the cap. - pub(super) fn deeper(self, cap_name: &str) -> Result { + /// Returns a `CallDepthExceeded` failure when that depth passes the cap. + pub(super) fn deeper(self) -> Result { let depth = self.0 + 1; if depth > MAX_CALL_DEPTH { - return Err(error::call_depth_exceeded(MAX_CALL_DEPTH, cap_name)); + return Err(error::call_depth_exceeded(MAX_CALL_DEPTH)); } Ok(CallDepth(depth)) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index 23a2e023c..6033be789 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -86,15 +86,7 @@ impl Scheduler { item, index, var, - via, - } => self.dispatch_spawn( - id, - &target, - input.as_deref(), - TaskSeed { item, index }, - var, - via, - ), + } => self.dispatch_spawn(id, &target, input.as_deref(), TaskSeed { item, index }, var), Request::Timer { duration } => self.dispatch_timer(id, duration), Request::JoinAny { tasks } => self.dispatch_join_any(id, tasks), Request::Ready { task } => self.dispatch_ready(id, &task), @@ -151,7 +143,7 @@ impl Scheduler { var: &serde_json::Value, ) -> Result { let chain = &self.chains[id]; - let depth = chain.call_depth.deeper("call")?; + let depth = chain.call_depth.deeper()?; // An explicit input forks the chain's args (and `argv` re-derives // from them); a no-input call inherits the caller's context whole, // so the run's frozen `argv` - H1's repair included - reaches the 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 8da78a842..c8fc3aed5 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -14,7 +14,7 @@ use runvil_types::ids::TaskId; use runvil_vfs::detail; use crate::execute::section_context::TaskSeed; -use crate::lua::{Answer, SpawnVia}; +use crate::lua::Answer; use crate::{Result, error}; use super::super::admission::SpawnRecord; @@ -28,9 +28,7 @@ impl Scheduler { /// its entry, and resumes the spawner with the task's id; the child is /// enqueued behind the spawner, so `spawn` returns before the child /// runs. Its preparation fails on the depth cap, target resolution, - /// the worker check, or chain construction. `via` names the call that - /// started the task: a `fanout` arm's depth-cap refusal is named after - /// `tasks.fanout`. + /// the worker check, or chain construction. pub(in crate::execute::scheduler) fn dispatch_spawn( &mut self, id: ChainIndex, @@ -38,9 +36,8 @@ impl Scheduler { input: Option<&str>, seed: TaskSeed, var: serde_json::Value, - via: SpawnVia, ) -> Result<()> { - match self.prepare_spawn(id, target, input, seed, var, via) { + match self.prepare_spawn(id, target, input, seed, var) { Ok((task, child, record)) => { // The child waits for admission, queued behind the // spawner; the drain admits it when a slot frees up at @@ -53,12 +50,10 @@ impl Scheduler { } /// The fallible half of spawn dispatch: `call`'s depth cap against the - /// spawner's call-depth field (the refusal named `tasks.fanout` for an - /// arm, and `call` for a `call` or a `tasks.spawn`), `call`'s target - /// resolution over the spawner's - /// visible set, the worker-template check (a list section is not a - /// target), then the task chain one level deeper under the spawn's - /// `args` and `var` snapshot, with its own access capability (a + /// spawner's call-depth field, `call`'s target resolution over the + /// spawner's visible set, the worker-template check (a list section is + /// not a target), then the task chain one level deeper under the + /// spawn's `args` and `var` snapshot, with its own access capability (a /// concurrent thread of execution, forked from the spawner's so the /// spawn is the happens-before edge; every delivery of the task joins /// it back) and a fresh turn counter. The task's entry keeps the @@ -74,13 +69,9 @@ impl Scheduler { input: Option<&str>, seed: TaskSeed, var: serde_json::Value, - via: SpawnVia, ) -> Result<(TaskId, ChainIndex, SpawnRecord)> { let chain = &self.chains[id]; - let depth = chain.call_depth.deeper(match via { - SpawnVia::Fanout => "tasks.fanout", - SpawnVia::Spawn => "call", - })?; + let depth = chain.call_depth.deeper()?; // The spawning section, captured now: the start event reports it // at admission, which may come after the spawner's walk moved to // another section. diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs index cc68e8060..7313b1468 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -82,28 +82,24 @@ const DEPTH_FANOUT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ #[test] fn fanout_depth_cap_reads_the_chain_field() { // Pin of the fanout depth-cap guard: the cap fires from the requesting - // chain's call-depth field. The arm's spawn sets the fanout mark, so - // the spawn arm names the cap after `tasks.fanout` in the typed error - // itself; the shim re-raises that table and the retained typed error - // is substituted back at every `call` level, so the run's error is the - // typed error itself: the exact variant, the exact text, no - // runtime-error prefix or traceback. + // chain's call-depth field, and the spawn arm answers the arm's spawn + // with the typed error; the shim re-raises that table and the retained + // typed error is substituted back at every `call` level, so the run's + // error is the typed error itself: the exact variant, the exact text, + // no runtime-error prefix or traceback. let trace = scheduler_scenario(DEPTH_FANOUT).run(); let error = trace.error(); assert_eq!( error, - &crate::test_support::with_author_prose( - crate::error::call_depth_exceeded(8, "tasks.fanout"), - 13 - ), - "expected the typed depth-cap error named after fanout" + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8), 13), + "expected the typed depth-cap error on Alpha's fanout line" ); assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); assert_eq!( error.to_string(), - "tasks.fanout recursion exceeded cap of 8", - "the rendered text is exactly the fanout cap message: no call wording, no prefix" + "recursion exceeded cap of 8", + "the rendered text is exactly the cap message: no prefix" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs index 010caa7d4..cfcf4ef08 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs @@ -251,11 +251,7 @@ fn a_jump_descent_does_not_consume_call_depth() { let error = rig.trace().error(); assert!( - error - == &crate::test_support::with_author_prose( - crate::error::call_depth_exceeded(8, "call"), - 19 - ), + error == &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8), 19), "the depth cap must fail the run on X's call line, got {error:?}" ); assert_eq!( diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs index 87683fdb7..98ff97ff3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/driving.rs @@ -91,11 +91,11 @@ fn call_depth_cap_reads_the_chain_field() { let error = trace.error(); assert_eq!( error, - &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8, "call"), 12), + &crate::test_support::with_author_prose(crate::error::call_depth_exceeded(8), 12), "expected the typed call-depth error on Alpha's call line" ); assert_eq!(run_error_kind(error.code), RunErrorKind::Quota, "{error}"); - assert_eq!(error.to_string(), "call recursion exceeded cap of 8"); + assert_eq!(error.to_string(), "recursion exceeded cap of 8"); } #[test] diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs index 7c19edf4d..bddd3b8d0 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/chains.rs @@ -362,8 +362,8 @@ fn call_recursion_is_capped() { ); let rendered = error.to_string(); assert!( - rendered.contains("call recursion exceeded cap of 8"), - "expected the call recursion-cap error, got: {rendered}" + rendered.contains("recursion exceeded cap of 8"), + "expected the recursion-cap error, got: {rendered}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs index 965c82bab..e4d9811d1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/fanout_arms.rs @@ -6,6 +6,7 @@ use super::*; use crate::RunErrorKind; use crate::execute::run_error_kind; use crate::execute::tests::context::arm_fanout; +use crate::test_support::at_line; /// `prompt.list` inside a fanout arm reads a list section's items: the arm /// resolves like any section (the worker's siblings minus the worker, plus @@ -265,8 +266,13 @@ fn fanout_recursion_across_the_boundary_trips_the_depth_cap() { ); let rendered = error.to_string(); assert!( - rendered.contains("tasks.fanout recursion exceeded cap of 8"), - "expected the fanout recursion-cap error, got: {rendered}" + rendered.contains("recursion exceeded cap of 8"), + "expected the recursion-cap error, got: {rendered}" + ); + assert_eq!( + error.at, + at_line(16), + "the cap trips on A's `tasks.fanout` line: {error:?}" ); } @@ -304,8 +310,13 @@ fn call_inside_an_arm_spawned_near_the_cap_trips_the_depth_cap() { ); let rendered = error.to_string(); assert!( - rendered.contains("call recursion exceeded cap of 8"), - "expected the call recursion-cap error, got: {rendered}" + rendered.contains("recursion exceeded cap of 8"), + "expected the recursion-cap error, got: {rendered}" + ); + assert_eq!( + error.at, + at_line(33), + "the cap trips on the arm's own `call` line: {error:?}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/tasks.rs b/crates/runvil-internal/engine/src/execute/tests/tasks.rs index 07450a18e..749b80046 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tasks.rs @@ -258,8 +258,8 @@ fn spawn_shares_calls_target_resolution_and_raises_at_the_call_site() { fn spawn_shares_calls_depth_cap() { // Alpha and Beta spawn each other, each spawned chain one level // deeper (a section's visible set excludes itself, as for `call`); the - // chain at depth 8 is refused with `call`'s own cap message, as the - // spawn's answer, so the deepest chain catches and logs it. + // chain at depth 8 is refused under `call`'s cap, as the spawn's + // answer, so the deepest chain catches and logs it. let md = "---\nname: depth\ndescription: d\nrunvil: 0\n---\n\n\ # Depth\n\n\ ## Alpha\n\n\ @@ -292,10 +292,7 @@ fn spawn_shares_calls_depth_cap() { let records = TaskRecorder::replayed(rig.trace()).records(); let logged = format!( "prompt:12: {}", - authored( - ErrorCode::CallDepthExceeded, - "call recursion exceeded cap of 8", - ) + authored(ErrorCode::CallDepthExceeded, "recursion exceeded cap of 8") ); let refusals = records .iter() diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 84bb0bbc4..94867dd4a 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -20,12 +20,11 @@ pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, ModelReport, NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, RenderedTools, Request, - RosterEntry, ScriptReport, SectionText, SectionVm, SpawnVia, TaskDelivery, TaskPhase, - TaskStatus, ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, - UsageAnchor, VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, - current_static_set, install_preludes, install_store_shims, install_ui, output_reserve, - precheck, prepare_dispatch, prepare_model_dispatch, project_messages, render_item, - wire_name_of, + RosterEntry, ScriptReport, SectionText, SectionVm, TaskDelivery, TaskPhase, TaskStatus, + ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, + VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, current_static_set, + install_preludes, install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, + prepare_model_dispatch, project_messages, render_item, wire_name_of, }; // The store operation behind `execute::perform_vfs_op`, the entry point diff --git a/crates/runvil-internal/engine/src/lua/tests/shims.rs b/crates/runvil-internal/engine/src/lua/tests/shims.rs index 285343e36..fc3e0a6e9 100644 --- a/crates/runvil-internal/engine/src/lua/tests/shims.rs +++ b/crates/runvil-internal/engine/src/lua/tests/shims.rs @@ -9,8 +9,8 @@ use runvil_types::metrics::ToolCallEvent; use serde_json::json; use crate::lua::{ - AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, SpawnVia, ToolCallOutcome, VfsOp, - VfsOutcome, YieldParse, + AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, ToolCallOutcome, VfsOp, VfsOutcome, + YieldParse, }; use crate::model::ModelSet; @@ -49,8 +49,8 @@ fn call_yields_target_input_and_the_var_snapshot() { fn fanout_yields_a_spawn_per_member_starting_with_the_first() { // The fanout shim is Lua over the task protocol: its first yield is the // `spawn` of the first member, including the worker as the target, the - // member as the `item` seed, its 1-based position as `index`, the - // caller's `var` snapshot, and the fanout mark. + // member as the `item` seed, its 1-based position as `index`, and the + // caller's `var` snapshot. let vm = scheduler_vm(&ModelSet::default(), None); match yielded_request( &vm, @@ -62,14 +62,12 @@ fn fanout_yields_a_spawn_per_member_starting_with_the_first() { item, index, var, - via, } => { assert_eq!(target, "### Worker"); assert_eq!(input, None); assert_eq!(item, Some(json!("a"))); assert_eq!(index, Some(1)); assert_eq!(var, json!({})); - assert_eq!(via, SpawnVia::Fanout, "an arm's spawn sets the fanout mark"); } other => panic!("expected a spawn request, got {other:?}"), } @@ -110,14 +108,12 @@ fn tasks_spawn_yields_the_target_seeds_and_var() { item, index, var, - via, } => { assert_eq!(target, "## Child"); assert_eq!(input.as_deref(), Some("in")); assert_eq!(item, Some(json!({ "name": "a" }))); assert_eq!(index, Some(2)); assert_eq!(var, json!({ "k": 1 })); - assert_eq!(via, SpawnVia::Spawn, "`tasks.spawn` is not a fanout arm"); } other => panic!("expected a spawn request, got {other:?}"), } diff --git a/crates/runvil-internal/lua/src/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index 2f1e6e6fe..82e46faa1 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -71,10 +71,9 @@ end -- any spawn, and every arm is spawned up front - before any join - so no -- arm is ordered after a sibling: arms never see each other's writes, -- whatever the scheduler's admission order. Each arm is spawned with the --- member as its `item`, its 1-based position as `sys.index`, and the --- `fanout` mark (so the spawn arm's depth-cap refusal is named after --- `tasks.fanout` rather than `call`, and re-raises here as the retained --- typed error). The scheduler admits the arms under the chain's +-- member as its `item` and its 1-based position as `sys.index`; a spawn +-- refusal, such as the depth cap's, re-raises here as the retained typed +-- error. The scheduler admits the arms under the chain's -- concurrency limit, and `join_any` over the live set delivers the arms -- as they end. A `tool_loop_exhausted` arm -- becomes the incomplete stub and the fanout continues; any other arm @@ -152,7 +151,6 @@ local function fanout(...) item = members[index], index = index, var = var, - fanout = true, }) if not ok then restore() diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 842f55a1f..5b9d95c21 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -148,8 +148,8 @@ pub use crate::projection::project_messages; pub use crate::prose::ProseState; pub use crate::protocol::{ AdhocToolOutcome, Answer, Blocked, ChatOutcome, ChatResult, ContentPart, MessageContent, - MessageRecord, ModelCall, NonEmpty, RecordRole, Request, SpawnVia, TaskDelivery, TaskPhase, - TaskStatus, ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, + MessageRecord, ModelCall, NonEmpty, RecordRole, Request, TaskDelivery, TaskPhase, TaskStatus, + ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; pub use crate::scope::{ DiscoveryTool, Offer, OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, diff --git a/crates/runvil-internal/lua/src/protocol.rs b/crates/runvil-internal/lua/src/protocol.rs index 451c02a5d..b4b7e2211 100644 --- a/crates/runvil-internal/lua/src/protocol.rs +++ b/crates/runvil-internal/lua/src/protocol.rs @@ -35,4 +35,4 @@ pub use parse::YieldParse; pub(crate) use record::parse_record; pub use record::{ContentPart, MessageContent, MessageRecord, RecordRole, ToolCallRecord}; pub(crate) use render::{Retained, render_envelope}; -pub use request::{AdhocToolOutcome, ModelCall, Request, SpawnVia, VfsOp}; +pub use request::{AdhocToolOutcome, ModelCall, Request, VfsOp}; diff --git a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs index 65c6164a7..2f1f3a2bf 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs @@ -12,7 +12,7 @@ use std::time::Duration; use mlua::{Lua, LuaSerdeExt, Value}; use runvil_types::ids::TaskId; -use crate::protocol::{NonEmpty, Request, SpawnVia}; +use crate::protocol::{NonEmpty, Request}; use super::{FieldFailure, call_error, call_optional_string, call_string, call_target, shim_var}; @@ -34,7 +34,7 @@ fn call_task(table: &mlua::Table) -> std::result::Result { /// Parses a `spawn` request: the author-supplied `target`, the optional /// author-supplied `input`, `item`, and `index` seeds, plus the -/// shim-produced `var` snapshot and `fanout` mark. +/// shim-produced `var` snapshot. pub(super) fn parse_spawn( lua: &Lua, table: &mlua::Table, @@ -84,21 +84,9 @@ pub(super) fn parse_spawn( item, index, var, - via: shim_via(table)?, }) } -/// Reads a spawn's shim-produced `fanout` mark: the `fanout` shim marks its -/// arms, `tasks.spawn` leaves the field absent, and any other shape is a -/// hand-built yield. -fn shim_via(table: &mlua::Table) -> std::result::Result { - match table.raw_get::("fanout") { - Ok(Value::Nil | Value::Boolean(false)) => Ok(SpawnVia::Spawn), - Ok(Value::Boolean(true)) => Ok(SpawnVia::Fanout), - Ok(_) | Err(_) => Err(FieldFailure::Malformed), - } -} - /// Parses a `timer` request: the author-supplied `seconds` (the wait's /// `opts.timeout`), carried on as the `Duration` it converts to. The value /// must be a number that `Duration` can hold - non-negative, finite, and in diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 459c81ed4..9577799eb 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -20,18 +20,6 @@ use super::record::ToolCallRecord; use crate::error::Error; use crate::messages::MessageList; -/// Which author-facing call started a task chain. The Engine reports a -/// depth-cap refusal under the cap's name, not the call's: a `tasks.spawn` -/// shares the `call` cap and reports `call`, and a fanout arm reports -/// `tasks.fanout`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpawnVia { - /// `tasks.spawn`. - Spawn, - /// One arm of `tasks.fanout`. - Fanout, -} - /// A validated suspending Engine call, parsed from the yielded table. /// /// The parse happens at the resume boundary while the VM handle is live: a @@ -79,9 +67,6 @@ pub enum Request { /// The caller's `var` snapshot, seeded into the chain and discarded /// when it ends. var: serde_json::Value, - /// Which call started the task. Shim-produced: the `fanout` shim - /// marks its arms, and `tasks.spawn` leaves the mark absent. - via: SpawnVia, }, /// The wait shims' internal timeout timer: a leaf request whose work /// is one sleep, registered as an effect-backed task slot the caller diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs index 684b1c891..eb9b1c5cc 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs @@ -1,8 +1,7 @@ -//! The task-operation request parsers: `spawn`'s target, seeds, var -//! snapshot, and fanout mark; the `timer` leaf request's author-supplied -//! `seconds`, its domain checks, and the `Duration` it parses into; -//! `pending`'s refusal of any argument; and `concurrency`'s optional -//! shim-validated limit. +//! The task-operation request parsers: `spawn`'s target, seeds, and var +//! snapshot; the `timer` leaf request's author-supplied `seconds`, its +//! domain checks, and the `Duration` it parses into; `pending`'s refusal +//! of any argument; and `concurrency`'s optional shim-validated limit. use super::*; @@ -22,50 +21,17 @@ fn spawn_parses_target_seeds_and_var() { item, index, var, - via, } => { assert_eq!(target, "## Child"); assert_eq!(input.as_deref(), Some("override")); assert_eq!(item, Some(json!({ "name": "alpha" }))); assert_eq!(index, Some(3)); assert_eq!(var, json!({ "k": 1 })); - assert_eq!( - via, - crate::SpawnVia::Spawn, - "an absent mark is a plain `tasks.spawn`" - ); } other => panic!("expected a spawn request, got {other:?}"), } } -#[test] -fn spawn_reads_the_fanout_mark_and_rejects_a_non_boolean_one() { - // The mark is shim-produced: `true` from the fanout shim, absent from - // `tasks.spawn`; any other shape is a hand-built yield. - let lua = Lua::new(); - let table = lua_table( - &lua, - "{ op = 'spawn', target = '### Worker', fanout = true, \ - var = { k = 1 } }", - ); - match expect_request(Request::from_yield(&lua, &Value::Table(table))) { - Request::Spawn { via, .. } => assert_eq!( - via, - crate::SpawnVia::Fanout, - "the fanout shim's mark is read" - ), - other => panic!("expected a spawn request, got {other:?}"), - } - - let table = lua_table( - &lua, - "{ op = 'spawn', target = '### Worker', fanout = 'yes', \ - var = { k = 1 } }", - ); - assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); -} - #[test] fn spawn_without_options_parses_every_seed_as_absent() { let lua = Lua::new(); From 539342bb0def8edef820ab065ee652f35cb8734a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:08:34 -0800 Subject: [PATCH 09/36] Call the coroutine shim chunk a shim chunk, not a prelude The coroutine shim chunk shared the word prelude with a Pack's Lua prelude, so its docs now say shim chunk or shim install, and `install_shim_prelude` becomes `install_shims`. The embed, `@`-name, and compile-once rule is now stated once on `LuaProgram::compile_internal`, so each embedded chunk constant's doc says only what it holds, and the `take_failure`, `stash_shims`, and `install_store_shims` docs now match their code. A new test pins that `STORE_CHUNK_NAME` resolves to the file `STORE_SOURCE` embeds, as the other chunk names already are. --- .../runvil-internal/lua/src/__impl_coro.lua | 4 +- .../runvil-internal/lua/src/coro-failure.rs | 8 +- crates/runvil-internal/lua/src/coro.rs | 80 ++++++++----------- .../lua/src/engine_globals-store.rs | 18 +++-- crates/runvil-internal/lua/src/globals.rs | 8 +- crates/runvil-internal/lua/src/hardening.rs | 2 +- crates/runvil-internal/lua/src/program.rs | 5 +- crates/runvil-internal/lua/src/require.rs | 7 +- .../runvil-internal/lua/src/test_support.rs | 4 +- crates/runvil-internal/lua/src/tools.rs | 2 +- crates/runvil-internal/lua/src/vm/install.rs | 8 +- crates/runvil-internal/lua/src/vm/run.rs | 2 +- 12 files changed, 70 insertions(+), 78 deletions(-) diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index 192dee2f7..ba37aef76 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -1,4 +1,4 @@ --- Coroutine-protocol shim prelude for a section VM. +-- Coroutine-protocol shim chunk for a section VM. -- -- The Engine installs this after the Engine globals exist and before the -- Pack preludes. The chunk arguments are privileged captures, never @@ -337,7 +337,7 @@ return { call = call_section, -- The failure helpers, handed to the `tasks` and `fanout` chunks -- (`__impl_tasks.lua`, `__impl_fanout.lua`) so their shims raise the one - -- error shape this prelude defines. + -- error shape this chunk defines. helpers = { raise = raise, fail = fail, engine_type = engine_type }, loop = models_loop, compact = models_compact, diff --git a/crates/runvil-internal/lua/src/coro-failure.rs b/crates/runvil-internal/lua/src/coro-failure.rs index b49ad049c..236cce6c7 100644 --- a/crates/runvil-internal/lua/src/coro-failure.rs +++ b/crates/runvil-internal/lua/src/coro-failure.rs @@ -20,7 +20,7 @@ const FAILURE_REGISTRY: &str = "runvil.impl_coro.failure"; /// only the guard's own frame; this one holds the author's. const FAILURE_TRACEBACK_REGISTRY: &str = "runvil.impl_coro.failure_traceback"; -/// Builds the prelude's `stash_failure(failure)` capture: records the +/// Builds the shim chunk's `stash_failure(failure)` capture: records the /// block's raised value and its raise-point traceback for [`take_failure`]. pub(super) fn stash_failure_capture(lua: &Lua) -> Result { lua.create_function(|lua, failure: Value| { @@ -97,9 +97,9 @@ impl StashedFailure { } /// Takes what the guard stashed for the last failure of a guarded block. -/// Both slots are cleared on every call, so a later failure never sees a -/// stale value; the default (nothing raised, no traceback) when nothing -/// was stashed. +/// When a failure was stashed, both slots are cleared, so a later failure +/// never sees a stale value; when none was, the result is the default +/// (nothing raised, no traceback). /// /// # Errors /// Returns a `LuaRuntime` failure if a registry slot cannot be read or diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index c6be27712..872bb2afa 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -8,12 +8,9 @@ //! `(ok, result)` envelope; coroutine driving itself //! (`Thread::create`/`resume`) is pure Rust in the scheduler. Every shim //! yields through one wrapper, which first records the author's line as the -//! VM's call line. The source is pulled in with `include_str!` so chunk -//! line 1 is file line 1, compiled once through the usual [`LuaProgram`] -//! machinery, and loaded per VM. The chunk is named with an `@` prefix, so -//! PUC's `luaO_chunkid` renders shim frames as verbatim `file:line:` -//! references with no `[string "..."]` wrapper, and the line mapper -//! (`program.rs`) never touches them. +//! VM's call line. Each chunk compiles through +//! [`LuaProgram::compile_internal`], whose doc states the embed, `@`-name, +//! and compile-once rules. //! //! An adhoc tool call is a two-yield handshake inside the calling block //! coroutine. The `tools.call` shim yields `tool_call`; for an adhoc tool @@ -43,33 +40,31 @@ use crate::var::var_snapshot_table; use failure::stash_failure_capture; pub(crate) use failure::{StashedFailure, take_failure}; -/// The shim chunk's name: `@`-prefixed so PUC renders it verbatim as a file -/// path, making unexpected shim errors clickable `file:line:` references. +/// The shim chunk's name. const SHIM_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_coro.lua"; -/// The shim source, embedded verbatim so chunk line 1 is file line 1. +/// The shim chunk's source. const SHIM_SOURCE: &str = include_str!("__impl_coro.lua"); -/// The `tasks` namespace chunk's name, `@`-prefixed as the prelude's is. +/// The `tasks` namespace chunk's name. const TASKS_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_tasks.lua"; /// The `tasks` namespace source: spawn, the waits, the checks, note, and -/// cancel, split from the prelude so neither chunk outgrows the file -/// ceiling. It runs over the prelude's failure helpers. +/// cancel, split from the shim chunk so neither chunk outgrows the file +/// ceiling. It runs over the shim chunk's failure helpers. const TASKS_SOURCE: &str = include_str!("__impl_tasks.lua"); -/// The `fanout` chunk's name, `@`-prefixed as the prelude's is. +/// The `fanout` chunk's name. const FANOUT_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_fanout.lua"; /// The `fanout` shim source: Lua over the task protocol (`spawn`, -/// `join_any`, `cancel`), split from the prelude for the same file-ceiling -/// reason. It runs over the prelude's failure helpers plus the collection -/// enumerator and the item renderer; the scheduler's admission limit, not -/// a captured cap, bounds how many arms run at once. +/// `join_any`, `cancel`), split from the shim chunk for the same +/// file-ceiling reason. It runs over the shim chunk's failure helpers plus +/// the collection enumerator and the item renderer; the scheduler's +/// admission limit, not a captured cap, bounds how many arms run at once. const FANOUT_SOURCE: &str = include_str!("__impl_fanout.lua"); -/// The name of the chunk that builds the shims' recording `yield`, -/// `@`-prefixed as the shim chunks' names are. +/// The name of the chunk that builds the shims' recording `yield`. const YIELD_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/coro.rs"; /// The source of the shims' recording `yield`, over the `record` capture @@ -85,27 +80,23 @@ const YIELD_SOURCE: &str = pub(crate) const MODEL_TOOL_CALL_REGISTRY: &str = "runvil.impl_coro.model_tool_call"; /// The registry key for the shim's store function table, stashed by the -/// prelude install so the executor can install the store yield shims onto a +/// shim install so the executor can install the store yield shims onto a /// VM's `store` table after the Pack preludes install. const STORE_REGISTRY: &str = "runvil.impl_coro.store"; -/// The registry key for the shim's block guard, stashed by the prelude +/// The registry key for the shim's block guard, stashed by the shim /// install so [`block_guard`] can wrap every block coroutine the VM starts. const GUARD_REGISTRY: &str = "runvil.impl_coro.guard"; -/// The shim program, compiled once and loaded per VM. Compilation of the -/// bundled source fails only on a crate bug, and each install clones the -/// failure. +/// The shim program. static SHIM_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(SHIM_SOURCE, SHIM_CHUNK_NAME)); -/// The `tasks` namespace program, compiled once and loaded per VM after the -/// prelude, under the same failure contract. +/// The `tasks` namespace program, loaded after the shim chunk. static TASKS_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(TASKS_SOURCE, TASKS_CHUNK_NAME)); -/// The `fanout` program, compiled once and loaded per VM after the prelude, -/// under the same failure contract. +/// The `fanout` program, loaded after the shim chunk. static FANOUT_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(FANOUT_SOURCE, FANOUT_CHUNK_NAME)); @@ -121,7 +112,7 @@ static FANOUT_PROGRAM: LazyLock> = /// the chunk never reads a global; the chunk shims `models.infer` and installs /// `tools.call`, and the `call` shim comes back for the Engine to install as /// a global. The `tasks` namespace is a second chunk, run over the same -/// `yield` and `var_snapshot` captures plus the prelude's returned failure +/// `yield` and `var_snapshot` captures plus the shim chunk's returned failure /// helpers, and installed as the `tasks` global; `fanout` is a third, run /// over the same captures plus the collection enumerator and the item /// renderer, and installed on the `tasks` table as `tasks.fanout`. The @@ -147,9 +138,6 @@ static FANOUT_PROGRAM: LazyLock> = /// two captures, `enter_adhoc_handler` and `leave_adhoc_handler`, count up /// and down on `adhoc_handler_depth`, the counter the VM's `jump` reads, so /// `jump` refuses while an adhoc tool's handler runs, whatever reference -/// the two captures, `enter_adhoc_handler` and `leave_adhoc_handler`, count up -/// and down on `adhoc_handler_depth`, the counter the VM's `jump` reads, so -/// `jump` refuses while an adhoc tool's handler runs, whatever reference /// the handler calls it through. Then `cancel_requested` reads the /// cancel flag of `cancel_hook`, so the chunk's protected calls /// raise a failure caught under cancellation again instead of returning @@ -163,7 +151,7 @@ static FANOUT_PROGRAM: LazyLock> = /// # Errors /// Returns a `LuaRuntime` failure if the coroutine library, the shim chunk, or any /// install step fails. -pub(crate) fn install_shim_prelude( +pub(crate) fn install_shims( lua: &Lua, max_tool_iterations: usize, selection: &Arc>>, @@ -274,10 +262,10 @@ fn recording_yield(lua: &Lua, raw_yield: Function) -> Result { .map_err(error::lua) } -/// Runs the `fanout` chunk over the prelude's `yield` and `var_snapshot` +/// Runs the `fanout` chunk over the shim chunk's `yield` and `var_snapshot` /// captures and failure `helpers`, plus the collection enumerator, the item /// renderer, the two captures over the chain's current model `selection`, -/// and the prelude's `is_model_handle`, and returns the shim installed as +/// and the shim chunk's `is_model_handle`, and returns the shim installed as /// `tasks.fanout`. fn fanout_shim( lua: &Lua, @@ -370,7 +358,9 @@ fn selection_captures( } /// Stashes the shim chunk's `store` under its registry key, for the store -/// install that reads it after the prelude. +/// install that reads it after the Pack preludes install, and in test +/// builds its model-issued `tool_call` form under +/// `MODEL_TOOL_CALL_REGISTRY`. fn stash_shims(lua: &Lua, shims: &Table) -> Result<()> { #[cfg(any(test, feature = "test-support"))] { @@ -383,7 +373,7 @@ fn stash_shims(lua: &Lua, shims: &Table) -> Result<()> { .map_err(error::lua) } -/// Builds the prelude's `enter_adhoc_handler()` and `leave_adhoc_handler()` +/// Builds the shim chunk's `enter_adhoc_handler()` and `leave_adhoc_handler()` /// captures over `depth`: one step up and one step down, so nested /// handlers balance. fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function, Function)> { @@ -404,7 +394,7 @@ fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function Ok((enter, leave)) } -/// Returns the shim's block guard for a VM whose shim prelude already ran. +/// Returns the shim's block guard for a VM whose shim install already ran. /// /// The Engine creates every block coroutine from the guard and resumes it /// first with the block function: the guard runs the block under `xpcall` @@ -414,7 +404,7 @@ fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function /// intact instead of only as mlua's stringification. /// /// # Errors -/// Returns a `LuaRuntime` failure if the shim prelude never ran on this VM. +/// Returns a `LuaRuntime` failure if the shim install never ran on this VM. pub(crate) fn block_guard(lua: &Lua) -> Result { lua.named_registry_value(GUARD_REGISTRY).map_err(error::lua) } @@ -424,16 +414,16 @@ pub(crate) fn block_guard(lua: &Lua) -> Result { /// dispatchers to the shims too, so a store function a Pack prelude /// captured before this call (`local write = store.write`) yields as well. /// Every store operation, through whichever reference the prompt holds, -/// then suspends the block as a leaf yield - uniformly for all backends, -/// with no inline fast path, so interleaving behavior never depends on -/// which backend serves the mount. The Harness performs each one as an -/// `Effect::Vfs`, and a claims-model conflict through any of them ends -/// the run with a determinism violation that `pcall` cannot catch. +/// then suspends the block as a leaf yield. +/// [`Request::Store`](crate::Request::Store) states why every backend takes +/// this path. The Harness performs each one as an `Effect::Vfs`, and a +/// claims-model conflict through any of them ends the run with a +/// determinism violation that `pcall` cannot catch. /// /// The executor's section setup and live H1 setup are the only callers. /// /// # Errors -/// Returns a `LuaRuntime` failure if the shim prelude never ran on this VM, the +/// Returns a `LuaRuntime` failure if the shim install never ran on this VM, the /// `store` table is absent, or the install fails. pub fn install_store_shims(lua: &Lua) -> Result<()> { let shims: Table = lua diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index 80cb7f6e1..8734e38e2 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -18,12 +18,10 @@ use crate::program::LuaProgram; use crate::protocol::{VfsOp, VfsOutcome}; use read::{read_store, read_store_numbered}; -/// The store dispatcher chunk's name, `@`-prefixed as the shim chunks' -/// names are, so its frames render as verbatim `file:line:` references. +/// The store dispatcher chunk's name. const STORE_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_store.lua"; -/// The store dispatcher source, embedded verbatim so chunk line 1 is file -/// line 1. +/// The store dispatcher source. const STORE_SOURCE: &str = include_str!("__impl_store.lua"); /// The registry key of the store dispatchers' phase table, kept by @@ -31,9 +29,7 @@ const STORE_SOURCE: &str = include_str!("__impl_store.lua"); /// dispatcher to the yield shims once the Pack preludes have installed. const STORE_PHASE_REGISTRY: &str = "runvil.engine.store_phase"; -/// The store dispatcher program, compiled once and loaded per VM under the -/// shim programs' failure contract: compiling the bundled source fails -/// only on a crate bug. +/// The store dispatcher program. static STORE_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(STORE_SOURCE, STORE_CHUNK_NAME)); @@ -418,7 +414,13 @@ pub fn run_store_op(view: &Access, op: VfsOp) -> std::result::Result> = LazyLock::new(|| LuaProgram::compile_internal(GLOBALS_SOURCE, GLOBALS_CHUNK_NAME)); diff --git a/crates/runvil-internal/lua/src/hardening.rs b/crates/runvil-internal/lua/src/hardening.rs index dce634e17..e73173876 100644 --- a/crates/runvil-internal/lua/src/hardening.rs +++ b/crates/runvil-internal/lua/src/hardening.rs @@ -13,7 +13,7 @@ const HOOK_INTERVAL: u32 = 10_000; /// Removes code-loading, direct output, and reflection globals the base library /// provides. The `io`, `os`, `package`, and `debug` libraries are never -/// loaded; the coroutine library loads only while the shim prelude installs, +/// loaded; the coroutine library loads only while the shim chunk installs, /// and its global is stripped again. /// /// First installs the `_G` guard with its `setmetatable` and diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index fe7561866..c60e420d8 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -149,7 +149,10 @@ impl LuaProgram { /// Internal chunks are crate source, not author prompt source, so the /// compilation is bookkeeping. `chunk_name` serves as both the chunk /// name and the display location; an `@`-prefixed name renders as a - /// file path in errors, which the line mapper never rewrites. + /// file path in errors, which the line mapper never rewrites. Every + /// embedded chunk is included with `include_str!`, so chunk line 1 is + /// file line 1, and compiles once in a `LazyLock`; a compile failure is + /// a crate bug that each install clones. /// /// # Errors /// Returns a `LuaRuntime` failure if the temporary compiler VM cannot be diff --git a/crates/runvil-internal/lua/src/require.rs b/crates/runvil-internal/lua/src/require.rs index 3d6271ecc..d0a34fe37 100644 --- a/crates/runvil-internal/lua/src/require.rs +++ b/crates/runvil-internal/lua/src/require.rs @@ -28,14 +28,13 @@ use crate::program::LuaProgram; use crate::prose::ProseState; use crate::section::{Library, LibraryKey}; -/// The loader chunk's name, `@`-prefixed as the shim chunks' names are. +/// The loader chunk's name. const REQUIRE_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_require.lua"; -/// The loader source, embedded verbatim so chunk line 1 is file line 1. +/// The loader source. const REQUIRE_SOURCE: &str = include_str!("__impl_require.lua"); -/// The loader program, compiled once and loaded per VM, under the shim -/// programs' failure contract. +/// The loader program. static REQUIRE_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(REQUIRE_SOURCE, REQUIRE_CHUNK_NAME)); diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index 1dd3cb9e6..e8b3518f0 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -83,13 +83,13 @@ pub fn section_vm_peak() -> usize { } /// Installs the model-issued `tool_call` form as `tools.call_as_model` on a -/// VM whose shim prelude already ran, so a fixture section can yield a +/// VM whose shim install already ran, so a fixture section can yield a /// `tool_call` with a `call_id` straight at the driver's dispatch arm. In /// production `tools.call_as_model` never exists in any VM - not stubbed, /// simply absent. /// /// # Errors -/// Returns a `LuaRuntime` failure if the shim prelude was never installed +/// Returns a `LuaRuntime` failure if the shim chunk was never installed /// on this VM, the `tools` table is absent, or the install fails. pub fn install_model_tool_call_shim(lua: &Lua) -> Result<()> { let model_tool_call: Function = lua diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index 2356082ed..c67825846 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -10,7 +10,7 @@ //! `get` reads one tool object by id, `packs` reads the Pack objects, //! `offer` and `offer_dynamic` remember tools and Packs for the section's //! Static Set and Dynamic Set, `call` dispatches a tool by id or object -//! (installed by the coroutine shim prelude, since dispatch suspends), and +//! (installed by the coroutine shim chunk, since dispatch suspends), and //! `stats`, which the `stats` child installs, is the read-only view of the //! section's call counts: a total, and counts per Pack and per tool by id. //! Offering a tool the run does not hold is a hard error, and so is every diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index a24bc2ddd..e25fbcd1e 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -11,7 +11,7 @@ use runvil_types::emitter::Emitter; use serde_json::Value as Json; use super::{SYS_LIVE_POISONED, SectionVm, pack_sequence}; -use crate::coro::install_shim_prelude; +use crate::coro::install_shims; use crate::engine_globals::{install_log, install_store_table}; #[cfg(test)] use crate::error::Error; @@ -134,7 +134,7 @@ impl SectionVm { /// Installs the coroutine yield shims (`models.infer`, `models.loop`, /// `models.compact`, `call`, `tasks.fanout`, `tools.call`, the `tasks` - /// namespace) through `install_shim_prelude`, which also puts + /// namespace) through `install_shims`, which also puts /// `models.loop` and `models.compact` on the `models` table. /// `max_tool_iterations` is the run's resolved round cap, which every /// `models.loop` and model handle `loop` call on this VM spends. The @@ -142,9 +142,9 @@ impl SectionVm { /// through captures over the selection injection received. /// /// # Errors - /// Returns a `LuaRuntime` failure if the shim prelude cannot install. + /// Returns a `LuaRuntime` failure if the shim install fails. pub fn install_coro_shims(&mut self, max_tool_iterations: usize) -> Result<()> { - install_shim_prelude( + install_shims( &self.lua, max_tool_iterations, &self.state.selection, diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 0a37a314a..16b4ee30c 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -68,7 +68,7 @@ impl SectionVm { /// /// # Errors /// Returns an `Internal` failure if the jump slot is poisoned, or a - /// `LuaRuntime` failure if the program cannot load, the shim prelude + /// `LuaRuntime` failure if the program cannot load, the shim install /// never ran on this VM, or the thread cannot be created or hooked; a /// block failure returns the mapped runtime error. pub fn start_block_coro(&self, program: &LuaProgram) -> Result { From 9524b89b47aebc8377f7f5785d35b1c90cc9fc79 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:09:29 -0800 Subject: [PATCH 10/36] Say which Engine globals `engine_globals` installs The `engine_globals` module doc read as the full list of Engine globals, so it now names the persistent `log`, `untrusted`, `store`, and `ui` globals it installs and says the others install from their own modules. The `install_store_table` doc now says what `exists` returns, and two `log` budget comments drop a tag that pointed at a review list no longer in the repository. --- .../lua/src/engine_globals-store.rs | 8 ++++---- crates/runvil-internal/lua/src/engine_globals.rs | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index 8734e38e2..cc64158c2 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -228,10 +228,10 @@ fn record_store_conflict( /// inclusive line range (`read(path, start)` reads to end of file, /// `read(path, start, end)` slices); `read_numbered` returns it with /// absolute line numbers under the same optional bounds; `glob` returns an -/// array table of matching paths. A [`VfsError`] from any op is raised as -/// its store failure, a structured error value of kind `store`, so it -/// aborts the chunk and a `pcall` caller reads `err.kind`, `err.reason`, -/// and the variant's fields. +/// array table of matching paths, and `exists` returns a boolean. A +/// [`VfsError`] from any op is raised as its store failure, a structured +/// error value of kind `store`, so it aborts the chunk and a `pcall` caller +/// reads `err.kind`, `err.reason`, and the variant's fields. /// /// Every closure captures an `Arc` clone of the section's store view. /// The view locks the backend per call and is synchronous, so nothing is diff --git a/crates/runvil-internal/lua/src/engine_globals.rs b/crates/runvil-internal/lua/src/engine_globals.rs index 67e10b583..9ce6eecff 100644 --- a/crates/runvil-internal/lua/src/engine_globals.rs +++ b/crates/runvil-internal/lua/src/engine_globals.rs @@ -1,7 +1,8 @@ -//! Engine globals installed into section VMs: `log`, `untrusted`, and the -//! `store` table in every one, and `ui` when the run has an -//! application-state snapshot. Each is a persistent global: installed once -//! per section and valid across every chunk the VM runs. +//! The persistent `log`, `untrusted`, and `store` Engine globals every +//! section VM gets, and `ui` when the run has an application-state +//! snapshot. Each is installed once per section and stays valid across +//! every chunk the VM runs; the other Engine globals install from their own +//! modules. #[path = "engine_globals-store.rs"] mod store; @@ -33,7 +34,7 @@ fn log_checkpoint( return Err(mlua::Error::external("log expects exactly one argument")); } // Spend one unit of the per-VM log budget before doing any work; an - // exhausted budget refuses further checkpoints (lua 002). + // exhausted budget refuses further checkpoints. if log_budget .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1)) .is_err() @@ -57,8 +58,7 @@ fn log_checkpoint( )); } // Enforce a cumulative byte ceiling in addition to the event count, - // so many small events cannot emit unbounded total log volume - // (lua 002). + // so many small events cannot emit unbounded total log volume. if log_byte_budget .try_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { remaining.checked_sub(message.len()) From bf509994351903985a88aeb4f37ed3ba2c175db5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:14:27 -0800 Subject: [PATCH 11/36] Share one copy of each error helper across Engine crates The `failure`, `with_fact`, `internal`, and store-message helpers were copied across the types, Lua, Engine, and prompt crates, so each now has one definition the others import. `failure` and `with_fact` become free functions in `runvil_types::error` beside `place`, so the facade's `Error` gains no inherent method, and `runvil_lua` now exports `internal` and `store_failure`. The Engine and prompt crates re-export `internal` from `runvil_lua`, and `#[track_caller]` keeps each call site the reported location. --- crates/runvil-internal/engine/src/error.rs | 37 ++----------------- crates/runvil-internal/engine/src/lua.rs | 2 +- .../lua/src/engine_globals-store.rs | 3 +- .../runvil-internal/lua/src/engine_globals.rs | 2 +- .../lua/src/error-universal.rs | 14 +------ crates/runvil-internal/lua/src/error.rs | 3 +- crates/runvil-internal/lua/src/lib.rs | 4 +- crates/runvil-internal/prompt/src/error.rs | 16 +------- crates/runvil-internal/types/src/error.rs | 13 +++++++ 9 files changed, 28 insertions(+), 66 deletions(-) diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index b57bacab0..b8ac83d6c 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -20,12 +20,11 @@ use std::borrow::Cow; #[cfg(test)] pub(crate) use runvil_lua::{context_exhausted, lua_quota, tool_loop_exhausted}; pub(crate) use runvil_lua::{ - interrupted, lua_runtime, task_cancelled, task_consumed, task_not_owned, + internal, interrupted, lua_runtime, task_cancelled, task_consumed, task_not_owned, }; pub(crate) use runvil_types::error::Error; -use runvil_types::error::{ErrorCode, FactKey}; +use runvil_types::error::{ErrorCode, FactKey, failure, with_fact}; use runvil_types::ids::TaskId; -use serde_json::Value; #[cfg(test)] mod tests; @@ -38,27 +37,6 @@ pub(crate) use tools::{ /// Crate-internal result alias over [`Error`]. pub(crate) type Result = std::result::Result; -/// Builds a failure with `code`, `message`, and the code's default prose. -fn failure(code: ErrorCode, message: impl Into) -> Error { - Error::new(code, message, code.prose()) -} - -/// Gives `error` the fact `key` with `value`. -fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { - error.facts.insert(key, value.into()); - error -} - -/// An internal invariant failure: a state the surrounding code has already -/// guaranteed cannot occur, surfaced rather than skipped so it cannot pass -/// for a successful fall-through. Its location is the Rust source position -/// of the call site, which the run's failure message ends with. The message -/// is owned when it names a value. -#[track_caller] -pub(crate) fn internal(message: impl Into>) -> Error { - runvil_lua::internal_at(message, std::panic::Location::caller()) -} - /// An internal invariant failure that names the failure which broke the /// invariant after its own text, capturing the call site as [`internal`] /// does. @@ -140,20 +118,13 @@ pub(crate) fn tasks_live(tasks: &[TaskId]) -> Error { /// and a message that tells a run that declares no store so and follows /// the fixed probe text for any other failure. pub(crate) fn store(cause: &runvil_vfs::VfsError) -> Error { - store_with_message(cause, crate::lua::store_probe_message(cause)) + runvil_lua::store_failure(cause, crate::lua::store_probe_message(cause)) } /// One store operation's failure: the store's code and facts, and the /// model-facing message rendered with the operation for its wording. pub(crate) fn store_op(op: &crate::lua::VfsOp, cause: &runvil_vfs::VfsError) -> Error { - store_with_message(cause, crate::lua::store_error_message(op, cause)) -} - -/// The store failure `cause` with `message` in place of its own text. -fn store_with_message(cause: &runvil_vfs::VfsError, message: String) -> Error { - let mut error = runvil_lua::store_error(cause); - error.message = message; - error + runvil_lua::store_failure(cause, crate::lua::store_error_message(op, cause)) } /// Two execution identities that claimed one store path: `detail` is the diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 94867dd4a..5dcf13b85 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -10,7 +10,7 @@ //! //! - The error module, which re-exports the failure constructors the Lua //! crate shares with the Engine, and whose store constructors build a -//! store failure through [`store_error`](runvil_lua::store_error). +//! store failure through [`store_failure`](runvil_lua::store_failure). //! - Test-only code: the `#[cfg(test)]` shim install in `execute::section_vm`, //! which reaches `runvil_lua::test_support`, and this crate's test support, //! which takes `ToolOutputKind` from the crate root. diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index cc64158c2..4c1ccb05d 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -34,7 +34,8 @@ static STORE_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(STORE_SOURCE, STORE_CHUNK_NAME)); /// The store failure `cause` with `message` in place of its own text. -fn store_failure(cause: &VfsError, message: String) -> Error { +#[must_use] +pub fn store_failure(cause: &VfsError, message: String) -> Error { let mut error = store_error(cause); error.message = message; error diff --git a/crates/runvil-internal/lua/src/engine_globals.rs b/crates/runvil-internal/lua/src/engine_globals.rs index 9ce6eecff..5eb27d3fb 100644 --- a/crates/runvil-internal/lua/src/engine_globals.rs +++ b/crates/runvil-internal/lua/src/engine_globals.rs @@ -17,7 +17,7 @@ use runvil_types::untrusted::GuardNonce; use crate::error::{self, QuotaResource, Result, lua_quota}; pub(crate) use store::{install_store_table, route_store_to_shims}; -pub use store::{run_store_op, store_error_message, store_probe_message}; +pub use store::{run_store_op, store_error_message, store_failure, store_probe_message}; /// Maximum number of Unicode scalar values accepted by `log`. pub(crate) const LUA_LOG_CHARACTER_LIMIT: usize = 256; diff --git a/crates/runvil-internal/lua/src/error-universal.rs b/crates/runvil-internal/lua/src/error-universal.rs index 6422cf893..9515d0d66 100644 --- a/crates/runvil-internal/lua/src/error-universal.rs +++ b/crates/runvil-internal/lua/src/error-universal.rs @@ -4,9 +4,8 @@ use std::borrow::Cow; use std::num::NonZeroU32; -use runvil_types::error::{Error, ErrorCode, FactKey, Location}; +use runvil_types::error::{Error, ErrorCode, FactKey, Location, failure, with_fact}; use runvil_types::ids::TaskId; -use serde_json::Value; use super::QuotaResource; @@ -14,17 +13,6 @@ use super::QuotaResource; #[path = "error-universal-tests.rs"] mod tests; -/// Builds a failure with `code`, `message`, and the code's default prose. -fn failure(code: ErrorCode, message: impl Into) -> Error { - Error::new(code, message, code.prose()) -} - -/// Gives `error` the fact `key` with `value`. -fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { - error.facts.insert(key, value.into()); - error -} - /// A condition the prompt author caused and can fix in Lua, such as an /// empty `messages` list or a heading that names no section, as a /// `LuaRuntime` failure. The message is the specific failure as a noun diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index ff8cc80bf..8c9fad753 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -60,8 +60,9 @@ pub(crate) fn lua(source: mlua::Error) -> Error { /// An internal invariant failure, at the call site as its construction /// site. The message is owned when it names a value. +#[must_use] #[track_caller] -pub(crate) fn internal(message: impl Into>) -> Error { +pub fn internal(message: impl Into>) -> Error { internal_at(message, std::panic::Location::caller()) } diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 5b9d95c21..ba00be91f 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -131,10 +131,10 @@ pub use crate::dispatch::{ ModelReport, ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch, }; pub use crate::engine_globals::{ - install_ui, run_store_op, store_error_message, store_probe_message, + install_ui, run_store_op, store_error_message, store_failure, store_probe_message, }; pub use crate::error::{ - QuotaResource, context_exhausted, internal_at, interrupted, lua_quota, lua_runtime, + QuotaResource, context_exhausted, internal, internal_at, interrupted, lua_quota, lua_runtime, task_cancelled, task_consumed, task_not_owned, tool_loop_exhausted, }; pub use crate::error_value::{ diff --git a/crates/runvil-internal/prompt/src/error.rs b/crates/runvil-internal/prompt/src/error.rs index f6083732d..f1e5f2a3c 100644 --- a/crates/runvil-internal/prompt/src/error.rs +++ b/crates/runvil-internal/prompt/src/error.rs @@ -10,18 +10,14 @@ use std::num::NonZeroU32; use std::ops::Range; +pub(crate) use runvil_lua::internal; +pub(crate) use runvil_types::error::failure; use runvil_types::error::{Error, ErrorCode, FactKey, Location, coordinates, place}; use runvil_types::packs::PackId; /// The parser's result type over the universal [`Error`]. pub(crate) type Result = std::result::Result; -/// Builds a failure with no location, whose prose is the code's default -/// until [`with_prompt_context`] writes it. -pub(crate) fn failure(code: ErrorCode, message: impl Into) -> Error { - Error::new(code, message, code.prose()) -} - /// Places `error` on the whole file `line`, when known, as [`place`] does, /// leaving the location's file for [`with_prompt_context`] to fill. pub(crate) fn on_line(error: Error, line: Option) -> Error { @@ -48,14 +44,6 @@ pub(crate) fn failure_at( error } -/// Builds the failure for a broken parser invariant: a state the -/// surrounding code has already guaranteed cannot occur. Its location is -/// the Rust source position of the call site. -#[track_caller] -pub(crate) fn internal(message: &'static str) -> Error { - runvil_lua::internal_at(message, std::panic::Location::caller()) -} - /// Builds the frontmatter failure for a refused `uses:` list: `reason` is /// its `Reason` fact and the refused Pack id its `Name` fact. pub(crate) fn pack_refusal(reason: &str, pack: &PackId, message: String) -> Error { diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 07914f4fa..165eb1685 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -153,6 +153,19 @@ pub fn place(mut error: Error, file: &str, line: Option) -> Error { error } +/// Builds a failure with `code`, `message`, and the code's default prose. +#[must_use] +pub fn failure(code: ErrorCode, message: impl Into) -> Error { + Error::new(code, message, code.prose()) +} + +/// Gives `error` the fact `key` with `value`. +#[must_use] +pub fn with_fact(mut error: Error, key: FactKey, value: impl Into) -> Error { + error.facts.insert(key, value.into()); + error +} + /// Formats `at` as `{file}:{line}`: its file and start line, with no /// column. This is the only code that formats a location as `file:line`. #[must_use] From 9a4a152389d2835fb1735e3178dfe6da25da2f19 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:18:25 -0800 Subject: [PATCH 12/36] Correct error-value docs and gate test-only store fields Several error and error-value docs named only some of each kind's fields or sources, so each `ErrorKind` variant now names its fields, `Lua` lists all its sources, and the `error::universal`, `error_value`, and `authored_at` docs say what the code does. `store_error_value_fields`, which only tests call, now builds only under `test` or `test-support`, and its field-shape text moves onto `store_error` beside a note that both store paths build their failure there. The unused `ErrorField::as_integer` is removed. --- .../runvil-internal/lua/src/error-author.rs | 6 +-- .../lua/src/error-universal.rs | 6 ++- .../lua/src/error-value-store.rs | 31 ++++++++------- crates/runvil-internal/lua/src/error-value.rs | 39 ++++++++++--------- crates/runvil-internal/lua/src/lib.rs | 6 +-- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/crates/runvil-internal/lua/src/error-author.rs b/crates/runvil-internal/lua/src/error-author.rs index 8de4a2e4d..dabfe6087 100644 --- a/crates/runvil-internal/lua/src/error-author.rs +++ b/crates/runvil-internal/lua/src/error-author.rs @@ -105,9 +105,9 @@ pub(crate) fn author_prose(what: &str, code: ErrorCode) -> String { format!("{what}. {}", code.prose()) } -/// Places `error` on `line` of `file`, and gives a failure the prompt's -/// Lua caused whose prose is still its code's default the author prose -/// for `what`. +/// Places `error` on `line` of `file` as [`place`] does. When the prompt's +/// Lua caused the failure and its prose is still its code's default, the +/// prose becomes the author prose for `what`. pub(crate) fn authored_at(error: Error, file: &str, line: Option, what: &str) -> Error { let mut error = place(error, file, line); if is_authored(error.code) && error.prose == error.code.prose() { diff --git a/crates/runvil-internal/lua/src/error-universal.rs b/crates/runvil-internal/lua/src/error-universal.rs index 9515d0d66..cda17d3ee 100644 --- a/crates/runvil-internal/lua/src/error-universal.rs +++ b/crates/runvil-internal/lua/src/error-universal.rs @@ -1,5 +1,7 @@ -//! The failures this crate raises that the Engine raises too, through the -//! same constructors, so each failure has one text. +//! The constructors of the universal failures this crate raises, each the +//! one source of its failure's text. The Engine raises `lua_runtime`, +//! `interrupted`, the task refusals, and `internal_at`'s failures through +//! these same constructors. use std::borrow::Cow; use std::num::NonZeroU32; diff --git a/crates/runvil-internal/lua/src/error-value-store.rs b/crates/runvil-internal/lua/src/error-value-store.rs index b93c37398..f1876d040 100644 --- a/crates/runvil-internal/lua/src/error-value-store.rs +++ b/crates/runvil-internal/lua/src/error-value-store.rs @@ -20,7 +20,7 @@ const STORE_FACTS: [(&str, FactKey); 5] = [ ]; /// Returns the variant's own fields, without `reason`, in the shape -/// `store_error_value_fields` documents. +/// `store_error` documents. fn store_error_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> { use runvil_vfs::VfsError; let integer = |value: usize| ErrorField::Integer(i64::try_from(value).unwrap_or(i64::MAX)); @@ -70,17 +70,9 @@ fn store_error_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> } } -/// Returns the full field set a store error value carries: the `reason` -/// tag, as [`VfsError::tag`](runvil_vfs::VfsError::tag) names it, -/// first, then the variant's own fields: the paths the failure names, plus -/// `anchor` and its integer `count` for an anchor error, `rule` for an -/// invalid path, or the integer `valid_up_to` for text that is not UTF-8. -/// The rule is the [`PathReason`](runvil_vfs::PathReason) tag, which -/// `PathReason::from_tag` parses back. -/// -/// One source for the shape, so a failure the direct closures raise -/// while a Pack prelude installs and one answered through the -/// executor's effect path are indistinguishable to Lua code. +/// Returns the full field set a store error value carries, `reason` first, +/// in the shape [`store_error`] documents. +#[cfg(any(test, feature = "test-support"))] #[must_use] pub fn store_error_value_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> { let mut fields = vec![( @@ -95,6 +87,19 @@ pub fn store_error_value_fields(error: &runvil_vfs::VfsError) -> Vec<(String, Er /// 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. +/// +/// A store error value carries the `reason` tag, as `VfsError::tag` names +/// it, first, then the variant's own fields: the paths the failure names, +/// plus `anchor` and its integer `count` for an anchor error, `rule` for an +/// invalid path, or the integer `valid_up_to` for text that is not UTF-8. +/// The rule is the `PathReason` tag, which `PathReason::from_tag` parses +/// back. +/// +/// Both store paths, the direct closures a Pack prelude's install calls +/// and the executor's effect path, build their failure here, so a store +/// error value has the same `kind`, `code`, `reason`, and fields on both; +/// only the direct closures put the store's message ahead of the default +/// prose. #[must_use] pub fn store_error(error: &runvil_vfs::VfsError) -> Error { let code = store_error_code(error); @@ -115,7 +120,7 @@ pub fn store_error(error: &runvil_vfs::VfsError) -> Error { /// Returns the fields a store failure's universal error renders into its /// Lua error table, the inverse of [`store_error`]: the code's tag as /// `reason`, then each store fact under the field name -/// [`store_error_value_fields`] gives it, a string fact as a string and a +/// [`store_error`] documents for it, a string fact as a string and a /// count or an offset as an integer. pub(super) fn store_fact_fields(error: &Error) -> Vec<(String, ErrorField)> { let mut fields = vec![( diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index 10056eca4..c7426a5b9 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -10,8 +10,9 @@ //! which a table rendered from a universal error placed on a prompt line //! puts after that line's `file:line`; a caller that branches reads //! `kind`, `code`, the stable tag of the failure's [`ErrorCode`], and the -//! kind's own fields (`reason` for `context_exhausted`, `finish_reason` -//! for `empty_model_reply`). +//! kind's own fields (such as `reason` for `context_exhausted` and +//! `finish_reason` for `empty_model_reply`; [`error_fields`] gives them +//! all). //! //! [`ErrorKind`] names the closed vocabulary of kinds; [`error_kind`] and //! [`error_fields`] render a universal error into the shape; @@ -43,7 +44,9 @@ use crate::error::accepted_tags; pub(crate) use classify::{install_normalize_failure, normalized}; pub(crate) use raised::raised_from; -pub use store::{store_error, store_error_value_fields}; +pub use store::store_error; +#[cfg(any(test, feature = "test-support"))] +pub use store::store_error_value_fields; pub use universal::{error_fields, error_kind}; /// The registry key of the shared error metatable, created on first use per @@ -76,20 +79,25 @@ pub enum ErrorKind { ModelFailure, /// A model round found no selected model and no prompt-wide default. ModelRequired, - /// The model named a tool outside the section's advertised scope. + /// The model named a tool outside the section's advertised scope; + /// includes `name`. OutOfScopeTool, - /// A script `tools.call` named an alias with no binding in the run. + /// A script `tools.call` named an id that is no adhoc tool and no + /// catalog tool in the run; includes `name`. UnboundTool, /// A script `tools.call` named a catalog tool that only a model may - /// call, because a render shapes its arguments for the model. + /// call, because a render shapes its arguments for the model; includes + /// `name`. ModelOnlyTool, /// A dispatched tool's own failure. Tool, - /// A task operation named a task the caller does not own. + /// A task operation named a task the caller does not own; includes + /// `task`. TaskNotOwned, - /// A task's result was already delivered once. + /// A task's result was already delivered once; includes `task`. TaskConsumed, - /// A section ended while tasks it owns were still live. + /// A section ended while tasks it owns were still live; includes + /// `tasks`, the ids joined by `, `. TasksLive, /// The run was cancelled, the caller dropped a call the run was waiting /// on, or a wait delivered a task its owner cancelled (that value @@ -99,7 +107,9 @@ pub enum ErrorKind { /// [`VfsError`](runvil_vfs::VfsError) variant's fields. Store, /// A Lua authoring or runtime failure: a compile error, a runtime error - /// in author code, a shim's argument error, or an exhausted Engine quota. + /// or memory refusal in author code, an Engine function's argument + /// error, a failed substitution, the call-depth cap, or an exhausted + /// Engine quota. Lua, /// An internal invariant was violated, or a failure outside the prompt the /// author cannot act on that has no kind of its own. @@ -223,15 +233,6 @@ impl ErrorField { ErrorField::Integer(_) => None, } } - - /// The field's integer value, when it is one. - #[must_use] - pub fn as_integer(&self) -> Option { - match self { - ErrorField::String(_) => None, - ErrorField::Integer(value) => Some(*value), - } - } } /// Returns the VM's shared error metatable, creating it on first use. diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index ba00be91f..77f24e75b 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -137,9 +137,9 @@ pub use crate::error::{ QuotaResource, context_exhausted, internal, internal_at, interrupted, lua_quota, lua_runtime, task_cancelled, task_consumed, task_not_owned, tool_loop_exhausted, }; -pub use crate::error_value::{ - ErrorField, ErrorKind, error_fields, error_kind, store_error, store_error_value_fields, -}; +#[cfg(any(test, feature = "test-support"))] +pub use crate::error_value::store_error_value_fields; +pub use crate::error_value::{ErrorField, ErrorKind, error_fields, error_kind, store_error}; pub use crate::globals::{RESERVED_NAMES, Reserved, reserved_name}; pub use crate::messages::MessageList; pub use crate::prelude::install_preludes; From cad74b11c48f6f27d87f478154b5fc5a5d534a60 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:20:46 -0800 Subject: [PATCH 13/36] Read universal error string fields from one table `error_fields` and `field_facts` spelled the same code-to-field table once in each direction, so a code added to one match could drop its fact on read-back. A private `string_fact` now maps each code to its one string field and fact, and both directions read it, while `TasksLive` and the store kind keep their own arms. The round-trip test list now holds `UnboundTool`, `ModelOnlyTool`, `TaskConsumed`, and `TaskCancelled`, so it covers every code of each group. --- .../lua/src/error-value-raised-tests.rs | 8 ++- .../lua/src/error-value-universal.rs | 53 ++++++++++--------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/crates/runvil-internal/lua/src/error-value-raised-tests.rs b/crates/runvil-internal/lua/src/error-value-raised-tests.rs index 03c13cc5f..d37d69a35 100644 --- a/crates/runvil-internal/lua/src/error-value-raised-tests.rs +++ b/crates/runvil-internal/lua/src/error-value-raised-tests.rs @@ -11,8 +11,8 @@ use super::super::tests::every_store_failure; use super::super::{error_table, install_error_value, retained_error}; use super::raised_from; use crate::error::{ - QuotaResource, context_exhausted, internal, lua_quota, lua_runtime, task_not_owned, tool, - tool_loop_exhausted, + QuotaResource, context_exhausted, internal, lua_quota, lua_runtime, task_cancelled, + task_consumed, task_not_owned, tool, tool_loop_exhausted, }; /// The task id `path` names. @@ -36,11 +36,15 @@ fn located_by_their_fields() -> Vec { &[(FactKey::FinishReason, "length".into())], ), failure(ErrorCode::OutOfScopeTool, &[(FactKey::Name, "echo".into())]), + failure(ErrorCode::UnboundTool, &[(FactKey::Name, "echo".into())]), + failure(ErrorCode::ModelOnlyTool, &[(FactKey::Name, "echo".into())]), failure( ErrorCode::TasksLive, &[(FactKey::Tasks, serde_json::Value::from(vec!["1", "1.2"]))], ), task_not_owned(&task("0.4")), + task_consumed(&task("0.5")), + task_cancelled(&task("0.6")), lua_runtime("a refused edit"), tool_loop_exhausted(), ]; diff --git a/crates/runvil-internal/lua/src/error-value-universal.rs b/crates/runvil-internal/lua/src/error-value-universal.rs index 353a8a8cb..3b5a826d6 100644 --- a/crates/runvil-internal/lua/src/error-value-universal.rs +++ b/crates/runvil-internal/lua/src/error-value-universal.rs @@ -90,6 +90,23 @@ fn kind_of(code: ErrorCode) -> ErrorKind { } } +/// The one string field a failure with `code` holds and the fact behind +/// it, which both [`error_fields`] and [`field_facts`] read; `None` for a +/// code whose table holds no such field. +fn string_fact(code: ErrorCode) -> Option<(&'static str, FactKey)> { + match code { + ErrorCode::ContextExhausted => Some(("reason", FactKey::Reason)), + ErrorCode::EmptyReply => Some(("finish_reason", FactKey::FinishReason)), + ErrorCode::OutOfScopeTool | ErrorCode::UnboundTool | ErrorCode::ModelOnlyTool => { + Some(("name", FactKey::Name)) + } + ErrorCode::TaskNotOwned | ErrorCode::TaskConsumed | ErrorCode::TaskCancelled => { + Some(("task", FactKey::Task)) + } + _ => None, + } +} + /// The fact `key` of `error` as the string field `name`, or nothing when /// the fact is absent or not a string. fn string_field(error: &Error, name: &str, key: FactKey) -> Vec<(String, ErrorField)> { @@ -132,17 +149,11 @@ pub fn error_kind(error: &Error) -> ErrorKind { #[must_use] pub fn error_fields(error: &Error) -> Vec<(String, ErrorField)> { match error.code { - ErrorCode::ContextExhausted => string_field(error, "reason", FactKey::Reason), - ErrorCode::EmptyReply => string_field(error, "finish_reason", FactKey::FinishReason), - ErrorCode::OutOfScopeTool | ErrorCode::UnboundTool | ErrorCode::ModelOnlyTool => { - string_field(error, "name", FactKey::Name) - } ErrorCode::TasksLive => joined_tasks(error), - ErrorCode::TaskNotOwned | ErrorCode::TaskConsumed | ErrorCode::TaskCancelled => { - string_field(error, "task", FactKey::Task) - } code if kind_of(code) == ErrorKind::Store => store_fact_fields(error), - _ => Vec::new(), + code => string_fact(code) + .map(|(name, key)| string_field(error, name, key)) + .unwrap_or_default(), } } @@ -155,20 +166,7 @@ pub(super) fn field_facts( code: ErrorCode, fields: &BTreeMap, ) -> BTreeMap { - let string = |name: &str, key: FactKey| { - fields - .get(name) - .and_then(ErrorField::as_str) - .map(|value| (key, Value::from(value))) - .into_iter() - .collect() - }; match code { - ErrorCode::ContextExhausted => string("reason", FactKey::Reason), - ErrorCode::EmptyReply => string("finish_reason", FactKey::FinishReason), - ErrorCode::OutOfScopeTool | ErrorCode::UnboundTool | ErrorCode::ModelOnlyTool => { - string("name", FactKey::Name) - } ErrorCode::TasksLive => fields .get("tasks") .and_then(ErrorField::as_str) @@ -178,11 +176,14 @@ pub(super) fn field_facts( }) .into_iter() .collect(), - ErrorCode::TaskNotOwned | ErrorCode::TaskConsumed | ErrorCode::TaskCancelled => { - string("task", FactKey::Task) - } code if kind_of(code) == ErrorKind::Store => store_field_facts(fields), - _ => BTreeMap::new(), + code => string_fact(code) + .and_then(|(name, key)| { + let value = fields.get(name)?.as_str()?; + Some((key, Value::from(value))) + }) + .into_iter() + .collect(), } } From d4cf401b22a454fd1f8697e1962ca328dfb663fa Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:23:24 -0800 Subject: [PATCH 14/36] Move chunk-line helpers into a `chunk_line` module `program` and `error` imported each other only through five chunk-line helpers that use nothing but `std`, so the helpers move into a new crate-private `chunk_line` module and the import cycle ends. `chunk_line` also holds `STRING_CHUNK_OPENING` and `PROMPT_CHUNK_PREFIX`, and `error::author` holds `TRACEBACK_HEADING`, so every writer and parser spells each marker through one constant. Each site keeps its own `find`, `rfind`, or `split_once`. --- crates/runvil-internal/lua/src/chunk_line.rs | 150 ++++++++++++++++++ .../runvil-internal/lua/src/coro-failure.rs | 4 +- .../runvil-internal/lua/src/error-author.rs | 10 +- .../lua/src/error-value-classify.rs | 2 +- crates/runvil-internal/lua/src/error.rs | 4 +- crates/runvil-internal/lua/src/lib.rs | 1 + crates/runvil-internal/lua/src/prelude.rs | 6 +- .../runvil-internal/lua/src/program-tests.rs | 3 +- crates/runvil-internal/lua/src/program.rs | 134 +--------------- .../lua/src/tests/diagnostics.rs | 2 +- 10 files changed, 171 insertions(+), 145 deletions(-) create mode 100644 crates/runvil-internal/lua/src/chunk_line.rs diff --git a/crates/runvil-internal/lua/src/chunk_line.rs b/crates/runvil-internal/lua/src/chunk_line.rs new file mode 100644 index 000000000..c9272e0f9 --- /dev/null +++ b/crates/runvil-internal/lua/src/chunk_line.rs @@ -0,0 +1,150 @@ +//! Chunk names and chunk-line mapping: the `line N` name a prompt block +//! compiles under, and the rewrite of chunk-relative lines to prompt lines. + +use std::num::NonZeroU32; + +/// The text Lua writes in front of a string chunk's name where it gives a +/// position in that chunk, as in `[string "line 7"]:2:`. +pub(crate) const STRING_CHUNK_OPENING: &str = "[string \""; + +/// The start of a prompt block's chunk name, `line N`, where `N` is the +/// prompt line the block starts on. +pub(crate) const PROMPT_CHUNK_PREFIX: &str = "line "; + +/// The prompt-source line a compiler `diagnostic` names, for the chunk +/// named `chunk_name` that starts on `source_line`: the chunk-relative +/// line after the chunk's marker, mapped to its absolute line, or +/// `source_line` when the diagnostic names none or the mapping overflows. +pub(crate) fn compile_line( + chunk_name: &str, + source_line: NonZeroU32, + diagnostic: &str, +) -> NonZeroU32 { + let marker = format!("{STRING_CHUNK_OPENING}{chunk_name}\"]:"); + diagnostic + .find(&marker) + .map(|at| &diagnostic[at + marker.len()..]) + .and_then(|rest| { + let digits = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + absolute_line(source_line, rest[..digits].parse().ok()?) + }) + .and_then(NonZeroU32::new) + .unwrap_or(source_line) +} + +/// The prompt line of line `chunk_line` of the chunk named `chunk_name`, +/// when that is a prompt block's name, `line N`: the block's line `N` +/// mapped as [`absolute_line`] maps it. `None` for any other name, or when +/// the line overflows `u32`. +pub(crate) fn prompt_line(chunk_name: &str, chunk_line: u32) -> Option { + let start = chunk_name.strip_prefix(PROMPT_CHUNK_PREFIX)?; + if !start.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + absolute_line(start.parse().ok()?, chunk_line).and_then(NonZeroU32::new) +} + +/// The prompt line of the first position in `text` that Lua writes for a +/// prompt block's line, `[string "line N"]:M:`, as a traceback names its +/// innermost author frame. The `<[string "line N"]:K>` that names where a +/// function is defined has no colon after its line and is skipped. +pub(crate) fn first_prompt_line(text: &str) -> Option { + text.match_indices(STRING_CHUNK_OPENING) + .find_map(|(at, _)| { + let (chunk_name, rest) = text[at + STRING_CHUNK_OPENING.len()..].split_once("\"]:")?; + let digits = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + if !rest[digits..].starts_with(':') { + return None; + } + prompt_line(chunk_name, rest[..digits].parse().ok()?) + }) +} + +/// Rewrites chunk-relative line numbers for the named chunks in `owners`, +/// each a chunk name, the display location it maps to, and the +/// prompt-source line its chunk starts at, to absolute prompt-source lines, +/// and returns the rewritten message beside the first line it rewrote. +/// +/// Only `[string "{chunk name}"]:N:` occurrences of an owner's chunk name +/// are rewritten, each through the first owner whose chunk name it names, to +/// `{location}:{line}:`, the form Lua gives a file chunk. No later pass +/// matches that form, so a parent VM that surfaces a called or fanout +/// child's mapped error leaves the child's absolute lines alone, even for a +/// library both VMs loaded. The message gains a single leading +/// `{location}:{line}:` tag for the first occurrence rewritten; a message +/// with none passes through unchanged, with no line. A chunk line whose +/// absolute mapping would overflow `u32` keeps its original marker and +/// digits. +pub(crate) fn map_chunk_line_to_absolute( + message: &str, + owners: &[(&str, &str, NonZeroU32)], +) -> (String, Option) { + let markers: Vec<(String, &str, NonZeroU32)> = owners + .iter() + .filter(|(chunk_name, location, _)| !chunk_name.is_empty() && !location.is_empty()) + .map(|&(chunk_name, location, source_line)| { + ( + format!("{STRING_CHUNK_OPENING}{chunk_name}\"]:"), + location, + source_line, + ) + }) + .collect(); + let mut result = String::with_capacity(message.len() + 64); + let mut rest = message; + let mut tag: Option<(&str, u32)> = None; + while let Some(start) = rest.find(STRING_CHUNK_OPENING) { + result.push_str(&rest[..start]); + let at = &rest[start..]; + let Some((marker, location, source_line)) = markers + .iter() + .find(|(marker, ..)| at.starts_with(marker.as_str())) + else { + result.push_str(STRING_CHUNK_OPENING); + rest = &at[STRING_CHUNK_OPENING.len()..]; + continue; + }; + let after = &at[marker.len()..]; + let digit_end = after + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(after.len()); + let absolute = after[..digit_end] + .parse() + .ok() + .and_then(|chunk_line| absolute_line(*source_line, chunk_line)); + if let Some(absolute) = absolute { + tag.get_or_insert((location, absolute)); + result.push_str(location); + result.push(':'); + result.push_str(&absolute.to_string()); + rest = &after[digit_end..]; + } else { + result.push_str(marker); + rest = after; + } + } + result.push_str(rest); + + if let Some((location, absolute)) = tag { + // Leading `location:line:` tag naming the block's display location + // and its absolute line: ``section `Web Search` epilog:51: ...`` + ( + format!("{location}:{absolute}: {result}"), + NonZeroU32::new(absolute), + ) + } else { + (result, None) + } +} + +/// The absolute prompt-source line of `chunk_line`, a chunk-relative line, +/// in a chunk starting at `source_line`: the two summed, less one. `None` +/// when the sum overflows `u32`, so a pathological line count cannot wrap +/// into a wrong number. +fn absolute_line(source_line: NonZeroU32, chunk_line: u32) -> Option { + source_line.get().checked_add(chunk_line)?.checked_sub(1) +} diff --git a/crates/runvil-internal/lua/src/coro-failure.rs b/crates/runvil-internal/lua/src/coro-failure.rs index 236cce6c7..9851dd819 100644 --- a/crates/runvil-internal/lua/src/coro-failure.rs +++ b/crates/runvil-internal/lua/src/coro-failure.rs @@ -4,7 +4,7 @@ use mlua::{Function, Lua, Table, Value}; -use crate::error::{self, Error, Result}; +use crate::error::{self, Error, Result, TRACEBACK_HEADING}; use crate::error_value::{raised_from, retained_error}; /// The registry key of the last value a guarded block raised, written by @@ -82,7 +82,7 @@ impl StashedFailure { return std::borrow::Cow::Borrowed(error); } let (head, tail) = message - .rfind("\nstack traceback:") + .rfind(TRACEBACK_HEADING) .map_or((message.as_str(), ""), |at| message.split_at(at)); let head = self .raised diff --git a/crates/runvil-internal/lua/src/error-author.rs b/crates/runvil-internal/lua/src/error-author.rs index dabfe6087..250454ea3 100644 --- a/crates/runvil-internal/lua/src/error-author.rs +++ b/crates/runvil-internal/lua/src/error-author.rs @@ -10,7 +10,7 @@ use std::num::NonZeroU32; use mlua::Lua; use runvil_types::error::{Error, ErrorCode, place}; -use crate::program::prompt_line; +use crate::chunk_line::{STRING_CHUNK_OPENING, prompt_line}; /// The file label of a VM that was given none: the label a prompt that /// came from no file has. @@ -126,13 +126,17 @@ pub(crate) fn authored(lua: &Lua, error: Error) -> Error { authored_at(error, &prompt_file(lua), line, &what) } +/// The heading Lua puts on its own line between a failure's text and its +/// traceback. +pub(crate) const TRACEBACK_HEADING: &str = "\nstack traceback:"; + /// The failure's own text in `message`: the text before the traceback, /// without the `[string "..."]:N: ` positions Lua puts in front of it. pub(crate) fn failure_text(message: &str) -> &str { let mut text = message - .find("\nstack traceback:") + .find(TRACEBACK_HEADING) .map_or(message, |at| &message[..at]); - while let Some(rest) = text.strip_prefix("[string \"") { + while let Some(rest) = text.strip_prefix(STRING_CHUNK_OPENING) { let Some(close) = rest.find("\"]:") else { break; }; diff --git a/crates/runvil-internal/lua/src/error-value-classify.rs b/crates/runvil-internal/lua/src/error-value-classify.rs index 563b36123..ea231177f 100644 --- a/crates/runvil-internal/lua/src/error-value-classify.rs +++ b/crates/runvil-internal/lua/src/error-value-classify.rs @@ -7,11 +7,11 @@ use mlua::{Function, Lua, Value}; use runvil_types::error::Error; use super::error_table; +use crate::chunk_line::first_prompt_line; use crate::error::{ author_line, authored_at, call_line, failure_text, internal, lua_runtime, prompt_file, root_cause, root_text, }; -use crate::program::first_prompt_line; /// A Rust callback's failure as the universal error its table renders. /// diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index 8c9fad753..d7e779eb4 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -21,8 +21,8 @@ mod universal; #[cfg(any(test, feature = "test-support"))] pub(crate) use author::UNNAMED_FILE; pub(crate) use author::{ - author_line, author_prose, authored, authored_at, call_line, failure_text, prompt_file, - set_call_line, set_prompt_file, + TRACEBACK_HEADING, author_line, author_prose, authored, authored_at, call_line, failure_text, + prompt_file, set_call_line, set_prompt_file, }; pub use universal::{ context_exhausted, internal_at, interrupted, lua_quota, lua_runtime, task_cancelled, diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 77f24e75b..d7c0a3419 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -79,6 +79,7 @@ mod alias; mod argv; mod bindings; +mod chunk_line; mod collection; mod compactors; mod coro; diff --git a/crates/runvil-internal/lua/src/prelude.rs b/crates/runvil-internal/lua/src/prelude.rs index cedf428f2..e1a639a86 100644 --- a/crates/runvil-internal/lua/src/prelude.rs +++ b/crates/runvil-internal/lua/src/prelude.rs @@ -25,7 +25,7 @@ use mlua::chunk::ChunkMode; use mlua::{Lua, Table, Value}; use runvil_types::packs::{PackId, Prelude}; -use crate::error::{self, Error, Result, lua_runtime}; +use crate::error::{self, Error, Result, TRACEBACK_HEADING, lua_runtime}; use crate::proxy::{key_path, key_text, read_only_proxy}; /// The `_G` names a prelude's environment reads, as bound when it installs. @@ -218,7 +218,7 @@ fn load_failure(pack: &PackId, error: &mlua::Error) -> Error { mlua::Error::RuntimeError(message) => message.clone(), other => other.to_string(), }; - let (head, traceback) = match rendered.split_once("\nstack traceback:") { + let (head, traceback) = match rendered.split_once(TRACEBACK_HEADING) { Some((head, traceback)) => (head, Some(traceback)), None => (rendered.as_str(), None), }; @@ -227,7 +227,7 @@ fn load_failure(pack: &PackId, error: &mlua::Error) -> Error { defines functions; it must not call tools while loading." ); if let Some(traceback) = traceback { - message.push_str("\nstack traceback:"); + message.push_str(TRACEBACK_HEADING); message.push_str(traceback); } lua_runtime(message) diff --git a/crates/runvil-internal/lua/src/program-tests.rs b/crates/runvil-internal/lua/src/program-tests.rs index 96a600cd7..2ff69b540 100644 --- a/crates/runvil-internal/lua/src/program-tests.rs +++ b/crates/runvil-internal/lua/src/program-tests.rs @@ -9,7 +9,8 @@ use runvil_types::emitter::{Emitter, EventSink}; use runvil_types::error::{ErrorCode, FactKey, Location}; use serde_json::Value; -use super::{LuaProgram, compile_line, first_prompt_line, map_chunk_line_to_absolute, prompt_line}; +use super::LuaProgram; +use crate::chunk_line::{compile_line, first_prompt_line, map_chunk_line_to_absolute, prompt_line}; use crate::error::{QuotaResource, context_exhausted, internal, lua_quota}; /// The file every program here comes from. diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index c60e420d8..23ebecd27 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -7,6 +7,7 @@ use runvil_types::emitter::Emitter; use runvil_types::error::ErrorCode; use runvil_types::event::lifecycle; +use crate::chunk_line::{PROMPT_CHUNK_PREFIX, compile_line, map_chunk_line_to_absolute}; use crate::error::{ self, Error, Result, authored_at, failure_text, lua_compile, lua_runtime, root_text, }; @@ -107,7 +108,7 @@ impl LuaProgram { ) -> Result { emitter.report(section, lifecycle::LUA_COMPILATION_STARTED); - let chunk_name = format!("line {source_line}"); + let chunk_name = format!("{PROMPT_CHUNK_PREFIX}{source_line}"); let guarded = format!("{FRAME_GUARD}{source}"); let compiled = compiler() .map_err(|error| error::internal(error.to_string())) @@ -261,137 +262,6 @@ impl LuaProgram { } } -/// The prompt-source line a compiler `diagnostic` names, for the chunk -/// named `chunk_name` that starts on `source_line`: the chunk-relative -/// line after the chunk's marker, mapped to its absolute line, or -/// `source_line` when the diagnostic names none or the mapping overflows. -fn compile_line(chunk_name: &str, source_line: NonZeroU32, diagnostic: &str) -> NonZeroU32 { - let marker = format!("[string \"{chunk_name}\"]:"); - diagnostic - .find(&marker) - .map(|at| &diagnostic[at + marker.len()..]) - .and_then(|rest| { - let digits = rest - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(rest.len()); - absolute_line(source_line, rest[..digits].parse().ok()?) - }) - .and_then(NonZeroU32::new) - .unwrap_or(source_line) -} - -/// The prompt line of line `chunk_line` of the chunk named `chunk_name`, -/// when that is a prompt block's name, `line N`: the block's line `N` -/// mapped as [`absolute_line`] maps it. `None` for any other name, or when -/// the line overflows `u32`. -pub(crate) fn prompt_line(chunk_name: &str, chunk_line: u32) -> Option { - let start = chunk_name.strip_prefix("line ")?; - if !start.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - absolute_line(start.parse().ok()?, chunk_line).and_then(NonZeroU32::new) -} - -/// The prompt line of the first position in `text` that Lua writes for a -/// prompt block's line, `[string "line N"]:M:`, as a traceback names its -/// innermost author frame. The `<[string "line N"]:K>` that names where a -/// function is defined has no colon after its line and is skipped. -pub(crate) fn first_prompt_line(text: &str) -> Option { - const OPENING: &str = "[string \""; - text.match_indices(OPENING).find_map(|(at, _)| { - let (chunk_name, rest) = text[at + OPENING.len()..].split_once("\"]:")?; - let digits = rest - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(rest.len()); - if !rest[digits..].starts_with(':') { - return None; - } - prompt_line(chunk_name, rest[..digits].parse().ok()?) - }) -} - -/// Rewrites chunk-relative line numbers for the named chunks in `owners`, -/// each a chunk name, the display location it maps to, and the -/// prompt-source line its chunk starts at, to absolute prompt-source lines, -/// and returns the rewritten message beside the first line it rewrote. -/// -/// Only `[string "{chunk name}"]:N:` occurrences of an owner's chunk name -/// are rewritten, each through the first owner whose chunk name it names, to -/// `{location}:{line}:`, the form Lua gives a file chunk. No later pass -/// matches that form, so a parent VM that surfaces a called or fanout -/// child's mapped error leaves the child's absolute lines alone, even for a -/// library both VMs loaded. The message gains a single leading -/// `{location}:{line}:` tag for the first occurrence rewritten; a message -/// with none passes through unchanged, with no line. A chunk line whose -/// absolute mapping would overflow `u32` keeps its original marker and -/// digits. -pub(crate) fn map_chunk_line_to_absolute( - message: &str, - owners: &[(&str, &str, NonZeroU32)], -) -> (String, Option) { - const OPENING: &str = "[string \""; - let markers: Vec<(String, &str, NonZeroU32)> = owners - .iter() - .filter(|(chunk_name, location, _)| !chunk_name.is_empty() && !location.is_empty()) - .map(|&(chunk_name, location, source_line)| { - (format!("{OPENING}{chunk_name}\"]:"), location, source_line) - }) - .collect(); - let mut result = String::with_capacity(message.len() + 64); - let mut rest = message; - let mut tag: Option<(&str, u32)> = None; - while let Some(start) = rest.find(OPENING) { - result.push_str(&rest[..start]); - let at = &rest[start..]; - let Some((marker, location, source_line)) = markers - .iter() - .find(|(marker, ..)| at.starts_with(marker.as_str())) - else { - result.push_str(OPENING); - rest = &at[OPENING.len()..]; - continue; - }; - let after = &at[marker.len()..]; - let digit_end = after - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(after.len()); - let absolute = after[..digit_end] - .parse() - .ok() - .and_then(|chunk_line| absolute_line(*source_line, chunk_line)); - if let Some(absolute) = absolute { - tag.get_or_insert((location, absolute)); - result.push_str(location); - result.push(':'); - result.push_str(&absolute.to_string()); - rest = &after[digit_end..]; - } else { - result.push_str(marker); - rest = after; - } - } - result.push_str(rest); - - if let Some((location, absolute)) = tag { - // Leading `location:line:` tag naming the block's display location - // and its absolute line: ``section `Web Search` epilog:51: ...`` - ( - format!("{location}:{absolute}: {result}"), - NonZeroU32::new(absolute), - ) - } else { - (result, None) - } -} - -/// The absolute prompt-source line of `chunk_line`, a chunk-relative line, -/// in a chunk starting at `source_line`: the two summed, less one. `None` -/// when the sum overflows `u32`, so a pathological line count cannot wrap -/// into a wrong number. -fn absolute_line(source_line: NonZeroU32, chunk_line: u32) -> Option { - source_line.get().checked_add(chunk_line)?.checked_sub(1) -} - #[cfg(test)] #[path = "program-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/tests/diagnostics.rs b/crates/runvil-internal/lua/src/tests/diagnostics.rs index f3198be4a..5f9ce7bf4 100644 --- a/crates/runvil-internal/lua/src/tests/diagnostics.rs +++ b/crates/runvil-internal/lua/src/tests/diagnostics.rs @@ -3,7 +3,7 @@ //! that fail closed on a poisoned lock. use super::*; -use crate::program::map_chunk_line_to_absolute; +use crate::chunk_line::map_chunk_line_to_absolute; use crate::vm::AdhocTools; #[test] From 269b9e797bf89a978db6399eafcd671a5a211747 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:08:13 -0800 Subject: [PATCH 15/36] Replace `ModelReport` with `ModelCall` in model dispatch `ModelReport` held the same call id and turn as `ModelCall`, so `prepare_model_dispatch` now takes a `&ModelCall` and the engine builds one for each model-issued call. The dispatch docs and the crate `AGENTS.md` now describe the one executor that applies these bodies, not every executor. --- .../engine/src/execute/scheduler/apply.rs | 7 +++--- crates/runvil-internal/engine/src/lua.rs | 12 +++++----- crates/runvil-internal/lua/AGENTS.md | 2 +- .../runvil-internal/lua/src/dispatch-tests.rs | 12 +++++----- crates/runvil-internal/lua/src/dispatch.rs | 23 +++++-------------- crates/runvil-internal/lua/src/lib.rs | 4 +--- .../lua/src/protocol/request.rs | 9 ++++---- 7 files changed, 28 insertions(+), 41 deletions(-) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index 17b267571..4d21a7e70 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -25,8 +25,7 @@ use runvil_vfs::{ExecId, VfsError, detail}; use crate::execute::infer::accept_infer; use crate::lua::{ - Answer, ModelReport, ToolCallOutcome, VfsOp, VfsOutcome, prepare_dispatch, - prepare_model_dispatch, + Answer, ModelCall, ToolCallOutcome, VfsOp, VfsOutcome, prepare_dispatch, prepare_model_dispatch, }; use crate::model::Completion; use crate::{Error, Result, error}; @@ -274,9 +273,9 @@ impl ChainOps<'_> { // plain - it is the tool record's text for the next round, // never classified by output kind. CallSite::Model { call_id, .. } | CallSite::Batch { call_id, .. } => { - let report = ModelReport { - turn: call.report.turn, + let report = ModelCall { call_id: call_id.clone(), + turn: call.report.turn, }; prepare_model_dispatch( &call.binding, diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 5dcf13b85..3ca57a54c 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -18,12 +18,12 @@ pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, - Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, ModelReport, - NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, RenderedTools, Request, - RosterEntry, ScriptReport, SectionText, SectionVm, TaskDelivery, TaskPhase, TaskStatus, - ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, - VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, current_static_set, - install_preludes, install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, + Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, NonEmpty, OfferMode, + OfferedTool, OverflowReason, ProseState, RenderedTools, Request, RosterEntry, ScriptReport, + SectionText, SectionVm, TaskDelivery, TaskPhase, TaskStatus, ToolBinding, ToolCallCounts, + ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, VfsOp, VfsOutcome, + YieldParse, current_catalog_packs, current_dynamic_set, current_static_set, install_preludes, + install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, prepare_model_dispatch, project_messages, render_item, wire_name_of, }; diff --git a/crates/runvil-internal/lua/AGENTS.md b/crates/runvil-internal/lua/AGENTS.md index d2a0598fb..3097208ec 100644 --- a/crates/runvil-internal/lua/AGENTS.md +++ b/crates/runvil-internal/lua/AGENTS.md @@ -1,4 +1,4 @@ # runvil-lua - Engine functions that would create a parser-to-Lua dependency cycle stay in this crate rather than `runvil-prompt`. -- `prepare_dispatch` is the single tool-dispatch body used by every executor: synchronous, it applies the succeeded/failed observation, trust classification, the nonce wrap, and the `ToolResult` report to a bound tool's answer; the executor counts each call when it issues it, and adhoc and discovery tools report in the executor. Nothing in this crate performs a tool call: the executor issues the call as an effect, the Harness performs it, and `prepare_dispatch` (or `prepare_model_dispatch` under the model-issued rule) applies the rules when the answer lands. +- `prepare_dispatch` is the tool-dispatch body the executor applies: synchronous, it applies the succeeded/failed observation, trust classification, the nonce wrap, and the `ToolResult` report to a bound tool's answer; the executor counts each call when it issues it, and adhoc and discovery tools report in the executor. Nothing in this crate performs a tool call: the executor issues the call as an effect, the Harness performs it, and `prepare_dispatch` (or `prepare_model_dispatch` under the model-issued rule) applies the rules when the answer lands. diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index a69a2769d..ebeef98b9 100644 --- a/crates/runvil-internal/lua/src/dispatch-tests.rs +++ b/crates/runvil-internal/lua/src/dispatch-tests.rs @@ -92,10 +92,10 @@ fn prepare_dispatch_turns_a_canned_tool_error_into_the_typed_error() { ); } -fn model_report(call_id: &str) -> ModelReport { - ModelReport { - turn: 1, +fn model_call(call_id: &str) -> ModelCall { + ModelCall { call_id: call_id.to_owned(), + turn: 1, } } @@ -114,7 +114,7 @@ fn a_model_issued_tool_failure_becomes_untrusted_failure_text_under_its_call_id( &nonce, recorder.emitter(), TEST_SECTION, - &model_report("call_1"), + &model_call("call_1"), ) .expect("a model-issued call never fails for the tool's own failure"); assert_eq!( @@ -187,7 +187,7 @@ fn a_models_listing_call_receives_the_envelope() { &nonce, Recorder::default().emitter(), TEST_SECTION, - &model_report("call_1"), + &model_call("call_1"), ) .expect("a listing dispatches"); assert_eq!(outcome.content(), nonce.wrap("the listing")); @@ -249,7 +249,7 @@ fn a_model_issued_dispatch_reports_its_result_under_the_call_id() { &test_nonce(), recorder.emitter(), TEST_SECTION, - &model_report("call_2"), + &model_call("call_2"), ) .expect("the dispatch succeeds"); assert_eq!(outcome.content(), "echoed: hi"); diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 7f3ecd617..5af037019 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -1,4 +1,4 @@ -//! The shared tool-dispatch body every executor invokes. +//! The tool-dispatch bodies the executor applies when a bound tool's answer lands. //! //! [`prepare_dispatch`] is the one place a bound tool's answer composes the //! succeeded/failed observation, the untrusted nonce wrap, and the @@ -8,9 +8,7 @@ //! as untrusted failure text, and the `ToolResult` fires under the model's //! call id). Nothing here performs a call: the executor issues the call as //! an effect, the Harness performs it, and one of the two bodies applies the -//! rules when the answer lands. Keeping both bodies here - the crate every -//! executor already depends on - is what stops dispatch semantics from -//! forking. +//! rules when the answer lands. use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; @@ -20,6 +18,7 @@ use runvil_types::untrusted::GuardNonce; use crate::bindings::ToolBinding; use crate::error::{Result, tool}; use crate::error_value::{ErrorKind, error_kind}; +use crate::protocol::ModelCall; /// The run coordinates a script-initiated dispatch reports under. /// @@ -34,16 +33,6 @@ pub struct ScriptReport { pub turn: u32, } -/// The run coordinates a model-issued dispatch reports under: the turn the -/// call fired in and the call id the model issued. -#[derive(Debug, Clone)] -pub struct ModelReport { - /// The section's completed model-turn count at dispatch. - pub turn: u32, - /// The model-issued call id the result answers. - pub call_id: String, -} - /// The resolved outcome of one dispatched tool call: the final content - /// nonce-wrapped when untrusted, except a script's listing - beside its /// trust marking. [`prepare_model_dispatch`] reports the pair through the @@ -90,8 +79,8 @@ impl ToolDispatch { } } -/// Applies the dispatch rules to one bound tool call's answer: the shared -/// synchronous body every executor invokes once the tool has spoken. +/// Applies the dispatch rules to one bound tool call's answer: the +/// synchronous body the executor runs once the tool has spoken. /// /// The sequence is fixed: the succeeded/failed observation, then the trust /// rule - a trusted output passes verbatim, anything else is @@ -211,7 +200,7 @@ pub fn prepare_model_dispatch( nonce: &GuardNonce, emitter: &Emitter, section: &str, - report: &ModelReport, + report: &ModelCall, ) -> Result { let outcome = match dispatch_body(binding, vendor, call_result, nonce, emitter, section, None) { Ok(outcome) => outcome, diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index d7c0a3419..d9f3de94d 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -128,9 +128,7 @@ pub use crate::compactors::{ OverflowReason, UsageAnchor, exhaustion_detail, output_reserve, precheck, }; pub use crate::coro::install_store_shims; -pub use crate::dispatch::{ - ModelReport, ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch, -}; +pub use crate::dispatch::{ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch}; pub use crate::engine_globals::{ install_ui, run_store_op, store_error_message, store_failure, store_probe_message, }; diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 9577799eb..2aac8f080 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -183,10 +183,11 @@ pub enum Request { }, } -/// The model's side of a model-issued [`Request::ToolCall`]: the id the -/// model attached to the call and the turn of the round that requested it. -/// Shim-produced in both fields, never author-supplied, so a `call_id` -/// without a `turn`, or a `turn` without a `call_id`, is a malformed yield. +/// The model's side of a model-issued tool call: the id the model attached +/// to the call and the turn of the round that requested it. In a +/// [`Request::ToolCall`] both fields are shim-produced, never +/// author-supplied, so a `call_id` without a `turn`, or a `turn` without a +/// `call_id`, is a malformed yield. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelCall { /// The id the model attached to its tool call; `ToolResult` fires From 3499d9fde7abb6c8015719512bd7ca29a4f3abe6 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:10:31 -0800 Subject: [PATCH 16/36] Give the retained-error rule one home in the VM docs The `Answer`, `Retained`, and `render_envelope` docs restated how a typed error survives the resume envelope, so they now point to `SectionVm::resume_block_coro_answer`, which also states the cancel and quota exception. The protocol docs no longer claim one `Answer` variant per request, and they name the three submodules that use `NonEmpty`. --- crates/runvil-internal/lua/src/protocol.rs | 10 ++++---- .../lua/src/protocol/answer.rs | 19 ++++++-------- .../lua/src/protocol/render.rs | 25 +++++++------------ crates/runvil-internal/lua/src/vm/run.rs | 3 ++- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/crates/runvil-internal/lua/src/protocol.rs b/crates/runvil-internal/lua/src/protocol.rs index b4b7e2211..0587cb839 100644 --- a/crates/runvil-internal/lua/src/protocol.rs +++ b/crates/runvil-internal/lua/src/protocol.rs @@ -1,10 +1,10 @@ //! The coroutine protocol: validated request and answer types for the //! yield/resume boundary between section Lua and the scheduler driver. //! -//! Every suspending Engine call, one per [`Request`] variant, is a Lua-side -//! shim that yields a request table; the driver validates the yield into a -//! [`Request`], dispatches it, and resumes the coroutine with the -//! `(ok, result)` envelope rendered from an [`Answer`]. The two enums are +//! Every suspending Engine call is a Lua-side shim that yields a request +//! table; the driver validates the yield into a [`Request`], dispatches it, +//! and resumes the coroutine with the `(ok, result)` envelope rendered from +//! an [`Answer`]. The two enums are //! the audit surface: what a script can cause the Engine to do is one //! short read, and each variant's fields are the compiler-checked //! per-message contract. @@ -15,7 +15,7 @@ //! validation, `answer` the answer vocabulary (the [`Answer`] enum and its //! payload types), `parse` the yield-to-request validation, and `render` //! the answer-to-envelope rendering. `non_empty` holds the [`NonEmpty`] -//! sequence two of them use. +//! sequence that `request`, `record`, and `parse` use. mod answer; mod non_empty; diff --git a/crates/runvil-internal/lua/src/protocol/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 993dc036d..38fb04f7f 100644 --- a/crates/runvil-internal/lua/src/protocol/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/answer.rs @@ -238,20 +238,17 @@ pub struct TaskStatus { pub note: Option, } -/// One dispatched request's successful outcome, one variant per request. +/// One dispatched request's successful outcome. Each request kind has its +/// own variant except +/// [`Request::AdhocToolDone`](super::Request::AdhocToolDone), which the +/// driver answers with [`Answer::ToolCallResult`] for the `tools.call` it +/// closes. /// /// The driver answers a request with a `Result` over it, rendered to the /// `(ok, result)` envelope at resume time, and a failure is the `Err` side -/// whatever the request. On failure the envelope holds the error's -/// structured table for the shim to raise, and the driver -/// ([`SectionVm::resume_block_coro_answer`](crate::SectionVm::resume_block_coro_answer)) -/// retains the typed error beside that very table. When the block fails -/// by raising that table, the shim's own raise or the block's re-raise of -/// the value it caught, the typed error is substituted back; any other -/// failure, one carrying the same message included, stays the block's -/// own. This holds uniformly for leaf and structural requests: the -/// `Result` owns the typed error until the envelope is rendered, so a -/// `call` or `join_any` failure round-trips with its structure intact. +/// whatever the request; +/// [`SectionVm::resume_block_coro_answer`](crate::SectionVm::resume_block_coro_answer) +/// states how its typed error survives the envelope. /// /// The error is the universal [`Error`], and /// only a [`TaskDelivery`] holds one: a `join_any` member's failure is part diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index fe58ccd4f..3f7c03f1b 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -10,9 +10,9 @@ use crate::tools::adhoc_handler; use super::answer::{Answer, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome, VfsOutcome}; -/// An answer's typed error, retained while the shim raises its rendered -/// error table, beside that table: the block's raise of this very table, -/// and no other value, is the retained error's. +/// An answer's typed error and the error table rendered from it. +/// [`SectionVm::resume_block_coro_answer`](crate::SectionVm::resume_block_coro_answer) +/// states when the block's raise of that table reports the error. #[derive(Debug)] pub(crate) struct Retained { /// The typed error the answer owned. @@ -98,21 +98,14 @@ fn delivery_values( Ok((vec![id, Value::Boolean(ok), result], retained)) } -/// Renders one answered request's `(ok, result)` resume values for the -/// shim. +/// Renders one answered request's resume values for the shim, with the +/// typed error to retain when they hold one. /// /// On success the envelope is `(true, value...)`. On failure it is -/// `(false, table)`, where `table` is the error's structured value -/// (`kind`, `code`, `message`, and the kind's fields, as -/// [`error_table`] renders them, with `tostring` returning the message) - -/// the shim raises it -/// with `error(result, 0)`, so a printing author sees the error's prose -/// after the prompt line it was placed on, the call line of the yield it -/// answers unless it had a location, and a branching one reads `kind` - -/// and the typed error, so placed, is -/// retained beside that table, which identifies its raise. A successful -/// `join_any` whose member failed retains the member's error the same -/// way, since a shim may re-raise it at once. +/// `(false, table)`: the error, placed by [`authored`], as [`error_table`] +/// renders it, which the shim raises with `error(result, 0)`. A successful +/// `join_any` whose member failed retains the member's error too, since a +/// shim may re-raise it at once. /// /// # Errors /// Returns an `mlua` error if a Lua string, userdata, or table cannot be diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 16b4ee30c..b571663ad 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -128,7 +128,8 @@ impl SectionVm { /// answer owned is substituted back, so the Rust caller receives the /// structured error rather than a string. A block that caught the /// table and failed with anything else, its message text included, - /// keeps its own error. + /// keeps its own error. A block that was cancelled or spent an Engine + /// quota keeps that failure whatever it raised. /// /// # Errors /// Same contract as [`start_block_coro`](Self::start_block_coro), plus From 6eeec80597fc90d4a6e617802181cdae1ed1f165 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:11:49 -0800 Subject: [PATCH 17/36] Trim restated failure rules from the parse docs The `FieldFailure` type and the parse module doc already state where a field failure surfaces, so the readers and per-op parsers no longer repeat it. The parse module doc now names the parsers it holds itself, and the `parse::tasks` doc no longer lists a `pending` parser it does not have. --- crates/runvil-internal/lua/src/protocol/parse.rs | 9 ++++----- crates/runvil-internal/lua/src/protocol/parse/store.rs | 3 +-- crates/runvil-internal/lua/src/protocol/parse/tasks.rs | 10 ++++------ crates/runvil-internal/lua/src/protocol/parse/tools.rs | 10 ++++------ 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index e6b6a3e0a..15a0bc833 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -3,7 +3,8 @@ //! direct-yield message, and an author-argument failure becomes the call's //! answer so the shim raises it at the call site. Field reads are raw: the //! table comes from script space, so a metatable must not intercept or -//! forge a field. The per-op parsers sit in the siblings: `chat`, `store`, +//! forge a field. `infer`, `call`, and the argument-free `pending` parse +//! here; the other per-op parsers sit in the child modules `chat`, `store`, //! `tasks`, and `tools`. mod chat; @@ -97,8 +98,7 @@ fn call_optional_string( } /// Reads one shim-produced optional string field: absent or nil is `None`, -/// a string is `Some`, and any other shape is a malformed yield, since the -/// shims set the field by construction and no author argument reaches it. +/// a string is `Some`, and any other shape is a malformed yield. fn shim_optional_string( table: &mlua::Table, name: &str, @@ -115,8 +115,7 @@ fn shim_optional_string( /// Reads one shim-produced optional integer field: absent or nil is /// `None`, an integer within `u32` range is `Some`, and any other shape (a -/// float included) is a malformed yield, since the shims set the field by -/// construction and no author argument reaches it. +/// float included) is a malformed yield. fn shim_optional_u32( table: &mlua::Table, name: &str, diff --git a/crates/runvil-internal/lua/src/protocol/parse/store.rs b/crates/runvil-internal/lua/src/protocol/parse/store.rs index a231b0ba7..716d095a2 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/store.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/store.rs @@ -38,8 +38,7 @@ fn call_optional_line( } /// Parses a `store` request: the operation name and its author-supplied -/// arguments. Every wrong shape is the call's error, resumed as the answer -/// so the shim raises it at the call site - an author `pcall` catches it. +/// arguments. Every wrong shape is the call's error. pub(super) fn parse_store(table: &mlua::Table) -> std::result::Result { let op = call_string(table, "store_op")?; let op = match op.as_str() { diff --git a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs index 2f1f3a2bf..41bed183e 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs @@ -1,7 +1,7 @@ //! The task-operation request parsers: `spawn` and its seeds, the wait //! shims' internal `timer` and its author-supplied seconds, the `join_any` -//! set, the single-task `ready`, `status`, and `cancel`, the argument-free -//! `pending`, the `note` text, and the `concurrency` limit. The shims +//! set, the single-task `ready`, `status`, and `cancel`, the `note` text, +//! and the `concurrency` limit. The shims //! resolve a `Task` handle to its bare id before yielding, so every task //! field arrives as a path string; an id that does not parse is the //! author's argument error, raised at the call site. @@ -187,10 +187,8 @@ pub(super) fn parse_concurrency(table: &mlua::Table) -> std::result::Result { // A float with an integral value is the author writing `8.0`: // the shim's whole-number check let it through, so the parser - // converts it. An integral magnitude past `u64` (an author's - // `1e20`) is the call's error, raised at the call site like - // the shim's own refusal; a fractional, negative, or - // non-finite float is a hand-rolled yield and stays malformed. + // converts it. A fractional, negative, or non-finite float is a + // hand-rolled yield and stays malformed. if limit.fract() != 0.0 || limit < 1.0 { return Err(FieldFailure::Malformed); } diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index f2dd7599e..8844907a9 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -48,12 +48,10 @@ pub(super) fn parse_tool_call( /// names it by the wire name the model sent. An absent or nil `args` /// parses as the empty object (the empty-argument call every tool /// accepts). A wrong `alias` for a script call, or a non-table or -/// JSON-unrepresentable `args`, is the call's error, framed as the other -/// author-argument failures are, so an author `pcall` catches it at the -/// call site. `call_id` is set only by the loop shim for a model-issued -/// call, so a present non-string is a malformed yield rather than a call -/// error; `turn`, set by the same shim, is likewise malformed unless it is -/// an integer within `u32` range. +/// JSON-unrepresentable `args`, is the call's error. `call_id` is set only +/// by the loop shim for a model-issued call, so a present non-string is a +/// malformed yield rather than a call error; `turn`, set by the same shim, +/// is likewise malformed unless it is an integer within `u32` range. fn read_tool_call( lua: &Lua, table: &mlua::Table, From 67d1f08115746cd0b6b214c3f1dd5e65810b98ba Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:14:15 -0800 Subject: [PATCH 18/36] Correct request docs and narrow two protocol items `Request::Call::input` now says an absent override runs under the caller's own args, and `VfsOp::Delete` says it also removes an empty directory. `MAX_RECORD_META_BYTES` becomes private, and `NonEmpty::new` becomes a test-only constructor, since only this crate's tests call it. --- crates/runvil-internal/lua/src/protocol/non_empty.rs | 7 ++----- crates/runvil-internal/lua/src/protocol/record.rs | 2 +- crates/runvil-internal/lua/src/protocol/request.rs | 6 ++++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/runvil-internal/lua/src/protocol/non_empty.rs b/crates/runvil-internal/lua/src/protocol/non_empty.rs index b18b1836f..e736d885c 100644 --- a/crates/runvil-internal/lua/src/protocol/non_empty.rs +++ b/crates/runvil-internal/lua/src/protocol/non_empty.rs @@ -10,12 +10,9 @@ pub struct NonEmpty(Vec); impl NonEmpty { /// The sequence of `first` followed by `rest`. - /// - /// The way a consumer outside this crate builds one, so a non-empty - /// sequence needs no fallible step where the first element is already - /// in hand. + #[cfg(test)] #[must_use] - pub fn new(first: T, rest: Vec) -> Self { + pub(crate) fn new(first: T, rest: Vec) -> Self { let mut items = Vec::with_capacity(rest.len() + 1); items.push(first); items.extend(rest); diff --git a/crates/runvil-internal/lua/src/protocol/record.rs b/crates/runvil-internal/lua/src/protocol/record.rs index 810608618..9996fe099 100644 --- a/crates/runvil-internal/lua/src/protocol/record.rs +++ b/crates/runvil-internal/lua/src/protocol/record.rs @@ -8,7 +8,7 @@ use runvil_model_client::client::Role; use super::non_empty::NonEmpty; /// The most bytes a record's `meta` may take as compact JSON. -pub(crate) const MAX_RECORD_META_BYTES: usize = 8 * 1024; +const MAX_RECORD_META_BYTES: usize = 8 * 1024; /// One content part of a multimodal message: visible text or an image URL. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 2aac8f080..d2f8f107a 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -42,7 +42,8 @@ pub enum Request { Call { /// The heading string. target: String, - /// The optional input override; `None` runs under the run's own args. + /// The optional input override; `None` runs under the caller's own + /// args. input: Option, /// The caller's `var` snapshot, seeded into the chain and discarded /// when it ends. @@ -283,7 +284,8 @@ pub enum VfsOp { /// The replacement text. new: String, }, - /// `store.delete(path)`: deletes the file at `path`. Deleting a missing + /// `store.delete(path)`: removes the file or empty directory at `path`; + /// a directory that still holds entries is refused. Deleting a missing /// path succeeds. Delete { /// The author-supplied logical path. From 7ca443a6e19e161bf1ad9e3ca349bf2c7c4d31f0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:16:31 -0800 Subject: [PATCH 19/36] Hold a task's live position in its running phase `TaskStatus` held `section` and `blocked` beside `state`, so an ended task could still carry them. They now live on `TaskPhase::Running`, which loses `Copy`, and the engine sets them only for a live backing chain. The Lua status table keeps its shape. --- .../engine/src/execute/scheduler/waits.rs | 14 +++++++++----- .../lua/src/protocol/answer.rs | 19 ++++++++++--------- .../lua/src/protocol/render.rs | 15 ++++++++------- .../lua/src/protocol/tests/answer.rs | 12 ++++++++---- 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs index 15c8bf2c1..dd75b3f65 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs @@ -46,7 +46,10 @@ use super::{ChainIndex, Scheduler}; /// delivery is the owner's bookkeeping, not a lifecycle change. fn task_phase(state: TaskState) -> TaskPhase { match state { - TaskState::Running => TaskPhase::Running, + TaskState::Running => TaskPhase::Running { + section: None, + blocked: None, + }, TaskState::Done { ok } | TaskState::Delivered { ok } => TaskPhase::Done { ok }, TaskState::Cancelled => TaskPhase::Cancelled, TaskState::Abandoned => TaskPhase::Abandoned, @@ -137,8 +140,6 @@ impl Scheduler { let mut status = TaskStatus { target: entry.target.clone(), state: task_phase(entry.state.view()), - section: None, - blocked: None, turns: 0, tasks: Vec::new(), depth: 0, @@ -149,11 +150,14 @@ impl Scheduler { status.turns = chain.ctx.turns().load(Ordering::Relaxed); status.depth = u32::try_from(chain.call_depth.get()).unwrap_or(u32::MAX); if entry.state.is_live() { - status.section = chain + let section = chain .entered .is_some() .then(|| chain.section_name().to_owned()); - status.blocked = chain.blocked(); + status.state = TaskPhase::Running { + section, + blocked: chain.blocked(), + }; status.tasks = self.tasks.live_author_tasks(backing); } } diff --git a/crates/runvil-internal/lua/src/protocol/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 38fb04f7f..57268aa47 100644 --- a/crates/runvil-internal/lua/src/protocol/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/answer.rs @@ -151,10 +151,16 @@ pub struct TaskDelivery { } /// A task's lifecycle phase, as `tasks.status` reports it in `state`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskPhase { /// The task's backing chain or request is still running. - Running, + Running { + /// The section the backing chain is currently in, while it is live + /// and inside one. + section: Option, + /// What the backing chain is waiting on, while it waits. + blocked: Option, + }, /// The task ended with a result, whether or not a wait delivered it. Done { /// Whether the task returned rather than failed. @@ -170,9 +176,9 @@ impl TaskPhase { /// The tag `tasks.status` reports: `running`, `done`, `cancelled`, or /// `abandoned`. #[must_use] - pub(crate) fn tag(self) -> &'static str { + pub(crate) fn tag(&self) -> &'static str { match self { - TaskPhase::Running => "running", + TaskPhase::Running { .. } => "running", TaskPhase::Done { .. } => "done", TaskPhase::Cancelled => "cancelled", TaskPhase::Abandoned => "abandoned", @@ -223,11 +229,6 @@ pub struct TaskStatus { pub target: String, /// The lifecycle phase; a delivered task reports [`TaskPhase::Done`]. pub state: TaskPhase, - /// The section the backing chain is currently in, while it is live and - /// inside one. - pub section: Option, - /// What the backing chain is waiting on, while it waits. - pub blocked: Option, /// The task's model-turn count so far. pub turns: u32, /// The live tasks the task's chain owns, in spawn order. diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 3f7c03f1b..adceba9ea 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -31,16 +31,17 @@ fn task_status_table(lua: &Lua, status: TaskStatus) -> mlua::Result table.raw_set("target", status.target)?; table.raw_set("state", status.state.tag())?; match status.state { - TaskPhase::Running => {} + TaskPhase::Running { section, blocked } => { + if let Some(section) = section { + table.raw_set("section", section)?; + } + if let Some(blocked) = blocked { + table.raw_set("blocked", blocked.tag())?; + } + } TaskPhase::Done { ok } => table.raw_set("ok", ok)?, TaskPhase::Cancelled | TaskPhase::Abandoned => table.raw_set("ok", false)?, } - if let Some(section) = status.section { - table.raw_set("section", section)?; - } - if let Some(blocked) = status.blocked { - table.raw_set("blocked", blocked.tag())?; - } table.raw_set("turns", status.turns)?; table.raw_set("tasks", task_id_sequence(lua, &status.tasks)?)?; table.raw_set("depth", status.depth)?; diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index d0720a752..f36e59c67 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -180,7 +180,13 @@ fn a_status_answer_derives_ok_from_the_phase() { // and false for a task that was cancelled or abandoned. let lua = Lua::new(); for (state, expected) in [ - (TaskPhase::Running, "nil"), + ( + TaskPhase::Running { + section: None, + blocked: None, + }, + "nil", + ), (TaskPhase::Done { ok: true }, "true"), (TaskPhase::Done { ok: false }, "false"), (TaskPhase::Cancelled, "false"), @@ -188,9 +194,7 @@ fn a_status_answer_derives_ok_from_the_phase() { ] { let status = TaskStatus { target: "Child".to_owned(), - state, - section: None, - blocked: None, + state: state.clone(), turns: 0, tasks: Vec::new(), depth: 0, From 4b577ce97fdbc5ef426505cd4e69a3c12b72a23b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:18:54 -0800 Subject: [PATCH 20/36] Refuse tool_calls on a non-assistant record at the add The rule reads two fields of one record, so `parse_record` now refuses a non-empty `tool_calls` on every role but assistant, and the error lands on the call that adds the record. The late check in the projection and its test case go away, and the record and projection docs say where the rule now sits. --- .../lua/src/messages-tests-records.rs | 4 ++++ .../lua/src/projection-tests.rs | 6 ------ crates/runvil-internal/lua/src/projection.rs | 18 +++++++----------- .../runvil-internal/lua/src/protocol/record.rs | 13 +++++++++---- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/crates/runvil-internal/lua/src/messages-tests-records.rs b/crates/runvil-internal/lua/src/messages-tests-records.rs index 8f64f4b75..53c535f94 100644 --- a/crates/runvil-internal/lua/src/messages-tests-records.rs +++ b/crates/runvil-internal/lua/src/messages-tests-records.rs @@ -48,6 +48,10 @@ fn malformed_tool_calls_are_refused_naming_the_index() { r#"{ { role = "assistant", content = "", tool_calls = "raw" } }"#, "messages[1] tool_calls must be an array", ), + ( + r#"{ { role = "user", content = "hi", tool_calls = { { id = "call_1", name = "echo" } } } }"#, + "messages[1] sets tool_calls but is not an assistant message", + ), ]); } diff --git a/crates/runvil-internal/lua/src/projection-tests.rs b/crates/runvil-internal/lua/src/projection-tests.rs index 539bca28d..bc6933f83 100644 --- a/crates/runvil-internal/lua/src/projection-tests.rs +++ b/crates/runvil-internal/lua/src/projection-tests.rs @@ -344,12 +344,6 @@ fn metadata_beyond_the_contract_is_stripped_from_the_wire() { #[test] fn malformed_records_fail_at_projection_naming_the_index() { - let mut with_calls = user("hi"); - with_calls.tool_calls = vec![call("call_1", "echo")]; - assert_eq!( - projection_error(&[with_calls]), - "messages[1] sets tool_calls but is not an assistant message" - ); assert_eq!( projection_error(&[user("hi"), system("late")]), "messages[2] is a system message outside the leading system block" diff --git a/crates/runvil-internal/lua/src/projection.rs b/crates/runvil-internal/lua/src/projection.rs index 685307d26..92ed3f29d 100644 --- a/crates/runvil-internal/lua/src/projection.rs +++ b/crates/runvil-internal/lua/src/projection.rs @@ -8,9 +8,10 @@ //! dispatch, on every model call, over whichever records the section's list //! holds at that moment: //! -//! - `tool_calls` belong to assistant records (a record holds a -//! `tool_call_id` only as a tool result, by its type); a system message -//! is legal only in the leading block. +//! - A system message is legal only in the leading block. A record's role +//! rules sit with the record: it holds a `tool_call_id` only as a tool +//! result, by its type, and the record parse refuses `tool_calls` on +//! every role but assistant. //! - Tool-call IDs are unique across the whole list, and every assistant //! tool-call batch is answered atomically: the records immediately //! following the batch are its results, one per call, each exactly once. @@ -44,9 +45,9 @@ use crate::protocol::{ContentPart, MessageContent, MessageRecord, NonEmpty, Reco /// /// # Errors /// Returns a `LuaRuntime` failure naming the offending 1-based index when the list -/// violates a cross-record rule: a malformed record (tool calls on a -/// non-assistant, a system message outside the leading block, content -/// parts in a system block that must be composed), a duplicate tool-call +/// violates a cross-record rule: a malformed record (a system message +/// outside the leading block, content parts in a system block that must +/// be composed), a duplicate tool-call /// ID, an orphan tool record, or an assistant tool call with no result. pub fn project_messages(messages: &[MessageRecord]) -> Result> { validate(messages)?; @@ -76,11 +77,6 @@ fn validate(messages: &[MessageRecord]) -> Result<()> { let mut pending: Option<(usize, Vec<&str>)> = None; for (position, record) in messages.iter().enumerate() { let index = position + 1; - if record.role != RecordRole::Assistant && !record.tool_calls.is_empty() { - return Err(lua_runtime(format!( - "messages[{index}] sets tool_calls but is not an assistant message" - ))); - } match &record.role { RecordRole::System if position >= system_len => { return Err(lua_runtime(format!( diff --git a/crates/runvil-internal/lua/src/protocol/record.rs b/crates/runvil-internal/lua/src/protocol/record.rs index 9996fe099..ccbcadcc6 100644 --- a/crates/runvil-internal/lua/src/protocol/record.rs +++ b/crates/runvil-internal/lua/src/protocol/record.rs @@ -91,9 +91,8 @@ pub struct MessageRecord { pub role: RecordRole, /// The visible content. pub content: MessageContent, - /// The normalized tool calls the record holds. Only an assistant - /// record may send them: the record parse accepts them on any role, - /// and the projection refuses them on every other role. + /// The normalized tool calls the record holds; the record parse refuses + /// them on every role but assistant. pub tool_calls: Vec, /// Metadata the projection never sends; `None` when absent or JSON null. pub meta: Option, @@ -177,7 +176,8 @@ const CHAT_PART_TYPES: [&str; 2] = ["text", "image_url"]; /// content-parts array with known part types and payloads; a /// `tool_call_id` a string, required on tool records and refused on every /// other; a present `tool_calls` an array of normalized -/// `{id, name, arguments}` records; a `meta` of any JSON value, none when +/// `{id, name, arguments}` records, refused when non-empty on every role +/// but assistant; a `meta` of any JSON value, none when /// null, at most [`MAX_RECORD_META_BYTES`] as compact JSON. Fields beyond /// the five a record holds (`role`, `content`, `tool_call_id`, /// `tool_calls`, `meta`) are dropped. @@ -259,6 +259,11 @@ pub(crate) fn parse_record( return Err(format!("messages[{index}] tool_calls must be an array")); } }; + if role != RecordRole::Assistant && !tool_calls.is_empty() { + return Err(format!( + "messages[{index}] sets tool_calls but is not an assistant message" + )); + } let meta = match entry.get("meta") { None | Some(serde_json::Value::Null) => None, Some(meta) => { From d9a0130c3b5a8528ee042da34303295fd1a6943f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:09:13 -0800 Subject: [PATCH 21/36] Correct models and models loop docs to match the code The `models` docs claimed the five roles always arrive filled, and the loop docs still described a Lua shim loop that now runs in Rust. Each doc now states what the code does, and the raw model id rule keeps one home on `RawModelIds`. `UseOptions` and its `apply` become private, since only `models.rs` uses them. --- crates/runvil-internal/lua/src/models.rs | 60 +++++++------------ crates/runvil-internal/lua/src/models_loop.rs | 19 +++--- .../lua/src/models_loop/machine.rs | 2 +- crates/runvil-internal/lua/src/vm.rs | 8 +-- 4 files changed, 34 insertions(+), 55 deletions(-) diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index 791cce1c5..9f63b5ca2 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -1,13 +1,15 @@ //! The Lua `models` Engine global: `use` / `get` / `current`. //! -//! The five roles arrive filled, when the run is built, in the shared -//! [`ModelSet`], and the table selects among them by role name. `models.use` takes a role -//! name or a model handle and stores the binding it selects, with its -//! optional sampling options applied, as the section's current model. -//! `models.get` inspects a role without selecting it, and `models.current` -//! returns the handle for the current model. The suspending `models.infer`, -//! `models.loop`, and `models.compact` are coroutine shims added to this -//! table afterward. +//! The run builds the shared [`ModelSet`] once, holding all five roles or, +//! when the caller supplied no model, none, and the table selects among them +//! by role name; a role read from the empty set fails saying so. `models.use` +//! takes a role name or a model handle and stores the binding it selects, +//! with its optional sampling options applied, as the section's current +//! model. `models.get` inspects a role without selecting it, and +//! `models.current` returns the handle for the current model. The suspending +//! `models.infer`, `models.loop`, and `models.compact` are coroutine shims +//! added to this table afterward, because yield cannot cross the Rust +//! callback boundary. use std::num::NonZeroU32; use std::sync::Arc; use std::sync::Mutex; @@ -42,9 +44,8 @@ const RAW_ID_CONTEXT: NonZeroU32 = match NonZeroU32::new(8192) { None => unreachable!(), }; -/// Builds the raw-model-id fallback's binding: a `models.get` label outside -/// the five roles, resolved as a raw gateway catalog model id with no -/// invocation overrides and the fallback context window. +/// Builds the [`RawModelIds::Resolved`] binding for `alias`: no invocation +/// overrides and [`RAW_ID_CONTEXT`]. fn raw_gateway_binding(alias: &str) -> mlua::Result { let id = ModelId::gateway(alias).map_err(|error| { mlua::Error::external(format!("models.get model id {alias:?} is invalid: {error}")) @@ -110,14 +111,14 @@ fn role_binding(set: &ModelSet, role: ModelRole) -> mlua::Result { /// The sampling options a `models.use` call sets on its selection. An /// omitted field keeps the role's own value. #[derive(Debug, Clone, Copy, Default, PartialEq)] -pub(crate) struct UseOptions { +struct UseOptions { temperature: Option, max_tokens: Option, } impl UseOptions { /// Overrides the binding's invocation with the fields these options set. - pub(crate) fn apply(self, binding: ModelBinding) -> ModelBinding { + fn apply(self, binding: ModelBinding) -> ModelBinding { let mut invocation = binding.invocation().clone(); invocation.temperature = self.temperature.or(invocation.temperature); invocation.max_tokens = self.max_tokens.or(invocation.max_tokens); @@ -238,28 +239,11 @@ fn parse_use_options(options: Value, rest: &MultiValue) -> mlua::Result mlua::Result { - // The raw-model-id fallback: with raw ids resolved, a label - // outside the five roles resolves as a raw gateway catalog model - // id under the fallback context window. Gateway ids include `/`, - // `.`, and `:`, so the id's own validation is the only gate. + // Gateway ids include `/`, `.`, and `:`, so the id's own + // validation is the only gate. let binding = match (find_role(&label), raw_ids) { (Some(role), _) => role_binding(&frozen, role)?, (None, RawModelIds::Resolved) => raw_gateway_binding(&label)?, diff --git a/crates/runvil-internal/lua/src/models_loop.rs b/crates/runvil-internal/lua/src/models_loop.rs index 6d5b0585f..7a7866787 100644 --- a/crates/runvil-internal/lua/src/models_loop.rs +++ b/crates/runvil-internal/lua/src/models_loop.rs @@ -72,8 +72,8 @@ const COMPACT_RUNNING: &str = /// `max_tool_iterations` is the round cap, `compactors` the table that /// `compactors.fail` is read from at each call, `loop_running` the VM's /// flag that is set while one of its loops runs, and `hook` the VM's -/// cancellation hook, whose cancel flag the step reads where the shim -/// called `cancel_requested()`. +/// cancellation hook, whose cancel flag the step reads beside each raw +/// `pcall` outcome. /// /// # Errors /// Returns a `LuaRuntime` failure if the function cannot be created. @@ -241,8 +241,8 @@ fn compact( Ok(action) } -/// `loop_begin`'s answer to a refused argument: no step closure, then the -/// `"raise"` action with a new `LuaRuntime` failure. +/// The answer of `loop_begin` or `compact_begin` to a refused call: no step +/// closure, then the `"raise"` action with a new `LuaRuntime` failure. fn refuse(lua: &Lua, message: &str) -> mlua::Result { let mut action = act(lua, Then::RaiseNew(lua_runtime(message)))?; action.push_front(Value::Nil); @@ -306,10 +306,10 @@ fn answer( } /// Turns the machine's next step into the trampoline's tag and values: -/// each request table built as the shim builds it, a call's arguments -/// converted with `to_value`, the overflow reason as its `tag()`, a -/// normalized failure through `error_value::normalized`, and a new -/// failure as its error table, with author prose when the prompt's Lua +/// each request table in the shape its [`Then`] variant documents, a +/// call's arguments converted with `to_value`, the overflow reason as its +/// `tag()`, a normalized failure through `error_value::normalized`, and a +/// new failure as its error table, with author prose when the prompt's Lua /// caused it. fn act(lua: &Lua, then: Then) -> mlua::Result { let (tag, values) = match then { @@ -376,8 +376,7 @@ fn call_fields(lua: &Lua, call: &ToolCallEvent) -> mlua::Result<[(&'static str, ]) } -/// A request table as the shim built it: `op` and the given fields, each -/// nil field left unset. +/// A request table: `op` and the given fields, each nil field left unset. fn request(lua: &Lua, op: &str, fields: [(&str, Value); N]) -> mlua::Result { let table = lua.create_table()?; table.raw_set("op", op)?; diff --git a/crates/runvil-internal/lua/src/models_loop/machine.rs b/crates/runvil-internal/lua/src/models_loop/machine.rs index 5ad889336..b43d5529b 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine.rs @@ -55,7 +55,7 @@ pub(super) struct Machine { /// `None` when the call named no handle. handle: Option, /// The compactor argument, or `compactors.fail` as read when the - /// call began, which is not type-checked, as in the shim. + /// call began, which is not type-checked. compactor: V, /// The current batch's turn, sent back on every `tool_calls` and /// `tool_call` request. diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 73f6f90c3..060f5cb85 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -161,11 +161,9 @@ pub struct SectionVm { /// The VM's cancellation hook, shared with every block coroutine's hook /// (hooks are per-coroutine in PUC Lua). cancel_hook: CancelHook, - /// The raw-model-id fallback, resolved whenever the caller passes an - /// application-state snapshot (`RunConfig::with_ui`): then `models.get` - /// resolves a label outside the five roles as a raw gateway model id. Set - /// by [`allow_raw_model_ids`](SectionVm::allow_raw_model_ids) before - /// Engine injection; refused everywhere else. + /// The [`RawModelIds`] fallback `models.get` applies; set by + /// [`allow_raw_model_ids`](SectionVm::allow_raw_model_ids) before Engine + /// injection, refused everywhere else. raw_model_ids: RawModelIds, /// The section's one conversation, served as the `messages` global. messages: MessageList, From dbf81f1c6cb2f9c461bb57c59db296523ae9f4f3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:11:42 -0800 Subject: [PATCH 22/36] State which type-name rule each refusal uses The shim comments said their type names match the protocol parse, and `models::lua_type_name` said it matches Lua's `type()`, yet neither holds for `json.null`. Each comment and doc now states the rule its namer applies, and the models loop namer becomes `engine_type_name`, after the shim's `engine_type` whose names it copies. No message or test assertion changes. --- crates/runvil-internal/lua/src/__impl_coro.lua | 8 ++++---- crates/runvil-internal/lua/src/__impl_fanout.lua | 3 ++- crates/runvil-internal/lua/src/__impl_tasks.lua | 3 ++- crates/runvil-internal/lua/src/models.rs | 5 +++-- crates/runvil-internal/lua/src/models_loop.rs | 6 +++--- crates/runvil-internal/lua/src/models_loop/tests.rs | 4 ++-- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index ba37aef76..a57eda92d 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -45,10 +45,10 @@ local error = error -- names must not move when author code rebinds `math`. local math_type = math.type --- The Engine's name for a value's type, as the protocol parse reports it: --- Lua folds integers and floats into "number", while the Engine names an --- integer "integer" and a float "number", so a shim-raised argument error --- reads exactly as the parse-raised one for the same value. +-- The Engine's name for a value's type: Lua's `type()`, except that an +-- integer is "integer", as the protocol parse names it. The parse names a +-- light userdata such as `json.null` "lightuserdata", which `type()` cannot +-- tell from a full userdata, so a shim-raised error names it "userdata". local function engine_type(value) if math_type(value) == "integer" then return "integer" end return type(value) diff --git a/crates/runvil-internal/lua/src/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index 82e46faa1..0948588d7 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -9,7 +9,8 @@ -- `var` data table as a plain deep copy, `helpers` is the prelude's shared -- trio (`raise(kind, fields)` builds and raises the structured error -- table, `fail(result)` raises an envelope's failure value, and --- `engine_type(value)` names a value's type as the protocol parse would), +-- `engine_type(value)` names a value's type as Lua's `type()` does, with +-- an integer named "integer"), -- `collection_members(collection)` enumerates a collection as `(members)` -- or `(nil, message)` - the array part in order, then the hash part as -- `{ key, value }` pairs sorted by key - and `render_item(item)` renders a diff --git a/crates/runvil-internal/lua/src/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index 13aed9c68..80888354f 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -9,7 +9,8 @@ -- `var` data table as a plain deep copy, and `helpers` is the prelude's -- shared trio - `raise(kind, fields)` builds and raises the structured -- error table, `fail(result)` raises an envelope's failure value, and --- `engine_type(value)` names a value's type as the protocol parse would. +-- `engine_type(value)` names a value's type as Lua's `type()` does, with +-- an integer named "integer". -- -- A Task handle is a plain methodless table `{ task = id }`: every -- operation here is a namespace function that accepts the handle or the diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index 9f63b5ca2..cf893e063 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -64,8 +64,9 @@ pub(crate) fn selection_poisoned() -> mlua::Error { mlua::Error::external("model selection lock was poisoned") } -/// The Lua type name of `value`, as `type(value)` reports it: an integer is a -/// `number`. +/// The type name the `models.use` target and `set_selection` refusals give +/// `value`: mlua's type name, except that an integer is a `number`, so +/// `json.null` reads `lightuserdata`. pub(crate) fn lua_type_name(value: &Value) -> &'static str { match value { Value::Integer(_) | Value::Number(_) => "number", diff --git a/crates/runvil-internal/lua/src/models_loop.rs b/crates/runvil-internal/lua/src/models_loop.rs index 7a7866787..550d26c72 100644 --- a/crates/runvil-internal/lua/src/models_loop.rs +++ b/crates/runvil-internal/lua/src/models_loop.rs @@ -131,7 +131,7 @@ fn begin( other => { let message = format!( "compactor must be a function, got {}", - lua_type_name(&other) + engine_type_name(&other) ); return refuse(lua, &message); } @@ -212,7 +212,7 @@ fn compact( ("reason", &reason, "string"), ] { if value.type_name() != wanted { - let message = format!("{name} must be a {wanted}, got {}", lua_type_name(value)); + let message = format!("{name} must be a {wanted}, got {}", engine_type_name(value)); return refuse(lua, &message); } } @@ -436,7 +436,7 @@ fn malformed(what: &'static str) -> mlua::Error { /// the shim's `engine_type` names it. mlua's `type_name` differs for a /// light userdata (`lightuserdata`) and an mlua error value (`error`), /// both of which Lua names `userdata`. -fn lua_type_name(value: &Value) -> &'static str { +fn engine_type_name(value: &Value) -> &'static str { match value { Value::LightUserData(_) | Value::Error(_) => "userdata", other => other.type_name(), diff --git a/crates/runvil-internal/lua/src/models_loop/tests.rs b/crates/runvil-internal/lua/src/models_loop/tests.rs index bc23c8097..7bd44a57e 100644 --- a/crates/runvil-internal/lua/src/models_loop/tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/tests.rs @@ -13,7 +13,7 @@ use runvil_types::tools::ToolId; use serde_json::json; use super::machine::{Input, Phase, Then}; -use super::{LOOP_RUNNING, act, envelope_failure, loop_begin, lua_type_name, read_input}; +use super::{LOOP_RUNNING, act, engine_type_name, envelope_failure, loop_begin, read_input}; use crate::compactors::OverflowReason; use crate::error_value::{ErrorKind, error_kind, error_table, raised_from}; use crate::hardening::CancelHook; @@ -74,7 +74,7 @@ fn lua_type_name_is_luas_type_with_an_integer_named_integer() { Value::Integer(_) => "integer".to_owned(), _ => lua_type.call::(value.clone()).unwrap(), }; - assert_eq!(lua_type_name(&value), expected, "{value:?}"); + assert_eq!(engine_type_name(&value), expected, "{value:?}"); } } From 121a7b34228720856e2f062d25ccdfbf302f18cc Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:12:50 -0800 Subject: [PATCH 23/36] Rename private fields that collide with nearby names `Encoder::keys` held the path to the current value beside parameters named `keys` that hold a table's own keys, and `ListState::last` held the last send beside `last` range bounds. The fields become `path` and `last_send`, and the local in `Encoder::error` becomes `shown` so it does not shadow the new field. No behavior changes. --- crates/runvil-internal/lua/src/json/encode.rs | 22 +++++++++---------- crates/runvil-internal/lua/src/messages.rs | 9 +++++--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index 457f34856..3472f472c 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -161,7 +161,7 @@ struct Encoder { prefix: &'static str, debug: bool, marker: Table, - keys: Vec, + path: Vec, tables: Vec<*const c_void>, budget: WalkBudget, held: usize, @@ -173,7 +173,7 @@ impl Encoder { prefix, debug, marker: lua.array_metatable(), - keys: Vec::new(), + path: Vec::new(), tables: Vec::new(), budget: WalkBudget::for_vm(lua), held: 0, @@ -204,11 +204,11 @@ impl Encoder { /// The refusal of the value at the current path, which `failure` says /// why. fn error(&self, failure: &str) -> mlua::Error { - let path = self - .keys + let shown = self + .path .iter() - .fold("value".to_owned(), |path, key| key_path(&path, key)); - author_refusal(format!("{}: {path} {failure}", self.prefix)) + .fold("value".to_owned(), |shown, key| key_path(&shown, key)); + author_refusal(format!("{}: {shown} {failure}", self.prefix)) } /// `placeholder` in debug mode, or the error for `failure`. @@ -309,9 +309,9 @@ impl Encoder { let mut items = Vec::with_capacity(keys.len()); for key in keys { let value: Value = data.raw_get(key.clone())?; - self.keys.push(key); + self.path.push(key); let item = self.value(&value, level + 1); - self.keys.pop(); + self.path.pop(); items.push(item?); } Ok(items) @@ -330,7 +330,7 @@ impl Encoder { Ok(text) => names.push(text.to_owned()), Err(_) if self.debug => names.push(name.to_string_lossy()), Err(_) => { - self.keys.push(key.clone()); + self.path.push(key.clone()); return Err(self.error(NOT_UTF8)); } } @@ -338,9 +338,9 @@ impl Encoder { let mut object = Map::new(); for (name, key) in names.into_iter().zip(keys) { let value: Value = data.raw_get(key.clone())?; - self.keys.push(key); + self.path.push(key); let encoded = self.value(&value, level + 1); - self.keys.pop(); + self.path.pop(); object.insert(name, encoded?); } Ok(Json::Object(object)) diff --git a/crates/runvil-internal/lua/src/messages.rs b/crates/runvil-internal/lua/src/messages.rs index 31c279528..aa295aa36 100644 --- a/crates/runvil-internal/lua/src/messages.rs +++ b/crates/runvil-internal/lua/src/messages.rs @@ -88,7 +88,7 @@ struct ListState { /// The records, in order, shared with the views read from them. records: Vec>, /// The last issued send: its round, model name, and wire request. - last: Option<(RoundId, String, Vec)>, + last_send: Option<(RoundId, String, Vec)>, /// Attached at the first successful build and never detached. rendered: Option>, /// Set while a compactor runs over the list. @@ -169,7 +169,7 @@ impl MessageList { #[must_use] pub fn commit(&self, round: RoundId, model: &str, sent: &[Message]) -> Option<(RoundId, u64)> { let latest = (round, model.to_owned(), sent.to_vec()); - let previous = self.state().last.replace(latest); + let previous = self.state().last_send.replace(latest); previous.map(|(after, _, earlier)| { let keep = earlier .iter() @@ -183,7 +183,10 @@ impl MessageList { /// The name of the model the last send went to; none before the first. #[must_use] pub fn last_model(&self) -> Option { - self.state().last.as_ref().map(|(_, name, _)| name.clone()) + self.state() + .last_send + .as_ref() + .map(|(_, name, _)| name.clone()) } /// The attached frozen sets, if they are attached. From 4951eda8d6f2bcd53a0da86b2650617a80b4b24c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:10:06 -0800 Subject: [PATCH 24/36] Move `DiscoveryTool` into `bindings` and narrow offers `bindings` imported `scope` only for `DiscoveryTool`, so the type and its reserved-Pack test now live in `bindings`, and the root re-export lists it there. `OfferTarget` and `Offer` become `pub(crate)` and leave the root re-export, since no other crate names them. The `scope`, `bindings`, `ToolBinding`, `from_info`, and `static_set` docs now state what each holds, the model-only flag included. --- .../runvil-internal/lua/src/bindings-tests.rs | 17 +++++- crates/runvil-internal/lua/src/bindings.rs | 59 +++++++++++++++--- crates/runvil-internal/lua/src/lib.rs | 9 ++- crates/runvil-internal/lua/src/scope-tests.rs | 21 +------ crates/runvil-internal/lua/src/scope.rs | 60 +++---------------- crates/runvil-internal/lua/src/test_vm.rs | 4 +- crates/runvil-internal/lua/src/tools.rs | 4 +- 7 files changed, 84 insertions(+), 90 deletions(-) diff --git a/crates/runvil-internal/lua/src/bindings-tests.rs b/crates/runvil-internal/lua/src/bindings-tests.rs index d820d45ca..5ac26b9d6 100644 --- a/crates/runvil-internal/lua/src/bindings-tests.rs +++ b/crates/runvil-internal/lua/src/bindings-tests.rs @@ -3,7 +3,8 @@ //! selects the binding's output kind. A catalog tool's wire name is its //! own when set and its default otherwise, and the tool set reads the run //! catalog and derives each tool's vendor from its Pack, `runvil` for the -//! Engine's discovery tools. +//! Engine's discovery tools. The discovery tools' ids stay under a +//! reserved Pack id, so no vendor Pack can take the discovery namespace. use std::collections::BTreeMap; @@ -11,7 +12,7 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::json; -use super::{RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; +use super::{DiscoveryTool, RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; /// A tool info for `tools/fetch`. fn tool_info(structured: bool) -> ToolInfo { @@ -131,3 +132,15 @@ fn the_tool_set_reads_the_catalog_and_takes_each_tools_vendor_from_its_pack() { ); } } + +#[test] +fn every_discovery_tool_id_sits_under_a_reserved_pack() { + for tool in DiscoveryTool::ALL { + let id = ToolId::parse(tool.id()).expect("a discovery tool id is a valid tool id"); + assert!( + id.pack().is_reserved(), + "{} is not under a reserved Pack id", + tool.id() + ); + } +} diff --git a/crates/runvil-internal/lua/src/bindings.rs b/crates/runvil-internal/lua/src/bindings.rs index 2507d7107..85ed25835 100644 --- a/crates/runvil-internal/lua/src/bindings.rs +++ b/crates/runvil-internal/lua/src/bindings.rs @@ -1,5 +1,6 @@ -//! Tool bindings, the per-binding output kind, the run's shared tool set, -//! and how bound tools reach Lua. +//! Tool bindings, the per-binding output kind, the run's roster entries +//! and shared tool set, the Engine's discovery tools, and the wire name a +//! model calls a tool by. use std::borrow::Cow; use std::collections::BTreeMap; @@ -8,8 +9,6 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::Value as Json; -use crate::scope::DiscoveryTool; - /// How a bound tool's output resumes into Lua at the `tools.call` boundary. /// /// Declared on the binding (from the tool info's `structured_output`), not @@ -28,8 +27,8 @@ pub enum ToolOutputKind { /// One tool bound under the name its caller reaches it by, holding the /// tool's data - its schema, description, output kind, whether it lists -/// tools, and whether a stop leaves its calls running - and never its -/// implementation. +/// tools, whether only a model may call it, and whether a stop leaves its +/// calls running - and never its implementation. /// /// Run-time execution (schema preparation, script dispatch) reads the /// binding alone; a call is issued as an effect naming the identity, and @@ -62,8 +61,8 @@ pub struct ToolBinding { impl ToolBinding { /// Binds `alias` to the tool `info` describes: the description is the /// tool's own, the output kind follows the tool info's - /// structured-output flag, and the listing and stop flags are the tool - /// info's. + /// structured-output flag, and the listing, model-only, and stop flags + /// are the tool info's. #[must_use] pub fn from_info(alias: &str, info: &ToolInfo) -> Self { Self { @@ -241,6 +240,50 @@ impl ToolSet { } } +/// One of the Engine's two discovery tools, which join the end of a +/// section entry's Static Set when its Dynamic Set holds a tool: the model +/// finds Dynamic Set tools with the first and calls them with the second. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiscoveryTool { + /// `dynamic/search`, which the model calls as `GetDynamicTools`. + Search, + /// `dynamic/call`, which the model calls as `CallDynamicTool`. + Call, +} + +impl DiscoveryTool { + /// Both discovery tools, in the order they join the Static Set. + pub const ALL: [DiscoveryTool; 2] = [DiscoveryTool::Search, DiscoveryTool::Call]; + + /// The vendor both discovery tools report. + pub const VENDOR: &'static str = "runvil"; + + /// The tool's id, under the reserved Pack id `dynamic`. + #[must_use] + pub fn id(self) -> &'static str { + match self { + DiscoveryTool::Search => "dynamic/search", + DiscoveryTool::Call => "dynamic/call", + } + } + + /// The wire name the model calls the tool by. + #[must_use] + pub fn wire_name(self) -> &'static str { + match self { + DiscoveryTool::Search => "GetDynamicTools", + DiscoveryTool::Call => "CallDynamicTool", + } + } + + /// The discovery tool whose id is `id`, if it is one. + #[must_use] + pub fn of(id: &ToolId) -> Option { + let id = id.to_string(); + DiscoveryTool::ALL.into_iter().find(|tool| tool.id() == id) + } +} + #[cfg(test)] #[path = "bindings-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index d9f3de94d..dc890ed5e 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -122,7 +122,9 @@ mod tests; // The executor-facing surface: every item `runvil-engine` names crosses // here. The facade re-exports only `VfsOp` and `VfsOutcome`. pub use crate::argv::Argv; -pub use crate::bindings::{RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; +pub use crate::bindings::{ + DiscoveryTool, RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of, +}; pub use crate::collection::render_item; pub use crate::compactors::{ OverflowReason, UsageAnchor, exhaustion_detail, output_reserve, precheck, @@ -150,10 +152,7 @@ pub use crate::protocol::{ MessageRecord, ModelCall, NonEmpty, RecordRole, Request, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; -pub use crate::scope::{ - DiscoveryTool, Offer, OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, - ToolCallCounts, -}; +pub use crate::scope::{OfferMode, OfferedTool, Offers, RenderedTools, ToolCallCounts}; pub use crate::section::{Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionText}; pub use crate::vm::{ AdhocTools, CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_catalog_packs, diff --git a/crates/runvil-internal/lua/src/scope-tests.rs b/crates/runvil-internal/lua/src/scope-tests.rs index 6046d563b..297b65c08 100644 --- a/crates/runvil-internal/lua/src/scope-tests.rs +++ b/crates/runvil-internal/lua/src/scope-tests.rs @@ -1,10 +1,7 @@ -//! Tests for the scope types: the discovery tools' ids stay under a reserved -//! Pack id, so no vendor Pack can take the discovery namespace, and a count -//! increment seeds the id it was handed. +//! Tests for the scope types: a count increment seeds the id it was +//! handed. -use runvil_types::tools::ToolId; - -use super::{DiscoveryTool, ToolCallCounts}; +use super::ToolCallCounts; #[test] fn increment_seeds_a_missing_id_and_counts_it() { @@ -15,15 +12,3 @@ fn increment_seeds_a_missing_id_and_counts_it() { let snapshot = counts.snapshot().expect("the counts mutex is not poisoned"); assert_eq!(snapshot.get("std/read"), Some(&1)); } - -#[test] -fn every_discovery_tool_id_sits_under_a_reserved_pack() { - for tool in DiscoveryTool::ALL { - let id = ToolId::parse(tool.id()).expect("a discovery tool id is a valid tool id"); - assert!( - id.pack().is_reserved(), - "{} is not under a reserved Pack id", - tool.id() - ); - } -} diff --git a/crates/runvil-internal/lua/src/scope.rs b/crates/runvil-internal/lua/src/scope.rs index 91d3eb758..b6acad2e2 100644 --- a/crates/runvil-internal/lua/src/scope.rs +++ b/crates/runvil-internal/lua/src/scope.rs @@ -1,6 +1,5 @@ -//! Per-VM tool state: call counts, the offers a section made, the frozen -//! sets its first loop round built from them, and the Engine's two -//! discovery tools, which join the Static Set beside a Dynamic Set. +//! Per-VM tool state: call counts, the offers a section made, and the +//! frozen sets its first loop round built from them. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; @@ -72,7 +71,7 @@ impl ToolCallCounts { /// What one offer names: a tool by id, or a Pack, whose tools the render /// expands. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum OfferTarget { +pub(crate) enum OfferTarget { /// One tool, by id. Tool(ToolId), /// Every tool of a Pack, read when the section renders. @@ -100,11 +99,11 @@ impl std::fmt::Display for OfferMode { /// One remembered offer: what it names and which set it feeds. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Offer { +pub(crate) struct Offer { /// What the offer names. - pub target: OfferTarget, + pub(crate) target: OfferTarget, /// Which set the offer feeds. - pub mode: OfferMode, + pub(crate) mode: OfferMode, } /// One tool a section's offers name, as the merge takes it: a catalog @@ -163,8 +162,7 @@ impl Offers { /// merge order, and no wire name appears twice across them. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RenderedTools { - /// The Static Set: the tools every round of the section advertises, - /// in schema order. + /// The Static Set: the tools every round of the section advertises. pub static_set: Vec, /// The Dynamic Set: the tools the section offered dynamically, which /// no round advertises. @@ -189,50 +187,6 @@ impl RenderedTools { } } -/// One of the Engine's two discovery tools, which join the end of a -/// section entry's Static Set when its Dynamic Set holds a tool: the model -/// finds Dynamic Set tools with the first and calls them with the second. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiscoveryTool { - /// `dynamic/search`, which the model calls as `GetDynamicTools`. - Search, - /// `dynamic/call`, which the model calls as `CallDynamicTool`. - Call, -} - -impl DiscoveryTool { - /// Both discovery tools, in the order they join the Static Set. - pub const ALL: [DiscoveryTool; 2] = [DiscoveryTool::Search, DiscoveryTool::Call]; - - /// The vendor both discovery tools report. - pub const VENDOR: &'static str = "runvil"; - - /// The tool's id, under the reserved Pack id `dynamic`. - #[must_use] - pub fn id(self) -> &'static str { - match self { - DiscoveryTool::Search => "dynamic/search", - DiscoveryTool::Call => "dynamic/call", - } - } - - /// The wire name the model calls the tool by. - #[must_use] - pub fn wire_name(self) -> &'static str { - match self { - DiscoveryTool::Search => "GetDynamicTools", - DiscoveryTool::Call => "CallDynamicTool", - } - } - - /// The discovery tool whose id is `id`, if it is one. - #[must_use] - pub fn of(id: &ToolId) -> Option { - let id = id.to_string(); - DiscoveryTool::ALL.into_iter().find(|tool| tool.id() == id) - } -} - #[cfg(test)] #[path = "scope-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index fec9d7f10..e65b5ba12 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -184,8 +184,8 @@ pub(crate) fn offers(vm: &SectionVm) -> Vec { crate::OfferMode::Dynamic => "dynamic", }; match &offer.target { - crate::OfferTarget::Tool(id) => format!("{mode}:{id}"), - crate::OfferTarget::Pack(pack) => format!("{mode}:{pack}"), + crate::scope::OfferTarget::Tool(id) => format!("{mode}:{id}"), + crate::scope::OfferTarget::Pack(pack) => format!("{mode}:{pack}"), } }) .collect() diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index c67825846..dbfa6b4dd 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -26,13 +26,13 @@ use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::{Value as Json, json}; use crate::alias::Alias; -use crate::bindings::{ToolBinding, ToolSet}; +use crate::bindings::{DiscoveryTool, ToolBinding, ToolSet}; use crate::error::{self, Result}; use crate::json::to_json; use crate::messages::MessageList; use crate::packs::{ADHOC_PACK, install_pack_list, install_pack_objects}; use crate::proxy::key_text; -use crate::scope::{DiscoveryTool, OfferMode, Offers}; +use crate::scope::{OfferMode, Offers}; use crate::vm::AdhocTools; mod decode; From 05ebcad2005cbc7a6fe9da85075783cc1579bd6c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:13:10 -0800 Subject: [PATCH 25/36] Break three module cycles through `vm` `walk_budget`, `prose`, and `protocol::parse::tools` each imported one item from `vm`, which imports each of them. `DEFAULT_LUA_MEMORY_BYTES` now lives in `walk_budget` beside `HeapCeiling`, `SYS_LIVE_POISONED` in `sys`, and `scalar_return` with `value_to_string` in `section` beside `LuaBlockResult`. The two VM default docs now say each holds only until `apply_lua_limits` sets the run limits, in place of a claim that they match `RunLimits`. --- crates/runvil-internal/lua/src/prose.rs | 2 +- .../lua/src/protocol/parse/tools.rs | 2 +- crates/runvil-internal/lua/src/section.rs | 33 ++++++++++++++++++- crates/runvil-internal/lua/src/sys.rs | 3 ++ .../runvil-internal/lua/src/test_support.rs | 4 +-- crates/runvil-internal/lua/src/test_vm.rs | 2 +- crates/runvil-internal/lua/src/tests.rs | 3 +- crates/runvil-internal/lua/src/vm.rs | 16 +++------ crates/runvil-internal/lua/src/vm/install.rs | 4 +-- crates/runvil-internal/lua/src/vm/run.rs | 33 ++----------------- crates/runvil-internal/lua/src/walk_budget.rs | 6 ++-- 11 files changed, 55 insertions(+), 53 deletions(-) diff --git a/crates/runvil-internal/lua/src/prose.rs b/crates/runvil-internal/lua/src/prose.rs index ea0062b68..241291602 100644 --- a/crates/runvil-internal/lua/src/prose.rs +++ b/crates/runvil-internal/lua/src/prose.rs @@ -27,8 +27,8 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; use crate::error::{self, Result, lua_runtime}; +use crate::sys::SYS_LIVE_POISONED; use crate::var::var_to_json; -use crate::vm::SYS_LIVE_POISONED; /// The refusal an assignment of the `prose` global raises. pub(crate) const ASSIGNMENT_REFUSAL: &str = diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index 8844907a9..22eb0e8d2 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -4,8 +4,8 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; use crate::protocol::{AdhocToolOutcome, ModelCall, Request, ToolCallRecord}; +use crate::section::scalar_return; use crate::tools::{model_tool_name, tool_id}; -use crate::vm::scalar_return; use super::{FieldFailure, call_error, shim_optional_string, shim_optional_u32}; diff --git a/crates/runvil-internal/lua/src/section.rs b/crates/runvil-internal/lua/src/section.rs index 504b82f8a..5597622d1 100644 --- a/crates/runvil-internal/lua/src/section.rs +++ b/crates/runvil-internal/lua/src/section.rs @@ -4,7 +4,9 @@ use std::num::NonZeroU32; -use mlua::Value; +use mlua::{MultiValue, Value}; + +use crate::error::{Result, lua_runtime}; /// Outcome of a Lua block that may invoke `jump`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -17,6 +19,35 @@ pub enum LuaBlockResult { Jump(String, Option), } +/// Renders a returned Lua scalar as the section's result string. Tables and other +/// non-scalar returns are errors. +fn value_to_string(value: &Value) -> Result { + match value { + Value::String(s) => Ok(s.to_string_lossy()), + Value::Integer(i) => Ok(i.to_string()), + Value::Number(n) => Ok(n.to_string()), + Value::Boolean(b) => Ok(b.to_string()), + other => Err(lua_runtime(format!( + "cannot return a {} as a result", + other.type_name() + ))), + } +} + +/// Converts only the first returned Lua value into an optional scalar string. +/// +/// Later values are ignored. A missing or nil first value returns `None`. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the first value is not a supported +/// scalar. +pub(crate) fn scalar_return(returned: MultiValue) -> Result> { + match returned.into_iter().next() { + None | Some(Value::Nil) => Ok(None), + Some(value) => value_to_string(&value).map(Some), + } +} + /// Resolves a `call` / `jump` target from a heading string. /// /// # Errors diff --git a/crates/runvil-internal/lua/src/sys.rs b/crates/runvil-internal/lua/src/sys.rs index 6a67a3ba1..431e9f8ad 100644 --- a/crates/runvil-internal/lua/src/sys.rs +++ b/crates/runvil-internal/lua/src/sys.rs @@ -6,6 +6,9 @@ use serde_json::Value as Json; use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_proxy}; +/// The failure of a poisoned live `sys` slot. +pub(crate) const SYS_LIVE_POISONED: &str = "sys live slot was poisoned"; + /// Builds a sealed Lua `sys` table from runtime metadata. /// /// A [`read_only_proxy`] over the JSON object: a read of an absent field diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index e8b3518f0..1e9324956 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -20,8 +20,8 @@ use crate::error::{self, Error, Result, UNNAMED_FILE}; use crate::program::LuaProgram; use crate::protocol::{Answer, render_envelope}; use crate::scope::Offers; -use crate::section::LuaBlockResult; -use crate::vm::{InjectFailure, SectionVm, Uninjected, scalar_return}; +use crate::section::{LuaBlockResult, scalar_return}; +use crate::vm::{InjectFailure, SectionVm, Uninjected}; /// A per-thread tally of live section VMs. /// diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index e65b5ba12..697b3468a 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -19,8 +19,8 @@ use runvil_vfs::{Access, Origin, VfsRef}; use serde_json::{Value as Json, json}; use crate::error::{self, Result}; +use crate::section::scalar_return; use crate::tests::recording::null_emitter; -use crate::vm::scalar_return; use crate::{Argv, OfferedTool, RosterEntry, SectionText, SectionVm, ToolSet}; /// The one guard-nonce seed of every test VM. diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 4ca634687..958779539 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -29,7 +29,8 @@ use crate::test_vm::{ BuiltVm, LuaOutcome, TEST_SECTION, TestVm, fresh_access, run_chunk, test_nonce, }; use crate::var::{guarded_var, var_snapshot_table, var_to_json}; -use crate::vm::{DEFAULT_LUA_LOG_EVENTS, DEFAULT_LUA_MEMORY_BYTES}; +use crate::vm::DEFAULT_LUA_LOG_EVENTS; +use crate::walk_budget::DEFAULT_LUA_MEMORY_BYTES; #[path = "tests-recording.rs"] pub(crate) mod recording; diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 060f5cb85..6d29a88b6 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -41,23 +41,17 @@ use crate::messages::MessageList; use crate::models::RawModelIds; use crate::program::LuaProgram; use crate::scope::Offers; -use crate::walk_budget::HeapCeiling; +use crate::walk_budget::{DEFAULT_LUA_MEMORY_BYTES, HeapCeiling}; pub use adhoc::AdhocTools; pub use inject::InjectFailure; pub use run::CoroStep; -pub(crate) use run::scalar_return; pub use state::{current_catalog_packs, current_dynamic_set, current_static_set}; -/// Default per-VM Lua heap ceiling, matching the executor's `RunLimits`. -pub(crate) const DEFAULT_LUA_MEMORY_BYTES: usize = 64 * 1024 * 1024; - -/// Default per-VM `log()` event budget, matching the executor's `RunLimits`. +/// The `log()` event budget a VM holds from construction until +/// [`apply_lua_limits`](SectionVm::apply_lua_limits) sets the run's. pub(crate) const DEFAULT_LUA_LOG_EVENTS: u32 = 1024; -/// The failure of a poisoned live `sys` slot. -pub(crate) const SYS_LIVE_POISONED: &str = "sys live slot was poisoned"; - /// Returns the cumulative `log()` byte ceiling derived from the event budget. /// /// Bounds total log volume (bytes) even when each event is under the per-event @@ -201,8 +195,8 @@ impl SectionVm { #[cfg(any(test, feature = "test-support"))] crate::test_support::count_live_vm(&lua); // Bound the VM heap by default; `apply_lua_limits` may tighten or relax - // it to the caller's `RunLimits`. A default keeps every VM - // bounded even when the run installs no explicit limits. + // it to the caller's `RunLimits`. The default covers construction + // and any VM that never applies limits. lua.set_memory_limit(DEFAULT_LUA_MEMORY_BYTES) .map_err(error::lua)?; lua.set_app_data(HeapCeiling(DEFAULT_LUA_MEMORY_BYTES)); diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index e25fbcd1e..1b45d3767 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -10,7 +10,7 @@ use mlua::Value; use runvil_types::emitter::Emitter; use serde_json::Value as Json; -use super::{SYS_LIVE_POISONED, SectionVm, pack_sequence}; +use super::{SectionVm, pack_sequence}; use crate::coro::install_shims; use crate::engine_globals::{install_log, install_store_table}; #[cfg(test)] @@ -18,7 +18,7 @@ use crate::error::Error; use crate::error::{self, Result}; use crate::prose::ProseState; use crate::section::{Library, LibraryKey, SectionText, resolve_section_target}; -use crate::sys::seal_sys; +use crate::sys::{SYS_LIVE_POISONED, seal_sys}; #[cfg(test)] use crate::var::var_to_json; diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index b571663ad..965a43603 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -7,11 +7,11 @@ use runvil_types::error::{ErrorCode, place}; use super::{Jump, SectionVm}; use crate::coro::{StashedFailure, block_guard, take_failure}; -use crate::error::{self, Error, Result, interrupted, lua_runtime, prompt_file, set_call_line}; +use crate::error::{self, Error, Result, interrupted, prompt_file, set_call_line}; use crate::error_value::{ErrorKind, error_kind}; use crate::program::LuaProgram; use crate::protocol::{Answer, Request, Retained, YieldParse, render_envelope}; -use crate::section::LuaBlockResult; +use crate::section::{LuaBlockResult, scalar_return}; impl SectionVm { /// Takes any recorded jump: its target and the prompt line of the @@ -285,35 +285,6 @@ fn placed_like(error: Error, mapped: &Error) -> Error { } } -/// Renders a returned Lua scalar as the section's result string. Tables and other -/// non-scalar returns are errors. -fn value_to_string(value: &Value) -> Result { - match value { - Value::String(s) => Ok(s.to_string_lossy()), - Value::Integer(i) => Ok(i.to_string()), - Value::Number(n) => Ok(n.to_string()), - Value::Boolean(b) => Ok(b.to_string()), - other => Err(lua_runtime(format!( - "cannot return a {} as a result", - other.type_name() - ))), - } -} - -/// Converts only the first returned Lua value into an optional scalar string. -/// -/// Later values are ignored. A missing or nil first value returns `None`. -/// -/// # Errors -/// Returns a `LuaRuntime` failure when the first value is not a supported -/// scalar. -pub(crate) fn scalar_return(returned: MultiValue) -> Result> { - match returned.into_iter().next() { - None | Some(Value::Nil) => Ok(None), - Some(value) => value_to_string(&value).map(Some), - } -} - /// One step of a Lua block running on a coroutine. /// /// Produced by [`SectionVm::start_block_coro`] and diff --git a/crates/runvil-internal/lua/src/walk_budget.rs b/crates/runvil-internal/lua/src/walk_budget.rs index e1db75068..02c789bf6 100644 --- a/crates/runvil-internal/lua/src/walk_budget.rs +++ b/crates/runvil-internal/lua/src/walk_budget.rs @@ -10,8 +10,6 @@ use std::ffi::c_void; use mlua::{Lua, Value}; -use crate::vm::DEFAULT_LUA_MEMORY_BYTES; - /// The smallest cost of one value in the Lua heap, rounded down: an array /// slot takes at least about 9 bytes, so an unshared value that fits the /// heap has at most `ceiling / 9` values, and `ceiling / 8` leaves room @@ -39,6 +37,10 @@ pub(crate) struct TooLarge; /// for the memory limit. pub(crate) struct HeapCeiling(pub usize); +/// The Lua heap ceiling a VM holds from construction until +/// [`apply_lua_limits`](crate::SectionVm::apply_lua_limits) sets the run's. +pub(crate) const DEFAULT_LUA_MEMORY_BYTES: usize = 64 * 1024 * 1024; + /// What one walk has left to spend: values visited and bytes of text read. pub(crate) struct WalkBudget { nodes: usize, From 6afba74318236c63a4c9da4bae80f173b2482d1e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:14:47 -0800 Subject: [PATCH 26/36] Correct VM docs and drop `prose::install` Model handles have no methods, so `loop_running` and the Static Set definition in `AGENTS.md` now name only a `models.loop` call. The `re_seal_sys`, `vm::state`, suspending-call, and injection docs now say when the reseal runs, which installs follow injection, and that `store` calls suspend too, and drop claims about store callbacks and the emitter. `prose::install` was `swap` with its result dropped, so `install_lazy_prose` calls `swap` directly. --- AGENTS.md | 2 +- crates/runvil-internal/lua/src/prose.rs | 21 ++------------------ crates/runvil-internal/lua/src/vm.rs | 2 +- crates/runvil-internal/lua/src/vm/inject.rs | 6 +----- crates/runvil-internal/lua/src/vm/install.rs | 15 ++++++++------ crates/runvil-internal/lua/src/vm/state.rs | 7 ++++--- 6 files changed, 18 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc52ba28b..ce5072e50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - **Host**: an application that runs prompts through the Harness, such as Workshop or Papergate. The Host makes every policy decision. It runs prompts only through the Harness; it may also use the Engine's parser and types to read prompts and show events. - **Pack**: code which installs an optional native prelude, an optional Lua prelude, and zero or more tools. - **Plugin**: an entry point which installs zero or more Packs. -- **Static Set**: the tools a section entry renders into the model's tool list at its first loop round, the first round a `models.loop` or a model handle's `loop` sends. It is frozen after that. +- **Static Set**: the tools a section entry renders into the model's tool list at its first loop round, the first round a `models.loop` call sends. It is frozen after that. - **Dynamic Set**: the tools the model finds and calls through the Engine's two discovery tools. ### Using the terms diff --git a/crates/runvil-internal/lua/src/prose.rs b/crates/runvil-internal/lua/src/prose.rs index 241291602..e26e15db2 100644 --- a/crates/runvil-internal/lua/src/prose.rs +++ b/crates/runvil-internal/lua/src/prose.rs @@ -60,25 +60,8 @@ impl fmt::Debug for ProseState<'_> { } } -/// Installs `prose` as a fresh read-only lazy global on this VM. -/// -/// `render` runs at most once, on the first runtime read of `prose`, with -/// the section state snapshot; its result is memoized for later reads. -/// `sys_live` is the VM's live `sys` mirror, so a `sys` enrichment between -/// install and first read is visible to the render. -/// -/// # Errors -/// Returns a `LuaRuntime` failure if the read cannot be built or the guard cannot -/// record it. -pub(crate) fn install(lua: &Lua, sys_live: &Arc>, render: F) -> Result<()> -where - F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, -{ - swap(lua, sys_live, render).map(drop) -} - -/// Installs a fresh lazy `prose` over `render`, as [`install`] does, and -/// returns the read it replaced for [`restore`]. +/// Installs a fresh lazy `prose` over `render` and returns the read it +/// replaced for [`restore`]. /// /// The replaced read is the caller's own, memo included, so restoring it /// hands back text the caller already read without rendering again. diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 6d29a88b6..a922b25c5 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -131,7 +131,7 @@ pub struct SectionVm { /// How many adhoc tool handlers are running on this VM; `jump` refuses /// while it is above zero. adhoc_handler_depth: Arc, - /// Set while a `models.loop` or `handle:loop` runs on this VM. + /// Set while a `models.loop` call runs on this VM. loop_running: Arc, /// How many libraries `require` is loading on this VM; `jump` refuses /// while it is above zero. diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index 92d4a34d8..b7dee8d47 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -48,11 +48,7 @@ impl SectionVm { /// Installs Engine values while seeding `var` from an earlier VM, and /// gives back the injected VM. /// - /// Injection consumes the uninjected VM, so it runs once. The store - /// callbacks own a clone of the run-scoped store. `log` and `store` are - /// installed once for the section's whole lifecycle by - /// [`install_engine_globals`](SectionVm::install_engine_globals), which - /// captures a clone of the emitter rather than a per-chunk borrow. + /// Injection consumes the uninjected VM, so it runs once. /// /// The `var` global is a guarded proxy (see `guarded_var`): writes are /// validated for JSON-representability at the assigning line, and the diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 1b45d3767..fe3f87398 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -116,9 +116,11 @@ impl SectionVm { /// Installs the control surface: `jump` and the `prompt` reads as Rust /// callbacks (none suspends). /// - /// The suspending calls (`models.infer`, `call`, `tasks.fanout`, - /// `tools.call`, `tasks.*`) are the yield shims installed by - /// [`install_coro_shims`](Self::install_coro_shims). + /// The suspending calls are the yield shims + /// [`install_coro_shims`](Self::install_coro_shims) installs, and the + /// `store` calls, which suspend once + /// [`install_store_shims`](crate::install_store_shims) runs after the + /// Pack preludes. /// /// # Errors /// Returns a `LuaRuntime` failure if any global cannot be installed. @@ -274,8 +276,9 @@ impl SectionVm { globals.raw_set("prompt", prompt).map_err(error::lua) } - /// Replaces the sealed Lua `sys` global after scope close, to expose - /// `sys.model` once the section's model binding is fixed. + /// Replaces the sealed Lua `sys` global at the section entry's first + /// tool call, to expose `sys.model` once the section's current model is + /// fixed. /// /// # Errors /// Returns an `Internal` failure if the live slot is poisoned, or the @@ -309,6 +312,6 @@ impl SectionVm { where F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { - crate::prose::install(&self.lua, &self.state.sys_live, render) + crate::prose::swap(&self.lua, &self.state.sys_live, render).map(drop) } } diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 7100ff9a7..6e662b96b 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -1,6 +1,7 @@ -//! Section VM state reads: `var`, bare globals, `argv`, the tool call -//! counts, the frozen sets' entries, the section's offered tools and the -//! catalog Packs they come from, and its current model. +//! Section VM state reads, and the two installs that follow injection: +//! `var`, `argv`, the frozen sets' entries, the section's offered tools +//! and the catalog Packs they come from, and its current model; +//! `set_global_json` for fanout's `item`, and `tools.stats`. use std::sync::{Arc, Mutex}; From 83bbb8e557988b776a70b23c0967e8c50e283d7f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:08:27 -0800 Subject: [PATCH 27/36] Drop the alias module and keep the offer refusal private The offer verbs are the only callers of `refuse_after_render` and `TOOL_LIST_FIXED`, so both move into `tools/offer.rs` as private items, and `object_id` loses its `pub(super)`. `tools.adhoc` checks its name with `is_alias` directly instead of through `Alias`, whose own error it discarded, so `alias.rs` goes away. The two tool object constructor docs now say only which kind of tool each one builds. --- crates/runvil-internal/lua/src/alias.rs | 52 ------------------- crates/runvil-internal/lua/src/lib.rs | 1 - crates/runvil-internal/lua/src/tools.rs | 25 ++------- .../runvil-internal/lua/src/tools/decode.rs | 2 +- crates/runvil-internal/lua/src/tools/offer.rs | 15 +++++- .../runvil-internal/lua/src/tools/userdata.rs | 8 +-- 6 files changed, 22 insertions(+), 81 deletions(-) delete mode 100644 crates/runvil-internal/lua/src/alias.rs diff --git a/crates/runvil-internal/lua/src/alias.rs b/crates/runvil-internal/lua/src/alias.rs deleted file mode 100644 index 816c13dfb..000000000 --- a/crates/runvil-internal/lua/src/alias.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! 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::{Result, lua_runtime}; - -/// A prompt-local alias that passed the supported wire grammar. -/// -/// An alias never contains `/`, so it never reads as a tool id. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct Alias(String); - -impl Alias { - /// Validates `alias` against the wire grammar and wraps it. - /// - /// # Errors - /// Returns a `LuaRuntime` failure when `alias` is empty, exceeds 64 bytes, starts - /// with a non-letter, or contains a character other than a letter, - /// digit, `_`, or `-` after its first byte. - pub(crate) fn parse(alias: String) -> Result { - if is_alias(&alias) { - Ok(Alias(alias)) - } else { - Err(lua_runtime(format!( - "invalid alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" - ))) - } - } -} - -impl From for String { - fn from(alias: Alias) -> String { - alias.0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_alias_grammar_rejects_every_other_shape() { - let error = Alias::parse("1search".to_owned()).expect_err("the message names the grammar"); - assert!( - error - .to_string() - .contains("invalid alias \"1search\": expected [A-Za-z][A-Za-z0-9_-]{0,63}"), - "unexpected message: {error}" - ); - } -} diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index dc890ed5e..68d839d76 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -76,7 +76,6 @@ //! prompt line; the `file:line` goes into that `message` only, never //! into the prose. -mod alias; mod argv; mod bindings; mod chunk_line; diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index dbfa6b4dd..ba1c4bebf 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -22,10 +22,10 @@ use std::sync::{Arc, Mutex}; use mlua::{Function, Lua, MultiValue, Table, Value}; use runvil_model_client::detail::tool_schema_new; +use runvil_types::names::is_alias; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::{Value as Json, json}; -use crate::alias::Alias; use crate::bindings::{DiscoveryTool, ToolBinding, ToolSet}; use crate::error::{self, Result}; use crate::json::to_json; @@ -54,25 +54,10 @@ use offer::{Offering, install_offer}; /// `Adhoc` answer reads it back to hand the function to the shim. const ADHOC_HANDLERS_REGISTRY: &str = "runvil.tools.adhoc_handlers"; -/// The refusal of an offer function once the section's sets are frozen, -/// after the calling function's name. -const TOOL_LIST_FIXED: &str = "the section's tool list is fixed at its first models.loop round; \ - make every offer before the first loop, or offer in another \ - section"; - /// The prefix of a `tools.adhoc` JSON Schema `parameters` table that has no /// JSON form. const PARAMETERS_NOT_JSON: &str = "tools.adhoc: parameters schema cannot be encoded as JSON"; -/// Refuses `call`, an offer function, once `list` holds the section's -/// frozen sets. -pub(crate) fn refuse_after_render(call: &str, list: &MessageList) -> mlua::Result<()> { - if list.rendered_tools().is_some() { - return Err(mlua::Error::external(format!("{call}: {TOOL_LIST_FIXED}"))); - } - Ok(()) -} - /// Reads the handler `tools.adhoc` registered under the wire name `name` /// from the VM's handler table. /// @@ -212,11 +197,11 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str ))); } let name = attr_text(attrs, "name")?; - let name = String::from(Alias::parse(name.clone()).map_err(|_| { - mlua::Error::external(format!( + if !is_alias(&name) { + return Err(mlua::Error::external(format!( "tools.adhoc: invalid name {name:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" - )) - })?); + ))); + } if DiscoveryTool::ALL .iter() .any(|tool| tool.wire_name() == name) diff --git a/crates/runvil-internal/lua/src/tools/decode.rs b/crates/runvil-internal/lua/src/tools/decode.rs index c49e2d468..9aab3f62c 100644 --- a/crates/runvil-internal/lua/src/tools/decode.rs +++ b/crates/runvil-internal/lua/src/tools/decode.rs @@ -57,7 +57,7 @@ pub(crate) fn model_tool_name(value: &Value) -> mlua::Result { /// The id of the tool object `value` is, or `None` for any other value, /// a userdata that is not a tool object included. -pub(super) fn object_id(value: &Value) -> Option { +fn object_id(value: &Value) -> Option { match value { Value::UserData(object) => object .borrow::() diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index cc04c0f25..54330323d 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -15,7 +15,6 @@ use mlua::{Lua, Table, Value, Variadic}; use runvil_types::packs::PackId; use runvil_types::tools::ToolId; -use super::refuse_after_render; use super::userdata::LuaToolHandle; use crate::bindings::{RosterEntry, ToolSet, wire_name_of}; use crate::error::{self, Result}; @@ -26,6 +25,12 @@ use crate::scope::{OfferMode, OfferTarget, Offers}; use crate::vm::AdhocTools; use crate::walk_budget::HELD_KEY_LIMIT; +/// The refusal of an offer verb once the section's sets are frozen, +/// after the verb's name. +const TOOL_LIST_FIXED: &str = "the section's tool list is fixed at its first models.loop round; \ + make every offer before the first loop, or offer in another \ + section"; + /// What an offer verb checks its entries against and records into: the /// run's tool set, the section's offers, the VM's adhoc tools, and the /// section's conversation, whose frozen sets fix the offers. @@ -72,6 +77,14 @@ pub(super) fn install_offer( tools.set(key, offer).map_err(error::lua) } +/// Refuses the offer verb `verb` once `list` holds the section's frozen sets. +fn refuse_after_render(verb: &str, list: &MessageList) -> mlua::Result<()> { + if list.rendered_tools().is_some() { + return Err(mlua::Error::external(format!("{verb}: {TOOL_LIST_FIXED}"))); + } + Ok(()) +} + impl Offering { /// Reads `verb`'s arguments as the targets they name, checking every /// entry before returning any. No argument names nothing. diff --git a/crates/runvil-internal/lua/src/tools/userdata.rs b/crates/runvil-internal/lua/src/tools/userdata.rs index 82487253f..14a410b1a 100644 --- a/crates/runvil-internal/lua/src/tools/userdata.rs +++ b/crates/runvil-internal/lua/src/tools/userdata.rs @@ -29,9 +29,7 @@ pub(crate) struct LuaToolHandle { } impl LuaToolHandle { - /// Builds the object for an adhoc tool from its binding: the id, the - /// declared description, the parameter schema, and whether only a - /// model may call it. + /// Builds the object for an adhoc tool from its binding. #[must_use] pub(crate) fn from_binding(binding: &ToolBinding) -> Self { Self { @@ -42,9 +40,7 @@ impl LuaToolHandle { } } - /// Builds the object for a catalog tool from its info: the id, the - /// declared description, the parameter schema, and whether only a - /// model may call it. + /// Builds the object for a catalog tool from its info. #[must_use] pub(crate) fn from_info(info: &ToolInfo) -> Self { Self { From fb949072f1e83aaa5e3ead6d30f9a4e71161a728 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:13:24 -0800 Subject: [PATCH 28/36] Move adhoc tools to the crate root and split the lookup `AdhocTools` serves `tools` and `packs` as much as the VM, so it moves from `vm/adhoc.rs` to a crate-root `adhoc` module that also holds the adhoc call protocol doc, and `runvil_lua::AdhocTools` keeps its path. `AdhocTools::binding` picked the id or wire-name space by whether its argument held a `/`; `by_id` and `by_wire_name` replace it. Callers that hold a string id parse it with `ToolId::parse` first, and a string that does not parse holds no adhoc tool. --- .../execute/scheduler/tool_call-discovery.rs | 7 +--- .../engine/src/execute/scheduler/tool_call.rs | 14 +++---- .../runvil-internal/lua/src/{vm => }/adhoc.rs | 37 +++++++++++++------ crates/runvil-internal/lua/src/lib.rs | 4 +- crates/runvil-internal/lua/src/packs.rs | 2 +- .../lua/src/protocol/tests/answer.rs | 2 +- .../lua/src/tests/diagnostics.rs | 4 +- crates/runvil-internal/lua/src/tools.rs | 18 ++++----- crates/runvil-internal/lua/src/tools/offer.rs | 6 +-- crates/runvil-internal/lua/src/tools/stats.rs | 15 ++++++-- .../lua/src/tools/tests-adhoc.rs | 8 ++-- crates/runvil-internal/lua/src/vm.rs | 21 +++-------- crates/runvil-internal/lua/src/vm/state.rs | 5 ++- 13 files changed, 76 insertions(+), 67 deletions(-) rename crates/runvil-internal/lua/src/{vm => }/adhoc.rs (64%) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs index 459c584f7..e101b7962 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs @@ -148,12 +148,7 @@ impl ChainOps<'_> { args: Value, site: CallSite, ) -> Result { - let adhoc = self - .chain - .frame()? - .vm() - .adhoc_tools() - .binding(&target.id().to_string())?; + let adhoc = self.chain.frame()?.vm().adhoc_tools().by_id(target.id())?; if let Some(adhoc) = adhoc { return self.prepare_adhoc_call(&adhoc, args, &site); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs index 8780a99c9..c8d0c95e2 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs @@ -168,18 +168,16 @@ impl ChainOps<'_> { if set == OfferMode::Dynamic || DiscoveryTool::of(binding.id()).is_some() { return Ok(ToolRoute::Discovery(binding)); } - let adhoc = vm.adhoc_tools().binding(&binding.id().to_string())?; + let adhoc = vm.adhoc_tools().by_id(binding.id())?; return Ok(adhoc.map_or(ToolRoute::Catalog(binding), ToolRoute::Adhoc)); } - if name.contains('/') - && let Some(binding) = vm.adhoc_tools().binding(name)? + let tool = ToolId::parse(name).ok(); + if let Some(tool) = &tool + && let Some(binding) = vm.adhoc_tools().by_id(tool)? { return Ok(ToolRoute::Adhoc(binding)); } - let Some(info) = ToolId::parse(name) - .ok() - .and_then(|tool| tool_set.catalog().get(&tool)) - else { + let Some(info) = tool.and_then(|tool| tool_set.catalog().get(&tool)) else { return Err(unbound_tool_call(tool_set, name)); }; // A script cannot know the arguments a render shapes for a model, @@ -216,7 +214,7 @@ impl ChainOps<'_> { for tool in frozen { let seeded = DiscoveryTool::of(&tool).is_some() || tool_set.catalog().get(&tool).is_some() - || vm.adhoc_tools().binding(&tool.to_string())?.is_some(); + || vm.adhoc_tools().by_id(&tool)?.is_some(); if seeded { ids.push(tool); } diff --git a/crates/runvil-internal/lua/src/vm/adhoc.rs b/crates/runvil-internal/lua/src/adhoc.rs similarity index 64% rename from crates/runvil-internal/lua/src/vm/adhoc.rs rename to crates/runvil-internal/lua/src/adhoc.rs index 8f262db1a..6c97f3b12 100644 --- a/crates/runvil-internal/lua/src/vm/adhoc.rs +++ b/crates/runvil-internal/lua/src/adhoc.rs @@ -1,8 +1,19 @@ //! The adhoc tools a section VM defines: each one's binding, looked up by //! wire name or by id. +//! +//! An adhoc tool, defined with `tools.adhoc`, runs inside the calling +//! block's coroutine, never as an Engine function. The VM keeps each +//! tool's binding; the handler sits in the VM's handler table. A call +//! takes two yields: the shim's `tool_call`, which the scheduler answers +//! with the handler itself, and the shim's `adhoc_tool_done` after the +//! handler ran, which carries its result for the scheduler to report. +//! Every suspending call the handler makes in between is an ordinary +//! yield of the same coroutine. use std::sync::{Arc, Mutex, MutexGuard}; +use runvil_types::tools::ToolId; + use crate::bindings::ToolBinding; use crate::error::{self, Result}; @@ -39,23 +50,27 @@ impl AdhocTools { Ok(()) } - /// Returns the binding of the adhoc tool whose id is `name` when it - /// holds a `/`, else whose wire name is `name`. + /// Returns the binding of the adhoc tool whose id is `id`. + /// + /// # Errors + /// Returns an `Internal` failure if the entries lock was poisoned. + pub fn by_id(&self, id: &ToolId) -> Result> { + Ok(self + .entries()? + .iter() + .find(|binding| binding.id() == id) + .cloned()) + } + + /// Returns the binding of the adhoc tool whose wire name is `name`. /// /// # Errors /// Returns an `Internal` failure if the entries lock was poisoned. - pub fn binding(&self, name: &str) -> Result> { - let by_id = name.contains('/'); + pub fn by_wire_name(&self, name: &str) -> Result> { Ok(self .entries()? .iter() - .find(|binding| { - if by_id { - binding.id().to_string() == name - } else { - binding.alias() == name - } - }) + .find(|binding| binding.alias() == name) .cloned()) } diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 68d839d76..44a602314 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -76,6 +76,7 @@ //! prompt line; the `file:line` goes into that `message` only, never //! into the prose. +mod adhoc; mod argv; mod bindings; mod chunk_line; @@ -120,6 +121,7 @@ mod tests; // The executor-facing surface: every item `runvil-engine` names crosses // here. The facade re-exports only `VfsOp` and `VfsOutcome`. +pub use crate::adhoc::AdhocTools; pub use crate::argv::Argv; pub use crate::bindings::{ DiscoveryTool, RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of, @@ -154,6 +156,6 @@ pub use crate::protocol::{ pub use crate::scope::{OfferMode, OfferedTool, Offers, RenderedTools, ToolCallCounts}; pub use crate::section::{Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionText}; pub use crate::vm::{ - AdhocTools, CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_catalog_packs, + CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_catalog_packs, current_dynamic_set, current_static_set, }; diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index 6443386b7..e83226fe5 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -26,11 +26,11 @@ use mlua::{ use runvil_types::packs::PackId; use runvil_types::tools::ToolId; +use crate::adhoc::AdhocTools; use crate::bindings::{RosterEntry, ToolSet}; use crate::error::{self, Result}; use crate::proxy::read_only_walk; use crate::tools::tool_object; -use crate::vm::AdhocTools; /// The id of the adhoc Pack, which holds every tool `tools.adhoc` defines. pub(crate) const ADHOC_PACK: &str = "adhoc"; diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index f36e59c67..b805b0f21 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -257,7 +257,7 @@ fn lua_with_adhoc_grab() -> Lua { &lua.globals(), &std::sync::Arc::new(crate::ToolSet::default()), &std::sync::Arc::new(std::sync::Mutex::new(crate::Offers::default())), - &crate::vm::AdhocTools::default(), + &crate::adhoc::AdhocTools::default(), &crate::MessageList::default(), ) .expect("the tools install cannot fail on a fresh VM"); diff --git a/crates/runvil-internal/lua/src/tests/diagnostics.rs b/crates/runvil-internal/lua/src/tests/diagnostics.rs index 5f9ce7bf4..75678b27b 100644 --- a/crates/runvil-internal/lua/src/tests/diagnostics.rs +++ b/crates/runvil-internal/lua/src/tests/diagnostics.rs @@ -3,8 +3,8 @@ //! that fail closed on a poisoned lock. use super::*; +use crate::adhoc::AdhocTools; use crate::chunk_line::map_chunk_line_to_absolute; -use crate::vm::AdhocTools; #[test] fn lua_program_retains_source_and_round_trips_bytecode() { @@ -85,7 +85,7 @@ fn adhoc_tools_list_and_lookup_reads_fail_closed_on_poison() { .bindings() .expect_err("list reads must fail on a poisoned registry"); let looked_up = adhoc - .binding("adhoc/grab") + .by_id(&runvil_types::tools::ToolId::parse("adhoc/grab").expect("a valid adhoc id")) .expect_err("lookups must fail on a poisoned registry"); for error in [listed, looked_up] { assert!( diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index ba1c4bebf..cccecd826 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -26,6 +26,7 @@ use runvil_types::names::is_alias; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::{Value as Json, json}; +use crate::adhoc::AdhocTools; use crate::bindings::{DiscoveryTool, ToolBinding, ToolSet}; use crate::error::{self, Result}; use crate::json::to_json; @@ -33,7 +34,6 @@ use crate::messages::MessageList; use crate::packs::{ADHOC_PACK, install_pack_list, install_pack_objects}; use crate::proxy::key_text; use crate::scope::{OfferMode, Offers}; -use crate::vm::AdhocTools; mod decode; mod objects; @@ -211,9 +211,14 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str ))); } let description = attr_text(attrs, "description")?; - let id = format!("{ADHOC_PACK}/{}", name.to_ascii_lowercase()); + let id = + ToolId::parse(&format!("{ADHOC_PACK}/{}", name.to_ascii_lowercase())).map_err(|error| { + mlua::Error::external(error::internal(format!( + "an adhoc tool's id is a tool id: {error}" + ))) + })?; if adhoc - .binding(&name) + .by_wire_name(&name) .map_err(mlua::Error::external)? .is_some() { @@ -221,7 +226,7 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str "tools.adhoc name {name:?} is already registered" ))); } - if let Some(holder) = adhoc.binding(&id).map_err(mlua::Error::external)? { + if let Some(holder) = adhoc.by_id(&id).map_err(mlua::Error::external)? { return Err(mlua::Error::external(format!( "tools.adhoc name {name:?} has the id {id}, which {:?} already holds", holder.alias() @@ -239,11 +244,6 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str }; tool_schema_new(name.clone(), description.clone(), parameters.clone()) .map_err(|error| mlua::Error::external(format!("tools.adhoc: {error}")))?; - let id = ToolId::parse(&id).map_err(|error| { - mlua::Error::external(error::internal(format!( - "an adhoc tool's id is a tool id: {error}" - ))) - })?; Ok((name, ToolInfo::new(id, description, parameters))) } diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index 54330323d..2f9c3d386 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -16,13 +16,13 @@ use runvil_types::packs::PackId; use runvil_types::tools::ToolId; use super::userdata::LuaToolHandle; +use crate::adhoc::AdhocTools; use crate::bindings::{RosterEntry, ToolSet, wire_name_of}; use crate::error::{self, Result}; use crate::messages::MessageList; use crate::packs::{LuaPackHandle, is_adhoc, pack_object}; use crate::proxy::key_text; use crate::scope::{OfferMode, OfferTarget, Offers}; -use crate::vm::AdhocTools; use crate::walk_budget::HELD_KEY_LIMIT; /// The refusal of an offer verb once the section's sets are frozen, @@ -151,7 +151,7 @@ impl Offering { let known = match ToolId::parse(name) { Ok(id) if is_adhoc(&id.pack()) => self .adhoc - .binding(name) + .by_id(&id) .map_err(mlua::Error::external)? .is_some() .then_some(id), @@ -196,7 +196,7 @@ impl Offering { } Ok(self .adhoc - .binding(name) + .by_wire_name(name) .map_err(mlua::Error::external)? .map(|binding| binding.id().clone())) } diff --git a/crates/runvil-internal/lua/src/tools/stats.rs b/crates/runvil-internal/lua/src/tools/stats.rs index cd31c9cb2..491db94e5 100644 --- a/crates/runvil-internal/lua/src/tools/stats.rs +++ b/crates/runvil-internal/lua/src/tools/stats.rs @@ -18,11 +18,11 @@ use std::sync::Arc; use mlua::{Lua, Table, Value}; use runvil_types::tools::ToolId; +use crate::adhoc::AdhocTools; use crate::bindings::ToolSet; use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_walk}; use crate::scope::ToolCallCounts; -use crate::vm::AdhocTools; /// The path of the table itself, which its refusals open with. const STATS: &str = "tools.stats"; @@ -63,6 +63,15 @@ impl View { } impl Stats { + /// Whether `key` is a defined adhoc tool's id; a key that is no tool + /// id names none. + fn is_adhoc(&self, key: &str) -> Result { + match ToolId::parse(key) { + Ok(id) => Ok(self.adhoc.by_id(&id)?.is_some()), + Err(_) => Ok(false), + } + } + /// Every count, summed. fn total(&self) -> Result { Ok(self.counts.snapshot()?.values().sum()) @@ -79,7 +88,7 @@ impl Stats { *held.entry(pack.to_owned()).or_default() += count; } View::Tools => { - if self.catalog.contains(&id) || self.adhoc.binding(&id)?.is_some() { + if self.catalog.contains(&id) || self.is_adhoc(&id)? { held.insert(id, count); } } @@ -96,7 +105,7 @@ impl Stats { let tools = view == View::Tools; let hint = if tools && self.catalog.contains(key) { " (a catalog tool that was neither offered in this section nor called with tools.call)" - } else if tools && key.contains('/') && self.adhoc.binding(key)?.is_some() { + } else if tools && self.is_adhoc(key)? { " (an adhoc tool that was neither offered in this section nor called with tools.call)" } else if held.is_empty() { "" diff --git a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs index 8ca3aecb3..ff3699cc1 100644 --- a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs +++ b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs @@ -21,7 +21,7 @@ const GRAB: &str = "local grab = tools.adhoc({\n\ /// The schema the adhoc tool with wire name `name` on `vm` binds. fn adhoc_schema(vm: &crate::SectionVm, name: &str) -> Json { vm.adhoc_tools() - .binding(name) + .by_wire_name(name) .expect("the adhoc tools read") .unwrap_or_else(|| panic!("{name} is defined")) .schema() @@ -176,7 +176,7 @@ fn the_discovery_tools_wire_names_are_refused() { ); assert!( vm.adhoc_tools() - .binding("GetDynamicTools") + .by_wire_name("GetDynamicTools") .expect("the adhoc tools read") .is_none(), "no refused definition registers" @@ -201,7 +201,7 @@ fn a_definition_whose_tool_object_cannot_be_stored_leaves_no_tool_behind() { .expect("the tool-object table is restored"); assert_eq!( vm.adhoc_tools() - .binding("adhoc/x") + .by_id(&runvil_types::tools::ToolId::parse("adhoc/x").expect("a valid id")) .expect("the adhoc tools read"), None, "a failed definition registers nothing" @@ -273,7 +273,7 @@ fn a_wrapper_of_a_catalog_tool_takes_its_description_and_schema_unchanged() { .expect("the catalog holds the tool"); let wrapper = vm .adhoc_tools() - .binding("adhoc/search") + .by_id(&runvil_types::tools::ToolId::parse("adhoc/search").expect("a valid id")) .expect("the adhoc tools read") .expect("the wrapper is defined"); assert_eq!(wrapper.description(), catalog.description); diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index a922b25c5..737d3c6db 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -1,20 +1,9 @@ //! The per-section Lua VM: its construction, the typestate that orders -//! Engine injection before every later step, the section's one -//! `messages` list it creates, and the adhoc tools it defines. -//! Injection is in `inject`, the later installs in `install`, block -//! coroutines in `run`, state reads in `state`, and the adhoc tools in -//! `adhoc`. -//! -//! An adhoc tool, defined with `tools.adhoc`, runs inside the calling -//! block's coroutine, never as an Engine function. The VM keeps each -//! tool's binding; the handler sits in the VM's handler table. A call -//! takes two yields: the shim's `tool_call`, which the scheduler answers -//! with the handler itself, and the shim's `adhoc_tool_done` after the -//! handler ran, which carries its result for the scheduler to report. -//! Every suspending call the handler makes in between is an ordinary -//! yield of the same coroutine. +//! Engine injection before every later step, and the section's one +//! `messages` list it creates. Injection is in `inject`, the later +//! installs in `install`, block coroutines in `run`, and state reads in +//! `state`. -mod adhoc; mod inject; mod install; mod run; @@ -32,6 +21,7 @@ use runvil_types::untrusted::GuardNonce; use runvil_vfs::Access; use serde_json::Value as Json; +use crate::adhoc::AdhocTools; use crate::bindings::ToolSet; use crate::engine_globals::{LUA_LOG_CHARACTER_LIMIT, install_untrusted}; use crate::error::{self, Error, Result}; @@ -43,7 +33,6 @@ use crate::program::LuaProgram; use crate::scope::Offers; use crate::walk_budget::{DEFAULT_LUA_MEMORY_BYTES, HeapCeiling}; -pub use adhoc::AdhocTools; pub use inject::InjectFailure; pub use run::CoroStep; pub use state::{current_catalog_packs, current_dynamic_set, current_static_set}; diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 6e662b96b..ac07a509e 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -11,7 +11,8 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::Value as Json; -use super::{AdhocTools, SectionVm}; +use super::SectionVm; +use crate::adhoc::AdhocTools; use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{self, Result, lua_runtime}; use crate::packs::is_adhoc; @@ -226,7 +227,7 @@ fn expanded( match &offer.target { OfferTarget::Tool(id) => { let tool = if is_adhoc(&id.pack()) { - adhoc.binding(&id.to_string())?.map(OfferedTool::Adhoc) + adhoc.by_id(id)?.map(OfferedTool::Adhoc) } else { set.catalog().get(id).cloned().map(OfferedTool::Catalog) }; From 83a4fb2991bc8668fe7107ef615d548011b2f341 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:15:36 -0800 Subject: [PATCH 29/36] Share one registry lookup and frozen guard in proxy `tool_object` and `pack_object` read two registry tables with one body, and the tool and Pack userdata refused assignment with one closure that differed only in its label. `proxy` now holds `registry_object` and `add_frozen_guard`, and the two lookups stay as one-line wrappers whose names say which registry they read. The refusal texts are unchanged. --- crates/runvil-internal/lua/src/packs.rs | 25 ++---------- crates/runvil-internal/lua/src/proxy.rs | 38 ++++++++++++++++++- .../runvil-internal/lua/src/tools/objects.rs | 10 +---- .../runvil-internal/lua/src/tools/userdata.rs | 12 ++---- 4 files changed, 46 insertions(+), 39 deletions(-) diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index e83226fe5..f1dfc1be1 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -19,17 +19,14 @@ use std::collections::BTreeMap; -use mlua::{ - AnyUserData, Lua, MetaMethod, MultiValue, Table, UserData, UserDataFields, UserDataMethods, - Value, -}; +use mlua::{AnyUserData, Lua, MultiValue, Table, UserData, UserDataFields, UserDataMethods, Value}; use runvil_types::packs::PackId; use runvil_types::tools::ToolId; use crate::adhoc::AdhocTools; use crate::bindings::{RosterEntry, ToolSet}; use crate::error::{self, Result}; -use crate::proxy::read_only_walk; +use crate::proxy::{add_frozen_guard, read_only_walk, registry_object}; use crate::tools::tool_object; /// The id of the adhoc Pack, which holds every tool `tools.adhoc` defines. @@ -95,14 +92,7 @@ impl UserData for LuaPackHandle { } fn add_methods>(methods: &mut M) { - methods.add_meta_method( - MetaMethod::NewIndex, - |_, _, (key, _): (String, Value)| -> mlua::Result<()> { - Err(mlua::Error::external(format!( - "Pack objects are frozen: cannot assign field {key:?}" - ))) - }, - ); + add_frozen_guard(methods, "Pack objects"); } } @@ -112,14 +102,7 @@ impl UserData for LuaPackHandle { /// # Errors /// Returns an `mlua` error when the registry cannot be read. pub(crate) fn pack_object(lua: &Lua, name: &str) -> mlua::Result> { - let objects: Option
= lua.named_registry_value(PACK_OBJECTS_REGISTRY)?; - match objects - .map(|objects| objects.raw_get::(name)) - .transpose()? - { - Some(Value::UserData(object)) => Ok(Some(object)), - _ => Ok(None), - } + registry_object(lua, PACK_OBJECTS_REGISTRY, name) } /// The names of the VM's Pack objects, in name order: every one, and the diff --git a/crates/runvil-internal/lua/src/proxy.rs b/crates/runvil-internal/lua/src/proxy.rs index aeccb44af..3df7d3217 100644 --- a/crates/runvil-internal/lua/src/proxy.rs +++ b/crates/runvil-internal/lua/src/proxy.rs @@ -2,8 +2,11 @@ //! a sealed prelude global, and a prelude's `var` view - and the key //! formatter their refusals and field paths share. //! `tools.stats` and `tools.packs` build on the walkable [`read_only_walk`]. +//! Tool and Pack objects share [`registry_object`] and [`add_frozen_guard`]. -use mlua::{Function, Lua, MaybeSend, MultiValue, Table, Value}; +use mlua::{ + AnyUserData, Function, Lua, MaybeSend, MetaMethod, MultiValue, Table, UserDataMethods, Value, +}; /// Renders a table key for a refusal: a string as its text, an integer as /// its digits, and any other key by its type name. @@ -73,3 +76,36 @@ pub(crate) fn read_only_walk( proxy.set_metatable(Some(meta))?; Ok(proxy) } + +/// The userdata under `key` in the registry table `registry`, or `None` +/// when the table is absent or the entry is no userdata. +/// +/// # Errors +/// Returns an `mlua` error when the registry cannot be read. +pub(crate) fn registry_object( + lua: &Lua, + registry: &str, + key: &str, +) -> mlua::Result> { + let objects: Option
= lua.named_registry_value(registry)?; + match objects + .map(|objects| objects.raw_get::(key)) + .transpose()? + { + Some(Value::UserData(object)) => Ok(Some(object)), + _ => Ok(None), + } +} + +/// Installs the `__newindex` refusal of a frozen userdata: assigning any +/// field raises `{label} are frozen: cannot assign field {key:?}`. +pub(crate) fn add_frozen_guard>(methods: &mut M, label: &'static str) { + methods.add_meta_method( + MetaMethod::NewIndex, + move |_, _, (key, _): (String, Value)| -> mlua::Result<()> { + Err(mlua::Error::external(format!( + "{label} are frozen: cannot assign field {key:?}" + ))) + }, + ); +} diff --git a/crates/runvil-internal/lua/src/tools/objects.rs b/crates/runvil-internal/lua/src/tools/objects.rs index 311049acd..c3ed80fd3 100644 --- a/crates/runvil-internal/lua/src/tools/objects.rs +++ b/crates/runvil-internal/lua/src/tools/objects.rs @@ -11,6 +11,7 @@ use mlua::{AnyUserData, Lua, Table, Value}; use super::userdata::LuaToolHandle; use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{self, Result}; +use crate::proxy::registry_object; /// The registry key of the VM's tool-object table: one object per tool, /// keyed by id. @@ -22,14 +23,7 @@ pub(super) const TOOL_OBJECTS_REGISTRY: &str = "runvil.tools.objects"; /// # Errors /// Returns an `mlua` error when the registry cannot be read. pub(crate) fn tool_object(lua: &Lua, id: &str) -> mlua::Result> { - let objects: Option
= lua.named_registry_value(TOOL_OBJECTS_REGISTRY)?; - match objects - .map(|objects| objects.raw_get::(id)) - .transpose()? - { - Some(Value::UserData(object)) => Ok(Some(object)), - _ => Ok(None), - } + registry_object(lua, TOOL_OBJECTS_REGISTRY, id) } /// Builds the VM's tool-object table over the run catalog. diff --git a/crates/runvil-internal/lua/src/tools/userdata.rs b/crates/runvil-internal/lua/src/tools/userdata.rs index 14a410b1a..f7dfd1ba7 100644 --- a/crates/runvil-internal/lua/src/tools/userdata.rs +++ b/crates/runvil-internal/lua/src/tools/userdata.rs @@ -11,12 +11,13 @@ //! envelope. Unlike a model handle, which carries `infer` and `loop`, it //! is invoked only through `tools.call(tool, arguments)`. -use mlua::{LuaSerdeExt, MetaMethod, UserData, UserDataFields, UserDataMethods, Value}; +use mlua::{LuaSerdeExt, UserData, UserDataFields, UserDataMethods}; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::Value as Json; use crate::bindings::ToolBinding; use crate::packs::pack_object; +use crate::proxy::add_frozen_guard; /// One tool as Lua sees it: `id`, `description`, `parameters`, `pack`, and /// `model_only_call`, all read-only. @@ -76,13 +77,6 @@ impl UserData for LuaToolHandle { } fn add_methods>(methods: &mut M) { - methods.add_meta_method( - MetaMethod::NewIndex, - |_, _, (key, _): (String, Value)| -> mlua::Result<()> { - Err(mlua::Error::external(format!( - "Tool objects are frozen: cannot assign field {key:?}" - ))) - }, - ); + add_frozen_guard(methods, "Tool objects"); } } From 99473df51f09760f35edb8678b32284b7fb28996 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:19:44 -0800 Subject: [PATCH 30/36] Rename the offers handle and the VM install helpers The section offers handle was still named `runtime`, and two production methods carried suffixes while test helpers held the plain names. The handle is now `offers`; `inject_values_with_var` and `install_scheduler_control_globals` become `inject_values` and `install_control_globals`, and the test helpers become `inject_test_values`, `install_test_control_globals`, and `tool_set_and_offers`. `SectionVm::rendered_tools` is removed, so callers read the frozen sets through `messages().rendered_tools()`. --- .../engine/src/execute/scheduler/chat.rs | 2 +- .../execute/scheduler/tool_call-discovery.rs | 17 +++++----- .../engine/src/execute/scheduler/tool_call.rs | 6 ++-- .../engine/src/execute/scope.rs | 8 ++--- .../engine/src/execute/section_vm.rs | 6 ++-- .../engine/src/execute/tests/tool_scoping.rs | 16 +++++----- .../runvil-internal/engine/src/model/tests.rs | 2 +- crates/runvil-internal/lua/benches/surface.rs | 2 +- .../runvil-internal/lua/src/test_support.rs | 6 ++-- crates/runvil-internal/lua/src/test_vm.rs | 18 +++++------ .../lua/src/tests/section_vm.rs | 8 ++--- crates/runvil-internal/lua/src/tools.rs | 4 +-- crates/runvil-internal/lua/src/tools/offer.rs | 10 +++--- .../lua/src/tools/tests-adhoc.rs | 8 ++--- .../lua/src/tools/tests-render.rs | 12 +++---- crates/runvil-internal/lua/src/vm.rs | 4 +-- crates/runvil-internal/lua/src/vm/inject.rs | 2 +- crates/runvil-internal/lua/src/vm/install.rs | 6 ++-- crates/runvil-internal/lua/src/vm/state.rs | 31 +++++++------------ 19 files changed, 81 insertions(+), 87 deletions(-) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index 3a735688c..d0da84c4b 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs @@ -275,7 +275,7 @@ impl ChainOps<'_> { // absence is a scheduler fault, never an author-visible // out-of-scope refusal. let vm = chain.frame()?.vm(); - let frozen = vm.rendered_tools().ok_or_else(|| { + let frozen = vm.messages().rendered_tools().ok_or_else(|| { error::internal("a parked chat round's section holds its frozen sets") })?; let scope = Scope { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs index e101b7962..1bc980e48 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs @@ -82,14 +82,15 @@ impl ChainOps<'_> { site: CallSite, ) -> Result { let tool = DiscoveryTool::of(binding.id()); - let frozen = self - .chain - .frame()? - .vm() - .rendered_tools() - .ok_or(error::internal( - "a model call's section holds its frozen sets", - ))?; + let frozen = + self.chain + .frame()? + .vm() + .messages() + .rendered_tools() + .ok_or(error::internal( + "a model call's section holds its frozen sets", + ))?; let discovered = match tool { Some(DiscoveryTool::Search) => search(&frozen, &args)?, Some(DiscoveryTool::Call) => reach(&frozen, args), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs index c8d0c95e2..e3d53144a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs @@ -221,9 +221,9 @@ impl ChainOps<'_> { } ids } else { - let (runtime, adhoc) = (vm.offers(), vm.adhoc_tools()); - let static_tools = current_static_set(tool_set, runtime, adhoc)?; - let dynamic_tools = current_dynamic_set(tool_set, runtime, adhoc, &static_tools)?; + let (offers, adhoc) = (vm.offers(), vm.adhoc_tools()); + let static_tools = current_static_set(tool_set, offers, adhoc)?; + let dynamic_tools = current_dynamic_set(tool_set, offers, adhoc, &static_tools)?; static_tools .iter() .chain(&dynamic_tools) diff --git a/crates/runvil-internal/engine/src/execute/scope.rs b/crates/runvil-internal/engine/src/execute/scope.rs index aaf0cbfbe..b2f280e83 100644 --- a/crates/runvil-internal/engine/src/execute/scope.rs +++ b/crates/runvil-internal/engine/src/execute/scope.rs @@ -65,10 +65,10 @@ impl SectionOffers { /// # Errors /// Returns the expansion's error. pub(super) fn read(tool_set: &ToolSet, vm: &SectionVm) -> Result { - let (runtime, adhoc) = (vm.offers(), vm.adhoc_tools()); - let static_tools = current_static_set(tool_set, runtime, adhoc)?; - let dynamic_tools = current_dynamic_set(tool_set, runtime, adhoc, &static_tools)?; - let packs = current_catalog_packs(tool_set, runtime, adhoc)?; + let (offers, adhoc) = (vm.offers(), vm.adhoc_tools()); + let static_tools = current_static_set(tool_set, offers, adhoc)?; + let dynamic_tools = current_dynamic_set(tool_set, offers, adhoc, &static_tools)?; + let packs = current_catalog_packs(tool_set, offers, adhoc)?; Ok(Self { static_tools, dynamic_tools, diff --git a/crates/runvil-internal/engine/src/execute/section_vm.rs b/crates/runvil-internal/engine/src/execute/section_vm.rs index f555a9dfe..8c347b18b 100644 --- a/crates/runvil-internal/engine/src/execute/section_vm.rs +++ b/crates/runvil-internal/engine/src/execute/section_vm.rs @@ -107,7 +107,7 @@ pub(crate) struct SectionVmSetup<'a> { /// [`SectionVm::install_engine_globals`], the /// `ui()` global when the run supplied a snapshot, the `item` global when /// the seed includes one, the control surface -/// ([`SectionVm::install_scheduler_control_globals`] for `jump` and the +/// ([`SectionVm::install_control_globals`] for `jump` and the /// `prompt` reads, plus [`SectionVm::install_coro_shims`] for the /// suspending calls, which the scheduler drives as yield shims, /// `models.loop` and `models.compact` included), the run's Pack preludes, @@ -144,7 +144,7 @@ where vm.allow_raw_model_ids(); } let mut vm = vm - .inject_values_with_var( + .inject_values( setup.args, setup.sys, setup.access, @@ -185,7 +185,7 @@ where if let Some(item) = setup.seed.item { vm.set_global_json("item", item)?; } - vm.install_scheduler_control_globals(read)?; + vm.install_control_globals(read)?; vm.install_coro_shims(setup.max_tool_iterations)?; // The preludes read `tools` and `store` from `_G` as they install, so // they follow the yield shims. diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs index 104cac8d0..21d7a55ee 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs @@ -44,15 +44,15 @@ fn advertised(tools: &ToolSet, offered: &[OfferedTool]) -> Vec { } /// An offered tool stays out of the model-visible scope until -/// `tools.offer` names it: the scope snapshot over an untouched runtime is +/// `tools.offer` names it: the scope snapshot over untouched offers is /// empty. (The advertised-set half of this rule is the loop's schema /// build, pinned by the offer tests below.) #[test] fn offered_tools_are_not_injected_without_an_offer() { let (tool, _) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "capability", &tool); - let runtime = Mutex::new(runvil_lua::Offers::default()); - let effective = current_static_set(&tools, &runtime, &AdhocTools::default()) + let offers = Mutex::new(runvil_lua::Offers::default()); + let effective = current_static_set(&tools, &offers, &AdhocTools::default()) .expect("the scope must snapshot"); assert!( effective.is_empty(), @@ -68,12 +68,12 @@ fn an_offered_tool_advertises_its_concrete_schema_under_its_wire_name_and_dispat ]); let (tool, called) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "Concrete description.", &tool); - let mut runtime = runvil_lua::Offers::default(); - runtime.add_tool( + let mut offers = runvil_lua::Offers::default(); + offers.add_tool( ToolId::parse("tools/concrete").expect("a fixture tool id"), OfferMode::Static, ); - let effective = current_static_set(&tools, &Mutex::new(runtime), &AdhocTools::default()) + let effective = current_static_set(&tools, &Mutex::new(offers), &AdhocTools::default()) .expect("the offered scope snapshots"); let schemas = advertised(&tools, &effective); assert_eq!(schemas.len(), 1); @@ -118,7 +118,7 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { "Only", ) .expect("the section VM builds") - .inject_values("", &json!({}), &fresh_access()) + .inject_test_values("", &json!({}), &fresh_access()) .expect("values must inject"); // The section's `tools.offer` lands in its offers; the scope @@ -134,7 +134,7 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { .expect("the offer chunk must compile"); vm.run_chunk(&offer, &null_emitter(), "Only") .expect("tools.offer must succeed"); - let (tool_set, offers) = vm.tool_bag_handles(); + let (tool_set, offers) = vm.tool_set_and_offers(); let scope = current_static_set(&tool_set, &offers, vm.adhoc_tools()).expect("tool scope must snapshot"); let schemas = advertised(&tool_set, &scope); diff --git a/crates/runvil-internal/engine/src/model/tests.rs b/crates/runvil-internal/engine/src/model/tests.rs index 74ccdc15c..01a68a2ab 100644 --- a/crates/runvil-internal/engine/src/model/tests.rs +++ b/crates/runvil-internal/engine/src/model/tests.rs @@ -81,7 +81,7 @@ fn section_vm_with_models( emitter, section, )? - .inject_values("", &json!({}), &fresh_access()) + .inject_test_values("", &json!({}), &fresh_access()) .map_err(|failure| failure.teardown(emitter, section)) } diff --git a/crates/runvil-internal/lua/benches/surface.rs b/crates/runvil-internal/lua/benches/surface.rs index 994910572..d02a51543 100644 --- a/crates/runvil-internal/lua/benches/surface.rs +++ b/crates/runvil-internal/lua/benches/surface.rs @@ -47,7 +47,7 @@ fn builder_vm() -> SectionVm { SECTION, ) .expect("the bench VM builds") - .inject_values( + .inject_test_values( "", &json!({}), &std::sync::Arc::new( diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index 1e9324956..1d3da2eca 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -157,7 +157,7 @@ impl SectionVm { /// # Errors /// Returns the [`InjectFailure`] holding the bridging failure if Engine /// values cannot be bridged. - pub fn inject_values( + pub fn inject_test_values( self, args: &str, sys: &serde_json::Value, @@ -165,7 +165,7 @@ impl SectionVm { ) -> std::result::Result { let set = &self.bound_models; let selection = Arc::new(Mutex::new(set.binding("default").cloned())); - self.inject_values_with_var(args, sys, access, None, Argv::Frozen(None), &selection) + self.inject_values(args, sys, access, None, Argv::Frozen(None), &selection) } } @@ -217,7 +217,7 @@ impl SectionVm { /// Returns the shared tool set and the live section offers. #[must_use] - pub fn tool_bag_handles(&self) -> (Arc, Arc>) { + pub fn tool_set_and_offers(&self) -> (Arc, Arc>) { (Arc::clone(&self.tool_set), Arc::clone(self.offers())) } } diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index 697b3468a..cdb6404e4 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -84,7 +84,7 @@ pub(crate) fn run_chunk( emitter, section, )? - .inject_values(args, sys, access) + .inject_test_values(args, sys, access) .map_err(|failure| failure.teardown(emitter, section))?; vm.install_engine_globals(emitter, section)?; let returned: MultiValue = vm.lua().load(source).eval().map_err(error::lua)?; @@ -175,7 +175,7 @@ pub(crate) fn offer_set() -> ToolSet { pub(crate) fn offers(vm: &SectionVm) -> Vec { vm.offers() .lock() - .expect("the runtime mutex is not poisoned") + .expect("the offers mutex is not poisoned") .offers .iter() .map(|offer| { @@ -215,8 +215,8 @@ pub(crate) fn lua_over(set: ToolSet) -> BuiltVm { /// catalog tool's wire name and an adhoc tool's name. #[track_caller] pub(crate) fn scoped(vm: &SectionVm) -> Vec { - let (set, runtime) = vm.tool_bag_handles(); - crate::current_static_set(&set, &runtime, vm.adhoc_tools()) + let (tool_set, offers) = vm.tool_set_and_offers(); + crate::current_static_set(&tool_set, &offers, vm.adhoc_tools()) .expect("the scope resolves") .iter() .map(|tool| match tool { @@ -229,10 +229,10 @@ pub(crate) fn scoped(vm: &SectionVm) -> Vec { /// The ids of the tools `vm`'s dynamic offers name, in order. #[track_caller] pub(crate) fn dynamic(vm: &SectionVm) -> Vec { - let (set, runtime) = vm.tool_bag_handles(); + let (tool_set, offers) = vm.tool_set_and_offers(); let adhoc = vm.adhoc_tools(); - let bound = crate::current_static_set(&set, &runtime, adhoc).expect("the scope resolves"); - crate::current_dynamic_set(&set, &runtime, adhoc, &bound) + let bound = crate::current_static_set(&tool_set, &offers, adhoc).expect("the scope resolves"); + crate::current_dynamic_set(&tool_set, &offers, adhoc, &bound) .expect("the dynamic set resolves") .iter() .map(|tool| tool.id().to_string()) @@ -384,7 +384,7 @@ impl<'a> TestVm<'a> { vm.install_engine_globals(&self.emitter, self.section) .expect("the Engine globals install"); if self.shims { - vm.install_scheduler_control_globals(|_| { + vm.install_control_globals(|_| { Ok::, std::convert::Infallible>(None) }) .expect("the control globals install"); @@ -422,7 +422,7 @@ impl<'a> TestVm<'a> { } let empty = json!({}); let vm = fresh - .inject_values_with_var( + .inject_values( self.args, self.sys.unwrap_or(&empty), &access, diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index 7e486877c..93862000b 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -103,7 +103,7 @@ fn a_refused_injection_hands_back_the_vm_for_its_teardown() { let recorder = Recorder::default(); let failure = SectionVm::new(&test_nonce(), recorder.emitter(), "Seed") .expect("VM must build") - .inject_values_with_var( + .inject_values( "", &json!({}), &fresh_access(), @@ -176,7 +176,7 @@ fn section_vm_value_injection_bypasses_chunk_global_metatables() { "Test", ) .expect("VM must build") - .inject_values("private input", &json!({}), &fresh_access()) + .inject_test_values("private input", &json!({}), &fresh_access()) .expect("Engine values must inject"); let observer = null_emitter(); vm.install_engine_globals(&observer, "Test") @@ -289,7 +289,7 @@ fn section_lifecycle_reports_are_ordered_exact_and_payload_free() { let recorder = Recorder::default(); let vm = SectionVm::new(&test_nonce(), recorder.emitter(), "Gather") .expect("VM must build") - .inject_values("private input", &json!({}), &fresh_access()) + .inject_test_values("private input", &json!({}), &fresh_access()) .expect("Engine values must inject"); let observer = recorder.emitter().clone(); vm.install_engine_globals(&observer, "Gather") @@ -350,7 +350,7 @@ fn call_with_a_non_string_target_errors() { // `resolve_section_target` boundary as the Engine: a number is not a // heading, and the error says so. let vm = TestVm::new().build(); - vm.install_control_globals( + vm.install_test_control_globals( |target, _, _| resolve_section_target(target).map_err(crate::error::lua), |_| Err(crate::lua_runtime("prompt reads are not needed here")), ) diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index cccecd826..138d2b98c 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -94,7 +94,7 @@ pub(crate) fn install_tools( lua: &Lua, globals: &Table, set: &Arc, - runtime: &Arc>, + offers: &Arc>, adhoc_tools: &AdhocTools, list: &MessageList, ) -> Result<()> { @@ -104,7 +104,7 @@ pub(crate) fn install_tools( install_pack_list(lua, &tools, names)?; let offering = Offering { set: Arc::clone(set), - runtime: Arc::clone(runtime), + offers: Arc::clone(offers), adhoc: adhoc_tools.clone(), list: list.clone(), }; diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index 2f9c3d386..29354d76c 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -37,7 +37,7 @@ const TOOL_LIST_FIXED: &str = "the section's tool list is fixed at its first mod #[derive(Clone)] pub(super) struct Offering { pub(super) set: Arc, - pub(super) runtime: Arc>, + pub(super) offers: Arc>, pub(super) adhoc: AdhocTools, pub(super) list: MessageList, } @@ -61,14 +61,14 @@ pub(super) fn install_offer( .create_function(move |lua, args: Variadic| { refuse_after_render(&verb, &offering.list)?; let targets = offering.targets(lua, &verb, args)?; - let mut runtime = offering - .runtime + let mut offers = offering + .offers .lock() .map_err(|_| mlua::Error::external("section offers lock was poisoned"))?; for target in targets { match target { - OfferTarget::Tool(id) => runtime.add_tool(id, mode), - OfferTarget::Pack(pack) => runtime.add_pack(pack, mode), + OfferTarget::Tool(id) => offers.add_tool(id, mode), + OfferTarget::Pack(pack) => offers.add_pack(pack, mode), } } Ok(()) diff --git a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs index ff3699cc1..70f6191ed 100644 --- a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs +++ b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs @@ -266,8 +266,8 @@ fn a_wrapper_of_a_catalog_tool_takes_its_description_and_schema_unchanged() { tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, \ function(args) return tools.call(t, args) end)", ); - let (set, _) = vm.tool_bag_handles(); - let catalog = set + let (tool_set, _) = vm.tool_set_and_offers(); + let catalog = tool_set .catalog() .get(&runvil_types::tools::ToolId::parse("web/search").expect("a valid id")) .expect("the catalog holds the tool"); @@ -327,8 +327,8 @@ fn an_offered_adhoc_tool_counts_from_zero_and_an_unoffered_one_from_its_first_ca tools.adhoc({{ name = 'other', description = 'Other' }}, function() end)\n\ tools.offer(grab)" )); - let (set, runtime) = vm.tool_bag_handles(); - let ids: Vec<_> = crate::current_static_set(&set, &runtime, vm.adhoc_tools()) + let (tool_set, offers) = vm.tool_set_and_offers(); + let ids: Vec<_> = crate::current_static_set(&tool_set, &offers, vm.adhoc_tools()) .expect("the scope resolves") .iter() .map(|tool| tool.id().clone()) diff --git a/crates/runvil-internal/lua/src/tools/tests-render.rs b/crates/runvil-internal/lua/src/tools/tests-render.rs index f542b6d90..a0c6663e6 100644 --- a/crates/runvil-internal/lua/src/tools/tests-render.rs +++ b/crates/runvil-internal/lua/src/tools/tests-render.rs @@ -81,8 +81,8 @@ fn offers_that_share_a_wire_name_all_reach_the_merge_and_only_an_id_dedupes() { tools.offer(tools.adhoc({ name = 'web_search', description = 'Mine' }, function() end))\n\ tools.offer_dynamic('web')", ); - let (set, runtime) = vm.tool_bag_handles(); - let ids: Vec = crate::current_static_set(&set, &runtime, vm.adhoc_tools()) + let (tool_set, offers) = vm.tool_set_and_offers(); + let ids: Vec = crate::current_static_set(&tool_set, &offers, vm.adhoc_tools()) .expect("the static offers resolve") .iter() .map(|tool| tool.id().to_string()) @@ -141,8 +141,8 @@ fn each_catalog_pack_comes_once_where_one_of_its_tools_was_first_offered_into_ei tools.offer('gh/search')\n\ tools.offer_dynamic('web/fetch')", ); - let (set, runtime) = vm.tool_bag_handles(); - let packs: Vec = crate::current_catalog_packs(&set, &runtime, vm.adhoc_tools()) + let (tool_set, offers) = vm.tool_set_and_offers(); + let packs: Vec = crate::current_catalog_packs(&tool_set, &offers, vm.adhoc_tools()) .expect("the offers resolve") .iter() .map(ToString::to_string) @@ -171,8 +171,8 @@ fn dynamic_offers_leave_the_static_bindings_unchanged() { tools.offer_dynamic(tools.packs)", ); let bindings = |vm: &crate::SectionVm| { - let (set, runtime) = vm.tool_bag_handles(); - crate::current_static_set(&set, &runtime, vm.adhoc_tools()).expect("the scope resolves") + let (tool_set, offers) = vm.tool_set_and_offers(); + crate::current_static_set(&tool_set, &offers, vm.adhoc_tools()).expect("the scope resolves") }; assert_eq!(bindings(&plain), bindings(&with_dynamic)); } diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 737d3c6db..7dbb40a27 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -170,7 +170,7 @@ impl SectionVm { /// /// The VM shares an empty tool and model set, so the validating /// `tools.offer` installed by - /// [`inject_values_with_var`](Self::inject_values_with_var) refuses + /// [`inject_values`](Self::inject_values) refuses /// every id. /// /// # Errors @@ -228,7 +228,7 @@ impl SectionVm { /// /// The sets are the run's own handles, not snapshots: the run catalog /// and the filled roles back the validating `tools`/`models` tables that - /// [`inject_values_with_var`](Self::inject_values_with_var) installs. + /// [`inject_values`](Self::inject_values) installs. /// Nothing writes either set during a run. H1 is section 0 on this same /// path. /// diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index b7dee8d47..62a18c918 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -73,7 +73,7 @@ impl SectionVm { clippy::similar_names, reason = "args and argv are the spec'd global names; the pair is intentional" )] - pub fn inject_values_with_var( + pub fn inject_values( self, args: &str, sys: &Json, diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index fe3f87398..03eebcd16 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -81,7 +81,7 @@ impl SectionVm { /// lifecycle. /// /// Test-only: production installs `jump` and the `prompt` reads through - /// [`install_scheduler_control_globals`](Self::install_scheduler_control_globals) + /// [`install_control_globals`](Self::install_control_globals) /// and `call` as a yield shim. The /// callbacks own their run context, so the closures stay valid across /// every chunk this VM runs. The `jump` @@ -95,7 +95,7 @@ impl SectionVm { /// # Errors /// Returns a `LuaRuntime` failure if any global cannot be installed. #[cfg(test)] - pub(crate) fn install_control_globals(&self, call_callback: E, read: R) -> Result<()> + pub(crate) fn install_test_control_globals(&self, call_callback: E, read: R) -> Result<()> where E: Fn(Value, Option, Json) -> std::result::Result + Send + 'static, R: Fn(String) -> std::result::Result, Error> + Send + Sync + 'static, @@ -124,7 +124,7 @@ impl SectionVm { /// /// # Errors /// Returns a `LuaRuntime` failure if any global cannot be installed. - pub fn install_scheduler_control_globals(&self, read: R) -> Result<()> + pub fn install_control_globals(&self, read: R) -> Result<()> where R: Fn(String) -> std::result::Result, E> + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index ac07a509e..81fc59111 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -3,7 +3,7 @@ //! and the catalog Packs they come from, and its current model; //! `set_global_json` for fanout's `item`, and `tools.stats`. -use std::sync::{Arc, Mutex}; +use std::sync::Mutex; use mlua::{LuaSerdeExt, Value}; use runvil_model_client::model::ModelBinding; @@ -16,7 +16,7 @@ use crate::adhoc::AdhocTools; use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{self, Result, lua_runtime}; use crate::packs::is_adhoc; -use crate::scope::{OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, ToolCallCounts}; +use crate::scope::{OfferMode, OfferTarget, OfferedTool, Offers, ToolCallCounts}; use crate::tools::install_tool_call_counts as install_tool_call_counts_impl; use crate::var::var_to_json; @@ -104,13 +104,6 @@ impl SectionVm { ) } - /// Returns the section's frozen sets, or `None` when it has not frozen - /// them. - #[must_use] - pub fn rendered_tools(&self) -> Option> { - self.messages.rendered_tools() - } - /// Returns the section's current model: the binding `models.use` last /// stored, else the model the section started on. `None` means the caller /// supplied no model. @@ -142,11 +135,11 @@ impl SectionVm { /// tools are poisoned, or an offered id names no tool. pub fn current_static_set( set: &ToolSet, - runtime: &Mutex, + offers: &Mutex, adhoc: &AdhocTools, ) -> Result> { let mut tools: Vec = Vec::new(); - for tool in expanded(set, runtime, adhoc, Some(OfferMode::Static))? { + for tool in expanded(set, offers, adhoc, Some(OfferMode::Static))? { if !tools.iter().any(|held| held.id() == tool.id()) { tools.push(tool); } @@ -163,12 +156,12 @@ pub fn current_static_set( /// tools are poisoned, or an offered id names no tool. pub fn current_dynamic_set( set: &ToolSet, - runtime: &Mutex, + offers: &Mutex, adhoc: &AdhocTools, static_tools: &[OfferedTool], ) -> Result> { let mut tools: Vec = Vec::new(); - for tool in expanded(set, runtime, adhoc, Some(OfferMode::Dynamic))? { + for tool in expanded(set, offers, adhoc, Some(OfferMode::Dynamic))? { let held = static_tools .iter() .chain(&tools) @@ -190,11 +183,11 @@ pub fn current_dynamic_set( /// tools are poisoned, or an offered id names no tool. pub fn current_catalog_packs( set: &ToolSet, - runtime: &Mutex, + offers: &Mutex, adhoc: &AdhocTools, ) -> Result> { let mut packs: Vec = Vec::new(); - for tool in expanded(set, runtime, adhoc, None)? { + for tool in expanded(set, offers, adhoc, None)? { if let OfferedTool::Catalog(info) = tool { let pack = info.id.pack(); if !packs.contains(&pack) { @@ -211,19 +204,19 @@ pub fn current_catalog_packs( /// offer names. fn expanded( set: &ToolSet, - runtime: &Mutex, + offers: &Mutex, adhoc: &AdhocTools, mode: Option, ) -> Result> { - let runtime = runtime + let offers = offers .lock() .map_err(|_| error::internal("section offers lock was poisoned"))?; let mut tools = Vec::new(); - let offers = runtime + let held = offers .offers .iter() .filter(|offer| mode.is_none_or(|mode| offer.mode == mode)); - for offer in offers { + for offer in held { match &offer.target { OfferTarget::Tool(id) => { let tool = if is_adhoc(&id.pack()) { From 56b97661ca79349edd5f76054f1bbc235461a658 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:23:52 -0800 Subject: [PATCH 31/36] Raise the internal failure for poisoned locks in Lua The model selection, jump slot, and offers locks raised a bare string from Lua callbacks, so an author `pcall` saw kind `lua` for an Engine fault. Each callback now raises `error::internal` wrapped in `mlua::Error::external`, and each lock message is one constant, `SELECTION_POISONED`, `JUMP_SLOT_POISONED`, or `OFFERS_POISONED`, that the Rust-side reads share. New tests in `models-tests.rs` and `vm/tests.rs` poison each lock and assert that `pcall` reports kind `internal`. --- .../runvil-internal/lua/src/models-tests.rs | 20 +++++++++ crates/runvil-internal/lua/src/models.rs | 9 +++- crates/runvil-internal/lua/src/scope.rs | 3 ++ crates/runvil-internal/lua/src/tools/offer.rs | 4 +- crates/runvil-internal/lua/src/vm.rs | 5 +++ crates/runvil-internal/lua/src/vm/install.rs | 4 +- crates/runvil-internal/lua/src/vm/run.rs | 5 +-- crates/runvil-internal/lua/src/vm/state.rs | 7 ++-- crates/runvil-internal/lua/src/vm/tests.rs | 41 +++++++++++++++++++ 9 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 crates/runvil-internal/lua/src/vm/tests.rs diff --git a/crates/runvil-internal/lua/src/models-tests.rs b/crates/runvil-internal/lua/src/models-tests.rs index d09d9f422..513a8b065 100644 --- a/crates/runvil-internal/lua/src/models-tests.rs +++ b/crates/runvil-internal/lua/src/models-tests.rs @@ -477,3 +477,23 @@ fn a_poisoned_selection_reaches_the_caller_as_an_internal_error() { "the lock failure is Engine misuse, neither an authoring nor a model failure: {error:?}" ); } + +#[test] +fn a_poisoned_selection_raises_the_internal_failure_inside_lua() { + let mut models = ModelSet::default(); + models.push(bound_role("default", "m1")); + let vm = TestVm::new().models(models).shims().build(); + let poisoner = std::sync::Arc::clone(&vm.selection); + let _ = std::thread::spawn(move || { + let _guard = poisoner.lock(); + panic!("poison the model selection"); + }) + .join(); + + let kinds: (String, String) = vm.eval( + "local _, current = pcall(models.current)\n\ + local _, chosen = pcall(models.use, 'default')\n\ + return tostring(current.kind), tostring(chosen.kind)", + ); + assert_eq!(kinds, ("internal".to_owned(), "internal".to_owned())); +} diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index cf893e063..f697b56dc 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -59,9 +59,14 @@ fn raw_gateway_binding(alias: &str) -> mlua::Result { )) } -/// The error every selection read or write raises when its lock is poisoned. +/// The failure of a poisoned model selection lock. +pub(crate) const SELECTION_POISONED: &str = "model selection lock was poisoned"; + +/// The error a selection read or write inside a Lua callback raises when +/// its lock is poisoned. +#[track_caller] pub(crate) fn selection_poisoned() -> mlua::Error { - mlua::Error::external("model selection lock was poisoned") + mlua::Error::external(error::internal(SELECTION_POISONED)) } /// The type name the `models.use` target and `set_selection` refusals give diff --git a/crates/runvil-internal/lua/src/scope.rs b/crates/runvil-internal/lua/src/scope.rs index b6acad2e2..ff91eb0e2 100644 --- a/crates/runvil-internal/lua/src/scope.rs +++ b/crates/runvil-internal/lua/src/scope.rs @@ -128,6 +128,9 @@ impl OfferedTool { } } +/// The failure of a poisoned section offers lock. +pub(crate) const OFFERS_POISONED: &str = "section offers lock was poisoned"; + /// The offers one section VM made, in call order. #[derive(Debug, Default)] pub struct Offers { diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index 29354d76c..4e8d833d0 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -22,7 +22,7 @@ use crate::error::{self, Result}; use crate::messages::MessageList; use crate::packs::{LuaPackHandle, is_adhoc, pack_object}; use crate::proxy::key_text; -use crate::scope::{OfferMode, OfferTarget, Offers}; +use crate::scope::{OFFERS_POISONED, OfferMode, OfferTarget, Offers}; use crate::walk_budget::HELD_KEY_LIMIT; /// The refusal of an offer verb once the section's sets are frozen, @@ -64,7 +64,7 @@ pub(super) fn install_offer( let mut offers = offering .offers .lock() - .map_err(|_| mlua::Error::external("section offers lock was poisoned"))?; + .map_err(|_| mlua::Error::external(error::internal(OFFERS_POISONED)))?; for target in targets { match target { OfferTarget::Tool(id) => offers.add_tool(id, mode), diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 7dbb40a27..31e7db325 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -8,6 +8,8 @@ mod inject; mod install; mod run; mod state; +#[cfg(test)] +mod tests; use std::num::NonZeroU32; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; @@ -54,6 +56,9 @@ fn log_byte_budget(log_events: u32) -> usize { /// line of the author's `jump` call. type Jump = (String, Option); +/// The failure of a poisoned jump slot. +const JUMP_SLOT_POISONED: &str = "jump slot poisoned"; + /// Collects owned values into a 1-based Lua sequence table. fn pack_sequence(lua: &Lua, values: Vec) -> mlua::Result { let table = lua.create_table_with_capacity(values.len(), 0)?; diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 03eebcd16..384bb654a 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -10,7 +10,7 @@ use mlua::Value; use runvil_types::emitter::Emitter; use serde_json::Value as Json; -use super::{SectionVm, pack_sequence}; +use super::{JUMP_SLOT_POISONED, SectionVm, pack_sequence}; use crate::coro::install_shims; use crate::engine_globals::{install_log, install_store_table}; #[cfg(test)] @@ -233,7 +233,7 @@ impl SectionVm { let line = error::author_line(lua); let mut slot = jump_slot .lock() - .map_err(|_| mlua::Error::external("jump slot poisoned"))?; + .map_err(|_| mlua::Error::external(error::internal(JUMP_SLOT_POISONED)))?; *slot = Some((heading, line)); Err(mlua::Error::external("jump transfer")) }) diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 965a43603..7bf640f3a 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -5,7 +5,7 @@ use mlua::thread::ThreadStatus; use mlua::{MultiValue, Thread, Value}; use runvil_types::error::{ErrorCode, place}; -use super::{Jump, SectionVm}; +use super::{JUMP_SLOT_POISONED, Jump, SectionVm}; use crate::coro::{StashedFailure, block_guard, take_failure}; use crate::error::{self, Error, Result, interrupted, prompt_file, set_call_line}; use crate::error_value::{ErrorKind, error_kind}; @@ -246,9 +246,6 @@ impl SectionVm { } } -/// The failure of a poisoned jump slot. -const JUMP_SLOT_POISONED: &str = "jump slot poisoned"; - /// Whether `mapped` is a cancellation or an exhausted Engine quota, which /// keep their mapping whatever the block raised. fn is_fatal(mapped: &Error) -> bool { diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 81fc59111..5d49cf732 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -15,8 +15,9 @@ use super::SectionVm; use crate::adhoc::AdhocTools; use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{self, Result, lua_runtime}; +use crate::models::SELECTION_POISONED; use crate::packs::is_adhoc; -use crate::scope::{OfferMode, OfferTarget, OfferedTool, Offers, ToolCallCounts}; +use crate::scope::{OFFERS_POISONED, OfferMode, OfferTarget, OfferedTool, Offers, ToolCallCounts}; use crate::tools::install_tool_call_counts as install_tool_call_counts_impl; use crate::var::var_to_json; @@ -115,7 +116,7 @@ impl SectionVm { .selection .lock() .map(|selection| selection.clone()) - .map_err(|_| error::internal("model selection lock was poisoned")) + .map_err(|_| error::internal(SELECTION_POISONED)) } } @@ -210,7 +211,7 @@ fn expanded( ) -> Result> { let offers = offers .lock() - .map_err(|_| error::internal("section offers lock was poisoned"))?; + .map_err(|_| error::internal(OFFERS_POISONED))?; let mut tools = Vec::new(); let held = offers .offers diff --git a/crates/runvil-internal/lua/src/vm/tests.rs b/crates/runvil-internal/lua/src/vm/tests.rs new file mode 100644 index 000000000..1832468b9 --- /dev/null +++ b/crates/runvil-internal/lua/src/vm/tests.rs @@ -0,0 +1,41 @@ +//! Tests for the VM's own locks as Lua callbacks take them: a poisoned +//! lock raises the internal failure, which no author `pcall` mistakes for +//! a Lua error. + +use std::sync::{Arc, Mutex}; + +use crate::test_vm::{TestVm, offer_set}; + +/// Poisons `lock` by panicking on another thread while holding it. +fn poison(lock: &Arc>) { + let held = Arc::clone(lock); + let _ = std::thread::spawn(move || { + let _guard = held.lock(); + panic!("poison the lock"); + }) + .join(); +} + +#[test] +fn a_poisoned_jump_slot_raises_the_internal_failure_inside_lua() { + let vm = TestVm::new().shims().build(); + poison(&vm.jump_slot); + let kind: String = vm.eval( + "local ok, err = pcall(jump, '## Next')\n\ + assert(not ok, 'jump must raise')\n\ + return tostring(err.kind)", + ); + assert_eq!(kind, "internal"); +} + +#[test] +fn a_poisoned_offers_lock_raises_the_internal_failure_inside_lua() { + let vm = TestVm::new().tools(offer_set()).shims().build(); + poison(vm.offers()); + let kind: String = vm.eval( + "local ok, err = pcall(tools.offer, 'web/search')\n\ + assert(not ok, 'the offer must raise')\n\ + return tostring(err.kind)", + ); + assert_eq!(kind, "internal"); +} From 5a1f1f9d242752d3da3d5c73b5eefe19299bbacb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:08:28 -0800 Subject: [PATCH 32/36] Correct the Lua crate docs and narrow `exhaustion_detail` The crate doc now says a failed store op surfaces as that store failure, names all four items the facade re-exports, and points to `install_preludes` for the prelude rules instead of restating them. The README now says that store calls a Pack prelude makes while it installs run directly against the run's store view. `exhaustion_detail` has only in-crate users, so it becomes `pub(crate)` and leaves the root re-export. --- crates/runvil-internal/lua/README.md | 2 +- crates/runvil-internal/lua/src/compactors.rs | 4 ++-- crates/runvil-internal/lua/src/lib.rs | 24 +++++++++----------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/crates/runvil-internal/lua/README.md b/crates/runvil-internal/lua/README.md index 0bfcc87a0..ef91ab014 100644 --- a/crates/runvil-internal/lua/README.md +++ b/crates/runvil-internal/lua/README.md @@ -1,3 +1,3 @@ # Engine Lua runtime -This crate runs each section's Lua code in a fresh, restricted sandbox and installs the Engine globals for the run's store, models, tools, and tasks, and the section's one `messages` conversation. An Engine call that waits on the outside world suspends the Lua code and hands a request to the executor, so the sandbox itself performs no I/O. Packs can add Lua globals of their own, checked against a reserved-name list. +This crate runs each section's Lua code in a fresh, restricted sandbox and installs the Engine globals for the run's store, models, tools, and tasks, and the section's one `messages` conversation. Outside a Pack prelude's install, an Engine call that waits on the outside world suspends the Lua code and hands a request to the executor, so the sandbox performs no I/O of its own; store calls a prelude makes while it installs run directly against the run's store view. Packs can add Lua globals of their own, checked against a reserved-name list. diff --git a/crates/runvil-internal/lua/src/compactors.rs b/crates/runvil-internal/lua/src/compactors.rs index ec6c5c6db..be5ead801 100644 --- a/crates/runvil-internal/lua/src/compactors.rs +++ b/crates/runvil-internal/lua/src/compactors.rs @@ -35,7 +35,7 @@ use crate::messages::MessageList; /// Why a request cannot fit the model's context window. /// /// These are the two overflow tags the loop passes to a compactor, which -/// [`exhaustion_detail`] turns into a description. A compactor may also +/// `exhaustion_detail` turns into a description. A compactor may also /// receive a caller's own reason through `models.compact`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OverflowReason { @@ -88,7 +88,7 @@ impl std::fmt::Display for OverflowReason { /// `OverflowReason::tag` reads as that overflow's [`Display`](std::fmt::Display), /// and a caller's own reason reads as itself. #[must_use] -pub fn exhaustion_detail(reason: &str) -> Cow<'_, str> { +pub(crate) fn exhaustion_detail(reason: &str) -> Cow<'_, str> { match OverflowReason::from_tag(reason) { Some(overflow) => Cow::Owned(overflow.to_string()), None => Cow::Borrowed(reason), diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 44a602314..81f7dc6b4 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -27,15 +27,14 @@ //! in a run shares one set of virtual files even though contexts clear on each //! transition. A failed store op raises a structured error value of kind //! `store` carrying its `reason` and fields, which surfaces from the -//! block's coroutine (`SectionVm::start_block_coro` and its resumes) as a -//! Lua-category error. +//! block's coroutine (`SectionVm::start_block_coro` and its resumes) as +//! that store failure, never as a Lua runtime error. //! //! The VM also holds `tasks`, the shims for spawning, -//! waiting on, checking, noting, and cancelling tasks. Pack preludes -//! are the Lua source each Pack the prompt declares contributes: each -//! runs once per VM in an environment of its own before `require` -//! installs, and its globals are checked against the reserved-name list and -//! raw-set into `_G`. The `input` table that `user-input` defines is one: +//! waiting on, checking, noting, and cancelling tasks. Each Pack the +//! prompt declares can contribute a prelude, Lua that defines globals once +//! per VM before `require` installs ([`install_preludes`] states the +//! rules). The `input` table that `user-input` defines is one such global: //! `input.ask()` is an ordinary tool call to that Pack's ask tool. //! //! The `tools` table names every tool by its id: `tools.get` reads a tool @@ -54,7 +53,9 @@ //! //! Most of this crate's public items exist for `runvil-engine`'s //! executor, which drives the VM and the coroutine protocol; the facade -//! re-exports only the store protocol ([`VfsOp`], [`VfsOutcome`]). The +//! re-exports the store protocol ([`VfsOp`], [`VfsOutcome`], +//! [`store_error`]) and the roster entry ([`RosterEntry`]); +//! `crates/runvil/public-api.txt` holds the full surface. The //! `test_support` module, built for this crate's tests and under the //! `test-support` feature, holds what tests reach beyond that surface. //! @@ -119,17 +120,14 @@ pub(crate) mod test_vm; #[cfg(test)] mod tests; -// The executor-facing surface: every item `runvil-engine` names crosses -// here. The facade re-exports only `VfsOp` and `VfsOutcome`. +// The executor-facing surface: every item `runvil-engine` names crosses here. pub use crate::adhoc::AdhocTools; pub use crate::argv::Argv; pub use crate::bindings::{ DiscoveryTool, RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of, }; pub use crate::collection::render_item; -pub use crate::compactors::{ - OverflowReason, UsageAnchor, exhaustion_detail, output_reserve, precheck, -}; +pub use crate::compactors::{OverflowReason, UsageAnchor, output_reserve, precheck}; pub use crate::coro::install_store_shims; pub use crate::dispatch::{ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch}; pub use crate::engine_globals::{ From e84d52f43a78e9a33a2e84db3e32de4f2813f72d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 4 Nov 2026 02:11:16 -0800 Subject: [PATCH 33/36] Correct small Lua module docs and reuse `cancelled` The `hardening`, `iteration`, `var`, and `walk_budget` docs now name every case their code handles: the `table.concat` wrapper, the unordered resume of `next`, the `tasks.spawn` capture of `var`, and the offer and options entries `HELD_KEY_LIMIT` bounds. The `argv` module doc and `guarded_var` now point to `Argv::Frozen` and the registry constants instead of restating them. `CancelHook::is_cancelled` now calls `cancelled`, so the cancel flag read has one body. --- crates/runvil-internal/lua/src/argv.rs | 3 +-- crates/runvil-internal/lua/src/hardening.rs | 6 +++--- crates/runvil-internal/lua/src/iteration.rs | 7 ++++--- crates/runvil-internal/lua/src/var.rs | 18 ++++++------------ crates/runvil-internal/lua/src/walk_budget.rs | 15 ++++++++------- 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/crates/runvil-internal/lua/src/argv.rs b/crates/runvil-internal/lua/src/argv.rs index 0764dd624..2b04753c9 100644 --- a/crates/runvil-internal/lua/src/argv.rs +++ b/crates/runvil-internal/lua/src/argv.rs @@ -4,8 +4,7 @@ //! a plain writable value, so the repair pattern lives there: read the //! broken input from `args`, assign `argv = repaired`, and the executor //! reads the value back when the pass completes. Every other section gets -//! the frozen value: reads work (absent fields read nil), and any -//! assignment - `argv = ...` or `argv.field = ...` at any depth - raises. +//! the frozen value ([`Argv::Frozen`]). //! //! The freeze sits on the `_G` guard ([`crate::globals`]): `argv` is never //! a raw global in a frozen section, so every read and every write of the diff --git a/crates/runvil-internal/lua/src/hardening.rs b/crates/runvil-internal/lua/src/hardening.rs index e73173876..7e94c1303 100644 --- a/crates/runvil-internal/lua/src/hardening.rs +++ b/crates/runvil-internal/lua/src/hardening.rs @@ -1,5 +1,5 @@ -//! Sandbox hardening for section VMs: the `_G` guard, global removal, and -//! the cancellation hook. +//! Sandbox hardening for section VMs: the `_G` guard, global removal, the +//! `table.concat` wrapper, and the cancellation hook. use std::sync::{Arc, OnceLock}; @@ -128,7 +128,7 @@ impl CancelHook { /// Returns whether the installed cancel flag is set, `false` when no /// executor installed one. pub(crate) fn is_cancelled(&self) -> bool { - self.cancel.get().is_some_and(CancelHandle::is_cancelled) + cancelled(&self.cancel) } } diff --git a/crates/runvil-internal/lua/src/iteration.rs b/crates/runvil-internal/lua/src/iteration.rs index 74c542e1b..20327ee7e 100644 --- a/crates/runvil-internal/lua/src/iteration.rs +++ b/crates/runvil-internal/lua/src/iteration.rs @@ -232,9 +232,10 @@ fn ordered_keys(table: &Table) -> mlua::Result> { } /// The index in `keys` to return for a `next` call with `previous` as the -/// last key. An exact match advances one slot. A key whose value was cleared -/// (`previous` absent) resumes at the first key that sorts strictly after it, -/// so the cleared key is skipped rather than revisited. +/// last key. An exact match advances one slot. A cleared array or scalar key +/// (`previous` absent) resumes at the first key that sorts strictly after it; +/// a cleared unordered key resumes at the start of the unordered segment, +/// the fallback the module doc describes. fn next_index(keys: &[WalkKey], previous: &Value) -> Option { if matches!(previous, Value::Nil) { return (!keys.is_empty()).then_some(0); diff --git a/crates/runvil-internal/lua/src/var.rs b/crates/runvil-internal/lua/src/var.rs index 2c35b5ce0..a8fb0dad2 100644 --- a/crates/runvil-internal/lua/src/var.rs +++ b/crates/runvil-internal/lua/src/var.rs @@ -150,12 +150,9 @@ fn materialize_guarded_value( /// removes the key. Every nested table is itself an empty proxy over hidden /// data, so later incremental writes cross the same validation boundary instead /// of mutating a stored table directly. `__metatable` is set so author code -/// cannot replace the guard. The root data table is stashed in the Lua registry -/// (unreachable from sandboxed author code) and is the read-back source for -/// [`var_to_json`], which materializes nested proxies before serde conversion; -/// proxies never hold entries themselves. The root proxy is stashed alongside -/// it so [`var_to_json`] can reject an author reassigning the `var` global -/// instead of silently reading stale data. +/// cannot replace the guard. The root data table and the proxy live under +/// [`VAR_DATA_REGISTRY`] and [`VAR_PROXY_REGISTRY`] for [`var_to_json`]'s +/// read-back. pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result { let initial = match initial { Some(Json::Object(values)) => Json::Object(values.clone()), @@ -179,9 +176,6 @@ pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result) -> Result