diff --git a/Cargo.lock b/Cargo.lock index e5bdb256a..a5d6c299f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2137,6 +2137,7 @@ dependencies = [ "runvil-lua", "runvil-types", "serde", + "serde_json", "serde_yaml_ng", ] diff --git a/crates/build-xtask/src/api/items-tests.rs b/crates/build-xtask/src/api/items-tests.rs index 53fad03b8..2579a95a6 100644 --- a/crates/build-xtask/src/api/items-tests.rs +++ b/crates/build-xtask/src/api/items-tests.rs @@ -9,7 +9,7 @@ use super::*; #[test] fn every_approved_crate_passes_closure_and_any_other_is_refused() { let surface = Surface::default(); - for krate in ["serde", "serde_core", "serde_json", "serde_yaml_ng"] { + for krate in ["serde", "serde_core", "serde_json"] { let path = [krate.to_owned(), "Value".to_owned()]; assert_eq!(surface.refusal(krate, &path), None, "{krate}"); } @@ -29,7 +29,7 @@ fn a_facade_s_own_allowed_crate_passes_closure_and_its_requirement_names_the_cra let path = ["extra_sys".to_owned(), "Entry".to_owned()]; assert_eq!(surface.refusal("extra_sys", &path), None); assert!( - surface.required().ends_with("serde_yaml_ng, extra_sys)"), + surface.required().ends_with("serde_json, extra_sys)"), "{}", surface.required() ); diff --git a/crates/build-xtask/src/api/items.rs b/crates/build-xtask/src/api/items.rs index 83b45304b..fa0606505 100644 --- a/crates/build-xtask/src/api/items.rs +++ b/crates/build-xtask/src/api/items.rs @@ -15,11 +15,10 @@ use super::load::Loaded; const STD_CRATES: [&str; 3] = ["std", "core", "alloc"]; /// Third-party crates the surface may mention: serde's traits are how -/// dependents serialize the surface's types, and `serde_json` and -/// `serde_yaml_ng` supply the values some of those types carry. `serde` -/// defines its traits in `serde_core` and re-exports them, and rustdoc -/// names the defining crate. -const ALLOWLIST: [&str; 4] = ["serde", "serde_core", "serde_json", "serde_yaml_ng"]; +/// dependents serialize the surface's types, and `serde_json` supplies +/// the values some of those types carry. `serde` defines its traits in +/// `serde_core` and re-exports them, and rustdoc names the defining crate. +const ALLOWLIST: [&str; 3] = ["serde", "serde_core", "serde_json"]; /// What a closure mention must be, naming every crate it may come from: /// the shared ones, then the crates in `allowed`, which one facade may diff --git a/crates/harness-gateway-client/src/wire/request-tests.rs b/crates/harness-gateway-client/src/wire/request-tests.rs index 11d695b4a..f753ce7ec 100644 --- a/crates/harness-gateway-client/src/wire/request-tests.rs +++ b/crates/harness-gateway-client/src/wire/request-tests.rs @@ -72,7 +72,7 @@ fn tool_round(offered: &[Offered]) -> Round { let ctx = ctx .with_tools(catalog) .with_models(BTreeMap::from([(ModelRole::Default, model)])); - let mut run = Run::new(Arc::new(prompt), "", ctx); + let mut run = Run::new(Arc::new(prompt), "{}", ctx); let mut rounds = Vec::new(); let result = loop { let effects = match run.step() { diff --git a/crates/harness-internal/runner/src/effect_loop-tests.rs b/crates/harness-internal/runner/src/effect_loop-tests.rs index 314c64c9b..9a86e9cfc 100644 --- a/crates/harness-internal/runner/src/effect_loop-tests.rs +++ b/crates/harness-internal/runner/src/effect_loop-tests.rs @@ -42,7 +42,7 @@ fn lost_run() -> Run { ); let params = RunParams::new(SEED, Timestamp::UNIX_EPOCH) .with_tools(ToolCatalog::new([wait]).expect("the one-tool catalog is valid")); - let mut run = Run::new(Arc::new(prompt.expect("the prompt parses")), "", params); + let mut run = Run::new(Arc::new(prompt.expect("the prompt parses")), "{}", params); let Step::Pending { effects, .. } = run.step() else { panic!("the run parks on its tool call"); }; diff --git a/crates/harness-internal/runner/src/files.rs b/crates/harness-internal/runner/src/files.rs index 0cc64d267..49da5dbc2 100644 --- a/crates/harness-internal/runner/src/files.rs +++ b/crates/harness-internal/runner/src/files.rs @@ -1,78 +1,18 @@ -//! The Harness's half of a prompt's declared store files: the launch's input -//! text staged at the frontmatter's `input:` path before a run, and the -//! `output:` path read after it. +//! The Harness's half of a prompt's declared output file: the `output:` +//! path read after a run. //! -//! Both go through the handle's store view ([`VfsRef::acquire_store`]), -//! so the store's strict path rules apply to the declared paths, which -//! the parser takes as written, and the handle's policy and op sink see -//! each operation like any other. Both are synchronous, like the VFS, and -//! the Harness runs them inline in the run's future. +//! The read goes through the handle's store view +//! ([`VfsRef::acquire_store`]), so the store's strict path rules apply to +//! the declared path, which the parser takes as written, and the handle's +//! policy and op sink see the read like any other operation. It is +//! synchronous, like the VFS, and the Harness runs it inline in the run's +//! future. use runvil_engine::vfs::{ Origin, VfsError, VfsOp, VfsOutcome, VfsRef, make_store_error, perform_vfs_op, }; use runvil_engine::{Error, ErrorCode, ErrorFact}; -/// Puts the prompt's declared input in place in `vfs`'s store: writes -/// `text` at `declared`, overwriting any file there, or, when the launch -/// supplied no text, checks that the store already holds the file. -/// -/// # Errors -/// Returns `RequirementsUnmet` with the `Reason` fact `input_undeclared` -/// for text the prompt declares no file for, `RequirementsUnmet` with the -/// `Reason` fact `input_missing` for a declared file that is neither -/// supplied nor present, and the store failure's code when the store -/// refuses the write or the check. The last two carry the declared path -/// as the `Path` fact. The prose is the code's default. -pub fn stage_input( - vfs: &VfsRef, - declared: Option<&str>, - text: Option, -) -> Result<(), Error> { - let path = match (declared, &text) { - (None, None) => return Ok(()), - (None, Some(_)) => { - return Err(make_reasoned_error( - ErrorCode::RequirementsUnmet, - "input_undeclared", - "the launch supplied input text, but the prompt declares no `input:` file", - )); - } - (Some(path), _) => path, - }; - let store = |source: VfsError| { - let mut error = make_store_error(&source); - error.message = format!("the input file `{path}` could not be staged: {source}"); - error.facts.insert(ErrorFact::Path, path.into()); - error - }; - let view = vfs - .acquire_store(Origin::new(format!("input: {path}"))) - .map_err(store)?; - let path = path.to_owned(); - match text { - Some(contents) => perform_vfs_op(&view, VfsOp::Write { path, contents }) - .map(drop) - .map_err(store), - None => match perform_vfs_op(&view, VfsOp::Exists { path: path.clone() }) { - Ok(VfsOutcome::Bool(true)) => Ok(()), - Ok(_) => { - let mut error = make_reasoned_error( - ErrorCode::RequirementsUnmet, - "input_missing", - format!( - "the prompt declares the input file `{path}`, but the launch supplied no \ - input text and the store has no such file" - ), - ); - error.facts.insert(ErrorFact::Path, path.into()); - Err(error) - } - Err(source) => Err(store(source)), - }, - } -} - /// Reads the prompt's declared output file at `path` from `vfs`'s store. /// /// # Errors diff --git a/crates/harness-internal/runner/src/harness.rs b/crates/harness-internal/runner/src/harness.rs index 30b96b1d0..ff3a48899 100644 --- a/crates/harness-internal/runner/src/harness.rs +++ b/crates/harness-internal/runner/src/harness.rs @@ -142,12 +142,11 @@ impl Harness { /// is `RecorderFailed`. /// /// It begins the run's record, resolves the run's model through the - /// setup's broker, puts the prompt's declared input in place, makes - /// the run's Packs and waits for them to be ready, drives the run, and - /// reads the declared output file if the run completed. The run's - /// Pack instances drop before it returns. The returned future is - /// `Send`. Its only runtime needs are those of the Host's performers - /// and Packs. + /// setup's broker, makes the run's Packs and waits for them to be + /// ready, drives the run, and reads the declared output file if the + /// run completed. The run's Pack instances drop before it returns. + /// The returned future is `Send`. Its only runtime needs are those of + /// the Host's performers and Packs. /// /// Every run gets a record and ends with its result, which the report /// carries beside the run's id: the final text, or the error that @@ -157,13 +156,12 @@ impl Harness { /// calling `run`, or while the broker lists its models, ends the run /// canceled. A model list the broker fails to fetch ends it with the /// broker's failure, and a list that omits the selected model with a - /// `RequirementsUnmet` failure. A declared input file the Harness - /// fails to put in place ends it with the input's failure, and an - /// environment that falls short of the prompt's requirements, which - /// the run's first step finds, with a `RequirementsUnmet` failure. An - /// environment that falls short after a cancel is raised, as when the - /// cancel cuts short the wait for a Pack that is still starting, ends - /// the run canceled instead. A run that is pending with nothing to + /// `RequirementsUnmet` failure. An environment that falls short of + /// the prompt's requirements, which the run's first step finds, ends + /// it with a `RequirementsUnmet` failure too. An environment that + /// falls short after a cancel is raised, as when the cancel cuts + /// short the wait for a Pack that is still starting, ends the run + /// canceled instead. A run that is pending with nothing to /// await, which means the Harness lost track of an effect, ends with /// an `Internal` error. A run whose main chain waits on its inbox with /// nothing else to run is not stalled: it waits for a @@ -222,19 +220,7 @@ impl Harness { .frontmatter() .output() .map(|decl| decl.path().to_owned()); - let prepared = prepare(&host, prompt, &setup, run_id, params, model, cancel).await; - let (run, packs) = match prepared { - Ok(prepared) => prepared, - Err(refusal) if is_refusal(&refusal) => return Err(recorded(refusal)), - Err(error) => { - return Ok(RunReport { - run_id, - result: Err(error), - output: Err(make_not_completed_error()), - unread: control.send_queue().end(), - }); - } - }; + let (run, packs) = prepare(&host, prompt, &setup, params, model, cancel).await; let (result, unread) = drive( run, diff --git a/crates/harness-internal/runner/src/lib.rs b/crates/harness-internal/runner/src/lib.rs index 91caf95b2..0cfc98f97 100644 --- a/crates/harness-internal/runner/src/lib.rs +++ b/crates/harness-internal/runner/src/lib.rs @@ -6,17 +6,16 @@ //! run gets on top of the Host. A [`Harness`] over the Host, the parsed //! prompt, and the setup begins the run at the setup's recorder with the //! inputs the Engine refuses to draw itself, resolves the run's model -//! through the setup's broker, prepares an Engine `Run` (putting the -//! declared input file in place, making the run's instance of each of its -//! Packs and taking the run's snapshot of them), steps it, performs each -//! chat, render, tool-call, and timer effect through its performer and -//! answers each Vfs effect inline, feeds the answers back, delivers each -//! message the launcher sends through the run's [`RunControl`] to the -//! main chain, hands every event, effect, and answer to the run's -//! recorder, ends the run there with its result, the final text or the -//! error that ended it, and reads the declared output file, all inside the -//! one future [`Harness::run`] returns. A cancel is the error whose code -//! is `canceled`. A recorder +//! through the setup's broker, prepares an Engine `Run` (making the run's +//! instance of each of its Packs and taking the run's snapshot of them), +//! steps it, performs each chat, render, tool-call, and timer effect +//! through its performer and answers each Vfs effect inline, feeds the +//! answers back, delivers each message the launcher sends through the +//! run's [`RunControl`] to the main chain, hands every event, effect, and +//! answer to the run's recorder, ends the run there with its result, the +//! final text or the error that ended it, and reads the declared output +//! file, all inside the one future [`Harness::run`] returns. A cancel is +//! the error whose code is `canceled`. A recorder //! that refuses a write is the only way a run ends without a recorded //! result, and that refusal, a `RecorderFailed` error, is the only error //! [`Harness::run`] returns. A prompt that fails to parse is its caller's @@ -31,9 +30,9 @@ //! Host's own runtime. The crate's async needs come from `futures-util`, //! and `tokio` and `tokio-util` appear only under `[dev-dependencies]` //! (enforced by the Harness tokio ban in `cargo test -p build-xtask`). -//! - The Harness reads each [`RunSetup`] field once per run: the input is -//! staged in, the run works in, and the declared output is read from -//! the one filesystem the setup holds. +//! - The Harness reads each [`RunSetup`] field once per run: the run works +//! in, and the declared output is read from, the one filesystem the +//! setup holds. //! - The recorder is written in loop order: a step's events before the //! step's effects are issued, each effect before its performer starts, //! each answer before the run resumes with it, and each sent message's diff --git a/crates/harness-internal/runner/src/packs-tests.rs b/crates/harness-internal/runner/src/packs-tests.rs index b8f367222..174164370 100644 --- a/crates/harness-internal/runner/src/packs-tests.rs +++ b/crates/harness-internal/runner/src/packs-tests.rs @@ -248,7 +248,7 @@ fn unavailable_for(reason: &str) -> RosterEntry { fn refusal(run: &RunPacks, prompt: &Prompt) -> String { let ctx = RunParams::new(RunSeed::from_bytes([0; 32]), Timestamp::UNIX_EPOCH) .with_packs(run.roster()); - match Run::new(Arc::new(prompt.clone()), "", ctx).step() { + match Run::new(Arc::new(prompt.clone()), "{}", ctx).step() { Step::Done { result: Err(error), .. } => error.located_message(), diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index fa767ac5a..2d0242ca5 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -5,27 +5,20 @@ //! recorder with the start time the caller read, so the record holds both //! verbatim. `Harness::run_to_end` begins the run before anything else, so //! every run has a record. Then [`prepare`] takes the steps that build the -//! run: put the prompt's declared `input:` file in place in the store -//! (`files::stage_input`); hand the run's whole filesystem, real -//! directories and the declared store, to the params as given; make the -//! run's instance of each of its Packs, and take the run's snapshot of -//! them once each is ready, or the run's cancel fires, which yields the -//! catalog, the roster, the preludes, and the tool performer; set the -//! first three, the setup's limits, and the run's model on the params; -//! and build the `Run` beside that snapshot. +//! run: hand the run's whole filesystem, real directories and the +//! declared store, to the params as given; make the run's instance of +//! each of its Packs, and take the run's snapshot of them once each is +//! ready, or the run's cancel fires, which yields the catalog, the +//! roster, the preludes, and the tool performer; set the first three, the +//! setup's limits, and the run's model on the params; and build the `Run` +//! beside that snapshot. //! -//! An input file that cannot be put in place is a run that ended before -//! it began: the recorder ends it with that failure as its result, the -//! same error `prepare` returns, so the record answers "why did this run -//! fail" for a run the loop never saw. An environment that cannot satisfy -//! the prompt fails no preparation: the run is built, and its first step -//! ends it with the Engine's model-readable notice, or canceled when the -//! run's cancel fired first, as when a cancel cuts the wait short while a +//! Preparation never fails. An environment that cannot satisfy the prompt +//! is prepared all the same: the run is built, and its first step ends it +//! with the Engine's model-readable notice, or canceled when the run's +//! cancel fired first, as when a cancel cuts the wait short while a //! needed Pack is still starting. The effect loop records that end like //! any other. -//! -//! The input staging is store work and runs inline, like every other VFS -//! operation of the run. use std::path::PathBuf; use std::sync::Arc; @@ -33,13 +26,11 @@ use std::sync::Arc; use runvil_engine::Error; use runvil_engine::cancel::CancelHandle; use runvil_engine::model::{ModelInfo, ModelRole}; -use runvil_engine::prompt::FileDecl; use runvil_engine::{Prompt, Run, RunParams, RunSeed, Timestamp}; -use crate::files::stage_input; use crate::host::Host; use crate::packs::RunPacks; -use crate::recorder::{RunId, RunMeta, of_run, refused}; +use crate::recorder::{RunId, RunMeta}; use crate::setup::RunSetup; /// Begins the run at `setup.recorder`: draws its seed from the OS random @@ -64,10 +55,9 @@ pub(crate) async fn begin( Ok((run_id, RunParams::new(seed, started_at))) } -/// Stages `prompt`'s input in `setup.vfs`, makes the run's Packs from -/// `host.packs` and snapshots them once each is ready or `cancel` fires, -/// and builds the `Run` over `params` under `setup.limits`. Ends the run -/// at the recorder with an input failure as its result and returns it. +/// Makes the run's Packs from `host.packs` and snapshots them once each +/// is ready or `cancel` fires, and builds the `Run` over `params` under +/// `setup.limits`. /// /// The run's `ui()` snapshot is `setup`'s selection and first workspace /// root, and `model` fills all five roles, so the run's first step checks @@ -78,35 +68,14 @@ pub(crate) async fn begin( /// stands. A prompt the environment cannot satisfy is prepared all the /// same: the run's first step ends it with the Engine's notice, or as /// canceled when the run's cancel fired first. -/// -/// # Errors -/// Returns the input failure from `stage_input` when the declared input -/// file cannot be put in place. The failure keeps its own code and gains -/// the run's `Run` fact, and the run is ended with it as its result. -/// Returns `RecorderFailed` with the `Run` fact when the recorder refuses -/// that end, and the input failure never has that code. pub(crate) async fn prepare( host: &Host, prompt: Prompt, setup: &RunSetup, - run_id: RunId, params: RunParams, model: Option, cancel: &CancelHandle, -) -> Result<(Run, RunPacks), Error> { - // The declared input is in place before anything else sees the - // store: the run's first section may read it. - let declared = prompt.frontmatter().input().map(FileDecl::path); - if let Err(source) = stage_input(&setup.vfs, declared, setup.input_text.clone()) { - let error = of_run(run_id, source); - setup - .recorder - .end_run(run_id, Err(error.clone())) - .await - .map_err(|source| refused(run_id, source))?; - return Err(error); - } - +) -> (Run, RunPacks) { // The run's whole filesystem, the real directories and the declared // store, goes to the params as given. The run makes its instance of // each of its Packs, and the snapshot of them is taken once each is @@ -132,7 +101,7 @@ pub(crate) async fn prepare( ); let run = Run::new(Arc::new(prompt), &setup.args, params); - Ok((run, packs)) + (run, packs) } /// The `ui()` snapshot as a JSON object: `selected_model`, and the first diff --git a/crates/harness-internal/runner/src/recorder.rs b/crates/harness-internal/runner/src/recorder.rs index 84f9d284c..3974f926e 100644 --- a/crates/harness-internal/runner/src/recorder.rs +++ b/crates/harness-internal/runner/src/recorder.rs @@ -19,9 +19,8 @@ //! Engine runs ends with the result its last step returned. A run that //! ends before the Engine runs it ends with an error of its own: the //! cancel, when a cancel comes before or during the model lookup, or the -//! failure itself, when the model lookup fails or the prompt's input -//! cannot be put in place. A run the effect loop loses track of ends with -//! an `Internal` error. +//! lookup's failure. A run the effect loop loses track of ends with an +//! `Internal` error. use std::fmt; @@ -46,11 +45,10 @@ pub fn make_recorder_error(cause: impl Into bool { error.code == ErrorCode::RecorderFailed } @@ -59,16 +57,11 @@ pub(crate) fn is_refusal(error: &Error) -> bool { /// the run's `Run` fact, the refusal built with [`make_recorder_error`] when /// the recorder returned another code. pub(crate) fn refused(run: RunId, error: Error) -> Error { - let error = if is_refusal(&error) { + let mut error = if is_refusal(&error) { error } else { make_recorder_error(error) }; - of_run(run, error) -} - -/// `error` with run `run`'s id as its `Run` fact. -pub(crate) fn of_run(run: RunId, mut error: Error) -> Error { error.facts.insert(ErrorFact::Run, run.get().into()); error } diff --git a/crates/harness-internal/runner/src/setup.rs b/crates/harness-internal/runner/src/setup.rs index 7353a370c..1e64a61f1 100644 --- a/crates/harness-internal/runner/src/setup.rs +++ b/crates/harness-internal/runner/src/setup.rs @@ -23,18 +23,17 @@ pub struct RunSetup { pub recorder: Arc, /// Lists the models and performs model rounds. pub broker: Arc, - /// The run's whole filesystem: the input is staged in it, the run - /// works in it, and the declared output is read from it. + /// The run's whole filesystem: the run works in it, and the declared + /// output is read from it. pub vfs: VfsRef, /// The model id the user selected, if any. pub selected_model: Option, /// The workspace roots granted; the first is the `ui()` snapshot's /// `workspace_root`. pub workspace_roots: Vec, - /// The run's argument text. + /// The run's arguments: the text of one JSON object, which the run's + /// first step checks against the prompt's `args` declaration. pub args: String, - /// Text written to the prompt's `input:` file before the run starts. - pub input_text: Option, /// The run's resource limits. pub limits: RunLimits, /// The most tool calls in flight at once; the @@ -49,8 +48,8 @@ pub struct RunSetup { impl RunSetup { /// A setup over `recorder` and `broker`. Every other field starts /// empty or at its default: a fresh memory store, no selection, no - /// roots, no args, no input, `RunLimits::default()`, at most 10 tool - /// calls in flight, and no user. + /// roots, the empty object `{}` as its args, `RunLimits::default()`, + /// at most 10 tool calls in flight, and no user. #[must_use] pub fn new(recorder: Arc, broker: Arc) -> RunSetup { RunSetup { @@ -59,8 +58,7 @@ impl RunSetup { vfs: VfsRef::default(), selected_model: None, workspace_roots: Vec::new(), - args: String::new(), - input_text: None, + args: "{}".to_owned(), limits: RunLimits::default(), max_tool_calls_in_flight: DEFAULT_MAX_TOOL_CALLS_IN_FLIGHT, has_user: false, @@ -76,7 +74,6 @@ impl fmt::Debug for RunSetup { .field("selected_model", &self.selected_model) .field("workspace_roots", &self.workspace_roots) .field("args", &self.args) - .field("input_text", &self.input_text) .field("limits", &self.limits) .field("max_tool_calls_in_flight", &self.max_tool_calls_in_flight) .field("has_user", &self.has_user) diff --git a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs index 3560c771e..648d1f07c 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs @@ -50,7 +50,7 @@ fn infers_then_chats() -> Run { ); let ctx = RunParams::new(TEST_SEED, Timestamp::UNIX_EPOCH) .with_models(BTreeMap::from([(ModelRole::Default, model)])); - Run::new(prompt, "", ctx) + Run::new(prompt, "{}", ctx) } /// One round as the broker saw it: the last message's text and the round. diff --git a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index ceaa5a0bf..4f3fed561 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs @@ -90,7 +90,7 @@ fn run_of(sections: &str, cancel: &CancelHandle) -> Run { .with_tools(catalog) .with_models(BTreeMap::from([(ModelRole::Default, model)])) .with_cancel(cancel.clone()); - Run::new(prompt, "", ctx) + Run::new(prompt, "{}", ctx) } /// Parks every call until the test adds a permit to `gate`, then answers diff --git a/crates/harness-internal/runner/tests/it/harness.rs b/crates/harness-internal/runner/tests/it/harness.rs index 40e532494..6b898a19f 100644 --- a/crates/harness-internal/runner/tests/it/harness.rs +++ b/crates/harness-internal/runner/tests/it/harness.rs @@ -26,7 +26,7 @@ use std::time::Duration; use harness_runner::performers::{InferenceBroker, Timer}; use harness_runner::recorder::{MemoryRecorder, Record, RecordKind, RunId, RunRecorder}; use harness_runner::{Harness, Packs, RunControl, RunReport, RunSetup}; -use runvil_engine::vfs::VfsRef; +use runvil_engine::vfs::{Origin, VfsRef}; use runvil_engine::{Error, ErrorCode}; use runvil_plugin::PackRegistry; use serde_json::{Value, json}; @@ -49,8 +49,8 @@ mod stop_timing; const PATIENCE: Duration = Duration::from_secs(10); /// The setup of a run recorded through `recorder` and served by `broker`, -/// with [`MODEL`] selected, no arguments, no input text, and a fresh -/// memory store. +/// with [`MODEL`] selected, the empty object `{}` as its arguments, and a +/// fresh memory store. pub(crate) fn setup( recorder: Arc, broker: impl InferenceBroker + 'static, @@ -174,10 +174,9 @@ fn main_waits(records: &[Record]) -> usize { inbox_waits(records, "0") } -/// A prompt declaring `paper.md` as its input and `report.md` as its -/// output, whose one section writes the output from the input. +/// A prompt declaring `report.md` as its output, whose one section writes +/// it from the `paper.md` it finds in the store. const FILES: &str = "---\nname: files\ndescription: d\nrunvil: 0\n\ - input:\n path: paper.md\n description: The paper\n\ output:\n path: report.md\n description: The report\n---\n\n\ ## Only\n\n```lua\n\ store.write('report.md', 'seen: ' .. store.read('paper.md'))\nreturn 'done'\n```\n"; @@ -206,14 +205,19 @@ const READS_UI: &str = "---\nname: reads-ui\ndescription: d\nrunvil: 0\n---\n\n\ return tostring(ui().selected_model) .. '|' .. tostring(ui().workspace_root)\n```\n"; #[tokio::test] -async fn a_staged_input_and_a_declared_output_round_trip_beside_the_recorded_result() { +async fn a_seeded_file_and_a_declared_output_round_trip_beside_the_recorded_result() { let recorder = Arc::new(MemoryRecorder::new()); + let vfs = VfsRef::default(); + vfs.acquire_store(Origin::new("harness test")) + .expect("the default handle declares a store") + .write("paper.md", b"# Paper") + .expect("the seed is written"); let harness = launch( no_packs(), PendingTimer::default(), FILES, RunSetup { - input_text: Some("# Paper".to_owned()), + vfs, ..setup(recorder.clone(), ScriptedBroker::replying()) }, ); diff --git a/crates/harness-internal/runner/tests/it/prepare-args.rs b/crates/harness-internal/runner/tests/it/prepare-args.rs new file mode 100644 index 000000000..fd9036d01 --- /dev/null +++ b/crates/harness-internal/runner/tests/it/prepare-args.rs @@ -0,0 +1,81 @@ +//! The setup's arguments through preparation: a new setup holds the empty +//! object `{}`, which a prompt with no `args:` key runs on; the setup's +//! `args` reach the run as the text of one JSON object; and arguments the +//! prompt's `args` declaration refuses end the run at its first step as a +//! recorded failed run, never an error of `Harness::run`. + +use super::*; +use crate::support::assert_not_completed; + +/// A prompt with no `args:` key that returns the type of its `args` +/// beside the one field such a prompt declares, `text`. +const READS_ARGS: &str = "---\nname: reads-args\ndescription: d\nrunvil: 0\n---\n\n\ + ## Only\n\n```lua\nreturn type(args) .. '|' .. tostring(args.text)\n```\n"; + +/// A prompt that requires one string argument, `name`, and greets it. +const GREETS: &str = "---\nname: greets\ndescription: d\nrunvil: 0\n\ + args:\n type: object\n properties:\n name: { type: string }\n required: [name]\n\ + ---\n\n## Only\n\n```lua\nreturn 'Hello, ' .. args.name\n```\n"; + +/// The setup of a run recorded through `recorder` whose arguments are +/// `args`. +fn with_args(recorder: &Arc, args: &str) -> RunSetup { + RunSetup { + args: args.to_owned(), + ..setup_over(recorder) + } +} + +#[tokio::test] +async fn a_new_setup_holds_the_empty_object_which_a_prompt_with_no_args_key_runs_on() { + let setup = RunSetup::new(recorder(), Arc::new(ScriptedBroker::replying())); + assert_eq!(setup.args, "{}", "a new setup's args are the empty object"); + + let report = report_of(READS_ARGS, bare(), setup).await; + assert_eq!( + completed(report.result), + "table|nil", + "the run reads an `args` table with no field" + ); +} + +#[tokio::test] +async fn args_the_prompt_refuses_end_the_run_as_a_recorded_failed_run_not_an_error_of_run() { + let accepted = with_args(&recorder(), r#"{"name":"Ada"}"#); + assert_eq!( + completed(report_of(GREETS, bare(), accepted).await.result), + "Hello, Ada", + "the setup's args reach the run" + ); + + let recorder = recorder(); + let report = launch( + bare(), + Unused, + GREETS, + with_args(&recorder, r#"{"name":5}"#), + ) + .run() + .await + .expect("refused arguments are the run's result, not an error of `run`"); + + let error = report + .result + .as_ref() + .expect_err("the prompt refuses the arguments"); + assert_eq!(error.code, ErrorCode::ArgsInvalid); + assert_eq!(error.facts.get(&ErrorFact::Reason), Some(&json!("type"))); + assert_eq!(error.facts.get(&ErrorFact::Name), Some(&json!("name"))); + assert_eq!( + error.located_message(), + "argument `name` must be a string, got integer", + "the failure is the Engine's, verbatim, and names no line of the prompt" + ); + assert_eq!( + recorder.result(report.run_id), + Some(report.result.clone()), + "the recorder holds the refusal as the run's failure" + ); + assert_no_records(&recorder, report.run_id); + assert_not_completed(&report.output); +} diff --git a/crates/harness-internal/runner/tests/it/prepare-files.rs b/crates/harness-internal/runner/tests/it/prepare-files.rs index a77de7202..ec03a092b 100644 --- a/crates/harness-internal/runner/tests/it/prepare-files.rs +++ b/crates/harness-internal/runner/tests/it/prepare-files.rs @@ -1,157 +1,81 @@ -//! The prompt's declared store files: the launch's input text is staged -//! at the frontmatter's `input:` path before the run, through the store -//! view wherever the handle mounts the store; a Host-seeded store -//! satisfies the declaration; text with no declared file and a declared -//! file with neither text nor a seeded copy each refuse the run and end it -//! with a `RequirementsUnmet` failure, and a store that refuses the write -//! ends it with the store's refusal; and the report carries what a -//! completed run left at the declared `output:` path. - -use std::sync::Arc; +//! The prompt's declared output file: the report carries what a completed +//! run left at the frontmatter's `output:` path, read through the store +//! view wherever the handle mounts the store; a file the Host seeds in +//! the store is the run's to read; and a prompt that declares no output +//! reports `NoOutput`. use harness_runner::files::read_output; -use harness_runner::recorder::MemoryRecorder; use harness_runner::{RunReport, RunSetup}; -use runvil_engine::vfs::{MemoryBackend, Mode, ModePolicy, Origin, VfsRef}; -use runvil_engine::{Error, ErrorCode, ErrorFact}; +use runvil_engine::vfs::{MemoryBackend, Origin, VfsRef}; +use runvil_engine::{ErrorCode, ErrorFact}; use serde_json::json; -use super::{PLAIN, assert_no_records, bare, completed, recorder, report_of, run_of, setup_over}; +use super::{PLAIN, bare, completed, recorder, report_of, setup_over}; -/// A prompt declaring `paper.md` as its input and `report.md` as its -/// output, whose one section writes the output from the input. +/// A prompt declaring `report.md` as its output, whose one section writes +/// it from the `paper.md` it finds in the store. const FILES: &str = "---\nname: files\ndescription: d\nrunvil: 0\n\ - input:\n path: paper.md\n description: The paper\n\ output:\n path: report.md\n description: The report\n---\n\n\ ## Only\n\n```lua\n\ store.write('report.md', 'seen: ' .. store.read('paper.md'))\nreturn 'done'\n```\n"; -/// Runs `source` over `vfs` with `input_text` and returns the recorder -/// beside the run's report. -async fn run_over( - source: &str, - vfs: &VfsRef, - input_text: Option<&str>, -) -> (Arc, RunReport) { - let recorder = recorder(); +/// Writes `text` at `paper.md` in `vfs`'s store, as a Host seeds a store +/// before a run. +fn seed_paper(vfs: &VfsRef, text: &str) { + vfs.acquire_store(Origin::new("prepare files test")) + .unwrap() + .write("paper.md", text.as_bytes()) + .unwrap(); +} + +/// Runs `source` over `vfs` and returns the run's report. +async fn run_over(source: &str, vfs: &VfsRef) -> RunReport { let setup = RunSetup { vfs: vfs.clone(), - input_text: input_text.map(str::to_owned), - ..setup_over(&recorder) + ..setup_over(&recorder()) }; - let report = report_of(source, bare(), setup).await; - (recorder, report) -} - -/// Checks that `report` is of a run its input refused, whose record ended -/// with the refusal as its result and holds nothing before that end. -/// Returns the refusal. -fn refused(recorder: &MemoryRecorder, report: RunReport) -> Error { - let error = report.result.expect_err("the input refuses the run"); - let run_id = run_of(&error); - assert_eq!(run_id, report.run_id, "the refusal names its run"); - assert_eq!( - recorder.result(run_id), - Some(Err(error.clone())), - "the recorder holds the refusal as the run's result" - ); - assert_no_records(recorder, run_id); - error + report_of(source, bare(), setup).await } #[tokio::test] -async fn the_input_text_is_staged_at_the_declared_path_and_the_output_comes_back() { +async fn a_completed_run_reports_what_it_left_at_its_declared_output() { let vfs = VfsRef::default(); - let (_recorder, report) = run_over(FILES, &vfs, Some("# Paper")).await; + seed_paper(&vfs, "# Paper"); + let report = run_over(FILES, &vfs).await; assert_eq!(completed(report.result), "done"); assert_eq!(report.output, Ok("seen: # Paper".to_owned())); assert_eq!(read_output(&vfs, "report.md").unwrap(), "seen: # Paper"); } #[tokio::test] -async fn a_store_mounted_away_from_the_root_is_staged_by_its_logical_path() { +async fn a_store_mounted_away_from_the_root_is_read_by_its_logical_path() { let vfs = VfsRef::builder() .mount("/", MemoryBackend::new()) .store("/store", MemoryBackend::new()) .build(); - let (_recorder, report) = run_over(FILES, &vfs, Some("# Paper")).await; + seed_paper(&vfs, "# Paper"); + let report = run_over(FILES, &vfs).await; completed(report.result); + assert_eq!(report.output, Ok("seen: # Paper".to_owned())); let access = vfs.acquire(Origin::new("prepare files test")).unwrap(); - assert_eq!(access.read("/store/paper.md").unwrap(), b"# Paper"); + assert_eq!(access.read("/store/report.md").unwrap(), b"seen: # Paper"); assert!( - !access.exists("/paper.md").unwrap(), + !access.exists("/report.md").unwrap(), "the base never saw it" ); } #[tokio::test] -async fn a_host_seeded_store_satisfies_the_declared_input() { +async fn a_file_the_host_seeds_in_the_store_is_the_runs_to_read() { let vfs = VfsRef::default(); - vfs.acquire_store(Origin::new("prepare files test")) - .unwrap() - .write("paper.md", b"# Seeded") - .unwrap(); - let (_recorder, report) = run_over(FILES, &vfs, None).await; + seed_paper(&vfs, "# Seeded"); + let report = run_over(FILES, &vfs).await; assert_eq!(report.output, Ok("seen: # Seeded".to_owned())); } -#[tokio::test] -async fn input_text_for_a_prompt_that_declares_no_input_is_refused() { - let (recorder, report) = run_over(PLAIN, &VfsRef::default(), Some("# Paper")).await; - let source = refused(&recorder, report); - assert_eq!(source.code, ErrorCode::RequirementsUnmet); - assert_eq!( - source.message, - "the launch supplied input text, but the prompt declares no `input:` file" - ); - assert_eq!(source.prose, ErrorCode::RequirementsUnmet.prose()); - assert_eq!( - source.facts.get(&ErrorFact::Reason), - Some(&json!("input_undeclared")) - ); -} - -#[tokio::test] -async fn a_declared_input_neither_supplied_nor_in_the_store_is_refused() { - let (recorder, report) = run_over(FILES, &VfsRef::default(), None).await; - let source = refused(&recorder, report); - assert_eq!(source.code, ErrorCode::RequirementsUnmet); - assert_eq!( - source.facts.get(&ErrorFact::Reason), - Some(&json!("input_missing")) - ); - assert_eq!( - source.facts.get(&ErrorFact::Path), - Some(&json!("paper.md")), - "the refusal names the missing file" - ); -} - -#[tokio::test] -async fn a_store_that_refuses_the_staging_write_refuses_the_run() { - let vfs = VfsRef::builder() - .store("/", MemoryBackend::new()) - .policy(ModePolicy::new(Mode::Ask)) - .build(); - let (recorder, report) = run_over(FILES, &vfs, Some("# Paper")).await; - let source = refused(&recorder, report); - assert_eq!( - source.code, - ErrorCode::ApprovalRequired, - "the refusal is the store's" - ); - assert!( - source - .message - .starts_with("the input file `paper.md` could not be staged: "), - "the message holds the store's refusal: {source:?}" - ); - assert_eq!(source.facts.get(&ErrorFact::Path), Some(&json!("paper.md"))); -} - #[tokio::test] async fn a_prompt_without_an_output_declaration_reports_no_output() { - let (_recorder, report) = run_over(PLAIN, &VfsRef::default(), None).await; + let report = run_over(PLAIN, &VfsRef::default()).await; completed(report.result); let error = report.output.expect_err("an undeclared output has no text"); assert_eq!(error.code, ErrorCode::NoOutput); diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index e19fc9848..6c73b4a1f 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -7,9 +7,10 @@ //! the prepared run; each run makes its own instance of each of its Packs //! and drops it by the time `Harness::run` returns, handing its factory //! nothing but the Pack id; and the run's tool performer sends a -//! `ToolCall` effect to the Pack its id names. A Pack's prelude reaching -//! the prepared run sits in the `prelude` child module, the prompt's -//! declared input and output files sit in the `files` child module, the +//! `ToolCall` effect to the Pack its id names. The setup's arguments +//! reaching the prepared run sit in the `args` child module, a Pack's +//! prelude reaching the prepared run sits in the `prelude` child module, +//! the prompt's declared output file sits in the `files` child module, the //! wait for each Pack to be ready sits in the `ready` child module, and //! the built-in `runvil/user`, which only a run with a user holds, sits //! in the `user` child module. @@ -34,6 +35,8 @@ use crate::harness::launch; use crate::scripted::{MODEL, ScriptedBroker}; use crate::support::{Make, Unused, host_over, parsed, registry}; +#[path = "prepare-args.rs"] +mod args; #[path = "prepare-files.rs"] mod files; #[path = "prepare-prelude.rs"] @@ -87,7 +90,7 @@ pub(crate) fn building(pack: (PackInfo, Make)) -> Packs { /// The setup of a run recorded through `recorder` and served by the /// scripted broker. It selects no model, so the run binds the broker's -/// one model, and it holds no input and a fresh memory store. +/// one model, and it holds a fresh memory store. pub(crate) fn setup_over(recorder: &Arc) -> RunSetup { RunSetup::new(recorder.clone(), Arc::new(ScriptedBroker::replying())) } diff --git a/crates/harness-internal/runner/tests/it/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 1e2aae795..0a75f8937 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -121,15 +121,16 @@ fn prompt(sections: &[(&str, &str)]) -> Arc { Arc::new(prompt.expect("the fixture prompt parses")) } -/// A Pack-free run over `prompt` with a fixed seed and start, -/// prepared against the fixture [`catalog`], over the run's filesystem -/// `vfs`, under the cancel flag `cancel`. +/// A Pack-free run over `prompt` with a fixed seed and start and the +/// empty object `{}` as its arguments, prepared against the fixture +/// [`catalog`], over the run's filesystem `vfs`, under the cancel flag +/// `cancel`. fn prepared(prompt: Arc, vfs: VfsRef, cancel: &CancelHandle) -> Run { let ctx = RunParams::new(TEST_SEED, Timestamp::UNIX_EPOCH) .with_vfs(vfs) .with_tools(catalog()) .with_cancel(cancel.clone()); - Run::new(prompt, "", ctx) + Run::new(prompt, "{}", ctx) } /// A Pack-free run over `lua` with a fixed seed and start, over a diff --git a/crates/runvil-engine/examples/greeter.rs b/crates/runvil-engine/examples/greeter.rs index 4f914b26c..ef4b47647 100644 --- a/crates/runvil-engine/examples/greeter.rs +++ b/crates/runvil-engine/examples/greeter.rs @@ -94,7 +94,7 @@ pub fn greet() -> Result> { // 4. Give the params a cancel flag, keep a clone for anything that may // need to stop the run, and create the run. let cancel = CancelHandle::new(); - let mut run = Run::new(Arc::new(prompt), "", ctx.with_cancel(cancel.clone())); + let mut run = Run::new(Arc::new(prompt), "{}", ctx.with_cancel(cancel.clone())); // 5. Step until Done, logging each step's events and answering each // effect exactly once: an effect the step withdraws is dropped diff --git a/crates/runvil-engine/public-api.txt b/crates/runvil-engine/public-api.txt index 8818466ab..afa10d9fd 100644 --- a/crates/runvil-engine/public-api.txt +++ b/crates/runvil-engine/public-api.txt @@ -7,7 +7,6 @@ #[non_exhaustive] pub enum runvil_engine::model::ModelRole #[non_exhaustive] pub enum runvil_engine::model::Role #[non_exhaustive] pub enum runvil_engine::model::ThinkingMode -#[non_exhaustive] pub enum runvil_engine::prompt::ArgType #[non_exhaustive] pub enum runvil_engine::vfs::FileType #[non_exhaustive] pub enum runvil_engine::vfs::Mode #[non_exhaustive] pub enum runvil_engine::vfs::Op @@ -111,10 +110,7 @@ auto runvil_engine::packs::PackId: Freeze, RefUnwindSafe, Send, Sync, Unpin, Unw auto runvil_engine::packs::PackRef: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil_engine::packs::Prelude: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil_engine::packs::RosterEntry: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil_engine::prompt::ArgDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil_engine::prompt::ArgType: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil_engine::prompt::ArgsDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil_engine::prompt::ArgsDeclIter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil_engine::prompt::ArgsSchema: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil_engine::prompt::FileDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil_engine::prompt::Frontmatter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil_engine::prompt::RoleRequirements: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -205,10 +201,7 @@ derives runvil_engine::packs::PackId: Clone, Debug, Deserialize, Eq, Hash, Ord, derives runvil_engine::packs::PackRef: Clone, Debug, Deserialize, Eq, PartialEq derives runvil_engine::packs::Prelude: Clone, Debug, Eq, PartialEq derives runvil_engine::packs::RosterEntry: Clone, Debug, Eq, PartialEq -derives runvil_engine::prompt::ArgDecl: Clone, Debug, Deserialize, Eq, PartialEq -derives runvil_engine::prompt::ArgType: Clone, Copy, Debug, Deserialize, Eq, PartialEq -derives runvil_engine::prompt::ArgsDecl: Clone, Debug, Default, Deserialize, Eq, PartialEq -derives runvil_engine::prompt::ArgsDeclIter: Clone, Debug +derives runvil_engine::prompt::ArgsSchema: Clone, Debug, Default, Deserialize, Eq, PartialEq derives runvil_engine::prompt::FileDecl: Clone, Debug, Deserialize, Eq, PartialEq derives runvil_engine::prompt::Frontmatter: Clone, Debug, Deserialize, Eq, PartialEq derives runvil_engine::prompt::RoleRequirements: Clone, Copy, Debug, Deserialize, Eq, PartialEq @@ -252,14 +245,12 @@ impl core::fmt::Display for runvil_engine::ids::TaskId impl core::fmt::Display for runvil_engine::model::Role impl core::fmt::Display for runvil_engine::packs::GlobalName impl core::fmt::Display for runvil_engine::packs::PackId -impl core::fmt::Display for runvil_engine::prompt::ArgType impl core::fmt::Display for runvil_engine::vfs::PathReason impl core::fmt::Display for runvil_engine::vfs::VfsError impl core::fmt::Display for runvil_engine::vfs::VfsPath impl core::fmt::Display for runvil_engine::vfs::VfsPathBuf impl core::future::future::Future for runvil_engine::cancel::Canceled impl core::future::future::Future for runvil_engine::cancel::Canceled { type Output = () } -impl core::iter::traits::exact_size::ExactSizeIterator for runvil_engine::prompt::ArgsDeclIter<'_> impl core::ops::drop::Drop for runvil_engine::vfs::Access impl core::str::traits::FromStr for runvil_engine::ToolId impl core::str::traits::FromStr for runvil_engine::ToolId { type Err = runvil_engine::Error } @@ -278,13 +269,8 @@ impl runvil_engine::vfs::Policy for runvil_engine::vfs::ModePolicy impl runvil_engine::vfs::Vfs for runvil_engine::vfs::MemoryBackend impl runvil_engine::vfs::Vfs for runvil_engine::vfs::RealBackend impl runvil_engine::vfs::Vfs for runvil_engine::vfs::VfsRef -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil_engine::prompt::ArgsDecl -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil_engine::prompt::ArgsDecl { type IntoIter = runvil_engine::prompt::ArgsDeclIter<'a> } -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil_engine::prompt::ArgsDecl { type Item = (&'a str, &'a runvil_engine::prompt::ArgDecl) } impl<'a> core::iter::traits::iterator::Iterator for runvil_engine::model::ArgumentNames<'a> impl<'a> core::iter::traits::iterator::Iterator for runvil_engine::model::ArgumentNames<'a> { type Item = &'a str } -impl<'a> core::iter::traits::iterator::Iterator for runvil_engine::prompt::ArgsDeclIter<'a> -impl<'a> core::iter::traits::iterator::Iterator for runvil_engine::prompt::ArgsDeclIter<'a> { type Item = (&'a str, &'a runvil_engine::prompt::ArgDecl) } impl<'a> runvil_engine::vfs::OpEvent<'a> impl<'de, T> serde_core::de::Deserialize<'de> for runvil_engine::effect::PackToolSets where T: serde_core::de::Deserialize<'de> impl runvil_engine::PromptFinder for Func where Func: core::ops::function::Fn(&str) -> core::result::Result + core::marker::Send + core::marker::Sync @@ -499,20 +485,11 @@ pub fn runvil_engine::packs::PackRef::vendor(&self) -> &str pub fn runvil_engine::packs::Prelude::new(pack: runvil_engine::packs::PackId, source: impl core::convert::Into) -> runvil_engine::packs::Prelude pub fn runvil_engine::packs::Prelude::pack(&self) -> &runvil_engine::packs::PackId pub fn runvil_engine::packs::Prelude::source(&self) -> &str -pub fn runvil_engine::prompt::ArgDecl::default(&self) -> core::option::Option<&serde_yaml_ng::value::Value> -pub fn runvil_engine::prompt::ArgDecl::description(&self) -> core::option::Option<&str> -pub fn runvil_engine::prompt::ArgDecl::is_optional(&self) -> bool -pub fn runvil_engine::prompt::ArgDecl::kind(&self) -> runvil_engine::prompt::ArgType -pub fn runvil_engine::prompt::ArgsDecl::get(&self, name: &str) -> core::option::Option<&runvil_engine::prompt::ArgDecl> -pub fn runvil_engine::prompt::ArgsDecl::is_default(&self) -> bool -pub fn runvil_engine::prompt::ArgsDecl::is_empty(&self) -> bool -pub fn runvil_engine::prompt::ArgsDecl::iter(&self) -> runvil_engine::prompt::ArgsDeclIter<'_> -pub fn runvil_engine::prompt::ArgsDecl::len(&self) -> usize +pub fn runvil_engine::prompt::ArgsSchema::as_json(&self) -> &serde_json::value::Value pub fn runvil_engine::prompt::FileDecl::description(&self) -> &str pub fn runvil_engine::prompt::FileDecl::path(&self) -> &str -pub fn runvil_engine::prompt::Frontmatter::args(&self) -> &runvil_engine::prompt::ArgsDecl +pub fn runvil_engine::prompt::Frontmatter::args(&self) -> &runvil_engine::prompt::ArgsSchema pub fn runvil_engine::prompt::Frontmatter::description(&self) -> &str -pub fn runvil_engine::prompt::Frontmatter::input(&self) -> core::option::Option<&runvil_engine::prompt::FileDecl> pub fn runvil_engine::prompt::Frontmatter::max_tool_iterations(&self) -> core::option::Option pub fn runvil_engine::prompt::Frontmatter::name(&self) -> &str pub fn runvil_engine::prompt::Frontmatter::output(&self) -> core::option::Option<&runvil_engine::prompt::FileDecl> @@ -618,6 +595,7 @@ pub runvil_engine::Error::prose: alloc::string::String pub runvil_engine::ErrorCode::AlreadyExists = 602 pub runvil_engine::ErrorCode::Anchor = 609 pub runvil_engine::ErrorCode::ApprovalRequired = 611 +pub runvil_engine::ErrorCode::ArgsInvalid = 705 pub runvil_engine::ErrorCode::Backend = 621 pub runvil_engine::ErrorCode::CallDepthExceeded = 204 pub runvil_engine::ErrorCode::Canceled = 702 @@ -883,8 +861,8 @@ pub runvil_engine::event::Event::TaskFailed::provenance: runvil_engine::ids::Pro pub runvil_engine::event::Event::TaskFailed::section: alloc::string::String pub runvil_engine::event::Event::TaskFailed::task: runvil_engine::ids::TaskId pub runvil_engine::event::Event::TaskStarted { .. } +pub runvil_engine::event::Event::TaskStarted::args: core::option::Option pub runvil_engine::event::Event::TaskStarted::index: core::option::Option -pub runvil_engine::event::Event::TaskStarted::input: core::option::Option pub runvil_engine::event::Event::TaskStarted::item: core::option::Option pub runvil_engine::event::Event::TaskStarted::provenance: runvil_engine::ids::Provenance pub runvil_engine::event::Event::TaskStarted::section: alloc::string::String @@ -1037,10 +1015,6 @@ pub runvil_engine::packs::RosterEntry::Unavailable { .. } pub runvil_engine::packs::RosterEntry::Unavailable::reason: alloc::string::String pub runvil_engine::packs::RosterEntry::Usable { .. } pub runvil_engine::packs::RosterEntry::Usable::vendor: alloc::string::String -pub runvil_engine::prompt::ArgType::Boolean -pub runvil_engine::prompt::ArgType::Integer -pub runvil_engine::prompt::ArgType::Number -pub runvil_engine::prompt::ArgType::String pub runvil_engine::vfs::Entry::description: core::option::Option pub runvil_engine::vfs::Entry::name: alloc::string::String pub runvil_engine::vfs::Entry::stat: runvil_engine::vfs::Stat @@ -1199,9 +1173,7 @@ pub struct runvil_engine::model::Temperature(/* private fields */) pub struct runvil_engine::packs::GlobalName { /* private fields */ } pub struct runvil_engine::packs::PackRef { /* private fields */ } pub struct runvil_engine::packs::Prelude { /* private fields */ } -pub struct runvil_engine::prompt::ArgDecl { /* private fields */ } -pub struct runvil_engine::prompt::ArgsDecl { /* private fields */ } -pub struct runvil_engine::prompt::ArgsDeclIter<'a> { /* private fields */ } +pub struct runvil_engine::prompt::ArgsSchema { /* private fields */ } pub struct runvil_engine::prompt::FileDecl { /* private fields */ } pub struct runvil_engine::prompt::Frontmatter { /* private fields */ } pub struct runvil_engine::prompt::RoleRequirements { /* private fields */ } diff --git a/crates/runvil-engine/src/lib.rs b/crates/runvil-engine/src/lib.rs index fbf104407..d8f5d0d57 100644 --- a/crates/runvil-engine/src/lib.rs +++ b/crates/runvil-engine/src/lib.rs @@ -125,10 +125,7 @@ pub mod prompt { //! What a prompt's frontmatter declares: its files, Packs, //! arguments, and model roles. - pub use runvil_prompt::ArgDecl; - pub use runvil_prompt::ArgType; - pub use runvil_prompt::ArgsDecl; - pub use runvil_prompt::ArgsDeclIter; + pub use runvil_prompt::ArgsSchema; pub use runvil_prompt::FileDecl; pub use runvil_prompt::Frontmatter; pub use runvil_prompt::RoleRequirements; diff --git a/crates/runvil-engine/tests/suite/args.rs b/crates/runvil-engine/tests/suite/args.rs new file mode 100644 index 000000000..d6d97ea47 --- /dev/null +++ b/crates/runvil-engine/tests/suite/args.rs @@ -0,0 +1,127 @@ +//! Passing a run its arguments, and reading them back as `args`. + +use runvil_engine::{Error, ErrorCode, ErrorFact, Step}; +use serde_json::json; + +use super::support::{Scenario, TestResult, greeter, prompt}; + +/// Declares a required string `name` and an integer `times` whose default +/// is 1. +const DECLARED: &str = "args: { type: object, properties: { name: { type: string }, times: { type: integer, default: 1 } }, required: [name] }"; + +/// Greets its `text` argument in prose, and returns the prose as the +/// section's epilog reads it. +const GREETS_IN_PROSE: &str = concat!( + "---\nname: greeter\ndescription: Greets its text argument in prose.\nrunvil: 0\n---\n\n", + "## Greet\n\n", + "Hello, {{ args.text }}.\n\n", + "```lua\nreturn prose\n```\n", +); + +/// The failure the first step of `scenario`'s run of `source` ends the run +/// with, after checking that the step is `Done`, that the failure's code +/// is `ArgsInvalid`, that it names no line of the prompt, and that the +/// step reports no event. +fn refused(scenario: Scenario, source: &str) -> TestResult { + let mut driver = scenario.start(source)?; + let Step::Done { result, events, .. } = driver.run.step() else { + return Err("the first step ends a run whose arguments the prompt refuses".into()); + }; + let Err(error) = result else { + return Err(format!("expected the arguments to be refused, got {result:?}").into()); + }; + assert_eq!(error.code, ErrorCode::ArgsInvalid, "{error:?}"); + assert_eq!( + error.at, None, + "the arguments are the caller's, so the failure names no line of the prompt" + ); + assert!( + events.is_empty(), + "a refused run reports no events: {events:?}" + ); + Ok(error) +} + +#[test] +fn a_run_reads_the_arguments_its_declaration_accepts_and_the_default_of_one_left_out() -> TestResult +{ + let lua = "return args.name .. ' x' .. args.times"; + let source = prompt(DECLARED, &[("Greet", lua)]); + + let named = Scenario::default().args(r#"{"name":"Ada"}"#); + assert_eq!(named.run(&source)?.text()?, "Ada x1"); + let both = Scenario::default().args(r#"{"name":"Ada","times":3}"#); + assert_eq!(both.run(&source)?.text()?, "Ada x3"); + Ok(()) +} + +#[test] +fn prose_reads_a_field_of_the_arguments_through_a_placeholder() -> TestResult { + let scenario = Scenario::default().args(r#"{"text":"Ada"}"#); + assert_eq!(scenario.run(GREETS_IN_PROSE)?.text()?, "Hello, Ada."); + Ok(()) +} + +#[test] +fn a_prompt_with_no_args_key_runs_on_the_empty_object_and_on_one_string_named_text() -> TestResult { + let source = greeter("return tostring(args.text)"); + + let empty = Scenario::default().args("{}"); + assert_eq!(empty.run(&source)?.text()?, "nil"); + let texted = Scenario::default().args(r#"{"text":"hello"}"#); + assert_eq!(texted.run(&source)?.text()?, "hello"); + Ok(()) +} + +#[test] +fn text_that_is_not_a_json_object_ends_the_run_at_its_first_step_naming_no_argument() -> TestResult +{ + let source = greeter("return 'done'"); + let cases = [ + ("", "not_json", "the arguments are not valid JSON"), + ("hello", "not_json", "the arguments are not valid JSON"), + ( + r#""hello""#, + "not_an_object", + "the arguments must be a JSON object, got string", + ), + ( + "[]", + "not_an_object", + "the arguments must be a JSON object, got array", + ), + ]; + for (text, reason, message) in cases { + let error = refused(Scenario::default().args(text), &source)?; + assert_eq!( + error.facts.get(&ErrorFact::Reason), + Some(&json!(reason)), + "{text:?}: {error:?}" + ); + assert_eq!( + error.facts.get(&ErrorFact::Name), + None, + "{text:?}: no one argument is at fault" + ); + let located = error.located_message(); + assert!(located.starts_with(message), "{text:?}: {located}"); + } + Ok(()) +} + +#[test] +fn a_field_the_declaration_does_not_list_ends_the_run_at_its_first_step_naming_the_field() +-> TestResult { + let scenario = Scenario::default().args(r#"{"text":"hello","tone":"warm"}"#); + let error = refused(scenario, &greeter("return 'done'"))?; + assert_eq!( + error.facts.get(&ErrorFact::Reason), + Some(&json!("undeclared")) + ); + assert_eq!(error.facts.get(&ErrorFact::Name), Some(&json!("tone"))); + assert_eq!( + error.located_message(), + "argument `tone` is not declared; the declared arguments are `text`" + ); + Ok(()) +} diff --git a/crates/runvil-engine/tests/suite/event.rs b/crates/runvil-engine/tests/suite/event.rs index 0a7cc7d61..b39111244 100644 --- a/crates/runvil-engine/tests/suite/event.rs +++ b/crates/runvil-engine/tests/suite/event.rs @@ -9,22 +9,25 @@ use serde_json::Value; use super::support::{Scenario, TestResult, note, prompt, shout, text}; const SHOUT: &str = " - store.write('note.md', 'hello ' .. args) + store.write('note.md', 'hello ' .. args.text) return tools.call('example/text/shout', { text = models.infer(store.read('note.md')) }) "; +/// The arguments every run here takes: the one field `text`. +const WORLD: &str = r#"{"text":"world"}"#; + fn shouting() -> (String, Scenario) { let source = prompt("", &[("Greet", SHOUT)]); ( source, - Scenario::tool(shout(), [text("hi there")]).args("world"), + Scenario::tool(shout(), [text("hi there")]).args(WORLD), ) } #[test] fn every_event_round_trips_as_a_json_line_and_no_two_share_a_provenance() -> TestResult { - let source = note("'hello ' .. args"); - let trace = Scenario::default().args("world").run(&source)?; + let source = note("'hello ' .. args.text"); + let trace = Scenario::default().args(WORLD).run(&source)?; assert_eq!(trace.text()?, "hello world"); for event in &trace.events { diff --git a/crates/runvil-engine/tests/suite/ids.rs b/crates/runvil-engine/tests/suite/ids.rs index bae90a359..d39471d6c 100644 --- a/crates/runvil-engine/tests/suite/ids.rs +++ b/crates/runvil-engine/tests/suite/ids.rs @@ -8,14 +8,14 @@ use runvil_engine::ids::{Provenance, TaskId}; use super::support::{Scenario, TestResult, prompt, text}; const GREET: &str = " - local first = tasks.spawn('../Reply', { input = 'hello' }) - local second = tasks.spawn('../Reply', { input = 'bye' }) + local first = tasks.spawn('../Reply', { args = 'hello' }) + local second = tasks.spawn('../Reply', { args = 'bye' }) local results = tasks.join({ first, second }) return results[1].result .. ' ' .. results[2].result "; const REPLY: &str = " - return models.infer(args) + return models.infer(args.text) "; type Groups = BTreeMap>; diff --git a/crates/runvil-engine/tests/suite/main.rs b/crates/runvil-engine/tests/suite/main.rs index 0e9d820aa..020fb7abb 100644 --- a/crates/runvil-engine/tests/suite/main.rs +++ b/crates/runvil-engine/tests/suite/main.rs @@ -1,5 +1,6 @@ //! The facade's integration suite, written against `runvil_engine` paths only. +mod args; mod cancel; mod effect; mod event; diff --git a/crates/runvil-engine/tests/suite/prompt.rs b/crates/runvil-engine/tests/suite/prompt.rs index 1028f2a59..b106b1047 100644 --- a/crates/runvil-engine/tests/suite/prompt.rs +++ b/crates/runvil-engine/tests/suite/prompt.rs @@ -3,20 +3,20 @@ use runvil_engine::ErrorCode; use runvil_engine::Prompt; use runvil_engine::model::{ModelRole, ThinkingMode}; +use serde_json::json; use super::prepare::refusal; use super::support::{Built, Scenario, TestResult, model, prompt}; const CONTRACT: &str = " - input: { path: names.md, description: The names to greet } output: { path: note.md, description: The note the greeter leaves } uses: [text] - args: { times: { type: integer, optional: true, default: 1 }, name: { type: string } } + args: { type: object, properties: { times: { type: integer, default: 1 }, name: { type: string } }, required: [name] } models: { fast: {} } "; #[test] -fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> TestResult { +fn the_frontmatter_lists_every_declaration_and_gives_its_arguments_as_json_schema() -> TestResult { let source = prompt(CONTRACT, &[("Greet", "return 'done'")]); let parsed = Prompt::parse(&|_: &str| Ok(source.clone()), "prompt")?; let frontmatter = parsed.frontmatter(); @@ -26,21 +26,10 @@ fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> Te "A prompt the facade suite builds." ); - let files: Vec<_> = [frontmatter.input(), frontmatter.output()] - .into_iter() - .flatten() - .map(|file| (file.path().to_string(), file.description().to_owned())) - .collect(); - assert_eq!( - files, - [ - ("names.md".to_owned(), "The names to greet".to_owned()), - ( - "note.md".to_owned(), - "The note the greeter leaves".to_owned() - ), - ] - ); + let output = frontmatter + .output() + .map(|file| (file.path(), file.description())); + assert_eq!(output, Some(("note.md", "The note the greeter leaves"))); let packs: Vec<_> = frontmatter .uses() .iter() @@ -57,12 +46,18 @@ fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> Te .collect(); assert_eq!(roles, [(ModelRole::Fast, None)]); - assert!( - frontmatter - .args() - .iter() - .map(|(name, _)| name) - .eq(["name", "times"]) + assert_eq!( + frontmatter.args().as_json(), + &json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "times": { "type": "integer", "default": 1 }, + }, + "required": ["name"], + "additionalProperties": false, + }), + "the declaration reads back as plain JSON Schema, each closed object saying so" ); let misspelled = source.replace("uses:", "use:"); diff --git a/crates/runvil-engine/tests/suite/shipped.rs b/crates/runvil-engine/tests/suite/shipped.rs index 213e051e0..6295593ee 100644 --- a/crates/runvil-engine/tests/suite/shipped.rs +++ b/crates/runvil-engine/tests/suite/shipped.rs @@ -25,7 +25,7 @@ fn research_ada_lovelace() -> TestResult { Tool::untrusted("web/search", "search results"), [search, text("a factual summary")], ) - .args("Ada Lovelace") + .args(r#"{"text":"Ada Lovelace"}"#) .params(move |ctx| ctx.with_tools(catalog.clone())) .run(&fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?) } diff --git a/crates/runvil-engine/tests/suite/support/scenario.rs b/crates/runvil-engine/tests/suite/support/scenario.rs index ebe591451..f4041946b 100644 --- a/crates/runvil-engine/tests/suite/support/scenario.rs +++ b/crates/runvil-engine/tests/suite/support/scenario.rs @@ -33,9 +33,9 @@ type Edit = Box RunParams>; /// The seed every scenario's run takes. const TEST_SEED: RunSeed = RunSeed::from_bytes([7; 32]); -/// One test's setup. Every scenario gives the `default` role [`canned`] -/// unless it names a model or a role map. -#[derive(Default)] +/// One test's setup. Every scenario passes its run the empty object `{}` +/// as its arguments unless it sets them, and gives the `default` role +/// [`canned`] unless it names a model or a role map. pub(crate) struct Scenario { args: String, started_at: Timestamp, @@ -48,6 +48,22 @@ pub(crate) struct Scenario { params: Option, } +impl Default for Scenario { + fn default() -> Self { + Self { + args: "{}".to_owned(), + started_at: Timestamp::default(), + model: None, + roles: None, + tool: None, + replies: Vec::new(), + hold: None, + reverse: false, + params: None, + } + } +} + impl Scenario { /// A scenario that serves `replies` to the run's chat effects, in /// order, and registers no tool. @@ -74,7 +90,9 @@ impl Scenario { self } - /// Runs the prompt with `args` as its argument text. + /// Runs the prompt with `args` as its arguments: the text of one JSON + /// object, which the run's first step checks against the prompt's + /// `args` declaration. pub(crate) fn args(mut self, args: &str) -> Self { args.clone_into(&mut self.args); self diff --git a/crates/runvil-internal/lua/benches/surface.rs b/crates/runvil-internal/lua/benches/surface.rs index 1676a9b46..80df11857 100644 --- a/crates/runvil-internal/lua/benches/surface.rs +++ b/crates/runvil-internal/lua/benches/surface.rs @@ -48,7 +48,7 @@ fn builder_vm() -> SectionVm { ) .expect("the bench VM builds") .inject_test_values( - "", + &json!({}), &json!({}), &std::sync::Arc::new( runvil_vfs::VfsRef::default() diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index 268ae0272..7352873ca 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -139,15 +139,17 @@ end -- `call` for the code it is installed for. `from` is nil for a section's -- own code and a library's identity for that library's code; the request --- carries it, and the driver resolves the target's path from there. The +-- carries it, and the driver resolves the target's path from there. +-- `args` is the called chain's arguments, a table of named fields or a +-- string, and with none the chain reads the caller's `args`. The -- driver resumes it with the called chain's final text, or with nil for a -- chain that ended with no text, and that is the call's value. local function call_from(from) - return function(target, input) + return function(target, args) local ok, result = yield({ op = "call", target = target, - input = input, + args = args, var = var_snapshot(), from = from, }) diff --git a/crates/runvil-internal/lua/src/__impl_globals.lua b/crates/runvil-internal/lua/src/__impl_globals.lua index 141821b46..4241568bd 100644 --- a/crates/runvil-internal/lua/src/__impl_globals.lua +++ b/crates/runvil-internal/lua/src/__impl_globals.lua @@ -5,14 +5,15 @@ -- strips the raw functions it captures and before any other chunk -- captures `setmetatable` or `getmetatable`. The chunk arguments are -- privileged captures, never globals: `globals` is the VM's globals table, --- `state` is the Engine's slot table (`argv` once injection installs it, +-- `state` is the Engine's slot table (`args` once injection installs the +-- read-only table of the chain's arguments, -- `prose` once a block installs its render, -- `messages` once injection installs the section's list, and `author` for --- the metatable author code set on `_G`), and `refuse_argv()`, +-- the metatable author code set on `_G`), and `refuse_args()`, -- `refuse_prose()`, and `refuse_messages()` raise the Engine's assignment -- refusals. The chunk returns the guard and the two replacements for the -- Engine to install. -local globals, state, refuse_argv, refuse_prose, refuse_messages = ... +local globals, state, refuse_args, refuse_prose, refuse_messages = ... local base_setmetatable, base_getmetatable = setmetatable, getmetatable local rawequal, rawget, rawset = rawequal, rawget, rawset @@ -29,15 +30,15 @@ local guard = { __metatable = "_G is guarded" } -- `_G` keeps the author's other metamethods but never becomes weak. local own = { __index = true, __newindex = true, __metatable = true } --- `argv`, `prose`, and `messages` never reach the author: `argv` always --- serves the value injection installed, and `messages` always serves the +-- `args`, `prose`, and `messages` never reach the author: `args` always +-- serves the table injection installed, and `messages` always serves the -- section's one list. Every other key goes to the author's handler, read -- raw from the author's metatable on each access the way Lua reads a -- metamethod, and tail-called so the handler's error levels and yields -- behave as if it were the metamethod. function guard.__index(t, key) - if key == "argv" then - return state.argv + if key == "args" then + return state.args end if key == "prose" then local render = state.prose @@ -55,8 +56,8 @@ function guard.__index(t, key) end function guard.__newindex(t, key, value) - if key == "argv" then - return refuse_argv() + if key == "args" then + return refuse_args() end if key == "prose" then return refuse_prose() diff --git a/crates/runvil-internal/lua/src/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index 88b41ec03..b8ad967d1 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -21,11 +21,29 @@ local yield, var_snapshot, helpers = ... local raise, fail, engine_type = helpers.raise, helpers.fail, helpers.engine_type +local pairs, format = pairs, string.format + +-- Refuses a key of `opts` that `call` does not take, so a misspelled +-- option fails where it is written. `accepted` holds the keys the call +-- takes, and `listed` names them for the message. +local function check_keys(opts, call, accepted, listed) + for key in pairs(opts) do + if not accepted[key] then + local shown = type(key) == "string" and format("%q", key) or tostring(key) + raise("lua", { message = call .. ": unknown key " .. shown .. "; " .. listed }) + end + end +end + +local SPAWN_KEYS = { args = true, item = true, index = true } +local WAIT_KEYS = { timeout = true } -- tasks.spawn(target, opts?): start a chain over `target` and return at --- once with a Task handle. `opts.input` overrides the chain's args, --- `opts.item` becomes its `item` global, `opts.index` its `sys.index`; the --- caller's `var` seeds the chain. +-- once with a Task handle. `opts.args` is the chain's arguments, a table +-- of named fields or a string, and with none the chain reads the caller's +-- `args`; `opts.item` becomes its `item` global, `opts.index` its +-- `sys.index`; the caller's `var` seeds the chain. Any other key of +-- `opts` is refused. -- -- `spawn_from(from)` makes the function for the code it is installed for: -- `from` is nil for a section's own code and a library's identity for that @@ -36,11 +54,13 @@ local function spawn_from(from) opts = {} elseif type(opts) ~= "table" then raise("lua", { message = "tasks.spawn opts must be a table, got " .. engine_type(opts) }) + else + check_keys(opts, "tasks.spawn", SPAWN_KEYS, "accepted keys are args, item, index") end local ok, result = yield({ op = "spawn", target = target, - input = opts.input, + args = opts.args, item = opts.item, index = opts.index, var = var_snapshot(), @@ -82,12 +102,13 @@ end -- Resolves a wait's opts argument to its timeout in seconds, or nil when -- no timeout was given. The domain check (non-negative, finite) is the -- Engine's at the timer yield; the shape check is here so the message names --- the call. +-- the call. `timeout` is the one key a wait takes. local function wait_timeout(opts, call) if opts == nil then return nil end if type(opts) ~= "table" then raise("lua", { message = call .. " opts must be a table, got " .. engine_type(opts) }) end + check_keys(opts, call, WAIT_KEYS, "the accepted key is timeout") local timeout = opts.timeout if timeout ~= nil and type(timeout) ~= "number" then raise("lua", { message = call .. " timeout must be a number, got " .. engine_type(timeout) }) diff --git a/crates/runvil-internal/lua/src/argv.rs b/crates/runvil-internal/lua/src/args.rs similarity index 51% rename from crates/runvil-internal/lua/src/argv.rs rename to crates/runvil-internal/lua/src/args.rs index a7d9f1917..0e3e669ed 100644 --- a/crates/runvil-internal/lua/src/argv.rs +++ b/crates/runvil-internal/lua/src/args.rs @@ -1,12 +1,13 @@ -//! The `argv` global: the parsed form of the run's args string. +//! The `args` global: the chain's arguments, one JSON object, as a +//! read-only table. //! -//! `argv` is read-only in every section. It installs at Engine injection -//! on the `_G` guard ([`crate::globals`]): `argv` is never a raw global, +//! `args` is read-only in every section. It installs at Engine injection +//! on the `_G` guard ([`crate::globals`]): `args` is never a raw global, //! so every read and every write of the name crosses the guard, which -//! serves the installed value and refuses the assignment before any -//! metatable author code set on `_G` sees the key. The table value itself -//! sits behind read-only proxy tables whose `__newindex` rejects every -//! write, at any depth. +//! serves the installed table and refuses the assignment before any +//! metatable author code set on `_G` sees the key. The table itself sits +//! behind read-only proxy tables whose `__newindex` rejects every write, +//! at any depth. use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; @@ -14,35 +15,41 @@ use serde_json::Value as Json; use crate::error::{self, Result}; use crate::proxy::{key_text, read_only_proxy}; -/// The refusal an assignment of the `argv` global raises. +/// The refusal an assignment of the `args` global raises. pub(crate) const ASSIGNMENT_REFUSAL: &str = - "argv is read-only: assign to `var` or a section global instead"; + "args is read-only: assign to `var` or a section global instead"; -/// The label `getmetatable` returns for `argv` and each table inside it. -const LABEL: &str = "argv is read-only"; +/// The label `getmetatable` returns for `args` and each table inside it. +const LABEL: &str = "args is read-only"; -/// Installs `argv` on the `_G` guard. `None` (malformed args, or JSON -/// null) installs nil, so `if argv then` is the idiomatic malformed check. -/// Reads work (absent fields read nil), and every assignment - `argv = ...` -/// or a field write at any depth - raises. +/// Installs `args`, the chain's arguments, on the `_G` guard. The value is +/// a JSON object, so `args` is a table in every section, an empty one +/// when the chain was given no fields. Reads work (absent fields read +/// nil), and every assignment - `args = ...` or a field write at any +/// depth - raises. /// /// # Errors -/// Returns a `LuaRuntime` failure if the value cannot be bridged or the guard -/// cannot record it. -pub(crate) fn install(lua: &Lua, argv: Option<&Json>) -> Result<()> { - let value = match argv { - None | Some(Json::Null) => Value::Nil, - Some(json) => read_only_json_value(lua, json)?, - }; - crate::globals::set_argv(lua, value) +/// Returns an `Internal` failure if `args` is not a JSON object, and a +/// `LuaRuntime` failure if the value cannot be bridged or the guard cannot +/// record it. +pub(crate) fn install(lua: &Lua, args: &Json) -> Result<()> { + if !args.is_object() { + return Err(error::make_internal_error(format!( + "args must be a JSON object, got {}", + lua.to_value(args) + .map_err(error::make_lua_error)? + .type_name() + ))); + } + let value = read_only_json_value(lua, args)?; + crate::globals::set_args(lua, value) } -/// Builds the read-only Lua form of an argv JSON value: tables become -/// read-only proxies and scalars bridge directly. A nested null reads -/// `json.null`; only a top-level absent or null argv reads nil, which -/// [`install`] handles before this runs. A list's data table carries -/// mlua's array metatable, so an empty list read through the proxy still -/// encodes as `[]`, and a list holding a null encodes whole. +/// Builds the read-only Lua form of a JSON value inside `args`: tables +/// become read-only proxies and scalars bridge directly, so a null reads +/// `json.null`. A list's data table carries mlua's array metatable, so an +/// empty list read through the proxy still encodes as `[]`, and a list +/// holding a null encodes whole. fn read_only_json_value(lua: &Lua, value: &Json) -> Result { match value { Json::Array(values) => { @@ -84,7 +91,7 @@ fn read_only_table(lua: &Lua, data: mlua::Table) -> Result { Value::String(_) => format!("'{}'", key_text(key)), other => key_text(other), }; - format!("argv is read-only: cannot set field {field}") + format!("args is read-only: cannot set field {field}") }, LABEL, ) diff --git a/crates/runvil-internal/lua/src/collection.rs b/crates/runvil-internal/lua/src/collection.rs index e832ebe03..77f12243a 100644 --- a/crates/runvil-internal/lua/src/collection.rs +++ b/crates/runvil-internal/lua/src/collection.rs @@ -7,12 +7,14 @@ //! follows as `{ key, value }` pair tables sorted by key, because Lua's //! `pairs` order depends on the string hash seed and a fanout's arm order //! must not. An array member arrives as the arm's `item` value as itself; a -//! hash member arrives as the pair table (`item.key` / `item.value`). +//! hash member arrives as the pair table (`item.key` / `item.value`). A +//! collection that is a sealed view enumerates as the table it stands for. use mlua::{Lua, LuaSerdeExt, Table, Value}; use crate::error::{self, Result, make_lua_runtime_error}; use crate::order::{KeyError, SortKey, sort_key}; +use crate::proxy::{plain, view_data}; /// Enumerates fanout's collection argument as the sequence of members the /// shim spawns arms over: the array part (`1..=#t`) in order, then the hash @@ -32,6 +34,7 @@ pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result collection.type_name() ))); }; + let table = &view_data(table).map_err(error::make_lua_error)?; let border = table.raw_len(); let members = lua .create_table_with_capacity(border, 0) @@ -125,7 +128,9 @@ pub fn render_item(item: &serde_json::Value) -> String { /// # Errors /// Returns a `LuaRuntime` failure when the value has no JSON form. pub(crate) fn render_item_value(lua: &Lua, item: Value) -> Result { - let json: serde_json::Value = lua.from_value(item).map_err(error::make_lua_error)?; + let json: serde_json::Value = plain(lua, item) + .and_then(|item| lua.from_value(item)) + .map_err(error::make_lua_error)?; Ok(render_item(&json)) } @@ -268,6 +273,23 @@ mod tests { assert_eq!(items, vec![json!({"key": 5, "value": "five"})]); } + #[test] + fn collection_members_enumerates_a_view_as_the_table_it_stands_for() { + let lua = mlua::Lua::new(); + let items = members_json( + &lua, + "return setmetatable({}, { __index = { 'a', 'b', extra = 'c' }, __metatable = 'view' })", + ); + assert_eq!( + items, + vec![ + json!("a"), + json!("b"), + json!({"key": "extra", "value": "c"}) + ] + ); + } + #[test] fn collection_members_returns_an_empty_sequence_for_an_empty_table() { let lua = mlua::Lua::new(); @@ -377,5 +399,13 @@ mod tests { render_item_value(&lua, text).expect("a string renders"), "alpha" ); + let view = eval( + &lua, + "return setmetatable({}, { __index = { key = 'alpha', value = 1 }, __metatable = 'view' })", + ); + assert_eq!( + render_item_value(&lua, view).expect("a view renders as its data"), + "{\"key\":\"alpha\",\"value\":1}" + ); } } diff --git a/crates/runvil-internal/lua/src/error_value.rs b/crates/runvil-internal/lua/src/error_value.rs index 68738f46b..303486d96 100644 --- a/crates/runvil-internal/lua/src/error_value.rs +++ b/crates/runvil-internal/lua/src/error_value.rs @@ -302,6 +302,7 @@ fn kind_of(code: ErrorCode) -> ErrorKind { | ErrorCode::RequirementsUnmet | ErrorCode::Internal | ErrorCode::Stalled + | ErrorCode::ArgsInvalid | ErrorCode::RecorderFailed | ErrorCode::PluginLoadFailed | ErrorCode::NoOutput => ErrorKind::Internal, diff --git a/crates/runvil-internal/lua/src/globals-tests.rs b/crates/runvil-internal/lua/src/globals-tests.rs index 5e5103f79..40c6097fe 100644 --- a/crates/runvil-internal/lua/src/globals-tests.rs +++ b/crates/runvil-internal/lua/src/globals-tests.rs @@ -1,4 +1,4 @@ -//! Tests for the `_G` guard: `argv`, `prose`, and `messages` stay guarded +//! Tests for the `_G` guard: `args`, `prose`, and `messages` stay guarded //! whatever author code does to `_G`'s metatable, the author's metatable //! composes behind the //! guard, and `setmetatable` and `getmetatable` behave as the base @@ -14,8 +14,8 @@ use super::{GLOBALS_CHUNK_NAME, GLOBALS_SOURCE, RESERVED_NAMES, Reserved, reserv use crate::test_vm::{BuiltVm, TestVm}; use crate::tests::{assert_chunk_name_resolves, refusal_line}; -/// The refusal line of an assignment to the `argv` global. -const ARGV_REFUSAL: &str = "argv is read-only: assign to `var` or a section global instead"; +/// The refusal line of an assignment to the `args` global. +const ARGS_REFUSAL: &str = "args is read-only: assign to `var` or a section global instead"; /// The refusal line of an assignment to `prose`. const PROSE_REFUSAL: &str = "prose is read-only: assign to `var` or a section global instead"; @@ -29,49 +29,49 @@ const MESSAGES_REFUSAL: &str = "messages is the section's conversation and canno const PROSE: &str = "the block's prose"; /// Builds a section VM the way section setup does up to the Pack -/// preludes: `argv`, the Engine globals, the control globals, and the +/// preludes: `args`, the Engine globals, the control globals, and the /// coroutine shims, with a block's lazy `prose` installed and one `kept` /// user record in `messages`. #[track_caller] fn guarded_vm() -> BuiltVm { - let argv = json!({ "query": "papers" }); - let vm = TestVm::new().argv(Some(&argv)).shims().build(); + let args = json!({ "query": "papers" }); + let vm = TestVm::new().args(&args).shims().build(); vm.install_lazy_prose(|_| Ok(PROSE.to_owned())) .expect("the prose guard installs"); vm.exec("messages:user('kept')"); vm } -/// The caught refusals of assigning `argv`, `prose`, and `messages`, each +/// The caught refusals of assigning `args`, `prose`, and `messages`, each /// reduced to its first line. #[track_caller] fn assignment_refusals(vm: &BuiltVm) -> [String; 3] { let refusals: [String; 3] = vm .eval::<(String, String, String)>( - "local _, argv_err = pcall(function() argv = 'hijacked' end)\n\ + "local _, args_err = pcall(function() args = 'hijacked' end)\n\ local _, prose_err = pcall(function() prose = 'overwritten' end)\n\ local _, messages_err = pcall(function() messages = {} end)\n\ - return tostring(argv_err), tostring(prose_err), tostring(messages_err)", + return tostring(args_err), tostring(prose_err), tostring(messages_err)", ) .into(); refusals.map(|refusal| refusal_line(&refusal).unwrap_or(&refusal).to_owned()) } -/// Asserts `argv`, `prose`, and `messages` still read as the Engine set +/// Asserts `args`, `prose`, and `messages` still read as the Engine set /// them and still refuse assignment with their unchanged refusals. #[track_caller] fn assert_guards_hold(vm: &BuiltVm, after: &str) { let (query, prose, kept): (String, String, String) = - vm.eval("return argv.query, prose, messages[1].content"); + vm.eval("return args.query, prose, messages[1].content"); assert_eq!( (query.as_str(), prose.as_str(), kept.as_str()), ("papers", PROSE, "kept"), - "argv, prose, and messages read as the Engine set them after {after}" + "args, prose, and messages read as the Engine set them after {after}" ); - let [argv_refusal, prose_refusal, messages_refusal] = assignment_refusals(vm); + let [args_refusal, prose_refusal, messages_refusal] = assignment_refusals(vm); assert_eq!( - argv_refusal, ARGV_REFUSAL, - "argv stays read-only after {after}" + args_refusal, ARGS_REFUSAL, + "args stays read-only after {after}" ); assert_eq!( prose_refusal, PROSE_REFUSAL, @@ -104,7 +104,7 @@ fn the_globals_chunk_name_resolves_to_the_guard_file() { } #[test] -fn no_setmetatable_call_on_g_frees_argv_prose_or_messages() { +fn no_setmetatable_call_on_g_frees_args_prose_or_messages() { for attempt in [ "setmetatable(_G, nil)", "setmetatable(_G, {})", @@ -117,40 +117,18 @@ fn no_setmetatable_call_on_g_frees_argv_prose_or_messages() { vm.eval::<()>(attempt); assert_guards_hold(&vm, attempt); let captured: bool = vm.eval( - "return captured == nil or (captured.argv == nil and captured.prose == nil \ + "return captured == nil or (captured.args == nil and captured.prose == nil \ and captured.messages == nil)", ); assert!( captured, - "the author's __newindex never sees argv, prose, or messages after {attempt}" + "the author's __newindex never sees args, prose, or messages after {attempt}" ); } } #[test] -fn a_nil_argv_reads_nil_under_a_strict_index_and_no_write_hook_sees_it() { - let vm = TestVm::new().shims().build(); - let (absent, refusal, hooked, still_absent): (bool, String, bool, bool) = vm.eval( - "seen = {}\n\ - setmetatable(_G, {\n\ - __index = function(_, key) error('undefined global ' .. key, 2) end,\n\ - __newindex = function(_, key, value) seen[key] = value end,\n\ - })\n\ - local absent = argv == nil\n\ - local _, err = pcall(function() argv = { query = 'hijacked' } end)\n\ - return absent, tostring(err), seen.argv ~= nil, argv == nil", - ); - assert!( - absent, - "a nil argv reads nil, not the strict handler's error" - ); - assert_eq!(refusal_line(&refusal), Some(ARGV_REFUSAL)); - assert!(!hooked, "the author's write hook never sees argv"); - assert!(still_absent, "the refused assignment wrote nothing"); -} - -#[test] -fn editing_the_table_getmetatable_returns_cannot_free_argv_prose_or_messages() { +fn editing_the_table_getmetatable_returns_cannot_free_args_prose_or_messages() { let vm = guarded_vm(); let before: bool = vm.eval("return getmetatable(_G) == nil"); assert!(before, "with no author metatable, getmetatable(_G) is nil"); @@ -356,14 +334,14 @@ return table.concat(out, '\\n')"; #[test] fn sealed_engine_values_keep_their_metatable_protection() { let vm = guarded_vm(); - let (sys_label, argv_label, sys_refusal, var_refusal): (String, String, String, String) = vm + let (sys_label, args_label, sys_refusal, var_refusal): (String, String, String, String) = vm .eval( "local _, sys_err = pcall(function() setmetatable(sys, {}) end)\n\ local _, var_err = pcall(function() setmetatable(var, {}) end)\n\ - return getmetatable(sys), getmetatable(argv), tostring(sys_err), tostring(var_err)", + return getmetatable(sys), getmetatable(args), tostring(sys_err), tostring(var_err)", ); assert_eq!(sys_label, "sys is sealed"); - assert_eq!(argv_label, "argv is read-only"); + assert_eq!(args_label, "args is read-only"); assert_eq!( sys_refusal, "[string \"probe\"]:1: cannot change a protected metatable" diff --git a/crates/runvil-internal/lua/src/globals.rs b/crates/runvil-internal/lua/src/globals.rs index b31409fa2..d087c2333 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -1,9 +1,10 @@ //! The `_G` guard: the one metatable on a section VM's globals table. //! //! The guard owns the three Engine-owned globals, and no metatable author -//! code sets on `_G` ever sees any of their keys. `argv` -//! ([`crate::argv`]) is never a raw global: reads return the value -//! injection installed and writes raise the read-only refusal. `prose`, each block's +//! code sets on `_G` ever sees any of their keys. `args`, the read-only +//! table of the chain's arguments ([`crate::args`]), is never a raw +//! global: reads return the table injection installed and writes raise +//! the read-only refusal. `prose`, each block's //! lazily rendered text ([`crate::prose`]), is never a raw global either: //! reads render through the Engine and writes raise the read-only refusal. //! `messages`, the section's one conversation ([`crate::messages`]), is @@ -22,7 +23,7 @@ //! guard. For every other value both behave as the base functions, //! argument errors and `__metatable` protection included. With no debug //! library, `rawset`, or code loading in the sandbox, author code has no -//! route to the guard or to a raw `argv`, `prose`, or `messages` global. +//! route to the guard or to a raw `args`, `prose`, or `messages` global. //! //! The Lua side lives in `__impl_globals.lua`; the Rust side is the slot //! table the chunk reads, held in the registry. @@ -46,7 +47,7 @@ const GLOBALS_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_globals /// The guard source. const GLOBALS_SOURCE: &str = include_str!("__impl_globals.lua"); -/// The registry key of the slot table the guard chunk reads: `argv`, +/// The registry key of the slot table the guard chunk reads: `args`, /// `prose`, `messages`, and `author`. const STATE_REGISTRY: &str = "runvil.globals.state"; @@ -67,7 +68,7 @@ static GLOBALS_PROGRAM: LazyLock> = pub(crate) fn install(lua: &Lua) -> Result<()> { let globals = lua.globals(); let state = lua.create_table().map_err(error::make_lua_error)?; - let refuse_argv = refusal(lua, crate::argv::ASSIGNMENT_REFUSAL)?; + let refuse_args = refusal(lua, crate::args::ASSIGNMENT_REFUSAL)?; let refuse_prose = refusal(lua, crate::prose::ASSIGNMENT_REFUSAL)?; let refuse_messages = refusal(lua, crate::messages::ASSIGNMENT_REFUSAL)?; let (guard, replace_metatable, read_metatable): (Table, Function, Function) = GLOBALS_PROGRAM @@ -77,7 +78,7 @@ pub(crate) fn install(lua: &Lua) -> Result<()> { .call(( globals.clone(), state.clone(), - refuse_argv, + refuse_args, refuse_prose, refuse_messages, )) @@ -95,16 +96,17 @@ pub(crate) fn install(lua: &Lua) -> Result<()> { .map_err(error::make_lua_error) } -/// Puts `value` in the `argv` slot, which every later read of the global -/// returns. Every assignment of the global raises the read-only refusal, -/// with or without a value in the slot. +/// Puts `value`, the read-only table of the chain's arguments, in the +/// `args` slot, which every later read of the global returns. Every +/// assignment of the global raises the read-only refusal, with or without +/// a value in the slot. /// /// # Errors /// Returns a `LuaRuntime` failure if the guard's slot table is missing or cannot /// be written. -pub(crate) fn set_argv(lua: &Lua, value: Value) -> Result<()> { +pub(crate) fn set_args(lua: &Lua, value: Value) -> Result<()> { state(lua)? - .raw_set("argv", value) + .raw_set("args", value) .map_err(error::make_lua_error) } @@ -194,9 +196,8 @@ impl fmt::Display for Reserved { /// A global section setup installs must be listed here: the Engine's /// section setup tests compare this list against a set-up VM's globals in /// both directions. -pub const RESERVED_NAMES: [(&str, Reserved); 63] = [ +pub const RESERVED_NAMES: [(&str, Reserved); 62] = [ ("args", Reserved::EngineGlobal), - ("argv", Reserved::EngineGlobal), ("call", Reserved::EngineGlobal), ("compactors", Reserved::EngineGlobal), ("item", Reserved::EngineGlobal), diff --git a/crates/runvil-internal/lua/src/iteration-tests.rs b/crates/runvil-internal/lua/src/iteration-tests.rs index 147a30d0f..9a3e6c008 100644 --- a/crates/runvil-internal/lua/src/iteration-tests.rs +++ b/crates/runvil-internal/lua/src/iteration-tests.rs @@ -109,6 +109,56 @@ fn pairs_honors_a_pairs_metamethod() { assert_eq!(sequence(&vm, source), vec![json!("1:10"), json!("2:20")]); } +#[test] +fn pairs_and_next_walk_a_sealed_view_as_the_table_it_stands_for() { + let vm = TestVm::new().build(); + let source = "local view = setmetatable({}, {__index = {10, 20, extra = 'x'}, __metatable = 'view'}) \ + local out = {} \ + for k, v in pairs(view) do out[#out+1] = k .. ':' .. v end \ + local first = next(view) \ + out[#out+1] = 'first ' .. first \ + out[#out+1] = 'last ' .. tostring(next(view, 'extra')) \ + return out"; + assert_eq!( + sequence(&vm, source), + vec![ + json!("1:10"), + json!("2:20"), + json!("extra:x"), + json!("first 1"), + json!("last nil"), + ] + ); +} + +#[test] +fn pairs_walks_a_table_whose_index_is_not_sealed_as_itself() { + // Without `__metatable` the table is no view, and a view whose data + // only a function can read holds no table to walk. + let vm = TestVm::new().build(); + let source = "local open = setmetatable({own = 1}, {__index = {other = 2}}) \ + local computed = setmetatable({}, {__index = function() return 1 end, __metatable = 'view'}) \ + local out = {} \ + for k in pairs(open) do out[#out+1] = k end \ + for k in pairs(computed) do out[#out+1] = k end \ + return out"; + assert_eq!(sequence(&vm, source), vec![json!("own")]); +} + +#[test] +fn a_pairs_metamethod_on_a_sealed_view_still_wins() { + let vm = TestVm::new().build(); + let source = "local view = setmetatable({}, {__index = {a = 1}, __metatable = 'view', \ + __pairs = function() \ + local done = false \ + return function() if not done then done = true; return 'own', 'walk' end end \ + end}) \ + local out = {} \ + for k, v in pairs(view) do out[#out+1] = k .. ':' .. v end \ + return out"; + assert_eq!(sequence(&vm, source), vec![json!("own:walk")]); +} + #[test] fn pairs_honors_a_userdata_pairs_metamethod() { let vm = TestVm::new().build(); diff --git a/crates/runvil-internal/lua/src/iteration.rs b/crates/runvil-internal/lua/src/iteration.rs index 7c3e0ff8f..b0589e1dc 100644 --- a/crates/runvil-internal/lua/src/iteration.rs +++ b/crates/runvil-internal/lua/src/iteration.rs @@ -9,6 +9,11 @@ //! still wins, as in stock Lua; any other non-table value, a userdata without //! `__pairs` included, is refused. //! +//! A sealed view (see [`crate::proxy::unseal`]) walks as the table it +//! stands for: the keys are its data table's, each value is the one a +//! read through the view gives, and the state `pairs` returns is the view +//! itself, so a walk never hands out the data table. +//! //! `pairs` captures that order once and advances it by position, so a key //! whose value becomes `nil` mid-traversal is skipped rather than visited with //! a nil value, whatever its type: a cleared key is skipped without a lookup @@ -42,6 +47,7 @@ use mlua::{Lua, MultiValue, Table, Value}; use crate::error::{self, Result}; use crate::order::{SortKey, sort_key}; +use crate::proxy::view_data; /// Installs the deterministic `next` and `pairs` globals, replacing the base /// library's hash-order versions. @@ -106,24 +112,27 @@ impl WalkOrder { } /// The `next(table, key)` replacement: the key strictly after `key` in the -/// sorted order, with its value, or `(nil, nil)` at the end. +/// sorted order, with its value, or `(nil, nil)` at the end. A sealed view +/// is read as the table it stands for. fn deterministic_next( _lua: &Lua, (table, previous): (Table, Value), ) -> mlua::Result<(Value, Value)> { - let keys = ordered_keys(&table).map_err(mlua::Error::external)?; + let data = view_data(&table)?; + let keys = ordered_keys(&data).map_err(mlua::Error::external)?; let Some(index) = next_index(&keys, &previous) else { return Ok((Value::Nil, Value::Nil)); }; let key = keys[index].key.clone(); - let value: Value = table.raw_get(&key)?; + let value: Value = data.raw_get(&key)?; Ok((key, value)) } /// The `pairs(value)` replacement: a `__pairs` metamethod's results when the /// table's or userdata's metatable holds one, otherwise a stateful iterator -/// over the table's walk order. A userdata without `__pairs` is refused like -/// any other non-table value. +/// over the table's walk order, where a sealed view is read as the table it +/// stands for. A userdata without `__pairs` is refused like any other +/// non-table value. fn pairs(lua: &Lua, value: Value) -> mlua::Result { let metamethod = match &value { Value::Table(table) => match table.metatable() { @@ -162,15 +171,16 @@ fn pairs(lua: &Lua, value: Value) -> mlua::Result { // snapshot rather than re-deriving order from the live table makes the // walk independent of a key's sortability: a key cleared mid-loop reads as // nil and is skipped, and no key-based lookup can end the walk early. - let keys = ordered_keys(&table)?; + let data = view_data(&table)?; + let keys = ordered_keys(&data)?; // The VM is single-threaded, so the cursor is a plain `usize` mutated in // place rather than an atomic. - let state = table.clone(); + let state = table; let mut position = 0usize; let iterator = lua.create_function_mut(move |_, ()| { while let Some(entry) = keys.get(position) { position += 1; - let value: Value = table.raw_get(&entry.key)?; + let value: Value = data.raw_get(&entry.key)?; if !matches!(value, Value::Nil) { return Ok((entry.key.clone(), value)); } diff --git a/crates/runvil-internal/lua/src/json.rs b/crates/runvil-internal/lua/src/json.rs index 76fa66cf8..7f9c61261 100644 --- a/crates/runvil-internal/lua/src/json.rs +++ b/crates/runvil-internal/lua/src/json.rs @@ -15,7 +15,7 @@ mod encode; mod keys; pub(crate) use decode::text_input; -pub(crate) use encode::to_json; +pub(crate) use encode::{to_json, to_stored_json}; use mlua::{Lua, LuaSerdeExt, Table, Value}; diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index 864dc710a..aed0c55da 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -49,6 +49,21 @@ pub(crate) fn to_json( Encoder::new(lua, prefix, debug).value(value, 1) } +/// The JSON value that a write of `value` to `target` stores: `value` +/// under the `json.encode` rules, except that a float with no fractional +/// part stays a float, so a stored number reads back as the kind it was +/// written as. Every refusal names the failing path from `target` and +/// then the reason, and a value too large to walk is too large to store. +pub(crate) fn to_stored_json(lua: &Lua, target: &str, value: &Value) -> mlua::Result { + let mut encoder = Encoder { + root: target.to_owned(), + too_large: format!("{target} is too large to store"), + keeps_floats: true, + ..Encoder::new(lua, "", false) + }; + encoder.value(value, 1) +} + /// `json.encode_lines(list [, options])`: each item of `list` encoded by /// the `json.encode` rules and followed by a newline. pub(super) fn encode_lines(lua: &Lua, (list, options): (Value, Value)) -> mlua::Result { @@ -150,6 +165,12 @@ fn too_deep() -> String { /// on the path hold as Lua references. struct Encoder { prefix: &'static str, + /// What a refusal calls the walked value, before the path inside it. + root: String, + /// The refusal of a value too large to walk. + too_large: String, + /// Whether a float with no fractional part stays a float. + keeps_floats: bool, debug: bool, marker: Table, path: Vec, @@ -162,6 +183,9 @@ impl Encoder { fn new(lua: &Lua, prefix: &'static str, debug: bool) -> Self { Self { prefix, + root: format!("{prefix}: value"), + too_large: format!("{prefix}: value is too large to encode"), + keeps_floats: false, debug, marker: lua.array_metatable(), path: Vec::new(), @@ -175,7 +199,7 @@ impl Encoder { /// no path, and it never becomes a placeholder: one placeholder per /// remaining value would not bound the work. fn too_large(&self) -> mlua::Error { - too_large(self.prefix) + author_refusal(self.too_large.clone()) } /// Spends one value of the budget. @@ -198,8 +222,8 @@ impl Encoder { let shown = self .path .iter() - .fold("value".to_owned(), |shown, key| key_path(&shown, key)); - author_refusal(format!("{}: {shown} {failure}", self.prefix)) + .fold(self.root.clone(), |shown, key| key_path(&shown, key)); + author_refusal(format!("{shown} {failure}")) } /// `placeholder` in debug mode, or the error for `failure`. @@ -219,7 +243,7 @@ impl Encoder { Value::LightUserData(pointer) if pointer.0.is_null() => Ok(Json::Null), Value::Boolean(flag) => Ok(Json::Bool(*flag)), Value::Integer(integer) => Ok(Json::from(*integer)), - Value::Number(number) => match finite(*number) { + Value::Number(number) => match self.number(*number) { Some(json) => Ok(json), None if self.debug => Ok(Json::String(non_finite_text(*number))), None => Err(self.error(NOT_FINITE)), @@ -244,6 +268,15 @@ impl Encoder { } } + /// The JSON number `number` is, or `None` for NaN and the infinities. + fn number(&self, number: f64) -> Option { + if self.keeps_floats { + Number::from_f64(number).map(Json::Number) + } else { + finite(number) + } + } + /// Encodes `table` at `level`, reading through a sealed view to its /// data table, which counts for the cycle and depth checks instead. fn table(&mut self, table: &Table, level: usize) -> mlua::Result { diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index ac157f154..639e3231d 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -2,11 +2,12 @@ //! //! A section's Lua chunk runs in a fresh, restricted `mlua` VM: only the //! `string`, `table`, `math`, and `utf8` standard libraries plus the safe base -//! functions are available; the raw input `args` string and the runtime `sys` -//! table are exposed; a writable `var` table is provided for the block to -//! populate; an always-on `store` table gives the block the run's virtual -//! files; and an every-Nth-instruction hook polls the run's cancel flag, so -//! even an unbounded loop aborts promptly once the run is canceled. +//! functions are available; `args`, the read-only table of the chain's +//! arguments, and the runtime `sys` table are exposed; a writable `var` +//! table is provided for the block to populate; an always-on `store` table +//! gives the block the run's virtual files; and an every-Nth-instruction +//! hook polls the run's cancel flag, so even an unbounded loop aborts +//! promptly once the run is canceled. //! Direct `print` and `warn` are unavailable. A persistent `log(message)` //! callback accepts one bounded, single-line UTF-8 string and reports it //! through the run's emitter as an `Event::Lua` checkpoint. A persistent @@ -84,7 +85,7 @@ //! into the prose. mod adhoc; -mod argv; +mod args; mod bindings; mod chunk_line; mod collection; diff --git a/crates/runvil-internal/lua/src/messages-tests-list.rs b/crates/runvil-internal/lua/src/messages-tests-list.rs index b6e39b0fc..e10353c66 100644 --- a/crates/runvil-internal/lua/src/messages-tests-list.rs +++ b/crates/runvil-internal/lua/src/messages-tests-list.rs @@ -393,6 +393,14 @@ fn append_stores_a_record_without_its_extra_fields() { assert_eq!(vm.messages().to_records(), vec![user("raw")]); } +#[test] +fn append_reads_a_view_as_the_record_it_stands_for() { + let saved = json!({ "saved": { "role": "user", "content": "kept" } }); + let vm = TestVm::new().var(&saved).build(); + vm.exec("messages:append(var.saved)"); + assert_eq!(vm.messages().to_records(), vec![user("kept")]); +} + #[test] fn index_assignment_is_refused_and_len_counts_the_records() { let vm = TestVm::new().build(); diff --git a/crates/runvil-internal/lua/src/messages.rs b/crates/runvil-internal/lua/src/messages.rs index ff645b98e..a87026205 100644 --- a/crates/runvil-internal/lua/src/messages.rs +++ b/crates/runvil-internal/lua/src/messages.rs @@ -44,6 +44,7 @@ use serde_json::{Map, Value as Json}; use crate::bindings::ToolBinding; use crate::error::{self, Error, Result, author_refusal, make_lua_runtime_error}; use crate::protocol::{MessageRecord, RecordRole, parse_record}; +use crate::proxy::plain; use crate::scope::{OfferMode, RenderedTools}; /// The refusal of an assignment of the `messages` global. @@ -322,12 +323,15 @@ fn handle(this: &AnyUserData) -> mlua::Result { Ok(this.borrow::()?.clone()) } -/// One Lua value in its JSON form, or why it has none. +/// One Lua value in its JSON form, or why it has none. A view inside the +/// value is read as the table it stands for. fn json(lua: &Lua, value: Value) -> std::result::Result { - lua.from_value(value).map_err(|error| match error { - mlua::Error::DeserializeError(reason) => reason, - other => other.to_string(), - }) + plain(lua, value) + .and_then(|value| lua.from_value(value)) + .map_err(|error| match error { + mlua::Error::DeserializeError(reason) => reason, + other => other.to_string(), + }) } /// A whole record given to `append` or `replace`, in its JSON form, for diff --git a/crates/runvil-internal/lua/src/prelude-tests-environment.rs b/crates/runvil-internal/lua/src/prelude-tests-environment.rs index 3d127cdab..3615acd15 100644 --- a/crates/runvil-internal/lua/src/prelude-tests-environment.rs +++ b/crates/runvil-internal/lua/src/prelude-tests-environment.rs @@ -10,7 +10,7 @@ fn a_prelude_sees_only_its_restricted_environment() { install_ui(vm.lua(), Arc::new(json!({}))).expect("the ui global installs"); let probe = "\ function probe_hidden() - return type(ui) .. ',' .. type(argv) .. ',' .. type(sys) .. ',' .. type(models) + return type(ui) .. ',' .. type(args) .. ',' .. type(sys) .. ',' .. type(models) .. ',' .. type(first_global) end function probe_visible() @@ -34,7 +34,7 @@ end"; .expect("the preludes install"); let author_view: String = vm.eval( - "return type(ui) .. ',' .. type(argv) .. ',' .. type(sys) .. ',' .. type(models) \ + "return type(ui) .. ',' .. type(args) .. ',' .. type(sys) .. ',' .. type(models) \ .. ',' .. type(first_global)", ); assert!( diff --git a/crates/runvil-internal/lua/src/prelude-tests.rs b/crates/runvil-internal/lua/src/prelude-tests.rs index 02805f673..44355c1ed 100644 --- a/crates/runvil-internal/lua/src/prelude-tests.rs +++ b/crates/runvil-internal/lua/src/prelude-tests.rs @@ -17,13 +17,13 @@ use crate::{CoroStep, LuaProgram, Request, SectionVm, YieldParse, install_ui}; mod environment; /// Builds a section VM through section setup up to where preludes install: -/// Engine injection with a bound `argv`, the Engine globals, the control -/// globals, and every coroutine yield shim, whose install puts +/// Engine injection with one field in `args`, the Engine globals, the +/// control globals, and every coroutine yield shim, whose install puts /// `models.loop` on `models` too. #[track_caller] fn prelude_vm(setup: TestVm<'_>) -> BuiltVm { - let argv = json!({ "mode": "argv" }); - setup.argv(Some(&argv)).shims().build() + let args = json!({ "mode": "args" }); + setup.args(&args).shims().build() } /// [`section_vm`] with the guarded `var` seeded from `var`. @@ -101,8 +101,15 @@ fn a_table_global_is_sealed_at_its_top_level() { ); let seal: String = vm.eval("return getmetatable(kit)"); assert_eq!(seal, "kit is sealed"); - let seen: i64 = vm.eval("local n = 0\nfor _ in pairs(kit) do n = n + 1 end\nreturn n"); - assert_eq!(seen, 0, "pairs over the sealed proxy sees nothing"); + let seen: String = vm.eval( + "local names = {}\n\ + for name in pairs(kit) do names[#names + 1] = name end\n\ + return table.concat(names, ',')", + ); + assert_eq!( + seen, "greet", + "pairs over the sealed proxy walks the table it stands for" + ); } #[test] @@ -310,11 +317,11 @@ fn ui_and_item_collide_on_a_vm_that_binds_neither() { } #[test] -fn argv_prose_and_messages_collide_though_the_g_metatable_serves_them() { +fn args_prose_and_messages_collide_though_the_g_metatable_serves_them() { let vm = section_vm(); vm.install_lazy_prose(|_| Ok("the block's prose".to_owned())) .expect("the prose guard installs"); - for name in ["argv", "prose", "messages"] { + for name in ["args", "prose", "messages"] { let raw: Value = vm.lua().globals().raw_get(name).expect("a raw read"); assert!( raw.is_nil(), diff --git a/crates/runvil-internal/lua/src/prelude.rs b/crates/runvil-internal/lua/src/prelude.rs index a69bd8d68..7c290eef1 100644 --- a/crates/runvil-internal/lua/src/prelude.rs +++ b/crates/runvil-internal/lua/src/prelude.rs @@ -180,7 +180,7 @@ fn defined_globals(pack: &PackId, env: &Table) -> Result /// /// The reserved check covers the Engine globals that a raw `_G` read does /// not find on every VM (`ui` and `item` bind only on some, and the `_G` -/// guard serves `argv`, `prose`, and `messages`), so whether a +/// guard serves `args`, `prose`, and `messages`), so whether a /// prelude installs does not depend on the run's `ui()` snapshot or the /// section. fn check_collision( diff --git a/crates/runvil-internal/lua/src/prose.rs b/crates/runvil-internal/lua/src/prose.rs index 78afdd61f..e7d092939 100644 --- a/crates/runvil-internal/lua/src/prose.rs +++ b/crates/runvil-internal/lua/src/prose.rs @@ -27,6 +27,7 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; use crate::error::{self, Result, make_lua_runtime_error}; +use crate::proxy::plain; use crate::sys::SYS_LIVE_POISONED; use crate::var::var_to_json; @@ -125,7 +126,13 @@ where value.type_name() ))) } - other => Ok(Some(lua.from_value(other).map_err(error::make_lua_error)?)), + // A view the global holds is read as the table it stands + // for. + other => Ok(Some( + plain(lua, other) + .and_then(|value| lua.from_value(value)) + .map_err(error::make_lua_error)?, + )), } }; let rendered = render(ProseState { diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index 5a2f33fb2..f6bd2e4f6 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -24,6 +24,7 @@ use tasks::{ use tools::{parse_adhoc_tool_done, parse_tool_call, parse_tool_calls}; use crate::error::{self, Error, make_lua_runtime_error}; +use crate::json::to_json; use crate::models::LuaModelHandle; use crate::section::{LibraryKey, resolve_section_target}; @@ -80,17 +81,34 @@ fn call_string(table: &mlua::Table, name: &str) -> std::result::Result std::result::Result, FieldFailure> { - match table.raw_get::(name) { +) -> std::result::Result, FieldFailure> { + match table.raw_get::("args") { Ok(Value::Nil) => Ok(None), - Ok(Value::String(value)) => call_text(&value, name).map(Some), + Ok(Value::String(text)) => call_text(&text, "args") + .map(serde_json::Value::String) + .map(Some), + Ok(value @ Value::Table(_)) => match to_json(lua, "args", false, &value) { + Ok(object @ serde_json::Value::Object(_)) => Ok(Some(object)), + Ok(_) => Err(call_error(format!("{ARGS_SHAPE}, got a list"))), + Err(error) => Err(FieldFailure::Argument(error::make_lua_error(error))), + }, Ok(other) => Err(call_error(format!( - "{name} must be a string, got {}", + "{ARGS_SHAPE}, got {}", other.type_name() ))), Err(_) => Err(FieldFailure::Malformed), @@ -262,16 +280,16 @@ fn call_handle( } } -/// Parses a `call` request: the author-supplied `target` and `input`, +/// Parses a `call` request: the author-supplied `target` and `args`, /// plus the shim-produced `var` snapshot. fn parse_call(lua: &Lua, table: &mlua::Table) -> std::result::Result { let target = call_target(table)?; - let input = call_optional_string(table, "input")?; + let args = call_args(lua, table)?; let var = shim_var(lua, table)?; let from = shim_from(table)?; Ok(Request::Call { target, - input, + args, var, from, }) diff --git a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs index e2caeab9b..9bad92f29 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs @@ -13,11 +13,10 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; use runvil_types::ids::TaskId; use crate::protocol::{NonEmpty, Request}; +use crate::proxy::plain; use crate::section::scalar_return; -use super::{ - FieldFailure, call_error, call_optional_string, call_string, call_target, shim_from, shim_var, -}; +use super::{FieldFailure, call_args, call_error, call_string, call_target, shim_from, shim_var}; /// Parses one task id string; a malformed path is the call's error naming /// why the parse refused it. @@ -36,14 +35,14 @@ 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 +/// author-supplied `args`, `item`, and `index` seeds, plus the /// shim-produced `var` snapshot. pub(super) fn parse_spawn( lua: &Lua, table: &mlua::Table, ) -> std::result::Result { let target = call_target(table)?; - let input = call_optional_string(table, "input")?; + let args = call_args(lua, table)?; let item = match table.raw_get::("item") { Ok(Value::Nil) => None, Ok(value @ (Value::Function(_) | Value::UserData(_) | Value::Thread(_))) => { @@ -52,7 +51,8 @@ pub(super) fn parse_spawn( value.type_name() ))); } - Ok(value) => match lua.from_value(value) { + // A view inside the item is read as the table it stands for. + Ok(value) => match plain(lua, value).and_then(|value| lua.from_value(value)) { Ok(item) => Some(item), Err(error) => { return Err(call_error(format!( @@ -84,7 +84,7 @@ pub(super) fn parse_spawn( let from = shim_from(table)?; Ok(Request::Spawn { target, - input, + args, item, index, var, diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index 22eb0e8d2..b8fe26492 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -4,6 +4,7 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; use crate::protocol::{AdhocToolOutcome, ModelCall, Request, ToolCallRecord}; +use crate::proxy::plain; use crate::section::scalar_return; use crate::tools::{model_tool_name, tool_id}; @@ -47,7 +48,8 @@ pub(super) fn parse_tool_call( /// decoded through the one id-or-tool polymorphism; a model-issued 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 +/// accepts), and a view inside `args` is read as the table it stands for. +/// A wrong `alias` for a script call, or a non-table or /// 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, @@ -71,14 +73,16 @@ fn read_tool_call( }; let args = match table.raw_get::("args") { Ok(Value::Nil) => serde_json::Value::Object(serde_json::Map::new()), - Ok(value @ Value::Table(_)) => match lua.from_value(value) { - Ok(args) => args, - Err(error) => { - return Err(call_error(format!( - "args must be a JSON-representable table: {error}" - ))); + Ok(value @ Value::Table(_)) => { + match plain(lua, value).and_then(|value| lua.from_value(value)) { + Ok(args) => args, + Err(error) => { + return Err(call_error(format!( + "args must be a JSON-representable table: {error}" + ))); + } } - }, + } Ok(other) => { return Err(call_error(format!( "args must be a table, got {}", diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index ea70be34d..a3049f8bf 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -23,8 +23,9 @@ use crate::section::LibraryKey; /// A validated suspending Engine call, parsed from the yielded table. /// -/// The parse happens at the resume boundary while the VM handle is live: a -/// spawn's `item` seed converts through the serde bridge and the handle +/// The parse happens at the resume boundary while the VM handle is live: +/// the `args` table of a `call` or a spawn converts to JSON, a spawn's +/// `item` seed converts through the serde bridge, and the handle /// userdata's [`ModelBinding`] is cloned out of its borrow, so nothing /// lifetime-bound enters the enum. #[derive(Debug)] @@ -38,15 +39,16 @@ pub enum Request { /// The leading handle's frozen binding, else `None`. binding: Option, }, - /// `call(target, input?)`: run a contained chain over the target's + /// `call(target, args?)`: run a contained chain over the target's /// slice. The call returns the chain's final text, or nil for a chain /// that ended with none. Call { /// The target's section path, as the author wrote it. target: String, - /// The optional input override; `None` runs under the caller's own - /// args. - input: Option, + /// The chain's own arguments: a JSON object for a table of named + /// fields, or a JSON string for a string. `None` runs the chain + /// under the caller's own `args`. + args: Option, /// The caller's `var` snapshot, seeded into the chain and discarded /// when it ends. var: serde_json::Value, @@ -61,9 +63,10 @@ pub enum Request { Spawn { /// The target's section path, as the author wrote it. target: String, - /// `opts.input`: the chain's `args` override; `None` runs under the - /// caller's own args. - input: Option, + /// `opts.args`: the chain's own arguments, a JSON object for a table + /// of named fields, or a JSON string for a string. `None` runs the + /// chain under the caller's own `args`. + args: Option, /// `opts.item`: the chain's `item` global and `{{ item }}` seed; /// `None` installs no `item`. item: Option, diff --git a/crates/runvil-internal/lua/src/protocol/tests.rs b/crates/runvil-internal/lua/src/protocol/tests.rs index 548d841e3..fed9e6957 100644 --- a/crates/runvil-internal/lua/src/protocol/tests.rs +++ b/crates/runvil-internal/lua/src/protocol/tests.rs @@ -1,7 +1,8 @@ //! Protocol tests: yield parsing and answer envelopes. //! //! The submodules follow the protocol's own split: `parse` the generic -//! yield-to-request validation, `parse_chat` the message-list request, +//! yield-to-request validation, `parse_args` the `args` a `call` or a +//! spawn hands its chain, `parse_chat` the message-list request, //! `parse_tasks` the task-operation requests, `parse_tool_call` the //! `tool_call` request, `answer` the answer-to-envelope round trips, and //! `answer_retained` when a block's failure is the answer's retained @@ -100,6 +101,7 @@ fn lua_table_with(lua: &Lua, chunk: &str, args: impl IntoLuaMulti) -> mlua::Tabl mod answer; mod answer_retained; mod parse; +mod parse_args; mod parse_chat; mod parse_tasks; mod parse_tool_call; diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse.rs b/crates/runvil-internal/lua/src/protocol/tests/parse.rs index d37a3b336..d0b94ca30 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse.rs @@ -1,8 +1,9 @@ //! Yield-to-request parsing for the leaf and structural requests (`infer`, //! `call`, `tool_calls`, `adhoc_tool_done`), and the malformed-yield //! rejections shared by every op. The `tool_call` request is in -//! `parse_tool_call` and the task-operation requests (`spawn`, `timer`, -//! `concurrency`) are in `parse_tasks`. +//! `parse_tool_call`, the task-operation requests (`spawn`, `timer`, +//! `concurrency`) are in `parse_tasks`, and the `args` a `call` or a +//! spawn hands its chain are in `parse_args`. use super::*; @@ -42,22 +43,23 @@ fn infer_with_a_handle_clones_its_frozen_binding() { } #[test] -fn call_parses_target_input_and_var_snapshot() { +fn call_parses_target_args_and_var_snapshot() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'call', target = '../Child', input = 'override', var = { k = 1 } }", + "{ op = 'call', target = '../Child', args = { query = 'papers', limit = 3 }, \ + var = { k = 1 } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { Request::Call { target, - input, + args, var, from, } => { assert_eq!(target, "../Child"); - assert_eq!(input.as_deref(), Some("override")); + assert_eq!(args, Some(json!({ "query": "papers", "limit": 3 }))); assert_eq!(var, json!({ "k": 1 })); assert_eq!(from, None, "a request with no `from` names no library"); } @@ -66,7 +68,7 @@ fn call_parses_target_input_and_var_snapshot() { } #[test] -fn call_without_input_yields_none() { +fn call_without_args_yields_none() { let lua = Lua::new(); let table = lua_table( &lua, @@ -74,7 +76,7 @@ fn call_without_input_yields_none() { ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { - Request::Call { input, .. } => assert_eq!(input, None), + Request::Call { args, .. } => assert_eq!(args, None), other => panic!("expected a call request, got {other:?}"), } } diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_args.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_args.rs new file mode 100644 index 000000000..32c8820b5 --- /dev/null +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_args.rs @@ -0,0 +1,296 @@ +//! The `args` a `call` or a spawn hands the chain it starts: a table of +//! named fields parses as the JSON object it holds, read through +//! read-only views, a string parses as that string, and a list, a value +//! of any other type, or a table with no JSON form is the call's error. +//! The last tests run the shims: what `call`, `tasks.spawn`, and a +//! `tasks.fanout` arm yield, and a refusal raised at the call site. + +use super::*; +use crate::test_vm::{BuiltVm, TestVm}; +use crate::{CoroStep, LuaBlockResult, LuaProgram}; + +/// The requests that take `args`. +const OPS: [&str; 2] = ["call", "spawn"]; + +/// The refusal of a list. +const LIST_REFUSAL: &str = "args must be a table of named fields or a string, got a list"; + +/// Parses the `op` request whose `args` field is what the Lua expression +/// `args` gives on `vm`. +#[track_caller] +fn parse_args(vm: &BuiltVm, op: &str, args: &str) -> YieldParse { + let table: mlua::Table = vm.eval(&format!( + "return {{ op = '{op}', target = '../Child', args = {args}, var = {{}} }}" + )); + Request::from_yield(vm.lua(), &Value::Table(table)) +} + +/// The `args` of the `call` or spawn request a yield parsed to. +#[track_caller] +fn parsed_args(parse: YieldParse) -> Option { + match expect_request(parse) { + Request::Call { args, .. } | Request::Spawn { args, .. } => args, + other => panic!("expected a call or a spawn request, got {other:?}"), + } +} + +/// The message of the call's error a yield was refused with. +#[track_caller] +fn refusal(parse: YieldParse) -> String { + match parse { + YieldParse::Refused(Error { + code: ErrorCode::LuaRuntime, + message, + .. + }) => message, + other => panic!("expected the call's error, got {other:?}"), + } +} + +#[test] +fn a_table_of_named_fields_parses_as_its_json_object() { + let vm = TestVm::new().build(); + for op in OPS { + let args = parsed_args(parse_args( + &vm, + op, + "{ query = 'papers', limit = 3, tags = { 'a', 'b' }, \ + deep = { on = true, none = json.null, list = json.array() } }", + )); + assert_eq!( + args, + Some(json!({ + "query": "papers", + "limit": 3, + "tags": ["a", "b"], + "deep": { "on": true, "none": null, "list": [] }, + })), + "{op}" + ); + } +} + +#[test] +fn a_string_parses_as_that_string() { + let vm = TestVm::new().build(); + for op in OPS { + for (source, text) in [("'papers'", "papers"), ("''", "")] { + assert_eq!( + parsed_args(parse_args(&vm, op, source)), + Some(json!(text)), + "{op} {source}" + ); + } + } +} + +#[test] +fn a_read_only_view_parses_as_the_object_it_stands_for() { + let data = json!({ + "query": "papers", + "tags": ["a", "b"], + "nested": { "n": 1 }, + "none": null, + }); + let vm = TestVm::new().args(&data).var(&data).build(); + for op in OPS { + for (source, object) in [ + ("args", data.clone()), + ("args.nested", json!({ "n": 1 })), + ("var.nested", json!({ "n": 1 })), + ( + "{ q = args.query, filter = args.nested, tags = args.tags, cfg = var.nested }", + json!({ + "q": "papers", + "filter": { "n": 1 }, + "tags": ["a", "b"], + "cfg": { "n": 1 }, + }), + ), + ] { + assert_eq!( + parsed_args(parse_args(&vm, op, source)), + Some(object), + "{op} {source}" + ); + } + } +} + +#[test] +fn an_unmarked_empty_table_parses_as_the_empty_object() { + let vm = TestVm::new().build(); + for op in OPS { + assert_eq!( + parsed_args(parse_args(&vm, op, "{}")), + Some(json!({})), + "{op}" + ); + } +} + +#[test] +fn a_list_is_the_calls_error() { + let data = json!({ "tags": ["a", "b"], "none": [] }); + let vm = TestVm::new().args(&data).build(); + for op in OPS { + for list in [ + "{ 1, 2 }", + "json.array()", + "json.array({ 'a' })", + "args.tags", + "args.none", + ] { + assert_eq!( + refusal(parse_args(&vm, op, list)), + LIST_REFUSAL, + "{op} {list}" + ); + } + } +} + +#[test] +fn a_value_that_is_neither_a_table_nor_a_string_is_the_calls_error_naming_its_type() { + let vm = TestVm::new().build(); + for op in OPS { + for (value, kind) in [ + ("5", "integer"), + ("1.5", "number"), + ("true", "boolean"), + ("function() end", "function"), + ] { + assert_eq!( + refusal(parse_args(&vm, op, value)), + format!("args must be a table of named fields or a string, got {kind}"), + "{op} {value}" + ); + } + } +} + +#[test] +fn a_table_with_no_json_form_is_the_calls_error_naming_the_field() { + let vm = TestVm::new().build(); + for op in OPS { + for (table, message) in [ + ( + "{ f = function() end }", + "args: value.f must be JSON data, got function", + ), + ( + "{ deep = { n = 0/0 } }", + "args: value.deep.n is not a finite number", + ), + ( + "{ 'a', name = 'x' }", + "args: value mixes list items and named keys", + ), + ( + "{ [true] = 1 }", + "args: value has a boolean key; object keys must be strings", + ), + ( + "{ meta = sys }", + "args: value.meta cannot be read (sys is sealed); \ + copy the fields you need into a plain table", + ), + ] { + assert_eq!(refusal(parse_args(&vm, op, table)), message, "{op} {table}"); + } + } +} + +#[test] +fn a_string_that_is_not_utf8_is_the_calls_error() { + let vm = TestVm::new().build(); + for op in OPS { + assert_eq!( + refusal(parse_args(&vm, op, "'\\xff'")), + "args must be a valid UTF-8 string", + "{op}" + ); + } +} + +/// Runs `source` as a block on `vm` to its first yield and returns the +/// block, its coroutine, and what the yield parsed to. +#[track_caller] +fn first_yield(vm: &BuiltVm, source: &str) -> (LuaProgram, mlua::Thread, YieldParse) { + let block = LuaProgram::compile(source, "prompt", "args test block", NonZeroU32::MIN) + .expect("the block compiles"); + let step = vm.start_block_coro(&block).expect("the block starts"); + let CoroStep::Yielded(thread, values) = step else { + panic!("the block must suspend on a request, got {step:?}"); + }; + let parse = vm.request_from_yield(&values); + (block, thread, parse) +} + +#[test] +fn call_and_spawn_yield_the_args_their_author_wrote() { + let data = json!({ "query": "papers", "tags": ["a", "b"] }); + let fields = json!({ "query": "notes", "limit": 3 }); + for (source, args) in [ + ( + "return call('../Child', { query = 'notes', limit = 3 })", + Some(fields.clone()), + ), + ("return call('../Child', 'notes')", Some(json!("notes"))), + ("return call('../Child', args)", Some(data.clone())), + ("return call('../Child')", None), + ( + "return tasks.spawn('../Child', { args = { query = 'notes', limit = 3 } })", + Some(fields.clone()), + ), + ( + "return tasks.spawn('../Child', { args = 'notes' })", + Some(json!("notes")), + ), + ( + "return tasks.spawn('../Child', { args = args })", + Some(data.clone()), + ), + ("return tasks.spawn('../Child', { item = 'x' })", None), + ("return tasks.spawn('../Child')", None), + ("return tasks.fanout('../Child', args.tags)", None), + ] { + let vm = TestVm::new().args(&data).shims().build(); + let (_, _, parse) = first_yield(&vm, source); + assert_eq!(parsed_args(parse), args, "{source}"); + } +} + +#[test] +fn a_refused_args_value_raises_at_the_call_site_where_pcall_catches_it() { + for call in [ + "call('../Child', { 1, 2 })", + "tasks.spawn('../Child', { args = { 1, 2 } })", + ] { + let vm = TestVm::new().shims().build(); + let (block, thread, parse) = first_yield( + &vm, + &format!( + "local ok, err = pcall(function() return {call} end)\n\ + return tostring(ok) .. '|' .. tostring(err.kind) .. '|' .. tostring(err)" + ), + ); + let YieldParse::Refused(error) = parse else { + panic!("{call}: expected the call's error, got {parse:?}"); + }; + let step = vm + .resume_block_coro_answer(&block, &thread, Err(error)) + .expect("the pcall catches the refusal"); + let CoroStep::Done(LuaBlockResult::Returned(Some(caught))) = step else { + panic!("{call}: the block must return what it caught, got {step:?}"); + }; + assert_eq!( + caught, + format!( + "false|lua|prompt:1: {LIST_REFUSAL}. {}", + ErrorCode::LuaRuntime.prose() + ), + "{call}" + ); + } +} 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 7d5e67fe4..c8522c840 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs @@ -12,21 +12,21 @@ fn spawn_parses_target_seeds_and_var() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'spawn', target = '../Child', input = 'override', item = { name = 'alpha' }, \ - index = 3, var = { k = 1 } }", + "{ op = 'spawn', target = '../Child', args = { query = 'papers' }, \ + item = { name = 'alpha' }, index = 3, var = { k = 1 } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { Request::Spawn { target, - input, + args, item, index, var, from, } => { assert_eq!(target, "../Child"); - assert_eq!(input.as_deref(), Some("override")); + assert_eq!(args, Some(json!({ "query": "papers" }))); assert_eq!(item, Some(json!({ "name": "alpha" }))); assert_eq!(index, Some(3)); assert_eq!(var, json!({ "k": 1 })); @@ -65,9 +65,9 @@ fn spawn_without_options_parses_every_seed_as_absent() { ); match expect_request(Request::from_yield(&lua, &Value::Table(table))) { Request::Spawn { - input, item, index, .. + args, item, index, .. } => { - assert_eq!(input, None); + assert_eq!(args, None); assert_eq!(item, None); assert_eq!(index, None); } @@ -88,6 +88,25 @@ fn a_spawn_item_holding_json_null_parses_to_a_null_field() { } } +#[test] +fn a_view_as_a_spawn_item_parses_as_the_table_it_stands_for() { + let data = json!({ "rows": [{ "n": 1 }, { "n": 2 }] }); + let vm = crate::test_vm::TestVm::new().var(&data).build(); + for (item, parsed) in [ + ("var.rows[1]", json!({ "n": 1 })), + ("{ row = var.rows[2] }", json!({ "row": { "n": 2 } })), + ("var.rows", json!([{ "n": 1 }, { "n": 2 }])), + ] { + let table: mlua::Table = vm.eval(&format!( + "return {{ op = 'spawn', target = '../Child', item = {item}, var = {{}} }}" + )); + match expect_request(Request::from_yield(vm.lua(), &Value::Table(table))) { + Request::Spawn { item, .. } => assert_eq!(item, Some(parsed)), + other => panic!("expected a spawn request, got {other:?}"), + } + } +} + #[test] fn spawn_seed_shape_errors_are_the_calls_error() { // `item` and `index` are author options: a wrong shape returns as diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs index a5994a165..916172349 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs @@ -156,6 +156,27 @@ fn a_marked_empty_list_in_the_args_parses_to_an_empty_array() { } } +#[test] +fn a_view_in_the_args_parses_as_the_table_it_stands_for() { + let data = json!({ "value": "hi", "tags": ["a", "b"], "none": [] }); + let vm = crate::test_vm::TestVm::new().var(&data).build(); + for (args, parsed) in [ + ("var", data.clone()), + ( + "{ tags = var.tags, n = 1 }", + json!({ "tags": ["a", "b"], "n": 1 }), + ), + ] { + let table: mlua::Table = vm.eval(&format!( + "return {{ op = 'tool_call', alias = 'tools/echo', args = {args} }}" + )); + match expect_request(Request::from_yield(vm.lua(), &Value::Table(table))) { + Request::ToolCall { args, .. } => assert_eq!(args, parsed), + other => panic!("expected a tool_call request, got {other:?}"), + } + } +} + #[test] fn a_tool_call_with_a_tool_object_decodes_to_its_id() { // The id-or-tool-object polymorphism at the protocol boundary: a tool diff --git a/crates/runvil-internal/lua/src/proxy.rs b/crates/runvil-internal/lua/src/proxy.rs index 07439e7b0..1eeb899e3 100644 --- a/crates/runvil-internal/lua/src/proxy.rs +++ b/crates/runvil-internal/lua/src/proxy.rs @@ -1,16 +1,24 @@ -//! The read-only proxy every seal builds on - `sys`, an `argv` table, -//! a sealed prelude global, and a prelude's `var` view - the key +//! The read-only proxy every seal builds on - `sys`, the `args` table of +//! the chain's arguments and each table inside it, a sealed prelude +//! global, and a prelude's `var` view - the key //! formatter their refusals and field paths share, and [`unseal`], which //! reads a table through such views without calling into Lua. +//! [`view_len`] and [`view_data`] make a view measure and walk as the +//! table it stands for, and [`plain`] gives a value's plain form to a +//! conversion that reads a view as the empty table it is. //! `tools.stats` and `tools.packs` build on the walkable [`read_only_walk`]. //! Tool and Pack objects share [`registry_object`] and [`add_frozen_guard`]. use std::collections::HashSet; +use std::ffi::c_void; use mlua::{ - AnyUserData, Function, Lua, MaybeSend, MetaMethod, MultiValue, Table, UserDataMethods, Value, + AnyUserData, Function, Lua, LuaSerdeExt, MaybeSend, MetaMethod, MultiValue, Table, + UserDataMethods, Value, }; +use crate::walk_budget::{TooLarge, WalkBudget}; + /// 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. pub(crate) fn key_text(key: &Value) -> String { @@ -32,8 +40,12 @@ pub(crate) fn key_path(path: &str, key: &Value) -> String { /// Builds an empty proxy whose `__index` is `index`, whose `__newindex` /// raises the text `refusal` builds from the refused key, and whose -/// `__metatable` is `label`, so `pairs` over it sees nothing and -/// `getmetatable` returns only the label. +/// `__metatable` is `label`, so `getmetatable` returns only the label. +/// +/// A proxy whose `index` is a table is a view of that table: its `__len` +/// is the table's length, and `pairs`, `next`, and [`plain`] read the +/// table through it. A proxy whose `index` is a function holds no table +/// to measure or walk, so `#` gives 0 and `pairs` sees nothing. pub(crate) fn read_only_proxy( lua: &Lua, index: Value, @@ -46,6 +58,9 @@ pub(crate) fn read_only_proxy( }, )?; let metatable = lua.create_table()?; + if let Value::Table(data) = &index { + metatable.raw_set("__len", view_len(lua, data)?)?; + } metatable.raw_set("__index", index)?; metatable.raw_set("__newindex", newindex)?; metatable.raw_set("__metatable", label)?; @@ -118,6 +133,139 @@ pub(crate) fn unseal( } } +/// The `__len` of a view whose data table is `data`: the length of `data` +/// itself, so `#`, `table.concat`, `table.unpack`, and `table.insert` +/// measure the view as the table it stands for. +pub(crate) fn view_len(lua: &Lua, data: &Table) -> mlua::Result { + let data = data.clone(); + lua.create_function(move |_, _: MultiValue| Ok(data.raw_len())) +} + +/// The table `table` stands for: the data table of a sealed view, read by +/// [`unseal`], and `table` itself when it is no view or its data cannot be +/// read as a table. +pub(crate) fn view_data(table: &Table) -> mlua::Result
{ + match unseal(table, || Ok(()))? { + Unsealed::Data(data) => Ok(data), + Unsealed::Unreadable(_) | Unsealed::Loop => Ok(table.clone()), + } +} + +/// The most table levels [`plain`] descends, the top-level table being +/// level 1. A table nested past it stays as it is. +const PLAIN_DEPTH_LIMIT: usize = 200; + +/// What [`plain`] says when a value holds more values than its budget. +const TOO_LARGE_TO_CONVERT: &str = "the value is too large to convert"; + +/// `value` with a plain copy of the view's data in place of every sealed +/// view inside it, for a caller that converts the value with +/// `from_value`, which reads a view as the empty table it is. +/// +/// A value that holds no view is returned as it is, so its conversion is +/// the one the caller always made. A table that holds a view becomes a +/// fresh table whose other entries are the table's own, and a copy keeps +/// the list marker of the table it copies, so an empty list still converts +/// to `[]`. The walk counts every value it visits against a +/// [`WalkBudget`]. It does not descend into a table already on the path to +/// it, or one at a level past [`PLAIN_DEPTH_LIMIT`], and it leaves a view +/// whose data only a function can read as it is: the conversion that +/// follows keeps its own behavior for each. +/// +/// # Errors +/// Returns a deserialize error once the walk passes its budget, and the +/// failure of reading a table or of building a copy. +pub(crate) fn plain(lua: &Lua, value: Value) -> mlua::Result { + let mut walk = PlainWalk { + lua, + marker: lua.array_metatable(), + budget: WalkBudget::for_vm(lua), + path: Vec::new(), + }; + Ok(walk.value(&value)?.unwrap_or(value)) +} + +/// One [`plain`] walk: the list marker a copy keeps, what is left of the +/// budget, and the data tables on the path to the current value. +struct PlainWalk<'lua> { + lua: &'lua Lua, + marker: Table, + budget: WalkBudget, + path: Vec<*const c_void>, +} + +impl PlainWalk<'_> { + /// The plain form of `value`, or `None` when `value` holds no view and + /// stays as it is. + fn value(&mut self, value: &Value) -> mlua::Result> { + self.budget.charge_node().map_err(too_large)?; + let Value::Table(table) = value else { + return Ok(None); + }; + let budget = &mut self.budget; + let data = match unseal(table, || budget.charge_node().map_err(too_large))? { + Unsealed::Data(data) => data, + Unsealed::Unreadable(_) | Unsealed::Loop => return Ok(None), + }; + let pointer = data.to_pointer(); + if self.path.len() >= PLAIN_DEPTH_LIMIT || self.path.contains(&pointer) { + return Ok(None); + } + let is_view = pointer != table.to_pointer(); + self.path.push(pointer); + let copy = self.entries(&data, is_view); + self.path.pop(); + Ok(copy?.map(Value::Table)) + } + + /// A copy of `data` with the plain form of each value, made when + /// `data` is a view's data or holds a view, and `None` otherwise. The + /// copy starts at the first entry that needs it and takes the entries + /// before that one as they are, so a table that needs none is read + /// once and never copied. + fn entries(&mut self, data: &Table, is_view: bool) -> mlua::Result> { + let mut copy = if is_view { + Some(self.like(data)?) + } else { + None + }; + for (seen, pair) in data.pairs::().enumerate() { + let (key, value) = pair?; + let plain = self.value(&value)?; + if copy.is_none() && plain.is_some() { + let fresh = self.like(data)?; + for earlier in data.pairs::().take(seen) { + let (key, value) = earlier?; + fresh.raw_set(key, value)?; + } + copy = Some(fresh); + } + if let Some(copy) = © { + copy.raw_set(key, plain.unwrap_or(value))?; + } + } + Ok(copy) + } + + /// An empty table that converts as `data` does: marked as a list when + /// `data` is. + fn like(&self, data: &Table) -> mlua::Result
{ + let copy = self.lua.create_table()?; + if data + .metatable() + .is_some_and(|metatable| metatable == self.marker) + { + copy.set_metatable(Some(self.marker.clone()))?; + } + Ok(copy) + } +} + +/// The refusal of a [`plain`] walk past its budget. +fn too_large(_: TooLarge) -> mlua::Error { + mlua::Error::DeserializeError(TOO_LARGE_TO_CONVERT.to_owned()) +} + /// An empty proxy whose metatable holds `index`, a `__pairs` that walks /// with `step`, a `__newindex` that raises ` is read-only`, and that /// text as its `__metatable`, which protects it. diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index 11695094d..d894d7674 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -153,21 +153,23 @@ impl LuaProgram { impl SectionVm { /// Installs the section's Engine values, ahead of the Pack preludes, - /// with an empty `var`, a nil `argv`, and a selection that starts - /// on the `default` binding, if the model set has one. + /// with `args` as the chain's arguments, an empty `var`, and a + /// selection that starts on the `default` binding, if the model set + /// has one. /// /// # Errors /// Returns the [`InjectFailure`] holding the bridging failure if Engine - /// values cannot be bridged. + /// values cannot be bridged, or holding an `Internal` failure if `args` + /// is not a JSON object. pub fn inject_test_values( self, - args: &str, + args: &serde_json::Value, sys: &serde_json::Value, access: &Arc, ) -> std::result::Result { let set = &self.bound_models; let selection = Arc::new(Mutex::new(set.binding("default").cloned())); - self.inject_values(args, sys, access, None, None, &selection) + self.inject_values(args, sys, access, None, &selection) } } diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index f747edd0b..3b0573364 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -76,23 +76,24 @@ pub(crate) struct LuaOutcome { pub(crate) var: Json, } -/// Runs a section's Lua chunk with `args` and `sys` exposed, a writable `var` -/// table available, and a `store` table backed by `access`, returning the -/// chunk's return value and the final `var`. Store operations and `log` -/// report through `emitter` under `section`; direct `print` is unavailable. +/// Runs a section's Lua chunk with `args` (a JSON object) and `sys` +/// exposed, a writable `var` table available, and a `store` table backed +/// by `access`, returning the chunk's return value and the final `var`. +/// Store operations and `log` report through `emitter` under `section`; +/// direct `print` is unavailable. /// /// The `tools` table is the same validating one every section VM installs, /// over an empty shared set: a chunk that calls `tools.offer(...)` fails /// loudly because the run offers no tool. /// /// # Errors -/// Returns the failure if the sandbox cannot be built, `sys`, `var`, or -/// `store` cannot be bridged, the chunk fails to run (including a failing +/// Returns the failure if the sandbox cannot be built, `args`, `sys`, `var`, +/// or `store` cannot be bridged, the chunk fails to run (including a failing /// `store` op, which raises a Lua error), or it returns a value that cannot /// be rendered as a result string. pub(crate) fn run_chunk( source: &str, - args: &str, + args: &Json, sys: &Json, access: &ScopedAccess, emitter: &Emitter, @@ -266,16 +267,15 @@ type Selection = Arc>>; /// A section VM's setup as data. Unset, the VM shares an empty tool and /// model set, reports nowhere under [`TEST_SECTION`], gets a fresh store, -/// empty `args` and `sys`, a nil `argv`, and an empty `var`. +/// and an empty `args`, `sys`, and `var`. pub(crate) struct TestVm<'a> { tools: ToolSet, models: ModelSet, access: Option, emitter: Emitter, section: &'a str, - args: &'a str, + args: Option<&'a Json>, sys: Option<&'a Json>, - argv: Option<&'a Json>, var: Option<&'a Json>, limits: Option<(usize, u32)>, raw_model_ids: bool, @@ -297,9 +297,8 @@ impl<'a> TestVm<'a> { access: None, emitter: null_emitter(), section: TEST_SECTION, - args: "", + args: None, sys: None, - argv: None, var: None, limits: None, raw_model_ids: false, @@ -343,10 +342,11 @@ impl<'a> TestVm<'a> { self } - /// The `args` string Engine injection installs. + /// The chain's arguments, a JSON object, which Engine injection + /// installs as the read-only `args` table; unset, an empty object. #[track_caller] - pub(crate) fn args(mut self, args: &'a str) -> Self { - self.args = args; + pub(crate) fn args(mut self, args: &'a Json) -> Self { + self.args = Some(args); self } @@ -357,14 +357,6 @@ impl<'a> TestVm<'a> { self } - /// The `argv` value Engine injection installs read-only; `None` - /// installs nil. - #[track_caller] - pub(crate) fn argv(mut self, argv: Option<&'a Json>) -> Self { - self.argv = argv; - self - } - /// The value the guarded `var` starts from. #[track_caller] pub(crate) fn var(mut self, var: &'a Json) -> Self { @@ -467,11 +459,10 @@ impl<'a> TestVm<'a> { let empty = json!({}); let vm = fresh .inject_values( - self.args, + self.args.unwrap_or(&empty), self.sys.unwrap_or(&empty), &access.access, self.var, - self.argv, &selection, ) .expect("the Engine values inject"); diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index d73ec3411..e19631e98 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -1,4 +1,4 @@ -//! Crate-wide tests for section VMs: sandboxing, logging, tool scoping, store operations, `var`, `argv`, and the `json` global. +//! Crate-wide tests for section VMs: sandboxing, logging, tool scoping, store operations, `var`, `args`, sealed views, and the `json` global. //! //! Each submodule holds one topic. The helpers below are shared by them; //! `recording`, `assert_chunk_name_resolves`, `refusal_line`, and the @@ -35,7 +35,7 @@ use crate::walk_budget::DEFAULT_LUA_MEMORY_BYTES; #[path = "tests-recording.rs"] pub(crate) mod recording; -mod argv; +mod args; mod block_endings; mod budgets; mod cancellation; @@ -54,6 +54,7 @@ mod store_errors; mod store_reports; mod sys_and_var; mod tool_scoping; +mod views; /// The store view derived from a test's access: the same view the `store` /// table's closures operate through, for seeding and extraction. @@ -219,7 +220,9 @@ fn failing_access() -> ScopedAccess { ) } -fn run(source: &str, args: &str) -> Result { +/// Runs a chunk on a fresh store, with the JSON object `args` as its +/// `args` table. +fn run(source: &str, args: &Json) -> Result { run_chunk( source, args, @@ -235,7 +238,7 @@ fn run(source: &str, args: &str) -> Result { fn run_with(source: &str, access: &ScopedAccess) -> Result { run_chunk( source, - "", + &json!({}), &json!({ "id": 1, "when": "t" }), access, &null_emitter(), diff --git a/crates/runvil-internal/lua/src/tests/args.rs b/crates/runvil-internal/lua/src/tests/args.rs new file mode 100644 index 000000000..d6ef9c362 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/args.rs @@ -0,0 +1,225 @@ +//! The `args` global: a table in every section, read through the guard, +//! measured and walked as the object it holds, and read-only at any depth. + +use super::*; + +/// Runs one chunk on an args VM, returning the block's failure. +fn run_args(vm: &SectionVm, source: &str) -> Result> { + run_scalar(vm, &program(source), &null_emitter(), "Args") +} + +#[test] +fn args_reads_through_the_guard() { + let args = json!({ "query": "papers", "nested": { "hits": 2 } }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "assert(args.query == 'papers', 'a field reads through')\n\ + assert(args.nested.hits == 2, 'a nested field reads through')\n\ + assert(args.absent == nil, 'an absent field reads nil')\n\ + return args.query", + ) + .expect("args must read"); + assert_eq!(out.as_deref(), Some("papers")); + vm.teardown(); +} + +#[test] +fn args_rejects_reassignment() { + let args = json!({ "query": "papers" }); + let vm = TestVm::new().args(&args).build(); + let error = + run_args(&vm, "args = { query = 'hijacked' }").expect_err("reassigning args must fail"); + assert!( + error.to_string().contains("args is read-only"), + "the error names the rule: {error}" + ); + vm.teardown(); +} + +#[test] +fn args_rejects_writes_at_any_depth() { + let args = json!({ "query": "papers", "nested": { "hits": 2 } }); + let vm = TestVm::new().args(&args).build(); + let error = + run_args(&vm, "args.query = 'hijacked'").expect_err("a field write on args must fail"); + assert!( + error.to_string().contains("args is read-only"), + "the error names the rule: {error}" + ); + let error = + run_args(&vm, "args.nested.hits = 3").expect_err("a nested field write on args must fail"); + assert!( + error.to_string().contains("args is read-only"), + "a nested table rejects writes too: {error}" + ); + vm.teardown(); +} + +#[test] +fn an_args_write_raises_the_whole_refusal_for_a_string_integer_or_other_key() { + let args = json!({ "query": "papers" }); + let vm = TestVm::new().args(&args).build(); + for (target, refusal) in [ + ("args.query", "args is read-only: cannot set field 'query'"), + ("args[1]", "args is read-only: cannot set field 1"), + ("args[2.5]", "args is read-only: cannot set field number"), + ] { + let source = + format!("local ok, err = pcall(function() {target} = 1 end)\nreturn tostring(err)"); + let out = run_args(&vm, &source).expect("the pcall catches the refusal"); + let caught = out.expect("the chunk returns the caught error"); + assert_eq!(refusal_line(&caught), Some(refusal), "writing {target}"); + } + let label = + run_args(&vm, "return getmetatable(args)").expect("getmetatable reads the args label"); + assert_eq!(label.as_deref(), Some("args is read-only")); + vm.teardown(); +} + +#[test] +fn an_args_assignment_raises_the_whole_refusal() { + let args = json!({ "query": "papers" }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "local ok, err = pcall(function() args = {} end)\nreturn tostring(err)", + ) + .expect("the pcall catches the refusal"); + let caught = out.expect("the chunk returns the caught error"); + assert_eq!( + refusal_line(&caught), + Some("args is read-only: assign to `var` or a section global instead") + ); + vm.teardown(); +} + +#[test] +fn args_holding_an_empty_list_encodes_it_as_an_empty_array() { + let args = json!({ "items": [], "nested": { "more": [] } }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "return json.encode(args.items) .. json.encode(args.nested.more)", + ) + .expect("args encodes"); + assert_eq!(out.as_deref(), Some("[][]")); + vm.teardown(); +} + +#[test] +fn args_reads_nested_nulls_as_json_null() { + let args = json!({ "a": null, "ids": [1, null, 3] }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "assert(args.a == json.null, 'a null field reads json.null')\n\ + assert(args.ids[2] == json.null, 'a null list slot reads json.null')\n\ + return 'ok'", + ) + .expect("nested nulls read as json.null"); + assert_eq!(out.as_deref(), Some("ok")); + vm.teardown(); +} + +#[test] +fn args_is_a_table_even_when_empty() { + let vm = TestVm::new().build(); + let out = run_args( + &vm, + "assert(type(args) == 'table', 'args is a table')\n\ + assert(next(args) == nil, 'empty args has no first key')\n\ + assert(#args == 0, 'empty args has no items')\n\ + assert(args.absent == nil, 'an absent field reads nil')\n\ + return json.encode(args)", + ) + .expect("empty args must read"); + assert_eq!(out.as_deref(), Some("{}")); + let error = run_args(&vm, "args = {}").expect_err("assigning empty args must fail"); + assert!( + error.to_string().contains("args is read-only"), + "the error names the rule: {error}" + ); + let still_empty = run_args(&vm, "return tostring(next(args) == nil)") + .expect("args reads after the refused assignment"); + assert_eq!( + still_empty.as_deref(), + Some("true"), + "the refused assignment wrote nothing" + ); + vm.teardown(); +} + +#[test] +fn the_length_of_a_list_in_args_is_its_item_count() { + let args = json!({ "list": ["a", "b", "c"], "empty": [], "nested": { "list": [1, 2] } }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "return #args.list .. ',' .. #args.empty .. ',' .. #args.nested.list .. ',' .. #args", + ) + .expect("the lengths must read"); + assert_eq!(out.as_deref(), Some("3,0,2,0")); + vm.teardown(); +} + +#[test] +fn pairs_walks_args_and_a_list_inside_it_in_key_order() { + let args = json!({ "query": "papers", "limit": 3, "tags": ["x", "y"] }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "local seen = {}\n\ + for key, value in pairs(args) do seen[#seen + 1] = key .. '=' .. type(value) end\n\ + for index, tag in pairs(args.tags) do seen[#seen + 1] = index .. ':' .. tag end\n\ + return table.concat(seen, ' ')", + ) + .expect("the walk must run"); + assert_eq!( + out.as_deref(), + Some("limit=number query=string tags=table 1:x 2:y") + ); + vm.teardown(); +} + +#[test] +fn an_injection_of_args_that_are_not_an_object_is_refused_as_internal() { + let refused = |args: &Json| { + SectionVm::new(&test_nonce(), &null_emitter(), "Args") + .expect("VM must build") + .inject_test_values(args, &json!({}), &fresh_access().access) + .expect_err("args that are not an object are refused") + .teardown(&null_emitter(), "Args") + }; + for args in [json!(5), json!(["papers"]), json!(null)] { + let error = refused(&args); + assert_eq!(error.code, ErrorCode::Internal, "{args}"); + assert!( + error.message.contains("args must be a JSON object"), + "the refusal of {args} names the shape args must have: {}", + error.message + ); + } + let error = refused(&json!("papers")); + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!( + error.message, + "internal invariant violated: args must be a JSON object, got string" + ); +} + +#[test] +fn the_args_guard_leaves_other_globals_alone() { + let args = json!({ "query": "papers" }); + let vm = TestVm::new().args(&args).build(); + let out = run_args( + &vm, + "scratch = 42\n\ + assert(scratch == 42, 'a bare global still assigns')\n\ + assert(absent_global == nil, 'an absent global still reads nil')\n\ + return 'ok'", + ) + .expect("ordinary globals must be untouched by the args guard"); + assert_eq!(out.as_deref(), Some("ok")); + vm.teardown(); +} diff --git a/crates/runvil-internal/lua/src/tests/argv.rs b/crates/runvil-internal/lua/src/tests/argv.rs deleted file mode 100644 index a269395dd..000000000 --- a/crates/runvil-internal/lua/src/tests/argv.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! The `argv` global: read through the guard, and read-only at any depth. - -use super::*; - -/// Runs one chunk on an argv VM, returning the block's failure. -fn run_argv(vm: &SectionVm, source: &str) -> Result> { - run_scalar(vm, &program(source), &null_emitter(), "Argv") -} - -#[test] -fn argv_reads_through_the_guard() { - let argv = json!({ "query": "papers", "nested": { "hits": 2 } }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv( - &vm, - "assert(argv.query == 'papers', 'a field reads through')\n\ - assert(argv.nested.hits == 2, 'a nested field reads through')\n\ - assert(argv.absent == nil, 'an absent field reads nil')\n\ - return argv.query", - ) - .expect("argv must read"); - assert_eq!(out.as_deref(), Some("papers")); - vm.teardown(); -} - -#[test] -fn argv_rejects_reassignment() { - let argv = json!({ "query": "papers" }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let error = - run_argv(&vm, "argv = { query = 'hijacked' }").expect_err("reassigning argv must fail"); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); - vm.teardown(); -} - -#[test] -fn argv_rejects_writes_at_any_depth() { - let argv = json!({ "query": "papers", "nested": { "hits": 2 } }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let error = - run_argv(&vm, "argv.query = 'hijacked'").expect_err("a field write on argv must fail"); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); - let error = - run_argv(&vm, "argv.nested.hits = 3").expect_err("a nested field write on argv must fail"); - assert!( - error.to_string().contains("argv is read-only"), - "a nested table rejects writes too: {error}" - ); - vm.teardown(); -} - -#[test] -fn an_argv_write_raises_the_whole_refusal_for_a_string_integer_or_other_key() { - let argv = json!({ "query": "papers" }); - let vm = TestVm::new().argv(Some(&argv)).build(); - for (target, refusal) in [ - ("argv.query", "argv is read-only: cannot set field 'query'"), - ("argv[1]", "argv is read-only: cannot set field 1"), - ("argv[2.5]", "argv is read-only: cannot set field number"), - ] { - let source = - format!("local ok, err = pcall(function() {target} = 1 end)\nreturn tostring(err)"); - let out = run_argv(&vm, &source).expect("the pcall catches the refusal"); - let caught = out.expect("the chunk returns the caught error"); - assert_eq!(refusal_line(&caught), Some(refusal), "writing {target}"); - } - let label = - run_argv(&vm, "return getmetatable(argv)").expect("getmetatable reads the argv label"); - assert_eq!(label.as_deref(), Some("argv is read-only")); - vm.teardown(); -} - -#[test] -fn an_argv_assignment_raises_the_whole_refusal() { - let argv = json!({ "query": "papers" }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv( - &vm, - "local ok, err = pcall(function() argv = {} end)\nreturn tostring(err)", - ) - .expect("the pcall catches the refusal"); - let caught = out.expect("the chunk returns the caught error"); - assert_eq!( - refusal_line(&caught), - Some("argv is read-only: assign to `var` or a section global instead") - ); - vm.teardown(); -} - -#[test] -fn argv_holding_an_empty_list_encodes_it_as_an_empty_array() { - let argv = json!({ "items": [], "nested": { "more": [] } }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv( - &vm, - "return json.encode(argv.items) .. json.encode(argv.nested.more)", - ) - .expect("argv encodes"); - assert_eq!(out.as_deref(), Some("[][]")); - vm.teardown(); -} - -#[test] -fn argv_reads_nested_nulls_as_json_null() { - let argv = json!({ "a": null, "ids": [1, null, 3] }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv( - &vm, - "assert(argv.a == json.null, 'a null field reads json.null')\n\ - assert(argv.ids[2] == json.null, 'a null list slot reads json.null')\n\ - return 'ok'", - ) - .expect("nested nulls read as json.null"); - assert_eq!(out.as_deref(), Some("ok")); - vm.teardown(); -} - -#[test] -fn a_nil_argv_reads_nil_and_rejects_assignment() { - let vm = TestVm::new().argv(None).build(); - let out = run_argv(&vm, "assert(argv == nil, 'no argv reads nil') return 'ok'") - .expect("a nil argv reads nil"); - assert_eq!(out.as_deref(), Some("ok")); - let error = run_argv(&vm, "argv = {}").expect_err("assigning a nil argv must still fail"); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); - let still_nil = run_argv(&vm, "return tostring(argv == nil)") - .expect("argv reads after the refused assignment"); - assert_eq!( - still_nil.as_deref(), - Some("true"), - "the refused assignment wrote nothing" - ); - vm.teardown(); -} - -#[test] -fn a_json_null_argv_reads_nil() { - let argv = json!(null); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv(&vm, "return tostring(argv == nil)").expect("a null argv reads"); - assert_eq!(out.as_deref(), Some("true")); - vm.teardown(); -} - -#[test] -fn a_scalar_argv_reads_and_rejects_assignment() { - let argv = json!(5); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = - run_argv(&vm, "assert(argv == 5) return tostring(argv)").expect("a scalar argv reads"); - assert_eq!(out.as_deref(), Some("5")); - let error = run_argv(&vm, "argv = 6").expect_err("reassigning a scalar argv must fail"); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); - vm.teardown(); -} - -#[test] -fn the_argv_guard_leaves_other_globals_alone() { - let argv = json!({ "query": "papers" }); - let vm = TestVm::new().argv(Some(&argv)).build(); - let out = run_argv( - &vm, - "scratch = 42\n\ - assert(scratch == 42, 'a bare global still assigns')\n\ - assert(absent_global == nil, 'an absent global still reads nil')\n\ - return 'ok'", - ) - .expect("ordinary globals must be untouched by the argv guard"); - assert_eq!(out.as_deref(), Some("ok")); - vm.teardown(); -} diff --git a/crates/runvil-internal/lua/src/tests/budgets.rs b/crates/runvil-internal/lua/src/tests/budgets.rs index b6dfd706d..4efc6f973 100644 --- a/crates/runvil-internal/lua/src/tests/budgets.rs +++ b/crates/runvil-internal/lua/src/tests/budgets.rs @@ -10,7 +10,7 @@ fn a_long_running_loop_completes_with_no_instruction_ceiling() { // the cancel flag. let out = run( "local n = 0\nfor i = 1, 8000000 do n = n + 1 end\nreturn n", - "", + &json!({}), ) .expect("a loop of 8000000 steps must complete"); assert_eq!(out.returned.as_deref(), Some("8000000")); diff --git a/crates/runvil-internal/lua/src/tests/json/views.rs b/crates/runvil-internal/lua/src/tests/json/views.rs index a331f72b0..7d9faa179 100644 --- a/crates/runvil-internal/lua/src/tests/json/views.rs +++ b/crates/runvil-internal/lua/src/tests/json/views.rs @@ -1,4 +1,4 @@ -//! `json.encode` reading through sealed views: `var`, `argv`, and +//! `json.encode` reading through sealed views: `var`, `args`, and //! the views it refuses, `sys` and message-record views. use super::*; @@ -26,13 +26,13 @@ fn var_and_its_tables_encode_their_stored_data() { } #[test] -fn argv_encodes_its_stored_data() { - let argv = json!({ "query": "papers", "tags": [], "nested": { "hits": [1, 2] } }); - let vm = TestVm::new().argv(Some(&argv)).shims().build(); +fn args_encodes_its_stored_data() { + let args = json!({ "query": "papers", "tags": [], "nested": { "hits": [1, 2] } }); + let vm = TestVm::new().args(&args).shims().build(); for (call, text) in [ - ("json.encode(argv.tags)", "[]"), + ("json.encode(args.tags)", "[]"), ( - "json.encode(argv)", + "json.encode(args)", r#"{"nested":{"hits":[1,2]},"query":"papers","tags":[]}"#, ), ] { @@ -41,11 +41,11 @@ fn argv_encodes_its_stored_data() { } #[test] -fn argv_encodes_its_nested_nulls() { - let argv = json!({ "ids": [1, null, 3], "tail": [1, null], "a": null }); - let vm = TestVm::new().argv(Some(&argv)).shims().build(); +fn args_encodes_its_nested_nulls() { + let args = json!({ "ids": [1, null, 3], "tail": [1, null], "a": null }); + let vm = TestVm::new().args(&args).shims().build(); assert_eq!( - encoded(&vm, "json.encode(argv)"), + encoded(&vm, "json.encode(args)"), r#"{"a":null,"ids":[1,null,3],"tail":[1,null]}"# ); } diff --git a/crates/runvil-internal/lua/src/tests/logging.rs b/crates/runvil-internal/lua/src/tests/logging.rs index b9754155c..63771f009 100644 --- a/crates/runvil-internal/lua/src/tests/logging.rs +++ b/crates/runvil-internal/lua/src/tests/logging.rs @@ -57,7 +57,7 @@ fn compatibility_chunk_logs_interleave_with_engine_operations() { "log('before write')\n\ store.write('state.txt', 'value')\n\ log('after write')", - "", + &json!({}), &json!({}), &fresh_access(), &observer, @@ -112,7 +112,7 @@ fn log_accepts_exactly_one_bounded_control_free_utf8_string() { let observer = recorder.emitter().clone(); let error = run_chunk( source, - "", + &json!({}), &json!({}), &fresh_access(), &observer, @@ -134,7 +134,7 @@ fn log_accepts_exactly_one_bounded_control_free_utf8_string() { "log({})", serde_json::to_string(&too_long).expect("test string must serialize") ); - let error = run(&source, "").expect_err("257 characters must fail"); + let error = run(&source, &json!({})).expect_err("257 characters must fail"); assert!(error.to_string().contains(&format!( "log message must be at most {LUA_LOG_CHARACTER_LIMIT} characters" ))); @@ -148,7 +148,7 @@ fn log_accepts_exactly_one_bounded_control_free_utf8_string() { let observer = recorder.emitter().clone(); run_chunk( &source, - "", + &json!({}), &json!({}), &fresh_access(), &observer, @@ -203,16 +203,17 @@ fn log_cumulative_byte_budget_is_enforced_before_the_event_budget() { #[test] fn logging_does_not_change_results_or_store_effects_with_null_observer() { let source = "log('checkpoint')\n\ - var.answer = args\n\ - store.write('answer.txt', args)\n\ + var.answer = args.text\n\ + store.write('answer.txt', args.text)\n\ return var.answer"; + let args = json!({ "text": "same" }); let recorded_access = fresh_access(); let recorded_store = store_view(&recorded_access); let recorder = Recorder::default(); let observer = recorder.emitter().clone(); let observed_outcome = run_chunk( source, - "same", + &args, &json!({}), &recorded_access, &observer, @@ -223,7 +224,7 @@ fn logging_does_not_change_results_or_store_effects_with_null_observer() { let null_store = store_view(&null_access); let silent = run_chunk( source, - "same", + &args, &json!({}), &null_access, &null_emitter(), @@ -284,7 +285,7 @@ fn concurrent_logs_keep_their_sections_and_local_order() { let observer = recorder.emitter().clone(); run_chunk( "log('first'); log('second')", - "", + &json!({}), &json!({}), &fresh_access(), &observer, diff --git a/crates/runvil-internal/lua/src/tests/require.rs b/crates/runvil-internal/lua/src/tests/require.rs index ced73ce2a..edd9e682d 100644 --- a/crates/runvil-internal/lua/src/tests/require.rs +++ b/crates/runvil-internal/lua/src/tests/require.rs @@ -512,9 +512,9 @@ fn a_suspending_library_call_yields_from_the_callers_coroutine() { panic!("the library's call must suspend the caller's block"); }; match vm.request_from_yield(&values) { - YieldParse::Request(Request::Call { target, input, .. }) => { + YieldParse::Request(Request::Call { target, args, .. }) => { assert_eq!(target, "../worker"); - assert_eq!(input.as_deref(), Some("job")); + assert_eq!(args, Some(json!("job"))); } other => panic!("expected a call request, got {other:?}"), } diff --git a/crates/runvil-internal/lua/src/tests/require/environment.rs b/crates/runvil-internal/lua/src/tests/require/environment.rs index b476f4a3d..87acc613e 100644 --- a/crates/runvil-internal/lua/src/tests/require/environment.rs +++ b/crates/runvil-internal/lua/src/tests/require/environment.rs @@ -2,7 +2,7 @@ //! //! Two things hold. A library's code sees the VM's globals exactly as a //! section's own block does, through the `_G` guard: plain globals both -//! ways, `argv`, `messages`, `prose`, and a metatable the author set on +//! ways, `args`, `messages`, `prose`, and a metatable the author set on //! `_G`. And the suspending calls and the prompt reads that a library's //! code makes say which library made them, while the library loads and //! from a function called after the load, a tail call included. @@ -131,13 +131,13 @@ fn library_code_reaches_every_engine_global_it_does_not_bind() { } #[test] -fn library_code_reads_argv_and_cannot_assign_it() { - let argv = json!({ "topic": "x" }); +fn library_code_reads_args_and_cannot_assign_it() { + let args = json!({ "topic": "x" }); let vm = env_vm( - TestVm::new().argv(Some(&argv)), + TestVm::new().args(&args), kit(&[ - "topic_at_load = argv.topic\nfunction assign() argv = { topic = 'y' } end\n\ - function topic() return argv.topic end", + "topic_at_load = args.topic\nfunction assign() args = { topic = 'y' } end\n\ + function topic() return args.topic end", ]), ); let value = returned( @@ -151,7 +151,7 @@ fn library_code_reads_argv_and_cannot_assign_it() { "the read serves the installed value and the write is refused, got {value}" ); assert!( - value.contains(crate::argv::ASSIGNMENT_REFUSAL), + value.contains(crate::args::ASSIGNMENT_REFUSAL), "the refusal is the guard's own, got {value}" ); } @@ -462,7 +462,7 @@ fn a_library_call_still_suspends_and_resumes_the_callers_block() { }; assert!(matches!( vm.request_from_yield(&values), - YieldParse::Request(Request::Call { ref input, .. }) if input.as_deref() == Some("job") + YieldParse::Request(Request::Call { ref args, .. }) if *args == Some(json!("job")) )); let step = vm .resume_block_coro(&block, &thread, (true, "hi")) diff --git a/crates/runvil-internal/lua/src/tests/sandbox.rs b/crates/runvil-internal/lua/src/tests/sandbox.rs index 057124ff6..9d688d120 100644 --- a/crates/runvil-internal/lua/src/tests/sandbox.rs +++ b/crates/runvil-internal/lua/src/tests/sandbox.rs @@ -32,17 +32,20 @@ fn direct_output_is_absent_in_every_executable_lua_vm() { vm.teardown(); assert_eq!( - run("return tostring(print) .. ':' .. tostring(warn)", "") - .expect("compatibility VM must run") - .returned - .as_deref(), + run( + "return tostring(print) .. ':' .. tostring(warn)", + &json!({}) + ) + .expect("compatibility VM must run") + .returned + .as_deref(), Some("nil:nil") ); } #[test] fn safe_stdlib_present() { - let out = run("return string.upper(args)", "hi").unwrap(); + let out = run("return string.upper(args.text)", &json!({ "text": "hi" })).unwrap(); assert_eq!(out.returned.as_deref(), Some("HI")); } @@ -51,7 +54,7 @@ fn the_utf8_library_counts_cuts_and_encodes_code_points() { let out = run( "return utf8.len('café') .. '|' .. ('café'):sub(1, utf8.offset('café', 0, 5) - 1) \ .. '|' .. utf8.char(233)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("4|caf|é")); @@ -61,7 +64,7 @@ fn the_utf8_library_counts_cuts_and_encodes_code_points() { fn dangerous_globals_absent() { let out = run( "return tostring(io) .. ',' .. tostring(os) .. ',' .. tostring(require) .. ',' .. tostring(load) .. ',' .. tostring(package)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("nil,nil,nil,nil,nil")); @@ -132,7 +135,7 @@ fn setmetatable_refuses_finalizers_and_weak_globals_but_keeps_g_handlers() { #[test] fn untrusted_global_escapes_and_envelopes_any_string() { - let outcome = run("return untrusted('a < b')", "").expect("untrusted must run"); + let outcome = run("return untrusted('a < b')", &json!({})).expect("untrusted must run"); let wrapped = outcome.returned.expect("untrusted returns a string"); assert!( wrapped.starts_with("The text inside the untrusted_input_"), @@ -160,7 +163,7 @@ fn untrusted_global_wraps_every_call_under_the_run_nonce() { // nonce, so identical content produces a byte-identical envelope. let outcome = run( "return untrusted('same') .. '\\n@@SPLIT@@\\n' .. untrusted('same')", - "", + &json!({}), ) .expect("untrusted must run"); let wrapped = outcome.returned.expect("the chunk returns both envelopes"); @@ -172,7 +175,7 @@ fn untrusted_global_wraps_every_call_under_the_run_nonce() { #[test] fn untrusted_global_rejects_a_non_string_argument() { - let error = run("return untrusted({})", "").expect_err("a table is not a string"); + let error = run("return untrusted({})", &json!({})).expect_err("a table is not a string"); assert!( error.code == ErrorCode::LuaRuntime, "a non-string argument must surface as a Lua error, got {error:?}" diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index 765a91aab..5e34bfd8e 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -42,12 +42,12 @@ fn section_vm_preserves_one_environment_across_all_phases() { // level reads `args` and `store`; the functions it defines resolve the // same globals when later chunks call them. let first = program( - "first_saw_args = args\n\ + "first_saw_args = args.text\n\ first_saw_store = store.read('seed.txt')\n\ function decorate(value) return '<' .. value .. '>' end", ); let prologue = program( - "var.decorated = decorate(args)\n\ + "var.decorated = decorate(args.text)\n\ store.write('phase.txt', var.decorated)", ); let between = program("phase_marker = 'model answer'"); @@ -58,9 +58,10 @@ fn section_vm_preserves_one_environment_across_all_phases() { store .write("seed.txt", b"seeded") .expect("the memory store can seed a file"); + let args = json!({ "text": "input" }); let sys = json!({ "id": 7 }); let vm = TestVm::new() - .args("input") + .args(&args) .sys(&sys) .access(access.clone()) .inject(); @@ -104,11 +105,10 @@ fn a_refused_injection_hands_back_the_vm_for_its_teardown() { let failure = SectionVm::new(&test_nonce(), recorder.emitter(), "Seed") .expect("VM must build") .inject_values( - "", + &json!({}), &json!({}), &fresh_access().access, Some(&json!(5)), - None, &Arc::new(Mutex::new(None)), ) .expect_err("a non-object var seed is refused"); @@ -165,7 +165,7 @@ fn section_vm_value_injection_bypasses_chunk_global_metatables() { ); let inspect = program( "return tostring(captured.args) .. ',' .. tostring(captured.fixtures_search) .. ',' .. \ - args .. ',' .. type(fixtures_search) .. ',' .. tools.get('fixtures/search').id", + args.text .. ',' .. type(fixtures_search) .. ',' .. tools.get('fixtures/search').id", ); let bindings = fixture_set(&[("search", "search the web")]); let access = fresh_access(); @@ -177,7 +177,11 @@ fn section_vm_value_injection_bypasses_chunk_global_metatables() { "Test", ) .expect("VM must build") - .inject_test_values("private input", &json!({}), &access.access) + .inject_test_values( + &json!({ "text": "private input" }), + &json!({}), + &access.access, + ) .expect("Engine values must inject"); let observer = null_emitter(); vm.install_engine_globals(&observer, "Test", &access.scope) @@ -194,13 +198,14 @@ fn section_vm_value_injection_bypasses_chunk_global_metatables() { #[test] fn section_vm_reports_store_operations_in_each_chunk() { - let write = program("store.write('state.txt', args)"); + let write = program("store.write('state.txt', args.text)"); let read = program("return store.read('state.txt')"); let recorder = Recorder::default(); let access = fresh_access(); + let args = json!({ "text": "private input" }); let vm = TestVm::new() .section("Gather") - .args("private input") + .args(&args) .access(access.clone()) .inject(); let observer = recorder.emitter().clone(); @@ -290,13 +295,17 @@ fn section_vms_isolate_mutated_globals() { #[test] fn section_lifecycle_reports_are_ordered_exact_and_payload_free() { let first = program("private_global = 'chunk secret'"); - let prologue = program("var.value = args"); + let prologue = program("var.value = args.text"); let epilog = program("return 'epilog done'"); let recorder = Recorder::default(); let access = fresh_access(); let vm = SectionVm::new(&test_nonce(), recorder.emitter(), "Gather") .expect("VM must build") - .inject_test_values("private input", &json!({}), &access.access) + .inject_test_values( + &json!({ "text": "private input" }), + &json!({}), + &access.access, + ) .expect("Engine values must inject"); let observer = recorder.emitter().clone(); vm.install_engine_globals(&observer, "Gather", &access.scope) diff --git a/crates/runvil-internal/lua/src/tests/store.rs b/crates/runvil-internal/lua/src/tests/store.rs index 68689c85c..9a4c55595 100644 --- a/crates/runvil-internal/lua/src/tests/store.rs +++ b/crates/runvil-internal/lua/src/tests/store.rs @@ -41,7 +41,7 @@ fn store_exists_returns_boolean() { fn store_write_then_read_numbered_returns_numbered_content() { let out = run( "store.write('a.txt', 'first\\nsecond')\nreturn store.read_numbered('a.txt')", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("1| first\n2| second")); @@ -51,7 +51,7 @@ fn store_write_then_read_numbered_returns_numbered_content() { fn store_append_extends_the_file() { let out = run( "store.append('log.txt', 'one\\n')\nstore.append('log.txt', 'two')\nreturn store.read_numbered('log.txt')", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("1| one\n2| two")); @@ -61,7 +61,7 @@ fn store_append_extends_the_file() { fn store_str_replace_edits_in_place() { let out = run( "store.write('a.txt', 'the quick brown fox')\nstore.str_replace('a.txt', 'quick', 'slow')\nreturn store.read_numbered('a.txt')", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("1| the slow brown fox")); @@ -71,7 +71,7 @@ fn store_str_replace_edits_in_place() { fn store_delete_then_read_raises() { let err = run( "store.write('a.txt', 'gone soon')\nstore.delete('a.txt')\nreturn store.read('a.txt')", - "", + &json!({}), ) .expect_err("reading a deleted file must raise"); let msg = lua_error_message(&err); @@ -85,7 +85,7 @@ fn store_delete_then_read_raises() { fn store_read_with_start_only_reads_to_eof() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read('a.txt', 2)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("two\nthree")); @@ -95,7 +95,7 @@ fn store_read_with_start_only_reads_to_eof() { fn store_read_with_start_and_end_slices_inclusively() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read('a.txt', 2, 2)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("two")); @@ -105,7 +105,7 @@ fn store_read_with_start_and_end_slices_inclusively() { fn store_read_clamps_end_to_the_last_line() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read('a.txt', 2, 99)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("two\nthree")); @@ -115,7 +115,7 @@ fn store_read_clamps_end_to_the_last_line() { fn store_read_beyond_eof_returns_empty() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read('a.txt', 99)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("")); @@ -127,7 +127,7 @@ fn store_read_start_below_one_raises() { "store.write('a.txt', 'one')\nreturn store.read('a.txt', 0)", "store.write('a.txt', 'one')\nreturn store.read('a.txt', -1)", ] { - let err = run(source, "").expect_err("a start below 1 must raise"); + let err = run(source, &json!({})).expect_err("a start below 1 must raise"); let msg = lua_error_message(&err); assert!( msg.contains("invalid line range"), @@ -140,7 +140,7 @@ fn store_read_start_below_one_raises() { fn store_read_end_before_start_raises() { let err = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read('a.txt', 3, 2)", - "", + &json!({}), ) .expect_err("an end before start must raise"); let msg = lua_error_message(&err); @@ -154,7 +154,7 @@ fn store_read_end_before_start_raises() { fn store_read_end_without_start_raises() { let err = run( "store.write('a.txt', 'one')\nreturn store.read('a.txt', nil, 1)", - "", + &json!({}), ) .expect_err("an end without a start must raise"); let msg = lua_error_message(&err); @@ -211,7 +211,7 @@ fn store_read_numbered_pads_across_the_hundred_boundary() { fn store_read_numbered_clamps_end_to_the_last_line() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read_numbered('a.txt', 2, 99)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("2| two\n3| three")); @@ -221,7 +221,7 @@ fn store_read_numbered_clamps_end_to_the_last_line() { fn store_read_numbered_beyond_eof_returns_empty() { let out = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read_numbered('a.txt', 99)", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("")); @@ -233,7 +233,7 @@ fn store_read_numbered_start_below_one_raises() { "store.write('a.txt', 'one')\nreturn store.read_numbered('a.txt', 0)", "store.write('a.txt', 'one')\nreturn store.read_numbered('a.txt', -1)", ] { - let err = run(source, "").expect_err("a start below 1 must raise"); + let err = run(source, &json!({})).expect_err("a start below 1 must raise"); let msg = lua_error_message(&err); assert!( msg.contains("invalid line range"), @@ -246,7 +246,7 @@ fn store_read_numbered_start_below_one_raises() { fn store_read_numbered_end_before_start_raises() { let err = run( "store.write('a.txt', 'one\\ntwo\\nthree')\nreturn store.read_numbered('a.txt', 3, 2)", - "", + &json!({}), ) .expect_err("an end before start must raise"); let msg = lua_error_message(&err); @@ -260,7 +260,7 @@ fn store_read_numbered_end_before_start_raises() { fn store_read_numbered_end_without_start_raises() { let err = run( "store.write('a.txt', 'one')\nreturn store.read_numbered('a.txt', nil, 1)", - "", + &json!({}), ) .expect_err("an end without a start must raise"); let msg = lua_error_message(&err); @@ -341,7 +341,7 @@ fn installed_store_read_numbered_honors_line_bounds() { fn store_glob_returns_a_sorted_array() { let out = run( "store.write('src/b.rs', '')\nstore.write('src/a.rs', '')\nlocal g = store.glob('src/*.rs')\nreturn g[1] .. ',' .. g[2]", - "", + &json!({}), ) .unwrap(); assert_eq!(out.returned.as_deref(), Some("src/a.rs,src/b.rs")); @@ -353,7 +353,7 @@ fn store_error_surfaces_as_lua_error() { // its store failure (through `mlua::Error::external`). let err = run( "store.write('a.txt', 'na na na')\nstore.str_replace('a.txt', 'na', 'la')", - "", + &json!({}), ) .expect_err("an ambiguous anchor must raise"); let msg = lua_error_message(&err); diff --git a/crates/runvil-internal/lua/src/tests/store_reports.rs b/crates/runvil-internal/lua/src/tests/store_reports.rs index c86c4e4b8..cf42d3f75 100644 --- a/crates/runvil-internal/lua/src/tests/store_reports.rs +++ b/crates/runvil-internal/lua/src/tests/store_reports.rs @@ -73,7 +73,7 @@ fn store_reports_are_ordered_exact_and_payload_free_on_failure() { store.str_replace('secret/path.txt', 'missing secret', 'replacement')"; let error = run_chunk( source, - "private input", + &json!({ "text": "private input" }), &json!({ "id": 1, "when": "t" }), &access, &observer, @@ -185,8 +185,15 @@ fn every_store_operation_reports_its_exact_success_and_failure() { (case.prepare)(&access); let recorder = Recorder::default(); let observer = recorder.emitter().clone(); - run_chunk(case.source, "", &json!({}), &access, &observer, "Store") - .expect("the memory store operation succeeds"); + run_chunk( + case.source, + &json!({}), + &json!({}), + &access, + &observer, + "Store", + ) + .expect("the memory store operation succeeds"); assert_eq!( recorder.observations(), vec![("Store".to_owned(), case.success.clone())], @@ -197,8 +204,15 @@ fn every_store_operation_reports_its_exact_success_and_failure() { let access = failing_access(); let recorder = Recorder::default(); let observer = recorder.emitter().clone(); - let error = run_chunk(case.source, "", &json!({}), &access, &observer, "Store") - .expect_err("the failing backend rejects every operation"); + let error = run_chunk( + case.source, + &json!({}), + &json!({}), + &access, + &observer, + "Store", + ) + .expect_err("the failing backend rejects every operation"); assert!( error.code == ErrorCode::LuaRuntime, "a store failure surfaces through the Lua boundary, got {error:?}" @@ -224,7 +238,7 @@ fn store_observations_happen_before_later_lua_side_effects() { run_chunk( "store.write('first.txt', '')\nlog('mark')\nstore.write('second.txt', '')", - "", + &json!({}), &json!({}), &access, &observer, diff --git a/crates/runvil-internal/lua/src/tests/sys_and_var.rs b/crates/runvil-internal/lua/src/tests/sys_and_var.rs index d19fe2fb7..98ebc01b2 100644 --- a/crates/runvil-internal/lua/src/tests/sys_and_var.rs +++ b/crates/runvil-internal/lua/src/tests/sys_and_var.rs @@ -4,38 +4,50 @@ use super::*; #[test] -fn returns_args_verbatim() { +fn returns_an_args_field_verbatim() { assert_eq!( - run("return args", "hello").unwrap().returned.as_deref(), + run("return args.text", &json!({ "text": "hello" })) + .unwrap() + .returned + .as_deref(), Some("hello") ); } #[test] fn expression_only_compatibility_chunk_returns_its_value() { - assert_eq!(run("42", "").unwrap().returned.as_deref(), Some("42")); + assert_eq!( + run("42", &json!({})).unwrap().returned.as_deref(), + Some("42") + ); } #[test] fn no_return_is_none() { - assert_eq!(run("local x = 1", "hello").unwrap().returned, None); + assert_eq!(run("local x = 1", &json!({})).unwrap().returned, None); } #[test] fn reads_sys() { assert_eq!( - run("return sys.id", "").unwrap().returned.as_deref(), + run("return sys.id", &json!({})) + .unwrap() + .returned + .as_deref(), Some("1") ); assert_eq!( - run("return sys.when", "").unwrap().returned.as_deref(), + run("return sys.when", &json!({})) + .unwrap() + .returned + .as_deref(), Some("t") ); } #[test] fn unknown_sys_field_is_a_lua_error() { - let error = run("return sys.bogus", "").expect_err("missing sys field must fail"); + let error = run("return sys.bogus", &json!({})).expect_err("missing sys field must fail"); assert!( error.to_string().contains("unknown sys field 'bogus'"), "error was {error}" @@ -44,7 +56,8 @@ fn unknown_sys_field_is_a_lua_error() { #[test] fn writing_sys_field_is_a_lua_error() { - let existing = run("sys.when = 'x'", "").expect_err("writing an existing sys field must fail"); + let existing = + run("sys.when = 'x'", &json!({})).expect_err("writing an existing sys field must fail"); assert!( existing .to_string() @@ -52,7 +65,7 @@ fn writing_sys_field_is_a_lua_error() { "error was {existing}" ); - let created = run("sys.extra = 1", "").expect_err("creating a sys field must fail"); + let created = run("sys.extra = 1", &json!({})).expect_err("creating a sys field must fail"); assert!( created .to_string() @@ -70,17 +83,21 @@ fn a_sys_write_raises_the_whole_refusal_for_a_string_integer_or_other_key() { ] { let source = format!("local ok, err = pcall(function() {target} = 1 end)\nreturn tostring(err)"); - let out = run(&source, "").expect("the pcall catches the refusal"); + let out = run(&source, &json!({})).expect("the pcall catches the refusal"); let caught = out.returned.expect("the chunk returns the caught error"); assert_eq!(refusal_line(&caught), Some(refusal), "writing {target}"); } - let label = run("return getmetatable(sys)", "").unwrap(); + let label = run("return getmetatable(sys)", &json!({})).unwrap(); assert_eq!(label.returned.as_deref(), Some("sys is sealed")); } #[test] fn var_is_read_back() { - let out = run("var.greeting = 'hi ' .. args", "bob").unwrap(); + let out = run( + "var.greeting = 'hi ' .. args.text", + &json!({ "text": "bob" }), + ) + .unwrap(); assert_eq!( out.var.get("greeting").and_then(|v| v.as_str()), Some("hi bob") @@ -91,7 +108,7 @@ fn var_is_read_back() { fn var_guard_allows_json_data_and_reads_back() { let out = run( "var.n = 1\nvar.s = 'x'\nvar.t = { a = {1, 2} }\nvar.b = true", - "", + &json!({}), ) .expect("JSON data writes must pass the guard"); assert_eq!( @@ -102,7 +119,7 @@ fn var_guard_allows_json_data_and_reads_back() { #[test] fn var_rejects_a_function_at_the_assigning_line() { - let error = run("var.f = function() end", "") + let error = run("var.f = function() end", &json!({})) .expect_err("a function assigned into var must fail at the assigning line"); assert!( error @@ -114,7 +131,7 @@ fn var_rejects_a_function_at_the_assigning_line() { #[test] fn var_rejects_a_nested_function_at_the_assigning_line() { - let error = run("var.t = { f = function() end }", "") + let error = run("var.t = { f = function() end }", &json!({})) .expect_err("a nested function must fail the deep check at the assigning line"); assert!( error.to_string().contains("function"), @@ -133,7 +150,7 @@ fn var_guard_error_is_catchable_at_the_assigning_line() { assert(tostring(err):match('must be JSON data'), tostring(err))\n\ var.kept = 'yes'\n\ return var.kept", - "", + &json!({}), ) .expect("the caught guard error must not fail the chunk"); assert_eq!(out.returned.as_deref(), Some("yes")); @@ -149,7 +166,7 @@ fn var_guard_rejects_incremental_nested_function_writes_at_the_assigning_line() assert(tostring(err):match('var.t.f must be JSON data'), tostring(err))\n\ var.t.kept = 'yes'\n\ return var.t.kept", - "", + &json!({}), ) .expect("the nested guard error must remain catchable"); assert_eq!(out.returned.as_deref(), Some("yes")); @@ -185,16 +202,17 @@ fn assigning_nil_to_a_var_field_removes_it_from_var_and_its_snapshot() { #[test] fn an_empty_marked_list_in_var_reads_back_as_an_empty_array() { for source in ["var.items = json.array()", "var.items = json.decode('[]')"] { - let out = run(source, "").expect("a marked list passes the guard"); + let out = run(source, &json!({})).expect("a marked list passes the guard"); assert_eq!(out.var, json!({ "items": [] }), "after {source}"); } } #[test] fn a_nested_marked_list_reads_back_as_an_array_and_a_plain_empty_table_as_an_object() { - let nested = run("var.t = { list = json.array() }", "").expect("the nested list passes"); + let nested = + run("var.t = { list = json.array() }", &json!({})).expect("the nested list passes"); assert_eq!(nested.var, json!({ "t": { "list": [] } })); - let plain = run("var.items = {}", "").expect("an empty table passes the guard"); + let plain = run("var.items = {}", &json!({})).expect("an empty table passes the guard"); assert_eq!(plain.var, json!({ "items": {} })); } @@ -207,8 +225,11 @@ fn an_empty_list_seeded_from_rust_reads_back_as_an_empty_array() { #[test] fn json_null_in_var_reads_back_as_null_and_equals_json_null_in_lua() { - let out = run("var.x = json.null\nreturn tostring(var.x == json.null)", "") - .expect("json.null passes the guard"); + let out = run( + "var.x = json.null\nreturn tostring(var.x == json.null)", + &json!({}), + ) + .expect("json.null passes the guard"); assert_eq!(out.returned.as_deref(), Some("true")); assert_eq!(out.var, json!({ "x": null })); } @@ -217,13 +238,193 @@ fn json_null_in_var_reads_back_as_null_and_equals_json_null_in_lua() { fn reassigning_the_var_global_fails_read_back() { // `var = 5` drops the guarded proxy from reach; read-back must reject it // rather than silently roll the pre-reassignment data forward. - let error = run("var = 5", "").expect_err("reassigning the var global must fail at read-back"); + let error = + run("var = 5", &json!({})).expect_err("reassigning the var global must fail at read-back"); assert!( error.to_string().contains("`var` global was reassigned"), "the error must name the reassignment: {error}" ); } +/// Runs `write` on a fresh VM under `pcall`, after `var.t = {}` and +/// `var.list = { 'a' }`, and returns whether it raised, the error's kind, +/// its refusal line, and `var` as JSON text after it. +fn var_write(write: &str) -> (bool, String, String, String) { + let vm = TestVm::new().shims().build(); + let (raised, kind, message, var): (bool, String, String, String) = vm.eval(&format!( + "var.t = {{}}\n\ + var.list = {{ 'a' }}\n\ + local ok, err = pcall(function() {write} end)\n\ + if ok then return false, '', '', json.encode(var) end\n\ + return true, tostring(err.kind), tostring(err), json.encode(var)" + )); + let line = refusal_line(&message).unwrap_or(&message).to_owned(); + (raised, kind, line, var) +} + +#[test] +fn a_var_write_under_a_key_with_no_json_form_is_refused_at_the_assigning_line() { + // Each key would break the read-back of `var` long after the write, so + // the guard refuses it where it is written and stores nothing. + for (write, refusal) in [ + ( + "var[true] = 1", + "a key of var must be a string, got boolean", + ), + ("var[2.5] = 1", "a key of var must be a string, got number"), + ("var[{}] = 1", "a key of var must be a string, got table"), + // The root holds named fields alone. + ("var[1] = 'x'", "a key of var must be a string, got integer"), + ( + "var.t[true] = 1", + "a key of var.t must be a string or a list index, got boolean", + ), + ( + "var.t[2.5] = 1", + "a key of var.t must be a string or a list index, got number", + ), + ( + "var.t[0] = 'x'", + "a list index of var.t must be 1 or more, got 0", + ), + ( + "var.t[5] = 'x'", + "var.t[5] is past the end of the list, whose length is 0", + ), + ( + "var.list[3] = 'c'", + "var.list[3] is past the end of the list, whose length is 1", + ), + ] { + let (raised, kind, line, var) = var_write(write); + assert!(raised, "{write} must raise"); + assert_eq!(kind, "lua", "{write}"); + assert_eq!(line, refusal, "{write}"); + assert_eq!(var, r#"{"list":["a"],"t":{}}"#, "{write} stores nothing"); + } +} + +#[test] +fn a_var_list_takes_its_next_item_and_a_new_value_for_an_item_it_holds() { + for (write, held) in [ + ("var.list[2] = 'b'", r#"{"list":["a","b"],"t":{}}"#), + ("var.list[1] = 'z'", r#"{"list":["z"],"t":{}}"#), + // A float with no fraction is the integer it equals. + ("var.list[2.0] = 'b'", r#"{"list":["a","b"],"t":{}}"#), + ("var.t[1] = 'first'", r#"{"list":["a"],"t":["first"]}"#), + // A nil removes a key of any kind without a refusal. + ("var[true] = nil", r#"{"list":["a"],"t":{}}"#), + ] { + let (raised, _kind, line, var) = var_write(write); + assert!(!raised, "{write} must pass, got {line}"); + assert_eq!(var, held, "{write}"); + } +} + +#[test] +fn a_var_write_that_would_mix_list_items_and_named_fields_is_refused() { + // A table of JSON data is a list or an object. A write that would + // make one both is refused, where the read-back would drop a part. + for (write, refusal, held) in [ + ( + "var.list.name = 'x'", + "var.list.name is a named field, and var.list is a list", + r#"{"list":["a"],"t":{}}"#, + ), + ( + "var.t.a = 1 var.t[1] = 'x'", + "var.t[1] is a list item, and var.t holds named fields", + r#"{"list":["a"],"t":{"a":1}}"#, + ), + ( + "var.empty = json.array() var.empty.name = 'x'", + "var.empty.name is a named field, and var.empty is a list", + r#"{"empty":[],"list":["a"],"t":{}}"#, + ), + ] { + let (raised, kind, line, var) = var_write(write); + assert!(raised, "{write} must raise"); + assert_eq!(kind, "lua", "{write}"); + assert_eq!(line, refusal, "{write}"); + assert_eq!(var, held, "{write}"); + } +} + +#[test] +fn a_var_list_gives_up_its_last_item_alone() { + // Removing an item that is not the last would leave a gap. + let (raised, kind, line, var) = var_write("var.list[2] = 'b' var.list[1] = nil"); + assert!(raised, "the removal must raise"); + assert_eq!(kind, "lua"); + assert_eq!( + line, + "var.list[1] is not the last item of the list, whose length is 2; remove it with \ + table.remove" + ); + assert_eq!(var, r#"{"list":["a","b"],"t":{}}"#); + for (write, held) in [ + ("var.list[1] = nil", r#"{"list":[],"t":{}}"#), + ( + "var.list[2] = 'b' var.list[2] = nil", + r#"{"list":["a"],"t":{}}"#, + ), + ( + "var.list[2] = 'b' table.remove(var.list, 1)", + r#"{"list":["b"],"t":{}}"#, + ), + // An index the list does not hold has nothing to remove. + ("var.list[7] = nil", r#"{"list":["a"],"t":{}}"#), + ] { + let (raised, _kind, line, var) = var_write(write); + assert!(!raised, "{write} must pass, got {line}"); + assert_eq!(var, held, "{write}"); + } +} + +#[test] +fn a_var_write_of_a_value_with_no_json_form_names_the_failing_part() { + for (write, refusal) in [ + ( + "var.t = { [true] = 1 }", + "var.t has a boolean key; object keys must be strings", + ), + ( + "var.t = { 'a', name = 'x' }", + "var.t mixes list items and named keys", + ), + ( + "var.t = { [1] = 'a', [3] = 'c' }", + "var.t is missing list item [2]", + ), + ( + "var.t = { inner = { f = function() end } }", + "var.t.inner.f must be JSON data, got function", + ), + ( + "var.list[2] = { 'b', k = 1 }", + "var.list[2] mixes list items and named keys", + ), + ("var.n = 0/0", "var.n is not a finite number"), + ] { + let (raised, kind, line, var) = var_write(write); + assert!(raised, "{write} must raise"); + assert_eq!(kind, "lua", "{write}"); + assert_eq!(line, refusal, "{write}"); + assert_eq!(var, r#"{"list":["a"],"t":{}}"#, "{write} stores nothing"); + } +} + +#[test] +fn a_float_with_no_fraction_stays_a_float_in_var() { + let vm = TestVm::new().shims().build(); + let kinds: String = vm.eval( + "var.whole = 2.0\n\ + var.count = 2\n\ + var.t = { ratio = 1.0 }\n\ + return math.type(var.whole) .. ' ' .. math.type(var.count) .. ' ' .. math.type(var.t.ratio)", + ); + assert_eq!(kinds, "float integer float"); +} /// A VM whose Lua heap is 4 MiB, so a write may read at most 524,288 values /// and 4 MiB of text. fn small_heap_vm() -> BuiltVm { @@ -289,21 +490,17 @@ fn a_var_write_of_a_repeated_string_key_past_the_byte_budget_is_refused() { } #[test] -fn a_var_write_of_a_table_that_contains_itself_keeps_the_recursion_error() { +fn a_var_write_of_a_table_that_contains_itself_is_refused_as_a_cycle() { // `t` holds itself twice, so a walk that followed the cycle would branch - // two ways at each of 200 levels and run out of budget, reporting the - // write as too large. The walk stops at the table already on its path - // and leaves the cycle to the conversion, whose recursion error stays. + // two ways at each level and run out of budget, reporting the write as + // too large. The walk stops at the table already on its path. let vm = small_heap_vm(); vm.eval::<()>("t = {} t.a = t t.b = t"); let (raised, _kind, message) = var_write_of_t(&vm); assert!(raised, "a cyclic write must raise, not store"); - assert!( - message.contains("recursive table"), - "the conversion's own error must stay, got {message}" - ); - assert!( - !message.contains("too large to store"), - "a cycle is not oversized, got {message}" + assert_eq!( + refusal_line(&message), + Some("var.x.a contains itself"), + "a cycle is not oversized" ); } diff --git a/crates/runvil-internal/lua/src/tests/tool_scoping.rs b/crates/runvil-internal/lua/src/tests/tool_scoping.rs index 1f05bcc76..f8bd01f76 100644 --- a/crates/runvil-internal/lua/src/tests/tool_scoping.rs +++ b/crates/runvil-internal/lua/src/tests/tool_scoping.rs @@ -159,7 +159,8 @@ fn tool_objects_install_without_payload_reports() { #[test] fn offer_over_an_empty_catalog_fails_in_a_chunk() { - let error = run("tools.offer('web/search')", "").expect_err("an unknown id must fail loudly"); + let error = + run("tools.offer('web/search')", &json!({})).expect_err("an unknown id must fail loudly"); assert!( error .to_string() diff --git a/crates/runvil-internal/lua/src/tests/views.rs b/crates/runvil-internal/lua/src/tests/views.rs new file mode 100644 index 000000000..581d32e2f --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/views.rs @@ -0,0 +1,272 @@ +//! Sealed views as the tables they stand for: a `var` table and an `args` +//! table measure, iterate, and convert as their data, a write through a +//! view still crosses its guard, and a value with no view inside converts +//! as it does with no walk before it. + +use mlua::{LuaSerdeExt, Table}; + +use super::*; +use crate::proxy::plain; +use crate::test_vm::raised; + +/// A VM whose `var` and `args` each hold a list, an object, and an empty +/// list. +fn views_vm() -> BuiltVm { + let data = json!({ + "items": ["a", "b", "c"], + "cfg": { "mode": "fast", "depth": 2 }, + "empty": [], + }); + TestVm::new().var(&data).args(&data).shims().build() +} + +#[test] +fn the_length_of_a_view_is_the_length_of_its_table() { + let vm = views_vm(); + for root in ["var", "args"] { + let lengths: (i64, i64, i64, i64) = vm.eval(&format!( + "return #{root}.items, #{root}.cfg, #{root}.empty, #{root}" + )); + assert_eq!(lengths, (3, 0, 0, 0), "{root}"); + } +} + +#[test] +fn pairs_walks_a_view_as_its_table_in_key_order() { + let vm = views_vm(); + for root in ["var", "args"] { + let walked: String = vm.eval(&format!( + "local seen = {{}}\n\ + for key, value in pairs({root}) do seen[#seen + 1] = key .. ':' .. type(value) end\n\ + for index, value in pairs({root}.items) do seen[#seen + 1] = index .. '=' .. value end\n\ + for _ in pairs({root}.empty) do seen[#seen + 1] = 'never' end\n\ + return table.concat(seen, ' ')" + )); + assert_eq!( + walked, "cfg:table empty:table items:table 1=a 2=b 3=c", + "{root}" + ); + } +} + +#[test] +fn next_reads_a_view_as_its_table() { + let vm = views_vm(); + for root in ["var", "args"] { + let (first, value, second, ended, empty): (String, i64, String, bool, bool) = + vm.eval(&format!( + "local first, value = next({root}.cfg)\n\ + local second = next({root}.cfg, first)\n\ + return first, value, second, next({root}.cfg, second) == nil, \ + next({root}.empty) == nil" + )); + assert_eq!( + (first.as_str(), value, second.as_str()), + ("depth", 2, "mode"), + "{root}" + ); + assert!(ended, "{root}: the walk ends after the last key"); + assert!(empty, "{root}: an empty list has no first key"); + } +} + +#[test] +fn table_concat_and_table_unpack_read_a_view_as_its_table() { + let vm = views_vm(); + for root in ["var", "args"] { + let (joined, first, last, count): (String, String, String, i64) = vm.eval(&format!( + "local first, _, last = table.unpack({root}.items)\n\ + return table.concat({root}.items, ','), first, last, \ + select('#', table.unpack({root}.items))" + )); + assert_eq!( + (joined.as_str(), first.as_str(), last.as_str(), count), + ("a,b,c", "a", "c", 3), + "{root}" + ); + } +} + +#[test] +fn a_var_list_grows_and_shrinks_by_its_length() { + let vm = views_vm(); + let grown: String = vm.eval( + "var.items[#var.items + 1] = 'd'\n\ + table.insert(var.items, 'e')\n\ + return table.concat(var.items, ',') .. ' ' .. #var.items", + ); + assert_eq!(grown, "a,b,c,d,e 5"); + let shrunk: String = vm.eval( + "local last = table.remove(var.items)\n\ + return last .. ' ' .. #var.items .. ' ' .. json.encode(var.items)", + ); + assert_eq!(shrunk, r#"e 4 ["a","b","c","d"]"#); +} + +#[test] +fn a_write_through_a_value_pairs_hands_back_crosses_the_views_guard() { + let vm = views_vm(); + let refusal = raised(&vm, "for _, value in pairs(args) do value.extra = 1 end"); + assert_eq!( + refusal_line(&refusal), + Some("args is read-only: cannot set field 'extra'"), + "a table inside args is handed back as its read-only view" + ); + let refusal = raised(&vm, "table.insert(args.items, 'd')"); + assert_eq!( + refusal_line(&refusal), + Some("args is read-only: cannot set field 4") + ); + let state_is_the_view: bool = vm.eval("local _, state = pairs(args)\nreturn state == args"); + assert!(state_is_the_view, "pairs hands back the view, not its data"); + + vm.exec("for key, value in pairs(var) do if key == 'cfg' then value.seen = true end end"); + assert_eq!( + encoded(&vm, "json.encode(var.cfg)"), + r#"{"depth":2,"mode":"fast","seen":true}"#, + "a table inside var is handed back as its guarded view" + ); + let refusal = raised( + &vm, + "for key, value in pairs(var) do\n\ + if key == 'cfg' then value.f = function() end end\n\ + end", + ); + assert_eq!( + refusal_line(&refusal), + Some("var.cfg.f must be JSON data, got function"), + "the guard checks the write" + ); +} + +#[test] +fn a_view_assigned_to_var_is_stored_as_its_data() { + let vm = views_vm(); + vm.exec( + "var.copy = var.cfg\n\ + var.from_args = args.items\n\ + var.none = args.empty\n\ + var.mixed = { list = args.items, cfg = var.cfg, n = 1 }", + ); + for (call, text) in [ + ("json.encode(var.copy)", r#"{"depth":2,"mode":"fast"}"#), + ("json.encode(var.from_args)", r#"["a","b","c"]"#), + ("json.encode(var.none)", "[]"), + ( + "json.encode(var.mixed)", + r#"{"cfg":{"depth":2,"mode":"fast"},"list":["a","b","c"],"n":1}"#, + ), + ] { + assert_eq!(encoded(&vm, call), text, "{call}"); + } + vm.exec("var.copy.mode = 'slow'"); + assert_eq!( + encoded(&vm, "var.cfg.mode"), + "fast", + "the stored copy is its own data" + ); + assert_eq!( + vm.var().expect("var reads back")["from_args"], + json!(["a", "b", "c"]), + "the run's var holds the data" + ); +} + +#[test] +fn a_value_with_no_view_inside_is_handed_back_as_it_is() { + let vm = views_vm(); + let lua = vm.lua(); + for source in [ + "return { 1, { a = 'x' }, n = 2 }", + "return { 1, nil, 3 }", + "return {}", + "return 'text'", + "return sys", + ] { + let value: Value = lua.load(source).eval().expect("the value builds"); + let handed = plain(lua, value.clone()).expect("the walk ends"); + assert_eq!(handed, value, "{source}: the same value, not a copy"); + } +} + +#[test] +fn a_view_inside_a_value_becomes_a_plain_copy_of_its_data() { + let vm = views_vm(); + let lua = vm.lua(); + let cfg = json!({ "mode": "fast", "depth": 2 }); + let items = json!(["a", "b", "c"]); + for (source, json) in [ + ( + "return args", + json!({ "items": items.clone(), "cfg": cfg.clone(), "empty": [] }), + ), + ("return var.items", items.clone()), + ("return args.empty", json!([])), + ("return { 'first', var.cfg }", json!(["first", cfg])), + ( + "return { n = 1, deep = { list = { args.items } }, plain = { 1, 2 } }", + json!({ "n": 1, "deep": { "list": [items] }, "plain": [1, 2] }), + ), + ] { + let value: Value = lua.load(source).eval().expect("the value builds"); + let handed = plain(lua, value.clone()).expect("the walk ends"); + assert_ne!(handed, value, "{source}: a copy, not the view"); + let converted: Json = lua.from_value(handed).expect("the copy is JSON data"); + assert_eq!(converted, json, "{source}"); + } +} + +#[test] +fn a_table_beside_a_view_is_shared_with_the_copy_not_copied() { + let vm = views_vm(); + let lua = vm.lua(); + let (value, beside): (Table, Table) = lua + .load("local beside = { 1, 2 }\nreturn { view = args.items, beside = beside }, beside") + .eval() + .expect("the value builds"); + let Value::Table(handed) = plain(lua, Value::Table(value)).expect("the walk ends") else { + panic!("a table stays a table"); + }; + let shared: Table = handed.raw_get("beside").expect("the entry reads"); + assert_eq!(shared, beside, "a table with no view inside is not copied"); +} + +#[test] +fn a_table_that_contains_itself_converts_as_it_does_with_no_walk_before_it() { + let vm = views_vm(); + let lua = vm.lua(); + for source in [ + "local t = { n = 1 }\nt.me = t\nreturn t", + "local t = { list = args.items }\nt.me = t\nreturn t", + ] { + let value: Value = lua.load(source).eval().expect("the value builds"); + let direct = lua + .from_value::(value.clone()) + .expect_err("a table that contains itself has no JSON form"); + let handed = plain(lua, value).expect("the walk ends at the repeated table"); + let walked = lua + .from_value::(handed) + .expect_err("the walk leaves the table containing itself"); + assert_eq!(walked.to_string(), direct.to_string(), "{source}"); + } +} + +#[test] +fn a_walk_past_its_budget_is_refused() { + // A 4 MiB heap gives the walk 524,288 values. `t` is 19 levels of + // `{ t, t }` over one list: 20 tables in memory, and about 1 million + // values in a walk that reads a shared table once per reference. + let vm = TestVm::new() + .limits(4 * 1024 * 1024, DEFAULT_LUA_LOG_EVENTS) + .build(); + let lua = vm.lua(); + let value: Value = lua + .load("local t = { 1 }\nfor _ = 1, 19 do t = { t, t } end\nreturn t") + .eval() + .expect("the value builds"); + let error = plain(lua, value).expect_err("the walk runs out of budget"); + assert_eq!( + error.to_string(), + "deserialize error: the value is too large to convert" + ); +} diff --git a/crates/runvil-internal/lua/src/var.rs b/crates/runvil-internal/lua/src/var.rs index 35515be01..eaeea3036 100644 --- a/crates/runvil-internal/lua/src/var.rs +++ b/crates/runvil-internal/lua/src/var.rs @@ -3,9 +3,9 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use crate::error::{self, Result, make_lua_runtime_error}; -use crate::proxy::key_path; -use crate::walk_budget::WalkBudget; +use crate::error::{self, Result, author_refusal, make_lua_runtime_error}; +use crate::json::to_stored_json; +use crate::proxy::{key_path, view_len}; /// The registry key holding the `var` proxy's hidden data table. /// @@ -22,6 +22,101 @@ const VAR_PROXY_REGISTRY: &str = "runvil.var_proxy"; /// Registry map from every guarded proxy table to its hidden data table. const VAR_GUARDED_DATA_REGISTRY: &str = "runvil.var_guarded_data"; +/// The path of the root `var` table, whose fields are all named. +const ROOT_PATH: &str = "var"; + +/// 2^63, one past the largest integer a Lua table key holds. +const ABOVE_INTEGERS: f64 = 9_223_372_036_854_775_808.0; + +/// The integer `key` is as a table key: an integer as it is, and a float +/// with no fractional part as the integer it equals, which is the key a +/// Lua table stores for it. A metamethod receives such a float as written. +fn whole_key(key: &Value) -> Option { + match key { + Value::Integer(index) => Some(*index), + Value::Number(float) + if float.fract() == 0.0 && (-ABOVE_INTEGERS..ABOVE_INTEGERS).contains(float) => + { + #[expect( + clippy::cast_possible_truncation, + reason = "the float is a whole number inside i64's range" + )] + let index = *float as i64; + Some(index) + } + _ => None, + } +} + +/// Why the table at `path`, whose data is `data`, cannot take a write +/// under `key`, or `None` when it can. `removes` says that the write +/// assigns nil. +/// +/// A table of JSON data is an object, whose keys are strings, or a list, +/// whose keys are the indexes 1 to its length. The root is an object. A +/// write that would leave a table as neither is refused where it is +/// written, because the read-back of `var` would fail on the table, or +/// lose a part of it, long after the write: +/// +/// - a key that is neither a string nor an integer +/// - a named field in a list, and a list item in a table that holds named +/// fields +/// - an index below 1, or past the next item of the list +/// - the removal of an item that is not the last of its list +/// +/// A nil under any other key removes what the key holds, or nothing. +fn write_refusal( + lua: &Lua, + path: &str, + key: &Value, + removes: bool, + data: &mlua::Table, +) -> Option { + let len = data.raw_len(); + let marked = data + .metatable() + .is_some_and(|metatable| metatable == lua.array_metatable()); + if let Value::String(_) = key { + let is_list = marked || len > 0; + return (is_list && !removes).then(|| { + format!( + "{} is a named field, and {path} is a list", + key_path(path, key) + ) + }); + } + let index = whole_key(key).filter(|_| path != ROOT_PATH); + let Some(index) = index else { + let allowed = if path == ROOT_PATH { + "a string" + } else { + "a string or a list index" + }; + return (!removes) + .then(|| format!("a key of {path} must be {allowed}, got {}", key.type_name())); + }; + let len = i64::try_from(len).unwrap_or(i64::MAX); + if removes { + return (1..len).contains(&index).then(|| { + format!( + "{path}[{index}] is not the last item of the list, whose length is {len}; \ + remove it with table.remove" + ) + }); + } + if index < 1 { + return Some(format!( + "a list index of {path} must be 1 or more, got {index}" + )); + } + if !marked && len == 0 && !data.is_empty() { + return Some(format!( + "{path}[{index}] is a list item, and {path} holds named fields" + )); + } + (index > len.saturating_add(1)) + .then(|| format!("{path}[{index}] is past the end of the list, whose length is {len}")) +} fn guarded_json_value( lua: &Lua, value: &Json, @@ -69,33 +164,30 @@ fn guarded_table( guarded_data.raw_set(proxy.clone(), data.clone())?; let metatable = lua.create_table()?; metatable.set("__index", data.clone())?; + metatable.set("__len", view_len(lua, &data)?)?; let write_data = data; let nested_data = guarded_data.clone(); let table_path = path.to_owned(); let newindex = lua.create_function( move |lua, (_proxy, key, value): (Value, Value, Value)| -> mlua::Result<()> { - // The serde bridge turns nil into mlua's null sentinel, which - // would keep the key instead of removing it. - if value.is_nil() { + // A whole float names the item its integer names. + let key = whole_key(&key).map_or(key, Value::Integer); + let removes = value.is_nil(); + if let Some(refusal) = write_refusal(lua, &table_path, &key, removes, &write_data) { + return Err(author_refusal(refusal)); + } + // A nil removes the key, where the conversion would store a + // null under it. + if removes { return write_data.raw_set(key, value); } let target = key_path(&table_path, &key); - if let Value::Function(_) | Value::UserData(_) | Value::Thread(_) = value { - return Err(mlua::Error::runtime(format!( - "{target} must be JSON data, got {}", - value.type_name() - ))); - } - // `from_value` reads a shared table once per reference and builds - // the result on the Rust heap before any Lua allocation, so a - // value that outgrows the heap is refused before it converts. - if WalkBudget::for_vm(lua).check_value(&value).is_err() { - return Err(mlua::Error::runtime(format!( - "{target} is too large to store" - ))); - } - let json = lua.from_value::(value)?; + // The conversion reads through each view inside the value, + // refuses what JSON data cannot hold with the path of the + // failing part, and spends the budget of the VM, so a value + // that would outgrow the heap is refused before it converts. + let json = to_stored_json(lua, &target, &value)?; let rebuilt = guarded_json_value(lua, &json, &target, &nested_data)?; write_data.raw_set(key, rebuilt) }, @@ -142,12 +234,18 @@ fn materialize_guarded_value( /// table. /// /// The proxy is empty; reads go through `__index` against the data table, so -/// present values surface directly and absent keys read as Lua nil. -/// `__newindex` validates the assigned value for JSON-representability -/// through the serde bridge - a function, userdata, or thread is rejected at -/// the assigning line, and nested tables are deep-checked by the bridge - -/// then rebuilds it as fresh guarded data before writing through. Assigning nil -/// removes the key. Every nested table is itself an empty proxy over hidden +/// present values surface directly and absent keys read as Lua nil. `__len` +/// is the data table's length, and `pairs` and `next` walk the data table, +/// so a `var` table measures and iterates as the table it stands for. +/// `__newindex` converts the assigned value under the `json.encode` rules - +/// a function, userdata, or thread is rejected at the assigning line, as +/// is a table at any depth that mixes list items and named keys or has a +/// key that is not a string, and a view inside the value is stored as the +/// data it stands for - then rebuilds it as fresh guarded data before +/// writing through. It refuses, at the assigning line, a write that would +/// leave its table neither a list nor an object ([`write_refusal`]). +/// Assigning nil 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 and the proxy live under diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index e8b66f46b..b1d77b132 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -1,5 +1,5 @@ //! Engine injection: the one step that turns an uninjected section VM into -//! an injected one, installing `args`, `argv`, `sys`, `var`, the `tools`, +//! an injected one, installing `args`, `sys`, `var`, the `tools`, //! `models`, and `compactors` tables, and the VM's `messages` list. use std::sync::{Arc, Mutex}; @@ -57,29 +57,25 @@ impl SectionVm { /// to the arm's spawned identity, and a conflicting access from an /// identity not ordered after the arm's surfaces as a write race. /// - /// `argv` is the parsed form of the args string, installed read-only; - /// `None` installs nil. + /// `args` is the chain's arguments, one JSON object, which the `args` + /// global holds as a read-only table. /// /// `selection` holds the current model: `models.use` writes it, and /// [`current_model`](SectionVm::current_model) reads it. /// /// # Errors /// Returns the [`InjectFailure`] holding the bridging failure if Engine - /// values cannot be bridged. - #[expect( - clippy::similar_names, - reason = "args and argv are the spec'd global names; the pair is intentional" - )] + /// values cannot be bridged, or holding an `Internal` failure if `args` + /// or the `var` seed is not a JSON object. pub fn inject_values( self, - args: &str, + args: &Json, sys: &Json, access: &Arc, initial_var: Option<&Json>, - argv: Option<&Json>, selection: &Arc>>, ) -> std::result::Result { - if let Err(error) = self.install_values(args, sys, initial_var, argv, selection) { + if let Err(error) = self.install_values(args, sys, initial_var, selection) { return Err(InjectFailure { vm: Box::new(self), error: Box::new(error), @@ -92,23 +88,15 @@ impl SectionVm { })) } - #[expect( - clippy::similar_names, - reason = "args and argv are the spec'd global names; the pair is intentional" - )] fn install_values( &self, - args: &str, + args: &Json, sys: &Json, initial_var: Option<&Json>, - argv: Option<&Json>, selection: &Arc>>, ) -> Result<()> { let globals = self.lua.globals(); - globals - .raw_set("args", args) - .map_err(error::make_lua_error)?; - crate::argv::install(&self.lua, argv)?; + crate::args::install(&self.lua, args)?; let sys_table = seal_sys(&self.lua, sys)?; globals .raw_set("sys", sys_table) diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index e16745389..1030714a8 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -104,7 +104,8 @@ impl SectionVm { /// before each chunk and read after it by the control-run path. The /// `call` closure snapshots this VM's `var` at call time (reading the /// hidden data table through the in-scope `&Lua`) and hands the JSON to - /// its callback, so a contained chain seeds from a clone and its writes + /// its callback beside the target and the `args` value as the author + /// wrote them, so a contained chain seeds from a clone and its writes /// never reach this VM. /// /// # Errors @@ -112,7 +113,7 @@ impl SectionVm { #[cfg(test)] pub(crate) fn install_test_control_globals(&self, call_callback: E, read: R) -> Result<()> where - E: Fn(Value, Option, Json) -> std::result::Result + Send + 'static, + E: Fn(Value, Value, Json) -> std::result::Result + Send + 'static, R: Fn(String, Option) -> std::result::Result + Send + Sync @@ -121,9 +122,9 @@ impl SectionVm { let globals = self.lua.globals(); let call_fn = self .lua - .create_function(move |lua, (target, input): (Value, Option)| { + .create_function(move |lua, (target, args): (Value, Value)| { let var = var_to_json(lua).map_err(mlua::Error::external)?; - call_callback(target, input, var).map_err(mlua::Error::external) + call_callback(target, args, var).map_err(mlua::Error::external) }) .map_err(error::make_lua_error)?; globals diff --git a/crates/runvil-internal/lua/src/walk_budget.rs b/crates/runvil-internal/lua/src/walk_budget.rs index cb8a8bb79..4f965907d 100644 --- a/crates/runvil-internal/lua/src/walk_budget.rs +++ b/crates/runvil-internal/lua/src/walk_budget.rs @@ -6,9 +6,7 @@ //! without the instruction hook ever firing. The budget is sized from the //! heap ceiling so that only expansion beyond the heap is refused. -use std::ffi::c_void; - -use mlua::{Lua, Value}; +use mlua::Lua; /// The smallest cost of one value in the Lua heap, rounded down: an array /// slot takes at least about 9 bytes, so an unshared value that fits the @@ -16,10 +14,6 @@ use mlua::{Lua, Value}; /// above that. const BYTES_PER_NODE: usize = 8; -/// The most table levels `check_value` descends, the top-level table being -/// level 1. A table nested past it is not counted. -const CHECK_DEPTH_LIMIT: usize = 200; - /// The most Lua references one Rust walk may hold at once: an encode path's /// string, table, function, thread, and userdata keys, the entries of /// `json.encode`'s options table, or an offer verb's array entries, each of @@ -76,46 +70,4 @@ impl WalkBudget { self.bytes = self.bytes.checked_sub(n).ok_or(TooLarge)?; Ok(()) } - - /// Spends the budget on every value `value` holds, as a pre-walk for a - /// caller that converts the value with `from_value`, which reads a - /// shared table once per reference and builds the result on the Rust - /// heap before any Lua allocation. The walk reads each table lazily - /// with `Table::pairs`, so it never holds more than one entry of a - /// table, and it stops at the first value past the budget. It counts - /// every value it visits, repeats of a shared table included, and the - /// bytes of every string value and string key. It does not descend into - /// a table already on the path to it, or one at a level past - /// `CHECK_DEPTH_LIMIT`; the conversion that follows keeps its own - /// behavior for a cyclic or very deep value. - pub(crate) fn check_value(&mut self, value: &Value) -> Result<(), TooLarge> { - self.check_at(value, &mut Vec::new()) - } - - /// Spends the budget on `value` and, for a table, on its entries. - /// `path` holds the tables that lead to `value`. - fn check_at(&mut self, value: &Value, path: &mut Vec<*const c_void>) -> Result<(), TooLarge> { - self.charge_node()?; - match value { - Value::String(text) => self.charge_bytes(text.as_bytes().len()), - Value::Table(table) => { - let pointer = table.to_pointer(); - if path.len() >= CHECK_DEPTH_LIMIT || path.contains(&pointer) { - return Ok(()); - } - path.push(pointer); - // An entry that cannot be read ends the walk of this table, - // and the conversion that follows reports it. - for (key, value) in table.pairs::().map_while(Result::ok) { - if let Value::String(name) = &key { - self.charge_bytes(name.as_bytes().len())?; - } - self.check_at(&value, path)?; - } - path.pop(); - Ok(()) - } - _ => Ok(()), - } - } } diff --git a/crates/runvil-internal/prompt/Cargo.toml b/crates/runvil-internal/prompt/Cargo.toml index 870ff6a21..ea5ccae35 100644 --- a/crates/runvil-internal/prompt/Cargo.toml +++ b/crates/runvil-internal/prompt/Cargo.toml @@ -12,6 +12,7 @@ runvil-lua.workspace = true runvil-types.workspace = true pulldown-cmark.workspace = true serde.workspace = true +serde_json.workspace = true serde_yaml_ng.workspace = true [dev-dependencies] diff --git a/crates/runvil-internal/prompt/README.md b/crates/runvil-internal/prompt/README.md index acdd188dd..fa0f7c108 100644 --- a/crates/runvil-internal/prompt/README.md +++ b/crates/runvil-internal/prompt/README.md @@ -1,3 +1,3 @@ # Prompt loader -This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence in the body compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. It does no execution. +This crate loads a Markdown prompt into a ready-to-run tree of frontmatter, sections, and compiled Lua code, and it reports each failure with a classified error. Every Lua fence in the body compiles here, so a loaded prompt cannot fail on a syntax error partway through a run. The frontmatter's `args` key declares the one JSON object a caller passes the prompt, and the crate checks a caller's arguments against that declaration for the executor. It does no execution. diff --git a/crates/runvil-internal/prompt/src/contract.rs b/crates/runvil-internal/prompt/src/contract.rs index bd99d35b7..cc08ea7f8 100644 --- a/crates/runvil-internal/prompt/src/contract.rs +++ b/crates/runvil-internal/prompt/src/contract.rs @@ -3,15 +3,16 @@ //! The YAML is the whole contract: Packs install, models declare, args //! type. Parsing validates the static shape - each `uses:` entry a bare //! Pack id or vendor/name, no Pack id declared twice, no reserved Pack id, -//! the alias grammar on arg names, the five model role names, and each arg -//! default against its declared type - and exposes the FULL declaration on -//! the parsed [`Prompt`](crate::Prompt); checking the declaration against -//! the caller's environment is the job of the run's first step, never the -//! parser's. +//! the alias grammar on argument names, the five model role names, and the +//! keywords of the `args` declaration against each other - and exposes +//! the FULL declaration on the parsed [`Prompt`](crate::Prompt); checking +//! the declaration against the caller's environment and arguments is the +//! job of the run's first step, never the parser's. //! -//! `args` and `models` are defined in submodules; this root owns the `uses:` -//! checks, the arg-name check, and the map deserializer the two map keys -//! share. +//! `args` and `models` are defined in submodules, and `args_check` checks +//! a caller's arguments against the `args` declaration; this root owns +//! the `uses:` checks, the argument-name check, and the map deserializer +//! the two map keys share. use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -27,12 +28,15 @@ use serde::{Deserialize, Deserializer}; use crate::error; mod args; +mod args_check; mod models; #[cfg(test)] mod tests; -pub use args::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter}; +pub use args::ArgsSchema; +pub(crate) use args::TEXT_FIELD; +pub(crate) use args_check::accept as accept_args; pub(crate) use models::RoleMap; pub use models::RoleRequirements; @@ -93,7 +97,7 @@ where /// The visitor behind [`deserialize_contract_map`]: a streaming map walk so /// rejections keep their source position. struct MapVisitor { - /// The key kind (`model role` or `arg name`), for error messages. + /// The key kind (`model role` or `argument name`), for error messages. what: &'static str, /// The key and value types, without ownership or variance claims. _marker: PhantomData (K, T)>, diff --git a/crates/runvil-internal/prompt/src/contract/args.rs b/crates/runvil-internal/prompt/src/contract/args.rs index 7ba1d98e1..155ae4674 100644 --- a/crates/runvil-internal/prompt/src/contract/args.rs +++ b/crates/runvil-internal/prompt/src/contract/args.rs @@ -1,268 +1,718 @@ -//! Parses the `args` frontmatter key into typed arg declarations. +//! Parses the `args` frontmatter key: the shape of the one JSON object a +//! caller passes the prompt, declared as a subset of JSON Schema. //! -//! The declaration advertises and documents the prompt's args; it does not -//! enforce - enforcement belongs to the prompt's own Lua. There are no freeform -//! prompts: a prompt with no `args:` key gets the default [`ArgsDecl`]. +//! A declaration is a tree of nodes. Every node has a `type`, one of +//! `string`, `boolean`, `integer`, `number`, `array`, and `object`, and the +//! root is an `object`. The other keywords are `description` on any node, +//! `default` on a field's node, `enum` on a `string` or an `integer`, +//! `items` on an `array`, and `properties`, `required`, and +//! `additionalProperties` on an `object`. Any other keyword, a keyword on a +//! type it does not apply to, and a keyword written twice fail the parse, +//! so a misspelled keyword is never read as an ignored one. An object takes +//! only the fields its `properties` declare, unless it says +//! `additionalProperties: true`. +//! +//! The parse is a streaming walk, so a refusal keeps the YAML path and the +//! position of the keyword or node it is about. A prompt with no `args:` +//! key gets the default declaration: one optional string named `text`. The +//! run's first step checks the caller's arguments against the declaration +//! ([`super::args_check`]). + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::de::{self, DeserializeSeed, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value as Json}; +use serde_yaml_ng::Value as Yaml; -use std::collections::{BTreeMap, btree_map}; +use super::args_check; -use serde::Deserialize; +/// The name of the one argument the default declaration holds. +pub(crate) const TEXT_FIELD: &str = "text"; -use crate::contract; +/// The description of the default declaration's one argument. +const TEXT_DESCRIPTION: &str = "Freeform text for this prompt"; -/// The type of a declared prompt arg. +/// Every keyword a node may hold, as a refusal lists them. +const KEYWORDS: &str = "`type`, `properties`, `required`, `additionalProperties`, `items`, \ + `enum`, `description`, and `default`"; + +/// The arguments a prompt declares: the shape of the one JSON object its +/// caller passes it. /// -/// The `type` key of an arg declaration accepts only `string`, `boolean`, -/// `integer`, and `number`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum ArgType { - /// A string arg, declared `type: string`, whose default is a YAML string - /// such as `draft` or `'42'`. - String, - /// A boolean arg, declared `type: boolean`, whose default is the YAML - /// `true` or `false`. - Boolean, - /// An integer arg, declared `type: integer`, whose default is a YAML - /// integer such as `3` or `-1`. - Integer, - /// A numeric arg, declared `type: number`, whose default is any YAML - /// number, such as `3` or `1.5`. - Number, +/// Every prompt has a declaration. A prompt whose frontmatter omits the +/// `args` key gets the default one, an object with one optional string +/// named `text`, and a written copy of the default equals it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgsSchema { + /// The root node, whose kind is an object. + root: Node, + /// The declaration as plain JSON Schema, built once from the root. + json: Json, +} + +impl ArgsSchema { + /// Returns the declaration as plain JSON Schema, ready to publish as a + /// tool's input schema. + /// + /// The root is `type: object`. Every object lists its `properties`, + /// lists `required` when it requires a field, and says + /// `additionalProperties: false` unless it takes fields it does not + /// declare. + #[must_use] + pub fn as_json(&self) -> &serde_json::Value { + &self.json + } + + /// The root node. + pub(super) fn root(&self) -> &Node { + &self.root + } + + /// The declaration whose root is `root`. + fn from_root(root: Node) -> ArgsSchema { + let json = root.to_json(); + ArgsSchema { root, json } + } } -impl std::fmt::Display for ArgType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - ArgType::String => "string", - ArgType::Boolean => "boolean", - ArgType::Integer => "integer", - ArgType::Number => "number", +impl Default for ArgsSchema { + fn default() -> Self { + let text = Node { + kind: Kind::String { choices: None }, + description: Some(TEXT_DESCRIPTION.to_owned()), + default: None, + }; + ArgsSchema::from_root(Node { + kind: Kind::Object(ObjectNode { + fields: BTreeMap::from([(TEXT_FIELD.to_owned(), text)]), + required: BTreeSet::new(), + open: false, + }), + description: None, + default: None, }) } } -/// The declaration of one prompt arg: its type, whether it is optional, its -/// default, and its description. +impl<'de> Deserialize<'de> for ArgsSchema { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + NodeSeed(Place::Root) + .deserialize(deserializer) + .map(ArgsSchema::from_root) + } +} + +/// One node of a declaration: its type with what that type declares, its +/// description, and its default. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArgDecl { - kind: ArgType, - optional: bool, - default: Option, +pub(super) struct Node { + /// The node's type and the keywords that belong to it. + pub(super) kind: Kind, + /// The human-readable description. description: Option, + /// The value a field of this node takes when the caller leaves it + /// out. It matches the node, and its own missing fields hold their + /// defaults. + pub(super) default: Option, } -impl ArgDecl { - /// Returns the declared type. - #[must_use] - pub fn kind(&self) -> ArgType { - self.kind +/// A node's type, with the keywords that belong to that type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Kind { + /// A JSON string, one of `choices` when the node has an `enum`. + String { + /// The strings the node's `enum` lists. + choices: Option>, + }, + /// JSON `true` or `false`. + Boolean, + /// A JSON number with no fractional part that fits in 64 bits, one of + /// `choices` when the node has an `enum`. + Integer { + /// The integers the node's `enum` lists. + choices: Option>, + }, + /// Any JSON number. + Number, + /// A JSON array whose every item matches `items`. + Array { + /// The node of each item. + items: Box, + }, + /// A JSON object. + Object(ObjectNode), +} + +impl Kind { + /// The word a declaration writes under `type`. + fn word(&self) -> &'static str { + match self { + Kind::String { .. } => "string", + Kind::Boolean => "boolean", + Kind::Integer { .. } => "integer", + Kind::Number => "number", + Kind::Array { .. } => "array", + Kind::Object(_) => "object", + } } - /// Returns whether a call may omit this arg entirely. - /// - /// An omitted arg is absent from the call. An empty string is a supplied - /// value. - #[must_use] - pub fn is_optional(&self) -> bool { - self.optional + /// The type with its article, as a refusal names what a value must be. + pub(super) fn expected(&self) -> &'static str { + match self { + Kind::String { .. } => "a string", + Kind::Boolean => "a boolean", + Kind::Integer { .. } => "an integer", + Kind::Number => "a number", + Kind::Array { .. } => "an array", + Kind::Object(_) => "an object", + } } +} - /// Returns the declared default, when present. - #[must_use] - pub fn default(&self) -> Option<&serde_yaml_ng::Value> { - self.default.as_ref() +/// What an `object` node declares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ObjectNode { + /// The declared fields, by name. + pub(super) fields: BTreeMap, + /// The names of the fields a caller must send. + pub(super) required: BTreeSet, + /// Whether the object also takes fields it does not declare. + pub(super) open: bool, +} + +impl Node { + /// The node as plain JSON Schema. + fn to_json(&self) -> Json { + let mut schema = Map::new(); + schema.insert("type".to_owned(), self.kind.word().into()); + if let Some(description) = &self.description { + schema.insert("description".to_owned(), description.clone().into()); + } + if let Some(default) = &self.default { + schema.insert("default".to_owned(), default.clone()); + } + match &self.kind { + Kind::String { + choices: Some(choices), + } => { + schema.insert("enum".to_owned(), choices.clone().into()); + } + Kind::Integer { + choices: Some(choices), + } => { + schema.insert("enum".to_owned(), choices.clone().into()); + } + Kind::Array { items } => { + schema.insert("items".to_owned(), items.to_json()); + } + Kind::Object(object) => { + let properties = object + .fields + .iter() + .map(|(name, node)| (name.clone(), node.to_json())) + .collect::>(); + schema.insert("properties".to_owned(), Json::Object(properties)); + if !object.required.is_empty() { + let required: Vec<&str> = object.required.iter().map(String::as_str).collect(); + schema.insert("required".to_owned(), required.into()); + } + schema.insert("additionalProperties".to_owned(), object.open.into()); + } + Kind::String { choices: None } + | Kind::Integer { choices: None } + | Kind::Boolean + | Kind::Number => {} + } + Json::Object(schema) } +} - /// Returns the human-readable description, when present. - #[must_use] - pub fn description(&self) -> Option<&str> { - self.description.as_deref() +/// A field's node, which the `properties` map deserializes. +impl<'de> Deserialize<'de> for Node { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + NodeSeed(Place::Field).deserialize(deserializer) } } -/// The unchecked map form of an arg declaration: [`ArgDecl`]'s `Deserialize` -/// reads it, then refuses a `default` whose YAML type does not match `type`. -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct ArgDeclWire { - /// The declared type. - #[serde(rename = "type")] - kind: ArgType, - /// Whether a call may omit the field entirely. - #[serde(default)] - optional: bool, - /// The declared default value. - #[serde(default)] - default: Option, - /// The human-readable description. - #[serde(default)] - description: Option, +/// Where a node sits in a declaration, which decides whether it takes a +/// `default` and which type it must have. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Place { + /// The root of the declaration. + Root, + /// A field under `properties`. + Field, + /// The node under `items`. + Items, } -/// Type sanity: a declared default must match the declared type. -fn default_matches(kind: ArgType, value: &serde_yaml_ng::Value) -> bool { - match (kind, value) { - (ArgType::String, serde_yaml_ng::Value::String(_)) - | (ArgType::Boolean, serde_yaml_ng::Value::Bool(_)) - | (ArgType::Number, serde_yaml_ng::Value::Number(_)) => true, - (ArgType::Integer, serde_yaml_ng::Value::Number(n)) => n.is_i64() || n.is_u64(), - _ => false, +/// Deserializes the node at one [`Place`]. +struct NodeSeed(Place); + +impl<'de> DeserializeSeed<'de> for NodeSeed { + type Value = Node; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(NodeVisitor(self.0)) } } -/// Names the YAML type of `value` in the vocabulary of [`ArgType`]. -fn yaml_type(value: &serde_yaml_ng::Value) -> &'static str { - match value { - serde_yaml_ng::Value::Null => "null", - serde_yaml_ng::Value::Bool(_) => "boolean", - serde_yaml_ng::Value::Number(n) if n.is_i64() || n.is_u64() => "integer", - serde_yaml_ng::Value::Number(_) => "number", - serde_yaml_ng::Value::String(_) => "string", - serde_yaml_ng::Value::Sequence(_) => "sequence", - serde_yaml_ng::Value::Mapping(_) => "mapping", - serde_yaml_ng::Value::Tagged(_) => "tagged value", +/// The streaming walk over one node's keywords. +struct NodeVisitor(Place); + +impl<'de> Visitor<'de> for NodeVisitor { + type Value = Node; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a map of schema keywords") } -} -impl<'de> Deserialize<'de> for ArgDecl { - fn deserialize(deserializer: D) -> std::result::Result + fn visit_map(self, mut map: A) -> Result where - D: serde::Deserializer<'de>, + A: MapAccess<'de>, { - let wire = ArgDeclWire::deserialize(deserializer)?; - if let Some(default) = &wire.default - && !default_matches(wire.kind, default) - { - return Err(serde::de::Error::custom(format!( - "the default has type `{}`, which does not match the declared type `{}`", - yaml_type(default), - wire.kind - ))); + let mut raw = RawNode::default(); + while let Some(keyword) = map.next_key::()? { + raw.read(keyword, &mut map)?; } - Ok(ArgDecl { - kind: wire.kind, - optional: wire.optional, - default: wire.default, - description: wire.description, - }) + raw.into_node(self.0).map_err(de::Error::custom) } } -/// The args a prompt declares: a map from each arg name to its declaration. -/// -/// Every prompt has an args declaration. A prompt whose frontmatter omits -/// the `args` key gets the default declaration: one optional string arg -/// named `prose`. The argument string of such a prompt arrives wrapped as -/// `argv = { prose = "" }`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArgsDecl { - fields: BTreeMap, - /// Whether the `args:` key was absent; see [`ArgsDecl::is_default`]. - implicit: bool, +/// A keyword of a node. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Keyword { + Type, + Properties, + Required, + AdditionalProperties, + Items, + Enum, + Description, + Default, } -impl ArgsDecl { - /// Returns whether this is the default declaration, which a prompt gets - /// when its frontmatter omits the `args` key. - /// - /// This returns `false` for every explicit `args` key, even one that - /// declares the same single `prose` arg. - #[must_use] - pub fn is_default(&self) -> bool { - self.implicit +impl Keyword { + /// The keyword as a declaration writes it. + fn word(self) -> &'static str { + match self { + Keyword::Type => "type", + Keyword::Properties => "properties", + Keyword::Required => "required", + Keyword::AdditionalProperties => "additionalProperties", + Keyword::Items => "items", + Keyword::Enum => "enum", + Keyword::Description => "description", + Keyword::Default => "default", + } } +} - /// Returns the declaration of the arg named `name`, when present. - #[must_use] - pub fn get(&self, name: &str) -> Option<&ArgDecl> { - self.fields.get(name) +impl<'de> Deserialize<'de> for Keyword { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(KeywordVisitor) } +} + +/// Reads one map key as a [`Keyword`], refusing any other key where it +/// stands, so the refusal has the key's own line and column. +struct KeywordVisitor; + +impl Visitor<'_> for KeywordVisitor { + type Value = Keyword; - /// Iterates the declared args as `(name, declaration)` pairs, in name order. - pub fn iter(&self) -> ArgsDeclIter<'_> { - ArgsDeclIter { - fields: self.fields.iter(), + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a schema keyword") + } + + fn visit_str(self, text: &str) -> Result + where + E: de::Error, + { + match text { + "type" => Ok(Keyword::Type), + "properties" => Ok(Keyword::Properties), + "required" => Ok(Keyword::Required), + "additionalProperties" => Ok(Keyword::AdditionalProperties), + "items" => Ok(Keyword::Items), + "enum" => Ok(Keyword::Enum), + "description" => Ok(Keyword::Description), + "default" => Ok(Keyword::Default), + other => Err(E::custom(format!( + "unknown keyword {other:?}; the keywords are {KEYWORDS}" + ))), } } +} - /// Returns the number of declared args. - #[must_use] - pub fn len(&self) -> usize { - self.fields.len() +/// The word under a node's `type`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TypeWord { + String, + Boolean, + Integer, + Number, + Array, + Object, +} + +/// The six type words, in the order a refusal lists them. +const TYPE_WORDS: &[&str] = &["string", "boolean", "integer", "number", "array", "object"]; + +impl<'de> Deserialize<'de> for TypeWord { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(TypeWordVisitor) } +} - /// Returns whether zero args are declared. - #[must_use] - pub fn is_empty(&self) -> bool { - self.fields.is_empty() +/// Reads the value of `type` as one [`TypeWord`], refusing any other word +/// and any value that is not one word, such as a list of words. +struct TypeWordVisitor; + +impl Visitor<'_> for TypeWordVisitor { + type Value = TypeWord; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "one type word: `string`, `boolean`, `integer`, `number`, `array`, or `object`", + ) + } + + fn visit_str(self, text: &str) -> Result + where + E: de::Error, + { + match text { + "string" => Ok(TypeWord::String), + "boolean" => Ok(TypeWord::Boolean), + "integer" => Ok(TypeWord::Integer), + "number" => Ok(TypeWord::Number), + "array" => Ok(TypeWord::Array), + "object" => Ok(TypeWord::Object), + other => Err(E::unknown_variant(other, TYPE_WORDS)), + } } } -impl<'a> IntoIterator for &'a ArgsDecl { - type Item = (&'a str, &'a ArgDecl); - type IntoIter = ArgsDeclIter<'a>; +impl TypeWord { + /// The word as a declaration writes it. + fn word(self) -> &'static str { + match self { + TypeWord::String => "string", + TypeWord::Boolean => "boolean", + TypeWord::Integer => "integer", + TypeWord::Number => "number", + TypeWord::Array => "array", + TypeWord::Object => "object", + } + } +} - fn into_iter(self) -> ArgsDeclIter<'a> { - self.iter() +/// The fields under `properties`: a map from each argument name, checked +/// against the name grammar, to its node, with no name declared twice. +struct Fields(BTreeMap); + +impl<'de> Deserialize<'de> for Fields { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + super::deserialize_contract_map(deserializer, "argument name").map(Fields) } } -/// Yields the declared args of one [`ArgsDecl`] as `(name, declaration)` -/// pairs, in name order. -/// -/// [`ArgsDecl::iter`] returns it. -#[derive(Debug, Clone)] -#[must_use = "an iterator does nothing until it is consumed"] -pub struct ArgsDeclIter<'a> { - fields: btree_map::Iter<'a, String, ArgDecl>, +/// The keywords one node wrote, as read, before they are checked against +/// each other. +#[derive(Default)] +struct RawNode { + kind: Option, + fields: Option>, + required: Option>, + open: Option, + items: Option, + choices: Option>, + description: Option, + default: Option, +} + +impl RawNode { + /// Reads the value of `keyword` from `map`, refusing a keyword the + /// node already holds. + fn read<'de, A>(&mut self, keyword: Keyword, map: &mut A) -> Result<(), A::Error> + where + A: MapAccess<'de>, + { + let written = match keyword { + Keyword::Type => self.kind.is_some(), + Keyword::Properties => self.fields.is_some(), + Keyword::Required => self.required.is_some(), + Keyword::AdditionalProperties => self.open.is_some(), + Keyword::Items => self.items.is_some(), + Keyword::Enum => self.choices.is_some(), + Keyword::Description => self.description.is_some(), + Keyword::Default => self.default.is_some(), + }; + if written { + return Err(de::Error::custom(format!( + "keyword `{}` is written twice", + keyword.word() + ))); + } + match keyword { + Keyword::Type => self.kind = Some(map.next_value()?), + Keyword::Properties => self.fields = Some(map.next_value::()?.0), + Keyword::Required => self.required = Some(map.next_value()?), + Keyword::AdditionalProperties => self.open = Some(map.next_value()?), + Keyword::Items => self.items = Some(map.next_value_seed(NodeSeed(Place::Items))?), + Keyword::Enum => self.choices = Some(map.next_value()?), + Keyword::Description => self.description = Some(map.next_value()?), + Keyword::Default => self.default = Some(map.next_value()?), + } + Ok(()) + } + + /// Checks the keywords against each other and against the node's + /// `place`, and builds the node. The refusal is the text after the + /// node's path. + fn into_node(self, place: Place) -> Result { + let Some(kind) = self.kind else { + return Err( + "a node needs a `type`: `string`, `boolean`, `integer`, `number`, `array`, or \ + `object`" + .to_owned(), + ); + }; + if place == Place::Root && kind != TypeWord::Object { + return Err(format!( + "the root of `args` must be `type: object`, and it is `{}`", + kind.word() + )); + } + if let Some((keyword, applies)) = self.misplaced(kind) { + return Err(format!( + "`{keyword}` applies to {applies}, and this node is `{}`", + kind.word() + )); + } + match (place, &self.default) { + (Place::Root, Some(_)) => { + return Err("the root of `args` takes no `default`".to_owned()); + } + (Place::Items, Some(_)) => return Err("a list item takes no `default`".to_owned()), + _ => {} + } + let kind = match kind { + TypeWord::String => Kind::String { + choices: self.choices.as_deref().map(string_choices).transpose()?, + }, + TypeWord::Integer => Kind::Integer { + choices: self.choices.as_deref().map(integer_choices).transpose()?, + }, + TypeWord::Boolean => Kind::Boolean, + TypeWord::Number => Kind::Number, + TypeWord::Array => Kind::Array { + items: Box::new(self.items.ok_or("an `array` node needs `items`")?), + }, + TypeWord::Object => Kind::Object(object_node(self.fields, self.required, self.open)?), + }; + let mut node = Node { + kind, + description: self.description, + default: None, + }; + if let Some(default) = &self.default { + node.default = default_of(&node, default)?; + } + Ok(node) + } + + /// The first keyword the node holds that does not apply to `kind`, + /// with the nodes it applies to. + fn misplaced(&self, kind: TypeWord) -> Option<(&'static str, &'static str)> { + let object = kind == TypeWord::Object; + let listed = matches!(kind, TypeWord::String | TypeWord::Integer); + [ + ( + self.items.is_some() && kind != TypeWord::Array, + "items", + "an `array` node", + ), + ( + self.fields.is_some() && !object, + "properties", + "an `object` node", + ), + ( + self.required.is_some() && !object, + "required", + "an `object` node", + ), + ( + self.open.is_some() && !object, + "additionalProperties", + "an `object` node", + ), + ( + self.choices.is_some() && !listed, + "enum", + "a `string` or an `integer` node", + ), + ] + .into_iter() + .find(|(misplaced, ..)| *misplaced) + .map(|(_, keyword, applies)| (keyword, applies)) + } } -impl<'a> Iterator for ArgsDeclIter<'a> { - type Item = (&'a str, &'a ArgDecl); +/// What an `object` node declares, from its three keywords: `required` +/// names only declared fields, each once. +fn object_node( + fields: Option>, + required: Option>, + open: Option, +) -> Result { + let fields = fields.unwrap_or_default(); + let mut names = BTreeSet::new(); + for name in required.unwrap_or_default() { + if !fields.contains_key(&name) { + return Err(format!( + "`required` names `{name}`, which `properties` does not declare" + )); + } + if names.contains(&name) { + return Err(format!("`required` names `{name}` twice")); + } + names.insert(name); + } + Ok(ObjectNode { + fields, + required: names, + open: open.unwrap_or(false), + }) +} - fn next(&mut self) -> Option<(&'a str, &'a ArgDecl)> { - self.fields.next().map(|(name, decl)| (name.as_str(), decl)) +/// The strings a `string` node's `enum` lists: at least one, each once. +fn string_choices(listed: &[Yaml]) -> Result, String> { + let mut choices: Vec = Vec::with_capacity(listed.len()); + for value in listed { + let Yaml::String(text) = value else { + return Err(format!( + "the `enum` of a `string` node lists strings, and it lists {}", + yaml_kind(value) + )); + }; + if choices.contains(text) { + return Err(format!("`enum` lists {text:?} twice")); + } + choices.push(text.clone()); } + if choices.is_empty() { + return Err("`enum` lists no value".to_owned()); + } + Ok(choices) +} - fn size_hint(&self) -> (usize, Option) { - self.fields.size_hint() +/// The integers an `integer` node's `enum` lists: at least one, each once. +fn integer_choices(listed: &[Yaml]) -> Result, String> { + let mut choices: Vec = Vec::with_capacity(listed.len()); + for value in listed { + let Some(integer) = value.as_i64() else { + return Err(match value.as_u64() { + Some(large) => format!( + "the `enum` of an `integer` node lists integers that fit in 64 bits, and \ + it lists {large}" + ), + None => format!( + "the `enum` of an `integer` node lists integers, and it lists {}", + yaml_kind(value) + ), + }); + }; + if choices.contains(&integer) { + return Err(format!("`enum` lists {integer} twice")); + } + choices.push(integer); + } + if choices.is_empty() { + return Err("`enum` lists no value".to_owned()); } + Ok(choices) } -impl ExactSizeIterator for ArgsDeclIter<'_> {} +/// The default `node` declares: `written` as JSON, matched against the +/// node, with its own missing fields filled. A YAML null declares none. +fn default_of(node: &Node, written: &Yaml) -> Result, String> { + let mut default = yaml_to_json(written).map_err(|why| format!("the default holds {why}"))?; + if default.is_null() { + return Ok(None); + } + args_check::check(node, &mut default, "", args_check::Checked::Default) + .map_err(|mismatch| mismatch.of_default()) + .map(|()| Some(default)) +} -impl Default for ArgsDecl { - fn default() -> Self { - let mut fields = BTreeMap::new(); - fields.insert( - "prose".to_owned(), - ArgDecl { - kind: ArgType::String, - optional: true, - default: None, - description: Some("Freeform input for this prompt".to_owned()), - }, - ); - ArgsDecl { - fields, - implicit: true, +/// `value` as JSON, or what in it has no JSON form: a number that is not +/// finite, a key that is not a string, or a tagged value. +fn yaml_to_json(value: &Yaml) -> Result { + match value { + Yaml::Null => Ok(Json::Null), + Yaml::Bool(flag) => Ok(Json::Bool(*flag)), + Yaml::Number(number) => { + if let Some(integer) = number.as_i64() { + Ok(integer.into()) + } else if let Some(integer) = number.as_u64() { + Ok(integer.into()) + } else { + number + .as_f64() + .and_then(serde_json::Number::from_f64) + .map(Json::Number) + .ok_or("a number that is not finite") + } + } + Yaml::String(text) => Ok(Json::String(text.clone())), + Yaml::Sequence(items) => items.iter().map(yaml_to_json).collect(), + Yaml::Mapping(entries) => { + let mut object = Map::new(); + for (key, value) in entries { + let Yaml::String(name) = key else { + return Err("a key that is not a string"); + }; + object.insert(name.clone(), yaml_to_json(value)?); + } + Ok(Json::Object(object)) } + Yaml::Tagged(_) => Err("a tagged value"), } } -impl<'de> Deserialize<'de> for ArgsDecl { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - // Arg names are `argv` fields, never globals, so a reserved name - // such as the default declaration's `prose` is a valid arg name. - let fields = contract::deserialize_contract_map(deserializer, "arg name")?; - Ok(ArgsDecl { - fields, - implicit: false, - }) +/// Names the YAML kind of `value`, with its article, for a refusal. +fn yaml_kind(value: &Yaml) -> &'static str { + match value { + Yaml::Null => "a null", + Yaml::Bool(_) => "a boolean", + Yaml::Number(number) if number.is_i64() || number.is_u64() => "an integer", + Yaml::Number(_) => "a number", + Yaml::String(_) => "a string", + Yaml::Sequence(_) => "a sequence", + Yaml::Mapping(_) => "a mapping", + Yaml::Tagged(_) => "a tagged value", } } diff --git a/crates/runvil-internal/prompt/src/contract/args_check.rs b/crates/runvil-internal/prompt/src/contract/args_check.rs new file mode 100644 index 000000000..eafaeefef --- /dev/null +++ b/crates/runvil-internal/prompt/src/contract/args_check.rs @@ -0,0 +1,315 @@ +//! Checks a caller's arguments against a prompt's `args` declaration. +//! +//! [`accept`] reads the argument text as one JSON object, gives each +//! missing field its declared default, and refuses the first value that +//! does not match. The order is fixed: text that is not JSON, then JSON +//! that is not an object, then each object's declared fields in name +//! order, then its undeclared fields in name order. A declared field is +//! checked for a missing required value, then its type, then its `enum`, +//! then its own contents, and a list's items are checked in index order. +//! A missing field takes its default before that check, so a required +//! field that declares a default is never missing. +//! +//! A `null` sent for a field that is not required counts as the field +//! left out, so the field takes its default or stays absent. A `null` +//! sent for a required field is refused, with or without a default. A +//! `null` anywhere else is a value of no declared type, except in an +//! object that takes undeclared fields, where an undeclared field keeps +//! it. An `integer` takes any JSON number with no fractional part that +//! fits in 64 bits, and the accepted object holds it as an integer. + +use runvil_types::error::{Error, ErrorCode, ErrorFact}; +use serde_json::{Map, Value as Json}; + +use super::args::{ArgsSchema, Kind, Node, ObjectNode}; + +/// The most characters of a refused string a refusal shows. +const SHOWN_CHARACTERS: usize = 40; + +/// 2^63, one past the largest integer 64 bits hold. +const ABOVE_INTEGERS: f64 = 9_223_372_036_854_775_808.0; + +/// One value that does not match its node. +pub(super) struct Mismatch { + /// The tag of the rule the value broke, the failure's `Reason` fact. + reason: &'static str, + /// The argument the value belongs to as a path from the root, such as + /// `opts.depth` or `focus[1]`, where a list index counts from 0. It is + /// empty for the checked value itself. + name: String, + /// What the rule requires and what the value is, as the words that + /// follow the argument's name. + detail: String, +} + +impl Mismatch { + /// The refusal of a declared default this mismatch was found in: the + /// text after the node's path. + pub(super) fn of_default(&self) -> String { + if self.name.is_empty() { + format!("the default {}", self.detail) + } else { + format!("the default's `{}` {}", self.name, self.detail) + } + } + + /// The failure of a run whose arguments hold this mismatch. + fn into_error(self) -> Error { + make_args_error( + self.reason, + Some(&self.name), + format!("argument `{}` {}", self.name, self.detail), + ) + } +} + +/// Reads `text` as the arguments `schema` declares and returns the object +/// a run holds: the caller's fields, with each missing field's default. +/// +/// # Errors +/// Returns an `ArgsInvalid` failure with no location, whose `Reason` fact +/// is `not_json` for text that is not JSON, `not_an_object` for any JSON +/// but an object, and `required`, `type`, `enum`, or `undeclared` for the +/// first value that does not match, whose path is the `Name` fact. +pub(crate) fn accept(schema: &ArgsSchema, text: &str) -> Result { + let mut args: Json = serde_json::from_str(text).map_err(|cause| { + make_args_error("not_json", None, "the arguments are not valid JSON").with_cause(&cause) + })?; + if !args.is_object() { + return Err(make_args_error( + "not_an_object", + None, + format!( + "the arguments must be a JSON object, got {}", + json_kind(&args) + ), + )); + } + check(schema.root(), &mut args, "", Checked::Arguments).map_err(Mismatch::into_error)?; + Ok(args) +} + +/// What a checked value is, which words the refusal of a field that the +/// value's own object does not declare. +#[derive(Debug, Clone, Copy)] +pub(super) enum Checked { + /// A run's arguments, whose own object is the root of the declaration. + Arguments, + /// A declared default, whose own object is the node that declares it. + Default, +} + +/// Checks `value`, the argument named `name`, against `node`, filling the +/// default of each missing field inside it and turning each whole number +/// under an `integer` node into an integer. `checked` says what the value +/// with the empty name is. +pub(super) fn check( + node: &Node, + value: &mut Json, + name: &str, + checked: Checked, +) -> Result<(), Mismatch> { + let mismatch = |reason, detail: String| Mismatch { + reason, + name: name.to_owned(), + detail, + }; + let wrong_type = |value: &Json| { + mismatch( + "type", + format!("must be {}, got {}", node.kind.expected(), json_kind(value)), + ) + }; + match &node.kind { + Kind::String { choices } => { + let Json::String(text) = value else { + return Err(wrong_type(value)); + }; + match choices { + Some(choices) if !choices.contains(text) => { + let allowed: Vec = choices.iter().map(|c| format!("{c:?}")).collect(); + Err(mismatch( + "enum", + format!("must be one of {}, got {}", allowed.join(", "), shown(text)), + )) + } + _ => Ok(()), + } + } + Kind::Boolean if value.is_boolean() => Ok(()), + Kind::Number if value.is_number() => Ok(()), + Kind::Boolean | Kind::Number => Err(wrong_type(value)), + Kind::Integer { choices } => { + let integer = whole(value).map_err(|detail| mismatch("type", detail))?; + *value = integer.into(); + match choices { + Some(choices) if !choices.contains(&integer) => { + let allowed: Vec = choices.iter().map(i64::to_string).collect(); + Err(mismatch( + "enum", + format!("must be one of {}, got {integer}", allowed.join(", ")), + )) + } + _ => Ok(()), + } + } + Kind::Array { items } => { + let Json::Array(list) = value else { + return Err(wrong_type(value)); + }; + list.iter_mut().enumerate().try_for_each(|(index, item)| { + check(items, item, &format!("{name}[{index}]"), checked) + }) + } + Kind::Object(object) => { + let Json::Object(fields) = value else { + return Err(wrong_type(value)); + }; + check_fields(object, fields, name, checked) + } + } +} + +/// Checks the `fields` of the object named `name` against `object`: each +/// declared field in name order, then the first undeclared field. A +/// declared field that is left out takes its default, and one with no +/// default is refused when the object requires it. +fn check_fields( + object: &ObjectNode, + fields: &mut Map, + name: &str, + checked: Checked, +) -> Result<(), Mismatch> { + for (field, node) in &object.fields { + let path = if name.is_empty() { + field.clone() + } else { + format!("{name}.{field}") + }; + let required = object.required.contains(field); + // A null for a field that is not required is the field left out. + if !required && fields.get(field).is_some_and(Json::is_null) { + fields.remove(field); + } + let absent = |detail: &str| Mismatch { + reason: "required", + name: path.clone(), + detail: detail.to_owned(), + }; + match fields.get_mut(field) { + Some(Json::Null) => return Err(absent("is required, got null")), + Some(value) => check(node, value, &path, checked)?, + None => match &node.default { + Some(default) => { + fields.insert(field.clone(), default.clone()); + } + None if required => return Err(absent("is required")), + None => {} + }, + } + } + if object.open { + return Ok(()); + } + match fields.keys().find(|key| !object.fields.contains_key(*key)) { + Some(undeclared) => Err(Mismatch { + reason: "undeclared", + name: if name.is_empty() { + undeclared.clone() + } else { + format!("{name}.{undeclared}") + }, + detail: format!("is not declared; {}", declared(object, name, checked)), + }), + None => Ok(()), + } +} + +/// Names the fields the object named `name` declares, for the refusal of +/// an undeclared one. The object with the empty name is the root of the +/// declaration for a run's arguments, and the declaring node for a +/// default. +fn declared(object: &ObjectNode, name: &str, checked: Checked) -> String { + let names: Vec = object + .fields + .keys() + .map(|field| format!("`{field}`")) + .collect(); + let names = names.join(", "); + match (name.is_empty(), names.is_empty(), checked) { + (true, true, Checked::Arguments) => "the prompt declares no arguments".to_owned(), + (true, false, Checked::Arguments) => format!("the declared arguments are {names}"), + (true, true, Checked::Default) => "this node declares no fields".to_owned(), + (true, false, Checked::Default) => format!("the declared fields of this node are {names}"), + (false, true, _) => format!("`{name}` declares no fields"), + (false, false, _) => format!("the declared fields of `{name}` are {names}"), + } +} + +/// The integer `value` holds: an integer as it is, and a number with no +/// fractional part that 64 bits hold as that integer. A number that is +/// not written as a 64-bit integer is read as a float, and one whose +/// float is at either end of the 64-bit range is refused, because digits +/// past the range round to that end. Otherwise the refusal's words after +/// the argument's name. +fn whole(value: &Json) -> Result { + let Json::Number(number) = value else { + return Err(format!("must be an integer, got {}", json_kind(value))); + }; + if let Some(integer) = number.as_i64() { + return Ok(integer); + } + match number.as_f64() { + Some(float) if float.fract() != 0.0 => Err("must be an integer, got number".to_owned()), + Some(float) if !number.is_u64() && float.abs() < ABOVE_INTEGERS => { + #[expect( + clippy::cast_possible_truncation, + reason = "the float is a whole number inside i64's range" + )] + let integer = float as i64; + Ok(integer) + } + _ => Err(format!( + "must be an integer that fits in 64 bits, got {number}" + )), + } +} + +/// A refused string as a refusal shows it: quoted, and cut after +/// [`SHOWN_CHARACTERS`] characters. +fn shown(text: &str) -> String { + let mut clipped: String = text.chars().take(SHOWN_CHARACTERS).collect(); + if clipped.len() < text.len() { + clipped.push_str("..."); + } + format!("{clipped:?}") +} + +/// Names the JSON type of `value`, with `integer` for a number written +/// with no fraction and no exponent, and `number` for any other. +fn json_kind(value: &Json) -> &'static str { + match value { + Json::Null => "null", + Json::Bool(_) => "boolean", + Json::Number(number) if number.is_i64() || number.is_u64() => "integer", + Json::Number(_) => "number", + Json::String(_) => "string", + Json::Array(_) => "array", + Json::Object(_) => "object", + } +} + +/// Builds the failure of a run's arguments: `message` under `ArgsInvalid`, +/// with the `Reason` fact `reason` and, for one argument, its path as the +/// `Name` fact. The prose is the message and then the code's default. +fn make_args_error(reason: &str, name: Option<&str>, message: impl Into) -> Error { + let code = ErrorCode::ArgsInvalid; + let message = message.into(); + let prose = format!("{message}. {}", code.prose()); + let mut error = Error::new(code, message, prose); + error.facts.insert(ErrorFact::Reason, reason.into()); + if let Some(name) = name { + error.facts.insert(ErrorFact::Name, name.into()); + } + error +} diff --git a/crates/runvil-internal/prompt/src/contract/tests-args-check.rs b/crates/runvil-internal/prompt/src/contract/tests-args-check.rs new file mode 100644 index 000000000..78ea75afd --- /dev/null +++ b/crates/runvil-internal/prompt/src/contract/tests-args-check.rs @@ -0,0 +1,830 @@ +//! Tests for the check of a caller's arguments against the `args` +//! declaration: the object a run holds, with each default filled, and each +//! refusal with its message, prose, facts, and no location. + +use std::collections::BTreeMap; + +use runvil_types::error::Error; +use serde_json::Value as Json; + +use super::*; +use crate::Prompt; +use crate::detail::accept_args; + +/// A required string, an integer with a default, and a string with an +/// `enum`. +const SEARCH: &str = " + args: + type: object + properties: + query: { type: string, description: What to search for } + limit: { type: integer, default: 3 } + depth: { type: string, enum: [quick, full] } + required: [query] +"; + +/// A nested object, a list of strings, a list of objects, and a list of +/// lists, none of them required. +const NESTED: &str = " + args: + type: object + properties: + opts: + type: object + properties: + depth: { type: integer, default: 2 } + mode: { type: string } + focus: { type: array, items: { type: string } } + people: + type: array + items: + type: object + properties: + name: { type: string } + role: { type: string, default: guest } + required: [name] + grid: { type: array, items: { type: array, items: { type: integer } } } +"; + +/// The prompt `t` whose frontmatter ends with the YAML block `front`. +#[track_caller] +fn declaring(front: &str) -> Prompt { + parse_ok(&block_prompt(front)) +} + +/// The object a run of `prompt` holds for the arguments `text`. +#[track_caller] +fn accepted(prompt: &Prompt, text: &str) -> Json { + match accept_args(prompt, text) { + Ok(args) => args, + Err(error) => panic!("expected `{text}` to pass, got {error:?}"), + } +} + +/// The failure of the arguments `text` for a run of `prompt`. +#[track_caller] +fn refused(prompt: &Prompt, text: &str) -> Error { + match accept_args(prompt, text) { + Ok(args) => panic!("expected `{text}` to be refused, got {args}"), + Err(error) => error, + } +} + +/// Asserts that a run of `prompt` holds `held`, as compact JSON text, for +/// the arguments `text`. +#[track_caller] +fn assert_held(prompt: &Prompt, text: &str, held: &str) { + assert_eq!(accepted(prompt, text).to_string(), held, "{text}"); +} + +/// Asserts that `prompt` refuses the arguments `text` about the argument +/// `name`: an `ArgsInvalid` failure with `message`, the `Reason` fact +/// `reason`, the `Name` fact `name`, no location, and the prose that is +/// the message and then the code's default. +#[track_caller] +fn assert_refusal(prompt: &Prompt, text: &str, reason: &str, name: &str, message: &str) { + let error = refused(prompt, text); + assert_eq!(error.code, ErrorCode::ArgsInvalid, "{text}"); + assert_eq!(error.message, message, "{text}"); + assert_eq!( + error.prose, + format!("{message}. {}", ErrorCode::ArgsInvalid.prose()), + "{text}" + ); + assert_eq!( + error.facts, + BTreeMap::from([ + (ErrorFact::Reason, reason.into()), + (ErrorFact::Name, name.into()) + ]), + "{text}" + ); + assert_eq!(error.at, None, "{text}"); +} + +#[test] +fn text_that_is_not_json_is_refused_with_the_json_parsers_cause() { + let prompt = declaring(SEARCH); + for text in ["papers", "", r#"{"query":"#, r#"{"query":"papers"} x"#] { + let cause = serde_json::from_str::(text) + .expect_err("the text is not JSON") + .to_string(); + let error = refused(&prompt, text); + assert_eq!(error.code, ErrorCode::ArgsInvalid, "{text}"); + assert_eq!( + error.message, + format!("the arguments are not valid JSON: {cause}"), + "{text}" + ); + // The parser's cause joins the message alone, so the prose holds + // the message's own words and then the code's default. + assert_eq!( + error.prose, + format!( + "the arguments are not valid JSON. {}", + ErrorCode::ArgsInvalid.prose() + ), + "{text}" + ); + assert_eq!( + error.facts, + BTreeMap::from([(ErrorFact::Reason, "not_json".into())]), + "no argument is named: {text}" + ); + assert_eq!(error.at, None, "{text}"); + } +} + +#[test] +fn json_that_is_not_an_object_is_refused_naming_its_kind() { + let prompt = declaring(SEARCH); + for (text, kind) in [ + ("5", "integer"), + ("5.5", "number"), + (r#""x""#, "string"), + ("[1]", "array"), + ("true", "boolean"), + ("null", "null"), + ] { + let message = format!("the arguments must be a JSON object, got {kind}"); + let error = refused(&prompt, text); + assert_eq!(error.code, ErrorCode::ArgsInvalid, "{text}"); + assert_eq!(error.message, message, "{text}"); + assert_eq!( + error.prose, + format!("{message}. {}", ErrorCode::ArgsInvalid.prose()), + "{text}" + ); + assert_eq!( + error.facts, + BTreeMap::from([(ErrorFact::Reason, "not_an_object".into())]), + "no argument is named: {text}" + ); + assert_eq!(error.at, None, "{text}"); + } +} + +#[test] +fn each_type_takes_its_own_json_kind_and_refuses_every_other() { + // Each JSON value, with the kind a refusal names it by. + let values = [ + (r#""x""#, "string"), + ("true", "boolean"), + ("5", "integer"), + ("5.5", "number"), + ("[]", "array"), + ("{}", "object"), + ]; + // Each type's node, what a refusal says a value must be, and the kinds + // the type takes. + let types: [(&str, &str, &[&str]); 6] = [ + ("{ type: string }", "a string", &["string"]), + ("{ type: boolean }", "a boolean", &["boolean"]), + ("{ type: integer }", "an integer", &["integer"]), + ("{ type: number }", "a number", &["integer", "number"]), + ( + "{ type: array, items: { type: string } }", + "an array", + &["array"], + ), + ("{ type: object }", "an object", &["object"]), + ]; + for (node, expected, taken) in types { + let prompt = declaring(&format!( + "args: {{ type: object, properties: {{ value: {node} }} }}" + )); + for (value, kind) in values { + let text = format!(r#"{{"value":{value}}}"#); + if taken.contains(&kind) { + assert_held(&prompt, &text, &text); + } else { + assert_refusal( + &prompt, + &text, + "type", + "value", + &format!("argument `value` must be {expected}, got {kind}"), + ); + } + } + } +} + +#[test] +fn a_missing_field_takes_its_default_and_one_with_no_default_stays_absent() { + let prompt = declaring(SEARCH); + // `limit` takes its default, and `depth`, which declares none, is not + // added. + assert_held( + &prompt, + r#"{"query":"papers"}"#, + r#"{"limit":3,"query":"papers"}"#, + ); +} + +#[test] +fn a_sent_value_is_kept_where_the_field_has_a_default() { + let prompt = declaring(SEARCH); + assert_held( + &prompt, + r#"{"query":"papers","limit":7,"depth":"full"}"#, + r#"{"depth":"full","limit":7,"query":"papers"}"#, + ); +} + +#[test] +fn a_required_field_left_out_is_refused() { + assert_refusal( + &declaring(SEARCH), + "{}", + "required", + "query", + "argument `query` is required", + ); + assert_refusal( + &declaring(NESTED), + r#"{"people":[{"name":"ada"},{}]}"#, + "required", + "people[1].name", + "argument `people[1].name` is required", + ); +} + +#[test] +fn a_null_for_a_required_field_is_refused() { + assert_refusal( + &declaring(SEARCH), + r#"{"query":null}"#, + "required", + "query", + "argument `query` is required, got null", + ); + assert_refusal( + &declaring(NESTED), + r#"{"people":[{"name":null}]}"#, + "required", + "people[0].name", + "argument `people[0].name` is required, got null", + ); +} + +#[test] +fn a_required_field_with_a_default_takes_it_when_left_out_and_refuses_a_null() { + let prompt = declaring( + " + args: + type: object + properties: + limit: { type: integer, default: 3 } + required: [limit] + ", + ); + assert_held(&prompt, "{}", r#"{"limit":3}"#); + assert_held(&prompt, r#"{"limit":7}"#, r#"{"limit":7}"#); + assert_refusal( + &prompt, + r#"{"limit":null}"#, + "required", + "limit", + "argument `limit` is required, got null", + ); +} + +#[test] +fn an_undeclared_field_is_refused_listing_the_declared_arguments() { + let prompt = declaring(SEARCH); + let message = + "argument `n` is not declared; the declared arguments are `depth`, `limit`, `query`"; + assert_refusal( + &prompt, + r#"{"query":"papers","n":2}"#, + "undeclared", + "n", + message, + ); + // A null sent for a field the declaration does not list is a field + // all the same. + assert_refusal( + &prompt, + r#"{"query":"papers","n":null}"#, + "undeclared", + "n", + message, + ); +} + +#[test] +fn an_undeclared_field_is_refused_by_a_declaration_with_no_fields() { + let prompt = declaring("args: { type: object }"); + assert_held(&prompt, "{}", "{}"); + assert_refusal( + &prompt, + r#"{"n":2}"#, + "undeclared", + "n", + "argument `n` is not declared; the prompt declares no arguments", + ); +} + +#[test] +fn an_undeclared_field_inside_a_nested_object_is_named_by_its_path() { + let prompt = declaring(NESTED); + assert_refusal( + &prompt, + r#"{"opts":{"n":1}}"#, + "undeclared", + "opts.n", + "argument `opts.n` is not declared; the declared fields of `opts` are `depth`, `mode`", + ); + assert_refusal( + &prompt, + r#"{"people":[{"name":"ada","age":36}]}"#, + "undeclared", + "people[0].age", + "argument `people[0].age` is not declared; the declared fields of `people[0]` are \ + `name`, `role`", + ); + let bare = declaring("args: { type: object, properties: { opts: { type: object } } }"); + assert_refusal( + &bare, + r#"{"opts":{"n":1}}"#, + "undeclared", + "opts.n", + "argument `opts.n` is not declared; `opts` declares no fields", + ); +} + +#[test] +fn an_open_object_keeps_the_fields_it_does_not_declare_a_null_among_them() { + let prompt = declaring( + " + args: + type: object + additionalProperties: true + properties: + query: { type: string } + extras: + type: object + additionalProperties: true + properties: + kept: { type: string, default: all } + ", + ); + assert_held( + &prompt, + r#"{"query":"papers","note":null,"extras":{"n":1,"gone":null}}"#, + r#"{"extras":{"gone":null,"kept":"all","n":1},"note":null,"query":"papers"}"#, + ); + // The value of such a field is kept whole, a null inside it included. + let nested = r#"{"tags":["a",null,{"x":null}]}"#; + assert_held(&prompt, nested, nested); + // A null for a field the open object declares still counts as the + // field left out. + assert_held( + &prompt, + r#"{"extras":{"kept":null,"gone":null}}"#, + r#"{"extras":{"gone":null,"kept":"all"}}"#, + ); + // An open object checks the fields it declares. + assert_refusal( + &prompt, + r#"{"query":5,"note":1}"#, + "type", + "query", + "argument `query` must be a string, got integer", + ); + assert_refusal( + &prompt, + r#"{"extras":{"kept":5,"n":1}}"#, + "type", + "extras.kept", + "argument `extras.kept` must be a string, got integer", + ); +} + +#[test] +fn a_string_enum_takes_only_the_values_it_lists() { + let prompt = declaring(SEARCH); + for depth in ["quick", "full"] { + assert_held( + &prompt, + &format!(r#"{{"query":"papers","depth":"{depth}"}}"#), + &format!(r#"{{"depth":"{depth}","limit":3,"query":"papers"}}"#), + ); + } + for sent in ["slow", "Quick", ""] { + assert_refusal( + &prompt, + &format!(r#"{{"query":"papers","depth":"{sent}"}}"#), + "enum", + "depth", + &format!(r#"argument `depth` must be one of "quick", "full", got "{sent}""#), + ); + } +} + +#[test] +fn a_refused_long_string_is_cut_after_40_characters() { + let prompt = declaring(SEARCH); + let forty = "0123456789".repeat(4); + let accented = "\u{e9}".repeat(40); + for (sent, shown) in [ + // A string of 40 characters is shown whole. + (forty.clone(), forty.clone()), + // A longer one is cut after its first 40 characters, which count + // as characters, not as bytes. + (format!("{forty}x"), format!("{forty}...")), + (format!("{forty} and far more"), format!("{forty}...")), + (format!("{accented}\u{e9}"), format!("{accented}...")), + ] { + assert_refusal( + &prompt, + &format!(r#"{{"query":"papers","depth":"{sent}"}}"#), + "enum", + "depth", + &format!(r#"argument `depth` must be one of "quick", "full", got "{shown}""#), + ); + } +} + +#[test] +fn an_integer_enum_takes_only_the_values_it_lists() { + let prompt = declaring( + "args: { type: object, properties: { level: { type: integer, enum: [1, 2, 3] } } }", + ); + assert_held(&prompt, r#"{"level":2}"#, r#"{"level":2}"#); + // A whole number is the integer it equals. + assert_held(&prompt, r#"{"level":2.0}"#, r#"{"level":2}"#); + for (sent, shown) in [("5", "5"), ("5.0", "5"), ("-1", "-1")] { + assert_refusal( + &prompt, + &format!(r#"{{"level":{sent}}}"#), + "enum", + "level", + &format!("argument `level` must be one of 1, 2, 3, got {shown}"), + ); + } +} + +#[test] +fn a_mismatch_inside_a_nested_object_or_a_list_is_named_by_its_path() { + let prompt = declaring(NESTED); + for (text, name, said) in [ + ( + r#"{"opts":{"depth":"deep"}}"#, + "opts.depth", + "must be an integer, got string", + ), + // A list index counts from 0. + ( + r#"{"focus":[5]}"#, + "focus[0]", + "must be a string, got integer", + ), + ( + r#"{"focus":["a",5]}"#, + "focus[1]", + "must be a string, got integer", + ), + ( + r#"{"people":[{"name":"ada"},{"name":7}]}"#, + "people[1].name", + "must be a string, got integer", + ), + ( + r#"{"grid":[[1,2],[3,"x"]]}"#, + "grid[1][1]", + "must be an integer, got string", + ), + ] { + assert_refusal( + &prompt, + text, + "type", + name, + &format!("argument `{name}` {said}"), + ); + } +} + +#[test] +fn a_whole_number_for_an_integer_field_comes_back_as_an_integer() { + let prompt = declaring(SEARCH); + for (sent, held) in [ + ("5.0", "5"), + ("1e3", "1000"), + ("-2.0", "-2"), + ("-0.0", "0"), + ("5", "5"), + ] { + assert_held( + &prompt, + &format!(r#"{{"query":"papers","limit":{sent}}}"#), + &format!(r#"{{"limit":{held},"query":"papers"}}"#), + ); + } + // The same holds inside a nested object and inside a list. + assert_held( + &declaring(NESTED), + r#"{"grid":[[1.0,2e0]],"opts":{"depth":3.0}}"#, + r#"{"grid":[[1,2]],"opts":{"depth":3}}"#, + ); +} + +#[test] +fn a_number_with_a_fraction_is_refused_for_an_integer_field() { + let prompt = declaring(SEARCH); + for sent in ["5.5", "1e-1", "-0.5"] { + assert_refusal( + &prompt, + &format!(r#"{{"query":"papers","limit":{sent}}}"#), + "type", + "limit", + "argument `limit` must be an integer, got number", + ); + } +} + +#[test] +fn an_integer_past_64_bits_is_refused() { + let prompt = declaring(SEARCH); + // The refusal shows the number as the JSON parser read it, so one sent + // with an exponent is shown in the form the parser writes. + for (sent, shown) in [ + ("18446744073709551615", "18446744073709551615"), + ("9223372036854775808", "9223372036854775808"), + ("1e300", "1e+300"), + ("-1e30", "-1e+30"), + // One past the smallest integer reads as a float equal to it. + ("-9223372036854775809", "-9.223372036854776e+18"), + ] { + assert_refusal( + &prompt, + &format!(r#"{{"query":"papers","limit":{sent}}}"#), + "type", + "limit", + &format!("argument `limit` must be an integer that fits in 64 bits, got {shown}"), + ); + } + // The largest and the smallest integer 64 bits hold pass. + for sent in ["9223372036854775807", "-9223372036854775808"] { + assert_held( + &prompt, + &format!(r#"{{"query":"papers","limit":{sent}}}"#), + &format!(r#"{{"limit":{sent},"query":"papers"}}"#), + ); + } +} + +#[test] +fn a_number_field_takes_any_number_and_keeps_it_as_sent() { + let prompt = declaring("args: { type: object, properties: { ratio: { type: number } } }"); + for sent in [ + "5", + "5.0", + "-0.5", + "1e300", + "18446744073709551615", + "-9223372036854775808", + ] { + let text = format!(r#"{{"ratio":{sent}}}"#); + let parsed: Json = serde_json::from_str(&text).expect("the arguments are JSON"); + // JSON values compare an integer and a number with a fraction or + // an exponent as different, so each stays what the caller sent. + assert_eq!(accepted(&prompt, &text), parsed, "{text}"); + } + assert_held(&prompt, r#"{"ratio":5}"#, r#"{"ratio":5}"#); + assert_held(&prompt, r#"{"ratio":5.0}"#, r#"{"ratio":5.0}"#); +} + +#[test] +fn a_default_is_filled_inside_a_nested_object_the_caller_sent() { + let prompt = declaring(NESTED); + assert_held(&prompt, r#"{"opts":{}}"#, r#"{"opts":{"depth":2}}"#); + assert_held( + &prompt, + r#"{"opts":{"mode":"fast"}}"#, + r#"{"opts":{"depth":2,"mode":"fast"}}"#, + ); +} + +#[test] +fn a_default_is_filled_inside_each_object_item_of_a_list() { + let prompt = declaring(NESTED); + assert_held( + &prompt, + r#"{"people":[{"name":"ada"},{"name":"bob","role":"chair"},{"name":"cy"}]}"#, + concat!( + r#"{"people":[{"name":"ada","role":"guest"},{"name":"bob","role":"chair"},"#, + r#"{"name":"cy","role":"guest"}]}"#, + ), + ); +} + +#[test] +fn an_object_default_arrives_with_the_defaults_of_its_own_fields() { + let prompt = declaring( + " + args: + type: object + properties: + page: + type: object + properties: + size: { type: integer, default: 20 } + sort: { type: string } + default: { sort: date } + ", + ); + assert_held(&prompt, "{}", r#"{"page":{"size":20,"sort":"date"}}"#); + // An object the caller sent stands in place of the whole default, and + // its own missing fields are filled. + assert_held(&prompt, r#"{"page":{}}"#, r#"{"page":{"size":20}}"#); +} + +#[test] +fn an_object_left_out_stays_absent_when_it_declares_no_default() { + // `opts.depth` declares a default, which is filled inside an `opts` + // that is there. `opts` declares none, so a caller that leaves it out + // gets no `opts`. + assert_held(&declaring(NESTED), "{}", "{}"); +} + +#[test] +fn a_null_for_a_field_that_is_not_required_counts_as_left_out() { + // `limit` takes its default, and `depth`, which declares none, is + // absent. + assert_held( + &declaring(SEARCH), + r#"{"query":"papers","limit":null,"depth":null}"#, + r#"{"limit":3,"query":"papers"}"#, + ); + let nested = declaring(NESTED); + assert_held( + &nested, + r#"{"opts":{"depth":null,"mode":null}}"#, + r#"{"opts":{"depth":2}}"#, + ); + assert_held(&nested, r#"{"opts":null,"focus":null}"#, "{}"); +} + +#[test] +fn a_null_inside_a_list_is_refused() { + let prompt = declaring(NESTED); + for (text, name, said) in [ + ( + r#"{"focus":["a",null]}"#, + "focus[1]", + "must be a string, got null", + ), + ( + r#"{"people":[null]}"#, + "people[0]", + "must be an object, got null", + ), + ( + r#"{"grid":[[1,null]]}"#, + "grid[0][1]", + "must be an integer, got null", + ), + ( + r#"{"grid":[null]}"#, + "grid[0]", + "must be an array, got null", + ), + ] { + assert_refusal( + &prompt, + text, + "type", + name, + &format!("argument `{name}` {said}"), + ); + } +} + +#[test] +fn the_mismatch_in_the_field_that_sorts_first_by_name_is_reported() { + let prompt = declaring(SEARCH); + // `depth` sorts before `limit`, whichever the caller wrote first. + assert_refusal( + &prompt, + r#"{"query":"papers","limit":"many","depth":"slow"}"#, + "enum", + "depth", + r#"argument `depth` must be one of "quick", "full", got "slow""#, + ); + // `limit` sorts before `query`, which is required and missing. + assert_refusal( + &prompt, + r#"{"limit":"many"}"#, + "type", + "limit", + "argument `limit` must be an integer, got string", + ); +} + +#[test] +fn a_missing_required_field_is_reported_before_an_undeclared_field() { + assert_refusal( + &declaring(SEARCH), + r#"{"n":1}"#, + "required", + "query", + "argument `query` is required", + ); +} + +#[test] +fn a_declared_fields_mismatch_is_reported_before_an_undeclared_field() { + // `a` sorts before `limit`, and the declared fields are checked first. + assert_refusal( + &declaring(SEARCH), + r#"{"a":1,"query":"papers","limit":"many"}"#, + "type", + "limit", + "argument `limit` must be an integer, got string", + ); +} + +#[test] +fn the_undeclared_field_that_sorts_first_by_name_is_reported() { + assert_refusal( + &declaring(SEARCH), + r#"{"query":"papers","zeta":1,"beta":2}"#, + "undeclared", + "beta", + "argument `beta` is not declared; the declared arguments are `depth`, `limit`, `query`", + ); +} + +#[test] +fn a_value_of_the_wrong_type_is_refused_for_its_type_before_its_enum() { + assert_refusal( + &declaring(SEARCH), + r#"{"query":"papers","depth":5}"#, + "type", + "depth", + "argument `depth` must be a string, got integer", + ); +} + +#[test] +fn the_contents_of_a_field_are_checked_before_the_next_field() { + // `opts` sorts before `people`, so the undeclared field inside `opts` + // is reported and the wrong type of `people` is not. + assert_refusal( + &declaring(NESTED), + r#"{"people":"all","opts":{"n":1}}"#, + "undeclared", + "opts.n", + "argument `opts.n` is not declared; the declared fields of `opts` are `depth`, `mode`", + ); +} + +#[test] +fn the_items_of_a_list_are_checked_in_index_order() { + assert_refusal( + &declaring(NESTED), + r#"{"focus":["a",1,2]}"#, + "type", + "focus[1]", + "argument `focus[1]` must be a string, got integer", + ); +} + +#[test] +fn an_empty_object_passes_a_declaration_with_no_required_field() { + let prompt = declaring( + " + args: + type: object + properties: + limit: { type: integer, default: 3 } + depth: { type: string } + ", + ); + assert_held(&prompt, "{}", r#"{"limit":3}"#); +} + +#[test] +fn the_default_declaration_takes_an_empty_object_or_a_text() { + let prompt = parse_ok(&prompt("", &[])); + assert_held(&prompt, "{}", "{}"); + assert_held(&prompt, r#"{"text":"hello"}"#, r#"{"text":"hello"}"#); + assert_held(&prompt, r#"{"text":null}"#, "{}"); + assert_refusal( + &prompt, + r#"{"text":5}"#, + "type", + "text", + "argument `text` must be a string, got integer", + ); + assert_refusal( + &prompt, + r#"{"query":"papers"}"#, + "undeclared", + "query", + "argument `query` is not declared; the declared arguments are `text`", + ); +} diff --git a/crates/runvil-internal/prompt/src/contract/tests-args.rs b/crates/runvil-internal/prompt/src/contract/tests-args.rs new file mode 100644 index 000000000..3a8cbb7be --- /dev/null +++ b/crates/runvil-internal/prompt/src/contract/tests-args.rs @@ -0,0 +1,1416 @@ +//! Tests for the `args` key: the keywords a declaration takes and the JSON +//! Schema it publishes, the default declaration, and each refused +//! declaration with its YAML path, line, and column. + +use std::collections::BTreeMap; + +use serde_json::Value as Json; + +use super::*; +use crate::ArgsSchema; +use crate::detail::TEXT_FIELD; + +/// The keywords a node takes, as the refusal of any other keyword lists +/// them. +const KEYWORDS: &str = "the keywords are `type`, `properties`, `required`, \ + `additionalProperties`, `items`, `enum`, `description`, and `default`"; + +/// The six type words, as the refusal of any other word lists them. +const TYPES: &str = "expected one of `string`, `boolean`, `integer`, `number`, `array`, `object`"; + +/// What the refusal of a node with no `type` says. +const NEEDS_TYPE: &str = + "a node needs a `type`: `string`, `boolean`, `integer`, `number`, `array`, or `object`"; + +/// The JSON Schema the declaration in the YAML block `front` publishes. +#[track_caller] +fn published(front: &str) -> Json { + parse_ok(&block_prompt(front)) + .frontmatter() + .args() + .as_json() + .clone() +} + +/// A declaration with one field, `field`, whose node is the flow mapping +/// `node`. The node opens on file line 8 at column 12, which the YAML +/// parser reports as line 7 column 12. +fn one_field(node: &str) -> String { + format!("args:\n type: object\n properties:\n field: {node}\n") +} + +/// The JSON Schema published for a field whose node is the flow mapping +/// `node`. +#[track_caller] +fn published_field(node: &str) -> Json { + published(&one_field(node))["properties"]["field"].clone() +} + +/// Asserts that the declaration in the YAML block `front` fails the parse +/// with the message `invalid frontmatter: ` then `refusal`, located at the +/// file line and column `at`. +#[track_caller] +fn assert_refused(front: &str, refusal: &str, at: (u32, u32)) { + let error = parse_err(&block_prompt(front)); + let (line, column) = at; + assert_eq!(error.code, ErrorCode::FrontmatterInvalid, "{error:?}"); + assert_eq!( + error.message, + format!("invalid frontmatter: {refusal}"), + "{front}" + ); + assert_eq!( + location(&error), + Some(("prompt", line, column, line, column)), + "{refusal}" + ); +} + +/// Asserts that a field whose node is the flow mapping `node` fails the +/// parse with `refusal` about that node, located where the node opens. +#[track_caller] +fn assert_field_refused(node: &str, refusal: &str) { + assert_refused( + &one_field(node), + &format!("args.properties.field: {refusal} at line 7 column 12"), + (8, 12), + ); +} + +#[test] +fn each_of_the_six_types_parses_and_is_published_under_type() { + let schema = published( + " + args: + type: object + properties: + name: { type: string } + flag: { type: boolean } + count: { type: integer } + ratio: { type: number } + tags: { type: array, items: { type: string } } + opts: { type: object } + ", + ); + assert_eq!( + schema, + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "flag": { "type": "boolean" }, + "count": { "type": "integer" }, + "ratio": { "type": "number" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "opts": { "type": "object", "properties": {}, "additionalProperties": false }, + }, + "additionalProperties": false, + }) + ); +} + +#[test] +fn properties_are_published_by_name_at_every_depth() { + let schema = published( + " + args: + type: object + properties: + page: + type: object + properties: + size: { type: integer } + sort: + type: object + properties: + by: { type: string } + ", + ); + assert_eq!( + schema["properties"]["page"], + json!({ + "type": "object", + "properties": { + "size": { "type": "integer" }, + "sort": { + "type": "object", + "properties": { "by": { "type": "string" } }, + "additionalProperties": false, + }, + }, + "additionalProperties": false, + }) + ); +} + +#[test] +fn an_object_that_declares_no_field_publishes_empty_properties() { + let empty = json!({ "type": "object", "properties": {}, "additionalProperties": false }); + for front in [ + "args: { type: object }", + "args: { type: object, properties: {} }", + ] { + assert_eq!(published(front), empty, "{front}"); + } +} + +#[test] +fn required_is_published_in_name_order_and_only_when_it_names_a_field() { + let schema = published( + " + args: + type: object + properties: + query: { type: string } + depth: { type: string } + limit: { type: integer } + required: [query, depth] + ", + ); + assert_eq!(schema["required"], json!(["depth", "query"])); + + for front in [ + "args: { type: object, properties: { query: { type: string } } }", + "args: { type: object, properties: { query: { type: string } }, required: [] }", + ] { + assert_eq!(published(front).get("required"), None, "{front}"); + } +} + +#[test] +fn additional_properties_is_published_as_written_and_as_false_when_left_out() { + for (written, open) in [ + ("{ type: object, additionalProperties: true }", true), + ("{ type: object, additionalProperties: false }", false), + ("{ type: object }", false), + ] { + let root = published(&format!("args: {written}")); + assert_eq!( + root["additionalProperties"], + json!(open), + "the root: {written}" + ); + let field = published_field(written); + assert_eq!( + field["additionalProperties"], + json!(open), + "a field: {written}" + ); + } +} + +#[test] +fn items_declares_the_node_of_each_list_item() { + for (node, items) in [ + ( + "{ type: array, items: { type: string } }", + json!({ "type": "string" }), + ), + ( + "{ type: array, items: { type: array, items: { type: integer } } }", + json!({ "type": "array", "items": { "type": "integer" } }), + ), + ( + "{ type: array, items: { type: object, properties: { name: { type: string } }, \ + required: [name] } }", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"], + "additionalProperties": false, + }), + ), + ] { + assert_eq!( + published_field(node), + json!({ "type": "array", "items": items }), + "{node}" + ); + } +} + +#[test] +fn an_enum_is_published_in_the_order_written_on_a_string_and_on_an_integer() { + assert_eq!( + published_field("{ type: string, enum: [quick, full] }"), + json!({ "type": "string", "enum": ["quick", "full"] }) + ); + assert_eq!( + published_field("{ type: integer, enum: [3, 1, -2] }"), + json!({ "type": "integer", "enum": [3, 1, -2] }) + ); + // A quoted scalar is a string, whatever it looks like. + assert_eq!( + published_field("{ type: string, enum: ['1', 'true', 'null'] }"), + json!({ "type": "string", "enum": ["1", "true", "null"] }) + ); +} + +#[test] +fn a_description_is_published_on_the_root_a_field_and_a_list_item() { + let schema = published( + " + args: + type: object + description: What a search takes + properties: + focus: + type: array + description: The areas to cover + items: + type: string + description: One area + ", + ); + assert_eq!( + schema, + json!({ + "type": "object", + "description": "What a search takes", + "properties": { + "focus": { + "type": "array", + "description": "The areas to cover", + "items": { "type": "string", "description": "One area" }, + }, + }, + "additionalProperties": false, + }) + ); +} + +#[test] +fn a_default_is_published_as_json_for_a_scalar_a_list_and_an_object() { + for (node, default) in [ + ("{ type: string, default: quick }", json!("quick")), + ("{ type: boolean, default: false }", json!(false)), + ("{ type: integer, default: 3 }", json!(3)), + ("{ type: number, default: 1.5 }", json!(1.5)), + ( + "{ type: string, enum: [quick, full], default: full }", + json!("full"), + ), + ( + "{ type: array, items: { type: string }, default: [a, b] }", + json!(["a", "b"]), + ), + ( + "{ type: array, items: { type: string }, default: [] }", + json!([]), + ), + ("{ type: object, default: {} }", json!({})), + ( + "{ type: object, properties: { size: { type: integer }, tags: { type: array, \ + items: { type: string } } }, default: { size: 20, tags: [new] } }", + json!({ "size": 20, "tags": ["new"] }), + ), + ( + "{ type: object, additionalProperties: true, default: { any: [1, { deep: true }] } }", + json!({ "any": [1, { "deep": true }] }), + ), + ] { + assert_eq!(published_field(node)["default"], default, "{node}"); + } +} + +#[test] +fn a_default_holds_the_defaults_of_the_fields_it_leaves_out() { + // A published default is the whole value a missing field takes, so + // the fields the written default leaves out are filled at every depth. + let schema = published( + " + args: + type: object + properties: + page: + type: object + properties: + size: { type: integer, default: 20 } + sort: + type: object + properties: + by: { type: string, default: date } + desc: { type: boolean } + tags: + type: array + items: + type: object + properties: + label: { type: string } + weight: { type: number, default: 1 } + view: + type: object + properties: + mode: { type: string } + default: { mode: list } + default: { sort: {}, tags: [{ label: new }] } + ", + ); + assert_eq!( + schema["properties"]["page"]["default"], + json!({ + "size": 20, + "sort": { "by": "date" }, + "tags": [{ "label": "new", "weight": 1 }], + "view": { "mode": "list" }, + }) + ); +} + +#[test] +fn a_whole_number_default_of_an_integer_field_is_published_as_an_integer() { + for (node, text) in [ + ( + "{ type: integer, default: 5.0 }", + r#"{"default":5,"type":"integer"}"#, + ), + ( + "{ type: integer, default: 1e3 }", + r#"{"default":1000,"type":"integer"}"#, + ), + // A `number` field keeps its default as written. + ( + "{ type: number, default: 5 }", + r#"{"default":5,"type":"number"}"#, + ), + ( + "{ type: number, default: 5.0 }", + r#"{"default":5.0,"type":"number"}"#, + ), + ] { + assert_eq!(published_field(node).to_string(), text, "{node}"); + } +} + +#[test] +fn a_null_default_declares_no_default() { + for written in ["null", "~", ""] { + let node = format!("{{ type: integer, default: {written} }}"); + assert_eq!( + published_field(&node), + json!({ "type": "integer" }), + "{node}" + ); + } +} + +#[test] +fn the_published_json_is_sorted_and_says_whether_each_object_is_closed() { + let prompt = parse_ok(&block_prompt( + " + args: + type: object + description: What a search takes + required: [query] + properties: + query: { type: string, description: What to search for } + limit: { type: integer, default: 3 } + depth: { type: string, enum: [quick, full] } + focus: + type: array + items: { type: string } + page: + type: object + properties: + size: { type: integer, default: 20 } + filters: + type: object + additionalProperties: true + ", + )); + let json = + serde_json::to_string(prompt.frontmatter().args().as_json()).expect("a schema serializes"); + // Keys are in name order at every depth. A closed object says + // `"additionalProperties":false` and an open one `true`, every object + // lists its `properties`, and `required` is on the root alone, the one + // object that requires a field. + assert_eq!( + json, + concat!( + r#"{"additionalProperties":false,"description":"What a search takes","properties":{"#, + r#""depth":{"enum":["quick","full"],"type":"string"},"#, + r#""filters":{"additionalProperties":true,"properties":{},"type":"object"},"#, + r#""focus":{"items":{"type":"string"},"type":"array"},"#, + r#""limit":{"default":3,"type":"integer"},"#, + r#""page":{"additionalProperties":false,"properties":"#, + r#"{"size":{"default":20,"type":"integer"}},"type":"object"},"#, + r#""query":{"description":"What to search for","type":"string"}},"#, + r#""required":["query"],"type":"object"}"#, + ) + ); +} + +#[test] +fn a_prompt_with_no_args_key_declares_one_optional_string_named_text() { + let prompt = parse_ok(&prompt("", &[])); + let json = + serde_json::to_string(prompt.frontmatter().args().as_json()).expect("a schema serializes"); + assert_eq!( + json, + concat!( + r#"{"additionalProperties":false,"properties":{"text":"#, + r#"{"description":"Freeform text for this prompt","type":"string"}},"type":"object"}"#, + ) + ); + assert_eq!(TEXT_FIELD, "text"); +} + +#[test] +fn the_default_declaration_equals_a_written_copy_of_it() { + let implied = parse_ok(&prompt("", &[])); + let implied = implied.frontmatter().args(); + assert_eq!(*implied, ArgsSchema::default()); + + for front in [ + "args: { type: object, properties: { text: { type: string, description: Freeform text \ + for this prompt } } }", + // The same declaration with its keywords in another order and the + // two keywords a closed object with no required field may leave + // out. + "args: { additionalProperties: false, required: [], properties: { text: { description: \ + Freeform text for this prompt, type: string } }, type: object }", + ] { + let written = parse_ok(&prompt(front, &[])); + assert_eq!(written.frontmatter().args(), implied, "{front}"); + } + + let other = parse_ok(&prompt( + "args: { type: object, properties: { text: { type: string } } }", + &[], + )); + assert_ne!( + other.frontmatter().args(), + implied, + "a copy with no description is another declaration" + ); +} + +#[test] +fn an_unknown_keyword_is_refused_at_its_own_line_and_column() { + let error = parse_err(&block_prompt( + " + args: + type: object + properties: + query: + tipe: string + ", + )); + let message = "invalid frontmatter: args.properties.query: unknown keyword \"tipe\"; the \ + keywords are `type`, `properties`, `required`, `additionalProperties`, \ + `items`, `enum`, `description`, and `default` at line 8 column 7"; + assert_eq!(error.code, ErrorCode::FrontmatterInvalid); + assert_eq!(error.message, message); + // The YAML parser counts lines from the first line after the opening + // `---`, so the line 8 it reports is file line 9. + assert_eq!(location(&error), Some(("prompt", 9, 7, 9, 7))); + assert_eq!(error.facts, BTreeMap::new()); + assert_eq!( + error.prose, + format!( + "prompt:9: {message}. {}", + ErrorCode::FrontmatterInvalid.prose() + ) + ); +} + +#[test] +fn a_json_schema_keyword_outside_the_subset_is_refused_at_every_depth() { + for keyword in [ + "title", "minimum", "pattern", "format", "oneOf", "$ref", "const", "nullable", "Type", + ] { + assert_refused( + &format!("args:\n type: object\n {keyword}: 1\n"), + &format!("args: unknown keyword \"{keyword}\"; {KEYWORDS} at line 6 column 3"), + (7, 3), + ); + } + assert_refused( + " + args: + type: object + properties: + query: + type: string + minLength: 1 + ", + &format!( + "args.properties.query: unknown keyword \"minLength\"; {KEYWORDS} at line 9 column 7" + ), + (10, 7), + ); + assert_refused( + " + args: + type: object + properties: + focus: + type: array + items: + type: string + pattern: ^a + ", + &format!( + "args.properties.focus.items: unknown keyword \"pattern\"; {KEYWORDS} at line 11 \ + column 9" + ), + (12, 9), + ); +} + +#[test] +fn an_unknown_type_word_is_refused_listing_the_six_types() { + assert_refused( + " + args: + type: object + properties: + flag: + type: text + ", + &format!("args.properties.flag.type: unknown variant `text`, {TYPES} at line 8 column 13"), + (9, 13), + ); + // A type is one of the six words as written: another case, a short + // form, and `null` are not among them. + for word in ["String", "int", "float", "bool", "list", "any", "null"] { + assert_refused( + &one_field(&format!("{{ type: {word} }}")), + &format!( + "args.properties.field.type: unknown variant `{word}`, {TYPES} at line 7 column 20" + ), + (8, 20), + ); + } +} + +#[test] +fn a_type_that_is_not_one_word_is_refused_naming_what_a_type_is() { + // A type is one word, so a list of words and a map are each refused. + for (written, kind) in [("[string, integer]", "sequence"), ("{ a: 1 }", "map")] { + assert_refused( + &one_field(&format!("{{ type: {written} }}")), + &format!( + "args.properties.field.type: invalid type: {kind}, expected one type word: \ + `string`, `boolean`, `integer`, `number`, `array`, or `object` at line 7 \ + column 20" + ), + (8, 20), + ); + } +} + +#[test] +fn a_node_with_no_type_is_refused_where_the_node_opens() { + assert_refused( + " + args: + type: object + properties: + query: + description: What to search for + ", + &format!("args.properties.query: {NEEDS_TYPE} at line 8 column 7"), + (9, 7), + ); + assert_refused( + " + args: + properties: + query: { type: string } + ", + &format!("args: {NEEDS_TYPE} at line 5 column 3"), + (6, 3), + ); + assert_refused( + " + args: + type: object + properties: + focus: + type: array + items: + description: One area + ", + &format!("args.properties.focus.items: {NEEDS_TYPE} at line 10 column 9"), + (11, 9), + ); + assert_field_refused("{}", NEEDS_TYPE); +} + +#[test] +fn a_root_that_is_not_an_object_is_refused() { + for (kind, rest) in [ + ("string", ""), + ("boolean", ""), + ("integer", ""), + ("number", ""), + ("array", " items: { type: string }\n"), + ] { + assert_refused( + &format!("args:\n type: {kind}\n{rest}"), + &format!( + "args: the root of `args` must be `type: object`, and it is `{kind}` at line 5 \ + column 3" + ), + (6, 3), + ); + } +} + +#[test] +fn the_root_takes_no_default_and_no_enum() { + assert_refused( + " + args: + type: object + default: {} + ", + "args: the root of `args` takes no `default` at line 5 column 3", + (6, 3), + ); + assert_refused( + " + args: + type: object + enum: [a] + ", + "args: `enum` applies to a `string` or an `integer` node, and this node is `object` at \ + line 5 column 3", + (6, 3), + ); +} + +#[test] +fn a_list_item_takes_no_default() { + assert_refused( + " + args: + type: object + properties: + focus: + type: array + items: + type: string + default: all + ", + "args.properties.focus.items: a list item takes no `default` at line 10 column 9", + (11, 9), + ); +} + +#[test] +fn items_is_refused_on_a_node_that_is_not_an_array() { + assert_refused( + " + args: + type: object + properties: + query: + type: string + items: + type: string + ", + "args.properties.query: `items` applies to an `array` node, and this node is `string` at \ + line 8 column 7", + (9, 7), + ); + for kind in ["string", "boolean", "integer", "number", "object"] { + assert_field_refused( + &format!("{{ type: {kind}, items: {{ type: string }} }}"), + &format!("`items` applies to an `array` node, and this node is `{kind}`"), + ); + } +} + +#[test] +fn properties_required_and_additional_properties_are_refused_on_a_node_that_is_not_an_object() { + assert_refused( + " + args: + type: object + properties: + query: + type: string + properties: + words: { type: integer } + ", + "args.properties.query: `properties` applies to an `object` node, and this node is \ + `string` at line 8 column 7", + (9, 7), + ); + assert_refused( + " + args: + type: object + properties: + focus: + type: array + items: { type: string } + required: [first] + ", + "args.properties.focus: `required` applies to an `object` node, and this node is `array` \ + at line 8 column 7", + (9, 7), + ); + assert_refused( + " + args: + type: object + properties: + limit: + type: integer + additionalProperties: false + ", + "args.properties.limit: `additionalProperties` applies to an `object` node, and this \ + node is `integer` at line 8 column 7", + (9, 7), + ); + for (kind, word) in [ + ("string", "string"), + ("boolean", "boolean"), + ("integer", "integer"), + ("number", "number"), + ("array, items: { type: string }", "array"), + ] { + for (keyword, written) in [ + ("properties", "properties: {}"), + ("required", "required: []"), + ("additionalProperties", "additionalProperties: true"), + ] { + assert_field_refused( + &format!("{{ type: {kind}, {written} }}"), + &format!("`{keyword}` applies to an `object` node, and this node is `{word}`"), + ); + } + } +} + +#[test] +fn an_enum_is_refused_on_a_number_a_boolean_an_array_and_an_object() { + assert_refused( + " + args: + type: object + properties: + ratio: + type: number + enum: [0.5, 1] + ", + "args.properties.ratio: `enum` applies to a `string` or an `integer` node, and this node \ + is `number` at line 8 column 7", + (9, 7), + ); + for (kind, word) in [ + ("number", "number"), + ("boolean", "boolean"), + ("array, items: { type: string }", "array"), + ("object", "object"), + ] { + assert_field_refused( + &format!("{{ type: {kind}, enum: [a] }}"), + &format!( + "`enum` applies to a `string` or an `integer` node, and this node is `{word}`" + ), + ); + } +} + +#[test] +fn an_array_with_no_items_is_refused() { + assert_refused( + " + args: + type: object + properties: + focus: + type: array + description: The areas to cover + ", + "args.properties.focus: an `array` node needs `items` at line 8 column 7", + (9, 7), + ); +} + +#[test] +fn a_keyword_written_twice_is_refused_where_its_node_opens() { + assert_refused( + " + args: + type: object + properties: + query: + type: string + description: What to search for + type: integer + ", + "args.properties.query: keyword `type` is written twice at line 8 column 7", + (9, 7), + ); + for (keyword, node) in [ + ("type", "{ type: string, type: string }"), + ( + "description", + "{ type: string, description: a, description: b }", + ), + ("default", "{ type: string, default: a, default: b }"), + ("enum", "{ type: string, enum: [a], enum: [b] }"), + ( + "properties", + "{ type: object, properties: {}, properties: {} }", + ), + ("required", "{ type: object, required: [], required: [] }"), + ( + "additionalProperties", + "{ type: object, additionalProperties: true, additionalProperties: true }", + ), + ( + "items", + "{ type: array, items: { type: string }, items: { type: string } }", + ), + ] { + assert_field_refused(node, &format!("keyword `{keyword}` is written twice")); + } +} + +#[test] +fn required_names_only_declared_fields_and_each_one_once() { + assert_refused( + " + args: + type: object + properties: + query: { type: string } + required: [query, limit] + ", + "args: `required` names `limit`, which `properties` does not declare at line 5 column 3", + (6, 3), + ); + assert_refused( + " + args: + type: object + properties: + query: { type: string } + required: [query, query] + ", + "args: `required` names `query` twice at line 5 column 3", + (6, 3), + ); + assert_refused( + " + args: + type: object + required: [query] + ", + "args: `required` names `query`, which `properties` does not declare at line 5 column 3", + (6, 3), + ); + assert_field_refused( + "{ type: object, properties: { size: { type: integer } }, required: [sort] }", + "`required` names `sort`, which `properties` does not declare", + ); +} + +#[test] +fn an_enum_lists_at_least_one_value_and_each_value_once() { + assert_refused( + " + args: + type: object + properties: + depth: + type: string + enum: [quick, full, quick] + ", + "args.properties.depth: `enum` lists \"quick\" twice at line 8 column 7", + (9, 7), + ); + assert_field_refused("{ type: integer, enum: [1, 2, 1] }", "`enum` lists 1 twice"); + assert_field_refused("{ type: string, enum: [] }", "`enum` lists no value"); + assert_field_refused("{ type: integer, enum: [] }", "`enum` lists no value"); +} + +#[test] +fn an_enum_value_of_another_kind_than_its_node_is_refused() { + assert_refused( + " + args: + type: object + properties: + depth: + type: string + enum: [quick, 5] + ", + "args.properties.depth: the `enum` of a `string` node lists strings, and it lists an \ + integer at line 8 column 7", + (9, 7), + ); + for (value, kind) in [ + ("5", "an integer"), + ("1.5", "a number"), + ("true", "a boolean"), + ("~", "a null"), + ("[a]", "a sequence"), + ("{ a: 1 }", "a mapping"), + ] { + assert_field_refused( + &format!("{{ type: string, enum: [quick, {value}] }}"), + &format!("the `enum` of a `string` node lists strings, and it lists {kind}"), + ); + } + for (value, kind) in [ + ("two", "a string"), + ("1.5", "a number"), + ("true", "a boolean"), + ("~", "a null"), + ("[2]", "a sequence"), + ("{ a: 2 }", "a mapping"), + ] { + assert_field_refused( + &format!("{{ type: integer, enum: [1, {value}] }}"), + &format!("the `enum` of an `integer` node lists integers, and it lists {kind}"), + ); + } + // An integer past 64 bits is named by its value. + assert_field_refused( + "{ type: integer, enum: [1, 18446744073709551615] }", + "the `enum` of an `integer` node lists integers that fit in 64 bits, and it lists \ + 18446744073709551615", + ); +} + +#[test] +fn a_default_of_another_type_than_its_node_is_refused() { + assert_refused( + " + args: + type: object + properties: + limit: + type: integer + default: three + ", + "args.properties.limit: the default must be an integer, got string at line 8 column 7", + (9, 7), + ); + for (node, refusal) in [ + ( + "{ type: string, default: 42 }", + "the default must be a string, got integer", + ), + ( + "{ type: boolean, default: 'true' }", + "the default must be a boolean, got string", + ), + ( + "{ type: integer, default: 1.5 }", + "the default must be an integer, got number", + ), + ( + "{ type: number, default: [1] }", + "the default must be a number, got array", + ), + ( + "{ type: array, items: { type: string }, default: a }", + "the default must be an array, got string", + ), + ( + "{ type: object, default: [] }", + "the default must be an object, got array", + ), + ( + "{ type: integer, default: 18446744073709551615 }", + "the default must be an integer that fits in 64 bits, got 18446744073709551615", + ), + ] { + assert_field_refused(node, refusal); + } +} + +#[test] +fn a_default_outside_its_enum_is_refused() { + assert_refused( + " + args: + type: object + properties: + depth: + type: string + enum: [quick, full] + default: slow + ", + "args.properties.depth: the default must be one of \"quick\", \"full\", got \"slow\" at \ + line 8 column 7", + (9, 7), + ); + assert_field_refused( + "{ type: integer, enum: [1, 2], default: 3 }", + "the default must be one of 1, 2, got 3", + ); +} + +#[test] +fn an_object_or_list_default_is_checked_against_what_its_node_declares() { + // A value inside the default is named by its path from the default. + assert_refused( + " + args: + type: object + properties: + page: + type: object + properties: + limit: { type: integer } + default: { limit: many } + ", + "args.properties.page: the default's `limit` must be an integer, got string at line 8 \ + column 7", + (9, 7), + ); + for (node, refusal) in [ + ( + "{ type: object, properties: { size: { type: integer } }, default: { n: 1 } }", + "the default's `n` is not declared; the declared fields of this node are `size`", + ), + ( + "{ type: object, default: { n: 1 } }", + "the default's `n` is not declared; this node declares no fields", + ), + ( + "{ type: object, properties: { size: { type: integer } }, required: [size], \ + default: {} }", + "the default's `size` is required", + ), + ( + "{ type: object, properties: { size: { type: integer } }, required: [size], \ + default: { size: ~ } }", + "the default's `size` is required, got null", + ), + ( + "{ type: array, items: { type: string }, default: [a, 5] }", + "the default's `[1]` must be a string, got integer", + ), + ( + "{ type: object, properties: { tags: { type: array, items: { type: string } } }, \ + default: { tags: [a, ~] } }", + "the default's `tags[1]` must be a string, got null", + ), + ( + "{ type: object, properties: { sort: { type: object, properties: { by: { type: \ + string } } } }, default: { sort: { desc: true } } }", + "the default's `sort.desc` is not declared; the declared fields of `sort` are `by`", + ), + ] { + assert_field_refused(node, refusal); + } +} + +#[test] +fn a_default_holding_a_number_that_is_not_finite_or_a_tagged_value_is_refused() { + assert_refused( + " + args: + type: object + properties: + ratio: + type: number + default: .inf + ", + "args.properties.ratio: the default holds a number that is not finite at line 8 column 7", + (9, 7), + ); + assert_refused( + " + args: + type: object + properties: + token: + type: string + default: !secret hidden + ", + "args.properties.token: the default holds a tagged value at line 8 column 7", + (9, 7), + ); + for (node, refusal) in [ + ( + "{ type: number, default: -.inf }", + "the default holds a number that is not finite", + ), + ( + "{ type: number, default: .nan }", + "the default holds a number that is not finite", + ), + ( + "{ type: object, additionalProperties: true, default: { ratio: [.inf] } }", + "the default holds a number that is not finite", + ), + ( + "{ type: array, items: { type: number }, default: [1, !x 2] }", + "the default holds a tagged value", + ), + ( + "{ type: object, additionalProperties: true, default: { 1: a } }", + "the default holds a key that is not a string", + ), + ] { + assert_field_refused(node, refusal); + } +} + +#[test] +fn an_argument_name_outside_the_name_grammar_is_refused_where_properties_opens() { + // The refusal is placed where the `properties` map opens, at its first + // name, whichever name it is about. + assert_refused( + " + args: + type: object + properties: + query: { type: string } + 1bad: { type: string } + ", + "args.properties: invalid argument name `1bad`: expected [A-Za-z][A-Za-z0-9_-]{0,63} at \ + line 7 column 5", + (8, 5), + ); + let too_long = "x".repeat(65); + for (written, name) in [ + ("'has space'", "has space"), + ("a.b", "a.b"), + ("_a", "_a"), + ("''", ""), + ("caf\u{e9}", "caf\u{e9}"), + (too_long.as_str(), too_long.as_str()), + ] { + assert_refused( + &format!("args:\n type: object\n properties:\n {written}: {{ type: string }}\n"), + &format!( + "args.properties: invalid argument name `{name}`: expected \ + [A-Za-z][A-Za-z0-9_-]{{0,63}} at line 7 column 5" + ), + (8, 5), + ); + } +} + +#[test] +fn an_argument_name_may_be_a_lua_keyword_or_the_name_of_a_global() { + // A name is a field of the `args` object, so any name of the grammar + // is declared like any other, up to 64 characters. + let longest = "x".repeat(64); + let schema = published(&format!( + "args: {{ type: object, properties: {{ end: {{ type: boolean }}, store: {{ type: string \ + }}, args: {{ type: string }}, a_b-c9: {{ type: string }}, {longest}: {{ type: string }} \ + }} }}" + )); + let names: Vec<&str> = schema["properties"] + .as_object() + .expect("an object publishes its properties") + .keys() + .map(String::as_str) + .collect(); + assert_eq!(names, ["a_b-c9", "args", "end", "store", longest.as_str()]); +} + +#[test] +fn an_argument_declared_twice_is_refused() { + assert_refused( + " + args: + type: object + properties: + query: { type: string } + limit: { type: integer } + query: { type: integer } + ", + "args.properties: duplicate argument name `query`: contract map keys must be unique at \ + line 7 column 5", + (8, 5), + ); +} + +#[test] +fn an_args_value_that_is_a_scalar_or_a_sequence_is_refused() { + for (written, found) in [ + ("5", "integer `5`"), + ("text", "string \"text\""), + ("true", "boolean `true`"), + ("~", "unit value"), + ("[query, limit]", "sequence"), + ] { + assert_refused( + &format!("args: {written}"), + &format!( + "args: invalid type: {found}, expected a map of schema keywords at line 4 column 7" + ), + (5, 7), + ); + } + assert_refused( + " + args: + - query + - limit + ", + "args: invalid type: sequence, expected a map of schema keywords at line 5 column 3", + (6, 3), + ); +} + +#[test] +fn an_args_key_with_no_value_is_a_node_with_no_type() { + // Only a prompt with no `args:` key gets the default declaration. + assert_refused( + "args:", + &format!("args: {NEEDS_TYPE} at line 4 column 6"), + (5, 6), + ); +} + +#[test] +fn additional_properties_that_is_not_a_boolean_is_refused() { + assert_refused( + " + args: + type: object + additionalProperties: 5 + ", + "args.additionalProperties: invalid type: integer `5`, expected a boolean at line 6 \ + column 25", + (7, 25), + ); + // JSON Schema also takes a schema here, and the subset takes `true` or + // `false` alone. + for (written, found) in [ + ("{ type: string }", "map"), + ("yes", "string \"yes\""), + ("[true]", "sequence"), + ] { + assert_refused( + &format!("args:\n type: object\n additionalProperties: {written}\n"), + &format!( + "args.additionalProperties: invalid type: {found}, expected a boolean at line 6 \ + column 25" + ), + (7, 25), + ); + } +} + +#[test] +fn required_that_is_not_a_list_of_strings_is_refused() { + assert_refused( + " + args: + type: object + properties: + query: { type: string } + required: query + ", + "args.required: invalid type: string \"query\", expected a sequence at line 8 column 13", + (9, 13), + ); + assert_refused( + " + args: + type: object + properties: + query: { type: string } + required: + - query + - [limit] + ", + "args.required[1]: invalid type: sequence, expected a string at line 10 column 7", + (11, 7), + ); + assert_refused( + " + args: + type: object + properties: + query: { type: string } + required: { query: true } + ", + "args.required: invalid type: map, expected a sequence at line 8 column 13", + (9, 13), + ); +} + +#[test] +fn a_description_that_is_not_a_string_is_refused() { + assert_refused( + " + args: + type: object + properties: + query: + type: string + description: [What, to, search, for] + ", + "args.properties.query.description: invalid type: sequence, expected a string at line 9 \ + column 20", + (10, 20), + ); + assert_refused( + " + args: + type: object + description: + en: What a search takes + ", + "args.description: invalid type: map, expected a string at line 7 column 5", + (8, 5), + ); +} + +#[test] +fn a_node_or_a_keyword_value_of_the_wrong_yaml_kind_is_refused_at_the_value() { + assert_refused( + " + args: + type: object + properties: [query] + ", + "args.properties: invalid type: sequence, expected a map of argument name keys to \ + declarations at line 6 column 15", + (7, 15), + ); + assert_refused( + " + args: + type: object + properties: + query: string + ", + "args.properties.query: invalid type: string \"string\", expected a map of schema \ + keywords at line 7 column 12", + (8, 12), + ); + assert_refused( + " + args: + type: object + properties: + focus: + type: array + items: string + ", + "args.properties.focus.items: invalid type: string \"string\", expected a map of schema \ + keywords at line 9 column 14", + (10, 14), + ); + assert_refused( + " + args: + type: object + properties: + depth: + type: string + enum: quick + ", + "args.properties.depth.enum: invalid type: string \"quick\", expected a sequence at line \ + 9 column 13", + (10, 13), + ); +} diff --git a/crates/runvil-internal/prompt/src/contract/tests.rs b/crates/runvil-internal/prompt/src/contract/tests.rs index 074a2c024..6e0cf947a 100644 --- a/crates/runvil-internal/prompt/src/contract/tests.rs +++ b/crates/runvil-internal/prompt/src/contract/tests.rs @@ -5,21 +5,48 @@ use std::num::NonZeroU32; use runvil_types::error::{ErrorCode, ErrorFact}; use runvil_types::models::ModelRole; +use serde_json::json; -use super::{ArgType, ArgsDeclIter}; use crate::test_support::{assert_parse_error, parse_err, parse_ok, prompt}; use crate::tests::location; +#[path = "tests-args.rs"] +mod args; +#[path = "tests-args-check.rs"] +mod args_check; #[path = "tests-packs.rs"] mod packs; +/// Writes the prompt `t` whose frontmatter ends with the YAML block +/// `front`, minus the indentation every line of it shares, so a nested +/// declaration keeps its own indentation. +/// +/// The block's first line is file line 5, which the YAML parser counts as +/// line 4, and a column counts from the block's left edge. +fn block_prompt(front: &str) -> String { + let lines: Vec<&str> = front + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let shared = lines + .iter() + .map(|line| line.len() - line.trim_start().len()) + .min() + .unwrap_or(0); + let block: String = lines + .iter() + .flat_map(|line| [&line[shared..], "\n"]) + .collect(); + format!("---\nname: t\ndescription: d\nrunvil: 0\n{block}---\n\n## S\n\np\n") +} + #[test] fn the_full_contract_declaration_parses_and_round_trips() { let prompt = parse_ok(&prompt( " uses: [web, mcp] - args: { use_mcp: { type: boolean, default: true, \ - description: Search MCP-connected private sources } } + args: { type: object, properties: { use_mcp: { type: boolean, default: true, \ + description: Search MCP-connected private sources } } } models: { strong: { min_context: 200000, thinking: true }, \ fast: { thinking: false } } ", @@ -33,13 +60,13 @@ fn the_full_contract_declaration_parses_and_round_trips() { assert_eq!(packs[0].id().to_string(), "web"); assert_eq!(packs[1].id().to_string(), "mcp"); - let arg = fm.args().get("use_mcp").expect("the arg is declared"); - assert_eq!(arg.kind(), ArgType::Boolean); - assert!(!arg.is_optional()); - assert_eq!(arg.default(), Some(&serde_yaml_ng::Value::Bool(true))); assert_eq!( - arg.description(), - Some("Search MCP-connected private sources") + fm.args().as_json()["properties"]["use_mcp"], + json!({ + "type": "boolean", + "default": true, + "description": "Search MCP-connected private sources", + }) ); let strong = fm @@ -67,28 +94,12 @@ fn a_prompt_without_contract_keys_behaves_exactly_as_today() { } } -#[test] -fn an_omitted_args_key_yields_the_default_prose_declaration() { - // There are no freeform prompts: an absent `args:` key is the default - // declaration of one optional string field named `prose`. - let prompt = parse_ok(&prompt("", &[])); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 1); - let prose = args - .get("prose") - .expect("the default declaration names `prose`"); - assert_eq!(prose.kind(), ArgType::String); - assert!(prose.is_optional()); - assert_eq!(prose.default(), None); - assert_eq!(prose.description(), Some("Freeform input for this prompt")); -} - #[test] fn an_unknown_key_inside_a_contract_entry_is_rejected() { - // `deny_unknown_fields` must hold inside each new key's entries too: a - // typo'd field is an authoring error, not silently ignored. + // A misspelled key inside a contract key's entry is an authoring + // error, never a key the parse ignores. for front in [ - "args: { flag: { tipe: boolean } }", + "args: { type: object, properties: { flag: { tipe: boolean } } }", "models: { strong: { tier: 1 } }", ] { let error = parse_err(&prompt(front, &[])); @@ -96,106 +107,6 @@ fn an_unknown_key_inside_a_contract_entry_is_rejected() { } } -#[test] -fn an_arg_name_may_be_a_reserved_name_because_args_are_argv_fields() { - let front = - "args: { prose: { type: string }, store: { type: string }, end: { type: boolean } }"; - let prompt = parse_ok(&prompt(front, &[])); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 3); - assert!(args.get("store").is_some() && args.get("end").is_some()); -} - -#[test] -fn args_declarations_round_trip() { - let prompt = parse_ok(&prompt( - "args: { use_mcp: { type: boolean, default: true, description: Search private sources }, \ - limit: { type: integer, optional: true }, query: { type: string } }", - &[], - )); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 3); - let limit = args.get("limit").expect("declared"); - assert_eq!(limit.kind(), ArgType::Integer); - assert!(limit.is_optional()); - assert_eq!(limit.description(), None); - assert_eq!(args.get("query").expect("declared").kind(), ArgType::String); -} - -#[test] -fn an_arg_default_must_match_the_declared_type() { - for (kind, default, actual) in [ - ("boolean", "'true'", "string"), - ("integer", "1.5", "number"), - ("string", "42", "integer"), - ("number", "[1]", "sequence"), - ] { - let front = format!("args: {{ flag: {{ type: {kind}, default: {default} }} }}"); - assert_parse_error( - &prompt(&front, &[]), - ErrorCode::FrontmatterInvalid, - &format!( - "the default has type `{actual}`, which does not match the declared type `{kind}`" - ), - ); - } - // A matching default passes. - let prompt = parse_ok(&prompt( - "args: { limit: { type: integer, default: 3 } }", - &[], - )); - assert_eq!( - prompt - .frontmatter() - .args() - .get("limit") - .and_then(|a| a.default().cloned()), - Some(serde_yaml_ng::Value::Number(3.into())) - ); -} - -#[test] -fn an_arg_name_must_match_the_alias_grammar() { - let error = parse_err(&prompt("args: { '1bad': { type: string } }", &[])); - assert_eq!(error.code, ErrorCode::FrontmatterInvalid); -} - -#[test] -fn an_absent_args_key_marks_the_default_declaration() { - // No `args:` key: the implicit default declaration, whose interface - // prose wraps into `argv.prose`. - assert!( - parse_ok(&prompt("", &[])).frontmatter().args().is_default(), - "an absent args key yields the default declaration" - ); - - // An explicit `args:` key is a structured declaration, which never - // wraps. - let explicit = parse_ok(&prompt("args: { query: { type: string } }", &[])); - assert!(!explicit.frontmatter().args().is_default()); - - // Even an explicit declaration identical to the default shape is - // structured: declared is not defaulted. - let shaped = parse_ok(&prompt( - "args: { prose: { type: string, optional: true, \ - description: Freeform input for this prompt } }", - &[], - )); - let args = shaped.frontmatter().args(); - assert!(!args.is_default()); - assert_ne!( - *args, - super::ArgsDecl::default(), - "an explicit declaration never equals the implicit default" - ); -} - -#[test] -fn an_unknown_arg_type_is_rejected() { - let error = parse_err(&prompt("args: { flag: { type: text } }", &[])); - assert_eq!(error.code, ErrorCode::FrontmatterInvalid); -} - #[test] fn each_role_declared_with_both_requirements_reads_them_back() { for role in ModelRole::ALL { @@ -231,25 +142,6 @@ fn an_empty_role_declares_neither_requirement() { ); } -#[test] -fn args_iterate_in_name_order_through_a_named_iterator() { - let prompt = parse_ok(&prompt( - "args: { query: { type: string }, limit: { type: integer } }", - &[], - )); - let fm = prompt.frontmatter(); - - let args: ArgsDeclIter<'_> = fm.args().iter(); - assert_eq!(args.len(), 2); - let names: Vec<&str> = args.map(|(name, _)| name).collect(); - assert_eq!(names, ["limit", "query"]); - let mut kinds = Vec::new(); - for (_, decl) in fm.args() { - kinds.push(decl.kind()); - } - assert_eq!(kinds, [ArgType::Integer, ArgType::String]); -} - #[test] fn an_unknown_role_name_is_a_parse_error_that_lists_the_five_roles() { assert_parse_error( diff --git a/crates/runvil-internal/prompt/src/detail.rs b/crates/runvil-internal/prompt/src/detail.rs index 60661e7f1..b43d7ca97 100644 --- a/crates/runvil-internal/prompt/src/detail.rs +++ b/crates/runvil-internal/prompt/src/detail.rs @@ -10,6 +10,24 @@ use runvil_types::error::Error; use crate::{Prompt, Section}; +/// The name of the one argument a prompt with no `args:` key declares, +/// and the field a string given as a chain's arguments goes under. +pub const TEXT_FIELD: &str = crate::contract::TEXT_FIELD; + +/// Reads `text`, the arguments a caller passes a run of `prompt`, against +/// the prompt's `args` declaration, and returns the object the run holds: +/// the caller's fields, with the declared default of each field left out. +/// +/// # Errors +/// Returns an `ArgsInvalid` failure with no location when `text` is not +/// JSON, is JSON but no object, or holds a value the declaration refuses. +/// Its `Reason` fact is `not_json`, `not_an_object`, `required`, `type`, +/// `enum`, or `undeclared`, and for the last four its `Name` fact is the +/// path of the refused argument. +pub fn accept_args(prompt: &Prompt, text: &str) -> Result { + crate::contract::accept_args(prompt.frontmatter().args(), text) +} + /// Gives a parse failure the Engine raises about a parsed prompt the /// prompt's frontmatter `name` as its `Name` fact, its place in the /// prompt's `file`, and the prose a failure the parse returns has. diff --git a/crates/runvil-internal/prompt/src/frontmatter.rs b/crates/runvil-internal/prompt/src/frontmatter.rs index 28da47e4c..ec09691bb 100644 --- a/crates/runvil-internal/prompt/src/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/frontmatter.rs @@ -8,12 +8,12 @@ use runvil_types::models::ModelRole; use runvil_types::packs::PackRef; use crate::contract::RoleMap; -use crate::{ArgsDecl, Result, RoleRequirements, error}; +use crate::{ArgsSchema, Result, RoleRequirements, error}; -/// A declared input or output file in a prompt's frontmatter. +/// The file a prompt's frontmatter declares under `output:`. /// -/// The `path` is the file's name in the store, which the prompt reads or -/// writes. The `description` documents the file's purpose for the caller. +/// The `path` is the file's name in the store, which the prompt writes. +/// The `description` documents the file's purpose for the caller. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct FileDecl { @@ -62,9 +62,6 @@ pub struct Frontmatter { /// cap can reach execution. #[serde(default, deserialize_with = "max_tool_iterations")] max_tool_iterations: Option, - /// A file the prompt expects to find in the store when it starts. - #[serde(default)] - input: Option, /// A file the prompt will leave in the store when it finishes. #[serde(default)] output: Option, @@ -73,10 +70,10 @@ pub struct Frontmatter { /// section. #[serde(default)] uses: Vec, - /// The typed args declaration; an absent `args:` key yields the default - /// [`ArgsDecl`]. + /// The arguments the prompt declares; an absent `args:` key yields + /// the default [`ArgsSchema`]. #[serde(default)] - args: ArgsDecl, + args: ArgsSchema, /// The requirements declared per model role. #[serde(default)] models: RoleMap, @@ -145,14 +142,6 @@ impl Frontmatter { self.max_tool_iterations } - /// Returns the declared input file, when present. - /// - /// The prompt expects to find this file in the store when it starts. - #[must_use] - pub fn input(&self) -> Option<&FileDecl> { - self.input.as_ref() - } - /// Returns the declared output file, when present. /// /// The prompt leaves this file in the store when it finishes. @@ -170,11 +159,12 @@ impl Frontmatter { &self.uses } - /// Returns the typed args declaration. + /// Returns the arguments the prompt declares. /// - /// A prompt that omits the `args:` key yields the default [`ArgsDecl`]. + /// A prompt that omits the `args:` key yields the default + /// [`ArgsSchema`]. #[must_use] - pub fn args(&self) -> &ArgsDecl { + pub fn args(&self) -> &ArgsSchema { &self.args } diff --git a/crates/runvil-internal/prompt/src/lib.rs b/crates/runvil-internal/prompt/src/lib.rs index c5426d8c5..9243d5cd9 100644 --- a/crates/runvil-internal/prompt/src/lib.rs +++ b/crates/runvil-internal/prompt/src/lib.rs @@ -21,6 +21,11 @@ //! a loaded [`Prompt`] is ready to run: a syntax error surfaces here, never //! partway through a run. //! +//! The frontmatter's `args` key declares the one JSON object a caller +//! passes the prompt, as a subset of JSON Schema ([`ArgsSchema`]), and +//! [`detail::accept_args`] checks a caller's arguments against it for the +//! executor. +//! //! ## Invariants //! //! - May depend on: `runvil-types` and `runvil-lua`. @@ -43,7 +48,7 @@ mod sections; pub mod test_support; pub use body_text::BodyText; -pub use contract::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter, RoleRequirements}; +pub use contract::{ArgsSchema, RoleRequirements}; use error::Result; pub use finder::PromptFinder; pub use frontmatter::{FileDecl, Frontmatter, MAX_TOOL_ITERATIONS}; diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 874ee0a87..d0b4a5861 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -21,11 +21,29 @@ impl Prompt { /// /// - `FrontmatterInvalid` when the `---` delimiters are missing, the /// frontmatter fails to decode, a `uses:` entry is neither a Pack id - /// nor vendor/name, two entries share a Pack id, or an entry names a - /// Pack id the Engine reserves. A refused `uses:` list has the - /// `Reason` fact `pack_duplicate` or `pack_reserved` and the Pack id - /// as its `Name` fact, and is located on the `uses:` line when one - /// line declares the key. + /// nor vendor/name, two entries share a Pack id, an entry names a + /// Pack id the Engine reserves, or the `args:` declaration is outside + /// its subset of JSON Schema. A refused `uses:` list has the `Reason` + /// fact `pack_duplicate` or `pack_reserved` and the Pack id as its + /// `Name` fact, and is located on the `uses:` line when one line + /// declares the key. An `args:` declaration is refused when a node + /// holds a keyword other than `type`, `properties`, `required`, + /// `additionalProperties`, `items`, `enum`, `description`, and + /// `default`, or holds one twice; a node has no `type`, or one other + /// than `string`, `boolean`, `integer`, `number`, `array`, and + /// `object`; the root is not an `object`; an `array` has no `items`; + /// `items` is on a node that is not an `array`, `enum` on one that is + /// neither a `string` nor an `integer`, or `properties`, `required`, + /// or `additionalProperties` on one that is not an `object`; the root + /// or a list item has a `default`; a `default` has no JSON form or + /// does not match its node; an `enum` is empty, repeats a value, or + /// lists a value of another kind than its node; `required` names a + /// field that `properties` does not declare, or names one twice; or + /// an argument name is not `[A-Za-z][A-Za-z0-9_-]{0,63}` or is + /// declared twice. The message of a refused declaration names the + /// YAML path of the node or keyword, such as `args.properties.query`, + /// and the failure is located at the line and column the YAML parser + /// reports for it. /// - `StructureInvalid` when a heading skips a level (such as an H4 /// directly under an H2, or a first heading deeper than a later /// one), a heading is empty, or two sibling sections have the same diff --git a/crates/runvil-internal/prompt/src/tests/frontmatter.rs b/crates/runvil-internal/prompt/src/tests/frontmatter.rs index 54af7f8f5..390ed1bd0 100644 --- a/crates/runvil-internal/prompt/src/tests/frontmatter.rs +++ b/crates/runvil-internal/prompt/src/tests/frontmatter.rs @@ -1,5 +1,5 @@ //! Frontmatter tests: required keys and delimiters, the tool-loop cap, -//! the `runvil:` key, declared files, and the line of each key. +//! the `runvil:` key, the declared output file, and the line of each key. use std::collections::BTreeMap; use std::num::NonZeroU32; @@ -96,27 +96,19 @@ fn frontmatter_exposes_runvil_field() { } #[test] -fn frontmatter_parses_input_and_output() { +fn frontmatter_parses_output() { let prompt = parse_ok(&prompt( - " - input: { path: paper.md, description: The input paper } - output: { path: report.md, description: The output report } - ", + "output: { path: report.md, description: The output report }", &[], )); - let fm = prompt.frontmatter(); - let input = fm.input().expect("input declared"); - assert_eq!(input.path(), "paper.md"); - assert_eq!(input.description(), "The input paper"); - let output = fm.output().expect("output declared"); + let output = prompt.frontmatter().output().expect("output declared"); assert_eq!(output.path(), "report.md"); assert_eq!(output.description(), "The output report"); } #[test] -fn frontmatter_without_input_output_still_parses() { +fn frontmatter_without_output_still_parses() { let prompt = parse_ok(&prompt("", &[])); - assert!(prompt.frontmatter().input().is_none()); assert!(prompt.frontmatter().output().is_none()); } @@ -140,7 +132,7 @@ fn a_key_line_is_the_one_column_0_line_that_declares_the_key() { "'runvil' \t: 0\n", // 6: single-quoted, spaces and a tab "# output: x\n", // 7: a comment "args:\n", // 8: the colon ends the line - "input:x\n", // 9: no space after the colon + "output:x\n", // 9: no space after the colon "x-team.owner: a: b\n", // 10: any key, up to the first such colon "models: {}\n", // 11: declared twice "models: {}\n", // 12: declared twice diff --git a/crates/runvil-internal/runtime/benches/models_loop.rs b/crates/runvil-internal/runtime/benches/models_loop.rs index 7a0da7795..39ac533b6 100644 --- a/crates/runvil-internal/runtime/benches/models_loop.rs +++ b/crates/runvil-internal/runtime/benches/models_loop.rs @@ -81,7 +81,7 @@ fn run_once(prompt: &Prompt, chat: &ScriptedChat, context: u32) -> Result, + /// The failure the run's first step fails with when the caller's + /// arguments are not a JSON object or do not match the prompt's + /// `args` declaration, or `None` when they match. + args_refusal: Option, /// The run's `started_at` rendered as RFC 3339, stamped into every /// section's `sys.when`. when: Arc, @@ -107,8 +111,8 @@ struct SharedState { /// reporting handles, so it advances the same turn counter. /// The two sanctioned forks: [`with_task`](Self::with_task) giving a /// spawned chain its own task emitter and turn counter, and -/// [`with_args`](Self::with_args) passing a `call` or task-spawning arm's -/// input override into its contained chain. +/// [`with_args`](Self::with_args) giving the chain of a `call` or a +/// `tasks.spawn` the arguments it was passed. #[derive(Clone)] pub(crate) struct ChainState { /// The run-wide state every chain of the run shares. @@ -119,11 +123,10 @@ pub(crate) struct ChainState { /// shared tool-dispatch body) take the emitter too, so their reports /// land in the buffer in order with the scheduler's own. reporting: ReportingHandles, - /// The run's argument string for `{{ args }}` substitution. - args: Arc, - /// The chain's `argv`, as `derive_argv` derives it from `args`: every - /// section the chain enters reads it, and nothing writes it. - argv: Option>, + /// The chain's arguments, a JSON object: the `args` global of every + /// section the chain enters and the `{{ args }}` substitution source. + /// Nothing writes it. + args: Arc, } impl ChainState { @@ -136,6 +139,11 @@ impl ChainState { /// `params` lacks; the nonce derives from `params`'s seed and `when` /// renders `params`'s `started_at`, so two states over the same inputs /// agree on both. + /// + /// `args` is the caller's argument text. The state holds the object + /// the prompt's `args` declaration accepts it as, or, when the + /// declaration refuses it, the refusal the run's first step fails + /// with beside an empty object that no section reads. #[must_use] pub(super) fn root(prompt: Arc, args: &str, params: RunParams) -> Self { let RunParams { @@ -163,7 +171,13 @@ impl ChainState { TaskId::from(ChainId::root()), report_debug, )); - let derived_argv = derive_argv(&prompt, args).map(Arc::from); + let (args, args_refusal) = match crate::prompt::detail::accept_args(&prompt, args) { + Ok(args) => (args, None), + Err(refusal) => ( + serde_json::Value::Object(serde_json::Map::new()), + Some(refusal), + ), + }; Self { shared: Arc::new(SharedState { prompt, @@ -175,6 +189,7 @@ impl ChainState { tool_set, model_set, refusal, + args_refusal, when: Arc::from(started_at.to_rfc3339()), ui: ui.map(Arc::new), preludes: preludes.into(), @@ -185,8 +200,7 @@ impl ChainState { emitter, turns: Arc::new(AtomicU32::new(0)), }, - args: Arc::from(args), - argv: derived_argv, + args: Arc::new(args), } } @@ -240,16 +254,11 @@ impl ChainState { &self.shared.vfs } - /// The run's argument string. - pub(super) fn args(&self) -> &Arc { + /// The chain's arguments, a JSON object. + pub(super) fn args(&self) -> &Arc { &self.args } - /// The chain's `argv`, as `derive_argv` derives it. - pub(super) fn argv(&self) -> Option<&Arc> { - self.argv.as_ref() - } - /// The run's resource limits. pub(super) fn limits(&self) -> RunLimits { self.shared.limits @@ -307,6 +316,12 @@ impl ChainState { self.shared.refusal.as_ref() } + /// The failure the run's first step fails with, or `None` when the + /// prompt's `args` declaration accepts the caller's arguments. + pub(super) fn args_refusal(&self) -> Option<&Error> { + self.shared.args_refusal.as_ref() + } + /// The resolved tool-loop cap per `models.loop` call: the frontmatter's /// `max_tool_iterations` over the limits default, held to the limits /// ceiling. @@ -327,16 +342,22 @@ impl ChainState { crate::prompt::detail::sections(&self.shared.prompt).len() } - /// The context a contained chain runs under: `args` in place of the - /// run's own, because a `call`, a `tasks.spawn`, or a `tasks.fanout` - /// arm that was given an input overrides the run's args for the chain - - /// and `argv` re-derives from the chain's args, so the chain sees the - /// `argv` of what it was passed. + /// The context a contained chain runs under when a `call` or a + /// `tasks.spawn` passed it arguments: `args` in place of the caller's. + /// An object is the chain's arguments as given, and a string is the + /// one field `text` of them. The prompt's `args` declaration describes + /// what a caller passes the run, so nothing checks a chain's own + /// arguments against it. #[must_use] - pub(super) fn with_args(&self, args: &str) -> Self { + pub(super) fn with_args(&self, args: serde_json::Value) -> Self { + let args = match args { + serde_json::Value::String(text) => { + serde_json::json!({ crate::prompt::detail::TEXT_FIELD: text }) + } + object => object, + }; let mut ctx = self.clone(); - ctx.argv = derive_argv(&self.shared.prompt, args).map(Arc::from); - ctx.args = Arc::from(args); + ctx.args = Arc::new(args); ctx } @@ -371,7 +392,6 @@ impl ChainState { ) -> SectionVmSetup<'a> { SectionVmSetup { args: &self.args, - argv: self.argv.as_deref(), sys, access, scope, @@ -412,7 +432,6 @@ impl fmt::Debug for ChainState { .field("shared", &self.shared) .field("reporting", &self.reporting) .field("args", &self.args) - .field("argv", &self.argv) .finish() } } @@ -432,6 +451,7 @@ impl fmt::Debug for SharedState { .field("tool_set", &self.tool_set) .field("model_set", &self.model_set) .field("refusal", &self.refusal) + .field("args_refusal", &self.args_refusal) .field("when", &self.when) .field("ui", &self.ui) .field( @@ -463,22 +483,6 @@ fn run_tool_set( ) } -/// Derives a section's `argv` from the args string under the prompt's -/// declaration: a default-declared prompt wraps the interface prose into -/// the default shape (`argv.prose`, with the empty string present, not -/// absent); a structured declaration parses the string as JSON, and a parse -/// failure or a JSON `null` reads as nil (`if argv then` is the malformed -/// check). The executor never hard-errors on shape. -fn derive_argv(prompt: &Prompt, args: &str) -> Option { - if prompt.frontmatter().args().is_default() { - return Some(serde_json::json!({ "prose": args })); - } - match serde_json::from_str(args) { - Ok(serde_json::Value::Null) | Err(_) => None, - Ok(value) => Some(value), - } -} - #[cfg(test)] mod tests { //! Tests for `ChainState` construction, forking, task sequence seeding, @@ -514,7 +518,7 @@ mod tests { /// The state a run of `prompt` assembles from `params`, as `Run::new` /// assembles it. fn state_over(prompt: &Prompt, params: RunParams) -> ChainState { - ChainState::root(Arc::new(prompt.clone()), "", params) + ChainState::root(Arc::new(prompt.clone()), "{}", params) } #[test] @@ -565,27 +569,53 @@ mod tests { } #[test] - fn vm_setup_hands_the_vm_the_chains_argv() { + fn root_holds_the_object_the_declaration_accepts() { let prompt = test_prompt(); - let ctx = test_state(&prompt).with_args("{\"query\":\"papers\"}"); + let params = RunParams::new(TEST_SEED, runvil_types::timestamp::Timestamp::UNIX_EPOCH); + let ctx = ChainState::root(Arc::new(prompt), r#"{"text":"papers"}"#, params); + assert_eq!(**ctx.args(), json!({ "text": "papers" })); + assert_eq!(ctx.args_refusal(), None, "the declaration accepts them"); + } + + #[test] + fn root_keeps_the_refusal_of_arguments_the_declaration_refuses() { + let prompt = test_prompt(); + let params = RunParams::new(TEST_SEED, runvil_types::timestamp::Timestamp::UNIX_EPOCH); + let ctx = ChainState::root(Arc::new(prompt), r#"{"query":"papers"}"#, params); + let refusal = ctx.args_refusal().expect("the declaration refuses them"); + assert_eq!(refusal.code, runvil_types::error::ErrorCode::ArgsInvalid); + assert_eq!(refusal.at, None, "the caller's gap points at no line"); + assert_eq!(**ctx.args(), json!({}), "the state holds an empty object"); + } + + #[test] + fn vm_setup_hands_the_vm_the_chains_args() { + let prompt = test_prompt(); + let ctx = test_state(&prompt).with_args(json!({ "query": "papers" })); let sys = json!({}); let (access, scope) = fresh_access(); let selection = Arc::new(Mutex::new(None)); let setup = ctx.vm_setup(&sys, VmSeed::default(), &access, &scope, &selection, "Only"); assert_eq!( - setup.argv, - Some(&json!({ "prose": "{\"query\":\"papers\"}" })), - "the setup holds the argv the chain's args derive" + setup.args, + &json!({ "query": "papers" }), + "the setup holds the args the chain was passed" ); } + #[test] + fn a_string_passed_as_a_chains_args_is_their_one_text_field() { + let ctx = test_state(&test_prompt()).with_args(json!("chain text")); + assert_eq!(**ctx.args(), json!({ "text": "chain text" })); + } + #[test] fn forks_swap_only_their_own_fields() { let ctx = test_state(&test_prompt()); - let chain = ctx.with_args("chain-args"); - assert_eq!(&**chain.args(), "chain-args"); + let chain = ctx.with_args(json!({ "n": 1 })); + assert_eq!(**chain.args(), json!({ "n": 1 })); assert!(Arc::ptr_eq(&ctx.shared, &chain.shared)); - assert_eq!(&**ctx.args(), ""); + assert_eq!(**ctx.args(), json!({})); let turns = Arc::new(AtomicU32::new(7)); let task: TaskId = "0.4".parse().expect("a task id parses"); diff --git a/crates/runvil-internal/runtime/src/execute/requirements-tests.rs b/crates/runvil-internal/runtime/src/execute/requirements-tests.rs index da264f19f..060acb476 100644 --- a/crates/runvil-internal/runtime/src/execute/requirements-tests.rs +++ b/crates/runvil-internal/runtime/src/execute/requirements-tests.rs @@ -83,7 +83,7 @@ fn unavailable(pack: &str, reason: &str) -> (PackId, RosterEntry) { /// `None` when the run passes its start checks and completes. #[track_caller] fn refusal(prompt: Prompt, ctx: RunParams) -> Option { - let mut run = Run::new(Arc::new(prompt), "", ctx); + let mut run = Run::new(Arc::new(prompt), "{}", ctx); let Step::Done { result, events, .. } = run.step() else { panic!("the fixture run is done at its first step"); }; diff --git a/crates/runvil-internal/runtime/src/execute/run.rs b/crates/runvil-internal/runtime/src/execute/run.rs index a09a44fbd..ad766755c 100644 --- a/crates/runvil-internal/runtime/src/execute/run.rs +++ b/crates/runvil-internal/runtime/src/execute/run.rs @@ -122,7 +122,8 @@ impl fmt::Debug for Run { impl Run { /// Builds a run of `prompt` with the arguments `args` and the params - /// `params`. + /// `params`. `args` is the text of one JSON object, such as `{}` for + /// a run with nothing to pass. /// /// Building a run fills its five model roles from the params' role /// map, as [`RunParams::with_models`](super::RunParams::with_models) @@ -130,15 +131,18 @@ impl Run { /// holds whatever catalog and roster the params' builders set. /// /// Building a run never fails. The first `step` checks, in order, - /// that the prompt declares a supported `runvil:` version, that the - /// params' filesystem declares a store whose backend passes its - /// probe, and that the params meet what the prompt requires: each + /// that the prompt declares a supported `runvil:` version, that + /// `args` is a JSON object the prompt's `args` declaration accepts, + /// that the params' filesystem declares a store whose backend passes + /// its probe, and that the params meet what the prompt requires: each /// Pack the prompt declares is on the roster and can serve (a run given /// no roster skips this check), each declared role's requirements hold /// for the model that fills it, a non-empty role map has a `default` /// entry, and no tool of the catalog and no Pack of the roster has a /// Pack id the Engine reserves, such as `adhoc`. When a check fails, /// that step returns `Done` with the failure and no events. The + /// arguments failure has the code `ErrorCode::ArgsInvalid`, and its + /// message names the first argument that does not match. The /// requirements failure has the code `ErrorCode::RequirementsUnmet`, /// and its message names every gap. When the run's cancel flag is set /// as a check fails, the run ends with the cancel instead. diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs b/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs index bd12c2c44..78d923fdb 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs @@ -55,7 +55,7 @@ fn bound_sections(sections: &str, cancel: &CancelHandle) -> Run { .with_tools(catalog) .with_models(BTreeMap::from([(ModelRole::Default, model)])) .with_cancel(cancel.clone()); - Run::new(Arc::new(prompt), "", ctx) + Run::new(Arc::new(prompt), "{}", ctx) } /// Steps `run` to its next effect and answers it `Dropped`, returning the diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs b/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs index fcfb324e4..2919e9689 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs @@ -21,7 +21,7 @@ pub(super) fn main_task() -> TaskId { fn run_of_source(source: &str) -> Run { let parsed = Prompt::parse(&|_: &str| Ok(source.to_owned()), "prompt") .expect("the inbox test prompt parses"); - Run::new(Arc::new(parsed), "", run_params()) + Run::new(Arc::new(parsed), "{}", run_params()) } /// A run of the Pack-free prompt whose sections are `sections`, the first diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs b/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs index 6a7ff3d1e..0e014f074 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs @@ -37,7 +37,7 @@ fn run_with_idle_cap(sections: &[(&str, &str)], cap: usize) -> Run { max_idle_tasks: NonZeroUsize::new(cap).expect("the cap is non-zero"), ..RunLimits::default() }; - Run::new(Arc::new(parsed), "", run_params().with_limits(limits)) + Run::new(Arc::new(parsed), "{}", run_params().with_limits(limits)) } /// Steps `run` to its end and returns its result and the last step's diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-send.rs b/crates/runvil-internal/runtime/src/execute/run/tests-send.rs index c2f279d06..fc2ac9717 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-send.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-send.rs @@ -108,7 +108,7 @@ fn a_non_owner_sending_by_id_string_raises_task_not_owned() { ( "Main", "local a = tasks.spawn('../A')\n\ - local b = tasks.spawn('../B', { input = a.task })\n\ + local b = tasks.spawn('../B', { args = { sibling = a.task } })\n\ local _, _, from_b = tasks.join_any({ b })\n\ local _, own = pcall(tasks.send, sys.taskid, 'x')\n\ local _, unknown = pcall(tasks.send, '0.9', 'x')\n\ @@ -118,7 +118,7 @@ fn a_non_owner_sending_by_id_string_raises_task_not_owned() { ("A", "return tasks.receive()"), ( "B", - "local ok, err = pcall(tasks.send, args, 'x')\n\ + "local ok, err = pcall(tasks.send, args.sibling, 'x')\n\ return tostring(ok) .. ' ' .. err.kind", ), ]); diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-start.rs b/crates/runvil-internal/runtime/src/execute/run/tests-start.rs index 3f91798ea..0e42e4f5b 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-start.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-start.rs @@ -22,7 +22,7 @@ fn a_run_that_cannot_start_reports_its_failure_on_the_first_step() { let source = "---\nname: t\ndescription: d\nrunvil: 7\n---\n\n## Only\n\ndone\n"; let prompt = Prompt::parse(&|_: &str| Ok(source.to_owned()), "prompt") .expect("the prompt parses whatever version it declares"); - let mut run = Run::new(Arc::new(prompt), "", run_params()); + let mut run = Run::new(Arc::new(prompt), "{}", run_params()); let Step::Done { result, events, .. } = run.step() else { panic!("a run that cannot start is done at once"); }; @@ -48,6 +48,30 @@ fn a_run_that_cannot_start_reports_its_failure_on_the_first_step() { assert_eq!(again.code, ErrorCode::Internal, "{again:?}"); } +#[test] +fn a_run_whose_arguments_are_refused_reports_its_failure_on_the_first_step() { + // The empty string is no JSON object: a caller with nothing to pass + // sends `{}`. + let mut run = Run::new(prompt_of("return 'never runs'"), "", run_params()); + let Step::Done { result, events, .. } = run.step() else { + panic!("a run whose arguments are refused is done at once"); + }; + assert!(events.is_empty(), "nothing ran, nothing reported"); + let error = result.expect_err("text that is no JSON object fails the run"); + assert_eq!( + (error.code, error.located_message()), + ( + ErrorCode::ArgsInvalid, + "the arguments are not valid JSON: EOF while parsing a value at line 1 column 0" + .to_owned() + ) + ); + assert!( + run.decided_for_test(), + "a run that never started is decided" + ); +} + #[test] fn an_answer_before_the_first_step_fails_the_run_with_no_run_events() { let mut run = run_of("return 'never runs'", run_params()); diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs b/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs index 941316e69..87a4cfa55 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs @@ -62,7 +62,7 @@ fn steered_run(front: &str, sections: &[(&str, &str)], cancel: &CancelHandle) -> .with_tools(catalog) .with_models(BTreeMap::from([(ModelRole::Default, model)])) .with_cancel(cancel.clone()); - Run::new(Arc::new(parsed), "", params) + Run::new(Arc::new(parsed), "{}", params) } /// A run of one section, `Only`, whose Lua is `body` on the `default` diff --git a/crates/runvil-internal/runtime/src/execute/run/tests.rs b/crates/runvil-internal/runtime/src/execute/run/tests.rs index d649d00af..e9ac7ef48 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests.rs @@ -226,7 +226,7 @@ fn prompt_of(body: &str) -> Arc { /// A run of [`prompt_of`]`(body)`. fn run_of(body: &str, params: RunParams) -> Run { - Run::new(prompt_of(body), "", params) + Run::new(prompt_of(body), "{}", params) } /// Whether `events` include the run's end boundary. @@ -373,7 +373,7 @@ fn a_child_cancel_handles_cancel_is_observed_by_the_instruction_hook() { prompt_of( "local n = 0\nwhile true do\n n = n + 1\n if n == 1 then log('looping') end\nend", ), - "", + "{}", run_params().with_cancel(child), |fresh| { state = Some(fresh.clone()); diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/admission-tests.rs b/crates/runvil-internal/runtime/src/execute/scheduler/admission-tests.rs index 03dbfa647..169afa68e 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/admission-tests.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/admission-tests.rs @@ -75,7 +75,7 @@ impl Tree { fn spawn(admission: &mut Admission, tree: &mut Tree, owner: ChainIndex) -> ChainIndex { let id = tree.task(owner, 1); let record = SpawnRecord { - input: None, + args: None, section: format!("spawned {}", id.index()), }; admission.enqueue_spawn(id, record); @@ -272,7 +272,7 @@ fn a_pass_that_fails_on_an_internal_error_leaves_admission_as_it_found_it() { admission.enqueue_spawn( call, SpawnRecord { - input: None, + args: None, section: "not a task".to_owned(), }, ); @@ -298,7 +298,7 @@ fn a_pass_that_fails_after_admitting_a_resumption_leaves_its_slots_given_back() admission.enqueue_spawn( stray, SpawnRecord { - input: None, + args: None, section: "not a task".to_owned(), }, ); diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/admission.rs b/crates/runvil-internal/runtime/src/execute/scheduler/admission.rs index dfd8bd3ab..04069c8d0 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/admission.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/admission.rs @@ -32,15 +32,16 @@ use super::tasks::TaskArena; use super::{ChainIndex, Scheduler}; /// A queued task chain's spawn record, held from spawn until its -/// admission: the input override and the spawning section the start event -/// reports. The `var` snapshot is not in the record: it moves out of the +/// admission: the chain's own arguments and the spawning section the start +/// event reports. The `var` snapshot is not in the record: it moves out of the /// yield into the chain's `var` slot at spawn, the start event reports a /// copy of it at admission, and the chain's first section entry seeds from /// the slot. #[derive(Debug)] pub(super) struct SpawnRecord { - /// The `opts.input` override, reported in the start event. - pub(super) input: Option, + /// The chain's own `args`, when `opts.args` gave them, reported in + /// the start event. + pub(super) args: Option, /// The spawner's section at spawn, reported as the start event's /// section: a task admitted after its spawner moved on still reports /// where it was spawned, not where the spawner is now. @@ -385,7 +386,7 @@ impl Scheduler { provenance, task, target, - input: record.input, + args: record.args, item, index, var, diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs index a7ee7e016..8f015a405 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs @@ -373,7 +373,7 @@ pub(super) struct Chain { pub(super) parked: Option, /// The chain's fork of the run state: the run's own for the root walk, /// and the caller's for a `call` child or the spawner's for a task, - /// through `with_args` when the call or spawn gave an input. A task's + /// through `with_args` when the call or spawn passed arguments. A task's /// fork also reports under its own task id with its own turn counter /// (`with_task`). pub(super) ctx: ChainState, @@ -487,9 +487,20 @@ impl Chain { } /// What `tasks.status` reports the chain blocked on, or `None` while - /// it runs or between blocks. + /// it runs or between blocks. A chain that holds the answer to what it + /// was parked on waits on nothing, though it has not run since, so it + /// reports `None` too, unless it waits for its slots. pub(super) fn blocked(&self) -> Option { - self.parked.as_ref().map(Parked::blocked) + let parked = self.parked.as_ref()?; + let answered = self + .entered + .as_ref() + .and_then(|entered| entered.running.as_ref()) + .is_some_and(|running| running.answer.is_some()); + if answered && !matches!(parked, Parked::Queued) { + return None; + } + Some(parked.blocked()) } /// Ends the chain's `join_any` wait when its set holds `task`, and @@ -639,7 +650,7 @@ impl Chain { .expect("the test prompt parses"); let ctx = ChainState::root( Arc::new(prompt), - "", + "{}", RunParams::new(TEST_SEED, TEST_STARTED_AT), ); Chain { diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs index bb2ceb405..d8ebef1c4 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs @@ -84,13 +84,13 @@ impl Scheduler { } Request::Call { target, - input, + args, var, from, - } => self.dispatch_call(id, &target, input.as_deref(), &var, from.as_ref()), + } => self.dispatch_call(id, &target, args, &var, from.as_ref()), Request::Spawn { target, - input, + args, item, index, var, @@ -98,7 +98,7 @@ impl Scheduler { } => self.dispatch_spawn( id, &target, - input.as_deref(), + args, TaskSeed { item, index }, var, from.as_ref(), @@ -138,11 +138,11 @@ impl Scheduler { &mut self, id: ChainIndex, target: &str, - input: Option<&str>, + args: Option, var: &serde_json::Value, from: Option<&LibraryKey>, ) -> Result<()> { - match self.prepare_call(id, target, input, var, from) { + match self.prepare_call(id, target, args, var, from) { Ok(child) => { self.ready.push_back(child); Ok(()) @@ -159,18 +159,17 @@ impl Scheduler { &mut self, id: ChainIndex, target: &str, - input: Option<&str>, + args: Option, var: &serde_json::Value, from: Option<&LibraryKey>, ) -> Result { let chain = &self.chains[id]; 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 caller's `argv` reaches the chain rather than re-deriving - // from the unchanged args. - let child_ctx = match input { - Some(input) => chain.ctx.with_args(input), + // A call that passes arguments gives the chain its own `args`; a + // call that passes none hands the chain the caller's context + // whole, so the chain reads the caller's `args`. + let child_ctx = match args { + Some(args) => chain.ctx.with_args(args), None => chain.ctx.clone(), }; // A call chain is a blocking child: it borrows the caller's access diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/drive.rs b/crates/runvil-internal/runtime/src/execute/scheduler/drive.rs index f3f7352af..86cf7234b 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/drive.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/drive.rs @@ -164,13 +164,16 @@ impl Scheduler { } } - /// Checks that the run can start: the version gate, then the declared - /// store, then what the prompt requires of the params. + /// Checks that the run can start: the version gate, then the caller's + /// arguments, then the declared store, then what the prompt requires + /// of the params. /// /// # Errors /// 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 + /// naming the prompt; an `ArgsInvalid` failure with no location when + /// the arguments are not a JSON object or do not match the prompt's + /// `args` declaration; 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 params lack a declared Pack or a model /// the prompt's roles require, or names a Pack id the Engine reserves, @@ -197,6 +200,11 @@ impl Scheduler { prompt.file(), )); } + // The arguments are the caller's to fix, so their refusal points + // at no line of the prompt. + if let Some(refusal) = self.ctx.args_refusal() { + return Err(refusal.clone()); + } // Every run and every store call needs a declared store: the probe // stats the store root through a throwaway capability, so a handle // with no declaration fails here rather than on the first store call, diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs b/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs index f0f43dbd3..3814efbc9 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs @@ -545,12 +545,12 @@ impl Scheduler { &mut self, id: ChainIndex, target: &str, - input: Option<&str>, + args: Option, seed: TaskSeed, var: serde_json::Value, from: Option<&LibraryKey>, ) -> Result<()> { - match self.prepare_spawn(id, target, input, seed, var, from) { + match self.prepare_spawn(id, target, args, seed, var, from) { Ok((task, child, record)) => { // The child waits for admission, queued behind the // spawner; the drain admits it when a slot frees up at @@ -578,7 +578,7 @@ impl Scheduler { &mut self, id: ChainIndex, target: &str, - input: Option<&str>, + args: Option, seed: TaskSeed, var: serde_json::Value, from: Option<&LibraryKey>, @@ -590,13 +590,18 @@ impl Scheduler { // 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, - // which `with_task` replaces below. - let child_ctx = match input { - Some(input) => chain.ctx.with_args(input), - None => chain.ctx.clone(), + // A spawn that passes arguments gives the chain its own `args`, + // as a `call` that passes them does; otherwise the chain takes + // the spawner's context whole, but for the turn counter, which + // `with_task` replaces below. The start event reports the chain's + // own `args`, as the chain reads them. + let (child_ctx, own_args) = match args { + Some(args) => { + let ctx = chain.ctx.with_args(args); + let own = serde_json::Value::clone(ctx.args()); + (ctx, Some(own)) + } + None => (chain.ctx.clone(), None), }; // A spawned chain's effective limit starts with its spawner's: // its tasks run within the spawner's share and every ancestor's. @@ -667,7 +672,7 @@ impl Scheduler { // The task waits for admission from here, and the spawn record // holds what the start event reports once the task first runs. let record = SpawnRecord { - input: input.map(str::to_owned), + args: own_args, section: spawner_section, }; Ok((task, child, record)) diff --git a/crates/runvil-internal/runtime/src/execute/section_context.rs b/crates/runvil-internal/runtime/src/execute/section_context.rs index 0117e308b..5d24b0628 100644 --- a/crates/runvil-internal/runtime/src/execute/section_context.rs +++ b/crates/runvil-internal/runtime/src/execute/section_context.rs @@ -395,7 +395,7 @@ fn limited_vm(ctx: &ChainState, name: &str) -> Result> { /// 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 chain's `args`, shared with the chain rather than copied, /// 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. @@ -404,13 +404,11 @@ fn prose_renderer( item: Option, ) -> impl Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static { - let raw_args = Arc::clone(ctx.args()); - let argv = ctx.argv().cloned(); + let args = Arc::clone(ctx.args()); 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(), + args: &args, item: item.as_ref(), var: &state.var, sys: &state.sys, diff --git a/crates/runvil-internal/runtime/src/execute/section_vm.rs b/crates/runvil-internal/runtime/src/execute/section_vm.rs index af85ee26a..ec2ff7ebb 100644 --- a/crates/runvil-internal/runtime/src/execute/section_vm.rs +++ b/crates/runvil-internal/runtime/src/execute/section_vm.rs @@ -50,11 +50,9 @@ pub(crate) struct VmSeed<'a> { /// Bundled so the frame constructor threads one linear set of borrows /// into [`setup_section_vm`] rather than a dozen parameters. pub(crate) struct SectionVmSetup<'a> { - /// The run's argument string, installed as the `args` global. - pub(crate) args: &'a str, - /// The chain's `argv` (`ChainState::argv`), installed read-only; - /// `None` installs nil. - pub(crate) argv: Option<&'a serde_json::Value>, + /// The chain's arguments (`ChainState::args`), a JSON object, + /// installed read-only as the `args` global. + pub(crate) args: &'a serde_json::Value, /// The `sys` JSON the frame constructor built for this section entry. pub(crate) sys: &'a serde_json::Value, /// The chain step's VFS access capability backing the Lua `store` @@ -150,7 +148,6 @@ where setup.sys, setup.access, setup.seed.var, - setup.argv, setup.selection, ) .map_err(|failure| failure.teardown(setup.emitter, setup.section_name))?; diff --git a/crates/runvil-internal/runtime/src/execute/tests.rs b/crates/runvil-internal/runtime/src/execute/tests.rs index 3580a796c..677d10898 100644 --- a/crates/runvil-internal/runtime/src/execute/tests.rs +++ b/crates/runvil-internal/runtime/src/execute/tests.rs @@ -204,4 +204,5 @@ mod tool_listings; mod tool_loop; mod tool_scoping; mod unified_pipeline; +mod views; mod waits; diff --git a/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs b/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs index cc184811c..c446f02db 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs @@ -60,7 +60,7 @@ fn call_runs_named_section_as_subroutine() { .prose( "Research", " - Research {{ args }}. + Research {{ args.text }}. ```lua local answer = models.infer(prose) @@ -69,18 +69,23 @@ fn call_runs_named_section_as_subroutine() { ```", ); let trace = Scenario::new(source.build()) - .args("topic") + .args(r#"{"text":"topic"}"#) .bound() .chat(&gateway) .run(); assert_eq!(trace.text(), "research-reply"); + assert_eq!( + gateway.requests()[0].messages[0].content(), + "Research topic.", + "a call that passes no args reads the run's" + ); } -/// The `with_args` fork scopes a call's input over its whole chain: -/// a no-input `call` nested inside the chain defaults to the chain's +/// The `with_args` fork scopes a call's args over its whole chain: a +/// `call` that passes none, nested inside the chain, reads the chain's /// args, not the run's. #[test] -fn nested_call_without_input_inherits_the_chains_args() { +fn nested_call_without_args_reads_the_chains_args() { let gateway = ScriptedChat::new(vec![text("inner-reply")]); let source = PromptSource::new() .section( @@ -94,14 +99,14 @@ fn nested_call_without_input_inherits_the_chains_args() { .prose( "Inner", " - Args: {{ args }} + Args: {{ args.text }} ```lua return models.infer(prose) ```", ); let trace = Scenario::new(source.build()) - .args("run-args") + .args(r#"{"text":"run-args"}"#) .bound() .chat(&gateway) .run(); @@ -112,7 +117,7 @@ fn nested_call_without_input_inherits_the_chains_args() { let text = body.messages_json().to_string(); assert!( text.contains("chain-args"), - "the nested no-input call substitutes the chain's args: {text}" + "the nested call that passes no args substitutes the chain's args: {text}" ); assert!( !text.contains("run-args"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs b/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs index 93158cf78..876dae6c2 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs @@ -75,9 +75,9 @@ fn unread_preamble_prose_stays_inert_and_explicit_infer_requires_a_model() { fn a_library_function_resolves_engine_globals_when_called() { let source = PromptSource::new() .section("Result", "require '/helpers'\nreturn read_args()") - .section("helpers", "function read_args() return args end"); + .section("helpers", "function read_args() return args.text end"); let trace = Scenario::new(source.build()) - .args("later canned value") + .args(r#"{"text":"later canned value"}"#) .bound() .run(); @@ -97,11 +97,14 @@ fn a_library_calls_engine_globals_at_load_time() { .section( "loader", " - store.write('loaded.txt', args) + store.write('loaded.txt', args.text) log('library loaded')", ) .build(); - let trace = Scenario::new(source).args("load-time args").bound().run(); + let trace = Scenario::new(source) + .args(r#"{"text":"load-time args"}"#) + .bound() + .run(); assert_eq!(trace.text(), "load-time args"); assert_eq!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs index 2530fb8ec..aec52cca8 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs @@ -232,7 +232,7 @@ fn a_required_helper_survives_prologue_model_and_epilog() { " ```lua require '/helpers' - var.question = decorate(args) + var.question = decorate(args.text) ``` Ask using {{ var.question }}. @@ -246,7 +246,7 @@ fn a_required_helper_survives_prologue_model_and_epilog() { "function decorate(value) return '<' .. value .. '>' end", ); let trace = Scenario::new(source.build()) - .args("input") + .args(r#"{"text":"input"}"#) .bound() .chat(&gateway) .run(); diff --git a/crates/runvil-internal/runtime/src/execute/tests/run_inputs.rs b/crates/runvil-internal/runtime/src/execute/tests/run_inputs.rs index 9b0ba5036..fe31a27f4 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/run_inputs.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/run_inputs.rs @@ -27,7 +27,7 @@ const DRAWS: u64 = 0x5EED; /// the run's result. fn run_seeded(md: &str, seed: RunSeed) -> Result { let ctx = RunParams::new(seed, STARTED_AT); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { + let (result, _) = drive(Run::new(Arc::new(parse(md)), "{}", ctx), |_, effect| { panic!("no effect is issued: {effect:?}") }); result @@ -112,7 +112,7 @@ fn sys_when_is_timestamp_to_rfc3339_for_any_started_at() { ```\n"; let whole_second = Timestamp::from_unix_millis(1_700_000_000_000); let ctx = RunParams::new(TEST_SEED, whole_second); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { + let (result, _) = drive(Run::new(Arc::new(parse(md)), "{}", ctx), |_, effect| { panic!("no effect is issued: {effect:?}") }); let when = text_of(result); @@ -159,7 +159,7 @@ fn the_ui_global_serves_the_snapshot_taken_at_run_start() { return ui().selected_model .. '/' .. tostring(ui().workspace_root)\n\ ```\n"; let ctx = test_params().with_ui(json!({ "selected_model": "m-1", "workspace_root": null })); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { + let (result, _) = drive(Run::new(Arc::new(parse(md)), "{}", ctx), |_, effect| { panic!("no effect is issued: {effect:?}") }); assert_eq!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/preamble/entry.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/preamble/entry.rs index 0012eecc8..0c8d3c804 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/preamble/entry.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/preamble/entry.rs @@ -2,7 +2,7 @@ //! default-model selection, its entry id and empty section name, the //! section boundaries it reports and the run boundaries around them, the //! `var`, concurrency limit, and task that last into the next section, -//! read-only `argv`, a Lua error that ends the run as `LuaRuntime` on its +//! read-only `args`, a Lua error that ends the run as `LuaRuntime` on its //! line, `var` read back at a return and at the fall-through, a failed //! substitution that keeps its own code, and a failing library. @@ -225,18 +225,18 @@ fn a_task_spawned_in_the_preamble_is_joined_in_the_next_section() { } #[test] -fn argv_refuses_a_write_in_the_preamble_and_in_a_later_section() { - // `argv` is read-only in every section, the preamble included: an +fn args_refuses_a_write_in_the_preamble_and_in_a_later_section() { + // `args` is read-only in every section, the preamble included: an // assignment raises the refusal, which `pcall` catches. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ ```lua\n\ - local ok, err = pcall(function() argv = 'rewritten' end)\n\ + local ok, err = pcall(function() args = 'rewritten' end)\n\ assert(not ok, 'the preamble write is refused')\n\ var.refusal = tostring(err)\n\ ```\n\n\ ## Result\n\n\ ```lua\n\ - local ok, err = pcall(function() argv = 'rewritten' end)\n\ + local ok, err = pcall(function() args = 'rewritten' end)\n\ assert(not ok, 'the section write is refused')\n\ return var.refusal .. '|' .. tostring(err)\n\ ```\n"; @@ -244,7 +244,7 @@ fn argv_refuses_a_write_in_the_preamble_and_in_a_later_section() { let out = trace.text(); assert_eq!( - out.matches("argv is read-only: assign to `var` or a section global instead") + out.matches("args is read-only: assign to `var` or a section global instead") .count(), 2, "both writes are refused with the rule: {out}" diff --git a/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs b/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs index 84bd4090f..6508ccdb6 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs @@ -54,7 +54,7 @@ fn the_driver_runs_a_literal_prompt_with_no_effect() { // events open and close with the run's boundaries. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n## Only\n\n```lua\nreturn 'hello'\n```\n"; - let run = Run::new(Arc::new(parse(md)), "", test_params()); + let run = Run::new(Arc::new(parse(md)), "{}", test_params()); let (result, events) = drive(run, |_, effect| panic!("no effect is issued: {effect:?}")); assert_eq!(text_of(result), "hello"); assert!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite.rs b/crates/runvil-internal/runtime/src/execute/tests/suite.rs index d7a870702..41b6e6b7a 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite.rs @@ -1,7 +1,7 @@ //! Offline prompt-fixture suite: parses fixtures written the way a prompt //! author writes them and drives their execution on the serial test driver //! without a live gateway. Split by domain - parsing contracts, section -//! execution, fanout, control flow, the args/argv and lazy-prose surfaces, +//! execution, fanout, control flow, the `args` and lazy-prose surfaces, //! an author's own `_G` metatable, `require`, the `markdown` global, and //! the role map and start checks - over the run-level //! [`Scenario`](crate::test_support::scenario::Scenario). Its diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-chains.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-chains.rs new file mode 100644 index 000000000..cba05c6d6 --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-chains.rs @@ -0,0 +1,383 @@ +//! The arguments a contained chain reads. `call(target, args)` and +//! `tasks.spawn(target, { args = ... })` give the chain a table of named +//! fields as its `args`, or a string as the one field `text`, and a chain +//! given none reads its caller's. A list and a value of any other type +//! are refused at the call site. Nothing checks a chain's own arguments +//! against the prompt's declaration or fills a default in them. A +//! `TaskStarted` event reports the args a spawn gave its chain, as the +//! chain reads them, and a fanout arm reads its caller's. + +use runvil_prompt::test_support::{PromptSource, prompt}; +use runvil_types::error::ErrorCode; +use runvil_types::event::Event; +use serde_json::{Value, json}; + +use super::{SEARCH, papers}; +use crate::test_support::scenario::Trace; +use crate::test_support::{at_line, refused}; + +/// What a refused `args` must be, the text each refusal starts with. +const ARGS_SHAPE: &str = "args must be a table of named fields or a string"; + +/// The body of a chain that returns its `args` as JSON. +const ECHO: &str = "return json.encode(args)"; + +/// The `args` object a chain reads when it reads its caller's, in a run +/// of [`papers`] over a prompt declaring [`SEARCH`]. +const RUN_ARGS: &str = r#"{"limit":3,"query":"papers"}"#; + +/// A prompt declaring [`SEARCH`] whose `Main` runs `main` and whose `Sub` +/// returns its `args` as JSON. +fn calling(main: &str) -> String { + prompt(SEARCH, &[("Main", main), ("Sub", ECHO)]) +} + +/// A prompt declaring [`SEARCH`] whose `Main` spawns `Child` with the +/// options `opts`, joins it, and returns its result, and whose `Child` +/// returns its `args` as JSON. +fn spawning(opts: &str) -> String { + let main = format!( + "local task = tasks.spawn('../Child', {opts}) + local _, ok, result = tasks.join_any({{ task }}) + assert(ok, 'the task returns its args') + return result" + ); + prompt(SEARCH, &[("Main", &main), ("Child", ECHO)]) +} + +/// The `args` of every `TaskStarted` event `trace` reports, in order. +fn started_args(trace: &Trace) -> Vec> { + trace + .events() + .iter() + .filter_map(|event| match event { + Event::TaskStarted { args, .. } => Some(args.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn a_call_passes_a_table_of_named_fields_as_the_chains_args() { + let source = calling( + "return call('../Sub', { topic = 'chain', pages = { 1, 2 }, opts = { deep = true } })", + ); + assert_eq!( + papers(source).run().text(), + r#"{"opts":{"deep":true},"pages":[1,2],"topic":"chain"}"#, + "the chain reads the table it was passed in place of the caller's args" + ); +} + +#[test] +fn a_call_passes_a_string_as_the_one_field_text() { + let source = calling("return call('../Sub', 'plain words') .. call('../Sub', '')"); + assert_eq!( + papers(source).run().text(), + r#"{"text":"plain words"}{"text":""}"# + ); +} + +#[test] +fn a_call_that_passes_args_itself_hands_the_chain_the_callers_arguments() { + let source = calling( + "return call('../Sub', args) .. call('../Sub', { outer = args, limit = args.limit })", + ); + assert_eq!( + papers(source).run().text(), + format!(r#"{RUN_ARGS}{{"limit":3,"outer":{RUN_ARGS}}}"#), + "a read-only args table converts as the object it holds, at any depth" + ); +} + +#[test] +fn a_call_with_an_empty_table_gives_the_chain_empty_args() { + let source = calling("return call('../Sub', {})"); + assert_eq!(papers(source).run().text(), "{}"); +} + +#[test] +fn a_call_that_passes_no_args_reads_the_callers_args() { + let source = calling("return call('../Sub') .. call('../Sub', nil)"); + assert_eq!( + papers(source).run().text(), + format!("{RUN_ARGS}{RUN_ARGS}"), + "the main walk's chain reads the run's args" + ); + let nested = prompt( + SEARCH, + &[ + ("Main", "return call('../Mid', { query = 'mid' })"), + ("Mid", "return call('../Sub')"), + ("Sub", ECHO), + ], + ); + assert_eq!( + papers(nested).run().text(), + r#"{"query":"mid"}"#, + "a chain inside a called chain reads that chain's args, not the run's" + ); +} + +#[test] +fn a_placeholder_in_a_chain_reads_the_args_that_chain_reads() { + let source = PromptSource::new() + .front(SEARCH) + .section( + "Main", + " + local task = tasks.spawn('../Told', { args = { query = 'task' } }) + local _, _, told = tasks.join_any({ task }) + return call('../Told', { query = 'chain' }) .. '|' .. told .. '|' .. call('../Told')", + ) + .prose( + "Told", + " + Asked for {{ args.query }}. + + ```lua + return prose + ```", + ); + assert_eq!( + papers(source.build()).run().text(), + "Asked for chain.|Asked for task.|Asked for papers." + ); +} + +#[test] +fn a_chains_args_are_not_checked_against_the_declaration_and_take_no_default() { + // `SEARCH` requires a string `query`, defaults `limit` to 3, allows + // `depth` one of two values, and declares no `topic`. + let source = calling( + "return call('../Sub', { topic = 'chain', depth = 'deep' }) + .. call('../Sub', { query = 5 })", + ); + assert_eq!( + papers(source).run().text(), + r#"{"depth":"deep","topic":"chain"}{"query":5}"# + ); +} + +#[test] +fn a_call_with_a_list_is_refused_at_the_call_site_and_pcall_catches_it() { + let source = calling( + "local ok, err = pcall(call, '../Sub', { 'a', 'b' }) + local empty_ok, empty_err = pcall(call, '../Sub', json.array()) + return table.concat({ tostring(ok), err.kind, tostring(err), + tostring(empty_ok), tostring(empty_err) }, '|')", + ); + let refusal = format!("{ARGS_SHAPE}, got a list"); + assert_eq!( + papers(source).run().text(), + format!( + "false|lua|{}|false|{}", + refused(11, &refusal), + refused(12, &refusal) + ), + "a list, a marked empty one included, has no named fields" + ); +} + +#[test] +fn an_uncaught_call_with_a_list_ends_the_run_as_a_lua_error_on_the_calls_line() { + let source = calling("local ready = true\nreturn call('../Sub', { 'a', 'b' })"); + let trace = papers(source).run(); + let error = trace.error(); + assert_eq!(error.code, ErrorCode::LuaRuntime, "{error:?}"); + assert_eq!(error.message, format!("{ARGS_SHAPE}, got a list")); + assert_eq!(error.at, at_line(12), "{error:?}"); +} + +#[test] +fn a_call_with_args_of_another_type_is_refused_naming_the_type() { + for (value, kind) in [ + ("5", "integer"), + ("2.5", "number"), + ("true", "boolean"), + ("function() end", "function"), + ] { + let source = calling(&format!( + "local ok, err = pcall(call, '../Sub', {value}) + return tostring(ok) .. '|' .. err.kind .. '|' .. tostring(err)" + )); + assert_eq!( + papers(source).run().text(), + format!( + "false|lua|{}", + refused(11, &format!("{ARGS_SHAPE}, got {kind}")) + ), + "call args {value}" + ); + } +} + +#[test] +fn a_spawn_gives_its_chain_the_args_it_passes_and_task_started_reports_them() { + for (opts, chain_reads) in [ + ( + "{ args = { topic = 'task', pages = { 1, 2 } } }", + json!({ "topic": "task", "pages": [1, 2] }), + ), + ("{ args = 'plain words' }", json!({ "text": "plain words" })), + ("{ args = args }", json!({ "limit": 3, "query": "papers" })), + ("{ args = {} }", json!({})), + ] { + let trace = papers(spawning(opts)).run(); + assert_eq!(trace.text(), chain_reads.to_string(), "spawn opts {opts}"); + assert_eq!( + started_args(&trace), + [Some(chain_reads)], + "the start event reports the chain's args as the chain reads them: {opts}" + ); + } +} + +#[test] +fn a_spawn_that_passes_no_args_reads_the_spawners_and_task_started_reports_none() { + for opts in ["nil", "{}", "{ item = 'member', index = 2 }"] { + let trace = papers(spawning(opts)).run(); + assert_eq!(trace.text(), RUN_ARGS, "spawn opts {opts}"); + assert_eq!(started_args(&trace), [None], "spawn opts {opts}"); + } +} + +#[test] +fn a_spawned_chains_args_are_not_checked_against_the_declaration_and_take_no_default() { + let trace = papers(spawning("{ args = { topic = 'task', depth = 5 } }")).run(); + assert_eq!(trace.text(), r#"{"depth":5,"topic":"task"}"#); +} + +#[test] +fn a_spawn_with_a_list_or_another_type_as_its_args_is_refused_and_starts_no_task() { + let source = prompt( + SEARCH, + &[ + ( + "Main", + "local function refused(opts) + local ok, err = pcall(tasks.spawn, '../Child', opts) + assert(not ok, 'the spawn is refused') + return err.kind .. ':' .. tostring(err) + end + return refused({ args = { 'a', 'b' } }) .. '|' .. refused({ args = json.array() }) + .. '|' .. refused({ args = 5 }) .. '|' .. refused({ args = true })", + ), + ("Child", ECHO), + ], + ); + let trace = papers(source).run(); + let lua = |what: &str| format!("lua:{}", refused(12, &format!("{ARGS_SHAPE}, got {what}"))); + assert_eq!( + trace.text(), + [lua("a list"), lua("a list"), lua("integer"), lua("boolean")].join("|") + ); + assert!( + started_args(&trace).is_empty(), + "a refused spawn starts no task: {:?}", + trace.events() + ); +} + +#[test] +fn a_spawn_with_an_option_it_does_not_take_is_refused_and_starts_no_task() { + // The keys are checked in sorted order, so the first one that the + // spawn does not take is the one the refusal names. A table read + // from `var` is checked as the table it stands for. + let source = prompt( + SEARCH, + &[ + ( + "Main", + "local function refused(opts) + local ok, err = pcall(tasks.spawn, '../Child', opts) + assert(not ok, 'the spawn is refused') + return err.kind .. ':' .. tostring(err) + end + var.opts = { args = { n = 1 }, name = 'x' } + return refused({ itme = 'a' }) .. '|' .. refused({ zeta = 1, alpha = 2, index = 3 }) + .. '|' .. refused({ 'first' }) .. '|' .. refused(var.opts)", + ), + ("Child", ECHO), + ], + ); + let trace = papers(source).run(); + // The shim raises the refusal itself, so its text is the message alone. + let lua = |key: &str| { + format!("lua:tasks.spawn: unknown key {key}; accepted keys are args, item, index") + }; + assert_eq!( + trace.text(), + [ + lua(r#""itme""#), + lua(r#""alpha""#), + lua("1"), + lua(r#""name""#) + ] + .join("|") + ); + assert!( + started_args(&trace).is_empty(), + "a refused spawn starts no task: {:?}", + trace.events() + ); +} + +#[test] +fn a_spawn_takes_args_item_and_index_together() { + let source = prompt( + SEARCH, + &[ + ( + "Main", + "local task = tasks.spawn('../Child', { args = { n = 1 }, item = 'it', index = 4 }) + local _, ok, result = tasks.join_any({ task }) + assert(ok, tostring(result)) + return result", + ), + ( + "Child", + "return json.encode(args) .. ' ' .. item .. ' ' .. sys.index", + ), + ], + ); + assert_eq!(papers(source).run().text(), r#"{"n":1} it 4"#); +} + +#[test] +fn a_fanout_arm_reads_its_callers_args_and_task_started_reports_none_for_it() { + let source = prompt( + SEARCH, + &[ + ( + "Parent", + "local r = tasks.fanout('Worker', { 'a', 'b' }) + return r[1].text .. '|' .. r[2].text", + ), + ( + "### Worker", + "return item .. ':' .. args.query .. ':' .. args.limit", + ), + ], + ); + let trace = papers(source).run(); + assert_eq!(trace.text(), "a:papers:3|b:papers:3"); + assert_eq!(started_args(&trace), [None, None]); +} + +#[test] +fn a_fanout_arm_inside_a_called_chain_reads_that_chains_args() { + let source = prompt( + SEARCH, + &[ + ("Main", "return call('../Parent', { query = 'chain' })"), + ( + "Parent", + "local r = tasks.fanout('Worker', { 'a' }) + return r[1].text", + ), + ("### Worker", "return item .. ':' .. args.query"), + ], + ); + assert_eq!(papers(source).run().text(), "a:chain"); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-refusals.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-refusals.rs new file mode 100644 index 000000000..6cd5ed2f0 --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface-refusals.rs @@ -0,0 +1,333 @@ +//! The run's first step refusing its arguments. Text that is not JSON, +//! JSON that is not an object, and an object the prompt's `args` +//! declaration does not accept each end the run at its first step with +//! the code `args_invalid`: the failure has the reason and, for one +//! argument, its path as facts, no location, and a message that names +//! what is required and what was sent, and the run reports no event. The +//! check runs after the version gate and before the store probe and the +//! requirements refusal, and a cancel flag set before the first step ends +//! the run canceled instead. + +use std::collections::BTreeMap; + +use runvil_prompt::test_support::{one_section, prompt}; +use runvil_types::error::{Error, ErrorCode, ErrorFact}; +use runvil_types::models::ThinkingMode; +use runvil_vfs::{MemoryBackend, VfsRef}; +use serde_json::json; + +use super::{NESTED, SEARCH, search_prompt}; +use crate::test_support::scenario::{Scenario, test_model_with}; + +/// The body of a section a refused run never reaches: it writes +/// `ran.txt` to the store. +const NEVER_RUNS: &str = "store.write('ran.txt', 'x')\nreturn 'never runs'"; + +/// The failure `scenario`'s run ends with, after asserting that its first +/// step refused the arguments: the code is `ArgsInvalid`, the failure +/// points at no line, and the run reported no event, issued no effect, +/// took no model turn, and ran no section. +#[track_caller] +fn refusal(scenario: Scenario) -> Error { + let trace = scenario.run(); + let error = trace.error().clone(); + assert_eq!(error.code, ErrorCode::ArgsInvalid, "{error:?}"); + assert_eq!( + error.at, None, + "the arguments are the caller's to fix, so the failure names no line: {error:?}" + ); + assert_eq!(error.located_message(), error.message); + assert!( + trace.events().is_empty(), + "a refused run reports no event: {:?}", + trace.events() + ); + assert!( + trace.effects().is_empty(), + "a refused run issues nothing: {:?}", + trace.effects() + ); + assert_eq!(trace.turns(), 0, "a refused run never starts"); + assert!( + trace.read("ran.txt").is_err(), + "a refused run runs no section" + ); + error +} + +/// Asserts that a run of `source` given `args` is refused for `reason` +/// with `message`, naming the argument `name`: the facts are the reason +/// and the name alone, and the prose is the message and then the code's +/// default text. +#[track_caller] +fn assert_argument_refused(source: &str, args: &str, reason: &str, name: &str, message: &str) { + let error = refusal(Scenario::new(source).args(args)); + assert_eq!(error.message, message, "arguments {args}"); + assert_eq!( + error.prose, + format!("{message}. {}", ErrorCode::ArgsInvalid.prose()), + "arguments {args}" + ); + assert_eq!( + error.facts, + BTreeMap::from([ + (ErrorFact::Reason, json!(reason)), + (ErrorFact::Name, json!(name)), + ]), + "arguments {args}" + ); +} + +#[test] +fn text_that_is_not_json_ends_the_run_at_its_first_step_with_the_parsers_cause() { + let source = search_prompt(NEVER_RUNS); + for (args, cause) in [ + ("papers", "expected value at line 1 column 1"), + ("", "EOF while parsing a value at line 1 column 0"), + ( + r#"{"query":"papers""#, + "EOF while parsing an object at line 1 column 17", + ), + ( + r#"{"query":"papers"} and more"#, + "trailing characters at line 1 column 20", + ), + ] { + let error = refusal(Scenario::new(&source).args(args)); + assert_eq!( + error.message, + format!("the arguments are not valid JSON: {cause}"), + "arguments {args:?}" + ); + assert_eq!( + error.prose, + format!( + "the arguments are not valid JSON. {}", + ErrorCode::ArgsInvalid.prose() + ), + "the parser's cause stays out of the prose: {args:?}" + ); + assert_eq!( + error.facts, + BTreeMap::from([(ErrorFact::Reason, json!("not_json"))]), + "arguments {args:?}" + ); + } +} + +#[test] +fn json_that_is_not_an_object_ends_the_run_at_its_first_step_naming_its_kind() { + let source = search_prompt(NEVER_RUNS); + for (args, kind) in [ + ("null", "null"), + ("true", "boolean"), + ("5", "integer"), + ("2.5", "number"), + (r#""papers""#, "string"), + (r#"["papers"]"#, "array"), + ] { + let error = refusal(Scenario::new(&source).args(args)); + let message = format!("the arguments must be a JSON object, got {kind}"); + assert_eq!(error.message, message, "arguments {args}"); + assert_eq!( + error.prose, + format!("{message}. {}", ErrorCode::ArgsInvalid.prose()), + "arguments {args}" + ); + assert_eq!( + error.facts, + BTreeMap::from([(ErrorFact::Reason, json!("not_an_object"))]), + "arguments {args}" + ); + } +} + +#[test] +fn an_argument_the_declaration_refuses_ends_the_run_with_its_reason_and_its_name() { + let source = search_prompt(NEVER_RUNS); + for (args, reason, name, message) in [ + ("{}", "required", "query", "argument `query` is required"), + ( + r#"{"query":null}"#, + "required", + "query", + "argument `query` is required, got null", + ), + ( + r#"{"query":5}"#, + "type", + "query", + "argument `query` must be a string, got integer", + ), + ( + r#"{"query":"papers","limit":2.5}"#, + "type", + "limit", + "argument `limit` must be an integer, got number", + ), + ( + r#"{"query":"papers","depth":"deep"}"#, + "enum", + "depth", + r#"argument `depth` must be one of "quick", "full", got "deep""#, + ), + ( + r#"{"query":"papers","n":2}"#, + "undeclared", + "n", + "argument `n` is not declared; the declared arguments are `depth`, `limit`, `query`", + ), + ] { + assert_argument_refused(&source, args, reason, name, message); + } +} + +#[test] +fn a_refused_argument_inside_an_object_or_a_list_is_named_by_its_path() { + let source = prompt(NESTED, &[("Only", NEVER_RUNS)]); + for (args, reason, name, message) in [ + ( + r#"{"opts":{"depth":"deep"}}"#, + "type", + "opts.depth", + "argument `opts.depth` must be an integer, got string", + ), + ( + r#"{"opts":{"width":1}}"#, + "undeclared", + "opts.width", + "argument `opts.width` is not declared; the declared fields of `opts` are `depth`", + ), + ( + r#"{"focus":["intro",7]}"#, + "type", + "focus[1]", + "argument `focus[1]` must be a string, got integer", + ), + ( + r#"{"focus":[null]}"#, + "type", + "focus[0]", + "argument `focus[0]` must be a string, got null", + ), + ( + r#"{"focus":"intro"}"#, + "type", + "focus", + "argument `focus` must be an array, got string", + ), + ] { + assert_argument_refused(&source, args, reason, name, message); + } +} + +#[test] +fn a_prompt_with_no_args_key_refuses_every_argument_but_a_string_named_text() { + let source = one_section(NEVER_RUNS); + assert_argument_refused( + &source, + r#"{"query":1}"#, + "undeclared", + "query", + "argument `query` is not declared; the declared arguments are `text`", + ); + assert_argument_refused( + &source, + r#"{"text":5}"#, + "type", + "text", + "argument `text` must be a string, got integer", + ); +} + +#[test] +fn a_declaration_of_no_fields_refuses_every_argument() { + let source = prompt("args: { type: object }", &[("Only", NEVER_RUNS)]); + assert_argument_refused( + &source, + r#"{"n":1}"#, + "undeclared", + "n", + "argument `n` is not declared; the prompt declares no arguments", + ); +} + +#[test] +fn an_unsupported_version_is_reported_before_refused_arguments() { + let source = search_prompt(NEVER_RUNS).replace("runvil: 0", "runvil: 7"); + let trace = Scenario::new(source).args("papers").run(); + let error = trace.error(); + assert_eq!( + (error.code, error.located_message()), + ( + ErrorCode::UnsupportedVersion, + "unsupported runvil version: 7 (this build supports major 0) (at prompt:4)".to_owned() + ), + "the version gate refuses the run first" + ); + assert!(trace.events().is_empty(), "nothing ran, nothing reported"); +} + +#[test] +fn refused_arguments_are_reported_before_an_undeclared_store() { + let source = search_prompt(NEVER_RUNS); + let no_store = || VfsRef::new(MemoryBackend::new()); + let error = refusal(Scenario::new(&source).vfs(no_store())); + assert_eq!(error.message, "argument `query` is required"); + + let accepted = Scenario::new(&source) + .vfs(no_store()) + .args(r#"{"query":"papers"}"#) + .run(); + assert_eq!( + accepted.error().code, + ErrorCode::NoStore, + "with arguments the declaration accepts, the store probe is next" + ); +} + +#[test] +fn refused_arguments_are_reported_before_an_unmet_requirement() { + let front = format!("{SEARCH}\nmodels: {{ strong: {{ min_context: 200000 }} }}"); + let source = prompt(&front, &[("Only", NEVER_RUNS)]); + let small = || test_model_with("small", 32_000, ThinkingMode::Switchable); + let error = refusal( + Scenario::new(&source) + .current_model(small()) + .args(r#"{"query":5}"#), + ); + assert_eq!( + error.message, + "argument `query` must be a string, got integer" + ); + + let accepted = Scenario::new(&source) + .current_model(small()) + .args(r#"{"query":"papers"}"#) + .run(); + assert_eq!( + accepted.error().code, + ErrorCode::RequirementsUnmet, + "with arguments the declaration accepts, the requirements refusal is next" + ); +} + +#[test] +fn refused_arguments_after_the_runs_cancel_fired_end_the_run_canceled_with_no_event() { + let source = search_prompt(NEVER_RUNS); + for args in ["papers", "5", r#"{"query":5}"#] { + let mut driving = Scenario::new(&source).args(args).start(); + driving.cancel(); + let trace = driving.finish(); + assert!( + trace.error().is_canceled(), + "arguments {args} after a cancel end the run canceled, got {:?}", + trace.result() + ); + assert!( + trace.events().is_empty(), + "a run that never started reports no event: {:?}", + trace.events() + ); + } +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs index 9761dded6..aca90ba41 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs @@ -1,348 +1,444 @@ -//! The args/argv surface: `args` is the exact passed string always; `argv` -//! is the parsed JSON on success and nil otherwise (`if argv then` is the -//! malformed check); a default-declared prompt wraps interface prose into -//! `argv.prose`; `argv` is read-only in every section, so a prompt that -//! gets malformed input builds its own from `args` and passes it on in -//! `var`; and `{{ argv }}` joins the prose substitution namespaces. - -use runvil_prompt::test_support::{PromptSource, prompt}; +//! The `args` surface. A run's arguments are one JSON object. The run's +//! first step checks it against the prompt's `args` declaration, and every +//! section then reads the checked object as the read-only table `args`, +//! in Lua and through `{{ args.field }}` placeholders. The check fills a +//! declared default, reads a `null` for a field that is not required as +//! the field left out, holds a whole number under `integer` as an +//! integer, and keeps the undeclared fields of an object that takes them. +//! A prompt with no `args:` key declares one optional string named +//! `text`. A tool handler's `args` parameter is that call's arguments. +//! +//! The first-step refusals sit in `refusals`, and the arguments the chain +//! of a `call`, of a `tasks.spawn`, and of a fanout arm reads sit in +//! `chains`. + +use runvil_prompt::test_support::{PromptSource, one_section, prompt}; use runvil_types::error::ErrorCode; use crate::test_support::scenario::Scenario; +use crate::test_support::{at_line, authored, refused}; + +/// A declaration of three arguments: a required string `query`, an +/// integer `limit` that defaults to 3, and a string `depth` that is +/// `quick` or `full`. +const SEARCH: &str = "args: { type: object, properties: { query: { type: string }, \ + limit: { type: integer, default: 3 }, depth: { type: string, enum: [quick, full] } }, \ + required: [query] }"; + +/// A declaration with nested shapes: an object `opts` whose integer +/// `depth` defaults to 2, and a list of strings `focus`. +const NESTED: &str = "args: { type: object, properties: { \ + opts: { type: object, properties: { depth: { type: integer, default: 2 } } }, \ + focus: { type: array, items: { type: string } } } }"; -/// One declared (required) `query` string. The declaration advertises and -/// documents; it never enforces - enforcement is the prompt's own Lua. -const QUERY: &str = "args: { query: { type: string } }"; +/// A prompt declaring [`SEARCH`] whose one section `Only` runs `lua`. +fn search_prompt(lua: &str) -> String { + prompt(SEARCH, &[("Only", lua)]) +} -/// A prompt declaring [`QUERY`] whose one section `Only` runs `lua`. -fn query_prompt(lua: &str) -> String { - prompt(QUERY, &[("Only", lua)]) +/// A run of `source` whose caller passes the one argument `query`, the +/// string `papers`. +fn papers(source: impl Into) -> Scenario { + Scenario::new(source).args(r#"{"query":"papers"}"#) } #[test] -fn args_is_the_exact_passed_string_and_substitutes_unmodified() { - let source = PromptSource::new().front(QUERY).prose( +fn a_section_reads_its_arguments_as_fields_of_the_args_table() { + let source = search_prompt( + " + assert(type(args) == 'table', 'args is a table') + assert(args.depth == nil, 'a field the caller left out reads nil') + assert(args.absent == nil, 'a field the declaration lacks reads nil') + return args.query .. ', limit ' .. args.limit", + ); + assert_eq!(papers(source).run().text(), "papers, limit 3"); +} + +#[test] +fn a_placeholder_reads_a_field_of_args_at_any_depth() { + let source = PromptSource::new().front(NESTED).prose( "Only", " - Args: {{ args }} + Depth {{ args.opts.depth }} of {{ args.opts }}, on {{ args.focus }}. ```lua - assert(args == ' spaced { not json ', 'args is the exact passed string') return prose ```", ); let trace = Scenario::new(source.build()) - .args(" spaced { not json ") + .args(r#"{"opts":{},"focus":["intro","proofs"]}"#) .run(); - let out = trace.text(); - assert!( - out.contains("Args: spaced { not json "), - "{{ args }} renders the raw string unmodified: {out:?}" + assert_eq!( + trace.text(), + r#"Depth 2 of {"depth":2}, on ["intro","proofs"]."# ); } #[test] -fn argv_is_the_parsed_json_on_success() { - let source = query_prompt( - " - assert(argv, 'parsed JSON makes argv present') - assert(argv.query == 'papers', 'structured access is argv.query') - return argv.query", - ); - let trace = Scenario::new(source).args("{\"query\":\"papers\"}").run(); - assert_eq!(trace.text(), "papers"); -} +fn a_bare_args_placeholder_renders_the_whole_object_as_compact_json() { + let whole = " + Whole: {{ args }} -#[test] -fn argv_is_nil_on_malformed_json() { - // `if argv then` is the idiomatic malformed check. - let source = query_prompt( - " - if argv then error('malformed JSON must read as nil argv') end - return 'nil'", + ```lua + return prose + ```"; + let declared = PromptSource::new().front(SEARCH).prose("Only", whole); + let trace = Scenario::new(declared.build()) + .args(r#"{ "query": "papers", "depth": "full" }"#) + .run(); + assert_eq!( + trace.text(), + r#"Whole: {"depth":"full","limit":3,"query":"papers"}"#, + "the keys are sorted and the default is filled" + ); + let undeclared = PromptSource::new().prose("Only", whole); + assert_eq!( + Scenario::new(undeclared.build()).run().text(), + "Whole: {}", + "an empty object renders as one" ); - let trace = Scenario::new(source).args("not json").run(); - assert_eq!(trace.text(), "nil", "malformed JSON is nil argv"); } #[test] -fn valid_json_scalars_make_argv_a_number_or_boolean_and_null_reads_as_nil() { - let source = query_prompt("return tostring(argv)"); - for (args, rendered) in [("42", "42"), ("true", "true")] { - let trace = Scenario::new(source.clone()).args(args).run(); - assert_eq!( - trace.text(), - rendered, - "a valid JSON {args} makes argv a scalar" - ); - } - let source = query_prompt( +fn a_placeholder_for_a_field_args_lacks_is_a_substitution_error_pcall_catches() { + let source = PromptSource::new().front(SEARCH).prose( + "Only", " - if argv then error('JSON null must read as nil') end - return 'nil'", + Depth: {{ args.depth }}. + + ```lua + local ok, err = pcall(function() return prose end) + assert(not ok, 'a field the caller left out must not render as empty text') + return err.kind .. '|' .. tostring(err) + ```", + ); + assert_eq!( + papers(source.build()).run().text(), + format!( + "lua|prompt:10: missing {{{{ args.depth }}}}. {}", + ErrorCode::Substitution.prose() + ) ); - assert_eq!(Scenario::new(source).args("null").run().text(), "nil"); } #[test] -fn the_executor_never_hard_errors_on_shape() { - // `query` is declared a string and arrives as a number: the run - // succeeds; shape enforcement belongs to the prompt's own Lua. - let source = query_prompt("return tostring(argv.query)"); - let trace = Scenario::new(source).args("{\"query\":5}").run(); - assert_eq!(trace.text(), "5"); -} +fn an_uncaught_placeholder_for_a_field_args_lacks_ends_the_run_on_its_template_line() { + let source = PromptSource::new().front(SEARCH).prose( + "Only", + " + Query: {{ args.query }}. + Depth: {{ args.depth }}. -#[test] -fn a_strict_preamble_errors_on_a_missing_field() { - // The preamble runs first, so its own check of `argv` fails the run - // before the next section, as a Lua error like any section's. - let source = PromptSource::new() - .front(QUERY) - .preamble("assert(argv and argv.query, 'query is required')") - .section("Only", "return 'unreachable'"); - let trace = Scenario::new(source.build()).args("{}").run(); + ```lua + return prose + ```", + ); + let trace = papers(source.build()).run(); let error = trace.error(); - assert_eq!(error.code, ErrorCode::LuaRuntime, "{error:?}"); - assert!( - error.to_string().contains("query is required"), - "the assertion notice surfaces: {error}" + assert_eq!(error.code, ErrorCode::Substitution, "{error:?}"); + assert_eq!( + error.message, + "missing {{ args.depth }} [MissingKey at byte 32]" ); + assert_eq!(error.at, at_line(11), "{error:?}"); } #[test] -fn a_section_builds_its_input_from_args_and_passes_it_on_in_var() { - let source = PromptSource::new() - .front(QUERY) - .section( - "First", - " - assert(argv == nil, 'the malformed input reads as nil argv') - var.input = { query = args }", - ) - .prose( - "Second", - " - Query: {{ var.input.query }} - - ```lua - assert(argv == nil, 'argv is still nil in the next section') - return prose - ```", - ); - let trace = Scenario::new(source.build()).args("broken json").run(); - assert_eq!(trace.text(), "Query: broken json"); -} - -#[test] -fn assigning_argv_is_an_error() { - let source = query_prompt("argv = { query = 'hijacked' }"); - let trace = Scenario::new(source).args("{\"query\":\"x\"}").run(); - let error = trace.error(); - assert!( - error - .to_string() - .contains("argv is read-only: assign to `var` or a section global instead"), - "the error names the rule: {error}" +fn an_args_table_measures_and_iterates_as_the_object_it_holds() { + let source = PromptSource::new().front(NESTED).section( + "Only", + " + local keys = {} + for key in pairs(args) do keys[#keys + 1] = key end + table.sort(keys) + local indexed = {} + for index, name in ipairs(args.focus) do indexed[#indexed + 1] = index .. '=' .. name end + local first_key, first_value = next(args.opts) + local a, b, c = table.unpack(args.focus) + return table.concat({ + table.concat(keys, ','), #args.focus, table.concat(args.focus, '+'), + table.concat(indexed, ','), first_key .. '=' .. first_value, a .. b .. c, + }, '|')", ); + let trace = Scenario::new(source.build()) + .args(r#"{"opts":{"depth":4},"focus":["a","b","c"]}"#) + .run(); + assert_eq!(trace.text(), "focus,opts|3|a+b+c|1=a,2=b,3=c|depth=4|abc"); } -#[test] -fn assigning_a_nil_argv_is_an_error() { - let source = query_prompt("argv = { query = 'built' }"); - let trace = Scenario::new(source).args("broken json").run(); - let error = trace.error(); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); +/// Lua that tries to assign `args` and to set a field of it, each under +/// `pcall`, and returns the two refusals and the field as it then reads. +const TWO_WRITES: &str = " + local function refused(write) + local ok, err = pcall(write) + assert(not ok, 'the write is refused') + return tostring(err) + end + return refused(function() args = { query = 'other' } end) + .. '|' .. refused(function() args.query = 'other' end) + .. '|' .. tostring(args.query)"; + +/// Asserts `text` is what [`TWO_WRITES`] returns when its block starts on +/// `first_line` of the prompt: each write is refused with the read-only +/// rule on its own line, and `args.query` still reads `query`. +#[track_caller] +fn assert_both_writes_refused(text: &str, first_line: u32, query: &str) { + let expected = [ + refused( + first_line + 5, + "args is read-only: assign to `var` or a section global instead", + ), + refused( + first_line + 6, + "args is read-only: cannot set field 'query'", + ), + query.to_owned(), + ]; + assert_eq!(text, expected.join("|")); } #[test] -fn a_field_write_on_argv_is_an_error() { - let source = query_prompt("argv.query = 'hijacked'"); - let trace = Scenario::new(source).args("{\"query\":\"x\"}").run(); - let error = trace.error(); - assert!( - error - .to_string() - .contains("argv is read-only: cannot set field 'query'"), - "the error names the rule: {error}" - ); +fn args_is_read_only_in_a_walk_section() { + let trace = papers(search_prompt(TWO_WRITES)).run(); + assert_both_writes_refused(trace.text(), 11, "papers"); } #[test] -fn argv_is_read_only_in_the_preamble_too() { +fn args_is_read_only_in_the_preamble() { let source = PromptSource::new() - .front(QUERY) - .preamble("argv = { query = 'hijacked' }") + .front(SEARCH) + .preamble(TWO_WRITES) .section("Only", "return 'unreachable'"); - let trace = Scenario::new(source.build()) - .args("{\"query\":\"x\"}") - .run(); - let error = trace.error(); - assert!( - error.to_string().contains("argv is read-only"), - "the error names the rule: {error}" - ); + let trace = papers(source.build()).run(); + assert_both_writes_refused(trace.text(), 9, "papers"); } #[test] -fn absent_is_not_the_empty_string() { - // An optional field omitted is nil; passed empty it is "". The two are - // distinguishable in Lua. +fn args_is_read_only_in_a_called_chain() { let source = prompt( - "args: { prose: { type: string, optional: true } }", - &[( - "Only", - " - if argv.prose == nil then return 'absent' end - assert(argv.prose == '', 'present and empty is the empty string') - return 'empty'", - )], + SEARCH, + &[ + ("Main", "return call('../Sub', { query = 'chain' })"), + ("Sub", TWO_WRITES), + ], ); - let omitted = Scenario::new(source.clone()).args("{}").run(); - assert_eq!(omitted.text(), "absent"); - let empty = Scenario::new(source).args("{\"prose\":\"\"}").run(); - assert_eq!(empty.text(), "empty"); + let trace = papers(source).run(); + assert_both_writes_refused(trace.text(), 17, "chain"); } #[test] -fn a_default_declared_prompt_wraps_interface_prose() { - // No `args:` key: the default declaration wraps prose into argv.prose, - // and args still holds the exact passed string. +fn args_is_read_only_in_a_task() { let source = prompt( - "", - &[( - "Only", - " - assert(argv and argv.prose == 'hello there', 'prose wraps into argv.prose') - assert(args == 'hello there', 'args holds the exact passed string') - return argv.prose", - )], + SEARCH, + &[ + ( + "Main", + " + local task = tasks.spawn('../Child', { args = { query = 'task' } }) + local _, ok, result = tasks.join_any({ task }) + assert(ok, 'the task catches both refusals') + return result", + ), + ("Child", TWO_WRITES), + ], ); - let trace = Scenario::new(source).args("hello there").run(); - assert_eq!(trace.text(), "hello there"); + let trace = papers(source).run(); + assert_both_writes_refused(trace.text(), 20, "task"); } #[test] -fn a_default_declared_prompt_wraps_empty_prose_as_present() { - // Interface prose is always present, so empty prose wraps as the - // present empty string, distinguishable from an absent field. +fn args_is_read_only_in_a_fanout_arm() { let source = prompt( - "", - &[( - "Only", - " - assert(argv, 'empty prose still wraps') - assert(argv.prose ~= nil, 'the field is present') - assert(argv.prose == '', 'present and empty is the empty string') - return 'ok'", - )], + SEARCH, + &[ + ( + "Parent", + " + local r = tasks.fanout('Worker', { 'alpha' }) + assert(r[1].ok, 'the arm catches both refusals') + return r[1].text", + ), + ("### Worker", TWO_WRITES), + ], ); - assert_eq!(Scenario::new(source).run().text(), "ok"); + let trace = papers(source).run(); + assert_both_writes_refused(trace.text(), 19, "papers"); } #[test] -fn argv_joins_the_substitution_namespaces() { - let source = PromptSource::new().front(QUERY).prose( - "Only", - " - Whole: {{ argv }}; Field: {{ argv.query }} +fn an_uncaught_write_to_args_ends_the_run_as_a_lua_error_on_its_line() { + for (write, refusal) in [ + ( + "args = { query = 'other' }", + "args is read-only: assign to `var` or a section global instead", + ), + ( + "args.query = 'other'", + "args is read-only: cannot set field 'query'", + ), + ("args[1] = 'other'", "args is read-only: cannot set field 1"), + ] { + let trace = papers(search_prompt(&format!("local ready = true\n{write}"))).run(); + let error = trace.error(); + assert_eq!(error.code, ErrorCode::LuaRuntime, "{write}: {error:?}"); + assert_eq!( + error.prose, + authored(ErrorCode::LuaRuntime, refusal), + "{write}" + ); + assert_eq!(error.at, at_line(12), "{write}: {error:?}"); + } +} - ```lua - return prose - ```", - ); - let trace = Scenario::new(source.build()) - .args("{\"query\":\"papers\",\"n\":2}") +#[test] +fn a_field_the_caller_left_out_takes_its_declared_default_at_any_depth() { + let top = search_prompt("return args.query .. ', limit ' .. args.limit"); + assert_eq!(papers(&top).run().text(), "papers, limit 3"); + let sent = Scenario::new(top) + .args(r#"{"query":"papers","limit":10}"#) .run(); assert_eq!( - trace.text(), - "Whole: {\"n\":2,\"query\":\"papers\"}; Field: papers" + sent.text(), + "papers, limit 10", + "a field the caller sent keeps its value" + ); + + let nested = prompt( + NESTED, + &[("Only", "return tostring(args.opts and args.opts.depth)")], + ); + let inside = Scenario::new(&nested).args(r#"{"opts":{}}"#).run(); + assert_eq!( + inside.text(), + "2", + "an object the caller sent has its own defaults filled" + ); + assert_eq!( + Scenario::new(nested).run().text(), + "nil", + "an object with no default of its own stays left out" ); } #[test] -fn dotted_indexing_into_a_scalar_argv_is_a_catchable_substitution_error() { - let source = PromptSource::new().front(QUERY).prose( - "Only", +fn a_null_for_a_field_that_is_not_required_counts_as_the_field_left_out() { + let source = search_prompt( " - Value: {{ argv.query.x }} - - ```lua - local ok, err = pcall(function() return prose end) - assert(not ok, 'dotted indexing into a scalar must fail') - assert(tostring(err):match('missing'), 'a substitution error, never a silent empty string: ' .. tostring(err)) - return 'caught' - ```", + assert(args.depth == nil, 'a null with no default reads nil, as a field left out does') + return args.query .. ', limit ' .. args.limit", ); - let trace = Scenario::new(source.build()) - .args("{\"query\":\"scalar\"}") + let trace = Scenario::new(source) + .args(r#"{"query":"papers","limit":null,"depth":null}"#) .run(); assert_eq!( trace.text(), - "caught", - "pcall catches the substitution error" + "papers, limit 3", + "the default fills the null" ); } #[test] -fn substituting_a_nil_argv_is_an_error() { - let source = PromptSource::new().front(QUERY).prose( +fn an_integer_argument_takes_a_whole_number_in_any_notation_and_holds_an_integer() { + let source = PromptSource::new().front(SEARCH).prose( "Only", " - Value: {{ argv }} + Limit {{ args.limit }}. ```lua - return prose + return prose .. ' ' .. math.type(args.limit) .. ' ' .. args.limit ```", ); - let trace = Scenario::new(source.build()).args("not json").run(); - let error = trace.error(); - assert!( - error.to_string().contains("argv"), - "a nil argv must not render as a silent empty string: {error}" - ); + for (sent, held) in [("5.0", "5"), ("1e3", "1000"), ("7", "7"), ("-2.0", "-2")] { + let trace = Scenario::new(source.build()) + .args(&format!(r#"{{"query":"papers","limit":{sent}}}"#)) + .run(); + assert_eq!( + trace.text(), + format!("Limit {held}. integer {held}"), + "limit sent as {sent}" + ); + } } #[test] -fn a_call_chain_derives_argv_from_its_own_args_read_only() { +fn an_object_that_takes_undeclared_fields_keeps_them_as_sent() { let source = prompt( - QUERY, - &[ - ("Main", "return call('../Sub', '{\"query\":\"chain\"}')"), - ( - "Sub", - " - assert(args == '{\"query\":\"chain\"}', 'the chain sees its own args') - assert(argv.query == 'chain', 'argv derives from the chain args') - local wrote = pcall(function() argv.query = 'x' end) - assert(not wrote, 'the chain argv is read-only') - return argv.query", - ), - ], + "args: { type: object, properties: { query: { type: string } }, \ + additionalProperties: true }", + &[( + "Only", + " + assert(args.spare == json.null, 'a null in an undeclared field is kept') + return args.query .. '|' .. args.n .. '|' .. json.encode(args.extra)", + )], ); - let trace = Scenario::new(source).args("{\"query\":\"run\"}").run(); - assert_eq!(trace.text(), "chain"); + let trace = Scenario::new(source) + .args(r#"{"query":"papers","n":2,"extra":{"deep":[1,null]},"spare":null}"#) + .run(); + assert_eq!(trace.text(), r#"papers|2|{"deep":[1,null]}"#); +} + +/// A prompt with no `args:` key whose one section returns `args` as JSON. +fn undeclared_prompt() -> String { + one_section("return json.encode(args)") } #[test] -fn a_no_input_call_inherits_its_callers_argv() { - let source = prompt( - QUERY, - &[ - ("Main", "return call('../Mid', '{\"query\":\"mid\"}')"), - ("Mid", "return call('../Sub')"), - ( - "Sub", - " - assert(args == '{\"query\":\"mid\"}', 'a no-input call inherits its caller args') - assert(argv.query == 'mid', 'a no-input call inherits its caller argv') - return argv.query", - ), - ], +fn an_empty_object_runs_a_prompt_with_no_args_key() { + let source = one_section( + " + assert(type(args) == 'table', 'args is a table with nothing passed') + assert(next(args) == nil and #args == 0, 'an empty args holds nothing') + assert(args.text == nil, 'the optional text reads nil when left out') + return json.encode(args)", + ); + assert_eq!(Scenario::new(source).args("{}").run().text(), "{}"); +} + +#[test] +fn a_prompt_with_no_args_key_takes_one_optional_string_named_text() { + let trace = Scenario::new(undeclared_prompt()) + .args(r#"{"text":"hello"}"#) + .run(); + assert_eq!(trace.text(), r#"{"text":"hello"}"#); + let empty = Scenario::new(undeclared_prompt()) + .args(r#"{"text":""}"#) + .run(); + assert_eq!( + empty.text(), + r#"{"text":""}"#, + "an empty string is a value, not the field left out" + ); + let null = Scenario::new(undeclared_prompt()) + .args(r#"{"text":null}"#) + .run(); + assert_eq!(null.text(), "{}", "a null text counts as left out"); +} + +#[test] +fn a_handlers_args_parameter_is_the_calls_arguments_and_the_section_still_reads_the_runs() { + let source = search_prompt( + " + local grab = tools.adhoc({ name = 'grab', description = 'Grab a value', + parameters = { value = 'string' } }, function(args) + return 'call ' .. args.value .. ' ' .. tostring(args.query) + end) + local probe = tools.adhoc({ name = 'probe', description = 'Read the run', + parameters = { value = 'string' } }, function(call_args) + return 'global ' .. args.query .. ' ' .. call_args.value + end) + local grabbed = tools.call(grab, { value = 'hi' }) + local probed = tools.call(probe, { value = 'yo' }) + return grabbed .. '|' .. probed .. '|run ' .. args.query .. ' ' .. tostring(args.value)", + ); + assert_eq!( + papers(source).run().text(), + "call hi nil|global papers yo|run papers nil", + "the parameter shadows the global inside the handler alone" ); - let trace = Scenario::new(source).args("{\"query\":\"run\"}").run(); - assert_eq!(trace.text(), "mid"); } + +#[path = "args_surface-chains.rs"] +mod chains; +#[path = "args_surface-refusals.rs"] +mod refusals; diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs index 0f2093762..6942ad729 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs @@ -15,7 +15,7 @@ const RULES: &str = " Indented first line.\n\n\ var.never = 'run'\n\ ```\n\n\ ---\n\n\ -Closing line with {{ args }}."; +Closing line with {{ args.text }}."; /// The sections every test joins after its own `Main`: a child list `Kid` /// with a grandchild `Grand`, a numbered `Nums`, the raw `Rules`, an empty diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/run_setup.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/run_setup.rs index 04ce575f0..2c986cca8 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/run_setup.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/run_setup.rs @@ -56,7 +56,7 @@ fn a_store_backend_that_refuses_its_session_fails_the_run_at_the_first_step() { let vfs = VfsRef::builder().store("/", RefusingStore).build(); let mut run = crate::execute::run::Run::new( Arc::new(prompt.expect("the fixture parses")), - "", + "{}", test_params().with_vfs(vfs), ); match run.step() { diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs index bb700c3cc..f1fe1abdf 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs @@ -147,7 +147,7 @@ fn a_caught_store_error_raised_again_after_a_suspending_call_keeps_store() { " local ok, err = pcall(function() return store.read('missing.txt') end) assert(ok == false, 'the missing read fails') - call('Sibling', '') + call('Sibling') error(err)", ), ("### Sibling", "return 'sibling'"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/execution.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/execution.rs index b7a29ecb1..d2b547e57 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/execution.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/execution.rs @@ -78,7 +78,9 @@ fn log_fixture_reports_exact_author_checkpoints() { #[test] fn prologue_return_fixture_skips_model_and_epilog() { - let trace = Scenario::new(PROLOGUE_RETURN).args("early result").run(); + let trace = Scenario::new(PROLOGUE_RETURN) + .args(r#"{"text":"early result"}"#) + .run(); assert_eq!(trace.text(), "early result"); assert!( trace.read("unreachable.txt").is_err(), @@ -92,7 +94,9 @@ fn prologue_return_fixture_skips_model_and_epilog() { #[test] fn store_fixture_persists_state_across_fall_through() { - let trace = Scenario::new(STORE_FALLTHROUGH).args("handoff value").run(); + let trace = Scenario::new(STORE_FALLTHROUGH) + .args(r#"{"text":"handoff value"}"#) + .run(); assert_eq!(trace.text(), "handoff value"); assert_eq!( trace @@ -112,7 +116,10 @@ fn store_fixture_persists_state_across_fall_through() { #[test] fn concurrent_runs_keep_their_records_separate() { let start = |args| Scenario::new(STORE_FALLTHROUGH).args(args).start(); - let (mut first, mut second) = (start("first result"), start("second result")); + let (mut first, mut second) = ( + start(r#"{"text":"first result"}"#), + start(r#"{"text":"second result"}"#), + ); // Both runs take every step on this thread, one step of each in turn. assert!(first.step(), "the first run is pending on its store write"); assert!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs index 4195d60f8..bd4d85983 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs @@ -1,26 +1,27 @@ //! An author's own metatable on `_G`: it keeps working for every other //! global (defaults, strict errors, write hooks) in the preamble and in //! later sections, set by a required library and across fences, while -//! `argv`, `prose`, and `messages` stay guarded whatever author code does +//! `args`, `prose`, and `messages` stay guarded whatever author code does //! to `_G`'s metatable. use runvil_prompt::test_support::PromptSource; use crate::test_support::scenario::Scenario; -/// One declared `query` string, so `argv` is the parsed JSON or nil. +/// One declared optional `query` string, so `args` is a table that holds +/// the field or does not. fn query_source() -> PromptSource { - PromptSource::new().front("args: { query: { type: string } }") + PromptSource::new().front("args: { type: object, properties: { query: { type: string } } }") } #[test] -fn a_library_defaults_metatable_works_beside_read_only_argv_and_prose() { +fn a_library_defaults_metatable_works_beside_read_only_args_and_prose() { let source = query_source() .preamble( " require '/defaults' assert(tone == 'friendly', 'the preamble reads the default') - assert(not pcall(function() argv = 'hijacked' end), 'argv is read-only in the preamble') + assert(not pcall(function() args = 'hijacked' end), 'args is read-only in the preamble') assert(not pcall(function() prose = 'x' end), 'prose is read-only in the preamble')", ) .prose( @@ -32,8 +33,8 @@ fn a_library_defaults_metatable_works_beside_read_only_argv_and_prose() { require '/defaults' assert(prose == 'Tone: friendly.', 'a default renders in prose: ' .. prose) assert(missing == nil, 'a global with no default reads nil') - local ok, err = pcall(function() argv = 'hijacked' end) - assert(not ok and tostring(err):find('argv is read-only: assign to `var` or a section global instead', 1, true), 'argv stays read-only: ' .. tostring(err)) + local ok, err = pcall(function() args = 'hijacked' end) + assert(not ok and tostring(err):find('args is read-only: assign to `var` or a section global instead', 1, true), 'args stays read-only: ' .. tostring(err)) var.n = 2 ``` @@ -50,9 +51,9 @@ fn a_library_defaults_metatable_works_beside_read_only_argv_and_prose() { "Second", " require '/defaults' - assert(argv.query == 'x', 'argv reads in a later section') - assert(not pcall(function() argv = 'hijacked' end), 'argv stays read-only in a later section') - return argv.query .. ' ' .. tone", + assert(args.query == 'x', 'args reads in a later section') + assert(not pcall(function() args = 'hijacked' end), 'args stays read-only in a later section') + return args.query .. ' ' .. tone", ) .section( "defaults", @@ -67,7 +68,7 @@ fn a_library_defaults_metatable_works_beside_read_only_argv_and_prose() { } #[test] -fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and_messages() { +fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_args_prose_and_messages() { let source = query_source() .preamble( " @@ -86,7 +87,7 @@ fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and assert(not ok, 'an undefined global raises') assert(type(err) == 'string', 'the author error comes back as raised, got ' .. type(err)) assert(err:find(']:2: undefined global undefined_here', 1, true), 'the error names the reading line: ' .. err) - assert(argv.query == 'x', 'the guard serves argv, not the strict handler') + assert(args.query == 'x', 'the guard serves args, not the strict handler') assert(prose == 'Hi.', 'the guard serves prose, not the strict handler') assert(#messages == 0, 'the guard serves messages, not the strict handler') return 'strict' @@ -106,16 +107,17 @@ fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and } #[test] -fn a_nil_argv_reads_nil_under_a_strict_index_and_a_write_hook_never_sees_it() { +fn an_empty_args_reads_as_a_table_under_a_strict_index_and_a_write_hook_never_sees_it() { let source = query_source() .section( "Only", " require '/hooks' - assert(argv == nil, 'a nil argv reads nil, not the strict handler error') - assert(not pcall(function() argv = { query = 'built' } end), 'a nil argv is read-only') - assert(seen.argv == nil, 'the write hook never sees argv') - assert(argv == nil, 'the refused assignment wrote nothing') + assert(type(args) == 'table', 'an empty args reads as a table, not the strict handler error') + assert(args.query == nil, 'a field the caller left out reads nil') + assert(not pcall(function() args = { query = 'built' } end), 'an empty args is read-only') + assert(seen.args == nil, 'the write hook never sees args') + assert(args.query == nil, 'the refused assignment wrote nothing') return 'guarded'", ) .section( @@ -127,12 +129,12 @@ fn a_nil_argv_reads_nil_under_a_strict_index_and_a_write_hook_never_sees_it() { __newindex = function(_, key, value) seen[key] = value end, })", ); - let trace = Scenario::new(source.build()).args("broken json").run(); + let trace = Scenario::new(source.build()).run(); assert_eq!(trace.text(), "guarded"); } #[test] -fn setmetatable_on_g_in_a_block_frees_neither_argv_nor_prose() { +fn setmetatable_on_g_in_a_block_frees_neither_args_nor_prose() { let source = query_source().prose( "Only", " @@ -140,18 +142,19 @@ fn setmetatable_on_g_in_a_block_frees_neither_argv_nor_prose() { ```lua setmetatable(_G, nil) - local a = pcall(function() argv = 'x' end) + local a = pcall(function() args = 'x' end) setmetatable(_G, {}) local b = pcall(function() prose = 'x' end) captured = {} setmetatable(_G, { __newindex = function(_, k, v) captured[k] = v end }) - local c = pcall(function() argv = 'x' end) + local c = pcall(function() args = 'x' end) local d = pcall(function() prose = 'x' end) assert(not (a or b or c or d), 'every assignment is refused') - assert(captured.argv == nil and captured.prose == nil, 'the author hook never sees argv or prose') + assert(captured.args == nil, 'the author hook never sees args') + assert(captured.prose == nil, 'the author hook never sees prose') plain = 'y' assert(captured.plain == 'y', 'the author hook sees other globals') - assert(argv.query == 'x' and prose == 'First.', 'both still read as the Engine set them') + assert(args.query == 'x' and prose == 'First.', 'both still read as the Engine set them') ``` Second. @@ -159,7 +162,7 @@ fn setmetatable_on_g_in_a_block_frees_neither_argv_nor_prose() { ```lua assert(prose == 'Second.', 'the next fence installs its prose under the author metatable') assert(getmetatable(_G).__newindex ~= nil, 'the author metatable survives the next fence') - assert(not pcall(function() argv = 'x' end), 'argv is still read-only') + assert(not pcall(function() args = 'x' end), 'args is still read-only') return 'guarded' ```", ); @@ -212,7 +215,7 @@ fn getmetatable_on_g_returns_a_librarys_metatable_and_edits_apply_live() { getmetatable(_G).__index = function(_, key) return 'live ' .. key end assert(tone == 'live tone', 'an edit applies at once') getmetatable(_G).__newindex = function() end - assert(not pcall(function() argv = 'x' end), 'an edit cannot free argv') + assert(not pcall(function() args = 'x' end), 'an edit cannot free args') return tone", ) .section( diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs index 62d20a0b2..459be9a32 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs @@ -80,7 +80,7 @@ fn verify_require_library(prompt: &Prompt) { assert!(prepare.prologue().is_none()); assert_eq!( prepare.epilog().map(LuaProgram::source), - Some("require '/helpers'\nvar.subject = normalize(args)") + Some("require '/helpers'\nvar.subject = normalize(args.text)") ); assert_eq!(prepare.children().len(), 1); assert_eq!(prepare.children()[0].name(), "Author note"); @@ -130,7 +130,7 @@ fn verify_prologue_prose_epilog(prompt: &Prompt) { assert_eq!(transform.name(), "Transform"); assert_eq!( transform.prologue().map(LuaProgram::source), - Some("var.subject = args") + Some("var.subject = args.text") ); assert_eq!(transform.prose(), "Write about {{ var.subject }}."); assert_eq!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/prepare.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/prepare.rs index c9386871f..3224b89af 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/prepare.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/prepare.rs @@ -96,7 +96,7 @@ fn declaring(front: &str, lua: &str) -> String { /// The state a run of `prompt` builds over `params`, as `Run::new` builds /// it. fn state_of(prompt: &Prompt, params: RunParams) -> ChainState { - ChainState::root(Arc::new(prompt.clone()), "", params) + ChainState::root(Arc::new(prompt.clone()), "{}", params) } /// The refusal notice of `scenario`'s run, after asserting that its first diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs index 7a1e92b7e..67164cc40 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs @@ -14,7 +14,7 @@ fn a_section_uses_the_table_of_functions_a_sibling_library_returns() { "Main", " local shared = require '/shared_stuff' - return shared.search_all(argv.prose)", + return shared.search_all(args.text)", ), ( "shared_stuff", @@ -27,7 +27,7 @@ fn a_section_uses_the_table_of_functions_a_sibling_library_returns() { ), ], ); - let trace = require_case(source).args("rust").run(); + let trace = require_case(source).args(r#"{"text":"rust"}"#).run(); assert_eq!(trace.text(), "searched rust everywhere"); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs index 4aef09551..0d0432931 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs @@ -64,7 +64,7 @@ fn a_library_hands_its_prose_to_the_caller_and_leaves_the_callers_prose_alone() .prose( "house_style", " - Write in a {{ style }} voice for {{ args }}. + Write in a {{ style }} voice for {{ args.text }}. ```lua return prose @@ -80,7 +80,9 @@ fn a_library_hands_its_prose_to_the_caller_and_leaves_the_callers_prose_alone() error('the style broke') ```", ); - let trace = require_case(source.build()).args("readers").run(); + let trace = require_case(source.build()) + .args(r#"{"text":"readers"}"#) + .run(); assert_eq!( trace.text(), "First one.|First one.|Write in a terse voice for readers.|false|false|Broken terse.\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/vfs.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/vfs.rs index 3b8504c06..0b59aad6d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/vfs.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/vfs.rs @@ -1,8 +1,8 @@ //! The executor's `VfsRef` contract with its caller: an end-to-end run //! over the caller's handle, and the seed-run-extract round trip a caller -//! drives with no real files - seed the declared input through the run's -//! handle, run, extract the declared output, and charge a -//! missing output to the prompt's promise as an explicit contract error. +//! drives with no real files - seed a file through the run's handle, run, +//! extract the declared output, and charge a missing output to the +//! prompt's promise as an explicit contract error. //! Then the run-bounded scope: a run's `Done`, or its drop before `Done`, //! ends its scope however long the caller holds the store views it was //! handed. @@ -24,9 +24,6 @@ const ROUND_TRIP_PROMPT: &str = concat!( "name: round-trip\n", "description: Seed, run, extract\n", "runvil: 0\n", - "input:\n", - " path: paper.md\n", - " description: The input paper\n", "output:\n", " path: report.md\n", " description: The output report\n", @@ -44,9 +41,6 @@ const MISSING_OUTPUT: &str = concat!( "name: missing-output\n", "description: Never writes its promised report\n", "runvil: 0\n", - "input:\n", - " path: paper.md\n", - " description: The input paper\n", "output:\n", " path: report.md\n", " description: The output report\n", @@ -85,28 +79,23 @@ fn extract_declared_output(store: &Access, prompt: &Prompt) -> Result (String, VfsRef) { let vfs = VfsRef::default(); - seed_declared_input(&vfs, &parse_ok(source), contents); + seed_paper(&vfs, contents); let trace = Scenario::new(source).vfs(vfs.clone()).run(); (trace.text().to_owned(), vfs) } @@ -220,7 +209,7 @@ const HELD_STORE: &str = "---\nname: held-store\ndescription: d\nrunvil: 0\n---\ /// Starts the `HELD_STORE` run over `vfs`. fn held_store_run(vfs: &VfsRef) -> Run { let ctx = test_params().with_vfs(vfs.clone()); - Run::new(Arc::new(parse(HELD_STORE)), "", ctx) + Run::new(Arc::new(parse(HELD_STORE)), "{}", ctx) } /// Answers `effects` through the local performer, keeping each one in diff --git a/crates/runvil-internal/runtime/src/execute/tests/tasks.rs b/crates/runvil-internal/runtime/src/execute/tests/tasks.rs index 4f9222b5a..d04072fa6 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tasks.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tasks.rs @@ -148,7 +148,7 @@ fn a_finished_child_moves_its_entry_to_done_and_reports_task_succeeded() { &Observation::TaskStarted { task: task("0.0"), target: "Child".to_owned(), - input: None, + args: None, item: None, index: None, var: json!({}), @@ -202,11 +202,11 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { let md = spawner_prompt( "var.k = 1\n\ log('root taskid=' .. sys.taskid)\n\ - tasks.spawn('../Child', { input = 'child args', item = { name = 'alpha' }, index = 7 })\n\ + tasks.spawn('../Child', { args = { topic = 'child args' }, item = { name = 'alpha' }, index = 7 })\n\ store.write('park', 'x')\n\ return 'done'", "log('taskid=' .. sys.taskid .. ' id=' .. sys.id .. ' index=' .. sys.index\n\ - .. ' item=' .. item.name .. ' args=' .. args .. ' k=' .. tostring(var.k))", + .. ' item=' .. item.name .. ' topic=' .. args.topic .. ' k=' .. tostring(var.k))", ); let rig = Rig::run(scheduler_scenario(md)); assert_eq!(rig.trace().text(), "done", "the run completes"); @@ -217,7 +217,7 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { &Observation::TaskStarted { task: task("0.0"), target: "Child".to_owned(), - input: Some("child args".to_owned()), + args: Some(json!({ "topic": "child args" })), item: Some(json!({ "name": "alpha" })), index: Some(7), var: json!({ "k": 1 }), @@ -226,7 +226,7 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { recorder.position("Spawner", &Observation::Lua("root taskid=0".to_owned())); recorder.position( "Child", - &Observation::Lua("taskid=0.0 id=0.0.0 index=7 item=alpha args=child args k=1".to_owned()), + &Observation::Lua("taskid=0.0 id=0.0.0 index=7 item=alpha topic=child args k=1".to_owned()), ); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs b/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs index 8bd378364..4a2b15c2b 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs @@ -298,3 +298,32 @@ fn the_timeout_option_is_validated_at_the_call_site() { // A rejected option starts no timer: the child is the run's only task. assert_eq!(rig.task_state_for_test(&task("0.1")), None); } + +#[test] +fn a_wait_with_an_option_it_does_not_take_is_refused_and_starts_no_timer() { + let md = tasks_prompt( + "local t = tasks.spawn('../Child')\n\ + local ok1, e1 = pcall(tasks.join_any, { t }, { timout = 5 })\n\ + assert(not ok1 and e1.kind == 'lua', tostring(e1))\n\ + local ok2, e2 = pcall(tasks.join, { t }, { timeout = 5, mode = 'all' })\n\ + assert(not ok2 and e2.kind == 'lua', tostring(e2))\n\ + local _, ok, result = tasks.join_any({ t })\n\ + assert(ok and result == 'quick', tostring(result))\n\ + return tostring(e1) .. '|' .. tostring(e2)", + &[("Child", "return 'quick'")], + ); + let mut rig = Rig::run(scheduler_scenario(md)); + let out = rig.trace().text(); + let parts: Vec<&str> = out.split('|').collect(); + assert_eq!(parts.len(), 2, "{out}"); + assert!( + parts[0].contains("tasks.join_any: unknown key \"timout\"; the accepted key is timeout"), + "{out}" + ); + assert!( + parts[1].contains("tasks.join: unknown key \"mode\"; the accepted key is timeout"), + "{out}" + ); + // A refused option starts no timer: the child is the run's only task. + assert_eq!(rig.task_state_for_test(&task("0.1")), None); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/tool_scoping.rs b/crates/runvil-internal/runtime/src/execute/tests/tool_scoping.rs index af824527e..17ffc5d47 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tool_scoping.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tool_scoping.rs @@ -118,7 +118,7 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { "Only", ) .expect("the section VM builds") - .inject_test_values("", &json!({}), &fresh_access().0) + .inject_test_values(&json!({}), &json!({}), &fresh_access().0) .expect("values must inject"); // The section's `tools.offer` lands in its offers; the scope diff --git a/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs b/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs index 4fa203f0c..612ec9b48 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs @@ -21,7 +21,7 @@ fn finite_pipeline_runs_the_unified_surface_end_to_end() { .prose( "Draft", " - Summarize in one word: {{ args }} + Summarize in one word: {{ args.text }} ```lua var.draft = models.infer(prose) @@ -37,9 +37,9 @@ fn finite_pipeline_runs_the_unified_surface_end_to_end() { assert(messages[#messages].role == 'assistant', 'the terminal record is the assistant reply') return call('../Deliver', messages[#messages].content)", ) - .section("Deliver", "return 'delivered: ' .. args"); + .section("Deliver", "return 'delivered: ' .. args.text"); let trace = Scenario::new(source.build()) - .args("quantum") + .args(r#"{"text":"quantum"}"#) .bound() .tool(echo_tool()) .chat(&gateway) diff --git a/crates/runvil-internal/runtime/src/execute/tests/views.rs b/crates/runvil-internal/runtime/src/execute/tests/views.rs new file mode 100644 index 000000000..046e33e30 --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/views.rs @@ -0,0 +1,229 @@ +//! A `var` table and an `args` table at the executor's boundaries: handed +//! to a tool call, to `tasks.fanout`, to a spawn's `item`, and to a bare +//! global in prose, each arrives as the data it stands for, and so does +//! an `args` table written to `var` or held as a message record's `meta`. + +use runvil_prompt::test_support::{PromptSource, one_section, prompt}; + +use super::*; + +/// A declaration of an object that takes any field, so each `args` test +/// passes the shapes it hands on. +const ANY_FIELDS: &str = "args: { type: object, additionalProperties: true }"; + +/// A tool that answers with its arguments as JSON text. +#[track_caller] +fn args_tool() -> FnTool { + FnTool::new("tools/args", json!({ "type": "object" }), |args| { + Ok(ToolOutput::trusted(args.to_string())) + }) + .description("Answer with the arguments as JSON text.") +} + +#[test] +fn a_var_table_handed_to_a_tool_call_arrives_as_its_data() { + let source = one_section( + "var.payload = { value = 'hi', tags = { 'a', 'b' }, none = json.array() }\n\ + return tools.call('tools/args', var.payload) .. ' ' ..\n\ + tools.call('tools/args', { inner = var.payload.tags })", + ); + let trace = Scenario::new(source).tool(args_tool()).run(); + assert_eq!( + trace.text(), + r#"{"none":[],"tags":["a","b"],"value":"hi"} {"inner":["a","b"]}"# + ); +} + +#[test] +fn a_var_list_fans_out_one_arm_for_each_member() { + let source = prompt( + "", + &[ + ( + "Parent", + "var.rows = { { name = 'alpha' }, { name = 'beta' } }\n\ + local r = tasks.fanout('Worker', var.rows)\n\ + return #r .. ':' .. r[1].text .. ',' .. r[2].text .. ':' .. r[2].item.name", + ), + ("### Worker", "return item.name .. '-' .. sys.index"), + ], + ); + assert_eq!(Scenario::new(source).run().text(), "2:alpha-1,beta-2:beta"); +} + +#[test] +fn a_var_table_as_a_spawn_item_arrives_as_its_data() { + let source = prompt( + "", + &[ + ( + "Parent", + "var.rows = { { name = 'alpha' } }\n\ + local task = tasks.spawn('Worker', { item = { row = var.rows[1] } })\n\ + local _, ok, result = tasks.join_any({ task })\n\ + return tostring(ok) .. ':' .. result", + ), + ("### Worker", "return item.row.name"), + ], + ); + assert_eq!(Scenario::new(source).run().text(), "true:alpha"); +} + +#[test] +fn a_bare_global_holding_a_var_table_renders_as_its_data_in_prose() { + let source = PromptSource::new().prose( + "Only", + " + ```lua + var.files = { 'a.md', 'b.md' } + files = var.files + first = { name = var.files[1], all = var.files } + ``` + + Files: {{ files }}; first {{ first.name }} of {{ first.all }}. + + ```lua + return prose + ```", + ); + assert_eq!( + Scenario::new(source.build()).run().text(), + "Files: [\"a.md\",\"b.md\"]; first a.md of [\"a.md\",\"b.md\"]." + ); +} + +#[test] +fn an_args_table_handed_to_a_tool_call_arrives_as_its_data() { + let source = prompt( + ANY_FIELDS, + &[( + "Only", + "return tools.call('tools/args', args) .. ' ' ..\n\ + tools.call('tools/args', { inner = args.tags, row = args.rows[1] })", + )], + ); + let trace = Scenario::new(source) + .args(r#"{"value":"hi","tags":["a","b"],"none":[],"rows":[{"name":"alpha"}]}"#) + .tool(args_tool()) + .run(); + assert_eq!( + trace.text(), + r#"{"none":[],"rows":[{"name":"alpha"}],"tags":["a","b"],"value":"hi"} {"inner":["a","b"],"row":{"name":"alpha"}}"# + ); +} + +#[test] +fn an_args_list_fans_out_one_arm_for_each_member() { + let source = prompt( + ANY_FIELDS, + &[ + ( + "Parent", + "local r = tasks.fanout('Worker', args.rows)\n\ + return #r .. ':' .. r[1].text .. ',' .. r[2].text .. ':' .. r[2].item.name", + ), + ("### Worker", "return item.name .. '-' .. sys.index"), + ], + ); + let trace = Scenario::new(source) + .args(r#"{"rows":[{"name":"alpha"},{"name":"beta"}]}"#) + .run(); + assert_eq!(trace.text(), "2:alpha-1,beta-2:beta"); +} + +#[test] +fn an_args_table_as_a_spawn_item_arrives_as_its_data() { + let source = prompt( + ANY_FIELDS, + &[ + ( + "Parent", + "local task = tasks.spawn('Worker', { item = { row = args.rows[1], whole = args } })\n\ + local _, ok, result = tasks.join_any({ task })\n\ + return tostring(ok) .. ':' .. result", + ), + ( + "### Worker", + "return item.row.name .. ' of ' .. json.encode(item.whole)", + ), + ], + ); + let trace = Scenario::new(source) + .args(r#"{"rows":[{"name":"alpha"}]}"#) + .run(); + assert_eq!(trace.text(), r#"true:alpha of {"rows":[{"name":"alpha"}]}"#); +} + +#[test] +fn an_args_table_written_to_var_is_its_data_which_var_can_then_change() { + let source = prompt( + ANY_FIELDS, + &[ + ( + "First", + "var.copy = args\n\ + var.tags = args.tags\n\ + var.copy.value = 'rewritten'\n\ + var.tags[#var.tags + 1] = 'c'", + ), + ( + "Second", + "return json.encode(var.copy) .. ' ' .. json.encode(var.tags) .. ' ' ..\n\ + args.value .. ' ' .. #args.tags", + ), + ], + ); + let trace = Scenario::new(source) + .args(r#"{"value":"hi","tags":["a","b"]}"#) + .run(); + assert_eq!( + trace.text(), + r#"{"tags":["a","b"],"value":"rewritten"} ["a","b","c"] hi 2"#, + "the write to `var` copies the data, so `args` reads as the caller sent it" + ); +} + +#[test] +fn an_args_table_as_a_message_records_meta_arrives_as_its_data() { + let source = prompt( + ANY_FIELDS, + &[( + "Only", + "messages:user('look', args)\n\ + messages:append({ role = 'user', content = 'go on', meta = { tags = args.tags } })\n\ + return json.encode(messages[1].meta) .. ' ' .. json.encode(messages[2].meta)", + )], + ); + let trace = Scenario::new(source) + .args(r#"{"value":"hi","tags":["a","b"]}"#) + .run(); + assert_eq!( + trace.text(), + r#"{"tags":["a","b"],"value":"hi"} {"tags":["a","b"]}"# + ); +} + +#[test] +fn a_bare_global_holding_an_args_table_renders_as_its_data_in_prose() { + let source = PromptSource::new().front(ANY_FIELDS).prose( + "Only", + " + ```lua + files = args.files + first = { name = args.files[1], all = args.files, whole = args } + ``` + + Files: {{ files }}; first {{ first.name }} of {{ first.all }}; whole {{ first.whole }}. + + ```lua + return prose + ```", + ); + let trace = Scenario::new(source.build()) + .args(r#"{"files":["a.md","b.md"]}"#) + .run(); + assert_eq!( + trace.text(), + r#"Files: ["a.md","b.md"]; first a.md of ["a.md","b.md"]; whole {"files":["a.md","b.md"]}."# + ); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/waits.rs b/crates/runvil-internal/runtime/src/execute/tests/waits.rs index 694a5c9e6..ed8cd43db 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/waits.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/waits.rs @@ -10,7 +10,7 @@ use runvil_types::event::Event; use super::context::task; -use super::scheduler::{asks, scheduler_scenario}; +use super::scheduler::{asks, is_timer, scheduler_scenario}; use super::tasks::TaskRecorder; use super::*; use crate::execute::scheduler::test_hooks::TaskState; @@ -183,6 +183,44 @@ fn status_reports_a_parked_task_and_then_a_finished_one() { ); } +#[test] +fn status_reports_no_block_for_a_task_whose_answer_arrived() { + // The spawner's timed wait and the child's model round are both held. + // The timer is released and then the round, before the run steps, so + // the spawner runs first and reads the child's status while the child + // holds its reply and has not run since: the round is counted, and the + // child waits on nothing. + let gateway = ScriptedChat::new(vec![text("slow answer")]); + let md = tasks_prompt( + "local t = tasks.spawn('../Child')\n\ + local first = tasks.join_any({ t }, { timeout = 30 })\n\ + assert(first == nil, 'the timer fires first')\n\ + local s = tasks.status(t)\n\ + log('state=' .. s.state .. ' section=' .. tostring(s.section)\n\ + .. ' blocked=' .. tostring(s.blocked) .. ' turns=' .. s.turns)\n\ + local _, ok, result = tasks.join_any({ t })\n\ + assert(ok and result == 'slow answer', tostring(result))\n\ + return 'done'", + &[("Child", "return models.infer('slow please')")], + ); + let slow = asks("slow please"); + let mut rig = Rig::start( + scheduler_scenario(md) + .chat(&gateway) + .hold(move |effect| is_timer(effect) || slow(effect)), + ); + rig.settle(); + rig.release(is_timer); + rig.release(asks("slow please")); + let rig = rig.finish(); + assert_eq!(rig.trace().text(), "done"); + assert_eq!( + TaskRecorder::replayed(rig.trace()).logs("Main"), + vec!["state=running section=Child blocked=nil turns=1".to_owned()], + "a task that holds its reply is not blocked on the round" + ); +} + #[test] fn a_non_owner_is_refused_with_task_not_owned() { // The child may read and annotate its own task (the self exception), diff --git a/crates/runvil-internal/runtime/src/lua/tests.rs b/crates/runvil-internal/runtime/src/lua/tests.rs index f5439a819..0cdb754bf 100644 --- a/crates/runvil-internal/runtime/src/lua/tests.rs +++ b/crates/runvil-internal/runtime/src/lua/tests.rs @@ -87,7 +87,6 @@ fn scheduler_vm_with_tools( section: "Test", tools, models, - argv: None, seed: VmSeed { var, item: None }, ui: None, }) @@ -103,14 +102,13 @@ struct VmBinds<'a> { section: &'a str, tools: &'a ToolSet, models: &'a ModelSet, - argv: Option<&'a serde_json::Value>, seed: VmSeed<'a>, ui: Option<&'a Arc>, } /// Builds the section VM `binds` describes through the real setup path, -/// with no args or preludes and a `require` whose resolver reaches no -/// library. +/// with the empty object as its `args`, no preludes, and a `require` +/// whose resolver reaches no library. #[track_caller] fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { let emitter = null_emitter(); @@ -122,13 +120,13 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { binds.section, ) .expect("the section VM builds"); + let args = json!({}); let sys = json!({}); let (access, scope) = fresh_access(); let set = binds.models; let selection = Arc::new(Mutex::new(set.binding("default").cloned())); let setup = SectionVmSetup { - args: "", - argv: binds.argv, + args: &args, sys: &sys, access: &access, scope: &scope, diff --git a/crates/runvil-internal/runtime/src/lua/tests/globals.rs b/crates/runvil-internal/runtime/src/lua/tests/globals.rs index 8b6cde65e..634dd516b 100644 --- a/crates/runvil-internal/runtime/src/lua/tests/globals.rs +++ b/crates/runvil-internal/runtime/src/lua/tests/globals.rs @@ -59,18 +59,16 @@ fn offering() -> ToolSet { /// Builds a section VM through the real setup path with every conditional /// Engine global bound: a caller-supplied application-state snapshot -/// (`ui`), a collection member (`item`), a non-nil `argv`, +/// (`ui`), a collection member (`item`), /// a block's lazy `prose`, the model roles in [`ROLE_LABELS`], the tools of /// [`offering`], and a `require` whose resolver reaches no library. fn fully_bound_vm() -> SectionVm { - let argv = json!({ "prose": "" }); let item = json!("member"); let ui = Arc::new(json!({})); let vm = set_up_vm(&VmBinds { section: "Globals", tools: &offering(), models: &roles(), - argv: Some(&argv), seed: VmSeed { var: None, item: Some(&item), diff --git a/crates/runvil-internal/runtime/src/lua/tests/shims.rs b/crates/runvil-internal/runtime/src/lua/tests/shims.rs index 67d4dc968..02303f7b9 100644 --- a/crates/runvil-internal/runtime/src/lua/tests/shims.rs +++ b/crates/runvil-internal/runtime/src/lua/tests/shims.rs @@ -31,18 +31,18 @@ fn models_infer_yields_a_well_formed_request() { } #[test] -fn call_yields_target_input_and_the_var_snapshot() { +fn call_yields_target_args_and_the_var_snapshot() { let var = json!({ "k": 1 }); let vm = scheduler_vm(&ModelSet::default(), Some(&var)); - match yielded_request(&vm, r#"return call("../Child", "override")"#) { + match yielded_request(&vm, r#"return call("../Child", { query = "papers" })"#) { Request::Call { target, - input, + args, var, from, } => { assert_eq!(target, "../Child"); - assert_eq!(input.as_deref(), Some("override")); + assert_eq!(args, Some(json!({ "query": "papers" }))); assert_eq!(var, json!({ "k": 1 })); assert_eq!(from, None, "a section's own call names no library"); } @@ -50,6 +50,27 @@ fn call_yields_target_input_and_the_var_snapshot() { } } +#[test] +fn call_yields_a_string_as_given_the_args_table_as_its_data_and_no_args_as_none() { + // The VM's `args` is the empty object, so `call(target, args)` yields + // that object, read through the read-only table. + let vm = scheduler_vm(&ModelSet::default(), None); + for (source, expected) in [ + ( + r#"return call("../Child", "plain text")"#, + Some(json!("plain text")), + ), + (r#"return call("../Child", args)"#, Some(json!({}))), + (r#"return call("../Child", {})"#, Some(json!({}))), + (r#"return call("../Child")"#, None), + ] { + match yielded_request(&vm, source) { + Request::Call { args, .. } => assert_eq!(args, expected, "{source}"), + other => panic!("expected a call request, got {other:?}"), + } + } +} + #[test] 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 @@ -60,14 +81,14 @@ fn fanout_yields_a_spawn_per_member_starting_with_the_first() { match yielded_request(&vm, r#"return tasks.fanout("Worker", {"a", "b"})"#) { Request::Spawn { target, - input, + args, item, index, var, from, } => { assert_eq!(target, "Worker"); - assert_eq!(input, None); + assert_eq!(args, None, "an arm reads its caller's args"); assert_eq!(item, Some(json!("a"))); assert_eq!(index, Some(1)); assert_eq!(var, json!({})); @@ -103,19 +124,18 @@ fn fanout_rejects_an_empty_collection_before_any_spawn() { fn tasks_spawn_yields_the_target_seeds_and_var() { let var = json!({ "k": 1 }); let vm = scheduler_vm(&ModelSet::default(), Some(&var)); - let source = - r#"return tasks.spawn("../Child", { input = "in", item = { name = "a" }, index = 2 })"#; + let source = r#"return tasks.spawn("../Child", { args = { query = "in" }, item = { name = "a" }, index = 2 })"#; match yielded_request(&vm, source) { Request::Spawn { target, - input, + args, item, index, var, from, } => { assert_eq!(target, "../Child"); - assert_eq!(input.as_deref(), Some("in")); + assert_eq!(args, Some(json!({ "query": "in" }))); assert_eq!(item, Some(json!({ "name": "a" }))); assert_eq!(index, Some(2)); assert_eq!(var, json!({ "k": 1 })); diff --git a/crates/runvil-internal/runtime/src/model/tests.rs b/crates/runvil-internal/runtime/src/model/tests.rs index 840692757..053cde415 100644 --- a/crates/runvil-internal/runtime/src/model/tests.rs +++ b/crates/runvil-internal/runtime/src/model/tests.rs @@ -79,7 +79,7 @@ fn section_vm_with_models( emitter, section, )? - .inject_test_values("", &json!({}), &fresh_access().0) + .inject_test_values(&json!({}), &json!({}), &fresh_access().0) .map_err(|failure| failure.teardown(emitter, section)) } diff --git a/crates/runvil-internal/runtime/src/subst-tests.rs b/crates/runvil-internal/runtime/src/subst-tests.rs index cc60442fe..bca833423 100644 --- a/crates/runvil-internal/runtime/src/subst-tests.rs +++ b/crates/runvil-internal/runtime/src/subst-tests.rs @@ -1,8 +1,9 @@ //! Regression coverage for `{{ }}` prose substitution: the resolution -//! of the `args`, `argv`, `item`, `var`, `sys`, and bare-global sources, the +//! of the `args`, `item`, `var`, `sys`, and bare-global sources, the //! escape grammar, path segment validation, and the failure reasons. use std::collections::BTreeMap; +use std::sync::LazyLock; use runvil_types::error::SourceLoc; @@ -22,13 +23,24 @@ fn no_globals(_: &str) -> Result> { /// 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 +/// The stock args object: a string, an integer, a nested object, a list, +/// and a null. +static STOCK_ARGS: LazyLock = LazyLock::new(|| { + json!({ + "name": "Acme Corp", + "limit": 3, + "row": { "a": 1 }, + "tags": ["x", "y"], + "none": null, + }) +}); + +/// Sources with the stock args object and no item, over the given /// `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", - argv: None, + args: &STOCK_ARGS, item: None, var, sys, @@ -79,78 +91,52 @@ fn substitution_diagnostics_escape_and_bound_the_placeholder() { } #[test] -fn resolves_args() { - assert_eq!(run("hi {{ args }}!").unwrap(), "hi Acme Corp!"); +fn resolves_an_args_field() { + assert_eq!(run("hi {{ args.name }}!").unwrap(), "hi Acme Corp!"); + assert_eq!(run("at most {{ args.limit }}").unwrap(), "at most 3"); } -// --- argv: the parsed-args namespace ------------------------------------ - #[test] -fn resolves_argv_whole_value() { - let var = json!({}); - let sys = json!({}); - let argv = json!({ "query": "papers", "n": 2 }); - let sources = Sources { - argv: Some(&argv), - ..test_sources(&var, &sys) - }; - let out = substitute("got {{ argv }}", &sources).unwrap(); - assert_eq!(out, "got {\"n\":2,\"query\":\"papers\"}"); -} - -#[test] -fn resolves_argv_dotted_path() { - let var = json!({}); - let sys = json!({}); - let argv = json!({ "query": "papers", "row": { "a": 1 } }); - let sources = Sources { - argv: Some(&argv), - ..test_sources(&var, &sys) - }; - let out = substitute("q={{ argv.query }} cell={{ argv.row.a }}", &sources).unwrap(); - assert_eq!(out, "q=papers cell=1"); +fn resolves_an_args_dotted_path() { + assert_eq!( + run("cell={{ args.row.a }} row={{ args.row }} tags={{ args.tags }}").unwrap(), + "cell=1 row={\"a\":1} tags=[\"x\",\"y\"]" + ); } #[test] -fn scalar_argv_renders_whole() { +fn bare_args_renders_the_whole_object_as_compact_json() { + assert_eq!( + run("got {{ args }}").unwrap(), + "got {\"limit\":3,\"name\":\"Acme Corp\",\"none\":null,\ + \"row\":{\"a\":1},\"tags\":[\"x\",\"y\"]}" + ); let var = json!({}); let sys = json!({}); - let argv = json!(42); + let empty = json!({}); let sources = Sources { - argv: Some(&argv), + args: &empty, ..test_sources(&var, &sys) }; - let out = substitute("{{ argv }}", &sources).unwrap(); - assert_eq!(out, "42"); + assert_eq!(substitute("got {{ args }}", &sources).unwrap(), "got {}"); } #[test] -fn nil_argv_is_an_error() { - let var = json!({}); - let sys = json!({}); - for prose in ["{{ argv }}", "{{ argv.x }}"] { - let e = substitute(prose, &test_sources(&var, &sys)).unwrap_err(); - assert_eq!(reason(&e), Some("nil_argv"), "path {prose:?}"); - assert!(e.to_string().contains("argv"), "names argv: {e}"); - assert!( - e.to_string().contains("is nil (argv holds no value)"), - "names the nil cause: {e}" - ); +fn an_args_field_the_object_lacks_is_a_missing_key() { + for prose in ["{{ args.query }}", "{{ args.row.b }}", "{{ args.name.x }}"] { + let e = err_of(prose); + assert_eq!(reason(&e), Some("missing_key"), "path {prose:?}"); + assert_eq!(e.message, format!("missing {prose} [MissingKey at byte 0]")); } } #[test] -fn dotted_index_into_a_scalar_argv_is_an_error() { - // Never a silent empty string: the existing missing-key failure. +fn a_null_args_field_is_a_null_value() { let var = json!({}); let sys = json!({}); - let argv = json!("scalar"); - let sources = Sources { - argv: Some(&argv), - ..test_sources(&var, &sys) - }; - let e = substitute("{{ argv.x }}", &sources).unwrap_err(); - assert_eq!(reason(&e), Some("missing_key")); + let e = substitute("{{ args.none }}", &test_sources(&var, &sys)).unwrap_err(); + assert_eq!(reason(&e), Some("null_value")); + assert_eq!(e.message, "missing {{ args.none }} [NullValue at byte 0]"); } #[test] @@ -206,7 +192,7 @@ fn escaped_delimiters_are_literal() { #[test] fn escape_then_real_placeholder_adjacent() { // First delimiter escaped, second one live and resolved. - assert_eq!(run(r"\{{x}}{{ args }}").unwrap(), "{{x}}Acme Corp"); + assert_eq!(run(r"\{{x}}{{ args.name }}").unwrap(), "{{x}}Acme Corp"); } #[test] @@ -217,16 +203,17 @@ fn lone_backslash_is_literal() { #[test] fn replacement_produced_delimiters_are_not_resubstituted() { - let var = json!({ "payload": "{{ args }}" }); + let var = json!({ "payload": "{{ args.name }}" }); let sys = json!({}); // `var.payload` renders text that looks like a placeholder; it must be // emitted verbatim, never resolved against args. + let args = json!({ "name": "SECRET" }); let sources = Sources { - args: "SECRET", + args: &args, ..test_sources(&var, &sys) }; let out = substitute("value: {{ var.payload }}", &sources).unwrap(); - assert_eq!(out, "value: {{ args }}"); + assert_eq!(out, "value: {{ args.name }}"); } // --- Path segment grammar ------------------------------------------------ @@ -398,8 +385,8 @@ fn non_json_bare_global_is_an_error() { #[test] fn bare_namespaces_still_require_a_key() { - // `{{ var }}` and `{{ sys }}` stay `bad_path`; only bare globals render - // whole values. + // `{{ var }}` and `{{ sys }}` are `bad_path`; only `args` and a bare + // global render whole values. for prose in ["{{ var }}", "{{ sys }}"] { let e = err_of(prose); assert_eq!(reason(&e), Some("bad_path"), "path {prose:?}"); @@ -427,9 +414,12 @@ fn null_value_and_nil_item_reasons() { #[test] fn not_a_table_reason() { - let e = err_of("{{ args.x }}"); + let e = err_of("{{ item.x }}"); assert_eq!(reason(&e), Some("not_a_table")); - assert!(e.to_string().contains("not a table")); + assert_eq!( + e.message, + "item takes no key path; {{ item }} renders the whole item [NotATable at byte 0]" + ); } // --- Null, arrays, trust-neutral passthrough ----------------------------- diff --git a/crates/runvil-internal/runtime/src/subst.rs b/crates/runvil-internal/runtime/src/subst.rs index 1290ce1cb..2c89ba457 100644 --- a/crates/runvil-internal/runtime/src/subst.rs +++ b/crates/runvil-internal/runtime/src/subst.rs @@ -2,11 +2,11 @@ //! //! When a section's Lua first reads the lazy `prose` value, the Engine //! resolves `{{ path }}` placeholders in the pending Markdown template. Lua -//! source is never substituted. Five sources are available: `args` (the single -//! raw input string), `argv` (the chain's `argv`, derived from its args; -//! nil when it holds no value), `item` (the item seeded into this section - a fanout arm's or a -//! `tasks.spawn` task's first entry - nil otherwise), `var` (values the -//! section's Lua wrote), and `sys` (runtime-provided metadata). An unknown +//! source is never substituted. Four sources are available: `args` (the +//! chain's arguments, a JSON object), `item` (the item seeded into this +//! section - a fanout arm's or a `tasks.spawn` task's first entry - nil +//! otherwise), `var` (values the section's Lua wrote), and `sys` +//! (runtime-provided metadata). An unknown //! first segment resolves as a bare global: a section-local Lua global //! (`x = 42` without `local`) read through a caller-supplied lookup, with //! dotted paths indexing into its JSON form. Resolution is a single pass with @@ -51,17 +51,14 @@ use crate::Result; /// (`var.`, `var..x`). /// - `unknown_namespace`: the leading namespace is not one of the known /// roots and names no bare global. -/// - `not_a_table`: a key path after `args` or `item`, which render whole -/// only. -/// - `missing_key`: a key path found no value: under `var`, `sys`, `argv`, +/// - `not_a_table`: a key path after `item`, which renders whole only. +/// - `missing_key`: a key path found no value: under `var`, `sys`, `args`, /// or a bare global. /// - `null_value`: the resolved value was JSON null, for a `var`, `sys`, -/// `argv`, or bare global source. A null `item` is not this: `{{ item }}` +/// `args`, or bare global source. A null `item` is not this: `{{ item }}` /// renders it as the text `null`. /// - `nil_item`: `{{ item }}` was used with no item seeded into this /// section. -/// - `nil_argv`: an `argv` placeholder, bare or dotted, was used while -/// `argv` was nil. /// - `serialize`: the bare-global lookup failed: the global cannot become /// JSON data, the name is `prose` itself, or the lookup raised. fn make_failure_error(reason: &str, offset: usize, detail: String) -> Error { @@ -87,12 +84,11 @@ type SubstResult = std::result::Result; /// The value sources `{{ }}` placeholders resolve against. /// -/// `var` and `sys` are JSON objects. +/// `args`, `var`, and `sys` are JSON objects. pub(crate) struct Sources<'a> { - /// The raw args string: `{{ args }}`. - pub(crate) args: &'a str, - /// The chain's `argv`: `{{ argv }}` and dotted paths. - pub(crate) argv: Option<&'a Value>, + /// The chain's arguments: `{{ args }}`, which renders the whole + /// object, and dotted paths. + pub(crate) args: &'a Value, /// The item seeded into this section on a fanout arm's or a /// `tasks.spawn` task's first entry, rendered per /// [`render_item`](crate::lua::render_item): `{{ item }}`. `None` on @@ -179,20 +175,6 @@ fn placeholder_line(line: Option, before: &str) -> Option(argv: Option<&'a Value>, path: &str, offset: usize) -> SubstResult<&'a Value> { - argv.ok_or_else(|| { - make_failure_error( - "nil_argv", - offset, - format!( - "{{{{ {} }}}} is nil (argv holds no value)", - path_preview(path) - ), - ) - }) -} - /// Resolves an unknown first segment as a bare global: the caller's lookup /// reads the section-local Lua global and converts it to JSON. fn bare_global_root( @@ -229,9 +211,6 @@ fn bare_global_root( /// Resolves a single `{{ }}` path to its rendered string. fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { - if path == "args" { - return Ok(sources.args.to_string()); - } if path == "item" { return sources.item.map(crate::lua::render_item).ok_or_else(|| { make_failure_error( @@ -241,10 +220,6 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult) -> SubstResult sources.var, "sys" => sources.sys, - "argv" => nil_argv(sources.argv, path, offset)?, - "args" => { - return Err(make_failure_error( - "not_a_table", - offset, - "args is a string, not a table".to_string(), - )); - } + "args" => sources.args, "item" => { return Err(make_failure_error( "not_a_table", @@ -305,8 +273,8 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { return Err(make_failure_error( "bad_path", diff --git a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs index 1ebd73674..3ab6ed92d 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs @@ -137,7 +137,7 @@ fn one_of_every_event_variant() -> Vec<(Event, Seam)> { provenance: provenance(), task: task.clone(), target: "Child".to_owned(), - input: None, + args: None, item: None, index: None, var: serde_json::json!({}), diff --git a/crates/runvil-internal/runtime/src/test_support/recording/forward.rs b/crates/runvil-internal/runtime/src/test_support/recording/forward.rs index cba8ce20b..2e03a1680 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/forward.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/forward.rs @@ -193,7 +193,7 @@ fn forward_task(event: Event, observer: &dyn Observer) { section, task, target, - input, + args, item, index, var, @@ -203,7 +203,7 @@ fn forward_task(event: Event, observer: &dyn Observer) { Observation::TaskStarted { task, target, - input, + args, item, index, var, diff --git a/crates/runvil-internal/runtime/src/test_support/recording/observation.rs b/crates/runvil-internal/runtime/src/test_support/recording/observation.rs index 9c2fb7882..cb9524772 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/observation.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/observation.rs @@ -170,8 +170,8 @@ pub(crate) enum Observation { task: TaskId, /// The name of the section the task's chain starts at. target: String, - /// The `opts.input` override, when given. - input: Option, + /// The chain's own `args`, when `opts.args` gave them. + args: Option, /// The `opts.item` seed, when given. item: Option, /// The `opts.index` seed, when given. diff --git a/crates/runvil-internal/runtime/src/test_support/scenario.rs b/crates/runvil-internal/runtime/src/test_support/scenario.rs index 687685ab8..713c0688f 100644 --- a/crates/runvil-internal/runtime/src/test_support/scenario.rs +++ b/crates/runvil-internal/runtime/src/test_support/scenario.rs @@ -250,12 +250,12 @@ pub(crate) struct Scenario { impl Scenario { /// A run of `source` under [`TEST_SEED`] and [`TEST_STARTED_AT`], with - /// empty args, a fresh memory store, no tools, no model, nothing filled - /// in, and no held effect. + /// the empty object `{}` as its args, a fresh memory store, no tools, + /// no model, nothing filled in, and no held effect. pub(crate) fn new(source: impl Into) -> Scenario { Scenario { source: source.into(), - args: String::new(), + args: "{}".to_owned(), seed: TEST_SEED, params: Box::new(|ctx| ctx), store: TestStore::new(), @@ -270,7 +270,9 @@ impl Scenario { } } - /// Sets the args string the run receives. + /// Sets the args the run receives: the text of one JSON object, which + /// the run's first step checks against the prompt's `args` + /// declaration. pub(crate) fn args(mut self, args: &str) -> Scenario { args.clone_into(&mut self.args); self diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/prologue-return.md b/crates/runvil-internal/runtime/tests/prompts/execution/prologue-return.md index d0cd1ac82..033c33601 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/prologue-return.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/prologue-return.md @@ -8,7 +8,7 @@ runvil: 0 ```lua log("returning early") -return args +return args.text ``` This prose must not reach a model. diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/store-fallthrough.md b/crates/runvil-internal/runtime/tests/prompts/execution/store-fallthrough.md index 70627cb89..5b12a94ac 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/store-fallthrough.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/store-fallthrough.md @@ -8,7 +8,7 @@ runvil: 0 ```lua log("writing state") -store.write("handoff.txt", args) +store.write("handoff.txt", args.text) ``` ## Read diff --git a/crates/runvil-internal/runtime/tests/prompts/valid/prologue-prose-epilog.md b/crates/runvil-internal/runtime/tests/prompts/valid/prologue-prose-epilog.md index ff815e02e..22251159b 100644 --- a/crates/runvil-internal/runtime/tests/prompts/valid/prologue-prose-epilog.md +++ b/crates/runvil-internal/runtime/tests/prompts/valid/prologue-prose-epilog.md @@ -10,7 +10,7 @@ Transform one model response. ## Transform ```lua -var.subject = args +var.subject = args.text ``` Write about {{ var.subject }}. diff --git a/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md b/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md index f661cf6b3..5f24ffbf0 100644 --- a/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md +++ b/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md @@ -12,7 +12,7 @@ Normalize the supplied subject. ```lua require '/helpers' -var.subject = normalize(args) +var.subject = normalize(args.text) ``` ### Author note diff --git a/crates/runvil-internal/types/src/emitter.rs b/crates/runvil-internal/types/src/emitter.rs index 1d2f09271..117431efa 100644 --- a/crates/runvil-internal/types/src/emitter.rs +++ b/crates/runvil-internal/types/src/emitter.rs @@ -441,7 +441,7 @@ mod tests { provenance, task: task.clone(), target: "Worker".to_owned(), - input: Some("in".to_owned()), + args: Some(serde_json::json!({ "text": "in" })), item: Some(serde_json::json!("x")), index: Some(2), var: serde_json::json!({ "k": 1 }), @@ -456,8 +456,8 @@ mod tests { let events = sink.take(); assert!(matches!( &events[0], - Event::TaskStarted { task: started, target, input: Some(input), item: Some(_), index: Some(2), var, .. } - if *started == task && target == "Worker" && input == "in" && var["k"] == 1 + Event::TaskStarted { task: started, target, args: Some(args), item: Some(_), index: Some(2), var, .. } + if *started == task && target == "Worker" && args["text"] == "in" && var["k"] == 1 )); assert!(matches!( &events[1], diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index feaf4b5da..a0189819f 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -88,7 +88,7 @@ fn an_error_is_a_cancel_exactly_when_its_code_is_canceled() { } /// Every code, in declaration order. -const ALL: [ErrorCode; 68] = [ +const ALL: [ErrorCode; 69] = [ ErrorCode::FrontmatterInvalid, ErrorCode::StructureInvalid, ErrorCode::FenceInvalid, @@ -154,6 +154,7 @@ const ALL: [ErrorCode; 68] = [ ErrorCode::Canceled, ErrorCode::Internal, ErrorCode::Stalled, + ErrorCode::ArgsInvalid, ErrorCode::RecorderFailed, ErrorCode::PluginLoadFailed, ErrorCode::NoOutput, @@ -227,7 +228,8 @@ fn family(code: ErrorCode) -> u16 { ErrorCode::RequirementsUnmet | ErrorCode::Canceled | ErrorCode::Internal - | ErrorCode::Stalled => 7, + | ErrorCode::Stalled + | ErrorCode::ArgsInvalid => 7, ErrorCode::RecorderFailed | ErrorCode::PluginLoadFailed | ErrorCode::NoOutput => 8, } } diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index c658ccfce..54c9de8df 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -431,6 +431,9 @@ pub enum ErrorCode { /// name `stalled`; names the tasks waiting on their /// inboxes. A run code, beside `Internal`. Stalled = 704, + /// The run's arguments are not a JSON object, or do not match the + /// prompt's `args:` declaration. + ArgsInvalid = 705, /// The run recorder failed to store a write. RecorderFailed = 801, @@ -519,6 +522,7 @@ impl ErrorCode { ErrorCode::Canceled => "canceled", ErrorCode::Internal => "internal", ErrorCode::Stalled => "stalled", + ErrorCode::ArgsInvalid => "args_invalid", ErrorCode::RecorderFailed => "recorder_failed", ErrorCode::PluginLoadFailed => "plugin_load_failed", ErrorCode::NoOutput => "no_output", @@ -595,6 +599,7 @@ impl ErrorCode { "canceled" => ErrorCode::Canceled, "internal" => ErrorCode::Internal, "stalled" => ErrorCode::Stalled, + "args_invalid" => ErrorCode::ArgsInvalid, "recorder_failed" => ErrorCode::RecorderFailed, "plugin_load_failed" => ErrorCode::PluginLoadFailed, "no_output" => ErrorCode::NoOutput, @@ -807,6 +812,10 @@ impl ErrorCode { "No chain can move, so the run stopped. Send each task that waits on its inbox the \ message it waits for, or stop waiting on that task." } + ErrorCode::ArgsInvalid => { + "The arguments do not match what the prompt declares. Send one JSON object that \ + matches its declared arguments." + } ErrorCode::RecorderFailed => "The run could not be recorded, so it stopped.", ErrorCode::PluginLoadFailed => { "A plugin could not be loaded, so its tools are not available." diff --git a/crates/runvil-internal/types/src/event-tests.rs b/crates/runvil-internal/types/src/event-tests.rs index 5dd7dc51f..ae1a5b9dc 100644 --- a/crates/runvil-internal/types/src/event-tests.rs +++ b/crates/runvil-internal/types/src/event-tests.rs @@ -135,7 +135,7 @@ fn task_samples() -> Vec { provenance: provenance("0", 2), task: task("0.0"), target: "Worker".to_owned(), - input: Some("arg text".to_owned()), + args: Some(json!({ "text": "arg text" })), item: Some(json!({ "key": "value" })), index: Some(3), var: json!({ "topic": "leap days" }), diff --git a/crates/runvil-internal/types/src/event.rs b/crates/runvil-internal/types/src/event.rs index c6e481dff..66391ebcd 100644 --- a/crates/runvil-internal/types/src/event.rs +++ b/crates/runvil-internal/types/src/event.rs @@ -267,8 +267,10 @@ events! { task: TaskId, /// The name of the section the task's chain starts at. target: String, - /// The `opts.input` override of the chain's `args`, when given. - input: Option, + /// The chain's own `args`, when `opts.args` gave them: the + /// object the chain reads, which for a string is that string + /// under `text`. A chain given none reads its spawner's. + args: Option, /// The `opts.item` seed installed as the chain's `item` global, /// when given. item: Option, diff --git a/crates/runvil/examples/run-prompt.rs b/crates/runvil/examples/run-prompt.rs index 161ad10d4..c93b7590e 100644 --- a/crates/runvil/examples/run-prompt.rs +++ b/crates/runvil/examples/run-prompt.rs @@ -20,13 +20,13 @@ use runvil::{BoxFuture, Error, ErrorCode, Harness, Host, RunSetup}; /// The name the Host finds the greeting prompt by. const GREET_NAME: &str = "greet.md"; -/// Reads the line in `name.txt` and writes its greeting to `reply.txt`. +/// Greets the name its `text` argument holds and writes the greeting to +/// `reply.txt`. const GREET: &str = concat!( "---\nname: greet\ndescription: Greets the operator by name\nrunvil: 0\n", - "input: { path: name.txt, description: The operator's name }\n", "output: { path: reply.txt, description: The greeting }\n", "---\n\n## Answer\n\n```lua\n", - "store.write('reply.txt', 'Hello, ' .. store.read('name.txt') .. '.')\n", + "store.write('reply.txt', 'Hello, ' .. args.text .. '.')\n", "```\n", ); @@ -90,7 +90,7 @@ async fn main() -> Result<(), Box> { // every other field keeps its default, such as a fresh store. let recorder = Arc::new(MemoryRecorder::new()); let setup = RunSetup { - input_text: Some("desk".to_owned()), + args: r#"{"text":"desk"}"#.to_owned(), ..RunSetup::new(recorder.clone(), Arc::new(Offline)) }; diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 2ecf79cb4..539b6b5f8 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -9,7 +9,6 @@ #[non_exhaustive] pub enum runvil::model::Role #[non_exhaustive] pub enum runvil::model::ThinkingMode #[non_exhaustive] pub enum runvil::plugin::PackOrigin -#[non_exhaustive] pub enum runvil::prompt::ArgType #[non_exhaustive] pub enum runvil::vfs::FileType #[non_exhaustive] pub enum runvil::vfs::Mode #[non_exhaustive] pub enum runvil::vfs::Op @@ -119,10 +118,7 @@ auto runvil::plugin::PackRegistry: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !U auto runvil::plugin::RenderContext: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::plugin::ServiceId: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::plugin::ToolContext: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe -auto runvil::prompt::ArgDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgType: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgsDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgsDeclIter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::prompt::ArgsSchema: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::FileDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::Frontmatter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::RoleRequirements: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -220,10 +216,7 @@ derives runvil::plugin::PackRegistry: Debug, Default derives runvil::plugin::RenderContext: Debug derives runvil::plugin::ServiceId: Clone, Copy, Debug, Eq, Hash, PartialEq derives runvil::plugin::ToolContext: Debug -derives runvil::prompt::ArgDecl: Clone, Debug, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgType: Clone, Copy, Debug, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgsDecl: Clone, Debug, Default, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgsDeclIter: Clone, Debug +derives runvil::prompt::ArgsSchema: Clone, Debug, Default, Deserialize, Eq, PartialEq derives runvil::prompt::FileDecl: Clone, Debug, Deserialize, Eq, PartialEq derives runvil::prompt::Frontmatter: Clone, Debug, Deserialize, Eq, PartialEq derives runvil::prompt::RoleRequirements: Clone, Copy, Debug, Deserialize, Eq, PartialEq @@ -266,12 +259,10 @@ impl core::fmt::Display for runvil::model::Role impl core::fmt::Display for runvil::packs::GlobalName impl core::fmt::Display for runvil::packs::PackId impl core::fmt::Display for runvil::plugin::ServiceId -impl core::fmt::Display for runvil::prompt::ArgType impl core::fmt::Display for runvil::vfs::PathReason impl core::fmt::Display for runvil::vfs::VfsError impl core::fmt::Display for runvil::vfs::VfsPath impl core::fmt::Display for runvil::vfs::VfsPathBuf -impl core::iter::traits::exact_size::ExactSizeIterator for runvil::prompt::ArgsDeclIter<'_> impl core::ops::drop::Drop for runvil::vfs::Access impl core::str::traits::FromStr for runvil::ToolId impl core::str::traits::FromStr for runvil::ToolId { type Err = runvil::Error } @@ -291,13 +282,8 @@ impl runvil::vfs::Policy for runvil::vfs::ModePolicy impl runvil::vfs::Vfs for runvil::vfs::MemoryBackend impl runvil::vfs::Vfs for runvil::vfs::RealBackend impl runvil::vfs::Vfs for runvil::vfs::VfsRef -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl { type IntoIter = runvil::prompt::ArgsDeclIter<'a> } -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl { type Item = (&'a str, &'a runvil::prompt::ArgDecl) } impl<'a> core::iter::traits::iterator::Iterator for runvil::model::ArgumentNames<'a> impl<'a> core::iter::traits::iterator::Iterator for runvil::model::ArgumentNames<'a> { type Item = &'a str } -impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ArgsDeclIter<'a> -impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ArgsDeclIter<'a> { type Item = (&'a str, &'a runvil::prompt::ArgDecl) } impl<'a> runvil::plugin::RenderContext<'a> impl<'a> runvil::plugin::ToolContext<'a> impl<'a> runvil::vfs::OpEvent<'a> @@ -538,20 +524,11 @@ pub fn runvil::plugin::ToolContext::access(&self) -> &'a runvil::vfs::Access pub fn runvil::plugin::ToolContext::new(tool: &'a runvil::ToolId, access: &'a runvil::vfs::Access, origin: &'a runvil::effect::ToolCallOrigin) -> Self pub fn runvil::plugin::ToolContext::origin(&self) -> &'a runvil::effect::ToolCallOrigin pub fn runvil::plugin::ToolContext::tool(&self) -> &'a runvil::ToolId -pub fn runvil::prompt::ArgDecl::default(&self) -> core::option::Option<&serde_yaml_ng::value::Value> -pub fn runvil::prompt::ArgDecl::description(&self) -> core::option::Option<&str> -pub fn runvil::prompt::ArgDecl::is_optional(&self) -> bool -pub fn runvil::prompt::ArgDecl::kind(&self) -> runvil::prompt::ArgType -pub fn runvil::prompt::ArgsDecl::get(&self, name: &str) -> core::option::Option<&runvil::prompt::ArgDecl> -pub fn runvil::prompt::ArgsDecl::is_default(&self) -> bool -pub fn runvil::prompt::ArgsDecl::is_empty(&self) -> bool -pub fn runvil::prompt::ArgsDecl::iter(&self) -> runvil::prompt::ArgsDeclIter<'_> -pub fn runvil::prompt::ArgsDecl::len(&self) -> usize +pub fn runvil::prompt::ArgsSchema::as_json(&self) -> &serde_json::value::Value pub fn runvil::prompt::FileDecl::description(&self) -> &str pub fn runvil::prompt::FileDecl::path(&self) -> &str -pub fn runvil::prompt::Frontmatter::args(&self) -> &runvil::prompt::ArgsDecl +pub fn runvil::prompt::Frontmatter::args(&self) -> &runvil::prompt::ArgsSchema pub fn runvil::prompt::Frontmatter::description(&self) -> &str -pub fn runvil::prompt::Frontmatter::input(&self) -> core::option::Option<&runvil::prompt::FileDecl> pub fn runvil::prompt::Frontmatter::max_tool_iterations(&self) -> core::option::Option pub fn runvil::prompt::Frontmatter::name(&self) -> &str pub fn runvil::prompt::Frontmatter::output(&self) -> core::option::Option<&runvil::prompt::FileDecl> @@ -656,6 +633,7 @@ pub runvil::Error::prose: alloc::string::String pub runvil::ErrorCode::AlreadyExists = 602 pub runvil::ErrorCode::Anchor = 609 pub runvil::ErrorCode::ApprovalRequired = 611 +pub runvil::ErrorCode::ArgsInvalid = 705 pub runvil::ErrorCode::Backend = 621 pub runvil::ErrorCode::CallDepthExceeded = 204 pub runvil::ErrorCode::Canceled = 702 @@ -753,7 +731,6 @@ pub runvil::RunReport::unread: alloc::vec::Vec pub runvil::RunSetup::args: alloc::string::String pub runvil::RunSetup::broker: alloc::sync::Arc pub runvil::RunSetup::has_user: bool -pub runvil::RunSetup::input_text: core::option::Option pub runvil::RunSetup::limits: runvil::RunLimits pub runvil::RunSetup::max_tool_calls_in_flight: core::num::nonzero::NonZeroUsize pub runvil::RunSetup::recorder: alloc::sync::Arc @@ -891,8 +868,8 @@ pub runvil::event::Event::TaskFailed::provenance: runvil::ids::Provenance pub runvil::event::Event::TaskFailed::section: alloc::string::String pub runvil::event::Event::TaskFailed::task: runvil::ids::TaskId pub runvil::event::Event::TaskStarted { .. } +pub runvil::event::Event::TaskStarted::args: core::option::Option pub runvil::event::Event::TaskStarted::index: core::option::Option -pub runvil::event::Event::TaskStarted::input: core::option::Option pub runvil::event::Event::TaskStarted::item: core::option::Option pub runvil::event::Event::TaskStarted::provenance: runvil::ids::Provenance pub runvil::event::Event::TaskStarted::section: alloc::string::String @@ -1062,10 +1039,6 @@ pub runvil::plugin::PackOrigin::Builtin pub runvil::plugin::PackOrigin::Plugin pub runvil::plugin::RenderContext::entry: &'a str pub runvil::plugin::RenderContext::model: &'a runvil::model::ModelInfo -pub runvil::prompt::ArgType::Boolean -pub runvil::prompt::ArgType::Integer -pub runvil::prompt::ArgType::Number -pub runvil::prompt::ArgType::String pub runvil::vfs::Entry::description: core::option::Option pub runvil::vfs::Entry::name: alloc::string::String pub runvil::vfs::Entry::stat: runvil::vfs::Stat @@ -1204,9 +1177,7 @@ pub struct runvil::plugin::PackRegistry { /* private fields */ } pub struct runvil::plugin::ServiceId { /* private fields */ } pub struct runvil::plugin::ServiceKey { /* private fields */ } pub struct runvil::plugin::ToolContext<'a> { /* private fields */ } -pub struct runvil::prompt::ArgDecl { /* private fields */ } -pub struct runvil::prompt::ArgsDecl { /* private fields */ } -pub struct runvil::prompt::ArgsDeclIter<'a> { /* private fields */ } +pub struct runvil::prompt::ArgsSchema { /* private fields */ } pub struct runvil::prompt::FileDecl { /* private fields */ } pub struct runvil::prompt::Frontmatter { /* private fields */ } pub struct runvil::prompt::RoleRequirements { /* private fields */ } diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 02234591b..a6a306780 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -15,8 +15,8 @@ //! [`Host::parse`] finds a prompt by its name and parses it. For each run //! the launcher builds a [`RunSetup`] with the run's recorder, inference //! broker, filesystem, model selection, workspace roots, [`RunLimits`], -//! tool-call cap, arguments, input text, and whether a user answers it, -//! and [`Harness::new`] takes the Host, the parsed prompt, and that setup. +//! tool-call cap, arguments, and whether a user answers it, and +//! [`Harness::new`] takes the Host, the parsed prompt, and that setup. //! [`Harness::run`] runs the prompt to its end and returns its //! [`RunReport`]. The run makes its own instance of each Pack it holds //! and drops the instances when it ends. While the run goes on, the @@ -185,10 +185,7 @@ pub mod prompt { //! What a prompt's frontmatter declares: its files, Packs, //! arguments, and model roles. - pub use runvil_engine::prompt::ArgDecl; - pub use runvil_engine::prompt::ArgType; - pub use runvil_engine::prompt::ArgsDecl; - pub use runvil_engine::prompt::ArgsDeclIter; + pub use runvil_engine::prompt::ArgsSchema; pub use runvil_engine::prompt::FileDecl; pub use runvil_engine::prompt::Frontmatter; pub use runvil_engine::prompt::RoleRequirements; diff --git a/crates/runvil/tests/suite/launch.rs b/crates/runvil/tests/suite/launch.rs index 1afc9c57d..ce25b9110 100644 --- a/crates/runvil/tests/suite/launch.rs +++ b/crates/runvil/tests/suite/launch.rs @@ -10,21 +10,21 @@ use runvil::{ErrorCode, ErrorFact, Harness, RunSetup}; use crate::support::{Offline, launch, no_packs}; -/// A prompt that reads its declared input and writes its declared output. +/// A prompt that shouts its `text` argument into its declared output. const SHOUTS: &str = concat!( - "---\nname: shouts\ndescription: Shouts its input\nrunvil: 0\n", - "input: { path: line.txt, description: The line to shout }\n", + "---\nname: shouts\ndescription: Shouts its text argument\nrunvil: 0\n", "output: { path: shout.txt, description: The shouted line }\n", "---\n\n## Only\n\n```lua\n", - "store.write('shout.txt', string.upper(store.read('line.txt')))\n", + "store.write('shout.txt', string.upper(args.text))\n", "```\n", ); -/// A Harness running `source` over `vfs` with `hello` as its input. +/// A Harness running `source` over `vfs` with `hello` as its `text` +/// argument. fn harness(source: &str, vfs: VfsRef) -> Harness { let setup = RunSetup { vfs, - input_text: Some("hello".to_owned()), + args: r#"{"text":"hello"}"#.to_owned(), ..RunSetup::new(Arc::new(MemoryRecorder::new()), Arc::new(Offline)) }; launch(no_packs(), source, setup) diff --git a/crates/runvil/tests/suite/vfs.rs b/crates/runvil/tests/suite/vfs.rs index eff258c2e..0651dc1ae 100644 --- a/crates/runvil/tests/suite/vfs.rs +++ b/crates/runvil/tests/suite/vfs.rs @@ -1,7 +1,7 @@ -//! Guarding and watching the files a Host hands a run: a policy refuses an -//! undeclared path before the operation watcher sees it, and while the run -//! waits on its inbox, the Host's edit of a file the run read conflicts -//! and leaves the run's view intact. +//! Guarding and watching the files a Host hands a run: a policy refuses a +//! path off the desk before the operation watcher sees it, and while the +//! run waits on its inbox, the Host's edit of a file the run read +//! conflicts and leaves the run's view intact. use std::sync::{Arc, Mutex, PoisonError}; @@ -16,7 +16,6 @@ use crate::support::{Offline, launch, no_packs}; const REVIEW: &str = concat!( "---\nname: review\ndescription: Reads the notes, then waits for the operator\nrunvil: 0\n", "uses:\n - user\n", - "input: { path: notes.md, description: The operator's notes }\n", "output: { path: summary.md, description: The approved notes }\n", "---\n\n## Approve\n\n```lua\n", "local notes = store.read('notes.md')\n", @@ -28,7 +27,8 @@ const REVIEW: &str = concat!( /// Each watched operation's origin label and file. type Seen = Arc>>; -/// Refuses every path except the two files the review prompt declares. +/// Refuses every path except the two files the review prompt reads and +/// writes. struct DeskFiles; impl Policy for DeskFiles { @@ -93,12 +93,12 @@ async fn until_the_run_waits_on_its_inbox(recorder: &MemoryRecorder) { } #[test] -fn a_policy_refuses_an_undeclared_path_before_the_watcher_sees_it() { +fn a_policy_refuses_a_path_off_the_desk_before_the_watcher_sees_it() { let (vfs, seen) = desk_vfs(); vfs.acquire_store(Origin::new("desk seed")) .expect("the handle declares a store") .write("notes.md", b"Ship on Friday.") - .expect("the policy allows a declared file"); + .expect("the policy allows a desk file"); let stray = vfs .acquire_store(Origin::new("desk stray")) .expect("the handle declares a store") @@ -116,7 +116,7 @@ async fn a_hosts_edit_of_a_file_the_run_read_conflicts_while_the_run_waits_on_it vfs.acquire_store(Origin::new("desk seed")) .expect("the handle declares a store") .write("notes.md", b"Ship on Friday.") - .expect("the policy allows a declared file"); + .expect("the policy allows a desk file"); let recorder = Arc::new(MemoryRecorder::new()); let setup = RunSetup { diff --git a/plan/2026-11-13-3-json-args.plan.md b/plan/2026-11-13-3-json-args.plan.md new file mode 100644 index 000000000..a1ec4f5e1 --- /dev/null +++ b/plan/2026-11-13-3-json-args.plan.md @@ -0,0 +1,410 @@ +--- +name: JSON Args Schema +overview: "A prompt's arguments are one JSON object: the frontmatter key `args:` declares their shape as a JSON Schema subset, the Engine checks them at the first step, and the Lua global `args` holds the checked object. The input file, the four-type declaration, the raw argument string, and `argv` go, and the tests, shipped prompts, design pages, and guide are brought in line." +todos: + - id: plan-copy + content: "runvil-harness: on `master`, copy this plan to `plan/2026-11-13-3-json-args.plan.md` before any other change, and save the two research reports from this conversation (MCP tool schemas, Rust JSON Schema validators) as research" + status: completed + - id: input-file + content: "Harness runner and runvil-prompt: delete `stage_input`, `input_text`, the staging block, and the `input:` frontmatter key; `prepare` returns no `Result`; rewrite the staging tests to seed the store from the Host or to read the argument string; bless both `public-api.txt` listings; run every check in AGENTS.md; make commit 1 with the `Plan:` trailer" + status: completed + - id: views + content: "runvil-lua: `__len` on every view whose data is a table, `pairs` and `next` read through a view, and an author's table is read through views at `tools.call`, a spawn's `item`, a `var` write, `tasks.fanout`, a bare global in prose, and a message record; view tests on `var` tables and on injected argument tables; run every check in AGENTS.md; make commit 2 with the `Plan:` trailer" + status: completed + - id: schema + content: "runvil-types and runvil-prompt: `ArgsInvalid = 705`; rewrite `contract/args.rs` as `ArgsSchema` with its streaming visitor and the default `text` schema; `contract/args_check.rs` with default fill and the check; `detail::accept_args` and `TEXT_FIELD`; delete `ArgsDecl`, `ArgDecl`, `ArgType`, and `ArgsDeclIter`" + status: completed + - id: runtime + content: "runvil-runtime: `ChainState::root` calls `accept_args` and keeps a failure for `check_start`, which returns it after the version gate; `ChainState.args` is the checked object; delete `derive_argv`, the raw string, the `argv` substitution root, and `nil_argv`; `{{ args.x }}` takes a key path; a child's args are a JSON value and a string wraps as `{ text }`" + status: completed + - id: lua + content: "runvil-lua and runvil-types: the guarded read-only global is `args` and `argv` is gone; `argv.rs` becomes `args.rs` with its refusal texts renamed; `call` takes a table of named fields or a string, and `tasks.spawn` takes the same under `args`, converted through `to_json`, with a list refused; `TaskStarted.args` is JSON" + status: completed + - id: harness-api + content: "Harness and facades: `RunSetup.args` defaults to `{}`; re-export `ArgsSchema` in place of the four `Arg*` types; rewrite the docs that describe the argument string, the wrap, or the input file so that they state the rule as it is" + status: completed + - id: tests + content: "All crates: change the test defaults to `{}` first, turn plain-text argument strings into objects, rewrite `args_surface.rs`, rename the lua crate's `tests/argv.rs` to `tests/args.rs`, and add the new parser, checker, and run tests, none of which names a removed form" + status: completed + - id: prompts-api + content: "runvil-harness: the four shipped prompts, four runtime fixtures, the `run-prompt` and `greeter` examples, the bench, `prompt/README.md`, and both `public-api.txt` listings through `xtask api --bless`" + status: completed + - id: verify-code + content: "runvil-harness: run every check in AGENTS.md, run the `run-prompt` example, and run the three sweeps; hold the commit" + status: completed + - id: guide-checker + content: "Scratch: change the guide checker (`guide-check-uniform-sections`) to send `{}` where it sent an empty string, then run it over the guide for a starting list" + status: completed + - id: design + content: "runvil-design: bring `prompt-language.md`, `harness-and-host.md`, `files-and-the-vfs.md`, `engine.md`, `tasks-and-fanout.md`, the counts in `decisions/README.md`, and the language spec in line, each stating the rule as it is with no `Update:` bullet or dated bullet; one commit" + status: completed + - id: guide + content: "runvil-docs: rewrite chapter 6 and the affected parts of chapters 01 to 05, 07, 08, 10, 12, and 14 to 17 by the fixed anchors, one subagent per chapter, with every result and message from the built Engine; run the checker and the site build; one commit" + status: completed + - id: small-docs + content: "runvil and runvil-classroom: the `greet` sample in `README.md` and in `runvil-vision.md`; one commit each" + status: completed + - id: commit + content: "runvil-harness: mark the plan copy's todos done, record in it what the build found, and make commit 3 on `master` with the `Plan:` trailer" + status: completed +isProject: false +--- + +# A prompt's arguments are a JSON object + +A caller passes a prompt one JSON object. The frontmatter key `args:` declares its shape, the Engine checks it before the first section runs, and the Lua global `args` holds the checked object. The input file, the four-type declaration, the raw argument string, and `argv` go. `output:` stays for the next plan. + +Basis: five surveys of the repositories and my own reading of the code at `runvil-harness` `9dd953b5`. Nothing was built or run. + +## The rule + +- **The arguments are a JSON object.** Text that is not JSON, or JSON that is not an object, ends the run at its first step with the code `args_invalid`. A caller with nothing to pass sends `{}`. +- **`args:` declares the shape** as a subset of JSON Schema written in YAML. The keywords are `type` (`string`, `boolean`, `integer`, `number`, `array`, `object`), `properties`, `required`, `additionalProperties`, `items`, `enum`, `description`, and `default`. Any other keyword fails the parse. +- **Objects are closed.** A field the declaration does not list ends the run, unless its object says `additionalProperties: true`. +- **No `args:` key means one optional string named `text`.** A Host with plain text sends `{"text":"hello"}`. +- **A missing field that has a `default` is filled** before the check, at every depth. +- **`null` for a field that is not required counts as left out**, so a default fills it. `null` for a required field ends the run. +- **`args` holds the checked object**, read-only, in every section, and it is always a table. A placeholder reads a field as `{{ args.query }}`. +- **An `args` table behaves like the table it stands for**, except that it refuses a write. `#`, `pairs`, `next`, `ipairs`, `table.concat`, and `table.unpack` work on it, and it can be handed to `tools.call`, to `tasks.fanout`, to a spawn's `item`, and to `var`. A `var` table measures and iterates the same way. +- **`call(heading, args)` and `tasks.spawn(target, { args = ... })`** take a table of named fields, which becomes the child's `args`, or a string, which becomes `{ text = string }`. With neither, the child reads the caller's `args`. +- **One word everywhere:** the key `args:`, the global `args`, the `call` parameter, the spawn option, `TaskStarted.args`, and the code `args_invalid`. Rust already says `RunSetup.args` and `Run::new(prompt, args, params)`. +- **Deleted:** the input file (`input:` and `input_text`), the four-type `args:` declaration, the raw argument string, `argv`, and the `prose` wrap. + +````markdown +--- +name: search +description: Searches for papers on one query +runvil: 0 +args: + type: object + properties: + query: { type: string, description: What to search for } + limit: { type: integer, default: 3 } + depth: { type: string, enum: [quick, full] } + required: [query] +--- + +## Search + +```lua +return args.query .. ', limit ' .. args.limit +``` +```` + +- `{"query":"papers"}` returns `papers, limit 3`. +- `{"query":5}` ends the run: ``argument `query` must be a string, got integer``. +- `{"query":"papers","n":2}` ends the run: ``argument `n` is not declared; the declared arguments are `depth`, `limit`, `query` ``. +- `papers` ends the run: `the arguments are not valid JSON`. + +```mermaid +flowchart LR + hostArgs[Host args text] --> accept[accept_args] + accept -->|"object, defaults filled"| argsGlobal[args in sections] + accept -->|"args_invalid"| firstStep[First step fails] + callArgs[call or spawn] -->|"table, or string as text"| argsGlobal +``` + +## Nothing is left behind + +- **No compatibility code.** No alias, no fallback, and no code that recognizes a removed form. +- **An old prompt fails by the ordinary rules, with the ordinary message.** `input:` is an unknown frontmatter key, a field name written directly under `args:` is an unknown keyword, `argv` in Lua is an undefined global, and `{{ argv.x }}` is an unknown global. Nothing detects these cases, and no message names them. +- **No test names a removed form.** Tests cover the rule as it is, and a test of a deleted rule is deleted. +- **No text says what the rule replaced or why it changed.** That covers code comments, crate docs, READMEs, error messages, the guide, the design pages, the README sample, and the vision page. Each states the rule as it is. + +## Decisions I made where you left room + +- **Rust names that follow from `args` everywhere:** `ArgsSchema`, `Frontmatter::args()`, `accept_args`, `ErrorCode::ArgsInvalid`, and `TaskStarted.args`. Confidence: high - each is the same word. +- **A tool handler's parameter shadows the global.** Inside a handler written `function(args)`, `args` is that tool call's arguments, by Lua's own scoping. The Engine adds nothing for it, and guide chapter 12 says so in one sentence. Confidence: high - it is how Lua works. +- **The empty string is not valid, and the Harness default is `{}`.** One rule with no special case. Confidence: medium-high - it is the rule you stated, and a Host that passes nothing gets a valid empty object. +- **The code is `args_invalid`, number 705, and the check runs second**, after the version gate and before the store probe. Facts: `Reason` (`not_json`, `not_an_object`, `required`, `type`, `enum`, `undeclared`) and `Name` (the field, such as `focus[1]`). The error has no location, because bad arguments are the caller's gap, which is the rule `Requirements::refusal` follows. Confidence: medium on the code, low-medium on the order - no code depends on it. +- **The first mismatch is reported, in this order:** text that is not JSON, then JSON that is not an object, then each object's declared fields in name order, then its undeclared fields in name order. A declared field is checked for a missing required value, then a wrong type, then a value outside `enum`, then its own contents, and a list's items are checked in index order. Confidence: medium - a list of all mismatches saves a model a round trip, and can be added later. +- **The schema and its checker live in `runvil-prompt`**, in `contract/args.rs` and a new `contract/args_check.rs`. The runtime reaches the checker through `runvil_prompt::detail`. Confidence: medium - it keeps the keyword set in one crate, and adds `serde_json` to that crate's manifest. +- **`ArgsSchema` has one public method, `as_json()`.** It gives the schema as plain JSON Schema with `additionalProperties: false` written on each closed object, ready to publish as a tool's `inputSchema`. No key and a written copy of the default are equal. Confidence: medium. +- **Where JSON Schema leaves room, the subset is strict.** Confidence: medium on each. + - Every node has a `type`, one word of the six. + - An array has `items`. + - `enum` is for `string` and `integer` fields only. + - `null` is refused for a required field and inside a list, and it is kept inside an open object. `default: null` declares no default. + - `integer` takes any JSON number with no fractional part that fits in 64 bits, and `args` holds it as an integer, so `5.0` and `1e3` read as `5` and `1000`. + - A `default` must match its own node, checked at parse. + - A keyword on the wrong type (such as `items` on a string) fails the parse. + - Field names follow the name grammar, and `required` names only declared fields. + - A repeated keyword fails the parse, and the root takes no `default` and no `enum`. +- **The check and the published schema agree.** Every value that is valid under `as_json()` passes, with one limit, that an integer must fit in 64 bits, and one allowance, `null` for a field that is not required. Confidence: high - each rule above was chosen for it. +- **Views measure and iterate through `__len` and the Engine's own `pairs` and `next`.** `read_only_proxy`, when its data is a table, and `var`'s `guarded_table` set `__len` to the data table's length, and `pairs` and `next` read through a view. A `__pairs` metamethod still wins. The walk hands back the view, never the hidden data table, so a view stays the only way to the data. Every read-only view whose data is a table iterates this way, a fanout result and a sealed Pack global included. Confidence: medium - nothing has run it, and tests that expect an empty walk change. +- **A child's args are not checked against the schema and get no defaults.** The schema describes what a caller passes the prompt, not what one section passes another. A list-shaped table is refused at the call: `args must be a table of named fields or a string, got a list`. Confidence: medium. +- **`TaskStarted.args` records the child's `args` as JSON.** Its doc says the payload is enough to start the chain again. Confidence: medium. +- **`{{ args }}` renders the whole object as compact JSON**, as every table placeholder does. Confidence: high - it is the existing rule for a table. +- **The message for an undeclared field lists the declared ones**, so a model can correct itself in one try. Confidence: medium - wording only. +- **An `args` table passes every Engine boundary as its data.** Today a read-only table converts as an empty one everywhere except `json.encode`, so `tools.call(id, args)` would send `{}` and `tasks.fanout(worker, args.files)` would fail as an empty collection. Each site that takes an author's table reads through the view. A table with no view inside converts exactly as it does today, messages included, and a table that contains itself is refused as it is today. Confidence: high on the need - the code's own comment says a view serializes empty - and medium on the fix, which nothing has run. +- **The default field's description is `Freeform text for this prompt`.** A published tool shows it for `text`. Confidence: medium - wording only. +- **Shipped prompts declare what they need.** `greet` takes a required `name`, `research-person` and `analyst-example` a required `text`, and `echo` uses the default. Confidence: medium - the README sample grows by four lines. + +## Harness, commit 1: the input file goes + +- [files.rs](runvil-harness/crates/harness-internal/runner/src/files.rs): delete `stage_input` and the reasons `input_undeclared` and `input_missing`. `read_output`, `report_output`, and their five tests stay. +- [setup.rs](runvil-harness/crates/harness-internal/runner/src/setup.rs): delete `input_text`. +- [prepare.rs](runvil-harness/crates/harness-internal/runner/src/prepare.rs): delete the staging block (lines 97 to 108). `prepare` can no longer fail, so it returns `(Run, RunPacks)`, and the two `Err` arms in [harness.rs](runvil-harness/crates/harness-internal/runner/src/harness.rs) (lines 225 to 237) go with it. +- [frontmatter.rs](runvil-harness/crates/runvil-internal/prompt/src/frontmatter.rs): delete the `input` field and `Frontmatter::input`. `FileDecl` stays for `output:`. From here on `input:` is an unknown key. +- **Tests:** + - [prepare-files.rs](runvil-harness/crates/harness-internal/runner/tests/it/prepare-files.rs): the three refusal tests go with their rules. The other three keep their coverage with a file the Host seeds in the store: the output round trip, the store mounted away from the root, and the Host-seeded file. + - A fixture that staged a file seeds it in the store from the Host, so the later commits leave it alone: `runner/tests/it/harness.rs`, `runvil/tests/suite/vfs.rs`, and `runtime/.../suite/vfs.rs`. + - `runvil/tests/suite/launch.rs` and the `run-prompt` example are about a launch, so they take their text from the argument string. + - `prompt/src/tests/frontmatter.rs` and `runvil-engine/tests/suite/prompt.rs` only declared the key, and they drop it. +- Doc lines in the runner's `lib.rs` and `recorder.rs` and in `runvil/src/lib.rs`. Both `public-api.txt` listings lose `RunSetup::input_text` and `Frontmatter::input` through `xtask api --bless`. + +## Engine, commit 2: a view behaves like its table + +A view is the empty stand-in an author holds for a read-only table or for a `var` table. This commit does not depend on the args rule, and it is made as soon as it passes. + +- **Views measure:** `read_only_proxy` in [proxy.rs](runvil-harness/crates/runvil-internal/lua/src/proxy.rs), when its data is a table, and `guarded_table` in [var.rs](runvil-harness/crates/runvil-internal/lua/src/var.rs) set `__len`. The hardened `table.concat` already reads `#list` and `list[k]`, so it needs no change. +- **Views iterate:** `pairs` and `next` in [iteration.rs](runvil-harness/crates/runvil-internal/lua/src/iteration.rs) read through a view with `unseal`. A `__pairs` metamethod still wins, and the walk hands back the view. +- **An author's table is read through views before it is converted**, at six sites: + - `tools.call` arguments (`protocol/parse/tools.rs`) + - a spawn's `item` (`protocol/parse/tasks.rs`) + - a `var` write (`var.rs`) + - the fanout collection and its members (`collection.rs`) + - a bare global in prose (`prose.rs`) + - a message record (`messages.rs`) +- **One helper makes the plain copy.** It sits in `proxy.rs`, is built on `unseal`, and is modeled on `materialize_guarded_value`. It keeps the list marker, so an empty list stays `[]`, and it is held to the walk budget. +- **Tests:** + - `#`, `pairs`, `next`, `table.concat`, and `table.unpack` on a `var` list and object and on an injected argument list and object. + - `var.items[#var.items + 1] = x` and `table.insert(var.items, x)` append. + - A write through the value `pairs` hands back is refused. + - A `var` table handed to `tools.call`, to `tasks.fanout`, to a spawn's `item`, to another `var` field, and to a bare global in prose arrives as its data. + - A table with no view inside converts as before, messages included, and a table that contains itself is refused as before. + - Tests that expect `#` to be 0 or `pairs` to yield nothing on a view state the real length and entries. + +## Engine and Harness, commit 3: the args rule + +`runvil-types`: + +- [error.rs](runvil-harness/crates/runvil-internal/types/src/error.rs): `ArgsInvalid = 705` with its tag, its parse arm, and its prose. `error-tests.rs` lists 69 codes, and `kind_of` in `lua/src/error_value.rs` gains the arm. +- [event.rs](runvil-harness/crates/runvil-internal/types/src/event.rs): `TaskStarted.input` becomes `args`, an `Option`. + +`runvil-prompt`: + +- [contract/args.rs](runvil-harness/crates/runvil-internal/prompt/src/contract/args.rs) is rewritten. It holds `ArgsSchema` and its default, and `ArgsDecl`, `ArgDecl`, `ArgType`, and `ArgsDeclIter` are deleted. A streaming serde visitor builds the schema, so an error keeps its YAML path, line, and column: + +````text +invalid frontmatter: args.properties.query: unknown keyword "tipe" at line 8 column 7 +```` + +- Field names go through `deserialize_contract_map`, so the name grammar and the duplicate check stay in use. +- New `contract/args_check.rs` fills defaults and checks one JSON value. +- [detail.rs](runvil-harness/crates/runvil-internal/prompt/src/detail.rs): `accept_args(prompt, text)` returns the checked object or an `args_invalid` error, and `TEXT_FIELD` is `"text"`. +- `Frontmatter::args()` returns `&ArgsSchema`. `Cargo.toml` gains `serde_json`. + +`runvil-runtime`: + +- [context.rs](runvil-harness/crates/runvil-internal/runtime/src/execute/context.rs): delete `derive_argv` and the raw string. `ChainState::root` calls `accept_args`. `ChainState.args` is an `Arc`, the checked object, never optional. A failure is kept in `SharedState` beside `refusal`, with no location. `with_args(value)` wraps a string as `{ text }`. +- [drive.rs](runvil-harness/crates/runvil-internal/runtime/src/execute/scheduler/drive.rs): `check_start` returns that failure after the version gate. The docs there and in `run.rs` list four checks. +- [subst.rs](runvil-harness/crates/runvil-internal/runtime/src/subst.rs): the `args` root reads the object and takes a key path. Delete the `argv` root, `nil_argv`, and the refusal of `args.x`. +- `section_vm.rs`, `section_context.rs`, `scheduler/dispatch.rs`, `scheduler/tasks.rs`, `scheduler/admission.rs`: one `args` value, and a child's args are an `Option`. + +`runvil-lua`: + +- [argv.rs](runvil-harness/crates/runvil-internal/lua/src/argv.rs) is renamed `args.rs`. The `_G` guard in [globals.rs](runvil-harness/crates/runvil-internal/lua/src/globals.rs) and `__impl_globals.lua` serves `args`, and the refusal texts say `args is read-only`. +- [vm/inject.rs](runvil-harness/crates/runvil-internal/lua/src/vm/inject.rs): `inject_values` takes one `args: &Json`, and the raw global is no longer set. +- `RESERVED_NAMES` loses `argv` and has 62 entries. +- [protocol/parse.rs](runvil-harness/crates/runvil-internal/lua/src/protocol/parse.rs), `parse/tasks.rs`, `request.rs`, `__impl_coro.lua`, `__impl_tasks.lua`: the field is `args`, a table or a string. A table converts through `json::encode::to_json`, which reads through the read-only proxy, so `call(h, args)` passes the caller's arguments on. + +Harness and facades: + +- `RunSetup.args` defaults to `{}`. +- Both facades re-export `ArgsSchema` in place of the four `Arg*` types, and both `public-api.txt` listings are blessed. + +Traps the surveys found: + +- The wording check fails on a standalone "host" in Engine text, so no schema example there names a field `host`. +- Doc comments hold no doctests, so YAML examples get a non-Rust fence tag. +- The test prompt builder trims indentation, so nested YAML in tests is flow style or a hand-written source. +- A struct has at most 12 fields and a function at most 100 lines. +- `inject_values` has two `#[expect(clippy::similar_names)]` attributes for the `args` and `argv` pair. They go with the parameter, or clippy fails on an unfulfilled expectation. +- `runvil-prompt` gains `serde_json`, which changes `Cargo.lock`, and CI builds with `--locked`. + +## Tests + +These belong to commit 3. Commit 2 lists its own. + +- **Defaults first.** `Scenario` in `runtime/src/test_support/scenario.rs` and in the engine suite, the 13 direct `Run::new` sites in the runtime, the 7 literal `""` sites in the runner and the gateway client, the bench, and the `greeter` example all pass `{}`. That keeps about 900 runtime tests and 120 Harness tests running. +- **Plain-text argument strings become objects.** Of 37 `.args(` sites in the runtime, 18 plain strings become `{"text": ...}` and 2 non-objects become refusal tests. The 3 engine-suite sites change the same way. +- **`args_surface.rs` (19 tests) is rewritten for the rule.** Tests of a deleted rule go: never errors on shape, a nil value, a scalar value, and the `prose` wrap. +- **`runvil-lua`:** `tests/argv.rs` becomes `tests/args.rs` without its nil and scalar cases, five test files read a field where they read the raw string, and the protocol parse tests gain table args. +- **`subst-tests.rs` (36 tests):** the `args` root tests read fields, and the tests of the `argv` root, `nil_argv`, and the refusal of `args.x` go. +- **New parser tests:** + - Each keyword, and each refusal with its path, line, and column. + - The default schema equals a written copy of it. + - The published JSON of a declaration, as golden text. +- **New checker tests:** + - Each type, `required`, an undeclared field, an open object, `enum`, nested objects, list items. + - `5.0` and `1e3` for an integer, a number past 64 bits, and a default filled at depth. + - `null` for a field that is not required, for a required field, inside a list, and inside an open object. +- **New run tests:** + - Each first-step failure with its code, `Reason`, and `Name`, and with no location. No event is reported, and a set cancel flag wins. + - `{}` runs a prompt with no `args:` key. + - `call` with a table, with a string, with `args`, and with a list. `tasks.spawn` with each, and `TaskStarted.args`. + - A fanout arm reads its caller's `args`. + - Inside a tool handler written `function(args)`, `args` is the call's arguments. + - An `args` table handed to `tools.call`, to `tasks.fanout`, to a spawn's `item`, to `var`, and to a bare global in prose arrives as its data. + +## Prompts and examples + +- `prompts/`: `greet`, `echo`, `research-person`, and `analyst-example` change as decided above. `hello` is unchanged. +- Four runtime fixtures read `args.text`: `prologue-return.md`, `store-fallthrough.md`, `prologue-prose-epilog.md`, `require-library.md`. +- The `run-prompt` example passes `{"text":"desk"}` and writes its greeting from `args.text`. +- `prompt/README.md` and the crate docs that describe the argument string, the wrap, or the input file state the rule as it is. + +## Design pages: `runvil-design`, one commit + +- [prompt-language.md](runvil-design/decisions/prompt-language.md): + - The walk-through, the entry on frontmatter keys, and the entries on `call` and on the globals. The counts of keys, globals, and reserved names come from the built Engine. + - All four entries of section 5. + - The owner quote "there must be no freeform prompts" stays with the default declaration. The sentence on the name `prose` goes. + - Each entry gives the reason for the rule as it stands: + - A JSON object lets any prompt be published as an MCP tool, whose arguments are an object. + - The declaration is JSON Schema because that is the form a tool publishes. It is a subset so that an unknown keyword fails the parse, which catches a misspelling. + - An object is closed so that a misspelled optional field is refused, where an open object would drop it. + - A default is filled because a caller that reads the schema expects it. + - `null` for a field that is not required counts as left out because models send it that way, and the `std` Pack's checker reads it the same. + - The name is `args` because it can only mean what the caller passed, where "input" could also mean what a user sends during the run. + - The default field is `text`, the word for what a chain returns. + - A read-only table is a stand-in because Lua calls `__newindex` only for a key the table does not hold. + - The entries on `var` and on the read-only views state that a view measures and iterates like the table it stands for. +- **No page gains an `Update:` bullet or a dated bullet**, and no entry says what its rule replaced. +- **Other pages:** + - `harness-and-host.md`: the staging entries, and `RunSetup`'s field count. + - `files-and-the-vfs.md`: lines 90 and 146 to 148. + - `engine.md`: four start checks, and what the Engine injects. + - `tasks-and-fanout.md`: `tasks.spawn` and `TaskStarted`. + - `decisions/README.md`: the counts. +- [architect-runvil-prompting-language.md](runvil-design/design/architect-runvil-prompting-language.md): the construct index, Source syntax, State and globals, `call`, and the three recipes that read `args` as a string. + +## Guide: `runvil-docs`, one commit + +- **Scale:** chapter 6 has 72 headings, and about 60 are deleted or rewritten. Across the guide, 25 example prompts declare `args:` in the four-type form, 3 declare an input file, 36 example runs pass plain text, 31 Lua lines and 44 placeholders read `args` as a string, and about 445 lines name `argv`. +- **Anchors, fixed here so chapters can be written in parallel:** + - `06-arguments.md`: `#declaring-arguments-in-frontmatter`, `#argument-types`, `#required-arguments-defaults-and-descriptions`, `#declaration-errors`, `#argument-errors`, `#reading-arguments-from-args`, `#prompts-with-no-args-key`, `#args-is-read-only`, `#missing-fields-and-null-values`. + - `02-file-structure.md`: `#the-output-file-declaration`. `#the-name-grammar-for-arg-names-and-adhoc-tool-names` is unchanged. + - `07-substitution.md`: `#inserting-arguments-with-args`. + - `08-jump-and-call.md`: `#call-args-and-var` and `#args-in-a-called-chain`. + - Every link to an anchor that no longer exists goes to the one above that covers its subject. The checker's anchor pass finds each one. +- **No chapter mentions a removed form, a change, or a migration.** A section that taught a removed rule is deleted. +- **Chapters with structural work:** + - 02: the key table, the contract keys, the input file sections. + - 04: four start checks, and "Staging the input file" goes. + - 05: "Reading arrays and other tables from `var`" and the `json.encode` example beside it state that `#`, `pairs`, and `table.concat` work on a `var` table. + - 06: all of it. + - 07: the `args` root takes a key path, and `NilArgv` and the `args` case of `NotATable` go. + - 08: call args. + - 12: one sentence on a handler's `args` parameter, and the reserved names. + - 15: spawn args. + - 16 and 17: the code tables and the key, global, and substitution rows. +- **Lighter work:** 01, 03, 10, and 14 change examples and single mentions. +- **Every result and message comes from a run of the built Engine.** +- **Checks:** the scratch guide checker (`guide-check-uniform-sections`) sends `{}` where it sent an empty string. Then `cargo xtask site --books-only` from `runvil` with `RUNVIL_DOCS` set, and a grep for `argv`, `argument string`, `wrap`, `input file`, `input text`, and `never enforced`, with each remaining hit read. + +## Other documents, one commit each + +- [README.md](runvil/README.md) in `runvil`: the `greet` sample (lines 42 to 63) and the sentence at line 65. +- [runvil-vision.md](runvil-classroom/runvil-vision.md): the same sample (lines 21 to 42). + +## Left alone + +- **`runvil-workshop`.** It pins the Harness by commit, so it changes when you move the pin. That commit needs: + - the pin in `Cargo.toml` + - the contract DTO in `server/src/routes/prompts.rs`: `args` becomes the schema JSON, and the input `FileDto` goes + - `run-api.ts` and `run-rows.ts`: rows from the schema's properties, a JSON box for list and object fields, and no input-file row or Choose Input dialog + - `prompts-tests.rs`, `run-api.mjs`, and `run-panel.mjs` + - `workshop-app.md` and `workshop-ui.md` in `runvil-design` + - Agent runs need nothing: `chat.md` reads no arguments, and the `RunSetup` default is `{}`. +- **`wg21-paperflow`.** Its vendored copy of the old Engine isolates it. `paperweight.md` and its launcher depend on the input file. +- **Student work in `runvil-classroom`.** Brazeal's prompt, kirill's two prompts, and kirill's runner use the raw argument string, `input:`, or `input_text`. Each builds against an older pin. +- **Dated records:** every `plan/` and `vibe/` folder, `research/`, and the untracked prompts under `runvil/local/`. +- **`output:`**, the store mount, and `execute`, which are later plans. + +## Order, commits, and git + +```mermaid +flowchart LR + fileGone[Input file gone] --> views[Views as tables] + views --> rule[Args rule] + rule --> design[Design pages] + rule --> guide[Guide chapters] + rule --> small["README, vision"] + design --> last[Harness commit 3] + guide --> last + small --> last +``` + +- The plan is copied to `runvil-harness/plan/2026-11-13-3-json-args.plan.md` first, and commit 1 includes it. The two research reports from this conversation (MCP tool schemas, Rust JSON Schema validators) are saved as research in the same step. They exist only in this chat, so that step needs the chat that wrote the plan. +- Commits 1 and 2 are made as each passes its checks. The args-rule code is held uncommitted while the documents are written against the built Engine. It is committed last, as commit 3, so the plan copy records every todo as done and gains a section on what the build found. +- Harness commits end with `Plan: /plan/2026-11-13-3-json-args.plan.md`. The other repositories' commits end with `Plan: runvil-harness/plan/2026-11-13-3-json-args.plan.md`. +- Every commit stays local on `master`. Pushing is yours to call. +- A removed file is moved to `cabinet/_trash/`. + +**Parallel work:** in commit 3, `runvil-prompt` and `runvil-lua` change in parallel once the two `runvil-types` edits are in, and `runvil-runtime` follows both. Once the Engine compiles, tests are fixed crate by crate in parallel, and the guide checker is updated beside them. Once the code passes, the design pages, the guide chapters (one subagent each), and the two small documents run in parallel. + +## Verification + +Before each Harness commit, every command in [AGENTS.md](runvil-harness/AGENTS.md): format, clippy, nextest, both doc builds, `cargo test -p build-xtask`, the wording check, `xtask api --check`, and dylint. Then `cargo run -p runvil --example run-prompt`, which CI compiles but never runs, and three checks that CI adds to that list: `cargo doc -p runvil --no-deps`, the private-items doc build of `runvil-runtime`, and `cargo deny check`. Then three sweeps over `crates/` and `prompts/`: + +- **Deleted names**, expecting no hit: `ArgsDecl`, `ArgDecl`, `ArgType`, `ArgsDeclIter`, `is_default`, `stage_input`, `input_text`, `input_undeclared`, `input_missing`, `derive_argv`, `nil_argv`, `argv`. +- **Wording**, over the added lines of every repository this plan touches: `no longer`, `formerly`, `previously`, `used to`, `legacy`, `deprecat`, `migrat`, `replaced`, `renamed`, `changed`. +- **`{{ args }}`**, over `prompts/` and every fixture: each hit is read, because the whole object renders as JSON where a field was meant. + +## What the build found + +The design held. The first build of the checker had four faults, and each was found and fixed before commit 3. These points differ from the plan above, or add to it. + +The checker: + +- **A required field that has a default takes the default.** The first build refused it as missing. A `null` for it is still refused, with or without the default. The test `a_required_field_with_a_default_takes_it_when_left_out_and_refuses_a_null` holds the rule. +- **A default is refused in the words of a default.** The first build refused a default that holds an undeclared field with the words for a run's arguments. The refusal now ends `this node declares no fields` or ``the declared fields of this node are `size` ``. +- **A `type` that is not one word has a message of its own.** `type: [string, integer]` fails with ``invalid type: sequence, expected one type word: `string`, `boolean`, `integer`, `number`, `array`, or `object` ``. An unknown word keeps the message ``unknown variant `text`, expected one of ...``. +- **An integer in an `enum` that is past 64 bits is named by its value.** The first build said that the list holds integers and that it holds an integer. +- **A number one past the smallest integer is refused.** A number that is not written as a 64-bit integer is read as a float. A float at either end of the 64-bit range is refused, because digits past the range round to that end. Below that, a float reads as the integer it holds, so `9223372036854775000.0` reads `9223372036854774784`. +- **A list item is counted from 0 in an argument error and from 1 in the refusal of a call's table.** `files[1]` names the second item of the caller's JSON, and `value.files[2]` names the second item of a Lua table. +- **The message for text that is not JSON has the JSON parser's reason after the plan's words**, as in `the arguments are not valid JSON: expected value at line 1 column 1`. The prose leaves the reason out. +- **A refusal is located where its node opens.** An unknown keyword and an unknown type word are located where they stand. A bad or a repeated argument name is located at the first name of its `properties` map, which is what `deserialize_contract_map` does for every contract map. +- **A scalar reads as a string where the declaration asks for a string**, so `description: 5` parses, as it does for every string of the frontmatter. `args:` with no value fails with ``a node needs a `type` ``, and `args: ~` fails with `invalid type: unit value, expected a map of schema keywords`. + +The Engine: + +- **`args` is served by the guard on `_G`.** It is not a raw key of the globals table. +- **`TaskStarted.args` is `None` for a chain that reads its spawner's `args`**, and a fanout arm is such a chain. +- **`tasks.spawn` refuses an option that it does not take**, and `tasks.join_any` and `tasks.join` do the same. `tasks.spawn('Sub', { input = 'x' })` raises `tasks.spawn: unknown key "input"; accepted keys are args, item, index`. Commit 3 left such a key ignored, and the commit after it added the check. +- **`argv` reads nil, as any global that nothing set.** An index of it raises Lua's own error, and `{{ argv.x }}` fails with `unknown namespace or global 'argv'`. +- **`json.null` as the arguments of a call is named `lightuserdata`**, which is the Engine's word for it in every refusal of an argument. +- **`setmetatable(args, {})` raises a plain string**, `cannot change a protected metatable`, with no `kind` and no `code`. A refused write is an error value with the kind `lua` and the code `lua_runtime`. +- **`table.sort` on a list inside `args` raises only when it must move an item.** +- **The list that `tasks.fanout` returns is a plain table.** Each result in it is a read-only view. +- **The Engine installs 20 globals and reserves 62 names**, and `markdown` is one of the globals. `ErrorCode` has 69 codes. +- **Neither `public-api.txt` listing names `serde_yaml_ng`**, and the commit after commit 3 took its entry out of the allowlist of `build-xtask`. +- **`echo` returns `done` for `{}`**, because `return args.text` returns nil. +- **A task that holds the answer it waited for reports no block.** `tasks.status` read `blocked` as `chat` with the round already counted in `turns`, when a model reply and a timer both arrived before the next step. `Chain::blocked` now gives `None` for a chain that holds its answer, unless the chain waits for its slots. +- **The `var` guard refuses a key that JSON data has no place for, at the assigning line.** `var[true] = 1` passed the write and failed the run at the end of the block with a raw `deserialize error`, and `var[1] = 1` failed there as an internal fault. The root takes string keys alone. A table under it takes a string, or the index of an item it holds or of the next one. +- **A `var` write converts its value by the rules of `json.encode`**, since the commit after commit 3. `var.t = { 'a', name = 'x' }` raises `var.t mixes list items and named keys`, where it stored `["a"]` and raised nothing. A refusal names the failing part by its path under `var`, and a float with no fractional part stays a float. +- **A `var` write that would leave its table neither a list nor an object is refused**, in the same commit: a named field in a list, a list item in a table that holds named fields, and the removal of an item that is not the last of its list. + +Tests and checks: + +- **The whole workspace runs 3,787 tests, all passing.** It ran 3,653 after commit 2. `runvil-prompt` has 216 tests where it had 145, `runvil-runtime` has 1,216 where it had 1,179, `runvil-lua` has 802 where it had 796, the suite of `runvil-engine` has 46 where it had 41, and `harness-runner` has 175 where it had 173. +- **Seven test files are added:** `tests-args.rs` and `tests-args-check.rs` in `runvil-prompt`, `args_surface-chains.rs` and `args_surface-refusals.rs` in `runvil-runtime`, `parse_args.rs` in `runvil-lua`, `args.rs` in the suite of `runvil-engine`, and `prepare-args.rs` in `harness-runner`. +- **Every check listed under Verification passed on the final tree.** `cargo dylint` runs with `CARGO_BUILD_WARNINGS` unset, because the link step of the lint library gives a warning of its own. +- **The sweeps.** The deleted names have no hit. The wording sweep has one hit, `unchanged` in the description of `echo`. `{{ args }}` has nine hits, none in `prompts/`, and each one means the whole object. +- **No file is removed.** `argv.rs` and `tests/argv.rs` in `runvil-lua` are renamed to `args.rs` and `tests/args.rs`. +- **The runner and the gateway client had six literal `""` argument sites**, where the plan counted seven. + +Documents: + +- **The guide checker reads the arguments of an example from its lead-in**: the code span directly after the word "arguments", as in "Run it with the arguments `{"query":"papers"}`:". With no such span it sends `{}`. +- **The checker finds 536 examples in the 17 chapters, and 363 return their documented result.** Before the chapter work it found 540, and 317 returned theirs. Of the 25 that fail to parse, 24 teach a parse error and one is a Markdown sample that is no prompt. The 65 that differ each need a model reply, a Pack tool, a store file, or a Host that the checker does not have, and none of them is about arguments. +- **Chapter 6 has 18 examples, and all 18 match.** About 300 more runs stand behind its fragments and the results that its sentences state. +- **The guide links resolve.** The checker found 2,170 links in 30 files and no broken one, and `cargo xtask site --books-only` built both books with the 14 fixed anchors in them. +- **One agent edited chapters 1, 3, and 10**, which each needed a few lines, where the plan gave each chapter its own agent. +- **The statement on a sealed prelude table in chapter 12 is from the test `a_table_global_is_sealed_at_its_top_level`**, because the checker loads no Pack prelude. The codes of the errors, the place of the store check among the start checks, and the cancel rule are from the Engine's tests too, because the checker prints a failed run's message and not its code. +- **The guide names no field that the code lacks.** `sys.execution`, the `execution` field of an event, and the `name` and `prompt_hash` of a run's record are gone from it, and chapter 5 gives `must be JSON data` as the message of a refused `var` write. The design pages list 74 gaps where they listed 81. +- **The `status-snapshot` example of chapter 15 is right.** It shows `turns` as 0 while the model round is in flight, and the checker returns that text with its `--slow-model` option, which answers a round only when the run has no other work. +- **The design pages keep their counts of entries.** The page on the prompt language has 47 entries and the page on the Harness has 39, as before. One update bullet of `prompt-language.md` lost its words on `argv`. The `markdown` table is built, so that page lists it among the globals, says what its five functions do, and has no planned-work item for it. +- **Commits.** `6ef23a8e` and `8b7ad5f9` here are commits 1 and 2. `runvil-design` commits `f242554`, `e260e7c`, `95eaa9d`, and `4d99657`, `runvil-docs` commits `a525b76`, `beb2f3b`, and `48158dc`, `runvil` commit `003bebd1e`, and `runvil-classroom` commit `ce9f242` hold the document changes. Each is local on `master`. diff --git a/prompts/analyst-example.md b/prompts/analyst-example.md index 26e5e973c..7c72021ab 100644 --- a/prompts/analyst-example.md +++ b/prompts/analyst-example.md @@ -2,6 +2,11 @@ name: analyst_example description: Demonstrate frontmatter model roles and models.use for careful model selection. runvil: 0 +args: + type: object + properties: + text: { type: string, description: The text to analyze } + required: [text] models: strong: min_context: 40000 @@ -17,7 +22,7 @@ models.use("strong") Analyze the following input carefully and return a short factual summary with no preamble: -{{ args }} +{{ args.text }} ```lua return models.infer(prose) diff --git a/prompts/echo.md b/prompts/echo.md index b18de4a2d..1754c3009 100644 --- a/prompts/echo.md +++ b/prompts/echo.md @@ -1,13 +1,13 @@ --- name: echo -description: Return the input argument unchanged +description: Return the text argument unchanged runvil: 0 --- -Returns the single input argument verbatim, entirely in Lua, with no model call. +Returns its `text` argument verbatim, entirely in Lua, with no model call. ## Main ```lua -return args +return args.text ``` diff --git a/prompts/greet.md b/prompts/greet.md index a94329935..7f4968f69 100644 --- a/prompts/greet.md +++ b/prompts/greet.md @@ -1,16 +1,20 @@ --- name: greet -description: Greet the named input using a Lua-computed value +description: Greet a name using a Lua-computed value runvil: 0 +args: + type: object + properties: + name: { type: string, description: The name to greet } + required: [name] --- -Computes a greeting from the input in Lua, substitutes it into the prose, and -has the model echo it. +Computes a greeting for its `name` argument in Lua, substitutes it into the prose, and has the model echo it. ## Main ```lua -var.greeting = "Hello, " .. args .. "!" +var.greeting = "Hello, " .. args.name .. "!" ``` Repeat exactly, with no extra words: {{ var.greeting }} diff --git a/prompts/research-person.md b/prompts/research-person.md index 616346e29..d34bfba41 100644 --- a/prompts/research-person.md +++ b/prompts/research-person.md @@ -5,6 +5,11 @@ runvil: 0 max_tool_iterations: 20 uses: - web +args: + type: object + properties: + text: { type: string, description: A request about a person } + required: [text] --- ## Research @@ -17,7 +22,7 @@ You research people using live web tools and return a compact, factual summary. Your input is a request about a person: -{{ args }} +{{ args.text }} Do this: