diff --git a/AGENTS.md b/AGENTS.md index 9af9d2720..684762465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that - PromptForge crates are named `promptforge` and promptforge-* and must not depend on gateway, workshop, or harness crates - PromptForge has one public crate, `promptforge` at crates/promptforge/: a facade of single-item re-exports grouped into documented role modules. Crates outside the family may depend only on `promptforge`, never on a promptforge-* crate. Everything else lives under crates/promptforge-internal/, a manifestless container private to the family that holds the engine (`promptforge-engine`), the types crate (`promptforge-types`), the virtual filesystem (`promptforge-vfs`), and the lua, parser, and model-client crates; `promptforge` is the only outside crate permitted to depend into it - The desktop app (the `workshop` crate) depends on `workshop-server-api` and never on `workshop-server`; the facade is the desktop app's entire view of the server -- Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates. PromptForge's own public surface is the `promptforge` facade, and its types crate (`promptforge-types`) has left shared-* for the private container; Gateway's is gateway-api-types and gateway-api-discovery, named gateway-* now that both have left shared-*; the types crate contains the wire vocabulary only, never code +- Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates. PromptForge's own public surface is the `promptforge` facade, and its types crate (`promptforge-types`) has left shared-* for the private container; Gateway's is gateway-api-types and gateway-api-discovery, named gateway-* now that both have left shared-*; `promptforge-types` holds the shared vocabulary together with host-support code (the untrusted guards, the cancellation tree, and the event emitter) - Crates named build-* are for building specific outputs - Dependency rules bind all kinds: normal, dev, build, and target-specific dependencies. One exception: a crate under crates/promptforge-internal/ may list `promptforge` in `[dev-dependencies]` only so its doc examples compile against the facade paths hosts see. No unit test, integration test, or bench imports it. This edge is exempt from the one-way flow rule under Structural Rules. diff --git a/Cargo.lock b/Cargo.lock index 4e291abc5..55164e331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2598,6 +2598,7 @@ dependencies = [ "harness-log", "harness-runner", "harness-sessions", + "promptforge", "workspace-hack", ] diff --git a/crates/harness-internal/models/tests/it/end_to_end.rs b/crates/harness-internal/models/tests/it/end_to_end.rs index 73c03169c..db622044b 100644 --- a/crates/harness-internal/models/tests/it/end_to_end.rs +++ b/crates/harness-internal/models/tests/it/end_to_end.rs @@ -322,6 +322,7 @@ async fn a_prepared_run_drives_end_to_end_and_records_the_whole_stream() { let services = Services { registry: None, vfs: promptforge::vfs::VfsRef::default(), + input_text: None, cancel: CancelHandle::new(), log: Arc::clone(&log), chat: Arc::new(GatewayChatPerformer::new(client, deltas)), diff --git a/crates/harness-internal/runner/src/files.rs b/crates/harness-internal/runner/src/files.rs new file mode 100644 index 000000000..0ba317527 --- /dev/null +++ b/crates/harness-internal/runner/src/files.rs @@ -0,0 +1,99 @@ +//! The host'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. +//! +//! 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; a +//! caller runs them on the blocking pool. + +use promptforge::vfs::{Origin, StoreOp, StoreOutcome, VfsError, VfsRef, perform_store_op}; + +/// Why a run's declared input file could not be put in place. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum InputFileError { + /// The launch supplied input text, but the prompt declares no + /// `input:` file to stage it at. + #[error("the launch supplied input text, but the prompt declares no `input:` file")] + Undeclared, + /// The prompt declares an input file that the launch did not supply + /// and the store does not already hold. + #[error( + "the prompt declares the input file `{path}`, but the launch supplied no input text \ + and the store has no such file" + )] + Missing { + /// The declared input path. + path: String, + }, + /// The store refused the staging write or the existence check. + #[error("the input file `{path}` could not be staged")] + Store { + /// The declared input path. + path: String, + /// The store's failure. + #[source] + source: VfsError, + }, +} + +/// 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 [`InputFileError::Undeclared`] for text the prompt declares no +/// file for, [`InputFileError::Missing`] for a declared file that is +/// neither supplied nor present, and [`InputFileError::Store`] when the +/// store refuses the write or the check. +pub fn stage_input( + vfs: &VfsRef, + declared: Option<&str>, + text: Option, +) -> Result<(), InputFileError> { + let path = match (declared, &text) { + (None, None) => return Ok(()), + (None, Some(_)) => return Err(InputFileError::Undeclared), + (Some(path), _) => path, + }; + let store = |source| InputFileError::Store { + path: path.to_owned(), + source, + }; + let view = vfs + .acquire_store(Origin::new(format!("input: {path}"))) + .map_err(store)?; + let path = path.to_owned(); + match text { + Some(contents) => perform_store_op(&view, StoreOp::Write { path, contents }) + .map(drop) + .map_err(store), + None => match perform_store_op(&view, StoreOp::Exists { path: path.clone() }) { + Ok(StoreOutcome::Bool(true)) => Ok(()), + Ok(_) => Err(InputFileError::Missing { path }), + Err(source) => Err(store(source)), + }, + } +} + +/// Reads the prompt's declared output file at `path` from `vfs`'s store. +/// +/// # Errors +/// Returns the store's failure, [`VfsError::NotFound`] when the run never +/// wrote the file. +pub fn read_output(vfs: &VfsRef, path: &str) -> Result { + let view = vfs.acquire_store(Origin::new(format!("output: {path}")))?; + let read = StoreOp::Read { + path: path.to_owned(), + start: None, + end: None, + }; + match perform_store_op(&view, read)? { + StoreOutcome::Text(text) => Ok(text), + other => Err(VfsError::Backend { + message: format!("a whole-file store read answered {other:?} instead of text"), + }), + } +} diff --git a/crates/harness-internal/runner/src/lib.rs b/crates/harness-internal/runner/src/lib.rs index 712d5d022..a7abe5af0 100644 --- a/crates/harness-internal/runner/src/lib.rs +++ b/crates/harness-internal/runner/src/lib.rs @@ -1,6 +1,7 @@ //! harness-runner - the harness effect loop: prepares an engine `Run` //! from a prompt file (drawing the host inputs the engine refuses to draw -//! itself, activating capabilities, opening the run's row), steps it, +//! itself, putting the declared input file in place, activating +//! capabilities, opening the run's row), steps it, //! performs each effect on tokio through one performer per effect kind, //! feeds the answers back, records every event, effect, and answer in the //! run log, and owns cancellation. @@ -35,6 +36,7 @@ pub mod cancel; mod display_chain; pub mod effect_loop; +pub mod files; pub mod performers; pub mod prepare; pub mod spawn; diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index a24d4ba0e..d64a403b7 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -6,8 +6,9 @@ //! CSPRNG and its `started_at` from the wall clock, both written to the //! run's row in the log before anything else, so the record can hand them //! back verbatim to a future replay. Then the ceremony the engine's -//! `Environment` expects of a host: parse; hand the run's whole -//! filesystem, host roots and the declared store, to the capabilities' +//! `Environment` expects of a host: parse; put the prompt's declared +//! `input:` file in place in the store (`files::stage_input`); hand the +//! run's whole filesystem, host roots and the declared store, to the capabilities' //! services and to the context as given; activate the prompt's declared //! capabilities against the caller's registry, which assembles the //! catalog, the preludes, and the implementation table; install the @@ -16,7 +17,8 @@ //! unsatisfiable prompt with the engine's model-readable notice; and //! build the `Run` beside its performers. //! -//! A refusal (or a prompt that fails to parse) is a run that ended before +//! A refusal (or a prompt that fails to parse, or an input file that +//! cannot be put in place) is a run that ended before //! it began: its row is closed as failed with the refusal as the message, //! so the log answers "why did this session fail" for a run the loop //! never saw. @@ -33,16 +35,18 @@ use promptforge::cancel::CancelHandle; use promptforge::event::Event; use promptforge::model::ModelDescriptor; use promptforge::timestamp::Timestamp; -use promptforge::vfs::VfsRef; +use promptforge::vfs::{VfsError, VfsRef}; use promptforge::{Environment, RunContext, RunError}; use promptforge::{ParseError, Prompt, Run}; use sha2::{Digest as _, Sha256}; use crate::display_chain::display_chain; use crate::effect_loop::{SharedLog, failed_outcome}; +use crate::files::{InputFileError, stage_input}; use crate::performers::{ ActivatedTools, ChatPerformer, LogTaskEvents, Performers, TokioTimer, VfsStore, }; +use crate::spawn::spawn_blocking_launch; /// What the caller owns and preparation borrows: the registry of /// installed capabilities, the host roots, the run's cancel flag, the @@ -57,6 +61,9 @@ pub struct Services { /// passed straight to the context's VFS and handed to the capabilities /// as the run's services. pub vfs: VfsRef, + /// The text staged at the prompt's declared `input:` path before the + /// run, when the launch supplied one. + pub input_text: Option, /// The run's cancel flag: handed to the context, to every capability /// activated for the run, and polled by the engine. pub cancel: CancelHandle, @@ -112,6 +119,9 @@ pub struct Prepared { /// run's own events; the caller hands them to its sink so the session /// sees them in order. pub parse_events: Vec, + /// The prompt's declared `output:` path, which the caller reads with + /// [`read_output`](crate::files::read_output) once the run completes. + pub output_path: Option, } /// Why a run could not be prepared. @@ -153,6 +163,18 @@ pub enum PrepareError { #[source] error: RunError, }, + /// The prompt's declared input file could not be put in place: the + /// launch supplied text the prompt declares no file for, the prompt + /// declares a file that is neither supplied nor in the store, or the + /// store refused. The run's row is closed as failed with kind `Input`. + #[error("the prompt's declared input file cannot be put in place")] + Input { + /// The run's row, closed with this refusal. + run_id: RunId, + /// Why the input could not be put in place. + #[source] + source: InputFileError, + }, /// The run log refused a write; the run cannot be recorded, so it is /// not prepared. #[error(transparent)] @@ -161,16 +183,18 @@ pub enum PrepareError { /// Prepares the prompt at `prompt_path` for one run with `args`: draws the /// run's seed and start and opens its row in the log, parses the prompt, -/// activates its declared capabilities against the caller's registry, -/// prepares the context, refuses an unsatisfiable prompt, and builds the -/// `Run` and its performers. +/// puts its declared input file in place, activates its declared +/// capabilities against the caller's registry, prepares the context, +/// refuses an unsatisfiable prompt, and builds the `Run` and its +/// performers. /// /// # Errors /// Returns [`PrepareError::Read`] when the file cannot be read (no row is -/// written), [`PrepareError::Parse`] when it does not parse and -/// [`PrepareError::Refused`] when the environment cannot satisfy it (in -/// both cases the row is closed as failed), and [`PrepareError::Log`] -/// when the log refuses a write. +/// written), [`PrepareError::Parse`] when it does not parse, +/// [`PrepareError::Input`] when its declared input file cannot be put in +/// place, and [`PrepareError::Refused`] when the environment cannot +/// satisfy it (in these three cases the row is closed as failed), and +/// [`PrepareError::Log`] when the log refuses a write. pub async fn prepare_run( prompt_path: &Path, args: &str, @@ -191,10 +215,12 @@ pub async fn prepare_run( /// source is attributed to in [`PrepareError::Parse`]. /// /// # Errors -/// Returns [`PrepareError::Parse`] when the source does not parse and -/// [`PrepareError::Refused`] when the environment cannot satisfy it (in -/// both cases the row is closed as failed), and [`PrepareError::Log`] -/// when the log refuses a write. Never [`PrepareError::Read`]. +/// Returns [`PrepareError::Parse`] when the source does not parse, +/// [`PrepareError::Input`] when its declared input file cannot be put in +/// place, and [`PrepareError::Refused`] when the environment cannot +/// satisfy it (in these three cases the row is closed as failed), and +/// [`PrepareError::Log`] when the log refuses a write. Never +/// [`PrepareError::Read`]. pub async fn prepare_source( source: &str, prompt_path: &Path, @@ -204,6 +230,7 @@ pub async fn prepare_source( let Services { registry, vfs, + input_text, cancel, log, chat, @@ -223,7 +250,7 @@ pub async fn prepare_source( .await .begin_run(RunMeta { session_id: session_id.clone(), - agent, + agent: agent.clone(), prompt_hash: prompt_hash(source), seed, flags: 0, @@ -256,6 +283,15 @@ pub async fn prepare_source( } }; + // The declared input is in place before anything else sees the + // store: the capabilities activate over the same filesystem, and the + // run's first section may read it. + stage_declared_input(&prompt, &vfs, input_text, &agent, &log, run_id).await?; + let output_path = prompt + .frontmatter() + .output() + .map(|decl| decl.path().to_owned()); + // The parse events were stamped under task `0` from zero; the run's // root task continues the sequence past them, so `(task_id, task_seq)` // is unique across every record of the run. @@ -306,7 +342,51 @@ pub async fn prepare_source( started_at, performers, parse_events, + output_path, + }) +} + +/// Puts `prompt`'s declared input file in place in `vfs`'s store on the +/// blocking pool, tagged with `agent`. A refusal closes `run_id`'s row as +/// failed under the `Input` kind, with the cause chain as its message. +async fn stage_declared_input( + prompt: &Prompt, + vfs: &VfsRef, + input_text: Option, + agent: &str, + log: &SharedLog, + run_id: RunId, +) -> Result<(), PrepareError> { + let declared = prompt + .frontmatter() + .input() + .map(|decl| decl.path().to_owned()); + if declared.is_none() && input_text.is_none() { + return Ok(()); + } + let staging = vfs.clone(); + let path = declared.clone(); + let staged = spawn_blocking_launch(agent, move || { + stage_input(&staging, path.as_deref(), input_text) }) + .await + .unwrap_or_else(|join| { + Err(InputFileError::Store { + path: declared.unwrap_or_default(), + source: VfsError::Backend { + message: format!("the staging task failed: {join}"), + }, + }) + }); + let Err(source) = staged else { + return Ok(()); + }; + let outcome = RunOutcome::Failed { + kind: "Input".to_owned(), + message: display_chain(&source), + }; + close_failed(log, run_id, outcome).await?; + Err(PrepareError::Input { run_id, source }) } /// Closes `run_id`'s row with `outcome`, a run that ended before the loop diff --git a/crates/harness-internal/runner/src/spawn.rs b/crates/harness-internal/runner/src/spawn.rs index df70d3f25..b0d0e0587 100644 --- a/crates/harness-internal/runner/src/spawn.rs +++ b/crates/harness-internal/runner/src/spawn.rs @@ -7,7 +7,7 @@ //! [`Provenance`] - so a run's tasks trace as a group and slice by task. //! The last two cover the work that performs no effect: a session's //! supervisor, whose span records the session id, and a launch's -//! filesystem probes, whose span records the agent name. Each is a +//! filesystem work, whose span records the agent name. Each is a //! permitted caller of the raw tokio method it wraps, and no other //! harness code is. @@ -110,8 +110,11 @@ where /// a span named `launch` that records the agent name under `agent`. /// /// A launch walks the agents directory and reads the agent's source -/// before any run or session exists, so the work has no [`Tag`] and no -/// session id; the agent name is what ties it to the launch that asked. +/// before any run or session exists, and each run puts the prompt's +/// declared input file in place before its first step and reads its +/// declared output file after its last. None of that performs an effect, +/// so the work has no [`Tag`]; the agent name is what ties it to the +/// launch that asked. /// The closure runs to completion even if its [`JoinHandle`] is aborted /// or dropped, just as with `tokio::task::spawn_blocking`. /// diff --git a/crates/harness-internal/runner/tests/it/prepare-files.rs b/crates/harness-internal/runner/tests/it/prepare-files.rs new file mode 100644 index 000000000..adb0f4349 --- /dev/null +++ b/crates/harness-internal/runner/tests/it/prepare-files.rs @@ -0,0 +1,150 @@ +//! 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, a declared file +//! with neither text nor a seeded copy, and a store that refuses the write +//! each refuse the run and close its row under the `Input` kind; and the +//! declared `output:` path comes back on the prepared run for the caller +//! to read once the run completes. + +use std::sync::Arc; + +use harness_log::RunOutcome; +use harness_runner::display_chain; +use harness_runner::effect_loop::{SharedLog, drive_run}; +use harness_runner::files::{InputFileError, read_output}; +use harness_runner::prepare::{PrepareError, Prepared, prepare_run}; +use promptforge::cancel::CancelHandle; +use promptforge::vfs::{MemoryBackend, Mode, ModePolicy, Origin, VfsError, VfsRef}; + +use super::{PLAIN, completed, log, prompt_file, services}; + +/// A prompt declaring `paper.md` as its input and `report.md` as its +/// output, whose one section writes the output from the input. +const FILES: &str = "---\nname: files\ndescription: d\npromptforge: 0\n\ + input:\n path: paper.md\n description: The paper\n\ + output:\n path: report.md\n description: The report\n---\n\n\ + # Title\n\n## Only\n\n```lua\n\ + store.write('report.md', 'seen: ' .. store.read('paper.md'))\nreturn 'done'\n```\n"; + +/// Prepares `source` over `vfs` with `input_text` and returns the result. +async fn prepare( + source: &str, + vfs: &VfsRef, + input_text: Option<&str>, +) -> (SharedLog, Result) { + let dir = tempfile::tempdir().unwrap(); + let log = log().await; + let mut services = services(&log, None); + services.vfs = vfs.clone(); + services.input_text = input_text.map(str::to_owned); + let prepared = prepare_run(&prompt_file(dir.path(), source), "", services).await; + (log, prepared) +} + +/// Checks that `error` is an input refusal whose row closed under the +/// `Input` kind with the cause chain as its message, and returns the cause. +async fn refused(log: &SharedLog, error: PrepareError) -> InputFileError { + let PrepareError::Input { run_id, source } = error else { + panic!("the failure is an input refusal: {error}"); + }; + let row = log.lock().await.run(run_id).await.unwrap(); + assert!(row.ended_at.is_some(), "the refused run's row is closed"); + assert_eq!( + row.outcome, + Some(RunOutcome::Failed { + kind: "Input".to_owned(), + message: display_chain(&source), + }), + "the row records the refusal under the Input kind" + ); + source +} + +#[tokio::test] +async fn the_input_text_is_staged_at_the_declared_path_and_the_output_path_comes_back() { + let vfs = VfsRef::default(); + let (log, prepared) = prepare(FILES, &vfs, Some("# Paper")).await; + let prepared = prepared.expect("a supplied declared input prepares"); + assert_eq!(prepared.output_path.as_deref(), Some("report.md")); + let outcome = drive_run( + prepared.run, + prepared.performers, + Arc::clone(&log), + prepared.run_id, + CancelHandle::new(), + |_event| {}, + ) + .await + .unwrap(); + assert_eq!(completed(outcome), "done"); + 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() { + let vfs = VfsRef::builder() + .mount("/", MemoryBackend::new()) + .store("/store", MemoryBackend::new()) + .build(); + let (_log, prepared) = prepare(FILES, &vfs, Some("# Paper")).await; + prepared.expect("a supplied declared input prepares"); + let access = vfs.acquire(Origin::new("prepare files test")).unwrap(); + assert_eq!(access.read("/store/paper.md").unwrap(), b"# Paper"); + assert!( + !access.exists("/paper.md").unwrap(), + "the base never saw it" + ); +} + +#[tokio::test] +async fn a_host_seeded_store_satisfies_the_declared_input() { + let vfs = VfsRef::default(); + vfs.acquire_store(Origin::new("prepare files test")) + .unwrap() + .write("paper.md", b"# Seeded") + .unwrap(); + let (_log, prepared) = prepare(FILES, &vfs, None).await; + prepared.expect("an input the store already holds prepares"); +} + +#[tokio::test] +async fn input_text_for_a_prompt_that_declares_no_input_is_refused() { + let (log, prepared) = prepare(PLAIN, &VfsRef::default(), Some("# Paper")).await; + let source = refused(&log, prepared.expect_err("undeclared input refuses")).await; + assert!(matches!(source, InputFileError::Undeclared), "{source:?}"); +} + +#[tokio::test] +async fn a_declared_input_neither_supplied_nor_in_the_store_is_refused() { + let (log, prepared) = prepare(FILES, &VfsRef::default(), None).await; + let source = refused(&log, prepared.expect_err("a missing input refuses")).await; + let InputFileError::Missing { path } = source else { + panic!("the refusal names the missing file: {source:?}"); + }; + assert_eq!(path, "paper.md"); +} + +#[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 (log, prepared) = prepare(FILES, &vfs, Some("# Paper")).await; + let source = refused(&log, prepared.expect_err("a refused write refuses")).await; + let InputFileError::Store { path, source } = source else { + panic!("the refusal is the store's: {source:?}"); + }; + assert_eq!(path, "paper.md"); + assert!( + matches!(source, VfsError::PermissionDenied { .. }), + "{source:?}" + ); +} + +#[tokio::test] +async fn a_prompt_without_an_output_declaration_has_no_output_path() { + let (_log, prepared) = prepare(PLAIN, &VfsRef::default(), None).await; + assert_eq!(prepared.unwrap().output_path, None); +} diff --git a/crates/harness-internal/runner/tests/it/prepare-input.rs b/crates/harness-internal/runner/tests/it/prepare-input.rs index 1b3e9798f..edc73821e 100644 --- a/crates/harness-internal/runner/tests/it/prepare-input.rs +++ b/crates/harness-internal/runner/tests/it/prepare-input.rs @@ -2,13 +2,16 @@ //! to every declared capability, or hands none when the host has nobody //! to ask; and the `promptforge/user-input` capability's `input.ask()` //! reaches it, is refused when required on a host without one, and -//! degrades when optional. +//! degrades when optional. A frontmatter alias named `input` collides +//! with the capability's prelude global and fails the run before any +//! effect, while an alias of another name runs beside it. use super::*; use std::sync::Mutex; use harness_capabilities::{InputBroker, InputError, Service, UserInput, activate}; +use harness_log::{RecordFilter, RecordKind}; use promptforge::Prompt; /// A prompt declaring the probe capability, with nothing to run. @@ -335,6 +338,74 @@ async fn input_ask_with_an_argument_raises() { ); } +#[tokio::test] +async fn an_alias_named_like_the_user_input_prelude_global_fails_the_run_before_any_effect() { + let dir = tempfile::tempdir().unwrap(); + let log = log().await; + let declaration = format!("{REQUIRED}tools:\n input: promptforge/user-input/ask\n"); + let prepared = prepare_run( + &prompt_file(dir.path(), &user_input_prompt(&declaration, ASKS_ONCE)), + "", + user_input_services(&log, Some(Arc::new(Scripted("unused")))), + ) + .await + .expect("the prompt parses and its requirements are met"); + let run_id = prepared.run_id; + let outcome = drive_run( + prepared.run, + prepared.performers, + Arc::clone(&log), + run_id, + CancelHandle::new(), + |_event| {}, + ) + .await + .expect("the loop reaches an outcome"); + let RunOutcome::Failed { kind, message } = outcome else { + panic!("the collision fails the run: {outcome:?}"); + }; + assert_eq!(kind, "Lua"); + assert!( + message.contains( + "capability `promptforge/user-input`: its prelude defines the global `input`, \ + which the prompt's frontmatter binds as a tool or model alias" + ), + "the failure names the capability, the global, and the alias: {message}" + ); + let effects = log + .lock() + .await + .records(run_id, RecordFilter::default()) + .await + .unwrap() + .into_iter() + .filter(|stored| stored.record.kind == RecordKind::Effect) + .count(); + assert_eq!(effects, 0, "the run fails before it issues any effect"); +} + +#[tokio::test] +async fn a_normal_alias_for_the_ask_tool_runs_beside_the_untouched_host_globals() { + let dir = tempfile::tempdir().unwrap(); + let log = log().await; + let declaration = format!("{REQUIRED}tools:\n ask: promptforge/user-input/ask\n"); + let outcome = drive_prompt( + dir.path(), + &user_input_prompt( + &declaration, + "return tools.call(ask) .. '|' .. ask.name .. '|' .. type(input.ask) .. '|' \ + .. type(store.read) .. '|' .. type(tools.call)", + ), + user_input_services(&log, Some(Arc::new(Scripted("hello")))), + ) + .await; + assert_eq!( + completed(outcome), + "hello|ask|function|function|function", + "the alias global is the ask tool, and input, store, and tools are the host's" + ); +} + #[tokio::test] async fn a_prompt_that_does_not_declare_user_input_has_no_input_global() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index bd3c78365..ac140e21b 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -6,8 +6,9 @@ //! `ToolCall` effect's id in the activated table. The host's optional //! input broker - handed to every activated capability and behind the //! `promptforge/user-input` capability - sits in the `input` child -//! module, and a capability's prelude reaching the prepared run sits in -//! the `prelude` child module. +//! module, a capability's prelude reaching the prepared run sits in +//! the `prelude` child module, and the prompt's declared input and output +//! files sit in the `files` child module. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -27,6 +28,8 @@ use promptforge::tools::{ToolError, ToolId, ToolOutput}; use crate::support::Unused; +#[path = "prepare-files.rs"] +mod files; #[path = "prepare-input.rs"] mod input; #[path = "prepare-prelude.rs"] @@ -67,6 +70,7 @@ fn services(log: &SharedLog, registry: Option>) -> Servi Services { registry, vfs: promptforge::vfs::VfsRef::default(), + input_text: None, cancel: CancelHandle::new(), log: Arc::clone(log), chat: Arc::new(Unused), diff --git a/crates/harness-internal/sessions/src/environment-tests.rs b/crates/harness-internal/sessions/src/environment-tests.rs index ce8a50290..0419e1c78 100644 --- a/crates/harness-internal/sessions/src/environment-tests.rs +++ b/crates/harness-internal/sessions/src/environment-tests.rs @@ -166,6 +166,7 @@ async fn a_prompt_that_needs_only_user_input_prepares_and_runs_on_an_unusable_ga let services = Services { registry: Some(Arc::clone(resources.registry())), vfs: VfsRef::default(), + input_text: None, cancel: CancelHandle::new(), log: Arc::clone(&log), chat: Arc::new(NoChat), diff --git a/crates/harness-internal/sessions/src/input-tests.rs b/crates/harness-internal/sessions/src/input-tests.rs index 9051eca0f..111990e1d 100644 --- a/crates/harness-internal/sessions/src/input-tests.rs +++ b/crates/harness-internal/sessions/src/input-tests.rs @@ -290,6 +290,7 @@ async fn an_ask_is_answered_when_the_registry_receives_the_text() { let services = Services { registry: Some(Arc::new(capabilities)), vfs: VfsRef::default(), + input_text: None, cancel: CancelHandle::new(), log: Arc::clone(&log), chat: Arc::new(NoChat), diff --git a/crates/harness-internal/sessions/src/lib.rs b/crates/harness-internal/sessions/src/lib.rs index 9ad8c00bf..e6f871b72 100644 --- a/crates/harness-internal/sessions/src/lib.rs +++ b/crates/harness-internal/sessions/src/lib.rs @@ -14,6 +14,11 @@ //! API (the gateway, the chat catalog, the host snapshot); this crate //! never resolves a gateway or reads a client's state itself. It is the //! one place a capability provider crate is named, at registration. +//! - A launch's filesystem (`LaunchOptions::vfs`) is the one client-built +//! handle the harness holds: every run of the session works in it, and +//! the harness reaches its store only through `VfsRef::acquire_store`, +//! to stage the prompt's declared input file and read its declared +//! output file. //! - The supervisor's state transitions are a pure reducer whose matches //! stay wildcard-free, so a new variant is a compile error. //! - A session's transcript is the run log: the live broadcast and a diff --git a/crates/harness-internal/sessions/src/protocol.rs b/crates/harness-internal/sessions/src/protocol.rs index fc5b052f7..695ec2a37 100644 --- a/crates/harness-internal/sessions/src/protocol.rs +++ b/crates/harness-internal/sessions/src/protocol.rs @@ -52,9 +52,15 @@ impl fmt::Display for SessionId { pub struct LaunchRequest { /// The agent's name, as discovered under the configured agents path. pub agent: String, - /// The run's argument text, handed to the prompt as its input. + /// The run's argument text, handed to the prompt as `args`. #[serde(default)] pub args: String, + /// The text staged at the prompt's declared `input:` file before each + /// run. A run is refused when the prompt declares no input file, or + /// when this is `None` and the session's store does not already hold + /// the declared one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_text: Option, } /// One durable entry of a session's event log. diff --git a/crates/harness-internal/sessions/src/runtime.rs b/crates/harness-internal/sessions/src/runtime.rs index 6fc9ee0ec..318317c85 100644 --- a/crates/harness-internal/sessions/src/runtime.rs +++ b/crates/harness-internal/sessions/src/runtime.rs @@ -18,12 +18,14 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use harness_log::{LogError, RunLog}; use harness_runner::effect_loop::SharedLog; use harness_runner::spawn::{spawn_blocking_launch, spawn_session}; +use promptforge::vfs::VfsRef; use tokio::sync::{OnceCell, mpsc}; use crate::discovery::{agent_source, discover_agents}; use crate::environment::{Bindings, CatalogBinding, GatewayBinding, HostSnapshot}; use crate::lifecycle::{CANCELLATION_CAPACITY, RunLifecycle}; use crate::protocol::{LaunchRequest, SessionId}; +use crate::session::files::SessionFiles; use crate::session::supervisor::{Supervisor, SupervisorParts}; use crate::session::{Session, SessionCore, SessionSeed}; @@ -40,6 +42,23 @@ pub struct HarnessConfig { pub state_dir: PathBuf, } +/// What a launch hands the session beyond its [`LaunchRequest`]: the +/// session's environment rather than data about what to run. Build it +/// with `..LaunchOptions::default()` so a later field is not a break. +#[derive(Debug, Clone, Default)] +pub struct LaunchOptions { + /// The filesystem every run of the session works in: its declared + /// store, and any host mounts, overlays, policy, and op sink the + /// client built into the handle with `promptforge::vfs`. Every run + /// shares it, relaunches included, so files a retired run wrote are + /// still there, and the declared input is staged and the declared + /// output read through its store. The harness owns the handle from + /// launch on; a backend, policy, or op sink in it runs inside the + /// session's store operations. `None` gives each run a fresh memory + /// store at `/`. + pub vfs: Option, +} + /// A refused launch. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -201,9 +220,12 @@ impl Harness { } /// Launches a session running the discovered agent `request.agent` - /// with `request.args` and returns it. The session runs until its + /// with `request.args`, staging `request.input_text` at the prompt's + /// declared input file, and returns it. The session runs until its /// program returns, fails, or it is closed; turn-cancel relaunches the /// program over the retained transcript without ending the session. + /// Each run works in a fresh memory store; [`Harness::launch_with`] + /// hands the session a filesystem of the client's own. /// /// # Errors /// Returns [`LaunchError::UnknownAgent`] when the name is not a @@ -213,7 +235,25 @@ impl Harness { /// the agent's source cannot be read, and [`LaunchError::Log`] when the /// run log cannot be opened. pub async fn launch(&self, request: LaunchRequest) -> Result { - let LaunchRequest { agent, args } = request; + self.launch_with(request, LaunchOptions::default()).await + } + + /// Launches a session as [`Harness::launch`] does, under `options`: + /// every run of the session works in `options.vfs` when it is set. + /// + /// # Errors + /// Returns the errors [`Harness::launch`] does. + pub async fn launch_with( + &self, + request: LaunchRequest, + options: LaunchOptions, + ) -> Result { + let LaunchRequest { + agent, + args, + input_text, + } = request; + let LaunchOptions { vfs } = options; // Resolving through the discovered list is the trust boundary: a // client-sent name never reaches the filesystem unless it is the // bare stem of a real `.md` file in the configured directory. The @@ -261,6 +301,7 @@ impl Harness { agent, source, args, + files: SessionFiles::new(vfs, input_text), lifecycle: Arc::new(RunLifecycle::new(events, cancellations)), log, }); diff --git a/crates/harness-internal/sessions/src/session.rs b/crates/harness-internal/sessions/src/session.rs index 53ce42b60..fc007d83a 100644 --- a/crates/harness-internal/sessions/src/session.rs +++ b/crates/harness-internal/sessions/src/session.rs @@ -21,9 +21,12 @@ //! read derives the same count from the event sequence through the one //! rule [`reply_stamp`], so live and replayed stamps agree. +pub(crate) mod files; pub(crate) mod run; pub(crate) mod supervisor; +pub use files::OutputError; + use std::fmt; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -35,6 +38,7 @@ use promptforge::event::Event; use promptforge::model::StreamDelta; use tokio::sync::{broadcast, mpsc, watch}; +use self::files::SessionFiles; use crate::discovery::AgentSource; use crate::input::{WaitError, WaitFrame, WaitRegistry, complete_input_response}; use crate::lifecycle::RunLifecycle; @@ -279,6 +283,8 @@ pub(crate) struct SessionCore { pub(crate) prompt_path: PathBuf, /// The run's argument text. pub(crate) args: String, + /// The filesystem, input text, and collected output of every run. + pub(crate) files: SessionFiles, /// Cancellation provenance and the accepted-turn exclusion boundary. pub(crate) lifecycle: Arc, /// The session's unresolved user-input waits. @@ -315,6 +321,7 @@ pub(crate) struct SessionSeed { pub(crate) source: AgentSource, pub(crate) prompt_path: PathBuf, pub(crate) args: String, + pub(crate) files: SessionFiles, pub(crate) lifecycle: Arc, pub(crate) log: SharedLog, } @@ -334,6 +341,7 @@ impl SessionCore { source: seed.source, prompt_path: seed.prompt_path, args: seed.args, + files: seed.files, lifecycle: seed.lifecycle, waits: Arc::new(WaitRegistry::new()), wait_frames, diff --git a/crates/harness-internal/sessions/src/session/files.rs b/crates/harness-internal/sessions/src/session/files.rs new file mode 100644 index 000000000..0d04b3f00 --- /dev/null +++ b/crates/harness-internal/sessions/src/session/files.rs @@ -0,0 +1,126 @@ +//! A session's declared files: the filesystem every run of the session +//! works in, the launch's text for the prompt's `input:` file, and what +//! the completed run left at its `output:` file, which +//! [`Session::output_text`] returns. +//! +//! The output is read once, as the run completes and before the session +//! reports `Closed`, so a client that awaits `Closed` and then asks for +//! it never races the read, and a host that tears its filesystem down +//! afterwards keeps the text. + +use std::sync::{Mutex, PoisonError}; + +use harness_runner::files::read_output; +use harness_runner::spawn::spawn_blocking_launch; +use promptforge::vfs::{VfsError, VfsRef}; + +use super::Session; + +/// Why a session has no output text to return. A missing output never +/// fails the run: the prompt's run succeeded, and only its declared +/// output file is absent. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum OutputError { + /// No run of the session has completed: it is still running, or it + /// failed, was cancelled, or was closed first. + #[error("no run of the session has completed")] + Unfinished, + /// The prompt declares no `output:` file. + #[error("the prompt declares no `output:` file")] + Undeclared, + /// The run completed without writing its declared output file. + #[error("the run completed without writing its declared output file `{path}`")] + Missing { + /// The declared output path. + path: String, + }, + /// The store refused the read of the declared output file. + #[error("the output file `{path}` could not be read")] + Store { + /// The declared output path. + path: String, + /// The store's failure. + #[source] + source: VfsError, + }, +} + +/// The per-session state behind the declared files. +pub(crate) struct SessionFiles { + /// The launch's filesystem, shared by every run; `None` gives each + /// run a fresh memory store. + vfs: Option, + /// The launch's text for the prompt's declared input file. + input_text: Option, + /// What the completed run left at the declared output file. + output: Mutex>, +} + +impl SessionFiles { + /// The files of a session launched over `vfs` with `input_text`. + pub(crate) fn new(vfs: Option, input_text: Option) -> Self { + Self { + vfs, + input_text, + output: Mutex::new(Err(OutputError::Unfinished)), + } + } + + /// The filesystem one run works in: the launch's handle, or a fresh + /// memory store at `/`. + pub(crate) fn run_vfs(&self) -> VfsRef { + self.vfs.clone().unwrap_or_default() + } + + /// The launch's text for the prompt's declared input file. + pub(crate) fn input_text(&self) -> Option { + self.input_text.clone() + } + + /// Reads the completed run's declared output file at `path` from + /// `vfs` on the blocking pool, tagged with `agent`, and keeps what it + /// finds for [`Session::output_text`]. + pub(crate) async fn collect(&self, agent: &str, vfs: VfsRef, path: Option) { + let output = match path { + None => Err(OutputError::Undeclared), + Some(path) => { + let read_path = path.clone(); + let read = spawn_blocking_launch(agent, move || read_output(&vfs, &read_path)) + .await + .unwrap_or_else(|join| { + Err(VfsError::Backend { + message: format!("the output read failed: {join}"), + }) + }); + match read { + Ok(text) => Ok(text), + Err(VfsError::NotFound { .. }) => Err(OutputError::Missing { path }), + Err(source) => Err(OutputError::Store { path, source }), + } + } + }; + *self.output.lock().unwrap_or_else(PoisonError::into_inner) = output; + } +} + +impl Session { + /// The text the session's completed run left at the prompt's declared + /// `output:` file. It is read as the run completes, before the + /// session reports `Closed`, so await `Closed` and then call this. + /// + /// # Errors + /// Returns [`OutputError::Unfinished`] until a run completes (and for + /// good when it failed, was cancelled, or was closed first), + /// [`OutputError::Undeclared`] for a prompt with no `output:` file, + /// [`OutputError::Missing`] when the run never wrote it, and + /// [`OutputError::Store`] when the store refused the read. + pub fn output_text(&self) -> Result { + self.core + .files + .output + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } +} diff --git a/crates/harness-internal/sessions/src/session/run.rs b/crates/harness-internal/sessions/src/session/run.rs index af6bfe09a..b2c67e3f0 100644 --- a/crates/harness-internal/sessions/src/session/run.rs +++ b/crates/harness-internal/sessions/src/session/run.rs @@ -1,7 +1,8 @@ //! One run of a session's program on the effect loop: resolve the //! client's current model, arm the run's cancel flag, build the session's -//! performers, prepare the run (opening its row in the log), and drive it -//! to its end. +//! performers, prepare the run (opening its row in the log and staging the +//! declared input file), drive it to its end, and read the declared +//! output file once it completes. //! //! Every event the run reports goes through the session core's sink once //! the log has recorded it, so the live broadcast and the transcript read @@ -84,9 +85,11 @@ pub(crate) async fn run_once( let cancel = core.arm_cancel(run); let limits = RunLimits::new(); let client = client.with_request_limits(limits.timeout(), limits.response_bytes()); + let vfs = core.files.run_vfs(); let services = Services { registry: Some(registry), - vfs: promptforge::vfs::VfsRef::default(), + vfs: vfs.clone(), + input_text: core.files.input_text(), cancel: cancel.clone(), log: Arc::clone(&core.log), chat: Arc::new(GatewayChatPerformer::new(client, core.delta_source.clone())), @@ -118,7 +121,7 @@ pub(crate) async fn run_once( let core = Arc::clone(&core); move |event: Event| core.observe(&event) }; - drive_run( + let outcome = drive_run( prepared.run, prepared.performers, Arc::clone(&core.log), @@ -127,13 +130,21 @@ pub(crate) async fn run_once( sink, ) .await - .map_err(RunFailure::Drive) + .map_err(RunFailure::Drive)?; + if matches!(outcome, RunOutcome::Completed { .. }) { + core.files + .collect(&core.agent, vfs, prepared.output_path) + .await; + } + Ok(outcome) } /// The row a failed preparation opened and closed, when it opened one. fn opened_run(error: &PrepareError) -> Option { match error { - PrepareError::Parse { run_id, .. } | PrepareError::Refused { run_id, .. } => Some(*run_id), + PrepareError::Parse { run_id, .. } + | PrepareError::Input { run_id, .. } + | PrepareError::Refused { run_id, .. } => Some(*run_id), // `Read`, `Log`, or a variant `harness-runner` adds behind its // `#[non_exhaustive]` `PrepareError`: none of them opened a row. _ => None, diff --git a/crates/harness-internal/sessions/tests/it/session-files.rs b/crates/harness-internal/sessions/tests/it/session-files.rs new file mode 100644 index 000000000..6f23f31a4 --- /dev/null +++ b/crates/harness-internal/sessions/tests/it/session-files.rs @@ -0,0 +1,174 @@ +//! A prompt's declared files through a session: the launch's input text is +//! staged at the declared input path and the completed run's declared +//! output comes back from `Session::output_text`; a host filesystem +//! handed over through `launch_with` is the one every run works in, its +//! store mounted wherever the host put it and its other mounts untouched; +//! a host-seeded store satisfies the declared input; a prompt without an +//! output file, and a run that never writes its own, report why; and a +//! declared input with nothing to stage fails the run as `RunFailed`. + +use std::path::Path; + +use harness_sessions::protocol::LaunchRequest; +use harness_sessions::runtime::{Harness, LaunchOptions}; +use harness_sessions::session::{FailureKind, OutputError, Session}; +use harness_sessions::transition::SessionState; +use promptforge::vfs::{MemoryBackend, Origin, VfsRef}; + +use super::{PATIENCE, harness, wait_for}; + +/// A prompt declaring `paper.md` in and `report.md` out, whose one +/// section writes the report from the paper. +const COPIES: &str = "---\nname: copies\ndescription: copies its input to its output\n\ + promptforge: 0\ninput:\n path: paper.md\n description: The paper\n\ + output:\n path: report.md\n description: The report\n---\n\n\ + # Copies\n\n## Only\n\n```lua\n\ + store.write('report.md', 'seen: ' .. store.read('paper.md'))\nreturn 'done'\n```\n"; + +/// The same declarations, but the section never writes the report. +const FORGETS: &str = "---\nname: forgets\ndescription: never writes its output\n\ + promptforge: 0\ninput:\n path: paper.md\n description: The paper\n\ + output:\n path: report.md\n description: The report\n---\n\n\ + # Forgets\n\n## Only\n\n```lua\nlocal _ = store.read('paper.md')\nreturn 'done'\n```\n"; + +/// A prompt that declares no files. +const PLAIN: &str = "---\nname: plain\ndescription: declares no files\npromptforge: 0\n---\n\n\ + # Plain\n\n## Only\n\n```lua\nreturn 'plain'\n```\n"; + +/// A harness over `dir` whose agents directory also holds `name.md`. +fn harness_with(dir: &Path, name: &str, source: &str) -> Harness { + let harness = harness(dir); + std::fs::write(dir.join("agents").join(format!("{name}.md")), source).unwrap(); + harness +} + +fn request(agent: &str, input_text: Option<&str>) -> LaunchRequest { + LaunchRequest { + agent: agent.to_owned(), + args: String::new(), + input_text: input_text.map(str::to_owned), + } +} + +/// Launches `request` under `options` and waits for the session to end. +async fn run_to_close( + harness: &Harness, + request: LaunchRequest, + options: LaunchOptions, +) -> Session { + let session = harness + .launch_with(request, options) + .await + .expect("the discovered agent launches"); + wait_for(&session, SessionState::Closed).await; + session +} + +#[tokio::test] +async fn the_input_text_round_trips_through_the_declared_output() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "copies", COPIES); + let session = harness + .launch(request("copies", Some("# Paper"))) + .await + .expect("the discovered agent launches"); + assert_eq!( + session.output_text(), + Err(OutputError::Unfinished), + "no run has completed yet" + ); + wait_for(&session, SessionState::Closed).await; + assert_eq!(session.output_text(), Ok("seen: # Paper".to_owned())); +} + +#[tokio::test] +async fn a_host_filesystem_is_the_one_the_session_works_in() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "copies", COPIES); + let extra = MemoryBackend::new(); + VfsRef::new(extra.clone()) + .acquire(Origin::new("session files test")) + .unwrap() + .write("/notes.md", b"host notes") + .unwrap(); + let vfs = VfsRef::builder() + .mount("/", MemoryBackend::new()) + .store("/store", MemoryBackend::new()) + .build() + .overlay("/extra", extra); + let options = LaunchOptions { + vfs: Some(vfs.clone()), + }; + let session = run_to_close(&harness, request("copies", Some("# Paper")), options).await; + assert_eq!(session.output_text(), Ok("seen: # Paper".to_owned())); + + // The run staged and wrote under the host's store root, and the host + // mount beside it kept its own file. + let access = vfs.acquire(Origin::new("session 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_eq!(access.read("/extra/notes.md").unwrap(), b"host notes"); +} + +#[tokio::test] +async fn a_host_seeded_store_satisfies_the_declared_input() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "copies", COPIES); + let vfs = VfsRef::default(); + vfs.acquire_store(Origin::new("session files test")) + .unwrap() + .write("paper.md", b"# Seeded") + .unwrap(); + let options = LaunchOptions { vfs: Some(vfs) }; + let session = run_to_close(&harness, request("copies", None), options).await; + assert_eq!(session.output_text(), Ok("seen: # Seeded".to_owned())); +} + +#[tokio::test] +async fn a_prompt_without_an_output_file_reports_it_undeclared() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "plain", PLAIN); + let session = run_to_close(&harness, request("plain", None), LaunchOptions::default()).await; + assert_eq!(session.output_text(), Err(OutputError::Undeclared)); +} + +#[tokio::test] +async fn a_run_that_never_writes_its_output_reports_it_missing() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "forgets", FORGETS); + let session = run_to_close( + &harness, + request("forgets", Some("# Paper")), + LaunchOptions::default(), + ) + .await; + assert_eq!( + session.output_text(), + Err(OutputError::Missing { + path: "report.md".to_owned() + }) + ); +} + +#[tokio::test] +async fn a_declared_input_with_nothing_to_stage_fails_the_run() { + let dir = tempfile::tempdir().unwrap(); + let harness = harness_with(dir.path(), "copies", COPIES); + let session = harness + .launch(request("copies", None)) + .await + .expect("the discovered agent launches"); + let mut errors = session.subscribe_errors(); + wait_for(&session, SessionState::Closed).await; + let failure = tokio::time::timeout(PATIENCE, errors.recv()) + .await + .expect("the refusal is reported in time") + .expect("the failure report arrives"); + assert_eq!(failure.kind, FailureKind::RunFailed); + assert!( + failure.message.contains("paper.md"), + "the report names the missing file: {}", + failure.message + ); + assert_eq!(session.output_text(), Err(OutputError::Unfinished)); +} diff --git a/crates/harness-internal/sessions/tests/it/session.rs b/crates/harness-internal/sessions/tests/it/session.rs index 0695da5ec..8088b46d2 100644 --- a/crates/harness-internal/sessions/tests/it/session.rs +++ b/crates/harness-internal/sessions/tests/it/session.rs @@ -6,7 +6,8 @@ //! a second run whose transcript indices continue; and a catalog whose //! models changed retires the run. The close path - draining outstanding //! effects and reporting the interrupt as one `Interrupted` failure - -//! sits in the `close` child module. +//! sits in the `close` child module, and the prompt's declared input and +//! output files in the `files` child module. use std::path::Path; use std::sync::Arc; @@ -26,6 +27,9 @@ use tokio::sync::broadcast; #[path = "session-close.rs"] mod close; +#[path = "session-files.rs"] +mod files; + #[path = "session-infer.rs"] mod infer; @@ -77,6 +81,7 @@ async fn launch(harness: &Harness) -> Session { .launch(LaunchRequest { agent: "asks".to_owned(), args: String::new(), + input_text: None, }) .await .expect("the discovered agent launches") @@ -88,6 +93,7 @@ async fn launch_agent(harness: &Harness, agent: &str) -> Session { .launch(LaunchRequest { agent: agent.to_owned(), args: String::new(), + input_text: None, }) .await .expect("the discovered agent launches") @@ -151,6 +157,7 @@ async fn an_unknown_agent_and_an_unbound_gateway_are_refused_at_launch() { .launch(LaunchRequest { agent: "../etc/passwd".to_owned(), args: String::new(), + input_text: None, }) .await .expect_err("a path-shaped name is not a discovered agent"); @@ -161,6 +168,7 @@ async fn an_unknown_agent_and_an_unbound_gateway_are_refused_at_launch() { .launch(LaunchRequest { agent: "asks".to_owned(), args: String::new(), + input_text: None, }) .await .expect_err("no gateway means no model round could ever complete"); diff --git a/crates/harness/Cargo.toml b/crates/harness/Cargo.toml index 492a21cd8..6633d77aa 100644 --- a/crates/harness/Cargo.toml +++ b/crates/harness/Cargo.toml @@ -10,12 +10,14 @@ description = "PromptForge harness public API: the surface through which Worksho documentation = "https://cppalliance.github.io/promptforge/harness/harness/index.html" # Every crate that defines a re-exported item: each `pub use` names the -# defining crate's path. +# defining crate's path. The engine's types a harness signature names are +# re-exported through the `promptforge` facade, their one public path. [dependencies] harness-capabilities.workspace = true harness-log.workspace = true harness-runner.workspace = true harness-sessions.workspace = true +promptforge.workspace = true workspace-hack.workspace = true [lints] diff --git a/crates/harness/src/lib.md b/crates/harness/src/lib.md index aa8347bd1..66d6a1b32 100644 --- a/crates/harness/src/lib.md +++ b/crates/harness/src/lib.md @@ -1,4 +1,4 @@ -The public API of the PromptForge harness family. Workshop and other clients depend on this crate alone: it holds the harness configuration, the gateway binding a client pushes at startup and on every gateway replacement, the session, event, and delta types a client renders, the awaitable [`cancel::CancelHandle`] a client selects over, and [`display_chain`], the renderer that turns a harness error and its cause chain into one line for a person. Every other harness crate is private to the family and reachable only through this one. +The public API of the PromptForge harness family. Workshop and other clients depend on this crate alone: it holds the harness configuration, the gateway binding a client pushes at startup and on every gateway replacement, the session, event, and delta types a client renders, the awaitable [`cancel::CancelHandle`] a client selects over, and [`display_chain`], the renderer that turns a harness error and its cause chain into one line for a person. Every other harness crate is private to the family and reachable only through this one. A client that builds a session's filesystem also uses the engine's public `promptforge::vfs`. # The harness handle and its bindings @@ -12,12 +12,18 @@ A gateway bearer key is never written to logs or `Debug` output. # Sessions -The session vocabulary clients speak and render is ids, launch requests, durable events, and ephemeral deltas. The live [`Session`] handle is what a client launches, sends input to, cancels, closes, and subscribes to events and deltas through. A session's transcript is the harness run log: subscribe first, then read [`Session::transcript`] past the last seen index. +The session vocabulary clients speak and render is ids, launch requests, durable events, and ephemeral deltas. The live [`Session`] handle is what a client launches, sends input to, cancels, closes, subscribes to events and deltas through, and reads the completed run's output file from. A session's transcript is the harness run log: subscribe first, then read [`Session::transcript`] past the last seen index. A session announces its input waits with [`WaitFrame`] values, and a refused answer returns a [`WaitError`]. A session's failure reports include a [`FailureKind`] a client matches on beside the display message; the sentence is never the classifier. A prompt asks its operator through the `promptforge/user-input` capability, whose `input.ask()` calls the ask tool by its full id, [`USER_INPUT_ASK_TOOL`]. The operator's answer comes back as that tool's result, so a client that shows a transcript recognizes a script's ask by the result's alias being this id. +# Declared files and the session's filesystem + +A prompt's frontmatter can declare an `input:` file it expects in the store when it starts and an `output:` file it leaves there when it finishes. [`LaunchRequest::input_text`] is staged at the declared input path before each run, and [`Session::output_text`] returns what the completed run left at the declared output path, read before the session reports `Closed`. A run is refused, and reported as [`FailureKind::RunFailed`], when the launch supplies input text for a prompt that declares no input file, or when the prompt declares one that the launch neither supplies nor finds already in the store. A declared output the run never wrote is [`OutputError::Missing`] and does not fail the run. + +[`Harness::launch`] gives each run a fresh memory store. [`Harness::launch_with`] takes [`LaunchOptions`], whose `vfs` is the filesystem every run of the session works in: any [`vfs::VfsRef`] the client builds, with its declared store beside host mounts, overlays, a policy, and an operation sink. It is the one thing a launch hands over as a live handle rather than data. The harness owns it from launch on and stages and reads the declared files through its store under the store's path rules, and any backend, policy, or sink in it runs inside the session's store operations. + # Errors A harness error's `Display` holds only its own message. A client that shows one to a person renders the cause chain through [`display_chain`]. diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index c7ba940a1..e7206e16c 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -15,7 +15,9 @@ pub use harness_sessions::protocol::SessionId; pub use harness_sessions::runtime::Harness; pub use harness_sessions::runtime::HarnessConfig; pub use harness_sessions::runtime::LaunchError; +pub use harness_sessions::runtime::LaunchOptions; pub use harness_sessions::session::FailureKind; +pub use harness_sessions::session::OutputError; pub use harness_sessions::session::Session; pub use harness_sessions::session::SessionFailure; pub use harness_sessions::transition::SessionState; @@ -39,3 +41,10 @@ pub mod log { pub use harness_log::LogError; pub use harness_log::RunId; } + +pub mod vfs { + #![doc = include_str!("vfs.md")] + + pub use promptforge::vfs::VfsError; + pub use promptforge::vfs::VfsRef; +} diff --git a/crates/harness/src/vfs.md b/crates/harness/src/vfs.md new file mode 100644 index 000000000..68b6389cc --- /dev/null +++ b/crates/harness/src/vfs.md @@ -0,0 +1,3 @@ +The engine's filesystem types a launch names: the [`VfsRef`] a client hands a session in [`crate::LaunchOptions::vfs`], and the [`VfsError`] a store read reports through [`crate::OutputError::Store`]. + +A client builds the handle with the engine's public `promptforge::vfs`: a builder that declares the store and mounts host directories or its own backends beside it, overlays, a policy, and an operation sink. The harness stages the prompt's declared input file and reads its declared output file through that handle's store. diff --git a/crates/harness/tests/suite/launch.rs b/crates/harness/tests/suite/launch.rs new file mode 100644 index 000000000..8c0e22ef8 --- /dev/null +++ b/crates/harness/tests/suite/launch.rs @@ -0,0 +1,34 @@ +//! The launch surface a client builds through the public API: the +//! engine's filesystem handle, named through `harness::vfs`, and the +//! output error a session reports. + +use harness::vfs::{VfsError, VfsRef}; +use harness::{LaunchOptions, OutputError}; + +#[test] +fn default_launch_options_hand_over_no_filesystem() { + assert!(LaunchOptions::default().vfs.is_none()); +} + +#[test] +fn a_client_hands_a_session_the_engines_filesystem_handle() { + let options = LaunchOptions { + vfs: Some(VfsRef::default()), + }; + assert!(options.vfs.is_some()); +} + +#[test] +fn an_output_store_failure_renders_its_own_message_and_sources_the_engines_error() { + let error = OutputError::Store { + path: "report.md".to_owned(), + source: VfsError::NotFound { + path: "report.md".to_owned(), + }, + }; + assert_eq!( + error.to_string(), + "the output file `report.md` could not be read" + ); + assert!(std::error::Error::source(&error).is_some()); +} diff --git a/crates/harness/tests/suite/main.rs b/crates/harness/tests/suite/main.rs index 2afee65c7..6e32217d4 100644 --- a/crates/harness/tests/suite/main.rs +++ b/crates/harness/tests/suite/main.rs @@ -1,3 +1,4 @@ //! The facade's integration suite, written against `harness` paths only. mod gateway; +mod launch; diff --git a/crates/promptforge-internal/README.md b/crates/promptforge-internal/README.md index 4e51e3ee9..afa9be621 100644 --- a/crates/promptforge-internal/README.md +++ b/crates/promptforge-internal/README.md @@ -24,4 +24,4 @@ The PromptForge virtual filesystem: canonical interned paths, the claims model, ## promptforge-model-client -The model vocabulary: the chat-completions wire types and SSE reassembly a `Chat` effect exchanges, and the model catalog and binding vocabulary. The engine and the Lua host call models through it. Depends on promptforge-types; it owns no transport - the HTTP client is the harness's (`harness-models`). +The model vocabulary: the chat-completions wire types and SSE reassembly a `Chat` effect exchanges, and the model catalog and binding vocabulary. The model catalog types (`ModelCatalog`, `ModelDescriptor`, `ModelId`, `ThinkingMode`) are defined in promptforge-types and re-exported by model-client's `model` module. The engine and the Lua host call models through it. Depends on promptforge-types; it owns no transport - the HTTP client is the harness's (`harness-models`). diff --git a/crates/promptforge-internal/engine/AGENTS.md b/crates/promptforge-internal/engine/AGENTS.md index 8913a4120..bdb3b855a 100644 --- a/crates/promptforge-internal/engine/AGENTS.md +++ b/crates/promptforge-internal/engine/AGENTS.md @@ -1,8 +1,8 @@ # promptforge-engine -This crate owns PromptForge document execution and run orchestration. It is private to `crates/promptforge-internal/`: hosts reach it only through the `promptforge` facade, which re-exports the run, effect, context, and error types from this crate's root. `execute` is private, and only `model`, `parser`, and `input` remain public modules, plus `test_support` behind the `test-support` feature. The single-public-crate rule that makes `promptforge` the only promptforge crate an outside crate may name is stated in the root `AGENTS.md` and enforced by `cargo test -p build-xtask`. +This crate owns PromptForge document execution and run orchestration. It is private to `crates/promptforge-internal/`: hosts reach it only through the `promptforge` facade, which re-exports the run, effect, context, requirement, and error types from this crate's root and takes the parser, model, Lua, and store vocabulary from the sibling crates that define it. `execute` is private, and only `model` and `parser` remain public modules, plus `test_support` behind the `test-support` feature; `model` and `parser` only re-export items their sibling crates define. The single-public-crate rule that makes `promptforge` the only promptforge crate an outside crate may name is stated in the root `AGENTS.md` and enforced by `cargo test -p build-xtask`. -- The crate root is the only public path for the `execute` module's items, so the facade names them `promptforge_engine::X`, never a path through `execute`. The public `input` and `test_support` modules are the exceptions: the facade re-exports their items by module path. The thin `lua`, `untrusted`, and `tools` modules are crate-internal import surfaces for the crates that own them; do not add public compatibility paths or re-exports. +- The crate root is the only public path for the `execute` module's items, so the facade names them `promptforge_engine::X`, never a path through `execute`. The facade re-exports no item by an engine module path, and `test_support` is for suites and benches that enable the feature. The thin `lua`, `untrusted`, and `tools` modules are crate-internal import surfaces for the crates that own them; do not add public compatibility paths or re-exports. - Concrete providers stay in their provider crates. The engine may re-export them for crate-internal use but never reacquires provider implementation. - Store access is decided only by the executor: every store view is derived from the chain's access inside the engine at dispatch; a host performing a `Store` effect uses the view it was given and never derives, widens, or retains store scope from it. - The executor imports parser, Lua, model-client, vfs, and host-support vocabulary from its sibling crates under `crates/promptforge-internal/`. Those crates never depend on this executor, apart from the doctest-only `promptforge` dev-dependency the root `AGENTS.md` excepts: their doc examples alone compile against the facade, and no unit test, integration test, or bench imports it. diff --git a/crates/promptforge-internal/engine/Cargo.toml b/crates/promptforge-internal/engine/Cargo.toml index 4b20c7c34..e15823f32 100644 --- a/crates/promptforge-internal/engine/Cargo.toml +++ b/crates/promptforge-internal/engine/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" keywords = ["promptforge", "prompt", "llm", "agent", "ai"] categories = ["development-tools", "text-processing", "api-bindings"] -description = "PromptForge API: prompt parser and the sans-IO Run state machine that executes sections as effects a host performs" +description = "PromptForge API: the sans-IO Run state machine that executes a parsed prompt's sections as effects a host performs" documentation = "https://cppalliance.github.io/promptforge/" [dependencies] @@ -38,8 +38,8 @@ workspace-hack.workspace = true test-support = ["dep:tokio"] [dev-dependencies] -# The suites write their fixture tools and brokers as `async fn` under the -# macro; the traits themselves are declared in its expanded form. +# The suites write their fixture tools as `async fn` under the macro; the +# fixture tool trait itself is declared in its expanded form. async-trait.workspace = true axum.workspace = true # The buffer `reqwest` yields per body chunk, named as the mock-gateway diff --git a/crates/promptforge-internal/engine/README.md b/crates/promptforge-internal/engine/README.md index 63dfd0960..9081d07fe 100644 --- a/crates/promptforge-internal/engine/README.md +++ b/crates/promptforge-internal/engine/README.md @@ -1,13 +1,9 @@ # promptforge-engine -A Rust library that turns Markdown files into executable AI prompt pipelines. You write a prompt as a document - YAML frontmatter for metadata, embedded Lua for logic, prose blocks for model instructions - and the library parses it into a validated representation, then runs it as a deterministic state machine: every model round, tool call, input wait, store operation, and timer is an effect value the host performs and answers, and every boundary is an event value the host logs. Structured multi-section prompts with tool dispatch, model orchestration, concurrent fanout, and a virtual filesystem, driven by a `step`/`resume` loop the host owns. +A Rust library that turns Markdown files into executable AI prompt pipelines. You write a prompt as a document - YAML frontmatter for metadata, embedded Lua for logic, prose blocks for model instructions - and the library parses it into a validated representation, then runs it as a deterministic state machine: every model round (`Chat`), tool call (`ToolCall`), store operation (`Store`), timer (`Timer`), and read of a task's reported history (`TaskEvents`) is an effect value the host performs and answers, and every boundary is an event value the host logs. Structured multi-section prompts with tool dispatch, model orchestration, concurrent fanout, and a virtual filesystem, driven by a `step`/`resume` loop the host owns. See the [PromptForge User Guide](https://cppalliance.github.io/promptforge/) for full documentation. -## Minimum Rust Version - -Rust 1.89 or later. - ## License -Licensed under the [Boost Software License 1.0](LICENSE). +Licensed under the Boost Software License 1.0. diff --git a/crates/promptforge-internal/engine/src/error.rs b/crates/promptforge-internal/engine/src/error.rs index 93628305e..f44c01d19 100644 --- a/crates/promptforge-internal/engine/src/error.rs +++ b/crates/promptforge-internal/engine/src/error.rs @@ -40,7 +40,7 @@ pub(crate) enum Error { /// The prompt frontmatter was not valid YAML, preserving the parser cause. /// /// This retains the originating YAML decode failure (a - /// `serde_yaml_ng::Error`) as the `#[source]` cause (F3) so + /// `serde_yaml_ng::Error`) as the `#[source]` cause so /// [`crate::ParseError`] can expose the frontmatter syntax location through /// [`std::error::Error::source`] instead of flattening it into the message. #[error("invalid frontmatter: {message}")] @@ -365,9 +365,11 @@ pub(crate) enum Error { #[error("unsupported promptforge version: {0} (this build supports major 0)")] UnsupportedVersion(u32), - /// The environment cannot satisfy the prompt's declared requirements: - /// required capabilities are missing, or the filled model fails a - /// declared hard requirement (a context minimum or hard keyword). + /// The environment cannot satisfy the prompt: a required capability is + /// missing, a required capability needs a host service this host does + /// not provide, two declared capabilities conflict, the filled model + /// fails a declared requirement (a context minimum or a hard keyword), + /// or an H1 block failed the prompt's hard gate. /// /// The notice is the whole message, written to be read by a model: it /// may arrive as tool output when the prompt runs as a sub-run tool. @@ -519,7 +521,7 @@ impl Error { } /// Wraps an `mlua` failure as [`Error::LuaRuntime`], preserving it as the - /// `#[source]` cause (F4) rather than flattening it to a string. + /// `#[source]` cause rather than flattening it to a string. #[cfg(test)] pub(crate) fn lua(source: mlua::Error) -> Error { Error::LuaRuntime { diff --git a/crates/promptforge-internal/engine/src/execute.rs b/crates/promptforge-internal/engine/src/execute.rs index 3ed8b24f6..12651dc7c 100644 --- a/crates/promptforge-internal/engine/src/execute.rs +++ b/crates/promptforge-internal/engine/src/execute.rs @@ -25,7 +25,7 @@ //! `resume`, and `cancel`, the `Step` it returns, and the effect vocabulary //! (the `Effect` a leaf arm issues, its serializable `EffectRecord`, and the //! `EffectAnswer` a host returns). -//! - `scheduler` - the chain-stack scheduler driving the coroutine protocol: +//! - `scheduler` - the chain scheduler driving the coroutine protocol: //! the live H1 pass, the walk, call chains, fanout, and the `chat` and //! `tool_call` rounds the section-visible `models.loop` shim yields. //! - `scope` - tool-scope validation and schema/dispatch preparation. @@ -126,8 +126,10 @@ pub fn perform_store_op( /// - [`RunErrorKind::Cancelled`] - the host cancelled the run (mid-run /// classification only; the interface reports [`RunResult::Cancelled`]). /// - [`RunErrorKind::Internal`] - an internal invariant failed. -/// - [`RunErrorKind::RequirementsUnmet`] - an H1 assertion or model -/// requirement the environment cannot satisfy. +/// - [`RunErrorKind::RequirementsUnmet`] - a missing required capability, +/// a missing host service, a capability conflict, an unmet model +/// requirement (a context minimum or a hard keyword), or a failed H1 +/// hard gate. #[derive(Debug)] pub enum RunResult { /// The run completed with its final text. Mirrors `Result` vocabulary, diff --git a/crates/promptforge-internal/engine/src/execute/config-limits.rs b/crates/promptforge-internal/engine/src/execute/config-limits.rs index 9ae6fd5f4..a5407e81e 100644 --- a/crates/promptforge-internal/engine/src/execute/config-limits.rs +++ b/crates/promptforge-internal/engine/src/execute/config-limits.rs @@ -67,11 +67,11 @@ impl RunLimits { #[must_use] pub fn new() -> RunLimits { RunLimits { - max_tool_iterations: nz_u32(24), - concurrency: nz_usize(8), - max_response_bytes: nz_u64(16 * 1024 * 1024), - lua_memory_bytes: nz_usize(64 * 1024 * 1024), - lua_log_events: nz_u32(1024), + max_tool_iterations: const { nz_u32(24) }, + concurrency: const { nz_usize(8) }, + max_response_bytes: const { nz_u64(16 * 1024 * 1024) }, + lua_memory_bytes: const { nz_usize(64 * 1024 * 1024) }, + lua_log_events: const { nz_u32(1024) }, request_timeout: Duration::from_secs(120), } } @@ -183,8 +183,8 @@ mod tests { assert_eq!(defaults.timeout(), Duration::from_secs(120)); let built = RunLimits::new() - .max_response_bytes(nz_u64(4 * 1024)) - .lua_log_events(nz_u32(7)) + .max_response_bytes(const { nz_u64(4 * 1024) }) + .lua_log_events(const { nz_u32(7) }) .request_timeout(Duration::from_secs(5)); assert_eq!(built.response_bytes().get(), 4 * 1024); assert_eq!(built.lua_logs().get(), 7); diff --git a/crates/promptforge-internal/engine/src/execute/config.rs b/crates/promptforge-internal/engine/src/execute/config.rs index e87f5c95c..adc8403b9 100644 --- a/crates/promptforge-internal/engine/src/execute/config.rs +++ b/crates/promptforge-internal/engine/src/execute/config.rs @@ -120,9 +120,6 @@ pub struct RunContext { /// shared library replays. Empty on a caller-built context that was /// never prepared. pub(crate) preludes: Vec, - // No test-only host field: the in-crate suites assemble a - // `RunHost` themselves and pass it to the tokio driver, so this - // production struct carries only the engine's inputs. } impl RunContext { diff --git a/crates/promptforge-internal/engine/src/execute/context.rs b/crates/promptforge-internal/engine/src/execute/context.rs index 6c7b0ba0d..f269a1592 100644 --- a/crates/promptforge-internal/engine/src/execute/context.rs +++ b/crates/promptforge-internal/engine/src/execute/context.rs @@ -63,8 +63,9 @@ pub(crate) struct RunState { argv: Option>, /// The run's resource limits. limits: RunLimits, - /// The run's event buffer, shared by every chain's emitter and every - /// spawned leaf task, drained by the driver after each dispatch round. + /// The run's event buffer, shared by every chain's emitter, spawned + /// task chains' included, and drained once per `step` into the batch + /// handed to the host. events: EventSink, /// This context's task-scoped emitter: the root task's at /// construction, a spawned chain's own after [`with_task`](Self::with_task). @@ -77,12 +78,12 @@ pub(crate) struct RunState { /// same flag the activated capabilities and the run's `cancel` share. cancel: CancelHandle, /// Test-only: a copy of every drained event, so a test can assert on - /// the values themselves - their provenance included - rather than on - /// what the host observer was handed. + /// the values themselves - their provenance included - without + /// collecting each step's batch. #[cfg(test)] tap: Option>>>, - /// The model-turn counter this context advances (the run's, or one - /// shared by all arms of a fanout). + /// The model-turn counter this context advances: the run's, or a + /// spawned task chain's own from [`with_task`](Self::with_task). turns: Arc, /// The shared library replayed as every section's first chunk; an empty /// compiled chunk when the prompt declares no `lua shared` library, so diff --git a/crates/promptforge-internal/engine/src/execute/requirements.rs b/crates/promptforge-internal/engine/src/execute/requirements.rs index bf83342b0..f65a14275 100644 --- a/crates/promptforge-internal/engine/src/execute/requirements.rs +++ b/crates/promptforge-internal/engine/src/execute/requirements.rs @@ -109,9 +109,8 @@ impl Requirements { /// required versus actual. #[must_use] pub fn notice(&self) -> String { - // Writing to a String is infallible; the `let _` mirrors the - // crate's established pattern (subst.rs) under the denied - // `unwrap_used`/`expect_used` lints. + // Writing to a String is infallible, so each `write!` result is + // discarded under the denied `unwrap_used`/`expect_used` lints. use std::fmt::Write as _; let mut notice = String::from("the environment cannot satisfy this prompt:"); for id in &self.missing_required { diff --git a/crates/promptforge-internal/engine/src/execute/run.rs b/crates/promptforge-internal/engine/src/execute/run.rs index e2867dbe2..d3d67acae 100644 --- a/crates/promptforge-internal/engine/src/execute/run.rs +++ b/crates/promptforge-internal/engine/src/execute/run.rs @@ -11,7 +11,6 @@ use std::sync::Arc; use promptforge_types::event::Event; use promptforge_types::ids::Provenance; -#[path = "run-effect.rs"] mod effect; pub use effect::{ @@ -222,9 +221,8 @@ impl Run { } } -/// Assembles the run state from the host's context: the version gate, the -/// shared library, and the declared store, in the order the run has -/// always checked them. +/// Assembles the run state from the host's context, checking the version +/// gate, the shared library, and the declared store in that order. /// /// # Errors /// Returns [`Error::UnsupportedVersion`] or a structural parse error for a @@ -263,5 +261,4 @@ fn prepare_state(prompt: Arc, args: &str, ctx: &RunContext) -> Result(&self, prompt: &'p Prompt) -> &'p str { + self.resolve(prompt) + .first() + .map_or(prompt.title(), Section::name) + } } /// The most precise prompt-source line known for `blocks`: the first @@ -284,6 +292,12 @@ struct Chain { /// The section of `slice` the chain is running, or the next entry /// candidate while the chain is between sections. index: usize, + /// The name of the section the chain most recently entered: the name + /// its reports carry on the walk, including between sections and after + /// the walk runs off its slice, where `index` names no running section. + /// Before the first entry, the slice's first section's name, or the + /// prompt's title for an empty slice. + entered: String, /// The suspended parent positions of the chain's jump-started child /// walks: the parent slice plus the jumper's index in it. A jump to a /// child pushes the current position and descends; when the child @@ -311,9 +325,7 @@ struct Chain { var: serde_json::Value, /// The chain's call nesting depth: each call child and each spawned /// task runs one level deeper. The recursion cap checks this field, - /// never the chain-stack length - task chains sit on the ready queue, - /// not the stack, so only the field keeps the accounting across a - /// spawn boundary. + /// which carries the depth across a spawn boundary as well as a call. call_depth: usize, /// The chain's effective admission limit: the most tasks this chain /// may have admitted at once. The root's is the run's ceiling @@ -394,13 +406,14 @@ impl Chain { } /// The chain's current section name for observations and errors: the - /// prompt's title for the live H1 pass, the section's name on the walk. + /// prompt's title for the live H1 pass, else the name of the section + /// the chain most recently entered - so a chain between sections, or + /// past its slice's last section, reports the section it just left. fn section_name(&self) -> &str { - let prompt = self.ctx.prompt(); if self.h1 { - prompt.title() + self.ctx.prompt().title() } else { - self.section(prompt).name() + &self.entered } } } @@ -426,9 +439,6 @@ pub(crate) struct Scheduler { ctx: RunState, /// The chain arena: append-only, indexed by [`ChainIndex`]. chains: Vec, - /// The call-nesting chain stack (LIFO): a call dispatch pushes - /// the child, the child's finish pops it. - stack: Vec, /// Chains eligible to resume (FIFO); the driver drains it before /// awaiting anything. ready: VecDeque, @@ -496,7 +506,6 @@ impl Scheduler { Self { ctx, chains: Vec::new(), - stack: Vec::new(), ready: VecDeque::new(), resuming: VecDeque::new(), spawned: VecDeque::new(), diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/chain.rs b/crates/promptforge-internal/engine/src/execute/scheduler/chain.rs index ef527801e..9a4c9baef 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/chain.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/chain.rs @@ -74,6 +74,7 @@ impl Scheduler { || TaskId::from(ChainId::root()), |parent| self.chains[parent.index()].task.clone(), ); + let entered = slice.first_name(ctx.prompt()).to_owned(); self.chains.push(Chain { lineage, counters, @@ -90,6 +91,7 @@ impl Scheduler { frame: None, slice, index, + entered, positions: Vec::new(), block: 0, coroutine: None, @@ -204,14 +206,7 @@ impl Scheduler { } match parent { None => *root_result = Some(outcome), - Some(parent_id) => { - debug_assert_eq!( - self.stack.pop(), - Some(id), - "a finishing child chain is the call stack's top" - ); - self.answer_inline(parent_id, Answer::Call(outcome)); - } + Some(parent_id) => self.answer_inline(parent_id, Answer::Call(outcome)), } } @@ -251,11 +246,6 @@ impl Scheduler { if let Some(effect) = effect { self.abort_effect(effect); } - // A chain on the call stack is the top here: only its own - // descendants sit above it, and the recursion already removed them. - if self.stack.last() == Some(&id) { - self.stack.pop(); - } let access = { let chain = &mut self.chains[id.index()]; chain.coroutine = None; diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs b/crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs index b7c4f8bfc..1e84f64c1 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs @@ -296,9 +296,9 @@ impl Scheduler { Ok(()) } - /// Dispatches a `call` request: constructs the child chain, pushes - /// it on the chain stack, and enqueues it; the parent blocks until the - /// child's finish delivers its final text as the answer. Every dispatch + /// Dispatches a `call` request: constructs the child chain and + /// enqueues it; the parent blocks until the child's finish delivers + /// its final text as the answer. Every dispatch /// failure - the depth cap, target resolution, child construction - is /// the call's answer, resumed into the caller so an author `pcall` can /// catch it. @@ -310,10 +310,7 @@ impl Scheduler { var: &serde_json::Value, ) { match self.prepare_call(id, target, input, var) { - Ok(child) => { - self.stack.push(child); - self.ready.push_back(child); - } + Ok(child) => self.ready.push_back(child), Err(error) => { self.answer_inline(id, Answer::Call(Err(error))); } diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/drive.rs b/crates/promptforge-internal/engine/src/execute/scheduler/drive.rs index 19b9022e0..6bfe0788e 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/drive.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/drive.rs @@ -196,7 +196,6 @@ impl Scheduler { self.ready.clear(); self.resuming.clear(); self.spawned.clear(); - self.stack.clear(); for (index, chain) in self.chains.iter().enumerate() { let Some(access) = chain.access.as_ref() else { continue; diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/h1.rs b/crates/promptforge-internal/engine/src/execute/scheduler/h1.rs index 25290b0a8..27ff8e6a1 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/h1.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/h1.rs @@ -61,6 +61,7 @@ impl Scheduler { frame: None, slice: SlicePath::root(), index: 0, + entered: SlicePath::root().first_name(self.ctx.prompt()).to_owned(), positions: Vec::new(), block: 0, coroutine: None, diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/tasks.rs b/crates/promptforge-internal/engine/src/execute/scheduler/tasks.rs index 9e0170746..0fc880dad 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/tasks.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/tasks.rs @@ -170,18 +170,16 @@ impl Scheduler { /// The fallible half of spawn dispatch, shared with the model's `task` /// built-in: `call`'s depth cap against the spawner's call-depth field /// (the refusal named after the author-facing call that tripped it, - /// `fanout` for an arm and `call` otherwise, so the text is the one - /// each path always had), `call`'s target resolution over the - /// spawner's visible set, the worker-template check (a list section is - /// not a target), then the task chain one level deeper under the - /// spawn's `args` and `var` snapshot, with its own access capability - /// (a concurrent thread of execution, forked from the spawner's so - /// the spawn is the happens-before edge; every delivery of the task - /// joins it back) and a - /// fresh turn counter. The child leaves this call queued for - /// admission - holding no Lua VM yet - with its `var` snapshot moved - /// into the chain and its start record stashed; the drain admits it - /// when a slot frees up. + /// `fanout` for an arm and `call` otherwise), `call`'s target + /// resolution over the spawner's visible set, the worker-template + /// check (a list section is not a target), then the task chain one + /// level deeper under the spawn's `args` and `var` snapshot, with its + /// own access capability (a concurrent thread of execution, forked + /// from the spawner's so the spawn is the happens-before edge; every + /// delivery of the task joins it back) and a fresh turn counter. The + /// child leaves this call queued for admission - holding no Lua VM + /// yet - with its `var` snapshot moved into the chain and its start + /// record stashed; the drain admits it when a slot frees up. #[expect( clippy::too_many_arguments, reason = "the spawn keeps the request's target, input, seeds, var snapshot, origin, and fanout mark explicit" @@ -472,13 +470,10 @@ impl Scheduler { } // `Author`, or an origin `promptforge-types` adds behind // its `#[non_exhaustive]` `TaskOrigin`: treated as the - // author's. Both origins as they stand today are driven - // through this arm by - // `an_ending_owner_leaks_its_author_task_and_never_its_model_task`, - // which is the whole of the guarantee: a variant added to - // `TaskOrigin` lands here silently until someone extends - // that test, because `#[non_exhaustive]` denies this crate - // the exhaustive match that would fail the build instead. + // author's. `#[non_exhaustive]` denies this crate an + // exhaustive match, so a new variant lands here silently. + // Only one test pins which origins leak: + // `an_ending_owner_leaks_its_author_task_and_never_its_model_task`. _ => leaked.push(task), } } @@ -497,8 +492,7 @@ impl Scheduler { /// abandons that chain's own tasks (as `OwnerAborted`) on the way, so /// a nested slot is already terminal when its owner's turn comes and /// `is_live()` skips it. The leaked-author list is discarded: no one - /// receives an outcome for a run that is ending. Detected by - /// `cancelling_a_run_settles_every_live_task_with_one_terminal_before_the_run_ends`. + /// receives an outcome for a run that is ending. pub(super) fn settle_all_tasks(&mut self, reason: AbandonReason) { let mut owners: Vec = self .tasks @@ -588,7 +582,7 @@ impl Scheduler { chain.slots_used > 0, "a chain holding a slot cannot release below zero" ); - chain.slots_used -= 1; + chain.slots_used = chain.slots_used.saturating_sub(1); at = chain.owner.or(chain.parent); } } diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs b/crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs index 38b606df9..7fa9e6388 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs @@ -189,8 +189,7 @@ impl Scheduler { // The counts seed from the section's effective scope; a bound alias // outside it must still be seeded here, because the increment // errors on an unseeded alias. The attempt counts at dispatch - - // before the tool runs, so a cancelled dispatch still counts, - // exactly as the shared body has always counted it. + // before the tool runs, so a cancelled dispatch still counts. counts.ensure(binding.alias())?; counts.increment(binding.alias())?; let origin = ToolCallOrigin { diff --git a/crates/promptforge-internal/engine/src/execute/scheduler/walk.rs b/crates/promptforge-internal/engine/src/execute/scheduler/walk.rs index ad7da250e..a09892895 100644 --- a/crates/promptforge-internal/engine/src/execute/scheduler/walk.rs +++ b/crates/promptforge-internal/engine/src/execute/scheduler/walk.rs @@ -166,6 +166,7 @@ impl Scheduler { seed, )?; chain.frame = Some(frame); + slice[index].name().clone_into(&mut chain.entered); chain.block = 0; Ok(true) } diff --git a/crates/promptforge-internal/engine/src/execute/scope.rs b/crates/promptforge-internal/engine/src/execute/scope.rs index 93b0f7ccf..c4966ea24 100644 --- a/crates/promptforge-internal/engine/src/execute/scope.rs +++ b/crates/promptforge-internal/engine/src/execute/scope.rs @@ -72,7 +72,7 @@ pub(crate) fn prepare_scoped_tools( .model_description() .unwrap_or_else(|| binding.description()) .to_owned(); - // F7: build every advertised schema through the validated constructor, + // Build every advertised schema through the validated constructor, // so an unusable wire name or a non-object JSON Schema is refused here // rather than sent to the model. let schema = tool_schema_new( diff --git a/crates/promptforge-internal/engine/src/execute/section_context-construct.rs b/crates/promptforge-internal/engine/src/execute/section_context-construct.rs index 3ae52f9c5..2dd5b66b6 100644 --- a/crates/promptforge-internal/engine/src/execute/section_context-construct.rs +++ b/crates/promptforge-internal/engine/src/execute/section_context-construct.rs @@ -3,9 +3,10 @@ //! walked section (a spawned task's first entry included, seeded with its //! `item` and `sys.index`), the live H1 pass (section 0) - and hands back //! a live [`SectionContext`] whose `Drop` is the teardown boundary. The -//! setup half (host injection, host APIs, the control surface, the shared -//! replay, the captured alias bindings) is shared; only the seed, the `sys` -//! extras, and the `list_from_section` visible set differ. +//! setup half (host injection, host APIs, the control surface, the +//! capability preludes, the shared replay, the store yield shims, the +//! captured alias bindings) is shared; only the seed, the `sys` extras, +//! and the `list_from_section` visible set differ. use std::sync::Arc; @@ -27,8 +28,9 @@ impl SectionContext { /// preamble: the `sys` JSON, the section-started observation, VM /// construction and limits, the control surface (the `jump` and /// `list_from_section` callbacks resolved over the section's visible - /// set, plus the coroutine yield shims for the suspending calls), the - /// shared setup half (host injection, host APIs, the shared replay, the + /// set, plus the coroutine yield shims for the suspending calls), and + /// the rest of the shared setup half (host injection, host APIs, the + /// capability preludes, the shared replay, the store yield shims, the /// captured alias bindings). /// /// `siblings` is the caller's own walk slice, from which the section's @@ -63,7 +65,7 @@ impl SectionContext { let mut sys = ctx.sys_json(section_id, task_id, section.name()); // A spawned chain's `sys.index` is the spawn's own value, verbatim; // absent otherwise, so a walked section reading `sys.index` raises - // the sealed-sys unknown-field error exactly as before. + // the sealed-sys unknown-field error. if let Some(index) = seed.index { sys["index"] = serde_json::Value::from(index); } @@ -128,7 +130,8 @@ impl SectionContext { /// `when` like every section after it), VM construction over the /// run's shared sets, limits, and the shared /// setup half (host injection, host APIs, the control surface, the - /// coroutine shims, the shared replay, the captured alias bindings). + /// coroutine shims, the capability preludes, the shared replay, the + /// store yield shims, the captured alias bindings). /// /// H1's only deltas from a walked section: no `SECTION_STARTED` /// observation (the pass is not a walked section), an empty `var` seed diff --git a/crates/promptforge-internal/engine/src/execute/section_context.rs b/crates/promptforge-internal/engine/src/execute/section_context.rs index 49a61aa65..3a302fb53 100644 --- a/crates/promptforge-internal/engine/src/execute/section_context.rs +++ b/crates/promptforge-internal/engine/src/execute/section_context.rs @@ -67,8 +67,7 @@ pub(crate) struct LocalCall { pub(crate) struct SectionContext { /// The frame's engine: the owned section VM, `Some` from construction /// until the frame's `Drop` takes it for the teardown boundary. - /// `SectionVm` stays a standalone type in `lua/` with its own test - /// suite. + /// `SectionVm` is `promptforge-lua`'s type, tested in that crate. vm: Option, /// The section's own name, retained so `Drop` reports the teardown /// boundary and the completion observation without a parameter. diff --git a/crates/promptforge-internal/engine/src/execute/tests/local_tools.rs b/crates/promptforge-internal/engine/src/execute/tests/local_tools.rs index 6743c51b6..acf4acbfc 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/local_tools.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/local_tools.rs @@ -142,6 +142,39 @@ async fn local_tool_handler_error_surfaces_as_a_tool_failure() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_during_a_looping_local_tool_handler_returns_promptly() { + use std::time::{Duration, Instant}; + + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_1", "grab", "{\"value\":\"hi\"}"), + resp_text("unreachable"), + ]) + .await; + let prompt = parse(&grab_loop("while true do end")); + let (ctx, host) = loop_context(&prompt, ToolSet::default()); + + let mut driver = TokioDriver::new(&ctx, host, Some(gateway_client(gateway.addr()))); + let canceller = driver.cancel_handle(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + canceller.cancel(); + }); + + let start = Instant::now(); + let result = driver.drive().await; + + assert!( + start.elapsed() < Duration::from_secs(5), + "cancel during a looping local tool handler must return promptly, took {:?}", + start.elapsed() + ); + assert!( + matches!(result, Err(crate::Error::Interrupted)), + "expected Interrupted, got {result:?}" + ); +} + #[tokio::test(flavor = "current_thread")] async fn a_loop_handler_writes_and_reads_the_store_and_the_model_gets_the_text() { let gateway = ScriptedGateway::start(vec![ diff --git a/crates/promptforge-internal/engine/src/execute/tests/model_task_notices.rs b/crates/promptforge-internal/engine/src/execute/tests/model_task_notices.rs index bfb60f4e8..9db5f6d95 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/model_task_notices.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/model_task_notices.rs @@ -5,19 +5,28 @@ //! ends, with the still-running list when its timeout fires, with //! `nothing to wait for` when it has no task and no timeout, and as a //! plain sleep when only a timeout is given; a sibling chain keeps -//! stepping while the model is parked; and the notice text names how a -//! task ended (cancelled by the author, abandoned, failed). A scripted -//! mock gateway plays the model. The `await_tasks` timer cases (a pending -//! notice answers without a wait; a member's end cancels the unfired -//! timer) are in `model_task_awaits`, which shares the helpers here. +//! stepping while the model is parked; the notice text names how a +//! task ended (cancelled by the author, abandoned, failed); and a notice +//! queued after the owner left its section - at the walk's end, or between +//! two sections - reports under the section the owner last entered. A +//! scripted mock gateway plays the model, except in the between-sections +//! case, which needs the serial driver's fixed answer order. The +//! `await_tasks` timer cases (a pending notice answers without a wait; a +//! member's end cancels the unfired timer) are in `model_task_awaits`, +//! which shares the helpers here. use std::collections::VecDeque; use std::time::Duration; -use promptforge_types::ids::TaskId; +use promptforge_types::event::Event; +use promptforge_types::ids::{AbandonReason, TaskId}; use super::model_tasks::{PARKED_CHILD, model_task_context_with, owner_prompt, task}; +use super::serial_driver::{infer_prompt, perform_locally, text_reply, tool_call_reply}; use super::*; +use crate::execute::RunResult; +use crate::execute::run::Run; +use crate::test_support::drive; use crate::test_support::tokio_driver::TokioDriver; /// A tool that answers each call in call order after the next scripted @@ -428,6 +437,135 @@ async fn notice_texts_name_how_a_task_ended() { ); } +#[tokio::test(flavor = "current_thread")] +async fn a_walk_that_runs_off_its_last_section_reports_the_abandoned_task_under_that_section() { + // `Last` is the walk's only section and falls through with the model's + // task still parked, so the walk position is past the slice's end when + // the chain's end abandons the task and queues its notice. + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_1", "task", "{\"target\":\"### Child\"}"), + resp_text("bye"), + ]) + .await; + let md = format!( + "---\nname: mt\ndescription: d\npromptforge: 0\n---\n\n\ + # ModelTasks\n\n\ + ## Last\n\n\ + ```lua\n{}\n```\n\n\ + ### Child\n\n\ + ```lua\n{PARKED_CHILD}\n```\n", + loop_owner("") + ); + let prompt = parse(&md); + let recorder = Arc::new(NoticeRecorder::default()); + let (ctx, host) = model_task_context_with( + &prompt, + Arc::clone(&recorder) as Arc, + Arc::new(SlowTool), + ); + TokioDriver::new(&ctx, host, Some(gateway_client(gateway.addr()))) + .drive() + .await + .expect("a model task the walk's end strands is abandoned, never leaked"); + let events = recorder.events(); + assert!( + events.iter().any(|(section, event)| section == "Child" + && *event + == Observation::TaskAbandoned { + task: task("0.0"), + reason: AbandonReason::OwnerReturned, + }), + "the walk's end abandons the live task: {events:?}" + ); + assert_eq!( + recorder.notices(), + vec![( + "Last".to_owned(), + task("0.0"), + "Task id=0.0 (## Child) was abandoned: the section ended".to_owned() + )], + "the notice reports under the walk's last section" + ); +} + +#[test] +fn a_task_that_ends_while_its_owner_is_between_sections_reports_under_the_section_just_ended() { + // The serial driver answers the owner's round 2 and the child's round + // in one batch, in issue order, and the two chains then alternate + // steps. The owner takes two to close `First` and the child two to end + // (its round's resume runs to the inline-answered note), so the child + // ends after `First` closes and before `Second` opens. + let md = format!( + "---\nname: mt\ndescription: d\npromptforge: 0\n---\n\n\ + # ModelTasks\n\n\ + ## First\n\n\ + ```lua\n{}\n```\n\n\ + ### Child\n\n\ + ```lua\n\ + models.infer('child work')\n\ + tasks.note('worked')\n\ + return 'child result'\n\ + ```\n\n\ + ## Second\n\n\ + ```lua\nreturn 'owner done'\n```\n", + loop_owner("") + ); + let prompt = parse(&md); + let (state, _host) = model_task_context_with( + &prompt, + Arc::new(NullObserver::default()), + Arc::new(SlowTool), + ); + let mut rounds = 0; + let (result, events) = drive(Run::from_state(state), |_, effect| { + perform_locally(effect, &mut |effect| { + if infer_prompt(effect) == "child work" { + return text_reply("child result"); + } + rounds += 1; + match rounds { + 1 => tool_call_reply("call_1", "task", json!({ "target": "### Child" })), + _ => text_reply("bye"), + } + }) + }); + let RunResult::Ok(text) = result else { + panic!("the owner returns: {result:?}"); + }; + assert_eq!(text, "owner done"); + let child = task("0.0"); + let first_finished = events + .iter() + .position( + |event| matches!(event, Event::SectionFinished { section, .. } if section == "First"), + ) + .expect("`First` completes"); + let second_started = events + .iter() + .position( + |event| matches!(event, Event::SectionStarted { section, .. } if section == "Second"), + ) + .expect("`Second` starts"); + let (notice_at, notice_section) = events + .iter() + .enumerate() + .find_map(|(at, event)| match event { + Event::TaskNotice { section, task, .. } if *task == child => { + Some((at, section.clone())) + } + _ => None, + }) + .expect("the child's end queues a notice"); + assert!( + first_finished < notice_at && notice_at < second_started, + "the child ends while its owner is between sections: {events:?}" + ); + assert_eq!( + notice_section, "First", + "the notice reports under the section just ended" + ); +} + #[tokio::test(flavor = "current_thread")] async fn a_model_issued_cancel_queues_no_notice() { let gateway = ScriptedGateway::start(vec![ diff --git a/crates/promptforge-internal/engine/src/execute/tests/models_loop_compactors.rs b/crates/promptforge-internal/engine/src/execute/tests/models_loop_compactors.rs index ef6a20ae2..6c3f35266 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/models_loop_compactors.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/models_loop_compactors.rs @@ -174,6 +174,41 @@ async fn a_compactor_that_returns_is_the_deferred_replacement_error() { assert_eq!(gateway.call_count(), 1, "the request left and was rejected"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_during_a_looping_compactor_returns_promptly() { + use std::time::{Duration, Instant}; + + let gateway = ScriptedGateway::start(vec![resp_text("unreachable")]).await; + let md = loop_prompt( + "local msgs = messages.new()\n\ + msgs:user(string.rep('x', 100000))\n\ + models.loop(msgs, function() while true do end end)\n\ + return 'unreachable'", + ); + let prompt = parse(&md); + let (ctx, host) = loop_context(&prompt, ToolSet::default()); + + let mut driver = TokioDriver::new(&ctx, host, Some(gateway_client(gateway.addr()))); + let canceller = driver.cancel_handle(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + canceller.cancel(); + }); + + let start = Instant::now(); + let result = driver.drive().await; + + assert!( + start.elapsed() < Duration::from_secs(5), + "cancel during a looping compactor must return promptly, took {:?}", + start.elapsed() + ); + assert!( + matches!(result, Err(crate::Error::Interrupted)), + "expected Interrupted, got {result:?}" + ); +} + #[tokio::test(flavor = "current_thread")] async fn a_compactors_own_string_raise_reaches_the_host_with_the_reason_tag() { // An author compactor's own untyped raise is re-raised as the value it diff --git a/crates/promptforge-internal/engine/src/execute/tests/preludes.rs b/crates/promptforge-internal/engine/src/execute/tests/preludes.rs index 9f4461011..301559c28 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/preludes.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/preludes.rs @@ -176,7 +176,7 @@ fn a_prelude_defining_ui_or_item_collides_on_a_host_that_binds_neither() { vec![prelude("acme/reserved", &format!("{name} = {{}}"))], &[ &format!("capability `acme/reserved`: its prelude defines the global `{name}`"), - "which is reserved for", + "which is reserved as a host global", ], ); } diff --git a/crates/promptforge-internal/engine/src/execute/tests/scheduler/concurrency.rs b/crates/promptforge-internal/engine/src/execute/tests/scheduler/concurrency.rs index bf71ad64f..b40008a15 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/scheduler/concurrency.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/scheduler/concurrency.rs @@ -2,13 +2,14 @@ //! limit and reads the effective limit back, a queued task reads //! `blocked == 'queued'` until its start event fires at admission, a //! resumed task is admitted ahead of fresh starts, a nested fanout -//! does not deadlock under a ceiling of one, and a queue that can never -//! be admitted is reported as a stall. +//! does not deadlock under a ceiling of one, two tasks' `call` children +//! finish in any order, and a queue that can never be admitted is +//! reported as a stall. use std::num::NonZeroUsize; use std::time::Duration; -use super::super::serial_driver::perform_locally; +use super::super::serial_driver::{Batching, drive_batched, perform_locally}; use super::*; use crate::execute::RunResult; use crate::execute::run::{Run, Step}; @@ -423,6 +424,50 @@ async fn a_nested_fanout_does_not_deadlock_under_a_ceiling_of_one() { ); } +#[test] +fn two_tasks_whose_call_children_finish_in_any_order_both_complete() { + // Both tasks are admitted together and each calls `Leaf`, whose round + // is issued in call order: A's child first, then B's. Answered in + // issue order, A's child finishes while B's is still running; answered + // in reverse, B's finishes first. Every order completes both calls. + let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + # Calls\n\n\ + ## Main\n\n\ + ```lua\n\ + var.who = 'a'\n\ + local a = tasks.spawn('### Caller')\n\ + var.who = 'b'\n\ + local b = tasks.spawn('### Caller')\n\ + local results = tasks.join({ a, b })\n\ + return results[1].result .. '|' .. results[2].result\n\ + ```\n\n\ + ### Caller\n\n\ + ```lua\nreturn call('#### Leaf')\n```\n\n\ + #### Leaf\n\n\ + ```lua\nreturn models.infer(var.who)\n```\n"; + let prompt = parse(md); + for batching in [ + Batching::OnePerStep, + Batching::AllAtOnce, + Batching::Reversed, + ] { + let (state, _host) = limited_context( + &prompt, + &TestStore::new(), + ceiling(2), + Arc::new(NullObserver::default()), + ); + let outcome = drive_batched(Run::from_state(state), batching); + let RunResult::Ok(text) = &outcome.result else { + panic!( + "both calls complete under {batching:?}: {:?}", + outcome.result + ); + }; + assert_eq!(text, "r(a)|r(b)", "under {batching:?}"); + } +} + #[test] fn a_queued_task_that_can_never_be_admitted_is_reported_as_a_stall() { // The walk parks on its store write, and the test wedges the walk's diff --git a/crates/promptforge-internal/engine/src/execute/tests/scheduler/fanout.rs b/crates/promptforge-internal/engine/src/execute/tests/scheduler/fanout.rs index 8c8952efd..8764da297 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/scheduler/fanout.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/scheduler/fanout.rs @@ -612,7 +612,7 @@ async fn fanout_worker_that_is_a_list_section_errors() { #[tokio::test(flavor = "current_thread")] async fn fanout_depth_cap_reads_the_chain_field() { // Pin of the fanout depth-cap guard: Alpha and Beta ping-pong calls - // down the chain stack, and the chain that lands at depth 8 calls + // through nested call chains, and the chain that lands at depth 8 calls // fanout - each arm would run one level deeper, so the cap fires from // the requesting chain's call-depth field. The arm's spawn sets the // fanout mark, so the spawn arm names the cap after `fanout` in the diff --git a/crates/promptforge-internal/engine/src/execute/tests/scheduler/walk.rs b/crates/promptforge-internal/engine/src/execute/tests/scheduler/walk.rs index 2db65a840..0e17fdc5c 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/scheduler/walk.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/scheduler/walk.rs @@ -88,10 +88,10 @@ async fn cancellation_while_suspended_on_infer_interrupts_the_run() { #[tokio::test(flavor = "current_thread")] async fn call_depth_cap_reads_the_chain_field() { - // Two sections calling each other ping-pong down the chain stack; the - // cap must fire from the requesting chain's call-depth field. The - // typed error then round-trips through every parent's answer envelope - // without flattening. + // Two sections calling each other ping-pong through nested call + // chains; the cap must fire from the requesting chain's call-depth + // field. The typed error then round-trips through every parent's + // answer envelope without flattening. let md = "---\nname: depth\ndescription: d\npromptforge: 0\n---\n\n\ # Depth\n\n\ ## Alpha\n\n\ diff --git a/crates/promptforge-internal/engine/src/execute/tests/suite.rs b/crates/promptforge-internal/engine/src/execute/tests/suite.rs index 9a4fdd6cf..32cfd570e 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/suite.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/suite.rs @@ -2,7 +2,8 @@ //! author writes them and drives their execution against the runtime //! without a live gateway. Split by domain - parsing contracts, section //! execution, fanout, control flow, the args/argv and lazy-prose surfaces, -//! and the prepare pass - over shared harness code in [`support`]. Its +//! an author's own `_G` metatable, and the prepare pass - over shared +//! harness code in [`support`]. Its //! cases reach engine-only items (the test drivers and recorders, the store //! facade, the parser's Lua programs), so they run here rather than //! against the `promptforge` facade, whose own suite holds the rest. @@ -11,6 +12,7 @@ mod args_surface; mod exec_flow; mod execution; mod fanout; +mod global_metatable; mod lazy_prose; mod parsing; mod prepare; diff --git a/crates/promptforge-internal/engine/src/execute/tests/suite/global_metatable.rs b/crates/promptforge-internal/engine/src/execute/tests/suite/global_metatable.rs new file mode 100644 index 000000000..f84948589 --- /dev/null +++ b/crates/promptforge-internal/engine/src/execute/tests/suite/global_metatable.rs @@ -0,0 +1,208 @@ +//! An author's own metatable on `_G`: it keeps working for every other +//! global (defaults, strict errors, write hooks) in the H1 pass and in +//! later sections, across the shared replay and across fences, while +//! `argv` and `prose` stay guarded whatever author code does to `_G`'s +//! metatable. + +use crate::RunError; + +use super::support::run_fixture; + +const EXECUTION: &str = "execute-test"; + +/// Runs a fixture offline with the given args string. +async fn run_args(md: &str, args: &str) -> Result { + run_fixture(md, "global-metatable", EXECUTION, args, None) + .await + .result +} + +/// The frontmatter every test shares: one declared `query` string, so +/// `argv` is the parsed JSON or nil. +macro_rules! args_prompt { + ($body:literal) => { + concat!( + "---\nname: t\ndescription: d\npromptforge: 0\n", + "args:\n query:\n type: string\n", + "---\n\n", + $body + ) + }; +} + +#[tokio::test] +async fn a_shared_defaults_metatable_works_beside_frozen_argv_and_read_only_prose() { + let md = args_prompt!( + "# T\n\n\ +```lua shared\n\ +defaults = { tone = 'friendly' }\n\ +setmetatable(_G, { __index = defaults })\n\ +```\n\n\ +```lua\n\ +assert(tone == 'friendly', 'H1 reads the default')\n\ +argv = { query = 'repaired' }\n\ +assert(not pcall(function() prose = 'x' end), 'prose is read-only in H1')\n\ +```\n\n\ +## First\n\n\ +Tone: {{ tone }}.\n\n\ +```lua\n\ +assert(prose == 'Tone: friendly.', 'a default renders in prose: ' .. prose)\n\ +assert(missing == nil, 'a global with no default reads nil')\n\ +local ok, err = pcall(function() argv = 'hijacked' end)\n\ +assert(not ok and tostring(err):find('argv is frozen outside H1: assign it in H1 only', 1, true), 'argv stays frozen: ' .. tostring(err))\n\ +var.n = 2\n\ +```\n\n\ +Second fence {{ var.n }}.\n\n\ +```lua\n\ +assert(prose == 'Second fence 2.', 'the second fence renders fresh: ' .. prose)\n\ +local ok, err = pcall(function() prose = 'x' end)\n\ +assert(not ok and tostring(err):find('prose is read-only: assign to `var` or a section global instead', 1, true), 'prose stays read-only: ' .. tostring(err))\n\ +assert(tone == 'friendly', 'the default survives the second fence')\n\ +```\n\n\ +## Second\n\n\ +```lua\n\ +assert(argv.query == 'repaired', 'the H1 repair reaches a later section')\n\ +assert(not pcall(function() argv = 'hijacked' end), 'argv stays frozen in a later section')\n\ +return argv.query .. ' ' .. tone\n\ +```\n" + ); + let out = run_args(md, "broken json").await.expect("the run succeeds"); + assert_eq!(out, "repaired friendly"); +} + +#[tokio::test] +async fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_and_prose() { + let md = args_prompt!( + "# T\n\n\ +```lua shared\n\ +setmetatable(_G, {\n\ + __index = function(_, key) error('undefined global ' .. key, 2) end,\n\ +})\n\ +```\n\n\ +```lua\n\ +local ok, err = pcall(function() return undefined_in_h1 end)\n\ +assert(not ok and tostring(err):find('undefined global undefined_in_h1', 1, true), 'H1 is strict: ' .. tostring(err))\n\ +```\n\n\ +## Only\n\n\ +Hi.\n\n\ +```lua\n\ +local ok, err = pcall(function() return undefined_here end)\n\ +assert(not ok, 'an undefined global raises')\n\ +assert(type(err) == 'string', 'the author error comes back as raised, got ' .. type(err))\n\ +assert(err:find(']:1: undefined global undefined_here', 1, true), 'the error names the reading line: ' .. err)\n\ +assert(argv.query == 'x', 'the guard serves argv, not the strict handler')\n\ +assert(prose == 'Hi.', 'the guard serves prose, not the strict handler')\n\ +return 'strict'\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"x\"}") + .await + .expect("the run succeeds"); + assert_eq!(out, "strict"); +} + +#[tokio::test] +async fn an_h1_repair_lands_under_a_strict_index_and_a_write_hook() { + let md = args_prompt!( + "# T\n\n\ +```lua shared\n\ +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\ +```\n\n\ +```lua\n\ +if argv == nil then argv = { query = 'repaired' } end\n\ +assert(seen.argv == nil, 'the write hook never sees argv')\n\ +```\n\n\ +## Only\n\n\ +```lua\n\ +assert(argv.query == 'repaired', 'the repair reaches the walk')\n\ +return argv.query\n\ +```\n" + ); + let out = run_args(md, "broken json").await.expect("the run succeeds"); + assert_eq!(out, "repaired"); +} + +#[tokio::test] +async fn setmetatable_on_g_in_a_block_frees_neither_argv_nor_prose() { + let md = args_prompt!( + "## Only\n\n\ +First.\n\n\ +```lua\n\ +setmetatable(_G, nil)\n\ +local a = pcall(function() argv = 'x' end)\n\ +setmetatable(_G, {})\n\ +local b = pcall(function() prose = 'x' end)\n\ +captured = {}\n\ +setmetatable(_G, { __newindex = function(_, k, v) captured[k] = v end })\n\ +local c = pcall(function() argv = 'x' end)\n\ +local d = pcall(function() prose = 'x' end)\n\ +assert(not (a or b or c or d), 'every assignment is refused')\n\ +assert(captured.argv == nil and captured.prose == nil, 'the author hook never sees argv or prose')\n\ +plain = 'y'\n\ +assert(captured.plain == 'y', 'the author hook sees other globals')\n\ +assert(argv.query == 'x' and prose == 'First.', 'both still read as the host set them')\n\ +```\n\n\ +Second.\n\n\ +```lua\n\ +assert(prose == 'Second.', 'the next fence installs its prose under the author metatable')\n\ +assert(getmetatable(_G).__newindex ~= nil, 'the author metatable survives the next fence')\n\ +assert(not pcall(function() argv = 'x' end), 'argv is still frozen')\n\ +return 'guarded'\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"x\"}") + .await + .expect("the run succeeds"); + assert_eq!(out, "guarded"); +} + +#[tokio::test] +async fn the_shared_library_cannot_assign_prose_before_the_first_block() { + let md = args_prompt!( + "# T\n\n\ +```lua shared\n\ +shared_prose = type(prose)\n\ +local ok, err = pcall(function() prose = 'shadowed' end)\n\ +shared_refusal = not ok and tostring(err)\n\ +```\n\n\ +## Only\n\n\ +The real prose.\n\n\ +```lua\n\ +assert(shared_prose == 'nil', 'prose reads nil while the library loads')\n\ +assert(shared_refusal and shared_refusal:find('prose is read-only', 1, true), 'the library cannot assign prose: ' .. tostring(shared_refusal))\n\ +return prose\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"x\"}") + .await + .expect("the run succeeds"); + assert_eq!(out, "The real prose."); +} + +#[tokio::test] +async fn getmetatable_on_g_returns_the_shared_librarys_metatable_and_edits_apply_live() { + let md = args_prompt!( + "# T\n\n\ +```lua shared\n\ +author_mt = { __index = { tone = 'friendly' } }\n\ +setmetatable(_G, author_mt)\n\ +```\n\n\ +## Only\n\n\ +```lua\n\ +assert(getmetatable(_G) == author_mt, 'getmetatable(_G) is the library metatable')\n\ +getmetatable(_G).__index = function(_, key) return 'live ' .. key end\n\ +assert(tone == 'live tone', 'an edit applies at once')\n\ +getmetatable(_G).__newindex = function() end\n\ +assert(not pcall(function() argv = 'x' end), 'an edit cannot free argv')\n\ +return tone\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"x\"}") + .await + .expect("the run succeeds"); + assert_eq!(out, "live tone"); +} diff --git a/crates/promptforge-internal/engine/src/execute/tests/suite/vfs.rs b/crates/promptforge-internal/engine/src/execute/tests/suite/vfs.rs index 9da8fb8d4..b166324d3 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/suite/vfs.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/suite/vfs.rs @@ -225,7 +225,7 @@ impl TempDir { .expect("the clock is after the epoch") .as_nanos(); let dir = std::env::temp_dir().join(format!( - "promptforge-api-vfs-{}-{unique}-{name}", + "promptforge-engine-vfs-{}-{unique}-{name}", std::process::id(), )); std::fs::create_dir_all(&dir).expect("the temp dir creates"); diff --git a/crates/promptforge-internal/engine/src/lib.md b/crates/promptforge-internal/engine/src/lib.md index bdb4d08cc..724a2aab0 100644 --- a/crates/promptforge-internal/engine/src/lib.md +++ b/crates/promptforge-internal/engine/src/lib.md @@ -1,6 +1,6 @@ PromptForge runtime core. -This crate holds the pieces that turn a prompt markdown file into a run: the [`parser`] that reads the file into a [`parser::Prompt`], and the [`Run`] state machine, which runs H1 once before walking sections top to bottom (fall-through), issuing every model round, tool call, store operation, and timer as an [`Effect`] value the host performs and answers, and reporting every boundary as an [`Event`](promptforge_types::event::Event) value the host logs. The engine performs no I/O and reads no clock; the harness is its production host and reaches it through the `promptforge` facade. The host-facing vocabulary a run is configured with - the model and tool catalogs, the tool contract, the event enum - sits in the `promptforge-types` crate; the chat vocabulary a `Chat` effect holds and its answer returns is [`model`]. The store handle a host seeds or extracts comes from `promptforge-vfs`. No model client is defined here: the harness owns the transport that performs a round and reaches the vocabulary through the facade. +This crate holds the [`Run`] state machine that turns a parsed prompt into a run; the parser that reads a prompt markdown file into a [`parser::Prompt`] is `promptforge-parser`'s, re-exported through [`parser`]. A run executes H1 once before walking sections top to bottom (fall-through), issuing every model round, tool call, store operation, timer, and read of a task's reported history as an [`Effect`] value (`Chat`, `ToolCall`, `Store`, `Timer`, `TaskEvents`) the host performs and answers, and reporting every boundary as an [`Event`](promptforge_types::event::Event) value the host logs. The engine performs no I/O and reads no clock; the harness is its production host and reaches it through the `promptforge` facade. The host-facing vocabulary a run is configured with - the model and tool catalogs, the tool contract, the event enum - sits in the `promptforge-types` crate; the chat vocabulary a `Chat` effect holds and its answer returns is [`model`]. The store handle a host seeds or extracts comes from `promptforge-vfs`. No model client is defined here: the harness owns the transport that performs a round and reaches the vocabulary through the facade. A source is a promptforge prompt only when its frontmatter declares a `promptforge:` version; [`promptforge_version`] reports it (or `None`), and the runtime refuses a source that lacks a supported version. diff --git a/crates/promptforge-internal/engine/src/lua/tests.rs b/crates/promptforge-internal/engine/src/lua/tests.rs index d058d765a..d6f03628d 100644 --- a/crates/promptforge-internal/engine/src/lua/tests.rs +++ b/crates/promptforge-internal/engine/src/lua/tests.rs @@ -1,9 +1,10 @@ //! Tests for the scheduler-mode section VM built through the executor's //! real `section_vm` setup path: the yield shims (`shims`), the error //! table and failure envelope contract (`errors`), the coroutine -//! mechanics the shims rely on (`coroutine`), and the Lua loop's -//! instruction cost (`quota`). This file holds the fixtures every sibling -//! drives: the test model and tool sets, the VM builder, and the +//! mechanics the shims rely on (`coroutine`), the Lua loop's +//! instruction cost (`quota`), and the reserved-name list against the +//! globals setup installs (`globals`). This file holds the fixtures every +//! sibling drives: the test model and tool sets, the VM builder, and the //! start-and-parse helpers. //! //! These sit in `promptforge-engine` (not in `promptforge-lua`) @@ -13,6 +14,7 @@ mod coroutine; mod errors; +mod globals; mod quota; mod shims; diff --git a/crates/promptforge-internal/engine/src/lua/tests/globals.rs b/crates/promptforge-internal/engine/src/lua/tests/globals.rs new file mode 100644 index 000000000..51c976128 --- /dev/null +++ b/crates/promptforge-internal/engine/src/lua/tests/globals.rs @@ -0,0 +1,118 @@ +//! The reserved-name list against the globals a section VM holds after the +//! executor's real setup path, compared in both directions: every global a +//! walked section or the H1 pass leaves in `_G` is a reserved name, and +//! every reserved global reads non-nil once the VM binds its conditional +//! ones (`ui`, `item`, `prose`). No capability prelude and no frontmatter +//! alias installs here, so what `_G` holds is exactly the host's. + +use std::sync::{Arc, Mutex}; + +use mlua::Value; +use serde_json::json; + +use promptforge_lua::{RESERVED_NAMES, Reserved, reserved_name}; + +use crate::execute::section_vm::{SectionVmSetup, VmSeed, setup_section_vm}; +use crate::lua::{LuaProgram, SectionVm, ToolSet}; +use crate::model::ModelSet; +use crate::test_support::recording::null_emitter; +use crate::untrusted::GuardNonce; + +/// Builds a section VM through the real setup path with every conditional +/// host global bound: a host-state snapshot (`ui`), a collection member +/// (`item`), a non-nil `argv` (writable as in the H1 pass, or frozen as in +/// every other section), and a block's lazy `prose`. +fn fully_bound_vm(argv_writable: bool) -> SectionVm { + let emitter = null_emitter(); + let mut vm = SectionVm::new_for_section( + &GuardNonce::from_seed(0x9e5), + &Arc::new(Mutex::new(ToolSet::default())), + &Arc::new(Mutex::new(ModelSet::default())), + &emitter, + "Globals", + ) + .expect("the section VM builds"); + let shared = LuaProgram::empty().expect("the empty shared program compiles"); + let sys = json!({}); + let argv = json!({ "prose": "" }); + let item = json!("member"); + let ui = Arc::new(json!({})); + let access = Arc::new( + promptforge_vfs::VfsRef::default() + .acquire(promptforge_vfs::Origin::new("reserved names fixture")) + .expect("the stock backend acquires"), + ); + let setup = SectionVmSetup { + args: "", + argv: Some(&argv), + argv_writable, + sys: &sys, + access: &access, + seed: VmSeed { + var: None, + item: Some(&item), + }, + emitter: &emitter, + section_name: "Globals", + shared: &shared, + max_tool_iterations: 24, + ui: Some(&ui), + preludes: &[], + frontmatter_aliases: &[], + raw_shims: false, + }; + let list_callback = + |_: String| -> std::result::Result, crate::Error> { Ok(Vec::new()) }; + setup_section_vm(&mut vm, &setup, list_callback).expect("the setup installs"); + vm.install_lazy_prose(|_| Ok("the block's prose".to_owned())) + .expect("the prose guard installs"); + vm +} + +/// Every key the VM's globals table holds, read raw (no metamethod runs). +fn raw_global_names(vm: &SectionVm) -> Vec { + vm.lua() + .globals() + .pairs::() + .map(|pair| match pair.expect("the globals walk").0 { + Value::String(name) => name.to_string_lossy(), + other => format!("{other:?}"), + }) + .collect() +} + +#[test] +fn every_global_section_setup_leaves_in_g_is_a_reserved_name() { + for argv_writable in [false, true] { + let vm = fully_bound_vm(argv_writable); + for name in raw_global_names(&vm) { + assert!( + matches!( + reserved_name(&name), + Some(Reserved::HostGlobal | Reserved::LuaGlobal) + ), + "section setup (argv writable: {argv_writable}) installs the global `{name}`, \ + which RESERVED_NAMES does not list as a host or Lua global; list it there" + ); + } + } +} + +#[test] +fn every_reserved_global_is_present_after_section_setup() { + for argv_writable in [false, true] { + let vm = fully_bound_vm(argv_writable); + let globals = vm.lua().globals(); + for (name, kind) in RESERVED_NAMES { + if kind == Reserved::LuaKeyword { + continue; + } + let value: Value = globals.get(name).expect("a global read"); + assert!( + !value.is_nil(), + "RESERVED_NAMES lists `{name}` as {kind}, but a fully bound section VM \ + (argv writable: {argv_writable}) reads it as nil; drop it or install it" + ); + } + } +} diff --git a/crates/promptforge-internal/engine/src/test_support.rs b/crates/promptforge-internal/engine/src/test_support.rs index 0792b20fb..8da40b70c 100644 --- a/crates/promptforge-internal/engine/src/test_support.rs +++ b/crates/promptforge-internal/engine/src/test_support.rs @@ -14,10 +14,10 @@ //! the timer wheel, and hands every event to the caller's sink. It is the //! host the engine's own suites drive. //! -//! [`RunHost`] bundles the resources the suites used to hand the retired -//! in-crate loop - an observer, a client, a fixture tool table, a delta -//! hook - and [`run_with_host`] is that loop's implicit-prepare path over -//! the tokio driver: prepare, refuse or run. The tool fixtures implement +//! [`RunHost`] bundles a suite's resources for one run - an observer, a +//! client, a fixture tool table, a delta hook - and [`run_with_host`] is +//! the implicit-prepare path over the tokio driver: prepare, refuse or +//! run. The tool fixtures implement //! the stand-in trait [`TestTool`]; the production trait is the harness's, //! which no engine crate names. //! [`Observer`](recording::Observer) and @@ -165,8 +165,8 @@ pub fn drive( } } -/// The retired loop's implicit-prepare path over the tokio driver: prepares -/// and runs `prompt` with the resources `host` bundles. +/// The implicit-prepare path over the tokio driver: prepares and runs +/// `prompt` with the resources `host` bundles. /// /// The environment's catalog is what prepare fills slots against; a suite /// with fixture tools installs their descriptors there diff --git a/crates/promptforge-internal/engine/src/test_support/recording.rs b/crates/promptforge-internal/engine/src/test_support/recording.rs index 340400701..2cf06ef65 100644 --- a/crates/promptforge-internal/engine/src/test_support/recording.rs +++ b/crates/promptforge-internal/engine/src/test_support/recording.rs @@ -23,9 +23,7 @@ use promptforge_types::ids::TaskId; use promptforge_types::metrics::{CallMetrics, ToolCallEvent}; use serde_json::Value; -#[path = "recording-forward.rs"] mod forward; -#[path = "recording-observation.rs"] mod observation; pub use forward::forward; diff --git a/crates/promptforge-internal/engine/src/test_support/recording-forward-tests.rs b/crates/promptforge-internal/engine/src/test_support/recording/forward-tests.rs similarity index 100% rename from crates/promptforge-internal/engine/src/test_support/recording-forward-tests.rs rename to crates/promptforge-internal/engine/src/test_support/recording/forward-tests.rs diff --git a/crates/promptforge-internal/engine/src/test_support/recording-forward.rs b/crates/promptforge-internal/engine/src/test_support/recording/forward.rs similarity index 99% rename from crates/promptforge-internal/engine/src/test_support/recording-forward.rs rename to crates/promptforge-internal/engine/src/test_support/recording/forward.rs index 0c0c99a93..4653e7c67 100644 --- a/crates/promptforge-internal/engine/src/test_support/recording-forward.rs +++ b/crates/promptforge-internal/engine/src/test_support/recording/forward.rs @@ -375,5 +375,5 @@ fn forward_debug(event: Event, debug: Option<&dyn DebugCapture>) { } #[cfg(test)] -#[path = "recording-forward-tests.rs"] +#[path = "forward-tests.rs"] mod tests; diff --git a/crates/promptforge-internal/engine/src/test_support/recording-observation.rs b/crates/promptforge-internal/engine/src/test_support/recording/observation.rs similarity index 100% rename from crates/promptforge-internal/engine/src/test_support/recording-observation.rs rename to crates/promptforge-internal/engine/src/test_support/recording/observation.rs diff --git a/crates/promptforge-internal/engine/src/test_support/tokio_driver.rs b/crates/promptforge-internal/engine/src/test_support/tokio_driver.rs index b2556caf4..fbd22c753 100644 --- a/crates/promptforge-internal/engine/src/test_support/tokio_driver.rs +++ b/crates/promptforge-internal/engine/src/test_support/tokio_driver.rs @@ -165,9 +165,9 @@ pub(crate) struct TokioDriver<'a> { impl<'a> TokioDriver<'a> { /// Builds the driver for one run over `state`, performing its effects /// and replaying its events through the `host` the suite assembled - /// itself. The host supplies the observer, broker, tools, delta hook, - /// and debug capture; `client` is the run's mock-gateway client when - /// the suite supplies one, overriding any on the host. + /// itself. The host supplies the observer, chat client, tools, delta + /// hook, and debug capture; `client` is the run's mock-gateway client + /// when the suite supplies one, overriding any on the host. #[cfg(test)] pub(crate) fn new( state: &RunState, @@ -518,7 +518,7 @@ impl std::fmt::Debug for TokioDriver<'_> { /// Aborts every performer still out when the driver is dropped /// mid-run - a host tearing the run down without driving it to its end. /// Dropping a bare `JoinHandle` detaches the task, which would strand a -/// broker wait or gateway round forever, so the drop applies the same +/// tool call or model round forever, so the drop applies the same /// abort the run's end does. impl Drop for TokioDriver<'_> { fn drop(&mut self) { diff --git a/crates/promptforge-internal/lua/AGENTS.md b/crates/promptforge-internal/lua/AGENTS.md index 7f64ec582..5806f56b8 100644 --- a/crates/promptforge-internal/lua/AGENTS.md +++ b/crates/promptforge-internal/lua/AGENTS.md @@ -1,9 +1,11 @@ # promptforge-lua -This crate owns the sandboxed Lua runtime, its host surface, and coroutine protocol vocabulary. +This crate owns the sandboxed Lua runtime, its host surface, and coroutine protocol vocabulary. The host surface includes the VFS-backed `store` table, the `messages` builders, the `tasks` shims, and the capability preludes (`prelude.rs`), such as the `input` table `promptforge/user-input` contributes. - Host functions that would create a parser-to-Lua dependency cycle stay in this crate rather than `promptforge-parser`. - Executors drive this crate. It never imports or composes an executor. - `prepare_dispatch` is the single tool-dispatch body used by every executor: synchronous, it applies counts, trust classification, the nonce wrap, and the `ToolResult` report to a tool's answer. Nothing in this crate performs a tool call: the executor issues the call as an effect, the host performs it, and `prepare_dispatch` (or `prepare_model_dispatch` under the model-issued rule) applies the rules when the answer lands. - The executor-facing items are public for `promptforge-engine`, not host API: the facade re-exports only `StoreOp` and `StoreOutcome`, and none of them is `#[doc(hidden)]` (the `build-xtask` ban rejects it). An operation only the engine performs on a host-visible type is a free function in `detail`; a helper only tests call sits behind `test-support`. +- Every global section setup installs is listed in `RESERVED_NAMES` in `globals.rs`, the one list the parser refuses frontmatter tool aliases and model role labels against and the prelude install refuses capability globals against. A new host global goes on that list in the same change; the engine's `lua::tests::globals` compares the list against a set-up VM in both directions. +- `_G` has exactly one metatable, the guard `globals.rs` installs at VM construction. Host code never calls `set_metatable` on the globals table and never raw-sets `argv` outside H1 or `prose`: `argv` and `prose` state goes through `globals::freeze_argv` and `globals::install_prose`, and an author's `_G` metatable lives behind the guard through the sandbox's `setmetatable` replacement. - Lua host capabilities are namespace functions over plain values; handles are frozen, inspectable userdata with no methods. New operations go in the owning namespace with an optional leading handle argument - do not add colon methods. Chainable `messages.new()` builders are the deliberate exception. diff --git a/crates/promptforge-internal/lua/README.md b/crates/promptforge-internal/lua/README.md index f0e7a0f64..06e2402e4 100644 --- a/crates/promptforge-internal/lua/README.md +++ b/crates/promptforge-internal/lua/README.md @@ -6,4 +6,25 @@ libraries plus safe base functions, an instruction-count hook, host tables for the run-scoped store, model and tool bindings, and the coroutine yield/resume protocol that lets suspending host calls (`models.infer`, `call`, `fanout`, `tools.call`) run under the executor's scheduler without -blocking a worker thread. +blocking a worker thread. One guard metatable on `_G` serves the frozen +`argv` and the lazy, read-only `prose`; an author's own `_G` metatable +composes behind it through the sandbox's `setmetatable` and +`getmetatable`, which can neither reveal nor replace the guard. Every +name `_G` holds after section setup, plus the Lua keywords, is on one +reserved-name list: no frontmatter tool alias, model role label, or +capability prelude global may take one. + +Beside the core globals, the VM holds: + +- `store`, the run's virtual files, backed by the `promptforge-vfs` store + view the executor derives for the chain; each operation is a direct call + while the shared library loads and a yield shim inside a block. +- `messages`, the conversation builders and the chainable `messages.new()`. +- `tasks`, the scheduler-mode shims for spawning, waiting on, checking, + noting, and cancelling tasks and reading their event history. +- Capability preludes: the Lua source an activated capability contributes, + run once per VM in an environment of its own before the shared library + replays, with its globals checked against the reserved list and raw-set + into `_G`. The `input` table that `promptforge/user-input` defines is + one: `input.ask()` is an ordinary tool call to that capability's ask + tool. diff --git a/crates/promptforge-internal/lua/src/__impl_coro.lua b/crates/promptforge-internal/lua/src/__impl_coro.lua index 7dffd4d1a..4eca8ec29 100644 --- a/crates/promptforge-internal/lua/src/__impl_coro.lua +++ b/crates/promptforge-internal/lua/src/__impl_coro.lua @@ -13,15 +13,18 @@ -- block's raised value for the host before the guard re-raises it, and -- `normalize_failure` turns a Rust callback's raised failure (mlua's -- opaque userdata) into the error table, passing every other value --- through unchanged, and `enter_local_handler()` and `leave_local_handler()` +-- through unchanged, `enter_local_handler()` and `leave_local_handler()` -- step the counter the host's `jump` reads, so `jump` refuses while a --- local tool's handler runs. The `tasks` namespace and the `fanout` shim live in --- their own chunks (`__impl_tasks.lua`, `__impl_fanout.lua`), installed by --- the host right after this one over the failure helpers this chunk --- returns. +-- local tool's handler runs, and `cancel_requested()` reports whether the +-- run's cancel flag is set: a failure caught under cancellation is the +-- instruction hook's abort, which must unwind to the block guard, so every +-- protected call below raises it again instead of returning it. The `tasks` +-- namespace and the `fanout` shim live in their own chunks +-- (`__impl_tasks.lua`, `__impl_fanout.lua`), installed by the host right +-- after this one over the failure helpers this chunk returns. local yield, var_snapshot, models, tools, compactors, max_tool_iterations, error_value, stash_failure, normalize_failure, enter_local_handler, - leave_local_handler = ... + leave_local_handler, cancel_requested = ... -- The base library's pcall and xpcall, captured before the replacements -- below are installed over the globals: the block guard needs the raw @@ -66,8 +69,11 @@ end -- author's own table, and an error table already built pass through -- untouched. The raw pcall is yieldable, and so is this Lua frame, so a -- shim yield inside the protected function still suspends the block. +-- Under cancellation the raw failure is raised again, so an author loop +-- around pcall cannot outlive the run. local function pcall_outcome(ok, ...) if ok then return true, ... end + if cancel_requested() then error((...), 0) end return false, normalize_failure((...)) end @@ -76,14 +82,20 @@ local function protected_call(f, ...) end -- The message handler sees the normalized failure; a non-function handler --- is left to the raw xpcall so its own argument error is unchanged. +-- is left to the raw xpcall so its own argument error is unchanged. Under +-- cancellation the handler's result is raised again instead of returned. +local function xpcall_outcome(ok, ...) + if not ok and cancel_requested() then error((...), 0) end + return ok, ... +end + local function protected_xcall(f, handler, ...) if type(handler) ~= "function" then - return raw_xpcall(f, handler, ...) + return xpcall_outcome(raw_xpcall(f, handler, ...)) end - return raw_xpcall(f, function(failure) + return xpcall_outcome(raw_xpcall(f, function(failure) return handler(normalize_failure(failure)) - end, ...) + end, ...)) end -- models.infer(handle?, prompt): an optional leading model handle runs the @@ -124,10 +136,12 @@ end -- (only when it returned) for the driver to report; afterward the -- handler's own failure is raised again unchanged, a rejected return -- raises the driver's error, and a returned value resumes as its text. +-- A failure under cancellation is raised at once, with no yield. local function run_local_tool(handler, args) enter_local_handler() local ok, value = raw_pcall(handler, args) leave_local_handler() + if not ok and cancel_requested() then error(value, 0) end local done = { op = "local_tool_done", ok = ok } if ok then done.value = value end local answered, result = yield(done) @@ -203,7 +217,7 @@ local EMPTY_MODEL_REPLY = "empty model reply" -- is normalized into the structured error table before re-raising, so the -- kind reaches an author pcall and the host alike. A compactor that -- returns instead of raising is the deferred replacement shape, which the --- active surface refuses. +-- active surface refuses. A failure under cancellation is raised raw. local function compact(compactor, reason) local ok, failure = raw_pcall(compactor, reason) if ok then @@ -212,6 +226,7 @@ local function compact(compactor, reason) .. "deferred; compactors.fail is the only shipped policy", }) end + if cancel_requested() then error(failure, 0) end error(normalize_failure(failure), 0) end diff --git a/crates/promptforge-internal/lua/src/__impl_globals.lua b/crates/promptforge-internal/lua/src/__impl_globals.lua new file mode 100644 index 000000000..6dd1fdd01 --- /dev/null +++ b/crates/promptforge-internal/lua/src/__impl_globals.lua @@ -0,0 +1,123 @@ +-- The `_G` guard for a section VM, and the `setmetatable` and +-- `getmetatable` replacements that keep it in place. +-- +-- The host runs this chunk first at VM construction, before hardening +-- 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 host's slot table (`argv_frozen` and `argv` once a +-- section freezes `argv`, `prose` once a block installs its render, and +-- `author` for the metatable author code set on `_G`), and `refuse_argv()` +-- and `refuse_prose()` raise the host's assignment refusals. The chunk +-- returns the guard and the two replacements for the host to install. +local globals, state, refuse_argv, refuse_prose = ... + +local base_setmetatable, base_getmetatable = setmetatable, getmetatable +local rawequal, rawget, rawset = rawequal, rawget, rawset +local error, next, pcall, select, type = error, next, pcall, select, type +local gsub = string.gsub + +-- The guard is `_G`'s only metatable, and nothing hands it out: the base +-- `setmetatable` refuses to replace it, and the replacements below answer +-- for the author's metatable instead. +local guard = { __metatable = "_G is guarded" } + +-- The guard's own fields. Every other field of the author's metatable is +-- copied onto the guard when the author sets it, so `_G` keeps the +-- author's other metamethods. +local own = { __index = true, __newindex = true, __metatable = true } + +-- `argv` and `prose` never reach the author: `argv` serves the frozen value +-- once a section freezes it and is otherwise a plain global, so the H1 +-- repair always lands in `_G`. 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 + end + if key == "prose" then + local render = state.prose + if render == nil then return nil end + return render() + end + local author = state.author + local handler = author and rawget(author, "__index") + if handler == nil then return nil end + if type(handler) == "function" then return handler(t, key) end + return handler[key] +end + +function guard.__newindex(t, key, value) + if key == "argv" then + if state.argv_frozen then return refuse_argv() end + rawset(t, key, value) + return + end + if key == "prose" then + return refuse_prose() + end + local author = state.author + local handler = author and rawget(author, "__newindex") + if handler == nil then + rawset(t, key, value) + elseif type(handler) == "function" then + return handler(t, key, value) + else + handler[key] = value + end +end + +local function forward(author) + for key in next, guard do + if not own[key] then guard[key] = nil end + end + if author == nil then return end + for key, value in next, author do + if not own[key] then guard[key] = value end + end +end + +-- A base function called through `pcall` cannot see the name it was +-- called by, so its argument error names it '?'; restore the name. +local function named(message, name) + return (gsub(message, "^bad argument #(%d+) to '%?'", "bad argument #%1 to '" .. name .. "'", 1)) +end + +-- Re-raising at level 2 puts the caller's position on the message, where +-- the base function's own error would have put it. +local function replace_metatable(...) + local target, metatable = ... + if not rawequal(target, globals) then + local ok, result = pcall(base_setmetatable, ...) + if not ok then error(named(result, "setmetatable"), 2) end + return result + end + if select("#", ...) < 2 or (metatable ~= nil and type(metatable) ~= "table") then + local _, message = pcall(base_setmetatable, {}, select(2, ...)) + error(named(message, "setmetatable"), 2) + end + local author = state.author + if author ~= nil and rawget(author, "__metatable") ~= nil then + error("cannot change a protected metatable", 2) + end + forward(metatable) + state.author = metatable + return target +end + +local function read_metatable(...) + if rawequal((...), globals) then + local author = state.author + if author == nil then return nil end + local protected = rawget(author, "__metatable") + if protected ~= nil then return protected end + return author + end + local ok, result = pcall(base_getmetatable, ...) + if not ok then error(named(result, "getmetatable"), 2) end + return result +end + +return guard, replace_metatable, read_metatable diff --git a/crates/promptforge-internal/lua/src/argv.rs b/crates/promptforge-internal/lua/src/argv.rs index 9d234c2e3..f1b1a1139 100644 --- a/crates/promptforge-internal/lua/src/argv.rs +++ b/crates/promptforge-internal/lua/src/argv.rs @@ -7,15 +7,14 @@ //! the frozen value: reads work (absent fields read nil), and any //! assignment - `argv = ...` or `argv.field = ...` at any depth - raises. //! -//! The freeze sits on the `_G` metatable, the same composition the lazy -//! `prose` guard uses: `argv` is never a raw global in a frozen section, so -//! every read and every write of the name crosses the guard, and every -//! other key delegates to whatever metatable was installed first (the -//! `prose` guard installs later and shadows this pair as its delegates, so -//! the two compose). The table value itself is deep-frozen behind proxy -//! tables whose `__newindex` rejects every write. +//! The freeze sits on the `_G` guard ([`crate::globals`]): `argv` is never +//! a raw global in a frozen section, so every read and every write of the +//! name crosses the guard, which serves the frozen value and refuses the +//! assignment before any metatable author code set on `_G` sees the key. +//! The table value itself is deep-frozen behind proxy tables whose +//! `__newindex` rejects every write. -use super::{Error, Json, Lua, LuaSerdeExt, MultiValue, Result, Value}; +use super::{Error, Json, Lua, LuaSerdeExt, Result, Value}; use crate::proxy::read_only_proxy; /// How a section VM installs the `argv` global at host injection. `None` @@ -31,13 +30,8 @@ pub enum Argv<'a> { Frozen(Option<&'a Json>), } -/// Marker field on a metatable this module installed: a re-install reuses -/// the recorded delegates instead of chaining a new handler over its own. -const GUARD_MARKER: &str = "__promptforge_argv_guard"; -/// The metatable field recording the `__index` the guard shadows. -const DELEGATE_INDEX: &str = "__promptforge_argv_delegate_index"; -/// The metatable field recording the `__newindex` the guard shadows. -const DELEGATE_NEWINDEX: &str = "__promptforge_argv_delegate_newindex"; +/// The refusal an assignment of the frozen `argv` global raises. +pub(crate) const ASSIGNMENT_REFUSAL: &str = "argv is frozen outside H1: assign it in H1 only"; /// Installs `argv` as a plain writable global: the H1 pass's mode. `None` /// (malformed args, or JSON null) installs nil, so `if argv then` is the @@ -58,91 +52,10 @@ pub(crate) fn install_writable(lua: &Lua, argv: Option<&Json>) -> Result<()> { /// /// # Errors /// Returns [`Error::Lua`] if the value cannot be bridged or the guard -/// metatable cannot be built or installed. +/// cannot record it. pub(crate) fn install_frozen(lua: &Lua, argv: Option<&Json>) -> Result<()> { let frozen = frozen_json_value(lua, argv)?; - let globals = lua.globals(); - let old = globals.metatable(); - // The delegates the new guard shadows: a metatable of our own already - // recorded its delegates, so a re-install reuses them rather than - // chaining over the previous handler; any other metatable (the prose - // guard's, a shared library's) contributes its own index pair. - let (delegate_index, delegate_newindex) = match &old { - Some(old) if matches!(old.raw_get::(GUARD_MARKER), Ok(Value::Boolean(true))) => ( - old.raw_get::(DELEGATE_INDEX).map_err(Error::lua)?, - old.raw_get::(DELEGATE_NEWINDEX) - .map_err(Error::lua)?, - ), - Some(old) => ( - old.raw_get::("__index").map_err(Error::lua)?, - old.raw_get::("__newindex").map_err(Error::lua)?, - ), - None => (Value::Nil, Value::Nil), - }; - let metatable = lua.create_table().map_err(Error::lua)?; - // Copy every other field the previous metatable installed, then shadow - // the index pair with the argv guard. - if let Some(old) = &old { - // `pairs` order is unspecified; each iteration only assigns one - // non-shadowed metatable field, so the copy's content is fixed. - for pair in old.clone().pairs::() { - let (key, value) = pair.map_err(Error::lua)?; - let shadowed = - matches!(&key, Value::String(name) if name == "__index" || name == "__newindex"); - if !shadowed { - metatable.raw_set(key, value).map_err(Error::lua)?; - } - } - } - metatable.raw_set(GUARD_MARKER, true).map_err(Error::lua)?; - metatable - .raw_set(DELEGATE_INDEX, delegate_index.clone()) - .map_err(Error::lua)?; - metatable - .raw_set(DELEGATE_NEWINDEX, delegate_newindex.clone()) - .map_err(Error::lua)?; - - let index = lua - .create_function(move |_, (target, key): (mlua::Table, Value)| { - if matches!(&key, Value::String(name) if name == "argv") { - return Ok(frozen.clone()); - } - match &delegate_index { - Value::Function(function) => Ok(function - .call::((target, key))? - .into_iter() - .next() - .unwrap_or(Value::Nil)), - Value::Table(table) => table.get(key), - _ => Ok(Value::Nil), - } - }) - .map_err(Error::lua)?; - metatable.raw_set("__index", index).map_err(Error::lua)?; - - let newindex = lua - .create_function( - move |_, (target, key, value): (mlua::Table, Value, Value)| -> mlua::Result<()> { - if matches!(&key, Value::String(name) if name == "argv") { - return Err(mlua::Error::runtime( - "argv is frozen outside H1: assign it in H1 only", - )); - } - match &delegate_newindex { - Value::Function(function) => { - function.call::((target, key, value))?; - Ok(()) - } - Value::Table(table) => table.set(key, value), - _ => target.raw_set(key, value), - } - }, - ) - .map_err(Error::lua)?; - metatable - .raw_set("__newindex", newindex) - .map_err(Error::lua)?; - globals.set_metatable(Some(metatable)).map_err(Error::lua) + crate::globals::freeze_argv(lua, frozen) } /// Builds the frozen Lua form of an argv JSON value: tables become diff --git a/crates/promptforge-internal/lua/src/coro.rs b/crates/promptforge-internal/lua/src/coro.rs index c49b363d2..bcd6cb039 100644 --- a/crates/promptforge-internal/lua/src/coro.rs +++ b/crates/promptforge-internal/lua/src/coro.rs @@ -27,7 +27,8 @@ use std::sync::{Arc, LazyLock}; use mlua::{Function, Table, Value}; use super::{ - Error, Lua, LuaProgram, Result, SharedSource, StdLib, route_store_to_shims, var_snapshot_table, + Error, InstructionBudget, Lua, LuaProgram, Result, SharedSource, StdLib, route_store_to_shims, + var_snapshot_table, }; use crate::error_value::{Raised, install_error_value, install_normalize_failure, raised_from}; @@ -144,11 +145,14 @@ static FANOUT_PROGRAM: LazyLock> = /// raised failure into the same table. The chunk's `pcall` and `xpcall` /// replacements, which run every caught value through that capture, are /// installed over the base library's globals here, so a host callback that -/// fails directly from Rust reaches author code in the one shape. The last +/// fails directly from Rust reaches author code in the one shape. The next /// two captures, `enter_local_handler` and `leave_local_handler`, count up /// and down on `local_handler_depth`, the counter the VM's `jump` reads, so /// `jump` refuses while a local tool's handler runs, whatever reference -/// the handler calls it through. +/// the handler calls it through. The last, `cancel_requested`, reads the +/// cancel flag of `instruction_budget`, so the chunk's protected calls +/// raise a failure caught under cancellation again instead of returning +/// it, and the hook's abort always reaches the block guard. /// /// # Errors /// Returns [`Error::Lua`] if the coroutine library, the shim chunk, or any @@ -157,6 +161,7 @@ pub(crate) fn install_shim_prelude( lua: &Lua, max_tool_iterations: usize, local_handler_depth: &Arc, + instruction_budget: &InstructionBudget, ) -> Result<()> { lua.load_std_libs(StdLib::COROUTINE).map_err(Error::lua)?; let globals = lua.globals(); @@ -173,6 +178,10 @@ pub(crate) fn install_shim_prelude( let normalize_failure = install_normalize_failure(lua).map_err(Error::lua)?; let (enter_local_handler, leave_local_handler) = local_handler_captures(lua, local_handler_depth)?; + let budget = instruction_budget.clone(); + let cancel_requested = lua + .create_function(move |_, ()| Ok(budget.is_cancelled())) + .map_err(Error::lua)?; let program = SHIM_PROGRAM.as_ref().map_err(Error::shared)?; let shims: Table = program .load(lua)? @@ -188,6 +197,7 @@ pub(crate) fn install_shim_prelude( normalize_failure, enter_local_handler, leave_local_handler, + cancel_requested, )) .map_err(Error::lua)?; let guard: Function = shims.raw_get("guard").map_err(Error::lua)?; diff --git a/crates/promptforge-internal/lua/src/globals-tests.rs b/crates/promptforge-internal/lua/src/globals-tests.rs new file mode 100644 index 000000000..0ba5757d0 --- /dev/null +++ b/crates/promptforge-internal/lua/src/globals-tests.rs @@ -0,0 +1,459 @@ +//! Tests for the `_G` guard: `argv` and `prose` 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 +//! functions for every other value. Also the reserved-name list's own +//! shape: each name once, and each keyword a word the compiler refuses as +//! a name. The list against a set-up VM's globals is the engine's test, +//! over its real section setup. + +use std::sync::Arc; + +use mlua::{FromLuaMulti, Lua, LuaOptions, StdLib}; +use promptforge_types::untrusted::GuardNonce; +use serde_json::json; + +use super::{GLOBALS_CHUNK_NAME, GLOBALS_SOURCE, RESERVED_NAMES, Reserved, reserved_name}; +use crate::tests::recording::null_emitter; +use crate::tests::{assert_chunk_name_resolves, refusal_line}; +use crate::{Argv, SectionVm}; + +const SECTION: &str = "Globals"; + +/// The refusal line of an assignment to the frozen `argv` global. +const ARGV_REFUSAL: &str = "argv is frozen outside H1: assign it in H1 only"; + +/// The refusal line of an assignment to `prose`. +const PROSE_REFUSAL: &str = "prose is read-only: assign to `var` or a section global instead"; + +/// The text every test VM's `prose` renders. +const PROSE: &str = "the block's prose"; + +/// Builds a section VM the way section setup does up to the shared replay: +/// frozen `argv`, the host APIs, the control globals, and the coroutine +/// shims, with a block's lazy `prose` installed. +fn frozen_vm() -> SectionVm { + let argv = json!({ "query": "papers" }); + section_vm(Argv::Frozen(Some(&argv))) +} + +/// [`frozen_vm`] with `argv` installed in the given mode. +fn section_vm(argv: Argv<'_>) -> SectionVm { + let emitter = null_emitter(); + let mut vm = + SectionVm::new(&GuardNonce::from_seed(11), &emitter, SECTION).expect("the VM builds"); + let access = Arc::new( + promptforge_vfs::VfsRef::default() + .acquire(promptforge_vfs::Origin::new("globals test fixture")) + .expect("the stock backend acquires"), + ); + vm.inject_host_with_var("", &json!({ "id": 1 }), &access, None, argv) + .expect("host values inject"); + vm.install_host_apis(&emitter, SECTION) + .expect("the host APIs install"); + vm.install_scheduler_control_globals(|_| { + Ok::, std::convert::Infallible>(Vec::new()) + }) + .expect("the control globals install"); + vm.install_coro_shims(1) + .expect("the coroutine shims install"); + vm.install_lazy_prose(|_| Ok(PROSE.to_owned())) + .expect("the prose guard installs"); + vm +} + +/// Runs author code on the VM's main state under the chunk name `probe`. +fn eval(vm: &SectionVm, source: &str) -> T { + vm.lua() + .load(source) + .set_name("probe") + .eval() + .expect("the author chunk runs") +} + +/// The caught refusals of assigning `argv` and `prose`, each reduced to its +/// first line. +fn assignment_refusals(vm: &SectionVm) -> (String, String) { + let (argv, prose): (String, String) = eval( + vm, + "local _, argv_err = pcall(function() argv = 'hijacked' end)\n\ + local _, prose_err = pcall(function() prose = 'overwritten' end)\n\ + return tostring(argv_err), tostring(prose_err)", + ); + ( + refusal_line(&argv).unwrap_or(&argv).to_owned(), + refusal_line(&prose).unwrap_or(&prose).to_owned(), + ) +} + +/// Asserts `argv` and `prose` still read as the host set them and still +/// refuse assignment with their unchanged refusals. +fn assert_guards_hold(vm: &SectionVm, after: &str) { + let (query, prose): (String, String) = eval(vm, "return argv.query, prose"); + assert_eq!( + (query.as_str(), prose.as_str()), + ("papers", PROSE), + "argv and prose read as the host set them after {after}" + ); + let (argv_refusal, prose_refusal) = assignment_refusals(vm); + assert_eq!( + argv_refusal, ARGV_REFUSAL, + "argv stays frozen after {after}" + ); + assert_eq!( + prose_refusal, PROSE_REFUSAL, + "prose stays read-only after {after}" + ); +} + +/// Runs `source` on a stock Lua VM with the section VM's standard libraries +/// under the same chunk name, the base functions untouched. +fn stock_eval(source: &str) -> String { + let lua = Lua::new_with( + StdLib::STRING | StdLib::TABLE | StdLib::MATH, + LuaOptions::default(), + ) + .expect("a stock VM builds"); + lua.load(source) + .set_name("probe") + .eval() + .expect("the stock chunk runs") +} + +#[test] +fn the_globals_chunk_name_resolves_to_the_guard_file() { + assert_chunk_name_resolves("GLOBALS_CHUNK_NAME", GLOBALS_CHUNK_NAME, GLOBALS_SOURCE); +} + +#[test] +fn no_setmetatable_call_on_g_frees_argv_or_prose() { + for attempt in [ + "setmetatable(_G, nil)", + "setmetatable(_G, {})", + "captured = {}\n\ + setmetatable(_G, { __newindex = function(t, k, v) captured[k] = v end })", + "setmetatable(_G, { __index = function() return 'author' end, \ + __newindex = function() end })", + ] { + let vm = frozen_vm(); + eval::<()>(&vm, attempt); + assert_guards_hold(&vm, attempt); + let captured: bool = eval( + &vm, + "return captured == nil or (captured.argv == nil and captured.prose == nil)", + ); + assert!( + captured, + "the author's __newindex never sees argv or prose after {attempt}" + ); + } +} + +#[test] +fn the_h1_argv_is_a_plain_global_that_no_author_metatable_intercepts() { + let vm = section_vm(Argv::Writable(None)); + let (absent, hooked, query): (bool, bool, String) = eval( + &vm, + "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\ + argv = { query = 'repaired' }\n\ + return absent, seen.argv ~= nil, argv.query", + ); + assert!( + absent, + "a nil H1 argv reads nil, not the strict handler's error" + ); + assert!(!hooked, "the author's write hook never sees argv"); + assert_eq!(query, "repaired", "the repair lands in _G"); + assert_eq!( + vm.argv_json().expect("the repair reads back"), + Some(json!({ "query": "repaired" })) + ); + let (prose_refusal, cleared): (String, bool) = eval( + &vm, + "local _, err = pcall(function() prose = 'x' end)\n\ + argv = nil\n\ + return tostring(err), argv == nil", + ); + assert_eq!(refusal_line(&prose_refusal), Some(PROSE_REFUSAL)); + assert!(cleared, "H1 can still clear argv"); +} + +#[test] +fn editing_the_table_getmetatable_returns_cannot_free_argv_or_prose() { + let vm = frozen_vm(); + let before: bool = eval(&vm, "return getmetatable(_G) == nil"); + assert!(before, "with no author metatable, getmetatable(_G) is nil"); + eval::<()>( + &vm, + "setmetatable(_G, {})\n\ + local mt = getmetatable(_G)\n\ + mt.__index = function() return 'author' end\n\ + mt.__newindex = function() end\n\ + mt.__metatable = nil", + ); + assert_guards_hold(&vm, "editing the author metatable"); +} + +#[test] +fn no_sandbox_route_reaches_the_guard_or_a_raw_global() { + let vm = frozen_vm(); + let reachable: String = eval( + &vm, + "local found = {}\n\ + for _, name in ipairs({ 'rawget', 'rawset', 'rawequal', 'rawlen', 'debug', 'load', \ + 'loadstring', 'dofile', 'loadfile', 'require', 'package', 'getfenv', 'setfenv', \ + 'collectgarbage', 'coroutine', 'io', 'os' }) do\n\ + if _G[name] ~= nil then found[#found + 1] = name end\n\ + end\n\ + return table.concat(found, ',')", + ); + assert_eq!( + reachable, "", + "no raw access, debug, or code-loading global" + ); +} + +#[test] +fn getmetatable_on_g_returns_the_author_metatable_and_edits_take_effect_live() { + let vm = frozen_vm(); + let (same, untouched, returned): (bool, bool, bool) = eval( + &vm, + "author = {}\n\ + local returned = setmetatable(_G, author) == _G\n\ + return getmetatable(_G) == author, next(author) == nil, returned", + ); + assert!(same, "getmetatable(_G) is the author's own table"); + assert!( + untouched, + "setting the author's table writes nothing into it" + ); + assert!(returned, "setmetatable(_G, mt) returns _G"); + + let live: String = eval( + &vm, + "getmetatable(_G).__index = function(_, key) return 'default ' .. key end\n\ + return missing_one", + ); + assert_eq!( + live, "default missing_one", + "a later __index edit applies at once" + ); + let (hooked, raw): (String, bool) = eval( + &vm, + "local seen = {}\n\ + author.__newindex = function(_, key, value) seen[key] = value end\n\ + hooked_global = 'x'\n\ + author.__index = nil\n\ + return seen.hooked_global, hooked_global == nil", + ); + assert_eq!(hooked, "x", "a later __newindex edit applies at once"); + assert!(raw, "the hooked write never landed in _G"); + assert_guards_hold(&vm, "live edits to the author metatable"); + + let cleared: (bool, String) = eval( + &vm, + "setmetatable(_G, nil)\n\ + plain_global = 'raw'\n\ + return getmetatable(_G) == nil, plain_global", + ); + assert_eq!( + cleared, + (true, "raw".to_owned()), + "setmetatable(_G, nil) clears the author metatable" + ); + assert_guards_hold(&vm, "clearing the author metatable"); +} + +#[test] +fn an_author_metatable_with_a_metatable_field_protects_g_as_lua_would() { + let vm = frozen_vm(); + let (label, tone, replace, clear): (String, String, String, String) = eval( + &vm, + "setmetatable(_G, { __metatable = 'locked', __index = { tone = 'friendly' } })\n\ + local _, replace = pcall(function() setmetatable(_G, {}) end)\n\ + local _, clear = pcall(function() setmetatable(_G, nil) end)\n\ + return getmetatable(_G), tone, tostring(replace), tostring(clear)", + ); + assert_eq!( + label, "locked", + "getmetatable(_G) returns the author's __metatable" + ); + assert_eq!(tone, "friendly", "the protected metatable keeps working"); + assert_eq!( + (replace.as_str(), clear.as_str()), + ( + "[string \"probe\"]:2: cannot change a protected metatable", + "[string \"probe\"]:3: cannot change a protected metatable", + ), + "a later setmetatable(_G) raises Lua's own refusal at the caller's line" + ); + assert_guards_hold(&vm, "a protected author metatable"); +} + +#[test] +fn setmetatable_on_g_rejects_what_the_base_function_rejects() { + let vm = frozen_vm(); + let (number, missing, boolean): (String, String, String) = eval( + &vm, + "local _, number = pcall(setmetatable, _G, 5)\n\ + local _, missing = pcall(setmetatable, _G)\n\ + local _, boolean = pcall(function() setmetatable(_G, false) end)\n\ + return tostring(number), tostring(missing), tostring(boolean)", + ); + assert_eq!( + number, + "bad argument #2 to 'setmetatable' (nil or table expected, got number)" + ); + assert_eq!( + missing, + "bad argument #2 to 'setmetatable' (nil or table expected, got no value)" + ); + assert_eq!( + boolean, + "[string \"probe\"]:3: bad argument #2 to 'setmetatable' (nil or table expected, got boolean)" + ); + let untouched: bool = eval(&vm, "return getmetatable(_G) == nil"); + assert!(untouched, "a rejected call records nothing"); +} + +#[test] +fn the_other_fields_of_the_author_metatable_apply_to_g_from_each_setmetatable() { + let vm = frozen_vm(); + let (called, shown): (String, String) = eval( + &vm, + "author = {\n\ + __call = function(_, name) return 'called ' .. name end,\n\ + __tostring = function() return 'globals' end,\n\ + }\n\ + setmetatable(_G, author)\n\ + return _G('twice'), tostring(_G)", + ); + assert_eq!(called, "called twice"); + assert_eq!(shown, "globals"); + let (before, after): (String, String) = eval( + &vm, + "author.__tostring = function() return 'edited' end\n\ + local before = tostring(_G)\n\ + setmetatable(_G, author)\n\ + return before, tostring(_G)", + ); + assert_eq!( + (before.as_str(), after.as_str()), + ("globals", "edited"), + "other fields are copied when setmetatable(_G, mt) runs" + ); + let (plain, callable): (bool, bool) = eval( + &vm, + "setmetatable(_G, nil)\n\ + return tostring(_G):find('^table: ') ~= nil, (pcall(_G))", + ); + assert!(plain, "clearing drops the copied __tostring"); + assert!(!callable, "clearing drops the copied __call"); + assert_guards_hold(&vm, "forwarded metamethods"); +} + +#[test] +fn setmetatable_and_getmetatable_on_other_values_match_stock_lua() { + let probe = "\ +local out = {} +local function case(f) + local results = table.pack(pcall(f)) + for i = 1, results.n do results[i] = tostring(results[i]) end + out[#out + 1] = table.concat(results, ' ', 1, results.n) +end +case(function() + local t, mt = {}, {} + return setmetatable(t, mt) == t, getmetatable(t) == mt +end) +case(function() return getmetatable(setmetatable({}, { __metatable = 'sealed' })) end) +case(function() return getmetatable(setmetatable({}, { __metatable = false })) end) +case(function() + local t = setmetatable({}, { __metatable = 'sealed' }) + setmetatable(t, {}) +end) +case(function() + local t = setmetatable({}, { __metatable = 'sealed' }) + setmetatable(t, nil) +end) +case(function() setmetatable(1, {}) end) +case(function() setmetatable({}, 5) end) +case(function() setmetatable({}) end) +case(function() setmetatable() end) +case(function() getmetatable() end) +case(function() return getmetatable('').__index == string end) +case(function() return getmetatable(1) end) +case(function() return select('#', setmetatable({}, nil)), select('#', getmetatable({})) end) +case(function() return setmetatable({}, { __index = function(_, k) return k .. '!' end }).x end) +out[#out + 1] = tostring(select(2, pcall(setmetatable, 1, {}))) +out[#out + 1] = tostring(select(2, pcall(setmetatable, setmetatable({}, { __metatable = 1 }), {}))) +out[#out + 1] = tostring(select(2, pcall(getmetatable))) +return table.concat(out, '\\n')"; + let vm = frozen_vm(); + let guarded: String = eval(&vm, probe); + assert_eq!(guarded, stock_eval(probe)); +} + +#[test] +fn sealed_host_values_keep_their_metatable_protection() { + let vm = frozen_vm(); + let (sys_label, argv_label, sys_refusal, var_refusal): (String, String, String, String) = eval( + &vm, + "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)", + ); + assert_eq!(sys_label, "sys is sealed"); + assert_eq!(argv_label, "argv is frozen"); + assert_eq!( + sys_refusal, + "[string \"probe\"]:1: cannot change a protected metatable" + ); + assert_eq!( + var_refusal, + "[string \"probe\"]:2: cannot change a protected metatable" + ); +} + +#[test] +fn each_reserved_name_is_listed_once() { + let mut names: Vec<&str> = RESERVED_NAMES.iter().map(|(name, _)| *name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), RESERVED_NAMES.len(), "a name is listed twice"); +} + +#[test] +fn the_compiler_refuses_each_reserved_keyword_as_a_name_and_takes_every_other_entry() { + let lua = Lua::new(); + let compiles = |source: &str| lua.load(source).into_function().is_ok(); + for (name, kind) in RESERVED_NAMES { + let as_local = compiles(&format!("local {name} = 1")); + match kind { + Reserved::LuaKeyword if name == "global" => { + assert!( + as_local, + "`global` is contextual in the vendored compat build" + ); + assert!(compiles("global declared"), "`global` opens a declaration"); + } + Reserved::LuaKeyword => assert!(!as_local, "`{name}` is a keyword"), + Reserved::HostGlobal | Reserved::LuaGlobal => { + assert!(as_local, "`{name}` is an ordinary Lua name"); + } + } + } +} + +#[test] +fn reserved_name_classifies_whole_case_sensitive_names_only() { + assert_eq!(reserved_name("store"), Some(Reserved::HostGlobal)); + assert_eq!(reserved_name("pairs"), Some(Reserved::LuaGlobal)); + assert_eq!(reserved_name("end"), Some(Reserved::LuaKeyword)); + for name in ["Store", "stores", "input", "search", ""] { + assert_eq!(reserved_name(name), None, "{name:?} is not reserved"); + } +} diff --git a/crates/promptforge-internal/lua/src/globals.rs b/crates/promptforge-internal/lua/src/globals.rs new file mode 100644 index 000000000..7ba59fcaa --- /dev/null +++ b/crates/promptforge-internal/lua/src/globals.rs @@ -0,0 +1,235 @@ +//! The `_G` guard: the one metatable on a section VM's globals table. +//! +//! The guard owns the two host-owned globals, and no metatable author code +//! sets on `_G` ever sees either key. `argv` outside H1 ([`crate::argv`]) +//! is never a raw global: reads return the frozen value and writes raise +//! the freeze refusal. In the H1 pass it behaves as a plain global, so the +//! repair always lands in `_G`. `prose`, each block's lazily rendered text +//! ([`crate::prose`]), is never a raw global either: reads render through +//! the host and writes raise the read-only refusal. Every other global +//! read or write goes to the author metatable's `__index` or `__newindex`, +//! looked up live on each access, and otherwise behaves as on a table with +//! no metatable. +//! +//! The guard carries `__metatable`, and the VM's `setmetatable` and +//! `getmetatable` globals are replacements that treat the globals table, +//! compared by raw identity, as the one special case: +//! `setmetatable(_G, mt)` records `mt` as the author's metatable behind the +//! guard, copying its other fields onto the guard, and `getmetatable(_G)` +//! returns the author's metatable (or its `__metatable` field), never the +//! 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, to a raw `prose` global, or to a raw `argv` global +//! outside H1. +//! +//! The Lua side lives in `__impl_globals.lua`; the host's side is the slot +//! table the chunk reads, held in the registry. +//! +//! [`RESERVED_NAMES`] is the other half of `_G`'s contract: every name the +//! globals table holds once section setup ends, before any capability +//! prelude installs, plus the Lua keywords. A frontmatter tool alias or +//! model role label installs as a global of its own name, so the parser +//! refuses one that is reserved, and a prelude global may not take one +//! either. + +use std::fmt; +use std::sync::LazyLock; + +use mlua::{Function, Table}; + +use super::{Error, Lua, LuaProgram, Result, SharedSource, Value}; + +/// The guard chunk's name, `@`-prefixed so PUC renders it verbatim as a +/// file path, as the shim chunks' names are. +const GLOBALS_CHUNK_NAME: &str = "@crates/promptforge-internal/lua/src/__impl_globals.lua"; + +/// The guard source, embedded verbatim so chunk line 1 is file line 1. +const GLOBALS_SOURCE: &str = include_str!("__impl_globals.lua"); + +/// The registry key of the slot table the guard chunk reads: `argv_frozen` +/// and `argv`, `prose`, and `author`. +const STATE_REGISTRY: &str = "promptforge.globals.state"; + +/// The guard program, compiled once and loaded per VM, under the shim +/// programs' failure contract. +static GLOBALS_PROGRAM: LazyLock> = + LazyLock::new(|| { + LuaProgram::compile_internal(GLOBALS_SOURCE, GLOBALS_CHUNK_NAME) + .map_err(crate::detail::shared_source_new) + }); + +/// Installs the `_G` guard and the `setmetatable` and `getmetatable` +/// replacements on a fresh VM. +/// +/// Must run before hardening removes `rawequal`, `rawget`, and `rawset`, +/// which the chunk captures, and before any other chunk captures the base +/// `setmetatable` or `getmetatable`. +/// +/// # Errors +/// Returns [`Error::Lua`] if the chunk cannot load or run, or the guard +/// and the replacements cannot be installed. +pub(crate) fn install(lua: &Lua) -> Result<()> { + let globals = lua.globals(); + let state = lua.create_table().map_err(Error::lua)?; + let refuse_argv = refusal(lua, crate::argv::ASSIGNMENT_REFUSAL)?; + let refuse_prose = refusal(lua, crate::prose::ASSIGNMENT_REFUSAL)?; + let (guard, replace_metatable, read_metatable): (Table, Function, Function) = GLOBALS_PROGRAM + .as_ref() + .map_err(Error::shared)? + .load(lua)? + .call((globals.clone(), state.clone(), refuse_argv, refuse_prose)) + .map_err(Error::lua)?; + lua.set_named_registry_value(STATE_REGISTRY, state) + .map_err(Error::lua)?; + globals + .raw_set("setmetatable", replace_metatable) + .map_err(Error::lua)?; + globals + .raw_set("getmetatable", read_metatable) + .map_err(Error::lua)?; + globals.set_metatable(Some(guard)).map_err(Error::lua) +} + +/// Freezes `argv` on the guard: reads return `frozen`, and every +/// assignment of the global raises the freeze refusal. +/// +/// # Errors +/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// be written. +pub(crate) fn freeze_argv(lua: &Lua, frozen: Value) -> Result<()> { + let state = state(lua)?; + state.raw_set("argv", frozen).map_err(Error::lua)?; + state.raw_set("argv_frozen", true).map_err(Error::lua) +} + +/// Installs `render` as the source of `prose` reads, replacing the +/// previous block's. +/// +/// # Errors +/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// be written. +pub(crate) fn install_prose(lua: &Lua, render: Function) -> Result<()> { + state(lua)?.raw_set("prose", render).map_err(Error::lua) +} + +/// The guard's slot table. +fn state(lua: &Lua) -> Result { + lua.named_registry_value(STATE_REGISTRY).map_err(Error::lua) +} + +/// A host function that raises `message` as a runtime error. +fn refusal(lua: &Lua, message: &'static str) -> Result { + lua.create_function(move |_, ()| -> mlua::Result<()> { Err(mlua::Error::runtime(message)) }) + .map_err(Error::lua) +} + +/// Why a name is reserved in a section VM's global namespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reserved { + /// A global the host installs: on every section VM, or only on some + /// (`ui` with a host-state snapshot, `item` in a spawned chain). + HostGlobal, + /// A Lua standard-library global the sandbox keeps. + LuaGlobal, + /// A Lua 5.5 keyword. + LuaKeyword, +} + +impl fmt::Display for Reserved { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Reserved::HostGlobal => "a host global", + Reserved::LuaGlobal => "a Lua standard-library global", + Reserved::LuaKeyword => "a Lua keyword", + }) + } +} + +/// Every name reserved in a section VM's global namespace, sorted within +/// each kind: the globals a section or H1 VM holds once section setup +/// ends, before any capability prelude installs, and the Lua 5.5 keywords. +/// +/// 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); 60] = [ + ("args", Reserved::HostGlobal), + ("argv", Reserved::HostGlobal), + ("call", Reserved::HostGlobal), + ("compactors", Reserved::HostGlobal), + ("fanout", Reserved::HostGlobal), + ("item", Reserved::HostGlobal), + ("jump", Reserved::HostGlobal), + ("list_from_section", Reserved::HostGlobal), + ("log", Reserved::HostGlobal), + ("messages", Reserved::HostGlobal), + ("models", Reserved::HostGlobal), + ("prose", Reserved::HostGlobal), + ("store", Reserved::HostGlobal), + ("sys", Reserved::HostGlobal), + ("tasks", Reserved::HostGlobal), + ("tools", Reserved::HostGlobal), + ("ui", Reserved::HostGlobal), + ("untrusted", Reserved::HostGlobal), + ("var", Reserved::HostGlobal), + ("_G", Reserved::LuaGlobal), + ("_VERSION", Reserved::LuaGlobal), + ("assert", Reserved::LuaGlobal), + ("error", Reserved::LuaGlobal), + ("getmetatable", Reserved::LuaGlobal), + ("ipairs", Reserved::LuaGlobal), + ("math", Reserved::LuaGlobal), + ("next", Reserved::LuaGlobal), + ("pairs", Reserved::LuaGlobal), + ("pcall", Reserved::LuaGlobal), + ("select", Reserved::LuaGlobal), + ("setmetatable", Reserved::LuaGlobal), + ("string", Reserved::LuaGlobal), + ("table", Reserved::LuaGlobal), + ("tonumber", Reserved::LuaGlobal), + ("tostring", Reserved::LuaGlobal), + ("type", Reserved::LuaGlobal), + ("xpcall", Reserved::LuaGlobal), + ("and", Reserved::LuaKeyword), + ("break", Reserved::LuaKeyword), + ("do", Reserved::LuaKeyword), + ("else", Reserved::LuaKeyword), + ("elseif", Reserved::LuaKeyword), + ("end", Reserved::LuaKeyword), + ("false", Reserved::LuaKeyword), + ("for", Reserved::LuaKeyword), + ("function", Reserved::LuaKeyword), + // The vendored Lua 5.5 builds with `LUA_COMPAT_GLOBAL`, so its lexer + // reads `global` as a contextual keyword rather than a reserved word; + // the language reserves it all the same. + ("global", Reserved::LuaKeyword), + ("goto", Reserved::LuaKeyword), + ("if", Reserved::LuaKeyword), + ("in", Reserved::LuaKeyword), + ("local", Reserved::LuaKeyword), + ("nil", Reserved::LuaKeyword), + ("not", Reserved::LuaKeyword), + ("or", Reserved::LuaKeyword), + ("repeat", Reserved::LuaKeyword), + ("return", Reserved::LuaKeyword), + ("then", Reserved::LuaKeyword), + ("true", Reserved::LuaKeyword), + ("until", Reserved::LuaKeyword), + ("while", Reserved::LuaKeyword), +]; + +/// Returns why `name` is reserved in a section VM's global namespace, or +/// `None` when a frontmatter alias or a capability prelude global may take +/// it. The match is case-sensitive, as Lua names are. +#[must_use] +pub fn reserved_name(name: &str) -> Option { + RESERVED_NAMES + .iter() + .find(|(reserved, _)| *reserved == name) + .map(|(_, kind)| *kind) +} + +#[cfg(test)] +#[path = "globals-tests.rs"] +mod tests; diff --git a/crates/promptforge-internal/lua/src/hardening.rs b/crates/promptforge-internal/lua/src/hardening.rs index ea66f0501..3833e1f53 100644 --- a/crates/promptforge-internal/lua/src/hardening.rs +++ b/crates/promptforge-internal/lua/src/hardening.rs @@ -1,4 +1,4 @@ -//! Sandbox hardening for section VMs: global removal, the instruction-budget hook, and scalar return rendering. +//! Sandbox hardening for section VMs: the `_G` guard, global removal, the instruction-budget hook, and scalar return rendering. use std::sync::OnceLock; @@ -13,11 +13,18 @@ use super::{ /// provides. The `io`, `os`, `package`, `coroutine`, and `debug` libraries are /// never loaded. /// +/// First installs the `_G` guard with its `setmetatable` and +/// `getmetatable` replacements ([`crate::globals`]). +/// /// Also wraps `table.concat` so a value with a `__tostring` metamethod /// (fanout result objects, host userdata) coerces like `tostring`, keeping /// existing `table.concat(results)` callers working with structured /// results. Plain tables, booleans, and nil still error as stock Lua would. pub(crate) fn harden(lua: &Lua) -> Result<()> { + // The guard captures the raw functions removed below, and every later + // chunk, the `table.concat` wrapper included, must capture the + // replacements rather than the base functions. + crate::globals::install(lua)?; let globals = lua.globals(); for name in [ "load", diff --git a/crates/promptforge-internal/lua/src/lib.rs b/crates/promptforge-internal/lua/src/lib.rs index 3aba4bab4..7b161fc2a 100644 --- a/crates/promptforge-internal/lua/src/lib.rs +++ b/crates/promptforge-internal/lua/src/lib.rs @@ -95,6 +95,7 @@ pub use error_value::{ ErrorField, ErrorKind, ErrorValue, Raised, error_table, store_error_fields, store_error_reason, store_error_value_fields, }; +mod globals; mod hardening; pub(crate) use hardening::{InstructionBudget, harden, install_instruction_budget, scalar_return}; mod coro; @@ -133,6 +134,7 @@ pub use coro::{install_section_loop_shim, install_store_shims}; pub use dispatch::{ ModelReport, ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch, }; +pub use globals::{RESERVED_NAMES, Reserved, reserved_name}; pub use handles::{LuaBlockResult, ToolBinding, ToolOutputKind, ToolSet, ToolView}; pub use host::{run_store_op, store_error_message}; pub use models::ModelRuntime; diff --git a/crates/promptforge-internal/lua/src/prelude-tests.rs b/crates/promptforge-internal/lua/src/prelude-tests.rs index 6deae9fd5..2c8f4ecc3 100644 --- a/crates/promptforge-internal/lua/src/prelude-tests.rs +++ b/crates/promptforge-internal/lua/src/prelude-tests.rs @@ -254,6 +254,35 @@ fn argv_and_prose_collide_though_the_g_metatable_serves_them() { } } +#[test] +fn a_prelude_sets_and_reads_metatables_as_the_base_functions_do() { + let vm = section_vm(); + install_preludes( + vm.lua(), + &[prelude( + "acme/meta", + "kit = {}\n\ + local shape = { __index = function(_, key) return key .. '!' end }\n\ + function kit.make() return setmetatable({}, shape) end\n\ + function kit.shaped(t) return getmetatable(t) == shape end\n\ + function kit.lock()\n\ + local t = setmetatable({}, { __metatable = 'locked' })\n\ + local ok, err = pcall(setmetatable, t, {})\n\ + return getmetatable(t), ok, tostring(err)\n\ + end", + )], + &[], + ) + .expect("the prelude installs"); + let (field, shaped): (String, bool) = + eval(&vm, "local t = kit.make()\nreturn t.x, kit.shaped(t)"); + assert_eq!((field.as_str(), shaped), ("x!", true)); + let (label, ok, message): (String, bool, String) = eval(&vm, "return kit.lock()"); + assert_eq!(label, "locked"); + assert!(!ok, "a protected metatable refuses replacement"); + assert_eq!(message, "cannot change a protected metatable"); +} + #[test] fn a_global_that_collides_with_a_frontmatter_alias_fails_naming_the_alias() { let vm = section_vm(); diff --git a/crates/promptforge-internal/lua/src/prelude.rs b/crates/promptforge-internal/lua/src/prelude.rs index 75a96bfb9..ed9c0e966 100644 --- a/crates/promptforge-internal/lua/src/prelude.rs +++ b/crates/promptforge-internal/lua/src/prelude.rs @@ -49,35 +49,17 @@ const VISIBLE_GLOBALS: [&str; 19] = [ "untrusted", ]; -/// Globals a raw `_G` read does not find on every VM, each with what binds -/// it: `ui` and `item` bind only on some VMs, and the `_G` metatable serves -/// `argv` outside H1 and `prose` once a block starts, so neither is a raw -/// global there. A prelude may define none of them on any host, so whether -/// a prelude installs does not depend on the host or the section, and no -/// prelude global shadows a guard. -const RESERVED_GLOBALS: [(&str, &str); 4] = [ - ( - "ui", - "the host-state global that hosts with a snapshot bind", - ), - ("item", "the collection member global that fanout arms bind"), - ("argv", "the parsed args global that every section binds"), - ( - "prose", - "the Markdown text global that each block binds when it starts", - ), -]; - /// Installs the run's capability preludes, in order, into a section VM. /// /// Each prelude loads from source under the chunk name /// `@capability:` in its own restricted environment (see the module -/// docs). The globals it defines must not collide with any name bound in -/// `_G`, with `ui`, `item`, `argv`, or `prose`, with `aliases` (the prompt's frontmatter tool -/// and model aliases, which install as globals after the shared replay), or -/// with an earlier prelude's globals. A table global installs as an empty -/// proxy that reads the prelude's table and refuses writes; any other value -/// installs as it is. +/// docs). The globals it defines must not collide with a reserved name +/// ([`crate::RESERVED_NAMES`]), with any other name bound in `_G`, with +/// `aliases` (the prompt's frontmatter tool and model aliases, which +/// install as globals after the shared replay), or with an earlier +/// prelude's globals. A table global installs as an empty proxy that reads +/// the prelude's table and refuses writes; any other value installs as it +/// is. /// /// The engine's section setup calls this after the coroutine yield shims /// install and before the shared library replays. The chunk is not a @@ -192,7 +174,12 @@ fn defined_globals(capability: &CapabilityId, env: &Table) -> Result(name).map_err(Error::lua)?, Value::Nil diff --git a/crates/promptforge-internal/lua/src/prose.rs b/crates/promptforge-internal/lua/src/prose.rs index bd1946b9c..1325172d1 100644 --- a/crates/promptforge-internal/lua/src/prose.rs +++ b/crates/promptforge-internal/lua/src/prose.rs @@ -10,22 +10,19 @@ //! Assigning to `prose` raises, and `{{ prose }}` inside the template is //! rejected as recursive. //! -//! The guard sits on the `_G` metatable: `__index` renders and memoizes -//! the `prose` key, `__newindex` rejects writes to it, and every other key -//! delegates to whatever metatable author code (say, the shared library) -//! installed first. Each install replaces the previous pair's handler, so -//! a later fence's buffer evaluates fresh while an already rendered string -//! the author kept in a local or `var` survives untouched. +//! The guard is the `_G` guard ([`crate::globals`]): it serves `prose` +//! through the current install's render and refuses every write, before +//! any metatable author code set on `_G` sees the key. Before the first +//! install, `prose` reads nil and still refuses writes. Each install +//! replaces the previous render, so a later fence's buffer evaluates fresh +//! while an already rendered string the author kept in a local or `var` +//! survives untouched. -use super::{Arc, Error, Json, Lua, LuaSerdeExt, MultiValue, Mutex, Result, Value, var_to_json}; +use super::{Arc, Error, Json, Lua, LuaSerdeExt, Mutex, Result, Value, var_to_json}; -/// Marker field on a metatable this module installed: a re-install reuses -/// the recorded delegates instead of chaining a new handler over its own. -const GUARD_MARKER: &str = "__promptforge_prose_guard"; -/// The metatable field recording the `__index` the guard shadows. -const DELEGATE_INDEX: &str = "__promptforge_prose_delegate_index"; -/// The metatable field recording the `__newindex` the guard shadows. -const DELEGATE_NEWINDEX: &str = "__promptforge_prose_delegate_newindex"; +/// The refusal an assignment of the `prose` global raises. +pub(crate) const ASSIGNMENT_REFUSAL: &str = + "prose is read-only: assign to `var` or a section global instead"; /// The section state a `prose` render snapshots at the first read. /// @@ -61,174 +58,78 @@ impl std::fmt::Debug for ProseState<'_> { /// install and first read is visible to the render. /// /// # Errors -/// Returns [`Error::Lua`] if the guard metatable cannot be built or -/// installed. +/// Returns [`Error::Lua`] if the read cannot be built or the guard cannot +/// record it. pub(crate) fn install(lua: &Lua, sys_live: &Arc>>, render: F) -> Result<()> where F: Fn(ProseState) -> mlua::Result + Send + Sync + 'static, { - let globals = lua.globals(); - let old = globals.metatable(); - // The delegates the new guard shadows: a metatable of our own already - // recorded its delegates, so a re-install reuses them rather than - // chaining over the previous handler; any other metatable (a shared - // library's) contributes its own index pair. - let (delegate_index, delegate_newindex) = match &old { - Some(old) if matches!(old.raw_get::(GUARD_MARKER), Ok(Value::Boolean(true))) => ( - old.raw_get::(DELEGATE_INDEX).map_err(Error::lua)?, - old.raw_get::(DELEGATE_NEWINDEX) - .map_err(Error::lua)?, - ), - Some(old) => ( - old.raw_get::("__index").map_err(Error::lua)?, - old.raw_get::("__newindex").map_err(Error::lua)?, - ), - None => (Value::Nil, Value::Nil), - }; - let metatable = lua.create_table().map_err(Error::lua)?; - // Copy every other field the previous metatable installed (a shared - // library's `_G` metatable keeps working), then shadow the index pair - // with the prose guard. - if let Some(old) = &old { - // `pairs` order is unspecified; each iteration only assigns one - // non-shadowed metatable field, so the copy's content is fixed. - for pair in old.clone().pairs::() { - let (key, value) = pair.map_err(Error::lua)?; - let shadowed = - matches!(&key, Value::String(name) if name == "__index" || name == "__newindex"); - if !shadowed { - metatable.raw_set(key, value).map_err(Error::lua)?; - } - } - } - metatable.raw_set(GUARD_MARKER, true).map_err(Error::lua)?; - metatable - .raw_set(DELEGATE_INDEX, delegate_index.clone()) - .map_err(Error::lua)?; - metatable - .raw_set(DELEGATE_NEWINDEX, delegate_newindex.clone()) - .map_err(Error::lua)?; - - // The memo slot is per install: the raw global stays unset, so every - // read and every write of `prose` crosses the guard, the read-only - // rejection cannot be escaped by an assignment after the first read, - // and the next fence's install starts unresolved. - let memo = Arc::new(Mutex::new(None::)); - let index = guard_index(lua, sys_live, &memo, &delegate_index, render)?; - let newindex = guard_newindex(lua, &delegate_newindex)?; - metatable.raw_set("__index", index).map_err(Error::lua)?; - metatable - .raw_set("__newindex", newindex) - .map_err(Error::lua)?; - globals.set_metatable(Some(metatable)).map_err(Error::lua) + let read = lazy_read(lua, sys_live, render)?; + crate::globals::install_prose(lua, read) } -/// Builds the guard's `__index`: a `prose` read renders once through the -/// host callback and memoizes; every other key delegates to the shadowed -/// `__index`. +/// Builds one install's `prose` read: the first call renders once through +/// the host callback and memoizes, and every later call returns the memo. /// /// # Errors /// Returns [`Error::Lua`] if the closure cannot be created. -fn guard_index( - lua: &Lua, - sys_live: &Arc>>, - memo: &Arc>>, - delegate_index: &Value, - render: F, -) -> Result +fn lazy_read(lua: &Lua, sys_live: &Arc>>, render: F) -> Result where F: Fn(ProseState) -> mlua::Result + Send + Sync + 'static, { - let memo = Arc::clone(memo); + // The memo slot is per install, so the next fence's install starts + // unresolved. + let memo = Mutex::new(None::); let sys_live = Arc::clone(sys_live); - let delegate_index = delegate_index.clone(); - lua.create_function(move |lua, (target, key): (mlua::Table, Value)| { - if matches!(&key, Value::String(name) if name == "prose") { - { - let guard = memo.lock().map_err(|_| { - mlua::Error::external(Error::Lua("prose memo slot was poisoned".to_owned())) - })?; - if let Some(rendered) = guard.as_ref() { - return lua.create_string(rendered).map(Value::String); - } - } - let var = var_to_json(lua).map_err(mlua::Error::external)?; - let sys = { - let guard = sys_live.lock().map_err(|_| { - mlua::Error::external(Error::Lua("sys live slot was poisoned".to_owned())) - })?; - guard.clone().ok_or_else(|| { - mlua::Error::external(Error::Lua( - "section VM host values have not been injected".to_owned(), - )) - })? - }; - let globals_lookup = |name: &str| -> Result> { - if name == "prose" { - return Err(Error::Lua( - "recursive {{ prose }}: prose cannot reference itself".to_owned(), - )); - } - let value: Value = lua.globals().get(name).map_err(Error::lua)?; - match value { - Value::Nil => Ok(None), - Value::Function(_) | Value::UserData(_) | Value::Thread(_) => { - Err(Error::Lua(format!( - "global `{name}` is a {}; bare globals in prose must be JSON data", - value.type_name() - ))) - } - other => Ok(Some(lua.from_value(other).map_err(Error::lua)?)), - } - }; - let rendered = render(ProseState { - var, - sys, - globals: &globals_lookup, - })?; - let mut guard = memo.lock().map_err(|_| { + lua.create_function(move |lua, ()| { + { + let guard = memo.lock().map_err(|_| { mlua::Error::external(Error::Lua("prose memo slot was poisoned".to_owned())) })?; - *guard = Some(rendered.clone()); - drop(guard); - return lua.create_string(&rendered).map(Value::String); - } - match &delegate_index { - Value::Function(function) => Ok(function - .call::((target, key))? - .into_iter() - .next() - .unwrap_or(Value::Nil)), - Value::Table(table) => table.get(key), - _ => Ok(Value::Nil), + if let Some(rendered) = guard.as_ref() { + return lua.create_string(rendered); + } } - }) - .map_err(Error::lua) -} - -/// Builds the guard's `__newindex`: a `prose` write raises the read-only -/// error; every other write delegates to the shadowed `__newindex`. -/// -/// # Errors -/// Returns [`Error::Lua`] if the closure cannot be created. -fn guard_newindex(lua: &Lua, delegate_newindex: &Value) -> Result { - let delegate_newindex = delegate_newindex.clone(); - lua.create_function( - move |_lua, (target, key, value): (mlua::Table, Value, Value)| -> mlua::Result<()> { - if matches!(&key, Value::String(name) if name == "prose") { - return Err(mlua::Error::runtime( - "prose is read-only: assign to `var` or a section global instead", + let var = var_to_json(lua).map_err(mlua::Error::external)?; + let sys = { + let guard = sys_live.lock().map_err(|_| { + mlua::Error::external(Error::Lua("sys live slot was poisoned".to_owned())) + })?; + guard.clone().ok_or_else(|| { + mlua::Error::external(Error::Lua( + "section VM host values have not been injected".to_owned(), + )) + })? + }; + let globals_lookup = |name: &str| -> Result> { + if name == "prose" { + return Err(Error::Lua( + "recursive {{ prose }}: prose cannot reference itself".to_owned(), )); } - match &delegate_newindex { - Value::Function(function) => { - function.call::((target, key, value))?; - Ok(()) + let value: Value = lua.globals().get(name).map_err(Error::lua)?; + match value { + Value::Nil => Ok(None), + Value::Function(_) | Value::UserData(_) | Value::Thread(_) => { + Err(Error::Lua(format!( + "global `{name}` is a {}; bare globals in prose must be JSON data", + value.type_name() + ))) } - Value::Table(table) => table.set(key, value), - _ => target.raw_set(key, value), + other => Ok(Some(lua.from_value(other).map_err(Error::lua)?)), } - }, - ) + }; + let rendered = render(ProseState { + var, + sys, + globals: &globals_lookup, + })?; + let mut guard = memo.lock().map_err(|_| { + mlua::Error::external(Error::Lua("prose memo slot was poisoned".to_owned())) + })?; + *guard = Some(rendered.clone()); + drop(guard); + lua.create_string(&rendered) + }) .map_err(Error::lua) } diff --git a/crates/promptforge-internal/lua/src/tests.rs b/crates/promptforge-internal/lua/src/tests.rs index 5d082a48a..4e51b0e81 100644 --- a/crates/promptforge-internal/lua/src/tests.rs +++ b/crates/promptforge-internal/lua/src/tests.rs @@ -2241,6 +2241,68 @@ fn a_pre_cancelled_run_aborts_a_tight_loop_promptly() { ); } +/// A section VM with the coroutine shims installed, so the shim's `pcall` +/// and `xpcall` replacements are live, under `cancel` when one is given. +fn shim_vm(cancel: Option) -> SectionVm { + let emitter = null_emitter(); + let mut vm = SectionVm::new(&test_nonce(), &emitter, "Loop").expect("VM must build"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host values must inject"); + vm.install_host_apis(&emitter, "Loop") + .expect("host APIs must install"); + vm.install_scheduler_control_globals(|_| { + Ok::, std::convert::Infallible>(Vec::new()) + }) + .expect("the control globals must install"); + vm.install_coro_shims(1).expect("coro shims must install"); + if let Some(cancel) = cancel { + vm.set_cancel(cancel); + } + vm +} + +/// Starts `source` as a block coroutine on a shim VM whose run is already +/// cancelled. +fn start_cancelled_block(source: &str) -> Result { + let handle = promptforge_types::cancel::CancelHandle::new(); + handle.cancel(); + shim_vm(Some(handle)).start_block_coro(&program(source)) +} + +#[test] +fn a_cancelled_run_unwinds_through_an_author_pcall_loop() { + let outcome = + start_cancelled_block("while true do pcall(function() while true do end end) end"); + assert!( + matches!(outcome, Err(Error::Interrupted)), + "an author pcall must not swallow cancellation, got {outcome:?}" + ); +} + +#[test] +fn a_cancelled_run_unwinds_through_an_author_xpcall_loop() { + let outcome = start_cancelled_block( + "while true do xpcall(function() while true do end end, function(e) return e end) end", + ); + assert!( + matches!(outcome, Err(Error::Interrupted)), + "an author xpcall with a message handler must not swallow cancellation, got {outcome:?}" + ); +} + +#[test] +fn a_pcall_failure_without_a_cancel_flag_still_returns_false_and_the_error() { + let step = shim_vm(None) + .start_block_coro(&program( + "local ok, err = pcall(error, 'boom')\nreturn tostring(ok) .. '|' .. tostring(err)", + )) + .expect("a caught failure must not fail the block"); + let CoroStep::Done(LuaBlockResult::Returned(returned)) = step else { + panic!("the block must return, got {step:?}"); + }; + assert_eq!(returned.as_deref(), Some("false|boom")); +} + #[test] fn add_without_declarations_fails_as_unbound_in_a_chunk() { let error = run("tools.add('web_search')", "").expect_err("an unbound alias must fail loudly"); diff --git a/crates/promptforge-internal/lua/src/vm.rs b/crates/promptforge-internal/lua/src/vm.rs index f9f774cc1..a5d887457 100644 --- a/crates/promptforge-internal/lua/src/vm.rs +++ b/crates/promptforge-internal/lua/src/vm.rs @@ -253,10 +253,10 @@ impl LocalTools { impl SectionVm { /// Creates a hardened section VM. /// - /// Construction installs only the sandbox, the deterministic - /// `pairs`/`next` walk, the default resource ceilings, the instruction - /// hook, and `untrusted` (wrapping under the run's - /// `nonce`). Everything else - the run's + /// Construction installs only the sandbox (the `_G` guard included), + /// the deterministic `pairs`/`next` walk, the default resource + /// ceilings, the instruction hook, and `untrusted` (wrapping under the + /// run's `nonce`). Everything else - the run's /// limits, the host values, the persistent host APIs, the control /// globals, the shared-library replay, and the captured alias globals - /// is a separate explicit step the caller drives in that order (see the @@ -427,7 +427,10 @@ impl SectionVm { /// Each bound slot becomes a bare global holding its handle userdata. /// The engine calls this after [`replay_shared`](Self::replay_shared), so /// a declared alias wins over a same-named shared global; the raw install - /// also bypasses any metatable the shared library set on `_G`. + /// also bypasses any metatable the shared library set on `_G`. The raw + /// install never replaces a host global only because the parser refuses + /// an alias on [`RESERVED_NAMES`](crate::RESERVED_NAMES) and one name + /// under both `tools` and `models`. /// /// # Errors /// Returns [`Error::Lua`] if a handle cannot be created or installed, or @@ -692,7 +695,12 @@ impl SectionVm { /// # Errors /// Returns [`Error::Lua`] if the shim prelude cannot install. pub fn install_coro_shims(&mut self, max_tool_iterations: usize) -> Result<()> { - install_shim_prelude(&self.lua, max_tool_iterations, &self.local_handler_depth) + install_shim_prelude( + &self.lua, + max_tool_iterations, + &self.local_handler_depth, + &self.instruction_budget, + ) } fn install_jump_global(&self, globals: &mlua::Table) -> Result<()> { @@ -790,7 +798,7 @@ impl SectionVm { } /// Installs the pending Markdown buffer as this VM's fresh read-only - /// lazy `prose` global, replacing any previous pair's handler. + /// lazy `prose` global, replacing any previous pair's render. /// /// The executor calls this before each Lua coroutine starts. `render` /// runs at most once, on the first runtime read of `prose`, with the @@ -799,7 +807,8 @@ impl SectionVm { /// the template is rejected as recursive. /// /// # Errors - /// Returns [`Error::Lua`] if the guard metatable cannot be installed. + /// Returns [`Error::Lua`] if the read cannot be built or the `_G` guard + /// cannot record it. pub fn install_lazy_prose(&self, render: F) -> Result<()> where F: Fn(ProseState) -> mlua::Result + Send + Sync + 'static, diff --git a/crates/promptforge-internal/model-client/Cargo.toml b/crates/promptforge-internal/model-client/Cargo.toml index 9d9d822ad..266e8a2d3 100644 --- a/crates/promptforge-internal/model-client/Cargo.toml +++ b/crates/promptforge-internal/model-client/Cargo.toml @@ -13,9 +13,9 @@ categories = ["data-structures"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] -# The canonical metrics vocabulary (Usage, LlamaTimings, VllmMetrics, -# ClientTiming, CallMetrics) this crate parses response bodies into and -# re-exports. +# The canonical metrics vocabulary this crate parses response bodies into, +# the StreamDelta the reassembly reports, and the model catalog types the +# `model` module re-exports. promptforge-types.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/promptforge-internal/model-client/README.md b/crates/promptforge-internal/model-client/README.md index 7acc15bc6..c87169178 100644 --- a/crates/promptforge-internal/model-client/README.md +++ b/crates/promptforge-internal/model-client/README.md @@ -6,7 +6,9 @@ The PromptForge model vocabulary: the chat-completions wire types a streamed body into one `Completion`, the model catalog (`ModelCatalog`, `ModelDescriptor`, `ModelId`), and the prompt-local binding vocabulary (`ModelBinding`, `ModelSet`, `ModelView`) the executor resolves model -declarations against. No transport: the HTTP client that sends a round to +declarations against. The model catalog types are defined in +`promptforge-types` and re-exported by this crate's `model` module. No +transport: the HTTP client that sends a round to the gateway is the harness's (`harness-models`). A round is always streamed. The transport asks for @@ -22,8 +24,9 @@ the serving `model`, `usage` token accounting (with cached- and reasoning-token details), llama.cpp `timings`, vLLM `metrics`, and the `client_timing` (TTFT, mean inter-token latency, end-to-end) the transport measured on its own clock. The metrics vocabulary (`Usage`, -`LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) is canonical -in `promptforge-types` and re-exported at this crate's root. A +`LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) and +`StreamDelta` are canonical in `promptforge-types`; this crate uses them +from there and does not re-export them. A malformed metadata section degrades to `None` with a diagnostic line that the engine reports as a `model_metadata_degraded` event; it never fails the call. diff --git a/crates/promptforge-internal/model-client/src/client.rs b/crates/promptforge-internal/model-client/src/client.rs index 13b9b76b4..44b501218 100644 --- a/crates/promptforge-internal/model-client/src/client.rs +++ b/crates/promptforge-internal/model-client/src/client.rs @@ -26,9 +26,6 @@ mod request; mod stream; mod wire; -// Canonical in `promptforge-types`; re-exported so the -// `promptforge_model_client::client::StreamDelta` path keeps resolving. -pub use promptforge_types::wire::StreamDelta; pub use read::{ChunkSource, read_body_capped, read_completion_stream}; pub use request::build_request_body; pub use stream::{Applied, SseScanner, StreamAccumulator, escape_controls}; diff --git a/crates/promptforge-internal/model-client/src/client/read.rs b/crates/promptforge-internal/model-client/src/client/read.rs index 0e35a1075..3e29d50a2 100644 --- a/crates/promptforge-internal/model-client/src/client/read.rs +++ b/crates/promptforge-internal/model-client/src/client/read.rs @@ -13,9 +13,10 @@ use std::future::Future; use std::time::{Duration, Instant}; use promptforge_types::metrics::ClientTiming; +use promptforge_types::wire::StreamDelta; use serde_json::Value; -use super::{Applied, Completion, SseScanner, StreamAccumulator, StreamDelta}; +use super::{Applied, Completion, SseScanner, StreamAccumulator}; use crate::Error; use crate::model::CompletionError; diff --git a/crates/promptforge-internal/model-client/src/client/stream.rs b/crates/promptforge-internal/model-client/src/client/stream.rs index bc10014cf..4882cf342 100644 --- a/crates/promptforge-internal/model-client/src/client/stream.rs +++ b/crates/promptforge-internal/model-client/src/client/stream.rs @@ -24,9 +24,10 @@ use std::collections::BTreeMap; use promptforge_types::metrics::ClientTiming; +use promptforge_types::wire::StreamDelta; use serde_json::{Map, Value}; -use super::{Completion, StreamDelta}; +use super::Completion; use crate::model::CompletionError; use crate::{Error, Result}; diff --git a/crates/promptforge-internal/model-client/src/client/wire.rs b/crates/promptforge-internal/model-client/src/client/wire.rs index 2a34907cc..f3588630c 100644 --- a/crates/promptforge-internal/model-client/src/client/wire.rs +++ b/crates/promptforge-internal/model-client/src/client/wire.rs @@ -146,8 +146,9 @@ pub enum ToolSchemaError { /// A tool invocation requested by the model. /// /// `OpenAI` returns tool calls with `function.arguments` as a JSON-encoded -/// string; this type holds that string parsed into a [`Value`] (falling back to -/// a string `Value` if it is not valid JSON). +/// string; the wire decoder stores that string decoded into a JSON object, +/// and fails the turn when the arguments are missing, not a string, not +/// valid JSON, or not an object. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct ToolCall { diff --git a/crates/promptforge-internal/model-client/src/lib.rs b/crates/promptforge-internal/model-client/src/lib.rs index 5696ca417..56fe9a166 100644 --- a/crates/promptforge-internal/model-client/src/lib.rs +++ b/crates/promptforge-internal/model-client/src/lib.rs @@ -4,24 +4,26 @@ //! [`client`] holds the chat-completions protocol vocabulary: the wire //! types that go out of the engine in a `Chat` effect and come back in its //! answer ([`client::Message`], [`client::ToolSchema`], -//! [`client::Completion`], [`client::StreamDelta`]), the request body -//! builder, and the SSE reassembly that folds a streamed body into a -//! [`client::Completion`] under the one strict turn rule set. [`model`] -//! holds the catalog and prompt-local binding vocabulary: +//! [`client::Completion`]), the request body builder, and the SSE +//! reassembly that folds a streamed body into a [`client::Completion`] +//! under the one strict turn rule set, reporting each decoded +//! [`promptforge_types::wire::StreamDelta`] as it goes. [`model`] holds +//! the catalog and prompt-local binding vocabulary: //! [`model::ModelCatalog`] built from the gateway's `GET /v1/models`, the //! validated [`model::ModelId`] identity, and the //! [`model::ModelBinding`]/[`model::ModelSet`]/[`model::ModelView`] types //! a host resolves and freezes model selections through, with //! [`model::CompletionError`] as the failure a round reports. //! -//! The metrics vocabulary ([`Usage`], [`LlamaTimings`], [`VllmMetrics`], -//! [`ClientTiming`], [`CallMetrics`]) is canonical in -//! `promptforge-types` and re-exported here: the reassembly parses each -//! response body's call metadata into it, and [`client::Completion`] holds -//! the result. The model identity/catalog vocabulary ([`model::ModelId`], +//! The metrics vocabulary in [`promptforge_types::metrics`] (`Usage`, +//! `LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) and +//! `StreamDelta` are canonical in `promptforge-types`, and this crate uses +//! them from there: the reassembly parses each response body's call +//! metadata into the metrics types, and [`client::Completion`] holds the +//! result. The model identity/catalog vocabulary ([`model::ModelId`], //! [`model::ModelCatalog`], [`model::ModelDescriptor`], -//! [`model::ThinkingMode`]) and the streaming [`client::StreamDelta`] are -//! canonical there too and re-exported through their historical paths. +//! [`model::ThinkingMode`]) is canonical there too and re-exported through +//! its historical `model` paths. //! //! The HTTP client that sends a round to the gateway and fetches its model //! list is the harness's (`harness-models`), the engine's production host; @@ -37,5 +39,3 @@ mod normalize; pub(crate) use crate::error::Result; pub use crate::error::{Error, Timeout}; - -pub use promptforge_types::metrics::{CallMetrics, ClientTiming, LlamaTimings, Usage, VllmMetrics}; diff --git a/crates/promptforge-internal/model-client/src/model/error.rs b/crates/promptforge-internal/model-client/src/model/error.rs index c5cf408cd..c3fbd4554 100644 --- a/crates/promptforge-internal/model-client/src/model/error.rs +++ b/crates/promptforge-internal/model-client/src/model/error.rs @@ -41,8 +41,9 @@ pub enum CompletionErrorKind { /// /// Holds a stable [`kind`](CompletionError::kind) classifier plus the /// `is_retryable`/`is_timeout`/`status` predicates, and preserves the underlying -/// transport cause through [`std::error::Error::source`]. `#[non_exhaustive]` -/// and constructible outside the crate only from the hidden internal type. +/// transport cause through [`std::error::Error::source`]. `#[non_exhaustive]`, +/// so a transport builds one only by converting the crate's +/// [`Error`](crate::Error) through `From`. /// /// # Examples /// diff --git a/crates/promptforge-internal/model-client/src/model/options.rs b/crates/promptforge-internal/model-client/src/model/options.rs index dcb91ed73..b9eb920d1 100644 --- a/crates/promptforge-internal/model-client/src/model/options.rs +++ b/crates/promptforge-internal/model-client/src/model/options.rs @@ -84,8 +84,6 @@ pub struct ModelInvocation { pub thinking: Option, } -// No `Eq`: `temperature` is an `f64`, so equality is not reflexive for NaN. - /// One prompt-local alias bound to a model identity and frozen invocation. // No `Eq`: the frozen invocation holds an `f64` temperature. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/promptforge-internal/model-client/src/normalize.rs b/crates/promptforge-internal/model-client/src/normalize.rs index af1a642e5..941ded019 100644 --- a/crates/promptforge-internal/model-client/src/normalize.rs +++ b/crates/promptforge-internal/model-client/src/normalize.rs @@ -31,12 +31,7 @@ const EMPTY_REPLY_REASONING_IGNORED: &str = "empty model reply: reasoning content was present but ignored"; /// A parsed assistant turn: outcome plus payload-free metadata. -/// -/// `Eq` is intentionally omitted: [`CompletionResult`] holds tool-call -/// arguments as a [`serde_json::Value`], which is not `Eq` (it can hold an -/// `f64`), so only `Clone` and `PartialEq` are coherent here. #[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] pub(crate) struct NormalizedTurn { /// The text or tool-call product the tool loop consumes. pub(crate) outcome: CompletionResult, diff --git a/crates/promptforge-internal/parser/src/build.rs b/crates/promptforge-internal/parser/src/build.rs index 619bd0a3f..36d00f954 100644 --- a/crates/promptforge-internal/parser/src/build.rs +++ b/crates/promptforge-internal/parser/src/build.rs @@ -1,9 +1,7 @@ -//! Frontmatter parsing and heading-tree construction (PF-PARSER-012). -//! -//! Split out of the `parser` facade so the facade (public types + -//! orchestration) stays small. This owns the [`Frontmatter`] model, the -//! `max_tool_iterations` cap, frontmatter splitting/version detection, and the -//! markdown heading walk that builds the [`Section`] tree. +//! Frontmatter parsing and heading-tree construction: the [`Frontmatter`] +//! model, the `max_tool_iterations` cap, frontmatter splitting/version +//! detection, and the markdown heading walk that builds the [`Section`] +//! tree. use std::ops::Range; diff --git a/crates/promptforge-internal/parser/src/contract.rs b/crates/promptforge-internal/parser/src/contract.rs index be16779b2..7d1a25c69 100644 --- a/crates/promptforge-internal/parser/src/contract.rs +++ b/crates/promptforge-internal/parser/src/contract.rs @@ -2,10 +2,11 @@ //! //! The YAML is the whole contract: capabilities install, tools bind, models //! declare, args type. Parsing validates the static shape - capability id -//! arity, the alias grammar on slot keys, the closed model-keyword -//! vocabulary, arg name and type sanity - and exposes the FULL declaration -//! on the parsed [`Prompt`](crate::Prompt); satisfying the declaration -//! against the host environment is prepare's job, never the parser's. +//! arity, the alias grammar on slot keys, the reserved names no tool alias +//! or model role label may take, the closed model-keyword vocabulary, arg +//! name and type sanity - and exposes the FULL declaration on the parsed +//! [`Prompt`](crate::Prompt); satisfying the declaration against the host +//! environment is prepare's job, never the parser's. //! //! `args` and `models` are defined in submodules; this root owns the //! capability and tool-slot shapes plus the map deserializer all four keys @@ -45,26 +46,35 @@ fn is_valid_alias(alias: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) } +/// How one contract map's keys are checked beyond the alias grammar. +#[derive(Clone, Copy)] +pub(crate) struct ContractKeys { + /// The frontmatter key the map sits under (`tools`), for error + /// messages. + pub(crate) map: &'static str, + /// The key kind (`tool alias`), for error messages. + pub(crate) what: &'static str, + /// A key that satisfies the grammar but is rejected because its + /// posture is deferred (the open toolset's `open`). + pub(crate) deferred: Option<&'static str>, + /// Whether each key installs as a section VM global of its own name, + /// so a reserved name ([`promptforge_lua::RESERVED_NAMES`]) is refused. + pub(crate) installs_global: bool, +} + /// Deserializes a contract map (`tools`, `models`, `args`): string keys -/// validated against the alias grammar, values deserialized as `T`, -/// duplicates rejected. -/// -/// `what` names the key kind in error messages ("tool alias", "model role -/// label", "arg name"); `reserved` names a key that satisfies the grammar -/// but is rejected because its posture is deferred (the open toolset's -/// `open`). +/// validated against the alias grammar and `keys`, values deserialized as +/// `T`, duplicates rejected. pub(crate) fn deserialize_contract_map<'de, D, T>( deserializer: D, - what: &'static str, - reserved: Option<&'static str>, + keys: ContractKeys, ) -> Result, D::Error> where D: Deserializer<'de>, T: Deserialize<'de>, { deserializer.deserialize_map(MapVisitor { - what, - reserved, + keys, _marker: PhantomData, }) } @@ -72,10 +82,8 @@ where /// The visitor behind [`deserialize_contract_map`]: a streaming map walk so /// rejections keep their source position. struct MapVisitor { - /// The key kind, for error messages. - what: &'static str, - /// A grammatically valid key that is rejected as reserved. - reserved: Option<&'static str>, + /// The checks the map's keys get. + keys: ContractKeys, /// The value type, without ownership or variance claims. _marker: PhantomData T>, } @@ -87,31 +95,44 @@ where type Value = BTreeMap; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "a map of {} keys to declarations", self.what) + write!( + formatter, + "a map of {} keys to declarations", + self.keys.what + ) } fn visit_map(self, mut map: A) -> Result where A: MapAccess<'de>, { + let ContractKeys { + map: map_key, + what, + deferred, + installs_global, + } = self.keys; let mut entries: BTreeMap = BTreeMap::new(); while let Some(key) = map.next_key::()? { - if self.reserved == Some(key.as_str()) { + if deferred == Some(key.as_str()) { return Err(de::Error::custom(format!( - "the `{key}` key is reserved for the deferred open toolset posture; it is not a usable {}", - self.what + "the `{key}` key is reserved for the deferred open toolset posture; it is not a usable {what}" ))); } if !is_valid_alias(&key) { return Err(de::Error::custom(format!( - "invalid {} `{key}`: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}", - self.what + "invalid {what} `{key}`: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" + ))); + } + if installs_global && let Some(kind) = promptforge_lua::reserved_name(&key) { + return Err(de::Error::custom(format!( + "{what} `{key}` in `{map_key}` is reserved ({kind}): tool aliases and model \ + role labels install as section VM globals, so none may take a reserved name" ))); } if entries.contains_key(&key) { return Err(de::Error::custom(format!( - "duplicate {} `{key}`: contract map keys must be unique", - self.what + "duplicate {what} `{key}`: contract map keys must be unique" ))); } entries.insert(key, map.next_value::()?); @@ -299,7 +320,10 @@ impl Visitor<'_> for ToolSlotVisitor { /// Aliases are prompt-local (the alias grammar); the model only ever sees /// the alias, never the global path. The reserved `open` key (the deferred /// open toolset posture) is rejected at parse, so a prompt cannot silently -/// half-declare the posture. +/// half-declare the posture. Each alias installs as a section VM global, +/// so an alias that names a host global, a Lua standard-library global the +/// sandbox keeps, or a Lua keyword is rejected too, as is an alias that is +/// also a model role label. #[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct ToolSlots { @@ -339,7 +363,30 @@ impl<'de> Deserialize<'de> for ToolSlots { where D: Deserializer<'de>, { - let slots = deserialize_contract_map(deserializer, "tool alias", Some("open"))?; + let slots = deserialize_contract_map( + deserializer, + ContractKeys { + map: "tools", + what: "tool alias", + deferred: Some("open"), + installs_global: true, + }, + )?; Ok(ToolSlots { slots }) } } + +/// Refuses a name declared both as a tool alias and as a model role label: +/// both install as section VM globals of their own name, so the model +/// handle would silently replace the tool handle. Returns the refusal's +/// message, naming the first shared name in sorted order. +pub(crate) fn check_distinct_aliases(tools: &ToolSlots, models: &ModelRoles) -> Result<(), String> { + match tools.iter().find(|(alias, _)| models.get(alias).is_some()) { + Some((alias, _)) => Err(format!( + "invalid frontmatter: `{alias}` is both a tool alias in `tools` and a model role \ + label in `models`; each installs as a section VM global of its own name, so the \ + two must differ" + )), + None => Ok(()), + } +} diff --git a/crates/promptforge-internal/parser/src/contract/args.rs b/crates/promptforge-internal/parser/src/contract/args.rs index 56fe5bb33..154595d59 100644 --- a/crates/promptforge-internal/parser/src/contract/args.rs +++ b/crates/promptforge-internal/parser/src/contract/args.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use serde::Deserialize; -use super::deserialize_contract_map; +use super::{ContractKeys, deserialize_contract_map}; /// The closed set of declared arg types. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -199,7 +199,17 @@ impl<'de> Deserialize<'de> for ArgsDecl { where D: serde::Deserializer<'de>, { - let fields = deserialize_contract_map(deserializer, "arg name", None)?; + // 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 = deserialize_contract_map( + deserializer, + ContractKeys { + map: "args", + what: "arg name", + deferred: None, + installs_global: false, + }, + )?; Ok(ArgsDecl { fields, implicit: false, diff --git a/crates/promptforge-internal/parser/src/contract/models.rs b/crates/promptforge-internal/parser/src/contract/models.rs index cb3b74b3f..1acdc9e6e 100644 --- a/crates/promptforge-internal/parser/src/contract/models.rs +++ b/crates/promptforge-internal/parser/src/contract/models.rs @@ -9,7 +9,7 @@ use std::num::NonZeroU32; use serde::Deserialize; -use super::deserialize_contract_map; +use super::{ContractKeys, deserialize_contract_map}; /// The closed model-keyword vocabulary. /// @@ -76,7 +76,10 @@ impl ModelRole { /// The prompt's declared model roles: label to role. /// /// Labels are prompt-local (the alias grammar); the model never sees a -/// concrete model id in the declaration. +/// concrete model id in the declaration. Each label installs as a section +/// VM global, so a label that names a host global, a Lua standard-library +/// global the sandbox keeps, or a Lua keyword is rejected at parse, as is +/// a label that is also a tool alias. #[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct ModelRoles { @@ -115,7 +118,15 @@ impl<'de> Deserialize<'de> for ModelRoles { where D: serde::Deserializer<'de>, { - let roles = deserialize_contract_map(deserializer, "model role label", None)?; + let roles = deserialize_contract_map( + deserializer, + ContractKeys { + map: "models", + what: "model role label", + deferred: None, + installs_global: true, + }, + )?; Ok(ModelRoles { roles }) } } diff --git a/crates/promptforge-internal/parser/src/contract/tests.rs b/crates/promptforge-internal/parser/src/contract/tests.rs index eaf5a7c69..a8afe448f 100644 --- a/crates/promptforge-internal/parser/src/contract/tests.rs +++ b/crates/promptforge-internal/parser/src/contract/tests.rs @@ -247,6 +247,135 @@ fn tool_slot_aliases_must_match_the_alias_grammar() { parse(&yaml).expect_err("a 65-character alias must be rejected"); } +/// One reserved name from each category, with the category the refusal +/// names: a guarded host global, a host table, a Lua base function, and a +/// Lua keyword (quoted so YAML keeps `true` a string). +const RESERVED_SAMPLES: [(&str, &str); 5] = [ + ("argv", "a host global"), + ("store", "a host global"), + ("pairs", "a Lua standard-library global"), + ("end", "a Lua keyword"), + ("true", "a Lua keyword"), +]; + +#[test] +fn a_reserved_name_is_refused_as_a_tool_alias_naming_the_map_and_the_category() { + for (name, kind) in RESERVED_SAMPLES { + let yaml = format!("name: x\ndescription: d\ntools:\n '{name}': promptforge/web/search\n"); + let error = parse(&yaml).expect_err("a reserved tool alias must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{name}: {error}"); + assert!( + error.to_string().contains(&format!( + "tool alias `{name}` in `tools` is reserved ({kind}): tool aliases and model \ + role labels install as section VM globals, so none may take a reserved name" + )), + "the refusal names the alias, the map, and why: {error}" + ); + assert_eq!(error.line(), Some(5), "the alias's own line: {error}"); + } +} + +#[test] +fn a_reserved_name_is_refused_as_a_model_role_label_naming_the_map_and_the_category() { + for (name, kind) in RESERVED_SAMPLES { + let yaml = format!("name: x\ndescription: d\nmodels:\n '{name}': {{}}\n"); + let error = parse(&yaml).expect_err("a reserved role label must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{name}: {error}"); + assert!( + error.to_string().contains(&format!( + "model role label `{name}` in `models` is reserved ({kind}): tool aliases and \ + model role labels install as section VM globals, so none may take a reserved \ + name" + )), + "the refusal names the label, the map, and why: {error}" + ); + assert_eq!(error.line(), Some(5), "the label's own line: {error}"); + } +} + +#[test] +fn every_reserved_name_is_refused_in_both_maps() { + for (name, _) in promptforge_lua::RESERVED_NAMES { + // `_G` and `_VERSION` fail the grammar's leading-letter rule first. + let expected = if name.starts_with('_') { + "invalid" + } else { + "is reserved" + }; + for yaml in [ + format!("name: x\ndescription: d\ntools:\n '{name}': promptforge/web/search\n"), + format!("name: x\ndescription: d\nmodels:\n '{name}': {{}}\n"), + ] { + let error = parse(&yaml).expect_err("a reserved name must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{name}: {error}"); + assert!( + error.to_string().contains(&format!("`{name}`")) + && error.to_string().contains(expected), + "{name}: {error}" + ); + } + } +} + +#[test] +fn a_name_that_only_resembles_a_reserved_one_still_parses() { + // Lua names are case-sensitive, and the rule matches whole names only. + for name in ["Store", "stores", "my_argv", "pairs2", "ending", "search"] { + let prompt = parse(&format!( + "name: x\ndescription: d\ntools:\n {name}: promptforge/web/search\n\ + models:\n {name}_model: {{}}\n" + )) + .expect("a non-reserved alias and role label parse"); + assert!(prompt.frontmatter().tools().get(name).is_some(), "{name}"); + assert!( + prompt + .frontmatter() + .models() + .get(&format!("{name}_model")) + .is_some(), + "{name}_model" + ); + } +} + +#[test] +fn an_arg_name_may_be_a_reserved_name_because_args_are_argv_fields() { + let prompt = parse(concat!( + "name: x\ndescription: d\n", + "args:\n", + " prose:\n type: string\n", + " store:\n type: string\n", + " end:\n type: boolean\n", + )) + .expect("reserved names parse as arg names"); + let args = prompt.frontmatter().args(); + assert_eq!(args.len(), 3); + assert!(args.get("store").is_some() && args.get("end").is_some()); +} + +#[test] +fn one_name_as_both_a_tool_alias_and_a_model_role_label_is_refused() { + let error = parse(concat!( + "name: x\ndescription: d\n", + "tools:\n scout: promptforge/web/search\n writer: promptforge/web/fetch\n", + "models:\n writer: {}\n scout: {}\n", + )) + .expect_err("a name in both maps must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{error}"); + assert_eq!( + error.to_string(), + "invalid frontmatter: `scout` is both a tool alias in `tools` and a model role label \ + in `models`; each installs as a section VM global of its own name, so the two must \ + differ", + "the refusal names the first shared name in sorted order" + ); + assert_eq!( + error.name(), + None, + "a frontmatter failure predates the name" + ); +} + #[test] fn args_declarations_round_trip() { let prompt = parse(concat!( diff --git a/crates/promptforge-internal/parser/src/parse.rs b/crates/promptforge-internal/parser/src/parse.rs index d66321b5c..e12fd1e5f 100644 --- a/crates/promptforge-internal/parser/src/parse.rs +++ b/crates/promptforge-internal/parser/src/parse.rs @@ -41,7 +41,8 @@ impl Prompt { /// /// # Errors /// The first half of the pair is a [`ParseError`] classified `Frontmatter` when the frontmatter - /// delimiters are missing or the frontmatter is invalid; `Structure` when + /// delimiters are missing or the frontmatter is invalid, a tool alias or + /// model role label is a reserved name, or one name is both; `Structure` when /// the required H1 is missing or the body has no `##` sections; `Fence` when /// the H1 opens with the removed `lua prompt` fence form, an exact fence /// is not closed, more than one `lua shared` fence exists, or a @@ -89,6 +90,8 @@ impl Prompt { column, } })?; + crate::contract::check_distinct_aliases(frontmatter.tools(), frontmatter.models()) + .map_err(|message| Error::parse(ParseErrorKind::Frontmatter, message))?; // Everything past the frontmatter postdates the prompt's name, so a // failure from here on is stamped with it (and its span's position). let name = frontmatter.name().to_owned(); diff --git a/crates/promptforge-internal/types/AGENTS.md b/crates/promptforge-internal/types/AGENTS.md index 9f73f08fd..139de4483 100644 --- a/crates/promptforge-internal/types/AGENTS.md +++ b/crates/promptforge-internal/types/AGENTS.md @@ -1,6 +1,6 @@ # promptforge-types -This crate holds shared host-support primitives and canonical runtime-event vocabulary. +This crate holds the canonical vocabulary every PromptForge crate shares - the run event, emitter, and metrics vocabulary, the model identity and catalog (`models`), the streaming delta (`wire`), the tool, capability, and naming vocabulary (`tools`, `capabilities`, `names`), run identity and replay (`ids`, `replay`, `timestamp`) - beside the host-support primitives: the untrusted guards and the polled cancellation tree. - Every `Event` the engine returns is report-only; reported data cannot steer an execution decision. - Read-side history is requested through the `TaskEvents` effect and answered by the host; the engine never reads back the events it returned. diff --git a/crates/promptforge-internal/types/Cargo.toml b/crates/promptforge-internal/types/Cargo.toml index 691321e12..de68ba707 100644 --- a/crates/promptforge-internal/types/Cargo.toml +++ b/crates/promptforge-internal/types/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge shared host-support primitives: untrusted guards, the polled cancellation tree, and the run event vocabulary" +description = "PromptForge shared vocabulary: run events and metrics, model, wire, tool, capability, and naming types, run identity and replay, untrusted guards, and the polled cancellation tree" readme = "README.md" keywords = ["prompt", "llm", "cancellation", "observability"] categories = ["rust-patterns"] diff --git a/crates/promptforge-internal/types/README.md b/crates/promptforge-internal/types/README.md index b0c459423..eebe4f045 100644 --- a/crates/promptforge-internal/types/README.md +++ b/crates/promptforge-internal/types/README.md @@ -1,9 +1,12 @@ # promptforge-types -Small shared host-support primitives for the PromptForge engine: -`untrusted` wraps untrusted external data in a nonce-guarded envelope, -`cancel` is the polled cancellation tree the engine observes, `event` is -the report-only `Event` vocabulary a run returns to its host, `emitter` is -the provenance-stamping `Emitter` every engine crate reports through, and -`metrics` is the model-call metrics vocabulary those events embed. The -crate declares no async runtime. +The shared vocabulary and host-support primitives at the bottom of the PromptForge crate graph: + +- `untrusted` wraps untrusted external data in a nonce-guarded envelope, and `cancel` is the polled cancellation tree the engine observes. +- `event` is the report-only `Event` vocabulary a run returns to its host, `emitter` is the provenance-stamping `Emitter` every engine crate reports through, and `metrics` is the model-call metrics vocabulary those events embed. +- `models` is the model identity and catalog vocabulary (`ModelId`, `ModelCatalog`, `ModelDescriptor`, `ThinkingMode`), and `wire` is the `StreamDelta` a host's streaming hook observes. +- `tools` is the implementation-free tool vocabulary (descriptor, catalog, trusted output, model-safe tool error), `capabilities` is the `CapabilityId` a prompt declares, and `names` is the one naming grammar both follow. +- `ids` is the deterministic identity of a run's chains and tasks and the `Provenance` replay key, `replay` holds the behavior `Flags` a run records and the replay error kinds, and `timestamp` is the UTC instant a run starts from. +- `detail` holds the unchecked identity constructors only the engine uses; the facade never re-exports it. + +The crate has no normal dependency on another PromptForge crate and declares no async runtime. Its one dev-dependency, the `promptforge` facade, exists only so the doc examples compile against the paths hosts see. diff --git a/crates/promptforge-internal/types/src/event.rs b/crates/promptforge-internal/types/src/event.rs index d7461df83..43af06707 100644 --- a/crates/promptforge-internal/types/src/event.rs +++ b/crates/promptforge-internal/types/src/event.rs @@ -412,9 +412,10 @@ events! { /// The sentence the model reads. text: String, }, - /// A task set its own progress note through `tasks.note`, the text - /// its owner reads through `task_status`. Reported under the task's - /// target section. + /// Reserved for a task setting its own progress note through + /// `tasks.note`, the text its owner reads through `task_status`, + /// under the task's target section. Not yet produced: the engine + /// stores the note on the chain without reporting it. TaskNote { /// The task that set the note. task: TaskId, diff --git a/crates/promptforge-internal/types/src/models.rs b/crates/promptforge-internal/types/src/models.rs index 9c2937036..12d51589b 100644 --- a/crates/promptforge-internal/types/src/models.rs +++ b/crates/promptforge-internal/types/src/models.rs @@ -239,7 +239,6 @@ impl ModelDescriptor { /// /// `#[non_exhaustive]` so the collision-free catalog invariant is only ever /// established through [`ModelCatalog::new`]/[`ModelCatalog::empty`]. -// No `Eq`: bindings hold `f64` temperatures transitively. #[derive(Debug, Clone, Default, PartialEq)] #[non_exhaustive] pub struct ModelCatalog { diff --git a/crates/promptforge-internal/vfs/README.md b/crates/promptforge-internal/vfs/README.md new file mode 100644 index 000000000..335a9298d --- /dev/null +++ b/crates/promptforge-internal/vfs/README.md @@ -0,0 +1,7 @@ +# promptforge-vfs + +The PromptForge virtual filesystem. It holds canonical interned paths (`VfsPath`, `VfsPathBuf`), the happens-before claims ledger behind the cloneable `VfsRef` handle and the `Access` capability it vends, the mount router built through `VfsRefBuilder`, the memory and host backends (`MemoryBackend`, `HostBackend`), and the promptforge mode policy (`ModePolicy`), the editor gate on mutations. `VfsRefBuilder::store` declares one mount as the store root, and the store view over it applies the store's strict logical-path rules; a default `VfsRef` is a memory store at `/`. + +The crate is std only, with no workspace or external dependencies, and its own manifest test fails if any dependency table gains an entry. It sits at the permanent bottom of the PromptForge dependency stack. + +The host backend resolves a path two ways. Operations on a path itself (`remove`, `exists`, `stat`, `mkdir`, `rename`) contain the parent under the root and act on a final-component link as a link, never its target. Operations on contents (`read`, `read_range`, `write`, `append`, `list`, `glob`, `copy`) follow links under the containment check, which denies a link that resolves outside the root. diff --git a/crates/promptforge-internal/vfs/src/detail.rs b/crates/promptforge-internal/vfs/src/detail.rs index 04b3d576d..ff8e47f4b 100644 --- a/crates/promptforge-internal/vfs/src/detail.rs +++ b/crates/promptforge-internal/vfs/src/detail.rs @@ -3,8 +3,10 @@ //! The `promptforge` facade never re-exports this module, so nothing here //! is reachable from a host: a host passes a run's capability through, and //! the engine alone forks it for concurrent arms, joins the arms' -//! identities back on delivery, derives the store view for store -//! calls, and ends the run's scope when the run ends. +//! identities back on delivery, derives the store view from a chain's +//! access for store calls, and ends the run's scope when the run ends. A +//! host that holds the handle acquires a store view in a scope of its own +//! with [`VfsRef::acquire_store`](crate::VfsRef::acquire_store). use std::fmt; use std::sync::{Arc, Weak}; @@ -561,4 +563,58 @@ mod tests { assert!(!access.exists("/inner/s/a.txt")?); Ok(()) } + + #[test] + fn acquire_store_roots_logical_paths_at_the_declared_store() -> Result<(), VfsError> { + let vfs = stock(); + { + let view = vfs.acquire_store(Origin::new("acquire_store test"))?; + view.write("paper.md", b"seeded")?; + assert_eq!(view.read("paper.md")?, b"seeded"); + } + // The write landed under the store root, beside the base at `/`. + let access = vfs.acquire(Origin::new("acquire_store test"))?; + assert_eq!(access.read("/my/store/paper.md")?, b"seeded"); + assert!(!access.exists("/paper.md")?); + Ok(()) + } + + #[test] + fn acquire_store_applies_the_strict_path_rules() -> Result<(), VfsError> { + let vfs = stock(); + let view = vfs.acquire_store(Origin::new("acquire_store test"))?; + match view.write("../escape.md", b"out") { + Err(VfsError::InvalidPath { + path, + reason: PathReason::Traversal, + }) => assert_eq!(path, "../escape.md"), + other => panic!("expected a traversal refusal, got {other:?}"), + } + Ok(()) + } + + #[test] + fn acquire_store_on_a_handle_without_a_store_is_unsupported() { + let vfs = VfsRef::builder().mount("/", MemoryBackend::new()).build(); + match vfs.acquire_store(Origin::new("acquire_store test")) { + Err(VfsError::Unsupported { detail, .. }) => { + assert!(detail.contains("no store"), "{detail}"); + } + other => panic!("expected no declared store, got {other:?}"), + } + } + + #[test] + fn acquire_store_is_a_scope_of_its_own() -> Result<(), VfsError> { + let vfs = stock(); + let (_access, run_view) = chain(&vfs); + run_view.write("claimed.md", b"run")?; + // A host view is a second scope: the live run's claim conflicts. + let host = vfs.acquire_store(Origin::new("acquire_store test"))?; + assert!(matches!( + host.write("claimed.md", b"host"), + Err(VfsError::Conflict { .. }) + )); + Ok(()) + } } diff --git a/crates/promptforge-internal/vfs/src/error.rs b/crates/promptforge-internal/vfs/src/error.rs index d65198d56..b88f98d45 100644 --- a/crates/promptforge-internal/vfs/src/error.rs +++ b/crates/promptforge-internal/vfs/src/error.rs @@ -6,9 +6,9 @@ use std::fmt; /// Why a path or glob pattern was rejected before any backend saw it. /// -/// Every [`VfsError::InvalidPath`] carries one. The store facade -/// re-exports this type and reuses the first nine reasons for its own -/// path validation; the last two are reported by VFS sites alone: +/// Every [`VfsError::InvalidPath`] carries one. The store view's +/// logical-path validation reports the first nine reasons; the last two +/// are reported by the glob and rename sites alone: /// [`PathReason::Wildcard`] for a glob pattern whose wildcard grammar is /// invalid, and [`PathReason::IntoDescendant`] for a rename into the /// source's own descendant. diff --git a/crates/promptforge-internal/vfs/src/handle.rs b/crates/promptforge-internal/vfs/src/handle.rs index a85240f90..a6cfa84f2 100644 --- a/crates/promptforge-internal/vfs/src/handle.rs +++ b/crates/promptforge-internal/vfs/src/handle.rs @@ -1236,9 +1236,7 @@ fn strip_root(root: &str, matched: &str) -> Option { } /// The largest logical store path, in bytes, accepted by the store -/// view: the ceiling the retired store facade enforced -/// (`MAX_STORE_PATH_BYTES`), kept so a store path stays a bounded -/// denial-of-service lever. +/// view, so a store path stays a bounded denial-of-service lever. const MAX_STORE_PATH_BYTES: usize = 1024; /// The store's strict logical-path rules, applied by the store view @@ -1500,6 +1498,24 @@ impl VfsRef { ) } + /// Acquires the store view for a new serial thread of execution: an + /// [`Access`] rooted at the declared store root whose operations + /// reach the store's own mount alone. It is [`VfsRef::acquire`] + /// followed by the store view, so it starts a new scope of its own + /// and never joins a run's. Logical paths join onto the store root + /// under the store's strict path rules, and errors come back in the + /// caller's logical form, so a host seeds and extracts store files + /// by the names the prompt uses without knowing where the store is + /// mounted. + /// + /// # Errors + /// Returns [`VfsError::Unsupported`] when the handle declares no + /// store, and the backend's error when it refuses to acquire the + /// identity. + pub fn acquire_store(&self, origin: Origin) -> Result { + self.acquire(origin)?.store_view() + } + /// Acquires the capability for the identity and scope in `cx`: a /// fresh acquire passes a new scope, and a mounted handle receives /// the caller's context, so the forwarded capability joins the diff --git a/crates/promptforge-internal/vfs/src/host.rs b/crates/promptforge-internal/vfs/src/host.rs index 5510e9365..cbe21626d 100644 --- a/crates/promptforge-internal/vfs/src/host.rs +++ b/crates/promptforge-internal/vfs/src/host.rs @@ -8,6 +8,13 @@ //! failure-atomic: a sibling temp file plus rename, so a failed //! operation leaves source, destination, and accounting unchanged. //! +//! Paths resolve two ways. Operations on a path itself (`remove`, +//! `exists`, `stat`, `mkdir`, `rename`) contain the parent and act on a +//! final-component link as a link, never its target. Operations on +//! contents (`read`, `read_range`, `write`, `append`, `list`, `glob`, +//! `copy`) follow links under the containment check, which denies a +//! link that resolves outside the root. +//! //! Stage 2 hardening (the Bashkit RealFs resolver trio, symlink //! policies, Windows long paths and device names) is deferred. The //! known stage 1 limitation: containment canonicalizes the nearest @@ -151,6 +158,30 @@ fn contain(root: &Path, candidate: &Path, original: &VfsPath) -> Result Result { + if candidate == root { + return Ok(root.to_path_buf()); + } + let (Some(parent), Some(name)) = (candidate.parent(), candidate.file_name()) else { + return Err(VfsError::PermissionDenied { + path: original.to_string(), + reason: format!("{original} escapes the mounted root"), + }); + }; + let mut resolved = contain(root, parent, original)?; + resolved.push(name); + Ok(resolved) +} + /// Uniquifies failure-atomic temp file names within the process. static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -274,6 +305,21 @@ fn file_type_of(file_type: fs::FileType) -> FileType { } } +/// Whether the entry is a directory link, a junction included, which +/// Windows removes with `remove_dir`: `remove_file` fails on one. +#[cfg(windows)] +fn is_dir_link(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::FileTypeExt; + metadata.file_type().is_symlink_dir() +} + +/// Whether the entry is a directory link needing `remove_dir`: never +/// off Windows, where `remove_file` removes any link. +#[cfg(not(windows))] +fn is_dir_link(_: &fs::Metadata) -> bool { + false +} + /// POSIX mode bits where the host tracks them. #[cfg(unix)] #[expect( @@ -399,6 +445,19 @@ impl HostAccess { } } + /// Resolves a canonical virtual path to its host path without + /// following a final-component link, applying containment to the + /// parent in rooted mode. + fn resolve_no_follow(&self, path: &VfsPath) -> Result { + match &self.root { + HostRoot::Identity => Ok(identity_to_host(path.as_str())), + HostRoot::Rooted(root) => { + let candidate = join_virtual(root, path.as_str()); + contain_no_follow(root, &candidate, path) + } + } + } + /// Translates a host path back to its virtual spelling. fn to_virtual(&self, host: &Path) -> String { match &self.root { @@ -499,7 +558,7 @@ impl VfsAccess for HostAccess { reason: "the mounted root cannot be removed".into(), }); } - let host = self.resolve(path)?; + let host = self.resolve_no_follow(path)?; let metadata = fs::symlink_metadata(&host).map_err(|err| map_io(path.as_str(), &err))?; // symlink_metadata does not follow links: a symlink is removed // as a link, never its target. @@ -509,6 +568,8 @@ impl VfsAccess for HostAccess { } else { fs::remove_dir(&host) } + } else if is_dir_link(&metadata) { + fs::remove_dir(&host) } else { fs::remove_file(&host) } @@ -516,7 +577,7 @@ impl VfsAccess for HostAccess { } fn exists(&self, path: &VfsPath) -> Result { - let host = self.resolve(path)?; + let host = self.resolve_no_follow(path)?; // symlink_metadata counts a dangling link as existing. Only a // confirmed absence is Ok(false); every other failure (a // denied permission, a genuine I/O error) surfaces as Err, as @@ -572,14 +633,14 @@ impl VfsAccess for HostAccess { } fn stat(&self, path: &VfsPath) -> Result { - let host = self.resolve(path)?; + let host = self.resolve_no_follow(path)?; let metadata = fs::symlink_metadata(&host).map_err(|err| map_io(path.as_str(), &err))?; Ok(stat_of(&metadata)) } fn mkdir(&mut self, path: &VfsPath, recursive: bool) -> Result<(), VfsError> { self.check_writable(path)?; - let host = self.resolve(path)?; + let host = self.resolve_no_follow(path)?; if fs::symlink_metadata(&host).is_ok() { return Err(VfsError::AlreadyExists { path: path.to_string(), @@ -613,8 +674,8 @@ impl VfsAccess for HostAccess { reason: PathReason::IntoDescendant, }); } - let host_from = self.resolve(from)?; - let host_to = self.resolve(to)?; + let host_from = self.resolve_no_follow(from)?; + let host_to = self.resolve_no_follow(to)?; // Validation finishes before the rename syscall, so a failed // rename changes nothing; the rename itself is atomic. fs::symlink_metadata(&host_from).map_err(|err| map_io(from.as_str(), &err))?; @@ -731,6 +792,38 @@ mod tests { std::os::unix::fs::symlink(target, link).is_ok() } + /// Creates a file link, returning false only when Windows refuses + /// for want of the symlink privilege (raw OS error 1314, + /// `ERROR_PRIVILEGE_NOT_HELD`). Every other failure is an error. + #[cfg(windows)] + fn make_file_link(link: &Path, target: &Path) -> Result { + const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314; + match std::os::windows::fs::symlink_file(target, link) { + Ok(()) => Ok(true), + Err(err) if err.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) => { + eprintln!( + "skipped: Windows refused a file symlink without the symlink privilege \ + (enable Developer Mode or run elevated)" + ); + Ok(false) + } + Err(err) => Err(map_io("creating the file link", &err)), + } + } + + /// Creates a file link. + #[cfg(unix)] + fn make_file_link(link: &Path, target: &Path) -> Result { + std::os::unix::fs::symlink(target, link) + .map_err(|err| map_io("creating the file link", &err))?; + Ok(true) + } + + /// Whether `host` itself is a link, without following it. + fn is_link(host: &Path) -> bool { + fs::symlink_metadata(host).is_ok_and(|metadata| metadata.file_type().is_symlink()) + } + #[test] fn a_rooted_backend_round_trips_files_and_directories() -> Result<(), VfsError> { let temp = TempDir::new()?; @@ -814,6 +907,145 @@ mod tests { Ok(()) } + #[test] + fn removing_a_link_to_an_in_root_file_removes_the_link_and_keeps_the_target() + -> Result<(), VfsError> { + let root = TempDir::new()?; + fs::write(root.path().join("target.txt"), b"kept") + .map_err(|err| map_io("seeding the target file", &err))?; + if !make_file_link(&root.path().join("link"), &root.path().join("target.txt"))? { + return Ok(()); + } + let mut access = rooted_access(root.path())?; + access.remove(&path("/link")?, false)?; + assert!(!is_link(&root.path().join("link")), "the link must be gone"); + assert_eq!(access.read(&path("/target.txt")?)?, b"kept"); + Ok(()) + } + + #[test] + fn path_operations_act_on_a_link_to_an_outside_file_as_a_link() -> Result<(), VfsError> { + let outside = TempDir::new()?; + let secret = outside.path().join("secret.txt"); + fs::write(&secret, b"classified") + .map_err(|err| map_io("seeding the outside file", &err))?; + let root = TempDir::new()?; + if !make_file_link(&root.path().join("link"), &secret)? { + return Ok(()); + } + let mut access = rooted_access(root.path())?; + assert!(access.exists(&path("/link")?)?); + assert_eq!(access.stat(&path("/link")?)?.file_type, FileType::Symlink); + assert!(matches!( + access.mkdir(&path("/link")?, false), + Err(VfsError::AlreadyExists { .. }) + )); + assert!( + matches!( + access.read(&path("/link")?), + Err(VfsError::PermissionDenied { .. }) + ), + "a read through the escaping link must be denied" + ); + assert!( + matches!( + access.write(&path("/link")?, b"x"), + Err(VfsError::PermissionDenied { .. }) + ), + "a write through the escaping link must be denied" + ); + access.rename(&path("/link")?, &path("/moved")?)?; + assert!(!access.exists(&path("/link")?)?); + assert!( + is_link(&root.path().join("moved")), + "the link itself must move" + ); + assert!( + secret.is_file(), + "the outside target must stay where it was" + ); + access.remove(&path("/moved")?, false)?; + assert!(!access.exists(&path("/moved")?)?); + assert_eq!( + fs::read(&secret).map_err(|err| map_io("reading the outside file", &err))?, + b"classified" + ); + Ok(()) + } + + #[test] + fn removing_a_dangling_link_succeeds() -> Result<(), VfsError> { + let root = TempDir::new()?; + if !make_file_link( + &root.path().join("dangling"), + &root.path().join("missing.txt"), + )? { + return Ok(()); + } + let mut access = rooted_access(root.path())?; + access.remove(&path("/dangling")?, false)?; + assert!( + !is_link(&root.path().join("dangling")), + "the link must be gone" + ); + Ok(()) + } + + #[test] + fn removing_a_directory_link_keeps_the_target_directory_and_its_contents() + -> Result<(), VfsError> { + let root = TempDir::new()?; + let target = root.path().join("real"); + fs::create_dir(&target).map_err(|err| map_io("creating the target directory", &err))?; + fs::write(target.join("keep.txt"), b"kept") + .map_err(|err| map_io("seeding the target file", &err))?; + let mut access = rooted_access(root.path())?; + for recursive in [false, true] { + let link = root.path().join("dirlink"); + assert!( + make_dir_link(&link, &target), + "the directory link must be created" + ); + access.remove(&path("/dirlink")?, recursive)?; + assert!( + !is_link(&link), + "the link must be gone (recursive: {recursive})" + ); + assert_eq!(access.read(&path("/real/keep.txt")?)?, b"kept"); + } + Ok(()) + } + + #[test] + fn path_operations_act_on_a_directory_link_to_an_outside_directory_as_a_link() + -> Result<(), VfsError> { + let outside = TempDir::new()?; + let secret = outside.path().join("secret.txt"); + fs::write(&secret, b"classified") + .map_err(|err| map_io("seeding the outside file", &err))?; + let root = TempDir::new()?; + assert!( + make_dir_link(&root.path().join("link"), outside.path()), + "the directory link must be created" + ); + let mut access = rooted_access(root.path())?; + assert!(access.exists(&path("/link")?)?); + assert_eq!(access.stat(&path("/link")?)?.file_type, FileType::Symlink); + access.rename(&path("/link")?, &path("/moved")?)?; + assert!(!access.exists(&path("/link")?)?); + assert!( + is_link(&root.path().join("moved")), + "the link itself must move" + ); + access.remove(&path("/moved")?, true)?; + assert!(!access.exists(&path("/moved")?)?); + assert_eq!( + fs::read(&secret).map_err(|err| map_io("reading the outside file", &err))?, + b"classified" + ); + Ok(()) + } + #[test] fn a_failed_write_leaves_the_destination_unchanged_and_no_temp_file_behind() -> Result<(), VfsError> { diff --git a/crates/promptforge/public-api.txt b/crates/promptforge/public-api.txt index 61ff94117..a5e0c9d26 100644 --- a/crates/promptforge/public-api.txt +++ b/crates/promptforge/public-api.txt @@ -696,6 +696,7 @@ pub fn promptforge::vfs::VfsPath::as_str(&self) -> &str pub fn promptforge::vfs::VfsPath::to_buf(&self) -> promptforge::vfs::VfsPathBuf pub fn promptforge::vfs::VfsPathBuf::as_str(&self) -> &str pub fn promptforge::vfs::VfsRef::acquire(&self, origin: promptforge::vfs::Origin) -> core::result::Result +pub fn promptforge::vfs::VfsRef::acquire_store(&self, origin: promptforge::vfs::Origin) -> core::result::Result pub fn promptforge::vfs::VfsRef::builder() -> promptforge::vfs::VfsRefBuilder pub fn promptforge::vfs::VfsRef::new(backend: impl promptforge::vfs::Vfs + 'static) -> promptforge::vfs::VfsRef pub fn promptforge::vfs::VfsRef::overlay(&self, prefix: &str, backend: impl promptforge::vfs::Vfs + 'static) -> promptforge::vfs::VfsRef diff --git a/crates/promptforge/src/capabilities.md b/crates/promptforge/src/capabilities.md index b3ef73d86..47f279a78 100644 --- a/crates/promptforge/src/capabilities.md +++ b/crates/promptforge/src/capabilities.md @@ -278,7 +278,7 @@ Here is what the engine does with the list. 1. **Copy it onto the run.** [`Environment::preludes`](crate::Environment::preludes) stores the list, and [`Environment::prepare`](crate::Environment::prepare) copies it onto the context, the same way it copies the tool catalog. 2. **Install it in every section.** Each section's Lua machine, the fanout arms and spawned task chains included, installs every prelude in list order. That happens after the host globals such as `tools` and `store` exist, and before the prompt's shared library runs, so the shared library can call what the preludes define. 3. **Keep each prelude to itself.** A prelude runs in an environment of its own. It sees the base functions that survive the sandbox, the `string`, `table`, and `math` libraries, `tools`, `store`, `untrusted`, and a read-only view of `var`. It does not see the other preludes. Each global it assigns becomes a global of every section. A table global is sealed at its top level: author code can read the table's own fields but cannot add or replace them, a table stored in one of those fields can still be changed, and `pairs` over the sealed table sees nothing. -4. **Fail early.** A prelude only defines functions. It runs as a plain chunk, not inside a section's coroutine, so a prelude that calls a tool while loading fails. A prelude also fails when one of its globals takes a name already in use: a host global, a name reserved for globals that only some sections have (`ui`, `item`, `argv`, and `prose`), a tool or model alias from the prompt's frontmatter, or a global of an earlier prelude. Either failure ends the run with [`RunErrorKind::Lua`](crate::RunErrorKind::Lua) when the first section's Lua machine is set up, before the run issues any effect, and the message names the capability. +4. **Fail early.** A prelude only defines functions. It runs as a plain chunk, not inside a section's coroutine, so a prelude that calls a tool while loading fails. A prelude also fails when one of its globals takes a name already in use: a reserved name (every host global, `ui`, `item`, `argv`, and `prose` included even where a section lacks them, every Lua standard-library global the sandbox keeps, and every Lua keyword, the same list no frontmatter alias may take), any other global the section already holds, a tool or model alias from the prompt's frontmatter, or a global of an earlier prelude. Either failure ends the run with [`RunErrorKind::Lua`](crate::RunErrorKind::Lua) when the first section's Lua machine is set up, before the run issues any effect, and the message names the capability. # Reference diff --git a/crates/promptforge/src/prompt.md b/crates/promptforge/src/prompt.md index cda286c3d..a3894db5a 100644 --- a/crates/promptforge/src/prompt.md +++ b/crates/promptforge/src/prompt.md @@ -116,7 +116,7 @@ The [Reference](#reference) gives the exact YAML form of every key. The frontmatter parser rejects anything it does not recognize. An unknown or misspelled top-level key fails the parse, so a typo such as `desciption:` fails loudly instead of being skipped. The same rule holds inside every entry of `capabilities:`, `tools:`, `args:`, and `models:`, where misspelled sub-keys such as `optionl`, `wants`, `tipe`, and `keyword` fail too. Malformed YAML fails the parse as well. A leading UTF-8 byte order mark is dropped before parsing. -Every frontmatter failure has the kind [`ParseErrorKind::Frontmatter`](crate::ParseErrorKind::Frontmatter). A bad value is reported at its exact line and column. [`ParseError::line`](crate::ParseError::line) and [`ParseError::column`](crate::ParseError::column) count from the top of the file, so the opening `---` line is line 1. [`ParseError::name`](crate::ParseError::name) returns [`None`], because a frontmatter failure happens before the prompt's name is known. Report the position to the prompt author under your own label for the source. +Every frontmatter failure has the kind [`ParseErrorKind::Frontmatter`](crate::ParseErrorKind::Frontmatter). A bad value is reported at its exact line and column, except a name declared under both `tools:` and `models:`, which spans two keys and has neither. [`ParseError::line`](crate::ParseError::line) and [`ParseError::column`](crate::ParseError::column) count from the top of the file, so the opening `---` line is line 1. [`ParseError::name`](crate::ParseError::name) returns [`None`], because a frontmatter failure happens before the prompt's name is known. Report the position to the prompt author under your own label for the source. ```` use promptforge::{ParseErrorKind, Prompt}; @@ -159,6 +159,35 @@ The bad id `web` sits on file line 5, and the value starts in column 5, after th Three kinds of name are local to a prompt: tool aliases under `tools:`, model role labels under `models:`, and arg names under `args:`. The model only ever sees these local names, never a global path. All three share one grammar, `[A-Za-z][A-Za-z0-9_-]{0,63}`, which is a letter followed by up to 63 letters, digits, underscores, or hyphens. So `1search`, `has space`, `has/slash`, and `has.dot` are rejected. A 64-character name passes, and a 65-character name fails. A name that appears twice in one map fails with "duplicate {what} `{key}`: contract map keys must be unique", where `{what}` is `tool alias`, `model role label`, or `arg name`. +Tool aliases and model role labels are Lua names too. Each one becomes a global of its own name in every section of a run, so neither may take a name the section's Lua already uses: a host global such as `store`, `argv`, `tools`, or `models`, a Lua standard-library global the sandbox keeps such as `pairs` or `string`, or a Lua keyword such as `end`. The separate language guide lists every reserved name. A reserved key fails with "{what} `{key}` in `{map}` is reserved ({category}): tool aliases and model role labels install as section VM globals, so none may take a reserved name", where `{map}` is `tools` or `models` and `{category}` is `a host global`, `a Lua standard-library global`, or `a Lua keyword`, at the key's own line and column. Arg names are exempt, because they name fields of the parsed arguments rather than globals. A name declared under both `tools:` and `models:` fails as well, because both would install the same global; that failure names the shared name and both maps and has no line or column. + +```` +use promptforge::{ParseErrorKind, Prompt}; + +let reserved = concat!( + "---\n", + "name: saver\n", + "description: saves a page\n", + "tools:\n", + " store: promptforge/web/fetch\n", + "---\n", + "\n", + "# Saver\n", +); +let (failed, _parse_events) = Prompt::parse(reserved, "saver"); +let error = failed.err().ok_or("the reserved alias fails the parse")?; +assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +assert_eq!(error.line(), Some(5)); +assert!(error + .to_string() + .contains("tool alias `store` in `tools` is reserved (a host global)")); + +let renamed = reserved.replace(" store:", " saver:"); +let (parsed, _parse_events) = Prompt::parse(&renamed, "saver"); +assert!(parsed?.frontmatter().tools().get("saver").is_some()); +# Ok::<(), Box>(()) +```` + The lookup methods [`ToolSlots::get`], [`ArgsDecl::get`], and [`ModelRoles::get`] take the name exactly as written, and the match is case-sensitive. A string outside the grammar can never be present, so looking one up returns [`None`]. # Passing the argument string diff --git a/crates/promptforge/src/vfs.md b/crates/promptforge/src/vfs.md index f8fb7feab..dc013cea4 100644 --- a/crates/promptforge/src/vfs.md +++ b/crates/promptforge/src/vfs.md @@ -330,7 +330,7 @@ assert_eq!(log[0].2, "observer example"); A run's store is the mount its handle declares, and the prompt's `store` table is scoped to it through a *store view*: an [`Access`] rooted at the declared store root, confined to the store's own mount, under the chain's identity. The engine derives one from the chain's capability for every store call, so a [`StoreOp`] can reach only files inside the store, and one chain never conflicts with itself through its view. One handle serves every section of a run, so store files persist from section to section even though each section's Lua state does not. [`RunContext::new`](crate::RunContext::new) starts with the default handle, a fresh memory store at `/`. A host with host roots builds the run's handle itself with [`VfsRef::builder`](crate::vfs::VfsRef::builder), mounting its base at `/` and declaring the store, and hands it to [`RunContext::vfs`](crate::RunContext::vfs). Several concurrent runs can share one host-backed base this way. Each run is its own scope, and two live scopes never order each other, so when a second run writes a path such as `/shared.txt` while the first run's claim on it is live, the write fails with [`VfsError::Conflict`], and the file keeps the first run's contents. A run whose handle declares no store fails with [`RunErrorKind::Store`](crate::RunErrorKind::Store). -A [`StoreOp`] names its paths logically, relative to the store root. So `notes.md` means `/notes.md` when the store is at `/`, and a [`StoreOp`] can reach only files inside the run's store. The host seeds and extracts through the store view's logical paths, as the example below does. The store view validates each logical path before any backend sees it, and reports a broken rule as [`VfsError::InvalidPath`] with a [`PathReason`]. The checks run in this order, and the first rule broken is reported: +A [`StoreOp`] names its paths logically, relative to the store root. So `notes.md` means `/notes.md` when the store is at `/`, and a [`StoreOp`] can reach only files inside the run's store. The host seeds and extracts through the store view's logical paths, as the example below does. [`VfsRef::acquire_store`] acquires that view in a scope of its own, so a host that seeds the store before a run or reads it after uses the names the prompt uses, wherever the handle mounts the store. The store view validates each logical path before any backend sees it, and reports a broken rule as [`VfsError::InvalidPath`] with a [`PathReason`]. The checks run in this order, and the first rule broken is reported: 1. The path is empty: [`PathReason::Empty`]. 2. The path is over 1024 bytes: [`PathReason::TooLong`]. @@ -341,7 +341,7 @@ A [`StoreOp`] names its paths logically, relative to the store root. So `notes.m A store failure reaches the author as an error value of kind `store`, carrying `reason`, the variant's fields (`path`, plus `anchor` and the integer `count` for an anchor error, or `rule` for an invalid path), and a model-facing `message` that says what failed and how to fix it. A missing file reads "file not found in store: notes.md". Rust code matches the [`VfsError`] variant directly and reads its fields; a host store performer that fails for its own reasons returns [`VfsError::Backend`]. -This example seeds a file through the context's handle, then calls [`perform_store_op`] against the store view, as a host loop does for each store effect: +This example seeds a file through a store view of the context's handle, then calls [`perform_store_op`] against a store view, as a host loop does for each store effect: ```` use promptforge::RunContext; @@ -349,11 +349,11 @@ use promptforge::timestamp::Timestamp; use promptforge::vfs::{perform_store_op, Origin, StoreOp, StoreOutcome, VfsError}; let ctx = RunContext::new("store example", 7, Timestamp::UNIX_EPOCH); -let seed = ctx.vfs_handle().acquire(Origin::new("seed"))?; -seed.write("/brief.md", b"one\ntwo\n")?; +let seed = ctx.vfs_handle().acquire_store(Origin::new("seed"))?; +seed.write("brief.md", b"one\ntwo\n")?; drop(seed); -let access = ctx.vfs_handle().acquire(Origin::new("store example"))?; +let access = ctx.vfs_handle().acquire_store(Origin::new("store example"))?; let read = StoreOp::Read { path: "brief.md".to_owned(), start: Some(2), end: None }; let StoreOutcome::Text(text) = perform_store_op(&access, read)? else { panic!("a read answers with text"); diff --git a/crates/promptforge/tests/suite/prepare.rs b/crates/promptforge/tests/suite/prepare.rs index b17783f6b..507021cf3 100644 --- a/crates/promptforge/tests/suite/prepare.rs +++ b/crates/promptforge/tests/suite/prepare.rs @@ -51,7 +51,7 @@ impl TempDir { .expect("the clock is after the epoch") .as_nanos(); let dir = std::env::temp_dir().join(format!( - "promptforge-api-prepare-{}-{unique}-{name}", + "promptforge-prepare-{}-{unique}-{name}", std::process::id(), )); std::fs::create_dir_all(&dir).expect("the temp dir creates"); diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index 3590a0e7c..8f81bc539 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -149,6 +149,7 @@ impl AgentSessions { .launch(LaunchRequest { agent: name.to_owned(), args: String::new(), + input_text: None, }) .await?; status::spawn_reporter( diff --git a/guide/promptforge-language-guide.md b/guide/promptforge-language-guide.md index ca9540012..931e1112a 100644 --- a/guide/promptforge-language-guide.md +++ b/guide/promptforge-language-guide.md @@ -881,7 +881,7 @@ The names here are `search` and `fetch`, `writer` and `analyst`, and `use_mcp`. The name grammar is `[A-Za-z][A-Za-z0-9_-]{0,63}`: 1 to 64 ASCII characters, a letter first, then letters, digits, `_`, or `-`. Letters are ASCII only. A 64-character name parses. Names such as `search`, `fetch`, `writer`, `analyst`, `use_mcp`, `limit`, and `query` all fit. An alias is the only name a model ever sees for a tool slot or a model role. -Each of `tools:`, `models:`, and `args:` is a YAML map keyed by alias, role label, or arg name, and each key appears once within its map. Every name is checked when the prompt loads, the grammar first and uniqueness second. All of these failures have parse error kind `Frontmatter`, and each message is the detail inside `invalid frontmatter: {detail}`: +Each of `tools:`, `models:`, and `args:` is a YAML map keyed by alias, role label, or arg name, and each key appears once within its map. Every name is checked when the prompt loads: the grammar first, then, for tool aliases and role labels only, the [reserved names](#reserved-names-for-aliases-and-role-labels), and uniqueness last. All of these failures have parse error kind `Frontmatter`, and each message is the detail inside `invalid frontmatter: {detail}`: ````text invalid tool alias `{key}`: expected [A-Za-z][A-Za-z0-9_-]{0,63} @@ -908,6 +908,30 @@ invalid alias "{alias}": expected [A-Za-z][A-Za-z0-9_-]{0,63} Lua code can catch this error with `pcall`, as [Catching and inspecting errors](05-lua-environment.md#catching-and-inspecting-errors) shows. Left uncaught, it fails the run with run error kind `Lua`, unless it reaches the H1 body's own Lua, as [How a failed run is classified](17-limits-and-errors.md#how-a-failed-run-is-classified) explains. +### Reserved names for aliases and role labels + +Every tool alias and every model role label becomes a bare Lua global of the same name in every section VM, as [Alias globals](12-tools.md#alias-globals) and [Role globals](10-models.md#role-globals) show. So neither may take a name the section VM already uses for something else. These names are reserved: + +- The host globals: `args`, `argv`, `call`, `compactors`, `fanout`, `item`, `jump`, `list_from_section`, `log`, `messages`, `models`, `prose`, `store`, `sys`, `tasks`, `tools`, `ui`, `untrusted`, and `var`. `ui` and `item` are reserved even though only some section VMs have them. +- The Lua standard-library globals the sandbox keeps: `assert`, `error`, `getmetatable`, `ipairs`, `math`, `next`, `pairs`, `pcall`, `select`, `setmetatable`, `string`, `table`, `tonumber`, `tostring`, `type`, and `xpcall`, plus `_G` and `_VERSION`, which the name grammar already rules out. +- The Lua 5.5 keywords: `and`, `break`, `do`, `else`, `elseif`, `end`, `false`, `for`, `function`, `global`, `goto`, `if`, `in`, `local`, `nil`, `not`, `or`, `repeat`, `return`, `then`, `true`, `until`, and `while`. + +These are exactly the globals of a section VM before any capability adds its own, together with the keywords. The match is exact and case-sensitive, so `Store`, `stores`, and `my_argv` are ordinary names. A reserved key fails the parse with parse error kind `Frontmatter`, reporting its line and column, and this detail: + +````text +{kind} `{key}` in `{map}` is reserved ({category}): tool aliases and model role labels install as section VM globals, so none may take a reserved name +```` + +Here `{kind}` is `tool alias` or `model role label`, `{map}` is `tools` or `models`, and `{category}` is `a host global`, `a Lua standard-library global`, or `a Lua keyword`. So `store: promptforge/web/fetch` under `tools:` fails with ``tool alias `store` in `tools` is reserved (a host global): ...``. Arg names are not checked against this list, because they name fields of `argv` rather than globals: `args:` may declare `prose` or `store`. + +One name also cannot be both a tool alias and a model role label, because both would install the same global. Such a pair fails the parse with parse error kind `Frontmatter` and this message, which names the first shared name in sorted order. Unlike the errors above, it reports no line or column: + +````text +invalid frontmatter: `{name}` is both a tool alias in `tools` and a model role label in `models`; each installs as a section VM global of its own name, so the two must differ +```` + +A declared capability can define globals of its own, such as the `input` table of `promptforge/user-input`. Those globals are known only once the capability's code runs, so an alias or label with the same name fails the run when its first section VM is set up, before the run does anything, as [Letting the model ask](05-lua-environment.md#letting-the-model-ask) shows. + ## The H1 title and its content The H1 heading's text is the prompt's title. Inner spaces and case stay exactly as written, surrounding whitespace is trimmed, and inline code or other markup keeps only its text: `# Demo Title` gives the title `Demo Title`, and `# Phase Boundaries` gives `Phase Boundaries`. The title is separate from the frontmatter `name`: `# Greeter` with `name: greeter` has the title `Greeter` and the name `greeter`. @@ -1557,15 +1581,15 @@ First: one. Second: two. The first block keeps its rendered text in `var.first`. The second block's `prose` is rendered fresh from the second paragraph with the new value of `var.word`, and the kept string does not change. -A block with no Markdown before it reads `prose` as the empty string `''`. +A block with no Markdown before it reads `prose` as the empty string `''`. Code that runs before any block has started, such as the shared library while it loads ([How the shared library loads](#how-the-shared-library-loads)), reads `prose` as nil. -`prose` is read-only. Assigning to it at any time, before or after the first read, raises this Lua error: +`prose` is read-only. Assigning to it at any time, before or after the first read and in the shared library too, raises this Lua error: ````text prose is read-only: assign to `var` or a section global instead ```` -Put derived text in `var` or in another global. +Put derived text in `var` or in another global. A metatable of your own on `_G` never changes how `prose` reads or refuses assignment ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). [`models.infer(prose)`](10-models.md#running-a-round-with-modelsinfer) sends the prose written above a block to the model: the rendered text is what the model is asked, and the call returns the reply. This prompt makes one model call carrying `Say something.` and returns the reply: @@ -1871,14 +1895,14 @@ A block can then `return ask(prose)`. Library functions look up globals when the Declaring a tool slot under `tools:` or a model role under `models:` gives the prompt a global of the same name, an alias global ([Tool slots and Tool objects](12-tools.md#tool-slots-and-tool-objects)). Alias globals install after the replay, so they are nil while the library's top-level code runs and present in every block after it, and a declared alias wins over a same-named global the library defines. The `tools` and `models` tables themselves are present at load, so a top-level `tools.add('search')` works. -The library can install a metatable on `_G`: +The library can install a metatable on `_G` ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)): ````lua captured = {} setmetatable(_G, { __newindex = function(_, key, value) captured[key] = value end }) ```` -The host sets `args` and the alias globals directly, so they never pass through the metatable's `__newindex` hook: with this library, `captured.args` stays nil in a later block while `args` works normally. The metatable keeps working in section blocks, so a block's `plain = 'x'` lands in `captured.plain`, while `prose` stays read-only and is still rendered at its first read. +The host sets `args` and the alias globals directly, so they never pass through the metatable's `__newindex` hook: with this library, `captured.args` stays nil in a later block while `args` works normally. The metatable keeps working in section blocks, so a block's `plain = 'x'` lands in `captured.plain`, while `prose` stays read-only and is still rendered at its first read, and `argv` stays frozen outside the H1 pass. The hook never sees `argv` or `prose`. In a fanout arm, `item` is installed before the replay, so the library's top-level code sees the arm's member and can set globals the worker section reads. With a library line `captured_by_shared = item`, a worker section that returns `tostring(captured_by_shared) .. '|' .. tostring(item)` gives `alpha|alpha` for the member `alpha`. @@ -2687,7 +2711,7 @@ Each section's Lua runs in a sandbox whose standard libraries are `string`, `tab - `tonumber`, `tostring`, and `type` - `_G` and `_VERSION` -That list is the whole toolkit. File access, the operating system, loading modules, and loading code from strings are outside it. Four of the base functions behave in a PromptForge way: `pairs` and `next` visit keys in a fixed order ([Deterministic table iteration](#deterministic-table-iteration)), and `pcall` and `xpcall` hand back error values ([Catching and inspecting errors](#catching-and-inspecting-errors)). +That list is the whole toolkit. File access, the operating system, loading modules, and loading code from strings are outside it. Six of the base functions behave in a PromptForge way: `pairs` and `next` visit keys in a fixed order ([Deterministic table iteration](#deterministic-table-iteration)), `pcall` and `xpcall` hand back error values ([Catching and inspecting errors](#catching-and-inspecting-errors)), and `setmetatable` and `getmetatable` give `_G` a metatable of your own that never replaces the guard on `argv` and `prose` ([Your own metatable on _G](#your-own-metatable-on-_g)). On every other value, `setmetatable` and `getmetatable` are standard Lua 5.5. The smallest block that uses the sandbox calls a library function and returns the result: @@ -2724,7 +2748,7 @@ On top of the sandbox, the runtime installs host globals in every section VM, wi - `call`, `jump`, `fanout`, and `list_from_section` - `tasks` -Four more appear only when they apply. `ui` is present when the host supplies a host-state snapshot. `item` is present inside a fanout arm, one of the concurrent runs that `fanout` starts ([Inside an arm](14-fanout.md#inside-an-arm)). A declared capability can define globals of its own, such as the `input` table that `promptforge/user-input` defines ([Asking the operator with input.ask](#asking-the-operator-with-inputask)). And every declared model role label and every tool slot alias becomes a bare global of its own. This chapter teaches `var`, `sys`, `ui`, `log`, and `input`; each of the others is taught in its own chapter. +Four more appear only when they apply. `ui` is present when the host supplies a host-state snapshot. `item` is present inside a fanout arm, one of the concurrent runs that `fanout` starts ([Inside an arm](14-fanout.md#inside-an-arm)). A declared capability can define globals of its own, such as the `input` table that `promptforge/user-input` defines ([Asking the operator with input.ask](#asking-the-operator-with-inputask)). And every declared model role label and every tool slot alias becomes a bare global of its own. None of those ever replaces a host global or a sandbox library global: a label or alias that names one fails the parse ([Reserved names for aliases and role labels](02-file-structure.md#reserved-names-for-aliases-and-role-labels)), and a capability global that names one fails the run before it does anything. This chapter teaches `var`, `sys`, `ui`, `log`, and `input`; each of the others is taught in its own chapter. ### Blocks, sections, and section VMs @@ -2882,14 +2906,31 @@ The suspending calls `models.infer`, `call`, `fanout`, `tools.call`, and the `ta ### Your own metatable on _G -You can install your own metatable on `_G`, for example from the `lua shared` fence ([The shared library](03-blocks-and-prose.md#the-shared-library)), and it keeps working alongside the `prose` global ([The prose global](03-blocks-and-prose.md#the-prose-global)). Reads and writes of every other global still go through your `__index` and `__newindex`, the metatable's other fields are kept, and your handlers run once per lookup no matter how many blocks have run. +You can give `_G` a metatable of your own with `setmetatable(_G, mt)`, for example from the `lua shared` fence ([The shared library](03-blocks-and-prose.md#the-shared-library)), to give missing globals a default, to raise an error for an undefined global, or to hook the writes of new globals. A read of a global that `_G` does not hold goes to your `__index`, and an assignment of a new global goes to your `__newindex`, as in standard Lua. Both are read from your metatable at every lookup, so a later change to that table takes effect at once, and your handlers run once per lookup no matter how many blocks have run. ````lua local defaults = { tone = 'friendly' } setmetatable(_G, { __index = defaults }) ```` -With that in the shared library, reading the unset global `tone` in any block gives `friendly`, while `prose` still reads the section's rendered prose. +With that in the shared library, reading the unset global `tone` in any block gives `friendly`, and `{{ tone }}` in prose renders `friendly` too. + +A strict metatable works the same way: + +````lua +setmetatable(_G, { + __index = function(_, name) error('undefined global ' .. name, 2) end, +}) +```` + +With that in the shared library, a block that reads an undefined global fails at the reading line with your message, and `pcall` hands back your message string exactly as you raised it. + +`argv` and `prose` are handled before your metatable and never reach it. Outside the H1 pass, reading `argv` gives the frozen value and assigning it raises `argv is frozen outside H1: assign it in H1 only` ([Frozen argv](06-arguments.md#frozen-argv)). In the H1 pass, `argv` is an ordinary writable global that your metatable never sees, so a nil `argv` reads as nil and the repair `argv = repaired` lands in `_G` even under a strict or write-hooking metatable ([The H1 repair pattern](06-arguments.md#the-h1-repair-pattern)). Reading `prose` gives the block's rendered prose, and assigning it raises ``prose is read-only: assign to `var` or a section global instead`` ([The prose global](03-blocks-and-prose.md#the-prose-global)). No metatable you set, clear, or change alters any of that, and neither name is ever passed to your `__index` or `__newindex`. + +- `getmetatable(_G)` returns your metatable, the very table you passed, or nil when you have set none. It never returns the guard that serves `argv` and `prose`. +- `setmetatable(_G, mt)` returns `_G`, and `setmetatable(_G, nil)` removes your metatable. `mt` must be a table or nil; anything else raises the standard message, such as `bad argument #2 to 'setmetatable' (nil or table expected, got number)`. +- A `__metatable` field in your metatable protects `_G` as it would any table: `getmetatable(_G)` returns that field's value, and a later `setmetatable(_G, ...)` raises `cannot change a protected metatable`. +- Your metatable's other fields, such as `__call` or `__tostring`, apply to `_G` as they stand when you call `setmetatable(_G, mt)`. A later change to one of them takes effect at your next `setmetatable(_G, mt)`, while `__index` and `__newindex` are always read live. ## Deterministic table iteration @@ -4080,7 +4121,7 @@ Run with `{}`, the assertion fails and `## Search` never runs. Because the failu The H1 body's Lua is the only place a prompt can write `argv`. When the H1 pass completes, before the walk starts, the value the H1 body left in `argv` is read back and frozen. Every other section gets `argv` read-only, including every section on the walk and every section in a [called chain](08-jump-and-call.md#call-input-and-args), the walk that a `call` starts. -Inside the H1 body, `argv` is an ordinary writable global with no guard in the way. That makes it the place to repair input: read the raw `args` string and assign `argv` a fixed-up value. Whatever `argv` holds when the H1 body finishes, the parsed input or your repair, is what every later section reads, both in Lua and in `{{ argv }}` and `{{ argv.field }}` placeholders: +Inside the H1 body, `argv` is an ordinary writable global with no guard in the way, and a metatable you put on `_G` never sees it, so a nil `argv` reads as nil and the repair lands even under a strict or write-hooking metatable ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). That makes it the place to repair input: read the raw `args` string and assign `argv` a fixed-up value. Whatever `argv` holds when the H1 body finishes, the parsed input or your repair, is what every later section reads, both in Lua and in `{{ argv }}` and `{{ argv.field }}` placeholders: ````markdown --- @@ -4180,6 +4221,8 @@ Outside the H1 body, `getmetatable` on any `argv` table returns the string `"arg Only the name `argv` is guarded. Every other global can still be defined, read, and assigned normally, so `scratch = 42` followed by `assert(scratch == 42)` works in any section. The [`prose` global](03-blocks-and-prose.md#the-prose-global), the rendered Markdown above a fence, keeps working normally beside it. +A metatable of your own on `_G` cannot lift the freeze. After `setmetatable(_G, mt)`, `setmetatable(_G, nil)`, or any change to the table `getmetatable(_G)` returns, `argv` still reads the frozen value and assigning it still raises the freeze error, and your `__index` and `__newindex` never see the name ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). + ## Freeze errors Assigning `argv` or writing into it in any section other than the H1 body raises a runtime error: @@ -6719,7 +6762,7 @@ models: Flow style works too, so a role fits on one line, as in `analyst: { keywords: [no-thinking, creative, chat], min_context: 32000, description: deep reasoning }`. Leave `models:` out of the frontmatter to declare no roles at all. -Give every role under `models:` a distinct label, written in the [name grammar](02-file-structure.md#names-for-aliases-roles-and-args) that every prompt-local name follows: an ASCII letter followed by up to 63 ASCII letters, digits, `_`, or `-`. +Give every role under `models:` a distinct label, written in the [name grammar](02-file-structure.md#names-for-aliases-roles-and-args) that every prompt-local name follows: an ASCII letter followed by up to 63 ASCII letters, digits, `_`, or `-`. Because each label becomes a Lua global of its own name, a label may not be one of the [reserved names](02-file-structure.md#reserved-names-for-aliases-and-role-labels), such as `models`, `tools`, or `string`, nor an alias under `tools:`. ### Declaration errors @@ -6727,8 +6770,11 @@ A mistake inside `models:` fails the parse with a [`Frontmatter`](17-limits-and- - A label used twice fails with ``duplicate model role label `{key}`: contract map keys must be unique``, which names the label. - A label outside the name grammar fails with ``invalid model role label `{key}`: expected [A-Za-z][A-Za-z0-9_-]{0,63}``, which names the label and the grammar. +- A reserved label fails with ``model role label `{key}` in `models` is reserved ({category}): tool aliases and model role labels install as section VM globals, so none may take a reserved name``, which names the label and whether it is a host global, a Lua standard-library global, or a Lua keyword. - Any other key inside a role, a keyword outside the seven, or a `min_context` of zero also fails the parse, with a message from the YAML reader. +A label that is also a key under `tools:` fails the parse with a `Frontmatter` error too, one that names the label and both maps but reports no line or column, as [Reserved names for aliases and role labels](02-file-structure.md#reserved-names-for-aliases-and-role-labels) shows. + ## Keywords and the thinking switch The seven keywords come in two kinds. The hard keywords are `thinking` and `no-thinking`, and the soft keywords are `frontier`, `fast`, `small`, `creative`, and `chat`. Every keyword is written in kebab-case, and any other word in `keywords:` fails the parse with a `Frontmatter` parse error. @@ -6863,7 +6909,7 @@ Each bound role is also a role global: a bare Lua global named after the role's return models.infer(analyst, prose) ```` -Role globals are set after the shared library loads, so a declared label wins over a same-named global that shared code defines. For the same reason, top-level code in the shared library cannot read role globals yet, while a function it defines can read them when a section calls it. When a role label is also a key under `tools:`, the bare global with that name holds the model handle. +Role globals are set after the shared library loads, so a declared label wins over a same-named global that shared code defines. For the same reason, top-level code in the shared library cannot read role globals yet, while a function it defines can read them when a section calls it. No role global ever replaces a host global, a sandbox library global, or a tool alias's global, because the parse refuses a label that would. ### Keeping the default's handle @@ -7879,7 +7925,7 @@ Each bad name produces one message, for the first check it fails. The overall se ## Tool slots and Tool objects -Each `tools:` entry declares a tool slot: an alias, which follows the prompt's [name grammar for aliases](02-file-structure.md#names-for-aliases-roles-and-args), bound to one tool path. The first two segments of the path name the declared capability that supplies the tool. +Each `tools:` entry declares a tool slot: an alias, which follows the prompt's [name grammar for aliases](02-file-structure.md#names-for-aliases-roles-and-args), bound to one tool path. The first two segments of the path name the declared capability that supplies the tool. Because each alias becomes a Lua global of its own name, an alias may not be one of the [reserved names](02-file-structure.md#reserved-names-for-aliases-and-role-labels), such as `store` or `pairs`, nor a label under `models:`. Prepare [fills each slot](04-how-a-prompt-runs.md#filling-tool-slots-and-model-roles) by exact match of its tool path against the run's tool catalog, which holds the activated capabilities' tools in declaration order. A slot whose path matches becomes a bound tool slot, and it stays bound to that same tool for the whole run. Slots are bound before any Lua runs, so Lua only chooses which bound slots the model sees, and scoping an alias that is not bound is an error. @@ -12337,7 +12383,7 @@ Common mistakes land in predictable kinds: malformed YAML and an out-of-range `m invalid frontmatter: {message} ```` -A value the contract rejects, such as a malformed capability id or an out-of-range `max_tool_iterations`, gives that key's own message. +A value the contract rejects, such as a malformed capability id, an out-of-range `max_tool_iterations`, or a tool alias that is a [reserved name](02-file-structure.md#reserved-names-for-aliases-and-role-labels), gives that key's own message. ### Structure @@ -12393,7 +12439,7 @@ A parse failure comes with a location when the parser can point at the problem. ### Frontmatter failures -A frontmatter failure, whether the YAML is invalid or the contract rejects a value, gives a 1-based line and a 1-based column. For a capability entry on line 5 whose value is not a capability id, the failure reports line 5 and column 5, where the value starts after the ` - ` list marker. +A frontmatter failure, whether the YAML is invalid or the contract rejects a value, gives a 1-based line and a 1-based column. For a capability entry on line 5 whose value is not a capability id, the failure reports line 5 and column 5, where the value starts after the ` - ` list marker. The one exception is a name declared both under `tools:` and under `models:`, which spans two keys and gives neither a line nor a column. A frontmatter failure has no prompt name, because the name comes from the frontmatter itself. Its location path is the placeholder ``, and the host may label the failure with its own name for the file instead. @@ -12774,6 +12820,7 @@ Every frontmatter key and value rule, with top-level keys first and nested keys | Name grammar for aliases, role labels, and arg names | `[A-Za-z][A-Za-z0-9_-]{0,63}` | none | [Prompt File Structure](02-file-structure.md#names-for-aliases-roles-and-args) | | `output.description` | string | none, required | [Prompt File Structure](02-file-structure.md#input-and-output-files) | | `output.path` | store filename, such as `report.md` | none, required | [Prompt File Structure](02-file-structure.md#input-and-output-files) | +| Reserved names for tool aliases and role labels | no host global, sandbox Lua global, or Lua keyword, such as `store`, `argv`, `pairs`, or `end`, and no name under both `tools` and `models`; the chapter lists every one | none | [Prompt File Structure](02-file-structure.md#reserved-names-for-aliases-and-role-labels) | | Tool path in `tools.{alias}` | `namespace/pack/name`, such as `promptforge/web/fetch` | none | [Tools](12-tools.md#capability-ids-and-tool-paths) | | `tools.{alias}` | tool path string | none | [Tools](12-tools.md#tool-slots-and-tool-objects) | @@ -12977,11 +13024,11 @@ Every section VM runs Lua 5.5 with these libraries and base functions. | `_VERSION` | `_VERSION` | the Lua version string | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `assert` | `assert(condition, message)` | raises `message` when `condition` is false | [The Lua Environment](05-lua-environment.md#calls-that-wait-and-errors-that-raise) | | `error` | `error(message)` | raises `message` | [The Lua Environment](05-lua-environment.md#calls-that-wait-and-errors-that-raise) | -| `getmetatable` | `getmetatable(v)` | as in standard Lua 5.5; `var` and `sys` give a guard string | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | +| `getmetatable` | `getmetatable(v)` | as in standard Lua 5.5; `var` and `sys` give a guard string, and `_G` gives the metatable you set or nil | [The Lua Environment](05-lua-environment.md#your-own-metatable-on-_g) | | `ipairs` | `ipairs(t)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `math` library | `math.{name}(...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `select` | `select(n, ...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | -| `setmetatable` | `setmetatable(t, mt)` | `t` | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | +| `setmetatable` | `setmetatable(t, mt)` | `t`; on `_G`, `mt` composes behind the `argv` and `prose` guard | [The Lua Environment](05-lua-environment.md#your-own-metatable-on-_g) | | `string` library | `string.upper(s)`, `s:match(pattern)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `table` library | `table.{name}(...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `table.concat` | `table.concat(list, sep, i, j)` | joined string; `__tostring` values render with `tostring` | [The Lua Environment](05-lua-environment.md#standard-lua-and-host-calls) | @@ -12995,7 +13042,7 @@ These globals, fanout result fields, and error value fields need no declaration. | Name | Form | Returns | Taught in | |---|---|---|---| -| `{alias}` | `{alias}` | Tool object for that bound tool slot | [Tools](12-tools.md#tool-slots-and-tool-objects) | +| `{alias}` | `{alias}` | Tool object for that bound tool slot; never a reserved name | [Tools](12-tools.md#tool-slots-and-tool-objects) | | `args` | `args` | the raw argument string | [Arguments](06-arguments.md#input-basics) | | `argv` | `argv` | the parsed argument string; `{ prose = args }` without `args:`, nil when structured input is not JSON | [Arguments](06-arguments.md#prose-input-and-structured-input) | | `call` | `call(target, input?)` | the called chain's result as a string | [Jump and Call](08-jump-and-call.md#jump-and-call-at-a-glance) | @@ -13014,7 +13061,7 @@ These globals, fanout result fields, and error value fields need no declaration. | `item` | `item` | the arm's member inside a fanout arm, or a task's `item` option | [Fanout](14-fanout.md#inside-an-arm) | | `item.key` and `item.value` | `item.key`, `item.value` | a keyed member's key and value | [Fanout](14-fanout.md#collections-and-member-order) | | `jump` | `jump(target)` | nothing; ends the block and the walk continues at `target` | [Jump and Call](08-jump-and-call.md#jump-and-call-at-a-glance) | -| `{label}` | `{label}` | model handle for that bound role | [Models](10-models.md#model-handles) | +| `{label}` | `{label}` | model handle for that bound role; never a reserved name | [Models](10-models.md#model-handles) | | `list_from_section` | `list_from_section(heading)` | 1-based array of the list section's item strings | [Blocks and Prose](03-blocks-and-prose.md#reading-list-items-from-lua) | | `log` | `log(message)` | nothing; records a `lua` checkpoint event | [The Lua Environment](05-lua-environment.md#checkpoints-with-log) | | `messages` | `messages.new()` | the `messages` namespace | [Conversations](11-conversations.md#building-message-lists) | diff --git a/guide/src/language/02-file-structure.md b/guide/src/language/02-file-structure.md index ac92e096b..f51a2156a 100644 --- a/guide/src/language/02-file-structure.md +++ b/guide/src/language/02-file-structure.md @@ -287,7 +287,7 @@ The names here are `search` and `fetch`, `writer` and `analyst`, and `use_mcp`. The name grammar is `[A-Za-z][A-Za-z0-9_-]{0,63}`: 1 to 64 ASCII characters, a letter first, then letters, digits, `_`, or `-`. Letters are ASCII only. A 64-character name parses. Names such as `search`, `fetch`, `writer`, `analyst`, `use_mcp`, `limit`, and `query` all fit. An alias is the only name a model ever sees for a tool slot or a model role. -Each of `tools:`, `models:`, and `args:` is a YAML map keyed by alias, role label, or arg name, and each key appears once within its map. Every name is checked when the prompt loads, the grammar first and uniqueness second. All of these failures have parse error kind `Frontmatter`, and each message is the detail inside `invalid frontmatter: {detail}`: +Each of `tools:`, `models:`, and `args:` is a YAML map keyed by alias, role label, or arg name, and each key appears once within its map. Every name is checked when the prompt loads: the grammar first, then, for tool aliases and role labels only, the [reserved names](#reserved-names-for-aliases-and-role-labels), and uniqueness last. All of these failures have parse error kind `Frontmatter`, and each message is the detail inside `invalid frontmatter: {detail}`: ````text invalid tool alias `{key}`: expected [A-Za-z][A-Za-z0-9_-]{0,63} @@ -314,6 +314,30 @@ invalid alias "{alias}": expected [A-Za-z][A-Za-z0-9_-]{0,63} Lua code can catch this error with `pcall`, as [Catching and inspecting errors](05-lua-environment.md#catching-and-inspecting-errors) shows. Left uncaught, it fails the run with run error kind `Lua`, unless it reaches the H1 body's own Lua, as [How a failed run is classified](17-limits-and-errors.md#how-a-failed-run-is-classified) explains. +### Reserved names for aliases and role labels + +Every tool alias and every model role label becomes a bare Lua global of the same name in every section VM, as [Alias globals](12-tools.md#alias-globals) and [Role globals](10-models.md#role-globals) show. So neither may take a name the section VM already uses for something else. These names are reserved: + +- The host globals: `args`, `argv`, `call`, `compactors`, `fanout`, `item`, `jump`, `list_from_section`, `log`, `messages`, `models`, `prose`, `store`, `sys`, `tasks`, `tools`, `ui`, `untrusted`, and `var`. `ui` and `item` are reserved even though only some section VMs have them. +- The Lua standard-library globals the sandbox keeps: `assert`, `error`, `getmetatable`, `ipairs`, `math`, `next`, `pairs`, `pcall`, `select`, `setmetatable`, `string`, `table`, `tonumber`, `tostring`, `type`, and `xpcall`, plus `_G` and `_VERSION`, which the name grammar already rules out. +- The Lua 5.5 keywords: `and`, `break`, `do`, `else`, `elseif`, `end`, `false`, `for`, `function`, `global`, `goto`, `if`, `in`, `local`, `nil`, `not`, `or`, `repeat`, `return`, `then`, `true`, `until`, and `while`. + +These are exactly the globals of a section VM before any capability adds its own, together with the keywords. The match is exact and case-sensitive, so `Store`, `stores`, and `my_argv` are ordinary names. A reserved key fails the parse with parse error kind `Frontmatter`, reporting its line and column, and this detail: + +````text +{kind} `{key}` in `{map}` is reserved ({category}): tool aliases and model role labels install as section VM globals, so none may take a reserved name +```` + +Here `{kind}` is `tool alias` or `model role label`, `{map}` is `tools` or `models`, and `{category}` is `a host global`, `a Lua standard-library global`, or `a Lua keyword`. So `store: promptforge/web/fetch` under `tools:` fails with ``tool alias `store` in `tools` is reserved (a host global): ...``. Arg names are not checked against this list, because they name fields of `argv` rather than globals: `args:` may declare `prose` or `store`. + +One name also cannot be both a tool alias and a model role label, because both would install the same global. Such a pair fails the parse with parse error kind `Frontmatter` and this message, which names the first shared name in sorted order. Unlike the errors above, it reports no line or column: + +````text +invalid frontmatter: `{name}` is both a tool alias in `tools` and a model role label in `models`; each installs as a section VM global of its own name, so the two must differ +```` + +A declared capability can define globals of its own, such as the `input` table of `promptforge/user-input`. Those globals are known only once the capability's code runs, so an alias or label with the same name fails the run when its first section VM is set up, before the run does anything, as [Letting the model ask](05-lua-environment.md#letting-the-model-ask) shows. + ## The H1 title and its content The H1 heading's text is the prompt's title. Inner spaces and case stay exactly as written, surrounding whitespace is trimmed, and inline code or other markup keeps only its text: `# Demo Title` gives the title `Demo Title`, and `# Phase Boundaries` gives `Phase Boundaries`. The title is separate from the frontmatter `name`: `# Greeter` with `name: greeter` has the title `Greeter` and the name `greeter`. diff --git a/guide/src/language/03-blocks-and-prose.md b/guide/src/language/03-blocks-and-prose.md index 59d045b64..64904b13a 100644 --- a/guide/src/language/03-blocks-and-prose.md +++ b/guide/src/language/03-blocks-and-prose.md @@ -401,15 +401,15 @@ First: one. Second: two. The first block keeps its rendered text in `var.first`. The second block's `prose` is rendered fresh from the second paragraph with the new value of `var.word`, and the kept string does not change. -A block with no Markdown before it reads `prose` as the empty string `''`. +A block with no Markdown before it reads `prose` as the empty string `''`. Code that runs before any block has started, such as the shared library while it loads ([How the shared library loads](#how-the-shared-library-loads)), reads `prose` as nil. -`prose` is read-only. Assigning to it at any time, before or after the first read, raises this Lua error: +`prose` is read-only. Assigning to it at any time, before or after the first read and in the shared library too, raises this Lua error: ````text prose is read-only: assign to `var` or a section global instead ```` -Put derived text in `var` or in another global. +Put derived text in `var` or in another global. A metatable of your own on `_G` never changes how `prose` reads or refuses assignment ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). [`models.infer(prose)`](10-models.md#running-a-round-with-modelsinfer) sends the prose written above a block to the model: the rendered text is what the model is asked, and the call returns the reply. This prompt makes one model call carrying `Say something.` and returns the reply: @@ -715,14 +715,14 @@ A block can then `return ask(prose)`. Library functions look up globals when the Declaring a tool slot under `tools:` or a model role under `models:` gives the prompt a global of the same name, an alias global ([Tool slots and Tool objects](12-tools.md#tool-slots-and-tool-objects)). Alias globals install after the replay, so they are nil while the library's top-level code runs and present in every block after it, and a declared alias wins over a same-named global the library defines. The `tools` and `models` tables themselves are present at load, so a top-level `tools.add('search')` works. -The library can install a metatable on `_G`: +The library can install a metatable on `_G` ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)): ````lua captured = {} setmetatable(_G, { __newindex = function(_, key, value) captured[key] = value end }) ```` -The host sets `args` and the alias globals directly, so they never pass through the metatable's `__newindex` hook: with this library, `captured.args` stays nil in a later block while `args` works normally. The metatable keeps working in section blocks, so a block's `plain = 'x'` lands in `captured.plain`, while `prose` stays read-only and is still rendered at its first read. +The host sets `args` and the alias globals directly, so they never pass through the metatable's `__newindex` hook: with this library, `captured.args` stays nil in a later block while `args` works normally. The metatable keeps working in section blocks, so a block's `plain = 'x'` lands in `captured.plain`, while `prose` stays read-only and is still rendered at its first read, and `argv` stays frozen outside the H1 pass. The hook never sees `argv` or `prose`. In a fanout arm, `item` is installed before the replay, so the library's top-level code sees the arm's member and can set globals the worker section reads. With a library line `captured_by_shared = item`, a worker section that returns `tostring(captured_by_shared) .. '|' .. tostring(item)` gives `alpha|alpha` for the member `alpha`. diff --git a/guide/src/language/05-lua-environment.md b/guide/src/language/05-lua-environment.md index ffe51d561..4a6cf3e8c 100644 --- a/guide/src/language/05-lua-environment.md +++ b/guide/src/language/05-lua-environment.md @@ -14,7 +14,7 @@ Each section's Lua runs in a sandbox whose standard libraries are `string`, `tab - `tonumber`, `tostring`, and `type` - `_G` and `_VERSION` -That list is the whole toolkit. File access, the operating system, loading modules, and loading code from strings are outside it. Four of the base functions behave in a PromptForge way: `pairs` and `next` visit keys in a fixed order ([Deterministic table iteration](#deterministic-table-iteration)), and `pcall` and `xpcall` hand back error values ([Catching and inspecting errors](#catching-and-inspecting-errors)). +That list is the whole toolkit. File access, the operating system, loading modules, and loading code from strings are outside it. Six of the base functions behave in a PromptForge way: `pairs` and `next` visit keys in a fixed order ([Deterministic table iteration](#deterministic-table-iteration)), `pcall` and `xpcall` hand back error values ([Catching and inspecting errors](#catching-and-inspecting-errors)), and `setmetatable` and `getmetatable` give `_G` a metatable of your own that never replaces the guard on `argv` and `prose` ([Your own metatable on _G](#your-own-metatable-on-_g)). On every other value, `setmetatable` and `getmetatable` are standard Lua 5.5. The smallest block that uses the sandbox calls a library function and returns the result: @@ -51,7 +51,7 @@ On top of the sandbox, the runtime installs host globals in every section VM, wi - `call`, `jump`, `fanout`, and `list_from_section` - `tasks` -Four more appear only when they apply. `ui` is present when the host supplies a host-state snapshot. `item` is present inside a fanout arm, one of the concurrent runs that `fanout` starts ([Inside an arm](14-fanout.md#inside-an-arm)). A declared capability can define globals of its own, such as the `input` table that `promptforge/user-input` defines ([Asking the operator with input.ask](#asking-the-operator-with-inputask)). And every declared model role label and every tool slot alias becomes a bare global of its own. This chapter teaches `var`, `sys`, `ui`, `log`, and `input`; each of the others is taught in its own chapter. +Four more appear only when they apply. `ui` is present when the host supplies a host-state snapshot. `item` is present inside a fanout arm, one of the concurrent runs that `fanout` starts ([Inside an arm](14-fanout.md#inside-an-arm)). A declared capability can define globals of its own, such as the `input` table that `promptforge/user-input` defines ([Asking the operator with input.ask](#asking-the-operator-with-inputask)). And every declared model role label and every tool slot alias becomes a bare global of its own. None of those ever replaces a host global or a sandbox library global: a label or alias that names one fails the parse ([Reserved names for aliases and role labels](02-file-structure.md#reserved-names-for-aliases-and-role-labels)), and a capability global that names one fails the run before it does anything. This chapter teaches `var`, `sys`, `ui`, `log`, and `input`; each of the others is taught in its own chapter. ### Blocks, sections, and section VMs @@ -209,14 +209,31 @@ The suspending calls `models.infer`, `call`, `fanout`, `tools.call`, and the `ta ### Your own metatable on _G -You can install your own metatable on `_G`, for example from the `lua shared` fence ([The shared library](03-blocks-and-prose.md#the-shared-library)), and it keeps working alongside the `prose` global ([The prose global](03-blocks-and-prose.md#the-prose-global)). Reads and writes of every other global still go through your `__index` and `__newindex`, the metatable's other fields are kept, and your handlers run once per lookup no matter how many blocks have run. +You can give `_G` a metatable of your own with `setmetatable(_G, mt)`, for example from the `lua shared` fence ([The shared library](03-blocks-and-prose.md#the-shared-library)), to give missing globals a default, to raise an error for an undefined global, or to hook the writes of new globals. A read of a global that `_G` does not hold goes to your `__index`, and an assignment of a new global goes to your `__newindex`, as in standard Lua. Both are read from your metatable at every lookup, so a later change to that table takes effect at once, and your handlers run once per lookup no matter how many blocks have run. ````lua local defaults = { tone = 'friendly' } setmetatable(_G, { __index = defaults }) ```` -With that in the shared library, reading the unset global `tone` in any block gives `friendly`, while `prose` still reads the section's rendered prose. +With that in the shared library, reading the unset global `tone` in any block gives `friendly`, and `{{ tone }}` in prose renders `friendly` too. + +A strict metatable works the same way: + +````lua +setmetatable(_G, { + __index = function(_, name) error('undefined global ' .. name, 2) end, +}) +```` + +With that in the shared library, a block that reads an undefined global fails at the reading line with your message, and `pcall` hands back your message string exactly as you raised it. + +`argv` and `prose` are handled before your metatable and never reach it. Outside the H1 pass, reading `argv` gives the frozen value and assigning it raises `argv is frozen outside H1: assign it in H1 only` ([Frozen argv](06-arguments.md#frozen-argv)). In the H1 pass, `argv` is an ordinary writable global that your metatable never sees, so a nil `argv` reads as nil and the repair `argv = repaired` lands in `_G` even under a strict or write-hooking metatable ([The H1 repair pattern](06-arguments.md#the-h1-repair-pattern)). Reading `prose` gives the block's rendered prose, and assigning it raises ``prose is read-only: assign to `var` or a section global instead`` ([The prose global](03-blocks-and-prose.md#the-prose-global)). No metatable you set, clear, or change alters any of that, and neither name is ever passed to your `__index` or `__newindex`. + +- `getmetatable(_G)` returns your metatable, the very table you passed, or nil when you have set none. It never returns the guard that serves `argv` and `prose`. +- `setmetatable(_G, mt)` returns `_G`, and `setmetatable(_G, nil)` removes your metatable. `mt` must be a table or nil; anything else raises the standard message, such as `bad argument #2 to 'setmetatable' (nil or table expected, got number)`. +- A `__metatable` field in your metatable protects `_G` as it would any table: `getmetatable(_G)` returns that field's value, and a later `setmetatable(_G, ...)` raises `cannot change a protected metatable`. +- Your metatable's other fields, such as `__call` or `__tostring`, apply to `_G` as they stand when you call `setmetatable(_G, mt)`. A later change to one of them takes effect at your next `setmetatable(_G, mt)`, while `__index` and `__newindex` are always read live. ## Deterministic table iteration diff --git a/guide/src/language/06-arguments.md b/guide/src/language/06-arguments.md index 7c428f695..d1abaa928 100644 --- a/guide/src/language/06-arguments.md +++ b/guide/src/language/06-arguments.md @@ -367,7 +367,7 @@ Run with `{}`, the assertion fails and `## Search` never runs. Because the failu The H1 body's Lua is the only place a prompt can write `argv`. When the H1 pass completes, before the walk starts, the value the H1 body left in `argv` is read back and frozen. Every other section gets `argv` read-only, including every section on the walk and every section in a [called chain](08-jump-and-call.md#call-input-and-args), the walk that a `call` starts. -Inside the H1 body, `argv` is an ordinary writable global with no guard in the way. That makes it the place to repair input: read the raw `args` string and assign `argv` a fixed-up value. Whatever `argv` holds when the H1 body finishes, the parsed input or your repair, is what every later section reads, both in Lua and in `{{ argv }}` and `{{ argv.field }}` placeholders: +Inside the H1 body, `argv` is an ordinary writable global with no guard in the way, and a metatable you put on `_G` never sees it, so a nil `argv` reads as nil and the repair lands even under a strict or write-hooking metatable ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). That makes it the place to repair input: read the raw `args` string and assign `argv` a fixed-up value. Whatever `argv` holds when the H1 body finishes, the parsed input or your repair, is what every later section reads, both in Lua and in `{{ argv }}` and `{{ argv.field }}` placeholders: ````markdown --- @@ -467,6 +467,8 @@ Outside the H1 body, `getmetatable` on any `argv` table returns the string `"arg Only the name `argv` is guarded. Every other global can still be defined, read, and assigned normally, so `scratch = 42` followed by `assert(scratch == 42)` works in any section. The [`prose` global](03-blocks-and-prose.md#the-prose-global), the rendered Markdown above a fence, keeps working normally beside it. +A metatable of your own on `_G` cannot lift the freeze. After `setmetatable(_G, mt)`, `setmetatable(_G, nil)`, or any change to the table `getmetatable(_G)` returns, `argv` still reads the frozen value and assigning it still raises the freeze error, and your `__index` and `__newindex` never see the name ([Your own metatable on _G](05-lua-environment.md#your-own-metatable-on-_g)). + ## Freeze errors Assigning `argv` or writing into it in any section other than the H1 body raises a runtime error: diff --git a/guide/src/language/10-models.md b/guide/src/language/10-models.md index e726aee3f..730a67336 100644 --- a/guide/src/language/10-models.md +++ b/guide/src/language/10-models.md @@ -99,7 +99,7 @@ models: Flow style works too, so a role fits on one line, as in `analyst: { keywords: [no-thinking, creative, chat], min_context: 32000, description: deep reasoning }`. Leave `models:` out of the frontmatter to declare no roles at all. -Give every role under `models:` a distinct label, written in the [name grammar](02-file-structure.md#names-for-aliases-roles-and-args) that every prompt-local name follows: an ASCII letter followed by up to 63 ASCII letters, digits, `_`, or `-`. +Give every role under `models:` a distinct label, written in the [name grammar](02-file-structure.md#names-for-aliases-roles-and-args) that every prompt-local name follows: an ASCII letter followed by up to 63 ASCII letters, digits, `_`, or `-`. Because each label becomes a Lua global of its own name, a label may not be one of the [reserved names](02-file-structure.md#reserved-names-for-aliases-and-role-labels), such as `models`, `tools`, or `string`, nor an alias under `tools:`. ### Declaration errors @@ -107,8 +107,11 @@ A mistake inside `models:` fails the parse with a [`Frontmatter`](17-limits-and- - A label used twice fails with ``duplicate model role label `{key}`: contract map keys must be unique``, which names the label. - A label outside the name grammar fails with ``invalid model role label `{key}`: expected [A-Za-z][A-Za-z0-9_-]{0,63}``, which names the label and the grammar. +- A reserved label fails with ``model role label `{key}` in `models` is reserved ({category}): tool aliases and model role labels install as section VM globals, so none may take a reserved name``, which names the label and whether it is a host global, a Lua standard-library global, or a Lua keyword. - Any other key inside a role, a keyword outside the seven, or a `min_context` of zero also fails the parse, with a message from the YAML reader. +A label that is also a key under `tools:` fails the parse with a `Frontmatter` error too, one that names the label and both maps but reports no line or column, as [Reserved names for aliases and role labels](02-file-structure.md#reserved-names-for-aliases-and-role-labels) shows. + ## Keywords and the thinking switch The seven keywords come in two kinds. The hard keywords are `thinking` and `no-thinking`, and the soft keywords are `frontier`, `fast`, `small`, `creative`, and `chat`. Every keyword is written in kebab-case, and any other word in `keywords:` fails the parse with a `Frontmatter` parse error. @@ -243,7 +246,7 @@ Each bound role is also a role global: a bare Lua global named after the role's return models.infer(analyst, prose) ```` -Role globals are set after the shared library loads, so a declared label wins over a same-named global that shared code defines. For the same reason, top-level code in the shared library cannot read role globals yet, while a function it defines can read them when a section calls it. When a role label is also a key under `tools:`, the bare global with that name holds the model handle. +Role globals are set after the shared library loads, so a declared label wins over a same-named global that shared code defines. For the same reason, top-level code in the shared library cannot read role globals yet, while a function it defines can read them when a section calls it. No role global ever replaces a host global, a sandbox library global, or a tool alias's global, because the parse refuses a label that would. ### Keeping the default's handle diff --git a/guide/src/language/12-tools.md b/guide/src/language/12-tools.md index c684f247a..8849fad2a 100644 --- a/guide/src/language/12-tools.md +++ b/guide/src/language/12-tools.md @@ -201,7 +201,7 @@ Each bad name produces one message, for the first check it fails. The overall se ## Tool slots and Tool objects -Each `tools:` entry declares a tool slot: an alias, which follows the prompt's [name grammar for aliases](02-file-structure.md#names-for-aliases-roles-and-args), bound to one tool path. The first two segments of the path name the declared capability that supplies the tool. +Each `tools:` entry declares a tool slot: an alias, which follows the prompt's [name grammar for aliases](02-file-structure.md#names-for-aliases-roles-and-args), bound to one tool path. The first two segments of the path name the declared capability that supplies the tool. Because each alias becomes a Lua global of its own name, an alias may not be one of the [reserved names](02-file-structure.md#reserved-names-for-aliases-and-role-labels), such as `store` or `pairs`, nor a label under `models:`. Prepare [fills each slot](04-how-a-prompt-runs.md#filling-tool-slots-and-model-roles) by exact match of its tool path against the run's tool catalog, which holds the activated capabilities' tools in declaration order. A slot whose path matches becomes a bound tool slot, and it stays bound to that same tool for the whole run. Slots are bound before any Lua runs, so Lua only chooses which bound slots the model sees, and scoping an alias that is not bound is an error. diff --git a/guide/src/language/17-limits-and-errors.md b/guide/src/language/17-limits-and-errors.md index c60b3a646..59607f434 100644 --- a/guide/src/language/17-limits-and-errors.md +++ b/guide/src/language/17-limits-and-errors.md @@ -229,7 +229,7 @@ Common mistakes land in predictable kinds: malformed YAML and an out-of-range `m invalid frontmatter: {message} ```` -A value the contract rejects, such as a malformed capability id or an out-of-range `max_tool_iterations`, gives that key's own message. +A value the contract rejects, such as a malformed capability id, an out-of-range `max_tool_iterations`, or a tool alias that is a [reserved name](02-file-structure.md#reserved-names-for-aliases-and-role-labels), gives that key's own message. ### Structure @@ -285,7 +285,7 @@ A parse failure comes with a location when the parser can point at the problem. ### Frontmatter failures -A frontmatter failure, whether the YAML is invalid or the contract rejects a value, gives a 1-based line and a 1-based column. For a capability entry on line 5 whose value is not a capability id, the failure reports line 5 and column 5, where the value starts after the ` - ` list marker. +A frontmatter failure, whether the YAML is invalid or the contract rejects a value, gives a 1-based line and a 1-based column. For a capability entry on line 5 whose value is not a capability id, the failure reports line 5 and column 5, where the value starts after the ` - ` list marker. The one exception is a name declared both under `tools:` and under `models:`, which spans two keys and gives neither a line nor a column. A frontmatter failure has no prompt name, because the name comes from the frontmatter itself. Its location path is the placeholder ``, and the host may label the failure with its own name for the file instead. diff --git a/guide/src/language/18-quick-reference.md b/guide/src/language/18-quick-reference.md index 8986b3076..e965508e8 100644 --- a/guide/src/language/18-quick-reference.md +++ b/guide/src/language/18-quick-reference.md @@ -45,6 +45,7 @@ Every frontmatter key and value rule, with top-level keys first and nested keys | Name grammar for aliases, role labels, and arg names | `[A-Za-z][A-Za-z0-9_-]{0,63}` | none | [Prompt File Structure](02-file-structure.md#names-for-aliases-roles-and-args) | | `output.description` | string | none, required | [Prompt File Structure](02-file-structure.md#input-and-output-files) | | `output.path` | store filename, such as `report.md` | none, required | [Prompt File Structure](02-file-structure.md#input-and-output-files) | +| Reserved names for tool aliases and role labels | no host global, sandbox Lua global, or Lua keyword, such as `store`, `argv`, `pairs`, or `end`, and no name under both `tools` and `models`; the chapter lists every one | none | [Prompt File Structure](02-file-structure.md#reserved-names-for-aliases-and-role-labels) | | Tool path in `tools.{alias}` | `namespace/pack/name`, such as `promptforge/web/fetch` | none | [Tools](12-tools.md#capability-ids-and-tool-paths) | | `tools.{alias}` | tool path string | none | [Tools](12-tools.md#tool-slots-and-tool-objects) | @@ -248,11 +249,11 @@ Every section VM runs Lua 5.5 with these libraries and base functions. | `_VERSION` | `_VERSION` | the Lua version string | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `assert` | `assert(condition, message)` | raises `message` when `condition` is false | [The Lua Environment](05-lua-environment.md#calls-that-wait-and-errors-that-raise) | | `error` | `error(message)` | raises `message` | [The Lua Environment](05-lua-environment.md#calls-that-wait-and-errors-that-raise) | -| `getmetatable` | `getmetatable(v)` | as in standard Lua 5.5; `var` and `sys` give a guard string | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | +| `getmetatable` | `getmetatable(v)` | as in standard Lua 5.5; `var` and `sys` give a guard string, and `_G` gives the metatable you set or nil | [The Lua Environment](05-lua-environment.md#your-own-metatable-on-_g) | | `ipairs` | `ipairs(t)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `math` library | `math.{name}(...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `select` | `select(n, ...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | -| `setmetatable` | `setmetatable(t, mt)` | `t` | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | +| `setmetatable` | `setmetatable(t, mt)` | `t`; on `_G`, `mt` composes behind the `argv` and `prose` guard | [The Lua Environment](05-lua-environment.md#your-own-metatable-on-_g) | | `string` library | `string.upper(s)`, `s:match(pattern)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `table` library | `table.{name}(...)` | as in standard Lua 5.5 | [The Lua Environment](05-lua-environment.md#the-sandbox-and-its-globals) | | `table.concat` | `table.concat(list, sep, i, j)` | joined string; `__tostring` values render with `tostring` | [The Lua Environment](05-lua-environment.md#standard-lua-and-host-calls) | @@ -266,7 +267,7 @@ These globals, fanout result fields, and error value fields need no declaration. | Name | Form | Returns | Taught in | |---|---|---|---| -| `{alias}` | `{alias}` | Tool object for that bound tool slot | [Tools](12-tools.md#tool-slots-and-tool-objects) | +| `{alias}` | `{alias}` | Tool object for that bound tool slot; never a reserved name | [Tools](12-tools.md#tool-slots-and-tool-objects) | | `args` | `args` | the raw argument string | [Arguments](06-arguments.md#input-basics) | | `argv` | `argv` | the parsed argument string; `{ prose = args }` without `args:`, nil when structured input is not JSON | [Arguments](06-arguments.md#prose-input-and-structured-input) | | `call` | `call(target, input?)` | the called chain's result as a string | [Jump and Call](08-jump-and-call.md#jump-and-call-at-a-glance) | @@ -285,7 +286,7 @@ These globals, fanout result fields, and error value fields need no declaration. | `item` | `item` | the arm's member inside a fanout arm, or a task's `item` option | [Fanout](14-fanout.md#inside-an-arm) | | `item.key` and `item.value` | `item.key`, `item.value` | a keyed member's key and value | [Fanout](14-fanout.md#collections-and-member-order) | | `jump` | `jump(target)` | nothing; ends the block and the walk continues at `target` | [Jump and Call](08-jump-and-call.md#jump-and-call-at-a-glance) | -| `{label}` | `{label}` | model handle for that bound role | [Models](10-models.md#model-handles) | +| `{label}` | `{label}` | model handle for that bound role; never a reserved name | [Models](10-models.md#model-handles) | | `list_from_section` | `list_from_section(heading)` | 1-based array of the list section's item strings | [Blocks and Prose](03-blocks-and-prose.md#reading-list-items-from-lua) | | `log` | `log(message)` | nothing; records a `lua` checkpoint event | [The Lua Environment](05-lua-environment.md#checkpoints-with-log) | | `messages` | `messages.new()` | the `messages` namespace | [Conversations](11-conversations.md#building-message-lists) | diff --git a/vibe/2026-09-28-4-internal-crates-critical-fixes.md b/vibe/2026-09-28-4-internal-crates-critical-fixes.md new file mode 100644 index 000000000..ad1e4de54 --- /dev/null +++ b/vibe/2026-09-28-4-internal-crates-critical-fixes.md @@ -0,0 +1,389 @@ +--- +name: Internal crates critical fixes +overview: "Fix the three critical defects found in the promptforge-internal review (cancellation swallowed by pcall, an out-of-bounds panic when a walk ends with a live model task, and host-backend symlink operations acting on link targets), and sweep up a set of small, mechanical fixes: dead scheduler state, defensive arithmetic, the _G guard lock, dead re-exports, stale docs and comments, and two flat-directory folds." +todos: + - id: lua-cancel + content: "Lua cancellation: cancel_requested capture; re-raise in pcall, xpcall, local handler call, compactor call; tests" + status: pending + - id: chain-section-label + content: "Engine: record entered section name on Chain; section_name reads it; walk-end and between-sections notice tests" + status: pending + - id: host-symlinks + content: "VFS host: no-follow resolution for remove, exists, stat, mkdir, rename; Windows directory links; symlink tests" + status: pending + - id: scheduler-cleanup + content: "Engine: delete dead call stack; saturating slot release; const nz_*; concurrent-call test" + status: pending + - id: lua-guard-lock + content: "Lua: __metatable on _G guard metatables in argv.rs and prose.rs; guard test (resolved by a separate change, not a step of this plan; see the Decision Record)" + status: completed + - id: dead-items + content: Remove NormalizedTurn non_exhaustive, unused StreamDelta and metrics re-exports; rename promptforge-api- temp prefixes + status: pending + - id: docs-sweep + content: Engine AGENTS/README/lib.md/Cargo description; types, lua, model-client, container README; new vfs README; root AGENTS.md line 55 + status: pending + - id: comments-sweep + content: Fix stale comments and remove history/audit-tag comments across engine, vfs, model-client, types, parser + status: pending + - id: dir-folds + content: Fold engine run-* into execute/run/ and recording-* into test_support/recording/ + status: pending + - id: verify + content: AGENTS.md gates, cargo deny, xtask api --check unchanged, facade docs build + status: pending +isProject: false +--- + +# Internal crates: critical fixes and safe sweep + + + +## Product Requirements + +A review of `crates/promptforge-internal/` at `8622b227` found three defects that can hang a host, crash it, or delete a file on disk. This plan fixes all three, each with a regression test that drives the exact failing path, and sweeps up small fixes that are mechanical or clearly correct and need no design decision. Everything the review found that needs a decision or real design work is listed under Deferred and Out of Scope. + +- Problem and users: + - Hosts and operators. A cancelled run whose Lua code loops inside `pcall` without yielding never returns from `Run::step`: the cancellation hook raises an ordinary `mlua::Error::RuntimeError` (`crates/promptforge-internal/lua/src/hardening.rs`, lines 145 to 148), and the shim's `pcall` and `xpcall` replacements return every caught failure without re-raising (`crates/promptforge-internal/lua/src/__impl_coro.lua`, lines 74 to 87). The instruction budget is effectively unlimited (`crates/promptforge-internal/lua/src/lib.rs`, line 69), so cancellation is the only way to stop a runaway loop. + - Hosts. `Run::step`, documented as infallible, panics when a walk runs off its last section while the chain owns a live model-origin task. `end_section` advances `chain.index` past the section (`crates/promptforge-internal/engine/src/execute/scheduler/walk.rs`, line 230), the walk calls `finish` (lines 150 and 192), `finish` settles owned tasks (`scheduler/chain.rs`, line 175), abandonment queues a model-task notice (`scheduler/tasks.rs`, line 471), and `queue_task_notice` calls `section_name()` (`scheduler/notices.rs`, line 72), which indexes `slice[self.index]` out of bounds (`scheduler.rs`, line 383). The same lookup reports a notice under a section the owner has not entered when a model task ends between sections (`scheduler/tasks.rs`, line 346). + - Operators using the host backend. In rooted mode every operation resolves through `contain`, which canonicalizes the full path when it exists (`crates/promptforge-internal/vfs/src/host.rs`, lines 131 to 133 and 392 to 399). Canonicalization follows a symlink at the final component, so `remove` (lines 494 to 516) deletes the link's target, and `rename`, `stat`, `exists`, and `mkdir` act on the target too. A link pointing outside the root cannot be removed at all. The trait contract says remove acts on the link, never the target (`crates/promptforge-internal/vfs/src/traits.rs`, lines 167 to 169), and the host code already uses `symlink_metadata` expecting link semantics (`host.rs`, lines 503 to 505, 520 to 528, 575 to 576). +- Goals: + - Cancellation always unwinds a Lua block, through any number of `pcall` or `xpcall` layers. + - No chain-end path indexes past its slice; task notices and other reports carry the section the chain most recently entered. + - Host-backend operations that act on a path itself act on a symlink as a link; operations that act on contents follow links and keep the containment check. + - The small fixes in Functional Specification land with no change to the facade's public API listing. +- Non-goals: + - Any item under Deferred and Out of Scope. + - Changes to `crates/promptforge/public-api.txt`. + - Splitting oversized files or enabling the 500-line check. +- Success criteria: + - A run cancelled while its block runs `while true do pcall(function() while true do end end) end` returns from `Run::step` with a cancelled outcome; the same holds for `xpcall`, a local tool handler that loops, and a compactor that loops. + - A prompt whose last section falls through while a model-origin task is live finishes without a panic, reports `TaskAbandoned`, and reports the notice under that last section. + - On the host backend, `remove("/link")` removes the link and leaves the target intact; a link to a path outside the root can be removed, stat'ed, checked for existence, and renamed; reading or writing through a link that escapes the root is still refused. + - Every check in Testing Plan passes and the facade listing is unchanged. +- Constraints: + - The engine performs no I/O and adds no effect kind or suspension point. + - Repository rules in `AGENTS.md` apply: comments state only non-obvious constraints, behavior changes ship with tests, errors state required versus actual. + - `promptforge-vfs` stays dependency-free (`crates/promptforge-internal/vfs/src/lib.rs`, line 191). +- Open questions: None + +## Functional Specification + +Cancellation becomes uncatchable by author code while the cancel flag is set. Chains remember the name of the section they last entered, so reports never need the current walk position to be valid. The host backend gains a no-follow resolution for operations on the path itself. The sweep is small, independent edits. + +- Actors and workflows: + - Author Lua under cancellation: any `pcall` or `xpcall` whose protected call fails while the run's cancel flag is set re-raises the failure instead of returning it. The shim's internal protected calls do the same: the local tool handler call (`__impl_coro.lua`, line 129) and the compactor call (line 208). The block guard (line 339) stays the boundary that returns the failure to Rust. + - Scheduler reporting: `section_name()` returns the prompt title during the live H1 pass, and otherwise the name of the section the chain most recently entered. Before a walking chain enters any section, it returns the name of the slice's first section, or the prompt title when the slice is empty. `section()` and `blocks()` keep indexing the live section and are only called with a live frame. + - Host backend: two resolutions. Follow resolution, which is today's `contain`, is used by `read`, `read_range`, `write`, `append`, `list`, `glob`, and `copy`. No-follow resolution canonicalizes only the parent and appends the final component unchanged, still requiring the parent to sit under the root. It is used by `remove`, `exists`, `stat`, `mkdir`, and both sides of `rename`. The mounted root `/` resolves to the root in both. + - On Windows, removing a directory symlink uses `fs::remove_dir` rather than `fs::remove_file`, because `remove_file` fails on a directory link. +- Inputs and outputs: + - Cancelled outcome: an uncaught cancellation still maps to the run's cancelled result at the VM failure boundary, as today. + - Task notices and `TaskAbandoned` events for a chain between sections, or past its last section, carry the section it last entered. +- States and validation: + - The shim obtains the cancel state through a new prelude capture backed by `InstructionBudget::is_cancelled` (`crates/promptforge-internal/lua/src/hardening.rs`, line 123). + - The recorded section name is set when `enter_section` enters a section and survives `end_section` and `finish`. +- Errors and recovery: + - The containment check for follow resolution is unchanged: a read or write whose final target escapes the root is refused with the existing "escapes the mounted root" denial. + - No-follow resolution refuses a path whose parent escapes the root with the same denial. +- Security and privacy behavior: + - A cancelled run can no longer keep a host thread busy indefinitely. + - Removing or renaming a link never touches the target, inside or outside the root. + - `_G`'s guard metatable carries `__metatable`, so `setmetatable(_G, ...)` can no longer strip the frozen `argv` and read-only `prose` guards, matching every other guard in the crate (`crates/promptforge-internal/lua/src/sys.rs`, line 93; `crates/promptforge-internal/lua/src/proxy.rs`, line 26). +- Acceptance criteria: + - Every Success criterion holds and every Testing Plan check passes. + + + + +## Technical Design + +All three critical fixes are local to one crate each: the Lua shim and its prelude captures, the scheduler's chain state, and the host backend's path resolution. The sweep touches documentation and comments across the six crates, a few dead items, and two directory layouts. No public facade item changes. + +- Architecture: + - Lua: cancellation is re-raised in Lua, at the only places author and shim code can catch errors, so no Rust-side error type needs to change. + - Engine: the section label becomes chain state instead of a derived lookup, which removes the dependency on a valid walk position from every report path. + - VFS: path resolution splits by intent, so each operation picks link or target semantics explicitly. +- Modules and interfaces: + - `crates/promptforge-internal/lua/src/coro.rs`: `install_shim_prelude` (line 156) passes a `cancel_requested` capture reading the VM's `InstructionBudget`. `crates/promptforge-internal/lua/src/__impl_coro.lua`: `protected_call` and `protected_xcall` (lines 74 to 87), the local handler call (line 129), and the compactor call (line 208) re-raise when it returns true. + - `crates/promptforge-internal/engine/src/execute/scheduler.rs`: `Chain` gains the recorded section name; `section_name()` (lines 397 to 405) reads it. `crates/promptforge-internal/engine/src/execute/scheduler/walk.rs`: `enter_section` sets it. + - `crates/promptforge-internal/vfs/src/host.rs`: a no-follow sibling of `contain` (lines 123 to 150) and of `HostAccess::resolve` (lines 392 to 399); `remove`, `exists`, `stat`, `mkdir`, and `rename` switch to it. +- File and public API changes: + - No facade item changes; `crates/promptforge/public-api.txt` stays identical. + - Deleted: the scheduler's `stack` field and its uses (`scheduler.rs`, lines 429 to 431 and 499; `scheduler/dispatch.rs`, line 314; `scheduler/chain.rs`, lines 208 to 212 and 254 to 258; `scheduler/drive.rs`, line 199). Its only readers are a `clear`, a `debug_assert` pop, and a conditional pop, so nothing decides from it. + - Moved: the engine's `run-effect.rs`, `run-effect-tests.rs`, and `run-tests.rs` into `crates/promptforge-internal/engine/src/execute/run/`, and `recording-forward.rs`, `recording-forward-tests.rs`, and `recording-observation.rs` into `crates/promptforge-internal/engine/src/test_support/recording/`, per the three-file directory rule in `AGENTS.md`. + - New: `crates/promptforge-internal/vfs/README.md`. +- Data, persistence, failure, security, and privacy constraints: + - No log, replay, or wire format changes. + - Release builds can no longer wrap the task slot counter: `slots_used -= 1` (`scheduler/tasks.rs`, line 591) becomes a saturating subtraction, and the `debug_assert` above it stays. + + + + +## Testing Plan + +Each critical fix gets a regression test that reproduces the exact failing path and fails before the fix. The sweep relies on the existing suites, the unchanged facade listing, and the docs build. Symlink tests run on Unix and on Windows when symlink creation is permitted. + +- Unit: + - Lua, under a set cancel flag: a block running `while true do pcall(function() while true do end end) end` ends with the cancelled error; the same with `xpcall` and a message handler; a local tool handler that loops ends the block; a compactor that loops ends the block. A `pcall` that catches an ordinary error while the flag is clear still returns `false, err`. + - Lua: `setmetatable(_G, nil)` and `getmetatable(_G)` no longer expose or remove the `argv` and `prose` guards; assigning `argv` outside H1 is still refused afterwards. + - VFS host backend: remove a link to a file inside the root and assert the target survives; remove, stat, exists, and rename a link whose target is outside the root; reading and writing through that link are still refused; remove a dangling link; on Windows, remove a directory link. Windows tests return early only when symlink creation fails with the privilege error, and say so in the test name or message. +- Integration and end-to-end: + - Engine: a prompt whose last section runs `tools.allow_tasks()`, lets the model start a task through the canned model, and falls through while the task is live. It asserts no panic, a `TaskAbandoned` event, and a task notice under that section's name. + - Engine: a model task that ends while its owner sits between two sections reports its notice under the section just ended. + - Engine: two concurrently admitted tasks that each `call` a section issuing a chat, answered in reverse issue order, complete in a debug build. +- Regression, security, and performance: + - `cargo xtask api --check` reports no difference from `crates/promptforge/public-api.txt`. + - The existing symlink escape tests in `crates/promptforge-internal/vfs/src/host.rs` (around line 787) still pass. +- Exit criteria: + - The `AGENTS.md` verification commands (`AGENTS.md`, lines 49 to 55). + - `cargo deny check`, which CI runs (`.github/workflows/ci.yml`, lines 340 to 341). + - The facade docs build that CI gates, which catches broken intra-doc links from the file moves. + + + + +## Decision Record + +The critical fixes are the smallest changes that close each failure path at its root. The sweep is limited to items that are mechanical or clearly correct, change no public API, and need no design call. Anything that alters behavior authors or hosts could depend on, or that needs a decision, is deferred. + +- Decisions: + - Fix the three critical defects. User's words: "Lets plan to fix the critical items and also sweep up the little fixes which you think are safe". + - Re-raise cancellation in the Lua shim when the cancel flag is set, instead of making the hook error uncatchable in Rust. Rationale: the shim already owns every `pcall` and `xpcall` author code can reach, and the flag check is one capture. + - Record the entered section name on the chain instead of guarding each `section_name()` caller. Rationale: it fixes both the out-of-bounds panic and the notice mislabeled between sections, and no future caller can reintroduce the bug. + - Split host-backend resolution into follow and no-follow instead of special-casing `remove`. Rationale: `rename`, `stat`, `exists`, and `mkdir` share the bug, and the existing `symlink_metadata` calls already expect link semantics. + - Include the `_G` `__metatable` lock in the sweep. Rationale: every other guard in the crate already sets it, and without it both `_G` guards can be removed in one call. It does stop author code from replacing `_G`'s metatable, which nothing in the tree does. + - Delete the scheduler's call stack rather than make it concurrency-aware. Rationale: nothing decides from it; it exists only to feed a `debug_assert` that fails when concurrent tasks each `call`. +- Rejected alternatives: + - Raising cancellation as a Rust `mlua::Error::external` value that `pcall` cannot catch. Reason: Lua's `pcall` catches every error value, so this would not help. Revisit: never. + - Returning a placeholder section name from `section_name()` when the index is past the slice. Reason: it would still mislabel notices between sections. Revisit: never. + - Fixing only `remove`. Reason: `rename`, `stat`, `exists`, and `mkdir` would still act on targets. Revisit: never. +- Assumptions, risks, and notes: + - Cancellation takes effect at the next hook firing, every 10,000 instructions (`crates/promptforge-internal/lua/src/lib.rs`, line 62); a loop that yields was already cancellable through the scheduler's teardown. + - `fs::remove_dir_all` does not follow symlinks on current stable Rust, so a recursive remove of a directory containing links stays inside it. + - Moving the `run-*` and `recording-*` files changes `#[path]` attributes and possibly intra-doc links; the docs build catches breakage. + - The Lua review's findings on `var.x = nil`, empty-looking proxies, the unused `models.infer` path, and the duplicate store implementation were reported by one reviewer and not re-checked; they are deferred for that reason as well. +- Resolved on 2026-09-28 by a separate change outside this plan: the `_G` `__metatable` lock (todo `lua-guard-lock`), first raised here as an open decision by the project survey. + - Resolution: option (c) below. `_G` now carries one guard metatable with `__metatable` set (`crates/promptforge-internal/lua/src/globals.rs` and `__impl_globals.lua`), serving `argv` and `prose` for every section. The sandbox's `setmetatable` and `getmetatable` are replacements that record an author's `_G` metatable behind the guard, so the documented pattern keeps working, both pinning tests pass unchanged, and no route in the sandbox removes or bypasses the guard. The guide passages are updated to match. This plan builds nothing for it. + - The decision above to include the lock rests on nothing in the tree replacing `_G`'s metatable. The user guide documents doing exactly that: "Your own metatable on _G" in `guide/src/language/05-lua-environment.md` (line 210) shows `setmetatable(_G, { __index = defaults })` in the shared library, and `guide/src/language/03-blocks-and-prose.md` (line 722) shows the same pattern. Two tests pin it: `section_vm_host_injection_bypasses_shared_global_metatables` (`crates/promptforge-internal/lua/src/tests.rs`, line 1177) and `lazy_prose_composes_with_a_shared_library_metatable` (`crates/promptforge-internal/engine/src/execute/tests/suite/lazy_prose.rs`, line 227). + - `inject_host_with_var` installs the frozen `argv` guard on `_G`'s metatable (`crates/promptforge-internal/engine/src/execute/section_vm.rs`, line 139) before `replay_shared` runs the shared library (line 163). With `__metatable` set on that guard, the documented `setmetatable(_G, ...)` raises "cannot change a protected metatable", the shared library fails to load, and both tests fail. + - By reading the same code (not by running it), the documented pattern today replaces the `argv` guard in every non-H1 section, and the prose guard then delegates the `argv` lookup to the author's `__index`, so `argv` reads nil there in the guide's example. This is the pre-existing gap the lock was meant to close. + - Options: (a) defer the lock together with the guard-composition question; (b) lock as specified and retire the documented pattern, rewriting both guide passages and both tests, an author-visible change; (c) keep author metatables and make them compose under the guards, for example a sandbox `setmetatable` that installs an author's `_G` metatable as the guards' delegate, which is new design. This Decision Record's opening rule defers anything authors could depend on, which favors (a). + - No step of this plan builds the Functional Specification's `_G` bullet (under Security and privacy behavior) or the Testing Plan's `_G` unit test; the separate change built and tested both, so Decomposition need not be rerun for them. + +### Deferred and Out of Scope + +- Deferred: an optional capability that backs a tool slot refuses the run (`crates/promptforge-internal/engine/src/execute/fill.rs`, lines 42 to 54). Needs a decision on whether optional capabilities may back tool slots. Revisit first. +- Deferred: the engine's `test-support` feature and public `test_support` module (`crates/promptforge-internal/engine/Cargo.toml`, lines 25 to 38; `engine/src/lib.rs`, lines 11 to 12). Needs a decision on whether the feature removal was meant to include the engine. +- Deferred: `Environment::max_depth`, which does nothing but is on the facade (`crates/promptforge/public-api.txt`, line 420). Removing it changes the public API. +- Deferred: narrowing the engine's unused public items (`pub mod model`, `pub mod parser`, root re-exports, `StoreOp`, `StoreOutcome`), which touches doctests and a bench. +- Deferred: VFS claim gaps (nested subtrees, created parent directories, list against recursive remove), Windows case and stream aliasing, and glob and grep ignoring nested mounts. Revisit as one claims-model plan. +- Deferred: `Completion::from_result` and `ToolCall::from_parts` skipping live-path validation, and restructuring `ClientError`. +- Deferred: the cancellation waker leak in `crates/promptforge-internal/types/src/cancel.rs` (lines 100 to 141). +- Deferred: parser span offsets, the indented `---` frontmatter close, headings inside blockquotes and lists, and missing error locations. +- Deferred: the Lua findings on `var.x = nil`, proxy enumeration, the unused `models.infer` path, and the duplicate store implementation, after re-verification. +- Deferred: a dropped live timer that never wakes its waiter, answers ignored after the run is decided, `unwrap_or_else(|| panic!(..))` sites, SSE edge cases, the vfs manifest test's parsing holes, and plan mode's `.md`-suffix check. +- Deferred: splitting the oversized files and adding the `## Invariants` marker so the 500-line check applies. Revisit after the splits, so the check does not fail the build on arrival. +- Out of scope: the `product-*` and `listing-*` directory folds in `crates/build-xtask/src/`, which are outside the internal crates. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p `. Plain `cargo build` builds only the default member, `crates/gateway/app` (package `gateway`). The six internal crates (`promptforge-engine`, `promptforge-lua`, `promptforge-vfs`, `promptforge-model-client`, `promptforge-types`, `promptforge-parser`) and the `promptforge` facade build with no UI or native prerequisites. Crates whose build scripts bundle a UI into `OUT_DIR` (`workshop-server`, `gateway-config-ui`, and their dependents, which the workspace-wide runs include) need `npm ci --prefix crates/workshop` and `npm ci --prefix crates/gateway/config-ui/ui` first. Before any `-p workshop` build, CI builds `cargo build --locked -p gateway --no-default-features` and stages it with `node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe` (undo with `node tools/stage-gateway-sidecar.mjs remove --target x86_64-pc-windows-msvc`). The clippy and full-suite runs build every member they check, so a separate workspace build adds nothing beside them, and a standalone `cargo check --workspace` never runs beside clippy. `.cargo/config.toml` aliases `cargo xtask` to `run -p build-xtask --` and `cargo workshop` to `run -p build-workshop --`, and links Windows builds with `rust-lld` and the static CRT. +- Focused test command pattern: `cargo nextest run --locked -p --all-features `, where `` is one or more test-name or module-path substrings (nextest runs a test matching any of them), such as `host::tests`, `model_task_notices`, or `scheduler::concurrency`. The internal crates keep every test inside `src/` as unit-test modules and have no integration target; the `promptforge` facade's integration target is `--test suite`. Drop `--all-features` for `workshop`, `workshop-server`, and `workshop-server-api`. Nextest skips doctests, so a doc example needs `cargo test --locked --doc -p --all-features`. `.config/nextest.toml` marks a test slow at 60 seconds and terminates it after three periods, 180 seconds. +- Component test command pattern: `cargo nextest run --locked -p --all-features`, then `cargo test --locked --doc -p --all-features` (the workshop trio without `--all-features`). The structural harness alone: `cargo test -p build-xtask`; its nightly-only fixtures: `cargo +nightly-2026-09-05 nextest run --locked -p build-xtask --run-ignored only`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. CI adds `cargo nextest run --locked -p workshop-workspace --all-features`, `cargo nextest run --locked -p workshop-server --features headless`, the gateway process-ownership race tests, and the UI `npm test` runs. The workspace run includes `build-xtask`, the structural harness. +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings` and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`, plus the headless gate `cargo check -p gateway --no-default-features`, `cargo deny check`, and `cargo audit` (CI's `supply-chain` job; `cargo-deny` 0.20.2 and `cargo-audit` 0.22.2 are installed locally). Per-package pattern for scoped runs: `cargo clippy --locked -p --all-targets --all-features -- -D warnings`. The UI typechecks (`npm run typecheck --workspaces --if-present` from `crates/workshop`, `npm run typecheck` from `crates/gateway/config-ui/ui`) apply only to UI changes. `.githooks/` holds a pre-commit hook (the formatter check) and a pre-push hook (the headless check, the workspace clippy, `cargo deny check`); neither is installed in this clone, since `core.hooksPath` is unset. +- Formatter check command: `cargo fmt --all --check` (`rustfmt.toml` sets `style_edition = "2024"`). No UI formatter or JS linter is configured. +- Docs command: with `RUSTDOCFLAGS` set to `-D warnings` (PowerShell: `$env:RUSTDOCFLAGS='-D warnings'`), run `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, then the facades alone with default features, `cargo doc -p promptforge --no-deps` and `cargo doc -p harness --no-deps`, then the engine's private items, `cargo doc --locked --no-deps --all-features -p promptforge-engine --document-private-items`. These are CI's `docs` job; CI's `check-workshop` job also builds `cargo doc --locked --no-deps -p workshop-server --document-private-items`. Per-package pattern: `cargo doc --locked --no-deps --all-features -p ` under the same flag. The facade surface check is `cargo +nightly-2026-09-05 xtask api --check` (the nightly pinned in `crates/build-xtask/src/api/toolchain.rs`, installed locally), compared against the committed `crates/promptforge/public-api.txt`. User guide: `cargo xtask site --books-only`; the combined guide regenerates with `cargo run --locked -q -p build-user-guide`. +- Test placement and naming conventions: + - Unit tests sit in a sibling file `-tests.rs`, wired as `#[cfg(test)] #[path = "-tests.rs"] mod tests;` (for example `engine/src/execute/requirements-tests.rs`, `lua/src/prelude-tests.rs`, `types/src/event-tests.rs`, `model-client/src/client/stream-tests.rs`). Small modules keep an inline `#[cfg(test)] mod tests {}` at the bottom (`engine/src/execute/config-limits.rs`, `parser/src/list.rs`), and `promptforge-vfs` uses inline blocks throughout (`vfs/src/host.rs` from line 645, whose helper `make_dir_link` makes a junction on Windows through `mklink /J` and a symlink on Unix). VFS tests return `Result<(), VfsError>`. + - `promptforge-engine`'s behavior suite is `src/execute/tests/`, declared by `execute/tests.rs`: one file per area (`model_task_notices.rs`, `model_tasks.rs`, `local_tools.rs`, `models_loop_compactors.rs`, `tool_loop.rs`, `run_termination.rs`, and others) plus `scheduler/` (`concurrency.rs`, `walk.rs`, `failures.rs`, `fanout.rs`, `live_h1.rs`, `store_gate.rs`) and `suite/`. These tests drive whole runs through the `test_support` drivers against canned or scripted models. `cancel_during_in_flight_tool_call_returns_promptly` (`tool_loop.rs`, line 470) is the mid-run cancel pattern: a multi-thread tokio test, `TokioDriver::cancel_handle`, a cancel after 100 ms, and an `Err(crate::Error::Interrupted)` assertion within 5 seconds. `engine/tests/prompts/` holds fixture prompts, not a test target. + - `promptforge-lua` keeps one large `src/tests.rs` beside its `-tests.rs` siblings, `protocol/tests/`, and `tools/tests.rs`; shared helpers sit in `tests-recording.rs`. Its cancellation tests, `long_running_lua_block_cancels_cooperatively` and `a_pre_cancelled_run_aborts_a_tight_loop_promptly`, are in `tests.rs`. `prelude-tests.rs`'s `section_vm_with_var` builds a section VM with the scheduler control globals and coroutine shims installed, the setup under which the shim's `pcall` and `xpcall` replacements are live; no Lua-crate test drives the shim's local-tool or compactor paths, which the engine suites reach through the scheduler. + - Integration targets: `tests/suite/` in the `promptforge` and `harness` facades, `tests/it/` in harness and workshop crates. Each `main.rs` opens with `#![expect(clippy::expect_used, clippy::unwrap_used, reason = ...)]`. + - Shared fixtures live in `test_support` modules behind a `test-support` feature (engine, lua, parser, runner, sessions) or a `test-fixtures` feature (gateway and workshop crates). + - Test functions are snake_case sentences stating the behavior, such as `a_rooted_backend_rejects_links_that_escape_the_mount_root`. `clippy.toml` allows `unwrap` and `expect` in tests; the workspace lints deny both elsewhere. + - Benches: `engine/benches/models_loop.rs` and `lua/benches/surface.rs`, both requiring `test-support`. +- Directory map: + - `crates/`: every Rust crate and UI package. Its root is the public layer: the `promptforge` facade (`src/`, `tests/suite/`, `public-api.txt`), the `harness` facade, `gateway-api-types`, `gateway-api-discovery`, `shared-error-source`, `shared-loopback`, `shared-ui` (TypeScript and CSS, not a crate), the `build-*` tooling crates, and `workspace-hack`. + - `crates/promptforge-internal/`: a manifestless container holding `engine` (`src/execute/` with `scheduler/` and `tests/`, `src/test_support/`, `src/lua/`, `src/model/`, `src/lib.md` as the crate doc, `benches/`, `tests/prompts/`), `lua` (the section VM, the Lua shims `__impl_coro.lua`, `__impl_fanout.lua`, `__impl_messages.lua`, `__impl_store.lua`, `__impl_tasks.lua`, plus `protocol/` and `tools/`), `vfs` (a flat `src/`, std only), `model-client` (`client/`, `model/`), `types` (`tools/`), and `parser` (`contract/`). Each crate has `AGENTS.md` and `Cargo.toml`; all but `vfs` have `README.md`; the container's `README.md` describes the six. + - `crates/harness-internal/`, `crates/workshop/`, and `crates/gateway/`: the other family containers (the harness runner, models, capabilities, log, sessions, and web crates; the Workshop desktop app, server, and UI npm workspaces; the gateway app and its subsystems, including the nested `stt/`). + - `guide/`: user guide chapter sources (`src/language/` among them), mdBook books, and the combined `guide/promptforge-language-guide.md`. `prompts/`: sample prompts. `tools/`: Node and Python maintenance scripts. + - `vibe/`: `archdoc.md`, dated plan and run records (`YYYY-MM-DD-N-slug.md`), reference notes, and a gitignored `scratch/`. + - `.github/workflows/ci.yml` (jobs `fmt`, `clippy`, `test`, `docs`, `check-workshop`, `check-workshop-linux`, `ui`, `supply-chain`, `api-surface`, and the aggregate `ci-green`), `.githooks/`, `.config/nextest.toml`, `.cargo/config.toml`, and at the root `Cargo.toml` (an explicit container member list; `default-members` is the gateway app), `rust-toolchain.toml` (stable), `clippy.toml`, `rustfmt.toml`, and `deny.toml`. +- Component boundaries: + - `promptforge` is a facade of single-item re-exports over `crates/promptforge-internal/*` and the only promptforge crate that code outside the family may name. `promptforge-*` crates depend on no gateway, workshop, or harness crate; an internal crate may list `promptforge` as a dev-dependency only so its doc examples compile. + - Inside the container dependencies run one way: `promptforge-vfs` depends on nothing, which its manifest test enforces; `promptforge-types` depends on no sibling; `promptforge-model-client` on types; `promptforge-lua` on model-client, types, and vfs; `promptforge-parser` on lua and types; `promptforge-engine` on all five. The engine performs no I/O and names tokio only behind `test-support`. + - `harness` fronts `crates/harness-internal/*`, whose crates depend only on `promptforge`. Workshop crates may name `harness`, `promptforge`, the gateway public pair, and `shared-*`. Gateway private crates depend on no promptforge, harness, or workshop crate. `shared-*` crates depend on no product crate. + - `cargo test -p build-xtask` enforces the topology, the `## Invariants` markers in workshop-* and harness-* crates, lint inheritance, and the 500-line ceiling in marker crates. None of the six internal crates carries the marker, so the ceiling does not bind them. `cargo xtask api --check` enforces the facade surface. +- Conventions summary: + - Rust 2024 edition on the stable toolchain. Workspace lints every member inherits: `unsafe_code = "forbid"`; `missing_docs`, `missing_debug_implementations`, and `unreachable_pub` warn; clippy `all` and `pedantic` denied; `unwrap_used` and `expect_used` denied; broken and private intra-doc links denied, so removing an item a doc comment links to fails the docs build. + - Source directories are flat: one or two files beside a parent module are `foo-bar.rs` wired with `#[path = "foo-bar.rs"] mod bar;`; at three they become a `foo/` subdirectory in standard layout with no path attributes, and they flatten back below three. At survey time `engine/src/execute/` holds three `run-*` siblings and `engine/src/test_support/` holds three `recording-*` siblings, both on the wrong side of the line. + - Behavior changes ship with tests in the same change. No new structural enforcement without explicit user approval. + - Error and status messages are written for a model reader: concise, self-contained, naming required versus actual. + - Comments explain only a non-obvious constraint, ordering requirement, or workaround; every workaround cites its upstream issue URL. + - JSON that reaches the run log round-trips exactly: sorted keys, finite numbers, `float_roundtrip`, never `preserve_order`. + - Cargo features gate real constraints, not product shape. Library and serve paths return failures instead of exiting or installing process-global state. + - CI passes `--locked` on builds and tests and fails when a build dirties the tree. + + + + +## Execution Instructions + +- Status: decomposed on 2026-09-28 against local `master` at `8622b227` ("Document the missing-service drop in Requirements::merge"), the commit the plan's review names, with a clean worktree. Every file, function, test, and line named below was found at its stated place at that commit; where the plan's earlier text was off, the step gives the corrected place. +- Path: Full. Four components, one step each, in dependency order. Each step's Todo line names the frontmatter todos it builds. +- Component order: + 1. **VFS host link semantics** (`host-symlinks`) - first. `promptforge-vfs` is the bottom of the dependency stack, and the later steps' Lua and engine suites run over it. It shares no file with any other step. + 2. **Lua cancellation** (`lua-cancel`) - second. `promptforge-lua` sits below the engine, so Step 3's engine suite runs over the fixed shim. Its two engine test additions touch no file Step 3 edits. + 3. **Engine scheduler** (`chain-section-label`, `scheduler-cleanup`) - third. Both todos edit the same scheduler files, and one test set covers both. + 4. **Internal crates sweep** (`dead-items`, `docs-sweep`, `comments-sweep`, `dir-folds`, `verify`) - last. Its docs describe the behavior Steps 1 to 3 leave, its comment edits sit in files Step 3 edits, and its directory folds move `execute/run-effect.rs`, `execute/run-effect-tests.rs`, `execute/run-tests.rs`, and the three `test_support/recording-*.rs` files, so the folds land after every step that could edit them. +- Standing rules for every step: + - Each step is one commit holding its code, docs, and tests. + - Line numbers refer to `8622b227`. Earlier steps move lines in files later steps edit (Step 3 in `scheduler/tasks.rs`, which Step 4 also edits); re-locate any reference by content before editing. + - The run starts at `cd38ce8c`, two commits past `8622b227`. Those two commits, `c18672e0` ("Guard argv and prose behind one locked _G metatable") and `cd38ce8c` ("Refuse reserved names as tool aliases and role labels"), are the separate `_G` change. They edited `promptforge-lua` files (`argv.rs`, `prose.rs`, `hardening.rs`, `lib.rs`, `vm.rs`, `prelude.rs`, `globals.rs`, `__impl_globals.lua`, the Lua README and AGENTS.md) and some engine and parser tests, so Lua-crate line numbers can be off by a few lines; re-locate by content. + - No type, signature, or doc change reaches `crates/promptforge/public-api.txt`. + - Verification cadence: Steps 1 to 3 each end a component, so the run verifies each at COMPONENT scope (the formatter check, per-package clippy, and the component tests of the touched packages). Step 4 is the final step and is verified at FULL scope, which covers the Testing Plan's exit criteria: the `AGENTS.md` Verification commands (`AGENTS.md`, lines 70 to 78, not the lines 49 to 55 the Testing Plan cites), `cargo deny check` (CI's `supply-chain` job, `.github/workflows/ci.yml` lines 363 to 364, not lines 340 to 341), the facade docs build, and `cargo +nightly-2026-09-05 xtask api --check`. + - Not done here: everything under Deferred and Out of Scope, and the `_G` lock, which a separate change resolved. + - Writing: plain English, single dashes only, never em dashes or double dashes. + + + +### Step 1: Act on host-backend symlinks as links [completed] + +- Component: VFS host link semantics +- Piece: the no-follow resolution and its five callers, built jointly in one commit. The resolver cannot land alone, because a private function with no caller fails `dead_code` under the denied-warnings clippy run, and one test set covers all five operations. +- Todo: `host-symlinks` +- Depends on: nothing +- Read: Product Requirements, the third Problem bullet, the third Goal, and the third Success criterion; Functional Specification, the Host backend and Windows bullets, Errors and recovery, and the second Security bullet; Technical Design, the VFS bullets; Testing Plan, the VFS host backend bullet and the escape-test regression bullet; Decision Record, the split-resolution decision, its rejected alternative, and the `remove_dir_all` note; Project Survey. +- Build, in this order: + - Tests first (see Tests). Confirm at `8622b227` that removing a link to an in-root file deletes the target, and that `remove`, `stat`, `exists`, and `rename` on a link whose target is outside the root fail with the "escapes the mounted root" denial. + - `crates/promptforge-internal/vfs/src/host.rs`: add a no-follow sibling of `contain` (line 123). It resolves the candidate's parent through `contain`, so the parent's nearest existing ancestor must still sit under the root, then appends the final component unchanged. The mounted root itself, for which `join_virtual` yields the root, resolves to the root. + - Add a no-follow sibling of `HostAccess::resolve` (line 392): identity mode returns the host path exactly as `resolve` does, and rooted mode calls the new function. + - Switch `remove` (line 494), `exists` (line 518), `stat` (line 574), `mkdir` (line 580), and both `host_from` and `host_to` in `rename` (lines 616 and 617) to the no-follow resolver. `read`, `read_range`, `write`, `append`, `list`, `glob`, and `copy` keep `resolve`, and the escape denial text is unchanged. + - `remove` on Windows: when `symlink_metadata` reports a directory link (`std::os::windows::fs::FileTypeExt::is_symlink_dir`, which a junction also satisfies), remove it with `fs::remove_dir`, because `fs::remove_file` fails on a directory link. Every other non-directory keeps `fs::remove_file`, and real directories keep today's `recursive` handling. The Windows branch sits behind `#[cfg(windows)]`, as the file's other platform code does. + - The module doc (lines 1 to 16) states the two resolutions: operations on a path itself (`remove`, `exists`, `stat`, `mkdir`, `rename`) act on a final-component link as a link, and operations on contents follow links under the containment check. Keep the Stage 2 deferral for what stays deferred. +- Tests: in `host.rs`'s `mod tests`, beside `a_rooted_backend_rejects_links_that_escape_the_mount_root` (line 783): + - A new helper `make_file_link` beside `make_dir_link` (lines 713 to 732): `std::os::unix::fs::symlink` on Unix, `std::os::windows::fs::symlink_file` on Windows. A test that needs a file link returns early only when Windows refuses with the privilege error (raw OS error 1314, `ERROR_PRIVILEGE_NOT_HELD`) and says so in an `eprintln!` message; any other creation failure fails the test. + - Removing a link to a file inside the root removes the link, and the target keeps its bytes. + - For a link whose target is a file outside the root: `exists` is true, `stat` reports `FileType::Symlink`, `rename` moves the link and leaves the target where it was, and `remove` removes the link while the outside target keeps its bytes. `read` and `write` through the link still fail with `PermissionDenied`. + - Removing a dangling link succeeds. + - Removing a directory link made with `make_dir_link` removes the link, and the target directory and its contents survive. The test asserts that the helper succeeded: on Windows it makes a junction, which needs no privilege, and exercises the new `fs::remove_dir` branch. + - Focused command: `cargo nextest run --locked -p promptforge-vfs --all-features host::tests`. + - Existing pins that must pass unchanged: `a_rooted_backend_rejects_links_that_escape_the_mount_root`, `a_rooted_backend_round_trips_files_and_directories`, and the rest of `host::tests`. +- Verify: COMPONENT scope for `promptforge-vfs`: `cargo fmt --all --check`; `cargo clippy --locked -p promptforge-vfs --all-targets --all-features -- -D warnings`; `cargo nextest run --locked -p promptforge-vfs --all-features`, then `cargo test --locked --doc -p promptforge-vfs --all-features`. +- Commit: one commit with the `host.rs` changes and tests. +- Done when: the new tests fail before the change and pass after it, and apart from the follow resolver no path-level operation in `host.rs` canonicalizes a final-component link. + + + + + +### Step 2: Let cancellation unwind through every Lua pcall [completed] + +- Component: Lua cancellation +- Piece: the `cancel_requested` capture and its four re-raise sites, built jointly in one commit. The capture has no use without a site that reads it, and one test set covers all four sites. +- Todo: `lua-cancel`. The Lua crate's other todo, `lua-guard-lock`, was resolved by a separate change and is not built here. +- Depends on: nothing. Placed after Step 1 because `promptforge-lua` depends on `promptforge-vfs`. +- Read: Product Requirements, the first Problem bullet, the first Goal, and the first Success criterion; Functional Specification, the Author Lua under cancellation bullet, the Cancelled outcome bullet, the prelude capture bullet under States and validation, and the first Security bullet; Technical Design, the Lua architecture bullet and the `coro.rs` and `__impl_coro.lua` interface bullet; Testing Plan, the Lua cancellation unit bullet; Decision Record, the re-raise decision, the rejected `mlua::Error::external` alternative, and the hook-interval note; Project Survey. +- Build, in this order: + - Tests first (see Tests). Before the fix the Lua-crate loops never end, so nextest terminates those tests after 180 seconds; that timeout is the expected failing-first result. Run the check once, filtered to the new tests. A case that already ends before the fix stays as a pin, and the return says so instead of contorting the test. + - `crates/promptforge-internal/lua/src/coro.rs`: `install_shim_prelude` (line 156) takes the VM's `InstructionBudget`, passed from `SectionVm::install_coro_shims` (`vm.rs`, line 694) out of the `instruction_budget` field (`vm.rs`, line 115), and hands the shim a new last chunk argument, `cancel_requested`: a Lua function returning `InstructionBudget::is_cancelled()` (`hardening.rs`, line 123). + - `crates/promptforge-internal/lua/src/__impl_coro.lua`: add `cancel_requested` to the chunk's argument list (lines 22 to 24) and to the header comment (lines 1 to 21), then: + - `protected_call` and `pcall_outcome` (lines 69 to 76): on failure, when `cancel_requested()` is true, raise the raw failure with `error(failure, 0)` instead of returning `false` and the normalized error. + - `protected_xcall` (lines 80 to 87), in both the function-handler and the non-function-handler branch: when the protected call fails and `cancel_requested()` is true, raise instead of returning. + - `run_local_tool` (lines 127 to 137): when `raw_pcall(handler, args)` (line 129) fails and `cancel_requested()` is true, call `leave_local_handler()` and raise the failure at once, before the `local_tool_done` yield. + - `compact` (lines 207 to 216): when `raw_pcall(compactor, reason)` (line 208) fails and `cancel_requested()` is true, raise the raw failure rather than the normalized one. + - `guard` (lines 338 to 340) is unchanged. It stays the boundary that returns the failure to Rust, where `SectionVm::map_chunk_failure` (`vm.rs`, line 1278) maps any failure under the cancel flag to `Error::Interrupted`. + - At `cd38ce8c` the Rust references sit at: `SectionVm::install_coro_shims`, `vm.rs` line 697; the `instruction_budget` field, `vm.rs` line 115; `SectionVm::map_chunk_failure`, `vm.rs` line 1282; `InstructionBudget::is_cancelled`, `hardening.rs` line 130. + - The `_G` guard needs no change and gets no re-raise. `__impl_globals.lua` captures the base `pcall` at load and uses it only around the base `setmetatable` and `getmetatable`, C functions that run no Lua instructions, so the instruction hook cannot raise inside those protected calls. The shim's `pcall` and `xpcall` replacements reach `_G` through `raw_set` in `install_shim_prelude`, which the guard metatable does not intercept. The Lua test VM installs the guard exactly as a section VM does, so the new tests run with it live. + - A `pcall` or `xpcall` that fails while the flag is clear behaves as today. +- Tests: + - `crates/promptforge-internal/lua/src/tests.rs`, beside `a_pre_cancelled_run_aborts_a_tight_loop_promptly` (line 2210). Build a section VM with the coroutine shims installed the way `section_vm_with_var` in `prelude-tests.rs` does (host injection, `install_host_apis`, `install_scheduler_control_globals`, `install_coro_shims`), install an already-cancelled `CancelHandle` with `SectionVm::set_cancel`, and start each block with `SectionVm::start_block_coro`. `while true do pcall(function() while true do end end) end` fails with `Error::Interrupted`; the same loop through `xpcall` with a function message handler fails with `Error::Interrupted`; and on a VM with no cancel flag installed, `pcall(error, 'boom')` still returns `false` and the error. + - `crates/promptforge-internal/engine/src/execute/tests/local_tools.rs`: a run whose local tool handler loops without yielding, cancelled mid-handler through `TokioDriver::cancel_handle` after a short delay in the manner of `cancel_during_in_flight_tool_call_returns_promptly` (`tool_loop.rs`, line 470), returns `Err(crate::Error::Interrupted)` within 5 seconds. + - `crates/promptforge-internal/engine/src/execute/tests/models_loop_compactors.rs`: the same for a `models.loop` whose compactor loops without yielding on an overflow round. + - Each new test's name contains `cancel`. + - Focused commands: `cargo nextest run --locked -p promptforge-lua --all-features cancel` and `cargo nextest run --locked -p promptforge-engine --all-features local_tools models_loop_compactors`. + - Existing pins that must pass unchanged: `long_running_lua_block_cancels_cooperatively`, `a_pre_cancelled_run_aborts_a_tight_loop_promptly`, and `cancel_during_in_flight_tool_call_returns_promptly`. +- Verify: COMPONENT scope for `promptforge-lua` and `promptforge-engine`: `cargo fmt --all --check`; `cargo clippy --locked -p --all-targets --all-features -- -D warnings` for each; the component test pattern for each. +- Commit: one commit with the `coro.rs`, `vm.rs`, and `__impl_coro.lua` changes and the new Lua and engine tests. +- Done when: every new cancelled-run test ends with `Error::Interrupted`, the flag-clear `pcall` test passes, and the Lua and engine suites pass. + + + + + +### Step 3: Keep chain reports off the walk position and drop the call stack [completed] + +- Component: Engine scheduler +- Piece: the recorded section label and the scheduler cleanup, built jointly in one commit. Both edit `scheduler.rs`, `scheduler/chain.rs`, and `scheduler/tasks.rs`, and three engine tests form one set covering both: two notice tests pin the label, and the concurrent-call test pins the stack deletion. The `const` wrap in `config-limits.rs` rides along as the component's one remaining mechanical fix. +- Todo: `chain-section-label`, `scheduler-cleanup` +- Depends on: nothing. Placed after Step 2 so the engine suite runs over the final Lua crate. +- Read: Product Requirements, the second Problem bullet, the second Goal, and the second Success criterion; Functional Specification, the Scheduler reporting bullet, the second Inputs and outputs bullet, and the recorded section name bullet under States and validation; Technical Design, the Engine bullets, the Deleted bullet, and the saturating subtraction bullet; Testing Plan, the three Engine integration bullets; Decision Record, the record-on-chain and delete-the-stack decisions and the rejected placeholder name; Project Survey. +- Build, in this order: + - Tests first (see Tests). Confirm at `8622b227` that the walk-end test panics with an index out of bounds in `Chain::section` (`scheduler.rs`, line 383), the between-sections test reports the wrong section name, and the concurrent-call test fails the `debug_assert_eq!` in `finish` (`scheduler/chain.rs`, lines 208 to 212). + - `crates/promptforge-internal/engine/src/execute/scheduler.rs`: `Chain` gains an owned field holding the name of the section the chain most recently entered. `section_name()` (lines 395 to 405) returns the prompt title when `h1` is set and the recorded name otherwise, and its doc says so. `section()` and `blocks()` are unchanged and keep indexing the live section. + - Initialize the field where a chain is built, by the Functional Specification's rule (the name of the slice's first section, or the prompt title when the slice is empty): the `Chain` literal in `start_chain` (`scheduler/chain.rs`, line 77) and the one in `start_live_h1` (`scheduler/h1.rs`, line 48), the only two `Chain` literals. + - `scheduler/walk.rs` `enter_section` (line 131): after building the frame for `slice[index]` (lines 158 to 168), record that section's name. The H1 branch (lines 135 to 145) leaves the field alone, and `end_section`, `pop_position`, and `finish` never change it. + - Delete the call stack: the `stack` field and its doc (`scheduler.rs`, lines 429 to 431) and its initializer (line 499); the push in `dispatch_call` (`scheduler/dispatch.rs`, line 314) and the doc's "pushes it on the chain stack" (lines 299 to 301); the `debug_assert_eq!` pop in `finish` (`scheduler/chain.rs`, lines 208 to 212), keeping the `answer_inline` call after it; the conditional pop and its comment (`scheduler/chain.rs`, lines 254 to 258); and the `clear` in `teardown` (`scheduler/drive.rs`, line 199). Reword the docs that describe the stack so none names it: the module doc's first line and its core-contents sentence (`scheduler.rs`, lines 1 and 32 to 36) and the `call_depth` field doc (lines 312 to 316). + - `scheduler/tasks.rs` `release_slots`: `chain.slots_used -= 1` (line 591) becomes a saturating subtraction; the `debug_assert!` above it (lines 587 to 590) stays. + - `crates/promptforge-internal/engine/src/execute/config-limits.rs`: wrap each `nz_*` call in `const { }`, in `RunLimits::new` (lines 70 to 74) and in the module's test (lines 186 and 187), so a zero literal fails to compile instead of reaching the macro's `unreachable!()` (line 15) at run time. The macro and its `const fn`s stay. No other file calls `nz_*`. +- Tests: + - `crates/promptforge-internal/engine/src/execute/tests/model_task_notices.rs`: a prompt whose last section runs `tools.allow_tasks()`, lets the canned model start a task, and falls through while the task is live. The run finishes without a panic, reports a `TaskAbandoned` event, and reports the task notice under that last section's name. + - Same file: a model task that ends while its owner sits between two sections reports its notice under the section just ended. + - `crates/promptforge-internal/engine/src/execute/tests/scheduler/concurrency.rs`: two concurrently admitted tasks that each `call` a section issuing a chat, with the chats answered in reverse issue order, both complete. Nextest builds in debug, so the deleted `debug_assert_eq!` would fire here. + - No test for the saturating release, since a debug build hits the kept `debug_assert!` first, and none for the `const` wrap, which is a compile-time property. + - Focused command: `cargo nextest run --locked -p promptforge-engine --all-features model_task_notices scheduler::concurrency`. + - Existing pins that must pass unchanged: `an_ending_owner_leaks_its_author_task_and_never_its_model_task` (`execute/tests/model_tasks.rs`, line 250), `cancelling_a_run_settles_every_live_task_with_one_terminal_before_the_run_ends` (`execute/tests/run_termination.rs`, line 78), and `run_limits_pins_all_six_defaults_and_the_untested_builders` in `config-limits.rs`. +- Verify: COMPONENT scope for `promptforge-engine`: `cargo fmt --all --check`; `cargo clippy --locked -p promptforge-engine --all-targets --all-features -- -D warnings`; `cargo nextest run --locked -p promptforge-engine --all-features`, then `cargo test --locked --doc -p promptforge-engine --all-features`. +- Commit: one commit with the scheduler, walk, chain, h1, dispatch, drive, tasks, and config-limits changes and the three tests. +- Done when: the three tests fail before the change and pass after it, no scheduler file names a call stack, and the engine suite passes. + + + + + +### Step 4: Sweep dead items and stale text, then fold two directories [completed] + +- Component: Internal crates sweep +- Piece: dead items, docs, comments, and the two directory folds, built jointly in one commit. None of them changes behavior, so no new test covers any one of them, and one pass of the full gates verifies all of them. Within the step, make the folds last, so every other edit, this step's and Steps 1 to 3's, already sits in the moved files. +- Todo: `dead-items`, `docs-sweep`, `comments-sweep`, `dir-folds`, `verify` +- Depends on: Steps 1 to 3. `vfs/README.md` describes Step 1's link semantics, the Lua docs follow Step 2, the comment edits in `scheduler/tasks.rs` and `scheduler/tool_call.rs` sit in the scheduler Step 3 edits, and the folds follow every edit to the moved files. +- Read: Product Requirements, the fourth Goal and the fourth Success criterion; Functional Specification, the opening paragraph; Technical Design, the File and public API changes bullets (the facade listing, Moved, and New); Testing Plan, the Regression bullets and Exit criteria; Decision Record, the file-move note under Assumptions; Project Survey. +- Build, dead items: + - `crates/promptforge-internal/model-client/src/normalize.rs` (line 39; the plan's earlier text placed it under `client/`): remove `#[non_exhaustive]` from the crate-private `NormalizedTurn`. + - `model-client/src/client.rs` (lines 29 to 31): remove the `StreamDelta` re-export and its comment. The re-export is not unused inside the crate: `client/read.rs` (line 18) and `client/stream.rs` (line 29) import `StreamDelta` through it with `use super::{...}`, and `client/stream-tests.rs` names it. Import it from `promptforge_types::wire::StreamDelta` in those files instead. + - `model-client/src/lib.rs` (line 41): remove the metrics re-export (`CallMetrics`, `ClientTiming`, `LlamaTimings`, `Usage`, `VllmMetrics`). + - Both removals break intra-doc links in `model-client/src/lib.rs`'s crate doc: `client::StreamDelta` (lines 7 and 23) and the five metrics names (lines 17 and 18). The denied `broken_intra_doc_links` lint turns those into a docs-build failure, so reword that doc: the metrics vocabulary and `StreamDelta` are canonical in `promptforge-types`, linked through `promptforge_types` paths or named as plain code, and the "re-exported through their historical paths" claim stays only for the `model` items it still covers. Update the `promptforge-types` dependency comment in `model-client/Cargo.toml`, which says the crate re-exports the metrics vocabulary. + - The facade re-exports `StreamDelta` and the metrics types from `promptforge_types` directly (`crates/promptforge/src/lib.rs`, lines 77 and 190 to 195), and `harness-models` names `StreamDelta` only through the facade, so no other crate changes and `public-api.txt` stays identical. The compiler and `xtask api --check` confirm it. + - Rename the temp-directory prefixes left from the facade's old name: `promptforge-api-prepare-` to `promptforge-prepare-` in `crates/promptforge/tests/suite/prepare.rs` (line 54), and `promptforge-api-vfs-` to `promptforge-engine-vfs-` in `crates/promptforge-internal/engine/src/execute/tests/suite/vfs.rs` (line 228). +- Build, docs: + - Engine: correct `crates/promptforge-internal/engine/AGENTS.md` (lines 3 and 5) to describe the current public modules, read from `engine/src/lib.rs`, and the facade's actual re-exports. In `engine/README.md`, list the current effects (Chat, ToolCall, Store, Timer, TaskEvents), and drop the missing `LICENSE` link (line 13) and the unsupported "Rust 1.89 or later" claim (line 9). In `engine/src/lib.md` (line 3), stop saying the engine holds the parser and add TaskEvents to the effect list. Reword the `description` in `engine/Cargo.toml` (line 12), which still opens "prompt parser and". + - Types: widen `types/README.md`, `types/AGENTS.md`, and the `types/Cargo.toml` description to the crate's full contents (among them `wire`, `models`, `tools`, `metrics`, `names`, `ids`, `replay`, `timestamp`, and `capabilities`, beside the untrusted guards, the cancellation tree, and the event vocabulary). + - Lua: add the capability preludes, `input`, `tasks`, `messages`, and the VFS-backed store to `lua/README.md` and `lua/AGENTS.md`. + - Model catalog: say in `crates/promptforge-internal/README.md` (the model-client paragraph, line 27) and in `model-client/README.md` that the model catalog types are defined in `promptforge-types` and re-exported by model-client. + - VFS: write `crates/promptforge-internal/vfs/README.md` in the shape of its siblings' READMEs: what the crate holds (canonical paths, claims, routing, the memory and host backends, the mode policy, the declared store root and store view), that it is std only with the manifest test enforcing it, and the host backend's two resolutions as Step 1 left them. + - Root: correct the root `AGENTS.md` statement that the types crate "contains the wire vocabulary only, never code" (line 54, the last clause of the Shared crates bullet; the plan's earlier text said line 55). +- Build, stale comments: rewrite each to match the code, or delete it when the code needs no comment. + - Engine: `execute/context.rs` (lines 66 to 67, 80 to 81, and 84 to 85); `execute/section_context-construct.rs` (lines 6 to 8 and 26 to 32, adding the preludes step); `execute/section_context.rs` (lines 70 to 71); `error.rs` (lines 367 to 370); `execute.rs` (lines 129 to 130); `test_support/tokio_driver.rs` (lines 168 and 521); and `engine/Cargo.toml` (line 41). + - VFS: `vfs/src/error.rs` (lines 9 to 14). + - Model-client: `client/wire.rs` (lines 147 to 150), `normalize.rs` (lines 34 to 37, at `model-client/src/normalize.rs`), `model/error.rs` (line 45), and the duplicate `No Eq` comment in `model/options.rs` (lines 87 and 90; the plan's earlier text said `options.rs`). + - Types: `models.rs` (line 242), and `event.rs` (lines 415 to 424), which must say `TaskNote` is reserved and not yet produced, as the facade's `event.md` says. +- Build, history comments: remove, or restate as a present-tense constraint, each comment that narrates history or cites an audit tag: `engine/src/test_support.rs` (lines 17 to 20 and 168; the plan's earlier text said 167); `engine/src/execute/config.rs` (lines 123 to 125); `engine/src/execute/requirements.rs` (lines 112 to 114); `engine/src/error.rs` (lines 43 and 522, the `F3` and `F4` tags); `engine/src/execute/scope.rs` (line 75, the `F7` tag); `engine/src/execute/section_context-construct.rs` (line 66); `engine/src/execute/run.rs` (lines 226 to 227); `scheduler/tasks.rs` (lines 173 to 174, 473 to 481, and 500 to 501 at `8622b227`, moved by Step 3); `scheduler/tool_call.rs` (lines 191 to 193); `vfs/src/handle.rs` (lines 1238 to 1241); and `parser/src/build.rs` (lines 1 to 6, the `PF-PARSER-012` tag). +- Build, directory folds, last: + - Move `engine/src/execute/run-effect.rs` to `execute/run/effect.rs`, `run-effect-tests.rs` to `execute/run/effect-tests.rs`, and `run-tests.rs` to `execute/run/tests.rs`. In `execute/run.rs`, drop the `#[path]` attributes on `mod effect` (line 14) and `mod tests` (line 266). In the moved `run/effect.rs`, its tests attribute (line 393) becomes `#[path = "effect-tests.rs"]`, the `-tests.rs` sibling convention. + - Move `engine/src/test_support/recording-forward.rs` to `test_support/recording/forward.rs`, `recording-forward-tests.rs` to `test_support/recording/forward-tests.rs`, and `recording-observation.rs` to `test_support/recording/observation.rs`. In `test_support/recording.rs`, drop the `#[path]` attributes on `mod forward` (line 26) and `mod observation` (line 28). In the moved `recording/forward.rs`, its tests attribute (line 378) becomes `#[path = "forward-tests.rs"]`. + - Move the files with a plain filesystem move; the session that commits stages the renames. No doc link or comment outside these `#[path]` attributes names the old paths. +- Tests: none new; the step changes no behavior. The existing suites of every touched package are the check, with the facade listing and the docs builds as the regression net. + - Focused command: `cargo nextest run --locked -p promptforge-engine -p promptforge-model-client -p promptforge-types -p promptforge-parser -p promptforge-vfs -p promptforge-lua -p promptforge --all-features`, then `cargo +nightly-2026-09-05 xtask api --check` with no difference from `crates/promptforge/public-api.txt`. +- Verify: FULL scope, as the final step: the Project Survey's build, formatter, linter (including `cargo check -p gateway --no-default-features` and `cargo deny check`), docs (including the facade docs build, the engine's private-items build, and `cargo +nightly-2026-09-05 xtask api --check` with `crates/promptforge/public-api.txt` unchanged), and full-suite commands (the workspace run, its doctests, and the workshop family run), plus `cargo test -p build-xtask`, which the `AGENTS.md` Verification list names. When the step changes a guide source under `guide/src/`, also regenerate the combined guide with `cargo run --locked -q -p build-user-guide` and run `cargo xtask site --books-only`. +- Commit: one commit with the dead-item removals and their import and doc repairs, the docs and comment edits, the new `vfs/README.md`, the root `AGENTS.md` correction, and the six moved files. +- Done when: no listed doc or comment still says something the code contradicts, the six files sit in `execute/run/` and `test_support/recording/` and no `#[path]` attribute names a `run-*` or `recording-*` file, `public-api.txt` is unchanged, and every FULL gate passes. + + + +- Exit: after Step 4, every Success criterion and Testing Plan check holds; the `_G` lock's criterion and test are covered by the separate change noted in the Decision Record. + + diff --git a/vibe/papergate-harness-migration.md b/vibe/papergate-harness-migration.md index 84f389562..d45f4928f 100644 --- a/vibe/papergate-harness-migration.md +++ b/vibe/papergate-harness-migration.md @@ -27,10 +27,10 @@ Each row is one thing Papergate does today (`src/app.rs`, `src/main.rs`) and wha | `Arc` and the `StderrObserver` printing `[{execution}] {section}: {event}` per `Observation` | The `Observer` trait and `Observation` enum are gone. Subscribe to `Session::subscribe_events()` (a `broadcast::Receiver`; each carries `index`, an optional `reply` id, and `event`, the logged engine `Event` as JSON with a `kind` tag, `execution`, `section`, and `provenance`). Print `event["section"]` and `event["kind"]` for the same progress line. `Session::transcript(from)` reads the same sequence from the log after the fact. Live model text arrives separately on `Session::subscribe_deltas()`. | | `fetch_model_catalog(&endpoint, &token)` and `ResolutionContext::new(&picker, &models, &ToolCatalog::new(&[])?)` | Nothing to call: the harness fetches the catalog and binds the prompt's `writer` role itself at launch. The harness resolves the model from `HostSnapshot::selected_model`, or, when that is `None`, from the first entry of the `CatalogBinding` it was given; with neither, the role stays unbound and the launch is refused with the engine's requirements notice. Papergate pushes one of the two before launching (see "Model selection" below). | | `promptforge_tool_picker::{Catalog, Config, ToolPicker}` built over an empty catalog | Gone. The harness assembles the tool catalog from the prompt's `capabilities:` declarations against its capability registry. `papergate.md` declares no capabilities and defines its one tool with `tools.add_local`, so nothing replaces this. | -| `RunConfig::new(execution).observer(observer).cancel(cancel)` and `execute::run(&parsed, "", resolution, &store, config).await` | `Harness::new(HarnessConfig { agents_path, state_dir })`, then `Harness::set_gateway(GatewayBinding { base_url, key, generation })`, then `Harness::launch(LaunchRequest { agent: "papergate".into(), args }).await -> Result`. The session runs the agent to completion; await `Session::subscribe_state()` reaching `SessionState::Closed`, or watch the transcript for `run_succeeded` or `run_failed`. | +| `RunConfig::new(execution).observer(observer).cancel(cancel)` and `execute::run(&parsed, "", resolution, &store, config).await` | `Harness::new(HarnessConfig { agents_path, state_dir })`, then `Harness::set_gateway(GatewayBinding { base_url, key, generation })`, then `Harness::launch(LaunchRequest { agent: "papergate".into(), args, input_text: Some(paper_md) }).await -> Result`. The session runs the agent to completion; await `Session::subscribe_state()` reaching `SessionState::Closed`, or watch the transcript for `run_succeeded` or `run_failed`. | | `execution` id minted with `fastrand` as `papergate-` | The harness mints the session id (`SessionId::fresh()`, 128 random bits) and uses it as the run's `execution`. Read it back with `Session::id()`. Drop `fastrand` unless it is used elsewhere. | | `promptforge_core::CancelHandle::new()`, `.clone()`, `.cancel()` from the Ctrl-C task; `RunError::is_cancelled` for exit code 130 | `harness::cancel::CancelHandle` has the same `new`, `child`, `cancel`, `is_cancelled` and adds the awaitable `cancelled()`, plus the task-local helpers `scope`, `maybe_scope`, `current`, `wait_cancelled`, `is_cancelled`. It moved here from the engine because it is a host concern. For the session itself, Ctrl-C calls `Session::close()` (cancel for good: outstanding effects are answered `Dropped`, state drains to `Closed`), not `Session::cancel()` (a turn cancel that relaunches the program). The durable `run_failed` event carries no reason, so detect the cancelled ending in Papergate: close was requested and then `Closed` arrived. | -| `FileStore::new(temp_dir)`, `StoreRef`, `seed_store` writing `paper.md`, `read_report` reading `report.md`, `remove_dir_all` afterwards | No equivalent through the door today. See "The store gap" below; it is the one item that needs a decision. | +| `FileStore::new(temp_dir)`, `StoreRef`, `seed_store` writing `paper.md`, `read_report` reading `report.md`, `remove_dir_all` afterwards | `LaunchRequest::input_text` carries the paper, which the harness stages at the prompt's declared `input:` path, and `Session::output_text()` returns what the run left at the declared `output:` path. No store directory to create or remove. See "Declared files" below. | | `PROMPTFORGE_GATEWAY_URL`, `PROMPTFORGE_GATEWAY_API_KEY` from the environment | Keep the variables; they populate `GatewayBinding { base_url, key, generation: 1 }`. Note `GatewayBinding::api_root()` appends `/v1` to `base_url`, so the URL variable must hold the gateway origin without the `/v1` suffix (or Papergate strips it). | | `Prompt` source read from `--prompt ` or the embedded `DEFAULT_PROMPT` | The harness launches agents by discovered name: the `.md` file stems under `HarnessConfig::agents_path`. Papergate writes its prompt source to `/papergate.md` (a temporary directory is fine) and launches `"papergate"`. `--prompt` writes the given file's contents to that path instead. | | Model-readable failure text from `execute::run` (`RunError`) | `LaunchError` for a refused launch (`UnknownAgent`, `GatewayUnusable`, `SessionState`, `Log`); `Session::subscribe_errors()` for a run that ended in error; `run_failed` in the transcript for the durable record. | @@ -46,16 +46,15 @@ The harness fetches the gateway's model list itself at launch and checks the sel where `model` is the catalog id Papergate wants the `writer` role bound to. Take it from a new `PAPERGATE_MODEL` environment variable or a `--model` flag; there is no gateway-side default the harness will pick for an unattended client. The model-catalog fetch helper (`fetch_model_catalog`) now lives in a private harness crate and is not reachable from outside the family. -## The store gap +## Declared files -Today Papergate seeds the run store with `paper.md` before the run and reads `report.md` from it afterwards, through the engine's `StoreRef` over a temporary directory. The prompt's frontmatter declares both paths as `input:` and `output:`. +Today Papergate seeds the run store with `paper.md` before the run and reads `report.md` from it afterwards, through the engine's `StoreRef` over a temporary directory. The prompt's frontmatter declares both paths as `input:` and `output:`, and the harness now honors both declarations, so `papergate.md` keeps its `store.read("paper.md")`, `store.read_numbered`, and `store.write("report.md", reply)` as they are. -Through `harness` there is no store access in either direction. A session's run is prepared over `promptforge::vfs::VfsRef::default()`, the default handle: a fresh memory store at `/` and nothing else, the store declared on the handle itself with `VfsRefBuilder::store` rather than added per run. Nothing on `Harness` or `Session` reads or writes it. The run's return value (the `RunResult::Ok(final_text)`) is written to the run log's `runs` row as `final_text`, but the session supervisor discards it and the door exposes no log reader, so a client cannot obtain it either. +- `LaunchRequest::input_text` is the paper's markdown. Before each run the harness writes it at the declared input path through the store's strict path rules. A run is refused, reported on `Session::subscribe_errors` as `FailureKind::RunFailed`, when the prompt declares no input file, or when it declares one and the launch supplies no text and the store does not already hold it. +- `Session::output_text()` returns what the completed run left at the declared output path. The harness reads it as the run completes and before the session reports `Closed`, so await `Closed` and then call it. It returns `OutputError::Missing { path }` when the run never wrote the file (the old "did not produce its declared output" error), `OutputError::Unfinished` when no run completed (the run failed, or Ctrl-C closed it first), and `OutputError::Undeclared` for a prompt with no output file. A missing output never fails the run. +- The default filesystem is a fresh memory store per run, which is what Papergate wants: nothing persists between runs, so `evidence.md` never leaks from one paper into the next, and there is no directory to remove. A host that wants its own filesystem (a host-backed store to keep `evidence.md` for debugging, host mounts, overlays, a policy, or an operation sink) builds a `promptforge::vfs::VfsRef` and launches with `Harness::launch_with(request, LaunchOptions { vfs: Some(handle) })`. Every run of that session then works in the handle, and the harness stages and reads the declared files through its store wherever it is mounted. -Two ways to close the gap, for Papergate's own plan to choose: - -1. Change the prompt, not the door. Deliver the paper as the run's argument (`LaunchRequest::args`) and have `papergate.md` read `args` instead of `store.read("paper.md")`, keeping `store.write("paper.md", args)` as its first statement if the `read_numbered` line ranges in `### Evaluate` are to stay as they are. Deliver the report as model text: the `## Analyze` section's `models.infer(prose)` already produces the report, and that call leaves an `assistant_reply` event with `origin: infer` carrying the report in `text` under `section == "Analyze"`. Papergate takes the last such event from the transcript. The `input:` and `output:` frontmatter declarations become documentation only. Cost: a 32k-context paper travels as one argument string, and the report is read from an event rather than a declared output. Recommended: it needs no change to the promptforge repository (confidence: medium; depends on Papergate accepting an event as the report's channel). -2. Extend the door. Give `LaunchRequest` an optional host root (a directory mounted into the run's VFS beside the store declared on the handle with `VfsRefBuilder::store`, since `RunContext::vfs` is the run's whole filesystem, or a set of seed files written into the store), and give `Session` a way to read the run's final text or a store file once `Closed`. This is a promptforge change with its own plan; the `HostSnapshot::workspace_roots` field already exists and is the natural carrier, but today it feeds only the `ui()` snapshot and mounts nothing. +The vendored prompt also needs its version line fixed: it says `promptforge: 1`, and the engine accepts only `promptforge: 0`, so an unfixed copy fails its run with "unsupported promptforge version: 1 (this build supports major 0)". ## The shape of the new run @@ -65,7 +64,8 @@ harness.set_gateway(GatewayBinding { base_url, key, generation: 1 }); harness.set_catalog(CatalogBinding { generation: 1, models: vec![json!({ "id": &model })] }); harness.set_host(HostSnapshot { selected_model: Some(model), workspace_roots: Vec::new() }); -let session = harness.launch(LaunchRequest { agent: "papergate".into(), args }).await?; +let request = LaunchRequest { agent: "papergate".into(), args: String::new(), input_text: Some(paper_md) }; +let session = harness.launch(request).await?; let mut events = session.subscribe_events(); let mut state = session.subscribe_state(); // Ctrl-C task: session.close() @@ -75,11 +75,10 @@ loop { Ok(()) = state.changed() => if *state.borrow() == SessionState::Closed { break }, } } -let transcript = session.transcript(0).await?; -// option 1: the report is the last `assistant_reply` event with `origin: infer` under section "Analyze" +let report = session.output_text()?; // OutputError::Missing when report.md was never written ``` -`agents_path` holds `papergate.md` (the embedded default or the `--prompt` file), and `state_dir` receives the harness's `runs.db`; both may be temporary directories removed after the run, as the store directory is today. The run log is the durable record the old stderr observer approximated; keep `state_dir` when the transcript is worth retaining. +`agents_path` holds `papergate.md` (the embedded default or the `--prompt` file), and `state_dir` receives the harness's `runs.db`; both may be temporary directories removed after the run. The run log is the durable record the old stderr observer approximated; keep `state_dir` when the transcript is worth retaining. ## Checklist @@ -88,5 +87,6 @@ let transcript = session.transcript(0).await?; - Write the prompt to `/papergate.md` and launch by name. - Push the gateway binding, a one-entry catalog, and the selected model before launch; strip `/v1` from the URL variable if present. - Move Ctrl-C to `Session::close()`; keep exit code 130 when close preceded `Closed`. -- Decide the store gap (option 1 or 2) and, under option 1, update `papergate.md` and read the report from the transcript. +- Pass the paper as `LaunchRequest::input_text`, read the report with `Session::output_text()` after `Closed`, and delete `with_temp_store`, `seed_store`, and `read_report`. +- Change the vendored `papergate.md` to `promptforge: 0`. - `CancelHandle` imports move from the engine to `harness::cancel`; `RunError::is_cancelled` is no longer on Papergate's path.