diff --git a/.cargo/config.toml b/.cargo/config.toml index 5526cff63..3e836ce66 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,6 +9,10 @@ rustflags = ["-C", "target-feature=+crt-static"] [alias] workshop = "run -p build-workshop --" xtask = "run -p build-xtask --" +# Outlines the workspace with the vibelight checkout next to this repo. Cargo +# builds it in release mode on first use and rebuilds it when its source +# changes. Run it from the repo root: the path is relative to the cwd. +vibelight = "run --release --manifest-path ../vibelight/Cargo.toml --" # Clippy appends a docs-link line to every diagnostic; the lint name already # identifies it. diff --git a/AGENTS.md b/AGENTS.md index 55c4d8b86..5dc8f37e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Multi-crate Rust workspace for the Engine, the Harness that runs it, the Gateway Seven terms have exactly one meaning each, everywhere in this repository: code comments, docs, rulebooks, and plans. Write them capitalized. -- **Engine**: the `runvil` and `runvil-*` crates. The Engine parses a prompt and steps a run. Whenever a run needs a model reply, a Pack's render of the tools a section offers it, a catalog tool's result, a timer, or a file outside a prelude's install, the Engine emits an effect and waits for its caller to answer it. In production the caller is the Harness. +- **Engine**: the `runvil` and `runvil-*` crates. The Engine parses a prompt and steps a run. Whenever a run needs a model reply, a Pack's render of the tools a section offers it, a Pack tool's result, a timer, or a file outside a prelude's install, the Engine emits an effect and waits for its caller to answer it. In production the caller is the Harness. - **Harness**: the `harness` and `harness-*` crates. The Harness steps the Engine, performs every effect, returns each answer, and records every run through the recorder the Host supplies. Production code runs prompts only through the Harness. - **Host**: an application that runs prompts through the Harness, such as Workshop or Papergate. The Host makes every policy decision. It runs prompts only through the Harness; it may also use the Engine's parser and types to read prompts and show events. - **Pack**: code which installs an optional native prelude, an optional Lua prelude, and zero or more tools. diff --git a/Cargo.lock b/Cargo.lock index d2cce542c..4db2beda0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4642,6 +4642,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "plugin-std" +version = "0.3.0" +dependencies = [ + "runvil-plugin", + "serde", + "serde_json", +] + [[package]] name = "plugin-user-input" version = "0.3.0" @@ -8694,6 +8703,7 @@ dependencies = [ "harness", "harness-gateway-client", "plugin-mcp", + "plugin-std", "plugin-user-input", "plugin-web", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 9c59f9561..038868896 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ harness-runner = { path = "crates/harness-internal/runner" } harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" } plugin-user-input = { path = "crates/plugin-user-input" } plugin-web = { path = "crates/plugin-web" } +plugin-std = { path = "crates/plugin-std" } plugin-mcp = { path = "crates/plugin-mcp" } gateway-routing = { path = "crates/gateway/routing" } runvil-lua = { path = "crates/runvil-internal/lua" } diff --git a/README.md b/README.md index 3c44e8269..a8f14fb2a 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The frontmatter declares one model role, `writer`, and the Lua under the title m ## How it fits together -The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a tool result, a timer, or a file, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. +The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. The gateway is the one process that holds model credentials, so no other process ever sees a vendor key. It serves an OpenAI-compatible API and routes each request to a cloud provider or to a model running on your own machine. It also provides web search, speech-to-text, text-to-speech, and a settings page in your browser, and you can run it on its own, on a desktop or a server. diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 34fd5e939..77fe33d39 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -1,3 +1,75 @@ -# Gateway family +# The gateway's API principle -This directory holds the gateway family's private crates, which together build the gateway, the separate server process that clients reach for models and the services around them. Code outside the family reaches the gateway only through the shared vocabulary and discovery crates one level up. +The gateway serves an API that other programs already know how to call. Each route follows the industry standard it is modeled on, where one exists, so a program written against that standard works against the gateway, and a client written against the gateway works against any server that implements the standard. + +## The rule + +Standards first. The chat, embeddings, models, speech, and transcription routes follow OpenAI's API: `POST /v1/chat/completions`, `POST /v1/embeddings`, `GET /v1/models`, `POST /v1/audio/speech`, and `POST /v1/audio/transcriptions`. The live stream on `/v1/realtime` follows OpenAI's Realtime transcription protocol: the same session object, the same audio buffer events, and the same `conversation.item.input_audio_transcription.delta` and `conversation.item.input_audio_transcription.completed` events. + +`/v1/rerank` is the one route where OpenAI publishes no shape, so it follows the rerank shape that Jina and Cohere publish and that vLLM also serves. A client written for that shape works against the gateway. + +Interoperability is checked in both directions. A standard client pointed at the gateway needs no special case, and a gateway client pointed at another server that speaks the standard needs none either. + +## Extensions, and nothing else + +Where a standard exists and the gateway needs something the standard lacks, the wire shape is the standard plus the smallest addition that makes the feature possible. Everything around the addition stays the standard's: route, methods, request fields, event names, error envelope. + +Every addition carries three properties: + +- It is named as an extension, with a name inside the standard's own namespace, so a reader of the standard's vocabulary can tell what it extends. +- It is documented beside the standard shape it extends, so both are read in one place. +- It is opt-in wherever the protocol offers a way to ask. A client that does not ask never receives the extra field or event. + +A few routes have no standard behind them. `GET /v1/audio/voices` follows the convention the OpenAI-compatible servers converged on, and `/v1/tools/web_search` and the `/admin` routes are ours alone. Those count as extensions from the start, and the same three properties apply to them. + +## Asking what a server offers + +A client has to know whether a server offers an extension before it relies on one, and the asking has to be harmless to a server that has never heard of the gateway. An absent answer means the standard alone, never that something is broken. + +The one nonstandard request has two candidate shapes. A dedicated capability endpoint is the preferred one, because it costs one request at connect time and every answer sits in one place. A response header on a call the client already makes costs no request, but it ties the answer to a call the client might never make, so it waits as a later convenience. Either way the plain server answers with a not-found, a no-content, or an empty result, which the client already tolerates. The endpoint is part of the intended arrangement the way the spec is. This is the rule the gateway follows when it adds one, and no such endpoint exists yet. + +The probe only reports. The feature turns on through the negotiated include token the protocol already has, and only when the probe said the server has it. A server that answers the probe and then refuses the token has a fault on its side, and the client calls that standard only, never an error to show the user. + +So a missing or negative answer produces an ordinary session, one that appends deltas and completes an item, with no second attempt, no error banner, and no probe attached to each request. Nothing the client shows suggests that a step failed, because none did. + +Guessing does not replace asking. A version string alone is not evidence, an extension request sent first with recovery after the error is not a probe, a 404 is not a hard failure, and no probe may change the shape of a standard response. + +## The live hypothesis + +The extension the gateway depends on most is the live hypothesis in realtime transcription. + +OpenAI's transcription stream is append-only. The `delta` events add text to an item, and the `completed` event closes it. Nothing in that shape can retract a word already sent. The gateway revises earlier words as more audio arrives and as later passes listen again, so it needs a way to say that part of the transcript changed. + +A replacement snapshot is the smallest addition that supports revision: the item's full current text, a revision number, the audio span the snapshot covers, and how that text divides into finalized, agreed, and tentative parts. The client rebuilds the take from the snapshot, then patches its document word by word against the text it showed before, so a revision costs a few small edits and leaves the rest of the document alone. + +The snapshot is the extra. That division is information the standard path never needs, because a standard client only appends, and a fallback ignores it. + +A session asks for the snapshot through `session.include`, the field OpenAI already uses to negotiate optional output, by naming `item.input_audio_transcription.hypothesis`. A session that never names it receives plain deltas and a completed transcript. + +## Nothing clever reaches the wire + +The gateway runs several speech models and decides which result wins. That machinery stays behind the API. Which model ran, how the gateway reconciled them, and how long any of it took never appear in a response or an event. A caller sees a transcription endpoint that happens to be more accurate, or earlier, than one model alone would be. It never sees a model pick. + +## The fallback is a requirement + +A client pointed at a server without the extension must still transcribe, with the plainer behavior the standard gives. It falls back to the standard and keeps working with less. + +A feature that only works against the gateway is a bug in the design. The test is whether the client still works when the extension is absent. + +## What this rules out + +- A parallel dialect of our own where a standard shape would do. +- Our own event or field names where a standard one exists. +- Removing an extension without a version step. +- An extension a caller cannot ignore. + +## Checking a change + +Before a route or a wire type changes, answer four questions: + +1. Does the standard shape still hold? +2. Is the addition the smallest one that makes the feature possible, and is it opt-in? +3. Does the client still work without the addition? +4. Is the addition named as an extension in the spec? + +The spec belongs with the gateway. The intended arrangement is a spec generated from the gateway's source rather than written by hand, with CI comparing the two so a route or a field cannot drift from its description. That keeps the principle in a check instead of in someone's memory. diff --git a/crates/harness-gateway-client/src/wire/parse-tests.rs b/crates/harness-gateway-client/src/wire/parse-tests.rs index 9cdd3dd44..c8e1b928b 100644 --- a/crates/harness-gateway-client/src/wire/parse-tests.rs +++ b/crates/harness-gateway-client/src/wire/parse-tests.rs @@ -2,6 +2,7 @@ //! reasoning side channel. use super::*; +use runvil::model::CompletionErrorKind; const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; diff --git a/crates/harness-gateway-client/src/wire/parse.rs b/crates/harness-gateway-client/src/wire/parse.rs index 495c4276e..8a34df1b2 100644 --- a/crates/harness-gateway-client/src/wire/parse.rs +++ b/crates/harness-gateway-client/src/wire/parse.rs @@ -24,16 +24,12 @@ //! diagnostic naming it. use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics}; -use runvil::model::{CompletionError, CompletionErrorKind, CompletionResult, ToolCall}; +use runvil::model::{CompletionError, CompletionResult, ToolCall}; use serde::Deserialize; use serde_json::Value; use crate::failure::{decoded, malformed}; -/// The specific added to an empty reply when reasoning was present but -/// ignored as answer text. -const REASONING_IGNORED: &str = "reasoning content was present but ignored"; - /// A parsed assistant turn: outcome plus payload-free metadata. #[derive(Debug, Clone, PartialEq)] pub(super) struct NormalizedTurn { @@ -94,23 +90,6 @@ fn turn_context(body: &Value) -> Result, CompletionError> { }) } -/// The empty-reply failure for a turn with no product: the kind's phrase, -/// extended with [`REASONING_IGNORED`] when an ignored reasoning side -/// channel was present, holding the choice's `finish_reason` so the tool -/// loop can classify the empty turn. -fn empty_reply(reasoning_present: bool, finish_reason: Option) -> CompletionError { - let kind = CompletionErrorKind::EmptyReply; - let error = if reasoning_present { - CompletionError::new(kind, format!("{}: {REASONING_IGNORED}", kind.phrase())) - } else { - CompletionError::new(kind, kind.phrase()) - }; - match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - } -} - /// Turns a chat-completions response body into a [`NormalizedTurn`]. /// /// # Errors @@ -157,7 +136,11 @@ pub(super) fn normalize(body: &Value) -> Result }); } - Err(empty_reply(reasoning_content.is_some(), finish_reason)) + let error = CompletionError::empty_reply(reasoning_content.is_some()); + Err(match finish_reason { + Some(reason) => error.with_finish_reason(reason), + None => error, + }) } /// Parses the OpenAI `message.tool_calls` array into [`ToolCall`]s. diff --git a/crates/harness-gateway-client/src/wire/request-tests.rs b/crates/harness-gateway-client/src/wire/request-tests.rs index b4139baad..df3283b69 100644 --- a/crates/harness-gateway-client/src/wire/request-tests.rs +++ b/crates/harness-gateway-client/src/wire/request-tests.rs @@ -79,9 +79,9 @@ fn tool_round(offered: &[Offered]) -> Round { for IssuedEffect { id, effect, .. } in effects { let answer = match effect { Effect::Chat { + binding, messages, tools, - options, .. } => { let reply = if rounds.is_empty() { @@ -98,7 +98,7 @@ fn tool_round(offered: &[Offered]) -> Round { rounds.push(Round { messages, tools, - options, + options: binding.completion_options(), }); EffectAnswer::Chat(Completion::from_result(reply, "m").map(Box::new)) } diff --git a/crates/harness-internal/runner/src/effect_loop.rs b/crates/harness-internal/runner/src/effect_loop.rs index ded70c989..c7541d875 100644 --- a/crates/harness-internal/runner/src/effect_loop.rs +++ b/crates/harness-internal/runner/src/effect_loop.rs @@ -340,11 +340,11 @@ impl Driver { binding, messages, tools, - options, round, .. } => { let broker = Arc::clone(&self.performers.broker); + let options = binding.completion_options(); let round = async move { let completion = broker.chat(binding, messages, tools, options, round).await; EffectAnswer::Chat(completion) diff --git a/crates/plugin-std/Cargo.toml b/crates/plugin-std/Cargo.toml new file mode 100644 index 000000000..64137c949 --- /dev/null +++ b/crates/plugin-std/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "plugin-std" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +# The Plugin contract: `PackInfo`, the Pack trait, the context a +# call is lent, and the tool, error, and service vocabulary. +runvil-plugin.workspace = true +# Each tool's definition is embedded JSON, read into a spec at construct. +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# `testing::TestCall`, which lends the tools a context without a Harness. +runvil-plugin = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/crates/plugin-std/build.rs b/crates/plugin-std/build.rs new file mode 100644 index 000000000..c03b9c50e --- /dev/null +++ b/crates/plugin-std/build.rs @@ -0,0 +1,13 @@ +//! Includes the file-size check's source instead of depending on +//! `build-ceiling`, because Plugin crates may name no `build-*` crate. + +#[expect( + unreachable_pub, + reason = "the check's public items are private to this build script" +)] +#[path = "../build-ceiling/src/lib.rs"] +mod build_ceiling; + +fn main() -> Result<(), build_ceiling::Violations> { + build_ceiling::check() +} diff --git a/crates/plugin-std/schemas/EDITS.md b/crates/plugin-std/schemas/EDITS.md new file mode 100644 index 000000000..03e6d744c --- /dev/null +++ b/crates/plugin-std/schemas/EDITS.md @@ -0,0 +1,53 @@ +# Definition edits + +Each file in this directory is Cursor's definition of the tool named by its +`wire_name`, snapshot on 2026-10-27. Everything in a file is Cursor's text +except the edits below, each made because the line describes behavior this +Pack does not have. + +## read.json + +- `Reads a file from the local filesystem.` became `Reads a file from the + run's filesystem.`, because every read goes through the run's virtual + filesystem, not the real one. +- `Assume this tool is able to read all files on the machine.` became + `Assume this tool is able to read every file the run can see.`, for the + same reason. +- `If the User provides a path to a file` became `If the prompt provides a + path to a file`, because a Runvil run has a prompt rather than a chat + user. +- Removed: `Image Support`, `PDF Support`, and the paragraph explaining how + images and PDFs are returned. This build reads text only, so a model told + otherwise would send a PDF and get an error. + +## write.json + +- Cursor's `Write` description, which is about creating or overwriting a + file. Reworded to name `StrReplace` for targeted changes and to require a + read before overwriting, matching Cursor's own guidance. No behavior was + added; only the wording is ours. + +## replace.json + +- Cursor's `StrReplace` definition. The wording is ours; the parameters, + their types, and the `required` list are Cursor's. + +## rm.json + +- Verbatim. Cursor's `Delete` description and schema are kept exactly. + +## glob.json + +- The `target_directory` description's default, `Cursor workspace root`, + became `the run's filesystem root`. + +## grep.json + +- Removed the sentence pointing at `rg`, since the tool searches the run's + filesystem itself. +- Removed the sentence pointing at the `Shell` tool for piping, because this + Pack offers no `Shell`. +- The `path` parameter's default, `Cursor workspace root`, became `the + run's filesystem root`. +- Every parameter, type, description, enum, and the `required` list is + otherwise Cursor's. diff --git a/crates/plugin-std/schemas/glob.json b/crates/plugin-std/schemas/glob.json new file mode 100644 index 000000000..f51f6b32b --- /dev/null +++ b/crates/plugin-std/schemas/glob.json @@ -0,0 +1,18 @@ +{ + "wire_name": "Glob", + "description": "Finds files by name pattern and returns their paths. Use this to locate files when you know part of a name or an extension but not the directory, and prefer it over listing directories one at a time.", + "parameters": { + "type": "object", + "properties": { + "glob_pattern": { + "type": "string", + "description": "The glob pattern to match files against" + }, + "target_directory": { + "type": "string", + "description": "The absolute path to the directory to search in. If not provided, the run's filesystem root is used." + } + }, + "required": ["glob_pattern"] + } +} diff --git a/crates/plugin-std/schemas/grep.json b/crates/plugin-std/schemas/grep.json new file mode 100644 index 000000000..c874d01b6 --- /dev/null +++ b/crates/plugin-std/schemas/grep.json @@ -0,0 +1,67 @@ +{ + "wire_name": "Grep", + "description": "Searches file contents with a regular expression and reports the matches. Use it to find where a symbol, string, or pattern occurs, and read the matching file before changing it.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regular expression pattern to search for in file contents" + }, + "path": { + "type": "string", + "description": "File or directory to search in. Defaults to the run's filesystem root." + }, + "glob": { + "type": "string", + "description": "Glob pattern to filter files (e.g. \"*.js\", \"*.{ts,tsx}\")" + }, + "type": { + "type": "string", + "description": "File type to search. Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types." + }, + "output_mode": { + "type": "string", + "enum": ["content", "files_with_matches", "count"], + "description": "Output mode: \"content\" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"files_with_matches\"." + }, + "-B": { + "type": "number", + "description": "Number of lines to show before each match. Requires output_mode: \"content\", ignored otherwise." + }, + "-A": { + "type": "number", + "description": "Number of lines to show after each match. Requires output_mode: \"content\", ignored otherwise." + }, + "-C": { + "type": "number", + "description": "Alias for context." + }, + "context": { + "type": "number", + "description": "Number of lines to show before and after each match. Requires output_mode: \"content\", ignored otherwise." + }, + "-n": { + "type": "boolean", + "description": "Show line numbers in output. Requires output_mode: \"content\", ignored otherwise. Defaults to true." + }, + "-i": { + "type": "boolean", + "description": "Case insensitive search" + }, + "multiline": { + "type": "boolean", + "description": "Enable multiline mode where . matches newlines and patterns can span lines. Default: false." + }, + "head_limit": { + "type": "number", + "description": "Limit output to first N lines/entries. Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly - large result sets waste context)." + }, + "offset": { + "type": "number", + "description": "Skip first N lines/entries before applying head_limit. Works across all output modes. Defaults to 0." + } + }, + "required": ["pattern"] + } +} diff --git a/crates/plugin-std/schemas/read.json b/crates/plugin-std/schemas/read.json new file mode 100644 index 000000000..ce3a58158 --- /dev/null +++ b/crates/plugin-std/schemas/read.json @@ -0,0 +1,22 @@ +{ + "wire_name": "Read", + "description": "Reads a file from the run's filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read every file the run can see. If the prompt provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Any lines longer than 2000 characters will be truncated\n- Results are returned using cat -n format, with line numbers starting at 1\n- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.\n- If you read a file that exists but has empty contents you will receive 'File is empty.'", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path of the file to read" + }, + "offset": { + "type": "number", + "description": "The line number to start reading from. Only provide if the file is too large to read at once. Negative values count backwards from the end." + }, + "limit": { + "type": "number", + "description": "The number of lines to read. Only provide if the file is too large to read at once." + } + }, + "required": ["path"] + } +} diff --git a/crates/plugin-std/schemas/replace.json b/crates/plugin-std/schemas/replace.json new file mode 100644 index 000000000..aa7718ec7 --- /dev/null +++ b/crates/plugin-std/schemas/replace.json @@ -0,0 +1,26 @@ +{ + "wire_name": "StrReplace", + "description": "Replaces an exact string in a file with a new string. The old string must be unique in the file unless replace_all is true, so include enough surrounding context to identify the one occurrence you mean. Read the file first.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path of the file to modify" + }, + "old_string": { + "type": "string", + "description": "The text to replace" + }, + "new_string": { + "type": "string", + "description": "The text to replace it with (must be different from old_string)" + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences of old_string (default false)" + } + }, + "required": ["path", "old_string", "new_string"] + } +} diff --git a/crates/plugin-std/schemas/rm.json b/crates/plugin-std/schemas/rm.json new file mode 100644 index 000000000..5acd23873 --- /dev/null +++ b/crates/plugin-std/schemas/rm.json @@ -0,0 +1,14 @@ +{ + "wire_name": "Delete", + "description": "Deletes a file at the specified path. The operation will fail gracefully if:\n - The file doesn't exist\n - The operation is rejected for security reasons\n - The file cannot be deleted", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path of the file to delete" + } + }, + "required": ["path"] + } +} diff --git a/crates/plugin-std/schemas/write.json b/crates/plugin-std/schemas/write.json new file mode 100644 index 000000000..e9ca3908f --- /dev/null +++ b/crates/plugin-std/schemas/write.json @@ -0,0 +1,18 @@ +{ + "wire_name": "Write", + "description": "Creates or overwrites a file at the specified path. Use this tool to create a new file or to replace the whole contents of an existing one. If the destination file already exists, you MUST first read it before writing, so a change you did not make is never overwritten. For a targeted change to an existing file, prefer StrReplace.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path of the file to write" + }, + "contents": { + "type": "string", + "description": "The contents to write to the file" + } + }, + "required": ["path", "contents"] + } +} diff --git a/crates/plugin-std/src/check-tests.rs b/crates/plugin-std/src/check-tests.rs new file mode 100644 index 000000000..1426f5c54 --- /dev/null +++ b/crates/plugin-std/src/check-tests.rs @@ -0,0 +1,140 @@ +//! Argument checking: a matching call passes, and each kind of mismatch +//! fails naming the parameter. + +use runvil_plugin::ToolErrorKind; +use serde_json::json; + +use super::check; + +/// The schema the `StrReplace` tool ships, which has a required string, an +/// optional string, and an optional boolean. +fn replace() -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "The path." }, + "old_string": { "type": "string", "description": "The old text." }, + "new_string": { "type": "string", "description": "The new text." }, + "replace_all": { "type": "boolean", "description": "Every occurrence." } + }, + "required": ["path", "old_string", "new_string"] + }) +} + +/// The refusal of `args` against `schema`, as the message and kind. +fn refused(schema: &serde_json::Value, args: &serde_json::Value) -> (String, ToolErrorKind) { + let Err(error) = check("StrReplace", schema, args) else { + panic!("{args} is refused"); + }; + (error.to_string(), error.kind()) +} + +#[test] +fn matching_arguments_pass() { + let full = json!({ + "path": "/a.md", + "old_string": "one", + "new_string": "two", + "replace_all": true + }); + assert!(check("StrReplace", &replace(), &full).is_ok(), "{full}"); + let least = json!({ "path": "/a.md", "old_string": "one", "new_string": "two" }); + assert!(check("StrReplace", &replace(), &least).is_ok(), "{least}"); +} + +#[test] +fn a_null_argument_list_reads_as_an_empty_object() { + let schema = json!({ "type": "object", "properties": {} }); + assert!(check("Read", &schema, &json!(null)).is_ok()); +} + +#[test] +fn a_missing_required_parameter_fails_naming_it() { + let args = json!({ "path": "/a.md", "old_string": "one" }); + let (message, kind) = refused(&replace(), &args); + assert_eq!( + message, + "std_StrReplace: `new_string` is required, and the call left it out" + ); + assert_eq!(kind, ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_required_parameter_passed_as_null_fails_naming_it() { + let args = json!({ "path": null, "old_string": "one", "new_string": "two" }); + let (message, kind) = refused(&replace(), &args); + assert_eq!( + message, + "std_StrReplace: `path` is required, and the call passed null" + ); + assert_eq!(kind, ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_parameter_of_the_wrong_type_fails_naming_it_and_the_value() { + let args = json!({ "path": "/a.md", "old_string": "one", "new_string": 2 }); + let (message, kind) = refused(&replace(), &args); + assert_eq!( + message, + "std_StrReplace: `new_string` must be a JSON string, and the call passed 2" + ); + assert_eq!(kind, ToolErrorKind::InvalidArguments); +} + +#[test] +fn arguments_that_are_not_an_object_fail() { + let schema = json!({ "type": "object", "properties": {} }); + let (message, kind) = refused(&schema, &json!(["a"])); + assert_eq!( + message, + "std_StrReplace: the arguments must be a JSON object" + ); + assert_eq!(kind, ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_value_outside_an_enum_fails_naming_the_allowed_values() { + let schema = json!({ + "type": "object", + "properties": { + "output_mode": { "type": "string", "enum": ["content", "count"] } + } + }); + let (message, kind) = refused(&schema, &json!({ "output_mode": "files" })); + assert_eq!( + message, + "std_StrReplace: `output_mode` must be one of \"content\", \"count\", and the call passed \"files\"" + ); + assert_eq!(kind, ToolErrorKind::InvalidArguments); +} + +#[test] +fn an_integer_schema_accepts_a_whole_number_and_refuses_a_fraction() { + let schema = json!({ + "type": "object", + "properties": { "limit": { "type": "integer" } } + }); + assert!(check("Read", &schema, &json!({ "limit": 3 })).is_ok()); + assert!(check("Read", &schema, &json!({ "limit": 3.5 })).is_err()); +} + +#[test] +fn an_array_schema_checks_every_element() { + let schema = json!({ + "type": "object", + "properties": { + "paths": { "type": "array", "items": { "type": "string" } } + } + }); + assert!(check("Read", &schema, &json!({ "paths": ["/a", "/b"] })).is_ok()); + assert!(check("Read", &schema, &json!({ "paths": ["/a", 2] })).is_err()); +} + +#[test] +fn a_parameter_the_schema_does_not_declare_passes_so_the_tool_can_see_it() { + let args = json!({ "path": "/a.md", "old_string": "one", "new_string": "two", "x": 1 }); + assert!( + check("StrReplace", &replace(), &args).is_ok(), + "extra arguments reach the tool, which refuses them itself" + ); +} diff --git a/crates/plugin-std/src/check.rs b/crates/plugin-std/src/check.rs new file mode 100644 index 000000000..c517b7c15 --- /dev/null +++ b/crates/plugin-std/src/check.rs @@ -0,0 +1,124 @@ +//! Argument checking against a tool's own JSON Schema. + +use runvil_plugin::{ToolError, ToolErrorKind}; +use serde_json::Value; + +/// Checks that `args` matches the `schema`, which is the `object` schema a +/// tool's definition file holds. +/// +/// Only the keywords these definitions use are honored: `type`, +/// `properties`, `required`, and `enum`, plus `items` for an array. A +/// schema this function does not understand is not a reason to refuse the +/// call, because the tool's own body decides what it can do with the value. +/// +/// # Errors +/// Returns an invalid-arguments [`ToolError`] naming the parameter and +/// what it expected. +pub(crate) fn check(tool: &str, schema: &Value, args: &Value) -> Result<(), ToolError> { + let args = if args.is_null() { + &Value::Object(serde_json::Map::new()) + } else { + args + }; + let Some(object) = args.as_object() else { + return Err(refusal(tool, "the arguments must be a JSON object")); + }; + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for name in required.iter().filter_map(Value::as_str) { + match object.get(name) { + None => { + return Err(refusal( + tool, + format!("`{name}` is required, and the call left it out"), + )); + } + Some(Value::Null) => { + return Err(refusal( + tool, + format!("`{name}` is required, and the call passed null"), + )); + } + Some(_) => {} + } + } + } + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return Ok(()); + }; + for (name, property) in properties { + let Some(value) = object.get(name).filter(|value| !value.is_null()) else { + continue; + }; + check_value(tool, name, property, value)?; + } + Ok(()) +} + +/// Checks one parameter's `value` against its `property` schema. +fn check_value(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), ToolError> { + if let Some(allowed) = property.get("enum").and_then(Value::as_array) + && !allowed.contains(value) + { + let allowed: Vec = allowed.iter().map(ToString::to_string).collect(); + return Err(refusal( + tool, + format!( + "`{name}` must be one of {}, and the call passed {value}", + allowed.join(", ") + ), + )); + } + let Some(expected) = property.get("type").and_then(Value::as_str) else { + return Ok(()); + }; + let matches = match expected { + "string" => value.is_string(), + "number" => value.is_number(), + "integer" => value.as_f64().is_some_and(|number| number.fract() == 0.0), + "boolean" => value.is_boolean(), + "array" => value.is_array(), + "object" => value.is_object(), + "null" => value.is_null(), + other => { + return Err(refusal( + tool, + format!("`{name}` has a schema type {other} this build does not know"), + )); + } + }; + if matches { + return check_items(tool, name, property, value); + } + Err(refusal( + tool, + format!("`{name}` must be a JSON {expected}, and the call passed {value}"), + )) +} + +/// Checks every element of `value`, when it is an array with an `items` +/// schema. +fn check_items(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), ToolError> { + let (Some(items), Some(elements)) = ( + property.get("items"), + value.as_array().filter(|_| property.get("items").is_some()), + ) else { + return Ok(()); + }; + for element in elements { + check_value(tool, name, items, element)?; + } + Ok(()) +} + +/// An invalid-arguments error whose message opens with the tool's id +/// segment. +fn refusal(tool: &str, reason: impl std::fmt::Display) -> ToolError { + ToolError::new( + ToolErrorKind::InvalidArguments, + format!("std_{tool}: {reason}"), + ) +} + +#[cfg(test)] +#[path = "check-tests.rs"] +mod tests; diff --git a/crates/plugin-std/src/lib.rs b/crates/plugin-std/src/lib.rs new file mode 100644 index 000000000..285d6d215 --- /dev/null +++ b/crates/plugin-std/src/lib.rs @@ -0,0 +1,77 @@ +//! `plugin-std` - the Pack that gives a run the standard file tools a +//! coding agent needs: read, write, string replace, delete, glob, and +//! grep. +//! +//! The crate's Plugin, [`install`], inserts the Pack `runvil/std`, whose +//! Pack id is `std`, and a run then holds all of its tools unless it +//! declares another vendor's `std`: +//! +//! | Lua id | Name the model calls | +//! |---|---| +//! | `std/read` | `Read` | +//! | `std/write` | `Write` | +//! | `std/replace` | `StrReplace` | +//! | `std/rm` | `Delete` | +//! | `std/glob` | `Glob` | +//! | `std/grep` | `Grep` | +//! +//! A script calls a tool by its Lua id, `tools.call("std/read", { path = +//! "/notes.md" })`, and offers it to the model with `tools.offer("std")` +//! or `tools.offer("std/read")`. The model then calls it by its wire name, +//! `Read`, which is Cursor's name for the same tool. +//! +//! The Pack has no prelude, so declaring it, as in `uses: [std]`, only +//! makes it required. It needs no per-run service and takes no +//! configuration but `null` or `{}`. +//! +//! # The definitions +//! +//! Each tool's description and parameter schema live in a JSON file under +//! `schemas/`, embedded at build time. They are Cursor's own +//! definitions, so a model that knows Cursor's tools already knows these, +//! with the lines that describe behavior this Pack does not have removed. +//! `schemas/EDITS.md` lists every such edit. +//! +//! # This build +//! +//! Every tool is a stub: a call with arguments that match the schema +//! answers [`NOT_IMPLEMENTED`], and a call whose arguments do not match +//! fails with an invalid-arguments +//! [`ToolError`](runvil_plugin::ToolError) naming the parameter. The +//! stubs exist so that the crate, the definitions, the ids, the wire +//! names, and the Lua path can be proven before any file logic exists. +//! +//! # Where the file access goes +//! +//! When the tools are real, every filesystem operation goes through the +//! call's [`Access`](runvil_plugin::Access), never the disk directly. That +//! puts every read and write under the run's `ModePolicy`, so a run in +//! `Mode::Ask` refuses every mutation without the Pack deciding anything, +//! and a run in `Mode::Plan` allows mutations to markdown paths alone. The +//! tools only report the resulting `VfsError` as a `ToolError` the model +//! can read. +//! +//! Shell is deliberately absent. An in-process shell over the same +//! filesystem is a separate Pack, so a run that wants one declares it. +//! +//! ## Invariants +//! +//! - May depend on: `runvil-plugin`, `shared-*` crates, and outside +//! libraries. `cargo test -p build-xtask` enforces the Plugin family's +//! allow-list. +//! - The crate names no async runtime and no filesystem of its own. Every +//! operation reaches files through the call's `Access`. +//! - A tool's description and parameter schema come from its JSON file +//! unchanged, except for the edits `schemas/EDITS.md` lists. A test per +//! tool holds the wire name in place. +//! - A call whose arguments the schema refuses is an +//! [`InvalidArguments`](runvil_plugin::ToolErrorKind::InvalidArguments) +//! failure that names the parameter. No other outcome reports the +//! arguments, so a model can tell a bad call from a missing +//! implementation. + +mod check; +mod pack; +mod spec; + +pub use crate::pack::{NOT_IMPLEMENTED, install}; diff --git a/crates/plugin-std/src/pack-tests.rs b/crates/plugin-std/src/pack-tests.rs new file mode 100644 index 000000000..e436e5929 --- /dev/null +++ b/crates/plugin-std/src/pack-tests.rs @@ -0,0 +1,57 @@ +//! Unit tests for the `std` Pack's stub text and the definitions it +//! builds from. + +use super::{NOT_IMPLEMENTED, STD}; +use crate::spec; + +#[test] +fn the_pack_is_runvil_std_and_takes_no_per_run_service() { + assert_eq!(STD.fqn, "runvil/std"); + assert!(STD.prelude.is_none()); + assert!(STD.needs.is_empty()); +} + +#[test] +fn the_stub_names_the_tool_in_every_place_the_text_mentions_it() { + let text = NOT_IMPLEMENTED.replace("", "Grep"); + assert_eq!( + text.matches("Grep").count(), + 2, + "the text names the tool twice: {text}" + ); + assert!(!text.contains(""), "{text}"); +} + +#[test] +fn the_stub_says_the_implementation_is_missing_and_the_result_is_stable() { + let text = NOT_IMPLEMENTED.replace("", "Write"); + for phrase in [ + "not implemented in this build", + "The implementation is missing", + "nothing in this session will change that", + "Calling Write again is safe", + "this same result every time", + "Carry on without it.", + ] { + assert!(text.contains(phrase), "missing {phrase:?}: {text}"); + } +} + +#[test] +fn the_stub_says_what_is_missing_before_it_says_anything_else() { + let text = NOT_IMPLEMENTED.replace("", "Read"); + assert!( + text.starts_with("The Read tool is not implemented"), + "the first clause says what is missing: {text}" + ); +} + +#[test] +fn the_specs_are_the_six_the_crate_names_in_order() { + let segments: Vec<&str> = spec::all() + .expect("every embedded definition parses") + .iter() + .map(|definition| definition.segment) + .collect(); + assert_eq!(segments, ["read", "write", "replace", "rm", "glob", "grep"]); +} diff --git a/crates/plugin-std/src/pack.rs b/crates/plugin-std/src/pack.rs new file mode 100644 index 000000000..c70f3b2e6 --- /dev/null +++ b/crates/plugin-std/src/pack.rs @@ -0,0 +1,128 @@ +//! The crate's Plugin, [`install`], the `std` Pack's `PackInfo`, the +//! `construct` that builds its one shared object, and the stub every tool +//! answers with in this build. + +use std::sync::Arc; + +use runvil_plugin::{ + HostServices, InstallError, Pack, PackFuture, PackId, PackInfo, PackRegistry, ToolContext, + ToolError, ToolId, ToolInfo, ToolOutput, +}; +use serde_json::{Value, json}; + +use crate::spec::{self, Definition}; + +/// What a call whose arguments match the schema answers in this build. +/// +/// The text says the implementation is missing rather than that the call +/// was wrong, because a model that reads it should stop calling the tool. +/// It also says a repeat call is safe, so a model that retries anyway +/// learns the outcome is the same rather than assuming the call was +/// malformed. `` is the tool's wire name. +pub const NOT_IMPLEMENTED: &str = "The tool is not implemented in this build. The \ + implementation is missing and nothing in this session will change that. Calling again \ + is safe, but it will return this same result every time, whatever the arguments. Carry on \ + without it."; + +/// The Pack's `PackInfo`. +/// +/// Its FQN is `runvil/std`, so its Pack id is `std`. It has no prelude, +/// needs no per-run service, and takes no configuration but `null` or +/// `{}`. +const STD: PackInfo = + PackInfo::new("runvil/std", construct).description("Read, write, search, and delete files"); + +/// The Plugin: inserts the `std` Pack into `packs` with `config["std"]`, +/// which is `null` when `config` has no `std` key. +/// +/// # Errors +/// Returns the registry's [`InstallError`], such as +/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/std` +/// already. +#[expect( + clippy::needless_pass_by_value, + reason = "the Plugin signature fixes the argument types" +)] +pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> { + packs.insert(STD, config["std"].clone()) +} + +/// The one object every run shares: its tool list. +#[derive(Debug)] +struct Std { + tools: Vec, +} + +/// Builds the Pack under the Pack id `name`: refuses any configuration but +/// `null` or `{}` and names each tool `/`. +#[expect( + clippy::needless_pass_by_value, + reason = "the PackInfo construct signature fixes the argument types" +)] +fn construct( + name: &PackId, + config: Value, + _services: &HostServices, +) -> Result, ToolError> { + if !(config.is_null() || config == json!({})) { + return Err(ToolError::message("std takes no configuration")); + } + let tools = spec::all()? + .iter() + .map(|definition| { + let id = ToolId::parse(&format!("{name}/{}", definition.segment)).map_err(|e| { + ToolError::message(format!( + "std could not name its {} tool", + definition.segment + )) + .with_source(e) + })?; + let info = ToolInfo::new( + id, + definition.description.clone(), + definition.parameters.clone(), + ) + .wire_name(definition.wire_name.clone()); + Ok(info) + }) + .collect::, ToolError>>()?; + Ok(Arc::new(Std { tools })) +} + +/// The stub text for the tool the model calls `wire_name`. +fn stub(wire_name: &str) -> String { + NOT_IMPLEMENTED.replace("", wire_name) +} + +/// The definition of the tool whose id ends in `segment`. +fn definition(segment: &str) -> Option<&'static Definition> { + spec::all() + .ok()? + .iter() + .find(|definition| definition.segment == segment) +} + +impl Pack for Std { + fn tools(&self) -> Vec { + self.tools.clone() + } + + fn call<'a>( + &'a self, + cx: ToolContext<'a>, + args: Value, + ) -> PackFuture<'a, Result> { + Box::pin(async move { + let name = cx.tool().name(); + let Some(definition) = definition(name) else { + return Err(ToolError::message(format!("std has no tool named {name}"))); + }; + crate::check::check(&definition.wire_name, &definition.parameters, &args)?; + Ok(ToolOutput::trusted(stub(&definition.wire_name))) + }) + } +} + +#[cfg(test)] +#[path = "pack-tests.rs"] +mod tests; diff --git a/crates/plugin-std/src/spec-tests.rs b/crates/plugin-std/src/spec-tests.rs new file mode 100644 index 000000000..4bca8158c --- /dev/null +++ b/crates/plugin-std/src/spec-tests.rs @@ -0,0 +1,102 @@ +//! The definitions: one file per tool, each parsed, each carrying the +//! wire name Cursor uses for the same tool. + +use super::all; + +/// The wire name each id segment must carry, which is Cursor's name for +/// the same tool. +const WIRE_NAMES: [(&str, &str); 6] = [ + ("read", "Read"), + ("write", "Write"), + ("replace", "StrReplace"), + ("rm", "Delete"), + ("glob", "Glob"), + ("grep", "Grep"), +]; + +#[test] +fn every_definition_parses_and_keeps_the_wire_name_it_was_snapshotted_with() { + let definitions = all().expect("every embedded definition parses"); + let read: Vec<(&str, &str)> = definitions + .iter() + .map(|definition| (definition.segment, definition.wire_name.as_str())) + .collect(); + assert_eq!(read, WIRE_NAMES); +} + +#[test] +fn every_definition_holds_a_description_and_an_object_schema_with_at_least_one_property() { + for definition in all().expect("every embedded definition parses") { + assert!( + !definition.description.trim().is_empty(), + "the {} tool has a description", + definition.segment + ); + assert_eq!( + definition.parameters.get("type").and_then(|t| t.as_str()), + Some("object"), + "the {} tool's schema describes an object: {}", + definition.segment, + definition.parameters + ); + let properties = definition + .parameters + .get("properties") + .and_then(|p| p.as_object()); + assert!( + properties.is_some_and(|properties| !properties.is_empty()), + "the {} tool takes parameters: {}", + definition.segment, + definition.parameters + ); + } +} + +#[test] +fn every_required_parameter_is_one_the_schema_declares_and_describes() { + for definition in all().expect("every embedded definition parses") { + let Some(required) = definition + .parameters + .get("required") + .and_then(|r| r.as_array()) + else { + continue; + }; + let properties = definition + .parameters + .get("properties") + .and_then(|p| p.as_object()) + .expect("a schema with required parameters declares properties"); + for name in required { + let name = name.as_str().expect("a required entry is a string"); + let property = properties.get(name).unwrap_or_else(|| { + panic!( + "the {} tool requires {name}, which its schema does not declare", + definition.segment + ) + }); + assert!( + property + .get("description") + .and_then(|d| d.as_str()) + .is_some_and(|text| !text.trim().is_empty()), + "the {} tool's {name} parameter has a description: {property}", + definition.segment + ); + } + } +} + +#[test] +fn no_definition_names_behavior_this_build_does_not_have() { + for definition in all().expect("every embedded definition parses") { + let text = format!("{} {}", definition.description, definition.parameters); + for absent in ["PDF", "Shell tool", "Cursor workspace"] { + assert!( + !text.contains(absent), + "the {} tool still mentions {absent}: {text}", + definition.segment + ); + } + } +} diff --git a/crates/plugin-std/src/spec.rs b/crates/plugin-std/src/spec.rs new file mode 100644 index 000000000..c68b6233d --- /dev/null +++ b/crates/plugin-std/src/spec.rs @@ -0,0 +1,86 @@ +//! The six tool definitions this crate ships, each an embedded JSON file. +//! +//! A file holds the name the model calls the tool by, the description the +//! model reads, and the JSON Schema its arguments must match. The names, +//! descriptions, and schemas are Cursor's own, with the lines that +//! describe behavior this Pack does not have removed; `schemas/EDITS.md` +//! records each of those edits. + +use std::sync::LazyLock; + +use runvil_plugin::{ToolError, ToolErrorKind}; +use serde::Deserialize; +use serde_json::Value; + +/// One tool's definition: its id segment, and the file's own contents. +#[derive(Debug)] +pub(crate) struct Definition { + /// The tool's last id segment, such as `read`, which its file's name + /// gives. + pub(crate) segment: &'static str, + /// The name the model calls the tool by. + pub(crate) wire_name: String, + /// The description the model reads. + pub(crate) description: String, + /// The JSON-Schema `object` the tool's arguments must match. + pub(crate) parameters: Value, +} + +/// One tool's file, as the file itself holds it. +#[derive(Debug, Deserialize)] +struct File { + /// The name the model calls the tool by. + wire_name: String, + /// The description the model reads. + description: String, + /// The JSON-Schema `object` the tool's arguments must match. + parameters: Value, +} + +/// Each tool's id segment beside the source of the file it reads. +const SOURCES: [(&str, &str); 6] = [ + ("read", include_str!("../schemas/read.json")), + ("write", include_str!("../schemas/write.json")), + ("replace", include_str!("../schemas/replace.json")), + ("rm", include_str!("../schemas/rm.json")), + ("glob", include_str!("../schemas/glob.json")), + ("grep", include_str!("../schemas/grep.json")), +]; + +/// The parsed definitions, one per [`SOURCES`] entry in order. +static DEFINITIONS: LazyLock, ToolError>> = LazyLock::new(|| { + SOURCES + .iter() + .map(|(segment, source)| { + let file: File = serde_json::from_str(source).map_err(|e| { + ToolError::new( + ToolErrorKind::Backend, + format!("std could not read the definition of its {segment} tool"), + ) + .with_source(e) + })?; + Ok(Definition { + segment, + wire_name: file.wire_name, + description: file.description, + parameters: file.parameters, + }) + }) + .collect() +}); + +/// Every definition, in [`SOURCES`] order. +/// +/// # Errors +/// Returns a backend [`ToolError`] naming the tool whose file does not +/// parse. The files are embedded, so this can only happen after an edit. +pub(crate) fn all() -> Result<&'static [Definition], ToolError> { + match &*DEFINITIONS { + Ok(definitions) => Ok(definitions), + Err(error) => Err(ToolError::new(error.kind(), error.to_string())), + } +} + +#[cfg(test)] +#[path = "spec-tests.rs"] +mod tests; diff --git a/crates/plugin-std/tests/it/main.rs b/crates/plugin-std/tests/it/main.rs new file mode 100644 index 000000000..cb6481d6c --- /dev/null +++ b/crates/plugin-std/tests/it/main.rs @@ -0,0 +1,9 @@ +//! Integration tests for `plugin-std`: the Pack `install` inserts, the +//! tools `construct` names under the Pack id it receives, and what a call +//! answers with arguments that match or miss the schema. +#![expect( + clippy::expect_used, + reason = "test helpers panic on setup failure, which is the desired behavior" +)] + +mod tools; diff --git a/crates/plugin-std/tests/it/tools.rs b/crates/plugin-std/tests/it/tools.rs new file mode 100644 index 000000000..21a02897b --- /dev/null +++ b/crates/plugin-std/tests/it/tools.rs @@ -0,0 +1,260 @@ +//! The `std` Pack through the crate's `install`: the one Pack it inserts +//! with its config, the six tools `construct` names under the Pack id it +//! receives, and what a call answers. + +use std::future::Future; +use std::pin::pin; +use std::sync::Arc; +use std::task::{Context, Poll, Waker}; + +use plugin_std::{NOT_IMPLEMENTED, install}; +use runvil_plugin::testing::TestCall; +use runvil_plugin::{ + HostServices, InstallError, OutputTrust, Pack, PackId, PackRegistry, ToolError, ToolErrorKind, + ToolId, ToolOutput, +}; +use serde_json::{Value, json}; + +/// Drives a future that never pends to its output. +fn block_on(future: F) -> F::Output { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } + } +} + +/// The Pack `install` inserts into a fresh registry. +fn std_pack() -> runvil_plugin::PackInfo { + let mut packs = PackRegistry::new(); + install(&mut packs, Value::Null).expect("a fresh registry takes the Pack"); + let (info, _id, _config) = packs + .into_entries() + .find(|(info, _, _)| info.fqn != "runvil/subagents") + .expect("install inserts one Pack"); + info +} + +/// Each Pack `install` inserts into a fresh registry with `config`, beside +/// the Pack id and config the registry holds it with. +fn inserted(config: Value) -> Vec<(String, String, Value)> { + let mut packs = PackRegistry::new(); + install(&mut packs, config).expect("a fresh registry takes the Pack"); + packs + .into_entries() + .filter(|(info, _, _)| info.fqn != "runvil/subagents") + .map(|(info, id, config)| (info.fqn.to_owned(), id.to_string(), config)) + .collect() +} + +/// The Pack built under the Pack id `name` with `config`. +fn construct(name: &str, config: Value) -> Result, ToolError> { + let name = PackId::parse(name).expect("a one-segment Pack id parses"); + (std_pack().construct)(&name, config, &HostServices::new()) +} + +/// The Pack built under `std`. +fn built() -> Arc { + let Ok(pack) = construct("std", Value::Null) else { + panic!("construct succeeds with no configuration"); + }; + pack +} + +/// The tool id `segment` under the Pack id `std`. +fn id(segment: &str) -> ToolId { + ToolId::parse(&format!("std/{segment}")).expect("a two-segment tool id parses") +} + +/// Calls the tool `segment` with `args`. +fn call(segment: &str, args: Value) -> Result { + let pack = built(); + let call = TestCall::new(id(segment)); + block_on(pack.call(call.context(), args)) +} + +#[test] +fn the_pack_is_runvil_std_with_no_prelude_and_no_per_run_needs() { + let info = std_pack(); + assert_eq!(info.fqn, "runvil/std"); + assert!(info.prelude.is_none()); + assert!(info.needs.is_empty()); + assert_eq!(info.description, "Read, write, search, and delete files"); +} + +#[test] +fn install_adds_the_std_pack_with_the_config_under_its_pack_id() { + for (config, expected) in [ + (json!({ "std": {}, "web": { "x": 1 } }), json!({})), + (json!({ "web": {} }), Value::Null), + (Value::Null, Value::Null), + ] { + let read = inserted(config.clone()); + assert_eq!( + read, + [("runvil/std".to_owned(), "std".to_owned(), expected)], + "{config}" + ); + } +} + +#[test] +fn install_returns_the_registrys_refusal() { + let mut packs = PackRegistry::new(); + install(&mut packs, Value::Null).expect("the first install inserts std"); + assert_eq!( + install(&mut packs, Value::Null), + Err(InstallError::DuplicatePack { fqn: "runvil/std" }) + ); +} + +#[test] +fn construct_names_six_tools_under_the_pack_id_it_receives() { + let Ok(pack) = construct("files", Value::Null) else { + panic!("construct succeeds with no configuration"); + }; + let ids: Vec = pack + .tools() + .iter() + .map(|tool| tool.id.to_string()) + .collect(); + assert_eq!( + ids, + [ + "files/read", + "files/write", + "files/replace", + "files/rm", + "files/glob", + "files/grep" + ] + ); +} + +#[test] +fn every_tool_carries_cursors_name_for_it_as_its_wire_name() { + let pack = built(); + let names: Vec<(String, Option)> = pack + .tools() + .iter() + .map(|tool| (tool.id.to_string(), tool.wire_name.clone())) + .collect(); + assert_eq!( + names, + [ + ("std/read".to_owned(), Some("Read".to_owned())), + ("std/write".to_owned(), Some("Write".to_owned())), + ("std/replace".to_owned(), Some("StrReplace".to_owned())), + ("std/rm".to_owned(), Some("Delete".to_owned())), + ("std/glob".to_owned(), Some("Glob".to_owned())), + ("std/grep".to_owned(), Some("Grep".to_owned())), + ] + ); +} + +#[test] +fn no_tool_survives_a_stop_or_reports_structured_output() { + for tool in built().tools() { + assert!(!tool.survives_stop, "{}: {}", tool.id, tool.survives_stop); + assert!( + !tool.structured_output, + "{}: {}", + tool.id, tool.structured_output + ); + assert!(!tool.describes_tools, "{}", tool.id); + } +} + +#[test] +fn construct_accepts_null_or_an_empty_object_and_refuses_any_other_configuration() { + assert!(construct("std", json!({})).is_ok()); + for config in [json!({ "max_chars": 10 }), json!([]), json!("std")] { + let Err(error) = construct("std", config.clone()) else { + panic!("{config} is refused"); + }; + assert_eq!(error.to_string(), "std takes no configuration"); + } +} + +#[test] +fn every_tool_answers_the_not_implemented_text_naming_itself() { + for (segment, wire_name) in [ + ("read", "Read"), + ("write", "Write"), + ("replace", "StrReplace"), + ("rm", "Delete"), + ("glob", "Glob"), + ("grep", "Grep"), + ] { + let args = match segment { + "read" | "rm" => json!({ "path": "/notes.md" }), + "write" => json!({ "path": "/notes.md", "contents": "hi" }), + "replace" => json!({ + "path": "/notes.md", + "old_string": "a", + "new_string": "b" + }), + "glob" => json!({ "glob_pattern": "**/*.md" }), + _ => json!({ "pattern": "TODO" }), + }; + let output = call(segment, args).expect("a matching call succeeds"); + let expected = NOT_IMPLEMENTED.replace("", wire_name); + assert_eq!(output.text(), expected, "{segment}"); + assert_eq!(output.trust(), OutputTrust::Trusted, "{segment}"); + } +} + +#[test] +fn the_not_implemented_text_says_the_result_never_changes() { + let text = NOT_IMPLEMENTED.replace("", "Read"); + assert!( + text.contains("nothing in this session will change that"), + "{text}" + ); + assert!(text.contains("Calling Read again is safe"), "{text}"); + assert!(text.contains("this same result every time"), "{text}"); + assert!(!text.contains('<'), "the name is filled in: {text}"); +} + +#[test] +fn a_missing_required_parameter_fails_naming_it() { + let error = call("grep", json!({})).expect_err("pattern is required"); + assert_eq!( + error.to_string(), + "std_Grep: `pattern` is required, and the call left it out" + ); + assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_parameter_of_the_wrong_type_fails_naming_it() { + let error = call("read", json!({ "path": 7 })).expect_err("path is a string"); + assert_eq!( + error.to_string(), + "std_Read: `path` must be a JSON string, and the call passed 7" + ); + assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_value_outside_an_enum_fails_listing_the_allowed_values() { + let error = call("grep", json!({ "pattern": "x", "output_mode": "lines" })) + .expect_err("output_mode has three allowed values"); + assert_eq!( + error.to_string(), + "std_Grep: `output_mode` must be one of \"content\", \"files_with_matches\", \"count\", \ + and the call passed \"lines\"" + ); + assert_eq!(error.kind(), ToolErrorKind::InvalidArguments); +} + +#[test] +fn a_call_of_a_tool_the_pack_lacks_fails_naming_it() { + let pack = built(); + let unknown = TestCall::new(id("copy")); + let error = + block_on(pack.call(unknown.context(), json!({}))).expect_err("std has no copy tool"); + assert_eq!(error.to_string(), "std has no tool named copy"); +} diff --git a/crates/runvil-internal/README.md b/crates/runvil-internal/README.md index fdce966a4..edcf9fac2 100644 --- a/crates/runvil-internal/README.md +++ b/crates/runvil-internal/README.md @@ -1,3 +1,3 @@ # Engine internals -This directory holds the Engine's private crates, which together parse a prompt program and step its run. Code outside the Engine reaches them only through the Engine's public API crate, the one crate allowed to depend on this directory. +This directory holds the Engine's private crates, which together parse a prompt program and step its run. Code outside the Engine reaches them only through the Engine's two public crates, `runvil` and `runvil-plugin`, the only crates outside this directory allowed to depend on it. diff --git a/crates/runvil-internal/engine/AGENTS.md b/crates/runvil-internal/engine/AGENTS.md index 0a8a2c42b..a65f4c22f 100644 --- a/crates/runvil-internal/engine/AGENTS.md +++ b/crates/runvil-internal/engine/AGENTS.md @@ -1,4 +1,4 @@ # runvil-engine -- 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; the caller, performing a `Vfs` effect, uses the view it was given and never derives, widens, or retains store scope from it. +- The Engine depends on no tool, search, or model implementation; a run reaches one only through an effect the caller performs. `cargo test -p build-xtask` enforces the dependency rules. +- Store access is decided only by the executor: every store view is derived from the chain's access inside the Engine at dispatch for a `Vfs` effect and once at section-VM install for the section's `store` table; the caller, performing a `Vfs` effect, uses the view it was given and never derives, widens, or retains store scope from it. diff --git a/crates/runvil-internal/engine/README.md b/crates/runvil-internal/engine/README.md index 65497d5b8..19bfa6ecd 100644 --- a/crates/runvil-internal/engine/README.md +++ b/crates/runvil-internal/engine/README.md @@ -1,3 +1,3 @@ # Engine executor -This crate is the Engine's executor: it runs a parsed prompt as a deterministic state machine. Each model round, Pack render, catalog tool call, `store` operation, and timer leaves the executor as an effect for the Harness to perform and answer, and every boundary is reported as an event. Adhoc tool calls, `GetDynamicTools` calls, refused discovery calls, and the store calls a Pack prelude makes while it installs run inside the Engine and are reported only as events, and the run's start probe reads the store directly. Given the same prompt, arguments, and `RunContext` inputs, its seed and start time included, and the same answers, a run produces the same effects and events. +This crate is the Engine's executor: it runs a parsed prompt as a deterministic state machine. Each model round, Pack render, Pack tool call, `store` operation, and timer leaves the executor as an effect for the Harness to perform and answer, and every boundary is reported as an event. Adhoc tool calls, `GetDynamicTools` calls, refused discovery calls, and the store calls a Pack prelude makes while it installs run inside the Engine and are reported only as events, and the run's start probe reads the store directly. Given the same prompt, arguments, and `RunContext` inputs, its seed and start time included, and the same answers delivered in the same order at the same steps, a run produces the same effects and events. diff --git a/crates/runvil-internal/engine/benches/models_loop.rs b/crates/runvil-internal/engine/benches/models_loop.rs index 2b9384037..b525d8bee 100644 --- a/crates/runvil-internal/engine/benches/models_loop.rs +++ b/crates/runvil-internal/engine/benches/models_loop.rs @@ -94,15 +94,15 @@ fn run_once(prompt: &Prompt, chat: &ScriptedChat, context: u32) -> RunResult { let (result, _) = drive(run, |_, effect| { perform_locally(effect, &mut |round| { let Effect::Chat { + binding, messages, tools, - options, .. } = round else { unreachable!("perform_locally hands the model only chat effects"); }; - let answer = chat.complete_now(messages, tools, options); + let answer = chat.complete_now(messages, tools, &binding.completion_options()); EffectAnswer::Chat(answer.map(Box::new)) }) }); diff --git a/crates/runvil-internal/engine/src/cancel.rs b/crates/runvil-internal/engine/src/cancel.rs deleted file mode 100644 index 2d117f98a..000000000 --- a/crates/runvil-internal/engine/src/cancel.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Cooperative cancellation for the Engine. -//! -//! The Engine is a pure state machine, so instead of selecting over a -//! cancellation token it polls a flag between chain steps and from the Lua -//! instruction hook. That flag is the synchronous [`CancelHandle`] from the -//! `runvil-types` crate, re-exported here so the crate's -//! `cancel::CancelHandle` path names the one handle a run holds. - -pub(crate) use runvil_types::cancel::CancelHandle; diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index 0b3433359..34d95dca9 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -1,26 +1,35 @@ //! The crate's internal error type. //! //! [`Error`] is a `pub(crate)` internal error type, never part of the public API. -//! Every public boundary returns its own typed error ([`crate::RunError`], -//! [`ParseError`](runvil_parser::ParseError), [`CompletionError`](runvil_model_client::model::CompletionError), -//! [`runvil_types::tools::ToolError`]); those wrappers -//! classify this internal type and keep its cause. See the module wrappers for -//! the `From` bridges that let internal `?` keep flowing through the error type. +//! The public boundary is [`crate::RunError`], and [`ParseError`](crate::parser::ParseError), +//! [`CompletionError`](crate::model::CompletionError), and +//! [`runvil_types::tools::ToolError`] each reach it through `RunError`'s +//! classification, which classifies this internal type and keeps its cause. +//! The `From` bridges that let internal `?` reach this type live in `convert`. //! //! A variant that keeps an originating error renders that error's text in //! its own `Display` and holds the error as a plain `cause` field behind //! [`Error::originating_error`], never as a `#[source]`, so a printer that //! walks the source chain shows the cause's text once. +//! +//! Three variants are `#[error(transparent)]` and so keep no `cause` field +//! of their own: `Parse`, `Completion`, and `Substitution` hand their whole +//! `Display` and `source` to the error they hold. That is the exception to +//! the field rule above, and it is why [`Error::originating_error`] answers +//! `None` for `Completion` and `Substitution` and the parser's own cause for +//! `Parse`. use runvil_types::ids::TaskId; +use crate::lua::DiscoveryTool; + mod convert; mod sets; #[cfg(test)] mod tests; mod value; -pub(crate) use sets::{SetRule, SetTool}; +pub(crate) use sets::{SetEvidence, SetRule, SetTool}; /// A type-erased owned error cause used by the internal error type. type BoxedSource = Box; @@ -41,12 +50,45 @@ fn cause_suffix(cause: Option<&BoxedSource>) -> String { cause.map(|cause| format!(": {cause}")).unwrap_or_default() } +/// The [`Error::OutOfScopeToolCall`] message: the name the model tried, +/// the Static Set's aliases labelled as what they are, the offered-but- +/// unscoped hint when the name is a catalog tool's wire name, and where a +/// Dynamic Set tool is reached from when the section has a Dynamic Set. +fn out_of_scope_message( + name: &str, + global_exists: bool, + static_aliases: &[String], + has_dynamic_set: bool, +) -> String { + let hint = if global_exists { + " (a catalog tool that was not offered in this section)" + } else { + "" + }; + // A section whose Dynamic Set holds nothing freezes no discovery tool, + // so naming one would point the model at a tool it cannot call. + let dynamic_set = if has_dynamic_set { + format!( + "; a Dynamic Set tool is reached through {} and {}", + DiscoveryTool::Search.wire_name(), + DiscoveryTool::Call.wire_name() + ) + } else { + String::new() + }; + format!( + "tool {name:?} is not in this section's scope; aliases this section offers: \ + {static_aliases:?}{hint}{dynamic_set}" + ) +} + /// The call name [`Error::CallDepthExceeded`] reports for a nesting that -/// passed the call-depth cap: `tasks.fanout` for a fanout arm, and `call` -/// for a `call` or a `tasks.spawn`, which shares its cap. +/// passed the call-depth cap. The name is the cap's, not the author's +/// construct: `tasks.fanout` for a fanout arm, and `call` for a `call` or a +/// `tasks.spawn`, which shares its cap. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DepthVia { - /// A `call`, or a `tasks.spawn`, which shares `call`'s cap. + /// A `call`, or a `tasks.spawn`. Call, /// One arm of a `tasks.fanout`. Fanout, @@ -64,10 +106,8 @@ impl std::fmt::Display for DepthVia { /// The crate's internal error type, spanning parsing, model, and execution /// failures. /// -/// This type is `pub(crate)` and never appears in the public API; the public -/// boundary errors wrap and classify it. The model variant holds the broker's -/// closed error whole, so no HTTP client's error type leaks through the -/// wrappers' `source()`. +/// The model variant holds the broker's closed error whole, so no HTTP +/// client's error type leaks through the wrappers' `source()`. #[derive(Debug, thiserror::Error)] pub(crate) enum Error { /// The prompt failed to parse: invalid frontmatter YAML or a @@ -82,7 +122,7 @@ pub(crate) enum Error { /// A model round or a catalog fetch failed, as the broker that performed /// it reported. /// - /// Holds the broker's [`CompletionError`](runvil_model_client::model::CompletionError) + /// Holds the broker's [`CompletionError`](crate::model::CompletionError) /// whole, so the closed kind, the fixed message, the opt-in detail, and /// the cause chain survive the public wrappers. The Engine branches on /// its kind only: a context overflow takes the provider overflow path @@ -90,8 +130,9 @@ pub(crate) enum Error { #[error(transparent)] Completion(crate::model::CompletionError), - /// The run was cancelled, or the caller dropped a call the run was - /// waiting on. + /// The run was cancelled, the caller dropped an effect the run was + /// waiting on, or a block raised a caught `cancelled` error value again + /// after another suspending call. #[error("interrupted: the run was cancelled or this call was stopped")] Interrupted, @@ -154,14 +195,11 @@ pub(crate) enum Error { /// render answered, and the rule. /// /// Constructed only by the merge. - #[error("{}", sets::sets_message(.rule, .tools))] + #[error("{}", sets::sets_message(.evidence))] ToolSets { - /// The rule the sets broke. - rule: SetRule, - /// Each tool that broke it: both tools of a clash, the one - /// rendered tool a render rule refuses, or none when the render - /// answered other Packs. - tools: Vec, + /// What broke the rule: the tools that broke one, or the Packs a + /// render answered. + evidence: SetEvidence, }, /// A `{{ }}` prose substitution failed (unknown/missing path, unclosed). @@ -184,7 +222,8 @@ pub(crate) enum Error { /// waited on nor cancelled is the author's bug: the chain's outcome /// becomes this error (the run's for the root walk, the call's answer /// for a `call` chain) and the leaked tasks are abandoned. The message - /// names the ids in spawn order; the Lua table lists them as `tasks`. + /// names the ids in spawn order, and the Lua table holds them as one + /// `tasks` string joined by `, `. #[error( "chain ended with tasks still live: {}; wait on or cancel every task a chain spawns before it ends", join_task_ids(.tasks) @@ -196,9 +235,10 @@ pub(crate) enum Error { /// A task operation named a task the caller does not own. /// - /// Only the spawning chain may wait on, inspect, or cancel a task; a - /// chain may additionally read the status of, and annotate, the task - /// it runs inside. An id that names no task at all is refused the same + /// Only the task's owner - its spawner, or the root walk for a task the + /// H1 pass spawned - may wait on, inspect, or cancel it; a chain may + /// additionally read the status of, and annotate, the task it runs + /// inside. An id that names no task at all is refused the same /// way, so a caller learns nothing about tasks it never started. #[error("task `{task}` is not a task this chain owns")] TaskNotOwned { @@ -230,14 +270,25 @@ pub(crate) enum Error { /// This is the model tool loop's error alone: a script `tools.call` /// resolves against the run's whole catalog and fails with /// [`Error::UnboundToolCall`] instead. - #[error("tool {name:?} is not in this section's scope; in-scope aliases: {in_scope:?}{}", if *.global_exists { " (a catalog tool that was not offered in this section)" } else { "" })] + #[error( + "{}", + out_of_scope_message(.name, *.global_exists, .static_aliases, *.has_dynamic_set) + )] OutOfScopeToolCall { /// The alias or identifier the model tried to use. name: String, /// Whether the name is a catalog tool's wire name. global_exists: bool, - /// The Static Set's wire names. - in_scope: Vec, + /// Whether the section has a Dynamic Set holding a tool, and so + /// offers the two discovery tools. + /// + /// The caller reads it from the frozen sets. The message must not + /// infer it from `static_aliases`: a catalog tool may take a + /// discovery tool's wire name while no Dynamic Set holds a tool. + has_dynamic_set: bool, + /// The Static Set's wire names: this section's own offers, with the + /// two discovery tools when a Dynamic Set holds a tool. + static_aliases: Vec, }, /// A `tools.call` named no adhoc tool and no tool in the run's catalog. @@ -266,13 +317,15 @@ pub(crate) enum Error { /// The prompt declares a `runvil:` major this build does not support, /// so it is refused rather than run under mismatched rules. + /// + /// Major 0 is the only supported major, and the `Display` names it + /// beside the major the prompt declared. #[error("unsupported runvil version: {0} (this build supports major 0)")] UnsupportedVersion(u32), - /// The environment cannot satisfy the prompt: a required Pack is - /// missing, unavailable, or needs a service the run lacks, 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 environment cannot satisfy the prompt, for any cause + /// [`RunErrorKind::RequirementsUnmet`](crate::RunErrorKind::RequirementsUnmet) + /// lists. /// /// The notice, followed by the cause's text when there is one, is the /// whole message, written to be read by a model. @@ -305,23 +358,23 @@ pub(crate) enum Error { /// The Rust source position of the construction site is captured (via /// [`Error::internal`] or [`Error::internal_with_cause`], both /// `#[track_caller]`) so [`crate::RunError::location`] can point at the - /// broken invariant. + /// broken invariant. A Lua-crate internal failure brings its own + /// captured site. #[error("internal invariant violated: {message}{}", cause_suffix(.cause.as_ref()))] Internal { - /// The violated invariant, as a noun phrase; owned when it names a - /// value, as a Lua internal message passed through whole may. + /// The violated invariant, as a noun phrase. message: std::borrow::Cow<'static, str>, - /// The Rust source file of the construction site (from `file!()`). + /// The Rust source file of the construction site. file: &'static str, - /// The 1-based line of the construction site (from `line!()`). + /// The 1-based line of the construction site. line: u32, /// The failure that broke the invariant, when one did. cause: Option, }, - /// A Lua log quota (log events or log bytes) ran out. This stable typed - /// variant lets the caller tell quota exhaustion apart from an authoring - /// error. + /// A Lua quota ran out: a `log()` event or byte budget. This stable + /// typed variant lets the caller tell quota exhaustion apart from an + /// authoring error. #[error("lua {resource} quota exceeded")] LuaQuota { /// The exhausted resource. @@ -356,6 +409,12 @@ pub(crate) enum Error { /// the operation (or the probe text, which says when the run declares no /// store), kept beside the error because the error alone does not name /// the operation. + /// + /// The cause is the concrete `VfsError` rather than the `BoxedSource` + /// the other cause-bearing variants hold, because the Lua error table + /// carries the tag and fields this crate rebuilds that type from, so a + /// raise that crossed the Lua boundary can be turned back into the + /// variant it stands in for. #[error("{message}")] Store { /// The rendered store failure message, from the Lua crate's diff --git a/crates/runvil-internal/engine/src/error/convert.rs b/crates/runvil-internal/engine/src/error/convert.rs index b4a47bc12..0594c3d1d 100644 --- a/crates/runvil-internal/engine/src/error/convert.rs +++ b/crates/runvil-internal/engine/src/error/convert.rs @@ -1,12 +1,11 @@ //! Building the internal error: its constructors, the `From` bridges that -//! map the gateway client, parser, and Lua crates' errors onto it variant +//! map the model-client, parser, and Lua crates' errors onto it variant //! for variant, and the rebuild of a raised Lua error table into the //! variant it stands in for. use std::borrow::Cow; use runvil_lua::Error as LuaError; -use runvil_model_client::model::ModelSetError; use runvil_parser::Error as ParserError; use runvil_types::ids::TaskId; use runvil_vfs::VfsError; @@ -16,7 +15,7 @@ use super::{BoxedSource, Error}; impl Error { /// Builds a parse failure with a stable classification and no source span. pub(crate) fn parse(kind: crate::parser::ParseErrorKind, message: impl Into) -> Error { - Error::Parse(runvil_parser::detail::parse_error(kind, message)) + Error::Parse(crate::parser::detail::parse_error(kind, message)) } /// Stamps the prompt's frontmatter name onto a parse failure raised by @@ -26,7 +25,7 @@ impl Error { pub(crate) fn with_prompt_name(self, name: &str) -> Error { match self { Error::Parse(error) => { - Error::Parse(runvil_parser::detail::with_prompt_name(error, name)) + Error::Parse(crate::parser::detail::with_prompt_name(error, name)) } other => other, } @@ -46,6 +45,21 @@ impl Error { } } + /// Builds an internal-invariant failure at a construction site the + /// caller already captured, such as the one a Lua-crate internal + /// failure brings. + pub(crate) fn internal_at( + message: impl Into>, + location: &'static std::panic::Location<'static>, + ) -> Error { + Error::Internal { + message: message.into(), + file: location.file(), + line: location.line(), + cause: None, + } + } + /// Builds an internal-invariant failure that keeps the failure which /// broke the invariant as its cause, capturing the call site as /// [`Error::internal`] does. @@ -109,15 +123,6 @@ impl From for Error { } } -/// Maps a poisoned model-set lock onto [`Error::Internal`]. The lock is the -/// run's own mutex failing, which no prompt author can cause and which is -/// not a model failure, so it never becomes an [`Error::Completion`]. -impl From for Error { - fn from(_: ModelSetError) -> Error { - Error::internal("model set mutex was poisoned") - } -} - impl From for Error { fn from(error: crate::model::CompletionError) -> Error { Error::Completion(error) @@ -126,16 +131,15 @@ impl From for Error { /// Maps a parse failure onto this internal type: a frontmatter or /// structured failure stays a parse failure whole, and a Lua compile or -/// internal failure maps onto its own variant, so `Display`, `source()` -/// chains, and `RunError` classification are unchanged by the parser -/// extraction. The parser crate's error type is not `#[non_exhaustive]` -/// (the two crates version together), so this match is total. +/// internal failure maps onto its own variant. The parser crate's error +/// type is not `#[non_exhaustive]` (the two crates version together), so +/// this match is total. impl From for Error { fn from(error: crate::parser::ParseError) -> Self { - match runvil_parser::detail::parse_error_into_inner(error) { + match crate::parser::detail::parse_error_into_inner(error) { inner @ (ParserError::ParseFrontmatter { .. } | ParserError::ParseStructured { .. }) => { - Error::Parse(runvil_parser::detail::parse_error_from_inner(inner)) + Error::Parse(crate::parser::detail::parse_error_from_inner(inner)) } ParserError::Lua(lua) => Error::from(lua), ParserError::Internal(message) => Error::internal(message), @@ -143,11 +147,10 @@ impl From for Error { } } -/// Maps the Lua crate's internal type back onto this one variant for -/// variant, so `Display`, `source()` chains, and `RunError`/`CompletionError` -/// classification are unchanged by the extraction. The Lua crate's error type -/// is not `#[non_exhaustive]` (the two crates version together), so this match -/// is total. +/// Maps the Lua crate's internal type onto this one variant for variant, +/// and rebuilds a raised error table through [`Error::from_raised`]. The +/// Lua crate's error type is not `#[non_exhaustive]` (the two crates +/// version together), so this match is total. impl From for Error { fn from(error: LuaError) -> Error { match error { @@ -174,7 +177,7 @@ impl From for Error { LuaError::Interrupted => Error::Interrupted, LuaError::Tool { message, cause } => Error::Tool { message, cause }, LuaError::Store { message, cause } => Error::Store { message, cause }, - LuaError::Internal(message) => Error::internal(message), + LuaError::Internal { message, location } => Error::internal_at(message, location), LuaError::Raised(raised) => Error::from_raised(raised), } } @@ -199,8 +202,8 @@ fn raised_field<'a>(raised: &'a runvil_lua::Raised, name: &str) -> Option<&'a st /// Rebuilds the structured [`runvil_vfs::VfsError`] a raised store /// error table stands in for, from its `reason`, its fields, and its -/// message. The table was built from the same variant by this crate's own -/// [`ErrorValue`](runvil_lua::ErrorValue) rendering, so the +/// message. The table was built from the same variant by +/// [`store_error_value_fields`](runvil_lua::store_error_value_fields), so the /// reconstruction is exact where the fields round-trip; a malformed table /// degrades to the closest variant rather than panicking. fn raised_store_error(raised: &runvil_lua::Raised) -> VfsError { @@ -289,9 +292,12 @@ fn raised_store_error(raised: &runvil_lua::Raised) -> VfsError { impl Error { /// Maps a structured error table that surfaced as a block's failure /// onto the variant its kind names, so a Lua-side raise classifies as - /// the Rust-raised error it stands in for. A kind whose variant needs - /// structure the table does not hold (the tool-scope errors, the task - /// errors, `model_failure`, `model_required`, `internal`) keeps its + /// the Rust-raised error it stands in for. A `task_not_owned` or + /// `task_consumed` table rebuilds its variant from its `task` field, and + /// every `cancelled` table maps to [`Error::Interrupted`]. A kind whose + /// variant needs structure the table does not hold (the tool-scope + /// errors, `tasks_live`, `model_failure`, `model_required`, `internal`), + /// or a `context_exhausted` or task table missing its field, keeps its /// message as a Lua failure. fn from_raised(raised: runvil_lua::Raised) -> Error { match raised.kind { diff --git a/crates/runvil-internal/engine/src/error/sets.rs b/crates/runvil-internal/engine/src/error/sets.rs index 2d002e1d6..f4197ef57 100644 --- a/crates/runvil-internal/engine/src/error/sets.rs +++ b/crates/runvil-internal/engine/src/error/sets.rs @@ -1,22 +1,17 @@ -//! The rules of the merge that builds a section entry's frozen sets, and -//! the message [`Error::ToolSets`](super::Error::ToolSets) renders when -//! the sets break one. +//! The rules of the merge that builds a section entry's frozen sets, the +//! evidence a broken rule carries, and the message +//! [`Error::ToolSets`](super::Error::ToolSets) renders when the sets break +//! one. -use runvil_lua::OfferMode; use runvil_types::packs::PackId; use runvil_types::tools::ToolId; -/// A merge rule a section entry's frozen sets broke. +use crate::lua::{NonEmpty, OfferMode}; + +/// A merge rule a section entry's frozen sets broke, among the rules that +/// name the tools that broke them. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum SetRule { - /// A render answers each Pack it was asked about once, in the order - /// asked; this one answered other Packs. - RenderedPacks { - /// The Packs the render was asked about, in order. - asked: Vec, - /// The Packs it answered, in order. - answered: Vec, - }, /// A render returns tools only for the sets the section offered a Pack /// into; this Pack's render returned some for another set. UnofferedSet(PackId), @@ -30,13 +25,35 @@ pub(crate) enum SetRule { UniqueWireName(String), } +/// What a broken merge rule is evidenced by, which is what its message +/// renders: the Packs a render answered, or the tools that broke one of +/// the rules that name them. +/// +/// The two are separate variants because the rules' cardinalities differ, +/// so a rule that names tools can never render with no tool to name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SetEvidence { + /// The Packs the render was asked about, in order, and the Packs it + /// answered, in order. + RenderedPacks { + asked: Vec, + answered: Vec, + }, + /// The rule that names tools, and the tools that broke it. + Tools { + /// The rule the tools broke. + rule: SetRule, + /// The tools that broke it: both tools of a clash, or the one + /// rendered tool a render rule refuses. + tools: NonEmpty, + }, +} + /// One tool that broke a merge rule: its id, whose first segment names its /// Pack, and the set it sits in. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SetTool { - /// The tool's id. pub(crate) id: ToolId, - /// The set the tool sits in. pub(crate) set: OfferMode, } @@ -46,9 +63,21 @@ fn pack_list(packs: &[PackId]) -> String { format!("[{}]", names.join(", ")) } -/// The message for `tools` breaking `rule`, written for a model: each tool -/// with its Pack and its set, and what the rule requires. -pub(super) fn sets_message(rule: &SetRule, tools: &[SetTool]) -> String { +/// The message for `evidence`, written for a model: each tool with its +/// Pack and its set, or the Packs a render answered, and what the rule +/// requires. +pub(super) fn sets_message(evidence: &SetEvidence) -> String { + let (rule, tools) = match evidence { + SetEvidence::RenderedPacks { asked, answered } => { + return format!( + "the render answered the Packs {} where the section offered the Packs {}; a \ + render answers each offered Pack once, in offer order", + pack_list(answered), + pack_list(asked) + ); + } + SetEvidence::Tools { rule, tools } => (rule, tools), + }; let tools = tools .iter() .map(|tool| { @@ -62,12 +91,6 @@ pub(super) fn sets_message(rule: &SetRule, tools: &[SetTool]) -> String { .collect::>() .join(" and "); match rule { - SetRule::RenderedPacks { asked, answered } => format!( - "the render answered the Packs {} where the section offered the Packs {}; a render \ - answers each offered Pack once, in offer order", - pack_list(answered), - pack_list(asked) - ), SetRule::UnofferedSet(pack) => format!( "the {pack} Pack's render returned {tools}, a set the section did not offer the \ {pack} Pack into; a render returns tools only for the sets the section offered its \ diff --git a/crates/runvil-internal/engine/src/error/tests.rs b/crates/runvil-internal/engine/src/error/tests.rs index 507b2506e..f6a8ee552 100644 --- a/crates/runvil-internal/engine/src/error/tests.rs +++ b/crates/runvil-internal/engine/src/error/tests.rs @@ -99,7 +99,8 @@ fn typed_error_survives_the_lua_external_boundary() { let original = Error::OutOfScopeToolCall { name: "echo".to_owned(), global_exists: false, - in_scope: vec!["other".to_owned()], + has_dynamic_set: false, + static_aliases: vec!["other".to_owned()], }; let display = original.to_string(); let external = mlua::Error::external(original); @@ -286,11 +287,13 @@ fn the_call_depth_cap_is_a_quota_that_lua_still_reads_as_lua() { #[test] fn an_owned_lua_internal_message_reaches_the_engine_error_whole() { - let message = format!( - "tool call counts: name {:?} was not pre-seeded", - "web/fetch" - ); - let error = Error::from(LuaError::Internal(std::borrow::Cow::Owned(message.clone()))); + let message = format!("tool call counts: id {:?} could not be read", "web/fetch"); + let expected_line = line!() + 1; + let site = std::panic::Location::caller(); + let error = Error::from(LuaError::Internal { + message: std::borrow::Cow::Owned(message.clone()), + location: site, + }); match &error { Error::Internal { message: carried, .. @@ -301,10 +304,17 @@ fn an_owned_lua_internal_message_reaches_the_engine_error_whole() { error.to_string(), format!("internal invariant violated: {message}") ); + let run_error = crate::RunError::from(error); + assert_eq!(run_error.kind(), crate::RunErrorKind::Internal); + let location = run_error + .location() + .expect("an internal fault reports a location"); assert_eq!( - crate::RunError::from(error).kind(), - crate::RunErrorKind::Internal + location.path, + file!(), + "the path is the construction site's file" ); + assert_eq!(location.line, Some(expected_line)); } #[test] @@ -323,8 +333,10 @@ fn a_poisoned_model_set_maps_to_an_internal_error_and_never_a_completion_error() }) .join(); - let failure = set.bindings().expect_err("a poisoned set cannot be read"); - let error = Error::from(failure); + let failure = set + .binding("writer") + .expect_err("a poisoned set cannot be read"); + let error = Error::from(runvil_lua::Error::from(failure)); assert!( matches!(&error, Error::Internal { message, .. } if message == "model set mutex was poisoned"), "the lock failure is an internal error: {error:?}" @@ -332,11 +344,11 @@ fn a_poisoned_model_set_maps_to_an_internal_error_and_never_a_completion_error() assert!(!crate::RunError::from(error).is_retryable()); } -#[test] -fn every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag() { +/// One failure of each store variant, with fields a table can carry. +fn one_failure_per_store_variant() -> Vec { use runvil_vfs::{PathReason, VfsError}; let path = || "notes.md".to_owned(); - let failures = [ + vec![ VfsError::NotFound { path: path() }, VfsError::AlreadyExists { path: path() }, VfsError::NotADirectory { path: path() }, @@ -396,8 +408,13 @@ fn every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag() { VfsError::Backend { message: "gone".to_owned(), }, - ]; - for failure in failures { + ] +} + +#[test] +fn every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag() { + use runvil_vfs::VfsError; + for failure in one_failure_per_store_variant() { let raised = runvil_lua::Raised { kind: runvil_lua::ErrorKind::Store, message: failure.to_string(), @@ -411,12 +428,21 @@ fn every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag() { assert_eq!(source.tag(), failure.tag(), "{failure:?}"); match (&source, &failure) { ( - VfsError::NotUtf8 { valid_up_to, .. }, - VfsError::NotUtf8 { - valid_up_to: raised_offset, - .. - }, - ) => assert_eq!(valid_up_to, raised_offset, "the offset survives"), + _, + VfsError::NotFound { .. } + | VfsError::AlreadyExists { .. } + | VfsError::NotADirectory { .. } + | VfsError::IsADirectory { .. } + | VfsError::DirectoryNotEmpty { .. } + | VfsError::ScopeEnded { .. } + | VfsError::ReadOnly { .. } + | VfsError::RootProtected { .. } + | VfsError::NotUtf8 { .. } + | VfsError::InvalidPath { .. } + | VfsError::InvalidRange { .. } + | VfsError::Anchor { .. } + | VfsError::NoStore, + ) => assert_eq!(source, failure, "{failure:?}"), ( VfsError::Io { kind, raw_os_error, .. diff --git a/crates/runvil-internal/engine/src/execute.rs b/crates/runvil-internal/engine/src/execute.rs index 0e0623c98..5e23ee997 100644 --- a/crates/runvil-internal/engine/src/execute.rs +++ b/crates/runvil-internal/engine/src/execute.rs @@ -9,7 +9,7 @@ //! The run's outcome type ([`RunResult`]) is defined here; the rest lives in //! focused private children: //! -//! - `bindings` - the run's journaled [`ModelBindings`]. +//! - `bindings` - the run's model bindings, [`ModelBindings`]. //! - `config` - the public [`RunContext`], whose `prepare` binds model //! roles, and [`RunLimits`]; installing Packs and taking each run's //! catalog from them is the Harness's job. @@ -95,28 +95,30 @@ pub fn perform_vfs_op( /// The outcome of a run, which the caller reads out of [`Step::Done`]. /// /// Every outcome is a value of this enum. That includes a prompt that -/// declines its task. The variant tells code what happened, and the -/// payload explains it to people and models. +/// declines its task: a decline is not an error and not a cancellation, so +/// the run still completes, with the prompt's reason for declining as the +/// [`Completed`](RunResult::Completed) text. The variant tells code what +/// happened, and the payload explains it to people and models. /// -/// # Outcomes -/// - [`RunResult::Completed`] - the run completed with its final text. -/// - [`RunResult::Cancelled`] - the caller cancelled the run, or dropped an -/// effect the run was waiting on and no `pcall` caught the cancelled -/// error it resumed with. -/// - [`RunResult::Failure`] - the run failed. +/// This enum is the one outcome type that is `#[non_exhaustive]`, so a +/// `match` on it needs a wildcard arm, while [`Step`], [`Effect`], and +/// [`EffectAnswer`] are deliberately closed. Those three are the caller's +/// protocol: its loop names every kind, so a new kind there must break its +/// build rather than reach code that cannot handle it. A run's outcome is +/// the exception because the caller consumes it as a result, and a wildcard +/// arm that treats an unfamiliar outcome as a failure is a sound default +/// where an unfamiliar effect kind is not. /// /// A failure's [`RunError`] has a [`kind`](RunError::kind) that classifies /// the failure by condition; [`RunErrorKind`] documents each kind. -/// -/// The enum is `#[non_exhaustive]`, so a `match` on it needs a wildcard -/// arm. #[derive(Debug)] #[non_exhaustive] pub enum RunResult { /// The run completed with its final text. Completed(String), - /// The caller cancelled the run, or dropped an effect the run was - /// waiting on and the cancelled error went uncaught. + /// The caller cancelled the run or dropped an effect the run was waiting + /// on and the cancelled error went uncaught, or a block raised a caught + /// `cancelled` error value again after another suspending call. Cancelled, /// The run failed. The error's kind classifies the failure. Failure(RunError), diff --git a/crates/runvil-internal/engine/src/execute/bindings.rs b/crates/runvil-internal/engine/src/execute/bindings.rs index f394ac6fb..26a8b9098 100644 --- a/crates/runvil-internal/engine/src/execute/bindings.rs +++ b/crates/runvil-internal/engine/src/execute/bindings.rs @@ -1,11 +1,11 @@ -//! The run's journaled model bindings: [`ModelBindings`]. +//! The run's model bindings: [`ModelBindings`]. use std::collections::BTreeMap; use crate::model::{ModelId, ModelInfo}; /// The models bound for a run: which model each declared role uses, and -/// the model info of every model the run may use. +/// the model info of every model a role is bound to. /// /// [`prepare`](super::RunContext::prepare) fills these bindings. It binds /// every declared role to the current model the caller passes it. @@ -13,17 +13,16 @@ use crate::model::{ModelId, ModelInfo}; /// A lookup goes from a role label to a model identity, and from that /// identity to the model's info. #[derive(Debug, Clone, Default, PartialEq, Eq)] -#[non_exhaustive] pub struct ModelBindings { - /// The decision, journaled: role label to the bound model's identity. + /// The decision: role label to the bound model's identity. roles: BTreeMap, - /// What this run may use: identity to model info. + /// The info of every model a role is bound to: identity to model info. models: BTreeMap, } impl ModelBindings { /// Binds the role `label` to `model`, recording the model info under - /// its identity. The fill function's only writer. + /// its identity. pub(super) fn bind(&mut self, label: &str, model: ModelInfo) { self.roles.insert(label.to_owned(), model.id().clone()); self.models.entry(model.id().clone()).or_insert(model); @@ -43,8 +42,12 @@ impl ModelBindings { self.roles.get(label).and_then(|id| self.models.get(id)) } - /// Returns the info of the model with the identity `id`, or - /// `None` when this run may not use that model. + /// Returns the info of the model with the identity `id`, or `None` + /// when no role is bound to that model. + /// + /// `None` does not mean the run may not use the model: a catalog model + /// that a UI snapshot reaches by raw id is not in this map either, + /// because only a role binding puts one here. #[must_use] pub fn model(&self, id: &ModelId) -> Option<&ModelInfo> { self.models.get(id) diff --git a/crates/runvil-internal/engine/src/execute/config-limits.rs b/crates/runvil-internal/engine/src/execute/config-limits.rs index 975b774a0..6479b668b 100644 --- a/crates/runvil-internal/engine/src/execute/config-limits.rs +++ b/crates/runvil-internal/engine/src/execute/config-limits.rs @@ -4,14 +4,9 @@ use std::num::{NonZero, NonZeroU32, NonZeroUsize}; /// Resource limits for one run. /// -/// The limits cover tool iterations per `models.loop` or model handle -/// `loop` call, task concurrency, Lua memory, and Lua log volume. -/// /// The defaults are built-in constants and are safe to use as they are. A /// caller changes one by setting its field: /// `let mut limits = RunLimits::default(); limits.max_concurrency = n;`. -/// A prompt's frontmatter `max_tool_iterations` field, when present, -/// overrides [`RunLimits::max_tool_iterations`] for that prompt. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub struct RunLimits { @@ -25,21 +20,25 @@ pub struct RunLimits { /// The run's concurrency ceiling: the most tasks the scheduler admits /// at once across the whole run. /// - /// Each spawned task counts against its owner's limit and against every - /// ancestor's limit, so limits nest. A fanout started inside one arm of - /// another fanout runs within that arm's remaining share. + /// A chain's `tasks.concurrency` call sets its own limit, clamped to its + /// enclosing chain's limit, and this ceiling is the root's. Each spawned + /// task counts against its owner's limit and every ancestor's, so limits + /// nest: a fanout started inside one arm of another fanout runs within + /// that arm's remaining share. pub max_concurrency: NonZeroUsize, /// The memory ceiling for each Lua virtual machine, in bytes. pub lua_memory_bytes: NonZeroUsize, /// The maximum number of `log` events that a prompt's Lua code can /// record in each Lua virtual machine. + /// + /// The log byte ceiling is derived from this count, so raising it raises + /// both budgets. pub lua_log_events: NonZeroU32, } impl Default for RunLimits { - /// Returns limits with the default values: 24 tool iterations per - /// `models.loop` or model handle `loop` call, 8 concurrent tasks, - /// 64 MiB of Lua memory, and 1024 Lua log events. + /// Returns limits with the default values: 24 tool iterations, 8 + /// concurrent tasks, 64 MiB of Lua memory, and 1024 Lua log events. fn default() -> RunLimits { RunLimits { max_tool_iterations: const { NonZero::new(24).unwrap() }, diff --git a/crates/runvil-internal/engine/src/execute/config.rs b/crates/runvil-internal/engine/src/execute/config.rs index c5d280996..6bb2d2e2e 100644 --- a/crates/runvil-internal/engine/src/execute/config.rs +++ b/crates/runvil-internal/engine/src/execute/config.rs @@ -6,6 +6,7 @@ use std::fmt; #[path = "config-limits.rs"] mod limits; +use runvil_types::cancel::CancelHandle; use runvil_types::emitter::DebugMode; use runvil_types::packs::{PackId, Prelude}; use runvil_types::replay::Flags; @@ -16,7 +17,7 @@ use runvil_vfs::VfsRef; pub use limits::RunLimits; -use crate::cancel::CancelHandle; +use crate::lua::RosterEntry; use crate::model::ModelInfo; use crate::parser::Prompt; @@ -42,9 +43,9 @@ use super::requirements::{Requirements, UnavailablePack, UsablePack}; /// the caller from `step`. /// /// The caller supplies the run's clock and randomness through `new`. -#[non_exhaustive] pub struct RunContext { - /// Run identity, stamped on every report and event. + /// The caller's run label, moved into `RunState::execution` at start and + /// read nowhere else. pub(super) name: String, /// The run's seed, as the caller supplied it: the source of the run id, /// the untrusted-envelope nonce, and every other in-run random choice. @@ -60,21 +61,19 @@ pub struct RunContext { pub(super) report_debug: DebugMode, /// The run's cancel flag, minted by `new` and replaced by `with_cancel`. pub(super) cancel: CancelHandle, - pub(crate) limits: RunLimits, + /// The run's resource limits; set by `with_limits`. + pub(super) limits: RunLimits, /// The application-state snapshot the `ui()` global serves; set by `with_ui`. pub(super) ui: Option, /// The run's whole filesystem; set by `with_vfs`. pub(super) vfs: VfsRef, - /// The run's model satisfaction, written by - /// [`prepare`](RunContext::prepare)'s fill function: which concrete - /// model each declared role is bound to. + /// The run's model bindings; filled by [`prepare`](RunContext::prepare). pub(super) model_bindings: ModelBindings, /// The run's tool catalog; set by `with_tools`. pub(super) tools: ToolCatalog, - /// Each roster Pack by name, with the reason when it can't serve; set by `with_packs`. - pub(super) roster: BTreeMap>, - /// The vendor of each roster Pack that can serve, by name; set by `with_packs`. - pub(super) vendors: BTreeMap, + /// Each roster Pack by name, with its vendor when it can serve and the + /// reason when it can't; set by `with_packs`. + pub(super) packs: BTreeMap, /// The run's Pack preludes, in declaration order; set by `with_preludes`. pub(super) preludes: Vec, } @@ -108,8 +107,7 @@ impl RunContext { vfs: VfsRef::default(), model_bindings: ModelBindings::default(), tools: ToolCatalog::default(), - roster: BTreeMap::new(), - vendors: BTreeMap::new(), + packs: BTreeMap::new(), preludes: Vec::new(), } } @@ -192,12 +190,10 @@ impl RunContext { /// that every section's `store` table operates on. The default is a /// fresh memory store at `/`. /// - /// [`prepare`](RunContext::prepare) keeps a handle - /// set here as given. Every tool call effect carries an access to this - /// same filesystem, so the tools and the run share one set of files. - /// The caller seeds files before the run and extracts output after it - /// through the prepared handle, returned by - /// [`vfs`](RunContext::vfs). + /// Every tool call effect carries an access to this same filesystem, so + /// the tools and the run share one set of files. The caller seeds files + /// before the run and extracts output after it through the prepared + /// handle, returned by [`vfs`](RunContext::vfs). #[must_use] pub fn with_vfs(mut self, vfs: VfsRef) -> RunContext { self.vfs = vfs; @@ -236,21 +232,25 @@ impl RunContext { usable: Vec, unavailable: Vec, ) -> RunContext { - let roster: BTreeMap> = usable - .iter() - .map(|entry| (entry.pack.clone(), None)) - .chain( - unavailable - .into_iter() - .map(|entry| (entry.pack, Some(entry.reason))), - ) - .collect(); - self.vendors = usable + self.packs = usable .into_iter() - .filter(|entry| matches!(roster.get(&entry.pack), Some(None))) - .map(|entry| (entry.pack, entry.vendor)) + .map(|entry| { + ( + entry.pack, + RosterEntry::Usable { + vendor: entry.vendor, + }, + ) + }) + .chain(unavailable.into_iter().map(|entry| { + ( + entry.pack, + RosterEntry::Unavailable { + reason: entry.reason, + }, + ) + })) .collect(); - self.roster = roster; self } @@ -280,11 +280,9 @@ impl RunContext { /// which the caller chooses, and reports what the caller must still /// satisfy, returning the context beside the report. /// - /// `prepare` uses the context's filesystem as given, because the caller - /// builds the run's whole filesystem, including its real directories and - /// the declared store. The caller checks before `prepare` that each - /// Pack the prompt declares can serve, and merges that report into the - /// one `prepare` returns. + /// `prepare` leaves the filesystem as given. The caller checks before + /// `prepare` that each Pack the prompt declares can serve, and merges + /// that report into the one `prepare` returns. /// /// Every declared model role binds to `model`. `prepare` then checks /// each role's hard keywords (`thinking`, `no-thinking`) and context @@ -311,29 +309,20 @@ impl RunContext { .tools() .iter() .map(|tool| tool.id.pack()) - .chain(self.roster.keys().cloned()) + .chain(self.packs.keys().cloned()) .filter(PackId::is_reserved) .collect(); (self, requirements) } - /// Returns the run's VFS handle, which is the run's whole filesystem. - /// - /// The filesystem includes the real directories and the declared - /// store. The caller seeds files through this handle before the run and - /// extracts output through it after the run. Set the handle with - /// [`with_vfs`](RunContext::with_vfs). + /// Returns the run's VFS handle, set with [`with_vfs`](RunContext::with_vfs). #[must_use] pub fn vfs(&self) -> &VfsRef { &self.vfs } - /// Returns the run's model bindings, which map each declared role to a - /// concrete model. - /// - /// The bindings also hold the `ModelInfo` values of the models this run may - /// use. [`prepare`](RunContext::prepare)'s fill - /// function writes them. + /// Returns the run's [`ModelBindings`], which + /// [`prepare`](RunContext::prepare) fills. #[must_use] pub fn model_bindings(&self) -> &ModelBindings { &self.model_bindings @@ -361,9 +350,9 @@ impl RunContext { /// Returns the run's id, derived from its seed. /// - /// The id is one-way, so it can be shown in logs, records, and views - /// without revealing the seed or the nonce. A replay under the recorded - /// seed has the same id. + /// The id is one-way, so it does not reveal the seed or the nonce. It + /// appears in a debug rendering only: events and reports carry the + /// run's `name`. A replay under the recorded seed has the same id. #[must_use] pub(crate) fn uid(&self) -> RunUid { RunUid::from_seed(&self.seed) @@ -399,8 +388,7 @@ impl fmt::Debug for RunContext { .field("vfs", &self.vfs) .field("model_bindings", &self.model_bindings) .field("tools", &self.tools) - .field("roster", &self.roster) - .field("vendors", &self.vendors) + .field("packs", &self.packs) .field( "preludes", &self.preludes.iter().map(Prelude::pack).collect::>(), diff --git a/crates/runvil-internal/engine/src/execute/context-bound.rs b/crates/runvil-internal/engine/src/execute/context-bound.rs index 1744e50e5..6d1e31c22 100644 --- a/crates/runvil-internal/engine/src/execute/context-bound.rs +++ b/crates/runvil-internal/engine/src/execute/context-bound.rs @@ -1,28 +1,29 @@ -//! The run-scoped sets built from the prepared bindings, and the `argv` -//! derivation: the pieces `RunState::new` assembles once per run. +//! The run-scoped tool and model sets and the `argv` derivation: the pieces +//! `RunState::new` assembles once per run. +//! +//! The sets are built once. [`derive_argv`] is not: `RunState::new` calls it +//! for the run's own args, and every args fork calls it again, so a +//! contained chain parses the input it was given. use std::collections::BTreeMap; -use runvil_parser::ModelKeyword; -use runvil_parser::detail::keyword_name; use runvil_types::packs::PackId; use runvil_types::tools::ToolCatalog; -use crate::lua::ToolSet; +use crate::lua::{RosterEntry, ToolSet}; use crate::model::{ModelBinding, ModelInvocation, ModelSet}; -use crate::parser::Prompt; +use crate::parser::{ModelKeyword, Prompt, detail}; use super::super::bindings::ModelBindings; /// Builds the run's shared tool set: the frontmatter's declared Packs, -/// and the run catalog, the roster, and each Pack's vendor, all moved out -/// of the context. Nothing is bound here: each section entry binds the -/// tools it offers when it freezes its sets. -pub(super) fn bound_tool_set( +/// and the run catalog and roster, all moved out of the context. Nothing +/// is bound here: each section entry binds the tools it offers when it +/// freezes its sets. +pub(super) fn run_tool_set( prompt: &Prompt, tools: ToolCatalog, - roster: BTreeMap>, - vendors: BTreeMap, + packs: BTreeMap, ) -> ToolSet { ToolSet::new( prompt @@ -31,18 +32,18 @@ pub(super) fn bound_tool_set( .iter() .map(|entry| entry.id().clone()) .collect(), - roster, + packs, tools, - vendors, ) } /// Builds the run's shared model set from the prepared `bindings`: every /// filled role becomes a binding under its label, holding the role's /// keyword set (the handle's `capabilities`), the hard-keyword thinking -/// switch as the frozen invocation, the model info's provider id when -/// the catalog names one, and the model info itself, which a section -/// entry's render receives. Unfilled roles produce no binding: +/// switch as the frozen invocation, the role's declared description (else +/// the model info's), the catalog model info's context window, the model +/// info's provider id when the catalog names one, and the model info itself, +/// which a section entry's render receives. Unfilled roles produce no binding: /// `models.use` on the label fails at run time. pub(super) fn bound_model_set(prompt: &Prompt, bindings: &ModelBindings) -> ModelSet { let mut set = ModelSet::default(); @@ -58,7 +59,7 @@ pub(super) fn bound_model_set(prompt: &Prompt, bindings: &ModelBindings) -> Mode _ => {} } } - let mut binding = ModelBinding::new( + let binding = ModelBinding::new( label, role.description().unwrap_or_else(|| info.description()), info.id().clone(), @@ -68,14 +69,11 @@ pub(super) fn bound_model_set(prompt: &Prompt, bindings: &ModelBindings) -> Mode .with_capabilities( role.keywords() .iter() - .map(|keyword| keyword_name(*keyword)) + .map(|keyword| detail::keyword_name(*keyword)) .map(str::to_owned) .collect(), ) .with_catalog_info(info.clone()); - if let Some(provider) = info.provider() { - binding = binding.with_provider(provider); - } set.push(binding); } set diff --git a/crates/runvil-internal/engine/src/execute/context-tests.rs b/crates/runvil-internal/engine/src/execute/context-tests.rs index ec3e4962c..c735b9be6 100644 --- a/crates/runvil-internal/engine/src/execute/context-tests.rs +++ b/crates/runvil-internal/engine/src/execute/context-tests.rs @@ -4,9 +4,14 @@ use runvil_types::packs::PackId; use runvil_types::tools::ToolId; +use serde_json::json; + use super::*; +use crate::execute::section_vm::VmSeed; use crate::execute::{UnavailablePack, UsablePack}; +use crate::lua::{Argv, RosterEntry}; use crate::model::ThinkingMode; +use crate::test_support::fresh_access; use crate::test_support::scenario::{TEST_SEED, test_model_with}; fn test_prompt() -> Prompt { @@ -68,6 +73,32 @@ fn derived_values_come_from_the_prompt_and_limits() { assert_eq!(ctx.max_tool_iterations(), 24); } +#[test] +fn vm_setup_hands_the_vm_the_runs_argv_in_the_mode_the_caller_asks_for() { + let prompt = test_prompt(); + let ctx = test_state(&prompt).with_args("{\"query\":\"papers\"}"); + let sys = json!({}); + let access = fresh_access(); + let requested = ctx.argv().cloned(); + let expected = requested.as_deref(); + assert!( + expected.is_some(), + "the fixture's args must derive an argv, or neither mode is observable" + ); + let frozen = ctx.vm_setup(&sys, VmSeed::default(), &access, "Only", false); + assert!( + matches!(frozen.argv, Argv::Frozen(value) if value == expected), + "an unprivileged constructor must get the run's argv frozen: {:?}", + frozen.argv + ); + let writable = ctx.vm_setup(&sys, VmSeed::default(), &access, "Only", true); + assert!( + matches!(writable.argv, Argv::Writable(value) if value == expected), + "the H1 pass's flag must get the run's argv writable: {:?}", + writable.argv + ); +} + #[test] fn forks_swap_only_their_own_fields() { let ctx = test_state(&test_prompt()); @@ -158,7 +189,7 @@ fn a_pack_in_both_roster_lists_records_no_vendor() { ) .with_packs( vec![UsablePack::new(web.clone(), "acme")], - vec![UnavailablePack::new(web, "the server is down")], + vec![UnavailablePack::new(web.clone(), "the server is down")], ), ); let tool = ToolId::parse("web/fetch").expect("a static valid tool id"); @@ -167,4 +198,11 @@ fn a_pack_in_both_roster_lists_records_no_vendor() { None, "a name in both lists counts as unavailable, so it has no vendor" ); + assert_eq!( + state.tool_set().roster_for_test().get(&web), + Some(&RosterEntry::Unavailable { + reason: "the server is down".to_owned(), + }), + "one roster entry per Pack, and the unavailable one won, carrying the given reason" + ); } diff --git a/crates/runvil-internal/engine/src/execute/context.rs b/crates/runvil-internal/engine/src/execute/context.rs index 4d0d97fed..eb5c8105e 100644 --- a/crates/runvil-internal/engine/src/execute/context.rs +++ b/crates/runvil-internal/engine/src/execute/context.rs @@ -10,6 +10,7 @@ use std::fmt; use std::sync::atomic::AtomicU32; use std::sync::{Arc, Mutex}; +use runvil_types::cancel::CancelHandle; use runvil_types::emitter::{Emitter, EventSink}; use runvil_types::event::Event; use runvil_types::ids::{ChainId, TaskId}; @@ -17,8 +18,7 @@ use runvil_types::packs::Prelude; use runvil_types::untrusted::GuardNonce; use runvil_vfs::{Access, VfsRef}; -use crate::cancel::CancelHandle; -use crate::lua::ToolSet; +use crate::lua::{Argv, ToolSet}; use crate::model::{ModelSet, ModelView}; use crate::parser::Prompt; @@ -50,8 +50,8 @@ pub(super) struct ReportingHandles { /// The three sanctioned forks: [`with_walk_state`](Self::with_walk_state) /// at the H1-to-walk handoff, [`with_task`](Self::with_task) giving a /// spawned chain its own task emitter and turn counter, and -/// [`with_args`](Self::with_args) passing a `call` call's args override -/// into its contained chain. +/// [`with_args`](Self::with_args) passing a `call` or task-spawning arm's +/// input override into its contained chain. #[derive(Clone)] pub(crate) struct RunState { /// The prompt this run executes. @@ -67,10 +67,10 @@ pub(crate) struct RunState { execution: Arc, /// The run's argument string for `{{ args }}` substitution. args: Arc, - /// The run's `argv`: the parsed form of `args` (`None` installs nil). - /// At construction this is the derived value the H1 pass starts from; - /// the walk's fork holds the value H1 left behind at the freeze, so - /// an H1 repair reaches every downstream section. + /// The run's `argv`, as `bound::derive_argv` derives it from `args`. At + /// construction this is the value the H1 pass starts from; the walk's + /// fork holds the value H1 left behind at the freeze, so an H1 repair + /// reaches every downstream section. argv: Option>, /// The run's resource limits. limits: RunLimits, @@ -101,9 +101,7 @@ pub(crate) struct RunState { /// The run's `started_at` rendered as RFC 3339, stamped into every /// section's `sys.when`, the H1 pass included. when: Arc, - /// The run's application-state snapshot; its presence gives every - /// section VM the `ui()` global and the raw-model-id `models.get` - /// fallback. + /// The run's application-state snapshot, when the caller supplied one. ui: Option>, /// The run's Pack preludes, in install order: every section VM /// installs each one before `require`. @@ -120,7 +118,7 @@ impl RunState { /// Builds the context for one run of `prompt` over `config`, which it /// consumes. The turn counter is minted here (starting at zero), as are /// the run's shared tool and model sets - built from the catalog, the - /// roster, the vendors, and the model bindings (only the bindings come + /// roster, and the model bindings (only the bindings come /// from [`RunContext::prepare`](super::RunContext::prepare), and they /// stay empty on a caller-built context that skipped it); the nonce /// derives from `config`'s seed and `when` renders `config`'s @@ -139,17 +137,16 @@ impl RunState { vfs, model_bindings, tools, - roster, - vendors, + packs, preludes, .. } = config; - let tool_set = Arc::new(bound::bound_tool_set(&prompt, tools, roster, vendors)); + let tool_set = Arc::new(bound::run_tool_set(&prompt, tools, packs)); let model_set = Arc::new(Mutex::new(bound::bound_model_set(&prompt, &model_bindings))); let execution: Arc = Arc::from(name); - // The root task's counter starts at the context's `provenance_start`: - // past any parse events the caller put in the stream ahead of the - // run, or at zero. + // The root task's event sequence starts at the context's + // `provenance_start`: past any parse events the caller put in the + // stream ahead of the run, or at zero. let events = EventSink::seeded(provenance_start); // The root chain - the main walk - is task `0`. let emitter = Arc::new(Emitter::new( @@ -234,15 +231,14 @@ impl RunState { &self.args } - /// The run's `argv`: the parsed form of the args string, or `None` - /// (nil) when it did not parse. On the walk this is the value H1 left - /// behind at the freeze. + /// The run's `argv`, as `bound::derive_argv` derives it; on the walk, + /// the value H1 left behind at the freeze. pub(super) fn argv(&self) -> Option<&Arc> { self.argv.as_ref() } /// The run's resource limits. - pub(crate) fn limits(&self) -> RunLimits { + pub(super) fn limits(&self) -> RunLimits { self.limits } @@ -291,13 +287,13 @@ impl RunState { /// call: the frontmatter's `max_tool_iterations` over the limits /// default. fn max_tool_iterations(&self) -> usize { - runvil_parser::detail::max_tool_iterations(self.prompt.frontmatter()) + crate::parser::detail::max_tool_iterations(self.prompt.frontmatter()) .resolve(self.limits.max_tool_iterations.get() as usize) } /// The run's top-level section count, reported as `sys.section_count`. fn section_count(&self) -> usize { - runvil_parser::detail::sections(&self.prompt).len() + crate::parser::detail::sections(&self.prompt).len() } /// The H1-to-walk handoff: the `argv` H1 left behind at the freeze, @@ -315,9 +311,10 @@ impl RunState { } /// The context a contained chain runs under: `args` in place of the - /// run's own, because a `call` call's explicit input overrides the - /// run's args for the chain - and `argv` re-derives from the chain's - /// args, so the chain sees the parsed form of what it was passed. + /// run's own, because a `call`, a `tasks.spawn`, or a `tasks.fanout` + /// arm that was given an input overrides the run's args for the chain - + /// and `argv` re-derives from the chain's args, so the chain sees the + /// `argv` of what it was passed. #[must_use] pub(super) fn with_args(&self, args: &str) -> Self { let mut ctx = self.clone(); @@ -344,18 +341,28 @@ impl RunState { /// the run-wide inputs from this context; the constructor supplies only /// its own deltas: the `sys` JSON, the seed, the chain step's access /// capability (the walk's own, a call chain's borrowed parent - /// capability, a task chain's spawned one), and the section name. + /// capability, a task chain's spawned one), the section name, and + /// whether `argv` installs writable. + /// + /// `argv_writable` picks the [`Argv`] mode that + /// [`SectionVmSetup::argv`] describes; the H1 pass is the only + /// production caller that passes `true`. pub(super) fn vm_setup<'a>( &'a self, sys: &'a serde_json::Value, seed: VmSeed<'a>, access: &'a Arc, section_name: &'a str, + argv_writable: bool, ) -> SectionVmSetup<'a> { + let argv = if argv_writable { + Argv::Writable(self.argv.as_deref()) + } else { + Argv::Frozen(self.argv.as_deref()) + }; SectionVmSetup { args: &self.args, - argv: self.argv.as_deref(), - argv_writable: false, + argv, sys, access, seed, @@ -374,14 +381,6 @@ impl RunState { /// section entry's hierarchical id, the entering chain's task id, and /// the section name. A task's first entry sets `index` at its own call /// site. - /// - /// `when` is the run's `started_at` rendered as RFC 3339, the same - /// string in every section; `id` is the section entry's hierarchical id - /// (the entering chain's id extended by its local entry counter), - /// rendered as a dot-separated path; `taskid` is the id of the nearest - /// enclosing task (the main walk is task `0`; a `call` child reports - /// its caller's task), the handle a chain passes to `tasks.*` to speak - /// about itself. pub(super) fn section_sys(&self, id: &str, task_id: &TaskId, section_name: &str) -> SectionSys { SectionSys { when: Arc::clone(&self.when), diff --git a/crates/runvil-internal/engine/src/execute/error.rs b/crates/runvil-internal/engine/src/execute/error.rs index fa2338c8d..00b61e45e 100644 --- a/crates/runvil-internal/engine/src/execute/error.rs +++ b/crates/runvil-internal/engine/src/execute/error.rs @@ -1,4 +1,5 @@ -//! The public run-error surface: [`RunError`] and its stable [`RunErrorKind`]. +//! The public run-error surface: [`RunError`], its stable [`RunErrorKind`], +//! and the [`SourceLocation`] that says where a failure happened. use std::fmt; use std::num::NonZeroU32; @@ -14,7 +15,8 @@ use crate::Error; #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RunErrorKind { - /// Parsing the prompt failed, or a compiled Lua region was invalid. + /// Parsing the prompt failed: invalid frontmatter YAML, a failure the + /// parser classified as structural, or a Lua block that did not compile. Parse, /// The prompt declared a `runvil:` major version outside the set /// this build supports. @@ -35,23 +37,31 @@ pub enum RunErrorKind { /// The run stops at once to keep the interleaving of their store access /// deterministic. Lua code cannot catch this failure. Determinism, - /// A section's Lua phase failed to run or return a usable value. + /// A section's Lua phase failed to run or return a usable value, or the + /// author's program broke where the Engine cannot fix it: a Pack prelude + /// that did not load, a chain that ended with tasks still live, a + /// task reached for by a chain that does not own it, a task result + /// waited on twice, a wait delivering a cancelled task that surfaced + /// uncaught, and a failed `{{ }}` substitution. Lua, - /// A Lua log quota (log events or log bytes) ran out, or a `call`, - /// `tasks.spawn`, or `tasks.fanout` arm would have nested past the - /// call-depth cap. + /// A Lua quota ran out: the `log()` event budget or the `log()` byte + /// budget. Also the `call`, `tasks.spawn`, or `tasks.fanout` arm that + /// would have nested past the call-depth cap. Quota, /// `compactors.fail` raised context exhaustion: a request overflowed the /// model's context window and the selected compactor left it over the /// limit, or a section's Lua raised it with its own reason. ContextExhausted, - /// An internal invariant failed. + /// An internal invariant failed, or the caller broke the `step` and + /// `resume` contract: a `step` after `Done`, an answer of the wrong + /// kind, an answer for an id the run never issued, or a second answer + /// for one effect. Internal, - /// The environment falls short of a requirement the prompt states: a - /// missing or unavailable required Pack, a service the caller left out, - /// a model requirement a bound model fails to meet (a context minimum - /// or a hard keyword), or an H1 block that failed the prompt's hard - /// gate. + /// The environment falls short of what the prompt needs: a missing or + /// unavailable required Pack, a service the caller left out, a model + /// requirement a bound model fails to meet (a context minimum or a hard + /// keyword), a reserved Pack id that the run's tool catalog or Pack + /// roster names, or an H1 block that failed the prompt's hard gate. RequirementsUnmet, } @@ -89,40 +99,6 @@ pub struct SourceLocation { pub span: Option>, } -impl SourceLocation { - /// Builds a location in `path` with no line, column, or span. - #[must_use] - pub(crate) fn new(path: impl Into) -> SourceLocation { - SourceLocation { - path: path.into(), - line: None, - column: None, - span: None, - } - } - - /// Sets the 1-based line. - #[must_use] - pub(crate) fn with_line(mut self, line: Option) -> SourceLocation { - self.line = line; - self - } - - /// Sets the 1-based column. - #[must_use] - pub(crate) fn with_column(mut self, column: Option) -> SourceLocation { - self.column = column; - self - } - - /// Sets the byte span of the offending region in the document body. - #[must_use] - pub(crate) fn with_span(mut self, span: Option>) -> SourceLocation { - self.span = span; - self - } -} - /// The error a failed prompt run reports. /// /// A run that fails ends with [`Step::Done`](super::Step::Done), whose @@ -134,7 +110,6 @@ impl SourceLocation { /// itself. A failure whose message does not hold its underlying error, such /// as a model round's, keeps it behind [`std::error::Error::source`] instead. #[derive(Debug)] -#[non_exhaustive] pub struct RunError { inner: Error, } @@ -184,14 +159,19 @@ impl RunError { &self.inner } - /// Returns the originating error this failure keeps, when it has one. + /// Returns the error this failure originated from, when it keeps one: a + /// Lua runtime failure keeps the `mlua` error, a Lua compile failure the + /// compiler diagnostic, a schema binding failure the validation error, a + /// tool failure the tool's own error, a store failure the store's + /// structured error, an unmet requirement the failure it stands in for, + /// an internal fault the failure that broke the invariant, and a parse + /// failure the parser's cause. A model round's failure and a failed + /// `{{ }}` substitution answer `None`: whatever cause each holds belongs + /// to the error it wraps. /// - /// A Lua failure keeps the Lua error, a tool failure the tool's own - /// error, a store failure the store's structured error, and a - /// frontmatter failure the YAML decode failure. The message already - /// holds the cause's text, so [`std::error::Error::source`] does not - /// return it again, and a printer that walks the source chain shows the - /// text once. + /// The message already holds the cause's text, so + /// [`std::error::Error::source`] does not return it again, and a printer + /// that walks the source chain shows the text once. #[must_use] pub fn originating_error(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { self.inner.originating_error() @@ -242,15 +222,18 @@ impl RunError { #[must_use] pub fn location(&self) -> Option { match &self.inner { - Error::Parse(error) => Some( - SourceLocation::new(error.name().unwrap_or("")) - .with_line(error.line().map(NonZeroU32::get)) - .with_column(error.column().map(NonZeroU32::get)) - .with_span(error.span()), - ), - Error::Internal { file, line, .. } => { - Some(SourceLocation::new(*file).with_line(Some(*line))) - } + Error::Parse(error) => Some(SourceLocation { + path: error.name().unwrap_or("").to_owned(), + line: error.line().map(NonZeroU32::get), + column: error.column().map(NonZeroU32::get), + span: error.span(), + }), + Error::Internal { file, line, .. } => Some(SourceLocation { + path: (*file).to_owned(), + line: Some(*line), + column: None, + span: None, + }), Error::Completion(_) | Error::Interrupted | Error::Lua { .. } diff --git a/crates/runvil-internal/engine/src/execute/fill.rs b/crates/runvil-internal/engine/src/execute/fill.rs index 455f6f55b..48199fa84 100644 --- a/crates/runvil-internal/engine/src/execute/fill.rs +++ b/crates/runvil-internal/engine/src/execute/fill.rs @@ -1,10 +1,7 @@ //! Prepare's fill function: the trivial model fill. -use runvil_parser::ModelKeyword; -use runvil_parser::detail::keyword_name; - use crate::model::ThinkingMode; -use crate::parser::Prompt; +use crate::parser::{ModelKeyword, Prompt, detail}; use super::bindings::ModelBindings; use super::requirements::{RequirementCheck, Requirements, UnmetRequirement}; @@ -48,7 +45,7 @@ pub(super) fn fill_model_bindings( requirements.unmet_models.push(UnmetRequirement { role: label.to_owned(), check: RequirementCheck::HardKeyword, - required: keyword_name(*keyword).to_owned(), + required: detail::keyword_name(*keyword).to_owned(), actual: actual.to_owned(), }); } diff --git a/crates/runvil-internal/engine/src/execute/infer-tests.rs b/crates/runvil-internal/engine/src/execute/infer-tests.rs index 77217a444..5844f11fb 100644 --- a/crates/runvil-internal/engine/src/execute/infer-tests.rs +++ b/crates/runvil-internal/engine/src/execute/infer-tests.rs @@ -1,6 +1,9 @@ //! The nested-inference answer: a round that comes back with tool calls, //! though none were advertised, fails as a malformed reply. +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + use runvil_types::ids::RoundId; use serde_json::json; @@ -17,9 +20,11 @@ fn an_infer_round_answered_with_tool_calls_fails_as_a_retryable_malformed_reply( let error = accept_infer( Ok(Box::new(completion)), RoundId::new(0), - &null_emitter(), + &ReportingHandles { + emitter: Arc::new(null_emitter()), + turns: Arc::new(AtomicU32::new(0)), + }, "Infer", - &AtomicU32::new(0), ) .expect_err("no tools were advertised, so tool calls are refused"); let Error::Completion(failure) = &error else { diff --git a/crates/runvil-internal/engine/src/execute/infer.rs b/crates/runvil-internal/engine/src/execute/infer.rs index a450c48c1..18a479a3c 100644 --- a/crates/runvil-internal/engine/src/execute/infer.rs +++ b/crates/runvil-internal/engine/src/execute/infer.rs @@ -1,6 +1,6 @@ //! The nested-inference round a section's `models.infer` yields resolve to. //! -//! One `infer` shape only: a single direct, tool-free gateway round on a +//! One `infer` shape only: a single direct, tool-free model round on a //! fresh conversation. `h:infer(prompt)` runs it with the handle's //! frozen binding; `models.infer(prompt)` resolves the section's //! current model and runs the same path. Neither form sends tools, reads or @@ -12,9 +12,6 @@ //! reports the round and renders its text, and the yielding chain resumes //! with the outcome. -use std::sync::atomic::AtomicU32; - -use runvil_types::emitter::Emitter; use runvil_types::event::ReplyOrigin; use runvil_types::event::lifecycle; use runvil_types::ids::RoundId; @@ -22,6 +19,7 @@ use runvil_types::ids::RoundId; use crate::Error; use crate::model::{Completion, CompletionError, CompletionErrorKind, CompletionResult}; +use super::context::ReportingHandles; use super::turn; /// Reports one completed infer round through [`turn::report_model_turn`], @@ -31,13 +29,12 @@ use super::turn; fn accept_infer_completion( completion: Completion, round: RoundId, - emitter: &Emitter, + reporting: &ReportingHandles, section: &str, - turns: &AtomicU32, ) -> Result { - let turn = turn::advance_turn(turns); + let turn = turn::advance_turn(&reporting.turns); let (outcome, _) = turn::report_model_turn( - emitter, + &reporting.emitter, section, turn, round, @@ -69,14 +66,14 @@ fn malformed_reply(specific: &str) -> Error { )) } -/// Applies one infer round's answer - the completion the performer -/// obtained, or its failure - reporting it exactly like one prose round -/// of `round` through the chain's `emitter` under `section`, and renders -/// its text. +/// Applies one infer round's answer, the completion the caller obtained +/// or its failure: it reports the round exactly like a prose round, +/// through `turn::report_model_turn` on the chain's reporting handles +/// under `section`, and renders its text. /// -/// A failed completion is a failed turn and the call's error. The -/// performer's task is aborted on cancellation before any answer lands, -/// so no `MODEL_TURN_FAILED` fires for an aborted round. +/// A failed completion is a failed turn and the call's error. A round the +/// caller drops never reaches here: the drop resumes the chain with a +/// cancellation, so no `MODEL_TURN_FAILED` fires for it. /// /// # Errors /// Returns the completion's failure, or an [`Error::Completion`] of kind @@ -85,18 +82,19 @@ fn malformed_reply(specific: &str) -> Error { pub(super) fn accept_infer( result: std::result::Result, CompletionError>, round: RoundId, - emitter: &Emitter, + reporting: &ReportingHandles, section: &str, - turns: &AtomicU32, ) -> Result { let completion = match result { Ok(completion) => completion, Err(error) => { - emitter.report(section, lifecycle::MODEL_TURN_FAILED); + reporting + .emitter + .report(section, lifecycle::MODEL_TURN_FAILED); return Err(Error::from(error)); } }; - accept_infer_completion(*completion, round, emitter, section, turns) + accept_infer_completion(*completion, round, reporting, section) } #[cfg(test)] diff --git a/crates/runvil-internal/engine/src/execute/requirements.rs b/crates/runvil-internal/engine/src/execute/requirements.rs index b77fda2c7..a5c36b473 100644 --- a/crates/runvil-internal/engine/src/execute/requirements.rs +++ b/crates/runvil-internal/engine/src/execute/requirements.rs @@ -8,6 +8,9 @@ use runvil_types::packs::PackId; #[path = "requirements-tests.rs"] mod tests; +/// The first line of every refusal notice. +pub(super) const NOTICE_HEADER: &str = "the environment cannot satisfy this prompt:"; + /// A report of what the caller must still satisfy before a prompt can run. /// /// [`RunContext::prepare`](super::RunContext::prepare) returns one @@ -73,6 +76,9 @@ impl Requirements { /// The merge skips an entry the report already holds, and keeps /// `reserved` the union of both reports' ids. /// + /// `unmet_models` is the exception: it is appended whole, so a role the + /// report already lists is listed again rather than skipped. + /// /// The merge drops the `missing_packs` entry of a Pack that lacks a /// service or is unavailable: the service or unavailable entry /// already names the real cause. @@ -108,15 +114,16 @@ impl Requirements { /// Returns the refusal notice, which explains what blocks the run. /// - /// Each line names what is missing or falls short, with required versus - /// actual. The notice is written for a model to read: concise, factual, - /// and self-contained. + /// Each line names what is missing or falls short. The model lines + /// carry required versus actual; the Pack lines name the Pack and, for + /// a service or an unavailable Pack, the reason. The notice is written + /// for a model to read: concise, factual, and self-contained. #[must_use] pub fn notice(&self) -> String { // 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:"); + let mut notice = String::from(NOTICE_HEADER); for id in &self.missing_packs { let _ = write!(notice, "\n- missing required Pack: {id}"); } @@ -177,6 +184,10 @@ fn push_new(entries: &mut Vec, incoming: Vec) { /// one the run holds. The caller reports each Pack's vendor as it picked the /// Pack, and every tool-call and tool-result event for one of the Pack's /// tools records it. +/// +/// The vendor promise holds only for a Pack the roster lists as usable. A +/// Pack named in both roster lists counts as unavailable, so it has no +/// vendor and its tools record none. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct UsablePack { diff --git a/crates/runvil-internal/engine/src/execute/run.rs b/crates/runvil-internal/engine/src/execute/run.rs index 1ac1e3295..c732cecfa 100644 --- a/crates/runvil-internal/engine/src/execute/run.rs +++ b/crates/runvil-internal/engine/src/execute/run.rs @@ -12,6 +12,7 @@ use std::fmt; use std::sync::Arc; +use runvil_types::cancel::CancelHandle; use runvil_types::event::Event; use runvil_types::ids::Provenance; @@ -22,7 +23,6 @@ pub use effect::{ PackToolSets, Round, ToolAnswerRecord, ToolCallOrigin, ToolCaller, }; -use crate::cancel::CancelHandle; use crate::parser::{ParseErrorKind, Prompt}; use crate::{Error, Result}; @@ -40,24 +40,20 @@ use super::scheduler::Scheduler; #[derive(Debug)] pub enum Step { /// The run continues. - /// - /// `effects` lists the effects this step issued for the caller to - /// perform, in issue order. Each comes with the provenance of the task - /// that built it. An empty list means every chain is waiting on an - /// effect that an earlier step issued. `events` lists the events the - /// step reported, in order. `withdrawn` lists the tool calls the step - /// orphaned while the run goes on. It may name a call that `effects` - /// lists, because one step can issue a call and then orphan it. Pending { - /// The effects the caller must perform and answer through - /// [`Run::resume`]. + /// The effects this step issued, in issue order, each with the + /// provenance of the task that built it, for the caller to perform + /// and answer through [`Run::resume`]. An empty list means every + /// chain is waiting on an effect that an earlier step issued. effects: Vec, - /// The events the step reported. + /// The events the step reported, in order. events: Vec, /// The tool-call effects this step orphaned, in effect-id order. /// Their answers will be discarded, so the caller may drop each one /// it still holds; it still owes each one answer. Empty once the /// run is decided, when the caller may drop everything it holds. + /// It may name a call that `effects` lists, because one step can + /// issue a call and then orphan it. withdrawn: Vec, }, /// The run is over. @@ -177,11 +173,9 @@ impl Run { } } - /// Builds the run over an assembled context: the constructor the - /// in-crate drivers and the suites use when they shape the context - /// themselves. + /// Builds the live run over a state that passed `prepare_state`. #[must_use] - pub(crate) fn from_state(state: RunState) -> Run { + fn from_state(state: RunState) -> Run { let cancel = state.cancel().clone(); Run { start: Start::Live(Box::new(Scheduler::new(state))), diff --git a/crates/runvil-internal/engine/src/execute/run/effect-tests.rs b/crates/runvil-internal/engine/src/execute/run/effect-tests.rs index 298138615..76bf1eb4a 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect-tests.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect-tests.rs @@ -26,15 +26,14 @@ fn answer_round_trip(record: &AnswerRecord) -> AnswerRecord { serde_json::from_str(&text).expect("a serialized answer record deserializes") } -/// Round 3's effect over `messages`, extending `after` by `keep` of them. -fn chat_effect(messages: Vec, after: Option, keep: u64) -> Effect { +/// Round 3's effect over `messages`, extending `after`, an earlier round +/// with how many of the messages it shares. +fn chat_effect(messages: Vec, after: Option<(RoundId, u64)>) -> Effect { let binding = test_binding("writer", "A general model for tests", "test-model"); Effect::Chat { - options: binding.completion_options(), binding, messages, after, - keep, tools: Vec::new(), round: Round::new(RoundId::new(3), ReplyOrigin::Chat), } @@ -51,7 +50,7 @@ fn three_messages() -> Vec { #[test] fn a_chat_record_copies_after_and_keep_and_logs_only_the_messages_after_keep() { - let effect = chat_effect(three_messages(), Some(RoundId::new(2)), 2); + let effect = chat_effect(three_messages(), Some((RoundId::new(2), 2))); let record = effect.record(); let EffectRecord::Chat { round, @@ -94,7 +93,7 @@ fn a_chat_record_copies_after_and_keep_and_logs_only_the_messages_after_keep() { #[test] fn a_keep_equal_to_the_message_count_logs_no_messages() { - let record = chat_effect(three_messages(), Some(RoundId::new(2)), 3).record(); + let record = chat_effect(three_messages(), Some((RoundId::new(2), 3))).record(); let EffectRecord::Chat { after, keep, @@ -181,7 +180,7 @@ fn records_written_before_the_render_existed_read_unchanged() { #[test] fn a_first_send_records_no_after_and_logs_every_message() { - let record = chat_effect(three_messages(), None, 0).record(); + let record = chat_effect(three_messages(), None).record(); let EffectRecord::Chat { after, keep, diff --git a/crates/runvil-internal/engine/src/execute/run/effect.rs b/crates/runvil-internal/engine/src/execute/run/effect.rs index 1fa6af6e6..ed8046e19 100644 --- a/crates/runvil-internal/engine/src/execute/run/effect.rs +++ b/crates/runvil-internal/engine/src/execute/run/effect.rs @@ -39,7 +39,7 @@ use serde_json::Value; use crate::lua::{VfsOp, VfsOutcome}; use crate::model::{Completion, CompletionError, Message, ToolSchema}; -use crate::model::{CompletionOptions, ModelBinding, Temperature}; +use crate::model::{ModelBinding, Temperature}; pub use record::{ AnswerRecord, ChatAnswerRecord, EffectRecord, FailureRecord, PackToolSets, ToolAnswerRecord, @@ -106,25 +106,22 @@ impl Round { #[derive(Debug)] pub enum Effect { /// One model round: send `messages` to the model with `tools` - /// advertised, using the frozen `options` built from `binding`. A - /// nested `models.infer` round sends one user message with an empty - /// tool list, and the Engine consumes only its completed reply. + /// advertised, using the options `binding` freezes. A nested + /// `models.infer` round sends one user message with an empty tool + /// list, and the Engine consumes only its completed reply. Chat { /// The binding the round runs under. binding: ModelBinding, /// The conversation to send, in wire order. messages: Vec, - /// The earlier round whose request this one extends: the previous - /// send of the same section's `messages` list. `None` on a - /// section's first send and on a nested `models.infer` round. - after: Option, - /// How many leading messages this request shares with `after`'s - /// request; 0 when `after` is `None`. - keep: u64, + /// The earlier round whose request this one extends, the previous + /// send of the same section's `messages` list, with how many + /// leading messages this request shares with that round's request. + /// `None` on a section's first send and on a nested `models.infer` + /// round. + after: Option<(RoundId, u64)>, /// The tool schemas advertised to the model for this round. tools: Vec, - /// The per-request completion options, built from `binding`. - options: CompletionOptions, /// The round's id and origin. The caller needs to forward live /// deltas only for a round whose origin is [`ReplyOrigin::Chat`]. /// The effect's record keeps the id and drops the origin, because @@ -152,12 +149,9 @@ pub enum Effect { }, /// One call to a Pack's tool: a run catalog tool, or a tool only the /// model sees, from the section entry's render. The caller resolves - /// `tool`, the tool's stable identity, to an implementation. `alias` - /// is the tool's wire name for a model call and its id for a script - /// call, and `origin` says who made the call and where. The effect's - /// record keeps both and leaves out `access`, a live handle, and - /// `survives_stop`, which the run catalog and the render's answer - /// already give. + /// `tool` to an implementation. The effect's record keeps every field + /// but `access`, a live handle, and `survives_stop`, which the run + /// catalog and the render's answer already give. ToolCall { /// The tool's stable live identity. tool: ToolId, @@ -186,15 +180,15 @@ pub enum Effect { /// /// `access` is an ordinary access that the Engine derives from the /// chain's capability at dispatch. It is rooted at the store declared - /// by the handle that the chain's access came from. Only the `store.*` - /// calls produce this effect. A tool reaches files through the access - /// its [`ToolCall`](Effect::ToolCall) effect carries. + /// by the handle that the chain's access came from. A tool reaches + /// files through the access its [`ToolCall`](Effect::ToolCall) effect + /// carries. /// /// The caller must use the access exactly as given and within the - /// scope it carries. Correctness holds whenever the caller drops the - /// access. Store claims follow happens-before order within the - /// run's scope. The run ends that scope at `Done` or when the run is - /// dropped, and after that the view refuses every operation. + /// scope it carries. When the caller drops the access does not affect + /// the run's correctness. Store claims follow happens-before order + /// within the run's scope. The run ends that scope at `Done` or when + /// the run is dropped, and after that the view refuses every operation. Vfs { /// The chain's store view: an access with the chain's identity /// that reaches only the store root. @@ -206,6 +200,11 @@ pub enum Effect { /// behind a timed wait. Timer { /// How long the sleep lasts. + /// + /// The effect holds a [`Duration`], but its record stores fractional + /// seconds as an `f64`, so a half-second timer records as + /// `{"Timer":{"seconds":0.5}}`. A sub-second duration keeps its + /// fraction there rather than rounding to whole seconds. duration: Duration, }, } @@ -239,18 +238,21 @@ impl Effect { binding, messages, after, - keep, tools, round, .. } => { let invocation = binding.invocation(); - let kept = usize::try_from(*keep).unwrap_or(usize::MAX); + let (after, keep) = match *after { + Some((after, keep)) => (Some(after), keep), + None => (None, 0), + }; + let kept = usize::try_from(keep).unwrap_or(usize::MAX); EffectRecord::Chat { round: round.id, alias: binding.alias().to_owned(), - after: *after, - keep: *keep, + after, + keep, messages: messages.iter().skip(kept).map(wire_value).collect(), tools: tools .iter() diff --git a/crates/runvil-internal/engine/src/execute/run/tests.rs b/crates/runvil-internal/engine/src/execute/run/tests.rs index ae0d3d85f..daa9e21d6 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests.rs @@ -45,11 +45,9 @@ fn binding() -> ModelBinding { fn a_chat_effect_records_its_round_alias_messages_tools_and_invocation() { let binding = binding(); let effect = Effect::Chat { - options: binding.completion_options(), binding, messages: vec![Message::user("ask")], after: None, - keep: 0, tools: vec![ tool_schema_new("grab", "Grab a value", json!({ "type": "object" })) .expect("a valid schema"), diff --git a/crates/runvil-internal/engine/src/execute/scheduler.rs b/crates/runvil-internal/engine/src/execute/scheduler.rs index a297058de..ae434c166 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler.rs @@ -106,10 +106,6 @@ use chain_record::{Chain, ChainArena, ChainIndex, ChatAnchor, Counters}; use effects::{CallSite, Continuation, EffectTable, Pending, ToolCallContinuation}; use tasks::TaskArena; -/// The deepest nesting a chain may run at, counting `call` children and -/// spawned tasks, fanout arms included; the first level counts. -const MAX_CALL_DEPTH: usize = 8; - /// The run's final result when no section produced a reply, which three /// fallback sites share: a prompt with neither H1 blocks nor sections, a /// top-level walk that ends without a scalar, and an H1 pass with no walk @@ -131,7 +127,9 @@ fn first_chunk_line(blocks: &[Block]) -> u32 { /// The observability origin for a capability the run acquires: `label` is /// the section or pass the capability serves, and the prompt's title /// stands in for a file name - a prompt's name is its title, since the -/// source may never have existed on disk. +/// source may never have existed on disk. Four shapes reach it: the H1 +/// pass's own title, the walk's title, a worker section's name for a task's +/// spawn, and the tool id a `tool_call`'s access fork reports under. fn prompt_origin(prompt: &Prompt, label: &str, blocks: &[Block]) -> Origin { Origin::at(label, prompt.title(), first_chunk_line(blocks)) } @@ -163,13 +161,8 @@ pub(crate) struct Scheduler { /// The effect table: every issued effect the caller has not answered /// yet, and the step's outgoing queue of issued and withdrawn effects. effects: EffectTable, - /// The task arena: one entry per task the run has started, keyed by the - /// task's id: its backing chain's id, or, for an internal timer, the id - /// it took from its owner's child counter. An entry outlives its chain: - /// it holds the terminal state and the undelivered outcome at least - /// until the owner takes the result or ends, and in fact for the run - - /// the arena is append-only like the chain arena, so `status` can report - /// a terminal state at any later time. + /// The task arena; [`TaskArena`] and the `tasks` module state its + /// lifecycle. tasks: TaskArena, /// Admission: the task chains waiting for slots - resumed chains, /// admitted first, and fresh spawns with their spawn records - and the @@ -180,7 +173,9 @@ pub(crate) struct Scheduler { phase: Phase, /// The run's scope, taken where the run acquires its root identity: /// closed when the run reaches `Done` or is dropped before it, so a - /// store view the caller still holds never outlives the run. + /// store view the caller still holds never outlives the run. The two + /// root-start sites (`h1::start_h1` and `walk::start_root_walk`) are its + /// only writers, each at the acquisition that opens the run's identity. scope: Option, } @@ -225,19 +220,20 @@ impl Scheduler { Arc::clone(self.ctx.prompt()) } - /// Whether the run's outcome is decided: the end boundary is reported - /// and only the orphans' answers stand between the run and `Done`. + /// Whether the run's outcome is decided: its end boundary is reported, + /// so at most the orphans' answers stand between the run and `Done`. pub(super) fn decided(&self) -> bool { matches!(self.phase, Phase::Ending(_) | Phase::Done) } - /// Resumes `id` at once with `answer`: the inline-answer path of every - /// request the core keeps, and of a chain end answering another chain. + /// Answers `id` at once with `answer` and queues it to resume: the + /// inline-answer path of every request the core keeps, and of a chain + /// end answering another chain. /// /// # Errors /// Returns [`Error::Internal`] when the chain has no suspended block - /// to take the answer. - fn answer_inline(&mut self, id: ChainIndex, answer: Answer) -> Result<()> { + /// to take the answer, or one that already holds an answer. + fn answer_inline(&mut self, id: ChainIndex, answer: Result>) -> Result<()> { self.chains[id].deliver(answer)?; self.ready.push_back(id); Ok(()) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs index 75c026246..bbba0b73d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs @@ -3,7 +3,7 @@ //! admission against the next, a task holds a slot at its owner and every //! ancestor, a task wait gives those slots back and a timer-only wait keeps //! them, a call chain gives them back through its nearest holding ancestor, -//! a forgotten chain leaves admission, and a pass that fails leaves +//! an aborted chain leaves admission, and a pass that fails leaves //! admission as it found it. use std::num::NonZeroUsize; @@ -233,16 +233,16 @@ fn a_call_chain_in_the_main_walk_has_no_slots_to_give_back() { } #[test] -fn a_forgotten_chain_leaves_its_queue_and_its_slots() { +fn an_aborted_chain_leaves_its_queue_and_its_slots() { let mut tree = Tree::new(); let mut admission = Admission::default(); let root = tree.root(1); let running = spawn(&mut admission, &mut tree, root); assert_eq!(plan(&mut admission, &tree), [Admitted::Started(running)]); let aborted = spawn(&mut admission, &mut tree, root); - admission.forget(aborted); + admission.drop_aborted(aborted); assert!(!admission.is_queued(aborted)); - admission.forget(running); + admission.drop_aborted(running); let next = spawn(&mut admission, &mut tree, root); assert_eq!(plan(&mut admission, &tree), [Admitted::Started(next)]); } @@ -305,10 +305,10 @@ fn a_pass_that_fails_after_admitting_a_resumption_leaves_its_slots_given_back() admission.plan(&tree.view()), Err(Error::Internal { .. }) )); - assert_eq!(admission.released.get(&call), Some(&task)); + assert_eq!(admission.gave_back.get(&call), Some(&task)); assert!(admission.resuming.contains(&call)); assert!(!admission.holding.contains(&task)); - admission.forget(stray); + admission.drop_aborted(stray); assert_eq!( plan(&mut admission, &tree), [Admitted::Resumed(call)], diff --git a/crates/runvil-internal/engine/src/execute/scheduler/admission.rs b/crates/runvil-internal/engine/src/execute/scheduler/admission.rs index 071ea6bb2..872807f39 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/admission.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/admission.rs @@ -5,10 +5,8 @@ //! and at every enclosing ancestor, held until the task ends - except //! while it, or a `call` chain it is blocked on, is parked on a task //! wait, which gives the slot back so its descendants can run - and a -//! chain admits at most its effective limit at once. The root's limit is the run's -//! [`RunLimits::max_concurrency`](crate::execute::RunLimits::max_concurrency) -//! ceiling; a spawned task and a call chain start with their parent's, -//! and `tasks.concurrency` sets it, clamped to the parent's. Queue +//! chain admits at most its effective limit at once. Each chain's limit +//! is its [`Chain::concurrency`](super::Chain::concurrency). Queue //! order is spawn order, with resumptions first. The task's start event //! fires at admission, when the task first runs. //! @@ -33,9 +31,10 @@ use super::{ChainIndex, Scheduler}; /// A queued task chain's spawn record, held from spawn until its /// admission: the input override and the spawning section the start event -/// reports. The `var` snapshot lives in the chain's `var` slot from spawn -/// on, moved once out of the yield - never copied - so a queued chain -/// shares it until admission installs it into the first section. +/// reports. The `var` snapshot is not in the record: it moves out of the +/// yield into the chain's `var` slot at spawn, the start event reports a +/// copy of it at admission, and the chain's first section entry seeds from +/// the slot. #[derive(Debug)] pub(super) struct SpawnRecord { /// The `opts.input` override, reported in the start event. @@ -48,7 +47,7 @@ pub(super) struct SpawnRecord { /// One chain an admission pass admitted, for the scheduler to apply. #[derive(Debug)] -pub(super) enum Admit { +enum Admit { /// A chain parked on a task wait took its slots back: it resumes with /// the answer its wake delivered. Resumed(ChainIndex), @@ -72,7 +71,7 @@ pub(super) struct ChainTree<'a> { impl ChainTree<'_> { /// What kind of chain `id` is. - pub(super) fn kind(&self, id: ChainIndex) -> &ChainKind { + fn kind(&self, id: ChainIndex) -> &ChainKind { &self.chains[id].kind } @@ -82,7 +81,7 @@ impl ChainTree<'_> { } /// The call parent blocked on `id`, when it is a `call` child. - pub(super) fn call_parent(&self, id: ChainIndex) -> Option { + fn call_parent(&self, id: ChainIndex) -> Option { self.chains[id].kind.parent() } @@ -100,7 +99,7 @@ impl ChainTree<'_> { /// The chains a slot the task chain `id` holds counts against: its /// owner and every enclosing ancestor. - pub(super) fn ancestors(&self, id: ChainIndex) -> impl Iterator + '_ { + fn ancestors(&self, id: ChainIndex) -> impl Iterator + '_ { std::iter::successors(self.enclosing(id), |at| self.enclosing(*at)) } } @@ -108,11 +107,8 @@ impl ChainTree<'_> { /// The run's admission state: the chains waiting for slots and the chains /// holding them. /// -/// A task holds a slot at its owner and at every ancestor. A task wait -/// gives those slots back, and a call chain gives them back through its -/// nearest ancestor that holds slots. Resumed chains are admitted before -/// fresh spawns. Slot counts are computed from the holders, never stored, -/// so no count can drift from them. +/// Slot counts are computed from the holders, never stored, so no count +/// can drift from them. #[derive(Debug, Default)] pub(super) struct Admission { /// Chains parked on a task wait that gave their admission slots back, @@ -129,9 +125,10 @@ pub(super) struct Admission { /// child gives them back while that child parks on a task wait. holding: HashSet, /// Each call chain parked on a task wait that gave back its nearest - /// holding ancestor's slots, with that ancestor: the chain's wake - /// takes the ancestor's slots back before the chain continues. - released: HashMap, + /// holding ancestor's slots, with that ancestor: the map of chains that + /// gave slots back, not of the slots, so the chain's wake takes the + /// ancestor's slots back before the chain continues. + gave_back: HashMap, } impl Admission { @@ -153,24 +150,26 @@ impl Admission { /// starve behind a long queue), then fresh spawns in spawn order. The /// slots in use are counted once per pass, and each admission adds its /// own. Every admitted chain's slot holder holds its slots from here; - /// the scheduler applies the rest. + /// the scheduler applies the rest. This is where both ordering and + /// counting live, which is what makes an admission count against the + /// next admission in the same pass. /// /// # Errors /// Returns [`Error::Internal`] when a resumed chain's slots belong to /// no admitted task, or a queued spawn is not a task chain. A failed /// pass leaves admission as it found it: both queues, the holders, and /// the given-back slots. - pub(super) fn plan(&mut self, tree: &ChainTree<'_>) -> Result> { + fn plan(&mut self, tree: &ChainTree<'_>) -> Result> { let mut admitted = Vec::new(); if self.resuming.is_empty() && self.spawned.is_empty() { return Ok(admitted); } let mut holding = self.holding.clone(); - let mut released = self.released.clone(); + let mut gave_back = self.gave_back.clone(); let mut used = slots_in_use(&holding, tree); let mut resuming = VecDeque::new(); for &id in &self.resuming { - let holder = slot_holder(&released, id); + let holder = slot_holder(&gave_back, id); if !can_admit(&used, tree, holder) { resuming.push_back(id); continue; @@ -181,7 +180,7 @@ impl Admission { )); } holding.insert(holder); - released.remove(&id); + gave_back.remove(&id); count_slot(&mut used, tree, holder); admitted.push(Admit::Resumed(id)); } @@ -209,7 +208,7 @@ impl Admission { self.resuming = resuming; self.spawned = spawned; self.holding = holding; - self.released = released; + self.gave_back = gave_back; Ok(admitted) } @@ -232,7 +231,7 @@ impl Admission { let mut at = tree.call_parent(id); while let Some(ancestor) = at { if self.holding.remove(&ancestor) { - self.released.insert(id, ancestor); + self.gave_back.insert(id, ancestor); return; } at = tree.call_parent(ancestor); @@ -244,9 +243,9 @@ impl Admission { /// call chain that gave back its ancestor's, are admitted again ahead /// of fresh starts, while the main walk and a chain that kept its /// slots (a timer-only wait) resume at once. - pub(super) fn wake(&mut self, id: ChainIndex, tree: &ChainTree<'_>) -> bool { + fn wake(&mut self, id: ChainIndex, tree: &ChainTree<'_>) -> bool { let requeue = match tree.kind(id) { - ChainKind::Call { .. } => self.released.contains_key(&id), + ChainKind::Call { .. } => self.gave_back.contains_key(&id), ChainKind::Task => !self.holding.contains(&id), ChainKind::Root | ChainKind::H1 => false, }; @@ -256,18 +255,22 @@ impl Admission { requeue } - /// Gives back `id`'s slots at its chain end, when it holds any. + /// Releases `id`'s slots at its chain end, so a queued sibling or a + /// resumption can have them. A chain that holds none, or whose slots + /// are given back while it is parked, changes nothing. pub(super) fn release(&mut self, id: ChainIndex) { self.holding.remove(&id); } - /// Drops an aborted chain: it leaves both queues, and the slots it - /// holds are given back. - pub(super) fn forget(&mut self, id: ChainIndex) { + /// Drops an aborted chain: it leaves both queues - no admission can + /// start it, and no wake can requeue it - and the slots it still holds + /// are released. The chain's own `gave_back` record goes too, since + /// no resumption will read it. + pub(super) fn drop_aborted(&mut self, id: ChainIndex) { self.resuming.retain(|queued| *queued != id); self.spawned.retain(|(queued, _)| *queued != id); self.holding.remove(&id); - self.released.remove(&id); + self.gave_back.remove(&id); } /// Drops every queued chain and every held slot: the run's teardown. @@ -275,7 +278,7 @@ impl Admission { self.resuming.clear(); self.spawned.clear(); self.holding.clear(); - self.released.clear(); + self.gave_back.clear(); } /// Marks the task chain `id` holding its slots, so a test can fill a @@ -287,10 +290,10 @@ impl Admission { } /// The chain whose slots `id`'s resumption takes back: the holding -/// ancestor a parked call chain gave back, as `released` records it, or +/// ancestor a parked call chain gave back, as `gave_back` records it, or /// the resumed task itself. -fn slot_holder(released: &HashMap, id: ChainIndex) -> ChainIndex { - released.get(&id).copied().unwrap_or(id) +fn slot_holder(gave_back: &HashMap, id: ChainIndex) -> ChainIndex { + gave_back.get(&id).copied().unwrap_or(id) } /// The admission slots in use at each chain, by arena index, counted @@ -322,9 +325,7 @@ fn can_admit(used: &[usize], tree: &ChainTree<'_>, id: ChainIndex) -> bool { impl Scheduler { /// Admits every queued task that can take a slot now, applying each - /// admission in the order [`Admission::plan`] made them: a resumed - /// chain leaves its wait for the ready queue - its start event already - /// fired at its first admission - and a fresh spawn starts. + /// admission [`Admission::plan`] made, in its order. /// /// # Errors /// Returns [`Error::Internal`] when the plan does, or when a started @@ -346,14 +347,11 @@ impl Scheduler { Ok(()) } - /// Starts one admitted task chain: fires the start event with its - /// spawn record on its owner's sequence (its spawner, or the root walk - /// for a task the H1 pass spawned) under the spawning section (the - /// spawn-time capture, so a task admitted after its spawner moved on - /// still reports where it was spawned), and enqueues it. The start - /// event marks admission - the task first runs now - so a task still - /// waiting for a slot reports no start event, and its Lua VM is only - /// created at its first section entry, after admission. + /// Starts one admitted task chain: enqueues it and fires its start + /// event with the spawn record on its owner's sequence (its spawner, or + /// the root walk for a task the H1 pass spawned) under the spawning + /// section (the spawn-time capture, so a task admitted after its + /// spawner moved on still reports where it was spawned). /// /// # Errors /// Returns [`Error::Internal`] when its task has no entry: a spawn @@ -391,16 +389,18 @@ impl Scheduler { Ok(()) } - /// Resumes a chain parked on a task wait with its delivered answer: a - /// chain that gave its admission slots back waits to take them again, - /// reading `queued` exactly as a fresh spawn's wait does, while the - /// main walk, and a chain that kept its slots (a timer-only wait), - /// resume inline. + /// Delivers its answer to a chain parked on a task wait: the chain is + /// parked on [`Parked::Queued`] when [`Admission::wake`] requeues it for + /// its slots, else pushed onto the ready queue at once. /// /// # Errors /// Returns [`Error::Internal`] when the chain has no suspended block - /// to take the answer. - pub(super) fn wake_from_wait(&mut self, id: ChainIndex, answer: Answer) -> Result<()> { + /// to take the answer, or one that already holds an answer. + pub(super) fn wake_from_wait( + &mut self, + id: ChainIndex, + answer: Result>, + ) -> Result<()> { self.chains[id].deliver(answer)?; let tree = ChainTree { chains: &self.chains, diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index 88395f070..a697f134f 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -87,22 +87,6 @@ fn classify_vfs_failure(op: &VfsOp, error: VfsError) -> Error { } } -/// The cancelled answer for a chain parked on `resume`'s kind of effect: -/// the protocol variant the chain's shim expects, holding the run's -/// cancellation error, which for a render is the round's chat answer. A -/// timer's drop is applied to its entry and its waiter instead, before -/// this is reached. -fn dropped_answer(resume: &Continuation) -> Answer { - match resume { - Continuation::Infer(_) => Answer::Infer(Err(Error::Interrupted)), - Continuation::Chat(_) | Continuation::Render(_) => Answer::Chat(Err(Error::Interrupted)), - Continuation::ToolCall(_) => Answer::ToolCallResult(Err(Error::Interrupted)), - // A timer's drop never reaches here; the cancelled store answer - // is the harmless stand-in should it ever do so. - Continuation::Vfs(_) | Continuation::Timer(_) => Answer::Store(Err(Error::Interrupted)), - } -} - impl Scheduler { /// Applies one performed effect's answer to `parked`, the entry /// `resume` took out of the effect table: turns the raw answer into the @@ -133,7 +117,7 @@ impl Scheduler { self.end_tool_call(chain, call.exec); match call.site { CallSite::Batch { slot, .. } => Some(slot), - CallSite::Script { .. } | CallSite::Model { .. } => None, + CallSite::Script | CallSite::Model { .. } => None, } } Continuation::Infer(_) @@ -144,10 +128,10 @@ impl Scheduler { }; let answer = match (resume, answer) { (Continuation::Timer(task), EffectAnswer::Dropped) => return self.drop_timer(&task), - (resume, EffectAnswer::Dropped) => dropped_answer(&resume), - (Continuation::Infer(round), EffectAnswer::Chat(result)) => { - Answer::Infer(self.with_chain(chain, |ops| ops.accept_infer(round, result))) - } + (_, EffectAnswer::Dropped) => Err(Error::Interrupted), + (Continuation::Infer(round), EffectAnswer::Chat(result)) => self + .with_chain(chain, |ops| ops.accept_infer(round, result)) + .map(Answer::Infer), (Continuation::Chat(round), EffectAnswer::Chat(result)) => self .with_chain(chain, |ops| { ops.accept_chat(round, result.map_err(Error::from)) @@ -155,18 +139,16 @@ impl Scheduler { (Continuation::Render(binding), EffectAnswer::Render(rendered)) => { return self.with_chain(chain, |ops| ops.accept_render(binding, rendered)); } - (Continuation::ToolCall(call), EffectAnswer::ToolCall(result)) => { - Answer::ToolCallResult( - self.with_chain(chain, |ops| ops.accept_tool_call(&call, result)), - ) - } + (Continuation::ToolCall(call), EffectAnswer::ToolCall(result)) => self + .with_chain(chain, |ops| ops.accept_tool_call(&call, result)) + .map(Answer::ToolCallResult), (Continuation::Vfs(op), EffectAnswer::Vfs(result)) => { match self.with_chain(chain, |ops| ops.accept_vfs(&op, result)) { // A claims-model conflict is fatal: the suspended // chains drop unarmed in the run's teardown, exactly // as on the cancellation path. Err(error @ Error::Determinism(_)) => return Err(error), - result => Answer::Store(result), + result => result.map(Answer::Store), } } (Continuation::Timer(task), EffectAnswer::Timer) => return self.fire_timer(&task), @@ -251,7 +233,7 @@ impl Scheduler { if self.ctx.cancel().is_cancelled() || !self.chains[owner].end_wait_for(task) { return Ok(()); } - self.wake_from_wait(owner, Answer::JoinAny(Err(Error::Interrupted))) + self.wake_from_wait(owner, Err(Error::Interrupted)) } } @@ -265,13 +247,7 @@ impl ChainOps<'_> { result: std::result::Result, CompletionError>, ) -> Result { let chain = &*self.chain; - accept_infer( - result, - round, - chain.ctx.emitter(), - chain.section_name(), - chain.ctx.turns(), - ) + accept_infer(result, round, chain.ctx.reporting(), chain.section_name()) } /// Applies a bound tool call's own answer through the shared dispatch @@ -299,7 +275,7 @@ impl ChainOps<'_> { // never classified by output kind. CallSite::Model { call_id, .. } | CallSite::Batch { call_id, .. } => { let report = ModelReport { - script: call.report, + turn: call.report.turn, call_id: call_id.clone(), }; prepare_model_dispatch( @@ -314,14 +290,14 @@ impl ChainOps<'_> { .map(|outcome| ToolCallOutcome::Plain(outcome.into_content())) .map_err(Error::from) } - CallSite::Script { .. } => match prepare_dispatch( + CallSite::Script => match prepare_dispatch( &call.binding, vendor, result, nonce, emitter, section, - Some(call.report), + call.report, ) { Ok(outcome) => { if outcome.listing() { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs index 5364c822a..748447940 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain.rs @@ -97,6 +97,47 @@ impl Scheduler { }) } + /// Starts a root chain and enqueues it: the H1 pass, the root walk, or + /// the walk a jump out of H1 starts. The root lineage and the root call + /// depth are what makes a chain a root chain, so neither is a + /// parameter, and `start` and `concurrency` are, because a walk + /// inherits the pass's start position and the pass's limit after + /// `tasks.concurrency` lowered it. A root start enqueues itself: the + /// chain is on the ready queue when this returns, and no caller pushes + /// it again. + /// + /// # Errors + /// Returns [`Error::Internal`] when the run's chain count exceeds + /// `u32`, leaving the ready queue unchanged. + #[expect( + clippy::too_many_arguments, + reason = "a root start inlines the `ChainStart` it would build, so no caller can set `lineage` or `call_depth`, and the four values it forwards verbatim from its site stay explicit" + )] + pub(super) fn start_root_chain( + &mut self, + kind: ChainKind, + counters: Counters, + ctx: RunState, + access: Arc, + start: SectionPos, + var: serde_json::Value, + concurrency: NonZeroUsize, + ) -> Result { + let id = self.start_chain(ChainStart { + lineage: ChainId::root(), + counters, + ctx, + access, + start, + kind, + var, + call_depth: CallDepth::ROOT, + concurrency, + })?; + self.ready.push_back(id); + Ok(id) + } + /// Finishes one chain: the frame's teardown boundary when the chain /// ends mid-section, the chain-end rules for the tasks it owns (a live /// task makes the outcome `tasks_live` and is abandoned), then the @@ -106,6 +147,10 @@ impl Scheduler { /// for the root chain, the call answer for a child chain, the task /// entry's outcome for a spawned chain. /// + /// Delivery is one of those three ways and a `Determinism` outcome + /// takes none of them: it ends the run before the delivery match. The + /// frame is armed for completion only when the chain is not H1. + /// /// `outcome` is the chain's end: a scalar return's value, `None` for a /// walk that ran off its slice's last section, or the chain's failure. pub(super) fn finish(&mut self, id: ChainIndex, outcome: Result>) -> ChainStep { @@ -187,7 +232,7 @@ impl Scheduler { } match parent { None => ChainStep::RunEnded(outcome), - Some(parent_id) => match self.answer_inline(parent_id, Answer::Call(outcome)) { + Some(parent_id) => match self.answer_inline(parent_id, outcome.map(Answer::Call)) { Ok(()) => ChainStep::Continue, Err(error) => ChainStep::RunEnded(Err(error)), }, @@ -229,8 +274,8 @@ impl Scheduler { chain.access.take() }; // A queued chain aborted before admission leaves both queues, and - // a running task's held slots are given back. - self.admission.forget(id); + // a running task's held slots are released. + self.admission.drop_aborted(id); // The chain-end join: the aborted chain's final clock still // transitively covers its tasks, for whoever joins the aborted // chain itself. @@ -239,16 +284,12 @@ impl Scheduler { } } - /// Orphans one in-flight leaf effect whose chain is going away: its - /// entry in the effect table becomes orphaned, so the caller's answer, - /// when it arrives, is discarded rather than failing the run. A tool - /// call's identity ends here as its answer would end it, so what the - /// tool already did is joined into the parked chain and anything it - /// tries later is refused. A tool call is also listed as withdrawn, - /// for the step to report. The caller still owes the answer: `Done` - /// waits for every issued effect, so the run ends only once the - /// caller has answered all of them. An id with no entry is left - /// unknown, so an answer for it still fails loudly. + /// Orphans one in-flight leaf effect whose chain is going away, as + /// [`EffectTable::orphan`](super::effects::EffectTable::orphan) + /// describes, and ends a tool call's identity through + /// [`Scheduler::end_tool_call`] as its answer would, so what the tool + /// already did is joined into the parked chain and anything it tries + /// later is refused. pub(super) fn abort_effect(&mut self, effect: EffectId) { if let Some((chain, exec)) = self.effects.orphan(effect) { self.end_tool_call(chain, exec); diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs index 350946d66..3abe338df 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_ops.rs @@ -1,18 +1,19 @@ //! The one-chain view: the requests that touch only their own chain run //! in a [`ChainOps`], which borrows that chain and the effect table -//! mutably and the run context shared, and owns nothing. A request in the -//! view can change only its own chain and the effect table: it can't -//! reach another chain, the task arena, admission, or the ready queue, -//! and the compiler enforces that. [`Scheduler::with_chain`] is the only -//! way into the view. The view's requests sit beside the arms they belong -//! to, in `impl ChainOps<'_>` blocks in `chat`, `dispatch`, `apply`, -//! `tool_call`, and `walk`. +//! mutably and owns nothing. A request in the view can change only its own +//! chain and the effect table: it can't reach another chain, the task +//! arena, admission, or the ready queue, and the compiler enforces that. +//! [`Scheduler::with_chain`] is the only way into the view. The view's +//! requests sit beside the arms they belong to, in the eight +//! `impl ChainOps<'_>` blocks this crate holds: the shared `prompt`, +//! `answer`, `issue`, and `number_round` live here in `chain_ops`, and the +//! arms live in `apply`, `chat`, `dispatch`, `tool_call`, its `adhoc` and +//! `discovery` children, and `walk`. use std::sync::Arc; use runvil_types::event::ReplyOrigin; -use crate::execute::context::RunState; use crate::execute::run::{Effect, EffectId, Round}; use crate::lua::Answer; use crate::parser::Prompt; @@ -31,17 +32,16 @@ pub(super) struct ChainOps<'a> { pub(super) chain: &'a mut Chain, /// The effect table the view's leaf arms issue into. effects: &'a mut EffectTable, - /// The run's own context, which holds the shared prompt tree. - ctx: &'a RunState, - /// How many answers the view delivered to the chain: one requeue each. - answered: usize, + /// Whether the view delivered an answer to the chain, which + /// [`Scheduler::with_chain`] requeues once. + answered: bool, } impl Scheduler { /// Runs `f` in the view of chain `id`, then pushes the chain onto the - /// ready queue once for each answer the view delivered. The view - /// pushes nothing else, so no other chain enters the queue between an - /// answer and its requeue. + /// ready queue if the view delivered an answer. The view pushes nothing + /// else, so no other chain enters the queue between an answer and its + /// requeue. pub(super) fn with_chain<'s, T>( &'s mut self, id: ChainIndex, @@ -51,11 +51,10 @@ impl Scheduler { id, chain: &mut self.chains[id], effects: &mut self.effects, - ctx: &self.ctx, - answered: 0, + answered: false, }; let result = f(&mut ops); - for _ in 0..ops.answered { + if ops.answered { self.ready.push_back(id); } result @@ -64,21 +63,23 @@ impl Scheduler { impl ChainOps<'_> { /// The prompt's shared handle: the chain resolves its walk position - /// against this clone so the tree borrow never pins the chain. + /// against this clone so the tree borrow never pins the chain. The + /// chain's own context fork is the source, which is the one `RunState` + /// the chain reads the prompt from. pub(super) fn prompt(&self) -> Arc { - Arc::clone(self.ctx.prompt()) + Arc::clone(self.chain.ctx.prompt()) } - /// Resumes the chain at once with `answer`: the inline-answer path of - /// every request in the view. The requeue is recorded for + /// Delivers `answer` to the chain's suspended block: the inline-answer + /// path of every request in the view. The requeue is recorded for /// [`Scheduler::with_chain`] to perform. /// /// # Errors /// Returns [`Error::Internal`] when the chain has no suspended block - /// to take the answer. - pub(super) fn answer(&mut self, answer: Answer) -> Result<()> { + /// to take the answer, or one that already holds an answer. + pub(super) fn answer(&mut self, answer: Result>) -> Result<()> { self.chain.deliver(answer)?; - self.answered += 1; + self.answered = true; Ok(()) } @@ -91,8 +92,8 @@ impl ChainOps<'_> { self.effects.issue(self.id, provenance, effect, resume) } - /// Numbers the model round about to be issued with `origin`: the run's - /// next round id, in dispatch order. + /// Numbers the model round about to be issued, through + /// [`EffectTable::number_round`]. pub(super) fn number_round(&mut self, origin: ReplyOrigin) -> Round { self.effects.number_round(origin) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index d4f2ad99a..dbcce33d6 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -1,8 +1,7 @@ //! The chain record and the arena that holds it. A record is one chain's //! identity, kind, walk stack, entered section, task bookkeeping, and //! walk-scoped slots. The [`ChainArena`] holds one per chain the run has -//! started, indexed by [`ChainIndex`]; the H1 pass and the walk after it -//! share the root lineage `0` and are two records. +//! started, indexed by [`ChainIndex`]. use std::fmt; use std::num::NonZeroUsize; @@ -21,8 +20,6 @@ use crate::lua::{Answer, Blocked}; use crate::parser::{Block, BodyText, Prompt, Section}; use crate::{Error, Result}; -use super::MAX_CALL_DEPTH; - mod anchor; mod arena; mod kind; @@ -60,6 +57,10 @@ impl BlockIndex { } } +/// The deepest nesting a chain may run at, counting `call` children and +/// spawned tasks, fanout arms included; the first level counts. +const MAX_CALL_DEPTH: usize = 8; + /// A chain's call nesting depth: each `call` child and each spawned task /// runs one level deeper than the chain that started it, so the cap holds /// across a spawn boundary as well as a call. @@ -142,12 +143,17 @@ pub(super) struct Running { /// The block's coroutine handle; a block that returns disposes of it. pub(super) coroutine: Thread, /// The answer delivered for the suspended coroutine, taken at resume. - pub(super) answer: Option>, + pub(super) answer: Option>>, } /// The section a chain has entered: built at each section entry, the H1 /// pass's included, and taken at the section's end, at chain end, and at -/// an abort, where the frame's drop is the teardown boundary. +/// an abort. The frame's drop is the teardown boundary on every path that +/// takes the `Entered`, and it reports `SECTION_FINISHED` only where the +/// section completed: `end_section`, `apply_jump`, and `finish` arm the +/// frame first, while the H1 pass's fall-through and scalar return, an +/// abort, and the run's teardown take it unarmed and are deliberately +/// silent. pub(super) struct Entered { /// The section's running or suspended Lua block, `None` between /// blocks. Declared first: a suspended coroutine drops before the @@ -245,17 +251,16 @@ impl Walk { /// [`Entered`]; the fall-through advance tears the old one down and the /// next entry constructs the next. pub(super) struct Chain { - /// The chain's hierarchical id: the parent chain's id extended by the - /// parent's local child counter (the root lineage is `0`, which the H1 - /// pass and the walk that follows it share as two arena records). - /// Every id the chain hands out - its children's, its section - /// entries' - extends this path, so two runs of one prompt allocate - /// identical ids however their chains interleave. + /// The chain's hierarchical [`ChainId`], allocated under the scheme + /// [`runvil_types::ids`] states: every id the chain hands out extends + /// it. The H1 pass and the walk after it are two arena records under + /// the root lineage `0`. pub(super) lineage: ChainId, - /// The chain's local child and entry counters under `lineage`. The - /// root chain's entry 0 is the H1 pass (section 0), consumed whether - /// or not the prompt has H1 blocks, so the first walked section is - /// always `0.1`. + /// The chain's local child and entry counters under `lineage`. This is + /// the normative `0.1` statement for the run: the root chain's entry 0 + /// is the H1 pass (section 0), consumed whether or not the prompt has + /// H1 blocks, so the first walked section is always `0.1`. Every other + /// site that names the first walked entry defers to this one. pub(super) counters: Counters, /// What kind of chain this is, with the links only that kind has. pub(super) kind: ChainKind, @@ -303,7 +308,10 @@ pub(super) struct Chain { /// the walk runs off its slice, where the walk names no running /// section. Before the first entry, the name of the section at the /// chain's start, or the prompt's title when the start is past its - /// slice. + /// slice. While a section is entered, this and the walk position name + /// the same section; this field is the only record between sections and + /// after teardown, and it is written only after the frame is + /// constructed. pub(super) entered_name: String, /// The walk's `var` table: seeds each section's VM at entry; the /// section's final `var` is read back before teardown and replaces the @@ -348,16 +356,16 @@ impl Chain { /// # Errors /// Returns [`Error::Internal`] when the chain's capability is gone, /// which only the chain-end paths do - a live chain always holds it. + /// The two teardown probes skip rather than fail on a gone capability: + /// `join_task` returns without a join, and `end_tool_call` ends the + /// call's identity with no owner to merge it into. pub(super) fn access(&self) -> Result<&Arc> { self.access .as_ref() .ok_or(Error::internal("a live chain holds its access capability")) } - /// Hands out the chain's next child id: its id extended by its local - /// child counter, which `call` children, spawned tasks, and the wait - /// shims' timers share, so the ids a chain hands out depend only on - /// the order of its own dispatches. + /// Hands out the chain's next child id, under [`Counters::next_child`]. /// /// # Errors /// Returns [`Error::Internal`] when one chain has started `u32::MAX` @@ -370,9 +378,8 @@ impl Chain { Ok(self.lineage.child(index)) } - /// Hands out the chain's next section-entry id: its id extended by its - /// local entry counter, the value the entered section reads as - /// `sys.id`. + /// Hands out the chain's next section-entry id, under + /// [`Counters::next_entry`]. /// /// # Errors /// Returns [`Error::Internal`] when one chain has entered `u32::MAX` @@ -408,10 +415,11 @@ impl Chain { /// the block resumes. /// /// # Errors - /// Returns [`Error::Internal`] when the chain has no suspended block: - /// every answer goes to a block that yielded for it. + /// Returns [`Error::Internal`] when the chain has no suspended block, or + /// when its block already holds an answer: every answer goes to a block + /// that yielded for it, once, and the first answer stays. #[track_caller] - pub(super) fn deliver(&mut self, answer: Answer) -> Result<()> { + pub(super) fn deliver(&mut self, answer: Result>) -> Result<()> { let Some(running) = self .entered .as_mut() @@ -421,6 +429,11 @@ impl Chain { "a delivered answer implies a suspended block", )); }; + if running.answer.is_some() { + return Err(Error::internal( + "a suspended block holds at most one delivered answer", + )); + } running.answer = Some(answer); Ok(()) } @@ -458,7 +471,7 @@ impl Chain { /// result outlives a mutable borrow of the chain. pub(super) fn blocks<'p>(&self, prompt: &'p Prompt) -> &'p [Block] { if self.kind.is_h1() { - runvil_parser::detail::h1_blocks(prompt) + crate::parser::detail::h1_blocks(prompt) } else { self.walk .current() diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/anchor.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/anchor.rs index 41df8776d..a15d27b19 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/anchor.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/anchor.rs @@ -1,5 +1,4 @@ -//! What a chain's `chat` rounds taught the context precheck about token -//! counts. +//! The chain record's [`ChatAnchor`]. use runvil_types::metrics::Usage; diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs index 0988f35f0..45b8315f1 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs @@ -12,11 +12,12 @@ use super::Chain; const MAX_CHAINS: usize = u32::MAX as usize; /// Arena index of a chain: indices, not references, so no chain ever holds -/// a pointer to another. Only [`ChainArena::push`] creates one, so every -/// index names a chain. The index is the scheduler's private handle; the -/// chain's identity for authors and callers is its hierarchical -/// [`ChainId`](runvil_types::ids::ChainId), which never depends on arena -/// order. +/// a pointer to another. Only [`ChainArena::push`] creates one outside test +/// builds, so every index names a chain; a `#[cfg(test)]` constructor mints +/// one for tables that only compare indices. The index is the scheduler's +/// private handle; the chain's identity for authors and callers is its +/// hierarchical [`ChainId`](runvil_types::ids::ChainId), which never depends +/// on arena order. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(in crate::execute::scheduler) struct ChainIndex(u32); @@ -90,7 +91,9 @@ impl Chain { /// The chain arena: every chain the run has started, indexed by /// [`ChainIndex`]. Every index the arena hands out names a chain: it holds -/// at most `u32::MAX` chains and never shrinks. +/// at most [`MAX_CHAINS`] and never shrinks. A test build lowers that bound +/// through a `#[cfg(test)]` setter, so the overflow path runs without +/// allocating the real bound. pub(in crate::execute::scheduler) struct ChainArena { chains: Vec, /// The chain-count bound in test builds: [`MAX_CHAINS`] until a test diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/kind.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/kind.rs index 7de81ddee..6e6d73477 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/kind.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/kind.rs @@ -1,10 +1,10 @@ -//! What kind of chain a record is: the main walk, the H1 pass, a -//! `call` child, or a task's backing chain, each with the links only that -//! kind has. +//! The chain record's [`ChainKind`]. use super::ChainIndex; -/// What kind of chain a record is. +/// What kind of chain a record is: the main walk, the H1 pass, a `call` +/// child, or a task's backing chain, each with the links only that kind +/// has. #[derive(Debug)] pub(in crate::execute::scheduler) enum ChainKind { /// The main walk. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index 5c263257a..6efe6cd2d 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs @@ -25,7 +25,6 @@ use std::sync::atomic::Ordering; -use runvil_model_client::detail::tool_call_arguments; use runvil_types::event::{ReplyOrigin, lifecycle}; use runvil_types::ids::RoundId; use runvil_types::metrics::ToolCallEvent; @@ -33,21 +32,24 @@ use runvil_types::tools::ToolInfo; use crate::execute::context::ReportingHandles; use crate::execute::run::{Effect, PackToolSets}; -use crate::execute::scope::{SectionOffers, offered_packs, prepare_effective_scope, tool_schema}; +use crate::execute::scope::{SectionOffers, freeze_tool_sets, offered_packs, tool_schema}; use crate::execute::turn::{Served, advance_turn, report_model_turn}; use crate::lua::{ Answer, ChatOutcome, ChatResult, MessageList, OverflowReason, RenderedTools, ToolSet, output_reserve, precheck, project_messages, resolve_model_binding, wire_name_of, }; -use crate::model::{Completion, CompletionErrorKind, CompletionResult, ModelBinding, ToolCall}; +use crate::model::{ + Completion, CompletionErrorKind, CompletionResult, ModelBinding, ToolCall, detail, +}; use crate::{Error, Result}; use super::dispatch::Dispatched; use super::{ChainOps, Continuation}; -/// The answer for a round refused as too large by `reason`'s gate, before -/// or by the provider: no round ran, so no finish reason is present and -/// `turn` is the unadvanced counter. +/// The answer for a round refused as too large by `reason`'s gate: either +/// the precheck, where no round ran, or a provider's overflow refusal, +/// where the round ran and reported no finish reason. So no finish reason is +/// present either way, and `turn` is the unadvanced counter. fn overflow_result(reason: OverflowReason, turn: u32) -> ChatResult { ChatResult { outcome: ChatOutcome::Overflow(reason), @@ -69,8 +71,8 @@ impl ChainOps<'_> { ) -> Result<()> { match self.prepare_chat(list, binding) { Ok(Dispatched::Issued) => Ok(()), - Ok(Dispatched::Answered(answer)) => self.answer(answer), - Err(error) => self.answer(Answer::Chat(Err(error))), + Ok(Dispatched::Answered(answer)) => self.answer(Ok(answer)), + Err(error) => self.answer(Err(error)), } } @@ -102,11 +104,11 @@ impl ChainOps<'_> { let chain = &*self.chain; let section = chain.section_name().to_owned(); let frame = chain.frame()?; - let vm = frame.vm()?; + let vm = frame.vm(); let binding = if let Some(binding) = binding { binding } else { - let current = resolve_model_binding(chain.ctx.models(), vm.model_runtime())?; + let current = resolve_model_binding(chain.ctx.models(), vm.model_selection())?; current.ok_or_else(|| Error::ModelRequired { section: section.clone(), })? @@ -118,8 +120,8 @@ impl ChainOps<'_> { let rendered = if let Some(rendered) = list.rendered_tools() { rendered } else { - let set = chain.ctx.tool_set(); - let offers = SectionOffers::read(set, vm)?; + let tool_set = chain.ctx.tool_set(); + let offers = SectionOffers::read(tool_set, vm)?; let offered = offered_packs(&offers); if !offered.is_empty() { let effect = Effect::Render { @@ -130,16 +132,14 @@ impl ChainOps<'_> { self.issue(effect, Continuation::Render(binding)); return Ok(Dispatched::Issued); } - let frozen = prepare_effective_scope( + let frozen = freeze_tool_sets( &offers, offered, - set.catalog(), + tool_set.catalog(), emitter.as_ref(), §ion, )?; - list.attach_rendered_tools(frozen); - list.rendered_tools() - .ok_or(Error::internal("a list holds the sets attached to it"))? + list.attach_rendered_tools(frozen) }; let tools = rendered .static_set @@ -167,21 +167,19 @@ impl ChainOps<'_> { let anchor = chain.anchor.measured(binding.id()); if let Err(reason) = precheck(&conversation, context, reserve, anchor) { emitter.report(§ion, lifecycle::MODEL_TURN_FAILED); - return Ok(Dispatched::Answered(Answer::Chat(Ok(Box::new( + return Ok(Dispatched::Answered(Answer::Chat(Box::new( overflow_result(reason, handles.turns.load(Ordering::Relaxed)), - ))))); + )))); } chain .anchor .sending(binding.id().clone(), conversation.clone()); let round = self.number_round(ReplyOrigin::Chat); - let (after, keep) = list.commit(round.id, binding.id().name(), &conversation); + let after = list.commit(round.id, binding.id().name(), &conversation); let effect = Effect::Chat { - options: binding.completion_options(), binding, messages: conversation, after, - keep, tools, round, }; @@ -201,7 +199,7 @@ impl ChainOps<'_> { ) -> Result<()> { match self.merge_render(rendered) { Ok(list) => self.dispatch_chat(&list, Some(binding)), - Err(error) => self.answer(Answer::Chat(Err(error))), + Err(error) => self.answer(Err(error)), } } @@ -217,17 +215,17 @@ impl ChainOps<'_> { fn merge_render(&self, rendered: Vec>) -> Result { let chain = &*self.chain; let frame = chain.frame()?; - let vm = frame.vm()?; - let set = chain.ctx.tool_set(); - let frozen = prepare_effective_scope( - &SectionOffers::read(set, vm)?, + let vm = frame.vm(); + let tool_set = chain.ctx.tool_set(); + let frozen = freeze_tool_sets( + &SectionOffers::read(tool_set, vm)?, rendered, - set.catalog(), + tool_set.catalog(), frame.reporting_handles().emitter.as_ref(), chain.section_name(), )?; let list = vm.messages().clone(); - list.attach_rendered_tools(frozen); + let _ = list.attach_rendered_tools(frozen); Ok(list) } @@ -254,7 +252,7 @@ impl ChainOps<'_> { &mut self, round: RoundId, result: Result>, - ) -> Result> { + ) -> Result>> { let chain = &*self.chain; let round = ArrivedRound { id: round, @@ -266,7 +264,7 @@ impl ChainOps<'_> { Err(error) => { let failure = round.failed(error); self.chain.anchor.settle(None); - return Ok(Answer::Chat(failure)); + return Ok(failure.map(Answer::Chat)); } }; // A round trip that produced a reply is a turn, whether the reply @@ -279,7 +277,7 @@ impl ChainOps<'_> { // The sets are frozen before the round is issued; their // absence is a scheduler fault, never an author-visible // out-of-scope refusal. - let vm = chain.frame()?.vm()?; + let vm = chain.frame()?.vm(); let frozen = vm.rendered_tools().ok_or(Error::internal( "a parked chat round's section holds its frozen sets", ))?; @@ -299,7 +297,7 @@ impl ChainOps<'_> { .as_ref() .and_then(|metrics| metrics.usage.as_ref()); self.chain.anchor.settle(usage); - Ok(Answer::Chat(result.map(Box::new))) + Ok(result.map(|result| Answer::Chat(Box::new(result)))) } } @@ -324,9 +322,7 @@ impl Scope<'_> { } /// One arrived round's reporting context: the round's id, the chain's -/// section label, and its reporting handles, whose turn counter is a task -/// chain's own, so its turn numbers, and `tasks.status`'s `turns`, count -/// only its own rounds. +/// section label, and its task's reporting handles. #[derive(Debug)] struct ArrivedRound { id: RoundId, @@ -421,7 +417,7 @@ impl ArrivedRound { .binding(call.name()) .map(|(binding, _)| binding.id()); let vendor = tool.and_then(|id| scope.tool_set.vendor_for(id)); - ToolCallEvent::new(call.id(), call.name(), tool_call_arguments(call).clone()) + ToolCallEvent::new(call.id(), call.name(), detail::tool_call_arguments(call)) .with_tool(tool.cloned()) .with_vendor(vendor.map(str::to_owned)) }) @@ -443,7 +439,8 @@ impl ArrivedRound { return Err(Error::OutOfScopeToolCall { name: rogue.name().to_owned(), global_exists: scope.global_exists(rogue.name()), - in_scope: scope + has_dynamic_set: !scope.frozen.dynamic_set.is_empty(), + static_aliases: scope .frozen .static_set .iter() diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index f69f0afa8..85a7871a0 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -4,10 +4,15 @@ //! emits nothing for the answer, which `apply_answer` handles when it //! lands. Every store operation is a leaf yield, handed to the caller //! uniformly for all backends - no inline fast path - so interleaving -//! behavior never depends on which backend serves the mount. -//! The `tool_call`, `tool_calls`, `adhoc_tool_done`, `chat`, `spawn`, -//! `timer`, and task wait, inspection, note, concurrency, and cancel arms -//! are defined in their own modules. +//! behavior never depends on which backend serves the mount. A refusal +//! decided before dispatch may still report its failure and answer inline, +//! so the arm returns without the chain ever parking. +//! +//! This file keeps three routing arms: `infer`, `call`, and `store`. Every +//! other group lives in its own module - `chat` the `chat` arm, `tasks` the +//! `spawn` arm, `tool_call` the `tool_call`, `tool_calls`, and +//! `adhoc_tool_done` arms, `timer` the `timer` arm, and `waits` the task +//! wait, inspection, note, concurrency, and cancel arms. use std::sync::Arc; @@ -98,14 +103,9 @@ impl Scheduler { Request::Concurrency { limit } => self.dispatch_concurrency(id, limit), Request::Note { text } => self.dispatch_note(id, text), Request::Cancel { task } => self.dispatch_cancel(id, &task), - Request::ToolCall { - alias, - args, - call_id, - turn, - } => self.with_chain(id, |ops| { - ops.dispatch_tool_call(&alias, args, call_id, turn) - }), + Request::ToolCall { alias, args, model } => { + self.with_chain(id, |ops| ops.dispatch_tool_call(&alias, args, model)) + } Request::ToolCalls { calls, turn } => { self.with_chain(id, |ops| ops.dispatch_tool_calls(calls, turn)) } @@ -135,7 +135,7 @@ impl Scheduler { self.ready.push_back(child); Ok(()) } - Err(error) => self.answer_inline(id, Answer::Call(Err(error))), + Err(error) => self.answer_inline(id, Err(error)), } } @@ -170,13 +170,10 @@ impl Scheduler { // `chain`'s arena borrow ends here; the resolution names the // target's slice by path, so nothing borrows the arena across it. let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target))?; - // The child's id is the caller's next child index: `call` children, - // spawned tasks, and the wait shims' timers share the caller's - // counter, so the id depends only on the caller's own dispatch - // order. - let chain_id = self.chains[id].next_child_id()?; + // The child's id is the caller's next child id. + let lineage = self.chains[id].next_child_id()?; self.start_chain(ChainStart { - lineage: chain_id, + lineage, counters: Counters::default(), ctx: child_ctx, access, @@ -197,7 +194,7 @@ impl ChainOps<'_> { fn dispatch_infer(&mut self, prompt: &str, binding: Option) -> Result<()> { match self.prepare_infer(prompt, binding) { Ok(()) => Ok(()), - Err(error) => self.answer(Answer::Infer(Err(error))), + Err(error) => self.answer(Err(error)), } } @@ -209,7 +206,7 @@ impl ChainOps<'_> { let binding = if let Some(binding) = binding { binding } else { - resolve_model_binding(chain.ctx.models(), chain.frame()?.vm()?.model_runtime())? + resolve_model_binding(chain.ctx.models(), chain.frame()?.vm().model_selection())? .ok_or_else(|| Error::ModelRequired { section: chain.section_name().to_owned(), })? @@ -219,11 +216,9 @@ impl ChainOps<'_> { // consumer. let round = self.number_round(ReplyOrigin::Infer); let effect = Effect::Chat { - options: binding.completion_options(), binding, messages: vec![Message::user(prompt)], after: None, - keep: 0, tools: Vec::new(), round, }; @@ -234,11 +229,8 @@ impl ChainOps<'_> { /// Dispatches a `store` request: derives the store view from the /// chain's access capability and issues the operation through it as a /// `Vfs` effect for the caller to perform, parking the chain in the - /// effect table exactly as a leaf I/O round does. Every store - /// operation takes this yield path uniformly (memory mounts and real - /// files alike, with no inline fast path) so interleaving behavior never - /// depends on which backend serves the mount. The operation's event is - /// pushed when the answer is applied, before the chain resumes. + /// effect table exactly as a leaf I/O round does. The operation's event + /// is pushed when the answer is applied, before the chain resumes. /// /// # Errors /// Returns [`Error::Internal`] when the live chain's access capability diff --git a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs index 19c8471ca..1d14b32ee 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/drive.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/drive.rs @@ -45,8 +45,10 @@ impl Scheduler { // ready, nothing parked, no slot that will ever free, and whatever // is waiting can never be woken) - fail loudly rather than hang. A // running drain returns only after `admit` left the ready queue - // empty, so no queued chain is admissible here and the two checks - // are the whole guard. + // empty, so no queued chain is admissible here. Those two + // conditions are the whole guard; the admission queue needs no + // third, because a chain that can be admitted is a queued chain the + // drain already offered. if matches!(self.phase, Phase::Running) && self.ready.is_empty() && !self.effects.has_awaited() @@ -126,15 +128,14 @@ impl Scheduler { /// with the generic completion. fn start(&mut self) -> Result<()> { let prompt = self.prompt(); - if runvil_parser::detail::h1_blocks(&prompt).is_empty() { - if runvil_parser::detail::sections(&prompt).is_empty() { + if crate::parser::detail::h1_blocks(&prompt).is_empty() { + if crate::parser::detail::sections(&prompt).is_empty() { self.end(Ok(GENERIC_COMPLETION.to_owned())); return Ok(()); } self.start_root_walk()?; } else { - let h1 = self.start_h1()?; - self.ready.push_back(h1); + self.start_h1()?; } Ok(()) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/effects-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/effects-tests.rs index 4ccc73c90..dec1bbd3a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/effects-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/effects-tests.rs @@ -56,7 +56,7 @@ fn issue_tool_call(table: &mut EffectTable, chain: ChainIndex) -> (EffectId, Exe let resume = Continuation::ToolCall(ToolCallContinuation { binding, report: ScriptReport { turn: 0 }, - site: CallSite::Script { turn: None }, + site: CallSite::Script, exec, }); (table.issue(chain, provenance(), timer(), resume), exec) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/effects.rs b/crates/runvil-internal/engine/src/execute/scheduler/effects.rs index 153eeaf16..28c6c369a 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/effects.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/effects.rs @@ -89,14 +89,14 @@ pub(super) struct ToolCallContinuation { /// model-issued call carries a call id, and only a batched one a slot. #[derive(Debug)] pub(super) enum CallSite { - /// A script's `tools.call`, under the turn its request named, if any. - /// The answer resumes the chain directly, classified by the binding's + /// A script's `tools.call`, under the turn counter at dispatch. The + /// answer resumes the chain directly, classified by the binding's /// declared output kind, and reports under no call id. - Script { turn: Option }, + Script, /// A model-issued call sent alone, under the turn of the round that - /// requested it, if the request named one. The answer resumes the - /// chain directly as content under the model's call id. - Model { call_id: String, turn: Option }, + /// requested it. The answer resumes the chain directly as content + /// under the model's call id. + Model { call_id: String, turn: u32 }, /// A model-issued call sent in a `tool_calls` batch, under the round's /// turn. The answer fills `slot` of the batch the chain is parked on. Batch { @@ -110,16 +110,17 @@ impl CallSite { /// The model's call id, or `None` for a script call. pub(super) fn call_id(&self) -> Option<&str> { match self { - Self::Script { .. } => None, + Self::Script => None, Self::Model { call_id, .. } | Self::Batch { call_id, .. } => Some(call_id.as_str()), } } - /// The turn the request named, if any. + /// The turn of the round that requested a model-issued call, or + /// `None` for a script call. pub(super) fn turn(&self) -> Option { match self { - Self::Script { turn } | Self::Model { turn, .. } => *turn, - Self::Batch { turn, .. } => Some(*turn), + Self::Script => None, + Self::Model { turn, .. } | Self::Batch { turn, .. } => Some(*turn), } } } @@ -229,10 +230,13 @@ impl EffectTable { } /// Orphans `id`, whose chain stopped waiting: the caller still owes the - /// answer, which is discarded on arrival. A tool call is also listed - /// as withdrawn, and its chain and identity are returned so the call - /// can be ended. An id with no entry stays unknown, so an answer for it - /// still fails loudly. + /// answer, which is discarded on arrival. Any continuation other than a + /// tool call is orphaned the same way and returns `None`, since only a + /// tool call has an identity to end: a `chat` round, an `infer`, a + /// render, a store operation, and a timer all leave the entry orphaned + /// with nothing to report. A tool call is also listed as withdrawn, and + /// its chain and identity are returned so the call can be ended. An id + /// with no entry stays unknown, so an answer for it still fails loudly. pub(super) fn orphan(&mut self, id: EffectId) -> Option<(ChainIndex, ExecId)> { let entry = self.outstanding.get_mut(&id)?; let Outstanding::Awaited(Pending { @@ -301,7 +305,10 @@ impl EffectTable { (issued, withdrawn) } - /// Drops the withdrawals listed since the last step. + /// Drops the withdrawals listed since the last step. `drive::end` calls + /// this after its teardown: the teardown orphans whatever is still in + /// flight silently, and those orphans are not listed, so a decided run + /// lists none rather than some. pub(super) fn clear_withdrawn(&mut self) { self.withdrawn.clear(); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs index bf236b97c..004fa9dca 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/h1.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/h1.rs @@ -9,53 +9,54 @@ //! The pass and the walk that follows it share the root lineage `0` and //! are two arena records: the hand-off starts the walk from the pass's //! `var` and frozen `argv`, continues the pass's child and entry counters, -//! so the first walked section is `0.1` and a child the pass started keeps -//! its index, and hands the pass's tasks to the walk as their owner. +//! so a child the pass started keeps its index and the first walked section +//! takes the next entry after the pass's entry 0 (see [`Chain::counters`] +//! for the normative statement), and hands the pass's tasks to the walk as +//! their owner. +//! +//! [`Chain::counters`]: super::Chain::counters use std::sync::Arc; -use runvil_types::ids::ChainId; - use crate::heading_address::{SectionIndex, SectionPos}; use crate::{Error, Result}; -use super::chain::{ChainStart, ChainStep}; -use super::chain_record::{CallDepth, ChainKind, Entered}; +use super::chain::ChainStep; +use super::chain_record::{ChainKind, Entered}; use super::{ChainIndex, Counters, GENERIC_COMPLETION, Scheduler, prompt_origin}; impl Scheduler { /// Starts the H1 pass as the driver loop's first chain: the prompt's /// H1 blocks under its title - section 0 - driven through the same - /// coroutine machinery as any section. + /// coroutine machinery as any section. The root start enqueues the + /// chain itself, so this returns no index and no caller pushes it. /// /// # Errors /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`, /// or [`Error::Store`] when the backend refuses acquisition. - pub(super) fn start_h1(&mut self) -> Result { + pub(super) fn start_h1(&mut self) -> Result<()> { // The H1 pass runs under the prompt's title, from its first // compiled H1 chunk. let origin = prompt_origin( self.ctx.prompt(), self.ctx.prompt().title(), - runvil_parser::detail::h1_blocks(self.ctx.prompt()), + crate::parser::detail::h1_blocks(self.ctx.prompt()), ); let access = self.ctx.vfs().acquire(origin).map_err(Error::store)?; self.scope = Some(runvil_vfs::detail::scope_handle(&access)); - // The pass is the root lineage's first record: its one frame takes - // entry 0, and the walk that follows, a second record under the - // same lineage, continues its counters. Its admission limit is the - // run's ceiling. - self.start_chain(ChainStart { - lineage: ChainId::root(), - counters: Counters::default(), - ctx: self.ctx.clone(), - access: Arc::new(access), - start: SectionPos::top(SectionIndex::FIRST), - kind: ChainKind::H1, - var: serde_json::json!({}), - call_depth: CallDepth::ROOT, - concurrency: self.ctx.limits().max_concurrency, - }) + // The pass's one frame takes the root lineage's entry 0, and the + // walk that follows continues its counters (see `Chain::lineage`). + // Its admission limit is the run's ceiling. + self.start_root_chain( + ChainKind::H1, + Counters::default(), + self.ctx.clone(), + Arc::new(access), + SectionPos::top(SectionIndex::FIRST), + serde_json::json!({}), + self.ctx.limits().max_concurrency, + )?; + Ok(()) } /// Ends the H1 pass at its fall-through: the final `var` read back @@ -69,8 +70,10 @@ impl Scheduler { /// # Errors /// Returns [`Error::Lua`] when the final `var` read-back fails or H1 /// left `argv` as non-JSON data, - /// or [`Error::Internal`] when the chain holds no frame. The walk - /// reuses the pass's capability, so the hand-off acquires nothing. + /// or [`Error::Internal`] when the chain holds no frame, when the walk + /// it hands off to cannot start because the run's chain count exceeds + /// `u32`, or when the pass's capability is gone - the walk reuses it, so + /// it acquires nothing and has nothing to fall back on. pub(super) fn end_h1(&mut self, id: ChainIndex, start: SectionPos) -> Result { let chain = &mut self.chains[id]; let Some(Entered { frame, .. }) = chain.entered.take() else { @@ -86,14 +89,14 @@ impl Scheduler { // with the walk, and a task the pass spawned joins the one root // identity. let walk_access = Arc::clone(chain.access()?); - // The walk shares the root lineage with the pass, so it continues - // the pass's counters: the pass took entry 0, the first walked section - // takes entry 1, and a child the pass started keeps its index. Its - // admission limit continues too: the pass may have lowered it with - // `tasks.concurrency`. + // The walk continues the pass's counters (see `Chain::counters` for + // the normative statement): the pass took entry 0, the first walked + // section takes entry 1, and a child the pass started keeps its + // index. Its admission limit continues too: the pass may have + // lowered it with `tasks.concurrency`. let counters = chain.counters; let concurrency = chain.concurrency; - if runvil_parser::detail::sections(self.ctx.prompt()).is_empty() { + if crate::parser::detail::sections(self.ctx.prompt()).is_empty() { // No walk follows, so the pass's end is the run's end: a task // the pass spawned and left live ends here under the same // rules as a finishing chain. @@ -105,22 +108,19 @@ impl Scheduler { // `argv`; H1's prompt-wide records already landed in the shared // sets the views read, and `when` is the run's own. let walk_ctx = self.ctx.with_walk_state(argv); - let root = self.start_chain(ChainStart { - lineage: ChainId::root(), + let root = self.start_root_chain( + ChainKind::Root, counters, - ctx: walk_ctx, - access: walk_access, + walk_ctx, + walk_access, start, - kind: ChainKind::Root, var, - call_depth: CallDepth::ROOT, concurrency, - })?; + )?; // The walk is the pass's continuation, so the tasks the pass // spawned are the walk's from here: it waits on, cancels, or leaks // them exactly as if it had spawned them. self.reassign_tasks(id, root); - self.ready.push_back(root); Ok(ChainStep::Continue) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/step.rs b/crates/runvil-internal/engine/src/execute/scheduler/step.rs index 7ef3c0f1d..b0809adc6 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/step.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/step.rs @@ -1,14 +1,13 @@ //! One chain's step to its next suspension point: resume a suspended -//! coroutine with its delivered answer, or advance the walk - entering the -//! next section, starting the next Lua block's coroutine, stashing one -//! prose block as the pending Markdown buffer, or falling through at a -//! section's end - then apply the block coroutine's outcome: park a -//! yielded chain on its request's dispatch, advance or finish a completed -//! block, and report the chunk's closing observation boundary. +//! coroutine with its delivered answer, or advance the walk, then apply the +//! block coroutine's outcome: park a yielded chain on its request's +//! dispatch, advance or finish a completed block, and report the chunk's +//! closing observation boundary. use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; +use crate::execute::requirements::NOTICE_HEADER; use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::{Answer, CoroStep, LuaBlockResult, Request, YieldParse}; use crate::parser::{Block, BodyText}; @@ -18,10 +17,6 @@ use super::chain::ChainStep; use super::chain_record::{Entered, Running}; use super::{ChainIndex, ChainOps, Scheduler}; -/// The notice a failed H1 gate ends the run with; the chunk's own failure -/// follows it as the cause. -const H1_GATE_FAILED: &str = "the environment cannot satisfy this prompt:\n- the H1 gate failed"; - /// What a Lua block's coroutine step leaves the chain to do, settled under /// the borrow of its entered section so the action can touch the /// scheduler's other fields. @@ -30,7 +25,7 @@ enum Settled { /// The block suspended on a request for its arm to dispatch. Dispatch(Request), /// The block suspended on a call answered on the spot. - Answer(Answer), + Answer(Result>), /// The block jumped to the heading. Jump(String), /// The block returned a scalar. @@ -66,7 +61,7 @@ fn settle_block( // keep their own classification either way. return Err(match error { error @ Error::Lua { .. } if is_h1 => Error::RequirementsUnmet { - notice: H1_GATE_FAILED.to_owned(), + notice: format!("{NOTICE_HEADER}\n- the H1 gate failed"), cause: Some(Box::new(error)), }, other => other, @@ -75,12 +70,9 @@ fn settle_block( }; match step { CoroStep::Yielded(coroutine, values) => { - let settled = match entered.frame.vm()?.request_from_yield(&values) { + let settled = match entered.frame.vm().request_from_yield(&values) { YieldParse::Request(request) => Settled::Dispatch(request), - // An argument-validation failure is the call's answer: - // the shim raises it at the call site, so an author - // `pcall` catches it. - YieldParse::Call(answer) => Settled::Answer(answer.map_error(Error::from)), + YieldParse::Refused(error) => Settled::Answer(Err(Error::from(error))), YieldParse::Malformed(error) => { emitter.report(name, lifecycle::LUA_CHUNK_FAILED); return Err(Error::from(error)); @@ -120,11 +112,9 @@ fn settle_block( impl Scheduler { /// Runs one ready chain to its next suspension point: resume a - /// suspended coroutine with its delivered answer, or advance the walk - - /// entering the next section, starting the next Lua block's coroutine, - /// stashing one prose block as the pending Markdown buffer, or falling - /// through at a section's end. The step never awaits: every dispatch - /// either issues its leaf effect or answers on the spot. + /// suspended coroutine with its delivered answer, or advance the walk. + /// The step never awaits: every dispatch either issues its leaf effect + /// or answers on the spot. /// /// The driver owns the chunk observation boundaries: STARTED at a /// block's start, SUCCEEDED or FAILED when its coroutine finally @@ -158,7 +148,7 @@ impl Scheduler { }; entered .frame - .vm()? + .vm() .resume_block_coro_answer(program, &coroutine, answer) } else { match entered.block_in(blocks) { @@ -174,7 +164,7 @@ impl Scheduler { } entered .frame - .vm()? + .vm() .start_block_coro(program) .map_err(Error::from) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs index 815598a96..0028364b2 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/task_end.rs @@ -9,8 +9,7 @@ //! leaked ids. Tasks survive a section's fall-through and a `jump` - those //! move the walk within one chain - and end with the chain itself: the root //! walk, a `call` child, or another task (a fanout arm among them). The H1 -//! pass and the walk after it share the root lineage `0` and are two arena -//! records, so the hand-off reassigns the pass's tasks to the walk. +//! hand-off reassigns the pass's tasks to the walk (see the `h1` module). use std::sync::Arc; @@ -130,7 +129,7 @@ impl Scheduler { reason: AbandonReason, ) -> Vec { let mut leaked = Vec::new(); - for task in self.tasks.live_with_timers(owner) { + for task in self.tasks.live_all(owner) { let Some((backing, target)) = self.tasks.stop(&task, Stop::Abandoned) else { continue; }; @@ -158,8 +157,9 @@ impl Scheduler { /// aborted and reports nothing; a chain aborts with everything it owns, /// then `terminal` fires under `target`. The backing ends first, so /// anything it owned reports before the task's own terminal event, - /// which is the last word on it. Returns whether the backing was a - /// chain. + /// which is the last word on it. A task still queued for a slot is + /// silent: it has no start event, so it reports no terminal, and the + /// abort is all that happens. Returns whether the backing was a chain. pub(super) fn end_backing( &mut self, backing: TaskBacking, @@ -203,11 +203,9 @@ impl Scheduler { } } - /// Moves every task `from` owns to `to`: the H1 hand-off, where the - /// pass and the walk share the root lineage `0` and are two arena - /// records, so a task the pass spawned is waited on, inspected, - /// cancelled, or leaked by the walk exactly as if the walk had spawned - /// it. The pass's admission + /// Moves every task `from` owns to `to`: the H1 hand-off, so a task the + /// pass spawned is waited on, inspected, cancelled, or leaked by the + /// walk exactly as if the walk had spawned it. The pass's admission /// accounting moves with the owner links: the slots the pass's tasks /// hold count against the walk from here, under the limit the walk /// inherited from the pass. diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs index e9d46412a..a87df5123 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs @@ -47,9 +47,9 @@ impl Scheduler { // spawner; the drain admits it when a slot frees up at // its owner and every ancestor. self.admission.enqueue_spawn(child, record); - self.answer_inline(id, Answer::Spawn(Ok(task))) + self.answer_inline(id, Ok(Answer::Spawn(task))) } - Err(error) => self.answer_inline(id, Answer::Spawn(Err(error))), + Err(error) => self.answer_inline(id, Err(error)), } } @@ -86,9 +86,8 @@ impl Scheduler { let spawner_section = chain.section_name().to_owned(); // An explicit input forks the chain's args (and `argv` re-derives // from them), as a `call` with input does; otherwise the chain - // inherits the spawner's context whole. The turn counter is the - // task's own, so its turn numbers, and `tasks.status`'s `turns`, - // count only its own rounds. + // inherits the spawner's context whole, but for the turn counter, + // which `with_task` replaces below. let child_ctx = match input { Some(input) => chain.ctx.with_args(input), None => chain.ctx.clone(), @@ -124,18 +123,16 @@ impl Scheduler { // moves into the chain: every delivery joins it, and the chain // end joins it last. let exec = detail::access_id(&access); - // The task's id is the spawner's next child index, shared with - // `call` children and the wait shims' timers, so it depends only on - // the spawner's own dispatch order; the task is its chain, named - // from the other side. - let chain_id = self.chains[id].next_child_id()?; - let task = TaskId::from(chain_id.clone()); + // The task's id is the spawner's next child id; the task is its + // chain, named from the other side. + let lineage = self.chains[id].next_child_id()?; + let task = TaskId::from(lineage.clone()); // The task's context reports under its own task id with its own // turn counter, so its events report its provenance and its turn // numbers, and `tasks.status`'s `turns`, count only its own rounds. let child_ctx = child_ctx.with_task(task.clone(), Arc::new(AtomicU32::new(0))); let child = self.start_chain(ChainStart { - lineage: chain_id, + lineage, counters: Counters::default(), ctx: child_ctx, access: Arc::new(access), diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs index e9897bde3..8bc821518 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs @@ -1,8 +1,8 @@ //! Tests for the task arena alone, one rule each: a stop ends only a live //! task, a completed task's outcome is delivered once, entries are never //! removed, `owned` admits only the owner, `visible` adds the chain -//! running inside the task, and `live_of` lists an owner's live tasks in -//! spawn order without internal timers. +//! running inside the task, and `live_author_tasks` lists an owner's live +//! tasks in spawn order without internal timers. use runvil_types::ids::{ChainId, TaskId}; use runvil_vfs::{Origin, VfsRef}; @@ -106,6 +106,24 @@ fn a_completed_task_is_done_and_its_outcome_is_taken_once() { ); } +#[test] +fn a_refused_delivery_leaves_the_entry_unchanged() { + let mut arena = TaskArena::default(); + let (cancelled, _) = spawn(&mut arena, chain(0), 0); + let (abandoned, _) = spawn(&mut arena, chain(0), 1); + let (running, _) = spawn(&mut arena, chain(0), 2); + assert!(arena.stop(&cancelled, Stop::Cancelled).is_some()); + assert!(arena.stop(&abandoned, Stop::Abandoned).is_some()); + for (task, state) in [ + (&cancelled, TaskState::Cancelled), + (&abandoned, TaskState::Abandoned), + (&running, TaskState::Running), + ] { + assert!(arena.take_delivery(task).is_err(), "{task}"); + assert_eq!(arena.state_of(task), Some(state), "{task}"); + } +} + #[test] fn completing_a_task_that_is_not_live_changes_nothing() { let mut arena = TaskArena::default(); @@ -179,7 +197,7 @@ fn visible_also_admits_a_caller_running_inside_the_task() { } #[test] -fn live_of_lists_an_owners_live_tasks_in_spawn_order_without_timers() { +fn live_author_tasks_lists_an_owners_live_tasks_in_spawn_order_without_timers() { let mut arena = TaskArena::default(); let (tenth, _) = spawn(&mut arena, chain(0), 10); let (second, _) = spawn(&mut arena, chain(0), 2); @@ -188,5 +206,5 @@ fn live_of_lists_an_owners_live_tasks_in_spawn_order_without_timers() { spawn(&mut arena, chain(5), 4); let (first, _) = spawn(&mut arena, chain(0), 0); assert!(arena.stop(&ended, Stop::Cancelled).is_some()); - assert_eq!(arena.live_of(chain(0)), [first, second, tenth]); + assert_eq!(arena.live_author_tasks(chain(0)), [first, second, tenth]); } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs index 6a6855505..46efd468e 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tasks.rs @@ -15,7 +15,8 @@ //! moves only from `Running` to `Done`, `Cancelled`, or `Abandoned`, and //! from `Done` to `Delivered`. It checks ownership, and a chain's view of //! the task it runs inside, and it lists an owner's tasks in spawn order -//! without the internal timers. +//! two ways: the author-visible tasks, and every live task with the +//! internal timers included. #[path = "tasks-spawn.rs"] mod spawn; @@ -96,8 +97,8 @@ pub(crate) enum TaskState { /// `ok` is whether it returned rather than failed, always `true` for a /// fired timer. Done { ok: bool }, - /// The owner took the outcome through a wait. `ok` is the outcome's, - /// kept so `status` still reports it after the wait took it. + /// The owner took the outcome through a wait. Whether the task ended + /// well, carried so `status` can report it after the outcome is gone. Delivered { ok: bool }, /// The owner cancelled the task. Cancelled, @@ -105,26 +106,13 @@ pub(crate) enum TaskState { Abandoned, } -impl TaskState { - /// Whether the task ended well, once it has ended: a cancelled or - /// abandoned task did not. - pub(super) fn ok(self) -> Option { - match self { - TaskState::Running => None, - TaskState::Done { ok } | TaskState::Delivered { ok } => Some(ok), - TaskState::Cancelled | TaskState::Abandoned => Some(false), - } - } -} - /// One task's entry in the arena. #[derive(Debug)] pub(super) struct TaskEntry { /// Where the task's work runs. pub(super) backing: TaskBacking, /// The chain that owns the task: its spawner, or the root walk for a - /// task the H1 pass spawned, since the pass and the walk share the root - /// lineage `0` as two arena records. The only chain allowed to wait on, + /// task the H1 pass spawned. The only chain allowed to wait on, /// inspect, or cancel the task, and the chain whose end ends it. /// Recorded only here: admission reads it as the task chain's /// enclosing chain. @@ -214,9 +202,9 @@ impl TaskArena { } } - /// The live tasks `owner` owns, in spawn order; an internal timer is - /// never listed. - pub(super) fn live_of(&self, owner: ChainIndex) -> Vec { + /// The live tasks `owner` owns that an author can see, in spawn order; + /// an internal timer is never listed. + pub(super) fn live_author_tasks(&self, owner: ChainIndex) -> Vec { self.live_entries(owner) .filter(|(_, entry)| !entry.is_internal()) .map(|(task, _)| task.clone()) @@ -225,7 +213,7 @@ impl TaskArena { /// Every live task `owner` owns, internal timers included, in spawn /// order: the tasks the owner's end ends. - pub(super) fn live_with_timers(&self, owner: ChainIndex) -> Vec { + pub(super) fn live_all(&self, owner: ChainIndex) -> Vec { self.live_entries(owner) .map(|(task, _)| task.clone()) .collect() @@ -289,26 +277,21 @@ impl TaskArena { let Some(entry) = self.entries.get_mut(task) else { return Err(Error::internal("a delivered task has an entry")); }; - let ok = matches!(entry.state, EntryState::Done(Ok(_))); - // Every state but `Done` goes back as it was. - match std::mem::replace(&mut entry.state, EntryState::Delivered { ok }) { - EntryState::Done(outcome) => outcome, - EntryState::Cancelled => { - entry.state = EntryState::Cancelled; - Err(Error::TaskCancelled { task: task.clone() }) - } - EntryState::Abandoned => { - entry.state = EntryState::Abandoned; - Err(Error::internal( - "an abandoned task's owner ended, so no wait can deliver it", - )) - } - state @ (EntryState::Running | EntryState::Delivered { .. }) => { - entry.state = state; - Err(Error::internal( - "only a terminal, undelivered entry is delivered", - )) + match &mut entry.state { + EntryState::Done(outcome) => { + let outcome = std::mem::replace(outcome, Ok(String::new())); + entry.state = EntryState::Delivered { + ok: outcome.is_ok(), + }; + outcome } + EntryState::Cancelled => Err(Error::TaskCancelled { task: task.clone() }), + EntryState::Abandoned => Err(Error::internal( + "an abandoned task's owner ended, so no wait can deliver it", + )), + EntryState::Running | EntryState::Delivered { .. } => Err(Error::internal( + "only a terminal, undelivered entry is delivered", + )), } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs index a61901c09..b80c70e97 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs @@ -92,8 +92,8 @@ impl Scheduler { self.tasks.state_of(task) } - /// The run's tool set: the Packs the prompt declares, the roster, the - /// run catalog, and each Pack's vendor. + /// The run's tool set: the Packs the prompt declares, the roster, and + /// the run catalog. pub(crate) fn tool_set_for_test(&self) -> &ToolSet { self.ctx.tool_set() } @@ -126,11 +126,33 @@ impl Scheduler { /// As [`name_before_first_entry_for_test`](Self::name_before_first_entry_for_test). pub(crate) fn answer_unsuspended_for_test(&mut self) -> Result<[Result<()>; 2]> { let id = self.start_probe_chain(0)?; - let inline = self.answer_inline(id, Answer::Note(Ok(()))); - let woken = self.wake_from_wait(id, Answer::Note(Ok(()))); + let inline = self.answer_inline(id, Ok(Answer::Note)); + let woken = self.wake_from_wait(id, Ok(Answer::Note)); Ok([inline, woken]) } + /// Delivers `Note` and then `Concurrency(1)` to the chain at arena + /// index 0 with no resume between them, returning what each delivery + /// reported and whether the suspended block still holds the `Note`. + pub(crate) fn deliver_twice_for_test(&mut self) -> ([Result<()>; 2], bool) { + let (id, _) = self + .chains + .iter() + .next() + .expect("the run has started a chain"); + let first = self.chains[id].deliver(Ok(Answer::Note)); + let second = self.chains[id].deliver(Ok(Answer::Concurrency(1))); + let holds_first = matches!( + self.chains[id] + .entered + .as_ref() + .and_then(|entered| entered.running.as_ref()) + .and_then(|running| running.answer.as_ref()), + Some(Ok(Answer::Note)) + ); + ([first, second], holds_first) + } + /// Starts a root chain over the top-level slice at `index` under a /// fresh access, entering no section. fn start_probe_chain(&mut self, index: usize) -> Result { diff --git a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs index 3b68ecda2..64f2fdf03 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/timer.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/timer.rs @@ -38,7 +38,7 @@ impl Scheduler { /// effect, and resumes the caller at once with the id. The wait shim /// raises a dispatch failure before any wait. pub(super) fn dispatch_timer(&mut self, id: ChainIndex, duration: Duration) -> Result<()> { - let answer = Answer::Timer(self.prepare_timer(id, duration)); + let answer = self.prepare_timer(id, duration).map(Answer::Timer); self.answer_inline(id, answer) } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs index a138d68e8..9d7ed56a5 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs @@ -1,6 +1,17 @@ //! The adhoc arm of the `tool_call` arm: a call routed to an adhoc tool, //! answered with its handler, and the `adhoc_tool_done` request that //! closes it. +//! +//! An adhoc tool call is a two-yield handshake. The `tool_call` answer is +//! [`ToolCallOutcome::Adhoc`], which hands the shim the handler, and the +//! frame opens an [`AdhocCall`] for it. The shim runs the handler inside the +//! chain's block coroutine, so the handler's own suspending calls are +//! ordinary requests of this chain, then yields `adhoc_tool_done`. That arm +//! closes the innermost open call and reports it: the succeeded or failed +//! observation and, on success, the `ToolResult` under the tool's id and the +//! call id recorded at dispatch, trusted unless the handler fetched a +//! listing while the call was open. An adhoc call never issues leaf work of +//! its own. use std::sync::Arc; @@ -32,7 +43,6 @@ impl ChainOps<'_> { // The attempt counts at dispatch, before the handler runs, so a // handler that then fails still counts. let counted = binding.id().to_string(); - counts.ensure(&counted)?; counts.increment(&counted)?; self.chain.frame_mut()?.push_adhoc_call(AdhocCall { tool: binding.id().clone(), @@ -41,12 +51,12 @@ impl ChainOps<'_> { report, listing: false, }); - Ok(Dispatched::Answered(Answer::ToolCallResult(Ok( + Ok(Dispatched::Answered(Answer::ToolCallResult( ToolCallOutcome::Adhoc { alias: binding.alias().to_owned(), args, }, - )))) + ))) } /// Dispatches an `adhoc_tool_done` request: closes the chain's @@ -103,6 +113,6 @@ impl ChainOps<'_> { Ok(ToolCallOutcome::Plain(String::new())) } }; - self.answer(Answer::ToolCallResult(answer)) + self.answer(answer.map(Answer::ToolCallResult)) } } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs index 0c6f6b424..94534ecf8 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs @@ -1,6 +1,7 @@ //! The discovery arm of the `tool_call` arm: a model call that names a //! discovery tool, or a Dynamic Set tool by its own wire name, decided in -//! full before any preparer runs. +//! full before any preparer runs - the freeze-before-preparer guarantee +//! this module rests on. //! //! `GetDynamicTools` answers inline from the section's frozen Dynamic Set: //! one tool's description and input schema for a `toolName`, which wins @@ -28,15 +29,13 @@ use runvil_types::tools::OutputTrust; use serde::Serialize; use serde_json::{Map, Value}; +use crate::execute::scope::PATTERN_LIMIT; use crate::lua::{Answer, DiscoveryTool, OfferMode, RenderedTools, ToolBinding, ToolCallOutcome}; use crate::{Error, Result}; use super::super::dispatch::Dispatched; use super::super::{CallSite, ChainOps}; -/// The longest `pattern` a search compiles, in characters. -const PATTERN_LIMIT: usize = 256; - /// The longest description a search result keeps whole, in characters. const DESCRIPTION_LIMIT: usize = 200; @@ -57,6 +56,8 @@ enum Discovered { struct Listed<'a> { tool: &'a str, description: Cow<'a, str>, + /// The tool's input schema, sent only by the `toolName` branch: a + /// search result leaves it out, so a listing stays small. #[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")] input_schema: Option<&'a Value>, } @@ -84,7 +85,7 @@ impl ChainOps<'_> { let frozen = self .chain .frame()? - .vm()? + .vm() .rendered_tools() .ok_or(Error::internal( "a model call's section holds its frozen sets", @@ -93,8 +94,9 @@ impl ChainOps<'_> { Some(DiscoveryTool::Search) => search(&frozen, &args)?, Some(DiscoveryTool::Call) => reach(&frozen, args), None => Discovered::Refused(format!( - "{name:?} is a dynamic tool; call it through CallDynamicTool with toolName {name:?}", - name = binding.alias() + "{name:?} is a dynamic tool; call it through {call} with toolName {name:?}", + name = binding.alias(), + call = DiscoveryTool::Call.wire_name(), )), }; let (text, trust, boundary) = match discovered { @@ -112,7 +114,6 @@ impl ChainOps<'_> { }; let counted = tool.unwrap_or(DiscoveryTool::Call).id(); let counts = self.section_call_counts()?; - counts.ensure(counted)?; counts.increment(counted)?; let turn = self.call_turn(&site); let chain = &*self.chain; @@ -133,9 +134,9 @@ impl ChainOps<'_> { &text, trust, ); - Ok(Dispatched::Answered(Answer::ToolCallResult(Ok( + Ok(Dispatched::Answered(Answer::ToolCallResult( ToolCallOutcome::Plain(text), - )))) + ))) } /// Hands a `CallDynamicTool` call's inner `args`, under the call's own @@ -150,7 +151,7 @@ impl ChainOps<'_> { let adhoc = self .chain .frame()? - .vm()? + .vm() .adhoc_tools() .binding(&target.id().to_string())?; if let Some(adhoc) = adhoc { @@ -217,7 +218,8 @@ fn reach(frozen: &RenderedTools, mut args: Value) -> Discovered { let target = match text_arg(tool, &args, "toolName") { Ok(Some(name)) => dynamic_target(tool, frozen, name).cloned(), Ok(None) => Err(format!( - "{tool}: toolName is required; pass a name from GetDynamicTools" + "{tool}: toolName is required; pass a name from {search}", + search = DiscoveryTool::Search.wire_name(), )), Err(refusal) => Err(refusal), }; @@ -265,8 +267,9 @@ fn dynamic_target<'a>( "{tool}: {name:?} is in your tool list, not a dynamic tool; call it directly" )), None => Err(format!( - "{tool}: no dynamic tool is named {name:?}; call GetDynamicTools with a pattern or \ - no arguments to find the names" + "{tool}: no dynamic tool is named {name:?}; call {search} with a pattern or \ + no arguments to find the names", + search = DiscoveryTool::Search.wire_name(), )), } } @@ -275,10 +278,11 @@ fn dynamic_target<'a>( /// [`PATTERN_LIMIT`] characters or that is no regex, naming the parser's /// reason, the last line of its report. fn compiled(pattern: &str) -> std::result::Result { + let tool = DiscoveryTool::Search.wire_name(); let length = pattern.chars().count(); if length > PATTERN_LIMIT { return Err(format!( - "GetDynamicTools: pattern is {length} characters, over the limit of \ + "{tool}: pattern is {length} characters, over the limit of \ {PATTERN_LIMIT}; shorten it" )); } @@ -287,7 +291,7 @@ fn compiled(pattern: &str) -> std::result::Result { let reason = report.lines().next_back().unwrap_or_default().trim(); let reason = reason.strip_prefix("error: ").unwrap_or(reason); format!( - "GetDynamicTools: pattern {pattern:?} is not a valid regex: {reason}; fix it, or \ + "{tool}: pattern {pattern:?} is not a valid regex: {reason}; fix it, or \ omit it to list every tool" ) }) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs index e5459002c..ba9d5aa67 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs @@ -32,17 +32,8 @@ //! an ordinary `tool_call`. The calls in flight run concurrently, each //! under its own forked identity. //! -//! An adhoc tool call is a two-yield handshake. The `tool_call` answer is -//! [`ToolCallOutcome::Adhoc`], which hands the shim the handler, and the -//! frame opens an [`AdhocCall`](crate::execute::section_context::AdhocCall) -//! for it. The shim runs the handler inside the -//! chain's block coroutine, so the handler's own suspending calls are -//! ordinary requests of this chain, then yields `adhoc_tool_done`. That -//! arm closes the innermost open call and reports it: the succeeded or -//! failed observation and, on success, the `ToolResult` under the tool's -//! id and the call id recorded at dispatch, trusted unless the handler -//! fetched a listing while the call was open. An adhoc call never issues -//! leaf work of its own. The adhoc arm sits in the `adhoc` child. +//! The adhoc arm and its two-yield handshake are documented in the `adhoc` +//! child. #[path = "tool_call-adhoc.rs"] mod adhoc; @@ -57,8 +48,8 @@ use runvil_vfs::detail; use crate::execute::run::{Effect, ToolCallOrigin, ToolCaller}; use crate::lua::{ - Answer, DiscoveryTool, OfferMode, ScriptReport, ToolBinding, ToolCallCounts, ToolCallOutcome, - ToolCallRecord, ToolSet, current_dynamic_set, current_tool_bindings, + Answer, DiscoveryTool, ModelCall, OfferMode, ScriptReport, ToolBinding, ToolCallCounts, + ToolCallOutcome, ToolCallRecord, ToolSet, current_dynamic_set, current_static_set, }; use crate::{Error, Result}; @@ -107,29 +98,23 @@ impl ChainOps<'_> { &mut self, alias: &str, args: serde_json::Value, - call_id: Option, - turn: Option, + model: Option, ) -> Result<()> { - let site = match call_id { - Some(call_id) => CallSite::Model { call_id, turn }, - None => CallSite::Script { turn }, - }; + let site = model.map_or(CallSite::Script, |ModelCall { call_id, turn }| { + CallSite::Model { call_id, turn } + }); match self.prepare_tool_call(alias, args, site) { // An issued call is parked on the effect table. Ok(Dispatched::Issued) => Ok(()), - Ok(Dispatched::Answered(answer)) => self.answer(answer), - Err(error) => self.answer(Answer::ToolCallResult(Err(error))), + Ok(Dispatched::Answered(answer)) => self.answer(Ok(answer)), + Err(error) => self.answer(Err(error)), } } - /// Dispatches a `tool_calls` request: the unanswered calls of one - /// model round, in the model's order. Each call before the first - /// whose route is not the catalog is issued as a `CallSite::Batch` - /// under the round's `turn` and its own slot, so every catalog call - /// issues in this step, and the loop sends the call it stopped at - /// alone. A call that fails to route or to prepare - /// fills its slot with the error and stops the batch. The slots park - /// on the chain, which is answered at once when none is open. + /// Dispatches a `tool_calls` request, the unanswered calls of one + /// model round, under the batch rule in the module doc: each catalog + /// call is issued as a `CallSite::Batch` under the round's `turn` and + /// its own slot, and the slots park on the chain. pub(super) fn dispatch_tool_calls( &mut self, calls: Vec, @@ -175,7 +160,7 @@ impl ChainOps<'_> { /// tool. fn tool_route(&self, name: &str, site: &CallSite) -> Result { let chain = &*self.chain; - let vm = chain.frame()?.vm()?; + let vm = chain.frame()?.vm(); let tool_set = chain.ctx.tool_set(); if site.call_id().is_some() { let (binding, set) = vm @@ -200,7 +185,7 @@ impl ChainOps<'_> { } /// The turn every report of a call at `site` carries: the requesting - /// round's turn when the site names one, else the chain's turn + /// round's turn for a model-issued call, else the chain's turn /// counter at dispatch. fn call_turn(&self, site: &CallSite) -> u32 { site.turn() @@ -214,7 +199,7 @@ impl ChainOps<'_> { /// they are rendered, and from its live offers of both kinds before. fn section_call_counts(&mut self) -> Result { let chain = &mut *self.chain; - let vm = chain.frame()?.vm()?; + let vm = chain.frame()?.vm(); let tool_set = chain.ctx.tool_set(); let ids = if let Some(frozen) = vm.rendered_ids() { let mut ids = Vec::with_capacity(frozen.len()); @@ -228,8 +213,8 @@ impl ChainOps<'_> { } ids } else { - let (runtime, adhoc) = (vm.tool_runtime(), vm.adhoc_tools()); - let static_tools = current_tool_bindings(tool_set, runtime, adhoc)?; + let (runtime, adhoc) = (vm.offers(), vm.adhoc_tools()); + let static_tools = current_static_set(tool_set, runtime, adhoc)?; let dynamic_tools = current_dynamic_set(tool_set, runtime, adhoc, &static_tools)?; static_tools .iter() @@ -251,27 +236,33 @@ impl ChainOps<'_> { /// Returns [`Error::Internal`] when the chain holds no such slot, /// which only an answer the scheduler should have orphaned produces, /// or when the full batch finds no suspended block to answer. - pub(super) fn fill_batch_slot(&mut self, slot: usize, answer: Answer) -> Result<()> { + pub(super) fn fill_batch_slot( + &mut self, + slot: usize, + answer: Result>, + ) -> Result<()> { let landed = match answer { - Answer::ToolCallResult(Ok(ToolCallOutcome::Plain(text))) => Ok(text), - Answer::ToolCallResult(Err(error)) => Err(error), - Answer::ToolCallResult(Ok( - ToolCallOutcome::Structured(_) | ToolCallOutcome::Adhoc { .. }, - )) - | Answer::Infer(_) - | Answer::Call(_) - | Answer::Spawn(_) - | Answer::Timer(_) - | Answer::JoinAny(_) - | Answer::Ready(_) - | Answer::Status(_) - | Answer::Pending(_) - | Answer::Concurrency(_) - | Answer::Note(_) - | Answer::Cancel(_) - | Answer::Chat(_) - | Answer::ToolCallsResult(_) - | Answer::Store(_) => Err(Error::internal( + Ok(Answer::ToolCallResult(ToolCallOutcome::Plain(text))) => Ok(text), + Err(error) => Err(error), + Ok( + Answer::ToolCallResult( + ToolCallOutcome::Structured(_) | ToolCallOutcome::Adhoc { .. }, + ) + | Answer::Infer(_) + | Answer::Call(_) + | Answer::Spawn(_) + | Answer::Timer(_) + | Answer::JoinAny(_) + | Answer::Ready(_) + | Answer::Status(_) + | Answer::Pending(_) + | Answer::Concurrency(_) + | Answer::Note + | Answer::Cancel + | Answer::Chat(_) + | Answer::ToolCallsResult(_) + | Answer::Store(_), + ) => Err(Error::internal( "a batched call lands as plain text or a failure", )), }; @@ -299,7 +290,7 @@ impl ChainOps<'_> { .into_iter() .flatten() .collect::>>(); - self.answer(Answer::ToolCallsResult(answer)) + self.answer(answer.map(Answer::ToolCallsResult)) } /// The fallible half of tool-call dispatch, by the call's route: an @@ -310,9 +301,8 @@ impl ChainOps<'_> { /// /// Every report the call makes carries the turn of the model round /// that requested it, so an adhoc handler earlier in the same batch - /// that runs model rounds cannot shift it. A script call, or a - /// model-issued one yielded without a turn, reports the counter's - /// value at dispatch. + /// that runs model rounds cannot shift it. A script call reports the + /// counter's value at dispatch. fn prepare_tool_call( &mut self, alias: &str, @@ -348,12 +338,9 @@ impl ChainOps<'_> { turn: self.call_turn(&site), }; let counts = self.section_call_counts()?; - // The counts seed from the section's two sets; a tool outside them - // must still be seeded here, because the increment errors on - // an unseeded id. The attempt counts at dispatch - before the tool - // runs, so a cancelled dispatch still counts. + // The attempt counts at dispatch - before the tool runs, so a + // cancelled dispatch still counts. let counted = binding.id().to_string(); - counts.ensure(&counted)?; counts.increment(&counted)?; let prompt = self.prompt(); let chain = &*self.chain; @@ -362,9 +349,9 @@ impl ChainOps<'_> { // one loop at a time, so no other round commits to the list // between a round's answer and the issue of its calls. let (caller, model) = match &site { - CallSite::Script { .. } => (ToolCaller::Script, None), + CallSite::Script => (ToolCaller::Script, None), CallSite::Model { .. } | CallSite::Batch { .. } => { - (ToolCaller::Model, frame.vm()?.messages().last_model()) + (ToolCaller::Model, frame.vm().messages().last_model()) } }; let mut origin = ToolCallOrigin::new(chain.ctx.execution(), chain.section_name(), caller) diff --git a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs index 9e8212ba0..46e994765 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/waits.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/waits.rs @@ -14,8 +14,7 @@ //! the owner may wait on it, so no wait can reach the entry. //! //! Ownership is the rule for every arm: only the chain that owns a task - -//! its spawner, or the root walk for a task the H1 pass spawned, since the -//! pass and the walk share the root lineage `0` as two arena records - may +//! its spawner, or the root walk for a task the H1 pass spawned - may //! wait on, check, list, or cancel it, and an id naming no task //! is refused the same way so a caller learns nothing about tasks it never //! started. `status` and `note` add the self exception: a chain may read @@ -48,7 +47,7 @@ use super::{ChainIndex, Scheduler}; fn task_phase(state: TaskState) -> TaskPhase { match state { TaskState::Running => TaskPhase::Running, - TaskState::Done { .. } | TaskState::Delivered { .. } => TaskPhase::Done, + TaskState::Done { ok } | TaskState::Delivered { ok } => TaskPhase::Done { ok }, TaskState::Cancelled => TaskPhase::Cancelled, TaskState::Abandoned => TaskPhase::Abandoned, } @@ -69,7 +68,7 @@ impl Scheduler { match self.tasks.first_terminal(id, &tasks) { Ok(Some(task)) => { let delivery = self.deliver(&task); - self.answer_inline(id, Answer::JoinAny(Ok(delivery))) + self.answer_inline(id, Ok(Answer::JoinAny(delivery))) } Ok(None) => { // Only a chain-backed task has an identity; a timer has none. @@ -82,7 +81,7 @@ impl Scheduler { self.admission.park(id, &tree, waits_on_tasks); Ok(()) } - Err(error) => self.answer_inline(id, Answer::JoinAny(Err(error))), + Err(error) => self.answer_inline(id, Err(error)), } } @@ -109,7 +108,7 @@ impl Scheduler { return Ok(()); } let delivery = self.deliver(task); - self.wake_from_wait(owner, Answer::JoinAny(Ok(delivery))) + self.wake_from_wait(owner, Ok(Answer::JoinAny(delivery))) } /// Dispatches a `ready` request: whether a task the chain owns has @@ -119,14 +118,14 @@ impl Scheduler { .tasks .owned(id, task) .map(|entry| !entry.state.is_live()); - self.answer_inline(id, Answer::Ready(answer)) + self.answer_inline(id, answer.map(Answer::Ready)) } /// Dispatches a `status` request over a task the chain owns or runs /// inside. pub(super) fn dispatch_status(&mut self, id: ChainIndex, task: &TaskId) -> Result<()> { let answer = self.task_status(id, task).map(Box::new); - self.answer_inline(id, Answer::Status(answer)) + self.answer_inline(id, answer.map(Answer::Status)) } /// Reads one task's status: the entry's facts and note, plus the backing @@ -135,11 +134,9 @@ impl Scheduler { /// keeps after the chain ends. fn task_status(&self, id: ChainIndex, task: &TaskId) -> Result { let entry = self.tasks.visible(id, &self.chains[id].task, task)?; - let state = entry.state.view(); let mut status = TaskStatus { target: entry.target.clone(), - state: task_phase(state), - ok: state.ok(), + state: task_phase(entry.state.view()), section: None, blocked: None, turns: 0, @@ -157,7 +154,7 @@ impl Scheduler { .is_some() .then(|| chain.section_name().to_owned()); status.blocked = chain.blocked(); - status.tasks = self.tasks.live_of(backing); + status.tasks = self.tasks.live_author_tasks(backing); } } Ok(status) @@ -166,8 +163,8 @@ impl Scheduler { /// Dispatches a `pending` request: the chain's live tasks in spawn /// order. pub(super) fn dispatch_pending(&mut self, id: ChainIndex) -> Result<()> { - let tasks = self.tasks.live_of(id); - self.answer_inline(id, Answer::Pending(Ok(tasks))) + let tasks = self.tasks.live_author_tasks(id); + self.answer_inline(id, Ok(Answer::Pending(tasks))) } /// Dispatches a `concurrency` request: with a limit, the chain's @@ -198,7 +195,7 @@ impl Scheduler { } else { self.chains[id].concurrency }; - self.answer_inline(id, Answer::Concurrency(Ok(effective.get()))) + self.answer_inline(id, Ok(Answer::Concurrency(effective.get()))) } /// Dispatches a `note` request: the text becomes the latest note of the @@ -207,13 +204,13 @@ impl Scheduler { /// main walk, task `0`, and its `call` children) records nothing. pub(super) fn dispatch_note(&mut self, id: ChainIndex, text: String) -> Result<()> { self.tasks.set_note(&self.chains[id].task, text); - self.answer_inline(id, Answer::Note(Ok(()))) + self.answer_inline(id, Ok(Answer::Note)) } /// Dispatches a `cancel` request over a task the chain owns. pub(super) fn dispatch_cancel(&mut self, id: ChainIndex, task: &TaskId) -> Result<()> { let answer = self.cancel_task(id, task); - self.answer_inline(id, Answer::Cancel(answer)) + self.answer_inline(id, answer.map(|()| Answer::Cancel)) } /// Cancels a live task `caller` owns: the entry moves to `Cancelled`, the diff --git a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs index 6d309265e..919005d99 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/walk.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/walk.rs @@ -6,13 +6,12 @@ //! with the parent position suspended on the chain's own walk stack //! until the child level exhausts. A prompt with H1 blocks runs them first //! as section 0 (the `h1` module), and the root walk starts from that -//! pass's `var` hand-off, sharing the root lineage `0` as a second arena -//! record; a prompt without H1 blocks starts the root walk directly. +//! pass's `var` hand-off, under the same root lineage +//! ([`Chain::lineage`](super::Chain::lineage)); a prompt without H1 blocks +//! starts the root walk directly. use std::sync::Arc; -use runvil_types::ids::ChainId; - use crate::execute::section_context::SectionContext; use crate::execute::walk_target::{ JumpTarget, h1_visible_sections, resolve_jump_target, section_position, @@ -21,16 +20,16 @@ use crate::heading_address::{self, SectionIndex, SectionPos, Visible}; use crate::parser::Block; use crate::{Error, Result}; -use super::chain::{ChainStart, ChainStep}; -use super::chain_record::{CallDepth, ChainKind, Entered}; +use super::chain::ChainStep; +use super::chain_record::{ChainKind, Entered}; use super::{ChainIndex, ChainOps, Counters, Scheduler, prompt_origin}; impl Scheduler { /// Starts the root walk chain over the top-level sections when the - /// prompt has no H1 blocks, seeded with an empty `var`, and enqueues - /// it. The walk is the root chain `0`; its entry 0 stays reserved for - /// the H1 pass the prompt does not have, so the first walked section - /// is `0.1` exactly as on a prompt whose pass ran. + /// prompt has no H1 blocks, seeded with an empty `var`. The walk is the + /// root chain `0`; its entry 0 stays reserved for the H1 pass the + /// prompt does not have, so the first walked section is `0.1` - see + /// [`Chain::counters`](super::Chain) for the normative statement. /// /// # Errors /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`, @@ -49,22 +48,19 @@ impl Scheduler { // where the walk starts. let prompt = self.ctx.prompt(); let blocks: &[Block] = - runvil_parser::detail::entry(prompt).map_or(&[], |section| section.blocks()); + crate::parser::detail::entry(prompt).map_or(&[], |section| section.blocks()); let origin = prompt_origin(prompt, prompt.title(), blocks); let access = self.ctx.vfs().acquire(origin).map_err(Error::store)?; self.scope = Some(runvil_vfs::detail::scope_handle(&access)); - let root = self.start_chain(ChainStart { - lineage: ChainId::root(), - counters: after_h1, - ctx: self.ctx.clone(), - access: Arc::new(access), - start: SectionPos::top(SectionIndex::FIRST), - kind: ChainKind::Root, - var: serde_json::json!({}), - call_depth: CallDepth::ROOT, - concurrency: self.ctx.limits().max_concurrency, - })?; - self.ready.push_back(root); + self.start_root_chain( + ChainKind::Root, + after_h1, + self.ctx.clone(), + Arc::new(access), + SectionPos::top(SectionIndex::FIRST), + serde_json::json!({}), + self.ctx.limits().max_concurrency, + )?; Ok(()) } diff --git a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs index 09bda344b..f79cc35f6 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests-render.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests-render.rs @@ -5,12 +5,27 @@ use super::*; -/// A failed merge's rule and the tools it names. +/// A failed merge's rule and the tools it names, as a vector so a case can +/// write the expected list inline. #[track_caller] fn broken(error: Error) -> (SetRule, Vec) { match error { - Error::ToolSets { rule, tools } => (rule, tools), - other => panic!("expected a broken merge rule, got {other:?}"), + Error::ToolSets { + evidence: SetEvidence::Tools { rule, tools }, + } => (rule, tools.into_vec()), + other => panic!("expected a broken merge rule naming tools, got {other:?}"), + } +} + +/// The Packs a failed render rule was asked about, and the Packs it was +/// answered with. +#[track_caller] +fn render_evidence(error: Error) -> (Vec, Vec) { + match error { + Error::ToolSets { + evidence: SetEvidence::RenderedPacks { asked, answered }, + } => (asked, answered), + other => panic!("expected a broken render rule naming Packs, got {other:?}"), } } @@ -59,14 +74,8 @@ fn rule_one_refuses_an_answer_whose_packs_are_not_the_ones_asked_in_offer_order( let answered_packs = answered.iter().map(|sets| sets.pack.clone()).collect(); let error = merge_tool_sets(&offers, answered, &tools).expect_err(case); assert_eq!( - broken(error), - ( - SetRule::RenderedPacks { - asked: vec![pack("gh"), pack("web")], - answered: answered_packs, - }, - Vec::new() - ), + render_evidence(error), + (vec![pack("gh"), pack("web")], answered_packs), "{case}" ); } @@ -189,3 +198,45 @@ fn each_set_keeps_offer_order_around_a_render_that_reorders_and_adds_model_only_ ["late=adhoc/late", "gh_one=gh/one", "gh_added=gh/added"] ); } + +#[test] +fn the_stored_pack_order_is_the_whole_of_what_a_render_is_asked() { + // `SectionOffers::packs` is the only producer of the Pack order, so a + // Pack it does not name is never asked: the offered tools still fill + // the slots it does name, and nothing else gains one. A second + // producer would ask a Pack the section never stored, at the front. + let offers = SectionOffers { + static_tools: vec![listed("web/fetch")], + dynamic_tools: vec![listed("gh/one")], + packs: vec![pack("web")], + }; + assert_eq!( + offered_packs(&offers), + [sets("web", vec![info("web/fetch")], Vec::new())], + "a Pack outside the stored order is asked nowhere" + ); +} + +#[test] +fn a_render_rules_message_names_both_asked_and_answered_packs_and_never_renders_subjectless() { + // The rule that names Packs carries `asked` and `answered` as its own + // shape, so its message can never render with no subject: the counts + // and both lists are the evidence, not tools it was handed. A + // `RenderedPacks` rule that could arrive with no tool list is the + // shape this variant replaces. + let tools = catalog([info("web/fetch"), info("gh/one")]); + let offers = SectionOffers { + static_tools: vec![listed("web/fetch")], + dynamic_tools: vec![listed("gh/one")], + packs: vec![pack("gh"), pack("web")], + }; + let asked = offered_packs(&offers); + let error = merge_tool_sets(&offers, asked.into_iter().rev().collect(), &tools) + .expect_err("the render answered the Packs in the wrong order"); + assert_eq!( + error.to_string(), + "the render answered the Packs [web, gh] where the section offered the Packs [gh, web]; \ + a render answers each offered Pack once, in offer order", + "the message names both the asked and the answered Packs" + ); +} diff --git a/crates/runvil-internal/engine/src/execute/scope-tests.rs b/crates/runvil-internal/engine/src/execute/scope-tests.rs index fda789156..55b089f0c 100644 --- a/crates/runvil-internal/engine/src/execute/scope-tests.rs +++ b/crates/runvil-internal/engine/src/execute/scope-tests.rs @@ -287,8 +287,11 @@ fn rule_five_fails_a_wire_name_used_twice_within_one_set_or_across_the_sets() { let tools = catalog([info("dup/a.b"), info("dup/a/b"), info("web/search")]); let clash = |error: Error| match error { Error::ToolSets { - rule: SetRule::UniqueWireName(wire_name), - tools, + evidence: + SetEvidence::Tools { + rule: SetRule::UniqueWireName(wire_name), + tools, + }, } => ( wire_name, tools diff --git a/crates/runvil-internal/engine/src/execute/scope.rs b/crates/runvil-internal/engine/src/execute/scope.rs index 54facb30f..6e6373a3f 100644 --- a/crates/runvil-internal/engine/src/execute/scope.rs +++ b/crates/runvil-internal/engine/src/execute/scope.rs @@ -8,33 +8,26 @@ use std::collections::BTreeMap; -use runvil_model_client::detail; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::{Value, json}; -use crate::error::{SetRule, SetTool}; +use crate::error::{SetEvidence, SetRule, SetTool}; use crate::execute::PackToolSets; use crate::lua::{ - DiscoveryTool, OfferMode, OfferedTool, RenderedTools, SectionVm, ToolBinding, ToolSet, - current_catalog_packs, current_dynamic_set, current_tool_bindings, wire_name_of, + DiscoveryTool, NonEmpty, OfferMode, OfferedTool, RenderedTools, SectionVm, ToolBinding, + ToolSet, current_catalog_packs, current_dynamic_set, current_static_set, wire_name_of, }; -use crate::model::ToolSchema; +use crate::model::{ToolSchema, detail}; use crate::{Error, Result}; /// The most Dynamic Set wire names the search tool's description lists. const LISTED_NAMES: usize = 100; -/// What the search tool does, before the Dynamic Set's names. -const SEARCH_USAGE: &str = "Lists and searches the dynamic tools, which CallDynamicTool calls: \ - pass toolName for one tool's description and input schema, pattern \ - for a regex search of their names, or neither to list them all"; - -/// What the call tool does. -const CALL_USAGE: &str = "Calls a dynamic tool: pass its name from GetDynamicTools as toolName \ - and its arguments as arguments."; +/// The longest `pattern` a search compiles, in characters. +pub(super) const PATTERN_LIMIT: usize = 256; /// One Pack's tools for `set`. fn set_of(sets: &PackToolSets, set: OfferMode) -> &[ToolInfo] { @@ -60,19 +53,23 @@ fn set_mut(sets: &mut PackToolSets, set: OfferMode) -> &mut Vec, dynamic_tools: Vec, + /// Each catalog Pack the offers name, once, in the order the section + /// first offered one of its tools, into either set. This is the only + /// copy of that order: [`offered_packs`] takes each Pack's slot from + /// it, so nothing else may produce or reorder the sequence. packs: Vec, } impl SectionOffers { - /// Reads the offers `vm`'s section has made, expanded against `set`. + /// Reads the offers `vm`'s section has made, expanded against `tool_set`. /// /// # Errors /// Returns the expansion's error. - pub(super) fn read(set: &ToolSet, vm: &SectionVm) -> Result { - let (runtime, adhoc) = (vm.tool_runtime(), vm.adhoc_tools()); - let static_tools = current_tool_bindings(set, runtime, adhoc)?; - let dynamic_tools = current_dynamic_set(set, runtime, adhoc, &static_tools)?; - let packs = current_catalog_packs(set, runtime, adhoc)?; + pub(super) fn read(tool_set: &ToolSet, vm: &SectionVm) -> Result { + let (runtime, adhoc) = (vm.offers(), vm.adhoc_tools()); + let static_tools = current_static_set(tool_set, runtime, adhoc)?; + let dynamic_tools = current_dynamic_set(tool_set, runtime, adhoc, &static_tools)?; + let packs = current_catalog_packs(tool_set, runtime, adhoc)?; Ok(Self { static_tools, dynamic_tools, @@ -105,11 +102,15 @@ impl SectionOffers { } } -/// Each catalog Pack `offers` names, once, in the order the section first -/// offered one of its tools, into either set, with the catalog tools -/// offered from it into each set, in offer order: what a render is asked. +/// Each catalog Pack `offers` names in `SectionOffers::packs` order, with +/// the catalog tools offered from it into each set, in offer order: what a +/// render is asked. pub(super) fn offered_packs(offers: &SectionOffers) -> Vec> { - let mut packs: Vec> = Vec::new(); + let mut packs: Vec> = offers + .packs + .iter() + .map(|pack| PackToolSets::new(pack.clone(), Vec::new(), Vec::new())) + .collect(); for (set, tools) in [ (OfferMode::Static, &offers.static_tools), (OfferMode::Dynamic, &offers.dynamic_tools), @@ -118,17 +119,11 @@ pub(super) fn offered_packs(offers: &SectionOffers) -> Vec Vec>, catalog: &ToolCatalog, @@ -243,11 +238,10 @@ fn check_render( }; if packs(asked) != packs(&answered) { return Err(Error::ToolSets { - rule: SetRule::RenderedPacks { + evidence: SetEvidence::RenderedPacks { asked: packs(asked), answered: packs(&answered), }, - tools: Vec::new(), }); } answered @@ -280,11 +274,16 @@ fn check_set( catalog: &ToolCatalog, ) -> Result> { let refuse = |rule: SetRule, tool: &ToolInfo| Error::ToolSets { - rule, - tools: vec![SetTool { - id: tool.id.clone(), - set, - }], + evidence: SetEvidence::Tools { + rule, + tools: NonEmpty::new( + SetTool { + id: tool.id.clone(), + set, + }, + Vec::new(), + ), + }, }; let mut kept: Vec = Vec::with_capacity(tools.len()); for tool in tools { @@ -359,10 +358,12 @@ fn discovery_tools(dynamic_set: &[Laid]) -> Result> { .map(|tool| tool.binding.alias()) .collect(); names.sort_unstable(); + let search = DiscoveryTool::Search.wire_name(); + let call = DiscoveryTool::Call.wire_name(); let shown = names[..names.len().min(LISTED_NAMES)].join(", "); let listed = if names.len() > LISTED_NAMES { let more = names.len() - LISTED_NAMES; - format!("{shown}, and {more} more; call GetDynamicTools to search") + format!("{shown}, and {more} more; call {search} to search") } else { shown }; @@ -370,10 +371,21 @@ fn discovery_tools(dynamic_set: &[Laid]) -> Result> { .into_iter() .map(|tool| { let (description, parameters) = match tool { - DiscoveryTool::Search => { - (format!("{SEARCH_USAGE} ({listed})"), search_parameters()) - } - DiscoveryTool::Call => (CALL_USAGE.to_owned(), call_parameters()), + DiscoveryTool::Search => ( + format!( + "Lists and searches the dynamic tools, which {call} calls: pass toolName \ + for one tool's description and input schema, pattern for a regex \ + search of their names, or neither to list them all ({listed})" + ), + search_parameters(), + ), + DiscoveryTool::Call => ( + format!( + "Calls a dynamic tool: pass its name from {search} as toolName and its \ + arguments as arguments." + ), + call_parameters(), + ), }; let id = ToolId::parse(tool.id()) .map_err(|_| Error::internal("a discovery tool's id is a tool id"))?; @@ -397,8 +409,10 @@ fn search_parameters() -> Value { }, "pattern": { "type": "string", - "description": "A regex of at most 256 characters matched against dynamic tool \ - names; (?i) makes it case-insensitive." + "description": format!( + "A regex of at most {PATTERN_LIMIT} characters matched against dynamic tool \ + names; (?i) makes it case-insensitive." + ) } } }) @@ -406,12 +420,13 @@ fn search_parameters() -> Value { /// The call tool's parameters: the target's name, and its arguments. fn call_parameters() -> Value { + let search = DiscoveryTool::Search.wire_name(); json!({ "type": "object", "properties": { "toolName": { "type": "string", - "description": "The dynamic tool's name, from GetDynamicTools." + "description": format!("The dynamic tool's name, from {search}.") }, "arguments": { "type": "object", @@ -449,8 +464,10 @@ fn refuse_clash(static_set: &[Laid], dynamic_set: &[Laid]) -> Result<()> { }; if let Some(first) = taken.get(wire_name) { return Err(Error::ToolSets { - rule: SetRule::UniqueWireName(wire_name.to_owned()), - tools: vec![first.clone(), site], + evidence: SetEvidence::Tools { + rule: SetRule::UniqueWireName(wire_name.to_owned()), + tools: NonEmpty::new(first.clone(), vec![site]), + }, }); } taken.insert(wire_name, site); diff --git a/crates/runvil-internal/engine/src/execute/section_context-construct.rs b/crates/runvil-internal/engine/src/execute/section_context-construct.rs index abd3446c5..1142d728c 100644 --- a/crates/runvil-internal/engine/src/execute/section_context-construct.rs +++ b/crates/runvil-internal/engine/src/execute/section_context-construct.rs @@ -3,9 +3,9 @@ //! walked section (a spawned task's first entry included, seeded with its //! `item` and `sys.index`), the H1 pass (section 0) - and hands back //! a live [`SectionContext`] whose `Drop` is the teardown boundary. The -//! setup half ([`section_vm::setup_section_vm`]) is shared; only the seed, -//! the `sys` extras, the visible set behind the `prompt` reads, and the -//! origin `require` resolves from differ. +//! setup half ([`section_vm::setup_section_vm`]) is shared, and the deltas +//! between the two constructors are the list on +//! [`SectionContext::new_h1`]. use std::sync::Arc; @@ -17,11 +17,11 @@ use crate::execute::context::RunState; use crate::execute::section_vm::{self, VmSeed}; use crate::execute::walk_target::{self, RequireOrigin}; use crate::heading_address::{SectionPos, Visible}; -use crate::lua::{Library, LibraryKey, SectionVm}; +use crate::lua::{Library, LibraryKey, SectionVm, Uninjected}; use crate::parser::Prompt; use crate::{Error, Result}; -use super::{SectionContext, TaskSeed}; +use super::{SectionContext, SectionSys, TaskSeed}; /// The resolver behind a frame's `require`: each heading resolves in /// `prompt` from the position a loading library's key names, or from @@ -35,6 +35,30 @@ fn library_resolver( } } +/// Builds the section VM for `name` over the run's shared sets, with the +/// run's cancel flag and Lua limits applied. +/// +/// # Errors +/// Returns the [`Error`] of the VM construction or the limits install. +fn limited_vm(ctx: &RunState, name: &str) -> Result> { + let vm = SectionVm::new_for_section( + ctx.nonce(), + ctx.tool_set(), + &ctx.model_set(), + ctx.emitter(), + name, + )?; + // The run's cancel flag reaches every block coroutine's hook. + vm.set_cancel(ctx.cancel().clone()); + // A limits failure propagates bare: no teardown runs here, so no + // LUA_TEARDOWN_* observation fires on this path. + vm.apply_lua_limits( + ctx.limits().lua_memory_bytes.get(), + ctx.limits().lua_log_events.get(), + )?; + Ok(vm) +} + impl SectionContext { /// Constructs the frame for one walked section and runs its setup /// preamble: the section's `sys`, the section-started observation, VM @@ -56,10 +80,8 @@ impl SectionContext { /// /// # Errors /// Returns [`Error::Internal`] when `pos` is past its slice, and - /// otherwise the [`Error`] of whichever step failed. A VM - /// construction or limits failure propagates bare, before any teardown - /// observation exists; a setup failure tears the fresh VM down first, so - /// the teardown boundary still fires exactly once on that path. + /// otherwise the [`Error`] of whichever step failed (see [`section_vm`] + /// for which failures tear the VM down). pub(crate) fn new( ctx: &RunState, access: &Arc, @@ -79,21 +101,7 @@ impl SectionContext { sys.index = seed.index; ctx.emitter() .report(section.name(), lifecycle::SECTION_STARTED); - let vm = SectionVm::new_for_section( - ctx.nonce(), - ctx.tool_set(), - &ctx.model_set(), - ctx.emitter(), - section.name(), - )?; - // The run's cancel flag reaches every block coroutine's hook. - vm.set_cancel(ctx.cancel().clone()); - // A limits failure propagates bare: no teardown runs here, so no - // LUA_TEARDOWN_* observation fires on this path. - vm.apply_lua_limits( - ctx.limits().lua_memory_bytes.get(), - ctx.limits().lua_log_events.get(), - )?; + let vm = limited_vm(ctx, section.name())?; let resolve = library_resolver( Arc::clone(ctx.prompt()), RequireOrigin::Section(pos.clone()), @@ -119,19 +127,12 @@ impl SectionContext { }, access, section.name(), + false, ); // Setup runs on the bare VM and tears it down itself on failure: // the frame does not exist yet, so its `Drop` cannot own this path. let vm = section_vm::setup_section_vm(vm, &setup, read, resolve, render)?; - Ok(Self { - vm: Some(vm), - completed: false, - sys, - item: seed.item, - counts: None, - adhoc_calls: Vec::new(), - reporting: ctx.reporting().clone(), - }) + Ok(Self::assemble(vm, sys, seed.item, ctx)) } /// Constructs the frame for the H1 pass - section 0 - through the same @@ -143,18 +144,17 @@ impl SectionContext { /// ([`section_vm::setup_section_vm`]). /// /// H1's only deltas from a walked section: no `SECTION_STARTED` - /// observation (the pass is not a walked section), an empty `var` seed - /// (it runs first and is never re-entered), `argv` installed writable - /// so the repair pattern can assign it, [`walk_target::h1_visible_sections`] - /// as the visible set behind the `prompt` reads, and - /// [`RequireOrigin::H1`], which reaches every top-level section, behind - /// `require`. + /// observation, and no `SECTION_FINISHED` since the scheduler never arms + /// the pass's completion (the pass is not a walked section), an empty + /// `var` seed (it runs first and is never re-entered), `argv` installed + /// writable so the repair pattern can assign it, + /// [`walk_target::h1_visible_sections`] as the visible set behind the + /// `prompt` reads, and [`RequireOrigin::H1`], which reaches every + /// top-level section, behind `require`. /// /// # Errors - /// Returns the [`Error`] of whichever step failed. A VM - /// construction or limits failure propagates bare, before any teardown - /// observation exists; a setup failure tears the fresh VM down first, so - /// the teardown boundary still fires exactly once on that path. + /// Returns the [`Error`] of whichever step failed (see [`section_vm`] + /// for which failures tear the VM down). pub(crate) fn new_h1( ctx: &RunState, access: &Arc, @@ -163,20 +163,7 @@ impl SectionContext { ) -> Result { let title = ctx.prompt().title(); let sys = ctx.section_sys(section_id, task_id, title); - let vm = SectionVm::new_for_section( - ctx.nonce(), - ctx.tool_set(), - &ctx.model_set(), - ctx.emitter(), - title, - )?; - vm.set_cancel(ctx.cancel().clone()); - // A limits failure propagates bare: no teardown runs here, so no - // LUA_TEARDOWN_* observation fires on this path. - vm.apply_lua_limits( - ctx.limits().lua_memory_bytes.get(), - ctx.limits().lua_log_events.get(), - )?; + let vm = limited_vm(ctx, title)?; let resolve = library_resolver(Arc::clone(ctx.prompt()), RequireOrigin::H1); let render = super::prose_renderer(ctx, None); let prompt = Arc::clone(ctx.prompt()); @@ -186,23 +173,33 @@ impl SectionContext { Visible::all(walk_target::h1_visible_sections(&prompt)), ) }; - // H1's one privilege: `argv` installs writable, so the repair - // pattern can assign it; the executor reads the value back at the - // freeze (see the scheduler's H1-to-walk handoff). + // `argv` installs writable, the H1 delta `SectionVmSetup::argv` + // describes; the executor reads the value back at the freeze (see + // the scheduler's H1-to-walk handoff). let sys_json = sys.to_json(); - let mut setup = ctx.vm_setup(&sys_json, VmSeed::default(), access, title); - setup.argv_writable = true; + let setup = ctx.vm_setup(&sys_json, VmSeed::default(), access, title, true); // Setup runs on the bare VM and tears it down itself on failure: // the frame does not exist yet, so its `Drop` cannot own this path. let vm = section_vm::setup_section_vm(vm, &setup, read, resolve, render)?; - Ok(Self { + Ok(Self::assemble(vm, sys, None, ctx)) + } + + /// The live frame over a set-up `vm`: not yet completed, with no + /// tool-call counts and no open adhoc call. + fn assemble( + vm: SectionVm, + sys: SectionSys, + item: Option, + ctx: &RunState, + ) -> Self { + Self { vm: Some(vm), completed: false, sys, - item: None, + item, counts: None, adhoc_calls: Vec::new(), reporting: ctx.reporting().clone(), - }) + } } } diff --git a/crates/runvil-internal/engine/src/execute/section_context.rs b/crates/runvil-internal/engine/src/execute/section_context.rs index 2b307c80a..00648d03f 100644 --- a/crates/runvil-internal/engine/src/execute/section_context.rs +++ b/crates/runvil-internal/engine/src/execute/section_context.rs @@ -2,18 +2,16 @@ //! //! [`SectionContext`] is born at a section entry and dies at its teardown. //! It owns the section VM plus the state the block walk reads and writes - -//! the section's [`SectionSys`], a spawned chain's `item`, and the tool-call counts - -//! and it holds the frame's effective reporting handles +//! the section's [`SectionSys`], a spawned chain's `item`, the tool-call +//! counts, the stack of open [`AdhocCall`]s, and the completion flag its +//! `Drop` reads - and it holds the frame's effective reporting handles //! (the task-scoped event emitter and the turn counter) seeded out of the //! run context; a task chain's context is the spawn's fork, so the handles //! reach the frame and the task's nested chains through the one value. -//! Each section entry is one construct-run-teardown cycle: a frame -//! constructor absorbs the VM construction and setup preamble -//! ([`SectionContext::new`] for a walked section, a spawned task's first -//! entry included, and [`SectionContext::new_h1`] for the H1 pass; -//! the two sit in the `construct` sibling), the scheduler's chain -//! steps run the blocks, and the frame's [`Drop`] impl is the single -//! teardown boundary. +//! Each section entry is one construct-run-teardown cycle: a constructor in +//! the `construct` child builds the frame, the scheduler's chain steps run +//! the blocks, and the frame's [`Drop`] impl is the single teardown +//! boundary. //! //! The run-scoped inputs (bindings, models, limits, the shared tools) //! arrive through the [`RunState`]. @@ -53,12 +51,17 @@ pub(super) struct TaskSeed { pub(super) struct SectionSys { /// The run's `started_at` as RFC 3339, the same in every section. pub(super) when: Arc, - /// The section entry's hierarchical id. + /// The section entry's hierarchical id: the entering chain's id + /// extended by its local entry counter, rendered as a dot-separated + /// path. pub(super) id: String, /// The nearest enclosing task: the main walk is task `0`, and a `call` - /// child reports its caller's task. + /// child reports its caller's task. A chain passes it to `tasks.*` to + /// speak about itself. pub(super) taskid: TaskId, - /// The section's name, or the prompt's title for the H1 pass. + /// The section's name, or the prompt's title for the H1 pass. Fixed at + /// construction, and the name the frame's `Drop` reports the teardown + /// boundary and the completion observation under. pub(super) section_name: String, /// The run's execution identifier. pub(super) execution: Arc, @@ -117,20 +120,14 @@ pub(super) struct AdhocCall { /// section entry is one frame, regardless of arrival mode (fall-through, /// jump, call); a jump ends the current frame and the driver builds a /// fresh one for the target - only `var` crosses, as call data. -/// No derives: the VM and the trait-object handles support neither `Clone` -/// nor `Debug`. +/// Not `Clone`: the frame owns the VM and its one teardown. pub(super) struct SectionContext { - /// The frame's VM: the owned section VM, `Some` from construction - /// until the frame's `Drop` takes it for the teardown boundary. - /// `SectionVm` is `runvil-lua`'s type, tested in that crate. + /// The frame's VM; [`live_vm`] states when the slot holds it. vm: Option, - /// Armed by [`SectionContext::mark_completed`] on the success path - /// only, so `Drop` fires `SECTION_FINISHED` on completion (a jump or - /// return included) and never on an error. + /// Whether [`SectionContext::mark_completed`] armed the frame's + /// `SECTION_FINISHED`. completed: bool, - /// The section's `sys`, whose `section_name` `Drop` reports the - /// teardown boundary and the completion observation under. The - /// first-call install sets its `model`. + /// The section's `sys`. The first-call install sets its `model`. sys: SectionSys, /// A spawned chain's `item` seed (a fanout arm's collection member) for /// `{{ item }}` substitution; `None` on every other entry. @@ -155,11 +152,12 @@ impl SectionContext { /// goes through the live VM. /// /// # Errors - /// Returns [`Error::Lua`] if the VM's `var` cannot be - /// converted back to JSON (the write guard keeps this conversion from - /// failing in practice), or [`Error::Internal`] if the VM is gone. + /// Returns [`Error::Lua`] when the section reassigned the `var` global, + /// which the walk relies on to fail the run, or when `var` cannot + /// convert back to JSON (the write guard keeps that second case from + /// happening in practice). pub(super) fn read_var(&self) -> Result { - Ok(self.vm()?.var()?) + Ok(self.vm().var()?) } /// Reads the H1 pass's `argv` back as JSON while the frame is live: the @@ -167,10 +165,9 @@ impl SectionContext { /// repair. `None` reads as nil. /// /// # Errors - /// Returns [`Error::Lua`] when H1 left `argv` as - /// non-JSON data, or [`Error::Internal`] if the VM is gone. + /// Returns [`Error::Lua`] when H1 left `argv` as non-JSON data. pub(super) fn read_argv(&self) -> Result> { - self.vm()?.argv_json().map_err(Error::from) + self.vm().argv_json().map_err(Error::from) } /// Arms the completion flag: the block walk completed (a jump or @@ -184,13 +181,9 @@ impl SectionContext { /// Borrows the frame's VM for the scheduler's coroutine driving (block /// start, yield validation, answer resume) and model resolution. /// - /// # Errors - /// Returns [`Error::Internal`] if the VM is gone, which only the frame's - /// own drop does - a live frame always holds it. - pub(super) fn vm(&self) -> Result<&SectionVm> { - self.vm.as_ref().ok_or(Error::internal( - "the section frame's VM lives until the frame's own drop", - )) + /// Infallible; [`live_vm`] states why. + pub(super) fn vm(&self) -> &SectionVm { + live_vm(self.vm.as_ref()) } /// The section entry's id: the value its Lua reads as `sys.id`, which @@ -199,19 +192,18 @@ impl SectionContext { &self.sys.id } - /// Installs the pending Markdown buffer as the VM's fresh read-only - /// lazy `prose` template before one Lua coroutine starts. The first - /// runtime read snapshots the section state, renders every `{{ }}` - /// substitution once, and memoizes the result; the buffer is the - /// scheduler's pending prose, empty when no Markdown accumulated. + /// Installs the scheduler's pending Markdown buffer, empty when no + /// Markdown accumulated, as the VM's lazy `prose` template before one + /// Lua coroutine starts, rendered through this frame's + /// [`prose_renderer`]; [`SectionVm::install_lazy_prose`] states when it + /// renders. /// /// # Errors - /// Returns [`Error::Lua`] if the guard cannot be installed, or - /// [`Error::Internal`] if the VM is gone. + /// Returns [`Error::Lua`] if the guard cannot be installed. pub(super) fn install_lazy_prose(&self, ctx: &RunState, template: &str) -> Result<()> { let template = template.to_owned(); let render = prose_renderer(ctx, self.item.clone()); - self.vm()? + self.vm() .install_lazy_prose(move |state: ProseState<'_>| render(&template, state))?; Ok(()) } @@ -246,11 +238,7 @@ impl SectionContext { let Self { vm, sys, counts, .. } = self; - let Some(vm) = vm.as_ref() else { - return Err(Error::internal( - "the section frame's VM lives until the frame's own drop", - )); - }; + let vm = live_vm(vm.as_ref()); let counts = match counts { Some(counts) => counts, None => counts.insert(install_first_call_state(vm, ctx, sys, ids)?), @@ -280,6 +268,18 @@ impl SectionContext { } } +/// The live VM a frame's slot holds: `Some` from construction until the +/// frame's own [`Drop`] takes it for the teardown boundary, which is the +/// only place the slot clears. Every accessor reads the slot through here, +/// so the invariant is stated once. +#[expect( + clippy::expect_used, + reason = "the slot clears only in this frame's own Drop, so a live frame always holds its VM" +)] +fn live_vm(vm: Option<&SectionVm>) -> &SectionVm { + vm.expect("a live frame holds its VM until drop") +} + /// The `{{ }}` renderer for one frame's prose templates, its own blocks' /// and the libraries `require` loads alike: each render substitutes from /// the run's `args` and `argv`, shared with the run rather than copied, @@ -324,7 +324,7 @@ fn install_first_call_state( ids: &[ToolId], ) -> Result { let counts = vm.install_tool_call_counts(ids)?; - let resolved_model = crate::lua::resolve_model_binding(ctx.models(), vm.model_runtime())?; + let resolved_model = crate::lua::resolve_model_binding(ctx.models(), vm.model_selection())?; if let Some(binding) = resolved_model.as_ref() { let mut enriched = sys.clone(); enriched.model = Some(binding.id().name().to_owned()); @@ -338,10 +338,8 @@ impl Drop for SectionContext { fn drop(&mut self) { // The single teardown boundary: every exit path - success, error, // or early return - drops the frame, so the VM tears down exactly - // once here. `SECTION_FINISHED` follows only on the armed - // (completed) path; an error reports the teardown pair alone. The - // `let-else` is defensive: `Drop` runs once, so the VM is always - // here, and the destructor stays infallible. + // once here. It uses `let-else` rather than `live_vm`, so the + // destructor never panics. let Some(vm) = self.vm.take() else { return; }; diff --git a/crates/runvil-internal/engine/src/execute/section_vm.rs b/crates/runvil-internal/engine/src/execute/section_vm.rs index 884ec4c50..9df812bd7 100644 --- a/crates/runvil-internal/engine/src/execute/section_vm.rs +++ b/crates/runvil-internal/engine/src/execute/section_vm.rs @@ -1,10 +1,8 @@ //! The Engine's setup half: one section VM's lifecycle from Engine injection //! through the store yield shims' install. //! -//! Both frame constructors - `SectionContext::new` for a walked section (a -//! spawned task's first entry included) and `SectionContext::new_h1` -//! for the H1 pass - run the sequence [`setup_section_vm`] lists. Only the -//! deltas live at the call site: the constructor builds its own `sys` JSON +//! Both frame constructors run the sequence [`setup_section_vm`] lists. Only +//! the deltas live at the call site: the constructor builds its own `sys` JSON //! (both take the chain's next entry `id`; a task's first entry adds its //! `index`), picks the [`VmSeed`] (the chain's `var`, plus the item seeded //! into this section on a task's first entry), and supplies the `read` @@ -51,11 +49,12 @@ pub(crate) struct VmSeed<'a> { pub(crate) struct SectionVmSetup<'a> { /// The run's argument string, installed as the `args` global. pub(crate) args: &'a str, - /// The run's `argv` (the parsed form of `args`; `None` installs nil). - pub(crate) argv: Option<&'a serde_json::Value>, - /// H1 only: `argv` installs writable, so the repair pattern can assign - /// it; every other section gets the frozen value. - pub(crate) argv_writable: bool, + /// The run's `argv` (`RunState::argv`; `None` installs nil), + /// with its mode: writable for the H1 pass alone, so the repair pattern + /// can assign it, and frozen for every other section. The caller picks + /// the mode at construction, so a writable value cannot reach a + /// non-H1 section. + pub(crate) argv: crate::lua::Argv<'a>, /// The `sys` JSON the frame constructor built for this section entry. pub(crate) sys: &'a serde_json::Value, /// The chain step's VFS access capability backing the Lua `store` @@ -72,8 +71,8 @@ pub(crate) struct SectionVmSetup<'a> { pub(crate) emitter: &'a Emitter, /// The section name used in observations and error messages. pub(crate) section_name: &'a str, - /// The run's resolved tool-loop cap per `models.loop` or model handle - /// `loop` call, captured by the `models.loop` shim as its round cap. + /// The run's tool-loop cap, as `RunState::max_tool_iterations` resolves + /// it, captured by the `models.loop` shim as its round cap. pub(crate) max_tool_iterations: usize, /// The run's application-state snapshot, when the caller supplied one: /// its presence gives the section VM the `ui()` global and the @@ -92,23 +91,24 @@ pub(crate) struct SectionVmSetup<'a> { /// Runs one section VM's setup sequence against a constructed, limited VM. /// /// The sequence is fixed and shared: Engine injection with the frame -/// constructor's [`VmSeed`], [`SectionVm::install_engine_globals`], the +/// constructor's [`VmSeed`], preceded by the raw-model-id opt-in when the run +/// supplied a snapshot (Engine injection's `models` table install reads that +/// flag, so the opt-in must land first), then +/// [`SectionVm::install_engine_globals`], the /// `ui()` global when the run supplied a snapshot, the `item` global when /// the seed includes one, the control surface /// ([`SectionVm::install_scheduler_control_globals`] for `jump` and the /// `prompt` reads, plus [`SectionVm::install_coro_shims`] for the -/// suspending calls, which the scheduler drives as yield shims), the -/// section's `models.loop` shim, the run's Pack preludes, +/// suspending calls, which the scheduler drives as yield shims, +/// `models.loop` and `models.compact` included), the run's Pack preludes, /// [`SectionVm::install_require`] over `resolve` and `render`, and the -/// store yield shims. The caller applies the Lua limits itself before -/// calling, so a limits failure propagates without touching the VM's -/// teardown observation path. +/// store yield shims. /// /// # Errors /// Returns the [`Error`] of whichever step failed, after tearing the VM /// down under the setup's section: Engine injection, Engine global /// install, `ui()` install, `item` install, control-global install, the -/// `models.loop` shim install, the prelude install (a +/// yield shims' install, the prelude install (a /// prelude that fails to load, or whose global collides, or a store /// conflict a prelude's store call recorded), the `require` install, or /// the store shims' install. @@ -132,13 +132,14 @@ where if setup.ui.is_some() { vm.allow_raw_model_ids(); } - let argv = if setup.argv_writable { - crate::lua::Argv::Writable(setup.argv) - } else { - crate::lua::Argv::Frozen(setup.argv) - }; let mut vm = vm - .inject_values_with_var(setup.args, setup.sys, setup.access, setup.seed.var, argv) + .inject_values_with_var( + setup.args, + setup.sys, + setup.access, + setup.seed.var, + setup.argv, + ) .map_err(|failure| Error::from(failure.teardown(setup.emitter, setup.section_name)))?; match install_after_injection(&mut vm, setup, read, resolve, render) { Ok(()) => Ok(vm), @@ -174,7 +175,6 @@ where } vm.install_scheduler_control_globals(read)?; vm.install_coro_shims(setup.max_tool_iterations)?; - crate::lua::install_section_loop_shim(vm.lua())?; // The preludes read `tools` and `store` from `_G` as they install, so // they follow the yield shims. let installed = crate::lua::install_preludes(vm.lua(), setup.preludes); diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index 07624ba61..a320b5371 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -16,7 +16,7 @@ use self::fixtures::untrusted_echo_tool; use super::turn::advance_turn; use super::*; use crate::Error; -use crate::lua::{SectionVm, current_tool_bindings}; +use crate::lua::{SectionVm, current_static_set}; use crate::model::{ModelInfo, ModelSet}; use crate::parser::ParseErrorKind; use crate::parser::Prompt; @@ -51,8 +51,8 @@ fn calls_slow_tool(effect: &Effect) -> bool { #[test] fn run_with_a_pre_cancelled_handle_fails_as_cancelled() { - use crate::cancel::CancelHandle; use runvil_parser::test_support::prompt; + use runvil_types::cancel::CancelHandle; // The explicit-cancel wiring of the public entry point: a handle passed // through `RunContext::with_cancel` is installed around the whole run body, so diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs index d5ee82d22..c380d5f7a 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs @@ -200,7 +200,7 @@ fn an_out_of_scope_tool_name_fails_the_round_with_out_of_scope_tool() { assert_eq!( trace.text(), "out_of_scope_tool|rogue|tool \"rogue\" is not in this section's scope; \ - in-scope aliases: [\"echo\"]" + aliases this section offers: [\"echo\"]" ); let events = trace.events(); assert!( @@ -225,11 +225,12 @@ fn an_out_of_scope_tool_name_fails_the_round_with_out_of_scope_tool() { Error::OutOfScopeToolCall { name, global_exists, - in_scope, + static_aliases, + .. } => { assert_eq!(name, "rogue"); assert!(!global_exists, "rogue is bound nowhere"); - assert_eq!(in_scope, &["echo".to_owned()]); + assert_eq!(static_aliases, &["echo".to_owned()]); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/execute/tests/context.rs b/crates/runvil-internal/engine/src/execute/tests/context.rs index 25602c52a..98e223ddb 100644 --- a/crates/runvil-internal/engine/src/execute/tests/context.rs +++ b/crates/runvil-internal/engine/src/execute/tests/context.rs @@ -88,7 +88,7 @@ const fn _public_execution_types_are_send_sync_static() { assert_send_sync_static::(); } -/// Lua-only prompts never build the gateway client, so these run offline. +/// Parses `md`, adding a `# Test prompt` title when the source has none. pub(super) fn parse(md: &str) -> Prompt { let source = if md.lines().any(|line| line.starts_with("# ")) { md.to_string() diff --git a/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs b/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs index 9ba448eb1..0e10be59e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs +++ b/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs @@ -268,7 +268,7 @@ fn an_adhoc_tool_defined_after_the_render_has_no_seeded_count() { #[test] fn tool_calls_count_zero_for_uncalled_alias_fails_epilog_assert() { // The first script dispatch installs the counts seeded from the - // effective scope, so an offered but uncalled tool reads as 0 and an + // frozen sets, so an offered but uncalled tool reads as 0 and an // author assert on it fails the run with its own message. let source = setup_prompt(PromptSource::new().front("uses: [tools]").prose( "Only", diff --git a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs index 368e58644..538aa2a0c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/exit_rules.rs @@ -371,3 +371,26 @@ fn an_empty_reply_that_ignored_reasoning_says_so_at_the_call_site() { reasoning content was present but ignored" ); } + +#[test] +fn an_empty_reply_with_whitespace_only_reasoning_keeps_the_plain_phrase() { + // The scripted model treats a whitespace-only reasoning channel as + // absent, so the message keeps the plain phrase. + let gateway = ScriptedChat::new(vec![ScriptedReply::Text { + model: MOCK_MODEL.to_owned(), + content: String::new(), + finish_reason: Some("stop".to_owned()), + reasoning: Some(" \n\t ".to_owned()), + metrics: None, + }]); + let source = one_section( + "messages:user('think blank') + local ok, err = pcall(models.loop) + return err.kind .. '|' .. tostring(err)", + ); + let trace = loop_scenario(source).chat(&gateway).run(); + assert_eq!( + trace.text(), + "empty_model_reply|the model replied with no text and no tool calls" + ); +} diff --git a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs index 0e772602b..11faf6cd3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs +++ b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs @@ -233,7 +233,7 @@ fn a_name_that_is_no_catalog_id_is_unbound_and_lists_the_catalog_ids() { #[test] fn a_model_issued_call_never_resolves_an_id() { let source = one_section( - "local ok, err = pcall(tools.call_as_model, 'call_1', 'tools/echo', { value = 'hi' })\n\ + "local ok, err = pcall(tools.call_as_model, 'call_1', 'tools/echo', { value = 'hi' }, 0)\n\ assert(not ok, 'a model-issued id call is refused')\n\ return err.kind .. ':' .. err.name", ); diff --git a/crates/runvil-internal/engine/src/execute/tests/live_infer.rs b/crates/runvil-internal/engine/src/execute/tests/live_infer.rs index 398a300a1..105b312a3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/live_infer.rs +++ b/crates/runvil-internal/engine/src/execute/tests/live_infer.rs @@ -1,11 +1,11 @@ //! Tests for live `models.infer` against a scripted gateway, including H1 chunks and required libraries. use runvil_parser::test_support::{PromptSource, parse_ok}; +use runvil_types::cancel::CancelHandle; use super::context::test_context; use super::fixtures::echo_tool; use super::*; -use crate::cancel::CancelHandle; use crate::test_support::scenario::{EXECUTION, TEST_MODEL, test_model}; /// The prompt declaring the `writer` role, with no default parked. diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs index 1a5eb73d3..dd759445f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop-author-shapes.rs @@ -226,16 +226,15 @@ fn the_chat_agent_shape_leaves_the_oldest_records_out_of_a_summary_past_its_budg return messages[#messages].content .. '|' .. tostring(messages[1].meta and messages[1].meta.summary)", chat_turns(&format!("{{ {} }}", inputs.join(", "))) )); - let wide = ModelSet::from_parts( - vec![ModelBinding::new( - "writer", - "A general model for tests", - ModelId::gateway("test-model").expect("the test model id is valid"), - ModelInvocation::new(), - NonZeroU32::new(131_072).expect("the window is non-zero"), - )], - Some("writer".to_owned()), - ); + let mut wide = ModelSet::default(); + wide.push(ModelBinding::new( + "writer", + "A general model for tests", + ModelId::gateway("test-model").expect("the test model id is valid"), + ModelInvocation::new(), + NonZeroU32::new(131_072).expect("the window is non-zero"), + )); + wide.set_default_alias("writer".to_owned()); let trace = loop_scenario(source).models(wide).chat(&gateway).run(); assert_eq!(trace.text(), "answer|true"); let bodies = gateway.requests(); diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs index 51c3b2ef5..6ea59ca6b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs @@ -29,13 +29,15 @@ mod author_shapes; /// default, model `test-model`) and `other` (model `other-model`), so an /// explicit handle provably runs on its own frozen binding. pub(super) fn loop_models() -> ModelSet { - ModelSet::from_parts( - vec![ - test_binding("writer", "A general model for tests", "test-model"), - test_binding("other", "A second model", "other-model"), - ], - Some("writer".to_owned()), - ) + let mut set = ModelSet::default(); + set.push(test_binding( + "writer", + "A general model for tests", + "test-model", + )); + set.push(test_binding("other", "A second model", "other-model")); + set.set_default_alias("writer".to_owned()); + set } /// The name a tool call's origin gives a round on the loop model bound diff --git a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs index db14ef45c..82f0cf331 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering-sets.rs @@ -157,10 +157,12 @@ fn the_scope_gate_says_a_name_exists_only_for_a_catalog_tools_own_wire_name() { match trace.error().inner() { Error::OutOfScopeToolCall { global_exists, - in_scope, + has_dynamic_set, + static_aliases, .. } => { - assert_eq!(in_scope, &["gh_search".to_owned()]); + assert!(!has_dynamic_set, "the section holds no Dynamic Set tool"); + assert_eq!(static_aliases, &["gh_search".to_owned()]); *global_exists } other => panic!("expected OutOfScopeToolCall, got {other:?}"), diff --git a/crates/runvil-internal/engine/src/execute/tests/precheck_anchor.rs b/crates/runvil-internal/engine/src/execute/tests/precheck_anchor.rs index 00d5bf861..6aed4936d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/precheck_anchor.rs +++ b/crates/runvil-internal/engine/src/execute/tests/precheck_anchor.rs @@ -310,14 +310,13 @@ fn a_return_to_the_first_model_uses_the_estimate() { fn run_with_max_tokens(lua: &str, max_tokens: u32) -> (String, ScriptedChat) { let loop_set = loop_models(); let capped = ModelInvocation::new().with_max_tokens(NonZeroU32::new(max_tokens)); - let models = ModelSet::from_parts( - loop_set - .bindings() - .iter() - .map(|binding| binding.clone().with_invocation(capped.clone())) - .collect(), - loop_set.default_alias().map(str::to_owned), - ); + let mut models = ModelSet::default(); + for binding in loop_set.bindings() { + models.push(binding.clone().with_invocation(capped.clone())); + } + if let Some(alias) = loop_set.default_alias() { + models.set_default_alias(alias.to_owned()); + } run_over(models, lua, vec![text("ok")]) } diff --git a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs index aed1f7353..4c637535c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs @@ -241,14 +241,12 @@ fn a_first_round_refused_for_a_missing_model_renders_nothing() { models.get('writer'):loop() return tostring(err)", ); - let unset = ModelSet::from_parts( - vec![test_binding( - "writer", - "A general model for tests", - "test-model", - )], - None, - ); + let mut unset = ModelSet::default(); + unset.push(test_binding( + "writer", + "A general model for tests", + "test-model", + )); let trace = loop_scenario(source) .models(unset) .offer("echo", echo_tool()) diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler.rs index 15e5f3cef..814c65f7c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler.rs @@ -22,14 +22,14 @@ use crate::test_support::scenario::{infer_prompt, test_binding}; /// as the prompt-wide default. The scheduler's tests bypass H1, so they /// pre-fill the run's shared set directly. fn writer_models() -> ModelSet { - ModelSet::from_parts( - vec![test_binding( - "writer", - "A general model for tests", - "test-model", - )], - Some("writer".to_owned()), - ) + let mut set = ModelSet::default(); + set.push(test_binding( + "writer", + "A general model for tests", + "test-model", + )); + set.set_default_alias("writer".to_owned()); + set } /// A run of `source` over the [`writer_models`], unprepared, so the run diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs index 728441334..43dd14f1b 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs @@ -46,7 +46,7 @@ fn a_script_tools_call_with_a_tool_object_dispatches_its_binding() { fn a_script_tools_call_with_an_unbound_name_lists_the_offered_ids() { // Script-initiated resolution runs against the run's whole offering, // so the unknown-name error lists every offered id, not the section's - // effective scope. + // frozen sets. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # ToolCall\n\n\ ## Only\n\n\ @@ -231,14 +231,13 @@ fn a_first_tool_call_whose_model_step_fails_leaves_the_install_to_the_next_call( tools.call('tools/echo', { value = 'y' })\n\ return sys.model\n\ ```\n"; - let unbound_default = ModelSet::from_parts( - vec![test_binding( - "writer", - "A general model for tests", - "test-model", - )], - Some("ghost".to_owned()), - ); + let mut unbound_default = ModelSet::default(); + unbound_default.push(test_binding( + "writer", + "A general model for tests", + "test-model", + )); + unbound_default.set_default_alias("ghost".to_owned()); let trace = Scenario::new(md) .unprepared() .models(unbound_default) diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs index dbbe368a2..b2c6edf2d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures.rs @@ -448,5 +448,27 @@ fn an_answer_for_a_chain_with_no_suspended_block_fails_loudly() { } } +#[test] +fn a_second_delivery_before_resume_is_an_internal_error() { + // A suspended block takes one answer at its resume, so a second + // delivery before then is a scheduler bug: it fails loudly and leaves + // the first answer in place rather than overwrite it. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Title\n\n\ + ## Only\n\n\ + ```lua\nreturn models.infer('ask')\n```\n"; + let mut rig = + Rig::start(scheduler_scenario(md).hold(|effect| matches!(effect, Effect::Chat { .. }))); + rig.settle(); + let ([first, second], holds_first) = rig.scheduler_for_test().deliver_twice_for_test(); + + assert!(first.is_ok(), "the first delivery lands: {first:?}"); + assert!( + matches!(&second, Err(Error::Internal { .. })), + "a second delivery before resume is a loud invariant failure: {second:?}" + ); + assert!(holds_first, "the block still holds the first answer"); +} + #[path = "failures-script-tools.rs"] mod script_tools; diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs index 75c92f73e..76f0553d8 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/h1/control_flow.rs @@ -146,3 +146,26 @@ fn the_h1_decision_tool_idiom_runs_before_the_walk() { "the loop runs the tool-call round and the terminal text round" ); } + +#[test] +fn a_limit_lowered_in_h1_is_the_walks_own_limit() { + // The hand-off carries the pass's effective limit into the walk as a + // root-chain parameter, so a `tasks.concurrency` call in H1 still bounds + // the tasks the walk spawns. Were the walk's limit re-read from the run's + // ceiling instead of carried, the walk would read back 8 here. + let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # Test prompt\n\n\ + ```lua\n\ + local n = tasks.concurrency(2)\n\ + assert(n == 2, 'the pass sets its own limit, got ' .. n)\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn 'walk limit: ' .. tasks.concurrency()\n```\n"; + let trace = h1_scenario(md).limits(ceiling(8)).run(); + + assert_eq!( + trace.text(), + "walk limit: 2", + "the walk inherits the pass's lowered limit, not the ceiling" + ); +} diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/core_rules.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/core_rules.rs index 818a7ac5e..71fce59f2 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/core_rules.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/walk/core_rules.rs @@ -1,11 +1,47 @@ //! The core walk rules: fall-through order, the generic result, `sys.id` //! per section entry, `call` chains over off-walk siblings, `var` across -//! fall-through and into calls, and the section boundaries fall-through -//! fires. +//! fall-through and into calls, the section boundaries fall-through +//! fires, and the root start's own enqueue. use super::*; use crate::execute::tests::context::sequence; +#[test] +fn a_prompt_without_h1_blocks_runs_its_first_section_exactly_once() { + // `start_root_walk` reaches the ready queue through `start_root_chain`, + // which enqueues. Were the chain enqueued twice - the helper and a + // caller's own push - the drain would pop it a second time while it is + // still ready, stepping it past `First` before `First` had run, and the + // section order would lose `First`. + let md = "---\nname: once\ndescription: d\nrunvil: 0\n---\n\n\ + # Once\n\n\ + ## First\n\n\ + ```lua\nstore.append('order.txt', 'First\\n')\n```\n\n\ + ## Second\n\n\ + ```lua\n\ + store.append('order.txt', 'Second\\n')\n\ + return store.read('order.txt')\n\ + ```\n"; + let trace = scheduler_scenario(md).run(); + + assert_eq!( + trace.text(), + "First\nSecond\n", + "each section runs once, in order" + ); + let chunk_started = detail::LUA_CHUNK_STARTED.to_string(); + let started: Vec = sequence(&trace) + .into_iter() + .filter(|(_, event)| *event == chunk_started) + .map(|(section, _)| section) + .collect(); + assert_eq!( + started, + ["First", "Second"], + "a root start enqueues itself once, so no section starts twice" + ); +} + #[test] fn sections_run_in_fall_through_order() { // A section without a return falls through to the next section in diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs index efa9a8a5d..f811826dc 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs @@ -96,7 +96,7 @@ fn a_script_calls_an_unoffered_adhoc_tool_by_id_or_object_before_and_after_the_r fn a_model_issued_adhoc_tool_call_reports_under_its_call_id() { let (scenario, gateway) = rendered_scenario(one_section(&format!( "{ADD_ADHOC_GRAB}{RENDER}\ - return tools.call_as_model('call_7', 'grab', {{ value = 'hi' }})" + return tools.call_as_model('call_7', 'grab', {{ value = 'hi' }}, 1)" ))); let trace = scenario.chat(&gateway).run(); assert_eq!(trace.text(), "got hi"); @@ -169,7 +169,7 @@ fn a_nested_adhoc_call_reports_both_results_innermost_first() { tools.offer(tools.adhoc({{ name = 'outer', description = 'Outer', parameters = {{ value = 'string' }} }}, function(args) return 'outer ' .. tools.call('adhoc/inner', {{ value = args.value }}) end)) - {RENDER}return tools.call_as_model('call_1', 'outer', {{ value = 'hi' }})", + {RENDER}return tools.call_as_model('call_1', 'outer', {{ value = 'hi' }}, 1)", ))); let trace = scenario.chat(&gateway).run(); assert_eq!(trace.text(), "outer inner hi"); @@ -212,7 +212,7 @@ fn a_bound_failure_inside_a_model_issued_adhoc_call_raises_kind_tool_in_the_hand assert(not ok, 'the bound failure raises inside the handler') return err.kind end)) - {RENDER}return tools.call_as_model('call_1', 'grab', {{}})", + {RENDER}return tools.call_as_model('call_1', 'grab', {{}}, 1)", ))); let trace = scenario.offer("fail", failing_tool()).chat(&gateway).run(); assert_eq!( diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs index d69463397..e4ae9a144 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs @@ -1,6 +1,6 @@ //! Tests for the scheduler's `tool_call` arm: the model-issued form -//! (`call_id: Some`), which always resumes with content and reports its -//! `ToolResult` under the model's call id; the script form (`call_id: +//! (`model: Some`), which always resumes with content and reports its +//! `ToolResult` under the model's call id; the script form (`model: //! None`), which keeps the raise-at-call-site behavior; adhoc tools, //! whose handlers run inside the calling block coroutine - store calls, //! nested adhoc calls, and bound calls included - with no leaf work of @@ -84,7 +84,7 @@ fn a_failing_bound_tool_with_a_call_id_resumes_with_untrusted_failure_text() { "tools.offer('tools/failing') messages:user('go') models.loop() - local out = tools.call_as_model('call_1', 'fail', {}) + local out = tools.call_as_model('call_1', 'fail', {}, 1) assert(type(out) == 'string', 'a model-issued call always resumes with content') return out", ); diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs index 05ad20381..80e4e8835 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_loop-scope-gate.rs @@ -45,19 +45,23 @@ fn model_calling_an_offered_but_unscoped_tool_is_a_hard_error() { Error::OutOfScopeToolCall { name, global_exists, - in_scope, + has_dynamic_set, + static_aliases, } => { assert_eq!(name, "global_tool"); assert!(*global_exists, "the name is an offered tool's wire name"); - assert_eq!(in_scope, &["scoped".to_owned()]); + assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); + assert_eq!(static_aliases, &["scoped".to_owned()]); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } + let message = error.to_string(); assert!( - error - .to_string() - .ends_with("[\"scoped\"] (a catalog tool that was not offered in this section)"), - "error message must hint offered-but-unscoped: {error}" + message.ends_with( + "aliases this section offers: [\"scoped\"] \ + (a catalog tool that was not offered in this section)" + ), + "the alias list ends before the offered-but-unscoped hint: {message}" ); assert_eq!(gateway.call_count(), 1, "the rejected round is the last"); } @@ -79,23 +83,25 @@ fn model_calling_an_advertised_tool_by_canonical_id_gets_no_not_offered_suffix() Error::OutOfScopeToolCall { name, global_exists, - in_scope, + has_dynamic_set, + static_aliases, } => { assert_eq!(name, "tools/scoped"); assert!(!*global_exists, "an id is no offered tool's wire name"); - assert_eq!(in_scope, &["scoped".to_owned()]); + assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); + assert_eq!(static_aliases, &["scoped".to_owned()]); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } let message = error.to_string(); - assert!( - message.ends_with("[\"scoped\"]"), - "error message must end with the in-scope list: {message}" - ); assert!( !message.contains("not offered in this section"), "an advertised tool's id must not hint offered-but-unscoped: {message}" ); + assert!( + message.ends_with("aliases this section offers: [\"scoped\"]"), + "the alias list ends the message: this section has no Dynamic Set: {message}" + ); } #[test] @@ -113,14 +119,16 @@ fn model_calling_a_defined_but_unoffered_adhoc_tool_gets_no_not_offered_suffix() Error::OutOfScopeToolCall { name, global_exists, - in_scope, + has_dynamic_set, + static_aliases, } => { assert_eq!(name, "grab"); assert!( !*global_exists, "an adhoc tool's name is no catalog tool's wire name" ); - assert_eq!(in_scope, &["scoped".to_owned()]); + assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); + assert_eq!(static_aliases, &["scoped".to_owned()]); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } @@ -181,25 +189,95 @@ fn model_calling_a_dynamic_set_tool_by_its_wire_name_gets_text_and_issues_no_cal #[test] fn with_dynamic_offers_a_name_outside_both_sets_fails_listing_only_the_static_set() { - // The in-scope list names the Static Set, the two discovery tools - // included, and none of the Dynamic Set's tools. + // The Static Set is what the list names, the two discovery tools + // included, and none of the Dynamic Set's tools. The message names the + // discovery tools because this section has them. let gateway = ScriptedChat::new(vec![calls(&[("call_1", "rogue", "{}")])]); let trace = scoped_loop(DYNAMIC_OFFERS).chat(&gateway).run(); match trace.error().inner() { Error::OutOfScopeToolCall { name, global_exists, - in_scope, + has_dynamic_set, + static_aliases, } => { assert_eq!(name, "rogue"); assert!(!*global_exists, "the name is no offered tool"); - assert_eq!(in_scope, &["scoped", "GetDynamicTools", "CallDynamicTool"]); + assert!(*has_dynamic_set, "the section offers a Dynamic Set"); + assert_eq!( + static_aliases, + &["scoped", "GetDynamicTools", "CallDynamicTool"] + ); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } + let message = trace.error().inner().to_string(); + assert!( + message.ends_with( + "; a Dynamic Set tool is reached through GetDynamicTools and CallDynamicTool" + ), + "the message names the discovery tools this section offers: {message}" + ); assert_eq!(gateway.call_count(), 1, "the rejected round is the last"); } +#[test] +fn a_section_without_a_dynamic_set_is_told_nothing_about_the_discovery_tools() { + // A section whose Dynamic Set holds nothing freezes no discovery tool, + // so naming one would point a model at a tool its round cannot call. + let gateway = ScriptedChat::new(vec![calls(&[("call_1", "rogue", "{}")])]); + let trace = scoped_loop("").chat(&gateway).run(); + let message = trace.error().inner().to_string(); + assert!( + message.ends_with("aliases this section offers: [\"scoped\"]"), + "the message ends at the Static Set: {message}" + ); + assert!( + !message.contains("GetDynamicTools"), + "a section with no Dynamic Set names no discovery tool: {message}" + ); +} + +#[test] +fn a_static_tool_wearing_a_discovery_wire_name_does_not_claim_a_dynamic_set() { + // The discovery clause reports the section's Dynamic Set, not the + // aliases the Static Set happens to hold. A catalog tool may take a + // discovery tool's wire name while no Dynamic Set holds a tool, so a + // section offering both names as ordinary static aliases has no + // Dynamic Set to point at and must claim none. + let gateway = ScriptedChat::new(vec![calls(&[("call_1", "rogue", "{}")])]); + let source = one_section(&format!( + "tools.offer('tools/discover')\ntools.offer('tools/call')\n{LOOP_TO_TEXT}" + )); + let trace = loop_scenario(source) + .offer("GetDynamicTools", scoped_tool("discover", "Discover.").0) + .offer("CallDynamicTool", scoped_tool("call", "Call.").0) + .chat(&gateway) + .run(); + let error = trace.error().inner(); + match error { + Error::OutOfScopeToolCall { + has_dynamic_set, + static_aliases, + .. + } => { + assert!(!*has_dynamic_set, "the section holds no Dynamic Set tool"); + assert_eq!( + static_aliases, + &["GetDynamicTools".to_owned(), "CallDynamicTool".to_owned()], + "the discovery wire names are ordinary static aliases here" + ); + } + other => panic!("expected OutOfScopeToolCall, got {other:?}"), + } + let message = error.to_string(); + assert!( + !message.contains("a Dynamic Set tool is reached"), + "a section with no Dynamic Set claims no dynamic route, even when its \ + static aliases wear the discovery wire names: {message}" + ); +} + #[test] fn model_calling_pure_unknown_tool_is_a_hard_error() { let gateway = ScriptedChat::new(vec![calls(&[( @@ -217,11 +295,13 @@ fn model_calling_pure_unknown_tool_is_a_hard_error() { Error::OutOfScopeToolCall { name, global_exists, - in_scope, + has_dynamic_set, + static_aliases, } => { assert_eq!(name, "nonexistent"); assert!(!*global_exists, "the name is no offered tool"); - assert_eq!(in_scope, &["echo".to_owned()]); + assert!(!*has_dynamic_set, "the section offers no Dynamic Set"); + assert_eq!(static_aliases, &["echo".to_owned()]); } other => panic!("expected OutOfScopeToolCall, got {other:?}"), } diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs index 347b4290c..959401b54 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs @@ -27,7 +27,6 @@ fn offering_of(alias: &str, description: &str, tool: &FnTool) -> ToolSet { Vec::new(), BTreeMap::new(), ToolCatalog::new([info]).expect("one tool"), - BTreeMap::new(), ) } @@ -52,8 +51,8 @@ fn advertised(tools: &ToolSet, offered: &[OfferedTool]) -> Vec { fn offered_tools_are_not_injected_without_an_offer() { let (tool, _) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "capability", &tool); - let runtime = Mutex::new(runvil_lua::ToolRuntime::default()); - let effective = current_tool_bindings(&tools, &runtime, &AdhocTools::default()) + let runtime = Mutex::new(runvil_lua::Offers::default()); + let effective = current_static_set(&tools, &runtime, &AdhocTools::default()) .expect("the scope must snapshot"); assert!( effective.is_empty(), @@ -69,12 +68,12 @@ fn an_offered_tool_advertises_its_concrete_schema_under_its_wire_name_and_dispat ]); let (tool, called) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "Concrete description.", &tool); - let mut runtime = runvil_lua::ToolRuntime::default(); + let mut runtime = runvil_lua::Offers::default(); runtime.add_tool( ToolId::parse("tools/concrete").expect("a fixture tool id"), OfferMode::Static, ); - let effective = current_tool_bindings(&tools, &Mutex::new(runtime), &AdhocTools::default()) + let effective = current_static_set(&tools, &Mutex::new(runtime), &AdhocTools::default()) .expect("the offered scope snapshots"); let schemas = advertised(&tools, &effective); assert_eq!(schemas.len(), 1); @@ -122,7 +121,7 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { .inject_values("", &json!({}), &fresh_access()) .expect("values must inject"); - // The section's `tools.offer` lands in its tool runtime; the scope + // The section's `tools.offer` lands in its offers; the scope // snapshot over it includes the offered tool's wire name. let offer = LuaProgram::compile( "tools.offer('tools/section')", @@ -134,9 +133,9 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { .expect("the offer chunk must compile"); vm.run_chunk(&offer, &null_emitter(), "Only") .expect("tools.offer must succeed"); - let (tool_set, tool_runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&tool_set, &tool_runtime, vm.adhoc_tools()) - .expect("tool scope must snapshot"); + let (tool_set, offers) = vm.tool_bag_handles(); + let scope = + current_static_set(&tool_set, &offers, vm.adhoc_tools()).expect("tool scope must snapshot"); let schemas = advertised(&tool_set, &scope); assert_eq!(schemas.len(), 1); assert_eq!(schemas[0].name(), "tools_section"); diff --git a/crates/runvil-internal/engine/src/execute/turn.rs b/crates/runvil-internal/engine/src/execute/turn.rs index 6637e0376..1dc481d4f 100644 --- a/crates/runvil-internal/engine/src/execute/turn.rs +++ b/crates/runvil-internal/engine/src/execute/turn.rs @@ -4,14 +4,13 @@ use std::sync::atomic::{AtomicU32, Ordering}; -use runvil_model_client::detail::completion_into_result; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; use runvil_types::event::{Event, ReplyOrigin}; use runvil_types::ids::RoundId; use runvil_types::metrics::CallMetrics; -use crate::model::{Completion, CompletionResult}; +use crate::model::{Completion, CompletionResult, detail}; /// Advances the shared turn counter, returning the 1-based index of the /// turn just started. The counter saturates at `u32::MAX`, so a run past @@ -19,11 +18,9 @@ use crate::model::{Completion, CompletionResult}; /// small ones. pub(super) fn advance_turn(turns: &AtomicU32) -> u32 { turns - .try_update(Ordering::Relaxed, Ordering::Relaxed, |current| { - Some(current.saturating_add(1)) + .update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.saturating_add(1) }) - // The closure always returns `Some`, so the fallback never applies. - .unwrap_or(u32::MAX) .saturating_add(1) } @@ -105,7 +102,7 @@ pub(super) fn report_model_turn( model, metrics, }; - let outcome = completion_into_result(completion); + let outcome = detail::completion_into_result(completion); if let CompletionResult::Text(text) = &outcome { if served.finish_reason.as_deref() == Some("length") { emitter.report(section, lifecycle::MODEL_TURN_TRUNCATED); diff --git a/crates/runvil-internal/engine/src/execute/walk_target.rs b/crates/runvil-internal/engine/src/execute/walk_target.rs index 8dc9361cc..7c39402a3 100644 --- a/crates/runvil-internal/engine/src/execute/walk_target.rs +++ b/crates/runvil-internal/engine/src/execute/walk_target.rs @@ -16,7 +16,7 @@ use crate::heading_address::{self, Reachable, SectionIndex, SectionPos, SlicePath, Visible}; use crate::lua::{Library, LibraryBlock, LibraryKey, SectionText}; -use crate::parser::{Block, Prompt, Section}; +use crate::parser::{Block, Prompt, Section, detail}; use crate::{Error, Result}; /// The position of `target` in `slice`, matched on the parser-unique @@ -31,7 +31,7 @@ pub(super) fn section_position(slice: &[Section], target: &Section) -> Option &[Section] { - runvil_parser::detail::sections(prompt) + detail::sections(prompt) } /// What a prompt read sees of the section `heading` names in a caller's @@ -152,7 +152,7 @@ impl RequireOrigin { /// What a `require` from here can name in `prompt`. fn reach<'p>(&'p self, prompt: &'p Prompt) -> Reachable<'p> { - let root = runvil_parser::detail::sections(prompt); + let root = detail::sections(prompt); match self { Self::H1 => Reachable::top(root), Self::Section(pos) => Reachable::of(root, pos), diff --git a/crates/runvil-internal/engine/src/heading_address.rs b/crates/runvil-internal/engine/src/heading_address.rs index b25233002..448a43f4c 100644 --- a/crates/runvil-internal/engine/src/heading_address.rs +++ b/crates/runvil-internal/engine/src/heading_address.rs @@ -21,7 +21,7 @@ //! A chain and a section's `prompt` reads find their slice by path and //! read it in place. -use crate::parser::{Prompt, Section}; +use crate::parser::{Prompt, Section, detail}; use crate::{Error, Result}; /// Where a sibling slice sits in the prompt tree: the index of each @@ -55,7 +55,7 @@ impl SlicePath { /// always in range; an out-of-range index resolves to the empty slice, /// which the walk treats as exhausted rather than panicking. pub(crate) fn resolve<'p>(&self, prompt: &'p Prompt) -> &'p [Section] { - let mut slice = runvil_parser::detail::sections(prompt); + let mut slice = detail::sections(prompt); for &index in &self.0 { slice = slice.get(index).map_or(&[], Section::children); } @@ -150,9 +150,9 @@ pub(crate) struct Visible<'a> { impl<'a> Visible<'a> { /// A walked section's set: `home` minus `home[caller]`, plus - /// `home[caller]`'s children. `caller` must index `home`; an - /// out-of-range one yields all of `home` and no children, the same set - /// as [`Visible::all`]. + /// `home[caller]`'s children. `caller` must index `home`, which the + /// `debug_assert!` below enforces: a caller outside the slice is a + /// scheduler bug, not a case this view answers. pub(crate) fn of(home: &'a [Section], caller: SectionIndex) -> Visible<'a> { debug_assert!( caller.0 < home.len(), @@ -312,20 +312,15 @@ fn parse_heading_address(heading: &str) -> Result<(usize, String)> { } /// Looks up a heading string like `"### Name"` in a caller's visible set, -/// read in place through `visible`, returning the single matching section -/// or `None`. -/// -/// The heading is parsed into an exact `(level, name)` address; a section -/// matches only when BOTH its level and name are equal. A heading outside the -/// visible set and a heading that names no section anywhere are the same -/// `None`, because only the visible set is ever searched. More than one match -/// is rejected, so an ambiguous heading never resolves to an arbitrary first -/// hit. +/// returning the single section at its exact address or `None`. A heading +/// outside the visible set and a heading that names no section anywhere are +/// the same `None`, because only the visible set is ever searched. /// /// # Errors /// Returns [`Error::Lua`] when the heading is malformed (see /// [`parse_heading_address`]) or when more than one visible section matches -/// the exact address. +/// the exact address, so an ambiguous heading never resolves to an arbitrary +/// first hit. pub(crate) fn find_visible<'a>(heading: &str, visible: Visible<'a>) -> Result> { find_one(heading, visible.iter(), |section| *section, "visible") } @@ -389,10 +384,8 @@ fn not_found<'a>(heading: &str, available: impl Iterator) -> /// the surfaces that must reach a section, such as `call` and `jump`. /// /// # Errors -/// Returns [`Error::Lua`] for every [`find_visible`] error, and when no -/// visible section matches the exact address. The not-found message lists the -/// view's sections in its order and nothing else, so the error channel -/// cannot leak the rest of the document's structure. +/// Returns [`Error::Lua`] for every [`find_visible`] error, and, through +/// [`not_found`], when no visible section matches the exact address. pub(crate) fn resolve_visible<'a>(heading: &str, visible: Visible<'a>) -> Result<&'a Section> { find_visible(heading, visible)?.ok_or_else(|| not_found(heading, visible.iter())) } @@ -401,9 +394,8 @@ pub(crate) fn resolve_visible<'a>(heading: &str, visible: Visible<'a>) -> Result /// for `require`. /// /// # Errors -/// Returns [`Error::Lua`] for every [`find_reachable`] error, and when no -/// reachable section matches the exact address. The not-found message -/// lists the reach in its order and nothing else. +/// Returns [`Error::Lua`] for every [`find_reachable`] error, and, through +/// [`not_found`], when no reachable section matches the exact address. pub(crate) fn resolve_reachable<'a>(heading: &str, reach: Reachable<'a>) -> Result> { find_reachable(heading, reach)? .ok_or_else(|| not_found(heading, reach.iter().map(|reached| reached.section))) diff --git a/crates/runvil-internal/engine/src/lib.rs b/crates/runvil-internal/engine/src/lib.rs index 97577199d..49079b10c 100644 --- a/crates/runvil-internal/engine/src/lib.rs +++ b/crates/runvil-internal/engine/src/lib.rs @@ -2,11 +2,11 @@ //! parsed prompt. //! //! A run executes the Lua under the `#` title once, then walks the sections -//! top to bottom. It issues every model round, Pack render, catalog tool -//! call, store operation, and timer as an [`Effect`] the Harness performs -//! and answers, and reports every boundary as an event returned from -//! `step`. Other crates reach these -//! items through the `runvil` facade. +//! top to bottom. It issues each model round, Pack render, Pack tool call, +//! `store` call outside a prelude's install, and timer as an [`Effect`] the +//! Harness performs and answers, and reports every boundary as an event +//! returned from `step`. Other crates reach these items through the +//! `runvil` facade. //! //! ## Invariants //! @@ -15,7 +15,6 @@ //! `runvil-model-client`. `cargo test -p build-xtask` enforces the //! product and container boundaries. -mod cancel; mod error; mod execute; mod heading_address; diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 89e66c173..780b17d79 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -1,17 +1,31 @@ //! The executor's import surface for `runvil-lua`, which implements the //! sandboxed section VM, the Engine globals, and the coroutine protocol; //! that crate documents them. +//! +//! This module is the one surface production code imports that crate +//! through: an item the Engine needs is re-exported here, and a production +//! module names `crate::lua` rather than `runvil_lua`. +//! +//! Three groups still name `runvil_lua` itself: +//! +//! - The error bridge, `error::convert` and `error::value`, which maps the +//! Lua crate's error type variant for variant and implements its +//! [`ErrorValue`](runvil_lua::ErrorValue) trait on this crate's error. +//! - Test-only code: the `#[cfg(test)]` shim install in `execute::section_vm`, +//! which reaches `runvil_lua::test_support`, and this crate's test support, +//! which takes `ToolOutputKind` from the crate root. +//! - The test suites, which build VM fixtures by hand. pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, - Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelReport, NonEmpty, - OfferMode, OfferedTool, OverflowReason, ProseState, QuotaResource, RenderedTools, Request, - ScriptReport, SectionText, SectionVm, SpawnVia, TaskDelivery, TaskPhase, TaskStatus, - ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, - VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, - current_tool_bindings, exhaustion_detail, install_preludes, install_section_loop_shim, - install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, - prepare_model_dispatch, project_messages, render_item, resolve_model_binding, wire_name_of, + Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, ModelReport, + NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, QuotaResource, RenderedTools, + Request, RosterEntry, ScriptReport, SectionText, SectionVm, SpawnVia, TaskDelivery, TaskPhase, + TaskStatus, ToolBinding, ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, + UsageAnchor, VfsOp, VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, + current_static_set, exhaustion_detail, install_preludes, install_store_shims, install_ui, + output_reserve, precheck, prepare_dispatch, prepare_model_dispatch, project_messages, + render_item, resolve_model_binding, wire_name_of, }; // The store operation behind `execute::perform_vfs_op`, the entry point diff --git a/crates/runvil-internal/engine/src/lua/tests.rs b/crates/runvil-internal/engine/src/lua/tests.rs index 3b7a45b14..9574768ec 100644 --- a/crates/runvil-internal/engine/src/lua/tests.rs +++ b/crates/runvil-internal/engine/src/lua/tests.rs @@ -24,13 +24,14 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use mlua::{MultiValue, Thread}; +use runvil_lua::test_support::answer_envelope; use runvil_lua::{Error, LuaProgram}; use runvil_types::tools::{ToolId, ToolInfo}; use runvil_types::untrusted::GuardNonce; use serde_json::json; use crate::execute::section_vm::{SectionVmSetup, VmSeed, setup_section_vm}; -use crate::lua::{Answer, Library, Request, SectionText, SectionVm, ToolSet, YieldParse}; +use crate::lua::{Answer, Argv, Library, Request, SectionText, SectionVm, ToolSet, YieldParse}; use crate::model::ModelSet; use crate::test_support::fresh_access; use crate::test_support::recording::null_emitter; @@ -38,10 +39,9 @@ use crate::test_support::scenario::{TEST_SEED, test_binding}; #[track_caller] fn test_models() -> ModelSet { - ModelSet::from_parts( - vec![test_binding("fast", "a fast model", "test-model")], - None, - ) + let mut set = ModelSet::default(); + set.push(test_binding("fast", "a fast model", "test-model")); + set } /// A minimal tool info behind a bound alias, for handle-form @@ -64,7 +64,6 @@ fn test_tools() -> ToolSet { Vec::new(), std::collections::BTreeMap::new(), runvil_types::tools::ToolCatalog::new([stub_tool()]).expect("one tool"), - std::collections::BTreeMap::new(), ) } @@ -88,8 +87,7 @@ fn scheduler_vm_with_tools( section: "Test", tools, models, - argv: None, - argv_writable: false, + argv: Argv::Frozen(None), seed: VmSeed { var, item: None }, ui: None, }) @@ -101,8 +99,7 @@ struct VmBinds<'a> { section: &'a str, tools: &'a ToolSet, models: &'a ModelSet, - argv: Option<&'a serde_json::Value>, - argv_writable: bool, + argv: Argv<'a>, seed: VmSeed<'a>, ui: Option<&'a Arc>, } @@ -126,7 +123,6 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { let setup = SectionVmSetup { args: "", argv: binds.argv, - argv_writable: binds.argv_writable, sys: &sys, access: &access, seed: binds.seed, @@ -184,12 +180,12 @@ fn parse_request(vm: &SectionVm, yielded: MultiValue) -> Request { } } -/// Renders `answer` as the shim's envelope and resumes the thread with it. +/// Renders the successful `answer` as the shim's envelope and resumes the +/// thread with it. #[track_caller] fn resume_with(vm: &SectionVm, thread: &Thread, answer: Answer) -> MultiValue { - let (envelope, _retained) = answer - .into_envelope(vm.lua()) - .expect("the envelope renders"); + let (envelope, _retained) = + answer_envelope(vm.lua(), Ok(answer)).expect("the envelope renders"); thread .resume::(envelope) .expect("the coroutine accepts the answer") diff --git a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs index 513fee852..c1e986b4e 100644 --- a/crates/runvil-internal/engine/src/lua/tests/coroutine.rs +++ b/crates/runvil-internal/engine/src/lua/tests/coroutine.rs @@ -5,8 +5,8 @@ //! state rolling forward across block coroutines. use runvil_lua::{Error, LuaProgram}; +use runvil_types::cancel::CancelHandle; -use crate::cancel::CancelHandle; use crate::lua::{CoroStep, LuaBlockResult, Request, YieldParse}; use crate::model::ModelSet; diff --git a/crates/runvil-internal/engine/src/lua/tests/errors.rs b/crates/runvil-internal/engine/src/lua/tests/errors.rs index 62f402186..cd7f0618c 100644 --- a/crates/runvil-internal/engine/src/lua/tests/errors.rs +++ b/crates/runvil-internal/engine/src/lua/tests/errors.rs @@ -10,9 +10,10 @@ use std::num::NonZeroU32; use mlua::MultiValue; +use runvil_lua::test_support::answer_envelope; use runvil_lua::{Error, ErrorKind, LuaProgram}; -use crate::lua::{Answer, CoroStep, LuaBlockResult}; +use crate::lua::{CoroStep, LuaBlockResult}; use crate::model::ModelSet; use crate::test_support::recording::null_emitter; @@ -128,7 +129,7 @@ fn a_failure_envelope_raises_a_table_holding_the_kind_and_fields() { }; let display = error.to_string(); match vm - .resume_block_coro_answer::(&program, &thread, Answer::Infer(Err(error))) + .resume_block_coro_answer::(&program, &thread, Err(error)) .expect("the pcall'd block resumes") { CoroStep::Done(LuaBlockResult::Returned(Some(text))) => { @@ -261,12 +262,12 @@ fn a_typed_error_substituted_at_the_coroutine_boundary_keeps_its_kind() { else { panic!("the shim yield must suspend the block"); }; - let answer = Answer::Infer(Err(Error::LuaQuota { - resource: runvil_lua::QuotaResource::Instruction, - })); + let answer = Err(Error::LuaQuota { + resource: runvil_lua::QuotaResource::LogEvent, + }); match vm.resume_block_coro_answer::(&program, &thread, answer) { Err(Error::LuaQuota { - resource: runvil_lua::QuotaResource::Instruction, + resource: runvil_lua::QuotaResource::LogEvent, }) => {} other => panic!("block {source:?} must surface the typed quota error, got {other:?}"), } @@ -287,10 +288,12 @@ fn a_structured_raise_surfacing_as_the_coroutine_failure_keeps_its_table() { else { panic!("the shim yield must suspend the block"); }; - let (envelope, _retained) = Answer::::Infer(Err(Error::ContextExhausted { - reason: "precheck".to_owned(), - })) - .into_envelope(vm.lua()) + let (envelope, _retained) = answer_envelope::( + vm.lua(), + Err(Error::ContextExhausted { + reason: "precheck".to_owned(), + }), + ) .expect("the envelope renders"); match vm.resume_block_coro(&program, &thread, envelope) { Err(Error::Raised(raised)) => { diff --git a/crates/runvil-internal/engine/src/lua/tests/globals.rs b/crates/runvil-internal/engine/src/lua/tests/globals.rs index fbbc20933..e88d7a4ee 100644 --- a/crates/runvil-internal/engine/src/lua/tests/globals.rs +++ b/crates/runvil-internal/engine/src/lua/tests/globals.rs @@ -20,7 +20,7 @@ use runvil_types::tools::{ToolId, ToolInfo}; use super::{VmBinds, set_up_vm}; use crate::execute::section_vm::VmSeed; -use crate::lua::{SectionVm, ToolSet}; +use crate::lua::{Argv, SectionVm, ToolSet}; use crate::model::ModelSet; use crate::test_support::scenario::test_binding; @@ -30,13 +30,11 @@ const ROLE_LABELS: [&str; 2] = ["writer", "tools"]; /// One role bound under each of [`ROLE_LABELS`]. fn roles() -> ModelSet { - ModelSet::from_parts( - ROLE_LABELS - .iter() - .map(|label| test_binding(label, "a role", "test-model")) - .collect(), - None, - ) + let mut set = ModelSet::default(); + for label in ROLE_LABELS { + set.push(test_binding(label, "a role", "test-model")); + } + set } /// The default wire names of the tools in [`offering`]'s catalog. @@ -57,26 +55,23 @@ fn offering() -> ToolSet { std::collections::BTreeMap::new(), runvil_types::tools::ToolCatalog::new([tool("gh/search"), tool("web/search")]) .expect("the ids differ"), - std::collections::BTreeMap::new(), ) } /// Builds a section VM through the real setup path with every conditional /// Engine global bound: a caller-supplied application-state snapshot -/// (`ui`), a collection member (`item`), a non-nil `argv` (writable as in -/// the H1 pass, or frozen as in every other section), a block's lazy -/// `prose`, the model roles in [`ROLE_LABELS`], the tools of +/// (`ui`), a collection member (`item`), a non-nil `argv` in the given +/// mode (writable as in the H1 pass, or frozen as in every other section), +/// a block's lazy `prose`, the model roles in [`ROLE_LABELS`], the tools of /// [`offering`], and a `require` whose resolver reaches no library. -fn fully_bound_vm(argv_writable: bool) -> SectionVm { - let argv = json!({ "prose": "" }); +fn fully_bound_vm(argv: Argv<'_>) -> SectionVm { let item = json!("member"); let ui = Arc::new(json!({})); let vm = set_up_vm(&VmBinds { section: "Globals", tools: &offering(), models: &roles(), - argv: Some(&argv), - argv_writable, + argv, seed: VmSeed { var: None, item: Some(&item), @@ -102,15 +97,19 @@ fn raw_global_names(vm: &SectionVm) -> Vec { #[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); + let argv = json!({ "prose": "" }); + for (mode, argv) in [ + ("frozen", Argv::Frozen(Some(&argv))), + ("writable", Argv::Writable(Some(&argv))), + ] { + let vm = fully_bound_vm(argv); for name in raw_global_names(&vm) { assert!( matches!( reserved_name(&name), Some(Reserved::EngineGlobal | Reserved::LuaGlobal) ), - "section setup (argv writable: {argv_writable}) installs the global `{name}`, \ + "section setup (argv {mode}) installs the global `{name}`, \ which RESERVED_NAMES does not list as an Engine or Lua global; list it there" ); } @@ -119,8 +118,12 @@ fn every_global_section_setup_leaves_in_g_is_a_reserved_name() { #[test] fn every_reserved_global_is_present_after_section_setup() { - for argv_writable in [false, true] { - let vm = fully_bound_vm(argv_writable); + let argv = json!({ "prose": "" }); + for (mode, argv) in [ + ("frozen", Argv::Frozen(Some(&argv))), + ("writable", Argv::Writable(Some(&argv))), + ] { + let vm = fully_bound_vm(argv); let globals = vm.lua().globals(); for (name, kind) in RESERVED_NAMES { if kind == Reserved::LuaKeyword { @@ -130,7 +133,7 @@ fn every_reserved_global_is_present_after_section_setup() { 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" + (argv {mode}) reads it as nil; drop it or install it" ); } } @@ -138,8 +141,12 @@ fn every_reserved_global_is_present_after_section_setup() { #[test] fn no_role_label_is_a_global_after_section_or_h1_setup() { - for argv_writable in [false, true] { - let vm = fully_bound_vm(argv_writable); + let argv = json!({ "prose": "" }); + for (mode, argv) in [ + ("frozen", Argv::Frozen(Some(&argv))), + ("writable", Argv::Writable(Some(&argv))), + ] { + let vm = fully_bound_vm(argv); let (writer, tools, call): (String, String, String) = vm .lua() .load("return type(writer), type(tools), type(tools.call)") @@ -149,21 +156,25 @@ fn no_role_label_is_a_global_after_section_or_h1_setup() { (writer.as_str(), tools.as_str(), call.as_str()), ("nil", "table", "function"), "a role label installs no global and leaves a same-named Engine global in place \ - (argv writable: {argv_writable})" + (argv {mode})" ); } } #[test] fn no_offered_tool_is_a_global_after_section_or_h1_setup() { - for argv_writable in [false, true] { - let vm = fully_bound_vm(argv_writable); + let argv = json!({ "prose": "" }); + for (mode, argv) in [ + ("frozen", Argv::Frozen(Some(&argv))), + ("writable", Argv::Writable(Some(&argv))), + ] { + let vm = fully_bound_vm(argv); let names = raw_global_names(&vm); for wire_name in OFFERED_WIRE_NAMES { assert!( !names.iter().any(|name| name == wire_name), "the offered tool `{wire_name}` installs no global \ - (argv writable: {argv_writable})" + (argv {mode})" ); } let ids: String = vm @@ -173,15 +184,19 @@ fn no_offered_tool_is_a_global_after_section_or_h1_setup() { .expect("the tool objects read"); assert_eq!( ids, "gh/search,web/search", - "the tools read through `tools.get` (argv writable: {argv_writable})" + "the tools read through `tools.get` (argv {mode})" ); } } #[test] fn models_get_returns_the_role_handle_after_section_or_h1_setup() { - for argv_writable in [false, true] { - let vm = fully_bound_vm(argv_writable); + let argv = json!({ "prose": "" }); + for (mode, argv) in [ + ("frozen", Argv::Frozen(Some(&argv))), + ("writable", Argv::Writable(Some(&argv))), + ] { + let vm = fully_bound_vm(argv); for label in ROLE_LABELS { let (kind, name, model_id): (String, String, String) = vm .lua() @@ -194,7 +209,7 @@ fn models_get_returns_the_role_handle_after_section_or_h1_setup() { (kind.as_str(), name.as_str(), model_id.as_str()), ("userdata", label, "test-model"), "models.get({label:?}) hands back the role's handle \ - (argv writable: {argv_writable})" + (argv {mode})" ); } } diff --git a/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs b/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs index d711bfc03..d9ae13ee7 100644 --- a/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs +++ b/crates/runvil-internal/engine/src/lua/tests/models_loop_contract.rs @@ -1,7 +1,7 @@ //! The `models.loop` contract case no real round produces: an empty answer //! that names no empty detail, which the model client refuses before it //! can reach the loop. The loop's yields are driven by hand, each answer -//! rendered through `Answer::into_envelope`. The loop's other contract +//! rendered through `test_support::answer_envelope`. The loop's other contract //! cases run end to end in `execute/tests/models_loop_contract.rs`. use runvil_lua::Error; @@ -13,11 +13,11 @@ use super::{parse_request, resume_with, scheduler_vm, start, test_models}; /// A completed round with no reply, no tool calls, and no empty detail, /// ending for `finish_reason`. fn empty_round(finish_reason: Option<&str>) -> Answer { - Answer::Chat(Ok(Box::new(ChatResult { + Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::Empty(None), finish_reason: finish_reason.map(str::to_owned), turn: 1, - }))) + })) } #[test] diff --git a/crates/runvil-internal/engine/src/lua/tests/quota.rs b/crates/runvil-internal/engine/src/lua/tests/quota.rs index 09e1da4e2..b65f7c3c9 100644 --- a/crates/runvil-internal/engine/src/lua/tests/quota.rs +++ b/crates/runvil-internal/engine/src/lua/tests/quota.rs @@ -1,7 +1,7 @@ -//! The loop trampoline's instruction cost. `models.loop` runs on the -//! author's Lua instruction budget, but its rules run in Rust behind the -//! loop shim's trampoline, so the every-Nth-instruction hook counts only -//! the trampoline's own instructions: a handful per yield. This bounds +//! The loop trampoline's instruction cost. `models.loop`'s rules run in +//! Rust behind the loop shim's trampoline, so the every-Nth-instruction +//! cancel hook counts only the trampoline's own instructions: a handful +//! per yield. This bounds //! that cost, so the trampoline stays a dispatcher that holds no rule and //! the loop's bookkeeping never taxes an author's block. @@ -30,7 +30,7 @@ const ROUND_INSTRUCTION_CEILING: u64 = 60; /// per yield in the measured span (the `tool_calls` and the next `chat`). /// A round under this floor means the hook is not firing on the loop /// thread, and the test would otherwise pass while showing nothing about -/// the quota. +/// the trampoline's cost. const ROUND_INSTRUCTION_FLOOR: u64 = 2; /// Rounds measured after the first, so the assertion reads steady-state @@ -39,7 +39,7 @@ const MEASURED_ROUNDS: i64 = 3; /// A completed round that requested one `tools_echo` call. fn tool_call_round() -> Answer { - Answer::Chat(Ok(Box::new(ChatResult { + Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::ToolCalls(vec![ToolCallEvent::new( "call_1", "tools_echo", @@ -47,16 +47,16 @@ fn tool_call_round() -> Answer { )]), finish_reason: Some("tool_calls".to_owned()), turn: 1, - }))) + })) } /// A completed round that produced the terminal reply. fn reply_round(text: &str) -> Answer { - Answer::Chat(Ok(Box::new(ChatResult { + Answer::Chat(Box::new(ChatResult { outcome: ChatOutcome::Reply(text.to_owned()), finish_reason: Some("stop".to_owned()), turn: 2, - }))) + })) } #[test] @@ -114,7 +114,7 @@ fn a_models_loop_round_costs_the_trampoline_a_few_lua_instructions_per_yield() { let yielded = resume_with( &vm, &thread, - Answer::ToolCallsResult(Ok(vec!["echoed".to_owned()])), + Answer::ToolCallsResult(vec!["echoed".to_owned()]), ); assert!( matches!(parse_request(&vm, yielded), Request::Chat { .. }), diff --git a/crates/runvil-internal/engine/src/lua/tests/shims.rs b/crates/runvil-internal/engine/src/lua/tests/shims.rs index 4370366f1..7bc677445 100644 --- a/crates/runvil-internal/engine/src/lua/tests/shims.rs +++ b/crates/runvil-internal/engine/src/lua/tests/shims.rs @@ -165,16 +165,13 @@ fn tools_call_yields_a_well_formed_request() { // protocol's ToolCall variant with the author's args as JSON. let vm = scheduler_vm(&ModelSet::default(), None); match yielded_request(&vm, r#"return tools.call("tools/echo", { value = "hi" })"#) { - Request::ToolCall { - alias, - args, - call_id, - turn, - } => { + Request::ToolCall { alias, args, model } => { assert_eq!(alias, "tools/echo"); assert_eq!(args, json!({ "value": "hi" })); - assert_eq!(call_id, None, "a script call leaves the call id unset"); - assert_eq!(turn, None, "a script call leaves the turn unset"); + assert_eq!( + model, None, + "a script call leaves the call id and turn unset" + ); } other => panic!("expected a tool_call request, got {other:?}"), } @@ -202,19 +199,19 @@ fn an_adhoc_tool_handler_runs_inside_the_block_coroutine() { return out .. '|' .. tostring(withheld) .. '|' .. tostring(jump ~= nil)", ); match parse_request(&vm, yielded) { - Request::ToolCall { alias, call_id, .. } => { + Request::ToolCall { alias, model, .. } => { assert_eq!(alias, "adhoc/grab"); - assert_eq!(call_id, None); + assert_eq!(model, None); } other => panic!("the block's first yield is the tool call, got {other:?}"), } let yielded = resume_with( &vm, &thread, - Answer::ToolCallResult(Ok(ToolCallOutcome::Adhoc { + Answer::ToolCallResult(ToolCallOutcome::Adhoc { alias: "grab".to_owned(), args: json!({ "value": "hi" }), - })), + }), ); match parse_request(&vm, yielded) { Request::Store { @@ -225,7 +222,7 @@ fn an_adhoc_tool_handler_runs_inside_the_block_coroutine() { } other => panic!("the handler's store call yields from the block, got {other:?}"), } - let yielded = resume_with(&vm, &thread, Answer::Store(Ok(VfsOutcome::Unit))); + let yielded = resume_with(&vm, &thread, Answer::Store(VfsOutcome::Unit)); match parse_request(&vm, yielded) { Request::AdhocToolDone { outcome: AdhocToolOutcome::Returned(text), @@ -235,7 +232,7 @@ fn an_adhoc_tool_handler_runs_inside_the_block_coroutine() { let returned = resume_with( &vm, &thread, - Answer::ToolCallResult(Ok(ToolCallOutcome::Plain("stored hi".to_owned()))), + Answer::ToolCallResult(ToolCallOutcome::Plain("stored hi".to_owned())), ); let text: String = vm .lua() @@ -263,7 +260,7 @@ fn the_loops_tool_call_yields_carry_the_turn_of_the_requesting_round() { finish_reason: Some("tool_calls".to_owned()), turn: 7, }; - let yielded = resume_with(&vm, &thread, Answer::Chat(Ok(Box::new(round)))); + let yielded = resume_with(&vm, &thread, Answer::Chat(Box::new(round))); match parse_request(&vm, yielded) { Request::ToolCalls { calls, turn } => { let ids: Vec<&str> = calls.iter().map(|call| call.id.as_str()).collect(); @@ -278,7 +275,7 @@ fn the_loops_tool_call_yields_carry_the_turn_of_the_requesting_round() { let yielded = resume_with( &vm, &thread, - Answer::ToolCallsResult(Ok(vec!["echoed".to_owned(), "echoed".to_owned()])), + Answer::ToolCallsResult(vec!["echoed".to_owned(), "echoed".to_owned()]), ); assert!(matches!(parse_request(&vm, yielded), Request::Chat { .. })); } @@ -322,8 +319,8 @@ fn tools_call_rejects_a_first_argument_that_is_neither_a_string_nor_a_tool() { let (_thread, yielded) = start(&vm, "return tools.call(42, {})"); let value = yielded.into_iter().next().expect("one yielded value"); match Request::from_yield(vm.lua(), &value) { - YieldParse::Call(answer) => { - let message = format!("{answer:?}"); + YieldParse::Refused(error) => { + let message = format!("{error:?}"); assert!( message.contains("tools.call takes a tool id or a tool object, got integer"), "the rejection names the expected forms: {message}" diff --git a/crates/runvil-internal/engine/src/model.rs b/crates/runvil-internal/engine/src/model.rs index 9b0d399fe..3e770be98 100644 --- a/crates/runvil-internal/engine/src/model.rs +++ b/crates/runvil-internal/engine/src/model.rs @@ -1,32 +1,37 @@ //! The model vocabulary a caller exchanges with a run: what a `Chat` effect -//! includes ([`Message`], [`ToolSchema`], [`CompletionOptions`], -//! [`ModelBinding`]) and what its answer returns ([`Completion`], -//! [`CompletionResult`], [`CompletionError`]), plus the prompt-local -//! binding vocabulary ([`ModelSet`], [`ModelView`], [`ModelInvocation`]) -//! and the model identity a run's roles are bound to ([`ModelInfo`], -//! [`ModelId`]). +//! includes ([`Message`], [`ToolSchema`], [`ModelBinding`]) and what its +//! answer returns ([`Completion`], [`CompletionResult`], +//! [`CompletionError`]), plus the prompt-local binding vocabulary +//! ([`ModelSet`], [`ModelView`], [`ModelInvocation`]) and the model +//! identity a run's roles are bound to ([`ModelInfo`], [`ModelId`]). //! -//! `models.default` sets the prompt-wide default role, conventionally -//! from the H1 body, and `models.use` sets a section entry's current -//! selection, which a later call replaces. A `models.infer` or -//! `models.loop` round with neither fails with a model-binding failure -//! surfaced through [`crate::RunError`]. +//! This module is the executor's import surface for the private +//! `runvil-model-client` crate, which holds the round and binding +//! vocabulary, and for the model identity `runvil-types` defines. Production +//! code reaches both through here: an item the Engine needs is re-exported, +//! and a production module names `crate::model` rather than the dependency. //! -//! The round and binding vocabulary comes from the private -//! `runvil-model-client` crate, and [`ModelId`], [`ModelInfo`], and -//! [`ThinkingMode`] from `runvil-types`; both are re-exported here. The -//! transport codec a round's performer runs (the request body builder, -//! the read loop over a transport's chunk source, and the error type it -//! builds a [`CompletionError`] from) is not: the facade publishes it from -//! `runvil-model-client` directly. The Engine itself never performs a -//! completion. +//! Two groups still name the dependency crates themselves: +//! +//! - This crate's test support and its test suites, which also need names +//! the production surface does not re-export, such as +//! `CompletionOptions`, `RawExchange`, `Role`, and `ToolSchemaError`. +//! - The crate's bench, `benches/models_loop.rs`, a separate crate that +//! cannot see a `pub(crate)` surface. +//! +//! Performing a round, its transport codec included, is the caller's job. +//! The Engine never performs a completion. pub(crate) use runvil_model_client::client::{ Completion, CompletionResult, Message, ToolCall, ToolSchema, }; +// The helpers behind the validating constructors and the round's reply +// checks, which the Engine calls where the rule each implements is +// documented. +pub(crate) use runvil_model_client::detail; pub(crate) use runvil_model_client::model::{ - CompletionError, CompletionErrorKind, CompletionOptions, ModelBinding, ModelInvocation, - ModelSet, ModelView, Temperature, + CompletionError, CompletionErrorKind, ModelBinding, ModelInvocation, ModelSet, ModelView, + Temperature, }; pub(crate) use runvil_types::models::{ModelId, ModelInfo, ThinkingMode}; diff --git a/crates/runvil-internal/engine/src/model/tests-always.rs b/crates/runvil-internal/engine/src/model/tests-always.rs index f61d84c24..cb3c303da 100644 --- a/crates/runvil-internal/engine/src/model/tests-always.rs +++ b/crates/runvil-internal/engine/src/model/tests-always.rs @@ -1,6 +1,7 @@ //! `models.default` (label form) integration tests. use super::*; +use runvil_model_client::model::CompletionOptions; #[test] fn models_default_takes_a_label_and_parks_the_prompt_wide_default() { diff --git a/crates/runvil-internal/engine/src/model/tests.rs b/crates/runvil-internal/engine/src/model/tests.rs index fb9c4c4f0..8fbf36c9a 100644 --- a/crates/runvil-internal/engine/src/model/tests.rs +++ b/crates/runvil-internal/engine/src/model/tests.rs @@ -9,7 +9,7 @@ use crate::test_support::fresh_access; use crate::test_support::recording::null_emitter; use crate::test_support::scenario::TEST_SEED; use crate::{Error, Result}; -use runvil_model_client::model::{CompletionOptions, ModelInvocation}; +use runvil_model_client::model::ModelInvocation; use runvil_types::emitter::Emitter; use runvil_types::untrusted::GuardNonce; use serde_json::json; @@ -58,7 +58,11 @@ fn bound_role( /// Shares a model set the way the run shares its own. fn shared_models(bindings: Vec) -> Arc> { - Arc::new(Mutex::new(ModelSet::from_parts(bindings, None))) + let mut set = ModelSet::default(); + for binding in bindings { + set.push(binding); + } + Arc::new(Mutex::new(set)) } /// Shares an empty tool set. diff --git a/crates/runvil-internal/engine/src/parser.rs b/crates/runvil-internal/engine/src/parser.rs index 1090bff84..e989102ce 100644 --- a/crates/runvil-internal/engine/src/parser.rs +++ b/crates/runvil-internal/engine/src/parser.rs @@ -1,5 +1,23 @@ //! The executor's import surface for `runvil-parser`, which parses a //! prompt file into a [`Prompt`] tree; that crate documents the prompt //! format. +//! +//! This module is the one surface production code imports that crate +//! through: an item or helper the Engine needs is re-exported here, and a +//! production module names `crate::parser` rather than `runvil_parser`. The +//! `detail` module is re-exported whole, since its helpers are the parser's +//! own algorithms, called where the rule each one implements is documented. +//! +//! Three groups still name `runvil_parser` itself: +//! +//! - `error::convert`, the error bridge, which maps the parser's error type +//! variant for variant. +//! - The test suites, which import the prompt fixtures under +//! `runvil_parser::test_support` and call a `detail` helper directly. +//! - The crate's bench, `benches/models_loop.rs`, a separate crate that +//! cannot see a `pub(crate)` surface. -pub(crate) use runvil_parser::{Block, BodyText, ParseError, ParseErrorKind, Prompt, Section}; +pub(crate) use runvil_parser::detail; +pub(crate) use runvil_parser::{ + Block, BodyText, ModelKeyword, ParseError, ParseErrorKind, Prompt, Section, +}; diff --git a/crates/runvil-internal/engine/src/subst-tests.rs b/crates/runvil-internal/engine/src/subst-tests.rs index 56ef704c5..3acd04ae1 100644 --- a/crates/runvil-internal/engine/src/subst-tests.rs +++ b/crates/runvil-internal/engine/src/subst-tests.rs @@ -117,6 +117,10 @@ fn nil_argv_is_an_error() { let e = substitute_inner(prose, &test_sources(&var, &sys)).unwrap_err(); assert_eq!(e.kind, SubstErrorKind::NilArgv, "path {prose:?}"); assert!(e.to_string().contains("argv"), "names argv: {e}"); + assert!( + e.to_string().contains("is nil (argv holds no value)"), + "names the nil cause: {e}" + ); } } diff --git a/crates/runvil-internal/engine/src/subst.rs b/crates/runvil-internal/engine/src/subst.rs index 508bfa512..2ac5c5532 100644 --- a/crates/runvil-internal/engine/src/subst.rs +++ b/crates/runvil-internal/engine/src/subst.rs @@ -3,8 +3,9 @@ //! When a section's Lua first reads the lazy `prose` value, the Engine //! resolves `{{ path }}` placeholders in the pending Markdown template. Lua //! source is never substituted. Five sources are available: `args` (the single -//! raw input string), `argv` (its parsed JSON form, nil when the args did not -//! parse), `item` (the item seeded into this section - a fanout arm's or a +//! raw input string), `argv` (the chain's `argv`: the derived value in the H1 +//! pass, the value the H1 left behind on the walk; nil when it holds no +//! value), `item` (the item seeded into this section - a fanout arm's or a //! `tasks.spawn` task's first entry - nil otherwise), `var` (values the //! section's Lua wrote), and `sys` (runtime-provided metadata). An unknown //! first segment resolves as a bare global: a section-local Lua global @@ -51,14 +52,16 @@ enum SubstErrorKind { /// A key path found no value: under `var`, `sys`, `argv`, or a bare /// global. MissingKey, - /// The resolved value was JSON null. + /// The resolved value was JSON null, for a `var`, `sys`, `argv`, or bare + /// global source. A null `item` is not this: `{{ item }}` renders it as + /// the text `null`. NullValue, /// `{{ item }}` was used with no item seeded into this section. NilItem, - /// `{{ argv }}` was used when the args string did not parse (or H1 left - /// `argv` nil). + /// An `argv` placeholder, bare or dotted, was used while `argv` was nil. NilArgv, - /// A bare global was not JSON data. + /// The bare-global lookup failed: the global cannot become JSON data, the + /// name is `prose` itself, or the lookup raised. Serialize, } @@ -124,27 +127,25 @@ type SubstResult = std::result::Result; /// The value sources `{{ }}` placeholders resolve against. /// -/// `var` and `sys` are JSON objects (`var` read back from the section's -/// Lua, `sys` built by the runtime). `argv` is the parsed form of the args -/// string - the run's frozen value on the walk - or `None` when the args -/// did not parse; `{{ argv }}` renders the whole value and `{{ argv.key }}` -/// indexes it, with a nil `argv` a hard error. `item` is the item seeded into -/// this section (a fanout arm's or a `tasks.spawn` task's first entry), or -/// `None` otherwise; it renders per [`render_item`](crate::lua::render_item). -/// `globals` resolves a bare global by name: `Ok(None)` when unset, -/// `Ok(Some(_))` with its JSON form when set. +/// `var` and `sys` are JSON objects. pub(crate) struct Sources<'a> { /// The raw args string: `{{ args }}`. pub(crate) args: &'a str, - /// The parsed form of the args string: `{{ argv }}` and dotted paths. + /// The chain's `argv`, which the H1 pass may repair: `{{ argv }}` and + /// dotted paths. pub(crate) argv: Option<&'a Value>, - /// The item seeded into this section: `{{ item }}`. + /// The item seeded into this section on a fanout arm's or a + /// `tasks.spawn` task's first entry, rendered per + /// [`render_item`](crate::lua::render_item): `{{ item }}`. `None` on + /// every other entry. pub(crate) item: Option<&'a Value>, /// The section's `var` table, read back from its Lua. pub(crate) var: &'a Value, /// The runtime-provided metadata: `{{ sys.key }}`. pub(crate) sys: &'a Value, - /// The bare-global lookup for `{{ name }}` resolution. + /// The bare-global lookup for `{{ name }}` resolution: `Ok(None)` when + /// unset, `Ok(Some(_))` with its JSON form when set, and `Err` when the + /// lookup fails, which a placeholder reports as `Serialize`. pub(crate) globals: &'a dyn Fn(&str) -> Result>, } @@ -154,12 +155,11 @@ pub(crate) struct Sources<'a> { /// untouched. /// /// # Errors -/// Returns [`Error::Substitution`](crate::Error::Substitution) for an unclosed -/// `{{`, an unknown namespace or missing bare global, an empty or whitespace -/// path segment, a bare `{{ var }}` or `{{ sys }}` with no key, a key path -/// after `args` or `item`, a missing key, a null value, a non-JSON bare -/// global, `{{ item }}` when `item` is `None`, or `{{ argv }}` when `argv` is -/// `None`. +/// Returns [`Error::Substitution`](crate::Error::Substitution) holding a +/// [`SubstitutionError`] whose kind is a [`SubstErrorKind`] variant, which is +/// the complete list of what a placeholder can fail with. The error carries +/// the byte offset of the offending placeholder and a message with its +/// bounded, control-escaped path preview. pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { Ok(substitute_inner(prose, sources)?) } @@ -210,7 +210,7 @@ fn nil_argv<'a>(argv: Option<&'a Value>, path: &str, offset: usize) -> SubstResu SubstErrorKind::NilArgv, offset, format!( - "{{{{ {} }}}} is nil (the args string is not JSON)", + "{{{{ {} }}}} is nil (argv holds no value)", path_preview(path) ), ) @@ -291,8 +291,6 @@ fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult (path, None), }; - // An unknown first segment resolves as a bare global: the caller's lookup - // reads the section-local Lua global and converts it to JSON. let global_value; let root = match namespace { "var" => sources.var, diff --git a/crates/runvil-internal/engine/src/test_support.rs b/crates/runvil-internal/engine/src/test_support.rs index 7252ea03d..9a34a5571 100644 --- a/crates/runvil-internal/engine/src/test_support.rs +++ b/crates/runvil-internal/engine/src/test_support.rs @@ -13,8 +13,11 @@ //! //! The `test-support` feature compiles those three alone, for //! `benches/models_loop.rs`, its only consumer: a bench is a separate -//! target that cannot see `#[cfg(test)]` code. The rest is this crate's -//! own test plumbing. `scenario` builds a whole run from its setup as data +//! target that cannot see `#[cfg(test)]` code. Only the items that bench +//! names stay `pub`; the reply builders and the request accessors are +//! `pub(crate)`, so the in-crate suites reach them and the bench cannot. +//! The rest is this crate's own test plumbing. `scenario` builds a whole +//! run from its setup as data //! and drives it on the serial driver, and `rig` drives a scenario's run //! with its effects tapped, its held effects released in the order a test //! chooses or its answers shuffled under a seed, and its scheduler in @@ -33,12 +36,18 @@ use crate::execute::{ use crate::lua::run_store_op; #[cfg(test)] -pub mod recording; +pub(crate) mod recording; #[cfg(test)] pub(crate) mod rig; #[cfg(test)] pub(crate) mod scenario; -#[path = "test_support/scripted-chat.rs"] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the reply builders and the request accessors are read only by the in-crate suites; the non-test `test-support` build compiles this module for the bench, which names only `ScriptedChat`, `ScriptedReply`, its variant fields, `new`, `complete_now`, `call_count`, `drive`, and `perform_locally`" + ) +)] pub mod scripted_chat; #[cfg(test)] pub(crate) mod seeded; @@ -48,7 +57,7 @@ pub(crate) mod temp_root; mod tools; #[cfg(test)] -pub use recording::forward; +pub(crate) use recording::forward; #[cfg(test)] pub(crate) use tools::FnTool; diff --git a/crates/runvil-internal/engine/src/test_support/recording.rs b/crates/runvil-internal/engine/src/test_support/recording.rs index 1b265feca..484584890 100644 --- a/crates/runvil-internal/engine/src/test_support/recording.rs +++ b/crates/runvil-internal/engine/src/test_support/recording.rs @@ -25,9 +25,9 @@ use serde_json::Value; mod forward; mod observation; -pub use forward::forward; +pub(crate) use forward::forward; pub(crate) use forward::forward_one; -pub use observation::Observation; +pub(crate) use observation::Observation; #[cfg(test)] pub(crate) use observation::detail; @@ -35,7 +35,7 @@ pub(crate) use observation::detail; /// /// Each method has a default body that discards the report, so a recorder /// pays only for the hooks it overrides. -pub trait Observer: Send + Sync { +pub(crate) trait Observer: Send + Sync { /// Records one typed [`Observation`] for `execution` and `section`. fn observe(&self, execution: &str, section: &str, event: Observation); @@ -59,75 +59,75 @@ pub trait Observer: Send + Sync { /// One completed assistant reply, as [`Observer::on_assistant_reply`] /// receives it. #[derive(Debug, Clone, Copy)] -pub struct ReplyReport<'a> { +pub(crate) struct ReplyReport<'a> { /// The caller-chosen run identifier. - pub execution: &'a str, + pub(crate) execution: &'a str, /// The event's reporting scope. - pub section: &'a str, + pub(crate) section: &'a str, /// The model-turn counter the reply was produced under. - pub turn: u32, + pub(crate) turn: u32, /// The reply text. - pub text: &'a str, + pub(crate) text: &'a str, /// The provider's stop label, when it sent one. - pub finish_reason: Option<&'a str>, + pub(crate) finish_reason: Option<&'a str>, /// The model that produced the reply. - pub model: &'a str, + pub(crate) model: &'a str, /// The call's measurements, when any were reported. - pub metrics: Option<&'a CallMetrics>, + pub(crate) metrics: Option<&'a CallMetrics>, /// The kind of round that produced the reply. - pub origin: ReplyOrigin, + pub(crate) origin: ReplyOrigin, } /// One batch of tool calls the model requested, as /// [`Observer::on_assistant_tool_calls`] receives it. #[derive(Debug, Clone, Copy)] -pub struct ToolCallsReport<'a> { +pub(crate) struct ToolCallsReport<'a> { /// The caller-chosen run identifier. - pub execution: &'a str, + pub(crate) execution: &'a str, /// The event's reporting scope. - pub section: &'a str, + pub(crate) section: &'a str, /// The model-turn counter the batch was requested under. - pub turn: u32, + pub(crate) turn: u32, /// The model that requested the calls. - pub model: &'a str, + pub(crate) model: &'a str, /// The calls, unexecuted. - pub calls: &'a [ToolCallEvent], + pub(crate) calls: &'a [ToolCallEvent], } /// One dispatched tool call's result, as [`Observer::on_tool_result`] /// receives it. #[derive(Debug, Clone, Copy)] -pub struct ToolResultReport<'a> { +pub(crate) struct ToolResultReport<'a> { /// The caller-chosen run identifier. - pub execution: &'a str, + pub(crate) execution: &'a str, /// The event's reporting scope. - pub section: &'a str, + pub(crate) section: &'a str, /// The model-turn counter the call was dispatched under. - pub turn: u32, + pub(crate) turn: u32, /// The provider-issued tool-call id the result answers. - pub tool_call_id: &'a str, + pub(crate) tool_call_id: &'a str, /// The alias the call named. - pub alias: &'a str, + pub(crate) alias: &'a str, /// The tool's output. - pub content: &'a str, + pub(crate) content: &'a str, /// Whether the dispatch treated the tool's output as trusted. - pub trust: OutputTrust, + pub(crate) trust: OutputTrust, } /// One completed block of model thinking, as [`Observer::on_thinking`] /// receives it. #[derive(Debug, Clone, Copy)] -pub struct ThinkingReport<'a> { +pub(crate) struct ThinkingReport<'a> { /// The caller-chosen run identifier. - pub execution: &'a str, + pub(crate) execution: &'a str, /// The event's reporting scope. - pub section: &'a str, + pub(crate) section: &'a str, /// The model-turn counter the block was produced under. - pub turn: u32, + pub(crate) turn: u32, /// The model that produced it. - pub model: &'a str, + pub(crate) model: &'a str, /// The thinking text. - pub text: &'a str, + pub(crate) text: &'a str, } /// An emitter over a sink nobody drains: the silent stand-in a suite diff --git a/crates/runvil-internal/engine/src/test_support/recording/forward.rs b/crates/runvil-internal/engine/src/test_support/recording/forward.rs index c4157644c..0c55ed945 100644 --- a/crates/runvil-internal/engine/src/test_support/recording/forward.rs +++ b/crates/runvil-internal/engine/src/test_support/recording/forward.rs @@ -115,7 +115,7 @@ macro_rules! debug_variants { } /// Replays `events`, in order, onto `observer`, dropping the debug pair. -pub fn forward(events: Vec, observer: &dyn Observer) { +pub(crate) fn forward(events: Vec, observer: &dyn Observer) { forward_impl(events, observer, None); } diff --git a/crates/runvil-internal/engine/src/test_support/recording/observation.rs b/crates/runvil-internal/engine/src/test_support/recording/observation.rs index 00805e650..0ada73aa8 100644 --- a/crates/runvil-internal/engine/src/test_support/recording/observation.rs +++ b/crates/runvil-internal/engine/src/test_support/recording/observation.rs @@ -93,7 +93,7 @@ pub(crate) mod detail { /// rendering is the stable trace string the suites compare. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] -pub enum Observation { +pub(crate) enum Observation { /// Prompt parsing began. ParseStarted, /// Prompt parsing and parse-time compilation completed successfully. @@ -250,7 +250,7 @@ impl Observation { /// Returns `None` for [`Observation::Lua`] and [`Observation::Other`], /// which hold a message. #[must_use] - pub fn label(&self) -> Option<&'static str> { + pub(crate) fn label(&self) -> Option<&'static str> { let label = match self { Observation::ParseStarted => "Parse started", Observation::ParseSucceeded => "Parse succeeded", diff --git a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs index 8e1217f73..7913876ee 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario-driving.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario-driving.rs @@ -63,13 +63,13 @@ impl Answers { fn perform(&self, effect: &Effect) -> EffectAnswer { match effect { Effect::Chat { + binding, messages, tools, - options, .. } => match &self.chat { Some(Chat::Script(chat)) => { - let answer = chat.complete_now(messages, tools, options); + let answer = chat.complete_now(messages, tools, &binding.completion_options()); EffectAnswer::Chat(answer.map(Box::new)) } Some(Chat::Closure(answer)) => answer(effect), diff --git a/crates/runvil-internal/engine/src/test_support/scripted-chat.rs b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs similarity index 76% rename from crates/runvil-internal/engine/src/test_support/scripted-chat.rs rename to crates/runvil-internal/engine/src/test_support/scripted_chat.rs index cd1494f8c..b8f5a3222 100644 --- a/crates/runvil-internal/engine/src/test_support/scripted-chat.rs +++ b/crates/runvil-internal/engine/src/test_support/scripted_chat.rs @@ -3,12 +3,11 @@ //! The Engine emits each round as a `Chat` effect for the caller to perform. //! [`ScriptedChat`] answers each round from a fixed script instead, with //! no HTTP and no wire code, and records what every round carried so a -//! suite asserts on the effect rather than on a request body. The -//! empty-reply wording copies the gateway client's reader, because the -//! Engine cannot depend on the gateway client; the two copies change -//! together. The reply constructors beside it - -//! [`text`], [`text_finish`], [`calls`], and [`fail`] - write the script. -//! The bench answers its rounds from the same model through the +//! suite asserts on the effect rather than on a request body. The reply +//! constructors beside it - `text`, `text_finish`, `calls`, and `fail` - +//! write the script, and they stay crate-private because only the in-crate +//! suites use them; the bench builds its one reply from the variant +//! directly. The bench answers its rounds from the same model through the //! `test-support` feature. use std::sync::{Arc, Mutex, PoisonError}; @@ -18,24 +17,20 @@ use runvil_model_client::model::{CompletionError, CompletionErrorKind, Completio use runvil_types::metrics::CallMetrics; use serde_json::Value; -/// The specific the reader adds to an empty reply whose reasoning it -/// ignored as answer text. -const REASONING_IGNORED: &str = "reasoning content was present but ignored"; - -/// The model every scripted reply names as its server. -pub const MOCK_MODEL: &str = "mock-model"; +/// The server model the reply builders name. +pub(crate) const MOCK_MODEL: &str = "mock-model"; /// A text reply naming [`MOCK_MODEL`], with no finish reason, reasoning, /// or metrics. #[must_use] -pub fn text(content: &str) -> ScriptedReply { +pub(crate) fn text(content: &str) -> ScriptedReply { mock_text(content, None) } /// A text reply naming [`MOCK_MODEL`] that the backend stopped with /// `finish_reason`. #[must_use] -pub fn text_finish(content: &str, finish_reason: &str) -> ScriptedReply { +pub(crate) fn text_finish(content: &str, finish_reason: &str) -> ScriptedReply { mock_text(content, Some(finish_reason)) } @@ -59,7 +54,7 @@ fn mock_text(content: &str, finish_reason: Option<&str>) -> ScriptedReply { /// When a call's arguments are not JSON. #[must_use] #[track_caller] -pub fn calls(calls: &[(&str, &str, &str)]) -> ScriptedReply { +pub(crate) fn calls(calls: &[(&str, &str, &str)]) -> ScriptedReply { let calls = calls .iter() .map(|&(id, name, arguments)| { @@ -77,16 +72,13 @@ pub fn calls(calls: &[(&str, &str, &str)]) -> ScriptedReply { /// A failed round of `kind`, with HTTP `status` and response `body`. /// /// For every kind it builds the kind's phrase with the status appended and -/// the body as the detail, skipping the gateway client's classification -/// (no credentials wording for 401 and 403, no parsed overflow counts), so -/// each test names its kind directly. +/// the body as the detail, so each test names its kind directly. #[must_use] -pub fn fail(kind: CompletionErrorKind, status: u16, body: &str) -> ScriptedReply { +pub(crate) fn fail(kind: CompletionErrorKind, status: u16, body: &str) -> ScriptedReply { ScriptedReply::Failure { kind, message: format!("{} (status {status})", kind.phrase()), detail: Some(body.to_owned()), - finish_reason: None, } } @@ -126,20 +118,13 @@ pub enum ScriptedReply { message: String, /// The provider text behind the failure, when there is some. detail: Option, - /// The backend's stop label, when it supplied one. - finish_reason: Option, }, } impl ScriptedReply { /// Answers a round with this reply: the answer /// [`ScriptedChat::complete_now`] gives. - /// - /// # Errors - /// Returns the scripted failure, the `EmptyReply` failure for an empty - /// text reply, and the `InvalidParts` failure for a tool call the - /// validating constructors refuse. - pub fn answer(&self) -> Result { + pub(crate) fn answer(&self) -> Result { match self { ScriptedReply::Text { model, @@ -148,9 +133,13 @@ impl ScriptedReply { reasoning, metrics, } => { - let reasoning = reasoning.as_deref().filter(|text| !text.is_empty()); + let reasoning = reasoning.as_deref().filter(|text| !text.trim().is_empty()); if content.trim().is_empty() { - return Err(empty_reply(reasoning.is_some(), finish_reason.as_deref())); + let error = CompletionError::empty_reply(reasoning.is_some()); + return Err(match finish_reason { + Some(reason) => error.with_finish_reason(reason), + None => error, + }); } let mut completion = Completion::from_result(CompletionResult::Text(content.clone()), model)?; @@ -176,46 +165,26 @@ impl ScriptedReply { kind, message, detail, - finish_reason, } => { let mut error = CompletionError::new(*kind, message); if let Some(detail) = detail { error = error.with_detail(detail); } - if let Some(reason) = finish_reason { - error = error.with_finish_reason(reason); - } Err(error) } } } } -/// The empty-reply failure the reader raises for a turn with no text and -/// no tool calls. -fn empty_reply(reasoning_present: bool, finish_reason: Option<&str>) -> CompletionError { - let phrase = CompletionErrorKind::EmptyReply.phrase(); - let message = if reasoning_present { - format!("{phrase}: {REASONING_IGNORED}") - } else { - phrase.to_owned() - }; - let error = CompletionError::new(CompletionErrorKind::EmptyReply, message); - match finish_reason { - Some(reason) => error.with_finish_reason(reason), - None => error, - } -} - /// What one round's `Chat` effect carried. #[derive(Clone, Debug)] -pub struct ScriptedCall { +pub(crate) struct ScriptedCall { /// The conversation the round sent. - pub messages: Vec, + pub(crate) messages: Vec, /// The tools the round advertised, in order. - pub tools: Vec, + pub(crate) tools: Vec, /// The options the round ran under. - pub options: CompletionOptions, + pub(crate) options: CompletionOptions, } impl ScriptedCall { @@ -225,14 +194,14 @@ impl ScriptedCall { /// # Panics /// When a message does not serialize. #[must_use] - pub fn messages_json(&self) -> Value { + pub(crate) fn messages_json(&self) -> Value { serde_json::to_value(&self.messages) .unwrap_or_else(|error| panic!("a message serializes to JSON: {error}")) } /// The wire names of the tools the round advertised, in order. #[must_use] - pub fn tool_names(&self) -> Vec<&str> { + pub(crate) fn tool_names(&self) -> Vec<&str> { self.tools.iter().map(ToolSchema::name).collect() } } @@ -276,7 +245,7 @@ impl ScriptedChat { /// Every recorded round, in the order the rounds arrived. #[must_use] - pub fn requests(&self) -> Vec { + pub(crate) fn requests(&self) -> Vec { self.requests .lock() .unwrap_or_else(PoisonError::into_inner) @@ -285,7 +254,7 @@ impl ScriptedChat { /// The most recently recorded round, if any. #[must_use] - pub fn last_request(&self) -> Option { + pub(crate) fn last_request(&self) -> Option { self.requests .lock() .unwrap_or_else(PoisonError::into_inner) diff --git a/crates/runvil-internal/lua/AGENTS.md b/crates/runvil-internal/lua/AGENTS.md index 41c839c34..fe2d3d81d 100644 --- a/crates/runvil-internal/lua/AGENTS.md +++ b/crates/runvil-internal/lua/AGENTS.md @@ -1,4 +1,4 @@ # runvil-lua - Engine functions that would create a parser-to-Lua dependency cycle stay in this crate rather than `runvil-parser`. -- `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 Harness performs it, and `prepare_dispatch` (or `prepare_model_dispatch` under the model-issued rule) applies the rules when the answer lands. +- `prepare_dispatch` is the single tool-dispatch body used by every executor: synchronous, it applies the succeeded/failed observation, trust classification, the nonce wrap, and the `ToolResult` report to a bound tool's answer; the executor counts each call when it issues it, and adhoc and discovery tools report in the executor. Nothing in this crate performs a tool call: the executor issues the call as an effect, the Harness performs it, and `prepare_dispatch` (or `prepare_model_dispatch` under the model-issued rule) applies the rules when the answer lands. diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index 4373eb813..b30914e75 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -1,4 +1,4 @@ --- Coroutine-protocol shim prelude for a scheduler-mode section VM. +-- Coroutine-protocol shim prelude for a section VM. -- -- The Engine installs this after the Engine globals exist and before the -- Pack preludes. The chunk arguments are privileged captures, never @@ -201,13 +201,15 @@ end -- The model-issued form of the same dispatch: `call_id` is the id the model -- attached to its tool call, and `turn` the turn of the round that --- requested it. The driver always resumes a bound tool with content (a --- tool's own failure becomes untrusted failure text) and fires ToolResult --- under the id and that turn, however far an adhoc handler earlier in the --- batch moved the counter. Only the test-only `tools.call_as_model` hook --- calls it now, since the loop builds its `tool_call` requests in Rust; --- authors never see it, and a hand-built yield including `call_id` or --- `turn` is refused as a malformed request when its shape is wrong. +-- requested it, both required, since the live turn counter lives in the +-- Engine rather than in Lua. The driver always resumes a bound tool with +-- content (a tool's own failure becomes untrusted failure text) and fires +-- ToolResult under the id and that turn, however far an adhoc handler +-- earlier in the batch moved the counter. Only the test-only +-- `tools.call_as_model` hook calls it now, since the loop builds its +-- `tool_call` requests in Rust; authors never see it, and a hand-built +-- yield including `call_id` or `turn` is refused as a malformed request +-- when its shape is wrong. local function tools_call_as_model(call_id, alias_or_tool, args, turn) return dispatch_tool({ op = "tool_call", diff --git a/crates/runvil-internal/lua/src/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index a234d1b10..832cf9914 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -1,4 +1,4 @@ --- The `fanout` shim for a scheduler-mode section VM: Lua over the task +-- The `fanout` shim for a section VM: Lua over the task -- protocol (`spawn`, `join_any`, `cancel`), so every wait inside a fanout -- is an ordinary yield and the scheduler keeps no fanout state of its own. -- diff --git a/crates/runvil-internal/lua/src/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index fb99a120a..ae05f553f 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -1,4 +1,4 @@ --- The `tasks` namespace shims for a scheduler-mode section VM: spawn, the +-- The `tasks` namespace shims for a section VM: spawn, the -- waits, the non-blocking checks, the progress note, cancel, and the -- admission limit. -- diff --git a/crates/runvil-internal/lua/src/argv.rs b/crates/runvil-internal/lua/src/argv.rs index c82af322e..380bcf893 100644 --- a/crates/runvil-internal/lua/src/argv.rs +++ b/crates/runvil-internal/lua/src/argv.rs @@ -37,8 +37,7 @@ pub enum Argv<'a> { 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 -/// idiomatic malformed check. +/// (malformed args, or JSON null) installs nil. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the value cannot be bridged or installed. @@ -50,8 +49,8 @@ pub(crate) fn install_writable(lua: &Lua, argv: Option<&Json>) -> Result<()> { lua.globals().raw_set("argv", value).map_err(Error::lua) } -/// Installs `argv` frozen: reads work, every assignment raises. This is the -/// mode of every section but H1 - the value H1 left behind at the freeze. +/// Installs `argv` frozen, the mode of every section but H1: the value H1 +/// left behind at the freeze. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the value cannot be bridged or the guard diff --git a/crates/runvil-internal/lua/src/handles-tests.rs b/crates/runvil-internal/lua/src/bindings-tests.rs similarity index 93% rename from crates/runvil-internal/lua/src/handles-tests.rs rename to crates/runvil-internal/lua/src/bindings-tests.rs index 7be3ca313..ae62c8140 100644 --- a/crates/runvil-internal/lua/src/handles-tests.rs +++ b/crates/runvil-internal/lua/src/bindings-tests.rs @@ -11,7 +11,7 @@ use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::json; -use super::{ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; +use super::{RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; /// A tool info for `tools/fetch`. fn tool_info(structured: bool) -> ToolInfo { @@ -75,9 +75,13 @@ fn the_tool_set_reads_the_catalog_and_takes_each_tools_vendor_from_its_pack() { let id = |id: &str| ToolId::parse(id).expect("the id is valid"); let set = ToolSet::new( Vec::new(), - BTreeMap::new(), + BTreeMap::from([( + pack("tools"), + RosterEntry::Usable { + vendor: "acme".to_owned(), + }, + )]), ToolCatalog::new([tool_info(false)]).expect("one tool"), - BTreeMap::from([(pack("tools"), "acme".to_owned())]), ); assert_eq!( set.catalog() diff --git a/crates/runvil-internal/lua/src/handles.rs b/crates/runvil-internal/lua/src/bindings.rs similarity index 60% rename from crates/runvil-internal/lua/src/handles.rs rename to crates/runvil-internal/lua/src/bindings.rs index 5bf1ebe86..429afd786 100644 --- a/crates/runvil-internal/lua/src/handles.rs +++ b/crates/runvil-internal/lua/src/bindings.rs @@ -1,10 +1,9 @@ -//! Tool bindings, the shared tool set, and the per-binding output kind that -//! shape how bound tools reach Lua, and the section text a prompt read sees. +//! Tool bindings, the per-binding output kind, the run's shared tool set, +//! and how bound tools reach Lua. use std::borrow::Cow; use std::collections::BTreeMap; -use mlua::Value; use runvil_types::packs::PackId; use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo}; use serde_json::Value as Json; @@ -42,7 +41,7 @@ pub struct ToolBinding { alias: String, /// The tool's own catalog description. description: String, - /// The selected stable live identity. + /// The tool's id. id: ToolId, /// The JSON-Schema `object` the tool's arguments must match, advertised /// under the alias. @@ -91,7 +90,7 @@ impl ToolBinding { &self.description } - /// Returns the selected stable live identity. + /// Returns the tool's id. #[must_use] pub fn id(&self) -> &ToolId { &self.id @@ -134,117 +133,67 @@ pub fn wire_name_of(info: &ToolInfo) -> Cow<'_, str> { } } -/// Outcome of a Lua block that may invoke `jump`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LuaBlockResult { - /// Normal completion with an optional scalar return. - Returned(Option), - /// `jump` transferred control to this heading (`## Name`). - Jump(String), -} - -/// Resolves a `call` / `jump` target from a heading string. +/// How one roster Pack stands in its run, which the caller decides. /// -/// # Errors -/// Returns a Lua error when the value is not a string. -pub(crate) fn resolve_section_target(value: Value) -> mlua::Result { - match value { - Value::String(s) => Ok(s.to_str()?.to_owned()), - other => Err(mlua::Error::external(format!( - "section target must be a string, got {}", - other.type_name() - ))), - } -} - -/// What a prompt read sees of one visible section. +/// The two variants carry what the run needs of the Pack and nothing else, +/// so a Pack is either usable with a vendor or unavailable with a reason, +/// never both. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct SectionText { - /// The section's own raw Markdown. - pub markdown: String, - /// Its pre-parsed list items; empty for a section that isn't a list. - pub items: Vec, +pub enum RosterEntry { + /// The Pack can serve the run: the vendor the caller picked it from. + Usable { + /// The vendor, one segment, such as `runvil` or `acme`. + vendor: String, + }, + /// The Pack cannot serve the run: the reason, written for the model + /// that reads the refusal. + Unavailable { + /// Why it cannot serve. + reason: String, + }, } -/// Which library a `require` resolved to, opaque to this crate. -/// -/// The caller encodes a library section's position in the identity -/// string; `require`'s cache keys on that identity, never on the heading -/// text, so one heading resolved from two positions names two libraries. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct LibraryKey(String); - -impl LibraryKey { - /// Builds a key whose stable identity is `identity`. - #[must_use] - pub fn new(identity: String) -> Self { - Self(identity) - } - - /// Returns the identity the key was built from. +impl RosterEntry { + /// Returns the vendor when the Pack can serve the run. #[must_use] - pub fn identity(&self) -> &str { - &self.0 + pub(crate) fn vendor(&self) -> Option<&str> { + match self { + RosterEntry::Usable { vendor } => Some(vendor), + RosterEntry::Unavailable { .. } => None, + } } } -/// What `require` loads from one library section. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Library { - /// Which library this is: the cache key, and the position a `require` - /// made while it loads resolves from. - pub key: LibraryKey, - /// The library section's heading with its markers, such as - /// `## helpers`, which its load reports and a cycle error name. - pub heading: String, - /// The section's blocks in order. - pub blocks: Vec, -} - -/// One block of a library section. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LibraryBlock { - /// The block's compiled Lua. - pub program: crate::LuaProgram, - /// The block's prose template, the Markdown before it. - pub prose: String, -} - -/// The run's tool set: the declared Packs, the run's roster, the run -/// catalog, and each Pack's vendor. Nothing changes it during a run, so the -/// run and every section VM share one `Arc`. Nothing in it is -/// bound: each section entry binds the tools it offers when it freezes its -/// sets. +/// The run's tool set: the declared Packs, the run's roster, and the run +/// catalog. Nothing changes it during a run, so the run and every section +/// VM share one `Arc`. Nothing in it is bound: each section entry +/// binds the tools it offers when it freezes its sets. #[derive(Debug, Clone, Default)] pub struct ToolSet { /// The Packs the frontmatter declares, in declaration order. declared: Vec, - /// Every Pack the caller reports the run holds, with or without - /// tools: no reason when it can serve the run, and the reason, written - /// for the model, when it can't. - roster: BTreeMap>, + /// Every Pack the caller reports the run holds, with or without tools: + /// one entry per Pack, holding its vendor when it can serve the run and + /// the reason, written for the model, when it can't. + roster: BTreeMap, /// The run catalog: every catalog tool's `ToolInfo` by id, the handle /// the caller's context holds, which is what Lua sees. catalog: ToolCatalog, - /// The vendor the caller reported for each Pack that can serve. - vendors: BTreeMap, } impl ToolSet { /// Builds the run's tool set from the declared Packs in declaration - /// order, the roster, the run catalog, and each Pack's vendor. + /// order, the roster, and the run catalog. #[must_use] pub fn new( declared: Vec, - roster: BTreeMap>, + roster: BTreeMap, catalog: ToolCatalog, - vendors: BTreeMap, ) -> Self { Self { declared, roster, catalog, - vendors, } } @@ -254,10 +203,11 @@ impl ToolSet { &self.declared } - /// Returns every installed Pack the caller reports, with the reason, - /// written for the model, when it cannot serve the run. + /// Returns every installed Pack the caller reports, each with its + /// vendor when it can serve the run, or the reason, written for the + /// model, when it cannot. Tests read it through `test_support`. #[must_use] - pub(crate) fn roster(&self) -> &BTreeMap> { + pub(crate) fn roster(&self) -> &BTreeMap { &self.roster } @@ -269,16 +219,17 @@ impl ToolSet { /// Returns the vendor the caller reported for the Pack the tool `id` /// belongs to, `runvil` for the Engine's discovery tools, or `None` - /// when the caller reported none, as for an adhoc tool. + /// when the caller reported none, as for an adhoc tool or a Pack the + /// roster lists as unable to serve. #[must_use] pub fn vendor_for(&self, id: &ToolId) -> Option<&str> { if DiscoveryTool::of(id).is_some() { return Some(DiscoveryTool::VENDOR); } - self.vendors.get(&id.pack()).map(String::as_str) + self.roster.get(&id.pack()).and_then(RosterEntry::vendor) } } #[cfg(test)] -#[path = "handles-tests.rs"] +#[path = "bindings-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/collection.rs b/crates/runvil-internal/lua/src/collection.rs index e21d18213..ddcd77be3 100644 --- a/crates/runvil-internal/lua/src/collection.rs +++ b/crates/runvil-internal/lua/src/collection.rs @@ -12,11 +12,7 @@ use mlua::{Lua, LuaSerdeExt, Table, Value}; use crate::error::{Error, Result}; - -#[path = "collection-order.rs"] -mod order; - -pub(crate) use order::{KeyError, SortKey, sort_key}; +use crate::order::{KeyError, SortKey, sort_key}; /// Enumerates fanout's collection argument as the sequence of members the /// shim spawns arms over: the array part (`1..=#t`) in order, then the hash @@ -95,8 +91,8 @@ fn check_member(member: &Value, index: &str) -> Result<()> { } /// Renders a fanout arm's item for prose substitution and stub text: -/// strings verbatim, numbers and booleans in their natural string form, -/// arrays and objects as compact JSON. +/// strings verbatim, numbers and booleans in their natural string form, and +/// arrays, objects, and null as compact JSON. #[must_use] pub fn render_item(item: &serde_json::Value) -> String { match item { @@ -115,7 +111,7 @@ pub fn render_item(item: &serde_json::Value) -> String { /// heading reads exactly as `{{ item }}` would render the same member. /// /// # Errors -/// Returns [`Error::Lua`] when the value has no JSON form. +/// Returns [`Error::LuaRuntime`] when the value has no JSON form. pub(crate) fn render_item_value(lua: &Lua, item: Value) -> Result { let json: serde_json::Value = lua.from_value(item).map_err(Error::lua)?; Ok(render_item(&json)) diff --git a/crates/runvil-internal/lua/src/compactors-tests.rs b/crates/runvil-internal/lua/src/compactors-tests.rs index 230fabacb..5b0d52668 100644 --- a/crates/runvil-internal/lua/src/compactors-tests.rs +++ b/crates/runvil-internal/lua/src/compactors-tests.rs @@ -63,16 +63,7 @@ fn raised_reason(error: &mlua::Error) -> &str { } #[test] -fn the_omitted_compactor_defaults_to_fail() { - // The optional compactor parameter's default: omitting it selects - // `compactors.fail`. - // Resolve through a call boundary, exactly as the loop's optional - // compactor parameter resolves. - fn resolve(selected: Option) -> Compactor { - selected.unwrap_or_default() - } - assert_eq!(Compactor::default(), Compactor::Fail); - assert_eq!(resolve(None), Compactor::Fail); +fn fail_raises_typed_exhaustion_for_a_provider_overflow() { let error = fail_invoked(OverflowReason::Provider); assert!( matches!(&error, Error::ContextExhausted { reason } if reason == "provider"), @@ -172,17 +163,20 @@ fn the_fail_policy_invokes_as_typed_exhaustion_for_either_reason() { fn compactors_clear_removes_every_record_and_keeps_the_send_and_the_rendered_tools() { let list = MessageList::default(); list.push(user("one")).unwrap(); - list.attach_rendered_tools(RenderedTools::default()); + let _ = list.attach_rendered_tools(RenderedTools::default()); let sent = [Message::user("one")]; let _ = list.commit(RoundId::new(0), "test-model", &sent); let lua = lua_with_compactors_over(&list); list.set_compacting(true); lua.load("compactors.clear('manual')").exec().unwrap(); assert!(list.to_records().is_empty(), "every record is gone"); - assert!(list.rendered(), "the frozen sets stay attached"); + assert!( + list.rendered_tools().is_some(), + "the frozen sets stay attached" + ); assert_eq!( list.commit(RoundId::new(1), "test-model", &sent), - (Some(RoundId::new(0)), 1), + Some((RoundId::new(0), 1)), "the last send stays, so the next send still compares with it" ); list.push(user("two")).unwrap(); diff --git a/crates/runvil-internal/lua/src/compactors.rs b/crates/runvil-internal/lua/src/compactors.rs index f1932f9e3..7a9648144 100644 --- a/crates/runvil-internal/lua/src/compactors.rs +++ b/crates/runvil-internal/lua/src/compactors.rs @@ -95,16 +95,11 @@ pub fn exhaustion_detail(reason: &str) -> Cow<'_, str> { } } -/// The compactor policies the `compactors` global ships. -/// -/// `Fail` is the omitted-compactor default: invoked with any reason, it -/// always raises typed context exhaustion. `Clear` removes every record, -/// so a section can start its conversation over without leaving, and -/// refuses unless a compactor is running over the list. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// The compactor policies the `compactors` global ships; `Fail` is the one +/// the loop reads when the compactor argument is omitted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Compactor { /// `compactors.fail`: always raises [`Error::ContextExhausted`]. - #[default] Fail, /// `compactors.clear`: removes every record from the list, leaving its /// last send and its frozen sets. It works only inside a compactor. @@ -200,6 +195,7 @@ impl UsageAnchor { } /// The anchored token count: the request's prompt plus its reply. + #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn tokens(&self) -> u64 { self.tokens @@ -233,7 +229,7 @@ impl UsageAnchor { /// /// The estimate counts message text and tool-call argument characters; an /// image part contributes nothing (its token cost bears no relation to the -/// data-URI length), and tool schemas and the system template are not +/// URL's length), and tool schemas and the system template are not /// counted, so an image-heavy conversation can still overflow at the /// provider - the provider-overflow path covers it. /// @@ -293,13 +289,12 @@ fn message_chars(message: &Message) -> u64 { /// the structured error table before re-raising it, so the kind reaches /// author code and the Engine alike; a direct call the block does not /// catch keeps the kind through the block's failure mapping, so the run -/// ends as context exhaustion either way. `compactors.clear` empties `list`, -/// and only while a compactor runs over it. +/// ends as context exhaustion either way. /// The namespace installs during Engine injection, right after the /// section's `messages` list. /// /// # Errors -/// Returns [`Error::Lua`] if a function or the global install fails. +/// Returns [`Error::LuaRuntime`] if a function or the global install fails. pub(crate) fn install_compactors(lua: &Lua, globals: &Table, list: &MessageList) -> Result<()> { let compactors = lua.create_table().map_err(Error::lua)?; for (name, policy) in [("fail", Compactor::Fail), ("clear", Compactor::Clear)] { diff --git a/crates/runvil-internal/lua/src/coro-tests.rs b/crates/runvil-internal/lua/src/coro-tests.rs index 59ddf9efc..c4fc52306 100644 --- a/crates/runvil-internal/lua/src/coro-tests.rs +++ b/crates/runvil-internal/lua/src/coro-tests.rs @@ -1,8 +1,11 @@ -//! Tests that the shim chunk names stay pointed at the shim files. +//! Tests that the shim chunk names stay pointed at the shim files, and +//! that the one shim install puts `models.loop` and `models.compact` on +//! `models`. use super::{ FANOUT_CHUNK_NAME, FANOUT_SOURCE, SHIM_CHUNK_NAME, SHIM_SOURCE, TASKS_CHUNK_NAME, TASKS_SOURCE, }; +use crate::test_vm::TestVm; use crate::tests::assert_chunk_name_resolves; #[test] @@ -19,3 +22,15 @@ fn the_tasks_chunk_name_resolves_to_the_tasks_file() { fn the_fanout_chunk_name_resolves_to_the_fanout_file() { assert_chunk_name_resolves("FANOUT_CHUNK_NAME", FANOUT_CHUNK_NAME, FANOUT_SOURCE); } + +#[test] +fn the_yield_shim_install_puts_models_loop_and_models_compact_on_models() { + let vm = TestVm::new().shims().build(); + let (looped, compacted): (String, String) = + vm.eval("return type(models.loop), type(models.compact)"); + assert_eq!( + (looped.as_str(), compacted.as_str()), + ("function", "function"), + "the coroutine shim install alone leaves both shims on `models`" + ); +} diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index 0952f17a1..2b20e2e47 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -33,9 +33,9 @@ use crate::engine_globals::route_store_to_shims; use crate::error::SharedSource; use crate::error::{Error, Result}; use crate::error_value::{install_error_value, install_normalize_failure}; -use crate::hardening::InstructionBudget; +use crate::hardening::CancelHook; use crate::program::LuaProgram; -use crate::sys::var_snapshot_table; +use crate::var::var_snapshot_table; use failure::stash_failure_capture; pub(crate) use failure::take_failure; @@ -65,23 +65,15 @@ const FANOUT_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_fanout.l /// a captured cap, bounds how many arms run at once. const FANOUT_SOURCE: &str = include_str!("__impl_fanout.lua"); -/// The registry key for the shim's `loop`, stashed by the prelude install so -/// the section setup can install it as `models.loop`. -const LOOP_REGISTRY: &str = "runvil.impl_coro.loop"; - -/// The registry key for the shim's `compact`, stashed by the prelude -/// install so the section setup can install it as `models.compact`. -const COMPACT_REGISTRY: &str = "runvil.impl_coro.compact"; - /// The registry key for the shim's model-handle methods, `infer` and /// `loop`, stashed by the prelude install for [`handle_method`]. const HANDLE_METHODS_REGISTRY: &str = "runvil.impl_coro.handle_methods"; -/// The registry key for the shim's model-issued `tool_call` form, stashed -/// by the prelude install so a test driver can install it as -/// `tools.call_as_model` and drive the driver's `call_id` path from a -/// fixture section. The registry is Rust-side only, and no VM ever -/// installs it as a global. +/// The registry key for the shim's model-issued `tool_call` form, which +/// test builds stash for `install_model_tool_call_shim` to install as +/// `tools.call_as_model`, so a fixture section can drive the driver's +/// `call_id` path. +#[cfg(any(test, feature = "test-support"))] pub(crate) const MODEL_TOOL_CALL_REGISTRY: &str = "runvil.impl_coro.model_tool_call"; /// The registry key for the shim's store function table, stashed by the @@ -121,8 +113,8 @@ static FANOUT_PROGRAM: LazyLock> = /// Installs the yield shims on a VM whose Engine globals already exist. /// -/// Scheduler-mode VMs load the coroutine standard library for the shim's -/// `yield` capture (a VM without the shims keeps exactly +/// The coroutine standard library loads for the shim's `yield` capture (a +/// test VM that never installs the shims keeps exactly /// `STRING | TABLE | MATH | UTF8`); the `coroutine` global is stripped again /// before returning, so author code cannot yield directly and a hand-rolled /// yield fails the driver's strict validation. The `models` and `tools` @@ -134,9 +126,9 @@ static FANOUT_PROGRAM: LazyLock> = /// helpers, and installed as the `tasks` global; `fanout` is a third, run /// over the same captures plus the collection enumerator and the item /// renderer, and installed on the `tasks` table as `tasks.fanout`. The -/// `models.loop` and `models.compact` shims are stashed in the registry -/// for [`install_section_loop_shim`], and the chunk's `handle_methods` -/// table, a model handle's `infer` and `loop`, for [`handle_method`]. +/// `models.loop` and `models.compact` shims are installed on the `models` +/// table, and the chunk's `handle_methods` table, a model handle's `infer` +/// and `loop`, is stashed in the registry for [`handle_method`]. /// `max_tool_iterations`, the run's resolved round cap, the `compactors` /// table, and `loop_running`, the VM's flag that refuses a loop started /// while one runs, go to [`loop_begin`](crate::models_loop::loop_begin), @@ -156,7 +148,7 @@ static FANOUT_PROGRAM: LazyLock> = /// and down on `adhoc_handler_depth`, the counter the VM's `jump` reads, so /// `jump` refuses while an adhoc tool's handler runs, whatever reference /// the handler calls it through. Then `cancel_requested` reads the -/// cancel flag of `instruction_budget`, so the chunk's protected calls +/// cancel flag of `cancel_hook`, 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. Next, /// `is_model_handle` reports whether a value is a model handle, so the @@ -165,14 +157,14 @@ static FANOUT_PROGRAM: LazyLock> = /// call for the trampoline. /// /// # Errors -/// Returns [`Error::Lua`] if the coroutine library, the shim chunk, or any +/// Returns [`Error::LuaRuntime`] if the coroutine library, the shim chunk, or any /// install step fails. pub(crate) fn install_shim_prelude( lua: &Lua, max_tool_iterations: usize, adhoc_handler_depth: &Arc, loop_running: &Arc, - instruction_budget: &InstructionBudget, + cancel_hook: &CancelHook, ) -> Result<()> { lua.load_std_libs(StdLib::COROUTINE).map_err(Error::lua)?; let globals = lua.globals(); @@ -189,9 +181,9 @@ pub(crate) fn install_shim_prelude( let normalize_failure = install_normalize_failure(lua).map_err(Error::lua)?; let (enter_adhoc_handler, leave_adhoc_handler) = adhoc_handler_captures(lua, adhoc_handler_depth)?; - let budget = instruction_budget.clone(); + let hook = cancel_hook.clone(); let cancel_requested = lua - .create_function(move |_, ()| Ok(budget.is_cancelled())) + .create_function(move |_, ()| Ok(hook.is_cancelled())) .map_err(Error::lua)?; let is_model_handle = lua .create_function(|_, value: Value| Ok(crate::models::is_handle(&value))) @@ -201,16 +193,16 @@ pub(crate) fn install_shim_prelude( max_tool_iterations, compactors, loop_running, - instruction_budget, + cancel_hook, )?; - let compact_begin = crate::models_loop::compact_begin(lua, loop_running, instruction_budget)?; + let compact_begin = crate::models_loop::compact_begin(lua, loop_running, cancel_hook)?; let program = SHIM_PROGRAM.as_ref().map_err(Error::shared)?; let shims: Table = program .load(lua)? .call(( yield_fn.clone(), var_snapshot.clone(), - models, + models.clone(), tools, error_value, stash_failure, @@ -240,6 +232,28 @@ pub(crate) fn install_shim_prelude( .call((yield_fn.clone(), var_snapshot.clone(), helpers.clone())) .map_err(Error::lua)?; globals.raw_set("tasks", &tasks).map_err(Error::lua)?; + let fanout = fanout_shim(lua, yield_fn, var_snapshot, helpers)?; + tasks.raw_set("fanout", fanout).map_err(Error::lua)?; + stash_shims(lua, &shims)?; + for name in ["loop", "compact"] { + let entry: Function = shims.raw_get(name).map_err(Error::lua)?; + models.raw_set(name, entry).map_err(Error::lua)?; + } + globals + .raw_set("coroutine", Value::Nil) + .map_err(Error::lua)?; + Ok(()) +} + +/// Runs the `fanout` chunk over the prelude's `yield` and `var_snapshot` +/// captures and failure `helpers`, plus the collection enumerator and the +/// item renderer, and returns the shim installed as `tasks.fanout`. +fn fanout_shim( + lua: &Lua, + yield_fn: Function, + var_snapshot: Function, + helpers: Table, +) -> Result { // The enumerator answers `(members)` or `(nil, message)` so the shim // raises the message as its own call error, exactly as the other // author-argument failures surface at the call site. @@ -259,7 +273,7 @@ pub(crate) fn install_shim_prelude( crate::collection::render_item_value(lua, item).map_err(mlua::Error::external) }) .map_err(Error::lua)?; - let fanout: Function = FANOUT_PROGRAM + FANOUT_PROGRAM .as_ref() .map_err(Error::shared)? .load(lua)? @@ -270,30 +284,22 @@ pub(crate) fn install_shim_prelude( collection_members, render_item, )) - .map_err(Error::lua)?; - tasks.raw_set("fanout", fanout).map_err(Error::lua)?; - stash_shims(lua, &shims)?; - globals - .raw_set("coroutine", Value::Nil) - .map_err(Error::lua)?; - Ok(()) + .map_err(Error::lua) } -/// Stashes the shim chunk's `loop`, `compact`, `handle_methods`, -/// `model_tool_call`, and `store` under their registry keys, for the -/// installs and the handle getters that read them after the prelude. +/// Stashes the shim chunk's `handle_methods` and `store` under their +/// registry keys, for the installs and the handle getters that read them +/// after the prelude. fn stash_shims(lua: &Lua, shims: &Table) -> Result<()> { - for (field, key) in [("loop", LOOP_REGISTRY), ("compact", COMPACT_REGISTRY)] { - let entry: Function = shims.raw_get(field).map_err(Error::lua)?; - lua.set_named_registry_value(key, entry) - .map_err(Error::lua)?; - } let handle_methods: Table = shims.raw_get("handle_methods").map_err(Error::lua)?; lua.set_named_registry_value(HANDLE_METHODS_REGISTRY, handle_methods) .map_err(Error::lua)?; - let model_tool_call: Function = shims.raw_get("model_tool_call").map_err(Error::lua)?; - lua.set_named_registry_value(MODEL_TOOL_CALL_REGISTRY, model_tool_call) - .map_err(Error::lua)?; + #[cfg(any(test, feature = "test-support"))] + { + let model_tool_call: Function = shims.raw_get("model_tool_call").map_err(Error::lua)?; + lua.set_named_registry_value(MODEL_TOOL_CALL_REGISTRY, model_tool_call) + .map_err(Error::lua)?; + } let store: Table = shims.raw_get("store").map_err(Error::lua)?; lua.set_named_registry_value(STORE_REGISTRY, store) .map_err(Error::lua) @@ -330,29 +336,11 @@ fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function /// intact instead of only as mlua's stringification. /// /// # Errors -/// Returns [`Error::Lua`] if the shim prelude never ran on this VM. +/// Returns [`Error::LuaRuntime`] if the shim prelude never ran on this VM. pub(crate) fn block_guard(lua: &Lua) -> Result { lua.named_registry_value(GUARD_REGISTRY).map_err(Error::lua) } -/// Installs the `models.loop` and `models.compact` yield shims on a VM -/// whose shim prelude already ran (`install_shim_prelude` stashed the -/// shims in the registry). -/// -/// The executor's section setup is the only caller. -/// -/// # Errors -/// Returns [`Error::Lua`] if the shim prelude was never installed on this -/// VM, the `models` table is absent, or the install fails. -pub fn install_section_loop_shim(lua: &Lua) -> Result<()> { - let models: Table = lua.globals().raw_get("models").map_err(Error::lua)?; - for (name, key) in [("loop", LOOP_REGISTRY), ("compact", COMPACT_REGISTRY)] { - let entry: Function = lua.named_registry_value(key).map_err(Error::lua)?; - models.raw_set(name, entry).map_err(Error::lua)?; - } - Ok(()) -} - /// The shim's `name` method for a model handle, `infer` or `loop`, from /// the table `install_shim_prelude` stashed; nil on a VM where the /// prelude never ran. @@ -377,7 +365,7 @@ pub(crate) fn handle_method(lua: &Lua, name: &str) -> mlua::Result { /// The executor's section setup and live H1 setup are the only callers. /// /// # Errors -/// Returns [`Error::Lua`] if the shim prelude never ran on this VM, the +/// Returns [`Error::LuaRuntime`] if the shim prelude never ran on this VM, the /// `store` table is absent, or the install fails. pub fn install_store_shims(lua: &Lua) -> Result<()> { let shims: Table = lua diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index 7d0b47f57..a7e73f7a4 100644 --- a/crates/runvil-internal/lua/src/dispatch-tests.rs +++ b/crates/runvil-internal/lua/src/dispatch-tests.rs @@ -24,7 +24,7 @@ fn prepare_dispatch_wraps_a_canned_untrusted_output_and_reports_it() { &nonce, recorder.emitter(), TEST_SECTION, - Some(ScriptReport { turn: 3 }), + ScriptReport { turn: 3 }, ) .expect("a canned output prepares without awaiting anything"); assert_eq!( @@ -69,7 +69,7 @@ fn prepare_dispatch_wraps_a_canned_untrusted_output_and_reports_it() { fn prepare_dispatch_turns_a_canned_tool_error_into_the_typed_error() { let recorder = Recorder::default(); let failing = binding("failing", &tool_at("tools/failing")); - let error = prepare_dispatch( + let error = dispatch_body( &failing, None, Err(ToolError::message("canned failure").with_kind(ToolErrorKind::Backend)), @@ -92,7 +92,7 @@ fn prepare_dispatch_turns_a_canned_tool_error_into_the_typed_error() { fn model_report(call_id: &str) -> ModelReport { ModelReport { - script: ScriptReport { turn: 1 }, + turn: 1, call_id: call_id.to_owned(), } } @@ -152,7 +152,7 @@ fn a_scripts_listing_call_passes_its_untrusted_text_verbatim_and_reports_it_untr &test_nonce(), recorder.emitter(), TEST_SECTION, - Some(ScriptReport { turn: 2 }), + ScriptReport { turn: 2 }, ) .expect("a listing dispatches"); assert_eq!(outcome.content(), "the [INST]"); @@ -201,7 +201,7 @@ fn the_listing_rule_needs_the_flag_and_untrusted_output() { &nonce, Recorder::default().emitter(), TEST_SECTION, - Some(ScriptReport { turn: 1 }), + ScriptReport { turn: 1 }, ) .expect("the call dispatches") }; @@ -221,7 +221,7 @@ fn the_listing_rule_needs_the_flag_and_untrusted_output() { &nonce, Recorder::default().emitter(), TEST_SECTION, - Some(ScriptReport { turn: 1 }), + ScriptReport { turn: 1 }, ); assert!( matches!(failed, Err(Error::Tool { .. })), diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 23c19689f..312a4ef18 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -3,8 +3,7 @@ //! [`prepare_dispatch`] is the one place a bound tool's answer composes the //! succeeded/failed observation, the untrusted nonce wrap, and the //! `ToolResult` report. It is synchronous and takes the tool's -//! outcome as a value, so the Harness performs the call and the Engine applies -//! the same rules when the answer arrives; [`prepare_model_dispatch`] is +//! outcome as a value; [`prepare_model_dispatch`] is //! the same body under the model-issued rule (a tool's own failure resumes //! as untrusted failure text, and the `ToolResult` fires under the model's //! call id). Nothing here performs a call: the executor issues the call as @@ -18,16 +17,14 @@ use runvil_types::event::lifecycle; use runvil_types::tools::{OutputTrust, ToolError, ToolOutput}; use runvil_types::untrusted::GuardNonce; +use crate::bindings::ToolBinding; use crate::error::{Error, Result}; -use crate::handles::ToolBinding; /// The run coordinates a script-initiated dispatch reports under. /// -/// [`prepare_dispatch`] fires the `ToolResult` event with them; a -/// model tool-loop dispatch passes `None` instead and reports the result itself, -/// because it owns the model-issued call id the script path lacks. The -/// script report's `tool_call_id` is therefore empty. The chain and depth -/// are not part of the report: the emitter +/// [`prepare_dispatch`] fires the `ToolResult` event with them under an +/// empty `tool_call_id`, since only a model-issued call has one. The chain +/// and depth are not part of the report: the emitter /// stamps every event with its provenance, which already names the task the /// event belongs to. #[derive(Debug, Clone, Copy)] @@ -36,25 +33,22 @@ pub struct ScriptReport { pub turn: u32, } -/// The run coordinates a model-issued dispatch reports under: the chain's -/// script coordinates plus the call id the model issued. -/// -/// [`prepare_model_dispatch`] fires the `ToolResult` event under -/// `call_id`, so the result correlates with the assistant tool-call record -/// that requested it. +/// The run coordinates a model-issued dispatch reports under: the turn the +/// call fired in and the call id the model issued. #[derive(Debug, Clone)] pub struct ModelReport { - /// The turn the call fired in. - pub script: ScriptReport, + /// The section's completed model-turn count at dispatch. + pub turn: u32, /// The model-issued call id the result answers. pub call_id: String, } /// The resolved outcome of one dispatched tool call: the final content - /// nonce-wrapped when untrusted, except a script's listing - beside its -/// trust marking. A model tool-loop dispatch reports the pair through the -/// `ToolResult` event itself; a script-initiated dispatch reads only the -/// content, its report already fired inside [`prepare_dispatch`]. +/// trust marking. [`prepare_model_dispatch`] reports the pair through the +/// `ToolResult` event under the model's call id; a script-initiated +/// dispatch reads only the content, its report already fired inside +/// [`prepare_dispatch`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolDispatch { content: String, @@ -103,20 +97,42 @@ impl ToolDispatch { /// nonce-wrapped before it can reach a model turn or a calling script, /// except that a script-initiated call to a binding that describes tools /// receives its untrusted output verbatim, still marked untrusted, as a -/// listing the script reads. A script-initiated call (`script` is `Some`) -/// also fires the `ToolResult` event with the final content and `vendor`, -/// the vendor of the tool's Pack; a model-loop call fires no content -/// event here: the loop reports the returned [`ToolDispatch`] under the -/// model-issued call id. +/// listing the script reads. It also fires the `ToolResult` event with the +/// final content and `vendor`, the vendor of the tool's Pack. /// /// `call_result` is the tool's own answer, however the caller obtained it. -/// Nothing here awaits: the Harness performs the call, and the Engine -/// applies these rules when the answer arrives. /// /// # Errors /// Returns [`Error::Tool`] when `call_result` is the tool's failure (its /// typed error retained as the cause). pub fn prepare_dispatch( + binding: &ToolBinding, + vendor: Option<&str>, + call_result: std::result::Result, + nonce: &GuardNonce, + emitter: &Emitter, + section: &str, + script: ScriptReport, +) -> Result { + dispatch_body( + binding, + vendor, + call_result, + nonce, + emitter, + section, + Some(script), + ) +} + +/// The body of [`prepare_dispatch`], with `script` optional. A model-issued +/// call passes `None`, and [`prepare_model_dispatch`] reports the returned +/// [`ToolDispatch`] under the model's call id. +/// +/// # Errors +/// Returns [`Error::Tool`] when `call_result` is the tool's failure (its +/// typed error retained as the cause). +pub(crate) fn dispatch_body( binding: &ToolBinding, vendor: Option<&str>, call_result: std::result::Result, @@ -174,16 +190,15 @@ pub fn prepare_dispatch( } /// Applies the model-issued dispatch rules to one bound tool call's answer: -/// [`prepare_dispatch`] under the model-loop failure rule, then the -/// `ToolResult` event report under the model's call id. +/// the dispatch rules of [`prepare_dispatch`], without its script report, +/// under the model-loop failure rule, then the `ToolResult` event report +/// under the model's call id. /// /// A model-issued call always resumes with content: the tool's own failure /// ([`Error::Tool`]) becomes the call's result - the error message /// nonce-wrapped as untrusted - so the model reads the failure and the -/// round continues; `prepare_dispatch` has already fired the failed -/// observation. Every other dispatch failure still propagates. Nothing -/// here awaits: the Harness performs the call, and the Engine applies -/// these rules to the answer. +/// round continues; the shared rules have already fired the failed +/// observation. Every other dispatch failure still propagates. /// /// # Errors /// Propagates any error of [`prepare_dispatch`] other than the tool's own @@ -197,19 +212,18 @@ pub fn prepare_model_dispatch( section: &str, report: &ModelReport, ) -> Result { - let outcome = - match prepare_dispatch(binding, vendor, call_result, nonce, emitter, section, None) { - Ok(outcome) => outcome, - Err(Error::Tool { message, .. }) => ToolDispatch { - content: nonce.wrap(&message), - trust: OutputTrust::Untrusted, - listing: false, - }, - Err(error) => return Err(error), - }; + let outcome = match dispatch_body(binding, vendor, call_result, nonce, emitter, section, None) { + Ok(outcome) => outcome, + Err(Error::Tool { message, .. }) => ToolDispatch { + content: nonce.wrap(&message), + trust: OutputTrust::Untrusted, + listing: false, + }, + Err(error) => return Err(error), + }; emitter.tool_result( section, - report.script.turn, + report.turn, &report.call_id, binding.alias(), Some(binding.id()), diff --git a/crates/runvil-internal/lua/src/engine_globals-store-read.rs b/crates/runvil-internal/lua/src/engine_globals-store-read.rs new file mode 100644 index 000000000..90218ea58 --- /dev/null +++ b/crates/runvil-internal/lua/src/engine_globals-store-read.rs @@ -0,0 +1,135 @@ +//! The store's read bodies: whole-file and line-range reads, plain or numbered. + +use std::fmt::Write as _; + +use runvil_vfs::{Access, VfsError}; + +/// Whether a store read prefixes each line with its absolute line number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LineNumbers { + /// The file's text verbatim. + Omitted, + /// Each line right-aligned behind its number and `"| "`. + Prefixed, +} + +/// Reads the file at `path` under the optional line bounds `store.read` +/// and `store.read_numbered` share, numbering lines as `numbers` says. +/// +/// No `start` reads the whole file; a present `start` slices a 1-based +/// inclusive line range. A negative bound converts to 0, which the range +/// validation rejects with the same error a zero bound produces, and an +/// `end` without a `start` is refused rather than silently ignored. +fn read_store_bounded( + view: &Access, + path: &str, + start: Option, + end: Option, + numbers: LineNumbers, +) -> std::result::Result { + let render: fn(&[&str], usize) -> String = match numbers { + LineNumbers::Prefixed => number_lines_from, + LineNumbers::Omitted => |lines, _| lines.join("\n"), + }; + match start { + None if end.is_some() => Err(VfsError::InvalidRange { + path: path.to_owned(), + reason: "start is required when end is given", + }), + None if numbers == LineNumbers::Omitted => view.read_string(path), + None => with_read_range(view, path, 1, None, render), + Some(start) => { + let start = usize::try_from(start).unwrap_or(0); + let end = end.map(|line| usize::try_from(line).unwrap_or(0)); + with_read_range(view, path, start, end, render) + } + } +} + +/// Runs the `store.read` operation both store paths share. +pub(super) fn read_store( + view: &Access, + path: &str, + start: Option, + end: Option, +) -> std::result::Result { + read_store_bounded(view, path, start, end, LineNumbers::Omitted) +} + +/// Runs the `store.read_numbered` operation both store paths share. +pub(super) fn read_store_numbered( + view: &Access, + path: &str, + start: Option, + end: Option, +) -> std::result::Result { + read_store_bounded(view, path, start, end, LineNumbers::Prefixed) +} + +/// Reads and resolves one line range while its owned contents remain live. +fn with_read_range( + view: &Access, + path: &str, + start: usize, + end: Option, + render: impl FnOnce(&[&str], usize) -> String, +) -> std::result::Result { + let contents = view.read_string(path)?; + let lines: Vec<&str> = contents.lines().collect(); + let Some((start, end)) = resolve_line_range(path, lines.len(), start, end)? else { + return Ok(String::new()); + }; + Ok(render(&lines[start - 1..end], start)) +} + +/// Resolves 1-based inclusive bounds against `line_count` into the +/// effective `(start, end)`, or `None` when the range falls entirely past +/// the last line. Evaluation order is fixed: a `start` below 1 is an +/// error; a `start` past the last line reads as empty; an omitted `end` +/// means the last line, and a given `end` clamps down to it; an `end` +/// before `start` at that point is an error. +fn resolve_line_range( + path: &str, + line_count: usize, + start: usize, + end: Option, +) -> std::result::Result, VfsError> { + if start == 0 { + return Err(VfsError::InvalidRange { + path: path.to_owned(), + reason: "start must be at least 1", + }); + } + if start > line_count { + return Ok(None); + } + let end = end.unwrap_or(line_count).min(line_count); + if end < start { + return Err(VfsError::InvalidRange { + path: path.to_owned(), + reason: "end must not be before start", + }); + } + Ok(Some((start, end))) +} + +/// Renders `lines` numbered absolutely from `start`, each number +/// right-aligned to the width of the largest emitted number, followed by +/// `"| "`; lines are joined with `"\n"` and there is no trailing newline. +fn number_lines_from(lines: &[&str], start: usize) -> String { + if lines.is_empty() { + return String::new(); + } + let last = start + lines.len() - 1; + let width = last.to_string().len(); + let mut out = String::new(); + for (index, line) in lines.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + let number = start + index; + // Writing to a String is infallible, so the result is discarded. + let _ = write!(out, "{number:>width$}| {line}"); + } + out +} diff --git a/crates/runvil-internal/lua/src/engine_globals-store.rs b/crates/runvil-internal/lua/src/engine_globals-store.rs index aff406e40..6634d6a4e 100644 --- a/crates/runvil-internal/lua/src/engine_globals-store.rs +++ b/crates/runvil-internal/lua/src/engine_globals-store.rs @@ -1,6 +1,9 @@ -//! The `store` table: Lua dispatchers over the direct store closures, -//! and the switch that routes them to the yield shims once the shared -//! library has loaded. +//! The `store` table: Lua dispatchers over the direct store closures, and +//! the switch that routes them to the yield shims once the Pack preludes +//! have installed. + +#[path = "engine_globals-store-read.rs"] +mod read; use std::sync::{Arc, LazyLock, Mutex, PoisonError}; @@ -9,11 +12,11 @@ use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle::{self, Lifecycle}; use runvil_vfs::{Access, VfsError}; -use super::{read_store, read_store_numbered}; use crate::error::SharedSource; use crate::error::{Error, Result}; use crate::program::LuaProgram; -use crate::protocol::VfsOp; +use crate::protocol::{VfsOp, VfsOutcome}; +use read::{read_store, read_store_numbered}; /// The store dispatcher chunk's name, `@`-prefixed as the shim chunks' /// names are, so its frames render as verbatim `file:line:` references. @@ -67,7 +70,7 @@ type Bounds = (String, Option, Option); struct StoreShared { view: Arc, reporter: Arc, - conflicts: Arc>>, + conflict: Arc>>, } impl StoreShared { @@ -89,11 +92,11 @@ impl StoreShared { { let view = Arc::clone(&self.view); let reporter = Arc::clone(&self.reporter); - let conflicts = Arc::clone(&self.conflicts); + let conflict = Arc::clone(&self.conflict); let (success, failure) = outcomes; lua.create_function(move |_, arguments: A| { let result = operation(&view, &arguments); - record_store_conflict(&conflicts, &result); + record_store_conflict(&conflict, &result); reporter.report(&result, success, failure); result.map_err(|source| mlua::Error::external(Error::store(&op(arguments), source))) }) @@ -196,13 +199,13 @@ pub fn store_probe_message(error: &VfsError) -> String { /// still end the run with a determinism violation, so the direct closures /// write it here rather than only raising it. fn record_store_conflict( - conflicts: &Mutex>, + conflict: &Mutex>, result: &std::result::Result, ) { if let Err(VfsError::Conflict { detail, .. }) = result { // A panic elsewhere cannot leave the slot half-written, so a // poisoned lock still takes the conflict. - *conflicts.lock().unwrap_or_else(PoisonError::into_inner) = Some(detail.clone()); + *conflict.lock().unwrap_or_else(PoisonError::into_inner) = Some(detail.clone()); } } @@ -212,7 +215,7 @@ fn record_store_conflict( /// caller's VFS access capability, so each operation runs under the /// caller's own identity and the store's strict path rules. /// Installed once per section with [`Lua::create_function`], so the table -/// stays valid across every chunk the VM runs without a live [`mlua::Scope`]. +/// stays valid across every chunk the VM runs. /// /// The table is a deterministic Engine global, present regardless of tool /// scoping. The mutating ops (`write`/`append`/`str_replace`/`delete`) return @@ -232,7 +235,7 @@ fn record_store_conflict( /// spawned from the caller's, so two unordered arms touching one path /// surface the conflict as [`VfsError::Conflict`]. A conflict these direct /// closures see - while the Pack preludes install, before the yield -/// shims install - is recorded in `conflicts`, the shared slot the Engine +/// shims install - is recorded in `conflict`, the shared slot the Engine /// reads when the preludes return, so the run ends with a determinism /// violation even if the prelude caught the error. /// @@ -252,7 +255,7 @@ pub(crate) fn install_store_table( access: &Arc, emitter: &Emitter, section: &str, - conflicts: &Arc>>, + conflict: &Arc>>, ) -> Result<()> { let table = lua.create_table().map_err(Error::lua)?; // One store view for the section's whole life: derived once from the @@ -269,7 +272,7 @@ pub(crate) fn install_store_table( emitter: emitter.clone(), section: section.to_owned(), }), - conflicts: Arc::clone(conflicts), + conflict: Arc::clone(conflict), }; let program = STORE_PROGRAM.as_ref().map_err(Error::shared)?; let (dispatcher, phase): (Function, Table) = program.load(lua)?.call(()).map_err(Error::lua)?; @@ -362,7 +365,7 @@ pub(crate) fn install_store_table( /// from here on exactly as `store.*` does. /// /// # Errors -/// Returns [`Error::Lua`] if the VM's store table was never installed or +/// Returns [`Error::LuaRuntime`] if the VM's store table was never installed or /// the switch fails. pub(crate) fn route_store_to_shims(lua: &Lua, shims: &Table) -> Result<()> { let phase: Table = lua @@ -371,6 +374,38 @@ pub(crate) fn route_store_to_shims(lua: &Lua, shims: &Table) -> Result<()> { phase.raw_set("shims", shims.clone()).map_err(Error::lua) } +/// Executes one validated store operation against the store view: the +/// executor's leaf-yield dispatch for a `store.*` call inside a block. +/// +/// The direct closures `install_store_table` builds share these operation +/// bodies. `view` is the store view the caller derived. +/// +/// # Errors +/// Returns the [`VfsError`] the operation produces: path or pattern +/// validation, not-found, anchor, range, conflict, or backend failure. +pub fn run_store_op(view: &Access, op: VfsOp) -> std::result::Result { + match op { + VfsOp::Write { path, contents } => view + .write(&path, contents.as_bytes()) + .map(|()| VfsOutcome::Unit), + VfsOp::Append { path, contents } => view + .append(&path, contents.as_bytes()) + .map(|()| VfsOutcome::Unit), + VfsOp::Read { path, start, end } => { + read_store(view, &path, start, end).map(VfsOutcome::Text) + } + VfsOp::ReadNumbered { path, start, end } => { + read_store_numbered(view, &path, start, end).map(VfsOutcome::Text) + } + VfsOp::StrReplace { path, old, new } => view + .str_replace(&path, &old, &new) + .map(|()| VfsOutcome::Unit), + VfsOp::Delete { path } => view.remove(&path, false).map(|_| VfsOutcome::Unit), + VfsOp::Glob { pattern } => view.glob(&pattern).map(VfsOutcome::Paths), + VfsOp::Exists { path } => view.exists(&path).map(VfsOutcome::Bool), + } +} + #[cfg(test)] mod tests { use std::sync::Mutex; diff --git a/crates/runvil-internal/lua/src/engine_globals.rs b/crates/runvil-internal/lua/src/engine_globals.rs index 13a0a00b9..f88c5ae93 100644 --- a/crates/runvil-internal/lua/src/engine_globals.rs +++ b/crates/runvil-internal/lua/src/engine_globals.rs @@ -1,35 +1,26 @@ -//! Engine globals installed into every section VM: `log`, `untrusted`, `ui`, -//! and the `store` table. +//! Engine globals installed into section VMs: `log`, `untrusted`, and the +//! `store` table in every one, and `ui` when the run has an +//! application-state snapshot. Each is a persistent global: installed once +//! per section and valid across every chunk the VM runs. #[path = "engine_globals-store.rs"] mod store; -use std::fmt::Write as _; use std::sync::Arc; use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; use runvil_types::emitter::Emitter; use runvil_types::untrusted::GuardNonce; -use runvil_vfs::Access; use crate::error::{Error, QuotaExceeded, QuotaResource, Result}; pub(crate) use store::{install_store_table, route_store_to_shims}; -pub use store::{store_error_message, store_probe_message}; +pub use store::{run_store_op, store_error_message, store_probe_message}; /// Maximum number of Unicode scalar values accepted by `log`. pub(crate) const LUA_LOG_CHARACTER_LIMIT: usize = 256; -/// Whether a store read prefixes each line with its absolute line number. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LineNumbers { - /// The file's text verbatim. - Omitted, - /// Each line right-aligned behind its number and `"| "`. - Prefixed, -} - /// Runs the persistent per-section `log(message)` Engine function. fn log_checkpoint( emitter: &Emitter, @@ -58,9 +49,9 @@ fn log_checkpoint( .to_str() .map_err(|_| mlua::Error::external("log message must be a UTF-8 string"))?; if message.chars().count() > LUA_LOG_CHARACTER_LIMIT { - return Err(mlua::Error::external( - "log message must be at most 256 characters", - )); + return Err(mlua::Error::external(format!( + "log message must be at most {LUA_LOG_CHARACTER_LIMIT} characters" + ))); } if message.chars().any(is_log_line_break_or_control) { return Err(mlua::Error::external( @@ -82,9 +73,8 @@ fn log_checkpoint( Ok(()) } -/// Installs `log` as a persistent global valid for the section's whole -/// lifecycle. The closure captures owned strings and Arc clones, so it -/// outlives any single chunk without an [`mlua::Scope`]. +/// Installs `log`. The closure captures owned strings and Arc clones, so it +/// outlives any single chunk. pub(crate) fn install_log( lua: &Lua, emitter: &Emitter, @@ -108,13 +98,9 @@ fn is_log_line_break_or_control(character: char) -> bool { character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') } -/// Installs `untrusted(s)` as a persistent global valid for the section's -/// whole lifecycle. The closure captures an owned clone of the run's nonce - -/// mlua's `create_function` requires `Fn + Send + 'static`, so no borrow can -/// cross the install - and every wrap the VM performs shares that one nonce. -/// Every string input succeeds, so the install needs no emitter, budget, or -/// [`mlua::Scope`]; a non-string argument fails through mlua's automatic -/// type error. +/// Installs `untrusted(s)`. Every wrap the VM performs shares one owned +/// clone of the run's nonce, and a non-string argument fails through mlua's +/// automatic type error. pub(crate) fn install_untrusted(lua: &Lua, nonce: &GuardNonce) -> Result<()> { let nonce = nonce.clone(); let untrusted = lua @@ -132,9 +118,8 @@ const UI_SNAPSHOT_OPTIONS: mlua::serde::SerializeOptions = mlua::serde::Serializ .serialize_none_to_null(false) .serialize_unit_to_null(false); -/// Installs `ui()` as a persistent global valid for the section's whole -/// lifecycle: each call converts the run's `snapshot` afresh into a new -/// table, JSON nulls reading as nil, so author code that mutates one +/// Installs `ui()`: each call converts the run's `snapshot` afresh into a +/// new table, JSON nulls reading as nil, so author code that mutates one /// result never sees the mutation on the next call. The snapshot is the /// application state the caller passed to `RunContext::with_ui` at run start; /// a later change takes effect on the next run. A run started without a @@ -146,199 +131,10 @@ const UI_SNAPSHOT_OPTIONS: mlua::serde::SerializeOptions = mlua::serde::Serializ /// its own copy of the JSON tree. /// /// # Errors -/// Returns [`Error::Lua`] if the function or the global cannot be created. +/// Returns [`Error::LuaRuntime`] if the function or the global cannot be created. pub fn install_ui(lua: &Lua, snapshot: Arc) -> Result<()> { let snapshot = lua .create_function(move |lua, ()| lua.to_value_with(snapshot.as_ref(), UI_SNAPSHOT_OPTIONS)) .map_err(Error::lua)?; lua.globals().raw_set("ui", snapshot).map_err(Error::lua) } - -/// Reads the file at `path` under the optional line bounds `store.read` -/// and `store.read_numbered` share, numbering lines as `numbers` says. -/// -/// No `start` reads the whole file; a present `start` slices a 1-based -/// inclusive line range. A negative bound converts to 0, which the range -/// validation rejects with the same error a zero bound produces, and an -/// `end` without a `start` is refused rather than silently ignored. -fn read_store_bounded( - view: &Access, - path: &str, - start: Option, - end: Option, - numbers: LineNumbers, -) -> std::result::Result { - match start { - None if end.is_none() => match numbers { - LineNumbers::Prefixed => read_range_numbered(view, path, 1, None), - LineNumbers::Omitted => view.read_string(path), - }, - None => Err(runvil_vfs::VfsError::InvalidRange { - path: path.to_owned(), - reason: "start is required when end is given", - }), - Some(start) => { - let start = usize::try_from(start).unwrap_or(0); - let end = end.map(|line| usize::try_from(line).unwrap_or(0)); - match numbers { - LineNumbers::Prefixed => read_range_numbered(view, path, start, end), - LineNumbers::Omitted => read_range(view, path, start, end), - } - } - } -} - -/// Runs the `store.read` operation both store paths share. -fn read_store( - view: &Access, - path: &str, - start: Option, - end: Option, -) -> std::result::Result { - read_store_bounded(view, path, start, end, LineNumbers::Omitted) -} - -/// Runs the `store.read_numbered` operation both store paths share. -fn read_store_numbered( - view: &Access, - path: &str, - start: Option, - end: Option, -) -> std::result::Result { - read_store_bounded(view, path, start, end, LineNumbers::Prefixed) -} - -/// Reads lines `start..=end` of the file at `path`, 1-based and inclusive, -/// joined with `"\n"` and no trailing newline. -fn read_range( - view: &Access, - path: &str, - start: usize, - end: Option, -) -> std::result::Result { - with_read_range(view, path, start, end, |lines, _| lines.join("\n")) -} - -/// Reads lines `start..=end` of the file at `path` numbered absolutely -/// from `start`, each number right-aligned to the width of the largest -/// emitted number, followed by `"| "`. -fn read_range_numbered( - view: &Access, - path: &str, - start: usize, - end: Option, -) -> std::result::Result { - with_read_range(view, path, start, end, number_lines_from) -} - -/// Reads and resolves one line range while its owned contents remain live. -fn with_read_range( - view: &Access, - path: &str, - start: usize, - end: Option, - render: impl FnOnce(&[&str], usize) -> String, -) -> std::result::Result { - let contents = view.read_string(path)?; - let lines: Vec<&str> = contents.lines().collect(); - let Some((start, end)) = resolve_line_range(path, lines.len(), start, end)? else { - return Ok(String::new()); - }; - Ok(render(&lines[start - 1..end], start)) -} - -/// Resolves 1-based inclusive bounds against `line_count` into the -/// effective `(start, end)`, or `None` when the range falls entirely past -/// the last line. Evaluation order is fixed: a `start` below 1 is an -/// error; a `start` past the last line reads as empty; an omitted `end` -/// means the last line, and a given `end` clamps down to it; an `end` -/// before `start` at that point is an error. -fn resolve_line_range( - path: &str, - line_count: usize, - start: usize, - end: Option, -) -> std::result::Result, runvil_vfs::VfsError> { - if start == 0 { - return Err(runvil_vfs::VfsError::InvalidRange { - path: path.to_owned(), - reason: "start must be at least 1", - }); - } - if start > line_count { - return Ok(None); - } - let end = end.unwrap_or(line_count).min(line_count); - if end < start { - return Err(runvil_vfs::VfsError::InvalidRange { - path: path.to_owned(), - reason: "end must not be before start", - }); - } - Ok(Some((start, end))) -} - -/// Renders `lines` numbered absolutely from `start`, each number -/// right-aligned to the width of the largest emitted number, followed by -/// `"| "`; lines are joined with `"\n"` and there is no trailing newline. -fn number_lines_from(lines: &[&str], start: usize) -> String { - if lines.is_empty() { - return String::new(); - } - let last = start + lines.len() - 1; - let width = last.to_string().len(); - let mut out = String::new(); - for (index, line) in lines.iter().enumerate() { - if index > 0 { - out.push('\n'); - } - let number = start + index; - // Writing to a String is infallible, so the result is discarded. - let _ = write!(out, "{number:>width$}| {line}"); - } - out -} - -/// Executes one validated store operation against the store view: the -/// executor's leaf-yield dispatch for a `store.*` call inside a block. -/// -/// The direct closures `install_store_table` builds are a second path. -/// They run only while the Pack preludes install, before -/// `route_store_to_shims` switches the table to the yield shims. They share -/// this function's operation bodies (the same [`Access`] calls and read -/// helpers) and add lifecycle events, store-conflict recording, and -/// `Error::store` wrapping. The bounded-read argument rules (a negative -/// bound converts to 0, an `end` without a `start` is refused) sit in the -/// shared `read_store_bounded` helper above. `view` is the store view the -/// caller derived; every operation maps onto one [`Access`] call over it. -/// -/// # Errors -/// Returns the [`VfsError`](runvil_vfs::VfsError) the operation -/// produces: path or pattern validation, not-found, anchor, range, -/// conflict, or backend failure. -pub fn run_store_op( - view: &Access, - op: crate::protocol::VfsOp, -) -> std::result::Result { - use crate::protocol::{VfsOp, VfsOutcome}; - match op { - VfsOp::Write { path, contents } => view - .write(&path, contents.as_bytes()) - .map(|()| VfsOutcome::Unit), - VfsOp::Append { path, contents } => view - .append(&path, contents.as_bytes()) - .map(|()| VfsOutcome::Unit), - VfsOp::Read { path, start, end } => { - read_store(view, &path, start, end).map(VfsOutcome::Text) - } - VfsOp::ReadNumbered { path, start, end } => { - read_store_numbered(view, &path, start, end).map(VfsOutcome::Text) - } - VfsOp::StrReplace { path, old, new } => view - .str_replace(&path, &old, &new) - .map(|()| VfsOutcome::Unit), - VfsOp::Delete { path } => view.remove(&path, false).map(|_| VfsOutcome::Unit), - VfsOp::Glob { pattern } => view.glob(&pattern).map(VfsOutcome::Paths), - VfsOp::Exists { path } => view.exists(&path).map(VfsOutcome::Bool), - } -} diff --git a/crates/runvil-internal/lua/src/error-tests.rs b/crates/runvil-internal/lua/src/error-tests.rs index 7c0912c46..eb53001d2 100644 --- a/crates/runvil-internal/lua/src/error-tests.rs +++ b/crates/runvil-internal/lua/src/error-tests.rs @@ -77,7 +77,6 @@ fn a_quota_error_names_its_exhausted_resource() { for (resource, expected) in [ (QuotaResource::LogEvent, "lua log event quota exceeded"), (QuotaResource::LogByte, "lua log byte quota exceeded"), - (QuotaResource::Instruction, "lua instruction quota exceeded"), ] { assert_eq!(Error::LuaQuota { resource }.to_string(), expected); } diff --git a/crates/runvil-internal/lua/src/error-value-store.rs b/crates/runvil-internal/lua/src/error-value-store.rs index 0c21cdcf5..44f670dfe 100644 --- a/crates/runvil-internal/lua/src/error-value-store.rs +++ b/crates/runvil-internal/lua/src/error-value-store.rs @@ -3,12 +3,8 @@ use super::ErrorField; -/// Returns the variant's own fields a store error value carries beside -/// `kind`, `message`, and `reason`: the paths the failure names, plus -/// `anchor` and its integer `count` for an anchor error, `rule` for an -/// invalid path, or the integer `valid_up_to` for text that is not UTF-8. -/// The rule is the [`PathReason`](runvil_vfs::PathReason) tag, which -/// `PathReason::from_tag` parses back. +/// Returns the variant's own fields, without `reason`, in the shape +/// `store_error_value_fields` documents. fn store_error_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> { use runvil_vfs::VfsError; let integer = |value: usize| ErrorField::Integer(i64::try_from(value).unwrap_or(i64::MAX)); @@ -61,7 +57,10 @@ fn store_error_fields(error: &runvil_vfs::VfsError) -> Vec<(String, ErrorField)> /// Returns the full field set a store error value carries: the `reason` /// tag, as [`VfsError::tag`](runvil_vfs::VfsError::tag) names it, /// first, then the variant's own fields: the paths the failure names, plus -/// the anchor, count, rule, or UTF-8 offset its variant holds. +/// `anchor` and its integer `count` for an anchor error, `rule` for an +/// invalid path, or the integer `valid_up_to` for text that is not UTF-8. +/// The rule is the [`PathReason`](runvil_vfs::PathReason) tag, which +/// `PathReason::from_tag` parses back. /// /// One source for the shape, so a failure the direct closures raise /// while a Pack prelude installs and one answered through the diff --git a/crates/runvil-internal/lua/src/error-value.rs b/crates/runvil-internal/lua/src/error-value.rs index 05b303547..497f11f82 100644 --- a/crates/runvil-internal/lua/src/error-value.rs +++ b/crates/runvil-internal/lua/src/error-value.rs @@ -49,7 +49,8 @@ pub enum ErrorKind { /// The model tool loop ran its iteration cap without a final reply. ToolLoopExhausted, /// A request overflowed the model's context window and the selected - /// compactor does not compact; includes `reason`. + /// compactor did not compact, or a caller raised it through + /// `compactors.fail` with its own reason; includes `reason`. ContextExhausted, /// The model returned a turn with no product; includes `finish_reason` /// when the backend supplied one. @@ -71,7 +72,9 @@ pub enum ErrorKind { TaskConsumed, /// A section ended while tasks it owns were still live. TasksLive, - /// The run was cancelled. + /// The run was cancelled, the caller dropped a call the run was waiting + /// on, or a wait delivered a task its owner cancelled (that value + /// includes `task`). Cancelled, /// A store operation's own failure; includes `reason` and the /// [`VfsError`](runvil_vfs::VfsError) variant's fields. @@ -194,7 +197,8 @@ impl ParseErrorKindError { pub enum ErrorField { /// A string-valued field. String(String), - /// An integer-valued field (a store anchor's `count`). + /// An integer-valued field (a store anchor's `count`, a UTF-8 failure's + /// `valid_up_to`). Integer(i64), } @@ -246,7 +250,7 @@ impl ErrorValue for Error { Error::Interrupted => ErrorKind::Cancelled, Error::Tool { .. } => ErrorKind::Tool, Error::Store { .. } => ErrorKind::Store, - Error::Internal(_) => ErrorKind::Internal, + Error::Internal { .. } => ErrorKind::Internal, Error::Raised(raised) => raised.kind, } } @@ -268,13 +272,13 @@ impl ErrorValue for Error { | Error::LuaQuota { .. } | Error::Interrupted | Error::Tool { .. } - | Error::Internal(_) => Vec::new(), + | Error::Internal { .. } => Vec::new(), } } } /// A structured error table read back into Rust: the kind, the message -/// `tostring` rendered, and the kind's string fields. +/// `tostring` rendered, and the kind's fields. /// /// This is the shape a block coroutine's failure takes when the raised /// value was an error table (built by the shim's `raise` or by @@ -288,7 +292,8 @@ pub struct Raised { /// The message `tostring` renders. pub message: String, /// The kind's own fields (`reason`, `finish_reason`, ...), string- or - /// integer-valued (a store anchor's `count`). + /// integer-valued (a store anchor's `count`, a UTF-8 failure's + /// `valid_up_to`). pub fields: BTreeMap, } @@ -369,7 +374,7 @@ pub(crate) fn error_table(lua: &Lua, error: &impl ErrorValue) -> mlua::Result mlua::Result { lua.create_function(|lua, (tag, fields): (String, Option)| { let kind = tag.parse::().map_err(|error| { - mlua::Error::external(Error::Internal(format!("the shim raised {error}").into())) + mlua::Error::external(Error::internal(format!("the shim raised {error}"))) })?; let fields = match fields { Some(fields) => fields, diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index 7daaf4709..8f9d8aff9 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -2,8 +2,8 @@ //! //! [`Error`] mirrors `runvil-engine`'s own internal error type: it //! is never part of the documented API. The executor's public boundary -//! (`runvil_engine::RunError`) wraps and classifies core's own error type, -//! which maps this one back variant-for-variant through +//! (`runvil_engine::RunError`) wraps and classifies `runvil-engine`'s +//! internal error type, which maps this one back variant-for-variant through //! `From`. The internal type is public only so //! `runvil-engine` can perform that mapping verbatim; the facade does //! not re-export it, and it is not marked `#[non_exhaustive]`, so the @@ -23,14 +23,12 @@ type BoxedSource = Box; /// A cloneable, shareable error cause. /// -/// Some caches re-produce a typed [`Error`] on every lookup (for example the -/// resolver decision cache), so a non-`Clone` dependency error cannot be moved -/// into a fresh [`Error`] each time. Wrapping it in a reference-counted -/// [`SharedSource`] lets the typed cause be retained as a cause and cloned -/// cheaply per lookup instead of being flattened to a string. The -/// compiled-program statics (the coroutine, tasks, and fanout shims, the -/// globals chunk, and the store shims) are the callers, through -/// `detail::shared_source_new`. +/// Some compiled-program statics re-produce a typed [`Error`] on every +/// lookup, so a non-`Clone` dependency error cannot be moved into a fresh +/// [`Error`] each time. Wrapping it in a reference-counted [`SharedSource`] +/// keeps the typed cause and clones it cheaply per lookup instead of +/// flattening it to a string. Every embedded chunk's compiled-program static +/// is a caller, through `detail::shared_source_new`. #[derive(Debug, Clone)] pub(crate) struct SharedSource(pub(crate) std::sync::Arc); @@ -53,8 +51,6 @@ pub enum QuotaResource { LogEvent, /// The cumulative `log()` byte budget. LogByte, - /// The per-VM instruction budget. - Instruction, } impl std::fmt::Display for QuotaResource { @@ -62,16 +58,12 @@ impl std::fmt::Display for QuotaResource { formatter.write_str(match self { QuotaResource::LogEvent => "log event", QuotaResource::LogByte => "log byte", - QuotaResource::Instruction => "instruction", }) } } -/// The crate's internal error type, spanning sandbox construction, Engine -/// value bridging, capability binding, and Lua compile/runtime failures. -/// -/// Public only so `runvil-engine` can convert it back onto its own -/// internal type variant-for-variant; the facade does not re-export it. +/// The crate's internal error type: every failure a section VM's setup, Lua +/// code, or Engine calls can end in. #[derive(Debug, thiserror::Error)] pub enum Error { /// A condition the prompt author caused and can fix in Lua, with no @@ -120,9 +112,9 @@ pub enum Error { cause: BoxedSource, }, - /// An Engine quota on Lua (log events, log bytes, or instructions) was - /// exhausted. A stable typed error rather than a bare `Lua(String)` so the caller - /// can distinguish quota exhaustion from an authoring error. + /// An Engine quota on Lua (log events or log bytes) was exhausted. A + /// stable typed error rather than a bare `Lua(String)` so the caller can + /// distinguish quota exhaustion from an authoring error. #[error("lua {resource} quota exceeded")] LuaQuota { /// The exhausted resource. @@ -173,9 +165,15 @@ pub enum Error { /// invariant, a lifecycle step taken out of order, or a poisoned lock. /// Surfaced as a concrete error rather than silently skipping work, so an /// impossible state cannot masquerade as a successful fall-through. The - /// message is owned when it names a value. - #[error("internal invariant violated: {0}")] - Internal(Cow<'static, str>), + /// message is owned when it names a value, and `location` is the + /// construction site. + #[error("internal invariant violated: {message}")] + Internal { + /// The violated invariant, as a noun phrase. + message: Cow<'static, str>, + /// The Rust source position of the construction site. + location: &'static std::panic::Location<'static>, + }, /// A structured error table raised in Lua surfaced as a block /// coroutine's failure with no retained typed error to substitute: the @@ -198,26 +196,14 @@ pub(crate) struct QuotaExceeded(pub(crate) QuotaResource); impl std::fmt::Display for QuotaExceeded { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self.0 { - QuotaResource::LogEvent => lua_quota::LOG_EVENT, - QuotaResource::LogByte => lua_quota::LOG_BYTE, - QuotaResource::Instruction => lua_quota::INSTRUCTION, + QuotaResource::LogEvent => "lua log event budget exceeded", + QuotaResource::LogByte => "lua log cumulative byte budget exceeded", }) } } impl std::error::Error for QuotaExceeded {} -/// The messages Lua Engine-quota refusals show: the [`QuotaExceeded`] -/// display text. -pub(crate) mod lua_quota { - /// Log event-count budget exhausted. - pub(crate) const LOG_EVENT: &str = "lua log event budget exceeded"; - /// Cumulative log byte budget exhausted. - pub(crate) const LOG_BYTE: &str = "lua log cumulative byte budget exceeded"; - /// Per-VM instruction budget exhausted. - pub(crate) const INSTRUCTION: &str = "lua instruction budget exceeded"; -} - impl Error { /// Wraps an `mlua` failure as [`Error::LuaRuntime`], keeping it as the /// cause rather than flattening it to a string. @@ -228,10 +214,9 @@ impl Error { } } - /// Re-produces a typed [`Error`] from a [`SharedSource`] a cache or - /// static captured once and replays on every lookup (the compiled-program - /// statics), cloning the `Arc` rather than flattening the cause to a - /// string. + /// Re-produces a typed [`Error`] from a [`SharedSource`] a + /// compiled-program static captured once, cloning the `Arc` rather than + /// flattening the cause to a string. pub(crate) fn shared(source: &SharedSource) -> Error { Error::LuaRuntime { message: source.to_string(), @@ -257,6 +242,16 @@ impl Error { } } + /// Builds an [`Error::Internal`], capturing the call site as its + /// construction site. The message is owned when it names a value. + #[track_caller] + pub(crate) fn internal(message: impl Into>) -> Error { + Error::Internal { + message: message.into(), + location: std::panic::Location::caller(), + } + } + /// Returns the originating error a cause-bearing variant keeps, whose /// text its `Display` already holds; `None` for every other variant. #[must_use] @@ -270,18 +265,25 @@ impl Error { | Error::LuaQuota { .. } | Error::ContextExhausted { .. } | Error::Interrupted - | Error::Internal(_) + | Error::Internal { .. } | Error::Raised(_) => None, } } } +/// An author error raised across the Lua boundary: an external +/// [`Error::Lua`], which `pcall` reports as kind `lua`. +pub(crate) fn author_refusal(message: String) -> mlua::Error { + mlua::Error::external(Error::Lua(message)) +} + /// Maps a poisoned model-set lock onto [`Error::Internal`], the mapping -/// `runvil-engine` applies too: the lock is the run's own mutex -/// failing, which no prompt author can cause, not a model failure. +/// the Engine receives through its `From`: the lock is +/// the run's own mutex failing, which no prompt author can cause, not a +/// model failure. impl From for Error { fn from(error: ModelSetError) -> Error { - Error::Internal(Cow::Owned(error.to_string())) + Error::internal(error.to_string()) } } diff --git a/crates/runvil-internal/lua/src/globals.rs b/crates/runvil-internal/lua/src/globals.rs index b6053fdd7..9f363ac8a 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -70,7 +70,7 @@ static GLOBALS_PROGRAM: LazyLock> /// `setmetatable` or `getmetatable`. /// /// # Errors -/// Returns [`Error::Lua`] if the chunk cannot load or run, or the guard +/// Returns [`Error::LuaRuntime`] 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(); @@ -105,7 +105,7 @@ pub(crate) fn install(lua: &Lua) -> Result<()> { /// assignment of the global raises the freeze refusal. /// /// # Errors -/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// Returns [`Error::LuaRuntime`] 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)?; @@ -113,21 +113,11 @@ pub(crate) fn freeze_argv(lua: &Lua, frozen: Value) -> Result<()> { 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) -} - /// Puts `render` in the `prose` slot, nil leaving `prose` reading nil, and /// returns the render it replaced, nil before the first install. /// /// # Errors -/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot /// be read or written. pub(crate) fn replace_prose(lua: &Lua, render: Value) -> Result { let state = state(lua)?; @@ -140,16 +130,16 @@ pub(crate) fn replace_prose(lua: &Lua, render: Value) -> Result { /// `messages` slot, which every later read of the global returns. /// /// # Errors -/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot /// be written. -pub(crate) fn install_messages(lua: &Lua, list: AnyUserData) -> Result<()> { +pub(crate) fn set_messages(lua: &Lua, list: AnyUserData) -> Result<()> { state(lua)?.raw_set("messages", list).map_err(Error::lua) } /// The section's list, as the `messages` slot holds it. /// /// # Errors -/// Returns [`Error::Lua`] if the guard's slot table is missing or cannot +/// Returns [`Error::LuaRuntime`] if the guard's slot table is missing or cannot /// be read, and [`Error::Internal`] if no list is installed. pub(crate) fn messages(lua: &Lua) -> Result { match state(lua)? @@ -157,8 +147,8 @@ pub(crate) fn messages(lua: &Lua) -> Result { .map_err(Error::lua)? { Value::UserData(list) => Ok(list), - _ => Err(Error::Internal( - "the section's messages list is not installed".into(), + _ => Err(Error::internal( + "the section's messages list is not installed", )), } } diff --git a/crates/runvil-internal/lua/src/hardening.rs b/crates/runvil-internal/lua/src/hardening.rs index c741a3675..8a5e5e099 100644 --- a/crates/runvil-internal/lua/src/hardening.rs +++ b/crates/runvil-internal/lua/src/hardening.rs @@ -1,7 +1,6 @@ //! Sandbox hardening for section VMs: the `_G` guard, global removal, and -//! the instruction-budget hook. +//! the cancellation hook. -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use mlua::{HookTriggers, Lua, Thread, Value, VmState}; @@ -12,25 +11,17 @@ use crate::error::{Error, Result}; /// How many instructions between hook firings. const HOOK_INTERVAL: u32 = 10_000; -/// Hook-firing trip budget, effectively unlimited. -/// -/// Long-running and infinite loops are legal: no instruction ceiling aborts a -/// block, so the hook's job is the cancellation poll and the run's -/// `CancelHandle` is the kill switch for a runaway loop. The typed quota -/// errors remain for the memory and log budgets. -const HOOK_BUDGET: u64 = u64::MAX; - /// Removes code-loading, direct output, and reflection globals the base library -/// provides. The `io`, `os`, `package`, `coroutine`, and `debug` libraries are -/// never loaded. +/// provides. The `io`, `os`, `package`, and `debug` libraries are never +/// loaded; the coroutine library loads only while the shim prelude installs, +/// and its global is stripped again. /// /// 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, Engine 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. +/// (fanout result objects, Engine userdata) coerces like `tostring`. Plain +/// tables, booleans, and nil still raise as stock Lua does. 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 @@ -94,37 +85,35 @@ end Ok(()) } -/// A VM's shared instruction-hook state. +/// A VM's shared cancellation-hook state. /// -/// The hook exists for cooperative cancellation: its trip budget -/// ([`HOOK_BUDGET`]) is effectively unlimited, so a long-running or infinite -/// loop is legal and only the run's cancel flag aborts it. The flag is the -/// run's synchronous [`CancelHandle`], installed once by the executor -/// through [`set_cancel`](Self::set_cancel); a VM no executor claimed -/// (a test fixture, the direct chunk path) is never cancelled. +/// The hook polls the run's cancel flag every [`HOOK_INTERVAL`] +/// instructions, so a long-running or infinite loop is legal and only the +/// flag aborts it. The flag is the run's synchronous [`CancelHandle`], +/// installed once by the executor through [`set_cancel`](Self::set_cancel); +/// a VM no executor claimed (a test fixture) is never cancelled. /// /// Instruction hooks are per-coroutine in PUC Lua: the hook installed on the /// main state at construction never fires inside a resumed coroutine, so -/// every block coroutine receives the same hook (over the same counter) via +/// every block coroutine receives the same hook (over the same flag) via /// [`install_on_thread`](Self::install_on_thread), keeping every chunk the /// VM runs cancellable. #[derive(Debug, Default, Clone)] -pub(crate) struct InstructionBudget { - fired: Arc, +pub(crate) struct CancelHook { /// The run's cancel flag, set once; the hook polls it on every firing. cancel: Arc>, } -impl InstructionBudget { - /// Installs the budget/cancellation hook on a block coroutine. +impl CancelHook { + /// Installs the cancellation hook on a block coroutine. /// /// # Errors - /// Returns [`Error::Lua`] if the VM rejects the hook callback. + /// Returns [`Error::LuaRuntime`] if the VM rejects the hook callback. pub(crate) fn install_on_thread(&self, thread: &Thread) -> Result<()> { thread .set_hook( HookTriggers::new().every_nth_instruction(HOOK_INTERVAL), - budget_hook(Arc::clone(&self.fired), Arc::clone(&self.cancel)), + cancel_poll(Arc::clone(&self.cancel)), ) .map_err(Error::lua) } @@ -141,13 +130,6 @@ impl InstructionBudget { pub(crate) fn is_cancelled(&self) -> bool { self.cancel.get().is_some_and(CancelHandle::is_cancelled) } - - /// Returns how many hook firings have counted, which leaves out a - /// firing that a set cancel flag aborted. - #[cfg(test)] - pub(crate) fn fired(&self) -> u64 { - self.fired.load(Ordering::Relaxed) - } } /// Returns whether `cancel` holds a set flag, the hook's poll of one @@ -157,10 +139,8 @@ fn cancelled(cancel: &OnceLock) -> bool { } /// Builds the every-Nth-instruction hook body shared by the main state and -/// every block coroutine: the cancellation poll, plus a trip budget that is -/// effectively unlimited ([`HOOK_BUDGET`]) and never fires in practice. -fn budget_hook( - fired: Arc, +/// every block coroutine: the cancellation poll. +fn cancel_poll( cancel: Arc>, ) -> impl Fn(&Lua, &mlua::debug::Debug<'_>) -> mlua::Result { move |_lua, _debug| { @@ -172,11 +152,6 @@ fn budget_hook( "lua execution cancelled".to_string(), )); } - if fired.fetch_add(1, Ordering::Relaxed) == HOOK_BUDGET { - return Err(mlua::Error::external(crate::error::QuotaExceeded( - crate::error::QuotaResource::Instruction, - ))); - } Ok(VmState::Continue) } } @@ -184,16 +159,16 @@ fn budget_hook( /// Installs the every-Nth-instruction hook that keeps a block cancellable. /// /// The hook covers the main state only; coroutines need -/// [`InstructionBudget::install_on_thread`] with the returned counter. +/// [`CancelHook::install_on_thread`] with the returned hook. /// /// # Errors -/// Returns [`Error::Lua`] if the VM rejects the hook callback. -pub(crate) fn install_instruction_budget(lua: &Lua) -> Result { - let budget = InstructionBudget::default(); +/// Returns [`Error::LuaRuntime`] if the VM rejects the hook callback. +pub(crate) fn install_cancel_hook(lua: &Lua) -> Result { + let hook = CancelHook::default(); lua.set_hook( HookTriggers::new().every_nth_instruction(HOOK_INTERVAL), - budget_hook(Arc::clone(&budget.fired), Arc::clone(&budget.cancel)), + cancel_poll(Arc::clone(&hook.cancel)), ) .map_err(Error::lua)?; - Ok(budget) + Ok(hook) } diff --git a/crates/runvil-internal/lua/src/iteration.rs b/crates/runvil-internal/lua/src/iteration.rs index 79caa60ed..c7f363a2f 100644 --- a/crates/runvil-internal/lua/src/iteration.rs +++ b/crates/runvil-internal/lua/src/iteration.rs @@ -24,29 +24,30 @@ //! scalar keys that sort after the array segment - booleans and smaller //! numbers may be visited more than once. No key is lost, and `pairs` is //! unaffected because it advances a captured snapshot rather than re-deriving -//! order from the live table. A cleared non-scalar key has no cross-process +//! order from the live table. A cleared unordered key has no cross-process //! position, so `next` falls back to the trailing segment such keys occupy, //! the best a stateless resume can do. //! Rebuilding //! costs `O(n log n)` per step; section tables are small and the determinism -//! is the point. The ordering covers string, number, and boolean keys, the -//! only keys a payload the run log stores can carry. A non-scalar key (table, -//! function, userdata, or thread) is still visited rather than dropped, but it -//! sits after every scalar key in the order Lua's own `next` produced it: such -//! a key has no cross-process position, and it cannot back a logged value. +//! is the point. The ordering covers boolean keys, finite number keys, and +//! valid UTF-8 string keys, the only keys a payload the run log stores can +//! hold. Every other key (a table, function, userdata, or thread, a +//! non-UTF-8 string, or an infinite number) is still visited rather than +//! dropped, after every ordered key, in the order Lua's own `next` produced +//! it. use std::cmp::Ordering; use mlua::{Lua, MultiValue, Table, Value}; -use crate::collection::{SortKey, sort_key}; use crate::error::{Error, Result}; +use crate::order::{SortKey, sort_key}; /// Installs the deterministic `next` and `pairs` globals, replacing the base /// library's hash-order versions. /// /// # Errors -/// Returns [`Error::Lua`] if either global cannot be created or installed. +/// Returns [`Error::LuaRuntime`] if either global cannot be created or installed. pub(crate) fn install_deterministic_iteration(lua: &Lua) -> Result<()> { let globals = lua.globals(); let next_fn = lua @@ -64,12 +65,12 @@ struct WalkKey { } /// Where a key sits in the walk order: the array segment, the scalar hash -/// segment (by [`SortKey`]), or the trailing non-scalar segment. The segments +/// segment (by [`SortKey`]), or the trailing unordered segment. The segments /// compare before the inner position, so an array key always precedes a /// boolean hash key even though [`SortKey`] ranks a boolean below a number; -/// the ordered list and the resumption comparator thus agree. A non-scalar key -/// has no cross-position, so all of them compare equal and keep their raw -/// `pairs` order. +/// the ordered list and the resumption comparator thus agree. An unordered +/// key, any key `sort_key` refuses, has no cross-process position, so all of +/// them compare equal and keep the order Lua's own `next` produced. enum WalkOrder { Array(i64), Scalar(SortKey), @@ -78,7 +79,7 @@ enum WalkOrder { impl WalkOrder { /// The segment rank: the array part, then scalar hash keys, then - /// non-scalar keys. Segments compare before any within-segment position. + /// unordered keys. Segments compare before any within-segment position. fn segment(&self) -> u8 { match self { WalkOrder::Array(_) => 0, @@ -88,8 +89,8 @@ impl WalkOrder { } /// Orders two walk positions by segment, then within the segment. All - /// non-scalar positions compare equal, so a stable sort keeps their raw - /// `pairs` order. + /// unordered positions compare equal, so a stable sort keeps their raw + /// traversal order. fn compare(&self, other: &Self) -> Ordering { match (self, other) { (WalkOrder::Array(left), WalkOrder::Array(right)) => left.cmp(right), @@ -182,8 +183,8 @@ fn pairs(lua: &Lua, value: Value) -> mlua::Result { } /// The table's live keys in deterministic walk order: the array part -/// (`1..=#table`) in index order, then the hash part's scalar keys by -/// [`SortKey`], then any non-scalar keys. +/// (`1..=#table`) in index order, then the hash part's keys `sort_key` +/// accepts, by [`SortKey`], then every key it refuses. fn ordered_keys(table: &Table) -> mlua::Result> { let border = table.raw_len(); let mut keys: Vec = Vec::with_capacity(border); @@ -232,9 +233,7 @@ fn ordered_keys(table: &Table) -> mlua::Result> { /// The index in `keys` to return for a `next` call with `previous` as the /// last key. An exact match advances one slot. A key whose value was cleared /// (`previous` absent) resumes at the first key that sorts strictly after it, -/// so the cleared key is skipped rather than revisited; a cleared non-scalar -/// key has no computed position, so the resume falls back to the trailing -/// segment, the best a stateless resume can offer. +/// so the cleared key is skipped rather than revisited. fn next_index(keys: &[WalkKey], previous: &Value) -> Option { if matches!(previous, Value::Nil) { return (!keys.is_empty()).then_some(0); @@ -253,16 +252,9 @@ fn next_index(keys: &[WalkKey], previous: &Value) -> Option { } /// The walk position a lone key would occupy, ignoring whether it is still in -/// the table: any integer `index >= 1` is an array key, a scalar hash key keeps -/// its [`SortKey`], and anything else is non-scalar. The integer case does not -/// consult the live border, so a cleared integer key resumes as an array key -/// and its position does not shift with the mutable length hint. Because an -/// absent integer key cannot be distinguished from a cleared array key, a -/// cleared non-array integer hash key (for example `t[7]` with no array part) -/// likewise resumes as if it were in the array part; the returned -/// `Array(index)` then replays the scalar keys that sort after the array -/// segment (booleans and smaller numbers may be visited more than once). No -/// key is lost, and `pairs`, which advances a captured snapshot, is unaffected. +/// the table: any integer `index >= 1` is an array key without consulting the +/// live border (the module doc gives the resume limit this sets), a hash key +/// `sort_key` accepts keeps its [`SortKey`], and any other key is unordered. fn previous_order(previous: &Value) -> WalkOrder { if let Value::Integer(index) = previous && *index >= 1 diff --git a/crates/runvil-internal/lua/src/json.rs b/crates/runvil-internal/lua/src/json.rs index 0a367aa03..9f0ac0d2a 100644 --- a/crates/runvil-internal/lua/src/json.rs +++ b/crates/runvil-internal/lua/src/json.rs @@ -18,7 +18,7 @@ pub(crate) use encode::to_json; use mlua::{Lua, LuaSerdeExt, Table, Value}; -use crate::error::{Error, Result}; +use crate::error::{Error, Result, author_refusal}; /// Installs the `json` table as a global. /// @@ -73,19 +73,13 @@ fn array(lua: &Lua, value: Value) -> mlua::Result
{ Ok(table) } Some(metatable) if metatable == marker => Ok(table), - Some(_) => Err(refusal( + Some(_) => Err(author_refusal( "json.array: the table already has a metatable".to_owned(), )), }, - other => Err(refusal(format!( + other => Err(author_refusal(format!( "json.array expects a table or no argument, got {}", other.type_name() ))), } } - -/// Raises `message` as an author error, which `pcall` reports as kind -/// `lua`. -fn refusal(message: String) -> mlua::Error { - mlua::Error::external(Error::Lua(message)) -} diff --git a/crates/runvil-internal/lua/src/json/decode.rs b/crates/runvil-internal/lua/src/json/decode.rs index eba8b2832..55d3ae853 100644 --- a/crates/runvil-internal/lua/src/json/decode.rs +++ b/crates/runvil-internal/lua/src/json/decode.rs @@ -9,7 +9,7 @@ use mlua::{BorrowedBytes, Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; -use super::refusal; +use crate::error::author_refusal; /// The most bytes either function takes. const INPUT_LIMIT: usize = 8_388_608; @@ -17,8 +17,8 @@ const INPUT_LIMIT: usize = 8_388_608; /// `json.decode(text)`: the one JSON document `text` holds, as Lua values. pub(super) fn decode(lua: &Lua, text: Value) -> mlua::Result { let bytes = input(text, "json.decode")?; - let document: Json = - serde_json::from_slice(&bytes).map_err(|error| refusal(format!("json.decode: {error}")))?; + let document: Json = serde_json::from_slice(&bytes) + .map_err(|error| author_refusal(format!("json.decode: {error}")))?; lua.to_value(&document) } @@ -31,7 +31,7 @@ pub(super) fn decode_lines(lua: &Lua, text: Value) -> mlua::Result { let values = serde_json::Deserializer::from_slice(&bytes) .into_iter::() .collect::, _>>() - .map_err(|error| refusal(format!("json.decode_lines: {error}")))?; + .map_err(|error| author_refusal(format!("json.decode_lines: {error}")))?; lua.to_value(&values) } @@ -41,7 +41,7 @@ fn input(text: Value, function: &str) -> mlua::Result { let text = match text { Value::String(text) => text, other => { - return Err(refusal(format!( + return Err(author_refusal(format!( "{function} expects a string, got {}", other.type_name() ))); @@ -49,7 +49,7 @@ fn input(text: Value, function: &str) -> mlua::Result { }; let bytes = text.as_bytes(); if bytes.len() > INPUT_LIMIT { - return Err(refusal(format!( + return Err(author_refusal(format!( "{function}: input exceeds the {INPUT_LIMIT}-byte limit" ))); } diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index 5179a3b86..9efa6296a 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -20,7 +20,7 @@ use mlua::{Lua, LuaSerdeExt, LuaString, Table, Value}; use serde_json::{Map, Number, Value as Json}; use super::keys::{Entries, Rule, Shape, entries, shape}; -use super::refusal; +use crate::error::author_refusal; use crate::proxy::{key_path, key_text}; use crate::walk_budget::{HELD_KEY_LIMIT, TooLarge, WalkBudget}; @@ -33,8 +33,8 @@ pub(super) fn encode(lua: &Lua, (value, options): (Value, Value)) -> mlua::Resul const FUNCTION: &str = "json.encode"; let debug = debug_option(&options, FUNCTION)?; let json = to_json(lua, FUNCTION, debug, &value)?; - let text = - serde_json::to_string(&json).map_err(|error| refusal(format!("{FUNCTION}: {error}")))?; + let text = serde_json::to_string(&json) + .map_err(|error| author_refusal(format!("{FUNCTION}: {error}")))?; lua.create_string(text) } @@ -56,7 +56,7 @@ pub(super) fn encode_lines(lua: &Lua, (list, options): (Value, Value)) -> mlua:: const FUNCTION: &str = "json.encode_lines"; let debug = debug_option(&options, FUNCTION)?; let Value::Table(list) = list else { - return Err(refusal(format!( + return Err(author_refusal(format!( "{FUNCTION} expects a list, got {}", list.type_name() ))); @@ -64,7 +64,7 @@ pub(super) fn encode_lines(lua: &Lua, (list, options): (Value, Value)) -> mlua:: let mut text = String::new(); for item in Encoder::new(lua, FUNCTION, debug).items(&list)? { let line = serde_json::to_string(&item) - .map_err(|error| refusal(format!("{FUNCTION}: {error}")))?; + .map_err(|error| author_refusal(format!("{FUNCTION}: {error}")))?; text.push_str(&line); text.push('\n'); } @@ -79,7 +79,7 @@ fn debug_option(options: &Value, function: &str) -> mlua::Result { Value::Nil => return Ok(false), Value::Table(table) => table, other => { - return Err(refusal(format!( + return Err(author_refusal(format!( "{function}: options must be a table, got {}", other.type_name() ))); @@ -91,14 +91,14 @@ fn debug_option(options: &Value, function: &str) -> mlua::Result { let mut debug = false; for key in options.keys { if !matches!(&key, Value::String(name) if name == "debug") { - return Err(refusal(format!( + return Err(author_refusal(format!( "{function}: unknown option {}", key_text(&key) ))); } let value: Value = table.raw_get(key)?; let Value::Boolean(flag) = value else { - return Err(refusal(format!( + return Err(author_refusal(format!( "{function}: option debug must be a boolean, got {}", value.type_name() ))); @@ -110,12 +110,11 @@ fn debug_option(options: &Value, function: &str) -> mlua::Result { /// The refusal for a value too large to walk: past the value budget, the /// byte budget, or the held-key limit. It has no path. -fn too_large(function: &str) -> mlua::Error { - refusal(format!("{function}: value is too large to encode")) +fn too_large(prefix: &str) -> mlua::Error { + author_refusal(format!("{prefix}: value is too large to encode")) } -/// Why a value cannot be encoded, before the function name and path are -/// added. +/// Why a value cannot be encoded, before the prefix and path are added. enum Failure { NotData(&'static str), NotUtf8, @@ -159,7 +158,7 @@ enum Resolved { /// is left of the budget for values and text, and how many keys the tables /// on the path hold as Lua references. struct Encoder { - function: &'static str, + prefix: &'static str, debug: bool, marker: Table, keys: Vec, @@ -169,9 +168,9 @@ struct Encoder { } impl Encoder { - fn new(lua: &Lua, function: &'static str, debug: bool) -> Self { + fn new(lua: &Lua, prefix: &'static str, debug: bool) -> Self { Self { - function, + prefix, debug, marker: lua.array_metatable(), keys: Vec::new(), @@ -185,7 +184,7 @@ impl Encoder { /// no path, and it never becomes a placeholder: one placeholder per /// remaining value would not bound the work. fn too_large(&self) -> mlua::Error { - too_large(self.function) + too_large(self.prefix) } /// Spends one value of the budget. @@ -208,7 +207,7 @@ impl Encoder { .keys .iter() .fold("value".to_owned(), |path, key| key_path(&path, key)); - refusal(format!("{}: {path} {}", self.function, failure.text())) + author_refusal(format!("{}: {path} {}", self.prefix, failure.text())) } /// `placeholder` in debug mode, or the error for `failure`. @@ -383,9 +382,9 @@ impl Encoder { let marked = self.is_marked(&data); let has_items = keys.iter().any(|key| matches!(key, Value::Integer(_))); if let (false, false, Some(key)) = (marked, has_items, keys.first()) { - return Err(refusal(format!( + return Err(author_refusal(format!( "{} expects a list, got a table with a {} key", - self.function, + self.prefix, key.type_name() ))); } diff --git a/crates/runvil-internal/lua/src/json/keys.rs b/crates/runvil-internal/lua/src/json/keys.rs index 947619133..d083e6479 100644 --- a/crates/runvil-internal/lua/src/json/keys.rs +++ b/crates/runvil-internal/lua/src/json/keys.rs @@ -5,7 +5,7 @@ use std::cmp::Ordering; use mlua::{Table, Value}; -use crate::collection::{KeyError, SortKey, sort_key}; +use crate::order::{KeyError, SortKey, sort_key}; /// What a table's keys make it. pub(super) enum Shape { diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index c76c25b61..115b0ac0a 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -26,10 +26,11 @@ //! from the executor, so every section //! in a run shares one set of virtual files even though contexts clear on each //! transition. A failed store op raises a structured error value of kind -//! `store` carrying its `reason` and fields, which surfaces from -//! `SectionVm::run_chunk` as a Lua-category error. +//! `store` carrying its `reason` and fields, which surfaces from the +//! block's coroutine (`SectionVm::start_block_coro` and its resumes) as a +//! Lua-category error. //! -//! In scheduler mode the VM also holds `tasks`, the shims for spawning, +//! The VM also holds `tasks`, the shims for spawning, //! waiting on, checking, noting, and cancelling tasks. Pack preludes //! are the Lua source each Pack the prompt declares contributes: each //! runs once per VM in an environment of its own before `require` @@ -43,7 +44,8 @@ //! `tools.adhoc` defines a Lua function as a tool of the adhoc Pack. //! `tools.offer` and `tools.offer_dynamic` remember tools and Packs, by //! id or object, for the section's Static Set and Dynamic Set, which the -//! section's first model call freezes; after that, every offer raises. +//! section's first loop round freezes; after that, every offer raises. +//! `models.infer` freezes nothing. //! //! `messages` is the section's one conversation: a list the VM creates //! empty at each section entry and serves as a global that no assignment @@ -67,6 +69,7 @@ mod alias; mod argv; +mod bindings; mod collection; mod compactors; mod coro; @@ -77,13 +80,13 @@ mod error; #[path = "error-value.rs"] mod error_value; mod globals; -mod handles; mod hardening; mod iteration; mod json; mod messages; mod models; mod models_loop; +mod order; mod packs; mod prelude; mod program; @@ -93,8 +96,10 @@ mod protocol; mod proxy; mod require; mod scope; +mod section; mod sys; mod tools; +mod var; mod vm; mod walk_budget; @@ -108,11 +113,12 @@ mod tests; // The executor-facing surface: every item `runvil-engine` names crosses // here. The facade re-exports only `VfsOp` and `VfsOutcome`. pub use crate::argv::Argv; +pub use crate::bindings::{RosterEntry, ToolBinding, ToolOutputKind, ToolSet, wire_name_of}; pub use crate::collection::render_item; pub use crate::compactors::{ OverflowReason, UsageAnchor, exhaustion_detail, output_reserve, precheck, }; -pub use crate::coro::{install_section_loop_shim, install_store_shims}; +pub use crate::coro::install_store_shims; pub use crate::dispatch::{ ModelReport, ScriptReport, ToolDispatch, prepare_dispatch, prepare_model_dispatch, }; @@ -124,26 +130,23 @@ pub use crate::error_value::{ ErrorField, ErrorKind, ErrorValue, ParseErrorKindError, Raised, store_error_value_fields, }; pub use crate::globals::{RESERVED_NAMES, Reserved, reserved_name}; -pub use crate::handles::{ - Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionText, ToolBinding, ToolOutputKind, - ToolSet, wire_name_of, -}; pub use crate::messages::MessageList; -pub use crate::models::ModelRuntime; +pub use crate::models::ModelSelection; pub use crate::prelude::install_preludes; pub use crate::program::LuaProgram; pub use crate::projection::project_messages; pub use crate::prose::ProseState; pub use crate::protocol::{ AdhocToolOutcome, Answer, Blocked, ChatOutcome, ChatResult, ContentPart, MessageContent, - MessageRecord, NonEmpty, RecordRole, Request, SpawnVia, TaskDelivery, TaskPhase, TaskStatus, - ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, + MessageRecord, ModelCall, NonEmpty, RecordRole, Request, SpawnVia, TaskDelivery, TaskPhase, + TaskStatus, ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; pub use crate::scope::{ - DiscoveryTool, Offer, OfferMode, OfferTarget, OfferedTool, RenderedTools, ToolCallCounts, - ToolRuntime, + DiscoveryTool, Offer, OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, + ToolCallCounts, }; +pub use crate::section::{Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionText}; pub use crate::vm::{ AdhocTools, CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_catalog_packs, - current_dynamic_set, current_tool_bindings, resolve_model_binding, + current_dynamic_set, current_static_set, resolve_model_binding, }; diff --git a/crates/runvil-internal/lua/src/messages-snapshot.rs b/crates/runvil-internal/lua/src/messages-snapshot.rs index dabc6145a..89ffaaad8 100644 --- a/crates/runvil-internal/lua/src/messages-snapshot.rs +++ b/crates/runvil-internal/lua/src/messages-snapshot.rs @@ -2,8 +2,7 @@ //! and the `compacting` flag set around that compactor. A snapshot shares //! each record with the list and with every view read from it, so taking //! one copies no record, and putting it back restores the list's records -//! exactly. Neither touches the list's last send or its rendered tool -//! list. +//! exactly. Neither touches the list's last send or its frozen sets. use std::sync::Arc; @@ -31,12 +30,12 @@ impl MessageList { Snapshot(self.state().records.clone()) } - /// Puts the records back; the send state and the frozen sets stay. + /// Puts the records back; the last send and the frozen sets stay. pub(crate) fn restore(&self, snapshot: Snapshot) { self.state().records = snapshot.0; } - /// Removes every record; the send state and the frozen sets stay. + /// Removes every record; the last send and the frozen sets stay. pub(crate) fn clear(&self) { self.state().records.clear(); } diff --git a/crates/runvil-internal/lua/src/messages-tests-list-commit.rs b/crates/runvil-internal/lua/src/messages-tests-list-commit.rs index 659e9946a..95e93daef 100644 --- a/crates/runvil-internal/lua/src/messages-tests-list-commit.rs +++ b/crates/runvil-internal/lua/src/messages-tests-list-commit.rs @@ -15,10 +15,7 @@ fn the_first_commit_extends_nothing() { let vm = TestVm::new().build(); let list = vm.messages(); vm.exec("messages:system('s'):user('u')"); - assert_eq!( - list.commit(RoundId::new(0), MODEL, &request(list)), - (None, 0) - ); + assert_eq!(list.commit(RoundId::new(0), MODEL, &request(list)), None); } #[test] @@ -30,7 +27,7 @@ fn a_resend_with_no_changes_keeps_every_message() { let _ = list.commit(RoundId::new(0), MODEL, &sent); assert_eq!( list.commit(RoundId::new(1), MODEL, &request(list)), - (Some(RoundId::new(0)), 2) + Some((RoundId::new(0), 2)) ); } @@ -45,7 +42,7 @@ fn appends_keep_the_whole_previous_request() { assert_eq!(next.len(), 4); assert_eq!( list.commit(RoundId::new(1), MODEL, &next), - (Some(RoundId::new(0)), 2) + Some((RoundId::new(0), 2)) ); } @@ -61,7 +58,7 @@ fn a_replace_in_the_middle_keeps_the_messages_before_the_first_change() { vm.exec("messages:replace(4, 4, { role = 'user', content = 'changed' })"); assert_eq!( list.commit(RoundId::new(1), MODEL, &request(list)), - (Some(RoundId::new(0)), 3) + Some((RoundId::new(0), 3)) ); } @@ -75,7 +72,7 @@ fn a_system_edit_keeps_nothing() { vm.exec("messages:replace(1, 1, { role = 'system', content = 'other' })"); assert_eq!( list.commit(RoundId::new(1), MODEL, &request(list)), - (Some(RoundId::new(0)), 0) + Some((RoundId::new(0), 0)) ); } @@ -91,7 +88,7 @@ fn removing_the_terminal_reply_keeps_all_but_the_last_message() { vm.exec("messages:replace(#messages, #messages)"); assert_eq!( list.commit(RoundId::new(1), MODEL, &request(list)), - (Some(RoundId::new(0)), 2) + Some((RoundId::new(0), 2)) ); } @@ -100,18 +97,15 @@ fn each_commit_records_its_round_for_the_next_one() { let vm = TestVm::new().build(); let list = vm.messages(); vm.exec("messages:user('u')"); - assert_eq!( - list.commit(RoundId::new(3), MODEL, &request(list)), - (None, 0) - ); + assert_eq!(list.commit(RoundId::new(3), MODEL, &request(list)), None); vm.exec("messages:assistant('a'):user('again')"); assert_eq!( list.commit(RoundId::new(7), MODEL, &request(list)), - (Some(RoundId::new(3)), 1) + Some((RoundId::new(3), 1)) ); assert_eq!( list.commit(RoundId::new(9), MODEL, &request(list)), - (Some(RoundId::new(7)), 3) + Some((RoundId::new(7), 3)) ); } diff --git a/crates/runvil-internal/lua/src/messages-tests.rs b/crates/runvil-internal/lua/src/messages-tests.rs index df96d25a9..f7017e134 100644 --- a/crates/runvil-internal/lua/src/messages-tests.rs +++ b/crates/runvil-internal/lua/src/messages-tests.rs @@ -1,6 +1,8 @@ //! Tests for the section's `messages` list and its parse through the //! protocol. +use std::sync::Arc; + use mlua::{Lua, LuaSerdeExt, Value}; use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::json; @@ -65,12 +67,22 @@ fn rendered(static_names: &[&str], dynamic_names: &[&str]) -> RenderedTools { #[test] fn a_list_is_rendered_only_once_sets_are_attached_and_keeps_the_first() { let list = MessageList::default(); - assert!(!list.rendered(), "a fresh list holds no frozen sets"); - assert!(list.rendered_tools().is_none()); - list.clone() + assert!( + list.rendered_tools().is_none(), + "a fresh list holds no frozen sets" + ); + let first = list + .clone() .attach_rendered_tools(rendered(&["first"], &["later"])); - assert!(list.rendered(), "a clone shares the attached sets"); - list.attach_rendered_tools(rendered(&["second"], &[])); + assert!( + list.rendered_tools().is_some(), + "a clone shares the attached sets" + ); + let second = list.attach_rendered_tools(rendered(&["second"], &[])); + assert!( + Arc::ptr_eq(&first, &second), + "a second attach hands back the sets the first left in place" + ); assert_eq!( *list.rendered_tools().expect("the list holds frozen sets"), rendered(&["first"], &["later"]), diff --git a/crates/runvil-internal/lua/src/messages-view.rs b/crates/runvil-internal/lua/src/messages-view.rs index eaa6fbb04..3a647d942 100644 --- a/crates/runvil-internal/lua/src/messages-view.rs +++ b/crates/runvil-internal/lua/src/messages-view.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use mlua::{AnyUserData, Function, Lua, LuaSerdeExt, MetaMethod, UserData, UserDataMethods, Value}; -use super::refusal; +use crate::error::author_refusal; use crate::protocol::MessageRecord; /// The fields a view reads, in the order `pairs` visits them. @@ -99,7 +99,7 @@ impl UserData for RecordView { methods.add_meta_method( MetaMethod::NewIndex, |_, _, _: (Value, Value)| -> mlua::Result<()> { - Err(refusal( + Err(author_refusal( "records read from messages are read-only; rewrite history with \ models.compact(compactor, reason)" .to_owned(), diff --git a/crates/runvil-internal/lua/src/messages.rs b/crates/runvil-internal/lua/src/messages.rs index e40463331..cc1dd20a2 100644 --- a/crates/runvil-internal/lua/src/messages.rs +++ b/crates/runvil-internal/lua/src/messages.rs @@ -23,8 +23,8 @@ //! list, so the round reads every record with no second validation. //! //! The list also holds the section's frozen sets, which the Engine -//! attaches at the first model call that builds them, so they last -//! exactly as long as the section. Once they are attached, every offer +//! attaches at the section's first loop round, so they last exactly as +//! long as the section. Once they are attached, every offer //! function raises. A compaction edits the list in place and keeps a //! snapshot of its records to put back if the compaction fails; neither //! the snapshot nor `compactors.clear` touches the last send or the @@ -39,8 +39,8 @@ use runvil_model_client::client::{Message, Role}; use runvil_types::ids::RoundId; use serde_json::{Map, Value as Json}; -use crate::error::{Error, Result}; -use crate::handles::ToolBinding; +use crate::bindings::ToolBinding; +use crate::error::{Error, Result, author_refusal}; use crate::protocol::{MessageRecord, RecordRole, parse_record}; use crate::scope::{OfferMode, RenderedTools}; @@ -60,11 +60,11 @@ pub(crate) const ASSIGNMENT_REFUSAL: &str = "messages is the section's conversat /// Installs `list` as the `messages` global, served from the guard's slot. /// /// # Errors -/// Returns [`Error::Lua`] if the userdata cannot be built or the guard's +/// Returns [`Error::LuaRuntime`] if the userdata cannot be built or the guard's /// slot table cannot be written. pub(crate) fn install_messages(lua: &Lua, list: &MessageList) -> Result<()> { let userdata = lua.create_userdata(list.clone()).map_err(Error::lua)?; - crate::globals::install_messages(lua, userdata) + crate::globals::set_messages(lua, userdata) } /// A section's conversation, which the `messages` global holds. The same @@ -192,18 +192,18 @@ impl MessageList { /// Records `sent` as the list's send in `round` to the model named /// `model`, and returns the previous send's round and how many leading - /// messages `sent` shares with that send; `(None, 0)` on the first. + /// messages `sent` shares with that send; `None` on the first. #[must_use] - pub fn commit(&self, round: RoundId, model: &str, sent: &[Message]) -> (Option, u64) { + pub fn commit(&self, round: RoundId, model: &str, sent: &[Message]) -> Option<(RoundId, u64)> { let latest = (round, model.to_owned(), sent.to_vec()); let previous = self.state().last.replace(latest); - previous.map_or((None, 0), |(after, _, earlier)| { + previous.map(|(after, _, earlier)| { let keep = earlier .iter() .zip(sent) .take_while(|(earlier, now)| earlier == now) .count(); - (Some(after), u64::try_from(keep).unwrap_or(u64::MAX)) + (after, u64::try_from(keep).unwrap_or(u64::MAX)) }) } @@ -213,12 +213,6 @@ impl MessageList { self.state().last.as_ref().map(|(_, name, _)| name.clone()) } - /// Whether frozen sets are attached; none means false. - #[must_use] - pub(crate) fn rendered(&self) -> bool { - self.state().rendered.is_some() - } - /// The attached frozen sets, if they are attached. #[must_use] pub fn rendered_tools(&self) -> Option> { @@ -234,12 +228,13 @@ impl MessageList { Some((binding.clone(), set)) } - /// Attaches `tools` as the section's frozen sets. A list that already - /// holds some keeps them. + /// Attaches `tools` as the section's frozen sets and returns the sets + /// the list holds. A list that already holds some keeps them. /// /// The Engine calls this only on a list with none attached. - pub fn attach_rendered_tools(&self, tools: RenderedTools) { - self.state().rendered.get_or_insert_with(|| Arc::new(tools)); + #[must_use] + pub fn attach_rendered_tools(&self, tools: RenderedTools) -> Arc { + Arc::clone(self.state().rendered.get_or_insert_with(|| Arc::new(tools))) } } @@ -277,12 +272,6 @@ fn check_leading_system(records: &[Arc]) -> std::result::Result<( } } -/// Raises `message` as a list method's error, which an author `pcall` -/// catches. -fn refusal(message: String) -> mlua::Error { - mlua::Error::external(Error::Lua(message)) -} - /// The list a method was called on. fn handle(this: &AnyUserData) -> mlua::Result { Ok(this.borrow::()?.clone()) @@ -300,7 +289,7 @@ fn json(lua: &Lua, value: Value) -> std::result::Result { /// the position `index` it takes. fn record_json(lua: &Lua, index: usize, value: Value) -> mlua::Result { json(lua, value).map_err(|reason| { - refusal(format!( + author_refusal(format!( "messages[{index}] must be a JSON-representable message table: {reason}" )) }) @@ -309,9 +298,9 @@ fn record_json(lua: &Lua, index: usize, value: Value) -> mlua::Result { /// Validates `entry` as the record at `index`, the end of `list`, and /// appends it. fn add(list: &MessageList, index: usize, entry: &Json) -> mlua::Result<()> { - let record = parse_record(index, entry).map_err(refusal)?; + let record = parse_record(index, entry).map_err(author_refusal)?; list.push(record) - .map_err(|refused| refusal(refused.to_string())) + .map_err(|refused| author_refusal(refused.to_string())) } /// Appends the record a builder makes from `role` and `fields`, leaving @@ -329,7 +318,7 @@ fn build( for (name, value) in fields { if !value.is_nil() { let value = json(lua, value).map_err(|reason| { - refusal(format!( + author_refusal(format!( "messages[{index}] {name} must be JSON-representable: {reason}" )) })?; @@ -366,8 +355,10 @@ fn key_index(key: &Value) -> Option { /// One `replace` bound under the integer rule. fn bound(name: &str, value: &Value) -> mlua::Result { integral(value).ok_or_else(|| match value { - Value::Number(bound) => refusal(format!("replace {name} must be an integer, got {bound}")), - other => refusal(format!( + Value::Number(bound) => { + author_refusal(format!("replace {name} must be an integer, got {bound}")) + } + other => author_refusal(format!( "replace {name} must be an integer, got {}", other.type_name() )), @@ -435,7 +426,7 @@ impl UserData for MessageList { |lua, (this, first, last, records): (AnyUserData, Value, Value, Variadic)| { let target = handle(&this)?; if !target.compacting() { - return Err(refusal( + return Err(author_refusal( "replace works only inside a compactor; rewrite history with \ models.compact(compactor, reason)" .to_owned(), @@ -446,19 +437,20 @@ impl UserData for MessageList { let len = target.len(); let (first, last) = match (usize::try_from(first), usize::try_from(last)) { (Ok(first), Ok(last)) if in_bounds(first, last, len) => (first, last), - _ => return Err(refusal(out_of_bounds(first, last, len).to_string())), + _ => return Err(author_refusal(out_of_bounds(first, last, len).to_string())), }; let records = records .into_iter() .enumerate() .map(|(offset, value)| { let index = first + offset; - parse_record(index, &record_json(lua, index, value)?).map_err(refusal) + parse_record(index, &record_json(lua, index, value)?) + .map_err(author_refusal) }) .collect::>>()?; target .replace(first, last, records) - .map_err(|refused| refusal(refused.to_string()))?; + .map_err(|refused| author_refusal(refused.to_string()))?; Ok(this) }, ); @@ -484,7 +476,7 @@ impl UserData for MessageList { methods.add_meta_method( MetaMethod::NewIndex, |_, _, _: (Value, Value)| -> mlua::Result<()> { - Err(refusal( + Err(author_refusal( "messages cannot be assigned to by index; add records with append(record), \ or rewrite history with models.compact(compactor, reason)" .to_owned(), diff --git a/crates/runvil-internal/lua/src/models-tests.rs b/crates/runvil-internal/lua/src/models-tests.rs index 95780389d..26edb4936 100644 --- a/crates/runvil-internal/lua/src/models-tests.rs +++ b/crates/runvil-internal/lua/src/models-tests.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use mlua::Value; use runvil_model_client::model::{ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature}; -use super::{ModelRuntime, UseOptions, option_check_order}; +use super::{ModelSelection, UseOptions, option_check_order}; use crate::alias::Alias; use crate::test_vm::{BuiltVm, TestVm}; @@ -16,7 +16,7 @@ fn alias(text: &str) -> Alias { } /// The runtime's selected label, if any. -fn used(runtime: &ModelRuntime) -> Option<&str> { +fn used(runtime: &ModelSelection) -> Option<&str> { runtime.selection().map(|(label, _)| label.as_str()) } @@ -24,7 +24,7 @@ fn used(runtime: &ModelRuntime) -> Option<&str> { fn model_runtime_select_allows_reselection() { // Selections are read at call time, so a later `models.use` replaces the // earlier one and steers the next model round. - let mut runtime = ModelRuntime::new(); + let mut runtime = ModelSelection::new(); assert!(used(&runtime).is_none()); runtime.select(alias("writer"), UseOptions::default()); assert_eq!(used(&runtime), Some("writer")); @@ -38,7 +38,7 @@ fn model_runtime_select_allows_reselection() { #[test] fn model_runtime_starts_with_no_selection() { - let runtime = ModelRuntime::new(); + let runtime = ModelSelection::new(); assert!(used(&runtime).is_none(), "fresh runtime has no selection"); } @@ -58,15 +58,10 @@ fn bound_role(label: &str, capabilities: &[&str]) -> ModelBinding { /// the `writer` and `critic` roles. #[track_caller] fn models_vm() -> BuiltVm { - TestVm::new() - .models(ModelSet::from_parts( - vec![ - bound_role("writer", &["frontier", "thinking"]), - bound_role("critic", &["fast"]), - ], - None, - )) - .build() + let mut models = ModelSet::default(); + models.push(bound_role("writer", &["frontier", "thinking"])); + models.push(bound_role("critic", &["fast"])); + TestVm::new().models(models).build() } #[test] @@ -106,7 +101,7 @@ fn models_use_selects_a_bound_role_by_label() { assert_eq!(handle, "writer|writer"); assert_eq!( used( - &vm.model_runtime() + &vm.model_selection() .lock() .expect("the model runtime mutex is not poisoned") ), @@ -136,7 +131,7 @@ fn models_use_options_reach_the_returned_handle() { assert_eq!(max_tokens, 256); assert_eq!( used( - &vm.model_runtime() + &vm.model_selection() .lock() .expect("the model runtime mutex is not poisoned") ), @@ -217,7 +212,7 @@ fn models_use_rejects_invalid_options_without_selecting() { ); assert!( used( - &vm.model_runtime() + &vm.model_selection() .lock() .expect("the model runtime mutex is not poisoned") ) @@ -288,7 +283,7 @@ fn models_use_reports_the_first_bad_option_in_key_order_on_every_state() { /// way the Engine's Chat-effect sites read it. #[track_caller] fn effective_sampling(vm: &BuiltVm) -> (Option, Option) { - let binding = crate::resolve_model_binding(vm.models.as_ref(), vm.model_runtime()) + let binding = crate::resolve_model_binding(vm.models.as_ref(), vm.model_selection()) .expect("the model state is readable") .expect("a selection resolves"); let invocation = binding.invocation(); @@ -411,7 +406,7 @@ fn the_raw_id_fallback_still_validates_the_gateway_id() { #[test] fn a_poisoned_model_set_reaches_the_caller_as_an_internal_error() { let vm = models_vm(); - vm.model_runtime() + vm.model_selection() .lock() .expect("the model runtime mutex is not poisoned") .select(alias("writer"), UseOptions::default()); @@ -422,10 +417,10 @@ fn a_poisoned_model_set_reaches_the_caller_as_an_internal_error() { }) .join(); - let error = crate::resolve_model_binding(vm.models.as_ref(), vm.model_runtime()) + let error = crate::resolve_model_binding(vm.models.as_ref(), vm.model_selection()) .expect_err("a poisoned model set cannot resolve a binding"); assert!( - matches!(&error, crate::Error::Internal(message) if message == "model set mutex was poisoned"), + matches!(&error, crate::Error::Internal { message, .. } if message == "model set mutex was poisoned"), "the lock failure is Engine misuse, neither an authoring nor a model failure: {error:?}" ); } diff --git a/crates/runvil-internal/lua/src/models-userdata.rs b/crates/runvil-internal/lua/src/models-userdata.rs index 30f1d5ad4..5b6cbf190 100644 --- a/crates/runvil-internal/lua/src/models-userdata.rs +++ b/crates/runvil-internal/lua/src/models-userdata.rs @@ -11,8 +11,9 @@ use mlua::{UserData, UserDataFields, Value}; use runvil_model_client::model::ModelBinding; -/// Inspectable Lua userdata returned by `models.use` / `models.default` / -/// `models.get`. +/// One model handle over a frozen `ModelBinding`. Its getters return raw +/// Lua numbers where the binding keeps validated types (`NonZeroU32`, +/// `Temperature`). #[derive(Debug, Clone)] pub(crate) struct LuaModelHandle { binding: ModelBinding, @@ -31,19 +32,13 @@ impl LuaModelHandle { &self.binding } - /// Returns the prompt-local alias. + /// Returns the role label the handle was obtained by; the `name` and + /// `label` fields both read it. #[must_use] fn name(&self) -> &str { self.binding.alias() } - /// Returns the role label the binding filled (the alias, under the - /// frontmatter's role vocabulary). - #[must_use] - fn label(&self) -> &str { - self.binding.alias() - } - /// Returns the bound role's full keyword set. #[must_use] fn capabilities(&self) -> &[String] { @@ -64,9 +59,6 @@ impl LuaModelHandle { } /// Returns the catalog context window size in tokens. - /// - /// The binding stores a [`NonZeroU32`](std::num::NonZeroU32); the raw `u32` - /// is exposed only here, at the Lua presentation boundary. #[must_use] fn context(&self) -> u32 { self.binding.context().get() @@ -85,10 +77,6 @@ impl LuaModelHandle { } /// Returns the frozen sampling temperature, when the role declared one. - /// - /// The binding stores a validated - /// [`Temperature`](runvil_model_client::model::Temperature); the - /// raw `f64` is exposed only here, at the Lua presentation boundary. #[must_use] fn temperature(&self) -> Option { self.binding @@ -98,9 +86,6 @@ impl LuaModelHandle { } /// Returns the frozen max generation tokens, when the role declared one. - /// - /// The binding stores a [`NonZeroU32`](std::num::NonZeroU32); the raw `u32` - /// is exposed only here, at the Lua presentation boundary. #[must_use] fn max_tokens(&self) -> Option { self.binding @@ -113,7 +98,7 @@ impl LuaModelHandle { impl UserData for LuaModelHandle { fn add_fields>(fields: &mut F) { fields.add_field_method_get("name", |_, this| Ok(this.name().to_owned())); - fields.add_field_method_get("label", |_, this| Ok(this.label().to_owned())); + fields.add_field_method_get("label", |_, this| Ok(this.name().to_owned())); fields.add_field_method_get("capabilities", |lua, this| { lua.create_sequence_from(this.capabilities().to_vec()) }); diff --git a/crates/runvil-internal/lua/src/models.rs b/crates/runvil-internal/lua/src/models.rs index 18f4b53f6..b89ec7393 100644 --- a/crates/runvil-internal/lua/src/models.rs +++ b/crates/runvil-internal/lua/src/models.rs @@ -36,8 +36,8 @@ pub(crate) enum RawModelIds { Resolved, } -/// The context window a raw gateway-id binding records: catalog metadata -/// the hack never sees, so a conservative default keeps the compactor +/// The context window a raw-model-id binding records: the fallback has no +/// catalog metadata, so a conservative default keeps the compactor /// precheck safe rather than refusing the model. const RAW_ID_CONTEXT: NonZeroU32 = match NonZeroU32::new(8192) { Some(value) => value, @@ -198,25 +198,27 @@ fn parse_use_options(options: Value, rest: &MultiValue) -> mlua::Result, +pub struct ModelSelection { + selection: Option<(Alias, UseOptions)>, } -impl ModelRuntime { +impl ModelSelection { pub(crate) fn new() -> Self { - ModelRuntime { used: None } + ModelSelection { selection: None } } /// Returns the current `models.use` label and its options, if any. pub(crate) fn selection(&self) -> Option<(&Alias, UseOptions)> { - self.used.as_ref().map(|(label, options)| (label, *options)) + self.selection + .as_ref() + .map(|(label, options)| (label, *options)) } /// Records a `models.use` selection, replacing any prior label and /// options: the selection is read at call time, so the latest call /// steers the next model round. fn select(&mut self, alias: Alias, options: UseOptions) { - self.used = Some((alias, options)); + self.selection = Some((alias, options)); } } @@ -241,17 +243,18 @@ impl ModelRuntime { /// without declaring its model. Refused, an undeclared alias is the usual /// error. /// -/// The coroutine shim layer installs the suspending `models.infer` and -/// `models.loop`, because yield cannot cross the Rust callback boundary. +/// The coroutine shim layer installs the suspending `models.infer`, +/// `models.loop`, and `models.compact`, because yield cannot cross the +/// Rust callback boundary. /// /// # Errors -/// Returns [`Error::Lua`] if a Lua table or callback cannot be created or +/// Returns [`Error::LuaRuntime`] if a Lua table or callback cannot be created or /// installed. pub(crate) fn install_models( lua: &Lua, globals: &Table, set: &Arc>, - runtime: &Arc>, + runtime: &Arc>, raw_ids: RawModelIds, ) -> Result<()> { let models = lua.create_table().map_err(Error::lua)?; @@ -276,7 +279,7 @@ pub(crate) fn install_models( let options = parse_use_options(options, &rest)?; let mut state = state .lock() - .map_err(|_| mlua::Error::external("model declaration runtime was poisoned"))?; + .map_err(|_| mlua::Error::external("model selection lock was poisoned"))?; state.select(label, options); Ok(LuaModelHandle::from_binding(options.apply(binding))) }, diff --git a/crates/runvil-internal/lua/src/models_loop.rs b/crates/runvil-internal/lua/src/models_loop.rs index 545d210b0..bf123eac5 100644 --- a/crates/runvil-internal/lua/src/models_loop.rs +++ b/crates/runvil-internal/lua/src/models_loop.rs @@ -26,7 +26,7 @@ use runvil_types::metrics::ToolCallEvent; use crate::error::{Error, Result}; use crate::error_value::{ErrorKind, error_table, normalized}; -use crate::hardening::InstructionBudget; +use crate::hardening::CancelHook; use crate::messages::MessageList; use crate::protocol::ChatResult; @@ -79,52 +79,44 @@ const COMPACT_RUNNING: &str = /// /// `max_tool_iterations` is the round cap, `compactors` the table that /// `compactors.fail` is read from at each call, `loop_running` the VM's -/// flag that is set while one of its loops runs, and `budget` the VM's -/// instruction budget, whose cancel flag the step reads where the shim +/// flag that is set while one of its loops runs, and `hook` the VM's +/// cancellation hook, whose cancel flag the step reads where the shim /// called `cancel_requested()`. /// /// # Errors -/// Returns [`Error::Lua`] if the function cannot be created. +/// Returns [`Error::LuaRuntime`] if the function cannot be created. pub(crate) fn loop_begin( lua: &Lua, max_tool_iterations: usize, compactors: Table, loop_running: &Arc, - budget: &InstructionBudget, + hook: &CancelHook, ) -> Result { let running = Arc::clone(loop_running); - let budget = budget.clone(); + let hook = hook.clone(); lua.create_function(move |lua, args: MultiValue| { - begin( - lua, - args, - max_tool_iterations, - &compactors, - &running, - &budget, - ) + begin(lua, args, max_tool_iterations, &compactors, &running, &hook) }) .map_err(Error::lua) } -/// `loop_begin`'s body: the entry flag, then that entry's argument checks -/// in Functional Specification's order, then the refusal while `running` -/// or the list's `compacting` flag is set, then the machine over the -/// section's list and its step closure, -/// whose body is `act(lua, machine.step(read_input(lua, machine.phase(), -/// values, &budget)?))`, then the first action. `running` is set only once -/// nothing here can fail and the first action leaves the machine running; -/// the step clears it when the machine is done, before it builds that -/// action, and whenever it fails. A failed step also clears the list's -/// `compacting` flag, which an overflow sets before the step builds the -/// compactor's action. +/// `loop_begin`'s body: the entry flag; for a handle's `loop`, the +/// receiver then the arity, and for `models.loop`, the handle-first +/// refusal then the arity; the compactor's type; the refusal while +/// `running` or the list's `compacting` flag is set; then the machine over +/// the section's list, its step closure, and the first action. `running` +/// is set only once nothing here can fail and the first action leaves the +/// machine running; the step clears it when the machine is done, before it +/// builds that action, and whenever it fails. A failed step also clears +/// the list's `compacting` flag, which an overflow sets before the step +/// builds the compactor's action. fn begin( lua: &Lua, args: MultiValue, max_rounds: usize, compactors: &Table, running: &Arc, - budget: &InstructionBudget, + hook: &CancelHook, ) -> mlua::Result { let mut args = args.into_iter(); let Some(Value::Boolean(method)) = args.next() else { @@ -170,11 +162,10 @@ fn begin( let flagged = list.clone(); let (mut machine, first) = Machine::begin(max_rounds, list, messages, handle, compactor); let started = !matches!(machine.phase(), Phase::Done); - let budget = budget.clone(); + let hook = hook.clone(); let cleared = Arc::clone(running); let step = lua.create_function_mut(move |lua, values: MultiValue| { - let then = - read_input(lua, machine.phase(), values, &budget).map(|input| machine.step(input)); + let then = read_input(lua, machine.phase(), values, &hook).map(|input| machine.step(input)); if matches!(machine.phase(), Phase::Done) { cleared.store(false, Ordering::Relaxed); } @@ -202,19 +193,19 @@ fn begin( /// action with the compactor and the reason; or `nil`, `"raise"`, and the /// refusal. The step takes the compactor's raw `pcall` outcome, settles it /// as an overflowed round's is settled but under the structural rules -/// only, and returns the `"return"` or `"raise"` action. `budget` is the -/// VM's instruction budget, whose cancel flag the step reads. +/// only, and returns the `"return"` or `"raise"` action. `hook` is the +/// VM's cancellation hook, whose cancel flag the step reads. /// /// # Errors -/// Returns [`Error::Lua`] if the function cannot be created. +/// Returns [`Error::LuaRuntime`] if the function cannot be created. pub(crate) fn compact_begin( lua: &Lua, loop_running: &Arc, - budget: &InstructionBudget, + hook: &CancelHook, ) -> Result { let running = Arc::clone(loop_running); - let budget = budget.clone(); - lua.create_function(move |lua, args: MultiValue| compact(lua, args, &running, &budget)) + let hook = hook.clone(); + lua.create_function(move |lua, args: MultiValue| compact(lua, args, &running, &hook)) .map_err(Error::lua) } @@ -224,7 +215,7 @@ fn compact( lua: &Lua, args: MultiValue, running: &AtomicBool, - budget: &InstructionBudget, + hook: &CancelHook, ) -> mlua::Result { let args: Vec = args.into_iter().collect(); if args.len() > 2 { @@ -248,7 +239,7 @@ fn compact( return refuse(lua, COMPACT_RUNNING); } let mut snapshot = Some(list.snapshot()); - let budget = budget.clone(); + let hook = hook.clone(); let compacted = list.clone(); let step = lua.create_function_mut(move |lua, (ok, value): (Value, Value)| { let Some(snapshot) = snapshot.take() else { @@ -257,7 +248,7 @@ fn compact( )); }; let outcome = if truthy(&ok) { Ok(value) } else { Err(value) }; - let then = settle(&compacted, snapshot, outcome, budget.is_cancelled(), None) + let then = settle(&compacted, snapshot, outcome, hook.is_cancelled(), None) .map_or_else(|refusal| refusal, |_| Then::Return); act(lua, then) })?; @@ -284,18 +275,18 @@ fn read_input( lua: &Lua, phase: &Phase, values: MultiValue, - budget: &InstructionBudget, + hook: &CancelHook, ) -> mlua::Result> { let mut values = values.into_iter(); let mut next = || values.next().unwrap_or(Value::Nil); let ok = truthy(&next()); let value = next(); match phase { - Phase::Compacting(_) | Phase::Handling => Ok(Input::Called { + Phase::Compacting(..) | Phase::Handling => Ok(Input::Called { outcome: if ok { Ok(value) } else { Err(value) }, - cancelled: budget.is_cancelled(), + cancelled: hook.is_cancelled(), }), - Phase::Chatting => Ok(Input::Answered(answer(lua, ok, value, |round| { + Phase::Chatting(_) => Ok(Input::Answered(answer(lua, ok, value, |round| { lua.unpack::(round)? .take::() .map(Box::new) @@ -453,8 +444,9 @@ fn truthy(value: &Value) -> bool { /// The adapter failure for a value the shim chunk or the renderer never /// produces: a crate bug, raised as an mlua callback error. +#[track_caller] fn malformed(what: &'static str) -> mlua::Error { - mlua::Error::external(Error::Internal(what.into())) + mlua::Error::external(Error::internal(what)) } /// Lua's `type()` name for `value`, with an integer named `integer`, as diff --git a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs index f48c4fc4d..ca5b6896a 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs @@ -134,7 +134,7 @@ fn a_round_starts_with_the_chat_naming_the_handle_and_pushes_nothing() { let list = MessageList::default(); let (machine, first) = Machine::begin(2, list.clone(), "list", Some("h"), "compactor"); assert_eq!(show(&first), "chat list h"); - assert!(matches!(machine.phase(), Phase::Chatting)); + assert!(matches!(machine.phase(), Phase::Chatting(_))); assert!( records(&list).is_empty(), "a round's start pushes no record" diff --git a/crates/runvil-internal/lua/src/models_loop/machine.rs b/crates/runvil-internal/lua/src/models_loop/machine.rs index 4d65ba638..e08466659 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine.rs @@ -69,9 +69,6 @@ pub(super) struct Machine { calls: Vec, /// The current batch's results so far, one per answered call. results: Vec, - /// The records from before this round's compaction, held until the - /// retried round's answer arrives. Set means the round has compacted. - snapshot: Option, /// What the machine waits on. phase: Phase, } @@ -80,11 +77,13 @@ pub(super) struct Machine { /// next. #[derive(Debug)] pub(super) enum Phase { - /// The `chat` answer: `(ok, round)`. - Chatting, + /// The `chat` answer: `(ok, round)`, with the records from before + /// this round's compaction, held until the retried round's answer + /// arrives. `Some` means the round has compacted. + Chatting(Option), /// The compactor's raw `pcall` after an overflow for this reason: - /// `(ok, value)`. - Compacting(OverflowReason), + /// `(ok, value)`, with the records from before the compaction. + Compacting(OverflowReason, Snapshot), /// The `tool_calls` answer for the batch's unanswered calls: /// `(ok, texts)`. Batching, @@ -181,7 +180,6 @@ impl Machine { turn: 0, calls: Vec::new(), results: Vec::new(), - snapshot: None, phase: Phase::Done, }; let first = machine.next_round(); @@ -200,23 +198,13 @@ impl Machine { /// error. pub(super) fn step(&mut self, input: Input) -> Then { match (std::mem::replace(&mut self.phase, Phase::Done), input) { - (Phase::Chatting, Input::Answered(answer)) => { - let compacted = self.snapshot.take(); - match answer { - Ok(round) => self.judge(*round, compacted), - Err(failure) => Then::Raise(failure), - } - } - (Phase::Compacting(reason), Input::Called { outcome, cancelled }) => { - let Some(snapshot) = self.snapshot.take() else { - self.list.set_compacting(false); - return Then::RaiseNew(raised(ErrorKind::Internal, UNEXPECTED_INPUT)); - }; + (Phase::Chatting(compacted), Input::Answered(answer)) => match answer { + Ok(round) => self.judge(*round, compacted), + Err(failure) => Then::Raise(failure), + }, + (Phase::Compacting(reason, snapshot), Input::Called { outcome, cancelled }) => { match settle(&self.list, snapshot, outcome, cancelled, Some(reason)) { - Ok(snapshot) => { - self.snapshot = Some(snapshot); - self.chat() - } + Ok(snapshot) => self.chat(Some(snapshot)), Err(refusal) => refusal, } } @@ -256,13 +244,14 @@ impl Machine { return Then::RaiseNew(raised(ErrorKind::ToolLoopExhausted, NOT_CONVERGED)); } self.rounds += 1; - self.chat() + self.chat(None) } /// Sends the list in a `chat` without counting a round: a new round's - /// send, or a compacted round's retry. - fn chat(&mut self) -> Then { - self.phase = Phase::Chatting; + /// send, or a compacted round's retry holding its `compacted` + /// snapshot. + fn chat(&mut self, compacted: Option) -> Then { + self.phase = Phase::Chatting(compacted); Then::Chat { messages: self.messages.clone(), handle: self.handle.clone(), @@ -315,9 +304,9 @@ impl Machine { self.list.restore(snapshot); return Then::RaiseNew(exhausted(reason)); } - self.snapshot = Some(self.list.snapshot()); + let snapshot = self.list.snapshot(); self.list.set_compacting(true); - self.phase = Phase::Compacting(reason); + self.phase = Phase::Compacting(reason, snapshot); Then::Compact { compactor: self.compactor.clone(), reason, diff --git a/crates/runvil-internal/lua/src/models_loop/tests.rs b/crates/runvil-internal/lua/src/models_loop/tests.rs index d55ab80ff..4977976b3 100644 --- a/crates/runvil-internal/lua/src/models_loop/tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/tests.rs @@ -16,10 +16,11 @@ use super::{LOOP_RUNNING, act, envelope_failure, loop_begin, lua_type_name, read use crate::compactors::OverflowReason; use crate::error::Error; use crate::error_value::{ErrorField, ErrorKind, Raised, error_table, raised_from}; -use crate::hardening::InstructionBudget; +use crate::hardening::CancelHook; use crate::messages::MessageList; use crate::models::LuaModelHandle; use crate::protocol::{AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, YieldParse}; +use crate::test_support::answer_envelope; /// A requested call named `echo`, its arguments naming its id. pub(super) fn echo_call(id: &str) -> ToolCallEvent { @@ -91,8 +92,8 @@ fn install_section_list(lua: &Lua) -> MessageList { fn begin_with_flag(lua: &Lua, max_rounds: usize) -> (Function, Arc) { let running = Arc::new(AtomicBool::new(false)); let compactors = lua.create_table().unwrap(); - let budget = InstructionBudget::default(); - let begin = loop_begin(lua, max_rounds, compactors, &running, &budget).unwrap(); + let hook = CancelHook::default(); + let begin = loop_begin(lua, max_rounds, compactors, &running, &hook).unwrap(); (begin, running) } @@ -181,7 +182,7 @@ fn a_running_loop_refuses_another_until_its_step_ends_or_fails() { 0, lua.create_table().unwrap(), &running, - &InstructionBudget::default(), + &CancelHook::default(), ) .unwrap(); let flag = Arc::clone(&running); @@ -240,9 +241,8 @@ fn a_step_failing_on_a_full_heap_after_an_overflow_leaves_no_flag_set() { outcome: ChatOutcome::Overflow(OverflowReason::Precheck), ..round() }; - let (envelope, _) = Answer::::Chat(Ok(Box::new(overflowed))) - .into_envelope(&lua) - .unwrap(); + let (envelope, _) = + answer_envelope(&lua, Ok(Answer::::Chat(Box::new(overflowed)))).unwrap(); lua.gc_collect().unwrap(); lua.set_memory_limit(lua.used_memory() + headroom).unwrap(); let stepped = step.call::(envelope); @@ -294,18 +294,17 @@ fn a_chat_answer_resumes_as_a_chat_result_userdata_the_step_takes_whole() { finish_reason: Some("tool_calls".to_owned()), ..round() }; - let (envelope, retained) = Answer::::Chat(Ok(Box::new(served.clone()))) - .into_envelope(&lua) - .unwrap(); + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::::Chat(Box::new(served.clone())))).unwrap(); assert!(retained.is_none()); let values = envelope.into_vec(); let Some(Value::UserData(resumed)) = values.get(1).cloned() else { panic!("the chat answer resumes as a userdata, got {values:?}"); }; assert!(resumed.is::()); - let budget = InstructionBudget::default(); + let hook = CancelHook::default(); let values = MultiValue::from_vec(values); - let input = read_input(&lua, &Phase::Chatting, values, &budget).unwrap(); + let input = read_input(&lua, &Phase::Chatting(None), values, &hook).unwrap(); let Input::Answered(Ok(taken)) = input else { panic!("the step reads the chat answer, got {input:?}"); }; @@ -342,18 +341,13 @@ fn act_yields_each_request_as_the_protocol_parses_it() { call: Box::new(echo_call("c1")), turn: 7, }; - let Request::ToolCall { - alias, - args, - call_id, - turn, - } = parsed(&lua, tool_call) - else { + let Request::ToolCall { alias, args, model } = parsed(&lua, tool_call) else { panic!("a tool_call request"); }; + let model = model.expect("the loop's call is model-issued"); assert_eq!( - (alias.as_str(), call_id.as_deref(), turn), - ("echo", Some("c1"), Some(7)) + (alias.as_str(), model.call_id.as_str(), model.turn), + ("echo", "c1", 7) ); assert_eq!(args, json!({ "id": "c1" })); let returned = Value::String(lua.create_string("text").unwrap()); diff --git a/crates/runvil-internal/lua/src/collection-order-tests.rs b/crates/runvil-internal/lua/src/order-tests.rs similarity index 95% rename from crates/runvil-internal/lua/src/collection-order-tests.rs rename to crates/runvil-internal/lua/src/order-tests.rs index ecbae7cab..8f8a9165e 100644 --- a/crates/runvil-internal/lua/src/collection-order-tests.rs +++ b/crates/runvil-internal/lua/src/order-tests.rs @@ -50,8 +50,8 @@ fn sort_key_classifies_scalars_and_rejects_unsortable_keys() { assert!(matches!(key, SortKey::Text(bytes) if bytes == b"alpha")); assert_eq!(label, "alpha"); - // A non-finite number has no ordered position, so it is rejected - // rather than silently ordered by a NaN or infinity comparison. + // A non-finite number is refused, and each caller decides its own + // diagnostic. let non_finite = sort_key(&Value::Number(f64::INFINITY)).expect_err("a non-finite number fails"); assert!(matches!(non_finite, KeyError::NotFinite)); diff --git a/crates/runvil-internal/lua/src/collection-order.rs b/crates/runvil-internal/lua/src/order.rs similarity index 90% rename from crates/runvil-internal/lua/src/collection-order.rs rename to crates/runvil-internal/lua/src/order.rs index 612ccaca1..7f8d1f086 100644 --- a/crates/runvil-internal/lua/src/collection-order.rs +++ b/crates/runvil-internal/lua/src/order.rs @@ -4,9 +4,10 @@ //! turns a table into an ordered sequence imposes the same order: booleans //! first (`false` before `true`), then numbers by value, then strings //! bytewise. [`sort_key`] classifies one Lua key into its [`SortKey`]; -//! [`SortKey::compare`] orders two of them. Fanout's member enumeration and -//! the deterministic `pairs`/`next` installer share both, so a table's order -//! is a function of its contents wherever it is read. +//! [`SortKey::compare`] orders two of them. Fanout's member enumeration, the +//! deterministic `pairs`/`next` installer, and `json.encode`'s key order +//! share both, so a table's order is a function of its contents wherever it +//! is read. use std::cmp::Ordering; @@ -103,7 +104,7 @@ fn compare_integer_float(integer: i64, float: f64) -> Ordering { /// Why a Lua value cannot be a sortable hash key. #[derive(Debug)] pub(crate) enum KeyError { - /// A number key that is NaN or infinite: it has no ordered position. + /// A number key that is NaN or infinite. NotFinite, /// A string key whose bytes are not valid UTF-8, so it has no label. NotUtf8(mlua::Error), @@ -115,10 +116,10 @@ pub(crate) enum KeyError { /// label naming it (a string's text, any other scalar's rendering). /// /// # Errors -/// Returns [`KeyError`] when the value has no ordered position: a non-scalar -/// key, a string whose bytes are not valid UTF-8, or a number that is not -/// finite. Each caller maps the failure onto its own diagnostic, so this -/// helper names no feature. +/// Returns [`KeyError`] when the value is not a string, number, or boolean, +/// when a string's bytes are not valid UTF-8 (it has no label), or when a +/// number is not finite. Each caller maps the failure onto its own +/// diagnostic, so this helper names no feature. pub(crate) fn sort_key(key: &Value) -> Result<(SortKey, String), KeyError> { match key { Value::String(s) => { @@ -139,5 +140,5 @@ pub(crate) fn sort_key(key: &Value) -> Result<(SortKey, String), KeyError> { } #[cfg(test)] -#[path = "collection-order-tests.rs"] +#[path = "order-tests.rs"] mod tests; diff --git a/crates/runvil-internal/lua/src/packs-tests.rs b/crates/runvil-internal/lua/src/packs-tests.rs index 51e8d61c1..e898c2bac 100644 --- a/crates/runvil-internal/lua/src/packs-tests.rs +++ b/crates/runvil-internal/lua/src/packs-tests.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use runvil_types::packs::PackId; use runvil_types::tools::ToolCatalog; -use crate::handles::ToolSet; +use crate::bindings::ToolSet; use crate::test_vm::{lua_over, offer_set, offers, raised, scoped, tool_at}; /// A Lua helper joining the `name` of each Pack object in a list. @@ -27,7 +27,6 @@ fn colliding_set() -> ToolSet { BTreeMap::new(), ToolCatalog::new(["extras/list", "get/one", "tools/tools"].map(tool_at)) .expect("the fixture ids differ"), - BTreeMap::new(), ) } diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index f4bbfceb3..3d26f4ff4 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -26,8 +26,9 @@ use mlua::{ use runvil_types::packs::PackId; use runvil_types::tools::ToolId; +use crate::bindings::{RosterEntry, ToolSet}; use crate::error::{Error, Result}; -use crate::handles::ToolSet; +use crate::proxy::read_only_walk; use crate::tools::tool_object; use crate::vm::AdhocTools; @@ -37,10 +38,6 @@ pub(crate) const ADHOC_PACK: &str = "adhoc"; /// The registry key of the VM's Pack-object table, keyed by name. const PACK_OBJECTS_REGISTRY: &str = "runvil.packs.objects"; -/// The label `getmetatable(tools.packs)` returns, which also protects the -/// metatable from `setmetatable`. -const PACKS_LABEL: &str = "tools.packs is read-only"; - /// Whether `pack` is the adhoc Pack. pub(crate) fn is_adhoc(pack: &PackId) -> bool { pack.to_string() == ADHOC_PACK @@ -132,9 +129,7 @@ pub(crate) struct PackNames { extras: Vec, } -/// Builds one Pack object per declared Pack, per usable roster Pack, per -/// other Pack with catalog tools, and the adhoc Pack over `adhoc`, and -/// returns their names. +/// Builds one Pack object per Pack `pack_tools` lists and returns their names. /// /// # Errors /// Returns [`Error::LuaRuntime`] if an object cannot be created or stored. @@ -173,7 +168,8 @@ pub(crate) fn install_pack_objects( /// /// # Errors /// Returns [`Error::LuaRuntime`] if a function or table cannot be created -/// or installed. +/// or installed, and [`Error::Internal`] if the `tools.packs` proxy comes +/// back without the metatable `__len` is set on. pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> Result<()> { let PackNames { all, extras } = names; let get = lua @@ -189,7 +185,6 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R .create_function(move |lua, ()| pack_array(lua, &extras)) .map_err(Error::lua)?; - let meta = lua.create_table().map_err(Error::lua)?; let indexed = all.clone(); let index = lua .create_function(move |lua, (_, key): (Table, Value)| match key { @@ -201,12 +196,10 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R }, }) .map_err(Error::lua)?; - meta.raw_set("__index", index).map_err(Error::lua)?; let count = all.len(); let len = lua .create_function(move |_, _: MultiValue| Ok(count)) .map_err(Error::lua)?; - meta.raw_set("__len", len).map_err(Error::lua)?; let step = lua .create_function(move |lua, (_, key): (Value, Value)| { let next = match key { @@ -222,21 +215,11 @@ pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> R } }) .map_err(Error::lua)?; - let pairs = lua - .create_function(move |_, proxy: Table| Ok((step.clone(), proxy, Value::Nil))) - .map_err(Error::lua)?; - meta.raw_set("__pairs", pairs).map_err(Error::lua)?; - let newindex = lua - .create_function(|_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external(PACKS_LABEL)) - }) - .map_err(Error::lua)?; - meta.raw_set("__newindex", newindex).map_err(Error::lua)?; - meta.raw_set("__metatable", PACKS_LABEL) - .map_err(Error::lua)?; - - let proxy = lua.create_table().map_err(Error::lua)?; - proxy.set_metatable(Some(meta)).map_err(Error::lua)?; + let proxy = read_only_walk(lua, index, step, "tools.packs").map_err(Error::lua)?; + let meta = proxy + .metatable() + .ok_or_else(|| Error::internal("tools.packs was built without a metatable"))?; + meta.raw_set("__len", len).map_err(Error::lua)?; tools.raw_set("packs", proxy).map_err(Error::lua) } @@ -268,7 +251,7 @@ fn pack_tools(set: &ToolSet, adhoc: &AdhocTools) -> BTreeMap let usable = set .roster() .iter() - .filter(|(_, reason)| reason.is_none()) + .filter(|(_, entry)| matches!(entry, RosterEntry::Usable { .. })) .map(|(name, _)| name); let mut packs: BTreeMap> = set .declared() diff --git a/crates/runvil-internal/lua/src/prelude-tests-environment.rs b/crates/runvil-internal/lua/src/prelude-tests-environment.rs index b3d598e80..e1a8a27cf 100644 --- a/crates/runvil-internal/lua/src/prelude-tests-environment.rs +++ b/crates/runvil-internal/lua/src/prelude-tests-environment.rs @@ -157,15 +157,10 @@ fn a_prelude_function_called_from_a_block_yields_its_tool_call() { panic!("the block must suspend on the tool call"); }; match vm.request_from_yield(&values) { - YieldParse::Request(Request::ToolCall { - alias, - args, - call_id, - .. - }) => { + YieldParse::Request(Request::ToolCall { alias, args, model }) => { assert_eq!(alias, "kit/run"); assert_eq!(args, json!({ "script": "ls" })); - assert_eq!(call_id, None, "a prelude's call is a script call"); + assert_eq!(model, None, "a prelude's call is a script call"); } other => panic!("expected a tool_call request, got {other:?}"), } diff --git a/crates/runvil-internal/lua/src/prelude-tests.rs b/crates/runvil-internal/lua/src/prelude-tests.rs index cfee0acef..2a959c4e4 100644 --- a/crates/runvil-internal/lua/src/prelude-tests.rs +++ b/crates/runvil-internal/lua/src/prelude-tests.rs @@ -12,23 +12,19 @@ use super::install_preludes; use crate::test_vm::{BuiltVm, TEST_SECTION, TestVm}; use crate::tests::recording::null_emitter; use crate::tests::refusal_line; -use crate::{ - Argv, CoroStep, LuaProgram, Request, SectionVm, YieldParse, install_section_loop_shim, - install_ui, -}; +use crate::{Argv, CoroStep, LuaProgram, Request, SectionVm, YieldParse, install_ui}; #[path = "prelude-tests-environment.rs"] mod environment; /// Builds a section VM through section setup up to where preludes install: /// Engine injection with a bound `argv`, the Engine globals, the control -/// globals, and every coroutine yield shim, `models.loop`'s included. +/// globals, and every coroutine yield shim, whose install puts +/// `models.loop` on `models` too. #[track_caller] fn prelude_vm(setup: TestVm<'_>) -> BuiltVm { let argv = json!({ "mode": "argv" }); - let vm = setup.argv(Argv::Frozen(Some(&argv))).shims().build(); - install_section_loop_shim(vm.lua()).expect("the loop shim installs"); - vm + setup.argv(Argv::Frozen(Some(&argv))).shims().build() } /// [`section_vm`] with the guarded `var` seeded from `var`. @@ -170,7 +166,7 @@ fn a_prelude_receives_its_packs_name_as_its_chunk_argument() { assert_eq!( (kit.as_str(), other.as_str()), ("kit", "other-kit"), - "each prelude reads its own Pack's local name from `...`" + "each prelude reads its own Pack's id from `...`" ); } diff --git a/crates/runvil-internal/lua/src/prelude.rs b/crates/runvil-internal/lua/src/prelude.rs index 5abdb323a..00ba549c8 100644 --- a/crates/runvil-internal/lua/src/prelude.rs +++ b/crates/runvil-internal/lua/src/prelude.rs @@ -2,7 +2,7 @@ //! contributes, installed into every section VM before `require`. //! //! A prelude runs once per VM as a main chunk whose `...` is its Pack's -//! local name, in an environment table of its own. That table's metatable `__index` is a lookup table of the base +//! id, in an environment table of its own. That table's metatable `__index` is a lookup table of the base //! functions that survive hardening, the `string`, `table`, `math`, and `utf8` //! libraries, `tools`, `store`, `untrusted`, `json`, and a view of `var` that is //! read-only at every depth, each read from `_G` when the prelude installs. Reading them then means @@ -199,9 +199,8 @@ fn check_collision( ))) } -/// Seals a prelude's table global at its top level: an empty proxy whose -/// `__index` reads the hidden table, whose `__newindex` raises, and whose -/// `__metatable` is set, so `pairs` over it sees nothing. +/// Seals a prelude's table global at its top level with a +/// [`read_only_proxy`] over the hidden table. fn seal(lua: &Lua, pack: &PackId, name: &str, hidden: Table) -> Result
{ let refusal = format!("{name} is read-only: Pack `{pack}` defines it; cannot set"); read_only_proxy( diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index 04329cf52..fa0cd419a 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -37,11 +37,8 @@ fn compile_chunk(source: &str, chunk_name: &str) -> std::result::Result, /// produced once by Lua 5.5. The bytecode is an in-memory implementation detail: /// it is not a stable or portable serialization format and must not be persisted. /// -/// Compilation does not execute the source. Loading a program (a crate-internal -/// step) creates a function in the supplied VM but likewise does not call it. -/// -/// `#[non_exhaustive]` so the crate can evolve the retained representation -/// (fields are already private) without a breaking change before release. +/// Compilation does not execute the source. Loading a program ([`load`](Self::load)) +/// creates a function in the supplied VM but likewise does not call it. /// /// # Sensitivity /// A program retains the author's original prompt Lua source verbatim, and @@ -55,7 +52,6 @@ fn compile_chunk(source: &str, chunk_name: &str) -> std::result::Result, /// name and a line number) and hold no source text. The crate itself never logs the /// retained source; compilation observations contain only fixed strings. #[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] pub struct LuaProgram { source: String, bytecode: Vec, @@ -129,27 +125,25 @@ impl LuaProgram { }) } - /// Compiles an internal chunk (the coroutine shim prelude) without - /// observations. + /// Compiles one of the crate's embedded Lua chunks without observations. /// /// Internal chunks are crate source, not author prompt source, so the - /// compilation is bookkeeping. `location` is the chunk name verbatim; - /// an `@`-prefixed name renders as a file path in errors, which the line - /// mapper never rewrites (it only touches an owner's own - /// `[string "{chunk name}"]:` marker). + /// compilation is bookkeeping. `chunk_name` serves as both the chunk + /// name and the display location; an `@`-prefixed name renders as a + /// file path in errors, which the line mapper never rewrites. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the temporary compiler VM cannot be /// created or the source fails to compile. - pub fn compile_internal(source: &str, location: &str) -> Result { - let bytecode = compile_chunk(source, location).map_err(|error| match error { + pub fn compile_internal(source: &str, chunk_name: &str) -> Result { + let bytecode = compile_chunk(source, chunk_name).map_err(|error| match error { CompilerError::Vm(error) | CompilerError::Chunk(error) => Error::lua(error), })?; Ok(Self { source: source.to_owned(), bytecode, - chunk_name: location.to_owned(), - location: location.to_owned(), + chunk_name: chunk_name.to_owned(), + location: chunk_name.to_owned(), source_line: NonZeroU32::MIN, }) } diff --git a/crates/runvil-internal/lua/src/projection.rs b/crates/runvil-internal/lua/src/projection.rs index 6ed69fb2b..2e936bcad 100644 --- a/crates/runvil-internal/lua/src/projection.rs +++ b/crates/runvil-internal/lua/src/projection.rs @@ -28,8 +28,7 @@ //! `{id, type, function: {name, arguments}}` object, the exact inverse of the //! Engine's own inbound parser, so records stay neutral while the wire //! dogfoods the gateway's ingress contract. The projection is recomputed per -//! dispatch for whichever model the call targets, so any later -//! provider-specific mapping changes this one module. +//! dispatch and is the same for every model. use std::collections::BTreeSet; @@ -165,8 +164,6 @@ fn project(messages: &[MessageRecord]) -> Vec { match system_len { 0 => {} 1 => projected.push(messages[0].clone()), - // Composition is textual; validation refused parts in a composed - // block, so the visible text is the whole content. _ => projected.push(MessageRecord { role: RecordRole::System, content: MessageContent::Text( @@ -257,9 +254,8 @@ fn content_parts(content: &MessageContent) -> Vec { } } -/// The visible text of one content: the text, or the concatenation of its -/// text parts. Only called on plain-text system records (validation refused -/// parts in a composed block), so this never drops an image. +/// The text of a plain-text content. Only called on composed system records, +/// where validation refused parts, so the parts arm is unreachable. fn visible_text(content: &MessageContent) -> &str { match content { MessageContent::Text(text) => text, diff --git a/crates/runvil-internal/lua/src/prose.rs b/crates/runvil-internal/lua/src/prose.rs index a75a9c394..cdbf0c115 100644 --- a/crates/runvil-internal/lua/src/prose.rs +++ b/crates/runvil-internal/lua/src/prose.rs @@ -27,8 +27,8 @@ use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; use crate::error::{Error, Result}; -use crate::sys::var_to_json; -use crate::vm::{NOT_INJECTED, SYS_LIVE_POISONED}; +use crate::var::var_to_json; +use crate::vm::SYS_LIVE_POISONED; /// The refusal an assignment of the `prose` global raises. pub(crate) const ASSIGNMENT_REFUSAL: &str = @@ -68,14 +68,13 @@ impl fmt::Debug for ProseState<'_> { /// install and first read is visible to the render. /// /// # Errors -/// Returns [`Error::Lua`] if the read cannot be built or the guard cannot +/// Returns [`Error::LuaRuntime`] if the read cannot be built or the guard cannot /// record it. -pub(crate) fn install(lua: &Lua, sys_live: &Arc>>, render: F) -> Result<()> +pub(crate) fn install(lua: &Lua, sys_live: &Arc>, render: F) -> Result<()> where F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { - let read = lazy_read(lua, sys_live, render)?; - crate::globals::install_prose(lua, read) + swap(lua, sys_live, render).map(drop) } /// Installs a fresh lazy `prose` over `render`, as [`install`] does, and @@ -85,9 +84,9 @@ where /// hands back text the caller already read without rendering again. /// /// # Errors -/// Returns [`Error::Lua`] if the read cannot be built or the guard cannot +/// Returns [`Error::LuaRuntime`] if the read cannot be built or the guard cannot /// record it. -pub(crate) fn swap(lua: &Lua, sys_live: &Arc>>, render: F) -> Result +pub(crate) fn swap(lua: &Lua, sys_live: &Arc>, render: F) -> Result where F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { @@ -98,7 +97,7 @@ where /// Puts back the read [`swap`] replaced. /// /// # Errors -/// Returns [`Error::Lua`] if the guard cannot record it. +/// Returns [`Error::LuaRuntime`] if the guard cannot record it. pub(crate) fn restore(lua: &Lua, saved: Value) -> Result<()> { crate::globals::replace_prose(lua, saved).map(drop) } @@ -107,8 +106,8 @@ pub(crate) fn restore(lua: &Lua, saved: Value) -> Result<()> { /// the Engine callback and memoizes, and every later call returns the memo. /// /// # Errors -/// Returns [`Error::Lua`] if the closure cannot be created. -fn lazy_read(lua: &Lua, sys_live: &Arc>>, render: F) -> Result +/// Returns [`Error::LuaRuntime`] if the closure cannot be created. +fn lazy_read(lua: &Lua, sys_live: &Arc>, render: F) -> Result where F: Fn(ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { @@ -124,14 +123,10 @@ where } } let var = var_to_json(lua).map_err(mlua::Error::external)?; - let sys = { - let guard = sys_live - .lock() - .map_err(|_| mlua::Error::external(Error::Internal(SYS_LIVE_POISONED.into())))?; - guard - .clone() - .ok_or_else(|| mlua::Error::external(Error::Internal(NOT_INJECTED.into())))? - }; + let sys = sys_live + .lock() + .map_err(|_| mlua::Error::external(Error::internal(SYS_LIVE_POISONED)))? + .clone(); let globals_lookup = |name: &str| -> Result> { if name == "prose" { return Err(Error::Lua( @@ -164,6 +159,7 @@ where } /// Returns the failure of a poisoned memo slot. +#[track_caller] fn memo_poisoned() -> mlua::Error { - mlua::Error::external(Error::Internal("prose memo slot was poisoned".into())) + mlua::Error::external(Error::internal("prose memo slot was poisoned")) } diff --git a/crates/runvil-internal/lua/src/protocol.rs b/crates/runvil-internal/lua/src/protocol.rs index 7eaacd760..451c02a5d 100644 --- a/crates/runvil-internal/lua/src/protocol.rs +++ b/crates/runvil-internal/lua/src/protocol.rs @@ -1,22 +1,21 @@ //! The coroutine protocol: validated request and answer types for the //! yield/resume boundary between section Lua and the scheduler driver. //! -//! A suspending Engine call (`models.infer(prompt)`, `h:infer(prompt)`, -//! `call`, `tasks.spawn`, `tasks.fanout`, `tools.call`, `store.*`, and the -//! `chat`, `tool_calls`, and `tool_call` rounds the `models.loop` shim -//! yields on the author's behalf) is a Lua-side shim that yields a request -//! table; the driver validates the yield into a [`Request`], dispatches -//! it, and resumes the coroutine with the `(ok, result)` envelope rendered -//! from an [`Answer`]. The two enums are the audit surface: what a script -//! can cause the Engine to do is one short read, and each variant's fields -//! are the compiler-checked per-message contract. +//! Every suspending Engine call, one per [`Request`] variant, is a Lua-side +//! shim that yields a request table; the driver validates the yield into a +//! [`Request`], dispatches it, and resumes the coroutine with the +//! `(ok, result)` envelope rendered from an [`Answer`]. The two enums are +//! the audit surface: what a script can cause the Engine to do is one +//! short read, and each variant's fields are the compiler-checked +//! per-message contract. //! //! The submodules split the protocol along that boundary: `request` the //! request vocabulary (the [`Request`] and [`VfsOp`] enums), `record` the -//! message-record types a chat request is built from, `answer` the answer -//! vocabulary (the [`Answer`] enum and its payload types), `parse` the -//! yield-to-request validation, and `render` the answer-to-envelope -//! rendering. `non_empty` holds the [`NonEmpty`] sequence two of them use. +//! message-record types a chat request is built from and their +//! validation, `answer` the answer vocabulary (the [`Answer`] enum and its +//! payload types), `parse` the yield-to-request validation, and `render` +//! the answer-to-envelope rendering. `non_empty` holds the [`NonEmpty`] +//! sequence two of them use. mod answer; mod non_empty; @@ -33,7 +32,7 @@ pub use answer::{ }; pub use non_empty::NonEmpty; pub use parse::YieldParse; -pub(crate) use parse::parse_record; +pub(crate) use record::parse_record; pub use record::{ContentPart, MessageContent, MessageRecord, RecordRole, ToolCallRecord}; -pub(crate) use render::Retained; -pub use request::{AdhocToolOutcome, Request, SpawnVia, VfsOp}; +pub(crate) use render::{Retained, render_envelope}; +pub use request::{AdhocToolOutcome, ModelCall, Request, SpawnVia, VfsOp}; diff --git a/crates/runvil-internal/lua/src/protocol/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 036c0793c..9c3e9db19 100644 --- a/crates/runvil-internal/lua/src/protocol/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/answer.rs @@ -1,19 +1,12 @@ -//! The answer vocabulary: one dispatched request's outcome and the payload -//! types its variants hold. -//! -//! An adhoc tool call is answered in two steps. The `tool_call` request's -//! answer is [`ToolCallOutcome::Adhoc`], which resumes the shim with the -//! handler itself so the shim runs it inside the calling coroutine. The -//! shim then yields `adhoc_tool_done` with the handler's result, and that -//! request's answer is an ordinary [`Answer::ToolCallResult`]: the text -//! on success, the error for a rejected return. +//! The answer vocabulary: one dispatched request's successful outcome and +//! the payload types its variants hold. use runvil_types::ids::TaskId; use runvil_types::metrics::ToolCallEvent; +use crate::bindings::ToolOutputKind; use crate::compactors::OverflowReason; use crate::error::{Error, Result}; -use crate::handles::ToolOutputKind; /// The result of one [`VfsOp`](super::VfsOp) run against the run's store /// view. @@ -41,15 +34,15 @@ pub enum VfsOutcome { Bool(bool), } -/// One dispatched `tools.call`'s successful output, classified by the -/// binding's declared [`ToolOutputKind`] so the envelope resumes the right -/// Lua shape: a plain binding's text resumes as a Lua string, a structured -/// binding's parsed JSON resumes as a Lua table through the serde boundary. -/// The Engine performs the one conversion. +/// One dispatched `tools.call`'s successful answer: a bound tool's output, +/// classified by the binding's declared [`ToolOutputKind`] so the envelope +/// resumes the right Lua shape (plain text as a Lua string, structured +/// JSON as a Lua table, the Engine performing the one conversion), or an +/// adhoc tool's handler for the shim to run. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ToolCallOutcome { - /// A plain binding's output text, resumed as a Lua string - every - /// existing tool, byte-identical to the tool loop's echo. + /// A plain binding's output text, resumed as a Lua string - + /// byte-identical to the tool loop's echo. Plain(String), /// A structured binding's parsed JSON output, resumed as a Lua table. Structured(serde_json::Value), @@ -140,13 +133,14 @@ impl mlua::UserData for ChatResult {} /// /// `outcome` is the task's final text, or its failure as the error value /// the shim hands back (`ok = false`): the task chain's own error, or the -/// `cancelled` value for a task that was cancelled or abandoned. The -/// delivery itself succeeded; a wait that fails outright (a task the -/// caller does not own, a result already delivered) is the outer -/// [`Answer::JoinAny`] error instead. A delivered failure is also the -/// envelope's retained typed error, so a shim that re-raises the member's -/// failure at once (the `fanout` shim's fatal-arm path) hands the driver -/// the member's own typed error rather than its rendering. +/// `cancelled` value for a task its owner cancelled (an abandoned task is +/// never delivered, since its owner has ended). The delivery itself +/// succeeded; a wait that fails outright (a task the caller does not own, +/// a result already delivered) fails the request instead, with no +/// [`Answer::JoinAny`]. A delivered failure is also the envelope's +/// retained typed error, so a shim that re-raises the member's failure at +/// once (the `fanout` shim's fatal-arm path) hands the driver the member's +/// own typed error rather than its rendering. #[derive(Debug)] pub struct TaskDelivery { /// The member that ended. @@ -161,7 +155,10 @@ pub enum TaskPhase { /// The task's backing chain or request is still running. Running, /// The task ended with a result, whether or not a wait delivered it. - Done, + Done { + /// Whether the task returned rather than failed. + ok: bool, + }, /// The owner cancelled the task. Cancelled, /// The task's owner ended while the task was live. @@ -175,7 +172,7 @@ impl TaskPhase { pub(crate) fn tag(self) -> &'static str { match self { TaskPhase::Running => "running", - TaskPhase::Done => "done", + TaskPhase::Done { .. } => "done", TaskPhase::Cancelled => "cancelled", TaskPhase::Abandoned => "abandoned", } @@ -225,9 +222,6 @@ pub struct TaskStatus { pub target: String, /// The lifecycle phase; a delivered task reports [`TaskPhase::Done`]. pub state: TaskPhase, - /// Whether the task ended well: `None` while it runs, `Some(false)` - /// for a failed, cancelled, or abandoned task. - pub ok: Option, /// The section the backing chain is currently in, while it is live and /// inside one. pub section: Option, @@ -243,93 +237,66 @@ pub struct TaskStatus { pub note: Option, } -/// One dispatched request's outcome, rendered to the `(ok, result)` envelope -/// at resume time. +/// One dispatched request's successful outcome, one variant per request. /// -/// On failure the envelope holds the error's structured table for the -/// shim to raise, and the driver +/// The driver answers a request with a `Result` over it, rendered to the +/// `(ok, result)` envelope at resume time, and a failure is the `Err` side +/// whatever the request. On failure the envelope holds the error's +/// structured table for the shim to raise, and the driver /// ([`SectionVm::resume_block_coro_answer`](crate::SectionVm::resume_block_coro_answer)) /// retains the typed error beside that very table. When the block fails /// by raising that table, the shim's own raise or the block's re-raise of /// the value it caught, the typed error is substituted back; any other /// failure, one carrying the same message included, stays the block's -/// own. This holds uniformly for leaf and structural answers: the enum -/// owns the typed error until the envelope is rendered, so a `Call` or -/// `JoinAny` failure round-trips with its structure intact. +/// own. This holds uniformly for leaf and structural requests: the +/// `Result` owns the typed error until the envelope is rendered, so a +/// `call` or `join_any` failure round-trips with its structure intact. /// -/// The error type is the driver's: the Lua side produces -/// `Answer<`[`Error`]`>` (argument-validation failures at the yield -/// boundary), while the executor's scheduler drives `Answer` over its own -/// error type so a dispatch failure (a gateway completion error, a binding +/// The error type is the driver's, and only a [`TaskDelivery`] holds one: +/// a `join_any` member's failure is part of a successful wait. The +/// executor's scheduler drives `Answer` over its own error type so a +/// member's dispatch failure (a gateway completion error, a binding /// failure) round-trips typed. #[derive(Debug)] pub enum Answer { /// The completion text for an `infer` request. - Infer(std::result::Result), + Infer(String), /// The contained chain's final text for a `call` request. - Call(std::result::Result), + Call(String), /// The started task's id for a `spawn` request, resumed as its path /// text; the shim wraps it in the methodless `Task` table. - Spawn(std::result::Result), + Spawn(TaskId), /// The started timer's task id for a `timer` request, resumed as its /// path text; the wait shim keeps it to wait on and cancel. - Timer(std::result::Result), + Timer(TaskId), /// The member delivered for a `join_any` request. - JoinAny(std::result::Result, E>), + JoinAny(TaskDelivery), /// Whether the task has ended, for a `ready` request. - Ready(std::result::Result), + Ready(bool), /// The task's status table for a `status` request. Boxed so the /// field-heavy [`TaskStatus`] does not size every answer. - Status(std::result::Result, E>), + Status(Box), /// The caller's live tasks in spawn order, for a `pending` request; /// the shim wraps each id in a `Task` handle. - Pending(std::result::Result, E>), + Pending(Vec), /// The chain's effective admission limit, for a `concurrency` /// request: the setter's clamped value, or the current one when the /// call gave no argument. - Concurrency(std::result::Result), - /// The unit outcome of a `note` request. - Note(std::result::Result<(), E>), - /// The unit outcome of a `cancel` request. - Cancel(std::result::Result<(), E>), + Concurrency(usize), + /// A `note` request recorded its text. + Note, + /// A `cancel` request ended its task, or found it already ended. + Cancel, /// The classified output for a `chat` request. Boxed so the field-heavy /// [`ChatResult`] does not size every answer the non-chat paths move. - Chat(std::result::Result, E>), + Chat(Box), /// The classified output for a `tools.call` request. - ToolCallResult(std::result::Result), - /// The texts of a `tool_calls` request's issued calls in call order, - /// or the first failure in call order. Fewer texts than calls means - /// the scheduler stopped at the call after the last one answered. - ToolCallsResult(std::result::Result, E>), + ToolCallResult(ToolCallOutcome), + /// The texts of a `tool_calls` request's issued calls in call order; + /// the first failure in call order fails the request instead. Fewer + /// texts than calls means the scheduler stopped at the call after the + /// last one answered. + ToolCallsResult(Vec), /// The outcome of a `store` request: the operation's return value. - Store(std::result::Result), -} - -impl Answer { - /// Maps the error type, leaving every success value untouched. - pub fn map_error(self, map: impl FnOnce(E) -> F) -> Answer { - match self { - Answer::Infer(result) => Answer::Infer(result.map_err(map)), - Answer::Call(result) => Answer::Call(result.map_err(map)), - Answer::Spawn(result) => Answer::Spawn(result.map_err(map)), - Answer::Timer(result) => Answer::Timer(result.map_err(map)), - Answer::JoinAny(result) => Answer::JoinAny(match result { - Ok(TaskDelivery { task, outcome }) => Ok(TaskDelivery { - task, - outcome: outcome.map_err(map), - }), - Err(error) => Err(map(error)), - }), - Answer::Ready(result) => Answer::Ready(result.map_err(map)), - Answer::Status(result) => Answer::Status(result.map_err(map)), - Answer::Pending(result) => Answer::Pending(result.map_err(map)), - Answer::Concurrency(result) => Answer::Concurrency(result.map_err(map)), - Answer::Note(result) => Answer::Note(result.map_err(map)), - Answer::Cancel(result) => Answer::Cancel(result.map_err(map)), - Answer::ToolCallResult(result) => Answer::ToolCallResult(result.map_err(map)), - Answer::ToolCallsResult(result) => Answer::ToolCallsResult(result.map_err(map)), - Answer::Chat(result) => Answer::Chat(result.map_err(map)), - Answer::Store(result) => Answer::Store(result.map_err(map)), - } - } + Store(VfsOutcome), } diff --git a/crates/runvil-internal/lua/src/protocol/non_empty.rs b/crates/runvil-internal/lua/src/protocol/non_empty.rs index e736d885c..b18b1836f 100644 --- a/crates/runvil-internal/lua/src/protocol/non_empty.rs +++ b/crates/runvil-internal/lua/src/protocol/non_empty.rs @@ -10,9 +10,12 @@ pub struct NonEmpty(Vec); impl NonEmpty { /// The sequence of `first` followed by `rest`. - #[cfg(test)] + /// + /// The way a consumer outside this crate builds one, so a non-empty + /// sequence needs no fallible step where the first element is already + /// in hand. #[must_use] - pub(crate) fn new(first: T, rest: Vec) -> Self { + pub fn new(first: T, rest: Vec) -> Self { let mut items = Vec::with_capacity(rest.len() + 1); items.push(first); items.extend(rest); diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index 9f8daf033..4de16963f 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -1,9 +1,10 @@ //! The yield-to-request validation: every field of a yielded table is //! checked before use, a malformed yield fails the block with the fixed //! direct-yield message, and an author-argument failure becomes the call's -//! answer so the shim raises it at the call site. The per-op parsers sit -//! in the siblings: `chat` (which owns the message-record validation), -//! `store`, `tasks`, and `tools`. +//! answer so the shim raises it at the call site. Field reads are raw: the +//! table comes from script space, so a metatable must not intercept or +//! forge a field. The per-op parsers sit in the siblings: `chat`, `store`, +//! `tasks`, and `tools`. mod chat; mod store; @@ -14,7 +15,6 @@ use mlua::{Lua, LuaSerdeExt, Value}; use runvil_model_client::model::ModelBinding; use chat::parse_chat; -pub(crate) use chat::parse_record; use store::parse_store; use tasks::{ parse_cancel, parse_concurrency, parse_join_any, parse_note, parse_ready, parse_spawn, @@ -22,11 +22,10 @@ use tasks::{ }; use tools::{parse_adhoc_tool_done, parse_tool_call, parse_tool_calls}; -use crate::error::{Error, Result}; -use crate::handles::resolve_section_target; +use crate::error::Error; use crate::models::LuaModelHandle; +use crate::section::resolve_section_target; -use super::answer::Answer; use super::request::Request; /// The fixed failure for a yield that is not a well-formed request table. @@ -42,27 +41,6 @@ fn direct_yield_error() -> Error { Error::Lua(DIRECT_YIELD.to_owned()) } -/// Fails the block with the fixed direct-yield message. -fn direct_yield() -> Result { - Err(direct_yield_error()) -} - -/// Reads one field off the request table. -/// -/// Reads are raw: the table comes from script space, so a metatable must not -/// intercept or forge a field. -fn raw_field(table: &mlua::Table, name: &str) -> Result { - table.raw_get::(name).or_else(|_| direct_yield()) -} - -/// Reads a required plain-table field as its JSON snapshot. -fn json_field(lua: &Lua, table: &mlua::Table, name: &str) -> Result { - match raw_field(table, name)? { - value @ Value::Table(_) => lua.from_value(value).or_else(|_| direct_yield()), - _ => direct_yield(), - } -} - /// How reading one request field failed. enum FieldFailure { /// A shim-internal field was absent or unreadable: the shims set those @@ -71,12 +49,12 @@ enum FieldFailure { /// An author-supplied argument had the wrong shape: the call's error, /// resumed as the answer so the shim raises it at the call site - an /// author `pcall` catches it. - Call(Error), + Argument(Error), } /// Frames one author-argument failure as the call's error. fn call_error(message: String) -> FieldFailure { - FieldFailure::Call(Error::Lua(message)) + FieldFailure::Argument(Error::Lua(message)) } /// Reads one author-supplied string value as UTF-8 text; invalid UTF-8 is @@ -160,16 +138,18 @@ fn shim_var( lua: &Lua, table: &mlua::Table, ) -> std::result::Result { - json_field(lua, table, "var").map_err(|_| FieldFailure::Malformed) + match table.raw_get::("var") { + Ok(value @ Value::Table(_)) => lua.from_value(value).map_err(|_| FieldFailure::Malformed), + Ok(_) | Err(_) => Err(FieldFailure::Malformed), + } } -/// Reads the author-supplied `target` of a `call` or `spawn` with the -/// `resolve_section_target` rule, so a non-string target keeps its -/// byte-identical error. +/// Reads the author-supplied `target` of a `call` or `spawn`; a non-string +/// target fails as `resolve_section_target` fails. fn call_target(table: &mlua::Table) -> std::result::Result { match table.raw_get::("target") { Ok(value) => { - resolve_section_target(value).map_err(|error| FieldFailure::Call(Error::lua(error))) + resolve_section_target(value).map_err(|error| FieldFailure::Argument(Error::lua(error))) } Err(_) => Err(FieldFailure::Malformed), } @@ -177,13 +157,17 @@ fn call_target(table: &mlua::Table) -> std::result::Result /// How one yielded value parsed at the resume boundary. #[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "a parse lives only until the driver matches it, and most yields are requests" +)] pub enum YieldParse { /// A well-formed request, ready to dispatch. Request(Request), /// A well-formed shim call whose author-supplied argument failed - /// validation: the call's answer, resumed into the caller so the shim - /// raises the error at the call site. - Call(Answer), + /// validation: the call's error, resumed into the caller so the shim + /// raises it at the call site. + Refused(Error), /// Not a well-formed request table: a hand-rolled or corrupted yield, /// failing the block with the fixed direct-yield message. Malformed(Error), @@ -197,16 +181,16 @@ impl Request { /// `op`, an unknown `op`, a shim-internal field of the wrong shape) is /// [`YieldParse::Malformed`] and fails the block with "scripts may not /// yield directly". A well-formed shim call whose author-supplied - /// argument fails validation is [`YieldParse::Call`]: the error is + /// argument fails validation is [`YieldParse::Refused`]: the error is /// returned as the call's answer so the shim raises it at the call site, - /// where an author `pcall` can catch it. One boundary conversion keeps - /// its own byte-identical error: a `call` or `spawn` target that is not - /// a string fails as `resolve_section_target` fails. + /// where an author `pcall` can catch it. One argument keeps another + /// boundary's error: a `call` or `spawn` target that is not a string + /// fails as `resolve_section_target` fails. pub fn from_yield(lua: &Lua, yielded: &Value) -> YieldParse { let Value::Table(table) = yielded else { return YieldParse::Malformed(direct_yield_error()); }; - let op = match raw_field(table, "op") { + let op = match table.raw_get::("op") { Ok(Value::String(op)) => match op.to_str() { Ok(op) => op.to_owned(), Err(_) => return YieldParse::Malformed(direct_yield_error()), @@ -214,55 +198,42 @@ impl Request { _ => return YieldParse::Malformed(direct_yield_error()), }; match op.as_str() { - "infer" => classify(parse_infer(table), |error| Answer::Infer(Err(error))), - "call" => classify(parse_call(lua, table), |error| Answer::Call(Err(error))), - "spawn" => classify(parse_spawn(lua, table), |error| Answer::Spawn(Err(error))), - "timer" => classify(parse_timer(table), |error| Answer::Timer(Err(error))), - "join_any" => classify(parse_join_any(table), |error| Answer::JoinAny(Err(error))), - "ready" => classify(parse_ready(table), |error| Answer::Ready(Err(error))), - "status" => classify(parse_status(table), |error| Answer::Status(Err(error))), + "infer" => classify(parse_infer(table)), + "call" => classify(parse_call(lua, table)), + "spawn" => classify(parse_spawn(lua, table)), + "timer" => classify(parse_timer(table)), + "join_any" => classify(parse_join_any(table)), + "ready" => classify(parse_ready(table)), + "status" => classify(parse_status(table)), "pending" => YieldParse::Request(Request::Pending), - "note" => classify(parse_note(table), |error| Answer::Note(Err(error))), - "concurrency" => classify(parse_concurrency(table), |error| { - Answer::Concurrency(Err(error)) - }), - "cancel" => classify(parse_cancel(table), |error| Answer::Cancel(Err(error))), - "tool_call" => classify(parse_tool_call(lua, table), |error| { - Answer::ToolCallResult(Err(error)) - }), - "tool_calls" => classify(parse_tool_calls(lua, table), |error| { - Answer::ToolCallsResult(Err(error)) - }), + "note" => classify(parse_note(table)), + "concurrency" => classify(parse_concurrency(table)), + "cancel" => classify(parse_cancel(table)), + "tool_call" => classify(parse_tool_call(lua, table)), + "tool_calls" => classify(parse_tool_calls(lua, table)), "adhoc_tool_done" => match parse_adhoc_tool_done(table) { Some(request) => YieldParse::Request(request), None => YieldParse::Malformed(direct_yield_error()), }, - "chat" => classify(parse_chat(table), |error| Answer::Chat(Err(error))), - "store" => classify(parse_store(table), |error| Answer::Store(Err(error))), + "chat" => classify(parse_chat(table)), + "store" => classify(parse_store(table)), _ => YieldParse::Malformed(direct_yield_error()), } } } /// Maps one per-op parse to the boundary outcome: a validated request, an -/// author-argument failure as the call's answer, or a malformed yield. -fn classify( - parsed: std::result::Result, - answer: impl FnOnce(Error) -> Answer, -) -> YieldParse { +/// author-argument failure as the call's error, or a malformed yield. +fn classify(parsed: std::result::Result) -> YieldParse { match parsed { Ok(request) => YieldParse::Request(request), - Err(FieldFailure::Call(error)) => YieldParse::Call(answer(error)), + Err(FieldFailure::Argument(error)) => YieldParse::Refused(error), Err(FieldFailure::Malformed) => YieldParse::Malformed(direct_yield_error()), } } -/// Parses an `infer` request: the author-supplied `prompt`, and the -/// optional handle's userdata whose frozen [`ModelBinding`] is cloned out -/// of its borrow while the VM handle is live. -/// -/// The handle is the receiver of `h:infer(prompt)`, so a wrong shape is -/// the call's error, not a malformed yield. +/// Parses an `infer` request: the author-supplied `prompt` and the +/// optional handle `call_handle` reads. fn parse_infer(table: &mlua::Table) -> std::result::Result { let prompt = call_string(table, "prompt")?; let binding = call_handle(table, "models.infer")?; @@ -293,9 +264,8 @@ fn call_handle( } } -/// Parses a `call` request: the author-supplied `target` (validated -/// with the `resolve_section_target` rule, keeping its byte-identical -/// error) and `input`, plus the shim-produced `var` snapshot. +/// Parses a `call` request: the author-supplied `target` and `input`, +/// plus the shim-produced `var` snapshot. fn parse_call(lua: &Lua, table: &mlua::Table) -> std::result::Result { let target = call_target(table)?; let input = call_optional_string(table, "input")?; diff --git a/crates/runvil-internal/lua/src/protocol/parse/chat.rs b/crates/runvil-internal/lua/src/protocol/parse/chat.rs index bdd92f870..601943b28 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/chat.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/chat.rs @@ -1,42 +1,22 @@ //! The chat request parser: the handle a handle's `loop` names and the -//! section's list, plus the per-record validation the list's builders run -//! as they add a record. +//! section's list. use mlua::Value; -use runvil_model_client::client::Role; use crate::messages::MessageList; -use crate::protocol::record::MAX_RECORD_META_BYTES; -use crate::protocol::{ - ContentPart, MessageContent, MessageRecord, NonEmpty, RecordRole, Request, ToolCallRecord, -}; +use crate::protocol::Request; use super::{FieldFailure, call_handle}; -/// The message roles the chat protocol accepts, comma-separated. -fn chat_roles() -> String { - Role::ALL - .iter() - .map(|role| role.as_str()) - .collect::>() - .join(", ") -} - -/// The content-part types the chat protocol accepts (the Multimodal -/// contract: text parts and data-URI image parts). -const CHAT_PART_TYPES: [&str; 2] = ["text", "image_url"]; - /// Parses a `chat` request: the optional `handle`, which a handle's /// `loop` sets from its receiver, and the `messages` list, which the loop /// sets to the section's list. /// /// The list validated each record as it added it, so the parse checks only /// that `messages` is a list and clones its handle; the driver projects the -/// records without re-checking them. An empty list parses: the driver -/// refuses it at dispatch. Only the Engine builds a chat request, so a -/// `messages` field that is not a list is a malformed yield. A handle -/// failure is the call's error, raised at the `models.loop` call site so a -/// program `pcall` catches it. The handle is checked first. +/// records without re-checking them. Only the Engine builds a chat +/// request, so a `messages` field that is not a list is a malformed yield. +/// The handle is checked first. pub(super) fn parse_chat(table: &mlua::Table) -> std::result::Result { let binding = call_handle(table, "models.loop")?; let list = match table.raw_get::("messages") { @@ -48,241 +28,3 @@ pub(super) fn parse_chat(table: &mlua::Table) -> std::result::Result std::result::Result { - parse_message(index, entry) -} - -/// Parses one message entry into its validated record. -fn parse_message( - index: usize, - entry: &serde_json::Value, -) -> std::result::Result { - let serde_json::Value::Object(entry) = entry else { - return Err(format!("messages[{index}] must be a message table")); - }; - let role = match entry.get("role") { - Some(serde_json::Value::String(role)) => match role.parse::() { - Ok(role) => role, - Err(_) => { - return Err(format!( - "messages[{index}] role {role:?} is unknown; known roles: {}", - chat_roles() - )); - } - }, - _ => { - return Err(format!( - "messages[{index}] role must be a string, one of: {}", - chat_roles() - )); - } - }; - let content = match entry.get("content") { - Some(serde_json::Value::String(text)) => MessageContent::Text(text.clone()), - Some(serde_json::Value::Array(parts)) => { - match NonEmpty::from_vec(parse_content_parts(index, parts)?) { - Some(parts) => MessageContent::Parts(parts), - None => return Err(content_refusal(index)), - } - } - _ => return Err(content_refusal(index)), - }; - let tool_call_id = match entry.get("tool_call_id") { - None => None, - Some(serde_json::Value::String(id)) => Some(id.clone()), - Some(_) => { - return Err(format!("messages[{index}] tool_call_id must be a string")); - } - }; - let role = match (role, tool_call_id) { - (Role::Tool, Some(tool_call_id)) => RecordRole::Tool { tool_call_id }, - (Role::Tool, None) => { - return Err(format!( - "messages[{index}] is a tool message and must set a string tool_call_id" - )); - } - (_, Some(_)) => { - return Err(format!( - "messages[{index}] sets a tool_call_id but is not a tool message" - )); - } - (Role::System, None) => RecordRole::System, - (Role::User, None) => RecordRole::User, - (Role::Assistant, None) => RecordRole::Assistant, - (other, None) => { - return Err(format!( - "messages[{index}] role {:?} is unknown; known roles: {}", - other.as_str(), - chat_roles() - )); - } - }; - let tool_calls = match entry.get("tool_calls") { - None => Vec::new(), - Some(serde_json::Value::Array(calls)) => calls - .iter() - .enumerate() - .map(|(position, call)| parse_tool_call_record(index, position + 1, call)) - .collect::, _>>()?, - Some(_) => { - return Err(format!("messages[{index}] tool_calls must be an array")); - } - }; - let meta = match entry.get("meta") { - None | Some(serde_json::Value::Null) => None, - Some(meta) => { - let size = meta.to_string().len(); - if size > MAX_RECORD_META_BYTES { - return Err(format!( - "messages[{index}] meta is {size} bytes as JSON; the limit is \ - {MAX_RECORD_META_BYTES} bytes" - )); - } - Some(meta.clone()) - } - }; - Ok(MessageRecord { - role, - content, - tool_calls, - meta, - }) -} - -/// The refusal for a `content` that is neither a string nor a non-empty -/// content-parts array. -fn content_refusal(index: usize) -> String { - format!("messages[{index}] content must be a string or a non-empty array of content parts") -} - -/// Parses one message's content-parts array: each part is a table whose -/// `type` names a known part kind, with that kind's required payload. -fn parse_content_parts( - index: usize, - parts: &[serde_json::Value], -) -> std::result::Result, String> { - parts - .iter() - .enumerate() - .map(|(position, part)| parse_content_part(index, position + 1, part)) - .collect() -} - -/// Parses one content part into its typed variant: a `text` part has a -/// string `text` field; an `image_url` part has an `image_url` table -/// with a string `url` field. -fn parse_content_part( - index: usize, - part_index: usize, - part: &serde_json::Value, -) -> std::result::Result { - let malformed = || { - format!( - "messages[{index}] content part {part_index} must be a table \ - with a string type field" - ) - }; - let serde_json::Value::Object(part) = part else { - return Err(malformed()); - }; - let kind = match part.get("type") { - Some(serde_json::Value::String(kind)) => kind.as_str(), - _ => return Err(malformed()), - }; - match kind { - "text" => match part.get("text") { - Some(serde_json::Value::String(text)) => Ok(ContentPart::Text(text.clone())), - _ => Err(format!( - "messages[{index}] content part {part_index} is a text part \ - and must set a string text field" - )), - }, - "image_url" => { - let Some(serde_json::Value::Object(image)) = part.get("image_url") else { - return Err(image_url_refusal(index, part_index)); - }; - match image.get("url") { - Some(serde_json::Value::String(url)) => Ok(ContentPart::ImageUrl(url.clone())), - _ => Err(image_url_refusal(index, part_index)), - } - } - unknown => Err(format!( - "messages[{index}] content part {part_index} has unknown type \ - {unknown:?}; known types: {}", - CHAT_PART_TYPES.join(", ") - )), - } -} - -/// The refusal for an `image_url` part without an `image_url` table -/// holding a string `url`. -fn image_url_refusal(index: usize, part_index: usize) -> String { - format!( - "messages[{index}] content part {part_index} is an image_url part and must set an \ - image_url table with a string url field" - ) -} - -/// Parses one tool call into its normalized record: a string `id`, a -/// string `name`, and an `arguments` object that normalizes to `{}` when -/// absent. -fn parse_tool_call_record( - index: usize, - call_index: usize, - call: &serde_json::Value, -) -> std::result::Result { - let serde_json::Value::Object(call) = call else { - return Err(format!( - "messages[{index}] tool_calls[{call_index}] must be a table" - )); - }; - let id = match call.get("id") { - Some(serde_json::Value::String(id)) => id.clone(), - _ => { - return Err(format!( - "messages[{index}] tool_calls[{call_index}] must set a string id" - )); - } - }; - let name = match call.get("name") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - return Err(format!( - "messages[{index}] tool_calls[{call_index}] must set a string name" - )); - } - }; - let arguments = match call.get("arguments") { - None | Some(serde_json::Value::Null) => serde_json::Value::Object(serde_json::Map::new()), - Some(arguments @ serde_json::Value::Object(_)) => arguments.clone(), - Some(_) => { - return Err(format!( - "messages[{index}] tool_calls[{call_index}] arguments must be a table" - )); - } - }; - Ok(ToolCallRecord { - id, - name, - arguments, - }) -} diff --git a/crates/runvil-internal/lua/src/protocol/parse/store.rs b/crates/runvil-internal/lua/src/protocol/parse/store.rs index 44e98bcb3..a231b0ba7 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/store.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/store.rs @@ -3,10 +3,9 @@ use mlua::Value; -use crate::Error; use crate::protocol::{Request, VfsOp}; -use super::{FieldFailure, call_string}; +use super::{FieldFailure, call_error, call_string}; /// Reads one author-supplied optional line bound: absent or nil is `None`, /// an integer (or a float with an integral value, as Lua's own integer @@ -30,10 +29,10 @@ fn call_optional_line( )] Ok(Some(line as i64)) } - Ok(other) => Err(FieldFailure::Call(Error::Lua(format!( + Ok(other) => Err(call_error(format!( "{name} must be an integer, got {}", other.type_name() - )))), + ))), Err(_) => Err(FieldFailure::Malformed), } } @@ -77,9 +76,7 @@ pub(super) fn parse_store(table: &mlua::Table) -> std::result::Result { - return Err(FieldFailure::Call(Error::Lua(format!( - "unknown store operation {other:?}" - )))); + return Err(call_error(format!("unknown store operation {other:?}"))); } }; Ok(Request::Store { op }) diff --git a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs index b7f4c5eb0..65c6164a7 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs @@ -32,8 +32,7 @@ fn call_task(table: &mlua::Table) -> std::result::Result { parse_task_id(&call_string(table, "task")?) } -/// Parses a `spawn` request: the author-supplied `target` (validated with -/// the `resolve_section_target` rule, as `call`'s is), the optional +/// Parses a `spawn` request: the author-supplied `target`, the optional /// author-supplied `input`, `item`, and `index` seeds, plus the /// shim-produced `var` snapshot and `fanout` mark. pub(super) fn parse_spawn( diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index c889d3db0..f901d431e 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -3,8 +3,8 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; -use crate::protocol::{AdhocToolOutcome, Request, ToolCallRecord}; -use crate::tools::{model_tool_name, tool_name}; +use crate::protocol::{AdhocToolOutcome, ModelCall, Request, ToolCallRecord}; +use crate::tools::{model_tool_name, tool_id}; use crate::vm::scalar_return; use super::{FieldFailure, call_error, shim_optional_string, shim_optional_u32}; @@ -18,7 +18,9 @@ struct ToolCallFields { turn: Option, } -/// Parses a `tools.call` request from the fields [`read_tool_call`] reads. +/// Parses a `tools.call` request from the fields [`read_tool_call`] reads: +/// a model-issued call when `call_id` and `turn` are both present, a +/// script call when both are absent, and a malformed yield otherwise. pub(super) fn parse_tool_call( lua: &Lua, table: &mlua::Table, @@ -29,12 +31,12 @@ pub(super) fn parse_tool_call( call_id, turn, } = read_tool_call(lua, table)?; - Ok(Request::ToolCall { - alias, - args, - call_id, - turn, - }) + let model = match (call_id, turn) { + (Some(call_id), Some(turn)) => Some(ModelCall { call_id, turn }), + (None, None) => None, + (Some(_), None) | (None, Some(_)) => return Err(FieldFailure::Malformed), + }; + Ok(Request::ToolCall { alias, args, model }) } /// Reads a `tool_call` table: the `alias` naming the tool and the @@ -60,7 +62,7 @@ fn read_tool_call( let decode = if call_id.is_some() { model_tool_name } else { - tool_name + tool_id }; let alias = match table.raw_get::("alias") { // Flatten to the call-error string so the answer frames exactly as diff --git a/crates/runvil-internal/lua/src/protocol/record.rs b/crates/runvil-internal/lua/src/protocol/record.rs index 6f383a5b5..810608618 100644 --- a/crates/runvil-internal/lua/src/protocol/record.rs +++ b/crates/runvil-internal/lua/src/protocol/record.rs @@ -1,6 +1,7 @@ //! The message-record vocabulary a chat request is built from: one //! validated record, its role, its content, the tool calls it holds, and -//! the metadata the projection never sends. +//! the metadata the projection never sends, plus the per-record +//! validation the list's builders run as they add a record. use runvil_model_client::client::Role; @@ -9,13 +10,12 @@ use super::non_empty::NonEmpty; /// The most bytes a record's `meta` may take as compact JSON. pub(crate) const MAX_RECORD_META_BYTES: usize = 8 * 1024; -/// One content part of a multimodal message: visible text or a data-URI -/// image reference. +/// One content part of a multimodal message: visible text or an image URL. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ContentPart { /// Visible text. Text(String), - /// A data-URI image reference. + /// An image URL; the record parse does not restrict its scheme. ImageUrl(String), } @@ -91,8 +91,9 @@ pub struct MessageRecord { pub role: RecordRole, /// The visible content. pub content: MessageContent, - /// The normalized tool calls the record holds; empty unless an - /// assistant turn requested tools. + /// The normalized tool calls the record holds. Only an assistant + /// record may send them: the record parse accepts them on any role, + /// and the projection refuses them on every other role. pub tool_calls: Vec, /// Metadata the projection never sends; `None` when absent or JSON null. pub meta: Option, @@ -100,7 +101,7 @@ pub struct MessageRecord { impl MessageRecord { /// The record's JSON form: `role`, `content`, and `tool_calls`, - /// `tool_call_id`, and `meta` when present, in the shapes the chat + /// `tool_call_id`, and `meta` when present, in the shapes the record /// parse accepts, so the parse turns it back into an equal record. #[must_use] pub(crate) fn to_json(&self) -> serde_json::Value { @@ -154,3 +155,246 @@ impl MessageContent { } } } + +/// The message roles the chat protocol accepts, comma-separated. +fn chat_roles() -> String { + Role::ALL + .iter() + .map(|role| role.as_str()) + .collect::>() + .join(", ") +} + +/// The content-part types the chat protocol accepts (the Multimodal +/// contract: text parts and image-URL parts). +const CHAT_PART_TYPES: [&str; 2] = ["text", "image_url"]; + +/// Validates one record in its JSON form, as a list builder adds it. +/// `index` is the 1-based position the record takes, named in the +/// error. +/// +/// The rules: a known role; `content` a string or a non-empty +/// content-parts array with known part types and payloads; a +/// `tool_call_id` a string, required on tool records and refused on every +/// other; a present `tool_calls` an array of normalized +/// `{id, name, arguments}` records; a `meta` of any JSON value, none when +/// null, at most [`MAX_RECORD_META_BYTES`] as compact JSON. Fields beyond +/// the five a record holds (`role`, `content`, `tool_call_id`, +/// `tool_calls`, `meta`) are dropped. +/// Cross-record checks - unique call IDs, complete call-result pairing, +/// provider-required alternation - belong to the per-dispatch projection +/// ([`crate::projection`]). +pub(crate) fn parse_record( + index: usize, + entry: &serde_json::Value, +) -> std::result::Result { + let serde_json::Value::Object(entry) = entry else { + return Err(format!("messages[{index}] must be a message table")); + }; + let role = match entry.get("role") { + Some(serde_json::Value::String(role)) => match role.parse::() { + Ok(role) => role, + Err(_) => { + return Err(format!( + "messages[{index}] role {role:?} is unknown; known roles: {}", + chat_roles() + )); + } + }, + _ => { + return Err(format!( + "messages[{index}] role must be a string, one of: {}", + chat_roles() + )); + } + }; + let content = match entry.get("content") { + Some(serde_json::Value::String(text)) => MessageContent::Text(text.clone()), + Some(serde_json::Value::Array(parts)) => { + match NonEmpty::from_vec(parse_content_parts(index, parts)?) { + Some(parts) => MessageContent::Parts(parts), + None => return Err(content_refusal(index)), + } + } + _ => return Err(content_refusal(index)), + }; + let tool_call_id = match entry.get("tool_call_id") { + None => None, + Some(serde_json::Value::String(id)) => Some(id.clone()), + Some(_) => { + return Err(format!("messages[{index}] tool_call_id must be a string")); + } + }; + let role = match (role, tool_call_id) { + (Role::Tool, Some(tool_call_id)) => RecordRole::Tool { tool_call_id }, + (Role::Tool, None) => { + return Err(format!( + "messages[{index}] is a tool message and must set a string tool_call_id" + )); + } + (_, Some(_)) => { + return Err(format!( + "messages[{index}] sets a tool_call_id but is not a tool message" + )); + } + (Role::System, None) => RecordRole::System, + (Role::User, None) => RecordRole::User, + (Role::Assistant, None) => RecordRole::Assistant, + (other, None) => { + return Err(format!( + "messages[{index}] role {:?} is unknown; known roles: {}", + other.as_str(), + chat_roles() + )); + } + }; + let tool_calls = match entry.get("tool_calls") { + None => Vec::new(), + Some(serde_json::Value::Array(calls)) => calls + .iter() + .enumerate() + .map(|(position, call)| parse_tool_call_record(index, position + 1, call)) + .collect::, _>>()?, + Some(_) => { + return Err(format!("messages[{index}] tool_calls must be an array")); + } + }; + let meta = match entry.get("meta") { + None | Some(serde_json::Value::Null) => None, + Some(meta) => { + let size = meta.to_string().len(); + if size > MAX_RECORD_META_BYTES { + return Err(format!( + "messages[{index}] meta is {size} bytes as JSON; the limit is \ + {MAX_RECORD_META_BYTES} bytes" + )); + } + Some(meta.clone()) + } + }; + Ok(MessageRecord { + role, + content, + tool_calls, + meta, + }) +} + +/// The refusal for a `content` that is neither a string nor a non-empty +/// content-parts array. +fn content_refusal(index: usize) -> String { + format!("messages[{index}] content must be a string or a non-empty array of content parts") +} + +/// Parses one message's content-parts array: each part is a table whose +/// `type` names a known part kind, with that kind's required payload. +fn parse_content_parts( + index: usize, + parts: &[serde_json::Value], +) -> std::result::Result, String> { + parts + .iter() + .enumerate() + .map(|(position, part)| parse_content_part(index, position + 1, part)) + .collect() +} + +/// Parses one content part into its typed variant: a `text` part has a +/// string `text` field; an `image_url` part has an `image_url` table +/// with a string `url` field. +fn parse_content_part( + index: usize, + part_index: usize, + part: &serde_json::Value, +) -> std::result::Result { + let malformed = || { + format!( + "messages[{index}] content part {part_index} must be a table \ + with a string type field" + ) + }; + let serde_json::Value::Object(part) = part else { + return Err(malformed()); + }; + let kind = match part.get("type") { + Some(serde_json::Value::String(kind)) => kind.as_str(), + _ => return Err(malformed()), + }; + match kind { + "text" => match part.get("text") { + Some(serde_json::Value::String(text)) => Ok(ContentPart::Text(text.clone())), + _ => Err(format!( + "messages[{index}] content part {part_index} is a text part \ + and must set a string text field" + )), + }, + "image_url" => { + let Some(serde_json::Value::Object(image)) = part.get("image_url") else { + return Err(image_url_refusal(index, part_index)); + }; + match image.get("url") { + Some(serde_json::Value::String(url)) => Ok(ContentPart::ImageUrl(url.clone())), + _ => Err(image_url_refusal(index, part_index)), + } + } + unknown => Err(format!( + "messages[{index}] content part {part_index} has unknown type \ + {unknown:?}; known types: {}", + CHAT_PART_TYPES.join(", ") + )), + } +} + +/// The refusal for an `image_url` part without an `image_url` table +/// holding a string `url`. +fn image_url_refusal(index: usize, part_index: usize) -> String { + format!( + "messages[{index}] content part {part_index} is an image_url part and must set an \ + image_url table with a string url field" + ) +} + +/// Parses one tool call into its normalized record: a string `id`, a +/// string `name`, and an `arguments` object that normalizes to `{}` when +/// absent. +fn parse_tool_call_record( + index: usize, + call_index: usize, + call: &serde_json::Value, +) -> std::result::Result { + let serde_json::Value::Object(call) = call else { + return Err(format!( + "messages[{index}] tool_calls[{call_index}] must be a table" + )); + }; + let id = match call.get("id") { + Some(serde_json::Value::String(id)) => id.clone(), + _ => { + return Err(format!( + "messages[{index}] tool_calls[{call_index}] must set a string id" + )); + } + }; + let name = match call.get("name") { + Some(serde_json::Value::String(name)) => name.clone(), + _ => { + return Err(format!( + "messages[{index}] tool_calls[{call_index}] must set a string name" + )); + } + }; + let arguments = match call.get("arguments") { + None | Some(serde_json::Value::Null) => serde_json::Value::Object(serde_json::Map::new()), + Some(arguments @ serde_json::Value::Object(_)) => arguments.clone(), + Some(_) => { + return Err(format!( + "messages[{index}] tool_calls[{call_index}] arguments must be a table" + )); + } + }; + Ok(ToolCallRecord { + id, + name, + arguments, + }) +} diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 9f8934f4a..2230e89ab 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -6,7 +6,7 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Table, Value}; use crate::error_value::{ErrorValue, error_table}; use crate::tools::adhoc_handler; -use super::answer::{Answer, TaskDelivery, TaskStatus, ToolCallOutcome, VfsOutcome}; +use super::answer::{Answer, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome, VfsOutcome}; /// An answer's typed error, retained while the shim raises its rendered /// error table, beside that table: the block's raise of this very table, @@ -20,14 +20,18 @@ pub(crate) struct Retained { } /// Renders one [`TaskStatus`] as the plain Lua status table. Absent -/// optional fields are never set, so they resume as nil; `tasks` is always -/// a sequence of id strings, empty when the task owns nothing live. +/// optional fields are never set, so they resume as nil; `ok` is absent +/// while the task runs, the outcome once it is done, and `false` for a +/// cancelled or abandoned task; `tasks` is always a sequence of id +/// strings, empty when the task owns nothing live. fn task_status_table(lua: &Lua, status: TaskStatus) -> mlua::Result { let table = lua.create_table()?; table.raw_set("target", status.target)?; table.raw_set("state", status.state.tag())?; - if let Some(ok) = status.ok { - table.raw_set("ok", ok)?; + match status.state { + TaskPhase::Running => {} + TaskPhase::Done { ok } => table.raw_set("ok", ok)?, + TaskPhase::Cancelled | TaskPhase::Abandoned => table.raw_set("ok", false)?, } if let Some(section) = status.section { table.raw_set("section", section)?; @@ -91,95 +95,86 @@ fn delivery_values( Ok((vec![id, Value::Boolean(ok), result], retained)) } -impl Answer { - /// Renders the `(ok, result)` resume values for the shim. - /// - /// On success the envelope is `(true, value...)`. On failure it is - /// `(false, table)`, where `table` is the error's structured value - /// (`kind`, `message` as the error's display string, and the kind's - /// fields, with `tostring` returning the message) - the shim raises it - /// with `error(result, 0)`, so a printing author sees exactly the Engine's - /// message and a branching one reads `kind` - and the typed error is - /// retained beside that table, which identifies its raise. A successful - /// `join_any` whose member failed retains the member's error the same - /// way, since a shim may re-raise it at once. - /// - /// # Errors - /// Returns an `mlua` error if a Lua string, userdata, or table cannot be - /// created on `lua`. - pub(crate) fn render(self, lua: &Lua) -> mlua::Result<(MultiValue, Option>)> { - let mut retained = None; - let values = match self { - Answer::Infer(Ok(text)) - | Answer::Call(Ok(text)) - | Answer::ToolCallResult(Ok(ToolCallOutcome::Plain(text))) => { - vec![Value::String(lua.create_string(&text)?)] - } - // The task id resumes as its path text; the shim builds the - // `{ task = id }` table around it, so no Engine handle crosses. - Answer::Spawn(Ok(task)) | Answer::Timer(Ok(task)) => { - vec![Value::String(lua.create_string(task.to_string())?)] - } - Answer::JoinAny(Ok(delivery)) => { - let (values, member_error) = delivery_values(lua, delivery)?; - retained = member_error; - values - } - Answer::Ready(Ok(ready)) => vec![Value::Boolean(ready)], - Answer::Status(Ok(status)) => vec![Value::Table(task_status_table(lua, *status)?)], - Answer::Pending(Ok(tasks)) => vec![Value::Table(task_id_sequence(lua, &tasks)?)], - // The effective limit resumes as a plain number, so the shim - // returns it as the call's value. - Answer::Concurrency(Ok(limit)) => { - vec![Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX))] - } - Answer::Note(Ok(())) | Answer::Cancel(Ok(())) => vec![Value::Nil], - // The one serde-boundary conversion: the parsed JSON output - // becomes the resumed Lua value, so the shim hands the script a - // table with no codec in author reach. - Answer::ToolCallResult(Ok(ToolCallOutcome::Structured(json))) => { - vec![lua.to_value(&json)?] - } - // The result slot stays nil: the handler and its arguments ride - // after it, so the shim tells an adhoc answer from a bound - // result by the handler's presence. - Answer::ToolCallResult(Ok(ToolCallOutcome::Adhoc { alias, args })) => vec![ - Value::Nil, - Value::Function(adhoc_handler(lua, &alias)?), - lua.to_value(&args)?, - ], - Answer::ToolCallsResult(Ok(texts)) => { - vec![Value::Table(lua.create_sequence_from(texts)?)] - } - // Opaque to Lua: only the loop's step reads it, by taking it - // back out whole. - Answer::Chat(Ok(result)) => vec![Value::UserData(lua.create_userdata(*result)?)], - Answer::Store(Ok(outcome)) => vec![store_value(lua, outcome)?], - Answer::Infer(Err(error)) - | Answer::Call(Err(error)) - | Answer::Spawn(Err(error)) - | Answer::Timer(Err(error)) - | Answer::JoinAny(Err(error)) - | Answer::Ready(Err(error)) - | Answer::Status(Err(error)) - | Answer::Pending(Err(error)) - | Answer::Concurrency(Err(error)) - | Answer::Note(Err(error)) - | Answer::Cancel(Err(error)) - | Answer::ToolCallResult(Err(error)) - | Answer::ToolCallsResult(Err(error)) - | Answer::Chat(Err(error)) - | Answer::Store(Err(error)) => { - let table = error_table(lua, &error)?; - return Ok(( - MultiValue::from_vec(vec![Value::Boolean(false), Value::Table(table.clone())]), - Some(Retained { error, table }), - )); - } - }; - let mut envelope = Vec::with_capacity(values.len() + 1); - envelope.push(Value::Boolean(true)); - envelope.extend(values); - Ok((MultiValue::from_vec(envelope), retained)) - } +/// Renders one answered request's `(ok, result)` resume values for the +/// shim. +/// +/// On success the envelope is `(true, value...)`. On failure it is +/// `(false, table)`, where `table` is the error's structured value +/// (`kind`, `message` as the error's display string, and the kind's +/// fields, with `tostring` returning the message) - the shim raises it +/// with `error(result, 0)`, so a printing author sees exactly the Engine's +/// message and a branching one reads `kind` - and the typed error is +/// retained beside that table, which identifies its raise. A successful +/// `join_any` whose member failed retains the member's error the same +/// way, since a shim may re-raise it at once. +/// +/// # Errors +/// Returns an `mlua` error if a Lua string, userdata, or table cannot be +/// created on `lua`. +pub(crate) fn render_envelope( + lua: &Lua, + answer: std::result::Result, E>, +) -> mlua::Result<(MultiValue, Option>)> { + let answer = match answer { + Ok(answer) => answer, + Err(error) => { + let table = error_table(lua, &error)?; + return Ok(( + MultiValue::from_vec(vec![Value::Boolean(false), Value::Table(table.clone())]), + Some(Retained { error, table }), + )); + } + }; + let mut retained = None; + let values = match answer { + Answer::Infer(text) + | Answer::Call(text) + | Answer::ToolCallResult(ToolCallOutcome::Plain(text)) => { + vec![Value::String(lua.create_string(&text)?)] + } + // The task id resumes as its path text; the shim builds the + // `{ task = id }` table around it, so no Engine handle crosses. + Answer::Spawn(task) | Answer::Timer(task) => { + vec![Value::String(lua.create_string(task.to_string())?)] + } + Answer::JoinAny(delivery) => { + let (values, member_error) = delivery_values(lua, delivery)?; + retained = member_error; + values + } + Answer::Ready(ready) => vec![Value::Boolean(ready)], + Answer::Status(status) => vec![Value::Table(task_status_table(lua, *status)?)], + Answer::Pending(tasks) => vec![Value::Table(task_id_sequence(lua, &tasks)?)], + // The effective limit resumes as a plain number, so the shim + // returns it as the call's value. + Answer::Concurrency(limit) => { + vec![Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX))] + } + Answer::Note | Answer::Cancel => vec![Value::Nil], + // The one serde-boundary conversion: the parsed JSON output + // becomes the resumed Lua value, so the shim hands the script a + // table with no codec in author reach. + Answer::ToolCallResult(ToolCallOutcome::Structured(json)) => { + vec![lua.to_value(&json)?] + } + // The result slot stays nil: the handler and its arguments ride + // after it, so the shim tells an adhoc answer from a bound + // result by the handler's presence. + Answer::ToolCallResult(ToolCallOutcome::Adhoc { alias, args }) => vec![ + Value::Nil, + Value::Function(adhoc_handler(lua, &alias)?), + lua.to_value(&args)?, + ], + Answer::ToolCallsResult(texts) => { + vec![Value::Table(lua.create_sequence_from(texts)?)] + } + // Opaque to Lua: only the loop's step reads it, by taking it + // back out whole. + Answer::Chat(result) => vec![Value::UserData(lua.create_userdata(*result)?)], + Answer::Store(outcome) => vec![store_value(lua, outcome)?], + }; + let mut envelope = Vec::with_capacity(values.len() + 1); + envelope.push(Value::Boolean(true)); + envelope.extend(values); + Ok((MultiValue::from_vec(envelope), retained)) } diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 8bce14e33..bd1ceacc9 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -20,9 +20,10 @@ use super::record::ToolCallRecord; use crate::Error; use crate::messages::MessageList; -/// Which author-facing call started a task chain: the depth-cap refusal -/// is named after it, so the typed error uses the wording the author -/// reads. +/// Which author-facing call started a task chain. The Engine reports a +/// depth-cap refusal under the cap's name, not the call's: a `tasks.spawn` +/// shares the `call` cap and reports `call`, and a fanout arm reports +/// `tasks.fanout`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpawnVia { /// `tasks.spawn`. @@ -51,8 +52,7 @@ pub enum Request { /// `call(target, input?)`: run a contained chain over the target's /// slice. Call { - /// The heading string, validated with the `resolve_section_target` - /// rule so a non-string target keeps its byte-identical error. + /// The heading string. target: String, /// The optional input override; `None` runs under the run's own args. input: Option, @@ -65,8 +65,7 @@ pub enum Request { /// chain shares `call`'s target resolution and depth cap, and refuses a /// list section as the target (the worker-template check). Spawn { - /// The heading string, validated with the `resolve_section_target` - /// rule so a non-string target keeps its byte-identical error. + /// The heading string. target: String, /// `opts.input`: the chain's `args` override; `None` runs under the /// caller's own args. @@ -146,19 +145,11 @@ pub enum Request { /// parses as the empty object. args: serde_json::Value, /// `Some`: a model-issued call, set by the loop shim from the - /// model's tool call. It always resumes with content (a tool's own - /// failure becomes untrusted failure text) and `ToolResult` fires - /// under this id. `None`: a script call, which keeps the - /// raise-at-call-site behavior. Shim-produced, never - /// author-supplied: a wrong shape is a malformed yield. - call_id: Option, - /// `Some`: the turn of the chat round that requested a - /// model-issued call, passed back by the loop shim from the - /// round's result. `None`: a script call, or the test-only - /// `tools.call_as_model` hook, which report the live counter. - /// Shim-produced, never author-supplied: a wrong shape is a - /// malformed yield. - turn: Option, + /// model's tool call and its round. It always resumes with content + /// (a tool's own failure becomes untrusted failure text). `None`: + /// a script call, which keeps the raise-at-call-site behavior and + /// reports the live turn counter. + model: Option, }, /// The unanswered calls of one model round, yielded by the loop's /// step: the scheduler issues every catalog call up to the first call @@ -182,33 +173,45 @@ pub enum Request { outcome: AdhocToolOutcome, }, /// One stateless tool-capable model round over the section's list, - /// yielded by the `models.loop` shim once per round. The dispatch arm - /// first refuses an empty list; the round sends the section's Static - /// Set, which the dispatch arm freezes with the Dynamic Set from the - /// section's offers at the first round to reach the merge and - /// attaches to the list. + /// yielded by the `models.loop` shim once per round. The round sends + /// the section's Static Set, which the dispatch arm freezes with the + /// Dynamic Set from the section's offers at the first round to reach + /// the merge and attaches to the list. Chat { /// The list the round sends, which may be empty, since the /// dispatch, not the parse, refuses an empty list. Its records /// were validated as the list added them. list: MessageList, - /// The receiver of a handle's `loop`, as its frozen binding cloned - /// out of the userdata while the VM handle is live; `None` when - /// the round names no handle, and the driver resolves the + /// The receiver of a handle's `loop`, as its frozen binding; `None` + /// when the round names no handle, and the driver resolves the /// section's current model. binding: Option, }, /// `store.*(...)`: one run-scoped store operation as a leaf yield. - /// Section VMs and the live H1 VM run the store shims. Every operation - /// takes this path uniformly - memory- and real-file-backed alike, with no - /// inline fast path - so interleaving behavior never depends on the - /// backend. + /// Once a VM's yield shims install, every operation takes this path, + /// memory- and real-file-backed alike, with no inline fast path, so + /// interleaving behavior never depends on the backend. Pack preludes + /// run before that and use the direct store closures. Store { /// The validated operation and its author-supplied arguments. op: VfsOp, }, } +/// The model's side of a model-issued [`Request::ToolCall`]: the id the +/// model attached to the call and the turn of the round that requested it. +/// Shim-produced in both fields, never author-supplied, so a `call_id` +/// without a `turn`, or a `turn` without a `call_id`, is a malformed yield. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelCall { + /// The id the model attached to its tool call; `ToolResult` fires + /// under it. + pub call_id: String, + /// The turn of the chat round that requested the call, which every + /// report of the call carries. + pub turn: u32, +} + /// How an adhoc tool's handler ended, as its `adhoc_tool_done` yield /// reports it. #[derive(Debug)] @@ -227,7 +230,8 @@ pub enum AdhocToolOutcome { /// One `store.*` call from a prompt script: which operation it is and the /// arguments the script passed. /// -/// The Engine checks the arguments once, when it parses the call. +/// The parse checks each argument's shape; the operation checks line +/// ranges when it runs. /// /// This type carries the full arguments of one of the eight `store.*` /// calls. The separate `vfs::Op` names only the kind of file operation diff --git a/crates/runvil-internal/lua/src/protocol/tests.rs b/crates/runvil-internal/lua/src/protocol/tests.rs index 480693e4b..099207fba 100644 --- a/crates/runvil-internal/lua/src/protocol/tests.rs +++ b/crates/runvil-internal/lua/src/protocol/tests.rs @@ -3,10 +3,9 @@ //! The submodules follow the protocol's own split: `parse` the generic //! yield-to-request validation, `parse_chat` the message-list request, //! `parse_tasks` the task-operation requests, `parse_tool_call` the -//! `tool_call` request, `answer` the answer-to-envelope round trips, -//! `answer_chat` the `chat` answer's typed error, and `answer_retained` -//! when a block's failure is the answer's retained error. The helpers -//! below are shared. +//! `tool_call` request, `answer` the answer-to-envelope round trips, and +//! `answer_retained` when a block's failure is the answer's retained +//! error. The helpers below are shared. use std::num::NonZeroU32; @@ -18,6 +17,7 @@ use runvil_types::ids::TaskId; use crate::Error; use crate::models::LuaModelHandle; +use crate::test_support::answer_envelope; use super::*; @@ -93,7 +93,6 @@ fn lua_table_with(lua: &Lua, chunk: &str, args: impl IntoLuaMulti) -> mlua::Tabl } mod answer; -mod answer_chat; mod answer_retained; mod parse; mod parse_chat; diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index a6a435c12..e7aab58af 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -1,15 +1,16 @@ //! Answer-to-envelope rendering: every [`Answer`] variant round-trips through -//! Lua as the `(ok, result)` envelope and retains its typed error. The -//! `chat` answer's error is in `answer_chat`. +//! Lua as the `(ok, result)` envelope, and a failure retains its typed +//! error. A successful `chat` answer resumes as an opaque userdata, which +//! the `models.loop` adapter's tests read. use super::*; #[test] fn an_ok_infer_answer_round_trips_through_lua() { let lua = Lua::new(); - let (envelope, retained) = Answer::::Infer(Ok("completion".to_owned())) - .into_envelope(&lua) - .expect("the envelope renders"); + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::::Infer("completion".to_owned()))) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -22,9 +23,9 @@ fn an_ok_infer_answer_round_trips_through_lua() { #[test] fn an_ok_call_answer_round_trips_through_lua() { let lua = Lua::new(); - let (envelope, retained) = Answer::::Call(Ok("chain text".to_owned())) - .into_envelope(&lua) - .expect("the envelope renders"); + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::::Call("chain text".to_owned()))) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -38,9 +39,8 @@ fn an_ok_call_answer_round_trips_through_lua() { fn an_ok_spawn_answer_resumes_the_task_id_as_its_path_text() { let lua = Lua::new(); let task: TaskId = "0.2".parse().expect("a task id parses"); - let (envelope, retained) = Answer::::Spawn(Ok(task)) - .into_envelope(&lua) - .expect("the envelope renders"); + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::::Spawn(task))).expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -51,16 +51,18 @@ fn an_ok_spawn_answer_resumes_the_task_id_as_its_path_text() { } #[test] -fn an_err_answer_round_trips_and_retains_the_typed_error() { +fn a_failed_answer_round_trips_and_retains_the_typed_error() { let lua = Lua::new(); - let (envelope, retained) = Answer::Call(Err(Error::LuaQuota { - resource: crate::QuotaResource::Instruction, - })) - .into_envelope(&lua) + let (envelope, retained) = answer_envelope::( + &lua, + Err(Error::LuaQuota { + resource: crate::QuotaResource::LogEvent, + }), + ) .expect("the envelope renders"); match retained { Some(Error::LuaQuota { - resource: crate::QuotaResource::Instruction, + resource: crate::QuotaResource::LogEvent, }) => {} other => panic!("expected the retained LuaQuota error, got {other:?}"), } @@ -68,7 +70,7 @@ fn an_err_answer_round_trips_and_retains_the_typed_error() { assert!(!ok); let (kind, message) = failure_parts(&lua, result); assert_eq!(kind, "lua"); - assert_eq!(message, "lua instruction quota exceeded"); + assert_eq!(message, "lua log event quota exceeded"); } #[test] @@ -79,17 +81,19 @@ fn a_join_any_delivery_of_a_failed_member_retains_the_members_typed_error() { // substitute the member's own error for the raised table. let lua = Lua::new(); let task: TaskId = "0.1".parse().expect("a task id parses"); - let (envelope, retained) = Answer::::JoinAny(Ok(TaskDelivery { - task, - outcome: Err(Error::LuaQuota { - resource: crate::QuotaResource::Instruction, - }), - })) - .into_envelope(&lua) + let (envelope, retained) = answer_envelope( + &lua, + Ok(Answer::::JoinAny(TaskDelivery { + task, + outcome: Err(Error::LuaQuota { + resource: crate::QuotaResource::LogEvent, + }), + })), + ) .expect("the envelope renders"); match retained { Some(Error::LuaQuota { - resource: crate::QuotaResource::Instruction, + resource: crate::QuotaResource::LogEvent, }) => {} other => panic!("expected the member's retained LuaQuota error, got {other:?}"), } @@ -104,18 +108,20 @@ fn a_join_any_delivery_of_a_failed_member_retains_the_members_typed_error() { assert_eq!(id, "0.1"); assert!(!member_ok, "the member failed"); assert_eq!(kind, "lua"); - assert_eq!(message, "lua instruction quota exceeded"); + assert_eq!(message, "lua log event quota exceeded"); } #[test] fn a_join_any_delivery_of_a_finished_member_retains_nothing() { let lua = Lua::new(); let task: TaskId = "0.1".parse().expect("a task id parses"); - let (envelope, retained) = Answer::::JoinAny(Ok(TaskDelivery { - task, - outcome: Ok("done".to_owned()), - })) - .into_envelope(&lua) + let (envelope, retained) = answer_envelope( + &lua, + Ok(Answer::::JoinAny(TaskDelivery { + task, + outcome: Ok("done".to_owned()), + })), + ) .expect("the envelope renders"); assert!(retained.is_none(), "a success retains nothing"); let (ok, member_ok, text): (bool, bool, String) = lua @@ -127,13 +133,52 @@ fn a_join_any_delivery_of_a_finished_member_retains_nothing() { assert_eq!(text, "done"); } +#[test] +fn a_status_answer_derives_ok_from_the_phase() { + // `ok` is absent while the task runs, the outcome once it is done, + // and false for a task that was cancelled or abandoned. + let lua = Lua::new(); + for (state, expected) in [ + (TaskPhase::Running, "nil"), + (TaskPhase::Done { ok: true }, "true"), + (TaskPhase::Done { ok: false }, "false"), + (TaskPhase::Cancelled, "false"), + (TaskPhase::Abandoned, "false"), + ] { + let status = TaskStatus { + target: "Child".to_owned(), + state, + section: None, + blocked: None, + turns: 0, + tasks: Vec::new(), + depth: 0, + note: None, + }; + let (envelope, retained) = + answer_envelope(&lua, Ok(Answer::::Status(Box::new(status)))) + .expect("the envelope renders"); + assert!(retained.is_none()); + let (ok, phase, status_ok): (bool, String, String) = lua + .load("local ok, status = ...; return ok, status.state, tostring(status.ok)") + .call(envelope) + .expect("the status table reads back through Lua"); + assert!(ok); + assert_eq!(phase, state.tag(), "{state:?}"); + assert_eq!(status_ok, expected, "{state:?}"); + } +} + #[test] fn an_ok_plain_tool_call_answer_round_trips_as_a_string() { let lua = Lua::new(); - let (envelope, retained) = - Answer::::ToolCallResult(Ok(ToolCallOutcome::Plain("echoed: hi".to_owned()))) - .into_envelope(&lua) - .expect("the envelope renders"); + let (envelope, retained) = answer_envelope( + &lua, + Ok(Answer::::ToolCallResult(ToolCallOutcome::Plain( + "echoed: hi".to_owned(), + ))), + ) + .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result) = echo_through_lua(&lua, envelope); assert!(ok); @@ -147,8 +192,7 @@ fn an_ok_plain_tool_call_answer_round_trips_as_a_string() { fn an_ok_structured_tool_call_answer_round_trips_as_a_table() { let lua = Lua::new(); let outcome = ToolCallOutcome::Structured(json!({ "text": "typed", "images": [] })); - let (envelope, retained) = Answer::::ToolCallResult(Ok(outcome)) - .into_envelope(&lua) + let (envelope, retained) = answer_envelope(&lua, Ok(Answer::::ToolCallResult(outcome))) .expect("the envelope renders"); assert!(retained.is_none()); let (ok, text, images_len): (bool, String, i64) = lua @@ -168,7 +212,7 @@ fn lua_with_adhoc_grab() -> Lua { &lua, &lua.globals(), &std::sync::Arc::new(crate::ToolSet::default()), - &std::sync::Arc::new(std::sync::Mutex::new(crate::ToolRuntime::default())), + &std::sync::Arc::new(std::sync::Mutex::new(crate::Offers::default())), &crate::vm::AdhocTools::default(), &crate::MessageList::default(), ) @@ -185,11 +229,13 @@ fn lua_with_adhoc_grab() -> Lua { #[test] fn an_adhoc_tool_call_answer_resumes_with_its_handler_and_args() { let lua = lua_with_adhoc_grab(); - let (envelope, retained) = Answer::::ToolCallResult(Ok(ToolCallOutcome::Adhoc { - alias: "grab".to_owned(), - args: json!({ "value": "hi" }), - })) - .into_envelope(&lua) + let (envelope, retained) = answer_envelope( + &lua, + Ok(Answer::::ToolCallResult(ToolCallOutcome::Adhoc { + alias: "grab".to_owned(), + args: json!({ "value": "hi" }), + })), + ) .expect("the envelope renders"); assert!(retained.is_none()); let (ok, result_is_nil, handler_is_fn, called): (bool, bool, bool, String) = lua @@ -211,11 +257,13 @@ fn an_adhoc_tool_call_answer_resumes_with_its_handler_and_args() { #[test] fn an_adhoc_tool_call_answer_for_an_unregistered_alias_is_an_error() { let lua = lua_with_adhoc_grab(); - let error = Answer::::ToolCallResult(Ok(ToolCallOutcome::Adhoc { - alias: "missing".to_owned(), - args: json!({}), - })) - .into_envelope(&lua) + let error = answer_envelope( + &lua, + Ok(Answer::::ToolCallResult(ToolCallOutcome::Adhoc { + alias: "missing".to_owned(), + args: json!({}), + })), + ) .expect_err("an alias with no registered handler cannot render"); assert!( error @@ -226,11 +274,10 @@ fn an_adhoc_tool_call_answer_for_an_unregistered_alias_is_an_error() { } #[test] -fn an_err_tool_call_answer_round_trips_and_retains_the_typed_error() { +fn a_cancelled_answer_round_trips_as_the_cancelled_kind_and_retains_the_typed_error() { let lua = Lua::new(); - let (envelope, retained) = Answer::ToolCallResult(Err(Error::Interrupted)) - .into_envelope(&lua) - .expect("the envelope renders"); + let (envelope, retained) = + answer_envelope::(&lua, Err(Error::Interrupted)).expect("the envelope renders"); match retained { Some(Error::Interrupted) => {} other => panic!("expected the retained Interrupted error, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer_chat.rs b/crates/runvil-internal/lua/src/protocol/tests/answer_chat.rs deleted file mode 100644 index 4708a8c9a..000000000 --- a/crates/runvil-internal/lua/src/protocol/tests/answer_chat.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Answer-to-envelope rendering for the `chat` answer's typed error. A -//! successful `chat` answer resumes as an opaque userdata, which the -//! `models.loop` adapter's tests read. - -use super::*; - -#[test] -fn an_err_chat_answer_round_trips_and_retains_the_typed_error() { - let lua = Lua::new(); - let (envelope, retained) = Answer::Chat(Err(Error::Interrupted)) - .into_envelope(&lua) - .expect("the envelope renders"); - match retained { - Some(Error::Interrupted) => {} - other => panic!("expected the retained Interrupted error, got {other:?}"), - } - let (ok, result) = echo_through_lua(&lua, envelope); - assert!(!ok); - let (kind, message) = failure_parts(&lua, result); - assert_eq!(kind, "cancelled"); - assert_eq!( - message, - "interrupted: the run was cancelled or this call was stopped" - ); -} diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs index ff612aa07..0fe4be56f 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer_retained.rs @@ -9,7 +9,7 @@ use crate::{CoroStep, LuaProgram}; /// The typed error every case answers the block's `models.infer` with. fn quota() -> Error { Error::LuaQuota { - resource: crate::QuotaResource::Instruction, + resource: crate::QuotaResource::LogEvent, } } @@ -30,8 +30,7 @@ fn answered(source: &str) -> std::result::Result { else { panic!("the infer shim must suspend the block"); }; - let ended = - vm.resume_block_coro_answer::(&program, &thread, Answer::Infer(Err(quota()))); + let ended = vm.resume_block_coro_answer::(&program, &thread, Err(quota())); drop(thread); vm.teardown(); ended @@ -45,7 +44,7 @@ fn a_block_raising_the_answered_error_table_surfaces_the_typed_error() { ] { match answered(source) { Err(Error::LuaQuota { - resource: crate::QuotaResource::Instruction, + resource: crate::QuotaResource::LogEvent, }) => {} other => panic!("block {source:?} must surface the typed quota error, got {other:?}"), } @@ -58,7 +57,7 @@ fn a_block_raising_the_answered_errors_text_as_its_own_keeps_its_own_error() { // block's own string, not the answer's table. match answered("local ok, err = pcall(models.infer, 'hi')\nerror(tostring(err), 0)") { Err(Error::LuaRuntime { message, .. }) => assert!( - message.contains("lua instruction quota exceeded"), + message.contains("lua log event quota exceeded"), "the block's own raise keeps its message: {message}" ), other => panic!("the block's own raise must stay its own error, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse.rs b/crates/runvil-internal/lua/src/protocol/tests/parse.rs index b33d12a2f..fd987c3bd 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse.rs @@ -218,7 +218,7 @@ fn an_infer_with_a_missing_or_non_string_prompt_is_the_calls_error() { ] { let table = lua_table(&lua, source); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Infer(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, expected, "{source}"); } other => panic!("expected the prompt call error for {source}, got {other:?}"), @@ -237,7 +237,7 @@ fn an_infer_with_a_wrong_handle_type_is_the_calls_error() { "{ op = 'infer', prompt = 'hi', handle = 'not a handle' }", ); match Request::from_yield(&lua, &Value::Table(as_string)) { - YieldParse::Call(Answer::Infer(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!( message, "models.infer handle must be a model handle, got string" @@ -254,7 +254,7 @@ fn an_infer_with_a_wrong_handle_type_is_the_calls_error() { wrong, ); match Request::from_yield(&lua, &Value::Table(as_other_userdata)) { - YieldParse::Call(Answer::Infer(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "models.infer handle must be a model handle"); } other => panic!("expected the handle call error, got {other:?}"), @@ -266,7 +266,7 @@ fn a_call_with_a_non_string_target_keeps_the_resolve_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'call', target = 42, var = { k = 1 } }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Call(Err(Error::LuaRuntime { message, .. }))) => { + YieldParse::Refused(Error::LuaRuntime { message, .. }) => { assert!( message.contains("section target must be a string, got integer"), "unexpected message: {message}" diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs index b6b5968dc..f8c48d010 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_chat.rs @@ -40,7 +40,7 @@ fn chat_request_with_handle(lua: &Lua, records: &str, handle: impl IntoLua) -> m fn expect_chat_call_error(parse: YieldParse, expected: &str) { match parse { - YieldParse::Call(Answer::Chat(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, expected); } other => panic!("expected the chat call error {expected:?}, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs index 1287a2d64..49e23a75b 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs @@ -109,7 +109,7 @@ fn spawn_seed_shape_errors_are_the_calls_error() { var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Spawn(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "item must be JSON data, got function"); } other => panic!("expected the item call error, got {other:?}"), @@ -120,7 +120,7 @@ fn spawn_seed_shape_errors_are_the_calls_error() { "{ op = 'spawn', target = '## Child', index = -1, var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Spawn(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "index must be a non-negative integer, got -1"); } other => panic!("expected the index call error, got {other:?}"), @@ -142,7 +142,7 @@ fn an_unconvertible_spawn_item_is_the_calls_error_naming_the_conversion_failure( .from_value::(item) .expect_err("a table holding a function has no JSON form"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Spawn(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!( message, format!("item must be a JSON-representable value: {cause}") @@ -158,7 +158,7 @@ fn a_malformed_task_id_is_the_calls_error_naming_the_parse_failure() { let cause = "0.x".parse::().expect_err("`0.x` is not a task id"); let table = lua_table(&lua, "{ op = 'ready', task = '0.x' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Ready(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!( message, format!("`0.x` is not a task id such as `0.1`: {cause}") @@ -181,7 +181,7 @@ fn a_spawn_with_a_non_string_target_keeps_the_resolve_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'spawn', target = 42, var = { k = 1 } }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Spawn(Err(Error::LuaRuntime { message, .. }))) => { + YieldParse::Refused(Error::LuaRuntime { message, .. }) => { assert!( message.contains("section target must be a string, got integer"), "unexpected message: {message}" @@ -224,7 +224,7 @@ fn timer_seconds_out_of_domain_are_the_calls_error() { for (seconds, needle) in cases { let table = lua_table(&lua, &format!("{{ op = 'timer', seconds = {seconds} }}")); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Timer(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert!( message.contains("timeout") && message.contains(needle), "the message names the option and the value: {message}" @@ -235,7 +235,7 @@ fn timer_seconds_out_of_domain_are_the_calls_error() { } let table = lua_table(&lua, "{ op = 'timer', seconds = 'soon' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Timer(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "timeout must be a number, got string"); } other => panic!("expected the timeout type error, got {other:?}"), @@ -288,7 +288,7 @@ fn concurrency_converts_a_whole_number_float_and_refuses_an_out_of_range_one() { } let table = lua_table(&lua, "{ op = 'concurrency', limit = 1e20 }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::Concurrency(Err(Error::Lua(message)))) => assert!( + YieldParse::Refused(Error::Lua(message)) => assert!( message.contains("positive whole number") && message.contains("64-bit"), "the message names the requirement and the range: {message}" ), @@ -300,10 +300,9 @@ fn concurrency_converts_a_whole_number_float_and_refuses_an_out_of_range_one() { fn a_timer_answer_resumes_the_task_id_as_its_path_text() { let lua = Lua::new(); let task: TaskId = "0.3".parse().expect("`0.3` is a valid task id"); - let answer: Answer = Answer::Timer(Ok(task)); - let (envelope, retained) = answer - .into_envelope(&lua) - .expect("a timer answer renders its envelope"); + let answer: Answer = Answer::Timer(task); + let (envelope, retained) = + answer_envelope(&lua, Ok(answer)).expect("a timer answer renders its envelope"); assert!(retained.is_none()); let (ok, value) = echo_through_lua(&lua, envelope); assert!(ok); diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs index 52e2fe303..93623ab28 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs @@ -14,16 +14,10 @@ fn tool_call_parses_alias_and_args() { ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { - Request::ToolCall { - alias, - args, - call_id, - turn, - } => { + Request::ToolCall { alias, args, model } => { assert_eq!(alias, "tools/echo"); assert_eq!(args, json!({ "value": "hi" })); - assert_eq!(call_id, None, "a script call omits the call id"); - assert_eq!(turn, None, "a script call omits the turn"); + assert_eq!(model, None, "a script call omits the call id and turn"); } other => panic!("expected a tool_call request, got {other:?}"), } @@ -37,13 +31,13 @@ fn tool_call_with_a_call_id_parses_it_as_a_model_issued_call() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'tool_call', alias = 'echo', call_id = 'call_7' }", + "{ op = 'tool_call', alias = 'echo', call_id = 'call_7', turn = 1 }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { - Request::ToolCall { alias, call_id, .. } => { + Request::ToolCall { alias, model, .. } => { assert_eq!(alias, "echo"); - assert_eq!(call_id.as_deref(), Some("call_7")); + assert_eq!(model.map(|model| model.call_id).as_deref(), Some("call_7")); } other => panic!("expected a tool_call request, got {other:?}"), } @@ -59,19 +53,24 @@ fn a_non_string_call_id_is_a_malformed_yield() { } #[test] -fn a_tool_call_without_a_turn_parses_it_as_none() { - // A script call and the test-only `tools.call_as_model` hook leave - // `turn` out; the scheduler falls back to the live counter. +fn a_model_issued_tool_call_without_a_turn_is_a_malformed_yield() { + // Every model-issued call names the turn of the round that requested + // it, so a `call_id` alone is a corrupted shim yield. let lua = Lua::new(); let table = lua_table( &lua, "{ op = 'tool_call', alias = 'echo', call_id = 'call_7' }", ); - let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); - match request { - Request::ToolCall { turn, .. } => assert_eq!(turn, None), - other => panic!("expected a tool_call request, got {other:?}"), - } + assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); +} + +#[test] +fn a_script_tool_call_with_a_turn_is_a_malformed_yield() { + // Only a model-issued call carries a turn; a script call reports the + // live counter, so a `turn` without a `call_id` has no producer. + let lua = Lua::new(); + let table = lua_table(&lua, "{ op = 'tool_call', alias = 'tools/echo', turn = 3 }"); + assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); } #[test] @@ -85,14 +84,41 @@ fn a_tool_call_with_a_turn_parses_the_requesting_round() { ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { - Request::ToolCall { call_id, turn, .. } => { - assert_eq!(call_id.as_deref(), Some("call_7")); - assert_eq!(turn, Some(3)); - } + Request::ToolCall { model, .. } => assert_eq!( + model, + Some(ModelCall { + call_id: "call_7".to_owned(), + turn: 3, + }) + ), other => panic!("expected a tool_call request, got {other:?}"), } } +#[test] +fn every_entry_of_a_tool_calls_batch_parses_under_the_batch_turn() { + // A batch entry carries no turn of its own: the loop's step names one + // turn for the whole round, and each entry's call id joins it. + let lua = Lua::new(); + let table = lua_table( + &lua, + "{ op = 'tool_calls', turn = 4, calls = {\n\ + { alias = 'echo', call_id = 'call_1', args = { value = 'a' } },\n\ + { alias = 'grab', call_id = 'call_2' },\n\ + } }", + ); + match expect_request(Request::from_yield(&lua, &Value::Table(table))) { + Request::ToolCalls { calls, turn } => { + let entries: Vec<_> = calls + .iter() + .map(|call| (call.id.as_str(), call.name.as_str(), turn)) + .collect(); + assert_eq!(entries, [("call_1", "echo", 4), ("call_2", "grab", 4)]); + } + other => panic!("expected a tool_calls request, got {other:?}"), + } +} + #[test] fn a_turn_outside_u32_or_not_an_integer_is_a_malformed_yield() { // `turn` is shim-produced, never author-supplied: a wrong shape is a @@ -151,7 +177,7 @@ fn a_tool_call_with_a_non_alias_alias_is_the_calls_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'tool_call', alias = 42 }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!( message, "tools.call takes a tool id or a tool object, got integer" @@ -168,7 +194,7 @@ fn a_script_call_naming_a_string_without_a_slash_is_the_calls_error() { let lua = Lua::new(); let table = lua_table(&lua, "{ op = 'tool_call', alias = 'grab' }"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "tools.call: \"grab\" is not a tool id"); } other => panic!("expected the id call error, got {other:?}"), @@ -183,7 +209,7 @@ fn a_tool_call_with_a_non_table_args_is_the_calls_error() { "{ op = 'tool_call', alias = 'tools/echo', args = 42 }", ); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!(message, "args must be a table, got integer"); } other => panic!("expected the args call error, got {other:?}"), @@ -204,7 +230,7 @@ fn a_tool_call_with_an_unrepresentable_args_table_is_the_calls_error() { .from_value::(args) .expect_err("a table holding a function has no JSON form"); match Request::from_yield(&lua, &Value::Table(table)) { - YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { + YieldParse::Refused(Error::Lua(message)) => { assert_eq!( message, format!("args must be a JSON-representable table: {cause}") diff --git a/crates/runvil-internal/lua/src/proxy.rs b/crates/runvil-internal/lua/src/proxy.rs index 72681a80d..aeccb44af 100644 --- a/crates/runvil-internal/lua/src/proxy.rs +++ b/crates/runvil-internal/lua/src/proxy.rs @@ -1,8 +1,9 @@ //! The read-only proxy every seal builds on - `sys`, a frozen `argv` table, //! a sealed prelude global, and a prelude's `var` view - and the key //! formatter their refusals and field paths share. +//! `tools.stats` and `tools.packs` build on the walkable [`read_only_walk`]. -use mlua::{Lua, MaybeSend, Table, Value}; +use mlua::{Function, Lua, MaybeSend, MultiValue, Table, Value}; /// Renders a table key for a refusal: a string as its text, an integer as /// its digits, and any other key by its type name. @@ -46,3 +47,29 @@ pub(crate) fn read_only_proxy( proxy.set_metatable(Some(metatable))?; Ok(proxy) } + +/// An empty proxy whose metatable holds `index`, a `__pairs` that walks +/// with `step`, a `__newindex` that raises ` is read-only`, and that +/// text as its `__metatable`, which protects it. +pub(crate) fn read_only_walk( + lua: &Lua, + index: Function, + step: Function, + path: &str, +) -> mlua::Result
{ + let label = format!("{path} is read-only"); + let pairs = + lua.create_function(move |_, proxy: Table| Ok((step.clone(), proxy, Value::Nil)))?; + let refusal = label.clone(); + let newindex = lua.create_function(move |_, _: MultiValue| -> mlua::Result<()> { + Err(mlua::Error::external(refusal.clone())) + })?; + let meta = lua.create_table()?; + meta.raw_set("__index", index)?; + meta.raw_set("__pairs", pairs)?; + meta.raw_set("__newindex", newindex)?; + meta.raw_set("__metatable", label)?; + let proxy = lua.create_table()?; + proxy.set_metatable(Some(meta))?; + Ok(proxy) +} diff --git a/crates/runvil-internal/lua/src/require.rs b/crates/runvil-internal/lua/src/require.rs index 1a01674a3..22af4183b 100644 --- a/crates/runvil-internal/lua/src/require.rs +++ b/crates/runvil-internal/lua/src/require.rs @@ -24,9 +24,9 @@ use serde_json::Value as Json; use crate::error::SharedSource; use crate::error::{Error, Result}; use crate::error_value::install_error_value; -use crate::handles::{Library, LibraryKey}; use crate::program::LuaProgram; use crate::prose::ProseState; +use crate::section::{Library, LibraryKey}; /// The loader chunk's name, `@`-prefixed as the shim chunks' names are. const REQUIRE_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_require.lua"; @@ -52,7 +52,7 @@ pub(crate) struct VmShare<'a> { /// The counter `jump` reads, stepped while a library loads. pub(crate) depth: &'a Arc, /// The live `sys` mirror a library block's `prose` renders from. - pub(crate) sys_live: &'a Arc>>, + pub(crate) sys_live: &'a Arc>, /// Every library block's program loaded into the VM, which a block /// failure maps through. pub(crate) programs: &'a Arc>>, @@ -193,7 +193,7 @@ fn begin_load_capture( fn prose_captures

( lua: &Lua, render: P, - sys_live: &Arc>>, + sys_live: &Arc>, ) -> Result<(Function, Function)> where P: Fn(&str, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, @@ -230,8 +230,8 @@ fn end_load_capture( let section = section.to_owned(); lua.create_function(move |_, finished: bool| { let Some((_, heading)) = lock(&loading, "library-load state")?.pop() else { - return Err(mlua::Error::external(Error::Internal( - "require ended a library load that never began".into(), + return Err(mlua::Error::external(Error::internal( + "require ended a library load that never began", ))); }; depth.fetch_sub(1, Ordering::Relaxed); @@ -250,9 +250,7 @@ fn end_load_capture( /// to an internal error. fn lock<'a, T>(slot: &'a Mutex, what: &str) -> mlua::Result> { slot.lock().map_err(|_| { - mlua::Error::external(Error::Internal( - format!("require's {what} was poisoned").into(), - )) + mlua::Error::external(Error::internal(format!("require's {what} was poisoned"))) }) } diff --git a/crates/runvil-internal/lua/src/scope-tests.rs b/crates/runvil-internal/lua/src/scope-tests.rs index 5c2fe3847..6046d563b 100644 --- a/crates/runvil-internal/lua/src/scope-tests.rs +++ b/crates/runvil-internal/lua/src/scope-tests.rs @@ -1,9 +1,20 @@ //! Tests for the scope types: the discovery tools' ids stay under a reserved -//! Pack id, so no vendor Pack can take the discovery namespace. +//! Pack id, so no vendor Pack can take the discovery namespace, and a count +//! increment seeds the id it was handed. use runvil_types::tools::ToolId; -use super::DiscoveryTool; +use super::{DiscoveryTool, ToolCallCounts}; + +#[test] +fn increment_seeds_a_missing_id_and_counts_it() { + let counts = ToolCallCounts::default(); + counts + .increment("std/read") + .expect("increment seeds an id it was never given"); + let snapshot = counts.snapshot().expect("the counts mutex is not poisoned"); + assert_eq!(snapshot.get("std/read"), Some(&1)); +} #[test] fn every_discovery_tool_id_sits_under_a_reserved_pack() { diff --git a/crates/runvil-internal/lua/src/scope.rs b/crates/runvil-internal/lua/src/scope.rs index 29daa1d67..4ef1295b8 100644 --- a/crates/runvil-internal/lua/src/scope.rs +++ b/crates/runvil-internal/lua/src/scope.rs @@ -1,20 +1,19 @@ -//! Per-VM tool runtime state: call counts, the offers a section made, the -//! frozen sets its first model call built from them, and the Engine's two +//! Per-VM tool state: call counts, the offers a section made, the frozen +//! sets its first loop round built from them, and the Engine's two //! discovery tools, which join the Static Set beside a Dynamic Set. -use std::borrow::Cow; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use runvil_types::packs::PackId; use runvil_types::tools::{ToolId, ToolInfo}; +use crate::bindings::ToolBinding; use crate::error::{Error, Result}; -use crate::handles::ToolBinding; /// Shared per-VM tool-call counts, keyed by tool id and seeded at 0 for -/// every id the installer was given. A dispatch seeds a missing key on -/// demand through [`ensure`](Self::ensure). +/// every id the installer was given. `increment` seeds a missing id at 0, +/// and `ensure` pre-seeds one so `tools.stats` lists it before any call. /// /// The executor increments a count when dispatch is attempted (even if the tool /// later errors). Lua reads the counts through the `tools.stats` table, which @@ -26,10 +25,10 @@ pub struct ToolCallCounts { } impl ToolCallCounts { - /// Creates a counts map pre-seeded with 0 for every name. + /// Creates a counts map pre-seeded with 0 for every id. #[must_use] - pub(crate) fn new(names: impl IntoIterator) -> Self { - let map: BTreeMap = names.into_iter().map(|name| (name, 0)).collect(); + pub(crate) fn new(ids: impl IntoIterator) -> Self { + let map: BTreeMap = ids.into_iter().map(|id| (id, 0)).collect(); Self { inner: Arc::new(Mutex::new(map)), } @@ -38,36 +37,30 @@ impl ToolCallCounts { fn lock(&self) -> Result>> { self.inner .lock() - .map_err(|_| Error::Internal("tool call counts mutex was poisoned".into())) + .map_err(|_| Error::internal("tool call counts mutex was poisoned")) } - /// Ensures `name` is present in the map, seeding it at 0 when new. + /// Ensures `id` is present in the map, seeding it at 0 when new. /// /// # Errors /// Returns [`Error::Internal`] if the mutex is poisoned. - pub fn ensure(&self, name: &str) -> Result<()> { + pub fn ensure(&self, id: &str) -> Result<()> { let mut map = self.lock()?; - map.entry(name.to_owned()).or_insert(0); + map.entry(id.to_owned()).or_insert(0); Ok(()) } - /// Increments the count for `name`. + /// Increments the count for `id`, seeding it at 0 first when new. /// /// # Errors - /// Returns [`Error::Internal`] if the mutex is poisoned or `name` was - /// never seeded. - pub fn increment(&self, name: &str) -> Result<()> { + /// Returns [`Error::Internal`] if the mutex is poisoned. + pub fn increment(&self, id: &str) -> Result<()> { let mut map = self.lock()?; - let count = map.get_mut(name).ok_or_else(|| { - Error::Internal(Cow::Owned(format!( - "tool call counts: name {name:?} was not pre-seeded" - ))) - })?; - *count += 1; + *map.entry(id.to_owned()).or_insert(0) += 1; Ok(()) } - /// Returns every seeded name with its count, read under one lock. + /// Returns every seeded id with its count, read under one lock. /// /// # Errors /// Returns [`Error::Internal`] if the mutex is poisoned. @@ -138,12 +131,12 @@ impl OfferedTool { /// The offers one section VM made, in call order. #[derive(Debug, Default)] -pub struct ToolRuntime { +pub struct Offers { /// Every offer, in call order, each held once. - pub offers: Vec, + pub(crate) offers: Vec, } -impl ToolRuntime { +impl Offers { /// Remembers an offer of the tool `id` under `mode`, unless an equal /// offer is already held. pub fn add_tool(&mut self, id: ToolId, mode: OfferMode) { @@ -164,10 +157,10 @@ impl ToolRuntime { } } -/// The two sets a section entry froze at its first model call: the one -/// record of what its model was shown. Each holds its tools' bindings -/// under their wire names, in merge order, and no wire name appears twice -/// across them. +/// The two sets a section entry froze at its first loop round +/// (`models.loop` or a handle's `loop`): the one record of what its model +/// was shown. Each holds its tools' bindings under their wire names, in +/// merge order, and no wire name appears twice across them. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RenderedTools { /// The Static Set: the tools every round of the section advertises, diff --git a/crates/runvil-internal/lua/src/section.rs b/crates/runvil-internal/lua/src/section.rs new file mode 100644 index 000000000..df3da86b8 --- /dev/null +++ b/crates/runvil-internal/lua/src/section.rs @@ -0,0 +1,81 @@ +//! What the VM exchanges with its caller about sections: a block's result, a +//! `call` or `jump` target, the text a prompt read sees, and the library +//! sections `require` loads. + +use mlua::Value; + +/// Outcome of a Lua block that may invoke `jump`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LuaBlockResult { + /// Normal completion with an optional scalar return. + Returned(Option), + /// `jump` transferred control to this heading (`## Name`). + Jump(String), +} + +/// Resolves a `call` / `jump` target from a heading string. +/// +/// # Errors +/// Returns a Lua error when the value is not a string. +pub(crate) fn resolve_section_target(value: Value) -> mlua::Result { + match value { + Value::String(s) => Ok(s.to_str()?.to_owned()), + other => Err(mlua::Error::external(format!( + "section target must be a string, got {}", + other.type_name() + ))), + } +} + +/// What a prompt read sees of one visible section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionText { + /// The section's own raw Markdown. + pub markdown: String, + /// Its pre-parsed list items; empty for a section that isn't a list. + pub items: Vec, +} + +/// Which library a `require` resolved to, opaque to this crate. +/// +/// The caller encodes a library section's position in the identity +/// string; `require`'s cache keys on that identity, never on the heading +/// text, so one heading resolved from two positions names two libraries. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LibraryKey(String); + +impl LibraryKey { + /// Builds a key whose stable identity is `identity`. + #[must_use] + pub fn new(identity: String) -> Self { + Self(identity) + } + + /// Returns the identity the key was built from. + #[must_use] + pub fn identity(&self) -> &str { + &self.0 + } +} + +/// What `require` loads from one library section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Library { + /// Which library this is: the cache key, and the position a `require` + /// made while it loads resolves from. + pub key: LibraryKey, + /// The library section's heading with its markers, such as + /// `## helpers`, which its load reports and a cycle error name. + pub heading: String, + /// The section's blocks in order. + pub blocks: Vec, +} + +/// One block of a library section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryBlock { + /// The block's compiled Lua. + pub program: crate::LuaProgram, + /// The block's prose template, the Markdown before it. + pub prose: String, +} diff --git a/crates/runvil-internal/lua/src/sys.rs b/crates/runvil-internal/lua/src/sys.rs index f0b6a9635..224f2fdf2 100644 --- a/crates/runvil-internal/lua/src/sys.rs +++ b/crates/runvil-internal/lua/src/sys.rs @@ -1,158 +1,20 @@ -//! The sealed `sys` table and the guarded `var` proxy that sandboxed author code reads and writes. - -use std::borrow::Cow; +//! The sealed `sys` table that sandboxed author code reads. use mlua::{Lua, LuaSerdeExt, Value}; use serde_json::Value as Json; use crate::error::{Error, Result}; -use crate::proxy::{key_path, key_text, read_only_proxy}; -use crate::walk_budget::WalkBudget; - -/// The registry key holding the `var` proxy's hidden data table. -/// -/// The registry is unreachable from sandboxed author code (no `debug` -/// library), so only Engine code holding a `&Lua` can read or replace the data -/// table behind the guarded global. -const VAR_DATA_REGISTRY: &str = "runvil.var_data"; - -/// The registry key holding the `var` proxy table itself, so read-back can -/// detect an author reassigning the global (`var = 5`), which would -/// otherwise silently strand the hidden data table. -const VAR_PROXY_REGISTRY: &str = "runvil.var_proxy"; - -/// Registry map from every guarded proxy table to its hidden data table. -const VAR_GUARDED_DATA_REGISTRY: &str = "runvil.var_guarded_data"; - -fn guarded_json_value( - lua: &Lua, - value: &Json, - path: &str, - guarded_data: &mlua::Table, -) -> mlua::Result { - match value { - Json::Array(values) => { - let data = lua.create_table_with_capacity(values.len(), 0)?; - data.set_metatable(Some(lua.array_metatable()))?; - for (index, value) in values.iter().enumerate() { - data.raw_set( - index + 1, - guarded_json_value( - lua, - value, - &format!("{path}[{}]", index + 1), - guarded_data, - )?, - )?; - } - guarded_table(lua, data, path, guarded_data).map(Value::Table) - } - Json::Object(values) => { - let data = lua.create_table_with_capacity(0, values.len())?; - for (key, value) in values { - data.raw_set( - key.as_str(), - guarded_json_value(lua, value, &format!("{path}.{key}"), guarded_data)?, - )?; - } - guarded_table(lua, data, path, guarded_data).map(Value::Table) - } - _ => lua.to_value(value), - } -} - -fn guarded_table( - lua: &Lua, - data: mlua::Table, - path: &str, - guarded_data: &mlua::Table, -) -> mlua::Result { - let proxy = lua.create_table()?; - guarded_data.raw_set(proxy.clone(), data.clone())?; - let metatable = lua.create_table()?; - metatable.set("__index", data.clone())?; - - let write_data = data; - let nested_data = guarded_data.clone(); - let table_path = path.to_owned(); - let newindex = lua.create_function( - move |lua, (_proxy, key, value): (Value, Value, Value)| -> mlua::Result<()> { - // The serde bridge turns nil into mlua's null sentinel, which - // would keep the key instead of removing it. - if value.is_nil() { - return write_data.raw_set(key, value); - } - let target = key_path(&table_path, &key); - if let Value::Function(_) | Value::UserData(_) | Value::Thread(_) = value { - return Err(mlua::Error::runtime(format!( - "{target} must be JSON data, got {}", - value.type_name() - ))); - } - // `from_value` reads a shared table once per reference and builds - // the result on the Rust heap before any Lua allocation, so a - // value that outgrows the heap is refused before it converts. - if WalkBudget::for_vm(lua).check_value(&value).is_err() { - return Err(mlua::Error::runtime(format!( - "{target} is too large to store" - ))); - } - let json = lua.from_value::(value)?; - let rebuilt = guarded_json_value(lua, &json, &target, &nested_data)?; - write_data.raw_set(key, rebuilt) - }, - )?; - metatable.set("__newindex", newindex)?; - metatable.set("__metatable", "var is guarded")?; - proxy.set_metatable(Some(metatable))?; - Ok(proxy) -} - -/// Copies a guarded value into plain tables for JSON conversion. A list's -/// data table carries mlua's array metatable, and its copy takes it too, -/// so an empty list converts to `[]` rather than `{}`. -fn materialize_guarded_value( - lua: &Lua, - value: Value, - guarded_data: &mlua::Table, -) -> mlua::Result { - let Value::Table(table) = value else { - return Ok(value); - }; - let source = match guarded_data.raw_get::(table.clone())? { - Value::Table(data) => data, - _ => table, - }; - let plain = lua.create_table()?; - let marker = lua.array_metatable(); - if source - .metatable() - .is_some_and(|metatable| metatable == marker) - { - plain.set_metatable(Some(marker))?; - } - // `pairs` order is unspecified; each iteration only assigns one entry - // into the fresh table, so the copy's content is fixed. - for pair in source.pairs::() { - let (key, value) = pair?; - plain.raw_set(key, materialize_guarded_value(lua, value, guarded_data)?)?; - } - Ok(Value::Table(plain)) -} +use crate::proxy::{key_text, read_only_proxy}; /// Builds a sealed Lua `sys` table from runtime metadata. /// -/// The proxy is empty; reads go through `__index` against the JSON object and -/// raise when the field is absent. Present `null` values surface as Lua nil. -/// `__newindex` rejects every write. `__metatable` is set so author code cannot -/// replace the seal. +/// A [`read_only_proxy`] over the JSON object: a read of an absent field +/// raises, and a present `null` reads as nil. pub(crate) fn seal_sys(lua: &Lua, sys: &Json) -> Result { let data = match sys { Json::Object(map) => map.clone(), other => { - return Err(Error::Internal(Cow::Owned(format!( - "sys must be a table, got {other}" - )))); + return Err(Error::internal(format!("sys must be a table, got {other}"))); } }; @@ -180,105 +42,3 @@ pub(crate) fn seal_sys(lua: &Lua, sys: &Json) -> Result { ) .map_err(Error::lua) } - -/// Builds the guarded `var` global: an empty proxy table over a hidden data -/// table. -/// -/// The proxy is empty; reads go through `__index` against the data table, so -/// present values surface directly and absent keys read as Lua nil. -/// `__newindex` validates the assigned value for JSON-representability -/// through the serde bridge - a function, userdata, or thread is rejected at -/// the assigning line, and nested tables are deep-checked by the bridge - -/// then rebuilds it as fresh guarded data before writing through. Assigning nil -/// removes the key. Every nested table is itself an empty proxy over hidden -/// data, so later incremental writes cross the same validation boundary instead -/// of mutating a stored table directly. `__metatable` is set so author code -/// cannot replace the guard. The root data table is stashed in the Lua registry -/// (unreachable from sandboxed author code) and is the read-back source for -/// [`var_to_json`], which materializes nested proxies before serde conversion; -/// proxies never hold entries themselves. The root proxy is stashed alongside -/// it so [`var_to_json`] can reject an author reassigning the `var` global -/// instead of silently reading stale data. -pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result { - let initial = match initial { - Some(Json::Object(values)) => Json::Object(values.clone()), - Some(other) => { - return Err(Error::Internal(Cow::Owned(format!( - "var must seed from a JSON object, got {}", - lua.to_value(other).map_err(Error::lua)?.type_name() - )))); - } - None => Json::Object(serde_json::Map::new()), - }; - let guarded_data = lua.create_table().map_err(Error::lua)?; - let Value::Table(proxy) = - guarded_json_value(lua, &initial, "var", &guarded_data).map_err(Error::lua)? - else { - return Err(Error::Internal("guarded var root was not a table".into())); - }; - let Value::Table(data) = guarded_data - .raw_get::(proxy.clone()) - .map_err(Error::lua)? - else { - return Err(Error::Internal("guarded var data table was missing".into())); - }; - // The named registry entries keep the data table alive and reachable for - // Engine read-back, and the proxy reachable for the reassignment check in - // `var_to_json`. - lua.set_named_registry_value(VAR_DATA_REGISTRY, data) - .map_err(Error::lua)?; - lua.set_named_registry_value(VAR_PROXY_REGISTRY, proxy.clone()) - .map_err(Error::lua)?; - lua.set_named_registry_value(VAR_GUARDED_DATA_REGISTRY, guarded_data) - .map_err(Error::lua)?; - Ok(proxy) -} - -/// Returns the hidden `var` data table as a plain deep-copied table. -/// -/// The guarded `var` proxy itself serializes empty; this reads what -/// [`var_to_json`] reads, materializing nested proxies, for the coroutine -/// shim's `var_snapshot` capture: a `call` or `tasks.fanout` request -/// includes the caller's `var` as a clone. -/// -/// # Errors -/// Returns [`Error::Lua`] under the same conditions as [`var_to_json`]. -pub(crate) fn var_snapshot_table(lua: &Lua) -> Result { - let proxy: mlua::Table = lua - .named_registry_value(VAR_PROXY_REGISTRY) - .map_err(Error::lua)?; - let current: Value = lua.globals().get("var").map_err(Error::lua)?; - match current { - Value::Table(ref table) if table.equals(&proxy).map_err(Error::lua)? => {} - _ => { - return Err(Error::Lua( - "the `var` global was reassigned; write `var.` instead".to_owned(), - )); - } - } - let data: mlua::Table = lua - .named_registry_value(VAR_DATA_REGISTRY) - .map_err(Error::lua)?; - let guarded_data: mlua::Table = lua - .named_registry_value(VAR_GUARDED_DATA_REGISTRY) - .map_err(Error::lua)?; - match materialize_guarded_value(lua, Value::Table(data), &guarded_data).map_err(Error::lua)? { - Value::Table(plain) => Ok(plain), - // The root data value is always a table; `guarded_var` builds it. - _ => Err(Error::Internal( - "guarded var data table was not a table".into(), - )), - } -} - -/// Reads the hidden `var` data table back as JSON. -/// -/// # Errors -/// Returns [`Error::Lua`] if the data table is absent (Engine values were -/// never injected), if the author reassigned the `var` global (the proxy is -/// no longer reachable, so the hidden table no longer reflects it), or if -/// the data cannot be represented as JSON. -pub(crate) fn var_to_json(lua: &Lua) -> Result { - lua.from_value(Value::Table(var_snapshot_table(lua)?)) - .map_err(Error::lua) -} diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index 6f74dc79c..e47037c4d 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -3,23 +3,28 @@ //! crate's tests and under the `test-support` feature, which only //! dev-dependencies and bench `required-features` enable. +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use mlua::{Function, Lua, MultiValue, Table}; use runvil_model_client::model::ModelSet; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; +use runvil_types::packs::PackId; use runvil_types::tools::ToolInfo; +use runvil_vfs::Access; +use crate::argv::Argv; +use crate::bindings::{RosterEntry, ToolBinding, ToolSet}; use crate::coro::MODEL_TOOL_CALL_REGISTRY; use crate::error::{Error, Result}; use crate::error_value::ErrorValue; -use crate::handles::{LuaBlockResult, ToolBinding, ToolSet}; -use crate::models::ModelRuntime; +use crate::models::ModelSelection; use crate::program::LuaProgram; -use crate::protocol::Answer; -use crate::scope::ToolRuntime; -use crate::vm::{SectionVm, scalar_return}; +use crate::protocol::{Answer, render_envelope}; +use crate::scope::Offers; +use crate::section::LuaBlockResult; +use crate::vm::{InjectFailure, SectionVm, Uninjected, scalar_return}; /// A per-thread tally of live section VMs. /// @@ -110,21 +115,33 @@ impl ToolBinding { } } -impl Answer { - /// Renders the `(ok, result)` resume envelope the driver resumes a - /// block with, for a test that resumes a coroutine by hand, handing - /// back the typed error the answer retains without the error table - /// that identifies its raise. - /// - /// # Errors - /// Returns an `mlua` error if a Lua string, userdata, or table cannot be - /// created on `lua`. - pub fn into_envelope(self, lua: &Lua) -> mlua::Result<(MultiValue, Option)> { - let (envelope, retained) = self.render(lua)?; - Ok((envelope, retained.map(|retained| retained.error))) +impl ToolSet { + /// Returns every installed Pack the caller reports with its one + /// [`RosterEntry`]: the map production reads only through + /// [`vendor_for`](Self::vendor_for) and the Pack walk, exposed so a + /// companion crate's test can assert the one-map shape. + #[must_use] + pub fn roster_for_test(&self) -> &BTreeMap { + self.roster() } } +/// Renders the `(ok, result)` resume envelope the driver resumes a block +/// with, for a test that resumes a coroutine by hand, handing back the +/// typed error the answer retains without the error table that +/// identifies its raise. +/// +/// # Errors +/// Returns an `mlua` error if a Lua string, userdata, or table cannot be +/// created on `lua`. +pub fn answer_envelope( + lua: &Lua, + answer: std::result::Result, E>, +) -> mlua::Result<(MultiValue, Option)> { + let (envelope, retained) = render_envelope(lua, answer)?; + Ok((envelope, retained.map(|retained| retained.error))) +} + impl LuaProgram { /// Maps a Lua runtime failure of a chunk that ran with no library /// programs loaded beside it: `map_runtime_error_among` with none @@ -135,12 +152,29 @@ impl LuaProgram { } } +impl SectionVm { + /// Installs the section's Engine values, ahead of the Pack preludes, + /// with an empty `var` and a frozen nil `argv`. + /// + /// # Errors + /// Returns the [`InjectFailure`] holding the bridging failure if Engine + /// values cannot be bridged. + pub fn inject_values( + self, + args: &str, + sys: &serde_json::Value, + access: &Arc, + ) -> std::result::Result { + self.inject_values_with_var(args, sys, access, None, Argv::Frozen(None)) + } +} + impl SectionVm { /// Executes a compiled Lua chunk in this VM's persistent environment. /// - /// The direct, scheduler-free path for running a section's Lua blocks; - /// the scheduler drives blocks through - /// [`start_block_coro`](Self::start_block_coro) instead. Store and + /// The test path that runs a section's Lua block without the scheduler; + /// production drives blocks through + /// [`start_block_coro`](Self::start_block_coro). Store and /// `log` reports go to the emitter captured by /// [`install_engine_globals`](Self::install_engine_globals); a nil or /// absent top-level return produces [`LuaBlockResult::Returned`]`(None)`. @@ -181,27 +215,24 @@ impl SectionVm { Ok(LuaBlockResult::Returned(scalar_return(returned)?)) } - /// Returns the shared tool set and the live section addition runtime. + /// Returns the shared tool set and the live section offers. #[must_use] - pub fn tool_bag_handles(&self) -> (Arc, Arc>) { - ( - Arc::clone(&self.bound_tools), - Arc::clone(self.tool_runtime()), - ) + pub fn tool_bag_handles(&self) -> (Arc, Arc>) { + (Arc::clone(&self.tool_set), Arc::clone(self.offers())) } /// Returns a snapshot of the shared model set and the live section - /// selection runtime. Production reads the run's shared set through the + /// model selection. Production reads the run's shared set through the /// model view instead. /// /// # Errors /// Returns [`Error::Internal`] if the shared model set's mutex is poisoned. - pub fn model_bag_handles(&self) -> Result<(ModelSet, Arc>)> { + pub fn model_bag_handles(&self) -> Result<(ModelSet, Arc>)> { let models = self .bound_models .lock() - .map_err(|_| Error::Internal("model set mutex was poisoned".into()))? + .map_err(|_| Error::internal("model set mutex was poisoned"))? .clone(); - Ok((models, Arc::clone(self.model_runtime()))) + Ok((models, Arc::clone(self.model_selection()))) } } diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index 4b8b104f2..1a674e9bb 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -21,7 +21,7 @@ use serde_json::{Value as Json, json}; use crate::error::{Error, Result}; use crate::tests::recording::null_emitter; use crate::vm::scalar_return; -use crate::{Argv, OfferedTool, SectionText, SectionVm, ToolSet}; +use crate::{Argv, OfferedTool, RosterEntry, SectionText, SectionVm, ToolSet}; /// The one guard-nonce seed of every test VM. pub(crate) const TEST_SEED: RunSeed = RunSeed::from_bytes([0x73; 32]); @@ -110,15 +110,14 @@ pub(crate) fn echo_tool() -> ToolInfo { tool_at("tools/echo") } -/// The tool set whose run catalog is `tools`, with nothing declared, no -/// roster, and no vendors. +/// The tool set whose run catalog is `tools`, with nothing declared and no +/// roster. #[track_caller] pub(crate) fn catalog_set(tools: impl IntoIterator) -> ToolSet { ToolSet::new( Vec::new(), BTreeMap::new(), ToolCatalog::new(tools).expect("a fixture catalog's ids differ"), - BTreeMap::new(), ) } @@ -132,14 +131,27 @@ pub(crate) fn offer_set() -> ToolSet { let pack = |name: &str| { runvil_types::packs::PackId::parse(name).expect("a fixture Pack id is a valid Pack id") }; + let usable = |name: &str| { + ( + pack(name), + RosterEntry::Usable { + vendor: "acme".to_owned(), + }, + ) + }; ToolSet::new( vec![pack("web"), pack("user-input")], [ - (pack("gh"), None), - (pack("mcp"), Some("the mcp server is down".to_owned())), - (pack("subagents"), None), - (pack("user-input"), None), - (pack("web"), None), + usable("gh"), + ( + pack("mcp"), + RosterEntry::Unavailable { + reason: "the mcp server is down".to_owned(), + }, + ), + usable("subagents"), + usable("user-input"), + usable("web"), ] .into_iter() .collect(), @@ -154,7 +166,6 @@ pub(crate) fn offer_set() -> ToolSet { .map(tool_at), ) .expect("the fixture ids differ"), - BTreeMap::new(), ) } @@ -162,7 +173,7 @@ pub(crate) fn offer_set() -> ToolSet { /// `static` or `dynamic`, in call order. #[track_caller] pub(crate) fn offers(vm: &SectionVm) -> Vec { - vm.tool_runtime() + vm.offers() .lock() .expect("the runtime mutex is not poisoned") .offers @@ -205,7 +216,7 @@ pub(crate) fn lua_over(set: ToolSet) -> BuiltVm { #[track_caller] pub(crate) fn scoped(vm: &SectionVm) -> Vec { let (set, runtime) = vm.tool_bag_handles(); - crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()) + crate::current_static_set(&set, &runtime, vm.adhoc_tools()) .expect("the scope resolves") .iter() .map(|tool| match tool { @@ -220,7 +231,7 @@ pub(crate) fn scoped(vm: &SectionVm) -> Vec { pub(crate) fn dynamic(vm: &SectionVm) -> Vec { let (set, runtime) = vm.tool_bag_handles(); let adhoc = vm.adhoc_tools(); - let bound = crate::current_tool_bindings(&set, &runtime, adhoc).expect("the scope resolves"); + let bound = crate::current_static_set(&set, &runtime, adhoc).expect("the scope resolves"); crate::current_dynamic_set(&set, &runtime, adhoc, &bound) .expect("the dynamic set resolves") .iter() @@ -343,8 +354,8 @@ impl<'a> TestVm<'a> { self } - /// The scheduler-mode control globals, whose prompt reads find no - /// section, and every coroutine yield shim. + /// The control globals, whose prompt reads find no section, and every + /// coroutine yield shim. #[track_caller] pub(crate) fn shims(mut self) -> Self { self.shims = true; diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 7ebd0468e..4b9d90d20 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -21,12 +21,12 @@ use self::recording::{Observation, Recorder, boundary, null_emitter}; use super::*; use crate::engine_globals::LUA_LOG_CHARACTER_LIMIT; use crate::error::Result; -use crate::handles::resolve_section_target; -use crate::hardening::install_instruction_budget; -use crate::sys::{guarded_var, var_snapshot_table, var_to_json}; +use crate::hardening::install_cancel_hook; +use crate::section::resolve_section_target; use crate::test_vm::{ BuiltVm, LuaOutcome, TEST_SECTION, TestVm, fresh_access, run_chunk, test_nonce, }; +use crate::var::{guarded_var, var_snapshot_table, var_to_json}; use crate::vm::{DEFAULT_LUA_LOG_EVENTS, DEFAULT_LUA_MEMORY_BYTES}; #[path = "tests-recording.rs"] diff --git a/crates/runvil-internal/lua/src/tests/budgets.rs b/crates/runvil-internal/lua/src/tests/budgets.rs index fe12825e8..3e79fdd35 100644 --- a/crates/runvil-internal/lua/src/tests/budgets.rs +++ b/crates/runvil-internal/lua/src/tests/budgets.rs @@ -49,7 +49,6 @@ fn an_author_raise_of_a_quota_refusals_text_is_an_ordinary_runtime_error() { for refusal in [ "lua log event budget exceeded", "lua log cumulative byte budget exceeded", - "lua instruction budget exceeded", ] { let error = run_scalar( &vm, diff --git a/crates/runvil-internal/lua/src/tests/cancellation.rs b/crates/runvil-internal/lua/src/tests/cancellation.rs index dfdcb8b5c..4eef2910a 100644 --- a/crates/runvil-internal/lua/src/tests/cancellation.rs +++ b/crates/runvil-internal/lua/src/tests/cancellation.rs @@ -1,4 +1,4 @@ -//! Cooperative cancellation: the instruction hook aborts a cancelled block, +//! Cooperative cancellation: the cancellation hook aborts a cancelled block, //! and the coroutine shim's `pcall` and `xpcall` neither swallow //! cancellation nor change an uncancelled failure. @@ -10,9 +10,9 @@ fn long_running_lua_block_cancels_cooperatively() { // An unbounded loop that, without cooperative cancellation, would run // forever: no instruction ceiling ends it. With the cancel flag set, the - // very first instruction-hook firing aborts it before counting itself; - // the hook's error is the raw cancellation message, which the VM - // classifies as `Error::Interrupted` once the flag is observed set. + // very first instruction-hook firing aborts it; the hook's error is the + // raw cancellation message, which the VM classifies as + // `Error::Interrupted` once the flag is observed set. let program = LuaProgram::compile( "local n = 0\nwhile true do n = n + 1 end", "cancel loop", @@ -26,22 +26,16 @@ fn long_running_lua_block_cancels_cooperatively() { handle.cancel(); let lua = Lua::new(); - let budget = install_instruction_budget(&lua).expect("hook installs on a fresh VM"); - budget.set_cancel(handle); + let hook = install_cancel_hook(&lua).expect("hook installs on a fresh VM"); + hook.set_cancel(handle); let func = program .load(&lua) .expect("compiled bytecode loads into a fresh VM"); - let fired = budget.fired(); let outcome = func.call::<()>(()); - assert_eq!( - budget.fired(), - fired, - "a cancelled Lua block must abort at the first hook firing, required no counted firing" - ); assert!( - budget.is_cancelled(), - "the budget reports the installed flag as set" + hook.is_cancelled(), + "the hook reports the installed flag as set" ); let raw = outcome .expect_err("a cancelled loop cannot finish") @@ -59,20 +53,14 @@ fn a_pre_cancelled_run_aborts_a_tight_loop_promptly() { // No instruction ceiling aborts a runaway block anymore; the cancel flag, // polled by the instruction hook, is the kill switch. With the flag set // before the chunk starts, the first hook firing inside a tight - // `while true do end` aborts it before counting itself. + // `while true do end` aborts it. let handle = CancelHandle::new(); handle.cancel(); let vm = TestVm::new().build(); vm.set_cancel(handle); - let fired = vm.hook_firings(); let outcome = run_scalar(&vm, &program("while true do end"), &null_emitter(), "Loop"); - assert_eq!( - vm.hook_firings(), - fired, - "a cancelled tight loop must abort at the first hook firing, required no counted firing" - ); vm.teardown(); assert!( matches!(outcome, Err(Error::Interrupted)), diff --git a/crates/runvil-internal/lua/src/tests/engine_misuse.rs b/crates/runvil-internal/lua/src/tests/engine_misuse.rs index 02b179d9d..3fcf802ad 100644 --- a/crates/runvil-internal/lua/src/tests/engine_misuse.rs +++ b/crates/runvil-internal/lua/src/tests/engine_misuse.rs @@ -7,7 +7,7 @@ use super::*; #[test] fn a_poisoned_store_conflict_slot_still_hands_over_its_conflict() { let vm = TestVm::new().build(); - let slot = vm.store_conflicts_handle(); + let slot = vm.store_conflict_handle(); let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut held = slot.lock().expect("the first lock is not poisoned"); *held = Some("a.txt claimed by two identities".to_owned()); diff --git a/crates/runvil-internal/lua/src/tests/logging.rs b/crates/runvil-internal/lua/src/tests/logging.rs index dd8aa8e9c..47cd6f33d 100644 --- a/crates/runvil-internal/lua/src/tests/logging.rs +++ b/crates/runvil-internal/lua/src/tests/logging.rs @@ -166,11 +166,9 @@ fn log_accepts_exactly_one_bounded_control_free_utf8_string() { serde_json::to_string(&too_long).expect("test string must serialize") ); let error = run(&source, "").expect_err("257 characters must fail"); - assert!( - error - .to_string() - .contains("log message must be at most 256 characters") - ); + assert!(error.to_string().contains(&format!( + "log message must be at most {LUA_LOG_CHARACTER_LIMIT} characters" + ))); let maximum = "é".repeat(LUA_LOG_CHARACTER_LIMIT); let source = format!( diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index a4a5c74c1..4c13da508 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -112,12 +112,12 @@ fn a_refused_injection_hands_back_the_vm_for_its_teardown() { ) .expect_err("a non-object var seed is refused"); assert!( - matches!(failure.error(), Error::Internal(message) if message.contains("JSON object")), + matches!(failure.error(), Error::Internal { message, .. } if message.contains("JSON object")), "the refusal names the seed's shape: {:?}", failure.error() ); let error = failure.teardown(recorder.emitter(), "Seed"); - assert!(matches!(error, Error::Internal(_))); + assert!(matches!(error, Error::Internal { .. })); assert_eq!( recorder.observations(), [ diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index 45afcc687..427ef0080 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -3,21 +3,20 @@ //! One Lua table holds every tool operation, mirroring the `models.*` //! namespacing of model operations. The shared [`ToolSet`] holds the run //! catalog, every catalog tool's `ToolInfo` by id. The model sees only -//! wire names: a tool's `ToolInfo.wire_name`, or else its id with `/` and -//! `.` replaced by `_`, such as `web_fetch`. Lua names a tool by its id -//! (`web/fetch`) or by its tool object, and a Pack by its id (`web`) or -//! its Pack object, never by a global. `adhoc` defines a prompt-author -//! Lua function as a tool of the adhoc Pack, `get` reads one tool object -//! by id, `packs` reads the Pack objects, `offer` and `offer_dynamic` -//! remember tools and Packs for the section's Static Set and Dynamic Set, -//! `call` dispatches a tool by id or object (installed by the coroutine -//! shim prelude, since dispatch suspends), and `stats`, which the `stats` -//! child installs, is the read-only view of the section's call counts: a -//! total, and counts per Pack and per tool by id. Offering a tool the run -//! does not hold is a hard error, and so is every offer once the -//! section's first model call froze its sets. The installation logic sits -//! here, out of the VM driver; the VM only calls the installers in setup -//! order. +//! wire names, as `wire_name_of` gives them, such as `web_fetch`. Lua +//! names a tool by its id (`web/fetch`) or by its tool object, and a Pack +//! by its id (`web`) or its Pack object, never by a global. `adhoc` +//! defines a prompt-author Lua function as a tool of the adhoc Pack, +//! `get` reads one tool object by id, `packs` reads the Pack objects, +//! `offer` and `offer_dynamic` remember tools and Packs for the section's +//! Static Set and Dynamic Set, `call` dispatches a tool by id or object +//! (installed by the coroutine shim prelude, since dispatch suspends), and +//! `stats`, which the `stats` child installs, is the read-only view of the +//! section's call counts: a total, and counts per Pack and per tool by id. +//! Offering a tool the run does not hold is a hard error, and so is every +//! offer once the section's first loop round froze its sets. The +//! installation logic sits here, out of the VM driver; the VM only calls +//! the installers in setup order. use std::sync::{Arc, Mutex}; @@ -27,13 +26,13 @@ use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::{Value as Json, json}; use crate::alias::Alias; +use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{Error, Result}; -use crate::handles::{ToolBinding, ToolSet}; use crate::json::to_json; use crate::messages::MessageList; use crate::packs::{ADHOC_PACK, install_pack_list, install_pack_objects}; use crate::proxy::key_text; -use crate::scope::{DiscoveryTool, OfferMode, ToolRuntime}; +use crate::scope::{DiscoveryTool, OfferMode, Offers}; use crate::vm::AdhocTools; mod decode; @@ -42,7 +41,7 @@ mod offer; mod stats; mod userdata; -pub(crate) use decode::{model_tool_name, tool_name}; +pub(crate) use decode::{model_tool_name, tool_id}; pub(crate) use stats::install_tool_call_counts; use decode::adhoc_params_schema; @@ -68,7 +67,7 @@ const PARAMETERS_NOT_JSON: &str = "tools.adhoc: parameters schema cannot be enco /// Refuses `call`, an offer function, once `list` holds the section's /// frozen sets. pub(crate) fn refuse_after_render(call: &str, list: &MessageList) -> mlua::Result<()> { - if list.rendered() { + if list.rendered_tools().is_some() { return Err(mlua::Error::external(format!("{call}: {TOOL_LIST_FIXED}"))); } Ok(()) @@ -89,8 +88,8 @@ pub(crate) fn adhoc_handler(lua: &Lua, name: &str) -> mlua::Result { }; match handler { Value::Function(handler) => Ok(handler), - _ => Err(mlua::Error::external(Error::Internal( - "an adhoc tool answer names a registered handler".into(), + _ => Err(mlua::Error::external(Error::internal( + "an adhoc tool answer names a registered handler", ))), } } @@ -100,10 +99,8 @@ pub(crate) fn adhoc_handler(lua: &Lua, name: &str) -> mlua::Result { /// objects, `tools.packs`, both offer verbs, `tools.adhoc`, and /// `tools.get`. /// -/// The coroutine shim prelude installs the suspending `tools.call` on this -/// table as a Lua function, because yield cannot cross the Rust callback -/// boundary. Both offer verbs refuse once `list`, the section's -/// conversation, holds the section's frozen sets. +/// Both offer verbs refuse once `list`, the section's conversation, holds +/// the section's frozen sets. /// /// # Errors /// Returns [`Error::LuaRuntime`] if a Lua table or callback cannot be created or @@ -112,7 +109,7 @@ pub(crate) fn install_tools( lua: &Lua, globals: &Table, set: &Arc, - runtime: &Arc>, + runtime: &Arc>, adhoc_tools: &AdhocTools, list: &MessageList, ) -> Result<()> { @@ -258,9 +255,9 @@ fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(Str tool_schema_new(name.clone(), description.clone(), parameters.clone()) .map_err(|error| mlua::Error::external(format!("tools.adhoc: {error}")))?; let id = ToolId::parse(&id).map_err(|error| { - mlua::Error::external(Error::Internal( - format!("an adhoc tool's id is a tool id: {error}").into(), - )) + mlua::Error::external(Error::internal(format!( + "an adhoc tool's id is a tool id: {error}" + ))) })?; Ok((name, ToolInfo::new(id, description, parameters))) } diff --git a/crates/runvil-internal/lua/src/tools/decode.rs b/crates/runvil-internal/lua/src/tools/decode.rs index 764b6857e..c49e2d468 100644 --- a/crates/runvil-internal/lua/src/tools/decode.rs +++ b/crates/runvil-internal/lua/src/tools/decode.rs @@ -19,7 +19,7 @@ use super::userdata::LuaToolHandle; /// # Errors /// Returns an `mlua` external error when the value is neither a string /// holding a `/` nor a tool object. -pub(crate) fn tool_name(value: &Value) -> mlua::Result { +pub(crate) fn tool_id(value: &Value) -> mlua::Result { match value { Value::String(text) => { let name = text.to_string_lossy(); @@ -71,13 +71,8 @@ pub(super) fn object_id(value: &Value) -> Option { /// `tools.adhoc` `parameters` table. Each value is a bare type string or a /// `{type, description}` array; every declared parameter is required. /// -/// The `required` list is the only Rust-side `table.pairs` walk in this crate -/// that builds an ordered array without sorting it, so it sorts its names -/// itself. The other ordered walks, `ordered_keys` in `iteration.rs` and -/// `collection_members` in `collection.rs`, already sort their output. The -/// walk's order is unspecified, so a schema left in it would read differently -/// in two VMs; `properties` needs no sort because it is a `serde_json::Map`, -/// a `BTreeMap` with no `preserve_order` in the graph. +/// `properties` is a `serde_json::Map`, a `BTreeMap` while `serde_json`'s +/// `preserve_order` is off, so only `required` needs the sort below. pub(super) fn adhoc_params_schema(params: &mlua::Table) -> mlua::Result { let mut properties = serde_json::Map::new(); let mut required = Vec::new(); diff --git a/crates/runvil-internal/lua/src/tools/objects.rs b/crates/runvil-internal/lua/src/tools/objects.rs index 3134d9eef..e34f4f52e 100644 --- a/crates/runvil-internal/lua/src/tools/objects.rs +++ b/crates/runvil-internal/lua/src/tools/objects.rs @@ -9,8 +9,8 @@ use mlua::{AnyUserData, Lua, Table, Value}; use super::userdata::LuaToolHandle; +use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{Error, Result}; -use crate::handles::{ToolBinding, ToolSet}; /// The registry key of the VM's tool-object table: one object per tool, /// keyed by id. @@ -35,7 +35,7 @@ pub(crate) fn tool_object(lua: &Lua, id: &str) -> mlua::Result Result<()> { let objects = lua.create_table().map_err(Error::lua)?; @@ -75,7 +75,7 @@ pub(super) fn remove_tool_object(lua: &Lua, id: &str) -> mlua::Result<()> { /// Installs `tools.get(id)`. /// /// # Errors -/// Returns [`Error::Lua`] if the function cannot be created or installed. +/// Returns [`Error::LuaRuntime`] if the function cannot be created or installed. pub(super) fn install_tool_get(lua: &Lua, tools: &Table) -> Result<()> { let get = lua .create_function(|lua, id: Value| match id { diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs index e19890077..600b2cf4c 100644 --- a/crates/runvil-internal/lua/src/tools/offer.rs +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -17,12 +17,12 @@ use runvil_types::tools::ToolId; use super::refuse_after_render; use super::userdata::LuaToolHandle; +use crate::bindings::{RosterEntry, ToolSet, wire_name_of}; use crate::error::{Error, Result}; -use crate::handles::{ToolSet, wire_name_of}; use crate::messages::MessageList; use crate::packs::{LuaPackHandle, is_adhoc, pack_object}; use crate::proxy::key_text; -use crate::scope::{OfferMode, OfferTarget, ToolRuntime}; +use crate::scope::{OfferMode, OfferTarget, Offers}; use crate::vm::AdhocTools; use crate::walk_budget::HELD_KEY_LIMIT; @@ -32,7 +32,7 @@ use crate::walk_budget::HELD_KEY_LIMIT; #[derive(Clone)] pub(super) struct Offering { pub(super) set: Arc, - pub(super) runtime: Arc>, + pub(super) runtime: Arc>, pub(super) adhoc: AdhocTools, pub(super) list: MessageList, } @@ -59,7 +59,7 @@ pub(super) fn install_offer( let mut runtime = offering .runtime .lock() - .map_err(|_| mlua::Error::external("tool declaration runtime was poisoned"))?; + .map_err(|_| mlua::Error::external("section offers lock was poisoned"))?; for target in targets { match target { OfferTarget::Tool(id) => runtime.add_tool(id, mode), @@ -150,7 +150,7 @@ impl Offering { }); } if let Ok(pack) = PackId::parse(name) { - if let Some(Some(reason)) = self.set.roster().get(&pack) { + if let Some(RosterEntry::Unavailable { reason }) = self.set.roster().get(&pack) { return Err(mlua::Error::external(format!( "{verb}: the {pack} Pack is unavailable in this run: {reason}" ))); diff --git a/crates/runvil-internal/lua/src/tools/stats.rs b/crates/runvil-internal/lua/src/tools/stats.rs index e3d6aedbd..0e7d7fcf1 100644 --- a/crates/runvil-internal/lua/src/tools/stats.rs +++ b/crates/runvil-internal/lua/src/tools/stats.rs @@ -15,12 +15,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::ops::Bound; use std::sync::Arc; -use mlua::{Function, Lua, MultiValue, Table, Value}; +use mlua::{Lua, Table, Value}; use runvil_types::tools::ToolId; +use crate::bindings::ToolSet; use crate::error::{Error, Result}; -use crate::handles::ToolSet; -use crate::proxy::key_text; +use crate::proxy::{key_text, read_only_walk}; use crate::scope::ToolCallCounts; use crate::vm::AdhocTools; @@ -137,7 +137,7 @@ impl Members { } /// Installs `tools.stats` over a fresh [`ToolCallCounts`] seeded with -/// `ids`, telling the ids Lua can see by `bound_tools`' run catalog and +/// `ids`, telling the ids Lua can see by `tool_set`'s run catalog and /// `adhoc`, and returns the counts so the scheduler can increment them. /// /// # Errors @@ -145,7 +145,7 @@ impl Members { /// installed. pub(crate) fn install_tool_call_counts( lua: &Lua, - bound_tools: &ToolSet, + tool_set: &ToolSet, adhoc: &AdhocTools, ids: &[ToolId], ) -> Result { @@ -153,7 +153,7 @@ pub(crate) fn install_tool_call_counts( let stats = Stats { counts: counts.clone(), catalog: Arc::new( - bound_tools + tool_set .catalog() .tools() .iter() @@ -209,7 +209,7 @@ fn stats_proxy(lua: &Lua, stats: &Stats) -> mlua::Result

{ None => Ok((Value::Nil, Value::Nil)), } })?; - proxy(lua, index, step, STATS) + read_only_walk(lua, index, step, STATS) } /// Builds the table of `view` over `stats`: a key it holds reads its @@ -246,26 +246,5 @@ fn view_proxy(lua: &Lua, stats: &Stats, view: View) -> mlua::Result
{ }; Ok(next.unzip()) })?; - proxy(lua, index, step, view.path()) -} - -/// An empty proxy whose metatable holds `index`, a `__pairs` that walks -/// with `step`, a `__newindex` that raises ` is read-only`, and that -/// text as its `__metatable`, which protects it. -fn proxy(lua: &Lua, index: Function, step: Function, path: &str) -> mlua::Result
{ - let label = format!("{path} is read-only"); - let pairs = - lua.create_function(move |_, proxy: Table| Ok((step.clone(), proxy, Value::Nil)))?; - let refusal = label.clone(); - let newindex = lua.create_function(move |_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external(refusal.clone())) - })?; - let meta = lua.create_table()?; - meta.raw_set("__index", index)?; - meta.raw_set("__pairs", pairs)?; - meta.raw_set("__newindex", newindex)?; - meta.raw_set("__metatable", label)?; - let proxy = lua.create_table()?; - proxy.set_metatable(Some(meta))?; - Ok(proxy) + read_only_walk(lua, index, step, view.path()) } diff --git a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs index e0b82fb26..8ca3aecb3 100644 --- a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs +++ b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs @@ -328,7 +328,7 @@ fn an_offered_adhoc_tool_counts_from_zero_and_an_unoffered_one_from_its_first_ca tools.offer(grab)" )); let (set, runtime) = vm.tool_bag_handles(); - let ids: Vec<_> = crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()) + let ids: Vec<_> = crate::current_static_set(&set, &runtime, vm.adhoc_tools()) .expect("the scope resolves") .iter() .map(|tool| tool.id().clone()) @@ -345,10 +345,9 @@ fn an_offered_adhoc_tool_counts_from_zero_and_an_unoffered_one_from_its_first_ca ), "{unoffered}" ); - counts.ensure("adhoc/other").expect("a call seeds its id"); counts .increment("adhoc/other") - .expect("the seeded id counts"); + .expect("a call seeds and counts its id"); let called: i64 = vm.eval("return tools.stats.tools['adhoc/other']"); assert_eq!(called, 1, "a call gives an unoffered adhoc tool its count"); } diff --git a/crates/runvil-internal/lua/src/tools/tests-offering.rs b/crates/runvil-internal/lua/src/tools/tests-offering.rs index 00a5c1322..8656cba83 100644 --- a/crates/runvil-internal/lua/src/tools/tests-offering.rs +++ b/crates/runvil-internal/lua/src/tools/tests-offering.rs @@ -224,7 +224,8 @@ fn an_array_is_read_through_its_metamethods() { fn the_render_refusal_comes_before_every_other_check_and_defining_still_works() { let vm = lua_over(offer_set()); vm.exec("tools.offer('web/search')"); - vm.messages() + let _ = vm + .messages() .attach_rendered_tools(RenderedTools::default()); for verb in ["tools.offer", "tools.offer_dynamic"] { for args in ["'gh'", "'web/search'", "'wbe'", "42", "", "'web', 'gh'"] { diff --git a/crates/runvil-internal/lua/src/tools/tests-parameters.rs b/crates/runvil-internal/lua/src/tools/tests-parameters.rs index ef701a99b..abab46800 100644 --- a/crates/runvil-internal/lua/src/tools/tests-parameters.rs +++ b/crates/runvil-internal/lua/src/tools/tests-parameters.rs @@ -10,8 +10,8 @@ use runvil_types::tools::{ToolCatalog, ToolId, ToolInfo, ToolOutput}; use serde_json::{Value as Json, json}; use crate::ToolBinding; -use crate::dispatch::prepare_dispatch; -use crate::handles::ToolSet; +use crate::bindings::ToolSet; +use crate::dispatch::dispatch_body; use crate::test_vm::{TEST_SECTION, lua_over, test_nonce}; use crate::tests::recording::null_emitter; @@ -45,7 +45,6 @@ pub(super) fn search_set() -> ToolSet { vec![PackId::parse("web").expect("`web` is a valid Pack id")], BTreeMap::new(), ToolCatalog::new([search_tool()]).expect("one tool"), - BTreeMap::new(), ) } @@ -153,7 +152,7 @@ fn strings(value: &Json, out: &mut Vec) { #[test] fn metadata_of_a_pack_whose_results_are_untrusted_never_takes_the_envelope() { let nonce = test_nonce(); - let result = prepare_dispatch( + let result = dispatch_body( &ToolBinding::from_info("web_search", &search_tool()), None, Ok(ToolOutput::untrusted("fetched page")), diff --git a/crates/runvil-internal/lua/src/tools/tests-render.rs b/crates/runvil-internal/lua/src/tools/tests-render.rs index 19bfff407..f542b6d90 100644 --- a/crates/runvil-internal/lua/src/tools/tests-render.rs +++ b/crates/runvil-internal/lua/src/tools/tests-render.rs @@ -82,7 +82,7 @@ fn offers_that_share_a_wire_name_all_reach_the_merge_and_only_an_id_dedupes() { tools.offer_dynamic('web')", ); let (set, runtime) = vm.tool_bag_handles(); - let ids: Vec = crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()) + let ids: Vec = crate::current_static_set(&set, &runtime, vm.adhoc_tools()) .expect("the static offers resolve") .iter() .map(|tool| tool.id().to_string()) @@ -172,7 +172,7 @@ fn dynamic_offers_leave_the_static_bindings_unchanged() { ); let bindings = |vm: &crate::SectionVm| { let (set, runtime) = vm.tool_bag_handles(); - crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()).expect("the scope resolves") + crate::current_static_set(&set, &runtime, vm.adhoc_tools()).expect("the scope resolves") }; assert_eq!(bindings(&plain), bindings(&with_dynamic)); } diff --git a/crates/runvil-internal/lua/src/tools/tests-stats.rs b/crates/runvil-internal/lua/src/tools/tests-stats.rs index 19332c5dc..a53452099 100644 --- a/crates/runvil-internal/lua/src/tools/tests-stats.rs +++ b/crates/runvil-internal/lua/src/tools/tests-stats.rs @@ -46,11 +46,10 @@ fn seeded() -> (BuiltVm, ToolCallCounts) { (vm, counts) } -/// Counts `times` calls of `id`, seeding it first as a dispatch does. +/// Counts `times` calls of `id`, as a dispatch does. fn call(counts: &ToolCallCounts, id: &str, times: u32) { - counts.ensure(id).expect("the counts seed"); for _ in 0..times { - counts.increment(id).expect("a seeded id counts"); + counts.increment(id).expect("an increment counts"); } } diff --git a/crates/runvil-internal/lua/src/tools/tests.rs b/crates/runvil-internal/lua/src/tools/tests.rs index 593bde151..2759b33ca 100644 --- a/crates/runvil-internal/lua/src/tools/tests.rs +++ b/crates/runvil-internal/lua/src/tools/tests.rs @@ -4,7 +4,7 @@ use mlua::{Lua, Value}; use serde_json::json; -use super::decode::{adhoc_params_schema, model_tool_name, tool_name}; +use super::decode::{adhoc_params_schema, model_tool_name, tool_id}; use super::userdata::LuaToolHandle; use crate::test_vm::{TestVm, echo_tool}; @@ -41,10 +41,10 @@ fn string(lua: &Lua, text: &str) -> Value { fn tool_name_accepts_an_id_and_refuses_a_string_without_a_slash() { let lua = Lua::new(); assert_eq!( - tool_name(&string(&lua, "tools/echo")).expect("an id decodes"), + tool_id(&string(&lua, "tools/echo")).expect("an id decodes"), "tools/echo" ); - let error = tool_name(&string(&lua, "grab")).expect_err("a bare name is not an id"); + let error = tool_id(&string(&lua, "grab")).expect_err("a bare name is not an id"); assert!( error .to_string() @@ -86,7 +86,7 @@ fn tool_name_reads_the_id_off_a_tool_object() { .create_userdata(echo_handle()) .expect("a fresh VM creates a tool object"); assert_eq!( - tool_name(&Value::UserData(userdata)).expect("a tool object decodes"), + tool_id(&Value::UserData(userdata)).expect("a tool object decodes"), "tools/echo", "a tool object stands for its id, not its wire name" ); @@ -95,7 +95,7 @@ fn tool_name_reads_the_id_off_a_tool_object() { #[test] fn tool_name_rejects_tables_other_types_and_other_userdata() { let lua = Lua::new(); - let number = tool_name(&Value::Integer(42)).expect_err("a number is not a tool"); + let number = tool_id(&Value::Integer(42)).expect_err("a number is not a tool"); assert!( number .to_string() @@ -106,7 +106,7 @@ fn tool_name_rejects_tables_other_types_and_other_userdata() { .load("{ id = 'tools/echo', name = 'tools_echo' }") .eval::() .expect("the record evaluates"); - let table = tool_name(&Value::Table(record)).expect_err("a record is not a tool"); + let table = tool_id(&Value::Table(record)).expect_err("a record is not a tool"); assert!( table.to_string().contains("got table"), "a plain record is not a tool: {table}" @@ -116,7 +116,7 @@ fn tool_name_rejects_tables_other_types_and_other_userdata() { let foreign = lua .create_userdata(Foreign) .expect("a fresh VM creates a foreign userdata"); - let other = tool_name(&Value::UserData(foreign)).expect_err("not a tool object"); + let other = tool_id(&Value::UserData(foreign)).expect_err("not a tool object"); assert!( other .to_string() diff --git a/crates/runvil-internal/lua/src/tools/userdata.rs b/crates/runvil-internal/lua/src/tools/userdata.rs index 57225d231..c4e27ab9f 100644 --- a/crates/runvil-internal/lua/src/tools/userdata.rs +++ b/crates/runvil-internal/lua/src/tools/userdata.rs @@ -14,7 +14,7 @@ use mlua::{LuaSerdeExt, MetaMethod, UserData, UserDataFields, UserDataMethods, V use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::Value as Json; -use crate::handles::ToolBinding; +use crate::bindings::ToolBinding; use crate::packs::pack_object; /// One tool as Lua sees it: `id`, `description`, `parameters`, and diff --git a/crates/runvil-internal/lua/src/var.rs b/crates/runvil-internal/lua/src/var.rs new file mode 100644 index 000000000..e6c33ff61 --- /dev/null +++ b/crates/runvil-internal/lua/src/var.rs @@ -0,0 +1,240 @@ +//! The guarded `var` proxy that sandboxed author code reads and writes. + +use mlua::{Lua, LuaSerdeExt, Value}; +use serde_json::Value as Json; + +use crate::error::{Error, Result}; +use crate::proxy::key_path; +use crate::walk_budget::WalkBudget; + +/// The registry key holding the `var` proxy's hidden data table. +/// +/// The registry is unreachable from sandboxed author code (no `debug` +/// library), so only Engine code holding a `&Lua` can read or replace the data +/// table behind the guarded global. +const VAR_DATA_REGISTRY: &str = "runvil.var_data"; + +/// The registry key holding the `var` proxy table itself, so read-back can +/// detect an author reassigning the global (`var = 5`), which would +/// otherwise silently strand the hidden data table. +const VAR_PROXY_REGISTRY: &str = "runvil.var_proxy"; + +/// Registry map from every guarded proxy table to its hidden data table. +const VAR_GUARDED_DATA_REGISTRY: &str = "runvil.var_guarded_data"; + +fn guarded_json_value( + lua: &Lua, + value: &Json, + path: &str, + guarded_data: &mlua::Table, +) -> mlua::Result { + match value { + Json::Array(values) => { + let data = lua.create_table_with_capacity(values.len(), 0)?; + data.set_metatable(Some(lua.array_metatable()))?; + for (index, value) in values.iter().enumerate() { + data.raw_set( + index + 1, + guarded_json_value( + lua, + value, + &format!("{path}[{}]", index + 1), + guarded_data, + )?, + )?; + } + guarded_table(lua, data, path, guarded_data).map(Value::Table) + } + Json::Object(values) => { + let data = lua.create_table_with_capacity(0, values.len())?; + for (key, value) in values { + data.raw_set( + key.as_str(), + guarded_json_value(lua, value, &format!("{path}.{key}"), guarded_data)?, + )?; + } + guarded_table(lua, data, path, guarded_data).map(Value::Table) + } + _ => lua.to_value(value), + } +} + +fn guarded_table( + lua: &Lua, + data: mlua::Table, + path: &str, + guarded_data: &mlua::Table, +) -> mlua::Result { + let proxy = lua.create_table()?; + guarded_data.raw_set(proxy.clone(), data.clone())?; + let metatable = lua.create_table()?; + metatable.set("__index", data.clone())?; + + let write_data = data; + let nested_data = guarded_data.clone(); + let table_path = path.to_owned(); + let newindex = lua.create_function( + move |lua, (_proxy, key, value): (Value, Value, Value)| -> mlua::Result<()> { + // The serde bridge turns nil into mlua's null sentinel, which + // would keep the key instead of removing it. + if value.is_nil() { + return write_data.raw_set(key, value); + } + let target = key_path(&table_path, &key); + if let Value::Function(_) | Value::UserData(_) | Value::Thread(_) = value { + return Err(mlua::Error::runtime(format!( + "{target} must be JSON data, got {}", + value.type_name() + ))); + } + // `from_value` reads a shared table once per reference and builds + // the result on the Rust heap before any Lua allocation, so a + // value that outgrows the heap is refused before it converts. + if WalkBudget::for_vm(lua).check_value(&value).is_err() { + return Err(mlua::Error::runtime(format!( + "{target} is too large to store" + ))); + } + let json = lua.from_value::(value)?; + let rebuilt = guarded_json_value(lua, &json, &target, &nested_data)?; + write_data.raw_set(key, rebuilt) + }, + )?; + metatable.set("__newindex", newindex)?; + metatable.set("__metatable", "var is guarded")?; + proxy.set_metatable(Some(metatable))?; + Ok(proxy) +} + +/// Copies a guarded value into plain tables for JSON conversion. A list's +/// data table carries mlua's array metatable, and its copy takes it too, +/// so an empty list converts to `[]` rather than `{}`. +fn materialize_guarded_value( + lua: &Lua, + value: Value, + guarded_data: &mlua::Table, +) -> mlua::Result { + let Value::Table(table) = value else { + return Ok(value); + }; + let source = match guarded_data.raw_get::(table.clone())? { + Value::Table(data) => data, + _ => table, + }; + let plain = lua.create_table()?; + let marker = lua.array_metatable(); + if source + .metatable() + .is_some_and(|metatable| metatable == marker) + { + plain.set_metatable(Some(marker))?; + } + // `pairs` order is unspecified; each iteration only assigns one entry + // into the fresh table, so the copy's content is fixed. + for pair in source.pairs::() { + let (key, value) = pair?; + plain.raw_set(key, materialize_guarded_value(lua, value, guarded_data)?)?; + } + Ok(Value::Table(plain)) +} + +/// Builds the guarded `var` global: an empty proxy table over a hidden data +/// table. +/// +/// The proxy is empty; reads go through `__index` against the data table, so +/// present values surface directly and absent keys read as Lua nil. +/// `__newindex` validates the assigned value for JSON-representability +/// through the serde bridge - a function, userdata, or thread is rejected at +/// the assigning line, and nested tables are deep-checked by the bridge - +/// then rebuilds it as fresh guarded data before writing through. Assigning nil +/// removes the key. Every nested table is itself an empty proxy over hidden +/// data, so later incremental writes cross the same validation boundary instead +/// of mutating a stored table directly. `__metatable` is set so author code +/// cannot replace the guard. The root data table is stashed in the Lua registry +/// (unreachable from sandboxed author code) and is the read-back source for +/// [`var_to_json`], which materializes nested proxies before serde conversion; +/// proxies never hold entries themselves. The root proxy is stashed alongside +/// it so [`var_to_json`] can reject an author reassigning the `var` global +/// instead of silently reading stale data. +pub(crate) fn guarded_var(lua: &Lua, initial: Option<&Json>) -> Result { + let initial = match initial { + Some(Json::Object(values)) => Json::Object(values.clone()), + Some(other) => { + return Err(Error::internal(format!( + "var must seed from a JSON object, got {}", + lua.to_value(other).map_err(Error::lua)?.type_name() + ))); + } + None => Json::Object(serde_json::Map::new()), + }; + let guarded_data = lua.create_table().map_err(Error::lua)?; + let Value::Table(proxy) = + guarded_json_value(lua, &initial, "var", &guarded_data).map_err(Error::lua)? + else { + return Err(Error::internal("guarded var root was not a table")); + }; + let Value::Table(data) = guarded_data + .raw_get::(proxy.clone()) + .map_err(Error::lua)? + else { + return Err(Error::internal("guarded var data table was missing")); + }; + // The named registry entries keep the data table alive and reachable for + // Engine read-back, and the proxy reachable for the reassignment check in + // `var_to_json`. + lua.set_named_registry_value(VAR_DATA_REGISTRY, data) + .map_err(Error::lua)?; + lua.set_named_registry_value(VAR_PROXY_REGISTRY, proxy.clone()) + .map_err(Error::lua)?; + lua.set_named_registry_value(VAR_GUARDED_DATA_REGISTRY, guarded_data) + .map_err(Error::lua)?; + Ok(proxy) +} + +/// Returns the hidden `var` data table as a plain deep-copied table. +/// +/// The guarded `var` proxy itself serializes empty; this reads what +/// [`var_to_json`] reads, materializing nested proxies, for the coroutine +/// shim's `var_snapshot` capture: a `call` or `tasks.fanout` request +/// includes the caller's `var` as a clone. +/// +/// # Errors +/// Returns [`Error::Lua`] if the author reassigned the `var` global, +/// [`Error::LuaRuntime`] if a registry slot cannot be read (Engine values +/// were never injected) or a nested proxy cannot be copied, and +/// [`Error::Internal`] if the root data value is not a table. +pub(crate) fn var_snapshot_table(lua: &Lua) -> Result { + let proxy: mlua::Table = lua + .named_registry_value(VAR_PROXY_REGISTRY) + .map_err(Error::lua)?; + let current: Value = lua.globals().get("var").map_err(Error::lua)?; + match current { + Value::Table(ref table) if table.equals(&proxy).map_err(Error::lua)? => {} + _ => { + return Err(Error::Lua( + "the `var` global was reassigned; write `var.` instead".to_owned(), + )); + } + } + let data: mlua::Table = lua + .named_registry_value(VAR_DATA_REGISTRY) + .map_err(Error::lua)?; + let guarded_data: mlua::Table = lua + .named_registry_value(VAR_GUARDED_DATA_REGISTRY) + .map_err(Error::lua)?; + match materialize_guarded_value(lua, Value::Table(data), &guarded_data).map_err(Error::lua)? { + Value::Table(plain) => Ok(plain), + // The root data value is always a table; `guarded_var` builds it. + _ => Err(Error::internal("guarded var data table was not a table")), + } +} + +/// Reads the hidden `var` data table back as JSON. +/// +/// # Errors +/// Fails as [`var_snapshot_table`] does, and with [`Error::LuaRuntime`] if +/// the data cannot be represented as JSON. +pub(crate) fn var_to_json(lua: &Lua) -> Result { + lua.from_value(Value::Table(var_snapshot_table(lua)?)) + .map_err(Error::lua) +} diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 2086908a1..4419d9cd5 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -31,15 +31,15 @@ use runvil_types::untrusted::GuardNonce; use runvil_vfs::Access; use serde_json::Value as Json; +use crate::bindings::ToolSet; use crate::engine_globals::{LUA_LOG_CHARACTER_LIMIT, install_untrusted}; use crate::error::{Error, Result}; -use crate::handles::ToolSet; -use crate::hardening::{InstructionBudget, harden, install_instruction_budget}; +use crate::hardening::{CancelHook, harden, install_cancel_hook}; use crate::iteration::install_deterministic_iteration; use crate::messages::MessageList; -use crate::models::{ModelRuntime, RawModelIds}; +use crate::models::{ModelSelection, RawModelIds}; use crate::program::LuaProgram; -use crate::scope::ToolRuntime; +use crate::scope::Offers; use crate::walk_budget::HeapCeiling; pub use adhoc::AdhocTools; @@ -47,7 +47,7 @@ pub use inject::InjectFailure; pub use run::CoroStep; pub(crate) use run::scalar_return; pub use state::{ - current_catalog_packs, current_dynamic_set, current_tool_bindings, resolve_model_binding, + current_catalog_packs, current_dynamic_set, current_static_set, resolve_model_binding, }; /// Default per-VM Lua heap ceiling, matching the executor's `RunLimits`. @@ -56,10 +56,6 @@ pub(crate) const DEFAULT_LUA_MEMORY_BYTES: usize = 64 * 1024 * 1024; /// Default per-VM `log()` event budget, matching the executor's `RunLimits`. pub(crate) const DEFAULT_LUA_LOG_EVENTS: u32 = 1024; -/// The failure of a `prose` read on a VM whose live `sys` slot was never -/// filled, which Engine injection fills. -pub(crate) const NOT_INJECTED: &str = "section VM Engine values have not been injected"; - /// The failure of a poisoned live `sys` slot. pub(crate) const SYS_LIVE_POISONED: &str = "sys live slot was poisoned"; @@ -82,16 +78,19 @@ fn pack_sequence(lua: &Lua, values: Vec) -> mlua::Result, + /// Live sealed `sys` JSON, mirrored for prose snapshots. + sys_live: Arc>, } /// One hardened, isolated Lua VM for a section's complete lifecycle. @@ -120,14 +119,14 @@ pub struct SectionVm { /// The run's shared tool set: the declared Packs, the roster, and the /// run catalog. Shared with the run, not snapshotted; nothing writes /// it during a run. - pub(crate) bound_tools: Arc, + pub(crate) tool_set: Arc, /// The run's shared model set: the frontmatter's filled roles plus the /// prompt-wide default. Shared for the same reason (`models.default`). pub(crate) bound_models: Arc>, - /// The section's tool-addition runtime, read by the executor's scope path. - tool_runtime: Arc>, - /// The section's model-selection runtime, read by the executor's scope path. - model_runtime: Arc>, + /// The section's offers, read by the executor's scope path. + offers: Arc>, + /// The section's model selection, read by the executor's scope path. + model_selection: Arc>, /// Set by Lua `jump` before it aborts the current chunk. jump_slot: Arc>>, /// How many adhoc tool handlers are running on this VM; `jump` refuses @@ -142,13 +141,11 @@ pub struct SectionVm { /// failure maps through them as well as through the started block, so /// library code reports its own location and prompt line. library_programs: Arc>>, - /// Live sealed `sys` JSON, mirrored for prose snapshots. - sys_live: Arc>>, /// The slot the direct store closures record a claims-model conflict /// into while the Pack preludes install, for the executor to read /// once they return via /// [`take_store_conflict`](Self::take_store_conflict). - store_conflicts: Arc>>, + store_conflict: Arc>>, /// Remaining `log()` events this VM may emit before the budget is exhausted. log_budget: Arc, /// Remaining cumulative `log()` message bytes this VM may emit. Bounds total @@ -156,9 +153,9 @@ pub struct SectionVm { log_byte_budget: Arc, /// The adhoc tools `tools.adhoc` defined. adhoc_tools: AdhocTools, - /// The VM's instruction-budget counter, shared with every block - /// coroutine's hook (hooks are per-coroutine in PUC Lua). - instruction_budget: InstructionBudget, + /// The VM's cancellation hook, shared with every block coroutine's hook + /// (hooks are per-coroutine in PUC Lua). + cancel_hook: CancelHook, /// The raw-model-id fallback, resolved whenever the caller passes an /// application-state snapshot (`RunContext::with_ui`): then `models.get` /// resolves an undeclared alias as a raw gateway catalog model id. Set @@ -180,9 +177,10 @@ impl SectionVm { /// run's `nonce`). Everything else - the run's /// limits, the Engine values, the persistent Engine globals, the control /// globals, and `require` - is a separate explicit step the caller - /// drives in that order (see the type-level docs). Every - /// lifecycle report goes through the emitter the caller hands each - /// step; the VM retains none. + /// drives in that order (see the type-level docs). Construction and + /// teardown report through the emitter the caller hands each step; the + /// persistent globals that `install_engine_globals` and + /// `install_require` install keep clones of theirs. /// /// The VM shares an empty tool and model set, so the validating /// `tools.offer` installed by @@ -200,28 +198,27 @@ impl SectionVm { #[cfg(any(test, feature = "test-support"))] crate::test_support::count_live_vm(&lua); // Bound the VM heap by default; `apply_lua_limits` may tighten or relax - // it to the caller's `RunLimits`. A safe non-env default keeps every VM + // it to the caller's `RunLimits`. A default keeps every VM // bounded even when the run installs no explicit limits. lua.set_memory_limit(DEFAULT_LUA_MEMORY_BYTES) .map_err(Error::lua)?; lua.set_app_data(HeapCeiling(DEFAULT_LUA_MEMORY_BYTES)); let mut vm = Self { lua, - bound_tools: Arc::new(ToolSet::default()), + tool_set: Arc::new(ToolSet::default()), bound_models: Arc::new(Mutex::new(ModelSet::default())), - tool_runtime: Arc::new(Mutex::new(ToolRuntime::default())), - model_runtime: Arc::new(Mutex::new(ModelRuntime::new())), + offers: Arc::new(Mutex::new(Offers::default())), + model_selection: Arc::new(Mutex::new(ModelSelection::new())), jump_slot: Arc::new(Mutex::new(None)), adhoc_handler_depth: Arc::new(AtomicU32::new(0)), loop_running: Arc::new(AtomicBool::new(false)), library_load_depth: Arc::new(AtomicU32::new(0)), library_programs: Arc::new(Mutex::new(Vec::new())), - sys_live: Arc::new(Mutex::new(None)), - store_conflicts: Arc::new(Mutex::new(None)), + store_conflict: Arc::new(Mutex::new(None)), log_budget: Arc::new(AtomicU32::new(DEFAULT_LUA_LOG_EVENTS)), log_byte_budget: Arc::new(AtomicUsize::new(log_byte_budget(DEFAULT_LUA_LOG_EVENTS))), adhoc_tools: AdhocTools::default(), - instruction_budget: InstructionBudget::default(), + cancel_hook: CancelHook::default(), raw_model_ids: RawModelIds::Refused, messages: MessageList::default(), state: Uninjected, @@ -235,8 +232,8 @@ impl SectionVm { if let Err(error) = install_untrusted(&vm.lua, nonce) { return vm.construction_failed(error, emitter, section); } - match install_instruction_budget(&vm.lua) { - Ok(budget) => vm.instruction_budget = budget, + match install_cancel_hook(&vm.lua) { + Ok(hook) => vm.cancel_hook = hook, Err(error) => return vm.construction_failed(error, emitter, section), } Ok(vm) @@ -261,7 +258,7 @@ impl SectionVm { section: &str, ) -> Result { let mut vm = Self::new(nonce, emitter, section)?; - vm.bound_tools = Arc::clone(tools); + vm.tool_set = Arc::clone(tools); vm.bound_models = Arc::clone(models); Ok(vm) } @@ -278,11 +275,12 @@ impl SectionVm { /// Applies the run's Lua resource limits to this VM. /// - /// Sets the heap ceiling (`lua_memory_bytes`) and resets the `log()` event - /// budget (`lua_log_events`). Called by the executor right after - /// construction, ahead of the Pack preludes and every block, so they - /// already spend the caller's run limits rather than only the safe - /// non-env defaults installed in `SectionVm::new`. + /// Sets the heap ceiling (`lua_memory_bytes`), records it for the + /// conversion walk budget, and resets the `log()` event budget + /// (`lua_log_events`) and the byte budget derived from it. Called by + /// the executor right after construction, ahead of the Pack preludes and + /// every block, so they spend the run's limits rather than the defaults + /// `SectionVm::new` installs. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the underlying VM rejects the memory @@ -317,14 +315,14 @@ impl SectionVm { /// running chunk as [`Error::Interrupted`]. A VM without one is never /// cancelled. The first install wins. pub fn set_cancel(&self, cancel: runvil_types::cancel::CancelHandle) { - self.instruction_budget.set_cancel(cancel); + self.cancel_hook.set_cancel(cancel); } /// The section's offers: what `tools.offer` and `tools.offer_dynamic` /// recorded. #[must_use] - pub fn tool_runtime(&self) -> &Arc> { - &self.tool_runtime + pub fn offers(&self) -> &Arc> { + &self.offers } /// The adhoc tools `tools.adhoc` defined on this VM. @@ -333,10 +331,10 @@ impl SectionVm { &self.adhoc_tools } - /// The section's model-selection runtime: what `models.use` recorded. + /// The section's model selection: what `models.use` recorded. #[must_use] - pub fn model_runtime(&self) -> &Arc> { - &self.model_runtime + pub fn model_selection(&self) -> &Arc> { + &self.model_selection } /// The section's one conversation, which the `messages` global holds diff --git a/crates/runvil-internal/lua/src/vm/adhoc.rs b/crates/runvil-internal/lua/src/vm/adhoc.rs index 7856d162e..749e93490 100644 --- a/crates/runvil-internal/lua/src/vm/adhoc.rs +++ b/crates/runvil-internal/lua/src/vm/adhoc.rs @@ -3,8 +3,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; +use crate::bindings::ToolBinding; use crate::error::{Error, Result}; -use crate::handles::ToolBinding; /// The adhoc tools `tools.adhoc` defined on a section VM, in definition /// order. @@ -27,7 +27,7 @@ impl AdhocTools { fn entries(&self) -> Result>> { self.entries .lock() - .map_err(|_| Error::Internal("adhoc tools registry was poisoned".into())) + .map_err(|_| Error::internal("adhoc tools registry was poisoned")) } /// Registers the adhoc tool `binding` binds. diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index 3fd6abc73..69a07dc4b 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -2,20 +2,21 @@ //! an injected one, installing `args`, `argv`, `sys`, `var`, the `tools`, //! `models`, and `compactors` tables, and the VM's `messages` list. -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use runvil_types::emitter::Emitter; use runvil_vfs::Access; use serde_json::Value as Json; -use super::{Injected, SYS_LIVE_POISONED, SectionVm, Uninjected}; +use super::{Injected, SectionVm, Uninjected}; use crate::argv::Argv; use crate::compactors::install_compactors; use crate::error::{Error, Result}; use crate::messages::install_messages; use crate::models::install_models; -use crate::sys::{guarded_var, seal_sys}; +use crate::sys::seal_sys; use crate::tools::install_tools; +use crate::var::guarded_var; /// A refused Engine injection: the error, beside the uninjected VM it was /// refused on, which the caller tears down at its own boundary. @@ -43,21 +44,6 @@ impl InjectFailure { } impl SectionVm { - /// Installs the section's Engine values, ahead of the Pack preludes, - /// with an empty `var` and a frozen nil `argv`. - /// - /// # Errors - /// Returns the [`InjectFailure`] holding the bridging failure if Engine - /// values cannot be bridged. - pub fn inject_values( - self, - args: &str, - sys: &Json, - access: &Arc, - ) -> std::result::Result { - self.inject_values_with_var(args, sys, access, None, Argv::Frozen(None)) - } - /// Installs Engine values while seeding `var` from an earlier VM, and /// gives back the injected VM. /// @@ -101,7 +87,10 @@ impl SectionVm { error, }); } - Ok(self.injected(Arc::clone(access))) + Ok(self.injected(Injected { + access: Arc::clone(access), + sys_live: Arc::new(Mutex::new(sys.clone())), + })) } #[expect( @@ -123,20 +112,13 @@ impl SectionVm { } let sys_table = seal_sys(&self.lua, sys)?; globals.raw_set("sys", sys_table).map_err(Error::lua)?; - { - let mut live = self - .sys_live - .lock() - .map_err(|_| Error::Internal(SYS_LIVE_POISONED.into()))?; - *live = Some(sys.clone()); - } let var = guarded_var(&self.lua, initial_var)?; globals.raw_set("var", var).map_err(Error::lua)?; install_tools( &self.lua, &globals, - &self.bound_tools, - &self.tool_runtime, + &self.tool_set, + &self.offers, &self.adhoc_tools, &self.messages, )?; @@ -144,56 +126,54 @@ impl SectionVm { &self.lua, &globals, &self.bound_models, - &self.model_runtime, + &self.model_selection, self.raw_model_ids, )?; install_messages(&self.lua, &self.messages)?; install_compactors(&self.lua, &globals, &self.messages) } - /// The same VM at the injected stage, holding `access`. - fn injected(self, access: Arc) -> SectionVm { + /// The same VM at the injected stage, holding `state`. + fn injected(self, state: Injected) -> SectionVm { let SectionVm { lua, - bound_tools, + tool_set, bound_models, - tool_runtime, - model_runtime, + offers, + model_selection, jump_slot, adhoc_handler_depth, loop_running, library_load_depth, library_programs, - sys_live, - store_conflicts, + store_conflict, log_budget, log_byte_budget, adhoc_tools, - instruction_budget, + cancel_hook, raw_model_ids, messages, state: Uninjected, } = self; SectionVm { lua, - bound_tools, + tool_set, bound_models, - tool_runtime, - model_runtime, + offers, + model_selection, jump_slot, adhoc_handler_depth, loop_running, library_load_depth, library_programs, - sys_live, - store_conflicts, + store_conflict, log_budget, log_byte_budget, adhoc_tools, - instruction_budget, + cancel_hook, raw_model_ids, messages, - state: Injected { access }, + state, } } } diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 9d34ceedd..8c4d3b4a4 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -13,11 +13,11 @@ use super::{SYS_LIVE_POISONED, SectionVm, pack_sequence}; use crate::coro::install_shim_prelude; use crate::engine_globals::{install_log, install_store_table}; use crate::error::{Error, Result}; -use crate::handles::{Library, LibraryKey, SectionText, resolve_section_target}; use crate::prose::ProseState; +use crate::section::{Library, LibraryKey, SectionText, resolve_section_target}; use crate::sys::seal_sys; #[cfg(test)] -use crate::sys::var_to_json; +use crate::var::var_to_json; impl SectionVm { /// Installs `log`, `store`, and `json` as persistent globals for the @@ -26,7 +26,7 @@ impl SectionVm { /// Called once after Engine injection. /// The closures capture owned strings, a clone of the emitter, and Arc /// clones of the log budget counters and the store view, so they stay - /// valid across every chunk this VM runs without a live [`mlua::Scope`]. + /// valid across every chunk this VM runs. /// The direct store closures record a claims-model conflict into the /// VM's slot for the executor to read with /// [`take_store_conflict`](Self::take_store_conflict). @@ -48,7 +48,7 @@ impl SectionVm { &self.state.access, emitter, section, - &self.store_conflicts, + &self.store_conflict, )?; crate::json::install(&self.lua) } @@ -61,7 +61,7 @@ impl SectionVm { pub fn take_store_conflict(&self) -> Option { // A panic elsewhere cannot leave the slot half-written, so a // poisoned lock still holds the conflict to hand over. - self.store_conflicts + self.store_conflict .lock() .unwrap_or_else(PoisonError::into_inner) .take() @@ -69,17 +69,19 @@ impl SectionVm { /// Shared store-conflict slot, for the poisoned-slot test. #[cfg(test)] - pub(crate) fn store_conflicts_handle(&self) -> Arc>> { - Arc::clone(&self.store_conflicts) + pub(crate) fn store_conflict_handle(&self) -> Arc>> { + Arc::clone(&self.store_conflict) } /// Installs `call`, `jump`, and the `prompt` reads (`prompt.section` /// and `prompt.list`) as persistent globals for the section's whole /// lifecycle. /// - /// Called once by `runvil-engine` after Engine injection. The + /// Test-only: production installs `jump` and the `prompt` reads through + /// [`install_scheduler_control_globals`](Self::install_scheduler_control_globals) + /// and `call` as a yield shim. The /// callbacks own their run context, so the closures stay valid across - /// every chunk this VM runs without a live [`mlua::Scope`]. The `jump` + /// every chunk this VM runs. The `jump` /// closure captures a clone of the VM's jump slot; the slot is reset /// before each chunk and read after it by the control-run path. The /// `call` closure snapshots this VM's `var` at call time (reading the @@ -108,8 +110,8 @@ impl SectionVm { self.install_prompt_table(&globals, read) } - /// Installs the scheduler-mode control surface: `jump` and the - /// `prompt` reads as Rust callbacks (none suspends). + /// Installs the control surface: `jump` and the `prompt` reads as Rust + /// callbacks (none suspends). /// /// The suspending calls (`models.infer`, `call`, `tasks.fanout`, /// `tools.call`, `tasks.*`) are the yield shims installed by @@ -127,11 +129,12 @@ impl SectionVm { self.install_prompt_table(&globals, read) } - /// Installs the coroutine yield shims (`models.infer`, `call`, - /// `tasks.fanout`, `tools.call`, the `tasks` namespace). - /// `max_tool_iterations` is the run's resolved round cap for the - /// `models.loop` shim a section install adds afterward; a VM that never - /// installs the loop shim passes any value. + /// Installs the coroutine yield shims (`models.infer`, `models.loop`, + /// `models.compact`, `call`, `tasks.fanout`, `tools.call`, the `tasks` + /// namespace) through `install_shim_prelude`, which also puts + /// `models.loop` and `models.compact` on the `models` table. + /// `max_tool_iterations` is the run's resolved round cap, which every + /// `models.loop` and model handle `loop` call on this VM spends. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the shim prelude cannot install. @@ -141,12 +144,11 @@ impl SectionVm { max_tool_iterations, &self.adhoc_handler_depth, &self.loop_running, - &self.instruction_budget, + &self.cancel_hook, ) } - /// Installs Runvil's `require` as a persistent global for the - /// section's whole lifecycle. + /// Installs Runvil's `require` as a persistent global. /// /// `resolver` finds a library from the heading as written. While a /// library loads, a `require` passes that library's key, so the @@ -190,7 +192,7 @@ impl SectionVm { section, &crate::require::VmShare { depth: &self.library_load_depth, - sys_live: &self.sys_live, + sys_live: &self.state.sys_live, programs: &self.library_programs, }, ) @@ -272,10 +274,11 @@ impl SectionVm { let sys_table = seal_sys(&self.lua, sys)?; globals.raw_set("sys", sys_table).map_err(Error::lua)?; let mut live = self + .state .sys_live .lock() - .map_err(|_| Error::Internal(SYS_LIVE_POISONED.into()))?; - *live = Some(sys.clone()); + .map_err(|_| Error::internal(SYS_LIVE_POISONED))?; + *live = sys.clone(); Ok(()) } @@ -289,12 +292,12 @@ impl SectionVm { /// the template is rejected as recursive. /// /// # Errors - /// Returns [`Error::Lua`] if the read cannot be built or the `_G` guard + /// Returns [`Error::LuaRuntime`] 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, { - crate::prose::install(&self.lua, &self.sys_live, render) + crate::prose::install(&self.lua, &self.state.sys_live, render) } } diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index ef846522a..4dacbbd53 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -2,15 +2,15 @@ //! resume to the classified end. use mlua::thread::ThreadStatus; -use mlua::{IntoLuaMulti, MultiValue, Thread, Value}; +use mlua::{MultiValue, Thread, Value}; use super::SectionVm; use crate::coro::{block_guard, take_failure}; use crate::error::{Error, Result}; use crate::error_value::ErrorKind; -use crate::handles::LuaBlockResult; use crate::program::LuaProgram; -use crate::protocol::{Answer, Request, Retained, YieldParse}; +use crate::protocol::{Answer, Request, Retained, YieldParse, render_envelope}; +use crate::section::LuaBlockResult; impl SectionVm { /// Takes any recorded jump target, propagating a poisoned jump-slot lock @@ -22,7 +22,7 @@ impl SectionVm { let mut slot = self .jump_slot .lock() - .map_err(|_| Error::Internal(JUMP_SLOT_POISONED.into()))?; + .map_err(|_| Error::internal(JUMP_SLOT_POISONED))?; Ok(slot.take()) } @@ -41,7 +41,7 @@ impl SectionVm { /// block, created from the block's loaded function on this persistent /// VM, so the VM's globals (`var` and the bare globals) roll forward /// across blocks. Instruction hooks are - /// per-coroutine in PUC Lua, so the VM's budget/cancellation hook is + /// per-coroutine in PUC Lua, so the VM's cancellation hook is /// installed on the fresh thread; the main-state hook from construction /// never fires inside a resumed coroutine. /// @@ -51,8 +51,9 @@ impl SectionVm { /// programs, and the scalar-return rule applies to the return values. /// A coroutine that yields suspends with the shim's request table; the /// driver resumes it with - /// [`resume_block_coro`](Self::resume_block_coro). No observation - /// events fire here; the driver owns the chunk observation boundaries. + /// [`resume_block_coro_answer`](Self::resume_block_coro_answer). No + /// observation events fire here; the driver owns the chunk observation + /// boundaries. /// /// The coroutine body is the shim's block guard, resumed first with /// the block function: the guard runs the block under `xpcall`, whose @@ -72,7 +73,7 @@ impl SectionVm { let function = program.load(&self.lua)?; let guard = block_guard(&self.lua)?; let thread = self.lua.create_thread(guard).map_err(Error::lua)?; - self.instruction_budget.install_on_thread(&thread)?; + self.cancel_hook.install_on_thread(&thread)?; let result = thread.resume::(function); self.step_block_coro::(program, thread, result, None) } @@ -81,11 +82,12 @@ impl SectionVm { /// /// # Errors /// Same contract as [`start_block_coro`](Self::start_block_coro). + #[cfg(any(test, feature = "test-support"))] pub fn resume_block_coro( &self, program: &LuaProgram, thread: &Thread, - args: impl IntoLuaMulti, + args: impl mlua::IntoLuaMulti, ) -> Result { let result = thread.resume::(args); self.step_block_coro::(program, thread.clone(), result, None) @@ -99,7 +101,7 @@ impl SectionVm { /// corrupted yield. Author code cannot reach /// `coroutine.yield` (the global is stripped at shim install), so this /// strict validation is defense in depth. A well-formed call whose - /// argument fails validation is [`YieldParse::Call`]: the error is + /// argument fails validation is [`YieldParse::Refused`]: the error is /// returned as the answer so the shim raises it at the call site. #[must_use] pub fn request_from_yield(&self, values: &MultiValue) -> YieldParse { @@ -130,12 +132,12 @@ impl SectionVm { &self, program: &LuaProgram, thread: &Thread, - answer: Answer, + answer: std::result::Result, E>, ) -> std::result::Result where E: crate::ErrorValue + From, { - let (envelope, retained) = answer.render(&self.lua).map_err(Error::lua)?; + let (envelope, retained) = render_envelope(&self.lua, answer).map_err(Error::lua)?; let result = thread.resume::(envelope); self.step_block_coro(program, thread.clone(), result, retained) } @@ -228,12 +230,12 @@ impl SectionVm { /// else maps through `LuaProgram::map_runtime_error_among` of `program` /// and of every library block `require` loaded into this VM. pub(crate) fn map_chunk_failure(&self, program: &LuaProgram, error: &mlua::Error) -> Error { - if self.instruction_budget.is_cancelled() { + if self.cancel_hook.is_cancelled() { return Error::Interrupted; } match self.library_programs.lock() { Ok(libraries) => program.map_runtime_error_among(error, &libraries), - Err(_) => Error::Internal("require's loaded library programs were poisoned".into()), + Err(_) => Error::internal("require's loaded library programs were poisoned"), } } } @@ -272,9 +274,9 @@ pub(crate) fn scalar_return(returned: MultiValue) -> Result> { /// One step of a Lua block running on a coroutine. /// /// Produced by [`SectionVm::start_block_coro`] and -/// [`SectionVm::resume_block_coro`]; consumed by the scheduler's driver, -/// which validates a [`Yielded`](CoroStep::Yielded) request, dispatches it, -/// and resumes the thread with the answer. +/// [`SectionVm::resume_block_coro_answer`]; consumed by the scheduler's +/// driver, which validates a [`Yielded`](CoroStep::Yielded) request, +/// dispatches it, and resumes the thread with the answer. #[derive(Debug)] pub enum CoroStep { /// The coroutine suspended on a shim yield; the yielded values are diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 790157d82..5b1c75447 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -2,7 +2,6 @@ //! counts, the frozen sets' entries, the section's offered tools and the //! catalog Packs they come from, and its effective model binding. -use std::borrow::Cow; use std::sync::{Arc, Mutex}; use mlua::{LuaSerdeExt, Value}; @@ -12,15 +11,13 @@ use runvil_types::tools::{ToolId, ToolInfo}; use serde_json::Value as Json; use super::{AdhocTools, SectionVm}; +use crate::bindings::{ToolBinding, ToolSet}; use crate::error::{Error, Result}; -use crate::handles::{ToolBinding, ToolSet}; -use crate::models::ModelRuntime; +use crate::models::ModelSelection; use crate::packs::is_adhoc; -use crate::scope::{ - OfferMode, OfferTarget, OfferedTool, RenderedTools, ToolCallCounts, ToolRuntime, -}; -use crate::sys::var_to_json; +use crate::scope::{OfferMode, OfferTarget, OfferedTool, Offers, RenderedTools, ToolCallCounts}; use crate::tools::install_tool_call_counts as install_tool_call_counts_impl; +use crate::var::var_to_json; impl SectionVm { /// Returns the current `var` table as JSON, read from the hidden data @@ -40,7 +37,8 @@ impl SectionVm { /// /// # Errors /// Returns [`Error::Lua`] when H1 left `argv` as a function, userdata, - /// or thread, or when its value cannot be represented as JSON. + /// or thread, and [`Error::LuaRuntime`] when its value cannot be + /// represented as JSON. pub fn argv_json(&self) -> Result> { let value: Value = self.lua.globals().get("argv").map_err(Error::lua)?; match value { @@ -74,16 +72,13 @@ impl SectionVm { /// key and lists the keys held, and `tools` holds only run catalog and /// adhoc tool ids. /// - /// The installation itself sits in the `tools` module; this method only - /// supplies the VM's own state. - /// /// Returns the `ToolCallCounts` handle so the scheduler can increment /// it. /// /// # Errors /// Returns [`Error::LuaRuntime`] when installing `tools.stats` fails. pub fn install_tool_call_counts(&self, ids: &[ToolId]) -> Result { - install_tool_call_counts_impl(&self.lua, &self.bound_tools, &self.adhoc_tools, ids) + install_tool_call_counts_impl(&self.lua, &self.tool_set, &self.adhoc_tools, ids) } /// Returns the binding the section's frozen sets hold under @@ -115,31 +110,25 @@ impl SectionVm { pub fn rendered_tools(&self) -> Option> { self.messages.rendered_tools() } - - /// How many of this VM's instruction-hook firings have counted. - #[cfg(test)] - pub(crate) fn hook_firings(&self) -> u64 { - self.instruction_budget.fired() - } } /// Reads the tools the section's static offers name without mutating the -/// tool runtime: each static offer in call order, a tool adding itself +/// offers: each static offer in call order, a tool adding itself /// and a Pack adding its tools in id order, each tool once, at its first /// place. Catalog ids resolve against the run catalog and adhoc ids /// against `adhoc`. /// -/// The section's first model call freezes its Static Set from these +/// The section's first loop round freezes its Static Set from these /// tools. An offered adhoc Pack expands to the adhoc tools defined when /// this function runs, so after the freeze the Engine reads the frozen /// sets instead. /// /// # Errors -/// Returns [`Error::Internal`] if the tool runtime's mutex or the adhoc +/// Returns [`Error::Internal`] if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. -pub fn current_tool_bindings( +pub fn current_static_set( set: &ToolSet, - runtime: &Mutex, + runtime: &Mutex, adhoc: &AdhocTools, ) -> Result> { let mut tools: Vec = Vec::new(); @@ -152,15 +141,15 @@ pub fn current_tool_bindings( } /// Reads the tools the section's dynamic offers name without mutating -/// the tool runtime: each tool once, in offer order, unless `static_tools` +/// the offers: each tool once, in offer order, unless `static_tools` /// holds its id. /// /// # Errors -/// Returns [`Error::Internal`] if the tool runtime's mutex or the adhoc +/// Returns [`Error::Internal`] if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. pub fn current_dynamic_set( set: &ToolSet, - runtime: &Mutex, + runtime: &Mutex, adhoc: &AdhocTools, static_tools: &[OfferedTool], ) -> Result> { @@ -178,16 +167,16 @@ pub fn current_dynamic_set( } /// Reads the catalog Packs the section's offers name without mutating -/// the tool runtime: each Pack once, in the order the section first +/// the offers: each Pack once, in the order the section first /// offered one of its tools, into either set, which is the order a /// section entry's render asks its Packs in. /// /// # Errors -/// Returns [`Error::Internal`] if the tool runtime's mutex or the adhoc +/// Returns [`Error::Internal`] if the offers' mutex or the adhoc /// tools are poisoned, or an offered id names no tool. pub fn current_catalog_packs( set: &ToolSet, - runtime: &Mutex, + runtime: &Mutex, adhoc: &AdhocTools, ) -> Result> { let mut packs: Vec = Vec::new(); @@ -208,13 +197,13 @@ pub fn current_catalog_packs( /// offer names. fn expanded( set: &ToolSet, - runtime: &Mutex, + runtime: &Mutex, adhoc: &AdhocTools, mode: Option, ) -> Result> { let runtime = runtime .lock() - .map_err(|_| Error::Internal("tool declaration runtime was poisoned".into()))?; + .map_err(|_| Error::internal("section offers lock was poisoned"))?; let mut tools = Vec::new(); let offers = runtime .offers @@ -228,9 +217,11 @@ fn expanded( } else { set.catalog().get(id).cloned().map(OfferedTool::Catalog) }; - tools.push(tool.ok_or_else(|| { - Error::Internal(Cow::Owned(format!("offered tool {id} names no tool"))) - })?); + tools.push( + tool.ok_or_else(|| { + Error::internal(format!("offered tool {id} names no tool")) + })?, + ); } OfferTarget::Pack(pack) if is_adhoc(pack) => { tools.extend(adhoc.bindings()?.into_iter().map(OfferedTool::Adhoc)); @@ -251,34 +242,32 @@ fn expanded( } /// Reads the section's effective model binding through the run's model view -/// without mutating the model runtime: the H2 `models.use` selection with +/// without mutating the model selection: the H2 `models.use` selection with /// its options applied, else the prompt-wide `models.default` baseline. /// /// # Errors -/// Returns [`Error::Internal`] if the model runtime's mutex or the shared +/// Returns [`Error::Internal`] if the model selection's mutex or the shared /// model set is poisoned, or the selected alias has no frozen binding. pub fn resolve_model_binding( bindings: &dyn ModelView, - runtime: &Mutex, + runtime: &Mutex, ) -> Result> { let selection = { let runtime = runtime .lock() - .map_err(|_| Error::Internal("model declaration runtime was poisoned".into()))?; + .map_err(|_| Error::internal("model selection lock was poisoned"))?; runtime .selection() .map(|(alias, options)| (alias.as_str().to_owned(), options)) }; let frozen = |alias: &str| -> Result { - bindings.binding(alias)?.ok_or_else(|| { - Error::Internal(Cow::Owned(format!( - "model alias {alias:?} has no frozen binding" - ))) - }) + bindings + .binding(alias)? + .ok_or_else(|| Error::internal(format!("model alias {alias:?} has no frozen binding"))) }; match selection { Some((alias, options)) => Ok(Some(options.apply(frozen(&alias)?))), - None => match bindings.default()? { + None => match bindings.default_alias()? { Some(alias) => Ok(Some(frozen(&alias)?)), None => Ok(None), }, diff --git a/crates/runvil-internal/lua/src/walk_budget.rs b/crates/runvil-internal/lua/src/walk_budget.rs index 599cd02ff..e1db75068 100644 --- a/crates/runvil-internal/lua/src/walk_budget.rs +++ b/crates/runvil-internal/lua/src/walk_budget.rs @@ -19,8 +19,7 @@ use crate::vm::DEFAULT_LUA_MEMORY_BYTES; const BYTES_PER_NODE: usize = 8; /// The most table levels `check_value` descends, the top-level table being -/// level 1. A table nested past it is not counted and converts as it does -/// today. +/// level 1. A table nested past it is not counted. const CHECK_DEPTH_LIMIT: usize = 200; /// The most keys one encode path may hold as Lua references at once: @@ -103,7 +102,7 @@ impl WalkBudget { } path.push(pointer); // An entry that cannot be read ends the walk of this table, - // and the conversion that follows reports it as it does today. + // and the conversion that follows reports it. for (key, value) in table.pairs::().map_while(Result::ok) { if let Value::String(name) = &key { self.charge_bytes(name.as_bytes().len())?; diff --git a/crates/runvil-internal/model-client/src/client/tests.rs b/crates/runvil-internal/model-client/src/client/tests.rs index bc3568ee8..4136dcd79 100644 --- a/crates/runvil-internal/model-client/src/client/tests.rs +++ b/crates/runvil-internal/model-client/src/client/tests.rs @@ -4,7 +4,7 @@ use runvil_types::metrics::{CallMetrics, Usage}; use serde_json::{Value, json}; use super::*; -use crate::detail::{message_from_validated_parts, tool_schema_new}; +use crate::detail::{message_from_validated_parts, tool_call_arguments, tool_schema_new}; use crate::model::CompletionErrorKind; use crate::test_support::{assert_json, pong}; @@ -86,11 +86,8 @@ fn a_role_parses_from_its_name_and_a_refusal_lists_the_accepted_roles() { fn tool_arguments_view_exposes_no_raw_value() { // The public arguments view surfaces typed accessors, never a // serde_json::Value. - let call = ToolCall { - id: "call_1".to_owned(), - name: "search".to_owned(), - arguments: json!({"query": "rust", "limit": 5}), - }; + let call = ToolCall::from_parts("call_1", "search", json!({"query": "rust", "limit": 5})) + .expect("a whole call"); let args = call.arguments(); assert!(!args.is_empty()); assert!(args.contains("query")); @@ -101,16 +98,40 @@ fn tool_arguments_view_exposes_no_raw_value() { assert_eq!(names, ["limit", "query"]); let json = args.to_json_string(); assert!(json.contains("\"query\":\"rust\""), "got {json}"); +} + +#[test] +fn an_arguments_view_of_an_empty_object_serializes_as_an_empty_object() { + // The storage is a map, so no payload can be null and no view can be + // null-backed. An empty object must still serialize to `{}` rather than + // to the empty string, which a tool would receive as unparseable + // arguments. + let call = ToolCall::from_parts("call_1", "noop", json!({})).expect("a whole call"); + let args = call.arguments(); + assert_eq!( + args.to_json_string(), + "{}", + "empty arguments are still a JSON object, never a blank string" + ); + assert!(args.is_empty(), "an object with no keys is empty"); + assert!(!args.contains("anything")); + assert_eq!(args.names().count(), 0); + assert_eq!( + args.names().size_hint(), + (0, Some(0)), + "the key iterator's hint matches the map it reads" + ); +} - // A null payload reads as empty. - let empty = ToolCall { - id: "c2".to_owned(), - name: "noop".to_owned(), - arguments: Value::Null, - }; - assert!(empty.arguments().is_empty()); - assert!(!empty.arguments().contains("anything")); - assert_eq!(empty.arguments().names().count(), 0); +#[test] +fn tool_call_arguments_hands_the_dispatcher_an_owned_value() { + // The dispatcher stores the arguments in a `ToolCallEvent`, whose field + // is already a `Value`, so the detail accessor returns an owned copy + // and every caller moves it instead of cloning it again. + let call = + ToolCall::from_parts("call_1", "search", json!({"query": "rust"})).expect("a whole call"); + let arguments: Value = tool_call_arguments(&call); + assert_eq!(arguments, json!({ "query": "rust" })); } #[test] diff --git a/crates/runvil-internal/model-client/src/client/wire-canned.rs b/crates/runvil-internal/model-client/src/client/wire-canned.rs index 0fd78b92d..8354bb949 100644 --- a/crates/runvil-internal/model-client/src/client/wire-canned.rs +++ b/crates/runvil-internal/model-client/src/client/wire-canned.rs @@ -14,7 +14,7 @@ use serde_json::Value; use super::{Completion, CompletionResult, RawExchange, ToolCall}; use crate::model::CompletionError; use crate::normalize::{ - check_call_arguments, check_call_id, check_call_name, check_unique_call_id, empty_reply_error, + check_call_id, check_call_name, check_unique_call_id, checked_call_arguments, empty_reply_error, }; impl Completion { @@ -140,11 +140,10 @@ impl ToolCall { let name = name.into(); check_call_id(&id)?; check_call_name(&name)?; - check_call_arguments(&arguments)?; Ok(ToolCall { id, name, - arguments, + arguments: checked_call_arguments(arguments)?, }) } } diff --git a/crates/runvil-internal/model-client/src/client/wire.rs b/crates/runvil-internal/model-client/src/client/wire.rs index a212ef67b..1b541d094 100644 --- a/crates/runvil-internal/model-client/src/client/wire.rs +++ b/crates/runvil-internal/model-client/src/client/wire.rs @@ -113,8 +113,10 @@ pub struct ToolSchema { } impl ToolSchema { - /// Returns the tool's name as sent to the model. It holds one or more - /// characters, all from `[A-Za-z0-9_.-]`. + /// Returns the tool's name as sent to the model. The + /// `crate::detail::tool_schema_new` builder is the only one, and it + /// accepts a leading ASCII letter followed by up to 63 letters, digits, + /// `_`, or `-`. #[must_use] pub fn name(&self) -> &str { &self.name @@ -171,13 +173,13 @@ pub enum ToolSchemaError { #[non_exhaustive] pub struct ToolCall { /// The id the model assigned to this call, echoed back with its result. - pub(super) id: String, + id: String, /// The name of the tool to invoke. - pub(super) name: String, + name: String, /// The parsed arguments for the call. The raw wire JSON stays /// crate-private: the caller inspects arguments through /// [`ToolCall::arguments`]. - pub(crate) arguments: Value, + pub(crate) arguments: serde_json::Map, } impl ToolCall { @@ -200,7 +202,7 @@ impl ToolCall { #[must_use] pub fn arguments(&self) -> ToolArguments<'_> { ToolArguments { - value: &self.arguments, + map: &self.arguments, } } } @@ -212,38 +214,35 @@ impl ToolCall { #[derive(Debug, Clone, Copy)] #[non_exhaustive] pub struct ToolArguments<'a> { - value: &'a Value, + map: &'a serde_json::Map, } impl ToolArguments<'_> { /// Returns the arguments object serialized as canonical JSON text. #[must_use] pub fn to_json_string(&self) -> String { - self.value.to_string() + // A map built from a `Value` tree always serializes, so the fallback + // arm is unreachable. `unwrap_or_default` is still wrong here: it + // would publish an empty arguments string, which a tool cannot parse. + serde_json::to_string(self.map).unwrap_or_else(|_| "{}".to_owned()) } /// Returns whether the arguments object is empty. #[must_use] pub fn is_empty(&self) -> bool { - match self.value { - Value::Null => true, - Value::Object(map) => map.is_empty(), - _ => false, - } + self.map.is_empty() } /// Returns whether the arguments object has a top-level key named `key`. #[must_use] pub fn contains(&self, key: &str) -> bool { - self.value - .as_object() - .is_some_and(|map| map.contains_key(key)) + self.map.contains_key(key) } /// Returns the top-level argument names of the arguments object. pub fn names(&self) -> ArgumentNames<'_> { ArgumentNames { - keys: self.value.as_object().map(serde_json::Map::keys), + keys: self.map.keys(), } } } @@ -254,19 +253,19 @@ impl ToolArguments<'_> { #[derive(Debug, Clone)] #[must_use = "an iterator does nothing until it is consumed"] pub struct ArgumentNames<'a> { - /// The object's keys, or `None` for a null payload. - keys: Option>, + /// The object's keys. + keys: serde_json::map::Keys<'a>, } impl<'a> Iterator for ArgumentNames<'a> { type Item = &'a str; fn next(&mut self) -> Option<&'a str> { - self.keys.as_mut()?.next().map(String::as_str) + self.keys.next().map(String::as_str) } fn size_hint(&self) -> (usize, Option) { - self.keys.as_ref().map_or((0, Some(0)), Iterator::size_hint) + self.keys.size_hint() } } @@ -351,7 +350,7 @@ pub struct Completion { /// One line per response metadata section that was present but /// malformed and degraded to `None`; empty for a well-formed body. The /// Engine reports each line as a `model_metadata_degraded` event. - pub(crate) metadata_diagnostics: Vec, + metadata_diagnostics: Vec, /// The request and response the broker attached for debug capture. raw: Option, } @@ -391,8 +390,8 @@ impl Completion { /// That is the name last set through /// [`with_model`](Completion::with_model), or else the name the /// completion was built with. A completion decoded from a response is - /// built with the model the response body named, or an empty name when - /// the body omits one. + /// labeled with the model the request body named, or, when the request + /// body named none, with the model the response body named. #[must_use] pub fn model(&self) -> &str { &self.model diff --git a/crates/runvil-internal/model-client/src/detail.rs b/crates/runvil-internal/model-client/src/detail.rs index 4018ad950..392b39aae 100644 --- a/crates/runvil-internal/model-client/src/detail.rs +++ b/crates/runvil-internal/model-client/src/detail.rs @@ -1,11 +1,10 @@ //! Operations on the wire types that only the Engine performs. //! //! The `runvil` facade never re-exports this module, so only Engine -//! crates reach it. Each function stands in for what would -//! otherwise be an inherent method or a public field on a facade-visible -//! type: building a message from pre-validated parts, reading the raw JSON -//! a message holds, building a tool schema, and reading a tool call's raw -//! arguments. +//! crates reach it. Each function reaches a field or a construction step +//! the facade-visible type keeps private. [`completion_into_result`] is the +//! one that splits a completion into the result the tool loop consumes and +//! the metadata the round reports. use serde_json::Value; @@ -93,9 +92,15 @@ pub fn tool_schema_new( /// Returns a tool call's parsed arguments as the raw wire JSON the /// dispatcher hands a tool. +/// +/// The call's arguments are stored as a map, and the dispatcher's +/// [`ToolCallEvent`] field is already a [`Value`], so the value is built +/// here and moved into the event rather than cloned at the call site. +/// +/// [`ToolCallEvent`]: runvil_types::metrics::ToolCallEvent #[must_use] -pub fn tool_call_arguments(call: &ToolCall) -> &Value { - &call.arguments +pub fn tool_call_arguments(call: &ToolCall) -> Value { + Value::Object(call.arguments.clone()) } /// Takes the text or tool-call outcome out of a completion. diff --git a/crates/runvil-internal/model-client/src/lib.rs b/crates/runvil-internal/model-client/src/lib.rs index d90846ce1..d8d10e93b 100644 --- a/crates/runvil-internal/model-client/src/lib.rs +++ b/crates/runvil-internal/model-client/src/lib.rs @@ -6,8 +6,10 @@ //! answer ([`client::Message`], [`client::ToolSchema`], //! [`client::Completion`]), with the validating constructors that run the //! neutral reply checks on every completion. [`model`] holds the catalog -//! and prompt-local binding vocabulary: the [`model::ModelCatalog`] the -//! caller supplies, the validated [`model::ModelId`] identity, and the +//! and prompt-local binding vocabulary: the +//! [`ModelCatalog`](runvil_types::models::ModelCatalog) the +//! caller supplies, the validated [`ModelId`](runvil_types::models::ModelId) +//! identity, and the //! [`model::ModelBinding`]/[`model::ModelSet`]/[`model::ModelView`] types //! model selections resolve and freeze through, with //! [`model::CompletionError`] as the failure a round reports. @@ -16,10 +18,12 @@ //! `LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) is //! canonical in `runvil-types`, and this crate uses it from there: //! [`client::Completion`] holds a round's metrics. The -//! model identity/catalog vocabulary ([`model::ModelId`], -//! [`model::ModelCatalog`], [`model::ModelInfo`], -//! [`model::ThinkingMode`]) is canonical there too and re-exported through -//! the `model` paths. +//! model identity/catalog vocabulary +//! ([`ModelId`](runvil_types::models::ModelId), +//! [`ModelCatalog`](runvil_types::models::ModelCatalog), +//! [`ModelInfo`](runvil_types::models::ModelInfo), +//! [`ThinkingMode`](runvil_types::models::ThinkingMode)) is canonical there +//! too, and `runvil-lua` reaches only the id through the `model` path. //! //! This crate contains no HTTP, no wire parsing, no prompt parser, no Lua //! runtime, and no executor. diff --git a/crates/runvil-internal/model-client/src/model.rs b/crates/runvil-internal/model-client/src/model.rs index 9bd49340f..76c4d3a66 100644 --- a/crates/runvil-internal/model-client/src/model.rs +++ b/crates/runvil-internal/model-client/src/model.rs @@ -1,12 +1,14 @@ //! Prompt-local model bindings: catalog, bind/use declarations, and invocation. //! -//! The caller supplies the [`ModelCatalog`]; building it happens outside -//! this crate. H1 `models.bind` resolves a description -//! against that catalog under hard constraints, freezes invocation -//! parameters, and stores the result in the Engine's run-scoped model -//! bindings. H2 `models.use` selects at most one binding per section; H1 -//! `models.default` supplies the prompt-wide default for sections that omit -//! `models.use`. Model-facing sections with neither binding fail with a +//! The caller supplies the +//! [`ModelCatalog`](runvil_types::models::ModelCatalog); building it happens +//! outside this crate. The prompt's frontmatter declares each model role, prepare +//! resolves every role against that catalog under the role's hard keywords +//! and context minimum, and the run builds its binding set from those +//! bindings before any Lua runs. H2 `models.use` selects at most one binding +//! per section; `models.default` supplies the prompt-wide default, +//! conventionally from H1, for sections that omit `models.use`. Model-facing +//! sections with neither binding fail with a //! model-binding failure surfaced through the run error the caller receives. mod error; @@ -17,12 +19,9 @@ pub use options::{ CompletionOptions, ModelBinding, ModelInvocation, ModelSet, ModelSetError, ModelView, Temperature, TemperatureError, }; -// The model identity/catalog vocabulary is canonical in -// `runvil-types` and re-exported here so existing -// `runvil_model_client::model::` paths keep resolving. -pub use runvil_types::models::{ - ModelCatalog, ModelCatalogError, ModelId, ModelIdError, ModelInfo, ThinkingMode, -}; +// `runvil-lua` names the model id through this path; the rest of the catalog +// vocabulary is imported from `runvil-types` directly. +pub use runvil_types::models::ModelId; #[cfg(test)] mod tests; diff --git a/crates/runvil-internal/model-client/src/model/error-tests.rs b/crates/runvil-internal/model-client/src/model/error-tests.rs index 8c81924a7..cd74d2ee9 100644 --- a/crates/runvil-internal/model-client/src/model/error-tests.rs +++ b/crates/runvil-internal/model-client/src/model/error-tests.rs @@ -240,3 +240,26 @@ fn a_specific_extends_the_phrase_after_a_colon_and_never_adds_detail() { assert_eq!(empty.finish_reason(), Some("stop")); assert!(!empty.is_retryable()); } + +#[test] +fn the_shared_empty_reply_owner_builds_the_phrase_and_the_reasoning_specific() { + // One owner for the empty-reply message: the bare phrase when the turn + // carried no reasoning, the phrase plus the reasoning-ignored specific + // when one was dropped, and the caller's finish reason either way. + let plain = CompletionError::empty_reply(false); + assert_eq!(plain.kind(), CompletionErrorKind::EmptyReply); + assert_eq!(plain.to_string(), CompletionErrorKind::EmptyReply.phrase()); + assert_eq!(plain.finish_reason(), None); + assert!(!plain.is_retryable()); + + let reasoned = CompletionError::empty_reply(true).with_finish_reason("stop"); + assert_eq!( + reasoned.to_string(), + format!( + "{}: reasoning content was present but ignored", + CompletionErrorKind::EmptyReply.phrase() + ) + ); + assert_eq!(reasoned.finish_reason(), Some("stop")); + assert_eq!(reasoned.detail(), None); +} diff --git a/crates/runvil-internal/model-client/src/model/error.rs b/crates/runvil-internal/model-client/src/model/error.rs index 98051718a..b3a932197 100644 --- a/crates/runvil-internal/model-client/src/model/error.rs +++ b/crates/runvil-internal/model-client/src/model/error.rs @@ -1,9 +1,20 @@ //! The model failure vocabulary: the closed set of kinds a broker maps its //! failures into, and the error that carries one. +//! +//! The message is the kind's fixed phrase. A failure built from an HTTP +//! status appends ` (status N)`, and a 401 or 403 is `Unavailable` with +//! `the model backend did not accept the credentials`. Only +//! `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts` +//! extend it with `: ` and text this repository's own code wrote. Provider +//! text never enters the message; it goes in `detail`. /// A type-erased owned error cause. type BoxedSource = Box; +/// The specific an empty reply adds to its phrase when the reader dropped a +/// reasoning side channel that it could not use as answer text. +const REASONING_IGNORED: &str = "reasoning content was present but ignored"; + /// The kind of failure a model round ended with. /// /// The kinds form a closed set, so a caller can branch on the kind alone. @@ -48,15 +59,7 @@ impl CompletionErrorKind { /// Returns the fixed phrase for this kind: one lowercase phrase written /// for a model to read. /// - /// The caller builds a [`CompletionError`] message from this phrase. For - /// a failure that came from an HTTP status, it appends ` (status N)`. A - /// 401 or 403 is `Unavailable`, and its message uses the phrase - /// `the model backend did not accept the credentials`. Only - /// `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts` - /// may extend the phrase with `: ` and specific text that the caller's - /// own code wrote. - /// Provider text never enters the message. It goes in the - /// [`detail`](CompletionError::detail). + /// The module page gives the message shapes a caller builds from it. #[must_use] pub fn phrase(self) -> &'static str { match self { @@ -132,18 +135,14 @@ impl CompletionErrorKind { /// detail is provider text that the caller bounded and control-escaped. It /// is an opt-in channel and never appears in `Display`. /// -/// The caller builds one with [`new`](CompletionError::new) or +/// The caller builds one with [`new`](CompletionError::new), +/// [`empty_reply`](CompletionError::empty_reply), or /// [`context_overflow`](CompletionError::context_overflow) and adds the -/// extras with the `with_` methods. +/// extras with the `with_` methods. `empty_reply` is the builder that +/// chooses between the bare phrase and the phrase extended with the +/// reasoning-ignored text. /// -/// The message is the kind's fixed phrase. For a failure that came from an -/// HTTP status, the caller appends ` (status N)`. A 401 or 403 is -/// `Unavailable`, and its message uses the phrase -/// `the model backend did not accept the credentials`. `MalformedResponse`, -/// `EmptyReply`, `Unavailable`, and `InvalidParts` may extend the message -/// with `: ` and specific text that the caller's own code wrote, such as -/// the byte limit that was hit. Provider text never enters the message. It goes in the -/// [`detail`](CompletionError::detail). +/// See the module page for the message shapes. #[derive(Debug)] #[non_exhaustive] pub struct CompletionError { @@ -159,15 +158,9 @@ pub struct CompletionError { impl CompletionError { /// Builds a failure of `kind` with `message` as its display text. /// - /// The caller uses the kind's fixed - /// [`phrase`](CompletionErrorKind::phrase) as the message. For a failure - /// that came from an HTTP status, it appends ` (status N)`. A 401 or 403 - /// is `Unavailable`, and its message uses the phrase - /// `the model backend did not accept the credentials`. For - /// `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts`, - /// the caller may extend the phrase with `: ` and specific text that its - /// own code wrote. - /// Provider text never enters the message. The caller passes it to + /// The caller passes the kind's fixed + /// [`phrase`](CompletionErrorKind::phrase); the extension rules live on + /// the module page. Provider text goes to /// [`with_detail`](CompletionError::with_detail). #[must_use] pub fn new(kind: CompletionErrorKind, message: impl Into) -> CompletionError { @@ -182,6 +175,24 @@ impl CompletionError { } } + /// Builds an [`EmptyReply`](CompletionErrorKind::EmptyReply) failure for a + /// turn that carried no text and no tool calls. + /// + /// The message is the kind's fixed phrase, extended with + /// `: reasoning content was present but ignored` when + /// `reasoning_present` says the turn's reasoning side channel was + /// dropped rather than used as answer text. A caller that knows the + /// backend's `finish_reason` adds it with + /// [`with_finish_reason`](CompletionError::with_finish_reason). + #[must_use] + pub fn empty_reply(reasoning_present: bool) -> CompletionError { + if reasoning_present { + CompletionError::specific(CompletionErrorKind::EmptyReply, REASONING_IGNORED) + } else { + CompletionError::phrased(CompletionErrorKind::EmptyReply) + } + } + /// Builds a [`ContextOverflow`](CompletionErrorKind::ContextOverflow) /// failure with the token counts the provider stated. Pass `None` for a /// count the provider omitted. diff --git a/crates/runvil-internal/model-client/src/model/options-tests.rs b/crates/runvil-internal/model-client/src/model/options-tests.rs index 89af0b1d0..701f94720 100644 --- a/crates/runvil-internal/model-client/src/model/options-tests.rs +++ b/crates/runvil-internal/model-client/src/model/options-tests.rs @@ -20,8 +20,8 @@ fn poisoned_set() -> Mutex { #[test] fn a_poisoned_model_set_reports_the_lock_error_from_every_view_method() { let set = poisoned_set(); - assert_eq!(set.bindings(), Err(ModelSetError)); - assert_eq!(ModelView::default(&set), Err(ModelSetError)); + assert!(set.lock().is_err(), "a poisoned set cannot be read"); + assert_eq!(ModelView::default_alias(&set), Err(ModelSetError)); assert_eq!(set.binding("writer"), Err(ModelSetError)); assert_eq!(ModelSetError.to_string(), "model set mutex was poisoned"); } @@ -29,8 +29,13 @@ fn a_poisoned_model_set_reports_the_lock_error_from_every_view_method() { #[test] fn a_healthy_model_set_answers_every_view_method() { let set = Mutex::new(ModelSet::default()); - assert_eq!(set.bindings(), Ok(Vec::new())); - assert_eq!(ModelView::default(&set), Ok(None)); + assert!( + set.lock() + .expect("a healthy set locks") + .bindings() + .is_empty() + ); + assert_eq!(ModelView::default_alias(&set), Ok(None)); assert_eq!(set.binding("writer"), Ok(None)); } @@ -94,6 +99,86 @@ fn a_binding_built_with_new_renders_a_never_thinking_info_from_its_own_parts() { ); } +#[test] +fn provider_prefers_the_catalog_and_falls_back_to_the_field() { + let info = ModelInfo::new( + ModelId::gateway("catalog-model").expect("a fixture model id"), + "The catalog's description", + window(65_536), + ThinkingMode::Switchable, + ) + .with_provider("acme"); + let from_catalog = ModelBinding::new( + "writer", + "The role's description", + info.id().clone(), + ModelInvocation::new(), + window(4096), + ) + .with_catalog_info(info.clone()); + assert_eq!( + from_catalog.provider(), + Some("acme"), + "a held catalog's provider answers even when with_provider was never called" + ); + let from_field = ModelBinding::new( + "writer", + "The role's description", + ModelId::gateway("field-model").expect("a fixture model id"), + ModelInvocation::new(), + window(4096), + ) + .with_provider("acme"); + assert_eq!( + from_field.provider(), + Some("acme"), + "a catalog-less binding still reports the provider its setter stored" + ); + let both = ModelBinding::new( + "writer", + "The role's description", + ModelId::gateway("both-model").expect("a fixture model id"), + ModelInvocation::new(), + window(4096), + ) + .with_provider("field-provider") + .with_catalog_info(info); + assert_eq!( + both.provider(), + Some("acme"), + "the catalog outranks the field when a binding holds both, the order the accessor's docs state" + ); +} + +#[test] +fn completion_options_reports_the_invocation_it_holds() { + let set = CompletionOptions::new("analyst") + .with_temperature(0.2) + .expect("0.2 is valid") + .with_thinking(true); + let invocation = set.invocation(); + assert_eq!( + invocation.temperature, + Some(Temperature::new(0.2).expect("0.2 is valid")), + "the held invocation carries the temperature the setter validated" + ); + assert_eq!( + invocation.thinking, + Some(true), + "the held invocation carries the thinking switch the setter stored" + ); + assert_eq!( + set.temperature(), + invocation.temperature, + "the getter and the held invocation cannot disagree on temperature" + ); + assert_eq!( + set.thinking(), + invocation.thinking, + "the getter and the held invocation cannot disagree on thinking" + ); +} + #[test] fn with_temperature_rejects_non_finite_and_out_of_range() { let base = || CompletionOptions::new("m"); @@ -118,7 +203,7 @@ fn with_temperature_rejects_non_finite_and_out_of_range() { base() .with_temperature(0.0) .expect("0.0 is valid") - .temperature + .temperature() .map(Temperature::get), Some(0.0) ); @@ -126,7 +211,7 @@ fn with_temperature_rejects_non_finite_and_out_of_range() { base() .with_temperature(TEMPERATURE_MAX) .expect("2.0 is valid") - .temperature + .temperature() .map(Temperature::get), Some(TEMPERATURE_MAX) ); @@ -134,7 +219,7 @@ fn with_temperature_rejects_non_finite_and_out_of_range() { base() .with_temperature(0.7) .expect("0.7 is valid") - .temperature + .temperature() .map(Temperature::get), Some(0.7) ); diff --git a/crates/runvil-internal/model-client/src/model/options.rs b/crates/runvil-internal/model-client/src/model/options.rs index afa2b8be9..23a38a727 100644 --- a/crates/runvil-internal/model-client/src/model/options.rs +++ b/crates/runvil-internal/model-client/src/model/options.rs @@ -42,14 +42,6 @@ impl Temperature { } } -impl TryFrom for Temperature { - type Error = TemperatureError; - - fn try_from(value: f64) -> std::result::Result { - Temperature::new(value) - } -} - /// The reason a sampling temperature was rejected. #[derive(Debug, Clone, PartialEq, thiserror::Error)] #[non_exhaustive] @@ -161,7 +153,8 @@ impl ModelBinding { } } - /// Sets the model's provider id. + /// Sets the provider id for a binding built without catalog info. A held + /// catalog's provider wins. #[must_use] pub fn with_provider(mut self, provider: impl Into) -> Self { self.provider = Some(provider.into()); @@ -227,10 +220,17 @@ impl ModelBinding { self.context } - /// Returns the provider id, or `None` when the catalog names none. + /// Returns the provider id, or `None` when neither a held catalog nor the + /// binding's own field names one. + /// + /// A held catalog's provider wins, because the catalog is the record the + /// binding was filled from. #[must_use] pub fn provider(&self) -> Option<&str> { - self.provider.as_deref() + self.catalog + .as_ref() + .and_then(ModelInfo::provider) + .or(self.provider.as_deref()) } /// Returns the model info a section entry's render receives for this @@ -249,8 +249,8 @@ impl ModelBinding { self.context, ThinkingMode::Never, ); - match &self.provider { - Some(provider) => info.with_provider(provider.clone()), + match self.provider() { + Some(provider) => info.with_provider(provider.to_owned()), None => info, } } @@ -259,12 +259,7 @@ impl ModelBinding { /// binding, from the model's name and the binding's request settings. #[must_use] pub fn completion_options(&self) -> CompletionOptions { - CompletionOptions { - model: self.id.name().to_owned(), - temperature: self.invocation.temperature, - max_tokens: self.invocation.max_tokens, - thinking: self.invocation.thinking, - } + CompletionOptions::new(self.id.name()).with_invocation(self.invocation.clone()) } } @@ -275,13 +270,10 @@ impl ModelBinding { #[non_exhaustive] pub struct CompletionOptions { /// The caller-facing model name sent on the wire. - pub(super) model: String, - /// Sampling temperature (a validated [`Temperature`]). - temperature: Option, - /// Maximum generation tokens (always non-zero). - max_tokens: Option, - /// When set, emits `chat_template_kwargs.enable_thinking`. - thinking: Option, + model: String, + /// Every request setting the options apply, held as one value so the + /// getters and the setters cannot disagree. + invocation: ModelInvocation, } impl CompletionOptions { @@ -291,9 +283,7 @@ impl CompletionOptions { pub fn new(model: impl Into) -> CompletionOptions { CompletionOptions { model: model.into(), - temperature: None, - max_tokens: None, - thinking: None, + invocation: ModelInvocation::new(), } } @@ -307,7 +297,7 @@ impl CompletionOptions { mut self, temperature: f64, ) -> std::result::Result { - self.temperature = Some(Temperature::new(temperature)?); + self.invocation.temperature = Some(Temperature::new(temperature)?); Ok(self) } @@ -316,14 +306,24 @@ impl CompletionOptions { /// The cap is a [`NonZeroU32`], so it always allows some output. #[must_use] pub fn with_max_tokens(mut self, max_tokens: NonZeroU32) -> CompletionOptions { - self.max_tokens = Some(max_tokens); + self.invocation.max_tokens = Some(max_tokens); self } /// Sets the thinking switch, sent as `chat_template_kwargs.enable_thinking`. #[must_use] pub fn with_thinking(mut self, thinking: bool) -> CompletionOptions { - self.thinking = Some(thinking); + self.invocation.thinking = Some(thinking); + self + } + + /// Replaces the request settings the options carry. + /// + /// A binding passes its own frozen [`ModelInvocation`] here, so the + /// options apply exactly the settings the binding holds. + #[must_use] + pub fn with_invocation(mut self, invocation: ModelInvocation) -> CompletionOptions { + self.invocation = invocation; self } @@ -344,27 +344,33 @@ impl CompletionOptions { &self.model } + /// Returns the request settings the options apply. + #[must_use] + pub fn invocation(&self) -> &ModelInvocation { + &self.invocation + } + /// Returns the sampling temperature, when one was set. #[must_use] pub fn temperature(&self) -> Option { - self.temperature + self.invocation.temperature } /// Returns the maximum number of tokens to generate, when a cap was set. #[must_use] pub fn max_tokens(&self) -> Option { - self.max_tokens + self.invocation.max_tokens } /// Returns the `enable_thinking` switch, when it was set. #[must_use] pub fn thinking(&self) -> Option { - self.thinking + self.invocation.thinking } } -/// The run's model set: the prompt-level bindings produced by live H1 -/// execution plus the prompt-wide `default` alias. +/// The run's model set: the prompt-level bindings built at run construction +/// from prepare's role bindings plus the prompt-wide `default` alias. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ModelSet { /// The bindings in declaration order. @@ -376,13 +382,6 @@ pub struct ModelSet { } impl ModelSet { - /// Reassembles a set from owned snapshots of its two parts (the - /// [`ModelView`] read pair). - #[must_use] - pub fn from_parts(bindings: Vec, default: Option) -> Self { - Self { bindings, default } - } - /// Returns bindings in declaration order. #[must_use] pub fn bindings(&self) -> &[ModelBinding] { @@ -426,23 +425,18 @@ pub struct ModelSetError; /// The read-only view over the run's [`ModelSet`]. /// -/// The run context shares the set as `Arc`; the live H1 pass -/// writes through its own concrete `Arc>` handle, and once -/// that VM is dropped no write handle remains. The trait is read-only, so -/// post-H1 frozenness is structural. Every method locks briefly and returns -/// an owned snapshot: a mutex guard cannot outlive the call. +/// The run holds one `ModelSet` behind a mutex and hands every section VM a +/// handle to it. This trait exposes reads only, so a reader cannot change the +/// set through it. The one write any Engine path performs is `models.default`, +/// which sets the default once and refuses a second, different label. Every +/// method locks briefly and returns an owned snapshot: a mutex guard cannot +/// outlive the call. pub trait ModelView: Send + Sync { - /// Returns an owned snapshot of the bindings in declaration order. - /// - /// # Errors - /// Returns [`ModelSetError`] if the set's mutex is poisoned. - fn bindings(&self) -> Result, ModelSetError>; - /// Returns the prompt-wide default alias set by `models.default`, if any. /// /// # Errors /// Returns [`ModelSetError`] if the set's mutex is poisoned. - fn default(&self) -> Result, ModelSetError>; + fn default_alias(&self) -> Result, ModelSetError>; /// Returns an owned clone of the binding for `alias`, if it was /// declared. @@ -460,11 +454,7 @@ fn lock_model_set( } impl ModelView for Mutex { - fn bindings(&self) -> Result, ModelSetError> { - Ok(lock_model_set(self)?.bindings.clone()) - } - - fn default(&self) -> Result, ModelSetError> { + fn default_alias(&self) -> Result, ModelSetError> { Ok(lock_model_set(self)?.default_alias().map(str::to_owned)) } diff --git a/crates/runvil-internal/model-client/src/model/tests.rs b/crates/runvil-internal/model-client/src/model/tests.rs index a74b8b863..3f745f4a4 100644 --- a/crates/runvil-internal/model-client/src/model/tests.rs +++ b/crates/runvil-internal/model-client/src/model/tests.rs @@ -75,7 +75,7 @@ fn binding_construction_is_atomic_with_context() { ctx(64_000), ); let opts = binding.completion_options(); - assert_eq!(opts.model, "remote"); + assert_eq!(opts.model(), "remote"); assert_eq!(binding.context().get(), 64_000); } @@ -96,13 +96,11 @@ fn a_model_set_grows_by_push_and_takes_its_default_alias_by_setter() { "push keeps declaration order" ); assert_eq!(set.default_alias(), Some("writer")); - assert_eq!( - set, - ModelSet::from_parts( - vec![binding("writer"), binding("critic")], - Some("writer".to_owned()) - ) - ); + let mut expected = ModelSet::default(); + expected.push(binding("writer")); + expected.push(binding("critic")); + expected.set_default_alias("writer".to_owned()); + assert_eq!(set, expected); } #[test] diff --git a/crates/runvil-internal/model-client/src/normalize-tests.rs b/crates/runvil-internal/model-client/src/normalize-tests.rs index 750fff42a..47c93c188 100644 --- a/crates/runvil-internal/model-client/src/normalize-tests.rs +++ b/crates/runvil-internal/model-client/src/normalize-tests.rs @@ -5,6 +5,7 @@ use serde_json::json; use super::*; use crate::client::{Completion, CompletionResult, ToolCall}; +use crate::model::CompletionErrorKind; const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls"; const INVALID_PARTS_PHRASE: &str = "the completion was built from invalid parts"; @@ -50,6 +51,21 @@ fn non_object_arguments_are_rejected() { } } +#[test] +fn checked_call_arguments_hands_back_the_object_it_proved() { + // A validating constructor cannot `unwrap` the map out of the value, + // because the workspace denies it, so the check returns what it proved. + // A rewrite that returned `()` or re-wrapped the payload as a `Value` + // would force a second parse at every construction site. + let arguments = json!({ "query": "rust", "limit": 5 }); + let map = checked_call_arguments(arguments.clone()).expect("an object is accepted"); + assert_eq!( + Value::Object(map), + arguments, + "the returned map holds exactly the entries the caller passed" + ); +} + #[test] fn duplicate_tool_call_ids_are_rejected() { let call = |name: &str| ToolCall::from_parts("dup", name, json!({})).expect("a whole call"); diff --git a/crates/runvil-internal/model-client/src/normalize.rs b/crates/runvil-internal/model-client/src/normalize.rs index dad5b9178..b26219941 100644 --- a/crates/runvil-internal/model-client/src/normalize.rs +++ b/crates/runvil-internal/model-client/src/normalize.rs @@ -12,11 +12,11 @@ use std::collections::HashSet; use serde_json::Value; use crate::Result; -use crate::model::{CompletionError, CompletionErrorKind}; +use crate::model::CompletionError; /// The empty-reply error for a turn with no product. pub(crate) fn empty_reply_error() -> CompletionError { - CompletionError::phrased(CompletionErrorKind::EmptyReply) + CompletionError::empty_reply(false) } /// Refuses a blank tool-call id. @@ -36,14 +36,19 @@ pub(crate) fn check_call_name(name: &str) -> Result<()> { } /// Refuses tool-call arguments that are not a JSON object, the shape tools -/// accept. -pub(crate) fn check_call_arguments(arguments: &Value) -> Result<()> { - if !arguments.is_object() { - return Err(CompletionError::invalid_parts( +/// accept, and hands back the object it proved. +/// +/// The check cannot `unwrap` the map out of the value, because the +/// workspace denies `unwrap_used` and `expect_used`, so it matches the +/// object arm and returns the map. A caller that needs the map then holds +/// it without a second parse. +pub(crate) fn checked_call_arguments(arguments: Value) -> Result> { + match arguments { + Value::Object(map) => Ok(map), + _ => Err(CompletionError::invalid_parts( "tool call arguments were not a JSON object", - )); + )), } - Ok(()) } /// Records `id` in `seen`, refusing an id another call in the turn has. diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 438bb4f8f..eddb3870c 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -39,8 +39,6 @@ #[non_exhaustive] pub struct runvil::ParseError { /* private fields */ } #[non_exhaustive] pub struct runvil::Prompt { /* private fields */ } #[non_exhaustive] pub struct runvil::Requirements { .. } -#[non_exhaustive] pub struct runvil::RunContext { /* private fields */ } -#[non_exhaustive] pub struct runvil::RunError { /* private fields */ } #[non_exhaustive] pub struct runvil::RunLimits { .. } #[non_exhaustive] pub struct runvil::SourceLocation { .. } #[non_exhaustive] pub struct runvil::UnavailablePack { .. } @@ -64,7 +62,6 @@ #[non_exhaustive] pub struct runvil::model::CompletionError { /* private fields */ } #[non_exhaustive] pub struct runvil::model::CompletionOptions { /* private fields */ } #[non_exhaustive] pub struct runvil::model::Message { /* private fields */ } -#[non_exhaustive] pub struct runvil::model::ModelBindings { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ModelCatalog { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ModelId { /* private fields */ } #[non_exhaustive] pub struct runvil::model::ModelIdError { /* private fields */ } @@ -351,8 +348,6 @@ derives runvil::vfs::Writability: Clone, Copy, Debug, Eq, Hash, PartialEq impl core::convert::From<&runvil::model::Completion> for runvil::effect::ChatAnswerRecord impl core::convert::From for runvil::ids::TaskId impl core::convert::From for runvil::vfs::VfsPathBuf -impl core::convert::TryFrom for runvil::model::Temperature -impl core::convert::TryFrom for runvil::model::Temperature { type Error = runvil::model::TemperatureError } impl core::error::Error for runvil::ParseError impl core::error::Error for runvil::RunError impl core::error::Error for runvil::ids::ParseIdError @@ -571,6 +566,7 @@ pub fn runvil::model::Completion::with_raw(self, raw: runvil::model::RawExchange pub fn runvil::model::Completion::with_reasoning_content(self, reasoning_content: impl core::convert::Into) -> runvil::model::Completion pub fn runvil::model::CompletionError::context_overflow(prompt_tokens: core::option::Option, window: core::option::Option, message: impl core::convert::Into) -> runvil::model::CompletionError pub fn runvil::model::CompletionError::detail(&self) -> core::option::Option<&str> +pub fn runvil::model::CompletionError::empty_reply(reasoning_present: bool) -> runvil::model::CompletionError pub fn runvil::model::CompletionError::finish_reason(&self) -> core::option::Option<&str> pub fn runvil::model::CompletionError::is_retryable(&self) -> bool pub fn runvil::model::CompletionError::kind(&self) -> runvil::model::CompletionErrorKind @@ -582,11 +578,13 @@ pub fn runvil::model::CompletionError::with_finish_reason(self, reason: impl cor pub fn runvil::model::CompletionError::with_source(self, source: impl core::convert::Into>) -> runvil::model::CompletionError pub fn runvil::model::CompletionErrorKind::phrase(self) -> &'static str pub fn runvil::model::CompletionErrorKind::tag(self) -> &'static str +pub fn runvil::model::CompletionOptions::invocation(&self) -> &runvil::model::ModelInvocation pub fn runvil::model::CompletionOptions::max_tokens(&self) -> core::option::Option pub fn runvil::model::CompletionOptions::model(&self) -> &str pub fn runvil::model::CompletionOptions::new(model: impl core::convert::Into) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::temperature(&self) -> core::option::Option pub fn runvil::model::CompletionOptions::thinking(&self) -> core::option::Option +pub fn runvil::model::CompletionOptions::with_invocation(self, invocation: runvil::model::ModelInvocation) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::with_max_tokens(self, max_tokens: core::num::nonzero::NonZeroU32) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::with_model(self, model: impl core::convert::Into) -> runvil::model::CompletionOptions pub fn runvil::model::CompletionOptions::with_temperature(self, temperature: f64) -> core::result::Result @@ -871,11 +869,9 @@ pub runvil::effect::ChatAnswerRecord::model: alloc::string::String pub runvil::effect::ChatAnswerRecord::reply: core::option::Option pub runvil::effect::ChatAnswerRecord::tool_calls: alloc::vec::Vec pub runvil::effect::Effect::Chat { .. } -pub runvil::effect::Effect::Chat::after: core::option::Option +pub runvil::effect::Effect::Chat::after: core::option::Option<(runvil::ids::RoundId, u64)> pub runvil::effect::Effect::Chat::binding: runvil::model::ModelBinding -pub runvil::effect::Effect::Chat::keep: u64 pub runvil::effect::Effect::Chat::messages: alloc::vec::Vec -pub runvil::effect::Effect::Chat::options: runvil::model::CompletionOptions pub runvil::effect::Effect::Chat::round: runvil::effect::Round pub runvil::effect::Effect::Chat::tools: alloc::vec::Vec pub runvil::effect::Effect::Render { .. } @@ -1497,6 +1493,8 @@ pub runvil::vfs::VfsOutcome::Unit pub runvil::vfs::Writability::ReadOnly pub runvil::vfs::Writability::ReadWrite pub struct runvil::Run { /* private fields */ } +pub struct runvil::RunContext { /* private fields */ } +pub struct runvil::RunError { /* private fields */ } pub struct runvil::RunSeed(/* private fields */) pub struct runvil::cancel::CancelHandle { /* private fields */ } pub struct runvil::cancel::Cancelled { /* private fields */ } @@ -1507,6 +1505,7 @@ pub struct runvil::ids::RoundId(/* private fields */) pub struct runvil::ids::TaskId(/* private fields */) pub struct runvil::model::ArgumentNames<'a> { /* private fields */ } pub struct runvil::model::ModelBinding { /* private fields */ } +pub struct runvil::model::ModelBindings { /* private fields */ } pub struct runvil::model::ParseRoleError { /* private fields */ } pub struct runvil::model::Temperature(/* private fields */) pub struct runvil::packs::GlobalName { /* private fields */ } diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index 5b0cc643b..54eeee66f 100644 --- a/crates/workshop/server/Cargo.toml +++ b/crates/workshop/server/Cargo.toml @@ -26,6 +26,8 @@ plugin-web.workspace = true # The MCP client Pack, installed once over every server of the # configured `mcp.json`. plugin-mcp.workspace = true +# The file-tool Pack every agent run's Host installs: `runvil/std`. +plugin-std.workspace = true # The Engine's public API: the events the agent socket frames as # transcript entries and the status reporter derives status from, and # the prompt parser behind the `/prompts/contract` route. diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index f3b52467d..c88e66bf5 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -139,11 +139,11 @@ fn read_mcp_servers(setting: Option<&Path>) -> Value { /// A Host over the Host-wide `services` after the server calls the /// Plugins of the Packs agents may declare: `web`, `user-input`, so they -/// can ask the operator, and `mcp`, with the `servers` object as its -/// configuration. Building the Host builds those Packs and the built-in -/// `subagents`. The ask tool is the fixed `user-input/ask` of the -/// `runvil/user-input` Pack, so its vendor is `runvil`, and `None` only if -/// the user-input Plugin was refused. +/// can ask the operator, `mcp`, with the `servers` object as its +/// configuration, and `std`, the file tools. Building the Host builds +/// those Packs and the built-in `subagents`. The ask tool is the fixed +/// `user-input/ask` of the `runvil/user-input` Pack, so its vendor is +/// `runvil`, and `None` only if the user-input Plugin was refused. fn with_plugins(services: HostServices, servers: Value) -> (HostContext, Option) { let mut packs = PackRegistry::new(); if let Err(error) = plugin_web::install(&mut packs, Value::Null) { @@ -166,6 +166,9 @@ fn with_plugins(services: HostServices, servers: Value) -> (HostContext, Option< if let Err(error) = plugin_mcp::install(&mut packs, Value::Object(mcp)) { tracing::warn!(%error, "the mcp Plugin inserted no Pack"); } + if let Err(error) = plugin_std::install(&mut packs, Value::Null) { + tracing::warn!(%error, "the std Plugin inserted no Pack"); + } (HostContext::new(packs, services), ask) } diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index 98fad159e..ecf67f776 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -112,7 +112,7 @@ async fn every_run_holds_the_packs_the_three_plugins_add_and_the_builtin() { let lists = "---\nname: lists\ndescription: reads its Packs\nrunvil: 0\n---\n\n\ # Lists\n\n## Only\n\n```lua\n\ local names = {}\n\ - for _, id in ipairs({ 'web', 'user-input', 'subagents', 'mcp' }) do\n\ + for _, id in ipairs({ 'web', 'user-input', 'subagents', 'mcp', 'std' }) do\n\ names[#names + 1] = tools.packs.get(id).name\n\ end\n\ return table.concat(names, ',')\n```\n"; @@ -120,7 +120,7 @@ async fn every_run_holds_the_packs_the_three_plugins_add_and_the_builtin() { assert_eq!( run_prompt(&Arc::new(host), lists.to_owned()).await, Some(RunOutcome::Completed { - final_text: "web,user-input,subagents,mcp".to_owned(), + final_text: "web,user-input,subagents,mcp,std".to_owned(), }), "a run that declares nothing holds every runvil Pack the server built" ); diff --git a/crates/workshop/ui/test/docs-claims.mjs b/crates/workshop/ui/test/docs-claims.mjs index a0bb20e7c..af6f2a81c 100644 --- a/crates/workshop/ui/test/docs-claims.mjs +++ b/crates/workshop/ui/test/docs-claims.mjs @@ -168,6 +168,7 @@ const PLUGIN_DOC_DIRS = [ "crates/plugin-web", "crates/plugin-user-input", "crates/plugin-mcp", + "crates/plugin-std", ]; test("the root AGENTS.md and the Pack-facing crates' comments never describe Pack activation", async () => { diff --git a/vibe/2026-10-27-2-plugin-std-file-tools.md b/vibe/2026-10-27-2-plugin-std-file-tools.md new file mode 100644 index 000000000..fe4ec18b4 --- /dev/null +++ b/vibe/2026-10-27-2-plugin-std-file-tools.md @@ -0,0 +1,290 @@ +--- +name: The std Pack, Cursor's six file tools +overview: "`crates/plugin-std` exists and passes every gate: the `runvil/std` Pack offers `Read`, `Write`, `StrReplace`, `Delete`, `Glob`, and `Grep` under Cursor's names and Cursor's own definitions, validates every call against the stored schema, and answers a not-implemented stub until real file logic lands. This plan records what shipped and specifies the remaining work: fix the one schema that drifted, then replace each stub with a real operation over the call's VFS access." +todos: + - id: name + content: "Confirm the crate name and the two name layers: crate `plugin-std`, Pack id `std`, Lua ids `std/read` and so on, model-facing wire names `Read`, `Write`, `StrReplace`, `Delete`, `Glob`, `Grep`" + status: completed + - id: template + content: "Read `plugin-web` end to end as the template for `install`, `construct`, the tool list, and the test layout" + status: completed + - id: schemas + content: "Extract Cursor's six definitions into `crates/plugin-std/schemas/*.json`, embedded at build time, with `schemas/EDITS.md` recording every line changed from Cursor's text" + status: completed + - id: crate + content: "Build the crate: the Pack, the argument check against the stored schema, the not-implemented stub, and the three unit test files" + status: completed + - id: wire + content: "Install the Pack in Workshop's agent Host through `with_plugins` in `crates/workshop/server/src/agents.rs`" + status: completed + - id: grep-schema-fix + content: "Settle whether Cursor's Grep really lacks `context` and `-n`, as the bundle report says and as this run's own tool context contradicts, then correct `schemas/grep.json` and the two defaults, with a test that pins the exact parameter set" + status: pending + - id: read-real + content: "Replace the Read stub: 1-indexed offset, negative offset from the end, limit, the 100,000-character cap with its exact error, six-wide right-aligned line numbers, and the not-shown markers" + status: pending + - id: write-real + content: "Replace the Write stub: one `Access::write`, parent creation through `Access::mkdir`, and the exact `Wrote contents to ` success text" + status: pending + - id: replace-real + content: "Replace the StrReplace stub, reusing `Access::str_replace` where its single-occurrence semantics match and adding the trimmed-line fallback it lacks" + status: pending + - id: delete-real + content: "Replace the Delete stub with `Access::remove`, mapping `Ok(false)` to the not-found result and refusing a directory" + status: pending + - id: glob-real + content: "Replace the Glob stub with `Access::glob`, after confirming recursion, case sensitivity, and dotfile behavior against Cursor's ripgrep walk" + status: pending + - id: grep-real + content: "Replace the Grep stub. Decide first whether the VFS gains a search capability or the Pack walks with `Access::glob` and reads with `Access::read_string`, because `Access` has no grep" + status: pending + - id: approval + content: "Map `VfsError::ApprovalRequired` to a model-readable refusal, and later wire it to real user approval" + status: pending + - id: shell + content: "Decide the shell separately, most likely a Pack over bashkit rather than a real process" + status: pending + - id: listdir + content: "Decide ListDir: Cursor gates it behind `enableDeprecatedLsTool` defaulting to false and is replacing it with a proto-only V2, so the plan defers it" + status: pending +isProject: false +--- + +# The std Pack: Cursor's six file tools in Runvil + + + +## Product Requirements + +Runvil agents need the file tools every coding agent has. This plan adds them as one more Plugin crate beside `plugin-mcp`, `plugin-user-input`, and `plugin-web`, and it deliberately copies Cursor's surface rather than inventing one, because a model already trained on Cursor's tools then needs no learning at all. + +- Problem and users: + - Runvil has web search, a fetch tool, an ask tool, and MCP, but no way for a model to touch a file. Every Host that wants a coding agent must build that itself. + - The users are prompt authors writing agents for Workshop, and the Hosts that run those prompts. + - The two constraints that shape the design are the VFS and the mode policy, both of which already exist. File access must go through `runvil_vfs` so a run's mode governs it, and the model-facing names must match Cursor's so a model needs no new instructions. +- Goals: + - One Pack, `runvil/std`, offering six tools under Cursor's own names and definitions. + - Every call validated against the stored definition, so a bad call fails as an invalid argument rather than reaching the operation. + - Every file operation performed through the call's VFS `Access`, so Ask and Plan mode gate it with no work in the Pack. + - A first stage that proves the crate, the definitions, the ids, the wire names, and the Lua path before any file logic exists. +- Non-goals: + - Shell. An in-process shell over the same filesystem is a separate Pack, so a run that wants one declares it. + - Images and PDFs. `Read` is text only. + - User approval for mutations. The VFS already answers `VfsError::ApprovalRequired`; wiring it to an operator flow is later work. + - A subagent or task tool, a notebook editor, a linter reader, and Cursor's `AwaitShell`, `ReadLints`, and `EditNotebook`. This crate is the file tools and nothing else. + - Any change to `crates/runvil/public-api.txt`. The crate is a Plugin, not part of the Engine's facade. +- Success criteria: + - `cargo nextest run -p plugin-std` passes, and every tool's test covers both the success path and each kind of argument failure. + - A model that knows Cursor's file tools can call every one of these by the same name with the same arguments, and gets a plain answer rather than a schema refusal. + - Every filesystem operation reaches files through `Access`, so a run in `Mode::Ask` cannot mutate anything and a run in `Mode::Plan` mutates only markdown paths. + - The whole workspace suite, both clippy runs, `cargo fmt --all --check`, both rustdoc gates, `cargo test -p build-xtask`, and `node --test crates/workshop/ui/test/docs-claims.mjs` pass. +- Constraints: + - Plugin crates may depend only on `runvil-plugin`, `shared-*` crates, and outside libraries. `cargo test -p build-xtask` enforces it. + - The crate names no async runtime. The Harness polls every effect on one task, so a call must not block while polled. + - Every Rust file stays at or under 500 lines, enforced by `build-ceiling` from the crate's own `build.rs`. + - Crate docs carry an `## Invariants` section, and the wording scan reads them. + - Error messages are written for a model to read: concise, factual, and naming the parameter and the value. +- Open questions: + - Whether Cursor's Grep really has no `context` and no `-n`. The bundle report says it has neither; this run's own tool context shows both. One of the two is stale, and the answer decides `schemas/grep.json`. + - Whether the VFS learns to search. `Access` has no grep, so `Grep` is either a Pack-level walk or a new VFS capability with its own claim. + - Whether `StrReplace` owns its matching or calls `Access::str_replace` and layers the fallback above it. Mixing the two puts the semantics in two places. + +## Functional Specification + +A prompt declares `std` in its frontmatter and its sections gain six tools. Lua calls them by id; the model calls them by Cursor's name. In this build every well-formed call succeeds with a text saying the implementation is missing, and only a malformed call fails. + +- Actors and workflows: + - A prompt declares the Pack with `uses: [std]`, which makes it required rather than merely present. + - Lua calls `tools.call("std/read", { path = "/notes.md" })`, or offers the Pack with `tools.offer("std")` or a single tool with `tools.offer("std/read")`. + - The model then calls the tool by its wire name, `Read`, and the Harness routes the call back to `std/read`. + - A Host that never installs the Pack leaves `std` unresolved, and a prompt declaring it is refused before the run starts, naming the Pack. +- Inputs and outputs: + - The six ids and their wire names are fixed: `std/read` is `Read`, `std/write` is `Write`, `std/replace` is `StrReplace`, `std/rm` is `Delete`, `std/glob` is `Glob`, `std/grep` is `Grep`. + - Each tool's description and parameter schema come from its JSON file, embedded with `include_str!` and parsed once into a `LazyLock`. + - A well-formed call answers this text, trusted, with the tool's wire name in both places: + + `The tool is not implemented in this build. The implementation is missing and nothing in this session will change that. Calling again is safe, but it will return this same result every time, whatever the arguments. Carry on without it.` + + - A call naming a tool the Pack does not offer answers `std has no tool named `. +- States and validation: + - The Pack takes no configuration. `construct` accepts `null` or `{}` and refuses every other value with `std takes no configuration`. + - The argument check honors only the keywords these definitions use: `type`, `properties`, `required`, `enum`, and `items`. A schema keyword it does not know is not a reason to refuse a call, because the tool's own operation decides what it can do. + - A `null` argument list reads as an empty object, so a tool taking no parameters is callable with no arguments. + - A parameter the schema does not declare passes the check, so the operation sees it and refuses it itself if it must. +- Errors and recovery: + - A missing required parameter is `InvalidArguments` with the text ``std_: `` is required, and the call left it out``. + - A required parameter passed as `null` says `` ` ` is required, and the call passed null``. + - A wrong type says `` `` must be a JSON string, and the call passed 2``. + - A value outside an enum says `` `` must be one of "content", "files_with_matches", "count", and the call passed "lines"``. + - Arguments that are not an object say `the arguments must be a JSON object`. + - A definition file that fails to parse fails `construct` with a backend error naming the tool, so a broken edit surfaces at Host build time rather than as a call failure. +- Security and privacy behavior: + - A successful stub answer is trusted, because it is first-party text that embeds no attacker-influenced data. + - Real operation output will be untrusted wherever it embeds file content. `Read`, `Glob`, `Grep`, and `StrReplace`'s context all carry data the model did not produce, so each marks its output accordingly when the stubs are replaced. + - Argument validation happens before any operation, so a malformed call cannot reach the filesystem at all. +- Acceptance criteria: + - Every one of the six tools appears in `Pack::tools()` under the Pack id `construct` receives, with its wire name set. + - A well-formed call of each tool answers the not-implemented text naming that tool, as a successful `ToolOutput`. + - Each kind of malformed call fails with `InvalidArguments` and a message naming the parameter. + - `install` inserts exactly one Pack, `runvil/std`, with the config under the `std` key, and `PackRegistry` refuses a second insert. + + + + +## Technical Design + +The crate sits beside the other three Plugins and reuses their shape exactly: a `PackInfo` constant, an `install` that inserts it, and a `construct` that builds one shared object holding the tool list. Everything novel is in how the definitions are stored and checked. + +- Architecture: + - `src/lib.rs` holds the crate docs, the module wiring, the Lua-id-to-wire-name table, and the `## Invariants` section the wording scan requires. + - `src/spec.rs` embeds the six JSON files with `include_str!`, parses them once into a `LazyLock, ToolError>>`, and hands out a `&'static [Definition]`. A parse failure is a `ToolError` naming the tool, so it surfaces at `construct` rather than at a call. + - `src/check.rs` is the argument check, `check(tool, schema, args)`. It walks `required`, then `properties`, then `enum`, then `type`, then `items`, and returns the first refusal naming the parameter. + - `src/pack.rs` holds the `PackInfo`, `install`, `construct`, the `Std` object, the `Pack` impl, and the `NOT_IMPLEMENTED` constant. + - Nothing in the crate names a runtime, a client, or a service. `construct` reads no Host-wide service and the Pack declares no `needs`. +- Modules and interfaces: + - `Definition` is a segment, a wire name, a description, and a parameters value. The JSON file's own shape is a private `File` struct of the same three fields without the segment. The segment comes from the file name in the `SOURCES` table rather than from the JSON, so a file cannot disagree with its own name. + - `construct(name, config, services)` refuses any configuration but null or an empty object, then builds one `ToolInfo` per definition from the id, the description, and the parameters, with the wire name set. The id is the Pack id `construct` receives, a slash, and the segment, so it always sits under the Pack as the Plugin contract requires. + - `Pack::tools` clones the stored list. `Pack::call` resolves the tool by the last id segment, looks its definition up by that segment, runs the check, and answers the stub text as trusted output. + - The definition lookup is a free function rather than a method, because it reads only the static tables and the compiler refuses an unused receiver. +- File and public API changes: + - The crate is new, so `crates/plugin-std` joins the workspace's member glob and the root `Cargo.toml` gains one entry under `[workspace.dependencies]`. `crates/runvil/public-api.txt` is unchanged, because a Plugin is not part of the Engine's facade. + - `crates/workshop/server/Cargo.toml` gains `plugin-std.workspace = true`, and `with_plugins` in `crates/workshop/server/src/agents.rs` installs the Pack beside the other three. + - `crates/workshop/ui/test/docs-claims.mjs` gains `crates/plugin-std` to its Plugin doc list, so the scan reads the crate's comments for the Pack-activation ban and the defined terms. + - `Cargo.lock` gains the crate and its `serde` edge. +- Data, persistence, failure, security, and privacy constraints: + - The definitions are checked in as JSON rather than written in Rust, so the stored text is auditable against Cursor's own and a diff shows exactly which words changed. `schemas/EDITS.md` records each edit as old line then new line. + - The snapshot date is 2026-10-27. Nothing regenerates the files; following a Cursor update is a deliberate re-snapshot with its own commit. + - No new dependencies beyond `serde` and `serde_json`, both already in the workspace. No unsafe code, no filesystem access of the crate's own, no build tooling. + - The real operations, when they land, go through `ToolContext::access()`, which lends an `Access` rooted at `/`. That is what puts every write under the Mode policy: the access gates every mutation before it performs one and answers an approval-required error under `Mode::Ask`, and a refusal for a non-markdown path under `Mode::Plan`. The Pack's only job is to translate that error for the model. + +### The VFS surface the real operations use + +`Access` in `crates/runvil-internal/vfs/src/handle/operations.rs` already provides most of what the six tools need. This table is the starting point for the real-operation work. + +- `read` and `read_string` serve `Read`, `Grep`, and `StrReplace`. +- `write` serves `Write`, and `mkdir` gives it the parent creation it needs. +- `str_replace` serves `StrReplace` for the single-occurrence case. +- `remove` serves `Delete`, and it answers `Ok(false)` for an absent path rather than an error. +- `glob` serves `Glob` and gives `Grep` its candidate list. +- `list` and `stat` serve `Access`'s directory and metadata needs. + +Three facts follow from that table: + +- `Access::str_replace` already rejects an empty old string and already requires exactly one occurrence, so only Cursor's trimmed-line fallback and `replace_all` are missing above it. +- `Access::remove` answering `Ok(false)` for an absent path is what `Delete` maps to its not-found result. +- `Access` has no search method at all. `Grep` is the one tool with no direct mapping, which is why its backend decision gates the work. + + + + +## Testing Plan + +The tests that shipped cover the crate bottom-up: the definitions, the argument check, the Pack, and the whole path through a Host-shaped call. The remaining work has its own tests per tool. + +- Unit: + - `src/spec-tests.rs` pins the six segments in order and the six wire names, checks that every definition carries a description and an object schema with at least one property, checks that every required parameter is declared and described, and refuses a definition that still names behavior this build lacks. + - `src/check-tests.rs` drives the check with a hand-written schema: a matching call passes, a missing required parameter fails naming it, a required parameter passed as null fails, a wrong type fails naming the parameter and the value, non-object arguments fail, a value outside an enum fails listing the allowed values, an integer schema accepts a whole number and refuses a fraction, an array schema checks every element, and an undeclared parameter passes through. + - `src/pack-tests.rs` pins the `PackInfo`: the FQN, no prelude, no per-run needs, and the stub text's exact clauses, including that it names the tool twice and starts by saying what is missing. +- Integration and end-to-end: + - `tests/it/tools.rs` goes through `install`, `construct`, and `TestCall`. It checks that `install` inserts one Pack with the config under the `std` key and that a second insert is refused, that `construct` names six tools under whatever Pack id it receives and refuses any configuration but null or an empty object, that every tool carries Cursor's wire name, that no tool survives a stop or reports structured output, that every tool answers the stub text naming itself, and that each kind of bad call fails with the parameter named. +- Regression, security, and performance: + - The stub text test asserts the whole sentence, so a future edit that softens "nothing in this session will change that" or drops the "safe to call again" clause fails. + - The definition test asserts that no description mentions PDFs, the Shell tool, or the Cursor workspace root, so an edit that reintroduces a promise this build cannot keep fails. +- Exit criteria, every command below passes: + - `cargo nextest run --locked -p plugin-std --all-features`. + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. + - Both clippy runs with `CARGO_BUILD_WARNINGS=deny`, and `cargo check -p gateway --no-default-features`. + - `cargo fmt --all --check`. + - `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`. + - `cargo test -p build-xtask` and `node --test crates/workshop/ui/test/docs-claims.mjs`. + + + + +## Decision Record + +- Decisions: + - Name it `std`, not `core`. Three subagents surveyed Zed, Pi, Goose, Codex, OpenCode, Gemini CLI, Cline, OpenHands, Claude Code, Continue, Roo Code, LangChain, and a set of blog posts. Nobody has a standard name for this bundle; the commonest is a plain `tools` directory, and the only distinctive one is Goose's `developer`. `std` reads as "the standard tools", sits well beside `plugin-mcp` and `plugin-web`, and avoids confusion with the Engine and Harness crates. Product owner's choice: "std yes". + - Two name layers, because Runvil has two audiences. Lua calls tools by id and models call them by wire name, and the two do not have to agree. The Lua ids stay short and prefixed (`std/rm`), and the wire names copy Cursor exactly (`Delete`), so a model that knows Cursor's tools needs no new instructions. + - Copy Cursor's definitions into checked-in JSON rather than writing descriptions by hand. Rationale: the model was trained against that exact text, and a stored copy can be diffed against Cursor's. + - Return the stub as a successful `ToolOutput`, never a `ToolError`. Rationale: a valid call must succeed so that success and failure are both exercised end to end, which could not happen if every call failed. Product owner: "I want it to be a success, so that we can test what happens with success versus failure." + - The stub names itself twice and says a repeat call is safe but identical. Rationale: a model that reads "not implemented" may retry, and it should learn from the text that retrying is pointless rather than conclude its arguments were wrong. Product owner: "point out it is safe to call again, but produces identical result." + - Edit Cursor's text where it promises behavior this build does not have, and record each edit. Rationale: a description that promises PDFs or points at a `Shell` tool we do not offer invites calls that will fail. Product owner: "yes edit those lines, we dont have pdf." + - Gate mutations with the VFS Mode policy rather than a Host service the tools check. Rationale: every operation already passes through the access, so a tool cannot forget the check and a future tool cannot bypass it, and the CLI case needs nothing because a Host that wants Agent mode simply never hands out a handle. +- Rejected alternatives: + - A Host-wide service the file tools consult to decide whether they may mutate. Rejected: it relies on each tool remembering to ask, and it duplicates a policy the VFS already enforces. + - Marking mounts read-only instead. `RealBackend` has a static `Writability::ReadOnly`, but it is a mount-time property, not a per-run setting, so it cannot express Ask mode flipping mid-run. + - `core` as the crate name. Rejected: it suggests the minimal no-allocation subset in Rust, and it reads as Engine or Harness rather than as a tool bundle. + - Exact Cursor text with no edits. Rejected by the product owner, because several lines describe behavior this Pack does not have. + - Keeping `StrReplace`'s matching entirely in the VFS, or entirely in the Pack. Still open; neither is chosen yet. +- Assumptions, risks, and notes: + - Cursor's `Grep` in this run's tool context has `context` and `-n`, and the reverse-engineered report says it has neither. The plan assumes the report is right about the shipped tool and this context is a variant, but that is unverified, so the schema fix waits on an answer. + - Cursor's `ListDir` is gated behind `enableDeprecatedLsTool` defaulting to false, and its replacement exists only as proto (`ListDirV2Params`, `ListDirV2Stream`, and a server config field `listDirV2PredefinedIgnoreGlobs`). There is no model-facing definition to copy, so this plan leaves it out. + - Cursor's `Glob`, `Grep`, and `ListDir` respect `.gitignore` and Cursor's own `.cursorignore`. The VFS has no ignore-file concept, so a Runvil listing or search sees `target/` and everything else. That divergence is unaddressed and will show up when the real operations land. + +### Deferred and Out of Scope + +- Deferred: `ListDir`, until Cursor ships its replacement and there is a definition worth copying. +- Deferred: user approval for mutations, which needs an operator flow the VFS's approval-required error can feed. +- Deferred: ignore-file handling in the VFS, if the divergence turns out to matter in practice. +- Out of scope: the shell, which is a separate Pack, most likely over bashkit. +- Out of scope: any change to the Engine, the Harness, or the facade. This is a Plugin crate and its tests. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (the workspace default member; plain `cargo build` builds only it). This crate: `cargo build -p plugin-std`. +- Focused test command pattern: `cargo nextest run --locked -p plugin-std --all-features`; a single test: add its name as a filter. Structural checks: `cargo test -p build-xtask`. Wording scan: `node --test crates/workshop/ui/test/docs-claims.mjs`. +- Component test command pattern: `cargo nextest run --locked -p --all-features`; drop `--all-features` for `workshop`, `workshop-server`, and `workshop-server-api`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. +- Linter command: `CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` and `CARGO_BUILD_WARNINGS=deny cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`; headless shape: `cargo check -p gateway --no-default-features`. +- Formatter check command: `cargo fmt --all --check`. +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`. +- Test placement and naming conventions: unit tests sit beside their module as a `-tests.rs` sibling wired by `#[cfg(test)] #[path = "-tests.rs"] mod tests;`, each opening with a `//!` line naming what it covers. Integration tests compile as one binary per crate under `tests/it/` for the Plugin crates, with `main.rs` plus one module per topic. +- Directory map: `crates/` holds the four Plugin crates at its top level (`plugin-mcp`, `plugin-std`, `plugin-user-input`, `plugin-web`) beside `runvil-plugin`, the contract they implement. The VFS is private to the runvil family at `crates/runvil-internal/vfs`. Workshop's Host is `crates/workshop/server`. +- Component boundaries: `plugin-*` crates depend only on `runvil-plugin`, `shared-*` crates, and outside libraries, and `cargo test -p build-xtask` enforces it. Only `workshop-server` and `harness-gateway-client` may name a Plugin crate among the Engine, gateway, and Harness crates. The VFS crate is std-only with no normal or build dependency. +- Conventions summary: Rust 2024, workspace version 0.3.0, BSL-1.0; workspace lints inherited (pedantic denied, `unwrap_used` and `expect_used` denied outside tests, `unsafe_code` denied); every Rust file at most 500 lines, enforced by the crate's own `build.rs` including the `build-ceiling` source; every source file opens with a `//!` doc; crate docs carry `## Invariants`; kebab-case sibling files `foo-bar.rs` wired by `#[path]` until a group reaches three files; capitalized defined terms (Engine, Harness, Host, Plugin) checked by `docs-claims.mjs`; model-readable error messages naming required versus actual; no doctests anywhere, enforced structurally. + + + + +## Execution Instructions + +The five shipped stages were done in one pass. The remaining work is one schema fix followed by six tools, each of which can land alone. + + +Already done, recorded for the record: the crate exists with the Pack, the six definitions, the argument check, the stubs, the three unit test files, the integration test, the workspace wiring, and the workshop-server install. Its tests and every workspace gate passed. + + + +Settle the Grep schema question, then fix `schemas/grep.json`. Compare this run's tool context, which has `context` and `-n`, against the report's claim that Cursor's Grep has neither. Whichever answer comes back, correct the file and add a test to `spec-tests.rs` that asserts the `Grep` parameter set exactly, so it cannot drift again. Note the answer in the plan's Decision Record rather than leaving the question standing. + + + +`Read`, because it is pure formatting over one `Access::read_string` and it establishes the line-number formatter the other tools reuse. Implement the 1-indexed offset, the negative offset counting from the end, `limit`, the 100,000-character cap as an error rather than a truncation, the six-wide right-aligned line numbers, and the above/below not-shown markers. Flip the tool's entry in `tests/it/tools.rs` from the stub text to real behavior in the same change. + + + +`Write` and `Delete` together, since both are one VFS call plus a message. `Write` is `Access::write` after `Access::mkdir` for the parent, and it answers `Wrote contents to ` with the argument as given. `Delete` is `Access::remove`, mapping `Ok(false)` to the not-found result and refusing a directory. Each gets its own commit. + + + +`StrReplace`, after deciding whether it owns its matching or layers the trimmed-line fallback above `Access::str_replace`. Either way the requirement is that untouched lines keep their exact bytes, because rewriting the whole normalized file reindents every line. Implement `replace_all`, and keep the failure strings exact. + + + +`Glob`, once its case sensitivity, recursion, and dotfile behavior are confirmed against Cursor's ripgrep walk. Adjust in the Pack rather than changing shared VFS behavior for one tool, and note any divergence in the Decision Record. + + + +`Grep`, last, because it is the largest and because its backend decision comes first. Either the VFS gains a search capability with its own claim, which is better for large trees, or the Pack walks with `Access::glob` and reads with `Access::read_string`, which is less infrastructure. Record the choice and its consequence for memory. + + + +After the six tools are real, wire `VfsError::ApprovalRequired` to a model-readable refusal, so a run in `Mode::Ask` that tries to write gets an answer explaining that approval is required rather than a raw VFS error. Real approval is later work and needs an operator flow. + + + diff --git a/vibe/2026-10-29-1-lua-slop-cleanup.md b/vibe/2026-10-29-1-lua-slop-cleanup.md new file mode 100644 index 000000000..c835c43d7 --- /dev/null +++ b/vibe/2026-10-29-1-lua-slop-cleanup.md @@ -0,0 +1,844 @@ +# Vibelight: runvil-internal/lua + + + +## Product Requirements + +### Scope + +- **Crate:** `runvil-lua` at `crates/runvil-internal/lua`, outline 430 KB, 9,256 lines +- **Size:** 135 source files, 28,852 lines of Rust +- **Outlines:** current at 2026-10-27 05:09 when the analysis read them +- **Context:** 48 other outlines, not analyzed +- **Excluded:** 7 crates, each build tooling or a test fixture +- **Doc roots:** + - the operator's own root: none stated, so the repository's own were used + - `crates/runvil-internal/lua/README.md` and `AGENTS.md` + - the repository's `README.md` and `AGENTS.md` + - `../runvil-docs/src/language/`, 17 chapters, the guide that documents this crate +- **Limits:** + - the outline holds tests, benches, and examples, so reachability is settled by search + - one vibelight warning: 99 items written more than one way, in Spellings + - `vibe/` is not a doc root: 264 dated records, too stale to settle anything + - `runvil-design/design/` is provisional by its own record, so it is not a doc root + - the guide goes stale too, so source settles every disagreement, not the doc + - a doc claim is drift only when the code contradicts it today, not when it is merely old + - four dispatch aborts did not stop their agents, so two wrote each of three files + - `findings-5.md` holds only the last drift writer; its two earlier drafts were lost + - line numbers were read at `7a5341bba`; HEAD has since advanced to `4eb444630` + - the engine moved in 61 files after that baseline, so every engine line citation has drifted + - the lua crate changed once: `NonEmpty::new` became `pub` in `1dbdbf70d`, which no item touches + - the outline is stale against HEAD, so a fresh `vibelight` run is needed before another scan + +- Stats: 121 raised and 9 added in verification, 15 rejected, 11 duplicates and 6 same-cause findings merged, 98 kept +- Stats: of the 98 kept, 86 cheap and 12 substantial, and both operator decisions answered in favor +- Goal: one crate that names each fact once, in the file that owns it, with no doc contradicting its own body +- Goal: delete the instruction-quota machinery the guide already says does not exist +- Goal: close the dispatch reporting path so a public entry point cannot skip its event +- Non-goal: no author-visible behavior change: every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace +- Non-goal: no new dependency, no new crate, no ratchet on line counts or test counts +- Success: the AGENTS.md Verification gates pass: the full suite, clippy with `CARGO_BUILD_WARNINGS=deny`, `cargo fmt --all --check`, the docs build with `RUSTDOCFLAGS="-D warnings"`, and the wording scan +- Success: a fresh plain `cargo vibelight` run at the repository root shows each renamed and deleted declaration + +## Functional Specification + +### Debt Inventory + +**State and invariants** + +- State (substantial), `Request::ToolCall` pairs two `Option`s whose third state exists only for a test hook, `src/protocol/request.rs:139` (S2-23) +- State (substantial), `TaskStatus.ok` restates what its phase already decides for every phase but `Done`, `src/protocol/answer.rs:218` (S2-12) +- State (substantial), every `Answer` variant wraps its own `Result` while the failure side is identical for all, `src/protocol/answer.rs:246` (S2-11) +- State (substantial), `increment` errors on an unseeded id, so every dispatch site must seed first and the arm is unreachable, `src/scope.rs:59` (S3-23) +- State (substantial), the compacting flag and the held snapshot are hand-paired across five sites, one arm unreachable, `src/models_loop/machine.rs:74` (S4-12) +- State (substantial), `attach_rendered_tools` returns nothing, so its engine caller re-reads a slot it just filled, `src/messages.rs:237` (S4-9) +- State (substantial), `sys_live` is an `Option` set before `injected()` returns, so every `None` arm is dead, `src/vm.rs:146` (S1-6) +- State (substantial), `prepare_dispatch` accepts `None` publicly and then fires no `ToolResult` at all, `src/dispatch.rs:119` (V3-3) +- State (substantial), `ModelReport` carries its turn in a field named `script`, typed `ScriptReport`, `src/dispatch.rs:46` (S6-17) + +**Vestiges** + +- Vestiges (substantial), the instruction quota cannot trip because `HOOK_BUDGET` is `u64::MAX`, and `InstructionBudget` is named for a budget it no longer spends, `src/hardening.rs:21` (S1-2) +- Vestiges (substantial), the `model_tool_call` stash installs into every production VM for a test-only hook, `src/coro.rs:294` (S1-4) +- Vestiges, `#[non_exhaustive]` on `LuaProgram` restricts nothing its private fields do not, `src/program.rs:58` (S1-17) +- Vestiges, `resume_block_coro` and `inject_values` have only test and bench callers, `src/vm/run.rs:84` (S1-14) +- Vestiges, eleven sites in nine files present a "scheduler mode" and a "direct chunk path" as production modes, `src/lib.rs:32` (S1-15) +- Vestiges, `Compactor` derives `Default` whose only use is one test, `src/compactors.rs:104` (S4-15) +- Vestiges, `UsageAnchor::tokens` is a production accessor only tests read, `src/compactors.rs:204` (S4-17) +- Vestiges, the `error` module doc names a "core" crate that does not exist, `src/error.rs:5` (S2-1) +- Vestiges, `SharedSource` and `Error::shared` cite a "resolver decision" cache nothing has, `src/error.rs:27` (S2-2) +- Vestiges, `ToolCallOutcome` docs predate structured and adhoc outputs, `src/protocol/answer.rs:44` (S2-13) +- Vestiges, `ToolBinding.id` is documented as "the selected stable live identity" with no selection, `src/handles.rs:45` (S3-7) + +**Duplication and reuse** + +- Duplication, `tools.stats` and `tools.packs` hand-build one walkable read-only proxy twice, `src/tools/stats.rs:255` (S3-24) +- Duplication, the author refusal helper, `Error::Lua` as external, is defined in two modules, `src/json.rs:89` (S4-27) +- Duplication, `read_range` and `read_range_numbered` are one-line wrappers of one function, `src/engine_globals.rs:213` (S4-5) +- Duplication, `rendered()` duplicates `rendered_tools()` over one field, `src/messages.rs:216` (S4-10) +- Duplication, `install_prose` is `replace_prose` without its result, `src/globals.rs:116` (S4-7) +- Duplication, `LuaModelHandle::label` duplicates `name`, both reading `binding.alias()`, `src/models-userdata.rs:43` (S3-14) +- Duplication, the log length refusal hardcodes the limit its own constant holds, `src/engine_globals.rs:62` (V4-1) +- Duplication, `error::lua_quota` is a module of three strings for one `Display` match, `src/error.rs:212` (S2-5) +- Duplication, the store error field list is written out in two docs that already differ, `src/error-value-store.rs:6` (S2-9) +- Duplication, `direct_yield`, `raw_field`, and `json_field` build errors their callers discard, `src/protocol/parse.rs:46` (S2-17) +- Reuse, `parse::store` frames author-argument errors by hand instead of through `call_error`, `src/protocol/parse/store.rs:33` (V2-2) +- Duplication, the `tools` and `models` installers restate their module docs' shim note, and the `models` copy lost `models.compact`, `src/models.rs:244` (S3-17, S6-7) + +**Naming** + +- Naming, the offers type is `ToolRuntime`, its locks say "declaration runtime", its field doc says "tool-addition runtime", and `ModelRuntime` holds only a selection, `src/scope.rs:141` (S3-12, S6-5, S1-12) +- Naming, `bound_tools` names the unbound `ToolSet` at eleven sites, `src/vm.rs:123` (S3-8) +- Naming, `ToolCallCounts` is keyed by tool id but its API calls the key a name, `src/scope.rs:29` (S6-3) +- Naming, `tool_name` returns a tool id, `src/tools/decode.rs:22` (S3-9) +- Naming, `current_tool_bindings` returns `OfferedTool`s that are not bindings, `src/vm/state.rs:140` (S1-16) +- Naming, `store_conflicts` is documented "The slot" and read by a singular accessor, `src/vm.rs:151` (S1-11) +- Naming, `globals` and `messages` each define a function named `install_messages`, `src/globals.rs:145` (S4-8) +- Naming, `YieldParse::Call` and `FieldFailure::Call` reuse the name of the `call` request, `src/protocol/parse.rs:180` (S2-16) +- Naming, `prelude` calls the Pack's id its "local name" in one sentence and its id in the next, `src/prelude.rs:4` (S1-21) +- Naming, `Encoder.function` holds `to_json`'s message prefix, not a function, `src/json/encode.rs:162` (S4-26) +- Naming, `compile_internal` says "the coroutine shim prelude" for six different chunks, `src/program.rs:132` (S1-19) +- Naming, the snapshot module says "rendered tool list" and "send state" for the last send and the frozen sets, `src/messages-snapshot.rs:5` (S4-11) + +**Placement and visibility** + +- Placement (substantial), `install_section_loop_shim` exists to be called right after `install_coro_shims`, for agent VMs that no longer exist, `src/coro.rs:347` (S1-5) +- Placement, the store operation bodies and read helpers live outside the store module, `src/engine_globals.rs:24` (S4-6) +- Placement, `handles` holds no handle and its doc omits most of its contents, `src/handles.rs:1` (S3-6) +- Placement, `mod order` sits under `collection`, a fanout detail, though three modules use it, `src/collection.rs:16` (S4-19) +- Placement, `sys.rs` holds nine `var` items beside the one `sys` item, `src/sys.rs:17` (S1-23) +- Placement, record validation lives in the chat request parser, which never calls it, `src/protocol/parse/chat.rs:67` (S2-20) +- Visibility, `ToolRuntime.offers` is `pub`, so outside code can break "each held once", `src/scope.rs:143` (S3-13) + +**Doc comments, by file** + +- Doc comments, `src/error.rs:70` names a stale span list and repeats the module doc (S2-3) +- Doc comments, `src/error.rs:30` lists the shared-source callers and omits the `require` loader (V1-1) +- Doc comments, `src/error-value.rs:277` says `Raised` holds strings, though fields also hold integers (S2-7) +- Doc comments, `src/error-value.rs:51` and `:74` scope two error kinds narrower than what maps to them (S2-6) +- Doc comments, `src/protocol.rs:4` lists suspending calls and omits most task operations (S2-10) +- Doc comments, `src/protocol/parse.rs:166` states the `resolve_section_target` rule in six docs (S2-18) +- Doc comments, `src/protocol/request.rs:34` restates the handle, empty-list, and adhoc rules (S2-19) +- Doc comments, `src/protocol/request.rs:201` says every store operation takes the yield path (S2-24) +- Doc comments, `src/protocol/request.rs:230` says arguments are checked at parse, then describes a run-time check (S2-25) +- Doc comments, `src/protocol/record.rs:12` promises data-URI images, but any URL string parses (S2-22) +- Doc comments, `src/protocol/record.rs:94` says `tool_calls` is assistant-only, but any role may hold it (S2-21) +- Doc comments, `src/protocol/answer.rs:143` says an abandoned task is delivered as `cancelled`; it is never delivered (V2-1) +- Doc comments, `src/dispatch.rs:108` says "the loop" reports a model-issued result; `prepare_model_dispatch` does (S3-3) +- Doc comments, `src/dispatch.rs:26` names a `tool_call_id` field `ScriptReport` does not hold (S3-4) +- Doc comments, `src/dispatch.rs:5` states the no-call rule four times in one file (S3-5) +- Doc comments, `src/models.rs:39` calls the documented raw-model-id fallback "the hack" (S3-16) +- Doc comments, `src/models-userdata.rs:14` repeats its module doc and one boundary rule three times (S3-15) +- Doc comments, `src/models_loop.rs:110` cites a plan section and quotes a closure body that is not the code (S3-18) +- Doc comments, `src/tools.rs:5` restates the wire-name fallback `wire_name_of` owns (S3-19) +- Doc comments, `src/tools/decode.rs:74` claims its walk is the crate's only unsorted one (S3-11) +- Doc comments, `src/engine_globals.rs:1` lists `ui` as installed into every section VM (S4-2) +- Doc comments, `src/engine_globals.rs:302` narrates the other store path that `install_store_table` owns (S4-3) +- Doc comments, `src/engine_globals.rs:85` plus six siblings narrate closure mechanics and `mlua::Scope` (S4-4) +- Doc comments, `src/engine_globals-store.rs:1` still names the "shared library" the crate replaced with `require` (S4-1) +- Doc comments, `src/projection.rs:260` promises a concatenation its `unreachable!` arm cannot return (S4-13) +- Doc comments, `src/projection.rs:30` implies a per-model projection that does not exist (S4-14) +- Doc comments, `src/compactors.rs:12` states each policy's rule five times in one module (S4-16) +- Doc comments, `src/collection-order.rs:106` says refused keys have no position; two kinds do (S4-20) +- Doc comments, `src/collection.rs:97` omits `null`, which `render_item` renders as compact JSON (S4-23) +- Doc comments, `src/collection-order.rs:6` omits `json.encode` from the order's sharers (S4-18) +- Doc comments, `src/iteration.rs:27` and `:66` call the trailing segment non-scalar; it holds scalar keys too (S4-24, V4-2) +- Doc comments, `src/iteration.rs:255` repeats the module doc's resume limit nearly word for word (S4-25) +- Doc comments, `src/argv.rs:6` restates the frozen rule and malformed check across four places (S4-28) +- Doc comments, `src/vm.rs:183` says the VM retains no emitter, though its persistent globals do (S1-9) +- Doc comments, `src/vm.rs:84` groups `set_cancel` with the stage-gated calls (S1-10) +- Doc comments, `src/vm.rs:279` names two limits and omits the ceiling it records and the byte budget it resets (S1-8) +- Doc comments, `src/vm/state.rs:77` explains where the delegation lives (S1-13) +- Doc comments, `src/hardening.rs:23` says `coroutine` is never loaded; the shim prelude loads it (S1-3) +- Doc comments, `src/walk_budget.rs:21` says a limit "converts as it does today" (S1-24) +- Doc comments, `src/program.rs:40` calls `load` a "crate-internal step"; it is `pub` (S1-18) +- Doc comments, `src/sys.rs:143` and `src/prelude.rs:202` restate the whole `read_only_proxy` contract before calling it (S1-22) +- Doc comments, `src/vm/install.rs:132` scopes `max_tool_iterations` to the loop shim; handles spend it too (V1-2) +- Doc comments, `src/packs.rs:135` restates the Pack membership rule the module doc and `pack_tools` already state (S6-14) + +**Documentation drift** + +- Drift, 31 `# Errors` sections in 16 files say `Error::Lua` where the body builds `Error::LuaRuntime` (S1-1, S3-1, V3-1) +- Drift, `src/lib.rs:44` and five siblings say the first model call freezes the sets; only the first loop round does (S3-2) +- Drift, `../runvil-docs/src/language/05-lua-environment.md:183` setup order never places five Engine globals (S5-2) +- Drift, `crates/runvil-internal/lua/AGENTS.md:4` says `prepare_dispatch` applies counts; it has not since `be75abaa7` (S5-1) + +**Report defects in the vibelight output** + +- None. `ToolDispatch::trust` is absent from the outline because `#[cfg(test)]` gates it, per the Legend +- Observation: the Spellings warning counts 99 spellings, mostly `mlua::Table` beside `Table`, both idiomatic + +**Rejected candidates** + +- Rejected: 15. 11 weak, 2 intentional (S1-20, S4-21, the second a documented guide limit), and 2 moot (S3-21, settled by the `RosterEntry` commit, and V3-2, settled when HEAD returned `ToolSet::roster()` to `pub(crate)` behind a `test_support` accessor) +- Merged: 11 duplicates folded into the line of the cause they repeat (S6-2, S6-4, S6-6, S6-8 to S6-13, S6-15, S6-16) +- Merged: 6 same-cause findings folded into one line each (S3-1 and V3-1 into S1-1; S6-5 and S1-12 into S3-12; V4-2 into S4-24; S6-7 into S3-17) + + + + +## Technical Design + +- **One rule for the `Error::Lua` mislabel.** A failure that passes through `Error::lua` or `Error::shared` is `Error::LuaRuntime`; only a directly built `Error::Lua(..)` is `Error::Lua`. Costs 29 doc edits, buys an end to the recurrence. Renaming the constructor to `lua_runtime` would prevent it structurally but touches every `map_err` in the crate, so it stays out. +- **Delete the instruction quota rather than keep it dormant.** `HOOK_BUDGET` is `u64::MAX`, the guide states three times that no instruction limit exists, and the engine pass deferred the variant to this one. Renaming `InstructionBudget` to `CancelHook` follows from that: the hook's only remaining job is the cancel poll. The firing counter goes too: once nothing trips, it only lets two tests observe that a cancelled firing is not counted, an ordering nothing else reads. +- **`Offers` and `ModelSelection`, not `SectionOffers`.** The engine already owns `execute::scope::SectionOffers`, so that name would collide at the crate seam. `Offers` is free in the workspace. Each type, its lock message, and its accessor name what the lock holds. +- **Narrow `prepare_dispatch` instead of documenting `None`.** The public entry point's `None` arm fires no event at all. Taking `ScriptReport` and keeping an `Option` body private makes the silent path unrepresentable, which is a smaller change than the doc fix it replaces. +- **Flatten `ModelReport` to `turn`.** It reads `script.turn` at its one site, and the field name asserts a path the call is not on. +- **Two phase variants for the compaction retry, not one field move.** The snapshot outlives the compacting flag by design: `step` restores it and `chat` retries holding it. Merging it into `Compacting` alone would drop the retry state. +- **Build the `sys` mirror at injection.** The `SectionVm` typestate already guarantees injection before every reader, so the mirror can be created when `install_values` succeeds and the `Option` with its dead `None` arms goes. +- **One coroutine shim installer.** `install_coro_shims` installs `models.loop` and `models.compact` itself. `max_tool_iterations` stays on it, because a model handle's `loop` spends the cap whether or not `models.loop` exists. + + + + +## Testing Plan + +- Full suite: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` +- Linter: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, with `CARGO_BUILD_WARNINGS=deny` +- Formatter: `cargo fmt --all --check` +- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, with `RUSTDOCFLAGS="-D warnings"` +- Facade docs and surface, because work item 11 edits the doc of `VfsOp`, which the facade re-exports: `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps`, then `cargo + xtask api --check` with `CARGO_BUILD_WARNINGS` unset, where the nightly is the one `crates/build-xtask/src/api/toolchain.rs` names +- Wording scan, because work item 5 edits a rulebook: `node --test crates/workshop/ui/test/docs-claims.mjs` +- Engine private docs, as CI runs them, because several items edit engine comments: `RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` +- Per-item loop: `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`, the full-suite command narrowed to the three crates this plan edits; the gates above run before each commit +- Docs reach: the docs gate resolves intra-doc links only in the docs it builds, which leave out `pub(crate)` and private items. `cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items`, the form CI runs for runvil-engine, also resolves the private `# Errors` links this plan edits; read its warnings for edited lines only, since it is not a CI gate +- No gate can tell `Error::Lua` from `Error::LuaRuntime` in a doc, because both links resolve; work item 1 is checked by reading each edited section against its body +- New test, `increment_seeds_a_missing_id_and_counts_it` in `src/scope-tests.rs`: increments an id on `ToolCallCounts::default()` that was never seeded and asserts `snapshot()` maps it to 1. Today `increment` returns the "was not pre-seeded" error, so the test's `expect` fails +- Extended test, `a_list_is_rendered_only_once_sets_are_attached_and_keeps_the_first` in `src/messages-tests.rs:65`: also asserts with `Arc::ptr_eq` that the second `attach_rendered_tools` returns the first attached sets. Today it does not compile, because the method returns `()` +- Changed test, `src/tests/logging.rs:172`: the expected refusal text is built from `LUA_LOG_CHARACTER_LIMIT`, as the input at `:163` already is +- Existing protection relied on: `tools/tests-offering.rs` for the offer-after-freeze rule, `projection-tests.rs:345` for `tool_calls` on a non-assistant role, `packs-tests.rs:100` and `tools/tests-stats.rs:108` for the proxy labels, `models_loop/machine-tests-compaction.rs:58` for the compacting flag, `tests/budgets.rs:6` for a loop past the old instruction budget, `dispatch-tests.rs` for both dispatch paths, and engine `execute/tests/models_loop-arguments.rs:117` for the loop's argument order + + + + +## Decision Record + +- Deleting `QuotaResource::Instruction` is a public enum change, but every user is in the workspace and the guide already documents the absence +- `Offers` replaces `ToolRuntime` and `ModelSelection` replaces `ModelRuntime`; the accessors follow, so `tool_runtime()` becomes `offers()` and `model_runtime()` becomes `model_selection()` +- `model_tool_name` keeps its name, since in this crate a tool's name is its wire name +- Eleven candidates repeated a cause another candidate raised; each folds into the surviving line, so the Debt Inventory cites one tag per cause +- The `RosterEntry` work is treated as landed: HEAD holds it, and the same series returned `ToolSet::roster()` to `pub(crate)` with `ToolSet::roster_for_test` in `test_support` for the one engine assertion +- Assumption: the guide fix in `../runvil-docs` is a second repository and needs its own commit +- Risk: HEAD advanced eight commits past this plan's baseline during review; if it moves again, reread the affected lines before editing them +- Operator decision: the `Answer` restructure lands in this pass, as work item 9, so the per-variant `Result` goes now rather than in a later pass +- Operator decision: the guide's setup-order fix lands as a second commit in `../runvil-docs`, as work item 27, in this same pass +- Consequence of the first decision: work item 9 runs after work item 8, since both edit `YieldParse` and `classify` +- Consequence of the second decision: work item 27 commits in a different repository, so it cannot share a commit or a rollback with the rest +- Review 2026-10-29: every lua-crate line citation checked landed on its symbol; the engine ones had drifted and now locate by symbol +- Review 2026-10-29: work item 9's site list was under-scoped. The engine holds 28 files naming `Answer` and 27 `Answer::*(Err(..))` sites; the item now says to finish by compiler error, not by list +- Review 2026-10-29: work item 10 would have left `TaskState::ok` as dead code and failed the `deny` lint gate; the item now deletes it +- Review 2026-10-29: work item 7 missed two test matches on `Phase::Chatting` as a unit variant and contradicted itself on `machine.rs:212`; both fixed +- Review 2026-10-29: work item 20 cannot give `ToolCallFields` a required turn, since batch entries carry none; the callers assemble `ModelCall` instead +- Review 2026-10-29: work item 22's move of `resume_block_coro` breaks on a private `step_block_coro`; it is now a `cfg` gate instead +- Review 2026-10-29: work item 21 folds the loop installer, so work item 27 renumbers the guide's step 7 away instead of keeping it +- Review 2026-10-29: `install_models` runs at injection (`vm/inject.rs:143`), before `install_coro_shims`, so the fold has the `models` table it needs +- Review 2026-10-29: `cargo xtask site --books-only` exists (`crates/build-xtask/src/main.rs`) but requires `RUNVIL_DOCS` to name the guide checkout; the item now sets it +- Review 2026-10-29: the shim table lives in `install_shim_prelude` (`coro.rs:275`), not `install_coro_shims`, so work item 21 places the loop install there +- Review 2026-10-29: work item 1 cannot blind-replace `Error::Lua`; 16 bodies build the variant directly and their docs are already correct +- Review 2026-10-29: work item 3's engine comment needs all four lines replaced, since its first half also goes false once `increment` seeds +- Review 2026-10-29: items 1, 3, and 8 named callers or sites that do not exist as described; each now names verified sites +- Decomposition 2026-10-29: work item 16 would have moved about 190 lines into the 402-line `engine_globals-store.rs`, past the 500-line build ceiling. `run_store_op` moves into that file, ending near 450 lines, and the read helpers move to a new private child module, `engine_globals-store-read.rs`, as `error-value.rs` holds `error-value-store.rs` +- Decomposition 2026-10-29: work item 10 repeated work item 7's `Phase::Chatting` test edits, which have nothing to do with `TaskPhase`; its step instead names the unit-variant match `TaskPhase::Done => "done"` in `protocol/answer.rs`, which breaks once `Done` takes a field + +### Open Questions + +None. Both questions were answered by the operator: the `Answer` restructure lands here, and the guide fix lands as a second commit. + +### Decomposition constraint + +- The operator requires a low step count: group the 27 work items into as few steps as shared files and shared causes allow, targeting 6 to 9 steps, never one step per work item +- Grouping rule: a step owns one commit, so every work item in it must touch overlapping files or share a cause; keep the required orderings intact (8 before 9, 20 before 21, 21 before 27) +- Do not split a step just because it spans two crates; the engine and the lua crate change together in most items + +### Deferred and Out of Scope + +- The engine's `bound_tool_set` carries the same misleading name as `bound_tools`; it belongs to `runvil-engine` +- `TOOL_LIST_FIXED`'s omission of `handle:loop` stays: the guide quotes the message verbatim and a test pins it +- Deferred: the `Compaction` value that would own the compacting flag end to end, revisit after work item 7 lands +- Deferred: renaming `Error::lua` to `Error::lua_runtime`, revisit if the mislabel recurs after this pass +- Noticed for the next scan: engine `execute/tests/happens_before.rs:9` cites "Functional Specification", a plan heading, in a test doc +- Noticed for the next scan: `iteration-tests.rs:245` says "A cleared non-scalar key"; check whether its key is scalar once work item 26 renames the trailing group "unordered" + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p `. Plain `cargo build --locked` builds only the default member, the gateway app (package `gateway`); the desktop app is `cargo build --locked -p workshop`. `rust-toolchain.toml` pins Rust 1.99, and CI also builds the Engine crates at the 1.95 floor. +- Focused test command pattern: `cargo nextest run --locked -p --all-features -E 'test()'`, for example `cargo nextest run --locked -p runvil-lua --all-features -E 'test(dispatch::tests)'` (verified with `cargo nextest list`; cargo-nextest 0.9.128 is installed). +- Component test command pattern: `cargo nextest run --locked -p --all-features`. The Workshop crates `workshop`, `workshop-server`, and `workshop-server-api` run without `--all-features`. A change in an internal Engine crate should also run the facade suite `cargo nextest run --locked -p runvil --all-features` and the structural checks `cargo test -p build-xtask`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, then the wording and rulebook scan `node --test crates/workshop/ui/test/docs-claims.mjs`. +- Linter command: with `CARGO_BUILD_WARNINGS=deny` (in PowerShell, `$env:CARGO_BUILD_WARNINGS = "deny"`), run `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`, plus the headless shape check `cargo check -p gateway --no-default-features`. Never run a standalone `cargo check --workspace` beside clippy. +- Formatter check command: `cargo fmt --all --check` (`rustfmt.toml` sets `style_edition = "2024"`; the pre-commit hook runs the same check). +- Docs command: with `RUSTDOCFLAGS="-D warnings"`, run `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` and the default-features facade build `cargo doc -p runvil --no-deps`. The facade surface check is `cargo +nightly-2026-09-05 xtask api --check` with `CARGO_BUILD_WARNINGS` unset (`Remove-Item Env:CARGO_BUILD_WARNINGS`); it compares against `crates/runvil/public-api.txt`. CI also runs `cargo doc -p harness --no-deps` and `cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items`. +- Test placement and naming conventions: + - Unit tests live beside their module as `-tests.rs`, or `-tests-.rs` when split, wired at the bottom of the module as `#[cfg(test)] #[path = "-tests.rs"] mod tests;`. Test names are full-sentence snake_case behavior statements, such as `prepare_dispatch_wraps_a_canned_untrusted_output_and_reports_it`. + - Once a test group reaches three files it becomes a subdirectory, such as `runvil-lua/src/tests/*.rs`, `src/protocol/tests/*.rs`, and `src/tools/tests-*.rs` beside `src/tools/tests.rs`. + - `runvil-lua` has no `tests/` directory: all its tests are under `src`, with shared helpers in `test_support.rs` (also exposed to companion crates by the `test-support` feature) and `test_vm.rs`. + - Integration tests are one binary per crate at `tests/suite/main.rs`, which declares one `mod` per topic, with helpers in `tests/suite/support.rs`. Prompt fixtures are Markdown files under `tests/prompts/valid/`, `tests/prompts/invalid/`, and `tests/prompts/execution/`. + - Benches use criterion under `benches/`; `runvil-lua`'s `surface` bench needs `--features test-support`. + - Engine tests never sleep, read the wall clock, wait with a timeout, or draw OS entropy. Pseudo-random input comes from a fixed seed that the failure prints, and temporary directory names come from the test's name, the process id, and a counter. `unwrap` and `expect` are allowed in tests only. +- Directory map: + - `crates/`: every Rust crate plus the TypeScript UI packages. Flat crates are `runvil` (the Engine facade), `runvil-plugin` (the Plugin API), `harness`, `harness-gateway-client`, the Packs' Plugins `plugin-std`, `plugin-web`, `plugin-mcp`, and `plugin-user-input`, the gateway wire crates `gateway-api-types` and `gateway-api-discovery`, the leaves `shared-error-source` and `shared-loopback`, and the tooling crates `build-xtask` (structural checks and `cargo xtask api`), `build-ceiling` (the 500-line file cap), `build-ui`, `build-user-guide`, `build-workshop`, and `build-llama-cuda`. + - Manifestless containers under `crates/` hold private family crates: `runvil-internal/` (`engine`, `lua`, `parser`, `types`, `vfs`, `model-client`), `harness-internal/` (`runner`, `plugin-loader`, `test-plugin`), `gateway/` (`app` is package `gateway`, plus `config`, `config-ui`, `local`, `logging`, `progress`, `protocol`, `routing`, `web-search`, `cloud-providers`, and `stt/` with `api`, `engine`, `backend-whisper`, and `whisper-ffi`), and `workshop/` (Rust crates `desktop`, `server`, `server-api`, `gateway`, `menu`, `protocol`, `registry`, `status`, `support`, `user-state`, `workspace`, `run-log`, and `agents`, plus the TypeScript packages `ui`, `look`, and `platform`). `crates/shared-ui` is a TypeScript and CSS package, not a crate. + - `runvil-lua` source is `crates/runvil-internal/lua/src/`: flat modules with kebab siblings, subdirectories `json/`, `models_loop/`, `protocol/`, `tools/`, `vm/`, and `tests/`, and the embedded Lua shims `__impl_coro.lua`, `__impl_fanout.lua`, `__impl_globals.lua`, `__impl_require.lua`, `__impl_store.lua`, and `__impl_tasks.lua`. + - `.vibelight/`: gitignored per-crate outlines in Markdown and `html/`, generated by `cargo vibelight`, which runs the sibling `../vibelight` checkout from the repo root. + - `vibe/`: dated plan records; `vibe/ACTIVE` names the active one, currently `vibe/2026-10-29-1-lua-slop-cleanup.md`. + - `guide/`: the mdbook user guide sources (`books`, `chrome`, `landing`). `prompts/`: example prompts. `tools/`: Node scripts with `.test.mjs` tests. `images/`: README art. `local/`: gitignored machine-local configs and fixtures. + - `.github/workflows/`: `ci.yml` plus release, nightly, and site workflows. `.githooks/`: pre-commit runs fmt; pre-push runs the docs-claims scan, the headless gateway check, clippy, and `cargo deny` when installed. `.config/nextest.toml`: nextest profiles. `.cargo/config.toml`: the `xtask`, `workshop`, and `vibelight` aliases and the Windows `rust-lld` linker. +- Component boundaries: + - Engine: `runvil` re-exports `runvil-engine`, which depends on `runvil-lua`, `runvil-parser`, `runvil-model-client`, `runvil-types`, and `runvil-vfs`. `runvil-parser` depends on `runvil-lua`, never the reverse, so functions that would create that cycle stay in `runvil-lua`. `runvil-lua` may depend only on `runvil-types`, `runvil-model-client`, and `runvil-vfs`; `runvil-model-client` depends on `runvil-types`; `runvil-types` and `runvil-vfs` are leaves. Engine crates never depend on `tokio`, `tokio-util`, `async-trait`, `reqwest`, `getrandom`, or `fastrand`, and they emit effects for the caller instead of performing work themselves. + - Plugin API: `runvil-plugin` depends on `runvil-types` and `runvil-vfs`; each `plugin-*` crate depends only on `runvil-plugin`. + - Harness: `harness` depends on `harness-runner`, `harness-plugin-loader`, `runvil`, and `runvil-plugin`. `harness-gateway-client` depends on `harness`, `runvil`, and `plugin-web`, and is the Harness-side client for the gateway. + - Gateway: `gateway` sits on its family crates plus `gateway-api-types`, `gateway-api-discovery`, and `shared-loopback`. No gateway crate depends on any Engine, Harness, or Plugin crate. + - Workshop: `workshop-server` is the hub, depending on the Harness, the Plugins, `runvil`, and the Workshop family crates. `workshop-server-api` wraps it, and the desktop app `workshop` depends on `workshop-server-api` and `gateway-api-discovery`. `workshop-protocol` and `workshop-support` are leaves, and `workshop-registry` sits on `workshop-protocol`. + - `cargo test -p build-xtask` enforces the product and container boundaries, the Workshop tier graph, the `## Invariants` marker, and lint inheritance. +- Conventions summary: + - Every crate sets `[lints] workspace = true`. Clippy `all` and `pedantic` are deny, as are `unwrap_used`, `expect_used`, `todo`, `exit`, `allow_attributes`, and `allow_attributes_without_reason`, so suppressions are written `#[expect(lint, reason = "...")]`. `missing_docs`, `missing_debug_implementations`, and `unreachable_pub` warn; `unsafe_code` is deny outside its owned boundary. The root `clippy.toml` disallows process-global installers such as `std::panic::set_hook` outside binary entry points, and `crates/runvil`, `crates/runvil-internal`, and `crates/runvil-plugin` carry their own `clippy.toml`. + - Every crate's `build.rs` calls `build_ceiling::check()`, which fails the build when any `.rs` file under `src`, `tests`, `benches`, or `examples` exceeds 500 lines. An oversized file splits only into private child modules of that file. + - Source directories are flat by default: one or two child files sit beside the parent as `-.rs` wired with `#[path]`, and three or more become a `/` subdirectory. Whichever conversion applies is done when touching the group. + - Each crate root doc has a `## Invariants` section naming its allowed dependencies and rules, and many crates have a local `AGENTS.md`. + - The seven defined terms (Engine, Harness, Host, Pack, Plugin, Static Set, Dynamic Set) are capitalized and used only in their defined sense. Engine crates call their driver "the caller" and never mention the Host. `docs-claims.mjs` enforces this wording. + - Dependencies are declared once in the root `[workspace.dependencies]`, each version pin or feature choice justified by a comment, and members use `.workspace = true`. + - Error and status messages are written for a model to read: concise, self-contained, and naming required versus actual. + - Comments explain only a non-obvious constraint, ordering requirement, or workaround, and every workaround cites its upstream issue URL. + - JSON that reaches a recorder or a replay comparison round-trips exactly: sorted keys, finite numbers, and `float_roundtrip` parsing. + - Behavior changes ship with tests in the same change. Cargo features gate real constraints only, such as `test-support` and `gateway/test-fixtures`. + + + + +## Execution Instructions + +- Each step is one commit holding its code and its tests. Run the steps in number order. +- The operator's decomposition constraint groups the 27 work items into 8 steps, each owning one commit of work items that share files or a cause. Each step lists its work items, the numbers the decision record and the testing plan cite, and an Order line; inside a step, do the work items in that order, since a later item edits what an earlier one leaves. Every ordering the decision record states holds: work item 8 before 9 (both in step 4), 20 before 21 (both in step 5), and 21 (step 5) before 27 (step 8). +- The letters and numbers in parentheses after a work item are tags from the analysis; they trace a finding and are never needed to act. +- Each step's Tests are its commit gate, run once every work item in the step is done, written for PowerShell at the repository root `c:\Users\Vinnie\cursor\runvil` (step 8 says where it commits). Each command sets the environment variable it needs, and the private-docs command clears both variables before it runs. Each step's Loop is the fast check between work items. + +**Components and pieces, in dependency order** + +1. **Error surface** (step 1). First, because its rule - a failure built through `Error::lua` or `Error::shared` is `Error::LuaRuntime` - governs every `# Errors` line later steps touch; because it removes `QuotaResource::Instruction` from the protocol tests before step 4 rewrites them; and because its `CancelHook` doc rewrite removes the "direct chunk path" wording that step 5 counts as already gone. + - Piece "error docs, quota, and refusal helper" (step 1, work items 1, 12, 2, and 18), sequential inside the step: work item 1 applies the variant rule across 16 files; work item 12 rewrites the `error` module docs and keeps the two cross-references work item 1 relies on; work item 2 deletes `mod lua_quota` from `src/error.rs`; and work item 18 then adds `author_refusal` there. All four edit the error surface and overlap in `error.rs`, `hardening.rs`, `globals.rs`, `messages.rs`, `coro.rs`, and the `vm` files. +2. **Tool surface** (steps 2-3). Second, because it renames `SectionVm` fields and accessors (`offers`, `model_selection`, `tool_set`, `store_conflict`, `current_static_set`), moves the `handles` and `sys` items into `bindings`, `section`, and `var`, and narrows the public dispatch entry point. Every later component edits files that name them, so landing them first means later steps write final names and import paths. + - Piece "offers and frozen sets" (step 2, work items 3, 4, 13, and 6), sequential inside the step: work item 3 changes the counts API and the offer types and leaves the `scope` module doc to work item 4, which states where the sets freeze; work item 13 then renames more fields in the same `vm` files; and work item 6 changes the method that attaches the frozen sets work item 4 documents. + - Piece "module layout and dispatch path" (step 3, work items 14, 15, and 5), after step 2 so the split moves items under their final names, and sequential inside the step: work item 14 splits `handles` and `sys`; work item 15 edits `seal_sys` in the slimmed `sys.rs` and builds its proxy among final imports; and work item 5 then rewrites `dispatch.rs` and `tools/tests-parameters.rs`, whose imports work item 14 just moved. The piece lands before step 4 because work items 5 and 9 both edit engine `execute/scheduler/apply.rs`, and the smaller change lands first. +3. **Protocol types** (step 4). Before the VM component, because work item 9's `Answer` change sets the signature of `resume_block_coro_answer`, which step 5 retargets doc links to, and work item 11's `request.rs` docs land before step 5 reshapes `Request::ToolCall` in the same file. + - Piece "parse, answer, and protocol docs" (step 4, work items 8, 9, 10, and 11), sequential inside the step: work item 9 needs work item 8's `YieldParse::Refused`; work item 10 reshapes `TaskStatus` in the `answer.rs` and `render.rs` work item 9 just rewrote; and work item 11 documents `CHAT_PART_TYPES` where work item 8 moved it and the `Answer` work item 9 left. +4. **VM setup and models loop** (steps 5-6). After protocol: work item 22 needs work item 2's hardening rewrite and work item 9's resume signature, and work item 20 edits `protocol/request.rs` after work item 11. + - Piece "VM setup" (step 5, work items 20, 21, 22, 17, and 23), sequential inside the step: work items 20, 21, and 22 all edit `coro.rs`, and work item 21 needs work item 20's stash edits; work item 22's `coro.rs` doc edit sits beside the loop-shim mention work item 21 deletes; work item 17 changes `SectionVm`'s fields and `Injected` in the `vm/inject.rs` and `vm/install.rs` that work items 21 and 22 edit; and work item 23 then rewrites the `vm.rs` docs around them. + - Piece "models loop" (step 6, work items 7 and 19), after step 5 because work item 19's `install_models` doc names the `models.loop` and `models.compact` shims that work item 21 now installs; inside the step, work item 7 changes the loop machine, and work item 19 then rewrites `loop_begin`'s doc over the closure work item 7 leaves. +5. **Store and module docs** (step 7). Independent of the components above, so it runs last among the Rust steps, where its `vm/install.rs` doc edits land on settled installers and its closing outline check sees every rename. + - Piece "placement and vestiges" (step 7, work items 16, 24, 25, and 26), in number order. They share a cause rather than files: each moves an item, or the one statement of a rule, to the module that owns it, or drops an attribute or derive nothing needs. Work item 26 carries the closing outline check, so it goes last. +6. **Guide setup order** (step 8). Last, because it describes the merged installer step 5 builds, and it commits in the second repository `c:\Users\Vinnie\cursor\runvil-docs`, so it shares no commit or rollback with the rest. + - Piece "guide" (step 8, work item 27): one step. + + + +### Step 1: the error surface: the `Error::Lua` doc rule, the error docs, the instruction quota, and one refusal helper [completed] + +- Component: Error surface +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 1, 12, 2, and 18, in one commit. +- Order: work item 1, then 12, then 2, then 18. Work item 12 keeps the cross-references work item 1 relies on; work item 2 deletes `mod lua_quota` from `src/error.rs` before work item 18 adds `author_refusal` there; and work items 2 and 18 keep the `# Errors` text work item 1 sets. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, plus each work item's acceptance below. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`, and for the docs-only work items 1 and 12, the workspace docs command in Tests; the Tests above gate the commit. + +**Work item 1 (S1-1, S3-1, V3-1): the `Error::Lua` doc rule, 29 `# Errors` sections** + +- Files: `src/coro.rs`, `src/hardening.rs`, `src/vm/install.rs`, `src/vm/state.rs`, `src/sys.rs`, `src/globals.rs`, `src/prose.rs`, `src/messages.rs`, `src/compactors.rs`, `src/engine_globals.rs`, `src/engine_globals-store.rs`, `src/iteration.rs`, `src/collection.rs`, `src/models.rs`, `src/models_loop.rs`, `src/tools/objects.rs` +- Scope: docs only. +- Problem: these `# Errors` sections promise `Error::Lua` for failures their bodies build through `Error::lua` or `Error::shared` (`src/error.rs:222-240`), both of which build `Error::LuaRuntime`. Rust code that matches the documented variant misses the real one, and `start_block_coro` (`src/vm/run.rs:66-69`) already documents as `Error::LuaRuntime` the same failure `block_guard` documents as `Error::Lua`. Rustdoc resolves both links, so no check catches it. +- Rule: a failure that passes through `Error::lua` or `Error::shared` is `Error::LuaRuntime`; only a directly built `Error::Lua(..)` is `Error::Lua`. Renaming the constructor to `lua_runtime` is deferred and out of scope. +- Change: replace "[`Error::Lua`]" with "[`Error::LuaRuntime`]" and keep the rest of the sentence in these 26 sections: `install_shim_prelude` (`coro.rs:168`), `block_guard` (`coro.rs:333`), `install_store_shims` (`coro.rs:380`), `InstructionBudget::install_on_thread` (`hardening.rs:122`), `install_instruction_budget` (`hardening.rs:190`), `SectionVm::install_lazy_prose` (`vm/install.rs:292`), `globals::install` (`globals.rs:73`), `freeze_argv` (`globals.rs:108`), `replace_prose` (`globals.rs:130`), `globals::install_messages` (`globals.rs:143`), `globals::messages` (`globals.rs:152`, the first clause only, since its `Error::Internal` clause is right), `prose::install` (`prose.rs:71`), `prose::swap` (`prose.rs:88`), `prose::restore` (`prose.rs:101`), `lazy_read` (`prose.rs:110`), `messages::install_messages` (`messages.rs:63`), `install_compactors` (`compactors.rs:302`), `install_ui` (`engine_globals.rs:149`), `route_store_to_shims` (`engine_globals-store.rs:365`), `install_deterministic_iteration` (`iteration.rs:49`), `render_item_value` (`collection.rs:118`), `install_models` (`models.rs:248`), `loop_begin` (`models_loop.rs:87`), `compact_begin` (`models_loop.rs:209`), `install_tool_objects` (`tools/objects.rs:38`), and `install_tool_get` (`tools/objects.rs:78`). Work item 2 renames the two `hardening.rs` items later in this step; edit their sections under the old names first. +- Change: `SectionVm::argv_json` (`vm/state.rs:42-43`) becomes "Returns [`Error::Lua`] when H1 left `argv` as a function, userdata, or thread, and [`Error::LuaRuntime`] when its value cannot be represented as JSON." +- Change: `var_snapshot_table` (`sys.rs:245`) becomes "Returns [`Error::Lua`] if the author reassigned the `var` global, [`Error::LuaRuntime`] if a registry slot cannot be read (Engine values were never injected) or a nested proxy cannot be copied, and [`Error::Internal`] if the root data value is not a table." +- Change: `var_to_json` (`sys.rs:277-280`) becomes "Fails as [`var_snapshot_table`] does, and with [`Error::LuaRuntime`] if the data cannot be represented as JSON." +- Change: skip `install_section_loop_shim` (`coro.rs:345`) and `globals::install_prose` (`globals.rs:120`), which step 5 deletes (work items 21 and 17). Do not blind-replace `Error::Lua` with `Error::LuaRuntime`: some bodies build the variant directly, and those docs are already right. Confirmed direct builders include `alias.rs:30`, `collection.rs:33` and `:60`, `prelude.rs:163` and `:197`, `projection.rs:64`, `:81`, `:87`, `:95`, `:121`, and `:143`, `prose.rs:137` and `:145`, `sys.rs:254`, `protocol/parse.rs:42`, `vm/run.rs:264`, `compactors.rs:120`, and `SectionVm::var` (`vm/state.rs:30`). For each site, read the body it documents and pick the variant the body actually builds. Keep the cross-references at `error.rs:92` and `:136`, the one place a reader learns that `Error::lua` builds `LuaRuntime`. +- Ceiling: `src/messages.rs` holds 499 lines and the 500-line build ceiling fails at 501, so edit its sentence in place without rewrapping it onto a new line. +- Acceptance: no gate can tell `Error::Lua` from `Error::LuaRuntime` in a doc, because both links resolve and no test pins these variants, so read each edited section against the body it documents. + +**Work item 12 (S2-1, S2-2, V1-1, S2-3, S2-6, S2-7, S2-9): the error module docs** + +- Files: `src/error.rs`, `src/error-value.rs`, `src/error-value-store.rs` +- Scope: docs only. +- Problem: the module doc names a "core" crate that does not exist; `SharedSource` and `Error::shared` cite a resolver cache nothing has and list the callers without the `require` loader; the `Error` type doc gives a span list that misses six variants and repeats the module doc; `ErrorKind::Cancelled` and `ErrorKind::ContextExhausted` are documented narrower than what maps to them; `Raised` says its fields are strings; and the store error field list is written out twice and already differs. +- Change: in the `error` module doc (`error.rs:5`), "wraps and classifies core's own error type, which maps this one back" becomes "wraps and classifies `runvil-engine`'s internal error type, which maps this one back". +- Change: the first paragraph of the `SharedSource` doc. Line 24 is the one-line summary, and the "Some caches..." paragraph is `:26-30`; replace `:26-30` and keep the summary. New text: "Some compiled-program statics re-produce a typed `Error` on every lookup, so a non-`Clone` dependency error cannot be moved into a fresh `Error` each time. Wrapping it in a reference-counted `SharedSource` keeps the typed cause and clones it cheaply per lookup instead of flattening it to a string." Its caller sentence (`error.rs:30-33`) becomes "Every embedded chunk's compiled-program static is a caller, through `detail::shared_source_new`." `Error::shared` (`error.rs:231-234`) becomes "Re-produces a typed `Error` from a `SharedSource` a compiled-program static captured once, cloning the `Arc` rather than flattening the cause to a string." +- Change: the `Error` type doc (`error.rs:70-74`) becomes "The crate's internal error type: every failure a section VM's setup, Lua code, or Engine calls can end in." Delete its second paragraph, which the module doc keeps. +- Change: `ErrorKind::Cancelled` (`error-value.rs:74`) becomes "The run was cancelled, the caller dropped a call the run was waiting on, or a wait delivered a task its owner cancelled (that value includes `task`)." `ErrorKind::ContextExhausted` (`error-value.rs:51-52`) becomes "A request overflowed the model's context window and the selected compactor did not compact, or a caller raised it through `compactors.fail` with its own reason; includes `reason`." +- Change: the `Raised` doc (`error-value.rs:276-277`) says "the kind's fields" in place of "the kind's string fields"; `ErrorField::Integer` (`error-value.rs:197-198`) becomes "An integer-valued field (a store anchor's `count`, a UTF-8 failure's `valid_up_to`)."; and the `fields` doc (`error-value.rs:290-291`) says "string- or integer-valued (a store anchor's `count`, a UTF-8 failure's `valid_up_to`)". +- Change: `store_error_value_fields` (`error-value-store.rs:61-68`) keeps the one full list, written as the detailed one: `anchor` and its integer `count`, `rule` as the `PathReason` tag, and the integer `valid_up_to`. The private `store_error_fields` (`error-value-store.rs:6-11`) becomes "Returns the variant's own fields, without `reason`, in the shape `store_error_value_fields` documents." +- Keep: the cross-references at `error.rs:92` and `:136` that tell a reader `Error::lua` builds `LuaRuntime`; work item 1 relies on them. +- Acceptance: the docs commands pass. + +**Work item 2 (S1-2, S2-5, S1-3): delete the instruction quota and rename the hook** + +- Files: `src/hardening.rs`, `src/error.rs`, `src/error-tests.rs`, `src/vm.rs`, `src/vm/install.rs`, `src/vm/run.rs`, `src/vm/state.rs`, `src/vm/inject.rs`, `src/coro.rs`, `src/models_loop.rs`, `src/models_loop/tests.rs`, `src/tests.rs`, `src/tests/cancellation.rs`, `src/tests/budgets.rs`, `src/protocol/tests/answer.rs`, `src/protocol/tests/answer_retained.rs`, and engine `lua/tests/errors.rs` +- Problem: `HOOK_BUDGET` is `u64::MAX` (`hardening.rs:21`), so the trip in `budget_hook` (`:175-179`) needs about 1.8e23 instructions. The guide says three times that no instruction limit exists (`../runvil-docs/src/language/16-limits-and-errors.md:208`, `:242`, `:244`), and the engine's cleanup pass deferred `QuotaResource::Instruction` to this one. The hook's names and docs still describe a budget, `error::lua_quota` (`error.rs:210-219`) is a module of message strings with one reader, and the `harden` doc says `coroutine` is never loaded although `install_shim_prelude` loads it (`coro.rs:177`) and strips the global again (`coro.rs:276-278`). +- Design: delete the quota rather than keep it dormant. The hook's only remaining job is the cancel poll, so `InstructionBudget` becomes `CancelHook`, and the firing counter goes too: once nothing trips, it only lets two tests observe that a cancelled firing is not counted. `QuotaResource` is public, but every user is in the workspace and the facade does not re-export it. +- Change: delete `HOOK_BUDGET`, the trip branch in `budget_hook`, and `QuotaResource::Instruction` (`error.rs:56-57`) with its `Display` arm (`error.rs:65`). +- Change: write the two remaining strings into the `QuotaExceeded` `Display` match (`error.rs:200-204`), `QuotaResource::LogEvent => "lua log event budget exceeded"` and `QuotaResource::LogByte => "lua log cumulative byte budget exceeded"`, and delete `mod lua_quota` with its doc comments. +- Change: delete the `fired` counter, `InstructionBudget::fired()` (`hardening.rs:145-150`), and `SectionVm::hook_firings()` (`vm/state.rs:119-123`); `budget_hook` loses its `fired` parameter, and the `AtomicU64` and `Ordering` imports go if nothing else uses them. +- Change: rename `InstructionBudget` to `CancelHook`, the `SectionVm` field `instruction_budget` to `cancel_hook`, `install_instruction_budget` to `install_cancel_hook`, and `budget_hook` to `cancel_poll`. The 36 lines that name them are in `coro.rs` (6), `hardening.rs` (5), `models_loop.rs` (6), `models_loop/tests.rs` (4), `vm.rs` (6), `vm/run.rs` (2), `vm/inject.rs` (2), and one each in `vm/state.rs`, `vm/install.rs`, `tests.rs`, `tests/budgets.rs`, and `tests/cancellation.rs`. +- Change: `hardening.rs:1-2` becomes "Sandbox hardening for section VMs: the `_G` guard, global removal, and the cancellation hook." The `CancelHook` doc (`hardening.rs:97-104`) becomes "A VM's shared cancellation-hook state. The hook polls the run's cancel flag every [`HOOK_INTERVAL`] instructions, so a long-running or infinite loop is legal and only the flag aborts it. The flag is the run's synchronous [`CancelHandle`], installed once by the executor through [`set_cancel`](Self::set_cancel); a VM no executor claimed (a test fixture) is never cancelled." Its per-coroutine paragraph (`:106-110`) stays, with "(over the same counter)" changed to "(over the same flag)". This rewrite also removes "the direct chunk path" at `hardening.rs:104`, the eleventh "scheduler mode" site that work item 22 in step 5 counts as done here. +- Change: `install_on_thread` (`hardening.rs:119`) becomes "Installs the cancellation hook on a block coroutine." `cancel_poll` (`:159-161`) becomes "Builds the every-Nth-instruction hook body shared by the main state and every block coroutine: the cancellation poll." `install_cancel_hook` (`:187`) says "with the returned hook" in place of "with the returned counter". Keep each `# Errors` section work item 1 edited. +- Change: `error.rs:123-124` begins "An Engine quota on Lua (log events or log bytes) was exhausted."; the `cancel_hook` field doc (`vm.rs:159-160`) becomes "The VM's cancellation hook, shared with every block coroutine's hook (hooks are per-coroutine in PUC Lua)."; `vm/run.rs:44` says "the VM's cancellation hook" in place of "the VM's budget/cancellation hook". +- Change: the `harden` doc's second sentence (`hardening.rs:24-25`) becomes "The `io`, `os`, `package`, and `debug` libraries are never loaded; the coroutine library loads only while the shim prelude installs, and its global is stripped again." Its `table.concat` paragraph (`:30-33`) ends after "coerces like `tostring`" with ". Plain tables, booleans, and nil still raise as stock Lua does." +- Change: in `tests/cancellation.rs`, delete each `let fired = ...` line and the `assert_eq!` on `budget.fired()` (`:34-41`) and on `vm.hook_firings()` (`:68-75`); keep the `is_cancelled`, "lua execution cancelled", and `Interrupted` assertions. Its module doc (`:1`) says "the cancellation hook", and the comments at `:11-15` and `:59-62` drop "before counting itself". +- Change: the samples of `QuotaResource::Instruction` at `protocol/tests/answer.rs:57`, `:63`, `:85`, `:92`, `protocol/tests/answer_retained.rs:12`, `:48`, and engine `lua/tests/errors.rs:265`, `:269` become `QuotaResource::LogEvent`, with any expected text they compare following that variant ("lua log event budget exceeded"). Delete the `error-tests.rs:80` row and the `"lua instruction budget exceeded"` entry at `tests/budgets.rs:52`. +- Acceptance: `tests/budgets.rs` still shows a loop past the old budget completing (`:6-17`) and author raises of the two remaining quota texts staying ordinary runtime errors (`:45`); `tests/cancellation.rs` keeps its cancellation assertions. The exhaustive matches on `QuotaResource` (`error.rs:62-66`, `:200-204`) flag every arm left behind. The two log quota messages read exactly as before. + +**Work item 18 (S4-8, S4-27, S4-26): `set_messages`, one author refusal helper, and the encoder's prefix** + +- Files: `src/globals.rs`, `src/messages.rs`, `src/messages-view.rs`, `src/json.rs`, `src/json/encode.rs`, `src/json/decode.rs`, `src/error.rs` +- Problem: two functions named `install_messages` sit at two layers: `messages::install_messages` (`messages.rs:60-68`) builds the userdata and calls `globals::install_messages` (`globals.rs:139-147`), which only writes the guard's slot beside `freeze_argv`, `replace_prose`, and the getter `messages`. The author refusal helper, `fn refusal(message: String) -> mlua::Error` returning `mlua::Error::external(Error::Lua(message))`, is defined in both `json.rs:87-91` and `messages.rs:280-284`. `Encoder.function` (`json/encode.rs:162`) holds the message prefix `to_json` takes (`:44-51`), such as `PARAMETERS_NOT_JSON` (`tools.rs:66`), not a function name. +- Change: rename `globals::install_messages` to `set_messages`; its one caller is `messages.rs:67`. Keep the `# Errors` text work item 1 set. +- Change: add `pub(crate) fn author_refusal(message: String) -> mlua::Error` as a free function in `src/error.rs`, not an associated one, documented "An author error raised across the Lua boundary: an external [`Error::Lua`], which `pcall` reports as kind `lua`." Delete both module copies and point the 25 `refusal(` calls in `json.rs`, `json/encode.rs`, `json/decode.rs`, `messages.rs`, and `messages-view.rs` at `crate::error::author_refusal`. Leave `globals::refusal` (`globals.rs:172`), an unrelated `(lua, &'static str) -> Result`. +- Change: rename `Encoder.function`, `Encoder::new`'s parameter (`json/encode.rs:172`), and the free `too_large`'s parameter (`:113-115`) to `prefix`; leave `debug_option`'s `function`, which always is one. `Failure`'s doc (`:117-118`) becomes "Why a value cannot be encoded, before the prefix and path are added." +- Ceiling: `src/messages.rs` holds 499 lines; this work item deletes its `refusal` copy, so it shrinks. `src/json/encode.rs` holds 477 lines; the renames add none. +- Acceptance: `messages-tests-list.rs:16-27` still downcasts the raise to `Error::Lua`, the json tests still read kind `lua`, and `tools/tests-adhoc.rs` still pins the `PARAMETERS_NOT_JSON` message. + + + + + +### Step 2: the counts API, `Offers` and `ModelSelection`, the tool names, and where the sets freeze [completed] + +- Component: Tool surface +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 3, 4, 13, and 6, in one commit. +- Order: work item 3, then 4, then 13, then 6. Work item 3 changes the counts API and the offer types and leaves the `scope` module doc to work item 4; work item 13 renames more fields in the `vm` files work item 3 just edited; and work item 6 changes the attach method whose freeze point work item 4 documents. All four overlap in `scope.rs`, `vm.rs`, `vm/state.rs`, `vm/inject.rs`, `lib.rs`, `tools.rs`, `messages.rs`, `test_support.rs`, and engine `execute/scope.rs`, `execute/scheduler/tool_call.rs`, and `execute/scheduler/chat.rs`. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, including the new test `increment_seeds_a_missing_id_and_counts_it` and the extended test `a_list_is_rendered_only_once_sets_are_attached_and_keeps_the_first`, plus each work item's acceptance below. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`; the Tests above gate the commit. + +**Work item 3 (S3-23, S6-3, S3-12, S6-5, S1-12, S3-13): the counts API, `Offers`, and `ModelSelection`** + +- Files: `src/scope.rs`, `src/scope-tests.rs`, `src/lib.rs`, `src/vm.rs`, `src/vm/state.rs`, `src/vm/inject.rs`, `src/tools.rs`, `src/tools/offer.rs`, `src/models.rs`, `src/models-tests.rs`, `src/test_support.rs`, `src/test_vm.rs`, `src/protocol/tests/answer.rs`, `src/tools/tests-adhoc.rs`, `src/tools/tests-stats.rs`, and engine `execute/scope.rs`, `execute/scheduler/tool_call.rs`, `execute/scheduler/tool_call-adhoc.rs`, `execute/scheduler/tool_call-discovery.rs`, `execute/scheduler/dispatch.rs`, `execute/scheduler/chat.rs`, `execute/section_context.rs`, `execute/tests/tool_scoping.rs`, `error/tests.rs` +- Problem: `ToolCallCounts::increment` (`scope.rs:54-68`) errors on an id that was never seeded, so every dispatch site calls `ensure` first, taking the lock twice, and the error arm cannot run. Its API calls the key a name, though every caller passes a tool id and "name" is this crate's word for a wire name. Beside it, `ToolRuntime` (`scope.rs:139-165`) holds only `offers`, its field doc says "tool-addition runtime" (`vm.rs:127`), and its locks say "tool declaration runtime"; `ModelRuntime` (`models.rs:197-219`) holds only a selection, `used`, under "model declaration runtime". `pub offers` (`scope.rs:143`) lets outside code break "each held once", which only `add` keeps, and nothing outside the crate reads it. +- Design: `Offers` and `ModelSelection`, not `SectionOffers`, because the engine already owns `execute::scope::SectionOffers` and that name would collide at the crate seam; `Offers` is free in the workspace. Each type, its lock message, and its accessor name what the lock holds. +- Change: write the failing test first in `src/scope-tests.rs`: `increment_seeds_a_missing_id_and_counts_it` calls `increment` on `ToolCallCounts::default()` for an id never seeded and asserts `snapshot()` maps it to 1. Today `increment` returns the "was not pre-seeded" error, so the test's `expect` fails. +- Change: `increment` becomes `*map.entry(id.to_owned()).or_insert(0) += 1` under one lock, and its `# Errors` keeps only the poisoned mutex. The type doc's second sentence (`scope.rs:16-17`) becomes "`increment` seeds a missing id at 0, and `ensure` pre-seeds one so `tools.stats` lists it before any call." Drop the `Cow` import if nothing else uses it. +- Change: delete the `ensure` before each `increment` at engine `execute/scheduler/tool_call.rs:347`, `tool_call-discovery.rs:118`, `tool_call-adhoc.rs:46`, and in the test helpers at `tools/tests-adhoc.rs:348` and `tools/tests-stats.rs:51`. Replace the whole four-line comment above engine `tool_call.rs:347` with "The attempt counts at dispatch - before the tool runs, so a cancelled dispatch still counts." The current comment's first two lines say a tool outside the section's sets "must still be seeded here, because the increment errors on an unseeded id", and both halves go false once `increment` seeds. Keep `ensure` at engine `execute/section_context.rs:249`, which seeds the visible zeros. +- Change: rename the parameters `names` to `ids` and `name` to `id` in `new`, `ensure`, `increment`, and `snapshot`, whose docs become "Creates a counts map pre-seeded with 0 for every id.", "Ensures `id` is present in the map, seeding it at 0 when new.", "Increments the count for `id`, seeding it at 0 first when new.", and "Returns every seeded id with its count, read under one lock." Engine `error/tests.rs:290` copies the deleted "was not pre-seeded" message to test owned-message passing; give it any other owned message. Keep the user-facing "seeded names" refusal at `tools/stats.rs:108`, which the guide quotes. +- Change: rename `ToolRuntime` to `Offers`, the `SectionVm` field `tool_runtime` and its accessor to `offers` and `offers()`, `ModelRuntime` to `ModelSelection` with its field `used` renamed `selection`, and the field `model_runtime` and its accessor to `model_selection` and `model_selection()`. Sites: `scope.rs:141`, `:146`; `models.rs:201`, `:205`, `:207`, `:254`; `lib.rs:132`, `:144`; `vm.rs:40`, `:42`, `:128`, `:130`, `:212-213`, `:326-327`, `:338-339`; `vm/state.rs:17`, `:20`, `:142`, `:163`, `:190`, `:211`, `:262`; `vm/inject.rs:139`, `:147`, `:160-161`, `:181-182`; `tools.rs:36`, `:115`; `tools/offer.rs:25`, `:35`; `test_support.rs:20`, `:23`, `:199`, `:202`, `:212`, `:218`; `test_vm.rs:176`; `models-tests.rs:8`, `:19`, `:27`, `:41`, `:109`, `:139`, `:220`, `:291`, `:414`, `:425`; `protocol/tests/answer.rs:171`. In the engine, find each site by name instead: `execute/scope.rs` (`vm.tool_runtime()`), `execute/scheduler/tool_call.rs`, `execute/scheduler/dispatch.rs`, `execute/scheduler/chat.rs`, `execute/section_context.rs` (`model_runtime()`), and `execute/tests/tool_scoping.rs`. In the `vm/state.rs` docs at `:126-127`, `:138`, `:155`, `:159`, `:181`, and `:186`, "tool runtime" becomes "offers". +- Change: the four poison messages become "section offers lock was poisoned" (`tools/offer.rs:62`, `vm/state.rs:217`) and "model selection lock was poisoned" (`models.rs:279`, `vm/state.rs:267`). +- Change: the field doc at `vm.rs:127` becomes "The section's offers, read by the executor's scope path."; `tool_bag_handles` (`test_support.rs:197`) becomes "Returns the shared tool set and the live section offers." +- Change: `pub offers` becomes `pub(crate) offers` (`scope.rs:143`); the type stays `pub`. Leave the module doc at `scope.rs:1-3` to work item 4, next in this step. +- Acceptance: `increment_seeds_a_missing_id_and_counts_it` passes. `tools/tests-stats.rs`, `tools/tests-adhoc.rs`, engine `execute/tests/debug_and_counts.rs:153`, `:167`, `:215`, and `execute/tests/discovery.rs:399` still pin exact counts, and `models-tests.rs:24-41` still pins the selection. The "seeded names" refusal text at `tools/stats.rs:108` is unchanged. + +**Work item 4 (S3-2): the first loop round freezes the sets, six docs** + +- Files: `src/lib.rs`, `src/tools.rs`, `src/scope.rs`, `src/messages.rs`, `src/vm/state.rs` +- Scope: docs only. +- Problem: six docs say the section's first model call freezes its sets. Only a loop round attaches them (engine `execute/scheduler/chat.rs:116-142`, `:230`); `models.infer` sends no tools and freezes nothing (engine `execute/scheduler/dispatch.rs:206-231`), as the guide (`12-tools.md:372`, `:913`, `:1172`) and the repository AGENTS.md's Static Set definition say. A reader orders offers before an `infer` for no reason, or expects an offer after one to raise. +- Change: `lib.rs:44-46` becomes "`tools.offer` and `tools.offer_dynamic` remember tools and Packs, by id or object, for the section's Static Set and Dynamic Set, which the section's first loop round freezes; after that, every offer raises. `models.infer` freezes nothing." +- Change: in `tools.rs:16-18`, the sentence ending "froze its sets." becomes "Offering a tool the run does not hold is a hard error, and so is every offer once the section's first loop round froze its sets." +- Change: `scope.rs:1-3` becomes "Per-VM tool state: call counts, the offers a section made, the frozen sets its first loop round built from them, and the Engine's two discovery tools, which join the Static Set beside a Dynamic Set." The first sentence at `scope.rs:167` becomes "The two sets a section entry froze at its first loop round (`models.loop` or a handle's `loop`): the one record of what its model was shown." +- Change: `messages.rs:25-27`, through "exactly as long as the section.", becomes "The list also holds the section's frozen sets, which the Engine attaches at the section's first loop round, so they last exactly as long as the section." +- Change: `vm/state.rs:132` becomes "The section's first loop round freezes its Static Set from these tools." +- Keep: `TOOL_LIST_FIXED` (`tools.rs:60-62`) as it is; the guide quotes it verbatim (`12-tools.md:920`, `:1020`) and `tools/tests-offering.rs:234` pins it. +- Ceiling: `src/messages.rs` sits at the 500-line build ceiling, which fails at 501; the replacement text must fit in no more lines than the three it replaces. +- Acceptance: the docs commands pass, and `tools/tests-offering.rs:234` still pins `TOOL_LIST_FIXED`. + +**Work item 13 (S3-8, S3-9, S1-16, S1-11, S3-19, S3-11): the tool-set, tool-id, static-set, and store-conflict names, and two `tools` docs** + +- Files: `src/vm.rs`, `src/vm/state.rs`, `src/vm/inject.rs`, `src/vm/install.rs`, `src/tools.rs`, `src/tools/stats.rs`, `src/tools/decode.rs`, `src/tools/tests.rs`, `src/tools/tests-adhoc.rs`, `src/tools/tests-render.rs`, `src/protocol/parse/tools.rs`, `src/test_support.rs`, `src/test_vm.rs`, `src/lib.rs`, `src/tests/engine_misuse.rs`, `src/engine_globals-store.rs`, and engine `lua.rs`, `execute/scope.rs`, `execute/scheduler/tool_call.rs`, `execute/tests.rs`, `execute/tests/tool_scoping.rs` +- Problem: four names describe a value they do not hold. `bound_tools` is the run's `ToolSet`, whose doc says "Nothing in it is bound" (`handles.rs:246-247`); `tool_name` returns a tool id; `current_tool_bindings` returns `OfferedTool`s, the static offers, rather than `ToolBinding`s; and `store_conflicts` is one slot that a later conflict overwrites (`engine_globals-store.rs:205`). The `tools` module doc also restates the wire-name fallback `wire_name_of` owns (`tools.rs:5-7` against `handles.rs:126-128`), and `adhoc_params_schema` claims to be the crate's only unsorted `pairs` walk (`tools/decode.rs:74-80`), a ranking no one can check locally. +- Change: rename `bound_tools` to `tool_set` at its eleven sites: `vm.rs:123`, `:210`, `:264`; `vm/state.rs:86`; `vm/inject.rs:138`, `:158`, `:179`; `tools/stats.rs:148`, `:156`; `test_support.rs:201`; and the doc at `tools/stats.rs:140`, which reads "by `tool_set`'s run catalog". Leave the engine's `bound_tool_set`, which is deferred and out of scope. +- Change: rename `tool_name` to `tool_id` at `tools/decode.rs:22`, the re-export at `tools.rs:45`, `protocol/parse/tools.rs:7` and `:63`, and `tools/tests.rs:7`, `:44`, `:47`, `:89`, `:98`, `:109`, `:119`. Leave `model_tool_name`, since a tool's name is its wire name in this crate. +- Change: rename `current_tool_bindings` to `current_static_set` at `vm/state.rs:140`, `vm.rs:50`, `lib.rs:148`, `test_vm.rs:219`, `:234`, `tools/tests-render.rs:85`, `:175`, `tools/tests-adhoc.rs:331`, and engine `lua.rs:12`, `execute/scope.rs:22`, `:73`, `execute/scheduler/tool_call.rs:61`, `:232`, `execute/tests.rs:19`, and `execute/tests/tool_scoping.rs:55`, `:76`, `:137`. +- Change: rename the `SectionVm` field `store_conflicts` to `store_conflict` (`vm.rs:151`, `:220`; `vm/inject.rs:168`, `:189`; `vm/install.rs:51`, `:64`), the test accessor `store_conflicts_handle` to `store_conflict_handle` (`vm/install.rs:72-73`, `tests/engine_misuse.rs:10`), and the `conflicts` field, locals, parameters, and doc mention in `engine_globals-store.rs` to `conflict` (`:70`, `:92`, `:96`, `:199`, `:205`, `:235`, `:255`, `:272`). +- Change: the wire-name sentence at `tools.rs:5-7` becomes "The model sees only wire names, as `wire_name_of` gives them, such as `web_fetch`." The `adhoc_params_schema` paragraph at `tools/decode.rs:74-80` becomes "`properties` is a `serde_json::Map`, a `BTreeMap` while `serde_json`'s `preserve_order` is off, so only `required` needs the sort below." +- Acceptance: the compiler catches every missed site, and `tools/tests.rs:41-120` still pins the decode behavior. + +**Work item 6 (S4-9, S4-10): the messages list hands back its frozen sets** + +- Files: `src/messages.rs`, `src/messages-tests.rs`, `src/tools.rs`, `src/compactors-tests.rs`, `src/tools/tests-offering.rs`, and engine `execute/scheduler/chat.rs` +- Problem: `attach_rendered_tools` (`messages.rs:237-243`) returns `()` though `get_or_insert_with` always leaves the slot `Some`, so engine `chat.rs:140-142` re-reads it with `rendered_tools()` and maps an impossible `None` to `Error::internal("a list holds the sets attached to it")`. Beside it, `rendered()` (`:216-220`) and `rendered_tools()` (`:222-226`) read one field. +- Change: first extend `a_list_is_rendered_only_once_sets_are_attached_and_keeps_the_first` (`messages-tests.rs:65-90`) to assert with `Arc::ptr_eq` that the second `attach_rendered_tools` returns the sets the first attach left in place. Today it does not compile, because the method returns `()`. +- Change: `attach_rendered_tools` returns the held `Arc`; engine `chat.rs:140` uses the returned value and deletes the re-read and its internal error. The workspace denies clippy's pedantic lints, `must_use_candidate` among them, so if clippy asks for `#[must_use]`, add it and bind the result with `let _ =` where a caller drops it: engine `chat.rs:230` (`merge_render`), `compactors-tests.rs:175`, and `tools/tests-offering.rs:228`. +- Change: delete `rendered()`; `refuse_after_render` (`tools.rs:71`), `compactors-tests.rs:182`, and `messages-tests.rs:68`, `:72` use `rendered_tools().is_some()`. +- Ceiling: `src/messages.rs` held 499 lines at the baseline, and the 500-line build ceiling fails at 501; deleting `rendered()` frees the room an added `#[must_use]` needs. `src/compactors-tests.rs` holds 478 lines. +- Acceptance: the extended test passes, and `tools/tests-offering.rs` still pins the offer-after-freeze refusal. + + + + + +### Step 3: split `handles` and `sys`, one walkable read-only proxy, and the dispatch report path [completed] + +- Component: Tool surface +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 14, 15, and 5, in one commit. +- Order: work item 14, then 15, then 5. Work item 14 splits `handles` and `sys`; work item 15 edits `seal_sys` in the slimmed `sys.rs` and builds its proxy in `tools/stats.rs` and `packs.rs`, whose imports work item 14 just moved; and work item 5 rewrites `dispatch.rs` and `tools/tests-parameters.rs`, which work item 14 also re-imports. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, plus each work item's acceptance below; the wording scan in Tests covers work item 5's `AGENTS.md` rulebook edit. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`; the Tests above gate the commit. + +**Work item 14 (S3-6, S3-7, S1-23): split `handles` and `sys`** + +- Files: `src/handles.rs` (becomes `src/bindings.rs` and `src/section.rs`), `src/handles-tests.rs` (becomes `src/bindings-tests.rs`), `src/sys.rs`, new `src/var.rs`, `src/lib.rs`, and the importers listed below +- Problem: `handles` holds tool bindings, the tool set, `RosterEntry`, block results, section targets, section text, and library sections, but no Lua handle (those are `LuaToolHandle`, `LuaModelHandle`, and `LuaPackHandle`), and its module doc (`handles.rs:1-2`) names fewer than half its items. `ToolBinding.id` is documented "The selected stable live identity" (`handles.rs:45`, `:94`), though `from_info` copies `info.id` with no selection (`:70`). `sys.rs` holds one `sys` item, `seal_sys`, beside nine `var` items. +- Change: `bindings.rs` takes `ToolOutputKind`, `ToolBinding`, `wire_name_of`, `RosterEntry`, and `ToolSet`, with the module doc "Tool bindings, the per-binding output kind, the run's shared tool set, and how bound tools reach Lua." `handles-tests.rs`, which tests only those items, becomes `bindings-tests.rs`, wired as `#[cfg(test)] #[path = "bindings-tests.rs"] mod tests;`. +- Change: `section.rs` takes `LuaBlockResult`, `resolve_section_target`, `SectionText`, `LibraryKey`, `Library`, and `LibraryBlock`, with the module doc "What the VM exchanges with its caller about sections: a block's result, a `call` or `jump` target, the text a prompt read sees, and the library sections `require` loads." +- Change: `ToolBinding.id`'s doc becomes "The tool's id." and `id()`'s becomes "Returns the tool's id." +- Change: `var.rs` takes `VAR_DATA_REGISTRY`, `VAR_PROXY_REGISTRY`, `VAR_GUARDED_DATA_REGISTRY`, `guarded_json_value`, `guarded_table`, `materialize_guarded_value`, `guarded_var`, `var_snapshot_table`, and `var_to_json`, with the `# Errors` text step 1 gave the last two; `sys.rs` keeps `seal_sys`. Split the `sys.rs:1` module doc between the two files. +- Change: in `lib.rs`, replace `mod handles;` with `mod bindings;` and `mod section;`, add `mod var;`, and point the root re-exports (`lib.rs:127-130`) at the new modules under the same names, so no public path changes. Update the imports in the 22 files that name `crate::handles::`, all under this crate's `src/`: `dispatch.rs`, `messages.rs`, `packs.rs`, `packs-tests.rs`, `protocol/answer.rs`, `protocol/parse.rs`, `require.rs`, `scope.rs`, `test_support.rs`, `tests.rs`, `tools.rs`, `tools/objects.rs`, `tools/offer.rs`, `tools/stats.rs`, `tools/tests-parameters.rs`, `tools/userdata.rs`, `vm.rs`, `vm/adhoc.rs`, `vm/install.rs`, `vm/run.rs`, `vm/state.rs`, and `lib.rs`. Update the `var` imports from `crate::sys::` at `coro.rs:38`, `prose.rs:30`, `tests.rs:26`, `vm/inject.rs:17`, `vm/install.rs:20`, and `vm/state.rs:22`. A `crate::handles::` hit in `crates/workshop/workspace/src/workspace/tests/close.rs` is that crate's own `handles` module, not this one; leave it. +- Ceiling: `src/messages.rs` sits at the 500-line build ceiling; its edit here is an import path only, so add no lines to it. +- Acceptance: the compiler catches every missed import. The engine imports through the crate root (engine `crates/runvil-internal/engine/src/lua.rs`), so no engine file changes, and every public path stays as it was. + +**Work item 15 (S3-24, S1-22): one walkable read-only proxy, and two seal docs** + +- Files: `src/proxy.rs`, `src/tools/stats.rs`, `src/packs.rs`, `src/sys.rs`, `src/prelude.rs` +- Problem: `tools::stats::proxy` (`tools/stats.rs:255-271`) builds an empty table whose metatable holds `__index`, a `__pairs` over `step`, a `__newindex` raising `mlua::Error::external(" is read-only")`, and that label as `__metatable`. `install_pack_list` builds the same four entries inline for `tools.packs` (`packs.rs:192-239`) with `PACKS_LABEL` (`packs.rs:42`), adding only `__len`, so a hardening change to one misses the other. Separately, the `seal_sys` doc (`sys.rs:143-148`) and prelude `seal` (`prelude.rs:202-204`) restate the contract of `read_only_proxy` (`proxy.rs:26-48`), which both bodies call. +- Change: move `tools::stats::proxy` into `src/proxy.rs` as `pub(crate) fn read_only_walk(lua, index, step, path)`, keeping its `external` refusal, and build `tools.stats`, `tools.stats.packs`, `tools.stats.tools`, and `tools.packs` through it. Do not fold it into `read_only_proxy`, which raises a per-key runtime error and has no `__pairs`; the guide documents both messages (`05-lua-environment.md:1819-1825`, `12-tools.md:569`, `:1249`). +- Change: `install_pack_list` raw-sets `__len` on the metatable `read_only_walk` returns (Rust's `metatable()` reads past `__metatable`), and drops `PACKS_LABEL` and its inline `__pairs`, `__newindex`, and `__metatable` code (`packs.rs:224-239`). Add one line to the `proxy` module doc (`proxy.rs:1-3`) naming `read_only_walk`'s users, `tools.stats` and `tools.packs`. +- Change: the `seal_sys` doc body becomes "A [`read_only_proxy`] over the JSON object: a read of an absent field raises, and a present `null` reads as nil." Prelude `seal`'s doc becomes "Seals a prelude's table global at its top level with a [`read_only_proxy`] over the hidden table." Both files import `read_only_proxy`, so the links resolve. Keep `read_only_var`'s depth rule. +- Acceptance: `packs-tests.rs:95-116` and `tools/tests-stats.rs:105-138` still pin the labels and the protected metatable. They match with `contains`, so they would not catch an added "runtime error: " prefix; keeping `mlua::Error::external` avoids one, and the ` is read-only` text stays exactly as the guide quotes it. + +**Work item 5 (V3-3, S6-17, S3-3, S3-4, S3-5, S5-1): the dispatch report path** + +- Files: `src/dispatch.rs`, `src/dispatch-tests.rs`, `src/tools/tests-parameters.rs`, the crate's `AGENTS.md` (`crates/runvil-internal/lua/AGENTS.md`), and engine `execute/scheduler/apply.rs` +- Problem: `prepare_dispatch` (`dispatch.rs:119-127`) is public with `script: Option`, and under `None` it fires no `ToolResult` (`:157-168`). Its outside callers are `dispatch-tests.rs` at lines 20, 72, 148, 197, and 217, `tools/tests-parameters.rs:155`, and engine `apply.rs:317`, and all but two pass `Some`; `None` comes only from `prepare_model_dispatch` (`dispatch.rs:201`) and the two tests that exercise it (`dispatch-tests.rs:72-80`, `tools/tests-parameters.rs:155-163`). `ModelReport` (`:46-51`) holds its turn in a field named `script`, typed `ScriptReport`, read only as `report.script.turn` (`:212`). Three docs send the reader to "the loop" or to a `tool_call_id` field for a report only `prepare_model_dispatch` fires, the no-call rule is stated four times in the file, and the crate's `AGENTS.md:4` says `prepare_dispatch` applies counts, which it has not since commit `be75abaa7`. +- Design: narrow `prepare_dispatch` instead of documenting `None`. Taking `ScriptReport` and keeping an `Option` body private makes the silent path unrepresentable. `ModelReport` flattens to `turn`, since the old field name asserts a path the call is not on. +- Change: move the body of `prepare_dispatch` into `pub(crate) fn dispatch_body` with the same parameters, `script: Option` included; it is crate-visible only because `tools/tests-parameters.rs:155` sits outside the `dispatch` module. The public `prepare_dispatch` takes `script: ScriptReport` and calls `dispatch_body` with `Some(script)`. `prepare_model_dispatch` and the two tests that pass `None` (`dispatch-tests.rs:72-80`, `tools/tests-parameters.rs:155-163`) call `dispatch_body`. The four other `dispatch-tests.rs` call sites pass `Some` and keep calling the public function. +- Change: `ModelReport` becomes `pub struct ModelReport { pub turn: u32, pub call_id: String }`. Its doc becomes "The run coordinates a model-issued dispatch reports under: the turn the call fired in and the call id the model issued.", and the `turn` field doc "The section's completed model-turn count at dispatch." `dispatch.rs:212` reads `report.turn`, engine `apply.rs:302` builds `turn: call.report.turn`, and `dispatch-tests.rs:93-97` follows. +- Change: `ScriptReport`'s doc (`dispatch.rs:26-29`) becomes "[`prepare_dispatch`] fires the `ToolResult` event with them under an empty `tool_call_id`, since only a model-issued call has one." and keeps "The chain and depth are not part of the report: ...". `ToolDispatch`'s second sentence (`:55-57`) becomes "[`prepare_model_dispatch`] reports the pair through the `ToolResult` event under the model's call id; a script-initiated dispatch reads only the content, its report already fired inside [`prepare_dispatch`]." +- Change: in the public `prepare_dispatch` doc (`:106-110`), the sentence from "A script-initiated call" becomes "It also fires the `ToolResult` event with the final content and `vendor`, the vendor of the tool's Pack." `dispatch_body`'s doc says "A model-issued call passes `None`, and [`prepare_model_dispatch`] reports the returned [`ToolDispatch`] under the model's call id." The public doc must not link `dispatch_body`, which `private_intra_doc_links` denies. +- Change: end the module doc's clause at `:5-7` at "takes the tool's outcome as a value", dropping ", so the Harness performs the call and the Engine applies the same rules when the answer arrives", and keep `:10-14`. Delete the "Nothing here awaits: ..." sentence from `prepare_dispatch` (`:113-114`) and from `prepare_model_dispatch` (`:184-186`). +- Change: the first sentence of the crate's `AGENTS.md:4` becomes "`prepare_dispatch` is the single tool-dispatch body used by every executor: synchronous, it applies the succeeded/failed observation, trust classification, the nonce wrap, and the `ToolResult` report to a bound tool's answer; the executor counts each call when it issues it, and adhoc and discovery tools report in the executor." Keep its second sentence. +- Acceptance: `dispatch-tests.rs` covers both paths (`:16-90`, and from `:100` the model report's turn and call id). No public caller can reach the path that fires no `ToolResult`. + + + + + +### Step 4: the parse module, the `Answer` failure side, the task status phase, and the protocol docs [completed] + +- Component: Protocol types +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc -p runvil --no-deps` + - `Remove-Item Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 8, 9, 10, and 11, in one commit. The operator chose to land work item 9's restructure in this pass, after work item 8, since both edit `YieldParse` and `classify`. +- Order: work item 8, then 9, then 10, then 11. Work item 9 needs work item 8's `YieldParse::Refused`; work item 10 reshapes `TaskStatus` in the `answer.rs` and `render.rs` work item 9 just rewrote; and work item 11 documents `CHAT_PART_TYPES` where work item 8 moved it and the `Answer` work item 9 left. All four overlap in `protocol/parse.rs`, `protocol/answer.rs`, the `protocol/parse/` files, `protocol/record.rs`, and `src/protocol/tests/`. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, plus each work item's acceptance below. The facade docs build and the facade surface check run because work item 11 edits the doc of `VfsOp`, a facade re-export: `cargo +nightly-2026-09-05 xtask api --check` (the nightly `crates/build-xtask/src/api/toolchain.rs` names, run with `CARGO_BUILD_WARNINGS` unset) must pass with `crates/runvil/public-api.txt` unchanged. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`, and for the docs-only work item 11, the workspace docs command in Tests; the Tests above gate the commit. + +**Work item 8 (S2-16, V2-2, S2-17, S2-20): the parse module's names, helpers, and record validators** + +- Files: `src/protocol/parse.rs`, `src/protocol/parse/store.rs`, `src/protocol/parse/tasks.rs`, `src/protocol/parse/tools.rs`, `src/protocol/parse/chat.rs`, `src/protocol/record.rs`, `src/protocol.rs`, `src/messages.rs`, `src/vm/run.rs`, `src/protocol/tests/parse.rs`, `src/protocol/tests/parse_tasks.rs`, `src/protocol/tests/parse_tool_call.rs`, `src/protocol/tests/parse_chat.rs`, `src/messages-tests-view.rs`, and engine `execute/scheduler/step.rs` and `lua/tests/shims.rs` +- Problem: `YieldParse::Call` (`parse.rs:180-190`) and `FieldFailure::Call` (`parse.rs:67-75`) mean "an author-supplied argument failed validation", while `Request::Call` and `Answer::Call` mean the `call(target, input?)` request, so tests read `YieldParse::Call(Answer::Call(Err(..)))` (`protocol/tests/parse.rs:269`) and the engine needs a comment at `step.rs:80-82` to explain the variant. `parse::store` builds `FieldFailure::Call(Error::Lua(format!(..)))` by hand at `store.rs:33-36` and `:80-82` instead of through `call_error` (`parse.rs:77-80`), the helper every other per-op parser uses. `direct_yield` (`:46`), `raw_field` (`:54`), and `json_field` (`:59`) are a three-layer chain whose errors are all discarded: `raw_field` has one caller at `:209` and `json_field` one at `:163`, and `json_field` itself calls `direct_yield` twice (`:61`, `:62`). The record validators sit in the chat request parser (`parse/chat.rs:16-27`, `:67-288`), which never calls them; their callers are the list builders (`messages.rs:312`, `:456`) and tests. +- Change: `parse::store` uses `call_error(format!(..))` at both sites, imports it from `super`, and drops `use crate::Error;` (`store.rs:6`). +- Change: rename `YieldParse::Call` to `YieldParse::Refused` and `FieldFailure::Call` to `FieldFailure::Argument` at their definitions, in `classify`, `call_error`, the docs of `Request::from_yield` and `SectionVm::request_from_yield`, `vm/run.rs:102`, `parse/tasks.rs`, `parse/tools.rs`, engine `execute/scheduler/step.rs:83` (deleting its comment at `:80-82` if it only explains the name), engine `lua/tests/shims.rs:325`, and the tests in `protocol/tests/parse.rs`, `parse_tasks.rs`, `parse_tool_call.rs`, and `parse_chat.rs`. The `call_*` helpers keep their names; there "call" means the shim call. +- Change: delete `direct_yield`, `raw_field`, and `json_field`, keeping `DIRECT_YIELD` and `direct_yield_error` as the one builder. Read `op` (`parse.rs:209`) with `table.raw_get::("op")` as the other readers do, and give `shim_var` (`:156-164`) `json_field`'s raw read and JSON conversion inline, each failure mapping to `FieldFailure::Malformed`. Add `raw_field`'s constraint to the module doc: "Field reads are raw: the table comes from script space, so a metatable must not intercept or forge a field." +- Change: move `parse_message`, `parse_content_part`, `parse_content_parts`, `parse_tool_call_record`, `chat_roles`, `CHAT_PART_TYPES`, `content_refusal`, and `image_url_refusal` from `parse/chat.rs` into `protocol/record.rs` beside `MessageRecord` and `MAX_RECORD_META_BYTES`, and rename `parse_message` to `parse_record`, deleting the one-line wrapper (`chat.rs:67-72`). Update the paths at `messages.rs:44`, `protocol.rs:36`, `protocol/parse.rs:17`, and `messages-tests-view.rs:9`. The `parse::chat` module doc (`chat.rs:1-3`) and the `parse` module doc (`parse.rs:1-6`) then describe only the requests, and `MessageRecord::to_json` (`record.rs:101-104`) says "the record parse" in place of "the chat parse". +- Ceiling: `protocol/record.rs` holds 156 lines and gains about 235, staying under 500. `src/messages.rs` sits at the 500-line build ceiling; its edit here is a path change only, so add no lines to it. +- Acceptance: `protocol/tests/parse.rs:85-203` (`assert_direct_yield`) still pins every malformed path, and `messages-tests-view.rs:47-98`, `messages-tests-records.rs`, and `messages-tests-list.rs` still pin the record refusal texts, which the move leaves unchanged. + +**Work item 9 (S2-11): the `Answer` failure side** + +- Files: `src/protocol/answer.rs`, `src/protocol/render.rs`, `src/protocol/parse.rs`, `src/protocol/parse/store.rs`, `src/protocol/parse/tasks.rs`, `src/protocol/parse/tools.rs`, `src/protocol/parse/chat.rs`, `src/vm/run.rs`, every file under `src/protocol/tests/`, and in the engine `execute/scheduler/`, `execute/error.rs`, `lua/tests/errors.rs`, and `lua/tests/shims.rs` +- Scope warning: this is the widest work item in the plan. A search of the engine finds 28 files that name `Answer` and 27 `Answer::*(Err(..))` constructions, well beyond the sites listed below. Do not treat the list as complete: after the type change, build and work through every compiler error. Grep `Answer::` across `crates/`, not just the engine. +- Problem: every `Answer` variant (`answer.rs:246-306`) wraps its own `Result`, but the Lua side never reads the variant of a failed answer: `render` sends `(false, table)` for all of them through one or-pattern (`render.rs:158-178`). Each engine failure site picks a variant only to wrap the error, `dropped_answer` (engine `apply.rs:95-103`) calls the store variant "the harmless stand-in" for a timer, and the one engine reader of a failed variant, `fill_batch_slot` (engine `tool_call.rs:254-276`), treats any failed non-`ToolCallResult` answer as an internal error nothing produces. +- Change: `Answer` holds success values only, with `JoinAny` keeping `TaskDelivery` for a member's failure; `map_error` (`answer.rs:308-334`) keeps only the variants that still carry `E`; render and resume take `Result, E>`; `YieldParse::Refused` (work item 8's name, set earlier in this step) holds a bare `Error`; and `classify` (`parse.rs:247-258`) loses its `answer` parameter, so the fourteen per-op closures at `parse.rs:216-242` go. +- Change: sites the analysis confirmed, as a starting set rather than an exhaustive one. The engine's failure wrappers: `apply.rs:95-103` (delete `dropped_answer` and its comment), `apply.rs:254`, `tasks-spawn.rs:52`, `waits.rs:85`, `dispatch.rs:138`, `:200`, `tool_call.rs:121`, `chat.rs:73`, `:204`, `chain.rs` (the `Answer` construction near `:235`), and `step.rs:83`. Functions and fields that take `Answer` and need the new shape: `chain_ops.rs`, `scheduler.rs`, `admission.rs`, and `chain_record.rs`. Whole-`Result` wrappers the analysis missed: `timer.rs`, `tool_call-adhoc.rs`, and further sites in `apply.rs`, `waits.rs`, `tool_call.rs`, and `chat.rs`. All of these are under engine `execute/scheduler/`. +- Change: `fill_batch_slot` becomes `Err(error) => Err(error)` for every failure; `SectionVm::resume_block_coro_answer` (`vm/run.rs`) and the `Retained` handling in `render.rs` follow the new signature, and so does any other `vm/run.rs` resume method the compiler flags. +- Change: the tests that pin a per-op failure variant change with it: `protocol/tests/parse.rs:221-275`, `parse_tasks.rs:112-291`, `parse_tool_call.rs:154-207`, `parse_chat.rs:43`, `answer.rs:56`, `:231`, `answer_chat.rs:10`, `answer_retained.rs:34`, engine `lua/tests/errors.rs:131`, `:267`, and the success-side constructions throughout the engine's own test files. `lua/tests/shims.rs` also names the variant. +- Acceptance: the compiler checks every match on `Answer`, which is the real completion test for this work item. The Lua envelope is unchanged: every failure still resumes the coroutine with `(false, table)`, and every success with the same values as before. + +**Work item 10 (S2-12): the task status phase holds the outcome** + +- Files: `src/protocol/answer.rs`, `src/protocol/render.rs`, engine `execute/scheduler/waits.rs`, and engine `execute/scheduler/tasks.rs` +- Problem: `TaskStatus.ok` (`answer.rs:228-230`) is `None` exactly when the phase is `Running` and `Some(false)` for `Cancelled` and `Abandoned`; only `Done` needs a value of its own. The one builder (engine `waits.rs:136-164`) derives both fields from one engine `TaskState`, which already holds `Done { ok }` and `Delivered { ok }` (engine `execute/scheduler/tasks.rs:95-117`), so the protocol type re-splits a fact the engine keeps whole. +- Change: `TaskPhase::Done { ok: bool }` (`answer.rs:158-169`), and delete `TaskStatus.ok`. The renderer (`render.rs:25-45`) derives the unchanged Lua `ok`: absent for `running`, the flag for `done`, and `false` otherwise. `task_phase` (engine `waits.rs:46-55`) maps `Done { ok } | Delivered { ok }` to `Done { ok }`, and the builder drops `ok: state.ok()` (`waits.rs:142`). Keep `section` and `blocked` as they are; both are `Option` even while running. +- Change: `TaskState::ok` (engine `execute/scheduler/tasks.rs:112`) has exactly one caller, `waits.rs:142`, so deleting that call leaves it dead. Delete the method too, or the `CARGO_BUILD_WARNINGS=deny` gate fails on it. +- Change: the unit-variant uses of `TaskPhase::Done` stop compiling once it takes a field. The phase-name match at `answer.rs:178` (`TaskPhase::Done => "done"`) becomes `TaskPhase::Done { .. } => "done"`; engine `waits.rs:51` is the `task_phase` arm the change above rewrites. The `TaskStatus.phase` doc at `answer.rs:226` links [`TaskPhase::Done`], which still resolves. +- Acceptance: the Lua `ok` field reads exactly as before for every phase. Engine `execute/tests/waits.rs`, `execute/tests/timeouts.rs`, and `execute/tests/scheduler/concurrency.rs` exercise `tasks.status`. + +**Work item 11 (S2-10, S2-13, V2-1, S2-18, S2-19, S2-21, S2-22, S2-24, S2-25): the protocol docs** + +- Files: `src/protocol.rs`, `src/protocol/answer.rs`, `src/protocol/request.rs`, `src/protocol/parse.rs`, `src/protocol/parse/tasks.rs`, `src/protocol/parse/chat.rs`, `src/protocol/record.rs`, `src/compactors.rs` +- Scope: docs only. +- Problem: the protocol docs miss most task requests in a list that claims to be the audit surface, predate structured and adhoc tool outputs, say an abandoned task is delivered though it never is, restate four rules in eleven places, call a role-unchecked field assistant-only, promise data-URI images where any URL parses, say every store call yields though Pack preludes call the direct closures, and say arguments are checked once at parse while describing a run-time check. +- Change: in `protocol.rs:4-12`, the sentence holding the parenthetical list becomes "Every suspending Engine call, one per `Request` variant, is a Lua-side shim that yields a request table; the driver validates the yield into a `Request`, dispatches it, and resumes the coroutine with the `(ok, result)` envelope rendered from an `Answer`." +- Change: the `ToolCallOutcome` doc (`answer.rs:44-48`) becomes "One dispatched `tools.call`'s successful answer: a bound tool's output, classified by the binding's declared `ToolOutputKind` so the envelope resumes the right Lua shape (plain text as a Lua string, structured JSON as a Lua table, the Engine performing the one conversion), or an adhoc tool's handler for the shim to run." Replace the whole doc, not just 44-46, or two lines are orphaned. In the `Plain` doc, delete only "every existing tool, ". The `TaskDelivery` clause at `answer.rs:142-143` becomes "the `cancelled` value for a task its owner cancelled (an abandoned task is never delivered, since its owner has ended)". +- Change: the target rule. In `Request::from_yield` (`parse.rs:200-204`), "One boundary conversion keeps its own byte-identical error:" becomes "One argument keeps another boundary's error:". `call_target` (`parse.rs:166-168`) becomes "Reads the author-supplied `target` of a `call` or `spawn`; a non-string target fails as `resolve_section_target` fails." Delete the parenthetical "(validated with the `resolve_section_target` rule, keeping its byte-identical error)" from `parse_call` (`parse.rs:296-298`) and "(validated with the `resolve_section_target` rule, as `call`'s is)" from `parse_spawn` (`parse/tasks.rs:35-38`). `Request::Call.target` (`request.rs:54-55`) and `Request::Spawn.target` (`request.rs:68-69`) become "The heading string." +- Change: the handle, empty-list, and adhoc rules. Keep the clone rule in the `Request` type doc (`request.rs:34-39`) and `call_handle` (`parse.rs:272-294`). `Request::Chat.binding` (`request.rs:195-198`) becomes "The receiver of a handle's `loop`, as its frozen binding; `None` when the round names no handle, and the driver resolves the section's current model." `parse_infer` (`parse.rs:260-265`) becomes "Parses an `infer` request: the author-supplied `prompt` and the optional handle `call_handle` reads." From `parse_chat` (`parse/chat.rs:35-39`), delete "An empty list parses: the driver refuses it at dispatch." and "A handle failure is the call's error, raised at the `models.loop` call site so a program `pcall` catches it." From the `Request::Chat` summary (`request.rs:185-186`), delete "The dispatch arm first refuses an empty list;" and keep the rule on `Request::Chat.list` (`:191-192`). Delete the second paragraph of the `answer` module doc (`answer.rs:4-9`), keeping the adhoc flow in the `request` module doc (`request.rs:5-10`). +- Change: the `MessageRecord.tool_calls` doc (`record.rs:94-95`) becomes "The normalized tool calls the record holds. Only an assistant record may send them: the record parse accepts them on any role, and the projection refuses them on every other role." +- Change: `ContentPart`'s doc (`record.rs:12-13`) says "visible text or an image URL"; `ImageUrl` (`record.rs:18`) becomes "An image URL; the record parse does not restrict its scheme."; the `CHAT_PART_TYPES` doc, now in `record.rs` since work item 8 moved it earlier in this step, ends "(the Multimodal contract: text parts and image-URL parts)"; and `compactors.rs:236` says "the URL's length" in place of "the data-URI length". +- Change: `Request::Store` (`request.rs:201-205`) becomes "`store.*(...)`: one run-scoped store operation as a leaf yield. Once a VM's yield shims install, every operation takes this path, memory- and real-file-backed alike, with no inline fast path, so interleaving behavior never depends on the backend. Pack preludes run before that and use the direct store closures." Leave `coro.rs:370-373` and `__impl_coro.lua:271-275`, which are accurate. +- Change: the `VfsOp` doc's second paragraph, at `request.rs:230`, which reads "The Engine checks the arguments once, when it parses the call.", becomes "The parse checks each argument's shape; the operation checks line ranges when it runs." Leave the summary sentence at `:227-228` alone, and leave the serde form untouched. +- Acceptance: the docs commands pass, the facade docs build and the facade surface check pass with `crates/runvil/public-api.txt` unchanged, and the new `VfsOp` sentence names no internal crate. + + + + + +### Step 5: the test-only hooks, one coroutine shim installer, the `sys` mirror at injection, and the VM docs [completed] + +- Component: VM setup and models loop +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 20, 21, 22, 17, and 23, in one commit. +- Order: work item 20, then 21, then 22, then 17, then 23. Work item 21 needs work item 20's `stash_shims` edits (the decision record requires 20 before 21); work item 22's `coro.rs` doc edit sits beside the loop-shim mention work item 21 deletes; work item 17 changes `SectionVm`'s fields and `Injected` in the `vm/inject.rs` and `vm/install.rs` that work items 21 and 22 edit; and work item 23 then rewrites the `vm.rs` docs around the fields work item 17 leaves. All five overlap in `coro.rs`, `__impl_coro.lua`, `test_support.rs`, `lib.rs`, `vm.rs`, `vm/inject.rs`, and `vm/install.rs`. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, including the new batch-turn parse test, plus each work item's acceptance below. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`, and for the docs-only work item 23, the workspace docs command in Tests; the Tests above gate the commit. + +**Work item 20 (S1-4, S2-23): the test-only tool-call hook and the tool-call request shape** + +- Files: `src/coro.rs`, `src/__impl_coro.lua`, `src/test_support.rs`, `src/protocol/request.rs`, `src/protocol/parse/tools.rs`, `src/lib.rs`, `src/protocol/tests/parse_tool_call.rs`, and engine `lua.rs`, `execute/scheduler/dispatch.rs`, `execute/scheduler/tool_call.rs`, `execute/scheduler/effects.rs`, `execute/tests/tool_call_arm.rs`, `execute/tests/full_id_calls.rs`, `execute/tests/tool_call_arm-adhoc-handlers.rs` +- Problem: `stash_shims` stores `model_tool_call` under `MODEL_TOOL_CALL_REGISTRY` on every VM (`coro.rs:294-296`), though its only reader is `test_support::install_model_tool_call_shim` (`test_support.rs:94-102`), whose one caller is behind `#[cfg(test)]` (engine `execute/section_vm.rs:192-195`). That hook, `tools.call_as_model`, is also the only producer of a third `Request::ToolCall` state: production yields `(None, None)` from the script shim (`__impl_coro.lua:197-200`) and `(Some, Some)` from the loop (`models_loop.rs:361-369`), `(Some, None)` comes only from the hook, and `(None, Some)` has no producer, yet the engine keeps `CallSite::Script { turn: Option }` for it (engine `effects.rs:88-125`). +- Change: gate the `model_tool_call` stash in `stash_shims` and the `MODEL_TOOL_CALL_REGISTRY` constant behind `#[cfg(any(test, feature = "test-support"))]`. `stash_shims`'s doc (`coro.rs:282-283`) stops naming `model_tool_call`, and the constant's doc (`coro.rs:80-85`) says test builds stash it for `install_model_tool_call_shim` rather than every prelude install. Work item 21, next in this step, removes `loop` and `compact` from the same `stash_shims` doc. Optionally move `tools_call_as_model` (`__impl_coro.lua:202-219`) into a test-only chunk that `install_model_tool_call_shim` loads, so production VMs never compile it. +- Change: `Request::ToolCall { alias, args, model: Option }` with `pub struct ModelCall { pub call_id: String, pub turn: u32 }`, documented on the type and both fields and re-exported beside `Request` in `lib.rs`, since the workspace warns on `missing_docs` and `unreachable_pub`. +- Change: `ToolCallFields` (`parse/tools.rs:12-19`) is shared by a `tools.call` request and one entry of the loop's `tool_calls` batch, and a batch entry carries no turn of its own; `parse_tool_calls` reads one `turn` for the whole batch (`:108`) and every entry's `call_id` is required (`:112-126`). So `ToolCallFields` cannot simply take the new shape. Keep `call_id: Option` and `turn: Option` in it, and have the two callers assemble `ModelCall`: `parse_tool_call` (`:21-36`) builds `model` only when `call_id` is `Some`, pairing it with its own `turn` and failing as malformed when a `call_id` arrives without a turn; `parse_tool_calls` builds `model` from the entry's required `call_id` and the batch's `turn`. +- Change: add a parse test in `src/protocol/tests/parse_tool_call.rs` for a batch of two entries asserting both carry the batch turn. The `turn: None` parse test (`protocol/tests/parse_tool_call.rs:62-75`) follows the new rule: a `call_id` without a `turn` now fails as malformed, so it asserts that refusal. +- Change: in the engine, `CallSite::Script` drops `turn` and `CallSite::Model.turn` becomes `u32`; update `execute/scheduler/dispatch.rs:101-108`, `tool_call.rs:106-116`, `call_turn` (`tool_call.rs:202-208`), and `tool_call.rs:311-315`. +- Change: the hook takes a required turn argument, since the live turn counter lives in the engine (`chain.ctx.turns()`), not in Lua. Update its five calls (engine `execute/tests/tool_call_arm.rs:87`, `full_id_calls.rs:236`, `tool_call_arm-adhoc-handlers.rs:99`, `:172`, `:215`). +- Acceptance: the batch test passes and both batch entries carry the batch turn. The engine dev-dependency enables `runvil-lua/test-support` (engine crate `Cargo.toml:33`), so the gated hook still serves those five tests, and `protocol/tests/parse_tool_call.rs:36-108` still pins the parse. A production build has no `MODEL_TOOL_CALL_REGISTRY`. + +**Work item 21 (S1-5, V1-2): one coroutine shim installer** + +- Files: `src/coro.rs`, `src/vm/install.rs`, `src/lib.rs`, `src/prelude-tests.rs`, and engine `execute/section_vm.rs` and `lua.rs` +- Problem: the only production setup calls `install_coro_shims` and then `install_section_loop_shim` back to back (engine `execute/section_vm.rs:176-177`). Commit `19c16834a` split the loop install out so agent VMs would not receive it, and no agent VM remains, yet the public installer, its re-export, the `LOOP_REGISTRY` and `COMPACT_REGISTRY` stashes (`coro.rs:68-74`, `:285-290`), and a runtime "prelude never installed" error survive for a call order that excludes them. `install_coro_shims`'s doc (`vm/install.rs:132-134`) also says `max_tool_iterations` serves only the `models.loop` shim a later install adds and that a VM without it "passes any value", though `loop_begin` spends the cap for a model handle's `loop` too (`coro.rs:199-205`, `__impl_coro.lua:260-261`). +- Design: one coroutine shim installer. `install_coro_shims` installs `models.loop` and `models.compact` itself. `max_tool_iterations` stays on it, because a model handle's `loop` spends the cap whether or not `models.loop` exists. +- Change: the shims table lives in `install_shim_prelude` (`coro.rs:275` calls `stash_shims(lua, &shims)`), not in `install_coro_shims`, which is a thin wrapper in `vm/install.rs:138` that passes five arguments into it. So the loop install belongs in `install_shim_prelude`, right after `stash_shims`, where the `shims` table is still in hand: read `shims.raw_get("loop")` and `shims.raw_get("compact")` and `raw_set` them on the `models` table there. The `models` table already exists at that point, because `install_models` runs during injection (`vm/inject.rs:143`), before any shim installer. `install_shim_prelude` moves `models` by value into the chunk call, so clone it or re-read the global before the loop install. +- Change: delete `install_section_loop_shim` (`coro.rs:347-353`), its re-export (`lib.rs:115`, keeping `install_store_shims`), the two registry stashes in `stash_shims` and the `LOOP_REGISTRY` and `COMPACT_REGISTRY` constants, and the doc mention at `coro.rs:136-139`. `stash_shims`'s doc becomes "Stashes the shim chunk's `handle_methods` and `store` under their registry keys, for the installs and the handle getters that read them after the prelude.", plus the test-build `model_tool_call` stash if work item 20 left a mention of it there. +- Change: update engine `execute/section_vm.rs:182` (the `crate::lua::install_section_loop_shim(vm.lua())?` line) and its doc near `:103`, the engine `lua.rs` re-export list, and `prelude-tests.rs:16`, `:30`. +- Change: the `install_coro_shims` doc (`vm/install.rs:130-134`) becomes "Installs the coroutine yield shims (`models.infer`, `models.loop`, `models.compact`, `call`, `tasks.fanout`, `tools.call`, the `tasks` namespace) through `install_shim_prelude`, which also puts `models.loop` and `models.compact` on the `models` table." followed by "`max_tool_iterations` is the run's resolved round cap, which every `models.loop` and model handle `loop` call on this VM spends." Keep the parameter on this installer and keep its `# Errors`. +- Change: VMs built through `TestVm::shims()` (`test_vm.rs:384-391`) now hold `models.loop` and `models.compact`. No test asserts their absence on such a VM: `models-tests.rs:91` (`a_handle_reads_nil_methods_where_the_shim_prelude_never_ran`) builds through `models_vm()`, which installs no shim prelude, so it still passes. `prelude-tests.rs:27-32` (`prelude_vm`) calls `install_section_loop_shim` directly after `.shims()`, so its line 30 goes with the installer, the `install_section_loop_shim` import at `:16` with it, and the doc at `:23-25` then says the yield shims install `models.loop` too. +- Acceptance: engine `execute/tests/models_loop.rs`, `models_loop_contract.rs`, `models_loop-arguments.rs`, and `unified_pipeline.rs` run through `setup_section_vm` and still pass, and `models-tests.rs:91` still passes. Every section VM still holds `models.loop` and `models.compact` exactly as before. + +**Work item 22 (S1-14, S1-15): the test-only VM methods and the "scheduler mode" wording** + +- Files: `src/vm/run.rs`, `src/vm/inject.rs`, `src/test_support.rs`, `src/lib.rs`, `src/vm/install.rs`, `src/coro.rs`, `src/test_vm.rs`, `src/__impl_coro.lua`, `src/__impl_fanout.lua`, `src/__impl_tasks.lua` +- Problem: `resume_block_coro` (`vm/run.rs:80-92`) is called only by tests (engine `lua/tests/errors.rs:295`, `lua/tests/coroutine.rs:85`, `src/tests/require.rs:491`), since production resumes through `resume_block_coro_answer` (engine `execute/scheduler/step.rs:158`). `inject_values` (`vm/inject.rs:45-59`) is called only by tests and the bench, since production injects through `inject_values_with_var`. Eleven sites in nine files present "scheduler mode" or a "direct chunk path" as a production mode, though every production block runs through `start_block_coro` and `run_chunk` lives in `test_support`. +- Change: keep `resume_block_coro` where it is and gate it with `#[cfg(any(test, feature = "test-support"))]`. It cannot move to `test_support.rs` as it stands: its body calls `step_block_coro` (`vm/run.rs:143`), a private function in the `vm` module that `test_support` at the crate root cannot reach. The gate is one attribute instead of a move, and `step_block_coro` stays beside `resume_block_coro_answer`, its production sibling. +- Change: move `inject_values` (`vm/inject.rs:45-59`) into a new `impl SectionVm` in `test_support.rs`; it calls only public API, so the move compiles. Retarget the two doc links that name `resume_block_coro`: in `start_block_coro`'s doc (`vm/run.rs:54`), "[`resume_block_coro`](Self::resume_block_coro)" becomes "[`resume_block_coro_answer`](Self::resume_block_coro_answer)", and in `CoroStep`'s doc (`vm/run.rs:275`), "[`SectionVm::resume_block_coro`]" becomes "[`SectionVm::resume_block_coro_answer`]". The bench already declares `required-features = ["test-support"]`. +- Change: `lib.rs:29-30` ends "which surfaces from the block's coroutine (`SectionVm::start_block_coro` and its resumes) as a Lua-category error."; `lib.rs:32` begins "The VM also holds `tasks`, the shims for spawning, ...". +- Change: `vm/install.rs:111` becomes "Installs the control surface: `jump` and the `prompt` reads as Rust callbacks (none suspends)." In `install_control_globals` (`vm/install.rs:80`), "Called once by `runvil-engine` after Engine injection." becomes "Test-only: production installs `jump` and the `prompt` reads through [`install_scheduler_control_globals`](Self::install_scheduler_control_globals) and `call` as a yield shim." +- Change: `coro.rs:124-126` begins "The coroutine standard library loads for the shim's `yield` capture (a test VM that never installs the shims keeps exactly `STRING | TABLE | MATH | UTF8`);" and keeps the rest of the sentence. +- Change: the `run_chunk` doc (`test_support.rs:154`) becomes "The test path that runs a section's Lua block without the scheduler; production drives blocks through [`start_block_coro`](Self::start_block_coro)."; `test_vm.rs:357` becomes "The control globals, whose prompt reads find no section, and every coroutine yield shim." +- Change: the first comment line of `__impl_coro.lua`, `__impl_fanout.lua`, and `__impl_tasks.lua` says "a section VM" in place of "a scheduler-mode section VM". The eleventh site, "the direct chunk path" at `hardening.rs:104`, already went with step 1's `CancelHook` doc rewrite (work item 2); confirm it is gone. +- Acceptance: the moved and gated methods' callers compile under `cfg(any(test, feature = "test-support"))`, and `broken_intra_doc_links` in the docs commands flags any production doc still linking a moved method. A search for "scheduler mode" and "direct chunk path" in `crates/runvil-internal/lua/src/` finds nothing. + +**Work item 17 (S1-6, S4-7): build the `sys` mirror at injection, and one prose installer** + +- Files: `src/vm.rs`, `src/vm/inject.rs`, `src/vm/install.rs`, `src/require.rs`, `src/prose.rs`, `src/globals.rs` +- Problem: `sys_live: Arc>>` is created `None` (`vm.rs:146`, `:219`) and set to `Some` inside `install_values` (`vm/inject.rs:126-132`) before `injected()` returns the injected VM; a failed install hands the VM back as `InjectFailure`, which is only torn down (`vm/inject.rs:22-42`). Every reader is a method of the injected `SectionVm` (`install_lazy_prose`, `install_require`, `re_seal_sys`), so every `None` arm is dead, and `NOT_INJECTED` (`vm.rs:59-61`) is read only at `prose.rs:133`. The `Option` spreads into `require::VmShare.sys_live` (`require.rs:55`), `prose_captures` (`require.rs:196-206`), and `prose::{install, swap, lazy_read}` (`prose.rs:73`, `:90`, `:111`). On those same signatures, `globals::install_prose` (`globals.rs:116-124`) writes the slot `replace_prose` (`globals.rs:126-137`) writes, and its one caller, `prose::install` (`prose.rs:63-79`), is `swap` (`prose.rs:90-96`) without the return value. +- Design: the `SectionVm` typestate already guarantees injection before every reader, so the mirror is created when `install_values` succeeds and the `Option` with its dead `None` arms goes. +- Change: in `inject_values_with_var`, once `install_values` succeeds, create `Arc::new(Mutex::new(sys.clone()))` and pass it to `injected()` (`vm/inject.rs:155`) as a field of `Injected` beside `access` (`vm.rs:93-95`), so `SectionVm` loses its `Option` field and `install_values` loses its lock block. Every signature above takes `&Arc>`; delete `NOT_INJECTED` and the `ok_or_else` at `prose.rs:133`, keeping `SYS_LIVE_POISONED`. +- Change: delete `globals::install_prose`; `prose::install` returns `swap(lua, sys_live, render).map(drop)`, as `restore` (`prose.rs:102-104`) already does over `replace_prose`. Its caller at `vm/install.rs:298` is unchanged, and the `# Errors` texts step 1 set on `prose::install`, `swap`, and `lazy_read` stay. +- Change: the sites to update are `vm.rs:59-61`, `:146`, `:219`; `vm/inject.rs:126-132`, `:155-198`; `vm/install.rs:193`, `:274-278`, `:298`; `require.rs:55`, `:85`, `:196-206`; and `prose.rs:31`, `:73`, `:90`, `:111`, `:128-134`. Deleting the lock block also leaves the `SYS_LIVE_POISONED` import at `vm/inject.rs:11` unused; remove it. +- Acceptance: engine `execute/tests/model_and_reply-globals.rs:64` (prose sees `sys.model` after `re_seal_sys`) and `:101` (a fanout arm sees it), engine `execute/tests/suite/lazy_prose.rs`, and `src/tests/require/prose.rs` still pin the lazy read. + +**Work item 23 (S1-8, S1-9, S1-10, S1-13, S1-24, S4-28): the VM docs** + +- Files: `src/vm.rs`, `src/vm/state.rs`, `src/walk_budget.rs`, `src/argv.rs` +- Scope: docs and one comment only. +- Problem: `apply_lua_limits`'s doc (`vm.rs:279-289`) names two limits and omits the heap ceiling it records for the walk budget (`vm.rs:294`, `walk_budget.rs:56-64`) and the byte budget it resets (`vm.rs:296-297`), and "non-env" (`vm.rs:203`, `:285`) contrasts with an environment configuration removed in commit `545535a10`. `SectionVm::new` says the VM retains no emitter (`vm.rs:183-185`), though the store and `require` globals keep clones. The `Uninjected` doc (`vm.rs:84-85`) groups the ungated `set_cancel` with two stage-gated calls, inviting a move that breaks four tests. `install_tool_call_counts` explains where its delegation lives (`vm/state.rs:77-78`). `walk_budget.rs` says a limit "converts as it does today". The `argv` installers restate the frozen rule and the malformed check the public `Argv` docs state. +- Change: `apply_lua_limits`'s second paragraph becomes "Sets the heap ceiling (`lua_memory_bytes`), records it for the conversion walk budget, and resets the `log()` event budget (`lua_log_events`) and the byte budget derived from it. Called by the executor right after construction, ahead of the Pack preludes and every block, so they spend the run's limits rather than the defaults `SectionVm::new` installs." In the body comment at `vm.rs:203`, "A safe non-env default" becomes "A default". +- Change: in `SectionVm::new` (`vm.rs:183-185`), "Every lifecycle report goes through the emitter the caller hands each step; the VM retains none" becomes "Construction and teardown report through the emitter the caller hands each step; the persistent globals that `install_engine_globals` and `install_require` install keep clones of theirs." The `Uninjected` doc becomes "A section VM's stage before Engine injection: construction, the run's limits, and the raw-model-id opt-in." Leave `set_cancel` where it is. +- Change: delete the paragraph at `vm/state.rs:77-78` ("The installation itself sits in the `tools` module; this method only supplies the VM's own state."). +- Change: `CHECK_DEPTH_LIMIT` (`walk_budget.rs:21-23`) becomes "The most table levels `check_value` descends, the top-level table being level 1. A table nested past it is not counted."; the inline comment at `walk_budget.rs:105-106` becomes "An entry that cannot be read ends the walk of this table, and the conversion that follows reports it." +- Change: `install_frozen` (`argv.rs:53-54`) becomes "Installs `argv` frozen, the mode of every section but H1: the value H1 left behind at the freeze."; `install_writable` (`argv.rs:39-41`) becomes "Installs `argv` as a plain writable global: the H1 pass's mode. `None` (malformed args, or JSON null) installs nil." Keep the module doc and the public `Argv` docs, which engine readers see through the `lib.rs:110` re-export. +- Acceptance: the docs commands pass. + + + + + +### Step 6: the compaction retry state, the snapshot words, and the models family docs [completed] + +- Component: VM setup and models loop +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Work items: 7 and 19, in one commit. +- Order: work item 7, then 19. Work item 7 changes the loop machine and the closure in `models_loop.rs`, and work item 19 then rewrites `loop_begin`'s doc in the same file over the closure work item 7 leaves. Work item 19's `install_models` doc names `models.loop` and `models.compact`, which step 5 (work item 21) now installs through the one shim installer. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, plus each work item's acceptance below. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`; the Tests above gate the commit. + +**Work item 7 (S4-12, S4-11): the compaction retry state and the snapshot words** + +- Files: `src/models_loop/machine.rs`, `src/models_loop.rs`, `src/messages-snapshot.rs`, `src/models_loop/tests.rs`, `src/models_loop/machine-tests.rs` +- Problem: `Machine.snapshot: Option` (`machine.rs:72-74`) sits beside `Phase::Compacting(OverflowReason)` (`:87`), and the list's compacting flag is set and cleared by hand at five sites (`models_loop.rs:184`, `:266`; `machine.rs:212`, `:319`, `:416`). At `machine.rs:210-214`, a Compacting phase with no snapshot clears the flag and raises `ErrorKind::Internal`, a state the code never builds. The snapshot also outlives the flag by design: after a kept compaction, `step` puts it back (`:216-218`) and `chat()` enters `Phase::Chatting` still holding it, so `judge` and `overflow` (`:276-279`, `:313-316`) can restore the list and raise `context_exhausted` on a second overflow. The snapshot module calls the last send and the frozen sets "rendered tool list" and "send state". +- Design: two phase variants carry the retry state, not one field move: `step` restores the snapshot and `chat` retries holding it, so merging it into `Compacting` alone would drop the retry state. A `Compaction` value owning the flag end to end is deferred. +- Change: `Phase::Compacting(OverflowReason, Snapshot)` and `Phase::Chatting(Option)`, where `Some` means the round has compacted and holds the retry state. `read_input`'s match (`models_loop.rs:294`, `:298`) becomes `Phase::Compacting(..)` and `Phase::Chatting(_)`. +- Change: delete `Machine.snapshot` (`machine.rs:72-74`), moving its doc ("held until the retried round's answer arrives. Set means the round has compacted") onto the `Chatting` variant, and delete the unreachable internal-error arm at `machine.rs:210-214`. That arm's `machine.rs:212` clear is the fifth hand-paired flag site, so after the deletion four remain: `models_loop.rs:184`, `:266`, and `machine.rs:319`, `:416`. Do not list `machine.rs:212` among the sites to keep. +- Change: two test matches on `Phase::Chatting` as a unit variant stop compiling once it takes a field: `src/models_loop/tests.rs:308` and `src/models_loop/machine-tests.rs:137` become `Phase::Chatting(_)`. +- Change: `messages-snapshot.rs:5-6` ends "exactly. Neither touches the list's last send or its frozen sets."; `:34` becomes "Puts the records back; the last send and the frozen sets stay."; `:39` becomes "Removes every record; the last send and the frozen sets stay." +- Ceiling: `src/models_loop/machine.rs` holds 487 lines; the deleted field and arm free more lines than the variant fields add. +- Acceptance: `models_loop/machine-tests-compaction.rs:58-80` and `:168`, `models_loop/tests.rs:253-259`, and engine `execute/tests/compaction.rs`, `compaction-rules.rs`, and `models_loop_compactors.rs` still pin the flag and the outcomes, including `context_exhausted` on a second overflow. + +**Work item 19 (S3-14, S3-15, S3-16, S3-17, S6-7, S3-18): the models family docs and the duplicate getter** + +- Files: `src/models.rs`, `src/models-userdata.rs`, `src/models_loop.rs`, `src/tools.rs` +- Problem: `LuaModelHandle::label` (`models-userdata.rs:40-45`) and `name` (`:34-38`) both return `self.binding.alias()` under docs that read as two facts, though the guide promises the two fields always hold the same string (`10-models.md:931`, `:941`). The handle's type doc (`:14-15`) repeats the module doc word for word, and three getters (`:67-69`, `:88-91`, `:101-103`) repeat one boundary paragraph. `RAW_ID_CONTEXT` (`models.rs:39-41`) calls the documented raw-model-id fallback "the hack". `install_models` (`models.rs:244-245`) names two of its module's three suspending shims, and `install_tools` (`tools.rs:103-105`) restates its module doc's shim note. `loop_begin`'s doc (`models_loop.rs:110-115`) cites a plan heading for its check order and quotes a closure body the code does not have; the closure (`:175-187`) also clears `running` and the list's `compacting` flag. +- Change: delete `LuaModelHandle::label` and register the `label` field over `this.name()` (`models-userdata.rs:116`). `name`'s doc becomes "Returns the role label the handle was obtained by; the `name` and `label` fields both read it." +- Change: the type doc becomes "One model handle over a frozen `ModelBinding`. Its getters return raw Lua numbers where the binding keeps validated types (`NonZeroU32`, `Temperature`)." Delete the boundary paragraph from `context`, `temperature`, and `max_tokens`. +- Change: `models.rs:39-41` becomes "The context window a raw-model-id binding records: the fallback has no catalog metadata, so a conservative default keeps the compactor precheck safe rather than refusing the model." `models.rs:244-245` becomes "The coroutine shim layer installs the suspending `models.infer`, `models.loop`, and `models.compact`, because yield cannot cross the Rust callback boundary." +- Change: in `install_tools` (`tools.rs:103-106`), delete the first sentence and keep "Both offer verbs refuse once `list`, the section's conversation, holds the section's frozen sets." +- Change: in `models_loop.rs:110-115`, replace the text from "then that entry's argument checks" through the quoted closure body with "`loop_begin`'s body: the entry flag; for a handle's `loop`, the receiver then the arity, and for `models.loop`, the handle-first refusal then the arity; the compactor's type; the refusal while `running` or the list's `compacting` flag is set; then the machine over the section's list, its step closure, and the first action." Keep the rest from "`running` is set only once". +- Acceptance: `models-tests.rs:103-105` still pins `label` and `name` equal, and engine `execute/tests/models_loop-arguments.rs:117` still pins the argument order the new text states. A handle's `label` field reads the same string as before. + + + + + +### Step 7: the store module, the program and compactor vestiges, the key order module, and the closing outline check [completed] + +- Component: Store and module docs +- Tests: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `$env:CARGO_BUILD_WARNINGS = "deny"; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo fmt --all --check` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:RUSTDOCFLAGS, Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo doc --locked --no-deps --all-features -p runvil-lua --document-private-items` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + - `cargo vibelight` + - `rg -w -e InstructionBudget -e HOOK_BUDGET -e install_instruction_budget -e budget_hook -e lua_quota -e ToolRuntime -e ModelRuntime -e tool_runtime -e model_runtime -e install_section_loop_shim -e LOOP_REGISTRY -e COMPACT_REGISTRY -e NOT_INJECTED -e install_prose -e read_range -e read_range_numbered -e direct_yield -e raw_field -e json_field -e PACKS_LABEL -e bound_tools -e current_tool_bindings -e store_conflicts -e parse_message -e 'src/handles\.rs' -e 'src/collection-order\.rs' .vibelight/runvil_lua.md; if ($LASTEXITCODE -ne 1) { throw "the lua outline still names a removed declaration" }` + - `foreach ($n in 'CancelHook', 'cancel_poll', 'install_cancel_hook', 'Offers', 'ModelSelection', 'model_selection', 'ModelCall', 'dispatch_body', 'read_only_walk', 'author_refusal', 'set_messages', 'tool_set', 'tool_id', 'current_static_set', 'store_conflict', 'parse_record', 'src/bindings\.rs', 'src/section\.rs', 'src/var\.rs', 'src/order\.rs') { rg -q -w $n .vibelight/runvil_lua.md; if ($LASTEXITCODE -ne 0) { throw "the lua outline lacks $n" } }` + +- Work items: 16, 24, 25, and 26, in one commit, plus the product contract's success check that a fresh plain `cargo vibelight` run shows each renamed and deleted declaration. +- Order: work item 16, then 24, then 25, then 26, then the closing check. The four share a cause rather than files: each moves an item, or the one statement of a rule, to the module that owns it, or drops an attribute or derive nothing needs. The closing check goes last because it reads the outline after every rename in steps 1-7. +- Paths: lua paths are under `crates/runvil-internal/lua/`, paths marked "engine" are under `crates/runvil-internal/engine/src/`, and commands run from `c:\Users\Vinnie\cursor\runvil`. Line numbers were read at `7a5341bba`, and earlier steps and earlier work items in this step shift them, so every line number is a hint: locate each site by symbol or quoted text, and where a line and a symbol disagree, the symbol wins. +- Constraint: no author-visible behavior change. Every Lua global, table shape, and message stays as the guide documents it, and every type or signature change stays inside the workspace. Add no dependency and no crate. Every `.rs` file stays at or under 500 lines, or `build_ceiling::check()` fails the build. +- Acceptance: the Tests pass, including the three outline commands, plus each work item's acceptance below. The `runvil-lua --document-private-items` build is not a CI gate and passes on exit status; read its warnings for the lines this step edited, and treat a warning on an edited line as a failure. +- Loop: between work items, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-parser --all-features`; the Tests above gate the commit. + +**Work item 16 (S4-1, S4-2, S4-3, S4-4, S4-5, S4-6, V4-1): the store module** + +- Files: `src/engine_globals.rs`, `src/engine_globals-store.rs`, new `src/engine_globals-store-read.rs`, `src/vm/install.rs`, `src/tests/logging.rs` +- Problem: the store operation bodies, the read helpers, and `run_store_op` live in the parent module (`engine_globals.rs:24-31`, `:157-343`) while the store module imports two of them back (`engine_globals-store.rs:12`). `read_range` and `read_range_numbered` (`engine_globals.rs:213-232`) are one-line wrappers that `read_store_bounded` picks between twice. Seven docs narrate closure capture and `mlua::Scope`, an alternative the crate never uses. The store module doc names the "shared library" that `require` replaced, the parent doc lists `ui` as installed in every VM though only a run with a snapshot installs it, `run_store_op`'s doc narrates the other store path, and the log length refusal (`engine_globals.rs:60-63`) hardcodes the 256 its constant holds, so its test (`tests/logging.rs:163-175`) would keep passing after the constant changed. +- Placement: `engine_globals-store.rs` holds 402 lines, and moving every helper into it would bring it to about 575, past the 500-line ceiling. So `run_store_op` moves into `engine_globals-store.rs` itself (about 45 lines, ending near 450), and the read helpers move into a new private child module of the store module, `engine_globals-store-read.rs`, wired at the top of `engine_globals-store.rs` as `#[path = "engine_globals-store-read.rs"] mod read;`, the way `error-value.rs` wires `error-value-store.rs`. The decision record holds this choice. +- Change: move `LineNumbers`, `read_store`, `read_store_numbered`, `read_store_bounded`, `resolve_line_range`, `with_read_range`, and `number_lines_from` into `engine_globals-store-read.rs`, with the module doc "The store's read bodies: whole-file and line-range reads, plain or numbered.", and give the items the store module calls `pub(super)`. Move `run_store_op` into `engine_globals-store.rs`; add `pub use store::run_store_op` beside the existing re-exports (`engine_globals.rs:19`) and delete the `use super::{read_store, read_store_numbered};` import, importing them from `read` instead. Every crate and engine path stays valid. +- Change: delete `read_range` and `read_range_numbered`. In `read_store_bounded`, choose `render: fn(&[&str], usize) -> String` once, `number_lines_from` or `|lines, _| lines.join("\n")` (which coerces because it captures nothing), and call `with_read_range`; keep `view.read_string` for the whole-file unnumbered read, so that read stays verbatim, trailing newline included. +- Change: the store module doc (`engine_globals-store.rs:1-3`) becomes "The `store` table: Lua dispatchers over the direct store closures, and the switch that routes them to the yield shims once the Pack preludes have installed." The parent module doc (`engine_globals.rs:1-2`) becomes "Engine globals installed into section VMs: `log`, `untrusted`, and the `store` table in every one, and `ui` when the run has an application-state snapshot. Each is a persistent global: installed once per section and valid across every chunk the VM runs." +- Change: the second paragraph of `run_store_op`'s doc becomes "The direct closures `install_store_table` builds share these operation bodies. `view` is the store view the caller derived." Keep its first sentence and its `# Errors`. +- Change: the closure narration. Only five docs mention `mlua::Scope`, at `engine_globals.rs:87` and `:116`, `engine_globals-store.rs:215`, and `vm/install.rs:29` and `:82`. Delete those five clauses. From `install_untrusted`, keep only its nonce and type-error facts. The phrase "as a persistent global valid for the section's whole lifecycle" appears in `install_ui` and `install_log`; delete it from both. In `install_require`, the sentence at `vm/install.rs:148-149` is "Installs Runvil's `require` as a persistent global for the section's whole lifecycle", a different wording with no "valid", so shorten it separately to "Installs Runvil's `require` as a persistent global." Keep the fact that the globals keep a clone of the emitter, and keep the store doc's sentence that the view locks per call and holds nothing across an await (`engine_globals-store.rs:228-230`). Step 5 already rewrote the `install_control_globals` doc near `vm/install.rs:82`; delete only its `mlua::Scope` clause if one remains. +- Change: the log length refusal becomes `format!("log message must be at most {LUA_LOG_CHARACTER_LIMIT} characters")`, and `tests/logging.rs:172` builds its expected text the same way, as the input at `:163` already does. +- Acceptance: the refusal still reads "log message must be at most 256 characters". `src/tests/store.rs` and `src/tests/store_reports.rs` still pin both reads' text and bounds, engine `execute/tests/observations.rs` and the fixture `crates/runvil-internal/engine/tests/prompts/execution/store-triad.md` run them end to end, and `tests/logging.rs:163-175` pins the limit through the constant. + +**Work item 24 (S1-17, S1-18, S1-19, S1-21, S6-14): the program, prelude, and packs docs** + +- Files: `src/program.rs`, `src/prelude.rs`, `src/prelude-tests.rs`, `src/packs.rs` +- Problem: `#[non_exhaustive]` on `LuaProgram` (`program.rs:58`) adds nothing its five private fields do not already prevent, and no workspace lint asks for it. Its type doc calls `load` "a crate-internal step" (`program.rs:40-41`), though `load` is `pub` and two outside tests call it. `compile_internal` (`program.rs:132-133`) says it compiles "the coroutine shim prelude", though six embedded chunks compile through it. The `prelude` module doc calls the prelude's `...` "its Pack's local name" (`prelude.rs:4-5`) while `install_preludes` calls the same value the Pack's id. `install_pack_objects` (`packs.rs:135-137`) restates, in a third wording, the membership rule the module doc and `pack_tools` state. +- Change: delete `#[non_exhaustive]` and its justifying paragraph (`program.rs:43-44`). runvil-parser re-exports `LuaProgram` (parser `crates/runvil-internal/parser/src/lib.rs:25`) and is also `publish = false`. +- Change: `program.rs:40-41` becomes "Loading a program ([`load`](Self::load)) creates a function in the supplied VM but likewise does not call it." +- Change: `compile_internal`'s first line becomes "Compiles one of the crate's embedded Lua chunks without observations."; rename its parameter `location` to `chunk_name` (`program.rs:144`) and replace the parameter's sentence with "`chunk_name` serves as both the chunk name and the display location; an `@`-prefixed name renders as a file path in errors, which the line mapper never rewrites." Callers pass it positionally, so none changes. +- Change: in the `prelude` module doc, "is its Pack's local name" becomes "is its Pack's id"; the assertion message at `prelude-tests.rs:173` says "its own Pack's id" in place of "its own Pack's local name". +- Change: `install_pack_objects`'s doc becomes "Builds one Pack object per Pack `pack_tools` lists and returns their names." +- Acceptance: the full suite covers runvil-parser's re-export of `LuaProgram`, and the docs commands pass. + +**Work item 25 (S4-13, S4-14, S4-15, S4-16, S4-17): the compactors and projection** + +- Files: `src/projection.rs`, `src/compactors.rs`, `src/compactors-tests.rs` +- Problem: `visible_text` (`projection.rs:260-262`) promises "the concatenation of its text parts" though its `Parts` arm is `unreachable!`; the module doc (`projection.rs:30-32`) implies a per-model projection, though `project_messages` takes only records and builds one shape. `Compactor` derives `Default` with `#[default] Fail` (`compactors.rs:104-107`) for one test whose local `resolve` claims to mirror the loop, which resolves an omitted compactor as a Lua value (`models_loop.rs:154`). The two policy rules ("fail always raises", "clear works only inside a compactor") are stated five times. `UsageAnchor::tokens` (`compactors.rs:202-206`) is a production accessor only tests read. +- Change: `visible_text`'s doc becomes "The text of a plain-text content. Only called on composed system records, where validation refused parts, so the parts arm is unreachable." The comment at `projection.rs:168-169` then repeats it and goes. The sentence at `projection.rs:30-32` becomes "The projection is recomputed per dispatch and is the same for every model." +- Change: drop `Default` and `#[default]` from `Compactor`. In `compactors-tests.rs:65-81`, delete `resolve` and the asserts at `:71-75`, keep the `fail_invoked` assertion, and rename the test from `the_omitted_compactor_defaults_to_fail` to `fail_raises_typed_exhaustion_for_a_provider_overflow`, since it no longer exercises omission; engine `execute/tests/models_loop_compactors.rs:19` pins the real omission path. +- Change: the `Compactor` doc (`compactors.rs:98-103`) becomes "The compactor policies the `compactors` global ships; `Fail` is the one the loop reads when the compactor argument is omitted." From `install_compactors` (`compactors.rs:296-297`), delete "`compactors.clear` empties `list`, and only while a compactor runs over it." Keep the module doc (`:12-16`), the variant docs, `invoke`'s `# Errors`, and `install_compactors`' external-error, downcast, normalization, and install-order sentences. +- Change: gate `UsageAnchor::tokens` behind `#[cfg(any(test, feature = "test-support"))]`, the gate `lib.rs:101` puts on `test_support`. Its readers are `compactors-tests.rs:342`, `:369`, `:375` and engine `execute/scheduler/chain_record/tests.rs`, a `#[cfg(test)]` module whose dev build enables `test-support`. +- Acceptance: `fail_raises_typed_exhaustion_for_a_provider_overflow` passes under its new name, `projection-tests.rs` still pins the composed-parts refusal, engine `execute/tests/models_loop_compactors.rs:19` still pins omission, and a production use of `tokens` would fail to build. + +**Work item 26 (S4-18, S4-19, S4-20, S4-23, S4-24, V4-2, S4-25): the key order module and the iteration docs** + +- Files: `src/collection.rs`, `src/collection-order.rs` (becomes `src/order.rs`), `src/collection-order-tests.rs` (becomes `src/order-tests.rs`), `src/lib.rs`, `src/iteration.rs`, `src/json/keys.rs` +- Problem: the crate-wide key order sits under `collection`, fanout's module (`collection.rs:16-19`), though `iteration.rs:42` and `json/keys.rs:8` also use it, and its module doc (`collection-order.rs:6-9`) omits `json.encode` among the order's sharers. `sort_key` and `KeyError::NotFinite` say refused keys have no position (`collection-order.rs:105-106`, `:117-121`), though `json::keys::position` places both kinds (`json/keys.rs:143-155`). `render_item` (`collection.rs:97-99`) omits `null`, which it renders as compact JSON. The iteration docs call the trailing segment non-scalar, though non-UTF-8 string keys and infinite number keys, both scalars, land there (`iteration.rs:215-223`, `:272-275`), and `previous_order` and `next_index` repeat the module doc's resume limit. +- Change: add a top-level `mod order;` to `lib.rs`; rename `collection-order.rs` to `order.rs` and `collection-order-tests.rs` to `order-tests.rs`, with the tests wired as `#[cfg(test)] #[path = "order-tests.rs"] mod tests;` (`collection-order.rs:141-143`); delete the `#[path] mod order;` and the re-export at `collection.rs:16-19`; and import `crate::order::{KeyError, SortKey, sort_key}` in `collection.rs`, `iteration.rs`, and `json/keys.rs`. Rename with `git mv` so history follows the files. +- Change: the order module doc's sharer sentence becomes "`SortKey::compare` orders two of them. Fanout's member enumeration, the deterministic `pairs`/`next` installer, and `json.encode`'s key order share both, so a table's order is a function of its contents wherever it is read." +- Change: `sort_key`'s `# Errors` becomes "Returns `KeyError` when the value is not a string, number, or boolean, when a string's bytes are not valid UTF-8 (it has no label), or when a number is not finite. Each caller maps the failure onto its own diagnostic, so this helper names no feature." `KeyError::NotFinite` becomes "A number key that is NaN or infinite." The test comment at `collection-order-tests.rs:53-54` becomes "A non-finite number is refused, and each caller decides its own diagnostic." +- Change: `render_item`'s doc becomes "Renders a fanout arm's item for prose substitution and stub text: strings verbatim, numbers and booleans in their natural string form, and arrays, objects, and null as compact JSON." +- Change: the module doc at `iteration.rs:32-36` becomes "The ordering covers boolean keys, finite number keys, and valid UTF-8 string keys, the only keys a payload the run log stores can hold. Every other key (a table, function, userdata, or thread, a non-UTF-8 string, or an infinite number) is still visited rather than dropped, after every ordered key, in the order Lua's own `next` produced it." At `iteration.rs:27`, "A cleared non-scalar key" becomes "A cleared unordered key". +- Change: `WalkOrder`'s last sentence (`iteration.rs:70-72`) becomes "An unordered key, any key `sort_key` refuses, has no cross-process position, so all of them compare equal and keep the order Lua's own `next` produced."; `compare`'s last sentence (`:91-92`) becomes "All unordered positions compare equal, so a stable sort keeps their raw traversal order."; `ordered_keys` (`:184-186`) becomes "The table's live keys in deterministic walk order: the array part (`1..=#table`) in index order, then the hash part's keys `sort_key` accepts, by [`SortKey`], then every key it refuses."; and in `WalkOrder` (`:67`) and `segment` (`:80-81`), "non-scalar" becomes "unordered". +- Change: `previous_order` (`iteration.rs:255-265`) becomes "The walk position a lone key would occupy, ignoring whether it is still in the table: any integer `index >= 1` is an array key without consulting the live border (the module doc gives the resume limit this sets), a hash key `sort_key` accepts keeps its [`SortKey`], and any other key is unordered." In `next_index` (`:234-236`), delete "; a cleared non-scalar key has no computed position, so the resume falls back to the trailing segment, the best a stateless resume can offer". +- Keep: `iteration-tests.rs:245` ("A cleared non-scalar key") is noticed for the next scan, not this one; leave it. +- Acceptance: `iteration-tests.rs:162-269` still pins the resume behavior, and `collection.rs:186-204` and `iteration-tests.rs:79` still pin the mixed-key order. + +**Closing outline check** + +- This is the last step in this repository, so its Tests regenerate the outlines with a plain `cargo vibelight` and read `.vibelight/runvil_lua.md`, which is gitignored and adds nothing to the commit. +- The first grep fails if the outline still names a removed declaration (`InstructionBudget`, `HOOK_BUDGET`, `install_instruction_budget`, `budget_hook`, `lua_quota`, `ToolRuntime`, `ModelRuntime`, `tool_runtime`, `model_runtime`, `install_section_loop_shim`, `LOOP_REGISTRY`, `COMPACT_REGISTRY`, `NOT_INJECTED`, `install_prose`, `read_range`, `read_range_numbered`, `direct_yield`, `raw_field`, `json_field`, `PACKS_LABEL`, `bound_tools`, `current_tool_bindings`, `store_conflicts`, `parse_message`, or the old `src/handles.rs` and `src/collection-order.rs` paths); the loop fails if it lacks a new name. +- A failure here names a rename an earlier step missed: fix it in this step's commit if it is in this step's files, and otherwise report it as blocked. + + + + + +### Step 8: the guide's setup order, in a second repository [completed] + +- Component: Guide setup order +- Tests: + - `$env:RUNVIL_DOCS = 'c:\Users\Vinnie\cursor\runvil-docs'; cargo xtask site --books-only` + +- Work items: 27 (S5-2). The operator chose to land this fix as a second commit in `../runvil-docs` in this same pass. +- Order: one work item. It runs after step 5, whose work item 21 merged the loop installer this step describes. +- Repository: edit and commit in `c:\Users\Vinnie\cursor\runvil-docs`, not in runvil; this commit shares no rollback with steps 1-7. The Tests command runs from the runvil root `c:\Users\Vinnie\cursor\runvil`, because the `xtask` alias lives in runvil's `.cargo/config.toml`. +- File: `src/language/05-lua-environment.md` in `runvil-docs` +- Problem: `:183` says "Every section VM is set up in one fixed order" and lists ten steps (`:185-194`), but no step installs `untrusted`, which exists from construction (runvil `crates/runvil-internal/lua/src/vm.rs:229-241`), and step 1 omits `tools`, `models`, `messages`, and `compactors`, which injection installs beside `args`, `argv`, `sys`, and `var` (runvil `crates/runvil-internal/lua/src/vm/inject.rs:118-151`). Pack preludes read `tools`, `store`, `untrusted`, and `json` as they install at step 8 (runvil `crates/runvil-internal/lua/src/prelude.rs:4-8`, guide `src/language/12-tools.md:2744-2751`), so a reader of the list finds no step that makes them exist. Confirm the line numbers against the guide file first; they are from the scan, and the guide is hand-edited. +- Change: add a new first entry, "`untrusted`, when the VM is built", and renumber the list; the old step 1 becomes "`args`, `argv`, `sys`, `var`, `tools`, `models`, `messages`, and `compactors`". +- Change: fold the old step 7 into step 6. Step 5 of this plan (work item 21) made `install_coro_shims` install `models.loop` and `models.compact`, so one installer performs what were steps 6 and 7, and the old step 7 has no separate installer left. Step 6 becomes "the suspending calls: `call`, the `tasks` table, `tools.call`, `models.infer`, `models.loop`, and `models.compact`", and the old steps 8, 9, and 10 renumber to 7, 8, and 9. If work item 21 was dropped, keep the old step 7 as its own entry instead. +- Acceptance: the list names a step for every global a Pack prelude reads, and the books build. `cargo xtask site --books-only` checks `RUNVIL_DOCS` first and fails before staging anything without it; `mdbook` must be on `PATH` or named by `MDBOOK` (it is at `C:\Users\Vinnie\.cargo\bin\mdbook.exe`). +- Constraint: guide prose only; the guide must keep describing the behavior the code has. +- Loop: the Tests command. + + + + diff --git a/vibe/2026-10-29-2-model-client-slop.md b/vibe/2026-10-29-2-model-client-slop.md new file mode 100644 index 000000000..60544ad4c --- /dev/null +++ b/vibe/2026-10-29-2-model-client-slop.md @@ -0,0 +1,346 @@ +--- +name: "Vibelight: runvil/crates/runvil-internal/model-client" +overview: Outline the model-client crate with vibelight, verify every slop candidate against the code and its docs, and leave a self-contained Cleanup Plan. +todos: + - id: resolve + content: Resolve repository root, vibelight.toml, and the outline index + status: completed + - id: outline + content: Verify outline freshness; rerun vibelight because the crate moved past it + status: completed + - id: docs + content: Collect doc roots that count as fact + status: completed + - id: analyze + content: Dispatch analysis and drift subagents in parallel + status: completed + - id: verify + content: Dispatch one verify subagent per findings file + status: completed + - id: plan + content: Write the Cleanup Plan into the plan file and plan tool copy + status: completed + - id: refine + content: Dispatch refiner and fold accepted changes back into both copies + status: completed +isProject: false +--- + +# Vibelight: runvil/crates/runvil-internal/model-client + + + +## Product Requirements + +### Scope + +- **Crate:** `runvil-model-client` at `crates/runvil-internal/model-client`, outline 70 KB +- **Outlines:** current for the target crate; the tree moved to `9b4c21a39` and no crate file changed +- **Context:** 48 other outlines, not analyzed; the other crates' outlines are now stale +- **Excluded:** 7 crates, all build tooling or one test fixture +- **Doc roots:** + - operator's own roots, none named, 2026-10-28 + - `crates/runvil-internal/model-client/README.md` + - `README.md` and `AGENTS.md` at the repository root + - `vibe/`, the repository's dated design and debt records + - the `runvil-docs` sibling clone at `b6c0d90`, 33 markdown files +- **Limits:** + - vibelight warned that 99 items are written more than one way + - `guide/books/*/book.toml` name a `src` tree this repository lacks, so no book root + - the `vibelight` on PATH is 0.2.0 and rejects this repository's `vibelight.toml` + - `runvil-lua` and `runvil-engine` were rewritten after the scan, so their line numbers were rechecked by hand + +Stats: 21 findings, all accepted, 18 cheap and 3 substantial, 2 candidates rejected; 1 finding already fixed; 20 remain in 9 work items; 0 operator decisions. + +Goals: remove the duplicate state, the unreferenced constructors, and the doc comments that are untrue. +Non-goals: the mutex around `ModelSet`, the `models.use` merge rules, and any slop in sibling crates. +Success: every work item's proof command passes, and a fresh `vibelight` run shows the changes. + +## Functional Specification + +### Debt Inventory + +State and duplication, one line each: + +- `ModelBinding` stores the provider twice, and only convention keeps the copies equal (S1-1) + - A catalog-only binding answers `provider() == None` while `render_info()` names the catalog's + - Changes what a facade-visible getter reports, so it needs a test and an operator decision +- `CompletionOptions` redeclares three `ModelInvocation` settings with a second setter pair (S1-2) +- `ToolArguments` models a null payload its own doc forbids (S1-3) + - The null arms are reachable only from a test literal that hand-builds a `ToolCall` + +Vestiges and naming: + +- `ModelView::default` shadows `Default::default`; `ModelSet` calls the same fact `default_alias` (S1-4) +- `ModelSet::from_parts` has eleven callers and every one is test code (S1-5) +- `TryFrom for Temperature` forwards to `new` and nothing anywhere calls it (S1-6) +- Five of the six `runvil_types` re-exports keep paths that nothing names resolving (S1-7) +- `CompletionOptions::model` is `pub(super)` while its three siblings are private (V1-1) +- Three wire fields stay crate-visible though only their own getters read them (V1-2) +- `bindings()` means a borrow on `ModelSet` and an owned clone on `ModelView` (V1-3) + +Doc comments in the crate, grouped by file: + +- `src/client/wire.rs`: `name` states a grammar the builder refuses, and `model` promises the response's name where decoding keeps the request's (S2-1, S2-2) +- `src/model.rs`: the module doc names `models.bind`, a global `runvil-lua` asserts is absent (S2-3) +- `src/model/options.rs`: `ModelView` claims an `Arc` share and a structural freeze, and `ModelSet` says bindings come from live H1 (S2-4, V2-1) +- `src/model/error.rs`: the message-shape rule is told three times in three wordings, and the builder list omits the third builder (S1-8, S2-7) +- `src/detail.rs`: the module doc enumerates four of its six functions (S2-9) + +Documentation drift in the guide, one line each: + +- `11-conversations.md:1217` lists `thinking` as a `models.use` option; the parser takes two keys (S2-5) +- `10-models.md:1453` says infer metrics hold only provider figures; the client adds its own (S2-8) + +Fixed since the scan: the guide's error-kind table gained its `InvalidParts` row, so that finding is done (S2-6). + +Rejected: 2 candidates, both weak, because the wording they question misleads no reader. + + + + +## Technical Design + +- **The provider read rule.** The catalog already carries a provider, so the field becomes the fallback a catalog-less binding needs rather than a second answer. One expression serves both the getter and the render, so they cannot disagree. Cost: `provider()` now reports a catalog's provider for a binding that never called `with_provider`. +- **One invocation inside `CompletionOptions`.** Holding `ModelInvocation` removes the hand copy in `completion_options` and the second setter family, while the four getters and four setters keep their exact signatures. `with_invocation` and `invocation()` are the two additions, because a test cannot otherwise observe what the setters stored. Cost: `with_temperature` still takes `f64` and validates, so the crate keeps two spellings of temperature validation by design. +- **Arguments as a map, not a value.** Storing `Map` in the field retires the null state instead of documenting it away, and the five view branches collapse to map calls. The validating constructor cannot unwrap, because the workspace denies `unwrap_used` and `expect_used`, so the object check returns the map it proved: `normalize::checked_call_arguments` takes the `Value` and hands back a `Map`, keeping today's refusal message word for word. +- **`to_json_string` has no infallible spelling to borrow.** `Value::to_string` is infallible, and `serde_json::to_string(&Map)` returns a `Result` even though a `Value` tree cannot hold a number that fails to serialize. The method matches on that result and falls back to `{}`, which is unreachable for a `Value`-built map and is not the empty-string failure `lua/src/projection.rs:307-311` warns about. + + + + +## Testing Plan + +Gate commands from the repository root, once each: + +- `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` +- `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` +- `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` +- `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` +- `cargo fmt --all --check` +- `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, with `RUSTDOCFLAGS="-D warnings"` +- `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps`, which builds the facade's docs without `--all-features` +- `cargo +nightly-2026-09-05 xtask api --check`, with `CARGO_BUILD_WARNINGS` unset +- `cargo test -p build-xtask` +- `node --test crates/workshop/ui/test/docs-claims.mjs` +- `vibelight` at the repository root, then read the rewritten outlines in `.vibelight/` + +New tests, both in `crates/runvil-internal/model-client/src/model/options-tests.rs`, which already imports `ModelInfo` and `ThinkingMode` from `super::*`: + +- `provider_prefers_the_catalog_and_falls_back_to_the_field`: a binding built with `with_catalog_info` alone reports the catalog's provider, and one built with `with_provider` alone still reports its own. + - Fails today: the catalog-only case answers `None`. + - Both halves are required, because `a_binding_built_with_new_renders_a_never_thinking_info_from_its_own_parts` covers only `render_info` and never calls `provider()`. +- `completion_options_reports_the_invocation_it_holds`: after `with_temperature(0.2)` and `with_thinking(true)`, `invocation().temperature` and `invocation().thinking` hold those values and the getters report the same. + - Pins the delegation the rewrite introduces; the two cannot drift once one field holds both. + +Existing tests that already pin the behavior and must pass unedited: + +- `model/tests.rs` read-back and `with_model` tests, `options-tests.rs` temperature range, `client/tests.rs` name validation. +- `error-tests.rs` `each_kind_has_its_fixed_phrase`, `a_phrased_failure_shows_only_the_fixed_phrase`, and `a_specific_extends_the_phrase_after_a_colon_and_never_adds_detail`. +- `normalize-tests.rs` `non_object_arguments_are_rejected`, which pins the refusal message the map rewrite must keep. +- `read-tests.rs` around line 170 pins that a decoded completion is labeled `routed-to`, the requested name, across a table of body models. +- `models-tests.rs` `the_models_namespace_has_no_bind` at line 73 and `models_use_rejects_invalid_options_without_selecting` at line 160, which pins the two-key rejection message at line 196. + + + + +## Decision Record + +- The guide edits land in the `runvil-docs` clone, which is its own repository and needs its own commit (S2-5, S2-8) +- The `InvalidParts` table row landed in `runvil-docs` commit `4f537d5` after the scan, so nothing is left to do for it (S2-6) +- The `models.bind` clause was logged as deferred by the 2026-10-26 engine pass, which said to revisit it in its own pass; that record is now closed and this is that pass (S2-3) +- Deleting `TryFrom` and the five re-exports narrows a facade-pinned surface, and Item 2 adds two methods to a published type, so `crates/runvil/public-api.txt` is re-blessed in both items; every crate here is `publish = false`, so each change is a bless and reverts in one line +- Rejected: rewriting both `Completion::model` sentences, because the first states the `with_model` rule correctly (S2-2) +- Rejected: naming all six `detail` functions, because a rule survives a seventh function and a list does not (S2-9) +- Rejected: keeping `ModelView::bindings` as a named seam for a Host-backed view, because no Engine path calls it and re-adding it is one signature (V1-3) +- Risk: the map rewrite must keep the refusal message, the crate-visible field that `detail.rs` reads, and the absence of `unwrap` and `expect`, which the workspace denies +- Rechecked on 2026-10-29 against `9b4c21a39`: the target crate is byte-identical since the scan, but `runvil-lua` and `runvil-engine` were rewritten in twelve commits, so every cross-crate citation here was re-derived by hand rather than trusted +- The engine record that deferred the `models.bind` clause is closed (`4eb444630`), and `runvil-docs` gained two commits, one of which fixed the guide's error-kind table outright + +- Step granularity: keep the step count low, favoring fewer, larger commits over many small ones, so a reviewer can hold the whole run in a few commits (operator instruction, 2026-10-29) + +### Open Questions + +None. The one open question is settled: `ModelBinding::provider()` returns the catalog's provider, falling back to its own field, and `render_info` takes its provider from that same expression (option A). The catalog is the authority because the field's own doc already says its value comes from the catalog, and one expression for both readers makes the two unable to disagree. The Engine's redundant `with_provider` call goes. + +### Deferred and Out of Scope + +- The mutex around `ModelSet`, which exists for the one writer `models.default`; removing it needs `runvil-lua` to change too +- `models.use` option precedence, documented only where the merge happens and agreeing with the guide +- The `ToolArguments` "canonical JSON" wording, whose ordering the workspace already pins through `serde_json` +- The wire-name grammar stated at four guide sites, `12-tools.md:298`, `:1701`, `:1755`, `:2224`; fix them in one guide pass +- `ToolSchema`'s three `pub(crate)` fields, which stay crate-visible because `detail::tool_schema_new` builds the type from outside `client`; narrowing them needs the construction moved behind a `pub(crate)` constructor in `client::wire` +- Any slop in `runvil-lua`, `runvil-engine`, or the harness crates, which this pass did not analyze + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway`. `default-members` in the root `Cargo.toml` is `crates/gateway/app`, so a bare `cargo build` builds only the gateway. The desktop app is explicit: `cargo build -p workshop`. +- Focused test command pattern: `cargo nextest run --locked -p -E 'test()'`, for example `cargo nextest run --locked -p runvil-model-client -E 'test(provider_prefers)'`. A bare trailing filter also works: `cargo nextest run --locked -p runvil-model-client provider`. `cargo-nextest 0.9.128` is installed and `cargo nextest list -p runvil-model-client --all-features` verifies against module paths such as `client::tests::`. +- Component test command pattern: `cargo nextest run --locked -p `. Engine work such as this plan narrows to `cargo nextest run --locked -p runvil-model-client -p runvil-engine -p runvil-lua`, and a change in an internal Engine crate also runs the facade suite `cargo nextest run --locked -p runvil --all-features`. Add `--all-features` for Engine, plugin, and Harness crates; drop it for `workshop`, `workshop-server`, and `workshop-server-api`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, then the Node suites and the structural checks below. +- Linter command: `CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, plus `CARGO_BUILD_WARNINGS=deny cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` for the Workshop crates. Never run a standalone `cargo check --workspace` beside these; the one permitted exception is `cargo check -p gateway --no-default-features`, a headless build shape clippy `--all-features` does not cover. The TypeScript packages have no linter config at all, so the JS gate is `npm run typecheck --workspaces --if-present` (`tsc --noEmit`) run from `crates/workshop` and `npm run typecheck` from `crates/gateway/config-ui/ui`, beside `npm test --workspaces --if-present`. +- Formatter check command: `cargo fmt --all --check`. Verified clean at survey time. +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, plus the facade gate `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps` without `--all-features`, and for Engine work `RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items`. Rustdoc lints are not covered by clippy. The guide site has its own build, `cargo xtask site`, which needs the `MDBOOK` environment variable naming an mdBook binary. +- Test placement and naming conventions: unit tests live beside their module as a `#[cfg(test)] mod tests;` declaration pointing at a flat kebab sibling through an explicit path attribute, as `src/normalize.rs` does with `#[path = "normalize-tests.rs"] mod tests;` and `src/model/options.rs` does with `#[path = "options-tests.rs"]`. A test group with three or more files becomes a `tests/` subdirectory instead. Integration suites go under the crate's `tests/` as a `main.rs` plus named module files, as `crates/harness/tests/suite/main.rs` wires `mod asker; mod broker; mod host; mod launch; mod support; mod vfs;`. Prompt fixtures are Markdown files under `tests/prompts/valid`, `tests/prompts/invalid`, and `tests/prompts/execution`. Test function names are snake_case full sentences naming the behavior and its expectation, with no `test_` prefix and no numbering: `provider_prefers_the_catalog_and_falls_back_to_the_field`, `a_role_round_trips_through_serde_as_its_lowercase_name`. Assertion messages explain why the expectation holds. +- Directory map: `crates/` holds every Cargo package. `crates/runvil` is the facade and `crates/runvil-internal/{engine,lua,model-client,parser,types,vfs}` the Engine crates behind it. `crates/harness`, `crates/harness-gateway-client`, and `crates/runvil-plugin` are the Harness and the Plugin contract, with `crates/harness-internal/{runner,plugin-loader,test-plugin}` nested beneath. `crates/plugin-{std,web,user-input,mcp}` are the shipped Plugins. `crates/gateway/*` is the gateway family, with `stt/` nesting `api`, `engine`, `backend-whisper`, and `whisper-ffi` one level deeper. `crates/workshop/*` is the Workshop Host family, `crates/workshop/ui` is its TypeScript SPA, and `crates/shared-ui` is the TypeScript+CSS package the gateway config UI consumes. `crates/build-*` are build tooling crates: `build-xtask` (aliased `xtask`) runs the API-surface and site checks, `build-ceiling` enforces compile-time file size, `build-ui`, `build-user-guide`, `build-workshop` (aliased `workshop`), and `build-llama-cuda`. Outside `crates/`, `guide/books/{gateway,language,workshop}` are the mdBook sources, `prompts/` holds sample prompt files, `tools/` holds Node scripts, `local/` is untracked scratch, `vibe/` holds dated plan records, `images/` is documentation art, and `.vibelight/` is generated HTML outlines excluded from analysis through `vibelight.toml`. +- Component boundaries: the Engine is `runvil` plus `runvil-*`, the Harness is `harness` and `harness-*`, and a Host runs prompts only through the Harness. `runvil-model-client` may depend on `runvil-types` and nothing else internal, and its `lib.rs` states so under `## Invariants`. `runvil-engine` sits above `runvil-{lua,model-client,parser,types,vfs}`, and the `runvil` facade sits above all six and re-exports each public item from its defining crate. `harness` depends downward on `runvil`, `runvil-plugin`, `harness-runner`, and `harness-plugin-loader`. `crates/gateway/app` depends only on `gateway-*`, `shared-*`, and platform crates and never on the Engine. Workshop tiers run vocabulary, then services, then features, then `workshop-server`, enforced by `cargo test -p build-xtask`. Dependency cycles are a design error, and Engine crates may never reach `tokio`, `tokio-util`, `async-trait`, `reqwest`, `getrandom`, or `fastrand`, directly or transitively. +- Conventions summary: every crate declares `[lints] workspace = true`, which denies `clippy::all`, `clippy::pedantic`, `unwrap_used`, `expect_used`, `todo`, `allow_attributes`, `disallowed_methods`, and `unsafe_code`, and warns on `missing_docs`. A suppression is written `#[expect(..., reason = "...")]`, never `#[allow]`. `allow-unwrap-in-tests` and `allow-expect-in-tests` in `clippy.toml` make `unwrap` and `expect` legal only in tests. Source directories are flat by default and a subdirectory needs three or more files, so a two-file test pair stays a kebab sibling with an explicit `#[path]` attribute. Every module and public item carries a `//!` or `///` doc comment written in prose, and Engine crate docs name "the caller" and never a Host application. Commit hooks run `cargo fmt --all --check` on commit and the wording scan, the headless gateway check, clippy, and `cargo deny check` on push. Behavior changes ship with tests in the same change. + + + + +## Execution Instructions + +The letters and numbers in parentheses, such as (S1-1), are tags from the analysis and are never needed to act. + +Three components, three steps, one commit each. No step spans two components. The four-step cap folds several work items into one commit wherever they touch the same crate and share a cause; each step names the items it carries, and the coverage line below accounts for all nine. No step owns the plan's seed copy, `vibe/ACTIVE`, or the close commit, because the Run Sequence owns them. + +### Component order and why + +- `model-client-wire` first. It changes the type of `ToolCall::arguments` and the return type of `detail::tool_call_arguments`, and `runvil-engine` consumes both, so the cross-crate signature settles before model-side work starts. +- `model-client-model` second. It renames and deletes `ModelView` members that `runvil-lua` and `runvil-engine` call, and its single re-bless must see the final method set. +- `runvil-docs-guide` last. It is a separate repository needing its own commit, its site build runs from the `runvil` root, and nothing in it depends on the Rust edits. + +Coverage: Step 1 carries Items 4, 5, and 8. Step 2 carries Items 1, 2, 3, 6, and 7. Step 3 carries Item 9. + + + +### Step 1: store tool arguments as a map and narrow the wire fields [completed] + +- Component: `model-client-wire` +- Tests: from the repository root, in order: + - `cargo nextest run --locked -p runvil-model-client -p harness-gateway-client -p runvil-engine -p runvil-lua` + - `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + +Carries Item 4 (S1-3), Item 5 (V1-2, S2-1, S2-2), and Item 8 (S2-9): the arguments type change, the cheap visibility and wording sweep beside it, and the `detail` module doc rule. They share one commit because Items 4 and 5 rewrite the same `ToolCall`, `ToolArguments`, and `ArgumentNames` code, and Item 8's replacement rule has to name what `detail.rs` still holds after Item 4. + +Two pieces, built sequentially in this one step: argument storage first, then visibility and doc wording. Sequential because the narrowed fields and their doc sentences must describe the final `Map` and `String` types, so writing them before the type change would mean writing them twice. + +**Piece 1, argument storage.** + +- `src/normalize.rs:40-46`: replace `check_call_arguments(arguments: &Value) -> Result<()>` with `pub(crate) fn checked_call_arguments(arguments: Value) -> Result>`. It takes the `Value` by value and returns the map it proved. The refusal keeps the message `tool call arguments were not a JSON object` word for word, so `normalize-tests.rs:40-53` (`non_object_arguments_are_rejected`) passes unedited. The constructor cannot `unwrap` or `expect` because the workspace denies both, so match `Value::Object(map)` and return `Ok(map)` on that arm. No clone is added anywhere. +- `src/client/wire-canned.rs:143`: the `check_call_arguments(&arguments)?;` statement becomes `arguments: checked_call_arguments(arguments)?` inside the `ToolCall` literal at line 144, and update the import list at line 17. Drop the now-unused local if it served only that call. +- `src/client/wire.rs:180`: `ToolCall::arguments` becomes `pub(crate) arguments: serde_json::Map`. +- `ToolArguments` at `src/client/wire.rs:213-217` holds `map: &'a serde_json::Map` and keeps `#[derive(Debug, Clone, Copy)]`. Then `to_json_string` (lines 221-223) becomes `serde_json::to_string(self.map).unwrap_or_else(|_| "{}".to_owned())` with a comment above it saying a `Value`-built map always serializes, so this arm is unreachable. Do not use `unwrap_or_default`, which would publish an empty arguments string, the failure `lua/src/projection.rs:307-311` warns about. `is_empty` becomes `self.map.is_empty()`, `contains` becomes `self.map.contains_key(key)`, and `names` becomes `ArgumentNames { keys: self.map.keys() }`. +- `ArgumentNames` at `src/client/wire.rs:254-260` holds `keys: serde_json::map::Keys<'a>`, and its field doc loses the `None`-for-a-null-payload sentence. `next` becomes `self.keys.next().map(String::as_str)` and `size_hint` becomes `self.keys.size_hint()`. +- `src/detail.rs:97-99`: `tool_call_arguments` returns an owned `Value` built as `Value::Object(call.arguments.clone())`. Its one caller, `crates/runvil-internal/engine/src/execute/scheduler/chat.rs:426`, passes `detail::tool_call_arguments(call).clone()` today; that `.clone()` becomes a move and must be dropped, because `ToolCallEvent::new`'s third parameter is already `serde_json::Value` (`crates/runvil-internal/types/src/metrics.rs:51`). +- `src/client/tests.rs:89-93`: build the call through `ToolCall::from_parts("call_1", "search", json!({"query": "rust", "limit": 5}))` instead of a struct literal. Delete the null half of `tool_arguments_view_exposes_no_raw_value` at lines 105-112, the `Value::Null` literal and its three assertions, because that state no longer exists. Keep the `null arguments` row at line 122 inside `from_parts_refuses_a_blank_id_a_blank_name_and_non_object_arguments`. +- Readers that need no edit: `src/detail.rs:98`, `src/client/wire.rs:203`, and the construction site in `wire-canned.rs`, which is a child module of `wire` and can reach the field. Every other `arguments` hit in `runvil-lua` is a `ToolCallRecord` or `ToolCallEvent` field of a different type, untouched here. Nothing outside the crate names the field, because it stays `pub(crate)`. + +**Piece 2, visibility and doc wording.** + +- Narrow three fields to private: `Completion.metadata_diagnostics` at `src/client/wire.rs:354`, and `ToolCall`'s `id` and `name` at lines 174 and 176. The first is `pub(crate)` today and the other two are `pub(super)`. Each is read only through its own getter, and the in-crate tests already read them that way. +- Confirm the visibility rule before editing, because `pub(crate)` and private are not interchangeable. Rust narrows a field only to the module that declares it, so private is reachable only inside `client::wire`. `wire-canned.rs` is that module's `#[path]` sibling, so its `Completion` literal at lines 51-57 and its `ToolCall` literal at line 144 still compile with no edit. +- Leave `Completion::result` (line 341) and `ToolCall::arguments` (line 180) at `pub(crate)`, because `src/detail.rs` reads both directly. Keep `ToolSchema`'s `name`, `description`, and `parameters` at lines 108-112 at `pub(crate)` too: `detail::tool_schema_new` at `src/detail.rs:87` builds them from outside `client`, so private there would not compile. This is why this step narrows three fields and not six. +- `ToolSchema::name`'s doc at lines 117-118 says the name "holds one or more characters, all from `[A-Za-z0-9_.-]`", but `detail::tool_schema_new` at `src/detail.rs:74-79` requires a leading ASCII letter, then only letters, digits, `_`, and `-`, capped at 64 bytes. Replace that second sentence with: "Returns the tool's name as sent to the model. The `crate::detail::tool_schema_new` builder is the only one, and it accepts a leading ASCII letter followed by up to 63 letters, digits, `_`, or `-`." Note that the name is written without square brackets: `ToolSchema::name` is facade-visible, and `xtask api --check` rejects a doc link from a facade-visible item into a crate the facade does not re-export, so a bracketed link there fails the gate. +- `Completion::model`'s doc at lines 393-395 ends with "A completion decoded from a response is built with the model the response body named, or an empty name when the body omits one." `crates/harness-gateway-client/src/wire/stream.rs:361-363` overwrites that with the requested model whenever the request body named one. Replace only that final sentence with: "A completion decoded from a response is labeled with the model the request body named, or, when the request body named none, with the model the response body named." Leave the preceding `with_model` sentence untouched; it is correct. + +**Piece 3, the `detail` module rule.** + +- Replace the four-item enumeration in the module doc at `src/detail.rs:3-8` with the rule: "Each function reaches a field or a construction step the facade-visible type keeps private. [`completion_into_result`] is the one that splits a completion into the result the tool loop consumes and the metadata the round reports." The enumeration names four of six functions and omits `completion_into_result` at line 103, which `engine/src/execute/turn.rs:107` uses to dismantle a whole completion. +- Keep the two accurate claims that sit around it: the `runvil` facade never re-exports this module (`crates/runvil/src/lib.rs:71-99` re-exports from `client` and `model` only), and only Engine crates reach it. + +Acceptance: the refusal text `tool call arguments were not a JSON object` still fails `non_object_arguments_are_rejected`'s cases, so `normalize-tests.rs:40-53` must pass unedited; `from_parts` still refuses a null payload at `src/client/tests.rs:122`; no `unwrap` or `expect` is added outside tests; and the docs gate passes with `broken_intra_doc_links` denied, so any doc link into a removed item is repointed in this step. + + + + + +### Step 2: one provider, one invocation, and the model module's untrue docs [completed] + +- Component: `model-client-model` +- Tests: from the repository root, in order: + - `cargo nextest run --locked -p runvil-model-client -p runvil-engine -p runvil-lua` + - `cargo nextest run --locked -p runvil-model-client -p harness-gateway-client -p workshop-server` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` + - `cargo fmt --all --check` + - `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps` + - `cargo +nightly-2026-09-05 xtask api --check` with `CARGO_BUILD_WARNINGS` unset + - `cargo test -p build-xtask` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + - `vibelight` at the repository root, then read the rewritten outlines in `.vibelight/`. The `vibelight` on PATH is 0.2.0 and rejects this repository's `vibelight.toml`, which the plan records under Limits, so a failure there is pre-existing: report it and do not chase it. + +Carries Item 1 (S1-1), Item 2 (S1-2), Item 3 (S1-4, S1-5, S1-6, V1-1, V1-3, S2-4, V2-1), Item 6 (S1-8, S2-7), and Item 7 (S1-7, S2-3). One commit, because all five items edit the model module for one cause, the module states duplicate facts in code and in prose that the code contradicts, and because Items 2 and 3 both move the public listing, so one blessing has to cover the net change. + +Two pieces, built sequentially: options state first (Items 1, 2, 3), then the untrue docs (Items 6, 7). Sequential because Item 3 rewrites the `ModelView` and `ModelSet` docs that sit beside the members it renames, and the blessing must run after every API change in the item. + +New tests, both in `src/model/options-tests.rs`, which already imports `ModelInfo` and `ThinkingMode` from `super::*`: + +- `provider_prefers_the_catalog_and_falls_back_to_the_field`: build one binding with `with_catalog_info` over a `ModelInfo` whose provider is `"acme"` and no `with_provider` call, and assert `provider() == Some("acme")`. That half fails today, because `provider()` at `src/model/options.rs:232` reads only the field. Build a second binding with `with_provider("acme")` and no catalog and assert the same value; that half passes today and must keep passing, and it is the half `options-tests.rs` already protects at lines 79-93 for `render_info` only. Both halves are required, because `a_binding_built_with_new_renders_a_never_thinking_info_from_its_own_parts` covers only `render_info` and never calls `provider()`. +- `completion_options_reports_the_invocation_it_holds`: after `with_temperature(0.2)` and `with_thinking(true)`, `invocation().temperature` and `invocation().thinking` hold those values and `temperature()` and `thinking()` report the same. This pins the delegation the rewrite introduces, so the two cannot drift once one field holds both. + +**Item 1, one provider.** `ModelBinding` stores the provider twice, in `provider: Option` at `src/model/options.rs:133` and in `catalog: Option` at line 136, and `with_provider` (line 166) and `with_catalog_info` (line 174) are independent setters. + +- Change `provider()` at line 232 to return `self.catalog.as_ref().and_then(ModelInfo::provider).or(self.provider.as_deref())`. One expression now serves both readers, so they cannot disagree. Accept the stated cost: `provider()` reports a catalog's provider for a binding that never called `with_provider`. +- Change `render_info`'s tail so its fallback takes the provider from that same expression: replace the `match &self.provider` block at lines 252-255 with `match self.provider() { Some(provider) => info.with_provider(provider.to_owned()), None => info }`. +- Reword `with_provider`'s doc at line 164 to: "Sets the provider id for a binding built without catalog info. A held catalog's provider wins." +- In `crates/runvil-internal/engine/src/execute/context-bound.rs`, delete the now-redundant `if let Some(provider) = info.provider()` block at lines 77-79, because `with_catalog_info(info.clone())` at line 76 already carries that provider. That removal also retires `let mut binding` at line 62 in favor of `let binding`, since nothing reassigns it afterward. Leave `engine/src/execute/context-tests.rs:168` alone; it exercises the fallback through `RunContext`. +- No edit in Lua: `crates/runvil-internal/lua/src/models-userdata.rs:78` and `:123` read `binding.provider()` and keep working. `engine/src/execute/tests/model_and_reply-handles.rs:177` pins the handle's provider as the catalog's through the engine path and must still pass. + +**Item 2, one invocation.** `CompletionOptions` declares `temperature`, `max_tokens`, and `thinking` as its own fields at `src/model/options.rs:280-284`, duplicating `ModelInvocation`'s fields at lines 78-87, and `ModelBinding::completion_options` at lines 261-268 copies the three by hand. + +- Replace the three fields with a single private `invocation: ModelInvocation`. Keep `model: String`. Item 3 makes it private, and it is private by the end of this step. +- Keep `model()`, `temperature()`, `max_tokens()`, and `thinking()` as getters that read through the held invocation, and keep `with_temperature`, `with_max_tokens`, and `with_thinking` with their exact current signatures, delegating to that field. `with_temperature` still takes `f64` and returns `Result`, so the crate keeps two spellings of temperature validation by design. +- Add `pub fn with_invocation(mut self, invocation: ModelInvocation) -> Self` and `pub fn invocation(&self) -> &ModelInvocation`. The second is not optional bookkeeping: the new test reads the field it pins, and `CompletionOptions` is `#[non_exhaustive]` with private fields, so no test can reach the invocation otherwise. +- Rewrite `CompletionOptions::new` at lines 291-297 to build an empty `ModelInvocation`, and `ModelBinding::completion_options` to `CompletionOptions::new(self.id.name()).with_invocation(self.invocation.clone())`. +- Nothing outside the crate changes. `crates/harness-gateway-client/src/wire/request.rs:39,61,64,67` reads the four getters, `crates/workshop/server/src/agents/broker.rs:99` and `:114` use `model()` and `with_model()`, and all five `binding.completion_options()` callers keep their signature: `harness-gateway-client/src/wire/request-tests.rs:101`, `model-client/src/model/tests.rs:77`, `engine/src/test_support/scenario-driving.rs:72`, `harness-internal/runner/src/effect_loop.rs:347`, and `engine/benches/models_loop.rs:105`. The benches compile only under the all-targets clippy gate. + +**Item 3, the cheap sweep.** Five deletions and renames, then two doc blocks. + +- Rename the `ModelView` trait method `default` at line 445 to `default_alias`, matching `ModelSet::default_alias` at line 401, so the name no longer collides with the `Default` trait that both `ModelInvocation` (line 72) and `ModelSet` (line 369) derive. Change the `impl ModelView for Mutex` arm at line 468 to call `ModelSet::default_alias`. All three call sites move in this step: `src/model/options-tests.rs:24` and `:33`, which spell the call as `ModelView::default_alias(&set)` so the trait method stays the one under test, and `crates/runvil-internal/lua/src/vm/state.rs:271`, where `bindings.default()?` on a `dyn ModelView` becomes `bindings.default_alias()?`. +- Delete `ModelSet::from_parts` at lines 379-384. All eleven callers are test fixtures or assertions: `engine/src/model/tests.rs:61`, `engine/src/lua/tests.rs:42`, `engine/src/lua/tests/globals.rs:33`, `engine/src/execute/tests/models_loop.rs:32`, `models_loop-author-shapes.rs:229`, `precheck_anchor.rs:313`, `rendered_tools.rs:244`, `scheduler.rs:25`, `scheduler/failures-script-tools.rs:234`, `lua/src/models-tests.rs:62`, and `model-client/src/model/tests.rs:101`. Rebuild each with `ModelSet::default()`, `push` for each binding, and `set_default_alias` where the fixture named a default. `model-client/src/model/tests.rs:101` is an equality assertion rather than a fixture, so compare against a second set built that way; `a_model_set_grows_by_push_and_takes_its_default_alias_by_setter` at line 83 already asserts that `push` plus `set_default_alias` produces what `from_parts` did, which is the equivalence the deletion relies on. +- Delete the `TryFrom for Temperature` impl at lines 45-51, leaving `Temperature::new` as the only way in. `lua/src/models.rs:110` decodes a temperature through `Temperature::new`, not the impl, so Lua behavior does not move, and `options-tests.rs:98-141` already pins the surviving constructor's range from both ends. +- Delete `bindings()` from the `ModelView` trait at lines 439-441 and from the impl at lines 464-466, leaving `binding()` and `default_alias()` as the trait's reads. Update `engine/src/error/tests.rs:324`, which calls `set.bindings()` on an `Arc>`, to reach the same lock failure through `set.binding("writer").expect_err("a poisoned set cannot be read")`, because the trait's `binding` still returns `Err(ModelSetError)` on a poisoned lock. Update `src/model/options-tests.rs:23` and `:32`, because `Mutex` has no `bindings` of its own once the trait arm is gone: line 23 becomes `assert!(set.lock().is_err(), "a poisoned set cannot be read")` and line 32 becomes `assert!(set.lock().expect("a healthy set locks").bindings().is_empty())`. The `bindings()` calls at `src/model/tests.rs:85` and `:91` and `engine/src/execute/tests/precheck_anchor.rs:187` reach the inherent `ModelSet` method and need no edit. +- Make `CompletionOptions::model` private at line 278, and read it through `model()` at `src/model/tests.rs:78`, which asserts the field directly as `opts.model` today. +- Reword `ModelSet`'s type doc at lines 366-367: it says "the prompt-level bindings produced by live H1 execution plus the prompt-wide `default` alias"; replace "produced by live H1 execution" with "built at run construction from prepare's role bindings". +- Reword `ModelView`'s doc at lines 429-432. It claims the context shares the set "as `Arc`", that "once that VM is dropped no write handle remains", and that "post-H1 frozenness is structural", none of which holds: the context holds `Arc>` at `engine/src/execute/context.rs:100`, every section VM gets a write-capable clone (`section_context-construct.rs:85`, `:170`, and `context.rs:285`), and `models.default` works from any section, as `lua/src/models.rs:228-237` says. Replace those four sentences with this paragraph, which already ends with the existing mutex-guard sentence, so nothing is duplicated: "The run holds one `ModelSet` behind a mutex and hands every section VM a handle to it. This trait exposes reads only, so a reader cannot change the set through it. The one write any Engine path performs is `models.default`, which sets the default once and refuses a second, different label. Every method locks briefly and returns an owned snapshot: a mutex guard cannot outlive the call." + +**Item 6, the message rule stated once.** The rule is told three times, on `phrase` at `src/model/error.rs:52-64`, on `CompletionError` at lines 143-151, and on `new` at lines 164-175, and the three copies already differ in small ways. + +- Add the rule once to the module doc at lines 1-2: "The message is the kind's fixed phrase. A failure built from an HTTP status appends ` (status N)`, and a 401 or 403 is `Unavailable` with `the model backend did not accept the credentials`. Only `MalformedResponse`, `EmptyReply`, `Unavailable`, and `InvalidParts` extend it with `: ` and text this repository's own code wrote. Provider text never enters the message; it goes in `detail`." +- Cut the three blocks to pointers. `phrase`'s doc becomes: "Returns the fixed phrase for this kind: one lowercase phrase written for a model to read. The module page gives the message shapes a caller builds from it." `CompletionError`'s doc keeps its field list and its "`Display` shows only the message" sentence, and replaces its four-sentence restatement with "See the module page for the message shapes." `new`'s doc becomes: "Builds a failure of `kind` with `message` as its display text. The caller passes the kind's fixed `phrase`; the extension rules live on the module page. Provider text goes to `with_detail`." +- Fix the builder list at lines 139-140, which names `new` and `context_overflow` where the public surface has three: `empty_reply` at line 199 is public and published at `crates/runvil/public-api.txt:574`. Replace the sentence with: "The caller builds one with [`new`](CompletionError::new), [`empty_reply`](CompletionError::empty_reply), or [`context_overflow`](CompletionError::context_overflow) and adds the extras with the `with_` methods. `empty_reply` is the builder that chooses between the bare phrase and the phrase extended with the reasoning-ignored text." `error-tests.rs:245` (`the_shared_empty_reply_owner_builds_the_phrase_and_the_reasoning_specific`) already pins that builder and must pass unedited. + +**Item 7, five re-exports and the `models.bind` clause.** + +- Replace the block at `src/model.rs:23-25` with `pub use runvil_types::models::ModelId;` plus the comment "`runvil-lua` names the model id through this path; the rest of the catalog vocabulary is imported from `runvil-types` directly." Only `ModelId` is ever named through that path: `lua/src/models.rs:17`, `lua/src/models_loop/tests.rs:9`, `lua/src/protocol/tests.rs:15`, and `lua/src/models-tests.rs:6`. The other five are imported from `runvil_types::models` directly at every use, for example `engine/src/model.rs:47`, and nothing in this crate imports them through `crate::model`. +- Repoint four doc links into the deleted block in the same change, because `broken_intra_doc_links` is denied: `src/lib.rs:9`, `:19`, and `:20`, and the unqualified `ModelCatalog` link at `src/model.rs:3`. Point them at `runvil_types::models::*`. The facade re-exports all six from `runvil_types` at `crates/runvil/src/lib.rs:93-98`, so those doc paths keep resolving at the facade. +- Fix the module doc's opening at `src/model.rs:4-8`: it says "H1 `models.bind` resolves a description against that catalog under hard constraints, freezes invocation parameters, and stores the result in the Engine's run-scoped model bindings", but no such global exists. `lua/src/models.rs:234-235` says "There is no `models.bind`: binding is the frontmatter's", and `the_models_namespace_has_no_bind` at `lua/src/models-tests.rs:73` asserts `models.bind == nil`. Replace that clause with: "The prompt's frontmatter declares each model role, prepare resolves every role against that catalog under the role's hard keywords and context minimum, and the run builds its binding set from those bindings before any Lua runs." Leave the `models.use` clause untouched, but leave nothing wrong beside it: the same doc assigns `models.default` to H1 at line 7, so reword that to "`models.default` supplies the prompt-wide default, conventionally from H1", matching `lua/src/models.rs:230-233`. + +**Blessing, once for both API-moving items.** Run `cargo +nightly-2026-09-05 xtask api --bless` from the repository root with `CARGO_BUILD_WARNINGS` unset, after Item 2 and Item 3 and before committing. The expected listing difference is four lines: the two new `CompletionOptions` methods added to the nine-line block at `crates/runvil/public-api.txt:586-594`, and the two `TryFrom for Temperature` lines removed at 354-355. All nine surviving `CompletionOptions` signatures keep their exact text. If the five re-export deletions also remove listing lines, name them in the commit message, because the facade re-exports all six names from `runvil_types` and should keep every facade path. Any other change means a signature moved and must be understood before committing. + +Acceptance: both new tests pass; the existing tests listed in the Testing Plan pass unedited, namely `model/tests.rs` read-back and `with_model`, `options-tests.rs` temperature range, `client/tests.rs` name validation, `error-tests.rs` `each_kind_has_its_fixed_phrase`, `a_phrased_failure_shows_only_the_fixed_phrase`, `a_specific_extends_the_phrase_after_a_colon_and_never_adds_detail`, `normalize-tests.rs` `non_object_arguments_are_rejected`, `read-tests.rs` around line 170, and `models-tests.rs:73` and `:160` with its two-key rejection message at line 196; `cargo +nightly-2026-09-05 xtask api --check` passes with `CARGO_BUILD_WARNINGS` unset; and the docs gates pass with `broken_intra_doc_links` denied. + + + + + +### Step 3: correct the two guide chapters [completed] + +- Component: `runvil-docs-guide` +- Tests: from the `runvil` repository root with `RUNVIL_DOCS` set to `c:\Users\Vinnie\cursor\runvil-docs`: + - `cargo xtask site --books-only` + +Carries Item 9 (S2-5, S2-8). One commit, in the `runvil-docs` clone, because that clone is its own repository; the plan's decision record requires the guide edits to land there under their own commit. Nothing in the `runvil` repository changes, so this step produces no commit there. + +One piece. The two chapters are one piece of prose work with the guide's house rules as its only shared contract, and both edits are single-sentence corrections, so there is nothing to divide. + +- These edits land in the `runvil-docs` clone, now at `b6c0d90`. Every chapter keeps one paragraph per line, uses no em dash and no double dash, and opens code fences with four backticks, per `runvil-docs/CONTRIBUTING.md`. +- In `src/language/11-conversations.md` line 1217, delete `, and \`thinking\`` so the sentence names `temperature` and `max_tokens` only, leaving "such as `temperature` and `max_tokens`, apply to every round of the loop". `models.use` accepts exactly those two keys and rejects every other, as `lua/src/models.rs:185-193` shows, and `thinking` is frozen from the role's hard keyword. +- In `src/language/10-models.md` line 1453, replace "The Engine adds no metrics of its own, so `metrics` holds only what the provider reported." with "The Engine adds nothing of its own, so every figure comes from one of four sources: the backend's `usage`, llama.cpp's `timings`, vLLM's `metrics`, and the client's own `client` timing." Those four are the sections `CallMetrics` declares at `crates/runvil-internal/types/src/metrics.rs:85-97`. +- Nothing else is needed in the guide. Its error-kind table already gained the `InvalidParts` row in commit `4f537d5`, which closed that finding. +- The build command is `cargo xtask site --books-only` run from the `runvil` root, which is what `runvil-docs/CONTRIBUTING.md:7` names; the `runvil-docs` clone has no `xtask` of its own. Do not add `SUMMARY.md`, `index.md`, or `src/introduction.md` to the clone, because the assembler writes those into the staged books only. + +Acceptance: both sentences read as quoted above, the guide keeps one paragraph per line, and `cargo xtask site --books-only` builds every book with no link failure. + + + + diff --git a/vibe/2026-10-30-1-engine-vibelight-cleanup.md b/vibe/2026-10-30-1-engine-vibelight-cleanup.md new file mode 100644 index 000000000..756370744 --- /dev/null +++ b/vibe/2026-10-30-1-engine-vibelight-cleanup.md @@ -0,0 +1,799 @@ +--- +name: Vibelight runvil-engine +overview: "Vibelight slop scan of the runvil-engine crate: outline it, raise and verify findings against the code and docs, and leave a self-contained Cleanup Plan." +todos: [] +isProject: false +--- + +# Vibelight: runvil-engine + + + +## Product Requirements + +### Scope + +- **Crate:** `runvil-engine` at `crates/runvil-internal/engine`, outline 708 KB + - outline: `.vibelight/runvil_engine.md` +- **Outlines:** current, reused as written (index 2026-10-30 08:09 UTC) +- **Context:** 48 other outlines listed in `.vibelight/index.md`, not analyzed +- **Excluded:** 7 crates, all build tooling, cargo aliases, or a test fixture +- **Doc roots:** + - `../runvil-docs`, the sibling guide clone, last commit 2026-10-29 + - the crate's `README.md` and `AGENTS.md` + - the repository's `README.md` and `AGENTS.md` + - `crates/README.md` and `crates/runvil-internal/README.md` + - the design records in `vibe/`, including the 2026-10-26 engine plans +- **Limits:** + - test, build, example, and doc test source adds qualified-path and lexical token hits + - that source has no catalog records + - method calls never fill `crates`, `tests`, or `direct_callers` + - macro-generated code is not counted + - dynamic trait dispatch and external consumers are settled by search + - plan mode: nothing in the repository is built, tested, or run + - a type's methods are cataloged under the type's module, wherever their file sits + +- **Stats:** 136 candidates, 112 accepted, 19 rejected, 5 merged; 103 cheap, 9 substantial; 3 decisions, all answered +- **Work:** 28 items, 8 of them substantial; 2 findings deferred; none wait on an answer +- **Goal:** every doc and comment states the current contract once, where its owner is +- **Goal:** the type or the compiler holds each rule a doc alone holds today, where that is cheap +- **Goal:** the engine's docs and the guide agree with the code on every cited passage +- **Non-goal:** new behavior, new public API, or any change to a persisted or wire format +- **Non-goal:** other crates, except the named runvil-lua sites and the `runvil-docs` guide +- **Success:** every repository gate in the Testing Plan passes +- **Success:** a fresh plain `vibelight` run shows the edited declarations and comments + +## Functional Specification + +### Debt Inventory + +State, types, and code + +- State: internal failures from runvil-lua report a line of `src/error/convert.rs`, not their own (S1-4) + - consequence: `RunError::location` breaks its public promise for every Lua-crate internal fault +- State: `Effect::Chat` allows `after: None` with `keep > 0`, which the docs forbid (S3-4) + - consequence: such an effect would log a request missing its first `keep` messages +- State: `Chain::deliver` silently replaces an answer it already holds (S4-3) + - `ChainOps::answered` counts answers that can never exceed one per view +- State: `TaskArena::take_delivery` writes `Delivered` first and restores three states by hand (S5-9) +- Duplication: `SectionContext::new` and `new_h1` copy VM construction, limits, setup, and literal (S5-4) +- Reuse: `accept_infer` takes `emitter` and `turns` apart though `ReportingHandles` bundles them (S2-12) +- Duplication: the discovery texts spell `GetDynamicTools` and `CallDynamicTool` as literals (S5-11) +- Duplication: the `pattern` schema says 256 while `PATTERN_LIMIT` lives elsewhere (S5-12) +- Duplication: `H1_GATE_FAILED` hard-codes the notice header `Requirements::notice` builds (S5-17) +- Vestiges: `advance_turn` keeps an `unwrap_or(u32::MAX)` that cannot run (S5-2) +- Vestiges: `SourceLocation` keeps four builder methods for one function in its module (S2-4) +- Vestiges: `From` for the engine error is reached only by a test (S1-24) +- Vestiges: `src/cancel.rs` is a one-item re-export no other `runvil_types` import uses (V1-2) +- Vestiges: `#[non_exhaustive]` on `ModelBindings`, `RunContext`, and `RunError` restricts nothing (S1-13, S2-5) +- Vestiges: `test_support/scripted-chat.rs` keeps a `#[path]` whose reason is gone (S8-4) +- Placement: `MAX_CALL_DEPTH` sits in the scheduler core, away from `CallDepth` (S3-14) +- Visibility: `RunContext::limits` is `pub(crate)` while its siblings are `pub(super)` (S1-14) +- Visibility: `Run::from_state` is `pub(crate)`, so in-crate code could skip the start checks (S2-6) +- Visibility: six admission items are `pub(super)` with every user in their own module (S4-6, V4-1) +- Visibility: the `cfg(test)` `recording` module is `pub`, against its module's own rule (S6-16) +- Naming: `Admission::take_back` gives slots up, where the module uses "take back" for regaining (S4-1) +- Naming: `bound_tool_set` binds nothing, by its own doc (S2-7) +- Naming: two bodies bind `chain_id: ChainId` beside `id: ChainIndex` (S3-12, S4-5) +- Naming: `prepare_effective_scope` keeps a term from the August design (S5-16) +- Naming: `SectionOffers::read` names the run's `ToolSet` `set`, a word the module uses for one set (S5-15) +- Test gap: the store error round trip checks only the tag, never the fields (S1-19) + +Doc text, by file + +- `src/error.rs`: wrong or restated docs at 10 sites (S1-3, S1-6, S1-7, S1-8, S1-9, S1-11, S1-22, V1-3, V5-1) +- `src/error/convert.rs`: `from_raised`, both bridge docs, and the store inverse (S1-1, S1-5, S1-19) +- `src/error/sets.rs`: four docs restate their parent (S1-10) +- `src/execute/config.rs`: the filesystem and bindings contracts stated four and three times (S1-23, V1-4) +- `src/execute/config-limits.rs`: struct and `default` docs repeat fields (S1-15, S1-16) +- `src/execute/bindings.rs`: "the fill function's only writer" reads false (S1-12) +- `src/execute/context.rs` and `section_vm.rs`: argv and ui stated on both, one copy wrong (S2-8, S5-6) +- `src/execute/context.rs`: `section_sys` documents `SectionSys` fields; a comment misnames a counter (S2-9, S2-10) +- `src/execute/run.rs`: `Step::Pending` restates its fields (S2-11) +- `src/execute/run/effect.rs`: `ToolCall` and `Vfs` docs repeat and blur their rules (S3-1, S3-2) +- `src/execute/scheduler.rs` and `chain.rs`: four docs restate or misstate (S3-6, S3-9, S3-10, S5-8) +- `src/execute/scheduler/chain_ops.rs`: `answer` says it resumes; `number_round` copies its callee (V4-3, S4-10) +- `src/execute/scheduler/admission.rs`: rules restated three times; `var` "never copied" is false (S4-7, S4-8) +- `chain_record.rs`, `kind.rs`, `anchor.rs`: id scheme and module docs restated (S4-9, S4-12) +- `dispatch.rs` and `tasks-spawn.rs`: call sites restate the child-counter rule (V4-2, S3-11) +- Root lineage: one rule restated at about ten scheduler sites (S3-8, S5-7) +- `src/execute/scheduler/chat.rs`: `ArrivedRound` says the turn counter is the chain's own (S4-11) +- `src/execute/scheduler/h1.rs`: the counters link targets the type, not the field (S5-19) +- `src/execute/section_context.rs`: four docs incomplete or restated (S5-1, S5-3, S5-21, S5-23) +- `src/execute/section_context-construct.rs`: roles and bare propagation in six docs (S5-5, S5-22) +- `src/execute/scheduler/tool_call.rs`: `dispatch_tool_calls` restates the batch rule (S5-10) +- `src/heading_address.rs`: two lookups restate rules their callees own (S6-1, S6-2) +- `src/subst.rs`: argv nil rule wrong at four sites; lookup errors incomplete (S6-9, S6-11, S6-13, S6-14) +- `src/model.rs`: a false reason, a restated Lua rule, and a Harness crate named (S6-7, S6-8, S8-3) +- `src/test_support/scripted-chat.rs`: three docs claim more than the code (S6-17, S6-18, V6-1) +- `src/lua/tests/quota.rs`: describes the deleted instruction budget (V7-1) + +Documentation drift + +- `RunErrorKind::RequirementsUnmet`, `Error::RequirementsUnmet`, guide ch. 4 and 16 miss reserved Pack ids (S2-1, V2-2, S7-5); docs change +- `RunErrorKind::Internal` omits caller contract breaches the code reports there (S2-2); doc changes +- `RunErrorKind::Quota` and `Error::LuaQuota` promise a deleted instruction budget (S7-4); docs change +- `RunErrorKind::Parse` and `Lua` docs misplace a block compile failure (S7-7); docs change +- `RunResult::Cancelled` omits a late re-raised `cancelled` value (V1-1); doc changes +- `Error::TaskNotOwned` gives ownership to the spawner; the root walk owns H1 tasks (V5-1); doc changes +- runvil-lua `SpawnVia` says the depth refusal names the spawn; it names the cap (S1-21); doc changes +- `infer.rs` credits the no-failure rule to the caller's abort and names Harness internals (V2-1); doc changes +- `lib.rs` and the crate README say "catalog tool call" and "every store operation" (S8-1, V8-2); docs change +- The crate README claims determinism without answer order (S8-2); doc changes +- `src/model.rs`, test comments name a Harness crate against `AGENTS.md:26` (S8-3, V8-1); comments change +- The crate `AGENTS.md` permits provider re-exports that `build-xtask` forbids (S8-5); rulebook changes +- `crates/runvil-internal/README.md` names one allowed dependent; `build-xtask` allows two (S8-6); doc changes +- Guide ch. 12 quotes the out-of-scope message with its old label (S7-1); guide changes +- Guide ch. 1 leaves `Dropped` out of the answer forms (S7-2); guide changes +- Guide ch. 4 and 16 promise `run_failed` and a parse failure for build-time refusals (S7-3, V7-4); guide changes +- Guide ch. 10 and 15 promise a per-task round cap that no code has (S7-6, V7-2); guide changes +- Guide ch. 5 omits discovery calls from when `sys.model` appears (S7-8); guide changes +- The nil-argv message names one of three causes, and the guide quotes it (S6-10); message and guide change +- `NotATable` names a case it does not cover; the guide calls the kind stable (S6-12); deferred, name kept + +Report defects in the vibelight output + +- A type's methods sit under the type's module, so a module-range split hands a child file's methods to its parent's call + +Rejected candidates + +- 19 rejected: 13 weak, 5 intentional by a design record or the guide, 1 false + + + + +## Technical Design + +- Lua internal location: `runvil_lua::Error::Internal` carries a `&'static Location<'static>` + - captured by a `#[track_caller]` `pub(crate)` constructor at each build site, read by the engine bridge + - rejected: a doc saying bridged faults report the bridge, which only records the papercut + - cost: every Lua-crate internal build site moves to the constructor +- Chat prefix: only the live `Effect::Chat` changes, to one `Option` of the round and the count + - `Effect::record` flattens it, so `EffectRecord::Chat` and the run log stay as they are + - rejected: changing the record too, a persisted format recorders store +- Double delivery: `Chain::deliver` refuses a second answer with an internal error + - one check covers its three entry points; `answered` becomes a `bool` +- Root lineage: the rule lives once, on `Chain::lineage` + - the `h1` module doc keeps why the pass shares the lineage and hands its tasks over + - each other site keeps only its local consequence; the ones that still name the lineage link to `Chain::lineage` +- Admission naming: `take_back` returns to `release`, reversing part of commit `b219b85db` + - `park` stays the reversible give-back that `wake` undoes +- Frame constructors: two private helpers in `construct` hold the VM build and the literal + - `new` keeps reporting `SECTION_STARTED` before it calls the helper + + + + +## Testing Plan + +Run from the `runvil` root. The pinned nightly is `nightly-2026-09-05` (`crates/build-xtask/src/api/toolchain.rs:19`), and the Engine's floor is Rust 1.95 (`rust-version` in the root `Cargo.toml`). + +- `cargo fmt --all --check` +- `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` with `CARGO_BUILD_WARNINGS=deny` (PowerShell: `$env:CARGO_BUILD_WARNINGS = "deny"`) +- `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` +- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` +- the facade docs build: `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps` +- the Harness facade docs build: `RUSTDOCFLAGS="-D warnings" cargo doc -p harness --no-deps` +- the private docs build: `RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` +- `cargo +nightly-2026-09-05 xtask api --check`, with `CARGO_BUILD_WARNINGS` unset (PowerShell: `Remove-Item Env:CARGO_BUILD_WARNINGS`) +- the floor build: `cargo +1.95 build --locked -p runvil -p runvil-engine -p runvil-lua -p runvil-parser -p runvil-types -p runvil-vfs -p runvil-model-client` +- `cargo test -p build-xtask` +- `node --test crates/workshop/ui/test/docs-claims.mjs` +- the guide's site build: `RUNVIL_DOCS=../runvil-docs cargo xtask site --books-only`, with mdBook 0.4.44 on `PATH` or named by `MDBOOK` +- a fresh plain `vibelight` at the repository root; the outlines in `.vibelight/` are never committed +- New test `a_second_delivery_before_resume_is_an_internal_error`, in `src/execute/tests/scheduler/failures.rs` + - asserts `Chain::deliver` refuses a second answer to a suspended block and keeps the first + - fails today: both deliveries return `Ok`, and the block holds the second answer +- New test `a_refused_delivery_leaves_the_entry_unchanged`, in `src/execute/scheduler/tasks-tests.rs` + - asserts a `Cancelled`, `Abandoned`, or `Running` entry is unchanged after `take_delivery` fails + - passes today; it pins the behavior the rewrite of `take_delivery` must keep +- Extended test `an_owned_lua_internal_message_reaches_the_engine_error_whole`, in `src/error/tests.rs` + - asserts the location's path and line are those of the site that built the Lua error + - fails today: the location names `src/error/convert.rs` line 177 +- Extended test `nil_argv_is_an_error`, in `src/subst-tests.rs` + - asserts the bare and dotted messages read "is nil (argv holds no value)" + - fails today: both read "is nil (the args string is not JSON)" +- Tightened test `every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag`, in `src/error/tests.rs` + - asserts the rebuilt error equals the original for every variant whose fields round-trip + - passes today; it catches the next field one side of the round trip drops + + + + +## Decision Record + +- Dropping `#[non_exhaustive]` from all-private structs is reversible: adding it back breaks no caller +- `Effect::Chat`'s public fields change; the change reverses inside the repository, because `runvil` is `publish = false` and every binding site outside the Engine names the variant with `..` +- `RunLimits::max_concurrency` keeps the clamp rule, because callers set it and cannot read private docs +- The admission module doc's copy of the clamp rule defers to `Chain::concurrency`; two more copies wait in Deferred +- The `id: ChainIndex` convention across about 45 parameters stays; only the two `chain_id` locals change +- The argv and ui value lives on `RunState`; how a VM installs it lives on `SectionVmSetup` +- Guide edits land in the `runvil-docs` clone, its own repository, under their own commit +- Assumption: `AtomicU32::update` exists at the Engine's 1.95 floor, where `try_update` already builds; the floor build settles it +- Risk: the Lua internal location touches every Lua-crate build site; the compiler finds each one +- The nil-argv message becomes `{{ path }} is nil (argv holds no value)`, with its three guide quotes (S6-10) + - operator's answer, 2026-10-30; the old parenthetical was false for a JSON `null` and for an H1 that left `argv` nil +- `NotATable` keeps its name, so the guide's promise of stable kind names holds (S6-12) + - operator's answer, 2026-10-30; the message beside the kind already says what happened +- The Engine Definition says "a Pack tool's result", and the root `README.md` follows (S8-1) + - operator's answer, 2026-10-30; model-only tools also leave as `Effect::ToolCall` + +### Open Questions + +- None remain; the three this scan raised are answered in the Decision Record above + +### Deferred and Out of Scope + +- Renaming the `NotATable` substitution kind after its rule, such as `WholeOnly` (S6-12) + - revisit when the substitution kinds next change; the guide promises the ten names are stable + +- Routing a parser Lua failure to `Error::Parse` in the test-only bridge (V7-3) + - revisit when engine production code first converts a parse error +- Capturing the parser's internal location the way the Lua crate's internal failures capture theirs (S1-4) + - runvil-parser builds `Error::Internal` at six sites that no Engine production path converts + - revisit with V7-3 +- Moving the store error inverse into runvil-lua beside its forward table (S1-19) + - revisit when a `VfsError` variant is next added +- runvil-types `ids.rs:4-5` and `:52-54` leave the wait shims' timers out of the child counter + - revisit in a runvil-types scan +- `ChainStart::concurrency` (`src/execute/scheduler/chain.rs:56-59`) and `dispatch_concurrency` (`src/execute/scheduler/waits.rs:171-177`) restate the start and clamp rule `Chain::concurrency` owns + - revisit in the next runvil-engine scan +- `dispatch_infer`'s doc (`src/execute/scheduler/dispatch.rs:193-196`) still calls the round "the single tool-free gateway round" + - revisit in the next runvil-engine scan + + + + +## Project Survey + +None + + + + +## Execution Instructions + +The letters and numbers in parentheses are tags from the analysis; nothing here needs them to act. + +Paths are below `crates/runvil-internal/engine/` unless they name another crate or `runvil-docs`. Line numbers are from commit `157bc77e9`; find each site by its symbol if a line has moved. Every gate named in a Prove line is spelled out in the Testing Plan. + +#### 1. Internal error docs in `src/error.rs`, and `SpawnVia` in runvil-lua + +- Problem: eleven docs and one body comment on the internal error type are stale, restated, or wrong +- Module doc, `:8-9`, the sentence that begins "See the module wrappers", new text (S1-7): + - "The `From` bridges that let internal `?` reach this type live in `convert`." +- `out_of_scope_message` body comment, `:69-73`: keep only its first sentence, which ends "a tool it cannot call"; delete the sentence that begins "The caller holds that fact", which the `has_dynamic_set` field doc (`:291-293`) already states (S1-9) +- `DepthVia` doc, `:89-95`, new text, one paragraph (V1-3): + - "The call name [`Error::CallDepthExceeded`] reports for a nesting that passed the call-depth cap. The name is the cap's, not the author's construct: `tasks.fanout` for a fanout arm, and `call` for a `call` or a `tasks.spawn`, which shares its cap." + - keep the variant docs at `:98` and `:100` +- `Error` doc, `:113-119`, new text (S1-8): + - first paragraph: "The crate's internal error type, spanning parsing, model, and execution failures." + - second paragraph: "The model variant holds the broker's closed error whole, so no HTTP client's error type leaks through the wrappers' `source()`." +- `Interrupted`, `:142-143`, new text (S1-6): + - "The run was cancelled, the caller dropped an effect the run was waiting on, or a block raised a caught `cancelled` error value again after another suspending call." +- `TasksLive`, `:232-233`: replace the last sentence, "The message names the ids in spawn order; the Lua table lists them as `tasks`.", with (S1-22): + - "The message names the ids in spawn order, and the Lua table holds them as one `tasks` string joined by `, `." +- `TaskNotOwned`, `:245-247`: replace the first sentence, through "the task it runs inside.", with (V5-1): + - "Only the task's owner - its spawner, or the root walk for a task the H1 pass spawned - may wait on, inspect, or cancel it; a chain may additionally read the status of, and annotate, the task it runs inside." + - keep the sentence that begins "An id that names no task at all" +- `RequirementsUnmet`, `:332-335`, new first paragraph; keep the second paragraph at `:337-338` (S2-1, S1-17): + - "The environment cannot satisfy the prompt, for any cause [`RunErrorKind::RequirementsUnmet`](crate::RunErrorKind::RequirementsUnmet) lists." +- `Internal::message`, `:371-372`: "The violated invariant, as a noun phrase." (S1-11) +- `Internal::file`, `:374`, and `Internal::line`, `:376` (S1-3): + - "The Rust source file of the construction site." and "The 1-based line of the construction site." +- `LuaQuota`, `:382-385`, new text (S7-4): + - "A Lua quota ran out: a `log()` event or byte budget. This stable typed variant lets the caller tell quota exhaustion apart from an authoring error." + - `QuotaResource` has only `LogEvent` and `LogByte` (`crates/runvil-internal/lua/src/error.rs:47-54`) +- runvil-lua `crates/runvil-internal/lua/src/protocol/request.rs:23-25`, the `SpawnVia` doc, new text (S1-21): + - "Which author-facing call started a task chain. The Engine reports a depth-cap refusal under the cap's name, not the call's: a `tasks.spawn` shares the `call` cap and reports `call`, and a fanout arm reports `tasks.fanout`." + - the Engine maps `SpawnVia::Spawn` to `DepthVia::Call` at `src/execute/scheduler/tasks-spawn.rs:79-82` +- Prove: the private docs build and the workspace docs build + +#### 2. Public run error docs, and `SourceLocation` literals + +- Problem: the public kind docs misstate four causes, and `SourceLocation` keeps builders only one function uses +- `src/execute/error.rs:18-19`, `RunErrorKind::Parse`, new text (S7-7): + - "Parsing the prompt failed: invalid frontmatter YAML, a failure the parser classified as structural, or a Lua block that did not compile." +- `:40-45`, `RunErrorKind::Lua`: replace "Lua source that did not compile" with "a Pack prelude that did not load" (S7-7) + - a prelude load failure fails the run as `Lua` (`src/execute/config.rs:272-275`, pinned by `src/execute/tests/preludes.rs:74`) +- `:47-50`, `RunErrorKind::Quota`, new text (S7-4): + - "A Lua quota ran out: the `log()` event budget or the `log()` byte budget. Also the `call`, `tasks.spawn`, or `tasks.fanout` arm that would have nested past the call-depth cap." +- `:56`, `RunErrorKind::Internal`, new text (S2-2): + - "An internal invariant failed, or the caller broke the `step` and `resume` contract: a `step` after `Done`, an answer of the wrong kind, an answer for an id the run never issued, or a second answer for one effect." + - the breaches are built at `src/execute/scheduler/drive.rs:78-81` and `:108-111`, `src/execute/run.rs:209-211`, and `src/execute/scheduler/apply.rs:155-192`; `src/execute/run/tests.rs:340` and `:489` pin them as kind `Internal` +- `:58-62`, `RunErrorKind::RequirementsUnmet`, new text (S2-1): + - "The environment falls short of what the prompt needs: a missing or unavailable required Pack, a service the caller left out, a model requirement a bound model fails to meet (a context minimum or a hard keyword), a reserved Pack id that the run's tool catalog or Pack roster names, or an H1 block that failed the prompt's hard gate." +- `:195-207`, `RunError::originating_error`, new text; keep `:209-211` (S2-3): + - "Returns the error this failure originated from, when it keeps one: a Lua runtime failure keeps the `mlua` error, a Lua compile failure the compiler diagnostic, a schema binding failure the validation error, a tool failure the tool's own error, a store failure the store's structured error, an unmet requirement the failure it stands in for, an internal fault the failure that broke the invariant, and a parse failure the parser's cause. A model round's failure and a failed `{{ }}` substitution answer `None`: whatever cause each holds belongs to the error it wraps." +- `:100-132`: delete `SourceLocation::new`, `with_line`, `with_column`, and `with_span` (S2-4) + - their only calls are the two arms of `RunError::location` (`:262-270`); a workspace search finds no other + - parse arm: `SourceLocation { path: error.name().unwrap_or("").to_owned(), line: error.line().map(NonZeroU32::get), column: error.column().map(NonZeroU32::get), span: error.span() }` + - internal arm: `SourceLocation { path: (*file).to_owned(), line: Some(*line), column: None, span: None }` + - the struct's `#[non_exhaustive]` (`:74`) binds only other crates, so the literals compile here +- `src/execute.rs:119-120`, `RunResult::Cancelled`, new text (V1-1): + - "The caller cancelled the run or dropped an effect the run was waiting on and the cancelled error went uncaught, or a block raised a caught `cancelled` error value again after another suspending call." +- Prove: the facade docs build and `cargo nextest run -p runvil-engine -E 'test(location) or test(internal_faults)'` + +#### 3. Bridge docs and the test-only `ModelSetError` bridge in `src/error/convert.rs`, and `src/error/sets.rs` + +- Problem: the bridge docs tell history and misstate `from_raised`, four set docs restate their parent, and one `From` impl serves only a test +- `convert.rs:290-295`, `from_raised`: replace the last sentence, the one that begins "A kind whose variant needs structure", with (S1-1): + - "A `task_not_owned` or `task_consumed` table rebuilds its variant from its `task` field, and every `cancelled` table maps to [`Error::Interrupted`]. A kind whose variant needs structure the table does not hold (the tool-scope errors, `tasks_live`, `model_failure`, `model_required`, `internal`), or a `context_exhausted` or task table missing its field, keeps its message as a Lua failure." + - the arms are at `:299-304`, `:321`, `:326-333`, and `:338-343` +- `convert.rs:127-132`, `From`, new text (S1-5): + - "Maps a parse failure onto this internal type: a frontmatter or structured failure stays a parse failure whole, and a Lua compile or internal failure maps onto its own variant. The parser crate's error type is not `#[non_exhaustive]` (the two crates version together), so this match is total." +- `convert.rs:146-150`, `From`, new text (S1-5): + - "Maps the Lua crate's internal type onto this one variant for variant, and rebuilds a raised error table through [`Error::from_raised`]. The Lua crate's error type is not `#[non_exhaustive]` (the two crates version together), so this match is total." +- `sets.rs`: delete four field docs that restate their parent (S1-10) + - `RenderedPacks::asked` (`:39`) and `answered` (`:41`); the variant doc at `:36-37` keeps "in order" + - `SetTool::id` (`:58`) and `SetTool::set` (`:60`); the struct doc at `:54-55` says both + - the fields are `pub(crate)`, so `missing_docs` does not fire +- Delete `From` for the engine error, which only a test reaches (S1-24) + - production reaches the model view only through runvil-lua's `resolve_model_binding`, whose own `From` (`crates/runvil-internal/lua/src/error.rs:264-271`) converts it + - delete the impl at `convert.rs:111-119` and its import at `:14` + - drop `ModelSetError` from the `crate::model` re-export at `src/model.rs:43-46`; nothing else in the crate names it + - retarget `a_poisoned_model_set_maps_to_an_internal_error_and_never_a_completion_error` (`src/error/tests.rs:308-333`): `let error = Error::from(runvil_lua::Error::from(failure));` + - its message assertion holds: `ModelSetError`'s `Display` is "model set mutex was poisoned" (`crates/runvil-internal/model-client/src/model/options.rs:422`) + - runvil-lua `crates/runvil-internal/lua/src/error.rs:264-266`: replace "the mapping `runvil-engine` applies too" with "the mapping the Engine receives through its `From`" +- Prove: the private docs build, the clippy gate, and `cargo nextest run -p runvil-engine -E 'test(poisoned) or test(raised_table)'` + +#### 4. Delete the one-item `cancel` module + +- Problem: `src/cancel.rs` holds only `pub(crate) use runvil_types::cancel::CancelHandle;`, while every other `runvil_types` item is imported by its own path (V1-2) +- Delete `src/cancel.rs` and `mod cancel;` at `src/lib.rs:18` +- Replace `use crate::cancel::CancelHandle;` with `use runvil_types::cancel::CancelHandle;` at the six sites + - `src/execute/config.rs:19`, `src/execute/context.rs:20`, `src/execute/run.rs:25` + - tests: `src/lua/tests/coroutine.rs:9`, `src/execute/tests.rs:54`, `src/execute/tests/live_infer.rs:8` +- Prove: the clippy gate + +#### 5. Check every store error's fields in its round-trip test, and name the renderer the inverse reads + +- Problem: `every_store_failure_rebuilds_from_its_raised_table_under_its_own_tag` (`src/error/tests.rs:335-433`) asserts the tag and `valid_up_to` only, so a lost `anchor`, `count`, or `rule` field goes unnoticed (S1-19) +- Assert `assert_eq!(source, failure, "{failure:?}")` for the variants whose fields all round-trip; `VfsError` derives `PartialEq` (`crates/runvil-internal/vfs/src/error.rs:21`) + - the path-only `NotFound`, `AlreadyExists`, `NotADirectory`, `IsADirectory`, `DirectoryNotEmpty`, `ScopeEnded`, `ReadOnly`, and `RootProtected` + - and `NotUtf8`, `InvalidPath`, `InvalidRange`, `Anchor`, and `NoStore` +- Keep the tag-only check for the variants whose text fields degrade + - `PermissionDenied`, `ApprovalRequired`, `OutsideRoot`, `CrossMount`, `Io`, `Unsupported`, `Conflict`, `Backend` + - keep the `Io` assertion that the rebuilt kind is `Other` with no OS code +- It passes today: `store_error_fields` (`crates/runvil-internal/lua/src/error-value-store.rs:8-55`) writes every field `raised_store_error` (`src/error/convert.rs:206-287`) reads back; it catches the next field one side drops +- `convert.rs:200-205`, the `raised_store_error` doc: replace "this crate's own [`ErrorValue`](runvil_lua::ErrorValue) rendering" with "[`store_error_value_fields`](runvil_lua::store_error_value_fields)" (S1-19) + - both the Engine's rendering (`src/error/value.rs:75`) and the Lua crate's direct store closures build their tables through it +- Prove: `cargo nextest run -p runvil-engine -E 'test(every_store_failure)'` and the private docs build + +#### 6. Lua-crate internal failures keep their construction site + +- Problem: `RunError::location` (`src/execute/error.rs:256-257`) promises the broken invariant's Rust file and line for kind `Internal` (S1-4) + - `runvil_lua::Error::Internal` holds only its message (`crates/runvil-internal/lua/src/error.rs:170`), and the bridge rebuilds it with `Error::internal(message)` at `src/error/convert.rs:177`, so every Lua-crate internal fault reports that line +- Test first: extend `an_owned_lua_internal_message_reaches_the_engine_error_whole` (`src/error/tests.rs:288-306`) + - assert that `crate::RunError::from(error).location()` has `path == file!()` and `line` equal to the line of the test's own `std::panic::Location::caller()` call, captured the way `internal_faults_report_the_rust_file_and_line` (`:199-211`) captures its line + - it fails today: the location names `src/error/convert.rs` line 177 + - today the test builds the error as `LuaError::Internal(Cow::Owned(..))`; after the change it builds `LuaError::Internal { message, location: std::panic::Location::caller() }` +- runvil-lua `crates/runvil-internal/lua/src/error.rs:164-170`: the variant becomes `Internal { message: Cow<'static, str>, location: &'static std::panic::Location<'static> }`, its `#[error]` reads `{message}`, and its doc adds that `location` is the construction site +- Add `#[track_caller] pub(crate) fn internal(message: impl Into>) -> Error` beside the other constructors (`:201-237`), capturing `std::panic::Location::caller()` + - route every production build site through it, 25 in all: `vm/state.rs` (4), `var.rs` (4), `tools.rs` (2), `prose.rs` (2), `require.rs` (2), `vm/run.rs` (2), and one each in `packs.rs`, `vm/install.rs`, `models_loop.rs`, `globals.rs`, `vm/adhoc.rs`, `scope.rs`, `sys.rs`, `error-value.rs:377`, and `From` at `error.rs:267-270` + - and the test support site `test_support.rs:234` + - the patterns at `error.rs:252` and `error-value.rs:253`, `:275` become `Error::Internal { .. }`; the tests at `models-tests.rs:423` and `tests/section_vm.rs:115`, `:120` match the `message` field +- Engine: add a `pub(crate)` constructor that takes a given location, such as `Error::internal_at(message, location)`, in `src/error/convert.rs` beside `internal` (`:35-47`) + - call it from the `LuaError::Internal { message, location }` arm at `convert.rs:177` + - leave the parser arm at `:141`, which no production path reaches + - `Error::Internal`'s doc (`src/error.rs:365-368`) adds that a Lua-crate internal failure brings its own captured site +- Prove: `cargo nextest run -p runvil-lua -p runvil-engine`, the clippy gate, and the private docs build + +#### 7. Drop `#[non_exhaustive]` from three public structs whose fields are all private + +- Problem: outside crates already cannot build or destructure these structs, so the attribute restricts nothing (S1-13, S2-5) +- Delete it at `src/execute/bindings.rs:16` (`ModelBindings`), `src/execute/config.rs:46` (`RunContext`), and `src/execute/error.rs:145` (`RunError`) + - `RunLimits` (`src/execute/config-limits.rs:16`) and `SourceLocation` (`src/execute/error.rs:74`) keep theirs, as their fields are public +- Regenerate `crates/runvil/public-api.txt` with `cargo +nightly-2026-09-05 xtask api --bless`, `CARGO_BUILD_WARNINGS` unset; lines 42, 43, and 67 lose the attribute +- Adding the attribute back to an all-private struct breaks no caller, so the change reverses cleanly +- Prove: `cargo +nightly-2026-09-05 xtask api --check` and the facade docs build + +#### 8. Run configuration docs and the limits field + +- `src/execute/config.rs:65`: `pub(crate) limits: RunLimits` becomes `pub(super) limits: RunLimits` (S1-14) + - doc, the struct's only undocumented field: "The run's resource limits; set by `with_limits`." + - its readers are the builder (`:145`), the `Debug` impl (`:401`), and the destructure in `RunState::new` (`src/execute/context.rs:130-145`), all inside `execute` +- Filesystem contract: keep it on `with_vfs` (`:190-201`), dropping its sentence "[`prepare`](RunContext::prepare) keeps a handle set here as given." at `:196-197` (S1-23) + - `vfs()` doc, `:325-330`: "Returns the run's VFS handle, set with [`with_vfs`](RunContext::with_vfs)." + - `prepare` doc, `:287-290`: replace the sentence "`prepare` uses the context's filesystem as given, because ... the declared store." with "`prepare` leaves the filesystem as given." +- Model bindings (V1-4): + - field doc, `:70-73`: "The run's model bindings; filled by [`prepare`](RunContext::prepare)." + - getter doc, `:336-340`: "Returns the run's [`ModelBindings`], which [`prepare`](RunContext::prepare) fills." +- `src/execute/bindings.rs:25-26`, the `bind` doc: delete its second sentence, "The fill function's only writer." (S1-12) +- `src/execute/config-limits.rs:5-14`, the `RunLimits` struct doc, new text (S1-15): + - "Resource limits for one run." + - "The defaults are built-in constants and are safe to use as they are. A caller changes one by setting its field: `let mut limits = RunLimits::default(); limits.max_concurrency = n;`." +- `default` doc, `:43-45`, new text (S1-15): + - "Returns limits with the default values: 24 tool iterations, 8 concurrent tasks, 64 MiB of Lua memory, and 1024 Lua log events." + - `run_limits_pins_all_four_defaults` (`:60-67`) pins the four numbers +- `max_concurrency` doc, `:25-30`, new text (S1-16): + - "The run's concurrency ceiling: the most tasks the scheduler admits at once across the whole run." + - "A chain's `tasks.concurrency` call sets its own limit, clamped to its enclosing chain's limit, and this ceiling is the root's. Each spawned task counts against its owner's limit and every ancestor's, so limits nest: a fanout started inside one arm of another fanout runs within that arm's remaining share." + - the clamp is `dispatch_concurrency` at `src/execute/scheduler/waits.rs:171-200` +- Prove: the facade docs build, the private docs build, and `cargo nextest run -p runvil-engine -E 'test(run_limits)'` + +#### 9. `RunState` and section VM docs, and `run_tool_set` + +- Problem: `bound_tool_set` binds nothing, by its own doc (S2-7) + - rename it `run_tool_set` at `src/execute/context-bound.rs:23` and its one call at `src/execute/context.rs:146`; keep `bound_model_set` + - module doc, `context-bound.rs:1-2`, new first sentence: "The run-scoped tool and model sets and the `argv` derivation: the pieces `RunState::new` assembles once per run." + - keep the "Nothing is bound here" sentence in the function's doc +- Problem: argv and ui are described on both `RunState` and `SectionVmSetup`; one copy is wrong (S2-8, S5-6) + - rule: what the value is lives on `RunState`; how a VM installs it lives on `SectionVmSetup` + - `bound::derive_argv` (`context-bound.rs:82-96`) owns the nil rule: a default-declared prompt always gets a value, and a structured prompt gets `None` when its args fail to parse or parse to JSON `null` +- `src/execute/context.rs:70-73`, the `argv` field doc, new text: + - "The run's `argv`, as `bound::derive_argv` derives it from `args`. At construction this is the value the H1 pass starts from; the walk's fork holds the value H1 left behind at the freeze, so an H1 repair reaches every downstream section." +- `:236-238`, the `argv` method doc: "The run's `argv`, as `bound::derive_argv` derives it; on the walk, the value H1 left behind at the freeze." +- `:104-106`, the `ui` field doc: "The run's application-state snapshot, when the caller supplied one." +- `:319-320`, the `with_args` doc: replace "so the chain sees the parsed form of what it was passed" with "so the chain sees the `argv` of what it was passed" +- `src/execute/section_vm.rs:54`: replace "(the parsed form of `args`; `None` installs nil)" with "(`RunState::argv`; `None` installs nil)"; keep the mode sentences at `:55-58` and `SectionVmSetup::ui` as written +- The tool-loop cap opens alike on both types + - keep `RunState::max_tool_iterations` (`context.rs:289-291`), which says what the value is + - `section_vm.rs:76-77`, `SectionVmSetup::max_tool_iterations`, new text: "The run's tool-loop cap, as `RunState::max_tool_iterations` resolves it, captured by the `models.loop` shim as its round cap." +- The H1-only writable `argv` is stated on `SectionVmSetup::argv` (`section_vm.rs:55-58`), which keeps it + - `context.rs:350-353`, the second paragraph of `RunState::vm_setup`, new text: "`argv_writable` picks the [`Argv`] mode that [`SectionVmSetup::argv`] describes; the H1 pass is the only production caller that passes `true`." + - `src/execute/section_context-construct.rs:190-192`, the comment above `new_h1`'s `ctx.vm_setup(.., true)` call, new text: "// `argv` installs writable, the H1 delta `SectionVmSetup::argv` describes; the executor reads the value back at the freeze (see the scheduler's H1-to-walk handoff)." + - keep the `new_h1` doc's delta list as written; it is the one list of H1's deltas +- Problem: `section_sys` documents `SectionSys`'s fields on the constructor (S2-9) + - delete the second paragraph of its doc, `context.rs:388-395`, with the blank doc line above it + - `src/execute/section_context.rs:56`, `SectionSys::id`: "The section entry's hierarchical id: the entering chain's id extended by its local entry counter, rendered as a dot-separated path." + - `:58-59`, `SectionSys::taskid`: "The nearest enclosing task: the main walk is task `0`, and a `call` child reports its caller's task. A chain passes it to `tasks.*` to speak about itself." +- `src/execute/context.rs:149-151`, the comment above `EventSink::seeded`, new text (S2-10): + - "// The root task's event sequence starts at the context's `provenance_start`: past any parse events the caller put in the stream ahead of the run, or at zero." +- Prove: the private docs build and the clippy gate + +#### 10. `Run::from_state` and `Step::Pending` + +- `src/execute/run.rs:180-190`: drop `pub(crate)` from `from_state` (S2-6) + - its only callers are `Run::new` (`:152`) and the `cfg(test)` `new_for_test` (`:172`), both after `prepare_state`; the suites call `new_for_test` + - doc: "Builds the live run over a state that passed `prepare_state`." +- `:42-61`, `Step::Pending`: the variant doc restates its fields (S2-11) + - variant doc: "The run continues." + - `effects`: "The effects this step issued, in issue order, each with the provenance of the task that built it, for the caller to perform and answer through [`Run::resume`]. An empty list means every chain is waiting on an effect that an earlier step issued." + - `events`: "The events the step reported, in order." + - `withdrawn`: keep the text, and add "It may name a call that `effects` lists, because one step can issue a call and then orphan it." +- Prove: the facade docs build and the private docs build + +#### 11. `accept_infer` takes the chain's reporting handles + +- Problem: `accept_infer` and `accept_infer_completion` take `emitter` and `turns` as two parameters, and nothing ties a task's emitter to its own counter (S2-12) +- Replace both parameters with `reporting: &ReportingHandles` in `accept_infer_completion` (`src/execute/infer.rs:31-37`) and `accept_infer` (`:85-91`), reading `&reporting.emitter` and `&reporting.turns` + - `accept_infer` passes it on at `:99` + - import `super::context::ReportingHandles`, whose fields are `pub(super)` in `execute::context`; drop the now-unused `AtomicU32` and `Emitter` imports at `:15` and `:17` + - the caller `ChainOps::accept_infer` (`src/execute/scheduler/apply.rs:250-256`) passes `chain.ctx.reporting()` + - `src/execute/infer-tests.rs:17-23` passes `&ReportingHandles { emitter: Arc::new(null_emitter()), turns: Arc::new(AtomicU32::new(0)) }`, importing `std::sync::Arc` and `std::sync::atomic::AtomicU32` +- Problem: the docs credit the no-failure rule to the caller's abort and name Harness internals (V2-1) + - `infer.rs:3`: replace "gateway round" with "model round" + - `infer.rs:72-79`, first paragraph: "Applies one infer round's answer, the completion the caller obtained or its failure: it reports the round exactly like a prose round, through `turn::report_model_turn` on the chain's reporting handles under `section`, and renders its text." + - second paragraph: "A failed completion is a failed turn and the call's error. A round the caller drops never reaches here: the drop resumes the chain with a cancellation, so no `MODEL_TURN_FAILED` fires for it." + - the drop path: `apply.rs:131` maps `EffectAnswer::Dropped` to `Error::Interrupted` before the infer arm, pinned by `src/execute/tests/live_infer.rs:202-237` +- Prove: `cargo nextest run -p runvil-engine -E 'test(infer)'` and the clippy gate + +#### 12. Effect docs in `src/execute/run/effect.rs` + +- `:151-158`, `Effect::ToolCall`, new text (S3-1): + - "One call to a Pack's tool: a run catalog tool, or a tool only the model sees, from the section entry's render. The caller resolves `tool` to an implementation. The effect's record keeps every field but `access`, a live handle, and `survives_stop`, which the run catalog and the render's answer already give." + - `Effect::record` (`:276-287`) keeps `tool`, `alias`, `args`, and `origin` +- `:182-195`, `Effect::Vfs` (S3-2): + - delete "Only the `store.*` calls produce this effect." at `:187-188`; the first sentence already names the eight `store.*` calls + - replace "Correctness holds whenever the caller drops the access." at `:192-193` with "When the caller drops the access does not affect the run's correctness." +- Prove: the facade docs build + +#### 13. `Effect::Chat` holds the shared prefix as one `Option` + +- Problem: `after: Option` and `keep: u64` allow `(None, k)` with `k > 0`, which the docs forbid (S3-4) + - in `src/execute/run/effect.rs:117-123`; `Effect::record` skips the first `keep` messages (`:251`, `:257`), so such an effect would log a request missing them +- Change the live effect only: `after: Option<(RoundId, u64)>`, the earlier round and its kept count; delete the `keep` field + - new `after` doc: "The earlier round whose request this one extends, the previous send of the same section's `messages` list, with how many leading messages this request shares with that round's request. `None` on a section's first send and on a nested `models.infer` round." +- runvil-lua `crates/runvil-internal/lua/src/messages.rs:193-208`: `MessageList::commit` returns `Option<(RoundId, u64)>`, `None` on the first send and `Some((after, keep))` after it + - its doc's "`(None, 0)` on the first" becomes "`None` on the first" + - its tests: every `commit` assertion in `messages-tests-list-commit.rs`, and `compactors-tests.rs:177-181` +- `Effect::record` (`effect.rs:241-266`) flattens it into the unchanged `EffectRecord::Chat { after, keep }`; `None` records `after: None, keep: 0`, so the run log format stays the same +- Callers: `src/execute/scheduler/chat.rs:178-185` (`let after = list.commit(..)`) and `src/execute/scheduler/dispatch.rs:221-228` (`after: None`, no `keep`) +- Tests that build the effect: `chat_effect` in `src/execute/run/effect-tests.rs:29-37` takes the `Option` in place of `after` and `keep`, with its callers passing such as `Some((RoundId::new(2), 2))`; and `src/execute/run/tests.rs:47-52` + - the record assertions at `effect-tests.rs:52-67`, `:95-106`, and `:182-193` stay as written +- Every binding site outside the Engine names the variant with `..`: harness-runner `effect_loop.rs:339-345`, harness-gateway-client `wire/request-tests.rs:81-85`, the facade's tests and example, and the bench (`benches/models_loop.rs:96-100`) +- Regenerate `crates/runvil/public-api.txt` with `cargo +nightly-2026-09-05 xtask api --bless`; line 875 changes type and line 877 goes +- Prove: `cargo nextest run -p runvil-engine -p runvil-lua -p harness-runner -p harness-gateway-client`, `cargo +nightly-2026-09-05 xtask api --check`, and the facade docs build + +#### 14. Scheduler core docs, and `MAX_CALL_DEPTH` beside `CallDepth` + +- `src/execute/scheduler.rs:232-233`, `decided`, new text (S3-10): + - "Whether the run's outcome is decided: its end boundary is reported, so at most the orphans' answers stand between the run and `Done`." + - the body also matches `Phase::Done`, and `src/execute/run/tests.rs:450` asserts a finished run stays decided +- `:238-239`, the first paragraph of `answer_inline`, new text; keep `# Errors` (S3-9): + - "Answers `id` at once with `answer` and queues it to resume: the inline-answer path of every request the core keeps, and of a chain end answering another chain." +- `:168-174`, the `tasks` field doc, new text (S5-8, S3-7): + - "The task arena; [`TaskArena`] and the `tasks` module state its lifecycle." +- Move `MAX_CALL_DEPTH` and its doc (`:109-111`) into `scheduler/chain_record.rs`, just above `CallDepth` (`:63-67`) (S3-14) + - keep the name and the private visibility; drop `use super::MAX_CALL_DEPTH` at `chain_record.rs:24` + - its only code use is `CallDepth::deeper` (`chain_record.rs:78-87`); test files name it only in comments +- `src/execute/scheduler/chain.rs:287-296`, `abort_effect`, new text (S3-6): + - "Orphans one in-flight leaf effect whose chain is going away, as [`EffectTable::orphan`](super::effects::EffectTable::orphan) describes, and ends a tool call's identity through [`Scheduler::end_tool_call`] as its answer would, so what the tool already did is joined into the parked chain and anything it tries later is refused." + - `chain.rs` imports `Scheduler` but not `EffectTable`, so that link names its path +- Prove: the private docs build and the clippy gate + +#### 15. A chain refuses a second answer before it resumes + +- Problem: `Chain::deliver` (`src/execute/scheduler/chain_record.rs:426-438`) overwrites an answer it holds (S4-3) + - its three entry points are `ChainOps::answer` (`chain_ops.rs:79-83`), `Scheduler::answer_inline` (`scheduler.rs:244-248`), and `Scheduler::wake_from_wait` (`admission.rs:410`) +- Test first: `a_second_delivery_before_resume_is_an_internal_error` in `src/execute/tests/scheduler/failures.rs`, beside `an_answer_for_a_chain_with_no_suspended_block_fails_loudly` (`:427-448`) + - `Rig::start` a `scheduler_scenario` over a prompt with a `# Title` and one section whose block runs `return models.infer('ask')`, holding the round with `.hold(|effect| matches!(effect, Effect::Chat { .. }))`, then `settle()`, so the root walk's block is suspended on the held round; the cancellation test at `:244-265` holds its rounds the same way + - add a hook in `src/execute/scheduler/test_hooks.rs` beside `answer_unsuspended_for_test`, such as `deliver_twice_for_test`: it delivers `Ok(Answer::Note)` and then `Ok(Answer::Concurrency(1))` to the chain at arena index 0, and returns both results with whether the block still holds `Answer::Note` + - assert the second result is `Err(Error::Internal { .. })` and that the block holds the first answer + - it fails today: both deliveries return `Ok`, and the block holds the second answer + - `Chain::for_test` enters no section, so the test needs a real run +- `Chain::deliver`: when `running.answer` is already `Some`, return `Err(Error::internal("a suspended block holds at most one delivered answer"))` and leave the first in place +- `src/execute/scheduler/chain_ops.rs:35-36`: `answered: usize` becomes `answered: bool` + - `answer` (`:79-83`) sets it; `with_chain` (`:56-58`) pushes the chain once when it is set + - field doc, `:35`: "Whether the view delivered an answer to the chain, which [`Scheduler::with_chain`] requeues once." + - `with_chain` doc, `:40-43`: "Runs `f` in the view of chain `id`, then pushes the chain onto the ready queue if the view delivered an answer. The view pushes nothing else, so no other chain enters the queue between an answer and its requeue." +- `chain_ops.rs:72-73`, the first sentence of `answer`, new text; keep the rest (V4-3): + - "Delivers `answer` to the chain's suspended block: the inline-answer path of every request in the view." +- `chain_ops.rs:93-94`, `number_round`, new text (S4-10): + - "Numbers the model round about to be issued, through [`EffectTable::number_round`]." +- Prove: `cargo nextest run -p runvil-engine -E 'test(delivery) or test(scheduler)'` and the private docs build + +#### 16. Admission: one word per slot change, narrower items, and true docs + +- Problem: `Admission::take_back` gives slots up, but the module says "take back" for regaining them (S4-1) + - rename it `release`: `src/execute/scheduler/admission.rs:265`, `src/execute/scheduler/chain.rs:214` + - tests: `admission-tests.rs:157`, `:181`, `:215` + - `park` stays the reversible give-back that `wake` undoes; no scheduler item is named `release` today + - doc, `admission.rs:262-264`: "Releases `id`'s slots at its chain end, so a queued sibling or a resumption can have them. A chain that holds none, or whose slots are given back while it is parked, changes nothing." + - `drop_aborted` doc, `:269-272`: replace "and the slots it still holds are given back" with "and the slots it still holds are released" + - caller comment, `chain.rs:211-213`: "A task chain that was holding admission slots releases them at its end, before its owner is woken: the owner's re-acquire and any queued sibling need the freed capacity." + - abort comment, `chain.rs:276-277`: "a running task's held slots are given back" becomes "a running task's held slots are released" + - the commit message says it reverses part of `b219b85db`, whose own rationale described giving back +- Drop `pub(super)` from six items whose users all sit in `admission.rs` or its test child (S4-6, V4-1) + - `ChainTree::kind` (`:75`), `ChainTree::call_parent` (`:85`), `ChainTree::ancestors` (`:103`) + - `Admission::plan` (`:166`), `Admission::wake` (`:250`), and `enum Admit` (`:51`) + - `ChainTree::enclosing` and `ChainTree::limit` stay `pub(super)`; `waits.rs:188-189` uses them +- `SpawnRecord` doc, `:34-38`, new text (S4-7): + - "A queued task chain's spawn record, held from spawn until its admission: the input override and the spawning section the start event reports. The `var` snapshot is not in the record: it moves out of the yield into the chain's `var` slot at spawn, the start event reports a copy of it at admission, and the chain's first section entry seeds from the slot." + - the copy is `let var = chain.var.clone();` at `:369`; the first entry seeds at `src/execute/scheduler/walk.rs:123-131` +- The module doc, `Admission`, and `wake_from_wait` restate each rule (S4-8) + - module doc, `:8-11`: replace the sentences from "The root's limit is the run's" through "clamped to the parent's." with "Each chain's limit is its [`Chain::concurrency`](super::Chain::concurrency)." + - `Admission` doc, second paragraph (`:111-115`): delete its first three sentences and keep "Slot counts are computed from the holders, never stored, so no count can drift from them." + - `wake_from_wait` doc, first paragraph (`:396-400`), new text; keep `# Errors`: "Delivers its answer to a chain parked on a task wait: the chain is parked on [`Parked::Queued`] when [`Admission::wake`] requeues it for its slots, else pushed onto the ready queue at once." +- Prove: `cargo nextest run -p runvil-engine -E 'test(admission)'` and the private docs build + +#### 17. Chain ids, lineage, and the root-lineage rule + +- `src/execute/scheduler/chain_record.rs:253-258`, `Chain::lineage`, new text, the rule's one home (S4-9, S3-8, S5-7): + - "The chain's hierarchical [`ChainId`], allocated under the scheme [`runvil_types::ids`] states: every id the chain hands out extends it. The H1 pass and the walk after it are two arena records under the root lineage `0`." +- The `chain_record` module doc, `:1-5`: end it at "indexed by [`ChainIndex`]." and delete the root-lineage clause that follows +- At each other site, keep only the local consequence (S3-8, S5-7) + - `scheduler/h1.rs:9-15`: keep as written; it is the one doc that says why the pass shares the lineage and hands its tasks to the walk + - `h1.rs:47-50`, comment: "// The pass's one frame takes the root lineage's entry 0, and the walk that follows continues its counters (see `Chain::lineage`). Its admission limit is the run's ceiling." + - `h1.rs:93-98`, comment: "// The walk continues the pass's counters (see `Chain::counters` for the normative statement): the pass took entry 0, the first walked section takes entry 1, and a child the pass started keeps its index. Its admission limit continues too: the pass may have lowered it with `tasks.concurrency`." + - `scheduler/walk.rs:8-10`: replace "sharing the root lineage `0` as a second arena record" with "under the same root lineage ([`Chain::lineage`](super::Chain::lineage))" + - `scheduler/waits.rs:16-18`: delete ", since the pass and the walk share the root lineage `0` as two arena records"; the owner rule before it stays + - `scheduler/task_end.rs:11-13`: replace the sentence "The H1 pass and the walk after it share ... to the walk." with "The H1 hand-off reassigns the pass's tasks to the walk (see the `h1` module)." + - `task_end.rs:207-211`, `reassign_tasks`: delete ", where the pass and the walk share the root lineage `0` and are two arena records" + - `scheduler/tasks.rs:114-116`, `TaskEntry::owner`: delete ", since the pass and the walk share the root lineage `0` as two arena records" + - `chain.rs:100-102` stays; it says what makes a chain a root chain +- `next_child_id` (`chain_record.rs:369-372`), new first paragraph: "Hands out the chain's next child id, under [`Counters::next_child`]." +- `next_entry_id` (`:385-387`), new first paragraph: "Hands out the chain's next section-entry id, under [`Counters::next_entry`]." + - keep both `# Errors` sections +- `scheduler/dispatch.rs:173-176`, comment: "// The child's id is the caller's next child id." (V4-2) +- `scheduler/tasks-spawn.rs:127-130`, comment: "// The task's id is the spawner's next child id; the task is its chain, named from the other side." (V4-2) +- `tasks-spawn.rs:87-91`, comment: cut its last sentence and end the one before it as "otherwise the chain inherits the spawner's context whole, but for the turn counter, which `with_task` replaces below." (S3-11) + - keep the comment at `:133-135`, above the `with_task` call +- Rename the `chain_id: ChainId` locals to `lineage`, with the `ChainStart { lineage, .. }` shorthand (S3-12, S4-5) + - in `prepare_call` (`dispatch.rs:177-179`) and `prepare_spawn` (`tasks-spawn.rs:131-138`, where `TaskId::from(chain_id.clone())` becomes `TaskId::from(lineage.clone())`) + - the `id: ChainIndex` parameters across the scheduler stay as they are +- `scheduler/h1.rs:17`: the link target becomes `super::Chain::counters`, the field that holds the normative statement (`chain_record.rs:260-264`) (S5-19) +- `chain_record/kind.rs` and `chain_record/anchor.rs`: the type's doc carries the description, and the module doc names the type (S4-12) + - `kind.rs:7`: "What kind of chain a record is: the main walk, the H1 pass, a `call` child, or a task's backing chain, each with the links only that kind has." + - `kind.rs:1-3`: "The chain record's [`ChainKind`]." + - `anchor.rs:1-2`: "The chain record's [`ChatAnchor`]." +- `scheduler/chat.rs:325-328`, `ArrivedRound`, new text (S4-11): + - "One arrived round's reporting context: the round's id, the chain's section label, and its task's reporting handles." + - a `call` child shares its caller's counter (`dispatch.rs:159-162`), so the old "a task chain's own" is false for root and `call` chains +- Prove: the private docs build and the clippy gate + +#### 18. A refused task delivery never writes the entry + +- Problem: `TaskArena::take_delivery` (`src/execute/scheduler/tasks.rs:277-302`) writes `Delivered` first (S5-9) + - then three arms write the old state back by hand (`:286`, `:290`, `:296`); a missed restore reports a delivery that never happened +- Test first: `a_refused_delivery_leaves_the_entry_unchanged` in `src/execute/scheduler/tasks-tests.rs` + - for each of `Cancelled`, `Abandoned`, and `Running`: `take_delivery` fails and `state_of` is unchanged + - build the states with the `spawn` helper (`:37`) and the stop calls `stopping_a_running_task_ends_it_and_returns_its_backing_and_target` (`:52-66`) makes + - it passes today and pins the behavior the rewrite must keep; `:86-107` and `:121-141` already pin the `Done` and `Delivered` arms +- Match on `&mut entry.state` instead + - the `Done(outcome)` arm takes it with `std::mem::replace(outcome, Ok(String::new()))` + - it then sets `entry.state = EntryState::Delivered { ok: outcome.is_ok() }` and returns the outcome + - every other arm returns its error without writing; delete the comment at `:282` +- Prove: `cargo nextest run -p runvil-engine -E 'test(delivery)'` + +#### 19. The two section frame constructors share one VM setup + +- Problem: `new` (`src/execute/section_context-construct.rs:82-96`, `:124-135`) and `new_h1` (`:167-180`, `:195-206`) copy each other (S5-4) + - VM construction, cancel, limits, setup, and a seven-field literal that differs only in `item`; the comments already differ, since only `new` explains `set_cancel` (`:89`) +- Add a private `fn limited_vm(ctx: &RunState, name: &str) -> Result>` + - it holds `SectionVm::new_for_section`, `set_cancel`, and `apply_lua_limits` under one pair of comments: the cancel comment from `:89` and the bare-propagation comment from `:91-92` + - add `Uninjected` to the module's `crate::lua` import (`:20`); `setup_section_vm` takes that type (`src/execute/section_vm.rs:120`) +- Add a private constructor for the literal, such as `fn assemble(vm: SectionVm, sys: SectionSys, item: Option, ctx: &RunState) -> Self` +- Both constructors call the two helpers; `new` still reports `SECTION_STARTED` (`:80-81`) before `limited_vm` +- Bare propagation is stated once, in `section_vm.rs:15-20`, which says why (S5-5) + - `new`'s `# Errors` (`:57-62`): "Returns [`Error::Internal`] when `pos` is past its slice, and otherwise the [`Error`] of whichever step failed (see [`section_vm`] for which failures tear the VM down)." + - `new_h1`'s `# Errors` (`:154-158`): "Returns the [`Error`] of whichever step failed (see [`section_vm`] for which failures tear the VM down)." + - the [`section_vm`] link resolves through `use crate::execute::section_vm::{self, VmSeed}` (`:17`) + - `section_vm.rs:107-109`: delete the sentence "The caller applies the Lua limits itself before calling, so a limits failure propagates without touching the VM's teardown observation path." + - `section_vm.rs:4-6`: replace "Both frame constructors - `SectionContext::new` for a walked section (a spawned task's first entry included) and `SectionContext::new_h1` for the H1 pass - run" with "Both frame constructors run" + - the `construct` module doc (`:1-8`) keeps the arrival list +- `new_h1` doc, `:146-147`, new opening (S5-22): + - "H1's only deltas from a walked section: no `SECTION_STARTED` observation, and no `SECTION_FINISHED` since the scheduler never arms the pass's completion (the pass is not a walked section), an empty `var` seed ..." with the rest unchanged + - the scheduler never calls `mark_completed` for the pass (`src/execute/scheduler/h1.rs:63-66`) +- Prove: `cargo nextest run -p runvil-engine -E 'test(h1) or test(observations)'` and the private docs build + +#### 20. Section frame docs in `src/execute/section_context.rs` + +- `:156-159`, `read_var`'s `# Errors`, new text (S5-1): + - "Returns [`Error::Lua`] when the section reassigned the `var` global, which the walk relies on to fail the run, or when `var` cannot convert back to JSON (the write guard keeps that second case from happening in practice)." + - runvil-lua raises the reassignment as `Error::Lua` (`crates/runvil-internal/lua/src/var.rs:208-219`); `src/execute/tests/scheduler/h1/pass.rs:150-173` pins it +- `live_vm` (`:273-276`) says its invariant is stated once; four other docs state it (S5-3) + - `vm` field, `:124-126`: "The frame's VM; [`live_vm`] states when the slot holds it." + - `vm()`, `:184-185`: replace the "Infallible: ..." paragraph with "Infallible; [`live_vm`] states why." + - `completed`, `:128-130`: "Whether [`SectionContext::mark_completed`] armed the frame's `SECTION_FINISHED`." + - `Drop` comment, `:341-346`: keep the first sentence, through "tears down exactly once here.", then "// It uses `let-else` rather than `live_vm`, so the destructor never panics." + - keep the `live_vm` doc, its lint reason, its `expect` message, and the `mark_completed` doc +- Module doc, `:4-5`, new text (S5-21): + - "It owns the section VM plus the state the block walk reads and writes - the section's [`SectionSys`], a spawned chain's `item`, the tool-call counts, the stack of open [`AdhocCall`]s, and the completion flag its `Drop` reads -" +- Module doc, `:10-16`, new text; the `construct` module doc keeps the arrival list (S5-5): + - "Each section entry is one construct-run-teardown cycle: a constructor in the `construct` child builds the frame, the scheduler's chain steps run the blocks, and the frame's [`Drop`] impl is the single teardown boundary." +- `install_lazy_prose`, `:197-201`, new first paragraph; keep `# Errors` (S5-23): + - "Installs the scheduler's pending Markdown buffer, empty when no Markdown accumulated, as the VM's lazy `prose` template before one Lua coroutine starts, rendered through this frame's [`prose_renderer`]; [`SectionVm::install_lazy_prose`] states when it renders." + - runvil-lua states the read-once rule in `crates/runvil-internal/lua/src/prose.rs:3-9` and on `SectionVm::install_lazy_prose` (`lua/src/vm/install.rs:285-292`) +- Prove: the private docs build + +#### 21. Discovery and scope: one spelling per name and limit, and clear names + +- `src/execute/scheduler/tool_call.rs:114-121`, `dispatch_tool_calls`, new text; the module doc at `:23-33` keeps the batch rule (S5-10): + - "Dispatches a `tool_calls` request, the unanswered calls of one model round, under the batch rule in the module doc: each catalog call is issued as a `CallSite::Batch` under the round's `turn` and its own slot, and the slots park on the chain." +- Format the discovery tool names from `DiscoveryTool::wire_name` (`crates/runvil-internal/lua/src/scope.rs:219-226`) (S5-11) + - `scheduler/tool_call-discovery.rs`: `:99` names `CallDynamicTool`, and `:222`, `:270`, `:283`, and `:292` name `GetDynamicTools`; format each from `DiscoveryTool::Call.wire_name()` or `DiscoveryTool::Search.wire_name()`, as `:173` and `:218` already do + - `src/execute/scope.rs`: `wire_name` is not a `const fn`, so build `SEARCH_USAGE` (`:30-32`) and `CALL_USAGE` (`:35-36`) with `format!` inside `discovery_tools`, which formats `SEARCH_USAGE` at `:379` and copies `CALL_USAGE` at `:381` + - also the listed-names suffix (`scope.rs:370`) and the `call_parameters` description (`:419`) + - the output stays byte for byte the same: `src/execute/tests/discovery.rs:40` and `:179-180`, `src/execute/tests/discovery-refusals.rs:45`, and `src/execute/scope-tests.rs:278` pin it +- Move `PATTERN_LIMIT` (`tool_call-discovery.rs:38-39`) into `scope.rs` as `pub(super) const`, visible to the private `discovery` module under `execute` (S5-12) + - import it in `tool_call-discovery.rs`; `compiled` (`:281`, `:283-284`) and its doc link (`:277`) read the import + - `scope.rs:405`: the `pattern` description becomes `format!("A regex of at most {PATTERN_LIMIT} characters ...")` inside the `json!`, keeping the rest of the string +- `SectionOffers::read` (`scope.rs:70-78`): rename its parameter `set` to `tool_set`, the doc's "expanded against `set`" included (S5-15) + - the caller locals `let set = chain.ctx.tool_set();` at `scheduler/chat.rs:123` (read at `:124` and `:137`) and `:220` (read at `:222` and `:224`) become `tool_set` + - `drop_shadowed` and the `set: OfferMode` names keep `set` +- Rename `prepare_effective_scope` to `freeze_tool_sets`, the guide's and the `scope` module's word (S5-16) + - `scope.rs:142`; `scheduler/chat.rs:35`, `:135`, `:221`; no other code names it + - test comments: `src/execute/tests/debug_and_counts.rs:270-271` "seeded from the effective scope" becomes "seeded from the frozen sets", and `src/execute/tests/scheduler/failures-script-tools.rs:48-49` "not the section's effective scope" becomes "not the section's frozen sets" +- Prove: `cargo nextest run -p runvil-engine -E 'test(discovery) or test(scope)'` and the clippy gate + +#### 22. Two small code tidies: the turn counter and the notice header + +- `src/execute/turn.rs:19-27`: `advance_turn` keeps a fallback the closure can never reach (S5-2) + - new body: `turns.update(Ordering::Relaxed, Ordering::Relaxed, |current| current.saturating_add(1)).saturating_add(1)` + - delete the comment at `:24`; keep the doc at `:15-18` + - the Engine builds at its 1.95 floor, where `try_update` already builds (`crates/runvil-internal/lua/src/engine_globals.rs:38`); if `update` is missing there, write `let (Ok(previous) | Err(previous)) = turns.try_update(...)` instead + - `src/execute/tests/exec_flow.rs:211-230` pins 1, 2, and saturation +- `src/execute/scheduler/step.rs:19-21`: `H1_GATE_FAILED` copies the notice header (S5-17) + - add `pub(super) const NOTICE_HEADER: &str = "the environment cannot satisfy this prompt:";` in `src/execute/requirements.rs`, and use it in `notice` at `:123` + - at `step.rs:67`, build `format!("{NOTICE_HEADER}\n- the H1 gate failed")`, importing `crate::execute::requirements::NOTICE_HEADER`; delete `step.rs:19-21` + - no test compares the H1 notice's full text: `a_failed_h1_assertion_ends_the_run_as_requirements_unmet` (`src/execute/tests/scheduler/h1/pass.rs:87-123`) reads it back without comparing it, so check by reading that the built string equals the old literal +- Prove: `cargo nextest run -p runvil-engine -E 'test(advance_turn) or test(notice) or test(failed_h1_assertion)'` and the floor build + +#### 23. Heading address docs in `src/heading_address.rs` + +- `find_visible`, `:314-328`, new doc; it restates the exact-address rule and states ambiguity twice (S6-1): + - "Looks up a heading string like `"### Name"` in a caller's visible set, returning the single section at its exact address or `None`. A heading outside the visible set and a heading that names no section anywhere are the same `None`, because only the visible set is ever searched." + - "# Errors" / "Returns [`Error::Lua`] when the heading is malformed (see [`parse_heading_address`]) or when more than one visible section matches the exact address, so an ambiguous heading never resolves to an arbitrary first hit." +- `resolve_visible` (`:391-394`) and `resolve_reachable` (`:402-405`) narrate where a rule lives (S6-2) + - in `resolve_visible`, replace "and when no visible section matches the exact address. The not-found rule is stated on [`not_found`], which owns it." with "and, through [`not_found`], when no visible section matches the exact address." + - the same in `resolve_reachable`, with `reachable` in place of `visible` +- Prove: the private docs build + +#### 24. Substitution docs in `src/subst.rs` + +- The argv nil rule is wrong at four sites; `derive_argv` (`src/execute/context-bound.rs:82-96`) owns it (S6-9) + - module doc, `:6-7`: replace "`argv` (its parsed JSON form, nil when the args did not parse)" with "`argv` (the chain's `argv`: the derived value in the H1 pass, the value the H1 left behind on the walk; nil when it holds no value)" + - `Sources::argv`, `:141`: "The chain's `argv`, which the H1 pass may repair: `{{ argv }}` and dotted paths." + - `NilArgv`, `:60-61`: "An `argv` placeholder, bare or dotted, was used while `argv` was nil." +- The `Sources` doc, `:127-137`, restates the module doc, and its argv copy has drifted (S6-13) + - new doc: "The value sources `{{ }}` placeholders resolve against." / "`var` and `sys` are JSON objects." + - `Sources::item`, `:143`: "The item seeded into this section on a fanout arm's or a `tasks.spawn` task's first entry, rendered per [`render_item`](crate::lua::render_item): `{{ item }}`. `None` on every other entry." +- Lookup failures other than non-JSON data are undocumented (S6-11) + - `SubstErrorKind::Serialize`, `:63`: "The bare-global lookup failed: the global cannot become JSON data, the name is `prose` itself, or the lookup raised." + - the `globals` field, `:149-150`: "The bare-global lookup for `{{ name }}` resolution: `Ok(None)` when unset, `Ok(Some(_))` with its JSON form when set, and `Err` when the lookup fails, which a placeholder reports as `Serialize`." + - the production lookup is `crates/runvil-internal/lua/src/prose.rs:130-146` +- Delete the comment at `:295-296`, which is `bare_global_root`'s doc (`:221-222`) word for word (S6-14) +- `nil_argv`, `:208-219`: the message's parenthetical names one of the three causes of a nil `argv` (S6-10) + - a JSON `null` under a structured declaration, or an H1 that leaves `argv` nil, gets "the args string is not JSON" too + - test first: in `nil_argv_is_an_error` (`src/subst-tests.rs:113-121`), assert `e.to_string().contains("is nil (argv holds no value)")` for both paths + - it fails today, because the message reads "is nil (the args string is not JSON)" + - `:214`, the format string becomes `"{{{{ {} }}}} is nil (argv holds no value)"` + - no other source writes the old text; the guide's three quotes change in the guide item +- Prove: `cargo nextest run -p runvil-engine -E 'test(subst) or test(argv)'` and the private docs build + +#### 25. The model import surface docs in `src/model.rs` + +- `:8-12`: delete the paragraph about `models.default` and `models.use`; runvil-lua owns both globals, and `Error::ModelRequired` (`src/error.rs:315-316`) states the failure (S6-8) +- `:20`: "Three groups still name the dependency crates themselves:" becomes "Two groups ..." +- `:22-26`: the two bullets give a false reason; replace them with one (S6-7): + - "This crate's test support and its test suites, which also need names the production surface does not re-export, such as `CompletionOptions`, `RawExchange`, `Role`, and `ToolSchemaError`." + - keep the bench bullet at `:27-28` +- `:30-34`: names a Harness crate and its internals, against `AGENTS.md:26` (S8-3) + - new text: "Performing a round, its transport codec included, is the caller's job. The Engine never performs a completion." +- Prove: the private docs build + +#### 26. Test support and test text + +- `src/test_support.rs:38-39` and `:60-61`: `recording` and its `forward` re-export become `pub(crate)`, as their `cfg(test)` siblings are (S6-16) + - narrow the `pub` items in `src/test_support/recording.rs`, `recording/forward.rs`, and `recording/observation.rs` to `pub(crate)` too, or `unreachable_pub` (`Cargo.toml:280`) fires on each +- Rename `src/test_support/scripted-chat.rs` to `scripted_chat.rs` with `git mv`, and delete the `#[path]` at `test_support.rs:51` (S8-4) + - `test_support/` holds six other files, so the standard layout applies (`AGENTS.md:71`); the module path, the bench's import, and the `cfg_attr` at `:44-50` stay as they are +- In that file (S6-17, S6-18, V6-1, S8-3): + - `MOCK_MODEL`, `:19`: "The server model the reply builders name." + - delete `ScriptedReply::answer`'s `# Errors` section (`:128-132`); `ScriptedChat::complete_now` keeps the same list + - module doc, `:7-10`: replace "because only the in-crate suites write one" with "because only the in-crate suites use them; the bench builds its one reply from the variant directly", and keep the sentence after it + - `fail`, `:73-76`: "For every kind it builds the kind's phrase with the status appended and the body as the detail, so each test names its kind directly." +- `src/execute/tests/context.rs:91`, the `parse` doc: "Parses `md`, adding a `# Test prompt` title when the source has none." (S8-3) +- `src/execute/tests/exit_rules.rs:376-378`, comment (V8-1): + - "// The scripted model treats a whitespace-only reasoning channel as absent, so the message keeps the plain phrase." +- `src/lua/tests/quota.rs` describes the deleted instruction budget (V7-1) + - module doc, `:1-4`: keep "The loop trampoline's instruction cost.", and replace the sentence that begins "`models.loop` runs on the author's Lua instruction budget" with "`models.loop`'s rules run in Rust behind the loop shim's trampoline, so the every-Nth-instruction cancel hook counts only the trampoline's own instructions: a handful per yield." + - `:33`, the `ROUND_INSTRUCTION_FLOOR` doc: replace "about the quota" with "about the trampoline's cost" +- Prove: the clippy gate, which builds the bench, and `cargo nextest run -p runvil-engine` + +#### 27. Crate docs and rulebooks + +- `src/lib.rs:4-7`, new first clause (S8-1, V8-2): + - "It issues each model round, Pack render, Pack tool call, `store` call outside a prelude's install, and timer as an [`Effect`] the Harness performs and answers" + - the start probe (`src/execute/run.rs:319-327`) is no `store` call, so the clause leaves it out +- The crate `README.md:3`: replace "catalog tool call" with "Pack tool call" (S8-1) +- The same sentence's determinism claim ends (S8-2): + - "and the same answers delivered in the same order at the same steps, a run produces the same effects and events." + - `src/execute/tests/serial_driver.rs:161-189` and `src/execute/tests/happens_before.rs:440-457` show the order matters +- The crate `AGENTS.md:3`, new bullet (S8-5): + - "The Engine depends on no tool, search, or model implementation; a run reaches one only through an effect the caller performs. `cargo test -p build-xtask` enforces the dependency rules." +- `crates/runvil-internal/README.md:3`, new text (S8-6): + - "Code outside the Engine reaches them only through the Engine's two public crates, `runvil` and `runvil-plugin`, the only crates outside this directory allowed to depend on it." + - `crates/build-xtask/src/product.rs:322-328` allows both +- The repository root's `AGENTS.md:9`, the Engine Definition: replace "a catalog tool's result" with "a Pack tool's result" (S8-1) + - model-only tools leave as `Effect::ToolCall` too, and the caller answers them like catalog tools + - `docs-claims.mjs` skips the Definitions section itself (`:39`, `:88`), so the edit cannot trip the scan +- The repository root's `README.md:59`: the same replacement, "a catalog tool's result" to "a Pack tool's result" (S8-1) +- Prove: `node --test crates/workshop/ui/test/docs-claims.mjs`, `cargo test -p build-xtask`, and the workspace docs build + +#### 28. Guide edits in the `runvil-docs` clone + +- Its own repository: one commit there, after the Rust items, and nothing in `runvil` changes; paths are below `src/language/` +- `12-tools.md`: the out-of-scope message changed its label and gained an ending (S7-1) + - replace `in-scope aliases:` with `aliases this section offers:` in the quotes at `:2314`, `:2518`, and `:2576`; neither the `search-loop` nor the `scope-check` example offers a Dynamic Set tool, so only the label changes + - `:2582`, new text: "- The message names the requested tool and lists the aliases the section offers by their wire names, not their tool ids, each in double quotes, as a bracketed list of quoted names, such as `["echo"]`. The list ends the message, unless the not-offered hint follows it or the section's Dynamic Set holds a tool, which adds `; a Dynamic Set tool is reached through GetDynamicTools and CallDynamicTool` last." + - `:2596`: replace "so their message ends with the in-scope list:" with "so their message ends with the offered list, or with the Dynamic Set ending when the section's Dynamic Set holds a tool:" + - `:2608`, the table cell: "`tool {name:?} is not in this section's scope; aliases this section offers: {static_aliases:?}`, followed by the not-offered hint when it applies, then `; a Dynamic Set tool is reached through GetDynamicTools and CallDynamicTool` when the section's Dynamic Set holds a tool" + - the code is `src/error.rs:58-87` in the engine; `src/execute/tests/tool_loop-scope-gate.rs:59-65` and `:215-220` pin it +- `01-what-a-prompt-is.md:608`, new text (S7-2): + - "The Harness answers every effect exactly once: with its result, with its failure, such as the reason a model round, a tool call, or a store operation failed, or as dropped when it gives the work up. A round the Host stops is answered as dropped, which the waiting call raises as the `cancelled` error, as [Stopping a model round](#stopping-a-model-round) shows." +- `04-how-a-prompt-runs.md`: a run refused when it is built reports no run events (S7-3) + - `:1664`: "A run that does not succeed" becomes "A run that started and does not succeed"; the rest stays + - the table at `:210-214` gains the row "| the run is refused when it is built: an unsupported or missing version, no declared store, or a store that fails its probe | `parse_started` | `parse_succeeded` |" + - `:286`: append "Such a run reports no run events, so its record ends with `parse_succeeded`, the last parse event." + - the engine side is `src/execute/run.rs:141-157` and `:203-216`, pinned by `src/execute/run/tests.rs:466-490` +- `16-limits-and-errors.md:836`: replace the clause ", including a file with no `runvil:` key, whose parse error reads `not a runvil prompt: no runvil version`." with "." and add after that sentence (V7-4): + - "A file with no `runvil:` key also fails with kind `Parse` and the message `not a runvil prompt: no runvil version`, but its prompt parses: the check runs when the run is built, after input staging, the wait for Packs, and the requirements check, so an input or requirements refusal comes first when one applies, and its record ends with `parse_succeeded`." +- `16-limits-and-errors.md:877`: replace "No instruction-count quota runs out in practice." with "No instruction-count quota exists." (S7-4) +- `04-how-a-prompt-runs.md:343`: replace "The Engine's own part checks model roles only, so" with "The Engine's own part checks model roles and the reserved Pack ids `adhoc` and `dynamic` in the run's catalog and roster, so" (S7-5) +- `16-limits-and-errors.md:894` (V2-2): + - replace "a requirement the prompt states" with "what the prompt needs" + - after "such as a context minimum or a hard keyword," insert " a tool in the run's catalog or a Pack in its roster has a Pack id the Engine reserves, `adhoc` or `dynamic`," +- `10-models.md:1455`: replace "so a task's infer rounds count against the task's own cap rather than the run's." with "so a task's `turn` numbers, and its `tasks.status` `turns`, count only its own rounds. No cap counts infer rounds." (S7-6) +- `15-tasks.md:2009`: replace "so its model rounds count against its own cap and never add to its spawner's count" with "so its rounds are numbered on its own counter and never add to its spawner's count"; the rest stays (V7-2) +- `05-lua-environment.md:1936`: replace "`sys.model` appears at the section entry's first call to a catalog tool or an adhoc tool," with "`sys.model` appears at the section entry's first tool call: a call to a catalog tool or an adhoc tool, or the model's call to a discovery tool, refused calls included," and keep the rest (S7-8) + - the engine installs it at every first tool call, discovery calls included (`src/execute/scheduler/tool_call-discovery.rs:116-118`) +- `07-substitution.md`: the nil-argv message's three quotes follow the engine's new text (S6-10) + - `:519`: replace "`{{ {path} }} is nil (the args string is not JSON)`" with "`{{ {path} }} is nil (argv holds no value)`" + - `:553`: replace "`{{ argv }} is nil (the args string is not JSON)`" with "`{{ argv }} is nil (argv holds no value)`" + - `:1292`, the `NilArgv` table row: its message cell becomes "`{{ {path} }} is nil (argv holds no value)`" +- Prove: the guide's site build, with `RUNVIL_DOCS` naming the clone + + diff --git a/vibe/2026-10/2026-10-28-1-engine-vibelight-cleanup.md b/vibe/2026-10/2026-10-28-1-engine-vibelight-cleanup.md new file mode 100644 index 000000000..f3784030d --- /dev/null +++ b/vibe/2026-10/2026-10-28-1-engine-vibelight-cleanup.md @@ -0,0 +1,474 @@ +--- +name: Vibelight Engine Cleanup +overview: Outline the runvil-internal/engine crate with vibelight, raise slop candidates from the outlines, verify every finding against the repository and docs, and deliver one self-contained Cleanup Plan. +todos: + - id: step-1 + content: "Step 1: One map for the Pack roster" + status: completed + - id: step-2 + content: "Step 2: Delete Effect::Chat's options field" + status: completed + - id: step-3 + content: "Step 3: Infallible VM access and an Argv-carrying setup" + status: completed + - id: step-4 + content: "Step 4: One producer for the Pack order and self-evidencing tool-set errors" + status: completed + - id: step-5 + content: "Step 5: Root-chain constructor, Admission names, ChainOps state, scheduler doc text" + status: completed + - id: step-6 + content: "Step 6: One owner for the empty-reply message, and a narrowed test-support" + status: completed + - id: step-7 + content: "Step 7: Engine doc sentences, one file at a time" + status: completed + - id: step-8 + content: "Step 8: Section docs and the three import surfaces" + status: completed + - id: step-9 + content: "Step 9: Engine repository drift, two files" + status: completed + - id: step-10 + content: "Step 10: runvil-docs checkout drift, three files" + status: completed +isProject: false +--- + +# Vibelight: runvil/crates/runvil-internal/engine + + + +## Product Requirements + +### Scope + +- **Crate:** `runvil-engine` at `crates/runvil-internal/engine`, outline 438 KB +- **Outlines:** current, reused as written, written 2026-10-27T12:09:24Z +- **Context:** 48 other outlines, not analyzed +- **Excluded:** 7 crates, all build tooling or test fixtures +- **Doc roots:** + - `runvil-docs` checkout, latest edit 2026-10-27, 18 language chapters + - the crate's `README.md` and `AGENTS.md` + - the repository's `README.md`, `AGENTS.md`, and 264 `vibe/` records +- **Limits:** + - the outline reports no test or bench declarations, so reachability is settled by search + - the outline carries no `Unconnected types` section, so vestige leads come from `Used by` alone + - 208 source files at 1769 KB, so analysis splits along module sections + - `runvil-docs` is its own git checkout at `c:\Users\Vinnie\cursor\runvil-docs`, so a drift fix there is a separate commit + +- **Stats:** 116 findings, 86 accepted, 30 rejected, 78 cheap, 8 substantial, 0 operator decisions outstanding. +- **Goals:** + - Remove the state and duplication findings, which are the only items that change what the code can express. + - Collapse the doc findings into one edit per file, with the exact replacement text. +- **Non-goals:** + - No behavior change unrelated to a finding; no new abstraction to satisfy a doc. + - No published-interface change, since every crate here is `publish = false`. +- **Success criteria:** + - Every work item is done and the crate's own gate commands pass. + - A fresh `vibelight` run shows each declaration and comment change and no new finding of the same kind. + +## Functional Specification + +### Debt Inventory + +Verified so far: files 1, 2, 3, 5, and 6. File 4 and the doc drift pass are still being verified. + +- **State:** `RunContext` keeps `roster` and `vendors` as two maps keyed by one `PackId`, where `vendors` is defined as the usable entries of `roster` and nothing holds them in step (`src/execute/config.rs:73`, `:248`). Consequence: a second writer or an edited filter stamps a vendor onto a tool-call event for a Pack the roster calls unavailable; both maps cross the crate seam into `runvil-lua::ToolSet` unchecked. (S2-10, S1-1) +- **State:** `Effect::Chat` stores the round's request settings twice, as `binding` and as `options`, where `options` is `binding.completion_options()` (`src/execute/run/effect.rs:127`). Consequence: `Effect::record` logs from `binding.invocation()`, so a caller-built effect logs a request nobody sent, in the structure a replay compares against. (S2-9) +- **State:** three root-chain constructions fill one nine-field `ChainStart` with four constants repeated, and neither the type nor the function states whether a root start enqueues itself (`src/execute/scheduler/chain.rs:35`). Consequence: `start_root_walk` and `end_h1` push their own chain while `start_h1` returns an index for `drive::start` to push, and the two multi-line sites differ in six fields with nothing naming which differ by rule. (S3-10) +- **State:** `SectionContext::vm` is an `Option` whose `None` no live frame can reach, so five error branches promise a failure that cannot happen (`src/execute/section_context.rs:126`, `:190`). Consequence: four crate-visible signatures carry a `Result` whose only error is a scheduler bug, and `tool_call_counts` repeats the check by hand with the same message. (S5-1) +- **State:** `SectionVmSetup` carries `argv` and `argv_writable` as separate fields, rebuilding at the VM boundary the `Argv` enum that already means that pair (`src/execute/section_vm.rs:55`, `:58`). Consequence: H1's writability rests on a mutation applied after the constructor returns, and nothing stops a writable value reaching a non-H1 section. (S5-2) +- **State:** `Error::ToolSets` pairs a `SetRule` with a `Vec` whose cardinality is a rule-dependent fact, so the type admits `RenderedPacks` carrying tools and a render rule carrying none (`src/error.rs:157`). Consequence: a shape the renderer never builds still renders a message with its subject missing. (S1-2) +- **Duplication:** one Pack order is kept twice, `SectionOffers::packs` and the local list inside `offered_packs`, and the second is discarded for a `sort_by_key` on a `position()` lookup whose `None` sorts first (`src/execute/scope.rs:63`, `:111`). Consequence: a Pack missing from the stored list moves to the front of what a render is asked, the mismatch the merge's first rule exists to catch. (S5-3) +- **Duplication:** the empty-reply message is maintained in two crates under a "change together" note (`src/test_support/scripted-chat.rs:23`, `harness-gateway-client/src/wire/parse.rs:35`), and the two copies have already diverged on whitespace-only reasoning. Consequence: the Engine copy treats `Some(" ")` as present and the gateway copy as absent, so the same scripted reply produces a different message than the reader it mirrors. (S6-2, V6-1) +- **Duplication:** `RunResult`'s `# Outcomes` list restates all three variant docs and has already drifted from the one it restates (`src/execute.rs:101`). Consequence: the variant doc a caller reads while matching is the weaker copy. (S1-17) +- **Duplication:** `ChainOps` holds its own `RunState` reference beside the chain's fork of the same state, and both reach the prompt through a wrapper (`src/execute/scheduler/chain_ops.rs:35`). Consequence: a future view method reaching for the view's copy silently reads the scheduler's `args`, `argv`, and reporting handles instead of the chain's, and the compiler cannot tell the two apart. (S3-25, S3-8) +- **Vestige:** `ScriptedReply::Failure` carries a `finish_reason` no constructor in the workspace ever sets, so the three lines that read it cannot run (`src/test_support/scripted-chat.rs:130`). (S6-6) +- **Naming:** `OutOfScopeToolCall::in_scope` holds the Static Set only, and its message labels it "in-scope aliases" (`src/error.rs:235`, `:239`). Consequence: a model reading the refusal to correct itself is shown a list labelled complete that omits the Dynamic Set, while also containing the two discovery tools. Seven test sites read the field by name. (S1-16) +- **Naming:** `Admission::release`, `forget`, and `park` name one state change three ways, and `released` names the opposite relation (`src/execute/scheduler/admission.rs:205`). (S3-11) +- **Visibility:** twelve items under `test-support` are public that only `#[cfg(test)]` suites use, where the bench names five (`src/test_support/scripted-chat.rs:26`). (S6-7) +- **Visibility:** `RunState::limits` is `pub(crate)` among `pub(super)` siblings (`src/execute/context.rs:243`). (S2-6) +- **Doc text**, by file: + - `src/execute.rs`: the `runvil_engine` layout line says "journaled" for bindings no event carries (`:12`); `RunResult`'s doc leaves a declining prompt's mechanism unstated (`:96`) and gives the `#[non_exhaustive]` attribute no reason beside three closed peers (`:111`). (S1-5, S1-11, S1-12, S1-17) + - `src/execute/bindings.rs`: "journaled" twice (`:1`, `:18`); `model`'s doc says `None` means the run may not use the model, when a UI snapshot reaches catalog models by raw id (`:46`). (S1-5, S1-3) + - `src/execute/config.rs`: the `name` field doc describes the job of the field it is moved into (`:47`); `uid`'s purpose names three consumers that do not exist (`:362`); `model_bindings` overstates its set (`:334`). (S1-4, S1-14, S1-3) + - `src/error.rs`: the module doc lists `CompletionError` as a wrapper of this type and describes the cause rule over a shape three transparent variants do not have (`:4`, `:10`); `Store`'s concrete cause departs from the `BoxedSource` rule with no reason (`:352`); `UnsupportedVersion` never names the supported major (`:267`); `LuaQuota` names two of three resources (`:322`); `DepthVia` splits the reported-name rule across two sentences (`:44`). (S1-6, S1-8, S1-7, S1-9, S1-10, S1-18) + - `src/error/convert.rs`: the module doc names the gateway client, a crate the boundary forbids, and `model.rs` sends the transport codec to a crate that has none (`:1`). (S1-13, S6-3) + - `src/execute/config-limits.rs`: `lua_log_events` does not say the byte ceiling is derived from it (`:34`). (S1-15) + - `src/execute/requirements.rs`: `merge` promises skipping that `unmet_models` does not do (`:72`); the `UsablePack` doc promises a vendor every tool event records, which fails for a Pack listed in both roster lists (`:174`); the notice doc says every line carries required versus actual, which only model lines do (`:110`). (S2-1, S2-3, S2-13) + - `src/execute/context.rs`: the fork list and `with_args` name `call` alone, though a `tasks.spawn` or fanout arm with an input takes the same fork (`:50`, `:316`). (S2-11) + - `src/execute/context-bound.rs`: the module doc says `RunState::new` assembles its pieces once per run, when `derive_argv` re-runs on every args fork (`:1`); the role-binding list omits the declared description and context window (`:40`). (S2-5, S2-12) + - `src/execute/error.rs`: the module doc names two of its three exports (`:1`); `RunErrorKind::Quota` and `Lua` name too few members (`:39`); `originating_error` lists four cause kinds of eight (`:187`); `Parse` still claims to cover a compiled Lua region, which now classifies as `Lua` (`:16`). (S2-7, S2-2, S2-8, V2-1) + - `src/execute/infer.rs`: the report path names a `round` item that does not exist, where `turn::report_model_turn` is meant (`:70`). (S2-4) + - `src/execute/run/effect.rs`: `Timer`'s field doc does not say the record stores fractional seconds (`:205`). (S2-14) + - `src/execute/section_context.rs`: the no-derives note claims neither `Clone` nor `Debug` is available, when only `Clone` is blocked (`:120`); the `sys` field doc restates the teardown reporting rule `SectionSys::section_name` owns (`:130`). (S5-4, V5-2) + - `src/execute/section_vm.rs`: the fixed-sequence list omits the raw-model-id opt-in, the one step that must land before injection (`:94`). (S5-6) + - `src/execute/section_context-construct.rs`: the module doc's delta list is short by two against `new_h1`'s (`:6`). (S5-7) + - `src/heading_address.rs`: `Visible::of` promises a fallback its own `debug_assert!` forbids, so the promise is true in release and panics in debug (`:152`); the not-found rule is stated on `not_found` and restated in two `# Errors` paragraphs (`:392`, `:404`). (S5-5, S5-8, V5-1) + - `src/subst.rs`: the `NullValue` variant states a null rule that `{{ item }}` does not follow, and the `# Errors` prose duplicates the ten `SubstErrorKind` variants one for one (`:54`, `:156`). (S6-4, S6-5) + - `src/model.rs`, `src/lua.rs`, `src/parser.rs`: each calls itself the import surface for its dependency while production code bypasses it, and the exception set has at least five groups rather than the two the docs name (`model.rs:1`, `lua.rs:1`, `parser.rs:1`). (S6-1) + - `src/execute/scheduler/chain.rs`: `finish` gives delivery as a closed three-way split though a `Determinism` outcome ends the run first, and states the teardown flatly though an H1 frame drops unarmed (`:100`); `start_chain` claims the `0.1` entry rule it only copies (`:64`). (S3-1, S3-6) + - `src/execute/scheduler/h1.rs`: `end_h1`'s `# Errors` omits the gone capability and the chain-count cause its body raises (`:68`). (S3-2) + - `src/execute/scheduler.rs`: `prompt_origin` defines `label` as a section or pass where one caller passes a tool id (`:130`); the `scope` field's single-writer rule is unstated where both writers assign unconditionally (`:181`). (S3-3, S3-8) + - `src/execute/scheduler/task_end.rs`: `end_backing` omits the queued-chain silence its body enforces (`:155`). (S3-4) + - `src/execute/scheduler/admission.rs`: the ordering rule is stated at four sites and the VM invariant at one, far from `plan`, which makes both true (`:150`, `:323`, `:349`). (S3-5) + - `src/execute/scheduler/walk.rs` and `chain_record.rs`: the `0.1` entry rule is stated four times and enforced by two literals, and `Chain::counters` is its normative home (`walk.rs:31`, `chain_record.rs:255`). (S3-6) + - `src/execute/scheduler/chain_ops.rs`: the module doc names five of eight files holding `impl` blocks, omitting its own (`:7`). (S3-7) + - `src/execute/scheduler/drive.rs`: the stall test names three conditions where the guard tests two (`:7`). (S3-11) + - `src/execute/scheduler/step.rs`: the module doc and `step_chain`'s doc both restate the four advance cases `walk` owns (`:1`, `:121`). (S3-9) + - `src/execute/scheduler/chain_record`: `ChainArena`'s doc says it holds at most `u32::MAX` chains, which a `#[cfg(test)]` field makes false in test builds (`arena.rs:91`); `ChainIndex` says only `push` creates one, which a `#[cfg(test)]` constructor also falsifies (`arena.rs:14`); `Entered`'s teardown clause reads as belonging to the abort alone (`:5065` outline); `Chain::access`' `# Errors` names only chain-end paths though two teardown probes skip on it (`:4897`); `entered_name`'s three-way storage and standing are unstated (`:4864`). (S4-5, S4-6, S4-4, S4-9, S4-28) + - `src/execute/scheduler/chat.rs`: `overflow_result` says no round ran, false on the provider path (`:5460`). (S4-11) + - `src/execute/scheduler/dispatch.rs`: the index sentence accounts for eleven of sixteen requests and omits the three this file implements (`:5555`); the module doc says no arm emits for an answer, which two refusing arms break (`:1`); `dispatch_vfs`' doc restates the module's leaf-yield rule (`:238`). (S4-13, V4-2, V4-1) + - `src/execute/scheduler/effects.rs`: `orphan` and `clear_withdrawn` leave their contracts to a caller's comment (`:5701`, `:5731`). (S4-14, S4-15) + - `src/execute/scheduler/tasks.rs`: `Delivered`'s rationale is written twice with its subject changed (`:99`). (S4-2) + - `src/execute/scheduler/tool_call`: `Listed::input_schema`'s presence rule lives in `search` and not on the field (`discovery.rs:6603`); the adhoc handshake is written in the parent and stubbed in the child that implements it (`tool_call.rs:6424`); the discovery child restates the parent's routing clause and buries its own guarantee (`discovery.rs:6488`). (S4-20, S4-23, S4-24) + - `src/execute/scheduler/waits.rs`: `live_of` is the narrower query under the shorter name (`:6094`). (S4-3) + +**Report defects in the vibelight output:** `ChatOutcome` is absent from the outline's External items although `lua.rs` re-exports it; the outline prints no test or bench declarations, so `ChainArena`'s `#[cfg(test)] max_chains` field is missing from its `Counts:` line and `ChainIndex::for_test` is invisible. + +**Rejected candidates:** 32. Fourteen are structural leads that died on a specific rule the code states (`Continuation::Infer`/`Chat` route to different accept paths; `ChainStep`'s named type documents three `RunEnded` producers; `Admission::plan`'s clones are its rollback; `Settled`'s split ends a borrow early; `ChainOps`' spread blocks are the module's stated rule; the three admission queues carry different records; settle-then-join ordering is documented; `settle_all_tasks`' skip is the one-terminal guard; `end_tool_call`'s `&self` is what lets an abort caller hold a chain borrow; `end_section`/`apply_jump` diverge immediately after the shared lines; `GENERIC_COMPLETION`'s three sites share one const; `h1`'s delta list must restate what it negates; `Chain::seed` and `var` differ in kind and lifetime; `Parked`'s empty states are load-bearing). Ten were extinguished as weak on the "has a consequence" test. Four were extinguished because the source refuted them: `Running::answer` and `Entered::running` are not exclusive, since `answer: None` is the ordinary suspended state; `ArrivedRound` is built and consumed inside one function, so no section entry can intervene; `TaskState`'s narrowing remedy does not compile, since a `use` cannot widen `pub(super)`; and `Chain::entered_name`'s module doc is a summary that already omits six other fields. The rest are duplicates of a kept finding. +**Documentation drift:** five passages, all doc-side fixes, each checked against the source they describe: + +- `crates/runvil-internal/engine/AGENTS.md:4` says every store view is derived "at dispatch", but a section's `store` table gets one view at VM install (`crates/runvil-internal/lua/src/engine_globals-store.rs:258`), which is what lets a prelude's install-time store call issue no effect. The rule needs both derivations named. (S7-1) +- `runvil-docs/src/language/01-what-a-prompt-is.md:604` says every store call is Harness work, contradicting `12-tools.md:2767`, which describes a prelude's install-time calls running directly. The shim install order at `src/execute/section_vm.rs:196` settles it in favor of `12-tools.md`, so the absolute in chapter 1 is the wrong side. (S7-2) +- `README.md:59` lists four effect kinds and omits the Pack render; `Effect` has five, and `AGENTS.md:9` and `runvil-docs/src/introduction.md:14` both already name all five. It also says "a tool result", where only a catalog tool call is answered by the caller. (S7-3) +- `runvil-docs/src/language/16-limits-and-errors.md:1078`'s table lists twelve of thirteen `CompletionErrorKind`s, missing `InvalidParts`, whose tag reaches a run record as `invalid_parts`. (S7-4) +- `runvil-docs/src/language/12-tools.md:2389` says the validation events fire once per section entry, but every freeze attempt reports its own pair (`src/execute/scope.rs:148`), and a failed freeze attaches nothing, so a later round renders and validates again. The same claim appears at `12-tools.md:915` and `01-what-a-prompt-is.md:600`. (S7-5) + + + + +## Technical Design + +- **One map for the Pack roster.** `roster` and `vendors` collapse into `BTreeMap` with `Usable { vendor }` and `Unavailable { reason }`, which makes the subset rule unrepresentable. The cost is a `runvil-lua` interface change, since `ToolSet` accepts the pair: the same one-map shape there, and `bound_tool_set` and `vendor_for` read one map. [2026-10-26-1-engine-slop-cleanup.md](runvil/vibe/2026-10-26-1-engine-slop-cleanup.md) deferred exactly this and fixed only the disagreement, so the plan finishes that record's own target. (S2-10, S1-1) +- **The Pack order's single producer.** `offered_packs` takes each Pack's slot up front from `offers.packs` and fills the sets as it walks the tools, so the throwaway local order and the `sort_by_key` both go. The alternative, keeping the body and adding an invariant sentence, leaves two producers of one order and was rejected. The existing order test at `src/execute/scope-tests-render.rs:33` pins the behavior a rewrite must keep. (S5-3) +- **Rule-dependent cardinality.** `NonEmpty` already exists in `runvil-lua`, is exported, and is imported by this crate, so the `ToolSets` split costs no new collection type. `RenderedPacks` moves to a sibling variant of a new private enum carrying only `asked` and `answered`, leaving the five rule variants that name tools. (S1-2) +- **Deleting `Effect::Chat::options`.** The field was added for a per-round UI model substitution, but the one consumer that performs it works on its own copy of the binding, which travels beside the field anyway. The performer calls `binding.completion_options()` where it read the field, the record keeps its shape because it already records from `binding`, and the divergence becomes unrepresentable. `Effect` is deliberately closed, so a hand-built effect can disagree today. (S2-9) +- **One root-chain constructor.** A `start_root_chain` helper fills the four constants `ChainStart` repeats across the three root sites. Its parameters must carry `start` and `concurrency`, because `end_h1` inherits the pass's concurrency when `tasks.concurrency` lowered it. The enqueue question stays open: two sites push their own chain and one returns an index for `drive::start` to push, and the helper's doc states whichever convention is chosen. (S3-10) +- **Narrowing `test-support`.** Every item narrowed to `pub(crate)` is used only under `#[cfg(test)]`, which `pub(crate)` reaches, and the bench is a separate crate, so the compiler proves the list: a bench-time item narrowed wrongly fails the bench build. This is a surface change counted cheap only because the crate is `publish = false` with every user in-repo. (S6-7) + +### Step sizing + +The operator requires between five and ten steps total. The work divides naturally into one substantial cluster and a large body of cheap doc sentences, so size the steps by cause rather than by file count: + +- Group the cheap doc items into a few steps by crate area, not one step per file and never one step per finding. A step may carry thirty replaced sentences. +- Give each substantial item its own step, or pair two that touch disjoint files and land cleanly together. +- Keep the five drift fixes in one step for the engine repository and one step for the `runvil-docs` checkout, since that is a separate git repository with its own commit. +- Never produce a step whose only content is a single sentence replacement. + + + + +## Testing Plan + +- `cargo nextest run --locked -p runvil-engine -p runvil-lua -p harness-gateway-client -p harness-runner` +- `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` +- `cargo fmt --all --check` +- `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` with `RUSTDOCFLAGS=-D warnings` +- `cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` +- `cargo test -p build-xtask` and `node --test crates/workshop/ui/test/docs-claims.mjs` +- `cargo bench -p runvil-engine --features test-support --no-run`, which compiles the bench against the narrowed surface +- `cargo + xtask api --check` if any public surface moves +- A fresh `vibelight` run at the repository root, whose outlines show each declaration and comment change; nothing under `.vibelight/` is committed +- No new test is written for a doc-only item. Behavior items rely on tests that already exist: + - `scope-tests-render.rs:33` pins the render order the `offered_packs` rewrite must preserve + - `h1/pass.rs:68` and `exit_rules.rs:45` pin the `0.1` entry ids the `start_root_chain` helper must keep + - `context-tests.rs:150` pins that a Pack in both roster lists records no vendor, from the side a one-map change must preserve + - `effect-tests.rs:164` pins `{"Timer":{"seconds":0.5}}`, which the `Timer` doc change describes rather than alters + - `happens_before.rs:433` pins the single root identity a double scope acquisition would break + + + + +## Decision Record + +- Design records settle four questions the analysis raised. `roster`/`vendors`: the disagreement is fixed and the one-map shape is deliberately deferred. `Effect::Chat::options`: added for a substitution its lone consumer performs on its own copy. `RunResult`'s `#[non_exhaustive]`: deliberate, with its reason unrecorded at the site. A decline is a `Completed` value, never a thrown error. Each site gets the reason its record already holds. +- The empty-reply copy is the one finding that changes a crate this plan does not own. `harness-gateway-client` reaches `CompletionError` through the `runvil` facade rather than depending on `runvil-model-client`, so a single owner is callable from both with no new dependency; but the remedy adds a method to a type the facade publishes, which a prior pass declined for that reason. +- `SectionContext::vm` keeps its `Option`. The `Drop` implementation needs `take()` to place the VM's destruction between the teardown pair, and removing it was rejected before. Only the accessors change. +- Reversal costs: every item is inside a `publish = false` crate. The one cross-crate addition is additive and non-breaking, and deleting it later would cost more than reverting it. +- The `runvil-docs` guide is treated as fact for author-facing behavior. All five drift passages it disagrees on were doc-side, so the guide changes and the code stays, except where the guide's own chapters contradict each other, which `12-tools.md` settles. +- **Operator answers, all three (a):** add `CompletionError::empty_reply` to `runvil-model-client`; narrow the twelve `test-support` items; have `start_root_chain` enqueue and stop `drive::start` pushing. +- Risk: the `offered_packs` and `start_root_chain` items rewrite working code whose current behavior is pinned by tests, so both must land against a green suite. Neither is a doc edit despite riding beside many. + +### Open Questions + +None. Each was answered by the operator, and the answers are folded into the Decision Record and the work items they affect. + +### Deferred and Out of Scope + +- The drift verifier was stopped short of a written report. Each of the five findings was checked against source directly, which is what the entries above cite, so nothing rests on an unverified claim. +- `Chain::entered_name`'s three-way storage is deferred as a type change. The copies serve different lifetimes, and removing one changes signatures without removing a state; the doc items in this plan state its standing instead. Revisit when a change touches section teardown. +- `SectionPos`' position-past-slice case and `Visible::of`'s profile-dependent promise: only the doc is fixed. A test for the release-path fallback is out of scope because the fallback is untested today and the `debug_assert!` is the intended contract. +- `ChatOutcome::Overflow`'s doc in `runvil-lua` repeats the "no round ran" clause this plan fixes in `chat.rs`. That crate is outside the target, so the sentence stays until a run that owns it takes the same edit. Revisit with any change to the overflow vocabulary. +- `MAX_CALL_DEPTH` bounds `call_children`'s cost at eight passes per abort, so no complexity note is written on a private method for an observation that cannot change a decision. +- Slop the verifiers noticed but no candidate owned: none reached the standard, so nothing is parked here. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` for the gateway and `cargo build -p workshop` for the desktop app; a bare `cargo build` builds only `default-members`, which is `crates/gateway/app`, and CI builds `cargo build --locked -p gateway --no-default-features` as the headless shape. +- Focused test command pattern: `cargo nextest run --locked -p -E 'test()'`, narrowed the way CI narrows its Windows job, e.g. `cargo nextest run --locked --no-default-features --features gateway/test-fixtures -p gateway -E 'package(gateway) & binary(it) & test()'`. +- Component test command pattern: Engine and plugins `cargo nextest run --locked -p runvil -p runvil-engine -p runvil-parser -p runvil-lua -p runvil-vfs -p runvil-types -p runvil-model-client`; Harness `cargo nextest run --locked -p harness -p harness-runner -p harness-plugin-loader`; Gateway `cargo nextest run --locked -p gateway`; Workshop partition `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api -p workshop-workspace`; Workshop UI `npm test --workspaces --if-present` in `crates/workshop`; config UI `npm test` in `crates/gateway/config-ui/ui`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, plus `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` for the Workshop crates. Structural checks run separately as `cargo test -p build-xtask` (product and container boundaries, Workshop tier graph, `## Invariants` marker, lint inheritance) and `node --test crates/workshop/ui/test/docs-claims.mjs` (Engine wording, rulebook Definitions, Pack lifecycle wording). nextest settings live in `.config/nextest.toml`, which caps the whisper suites in a `heavy` group and extends timeouts for the ignored native realtime speech tests. +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` with `CARGO_BUILD_WARNINGS=deny`, and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` for the Workshop crates. Do not pair either with a standalone `cargo check --workspace`; the sole exception is `cargo check -p gateway --no-default-features`. UI type checks are `npm run typecheck` in `crates/workshop` and `crates/gateway/config-ui/ui`. +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`). For the TypeScript and CSS packages there is no formatter or lint config in the tree: the only frontend config files are `tsconfig.json` in `crates/workshop/ui`, `crates/workshop/look`, `crates/workshop/platform`, and `crates/gateway/config-ui/ui`, so the frontend gates are `typecheck` and `test` only. +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, then the facades with default features (`cargo doc -p runvil --no-deps`, `cargo doc -p harness --no-deps`) and `cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items`. The mdBook sources under `guide/books` build through `MDBOOK= cargo xtask site [--books-only]`, and the facade surface check is `cargo + xtask api --check` with `CARGO_BUILD_WARNINGS` unset. +- Test placement and naming conventions: unit tests sit inline behind `#[cfg(test)]`, declared as `mod tests;` pointing at a sibling `tests.rs` or a `tests/` subdirectory, or as an inline `mod tests { }` block near the code they cover. Integration tests live in the crate's `tests/` tree as one entry-point binary per suite: `crates/runvil/tests/suite/main.rs` and `crates/harness/tests/suite/main.rs` declare `mod` files beside them, and `crates/gateway/app/tests/it/main.rs` does the same under `it/`, with subdirectories such as `boot/`, `chat/`, `realtime_stt/`, `speech/`, and `support/` grouping the modules. Test functions are long snake_case sentences stating the behavior and result, for example `answering_a_batch_in_reverse_gives_the_same_result_as_issue_order` and `each_effect_and_its_answer_log_as_one_json_line_that_parses_back`, and most return a `TestResult` alias rather than `()`. Prompt fixtures live in `crates/runvil/tests/prompts/{valid,invalid,execution}/*.md` and `crates/runvil-internal/engine/tests/prompts/`, and tests embed them with `include_str!`. Shared scaffolding sits in a `support.rs` module per suite. `crates/workshop/ui/test/*.mjs` holds the Node test files, each named for its subject. +- Directory map: `crates/` holds the whole Rust workspace plus the `shared-ui` TypeScript package and the `build-*` tooling crates (`build-ceiling`, `build-ui`, `build-user-guide`, `build-workshop`, `build-xtask`, `build-llama-cuda`). `crates/runvil` is the public facade; `crates/runvil-internal/` holds the private Engine crates (`engine`, `parser`, `lua`, `types`, `vfs`, `model-client`). `crates/harness` and `crates/harness-gateway-client` are the Harness facades, with `crates/harness-internal/` holding `runner`, `plugin-loader`, and the `test-plugin` fixture. `crates/plugin-*` are the four Plugins, and `crates/runvil-plugin` is the Plugin contract. `crates/gateway/` holds the gateway family (`app`, `config`, `config-ui`, `local`, `logging`, `progress`, `protocol`, `routing`, `web-search`, and the nested `stt/{api,engine,backend-whisper,whisper-ffi}`), with `crates/gateway-api-types` and `crates/gateway-api-discovery` beside it. `crates/workshop/` holds the desktop app (`desktop`, package `workshop`), `server`, `server-api`, `ui`, `look`, `platform`, `agents`, `gateway`, `menu`, `protocol`, `registry`, `run-log`, `status`, `support`, `user-state`, and `workspace`. `crates/shared-error-source` and `crates/shared-loopback` are shared leaves. Outside `crates/`: `guide/` holds the mdBook `books` (`gateway`, `language`, `workshop`), `chrome`, and `landing`; `prompts/` holds shipped prompt files; `tools/` holds the Node scripts `stage-gateway-sidecar.mjs` and `gateway-tts-live.mjs` with their tests; `vibe/` holds dated change notes by month plus a `scratch` area; `.vibelight/` holds generated crate outlines (one markdown file per crate plus `index.md`); `local/` holds operator-local config, prompts, profiles, and STT fixtures; `images/` holds artwork; `.config/`, `.cargo/`, `.githooks/`, and `.github/` hold nextest config, cargo aliases and target flags, git hooks, and CI workflows. +- Component boundaries: seven terms each have exactly one meaning and are always capitalized, Engine (the `runvil` and `runvil-*` crates), Harness (the `harness` and `harness-*` crates), Host (an application such as Workshop that makes every policy decision), Pack, Plugin, Static Set, and Dynamic Set. Dependency direction runs one way. Engine crates depend only among themselves, with `runvil-engine` on `runvil-lua` and `runvil-parser`, and `runvil-parser` on `runvil-lua`; the `runvil` facade re-exports all six internal crates. Plugins depend only on the `runvil-plugin` contract. The Harness facade depends on `runvil`, `runvil-plugin`, `harness-runner`, and `harness-plugin-loader`. Gateway crates stay internal to their family, with `gateway` on `gateway-local`, `gateway-routing`, `gateway-stt`, `gateway-web-search`, and the optional `gateway-config-ui`, and `gateway-stt-backend-whisper` on `gateway-stt-engine`. Workshop tiers upward through `workshop-support` and `workshop-server` into `workshop-server-api`, and the desktop app depends on the facade only, never on `workshop-server` directly. Engine crates never depend on `tokio`, `tokio-util`, `async-trait`, `reqwest`, `getrandom`, or `fastrand`, and Engine code never names the Host; `cargo test -p build-xtask` enforces these boundaries and the tier graph, while `crates/workshop/ui/test/docs-claims.mjs` enforces the wording rules. +- Conventions summary: edition 2024 everywhere, rustfmt `style_edition = "2024"`, a pinned `1.99` toolchain with a `1.95` MSRV for the Engine crates. Lints are inherited from the workspace root and are strict: `all` and `pedantic` denied, `unsafe_code` and `unsafe_op_in_unsafe_fn` denied, `unwrap_used` and `expect_used` denied (with `allow-unwrap-in-tests` and `allow-expect-in-tests` set in `clippy.toml`), `todo`, `exit`, `dbg_macro`, `allow_attributes`, and `allow_attributes_without_reason` all denied, and process-global installers such as `set_hook`, logger installation, and `CryptoProvider::install_default` banned outside a binary entry point. `missing_docs`, `missing_debug_implementations`, and `unreachable_pub` warn. Every crate doc carries a `## Invariants` section. Source directories stay flat by default: a subdirectory needs at least three files, and a two-file group lives as `foo-bar.rs` siblings wired with an explicit `#[path]`, converting in either direction at the three-file line. Comments explain non-obvious constraints and cite an upstream issue URL for every workaround. Error and status text is written for a model to read: concise, factual, naming required versus actual. Behavior changes ship with tests in the same change. Builds and tests must not write into the repository, and CI fails on a dirty tree. Workshop UI CSS uses only the custom properties from `@workshop/look` and `tokens/component.css`, and every persisted value goes through the `ui-storage` adapter into one of two homes, `ui-state.json` for account state or the workspace file for workspace state. Cargo aliases are `cargo xtask`, `cargo workshop`, and `cargo vibelight`. + + + + +## Execution Instructions + +Ten steps, one commit each. Order is by cause, not by file count. The Pack roster moves a `runvil-lua` interface that every later behavioral test links against, so it goes first; the doc-only steps come last so a doc edit never hides inside a code commit. Substantial items that touch one shared file share a step, because a step is one commit. Doc-only sentences are grouped by crate area, never one step per finding. + +**Line numbers are locators, not guarantees.** Steps 1 through 6 edit source that Steps 7 and 8 also describe, so every line number in those doc steps was captured before this plan was written and may have moved. Confirm each sentence by its text before replacing it, and if a sentence is not there, report it rather than guessing. + + + +### Step 1: One map for the Pack roster [completed] + +- Component: Pack roster +- Tests: `cargo nextest run --locked -p runvil-engine -p runvil-lua` + +**Artifacts.** In `crates/runvil-internal/engine/src/execute/config.rs`, add a private enum `RosterEntry` with two variants, `Usable { vendor: String }` and `Unavailable { reason: String }`, each documented with one sentence. Replace the `roster` and `vendors` fields with one `packs: BTreeMap`. Rewrite `with_packs` to fold both argument lists into that map with the unavailable entry winning, keeping its public signature and its three doc paragraphs minus the vendor/roster split. In `src/execute/context-bound.rs`, `bound_tool_set` takes the one map. In `crates/runvil-internal/lua/src/handles.rs`, `ToolSet` holds the one map and `vendor_for` answers from the entry. + +**Fixtures to update.** In the `runvil-lua` crate: `handles-tests.rs:76`, `test_vm.rs:117` and `:135`, `packs-tests.rs:25`, `tools/tests-parameters.rs:44`, `tools/offer.rs:153`. In the engine crate: `lua/tests.rs:63`, `lua/tests/globals.rs:55`, `execute/tests/tool_scoping.rs:26`. + +**Test case.** Write the new test before the change and keep it green after. A Pack listed in both roster lists records no vendor on the tool-call event; `context-tests.rs:150` already asserts that from the roster side, so extend it to assert the one-map shape answers with the `Unavailable` reason rather than a vendor. Expected fact, not just no-panic: the event's vendor field is absent and the reason string is the one `with_packs` was given. + +**Acceptance.** The subset rule becomes unrepresentable. The cost is one `runvil-lua` interface change, since `ToolSet` accepted the pair; that change is deliberate and settles the deferral recorded in `vibe/2026-10-26-1-engine-slop-cleanup.md`, which fixed only the disagreement. No new dependency is added on either side of the seam. + +**Flags.** This is the plan's one cross-crate interface change. It is inside `publish = false` crates and additive in information, so nothing is published from it. Findings: S2-10, S1-1. + + + + + +### Step 2: Delete `Effect::Chat`'s `options` field [completed] + +- Component: Engine state +- Tests: `cargo nextest run --locked -p runvil-engine -p harness-runner -p harness-gateway-client` + +**Artifacts.** In `src/execute/run/effect.rs`, remove the `options` field from the `Chat` variant and its doc line "The per-request completion options, built from `binding`". Reword the variant doc's "using the frozen `options` built from `binding`" to "using the options `binding` freezes". Drop the `options: binding.completion_options(),` line at `src/execute/scheduler/chat.rs:180` and `src/execute/scheduler/dispatch.rs:222`. + +**Performers.** Each performer reads `binding.completion_options()` where it read the field, so the request sent is unchanged: `src/execute/test_support/scenario-driving.rs`, `src/test_support/scripted-chat.rs`, the two test helpers at `src/execute/run/tests.rs:48` and `effect-tests.rs:33`, plus `benches/models_loop.rs`, `crates/harness-internal/runner/src/effect_loop.rs`, and `crates/harness-gateway-client/src/wire/request-tests.rs`. + +**Acceptance.** The record keeps its shape because `Effect::record` already logs from `binding.invocation()`, so a caller-built effect can no longer log a request nobody sent. `Effect` is deliberately closed, so the divergence is unrepresentable after this, not merely unused. The record tests at `effect-tests.rs:53`, `:96`, and `:164` must pass unchanged; that is the proof the logged shape did not move, including `{"Timer":{"seconds":0.5}}` at `:164`, which this step does not alter. Findings: S2-9. + + + + + +### Step 3: Infallible VM access and an `Argv`-carrying setup [completed] + +- Component: Engine state +- Tests: `cargo nextest run --locked -p runvil-engine -p runvil-lua` + +**Part one, `src/execute/section_context.rs`.** Change `pub(super) fn vm(&self) -> Result<&SectionVm>` to `pub(super) fn vm(&self) -> &SectionVm`, returning `self.vm.as_ref().expect("a live frame holds its VM until drop")`. Add a doc sentence stating that only the frame's own `Drop` clears the slot. Delete the `Error::Internal` clause from the `# Errors` paragraphs of `vm`, `read_var`, `read_argv`, and `install_lazy_prose`, and delete the by-hand `let Some(vm) = vm.as_ref() else { ... }` in `tool_call_counts`, calling `self.vm(..)` instead. The internal uses at `:162`, `:173`, and `:214` become `self.vm.` with no error mapping. + +**Call sites that drop `?`.** `scheduler/step.rs:78`, `:161`, `:177`; `scheduler/tool_call.rs:178`, `:217`, `:367`; `scheduler/tool_call-discovery.rs:87`, `:153`; `scheduler/chat.rs:105`, `:220`, `:282`; `scheduler/dispatch.rs:212`. Leave `read_var`'s callers on `Result`, because it still reports the Lua error: `scheduler/chain.rs:137`, `walk.rs:166`, `walk.rs:193`, `step.rs:108`, `h1.rs:79`. + +**Part two, `src/execute/section_vm.rs`.** Replace the `argv: Option<&'a Value>` and `argv_writable: bool` fields with one `argv: crate::lua::Argv<'a>` and delete the `:135-139` rebuild, passing the field straight to the VM. `RunState::vm_setup` at `src/execute/context.rs:357` takes the writable flag and builds the `Argv` once, so the decision is made at construction. `SectionContext::new_h1` at `src/execute/section_context-construct.rs:193` stops mutating the returned struct and passes the writable flag to `vm_setup` instead. Update the two test helpers at `lua/tests.rs:88` and `:126`, and the five loops at `lua/tests/globals.rs:105`. + +**Acceptance.** `SectionContext::vm` keeps its `Option`; the `Drop` implementation needs `take()` to place the VM's destruction between the teardown pair, and removing it was already rejected. Only the accessors change. No test asserts the deleted `Error::Internal` arm, which is why it can go. A writable `Argv` can no longer reach a non-H1 section, and H1-only writability stays pinned end to end by `execute/tests/suite/args_surface.rs:154` and `suite/global_metatable.rs:23`, both unchanged. Findings: S5-1, S5-2. + + + + + +### Step 4: One producer for the Pack order and self-evidencing tool-set errors [completed] + +- Component: Offers and errors +- Tests: `cargo nextest run --locked -p runvil-engine` + +**Pack order, `src/execute/scope.rs`.** Rewrite `offered_packs` to insert one slot per Pack up front in `offers.packs` order and fill each slot's sets while walking the tools. Delete the local order list it builds today and the `sort_by_key` at `:131`. Document on `SectionOffers::packs` that it is the only copy of that order. The alternative, keeping the body and adding an invariant sentence, leaves two producers of one order and was rejected; `scope-tests-render.rs:33` pins the asked order the rewrite must preserve, and `scope-tests.rs:57` with `scope-tests-render.rs:40`, `:82`, `:168` call the function directly. + +**Tool-set evidence, `src/error/sets.rs`.** Move `RenderedPacks { asked, answered }` out of `SetRule` into a sibling variant of a new private enum `SetEvidence` whose other variant holds a `runvil_lua::NonEmpty` beside the `SetRule` that names it. Change `Error::ToolSets` at `src/error.rs:157` to hold that enum and make `sets_message` match without an empty case. Update the construction sites at `src/execute/scope.rs:244`, `:282`, `:292`, `:446`, `src/error/value.rs:41` and `:81`, `src/error/tests.rs`, and `src/execute/scope-tests.rs:289`. `NonEmpty` already exists, is exported by `runvil-lua`, and is imported through `src/lua.rs:7`, so no collection type is invented. + +**Test case for the evidence.** No test constructs `RenderedPacks` with tools today; that shape is the defect. Add one that renders a rule-shaped message and asserts the subject is present, for example that a `RenderedPacks` message names both asked and answered counts and never renders with a missing subject. + +**Rename, `src/error.rs` and `src/execute/scheduler/chat.rs`.** Rename `OutOfScopeToolCall::in_scope` to `static_aliases` and relabel the message tail to "aliases this section offers", followed by a clause naming the two discovery tools. Update the field declaration, the `Display` at `:235`, the construction at `chat.rs:443`, and seven readers: `tool_loop-scope-gate.rs:45`, `:79`, `:113`, `:189`, `offering-sets.rs:158`, `chat_arm.rs:225`, `error/tests.rs:99`. Rewrite the two `ends_with` assertions at `tool_loop-scope-gate.rs:57` and `:90` to the new tail. + +**Acceptance.** The field name and the message agree: it holds the Static Set only, and the text no longer calls the list complete. The two discovery tools stay named in the message. Findings: S5-3, S1-2, S1-16. + + + + + +### Step 5: Root-chain constructor, `Admission` names, `ChainOps` state, scheduler doc text [completed] + +- Component: Scheduler +- Tests: `cargo nextest run --locked -p runvil-engine` + +**Artifacts, `src/execute/scheduler/chain.rs`.** Add `start_root_chain(&mut self, kind: ChainKind, counters: Counters, ctx: RunState, access: Arc, start: SectionPos, var: Value, concurrency: NonZeroUsize) -> Result`. It fills `lineage: ChainId::root()` and `call_depth: CallDepth::ROOT` itself, pushes the chain onto the ready queue itself, returns its index, and states the enqueue convention in its doc. Have `start_h1` (`h1.rs:48`), `end_h1` (`h1.rs:108`), and `start_root_walk` (`walk.rs:56`) call it and drop their own `push`, including `drive::start`'s push at `drive.rs:137`. Keep `prepare_call` and `prepare_spawn` on `ChainStart`, so this is an addition and not a type split. + +**Parameter rules.** `start` and `concurrency` must be parameters, because `end_h1` inherits the pass's concurrency when `tasks.concurrency` lowered it. The operator chose enqueue-in-helper, so the doc says a root start enqueues itself and no caller pushes. + +**Artifacts, `src/execute/scheduler/admission.rs`, the naming.** `release`, `forget`, and `park` name one state change three ways, and `released` names the opposite relation. Rename to state the effect, not the cause: `release` becomes `take_back` (it gives the chain's slots back at its end), `forget` becomes `drop_aborted` (it drops an aborted chain from both queues and from the holders), and `park` stays `park`, which already names the state a chain enters. Rename the `released` field to `gave_back`, since it is the map of chains that gave slots back, not the slots. Update the readers: `chain.rs:169` and `:233`, `waits.rs:82`, `drive.rs:218` (`clear` is unchanged), and the calls in `admission-tests.rs:117`, `:157`, `:170`, `:181`, `:193`, `:208`, `:215`, `:231`, `:243`, `:245`, `:294`, `:311`, plus the field reads at `:308`. Each renamed method keeps its existing doc paragraph with the term substituted; the three-way split becomes three names for three distinct operations, which is what the finding asks. + +**Artifacts, `src/execute/scheduler/chain_ops.rs`.** Remove the `ctx` field at `:35` and its initializer at `:54`, and have `prompt` return `Arc::clone(self.chain.ctx.prompt())`. The signature is unchanged, so no call site moves. After this, one `RunState` reference reaches the prompt: the chain's own fork, not the scheduler's sibling copy. + +**Acceptance.** `h1/pass.rs:68` and `exit_rules.rs:45` pin the `0.1` entry ids, and `happens_before.rs:433` pins the single root identity that a double scope acquisition would break. The scheduler suite pins that a started chain runs, which is the behavior the moved push must preserve. + +**Doc text, first group, one sentence each.** Each bullet below is one replaced or appended sentence, no code change: + +- `chain.rs`: `finish` delivers three ways, but a `Determinism` outcome ends the run first, and the frame is armed only when not H1. +- `h1.rs`: `end_h1`'s `# Errors` gains the gone capability and the chain-count cause its body raises. +- `scheduler.rs`: `prompt_origin` enumerates four `label` shapes, including the tool id a caller passes; the `scope` field states its single writer. +- `task_end.rs`: `end_backing` adds the queued-chain silence its body enforces. +- `admission.rs`: ordering and counting live in `plan`, which makes both true; `admit` and `admit_chain` shrink to one sentence each, keeping both `# Errors`. +- `chain_ops.rs`: name all eight files holding `impl ChainOps` blocks, including this one, and include `prompt` in the shared four. +- `walk.rs` and `chain_record.rs`: `Chain::counters` becomes the normative `0.1` statement, and the other three sites defer to it. +- `drive.rs`: the stall guard tests two conditions, and the admission queue needs no third. +- `step.rs`: cut the module doc's four-case list and `step_chain`'s copy, keeping the block-outcome half. + +**Doc text, second group, one sentence each.** Also doc-only, except the `waits.rs` renames: + +- `chain_record/arena.rs`: `ChainArena` holds at most `MAX_CHAINS`, and a test build lowers that through `set_max_for_test` so the overflow path runs without allocating the real bound. `ChainIndex` is created only by `push` outside test builds, and a `#[cfg(test)]` constructor mints one for tables that only compare indices. +- `chain_record.rs`: `Entered`'s drop is the teardown boundary in all three taking paths, reporting `SECTION_FINISHED` only where the section completed, so an abort's teardown is deliberately silent. `Chain::access`' `# Errors` also says the two teardown probes skip on it. `entered_name` states that the frame's `sys.section_name` and the walk position name the same section while entered, and that this field is the only record between sections and after teardown, written only after the frame is constructed. +- `chat.rs`: `overflow_result`'s doc names both the precheck, where no round ran, and a provider's overflow refusal, where the round ran and reported no finish reason. +- `dispatch.rs`: the routing sentence names the three arms this file keeps and the module and file each remaining group lives in. The opening qualifies that a refusal decided before dispatch may report its failure and answer inline. `dispatch_vfs` drops the repeated leaf-yield rule and keeps only its own contract. +- `effects.rs`: `orphan` says any continuation other than a tool call is orphaned and returns `None`. `clear_withdrawn` says `drive::end` clears the withdrawals after teardown orphaned the rest silently, so a decided run lists none rather than some. +- `tasks.rs`: `TaskState::Delivered` reads "Whether the task ended well, carried so `status` can report it after the outcome is gone", leaving the origin sentence on `EntryState::Delivered`. +- `tool_call.rs` and `tool_call-discovery.rs`: the adhoc paragraph moves into `tool_call-adhoc.rs`, leaving one pointer in the parent; the discovery child's opening reduces to its freeze-before-preparer guarantee; `Listed::input_schema` gains a doc naming the one branch that sends it. +- `waits.rs`: rename `live_of` to `live_author_tasks` and `live_with_timers` to `live_all`, updating `waits.rs:160`, `:169`, `task_end.rs:133`, the module doc at `tasks.rs:17`, and the test at `tasks-tests.rs:181`. + +**Findings.** S3-10, S3-25, S3-11; docs S3-1, S3-2, S3-3, S3-4, S3-5, S3-6, S3-7, S3-8, S3-9; second group S4-5, S4-6, S4-4, S4-9, S4-28, S4-11, S4-13, V4-2, V4-1, S4-14, S4-15, S4-2, S4-20, S4-23, S4-24, S4-3. + + + + +### Step 6: One owner for the empty-reply message, and a narrowed `test-support` [completed] + +- Component: Test support +- Tests: `cargo nextest run --locked -p runvil-engine -p runvil-model-client -p harness-gateway-client -p runvil`, then `cargo bench -p runvil-engine --features test-support --no-run`, then `cargo + xtask api --check` + +**Empty-reply owner.** Add `pub fn empty_reply(reasoning_present: bool) -> CompletionError` beside `CompletionError::new` in `crates/runvil-internal/model-client/src/error.rs:173`. Build it from the existing private `specific` formatter and `with_finish_reason`. Its doc says it reports `EmptyReply` and extends the phrase with the reasoning-ignored specific when reasoning was present. Call it from `normalize::empty_reply_error`. Then delete `REASONING_IGNORED` and the local `empty_reply` body in both `src/test_support/scripted-chat.rs:23` and `crates/harness-gateway-client/src/wire/parse.rs:35`, and delete the "two copies change together" sentence at `scripted-chat.rs:6`. The Engine copy's whitespace predicate at `scripted-chat.rs:151` becomes `!text.trim().is_empty()` as part of the move, since the gateway already treats whitespace-only reasoning as absent and that divergence is the reason the copies exist. + +**`harness-gateway-client` reachability.** It reaches `CompletionError` through the `runvil` facade rather than depending on `runvil-model-client`, so the single owner is callable from both with no new dependency. Note in the commit message that this adds a method to a type the facade publishes, which a prior pass declined only for that reason, and the operator answered yes. + +**Text pinned by tests.** The exact strings are asserted at `exit_rules.rs:352`, `model-client/src/model/error-tests.rs:230`, `wire/parse-tests.rs:248`, and `transport/tests/streaming.rs:68`. All four must pass without edits; a change to any string is a failure of this step, not a rebless. + +**Narrowing, `src/test_support/scripted-chat.rs`.** Change these to `pub(crate)`: `MOCK_MODEL` (`:26`), `text` (`:31`), `text_finish` (`:38`), `calls` (`:62`), `fail` (`:84`), `ScriptedReply::answer` (`:142`), `ScriptedCall` with its three fields (`:212`), `messages_json` (`:228`), `tool_names` (`:235`), `ScriptedChat::requests` (`:279`), and `last_request` (`:288`). + +**Keep `pub`.** `drive`, `perform_locally`, `ScriptedChat`, `new`, `complete_now`, `call_count`, `ScriptedReply`, and its variant fields. That list is exactly what `benches/models_loop.rs` names, and the bench is a separate crate, so `pub(crate)` cannot reach it. + +**Vestige in the same file.** Delete `ScriptedReply::Failure`'s `finish_reason` field and the `if let Some(reason)` block that reads it. No constructor in the workspace sets the field, so the three reading lines cannot run, and the bench never names `Failure`. Grep the workspace for `Failure` with a `finish_reason` before deleting; if a setter exists that this plan missed, stop and report rather than removing a reachable field. + +**Acceptance.** `cargo bench -p runvil-engine --features test-support --no-run` fails if the bench needed one of the narrowed items, which makes the compiler prove the list. If the facade publishes `empty_reply`, rebless `crates/runvil/public-api.txt`. Findings: S6-2, V6-1, S6-7, S6-6. + + + + + +### Step 7: Engine doc sentences, one file at a time [completed] + +- Component: Doc text +- Tests: `cargo nextest run --locked -p runvil-engine` and `RUSTDOCFLAGS=-D warnings cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + +Every bullet is replaced or appended prose plus one visibility token. No behavior changes, so no new test. Where a bullet cites a line number, treat it as a locator, not a guarantee: earlier steps in this plan edited several of these files, so confirm the sentence by its text before replacing it. + +**`src/error.rs` and `src/error/convert.rs`, seven edits.** + +- The module doc's wrapper list names `RunError` as the boundary, with `ParseError`, `CompletionError`, and `ToolError` reaching it through `RunError`'s classification. +- Append that `Parse`, `Completion`, and `Substitution` are transparent, so `originating_error` answers `None` for two of them and the parser's cause for `Parse`. +- On `Store`, append why its cause is the concrete `VfsError`: the Lua error table carries the tag and fields this crate rebuilds it from. +- On `UnsupportedVersion`, state that major 0 is the only supported major and that `Display` names it beside the declared one. +- On `LuaQuota`, write "A Lua quota ran out: a `log()` event or byte budget, or the instruction-hook budget that keeps a block cancellable." +- On `DepthVia`, add that the reported name is the cap's, not the author's construct, and reduce `Call`'s doc to "A `call`, or a `tasks.spawn`." +- In `convert.rs`, replace "the gateway client, parser, and Lua crates'" with "the model-client, parser, and Lua crates'". The old wording names a crate the boundary forbids this module to reference. + +**`src/execute.rs`, `src/execute/bindings.rs`, `src/execute/config.rs`, five edits.** + +- Delete "journaled" at `execute.rs:12`, `bindings.rs:1`, and `bindings.rs:18`; write "the run's model bindings", and on `roles` "The decision: role label to the bound model's identity". +- `model`'s doc: `None` means no role is bound, and a catalog model reachable by raw id is not in the map either. +- `models` and `model_bindings`: the map holds the info of every model a role is bound to. +- `name`: the caller's label, moved into `RunState::execution` at start and read nowhere else. +- `uid`: shown in a debug rendering only; events and reports carry `name`. + +**`RunResult`'s doc, `src/execute.rs`.** Delete the `# Outcomes` list first by itself, then rewrite the surviving paragraph. The rewrite names `Step`, `Effect`, and `EffectAnswer` as closed peers, says why the outcome is the exception to the closed set, and states that a declining prompt still completes with its reason as the `Completed` text. Deleting before rewriting is deliberate: the list restates all three variant docs and has already drifted from the one it restates. + +**Six files, one sentence each.** + +- `src/execute/config-limits.rs`: `lua_log_events` says the byte ceiling is derived from this count, so raising it raises both budgets. +- `src/execute/context.rs`: name `tasks.spawn` and `tasks.fanout` beside `call` in the fork list and on `with_args`, since an arm with an input takes the same fork; `limits` becomes `pub(super)` alongside its siblings. +- `src/execute/context-bound.rs`: state that `derive_argv` re-runs on every args fork, and add the declared description and context window to the role-binding list. +- `src/execute/infer.rs`: replace "one prose round of `round`" with `turn::report_model_turn`, which is the item that exists. +- `src/execute/requirements.rs`: `merge` appends `unmet_models` whole rather than skipping; the `UsablePack` vendor promise excludes a Pack listed in both roster lists; required versus actual is model lines only. +- `src/execute/run/effect.rs`: `Timer`'s record stores fractional seconds, which `effect-tests.rs:164` pins as `{"Timer":{"seconds":0.5}}`. Describe the shape; do not alter it. + +**`src/execute/error.rs`, five edits.** Add `SourceLocation` to the module doc so all three exports are named; name all three quota resources on `RunErrorKind::Quota` and all six members on `Lua`; extend `originating_error` to all eight cause kinds; drop `Parse`'s compiled-Lua claim, since a compiled Lua region now classifies as `Lua`. + +**Findings.** S1-6, S1-8, S1-7, S1-9, S1-10, S1-18, S1-13, V6-2, S1-5, S1-3, S1-4, S1-14, S1-17, S1-11, S1-12, S1-15, S2-11, S2-6, S2-5, S2-12, S2-4, S2-1, S2-3, S2-13, S2-14, S2-7, S2-2, S2-8, V2-1. + + + + + +### Step 8: Section docs and the three import surfaces [completed] + +- Component: Doc text +- Tests: `cargo nextest run --locked -p runvil-engine`, plus `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` for the import moves + +**Section docs, one edit each.** + +- `src/execute/section_context.rs`: the no-derives note becomes "Not `Clone`: the frame owns the VM and its one teardown", since only `Clone` is blocked. Drop the reporting half of the `sys` field doc and say on `SectionSys::section_name` that `Drop` reports under it, fixed at construction. +- `src/execute/section_vm.rs`: add the raw-model-id opt-in to `setup_section_vm`'s fixed sequence, after injection, with the reason it must land first. Leave the module doc alone. +- `src/execute/section_context-construct.rs`: reduce the `construct` module doc's delta clause to name `new_h1` as the list, because that list is short by two. +- `src/heading_address.rs`: on `Visible::of`, state the `debug_assert!` and drop the fallback promise, which today is true in release and panics in debug; reduce two `# Errors` paragraphs to a pointer at `not_found`, which owns the not-found rule. + +**`src/subst.rs`, two edits.** `NullValue` scopes its rule to `var`, `sys`, `argv`, and a bare global, and says a null `item` renders as the text `null`, which is what `{{ item }}` does. `substitute`'s `# Errors` names `SubstErrorKind` as the complete list, with the offset and bounded preview, replacing the prose that duplicates the ten variants one for one. + +**The three import surfaces, `src/model.rs`, `src/lua.rs`, `src/parser.rs`.** Each file states the rule once: this module is the import surface for its dependency, and production code reaches the dependency through it. Add `ModelSetError` to `model.rs` and `ModelKeyword` to `parser.rs`, which today are missing from the surfaces that claim to be complete. Route the eight bypassing imports through the surface. Name the exception groups in the doc rather than claiming two: at least five groups exist. In `model.rs`, name `harness-gateway-client` as where the transport codec lives, replacing the sentence that sends the codec to a crate that has none. + +**Acceptance.** The import moves are mechanical, so the suite proves them; a missed re-export is a compile error, not a silent change. No new abstraction is added to satisfy any of these docs. Findings: S5-4, V5-2, S5-6, S5-7, S5-5, S5-8, V5-1, S6-4, S6-5, S6-1, S6-3. + + + + + +### Step 9: Engine repository drift, two files [completed] + +- Component: Drift +- Tests: `cargo test -p build-xtask` and `node --test crates/workshop/ui/test/docs-claims.mjs` + +Doc edits only, in the engine repository, so they commit here rather than with the docs checkout. Both are checked against the source they describe. + +- **`crates/runvil-internal/engine/AGENTS.md`, line 4.** Replace "at dispatch" with "at dispatch for a `Vfs` effect and once at section-VM install for the section's `store` table", so the rule matches both derivations. The install-time derivation is what lets a prelude's install-time store call issue no effect, and it is visible at `crates/runvil-internal/lua/src/engine_globals-store.rs:258` and `src/execute/section_vm.rs:196`. +- **`README.md`, line 59.** Replace the effect list with "a model reply, a Pack's render of the tools a section offers, a catalog tool's result, a timer, or a file outside a prelude's install". `Effect` has five kinds and the current line lists four; `AGENTS.md:9` and `runvil-docs/src/introduction.md:14` already name all five, so this makes the third site agree. Also change "a tool result" to "a catalog tool's result", since only a catalog tool call is answered by the caller. + +**Acceptance.** `docs-claims.mjs` checks Engine wording, rulebook Definitions, and Pack lifecycle wording, so it is the gate that fails if the new phrasing breaks a wording rule. Run it with `cargo test -p build-xtask` in the same command as the plan's structural checks. Findings: S7-1, S7-3. + + + + + +### Step 10: `runvil-docs` checkout drift, three files [completed] + +- Component: Drift +- Tests: `RUNVIL_DOCS= cargo xtask site --books-only` from the engine repository, then `node --test crates/workshop/ui/test/docs-claims.mjs`. `RUNVIL_DOCS` names the docs checkout; `MDBOOK` names the mdBook binary and defaults to `mdbook` on PATH. + +`runvil-docs` is its own git checkout at `c:\Users\Vinnie\cursor\runvil-docs`, latest edit 2026-10-27, so this is a separate commit in a separate repository. It cannot share a commit with any step above. All three edits are guide-side: the guide's own chapters settle each case, and code stays put. + +- **`src/language/01-what-a-prompt-is.md`, line 604.** Qualify the absolute so it covers the calls a block makes, and add that a prelude's install-time call runs inside the Engine and reports only its event. This matches `12-tools.md:2767`, and the shim install order at `src/execute/section_vm.rs:196` settles the conflict in `12-tools.md`'s favor. +- **`src/language/16-limits-and-errors.md`, after line 1093.** Add one table row: `| InvalidParts | a caller built a tool call or completion from parts the validating constructors refuse | no |`. The table lists twelve of thirteen `CompletionErrorKind`s, and `InvalidParts` reaches a run record as `invalid_parts`. +- **`src/language/12-tools.md`, lines 2389 and 915, and `01-what-a-prompt-is.md:600`.** State the rule as one event pair per freeze attempt, with a failed freeze attaching nothing so a later round renders and validates again. Every freeze attempt reports its own pair at `src/execute/scope.rs:148`, so "once per section entry" is wrong at all three sites. + +**Ordering rule.** Commit this step in the `runvil-docs` repository, not the engine one. The step number here is plan ordering, not commit order within a single repository; this commit exists as its own change because the checkout is separate. + +**Flags.** Two of these are the only work in this plan that lands outside the cargo workspace, so no cargo gate covers them. The mdBook build is the only check available, plus reading the rendered page. Findings: S7-2, S7-4, S7-5. + + +Each step is one commit against a green suite. Run the step's own `Tests` line before committing; a doc-only step runs the crate suite and the rustdoc gate, because `RUSTDOCFLAGS=-D warnings` is what fails on a broken link or a stale intra-doc reference. Never merge a doc sentence into a code commit, and never split one step across two commits. + +Steps 9 and 10 land in separate repositories. Step 10's commit belongs to the `runvil-docs` checkout at `c:\Users\Vinnie\cursor\runvil-docs`, so it cannot be part of the engine repository's history. The step numbers order the plan, not a single repository's log. + + diff --git a/vibelight.toml b/vibelight.toml index 53803519e..ce975e7b5 100644 --- a/vibelight.toml +++ b/vibelight.toml @@ -12,3 +12,5 @@ exclude = [ "crates/harness-internal/test-plugin", # a test fixture or helper ] out = ".vibelight" +# Doc text that repeats on purpose, so the doc-text leads ignore it. +boilerplate = ["cargo test -p build-xtask"]