diff --git a/AGENTS.md b/AGENTS.md index bc52ba28b..fc292ce57 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 @@ -47,7 +47,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - A Cargo feature gates a real constraint such as a toolchain requirement or heavy native build. It does not describe product shape. Feature-disabled builds must not leak optional types into core paths. - Runtime and serve paths never compile native dependencies or invoke build tools. - Unsafe code stays in its explicitly owned boundary. -- Engine and Harness crates report every failure as the universal `Error` in `runvil-types`, which the `runvil` facade re-exports. A new kind of failure is a new `ErrorCode` variant in its hundreds family, with a number never used before, plus a `FactKey` variant only when no existing key names the value. They add no other error enum or struct, no `thiserror` derive, and no other `impl std::error::Error`; a foreign cause joins the message through `Error::with_cause`. `VfsError` and `ParsePathReasonError` in `runvil-vfs` are the only exceptions, and test code may define a throwaway error type to exercise `with_cause`. +- Engine and Harness crates report every failure as the universal `Error` in `runvil-types`, which the `runvil` facade re-exports. A new kind of failure is a new `ErrorCode` variant in its hundreds family, with a number never used before, plus a `FactKey` variant only when no existing key names the value. They add no other error enum or struct, no `thiserror` derive, and no other `impl std::error::Error`; a foreign cause joins the message through `Error::with_cause`. `VfsError` and `ParsePathReasonError` in `runvil-vfs` are the only exceptions, and test code may define a throwaway error type to exercise `with_cause`. A crate-private marker returned as `Err` and turned into `Error` by its caller, or a type that pairs an `Error` with a value the caller must still release, such as `InjectFailure`, is not another error type. - Comments explain a non-obvious constraint, ordering requirement, or workaround. Every platform or external-bug workaround cites its upstream issue URL in the explanatory comment. - JSON that reaches a recorder or a replay comparison round-trips exactly - `to_value`, `to_string`, `from_str` yield an identical value, object keys stay canonical (sorted), numbers must be finite. Exact parsing (`float_roundtrip`) carries that guarantee; a value derived and then logged is additionally rounded to its meaningful precision at the source. diff --git a/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/error.rs b/crates/runvil-internal/engine/src/error.rs index 6e58cefcb..b8ac83d6c 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -20,76 +20,23 @@ 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; -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; -/// 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 -} - -/// 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 -/// 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. @@ -141,18 +88,17 @@ 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`. 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!("{via} recursion exceeded cap of {cap}"), + format!("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 @@ -172,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/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-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/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index a71c59ed7..5f890de1a 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -193,25 +193,17 @@ 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", - ), - ] { - let error = call_depth_exceeded(8, via); - assert_eq!(error.code, ErrorCode::CallDepthExceeded); - assert_eq!(error.message, text); - assert_eq!(error.facts.get(&FactKey::Reason), Some(&Value::from(tag))); - assert_eq!(error.facts.get(&FactKey::Limit), Some(&Value::from(8))); - assert_eq!(failed(&error), (RunErrorKind::Quota, text.to_owned())); - assert_eq!( - (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] @@ -364,41 +356,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); - 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" - ); +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.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.rs b/crates/runvil-internal/engine/src/execute.rs index 5b8933d8f..ca14ebc3a 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 + /// 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/config-limits.rs b/crates/runvil-internal/engine/src/execute/config-limits.rs index 6479b668b..07ffde64d 100644 --- a/crates/runvil-internal/engine/src/execute/config-limits.rs +++ b/crates/runvil-internal/engine/src/execute/config-limits.rs @@ -10,8 +10,7 @@ use std::num::{NonZero, NonZeroU32, NonZeroUsize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub struct RunLimits { - /// The default maximum number of tool iterations per `models.loop` or - /// model handle `loop` call. + /// The default maximum number of tool iterations per `models.loop` call. /// /// A tool iteration is one model round of such a call; `models.infer` /// rounds do not count. A prompt's frontmatter `max_tool_iterations` diff --git a/crates/runvil-internal/engine/src/execute/context.rs b/crates/runvil-internal/engine/src/execute/context.rs index 67cdf80bd..d2098a6bd 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,15 +324,14 @@ 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` - /// call: the frontmatter's `max_tool_iterations` over the limits - /// default. + /// The resolved tool-loop cap per `models.loop` call: the frontmatter's + /// `max_tool_iterations` over the limits default. fn max_tool_iterations(&self) -> usize { self.shared .prompt diff --git a/crates/runvil-internal/engine/src/execute/error-tests.rs b/crates/runvil-internal/engine/src/execute/error-tests.rs index 25a034705..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; @@ -256,14 +278,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 0cb24e80b..8dd524648 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; @@ -197,10 +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 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 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) { @@ -215,12 +219,16 @@ 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 .get(&FactKey::Name) + .filter(|_| error.code != ErrorCode::FrontmatterInvalid) .and_then(serde_json::Value::as_str) .map(|name| format!("prompt {name}")), error @@ -237,11 +245,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 {}:{}", at.file, at.start_line)), - _ => None, + .map(|at| format!("at {}", coordinates(at))), } } 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/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..4d21a7e70 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; @@ -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, @@ -317,7 +316,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 +331,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 e22b345ba..7606d953d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -4,28 +4,28 @@ //! 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; -use crate::error::DepthVia; use crate::execute::context::ChainState; 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 @@ -55,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 @@ -74,12 +86,11 @@ 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 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, via)); + return Err(error::call_depth_exceeded(MAX_CALL_DEPTH)); } Ok(CallDepth(depth)) } @@ -133,61 +144,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/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/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index 36606705e..6033be789 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -16,9 +16,9 @@ use std::sync::Arc; +use runvil_types::error::place; 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}; @@ -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(DepthVia::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 @@ -241,12 +233,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/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/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 f99c7239d..c8fc3aed5 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -13,9 +13,8 @@ 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::lua::Answer; use crate::{Result, error}; use super::super::admission::SpawnRecord; @@ -29,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, @@ -39,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 @@ -54,18 +50,18 @@ 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 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, @@ -73,17 +69,14 @@ 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 => DepthVia::Fanout, - SpawnVia::Spawn => DepthVia::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. 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, @@ -157,6 +150,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/tool_call-discovery.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs index 459c584f7..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), @@ -148,12 +149,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..e3d53144a 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,16 +214,16 @@ 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); } } 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/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/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/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..b2f280e83 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}; @@ -66,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, @@ -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(()) } 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 c85ff8c62..8c347b18b 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; @@ -76,8 +77,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. @@ -106,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, @@ -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 @@ -143,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, @@ -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 { @@ -184,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.rs b/crates/runvil-internal/engine/src/execute/tests.rs index da4de1cb5..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, 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/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..949a57b7b 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,50 @@ 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] +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] @@ -121,6 +164,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 @@ -333,8 +377,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 +391,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 +414,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 +438,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/model_and_reply-handles.rs b/crates/runvil-internal/engine/src/execute/tests/model_and_reply-handles.rs index ee68ac2c2..f78cb860c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/model_and_reply-handles.rs +++ b/crates/runvil-internal/engine/src/execute/tests/model_and_reply-handles.rs @@ -1,4 +1,4 @@ -//! `models.get` and a handle's `infer`: a handle leaves the section +//! `models.get` and `models.infer(handle, ...)`: a handle leaves the section //! model alone, `models.infer` uses the section model and never binds //! `reply`, `models.use` re-selection steers the next round, a run whose //! caller gave no model fails `models.infer`, and a handle's `provider` reads @@ -101,7 +101,7 @@ fn handle_infer_uses_that_model_regardless_of_the_section_model() { assert_eq!( body.options.model(), "strong-model", - "a handle's infer must use the handle's model, not the section default" + "models.infer on a handle must use the handle's model, not the section default" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs index 8bd8d9afc..4a9e1aefa 100644 --- a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs +++ b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs @@ -1,6 +1,6 @@ //! Tests for section model selection and the prologue and epilog phases. //! The `sys.model`, `item`, and `reply` reads sit in `globals`, and the -//! `models.get` and handle `infer` cases in `handles`. +//! `models.get` and `models.infer(handle, ...)` cases in `handles`. use runvil_prompt::test_support::{PromptSource, parse_ok}; 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.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/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 f92abba65..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 @@ -60,48 +60,59 @@ 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 + // Pin of the fanout depth-cap guard: the cap fires from the requesting + // 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 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(); + 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, - DepthVia::Fanout - )), - "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" + ); +} + +#[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" ); } @@ -233,6 +244,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..0ec26fd0a 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,16 +1,17 @@ //! 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; #[test] fn h1_infer_runs_once() { - // The H1 pass selects the default model by label, a handle's `infer` - // yields through the shim, and the H1 `var` hand-off seeds the walk. + // The H1 pass selects the default model by label, `models.infer` on that + // handle yields through the shim, and the H1 `var` hand-off seeds the walk. let gateway = ScriptedChat::new(vec![text("h1 answer")]); let md = "---\nname: live-h1\ndescription: d\nrunvil: 0\n---\n\n\ # Live H1\n\n\ @@ -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/chains.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/chains.rs index a22e97c80..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,12 +251,8 @@ 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, - DepthVia::Call - )), - "the depth cap must fail the run, got {error:?}" + 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!( 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 419e50c26..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 @@ -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,14 +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, - DepthVia::Call - )), - "expected the typed call-depth error" + &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/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/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/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/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 c48d9dc69..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,13 +8,15 @@ 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 /// 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 +27,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 +35,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 +43,48 @@ 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] +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/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/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/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 7bca0e390..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) ); } @@ -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:?}" ); } @@ -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/tasks.rs b/crates/runvil-internal/engine/src/execute/tests/tasks.rs index dffa86b47..749b80046 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\ @@ -257,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\ @@ -289,9 +290,9 @@ 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, "recursion exceeded cap of 8") ); let refusals = records .iter() @@ -321,7 +322,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_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/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/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/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/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/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 84bb0bbc4..3ca57a54c 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. @@ -18,14 +18,13 @@ pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, - Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, ModelReport, - NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, 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, + 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, }; // The store operation behind `execute::perform_vfs_op`, the entry point diff --git a/crates/runvil-internal/engine/src/lua/tests.rs b/crates/runvil-internal/engine/src/lua/tests.rs index f8f66291f..03d0220b8 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 @@ -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/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/lua/tests/shims.rs b/crates/runvil-internal/engine/src/lua/tests/shims.rs index 285343e36..81edcda3a 100644 --- a/crates/runvil-internal/engine/src/lua/tests/shims.rs +++ b/crates/runvil-internal/engine/src/lua/tests/shims.rs @@ -1,16 +1,15 @@ //! The yield shims installed on a scheduler-mode section VM produce //! well-formed protocol requests: `models.infer`, `call`, `tasks.fanout`, and //! `tools.call` in their id and tool-object forms, the adhoc tool -//! handshake, the loop's batch and its requesting turn, a model handle's -//! `infer` method, and the `infer` and `loop` methods a model handle -//! carries. +//! handshake, the loop's batch and its requesting turn, `models.infer` with a +//! leading model handle, and a model handle's fields and lack of methods. 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 +48,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 +61,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 +107,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/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/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 8687d9130..22b8ffbe8 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -59,25 +59,40 @@ 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)) +} + +/// 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. +#[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/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/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/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/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index d7dffe23c..a57eda92d 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -1,8 +1,8 @@ --- 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 --- 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 @@ -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) @@ -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/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index c73fd4188..0948588d7 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -4,12 +4,13 @@ -- -- 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 -- 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 @@ -71,10 +72,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 +152,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/__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/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index ae05f553f..80888354f 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -4,12 +4,13 @@ -- -- 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 -- 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/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/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/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/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/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/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/coro-failure.rs b/crates/runvil-internal/lua/src/coro-failure.rs index b49ad049c..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 @@ -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| { @@ -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 @@ -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 ab6e2257f..872bb2afa 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -6,12 +6,11 @@ //! (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 -//! 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. +//! (`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. 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 @@ -41,31 +40,38 @@ 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`. +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 @@ -74,27 +80,23 @@ const FANOUT_SOURCE: &str = include_str!("__impl_fanout.lua"); 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)); @@ -104,12 +106,13 @@ 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` -/// tables are passed to the shim chunk as arguments, so the chunk never -/// reads a global; the chunk shims `models.infer` and installs +/// 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 /// 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 @@ -135,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 @@ -151,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>>, @@ -162,7 +162,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,10 +245,27 @@ pub(crate) fn install_shim_prelude( Ok(()) } -/// Runs the `fanout` chunk over the prelude's `yield` and `var_snapshot` +/// 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 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, @@ -341,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"))] { @@ -354,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)> { @@ -375,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` @@ -385,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) } @@ -395,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/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/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index 8412f21a2..4c1ccb05d 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -12,18 +12,16 @@ 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}; 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,14 +29,13 @@ 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)); /// 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 @@ -82,8 +79,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 +95,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) @@ -232,10 +229,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 @@ -418,7 +415,13 @@ pub fn run_store_op(view: &Access, op: VfsOp) -> std::result::Result 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..250454ea3 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::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. 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,48 +99,44 @@ 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` 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() { + 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 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-universal.rs b/crates/runvil-internal/lua/src/error-universal.rs index 6422cf893..cda17d3ee 100644 --- a/crates/runvil-internal/lua/src/error-universal.rs +++ b/crates/runvil-internal/lua/src/error-universal.rs @@ -1,12 +1,13 @@ -//! 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; -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 +15,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-value-classify.rs b/crates/runvil-internal/lua/src/error-value-classify.rs index bfce35890..ea231177f 100644 --- a/crates/runvil-internal/lua/src/error-value-classify.rs +++ b/crates/runvil-internal/lua/src/error-value-classify.rs @@ -1,12 +1,16 @@ //! 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::chunk_line::first_prompt_line; 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, }; /// 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-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-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-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-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(), } } diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index b5f4b659a..c7426a5b9 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -6,10 +6,13 @@ //! 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 (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; @@ -35,13 +38,15 @@ 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; 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 @@ -74,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 @@ -97,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. @@ -221,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. @@ -281,15 +284,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..d7e779eb4 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::{ + 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, task_consumed, task_not_owned, tool_loop_exhausted, @@ -56,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/globals.rs b/crates/runvil-internal/lua/src/globals.rs index bf2004faf..2aefa3271 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -42,19 +42,17 @@ use mlua::{AnyUserData, Function, Lua, Table, Value}; use crate::error::{self, Error, Result}; use crate::program::LuaProgram; -/// The guard chunk's name, `@`-prefixed so PUC renders it verbatim as a -/// file path, as the shim chunks' names are. +/// The guard chunk's name. const GLOBALS_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_globals.lua"; -/// The guard source, embedded verbatim so chunk line 1 is file line 1. +/// The guard source. const GLOBALS_SOURCE: &str = include_str!("__impl_globals.lua"); /// The registry key of the slot table the guard chunk reads: `argv_frozen` /// and `argv`, `prose`, `messages`, and `author`. const STATE_REGISTRY: &str = "runvil.globals.state"; -/// The guard program, compiled once and loaded per VM, under the shim -/// programs' failure contract. +/// The guard program. static GLOBALS_PROGRAM: LazyLock> = 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..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}; @@ -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 @@ -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/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/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 73ec5a623..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. //! @@ -66,10 +67,20 @@ //! - 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 adhoc; mod argv; mod bindings; +mod chunk_line; mod collection; mod compactors; mod coro; @@ -109,28 +120,26 @@ 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::{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::bindings::{ + DiscoveryTool, RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of, }; +pub use crate::collection::render_item; +pub use crate::compactors::{OverflowReason, UsageAnchor, 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_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::{ - 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; @@ -139,15 +148,12 @@ 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, -}; -pub use crate::scope::{ - DiscoveryTool, Offer, OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, - ToolCallCounts, + MessageRecord, ModelCall, NonEmpty, RecordRole, Request, TaskDelivery, TaskPhase, TaskStatus, + ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; +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/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/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. 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 791cce1c5..f697b56dc 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}")) @@ -58,13 +59,19 @@ 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 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", @@ -110,14 +117,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 +245,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..550d26c72 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. @@ -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); } } @@ -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)?; @@ -437,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/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/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:?}"); } } diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index 6443386b7..f1dfc1be1 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -19,18 +19,15 @@ 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; -use crate::vm::AdhocTools; /// The id of the adhoc Pack, which holds every tool `tools.adhoc` defines. pub(crate) const ADHOC_PACK: &str = "adhoc"; @@ -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/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 57e31349f..2ff69b540 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,9 @@ 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; +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. const FILE: &str = "agents/a.md"; @@ -87,12 +89,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 +121,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 +222,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..23ebecd27 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -7,10 +7,18 @@ 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, }; +/// 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 +84,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, @@ -97,14 +108,12 @@ 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| { - 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(), @@ -141,7 +150,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 @@ -189,19 +201,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 +223,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 +241,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)) @@ -239,109 +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]) - }) - .and_then(NonZeroU32::new) - .unwrap_or(source_line) -} - -/// Rewrites chunk-relative line numbers for the named chunks in `owners`, -/// each a chunk name, the display location it maps to, and the -/// prompt-source line its chunk starts at, to absolute prompt-source lines, -/// 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()); - if let Some(absolute) = absolute_line(*source_line, &after[..digit_end]) { - 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 prompt file and its - // absolute line: `briefer.md:51: ...` - ( - format!("{location}:{absolute}: {result}"), - NonZeroU32::new(absolute), - ) - } else { - (result, None) - } -} - -/// 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, - } -} - #[cfg(test)] #[path = "program-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/projection-tests.rs b/crates/runvil-internal/lua/src/projection-tests.rs index 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/prose.rs b/crates/runvil-internal/lua/src/prose.rs index ea0062b68..e26e15db2 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 = @@ -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/protocol.rs b/crates/runvil-internal/lua/src/protocol.rs index 451c02a5d..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; @@ -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/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 993dc036d..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. @@ -238,20 +239,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/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/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 65c6164a7..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. @@ -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 @@ -199,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..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}; @@ -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, diff --git a/crates/runvil-internal/lua/src/protocol/record.rs b/crates/runvil-internal/lua/src/protocol/record.rs index 810608618..ccbcadcc6 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)] @@ -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) => { diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 290822a72..adceba9ea 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. @@ -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)?; @@ -98,20 +99,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, -/// author prose for a failure the prompt's Lua caused, and a branching -/// one reads `kind` - and the typed error is -/// retained beside that table, which identifies its raise. A successful -/// `join_any` whose member failed retains the member's error the same -/// way, since a shim may re-raise it at once. +/// `(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/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 459c81ed4..d2f8f107a 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 @@ -54,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. @@ -79,9 +68,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 @@ -198,10 +184,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 @@ -297,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. diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index 8cf072584..b805b0f21 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] @@ -166,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"), @@ -174,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, @@ -239,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/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/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(); 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/require.rs b/crates/runvil-internal/lua/src/require.rs index 165444979..d0a34fe37 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}; @@ -27,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)); @@ -55,8 +55,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 +73,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 +131,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 +163,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 +181,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/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..ff91eb0e2 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 @@ -129,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 { @@ -163,8 +165,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 +190,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/section.rs b/crates/runvil-internal/lua/src/section.rs index df3da86b8..5597622d1 100644 --- a/crates/runvil-internal/lua/src/section.rs +++ b/crates/runvil-internal/lua/src/section.rs @@ -2,15 +2,50 @@ //! `call` or `jump` target, the text a prompt read sees, and the library //! sections `require` loads. -use mlua::Value; +use std::num::NonZeroU32; + +use mlua::{MultiValue, Value}; + +use crate::error::{Result, lua_runtime}; /// Outcome of a Lua block that may invoke `jump`. #[derive(Debug, Clone, PartialEq, Eq)] 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), +} + +/// 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. @@ -78,4 +113,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/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 0f6e3bcd5..1d3da2eca 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. /// @@ -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 @@ -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) } } @@ -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)?)) @@ -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 fec9d7f10..cdb6404e4 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. @@ -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| { @@ -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() @@ -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.rs b/crates/runvil-internal/lua/src/tests.rs index 197f34331..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; @@ -80,10 +81,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. @@ -223,7 +224,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}" ))), } @@ -347,12 +348,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..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::program::map_chunk_line_to_absolute; -use crate::vm::AdhocTools; +use crate::adhoc::AdhocTools; +use crate::chunk_line::map_chunk_line_to_absolute; #[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!( @@ -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/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 625c4e73c..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", ) @@ -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/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/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/tools.rs b/crates/runvil-internal/lua/src/tools.rs index 2356082ed..138d2b98c 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 @@ -22,18 +22,18 @@ 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::{ToolBinding, ToolSet}; +use crate::adhoc::AdhocTools; +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::vm::AdhocTools; +use crate::scope::{OfferMode, Offers}; mod decode; mod objects; @@ -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. /// @@ -109,7 +94,7 @@ pub(crate) fn install_tools( lua: &Lua, globals: &Table, set: &Arc, - runtime: &Arc>, + offers: &Arc>, adhoc_tools: &AdhocTools, list: &MessageList, ) -> Result<()> { @@ -119,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(), }; @@ -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) @@ -226,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() { @@ -236,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() @@ -254,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/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/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/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index cc04c0f25..4e8d833d0 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -15,24 +15,29 @@ 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::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::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, +/// 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. #[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, } @@ -56,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"))?; + .map_err(|_| mlua::Error::external(error::internal(OFFERS_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(()) @@ -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. @@ -138,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), @@ -183,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..70f6191ed 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" @@ -266,14 +266,14 @@ 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"); 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); @@ -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/tools/userdata.rs b/crates/runvil-internal/lua/src/tools/userdata.rs index 82487253f..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. @@ -29,9 +30,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 +41,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 { @@ -80,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"); } } 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