Skip to content

Add built-in model roles and model_only_call, and slim the run API - #188

Merged
vinniefalco merged 29 commits into
cppalliance:masterfrom
vinniefalco:master
Nov 1, 2026
Merged

vinniefalco merged 29 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

This started as one commit, the model_only_call flag. It now holds 29, in seven groups and one housekeeping commit, in branch order:

  • The model_only_call flag (1 commit): a Pack can keep a tool in the catalog while a script's tools.call refuses it.
  • Built-in model roles and a model that belongs to the chain (8 commits): every prompt gets five fixed roles, the Host maps roles to models, and models.use now lasts for the rest of the chain.
  • Plain model handles (1 commit): a handle holds fields only, and the models functions are the one way to run a model.
  • A smaller run API (11 commits): the Engine checks a run's start conditions in its first step, the Engine and the Harness share one outcome type, and the facade drops what no production code uses.
  • Housekeeping (1 commit): cf572c063 moves the October plan files under vibe/2026-10/, adds a line to crates/plugin-std/AGENTS.md that each tool's source goes in its own directory named after the tool's Lua name, and shortens the doc link in crates/runvil-internal/engine/src/execute/error.rs that failed the docs job.
  • A looser source layout rule (1 commit): 4cec10f24 replaces the three-file minimum for source subdirectories in AGENTS.md.
  • Real file tools in the std Pack (5 commits): Access::read_range, then the shared code with a real Read, the glob matcher with a real ListDir, and a real Grep, and then the commit that closes the plan.
  • Mount parents and a demo mount (1 commit): 331a2933b makes the parents of a mount show up as directories, adds a demo that maps a local directory into each run behind a switch that is off, and changes the built-in chat agent to declare the std tools.

The diff is 415 files, +19,228 and -7,445. Of those files, 222 are tests, fixtures, benches, and examples, and 22 are in vibe/ (19 renames into vibe/2026-10/ and 3 new plans). Source and docs outside tests, plans, and the API listing change by +6,432 and -3,828 lines, so the branch adds about 2,600 lines of non-test code, most of it the file tools. Before the file tools it removed about 780. crates/runvil/public-api.txt loses 327 lines and gains 73.

Each commit message gives its own account, and the commits build on each other in order, so reading them in order is the easiest way through.

Add the model_only_call flag to ToolInfo

A Pack may render a tool differently for each model: other parameter names, another schema, another description. Lua cannot read a schema, so a script that calls such a tool writes JSON from a guess. The tool still has to stay in the catalog, because tools.offer("std/replace") has to name it. So the flag keeps the catalog entry and refuses the script's call. The model still calls it, because the arguments a render shaped belong to the model that receives them.

Three facts stay independent:

  • In the catalog: Lua can name it, and tools.get returns a tool object.
  • Offerable: tools.offer accepts it, alone or with its Pack.
  • Script-callable: the new flag. false means tools.call refuses it and the model may still call it.

How it works:

  • ToolInfo::model_only_call and its builder sit beside structured_output, survives_stop, and describes_tools in crates/runvil-internal/types/src/tools/info.rs. It has #[serde(default)], so a run log written before this change still loads. ToolInfo::new leaves it false, so every existing tool behaves as before. The key is always serialized, so a recorded run shows the flag.
  • tool_route in crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs refuses in its script branch, after the one catalog lookup that a bare id and a tool object share. The model branch resolves against the section's frozen sets and is untouched.
  • The refusal is Error::ModelOnlyToolCall { name, id }, kind RunErrorKind::Tool. It reaches Lua as an error table tagged model_only_tool, so a prompt can pcall it and tell "model-only" apart from "no such tool".
  • bind in crates/runvil-internal/engine/src/execute/scope.rs copies the catalog's value onto every rendered tool with a catalog id, so a render cannot clear a declaration its own Pack made.
  • The Lua tool object gains model_only_call as a fifth read-only field.

No tool sets the flag yet. This is the mechanism only. The plan is vibe/2026-10-31-1-model-only-call-flag.md.

Give every prompt five model roles

Before this change a prompt wrote a models: block naming its own role labels, then called models.default(label) before its first model call. The Host then bound every declared role to its one current model. Now every prompt has the same five roles, and the caller decides which model fills each.

  • ModelRole is a closed type with Default, Strong, Small, Fast, and Creative, in the fixed order of ModelRole::ALL. It is #[non_exhaustive], reads only its lowercase names, and the error for any other name lists all five.
  • The caller gives RunConfig::with_models one map from role to model. A role with no entry uses the default entry. An empty map means the caller has no model, so a model call raises ModelRequired and a prompt that never calls a model still runs. A map with entries but no default entry is refused before the run starts, with a notice that the caller's role map has no default (RequirementCheck::Unbound).
  • fill_model_set builds one binding per role for every prompt, whether or not the prompt names the role.
  • models: front matter now only attaches requirements to those five roles. Each may state min_context and thinking. A role outside the five, a field outside those two, a repeated role, and a zero minimum are parse errors. description: and the keyword lists are gone, along with ModelKeyword, ModelRoles, and ModelRolesIter. Frontmatter::role_requirements is the one accessor.
  • Requirements are checked against the model that fills each role. thinking: true fails against a model that never thinks, thinking: false against one that always does, and a declared value sets the thinking switch on that role's requests only.
  • The run's model set is built once and shared as an Arc<ModelSet>. The mutex, ModelView, ModelSetError, and the default alias with its writer are deleted, so nothing writes to the set during a run.
  • models.default is deleted. Every section starts on the default role's model, or on no model when the caller gave none.
  • models.use takes a role name or a handle and applies its options on top of the handle's own settings. models.get returns a role's handle without selecting it. The new models.current returns the handle for the section's model.
  • ModelBinding drops its keyword list, so a handle shows its label, name, model id, description, context, thinking switch, and provider. A role's description is the description of the model that fills it.
  • Workshop's ModelDto reports each declared role as its name, minimum context, and thinking requirement, and the run panel shows them as text. parseModel rejects a thinking value that is neither a boolean nor null.

Make the current model belong to the chain

A switch of model used to end with the section that made it. Now it lasts across the sections of a chain.

  • A chain starts on the default role's model. The root walk that follows the opening pass starts on the model that pass ended on, including when a jump leaves the pass.
  • A call child and a spawned task, which includes every fanout arm, start on a copy of the parent's model taken when the call or spawn is dispatched. A task waiting in the queue is not changed by a later switch in its parent, and a child's switch never reaches its parent or its siblings.
  • Chain owns the selection its section VMs share, and ChainStart holds the model it starts on.
  • tasks.fanout has a model-first form, tasks.fanout(model, worker, collection). The parent switches to the given model only while the arms are spawned, and is put back before the first wait and also when a spawn fails part-way. It lives in __impl_fanout.lua, so the scheduler and the spawn request do not change. A first argument that is not a handle, or any other argument count, fails with an error that says what was expected.

Make model handles plain values

A handle holds its fields and has no methods. models.infer and models.loop take an optional leading handle, and the compactor of models.loop stays optional. handle_method, HANDLE_METHODS_REGISTRY, and the handle_methods table are deleted, and LOOP_ARITY now reads models.loop takes (compactor?) or (model, compactor?).

Check a run's start conditions in its first step

A Run always holds a boxed Scheduler, so a run that cannot start needs no state of its own. On the first step, check_start checks the runvil: version, then probes the declared store, then checks the prompt's requirements. A failed check ends the run and reports no run events. This replaces Start, Stillborn, Spent, prepare_state, and the refusal step the caller used to run before it built the run.

  • RunConfig::with_models replaces prepare. with_packs takes one map of RosterEntry values, and the Harness reports each Pack's status through the roster.
  • Requirements, UnmetRequirement, RequirementCheck, MissingService, UsablePack, and UnavailablePack leave the facade. The facade exports runvil::packs::RosterEntry.
  • A run with no with_packs call checks no declared Pack. A run with an empty roster finds each declared Pack missing.
  • When a start check fails and the cancel flag is set, the first step ends the run Cancelled. An unsupported version and an unmet requirement together report the version failure first.
  • A refused run is now prepared and driven like any other, so its record holds the parse events and then the end.

Use one outcome for the Engine and the Harness

Step::Done holds a closed RunOutcome: Completed, Failed with a RunErrorKind and a message, or Cancelled. The Harness records the outcome the Engine returns and no longer converts it, so effect_loop-outcome.rs is deleted. RunResult, RunError, and SourceLocation leave the facade.

  • The failure message holds the error's text and its source() chain. A parse failure adds (prompt {name}, line {line}, column {column}) with the parts it knows, and an internal failure adds (at {file}:{line}).
  • Harness::run begins the record before it resolves the model, so every run has a record and one outcome. A cancel before or during the model lookup ends as Cancelled, and a failed lookup or a stall ends as Failed. HarnessError keeps only Recorder, DriveError is gone, and RunReport::run_id is a RunId, no longer an Option.
  • The step that decides a run lists every outstanding effect in withdrawn. The effect loop answers Dropped for each one without performing it. Run::decided, Run::cancel, and Run::cancel_handle leave the facade, and the caller cancels through the flag it gives RunConfig::with_cancel.
  • Preparation failures use Engine kinds: Parse for a parse failure, RequirementsUnmet for an undeclared or missing input file, and Vfs for a store failure on an input. The Harness wrote Input for all three.

Delete facade surface that nothing uses

  • The record types EffectRecord, AnswerRecord, ChatAnswerRecord, ToolAnswerRecord, and FailureRecord are pub(crate). Effect::record and EffectAnswer::record return the record as a JSON Value, which is the value the Harness stored before.
  • Removed: Flags, ReplayError, the replay module, RunUid, with_flags, the RunConfig getters vfs, tools, seed, started_at, and flags, the events TaskResumed and TaskNote, ToolErrorKind::Cancelled, and the is_retryable methods.
  • IdError is the one error for every id parser and both catalogs. It replaces seven error types and their kind enums, and each message text stays the same.
  • RunErrorKind goes from 12 kinds to 10. UnsupportedVersion maps to Parse, Determinism to Vfs, and ModelRequired to RequirementsUnmet. from_tag still reads the retired Version and Determinism tags, so a stored run keeps its meaning, and as_str never writes them.

Split the run state and rename RunContext

RunState becomes SharedState, which holds the run-wide fields behind one Arc, and ChainState, which holds that Arc plus the fields a chain fork replaces (reporting, args, and argv). Every fork shares one SharedState. RunContext is renamed RunConfig in the Engine, the facade, the Harness runner, harness-gateway-client, and their tests, because the type holds the caller's inputs for one run and its module is config. HostRunContext in the Harness runner is a different type and keeps its name. The last commit fixes the execute module doc, which listed cancel as a method of Run.

Loosen the source layout rule

AGENTS.md said a source subdirectory must hold at least three files, and that a smaller group sits beside its parent as foo-bar.rs files wired with #[path = "foo-bar.rs"] mod bar;. The repository had about 250 of those attributes in src/ trees, and 185 of them only put a test file beside its module. The rule now says a module with more than one file keeps them in a directory named after it (foo.rs beside foo/bar.rs), a directory may hold any number of files, a module's unit tests go in foo/tests.rs, and new code adds no #[path] for layout. The existing attributes stay, because removing them would touch about 200 files and change no behavior.

Make the std file tools real

The std Pack shipped as six stubs that all answered one fixed text. This branch makes Read and Grep real and adds a seventh tool, ListDir (Lua id std/dir). Write, StrReplace, Delete, and Glob are still stubs. Every tool reads through the run's virtual filesystem, so the run's mode and policy apply to each access, and the Pack never touches the disk itself.

  • Access::read_range(path, offset, len) (07c1cbe7a) takes the same four steps as read (gate, read claim, event, backend) and passes the range to the backend's own read_range. It is the one new facade method, so public-api.txt gains one line. RealBackend reads only the bytes asked for, and a backend that does not override it reads the whole file and slices it.
  • Read (bea66707c) numbers each line as 1|text, caps its output at 100,000 bytes on a line boundary, and ends a cut read with a trailer that names the next offset. It stats the file first, reads at most 16 MiB through read_range, refuses binary content by sniffing the first 8,192 bytes, decodes UTF-8 lossily and UTF-16 by its mark, and names the path in every failure.
  • ListDir (316a76f0b) prints a bullet tree, breadth first, under a 15,000-byte cap with a note when it stops. It skips entries named .git, lists every other dotfile, and never enters a link. Its wire name is ListDir, not Cursor's deprecated LS.
  • Grep (f7472905c) uses the Rust regex crate and a literal flag, walks in path order, and pages with head_limit and offset. It counts what it skips by reason instead of failing on one entry, and it yields every 32 files or 2 MiB so a cancel can reach it.
  • Shared code in crates/plugin-std/src/: refusal.rs words every VfsError the same way for all tools, text.rs holds decode, binary sniff, line split, and clip_to_boundary, and matcher.rs is a glob matcher where only * and ** are special and a relative pattern matches at any depth. clip_to_boundary is the only function that shortens text inside a line, so no cut splits a UTF-8 code point.
  • Tests: plugin-std runs 386 tests, and its yield test counts Pending polls over 1,000 seeded files. A real-directory test in crates/workshop/server/tests/it/std_real_directory.rs mounts a directory with RealBackend::rooted and runs the list, find, read, and read-the-next-window flow through the Pack. It lives in workshop-server because a Plugin crate may not depend on the vfs crate.

94e6a172b closes the plan that drove these four commits.

Show mount parents and add a demo mount

The VFS router served each path from its longest-prefix mount and nothing else, so a directory that existed only because a mount sat under it did not exist. A model that listed / found nothing, and one that tried /_runvil got "Path does not exist", though /_runvil/language-guide worked. The router now reports such a directory: it exists, stats as a directory, and lists the mount's next path segment beside whatever its serving backend holds. The code is mount_children in router-access.rs, with three tests in router-tests.rs. This changes behavior for every VFS with nested mounts and changes no signature.

The same commit adds a demo in crates/workshop/server/src/agents/language_guide.rs. It maps one local directory read-only under /_runvil in each run, so a prompt can read the language guide with the file tools. The directory is a path on one machine, so ENABLED is false, and that constant is the one switch. Its tests map a temporary directory and run a prompt over it, so nothing in the suite needs that path.

chat.md declares std, offers user-input and std, and holds a commented-out messages:system line that names /_runvil/language-guide. The chat gate tests skip a leading system message, and the compaction mock does not count one, so they pass with that line on or off.

Breaking changes

For prompt authors:

  • models: may name only default, strong, small, fast, and creative, each with min_context, thinking, or both. Any other role, any other field, and a zero minimum fail to parse. There is no migration message.
  • models.default(...) is gone. A section that never calls models.use runs on the default role.
  • A handle has no methods. Pass it to models.infer or models.loop as the first argument.
  • The sample prompts and the built-in chat agent moved to the new keys, and greet.md and hello.md lost their models: block.

For code that embeds the Engine or the Harness:

  • RunContext is RunConfig, prepare is with_models, and Step::Done holds a RunOutcome.
  • The facade drops the types listed above and gains Access::read_range. crates/runvil/public-api.txt loses 327 lines and gains 73.

For recorded runs and Workshop:

  • A ToolInfo now always serializes model_only_call, so a render answer record shows "model_only_call":false. A line without the key still reads as false.
  • The run log reads the retired Version and Determinism tags as Parse and Vfs. It reads a stored tag it does not know, such as Input, as Internal with the stored message.
  • Workshop no longer prefixes a failed model lookup with "the run's model could not be resolved". A run cancelled before it starts now has a record with nothing between begin_run and end_run.
  • Workshop's built-in chat agent now declares std and offers user-input, std, and web, so its model can call Read, ListDir, and Grep, and can ask the operator a question.
  • A VFS with nested mounts now lists the parents of each mount as directories.

What this does not do

  • No production Host picks a model for each role. The Harness turns its one model into a map that holds only default (crates/harness-internal/runner/src/prepare.rs), so strong, small, fast, and creative all resolve to that model. Only an embedder that builds its own map can fill them differently.
  • The language guide lives in cppalliance/runvil-docs, not in this PR. Its model chapters match this branch in runvil-docs commit 1b99039: chapter 10 is rewritten, and the examples, errors, and links in the other chapters follow it. That commit is not pushed yet, so it needs pushing alongside this PR.
  • No compatibility path, alias, or migration error exists for the removed prompt forms.
  • No tool in crates/plugin-std sets model_only_call.
  • ModelRole has no FromStr or Display, because the serde error already names the five roles.
  • Write, StrReplace, Delete, and Glob in std are still stubs, and the read record that Write will need is not built.
  • ListDir and Grep skip entries named .git and apply no .gitignore, so a real repository's target/ and node_modules/ are searched. A shared ignore module comes later.
  • Workshop mounts nothing real by default. The demo mount is off, and the directory it names is a path on one machine. No test covers the demo mount with ENABLED set to true.

How this was verified

CI at 331a2933b, the head of this branch: every job passes. They are api-surface, check-workshop, check-workshop-linux, ci-green, clippy, docs, fmt, msrv, supply-chain, test, and ui.

CI at cf572c063, before the file tools: every job passed too. They are api-surface, check-workshop, check-workshop-linux, ci-green, clippy, docs, fmt, msrv, supply-chain, test, and ui. The docs job had failed at 3527ad195 on rustdoc::redundant_explicit_links in the doc of RunErrorKind, where [`RunOutcome::Failed`](super::RunOutcome::Failed) repeated its own target, and cf572c063 fixes it.

Each file-tool commit ran its focused tests, and the Grep commit ran the full gate set: workspace and Workshop nextest, both clippy runs with warnings denied, fmt, rustdoc, cargo test -p build-xtask, the wording scan, and cargo xtask api --check. For the mount commit, the Workshop suite passes (320 tests) and the rest of the workspace passes (4,065 tests).

The guide in runvil-docs builds with cargo xtask site in its books-only mode, with RUNVIL_DOCS pointing at the clone (mdBook 0.4.44). A link check over the guide finds only the two index.md files that the site assembler writes, and the rendered chapter 10 has every anchor the other chapters link to.

The commit messages name the tests that pin each change. A few to know:

  • ModelRole: the order of ModelRole::ALL, the round trip through as_str, and a reviewer role that the error rejects with all five names.
  • The start gate: a_run_that_cannot_start_reports_its_failure_on_the_first_step.
  • Retired tags: a_retired_tag_parses_to_the_kind_that_absorbed_it_and_no_kind_writes_it.
  • Public records: every_kinds_public_record_is_json_that_round_trips_exactly.
  • The flag: a script's tools.call by id and by tool object both fail with ModelOnlyToolCall, pcall catches it, tools.offer still accepts the tool, and a model's call of it reaches a ToolCall effect.

Reviewer notes

  • Most of the diff is mechanical. 222 of the 415 files are tests, fixtures, benches, and examples, and the RunContext to RunConfig rename touches many of them.
  • The model commits depend on each other, from 4f547a6b9 (the five roles) to 7075dfc4c (the model-first fanout), so review them in order. vibe/2026-10-31-2-built-in-model-roles.md holds that plan, and d4a6e3f3c closes it.
  • The file-tool commits build on each other too: 07c1cbe7a, bea66707c, 316a76f0b, then f7472905c. vibe/2026-11-01-1-std-read-listdir-grep.md holds that plan, and 94e6a172b closes it.
  • The start-check and outcome commits change what the facade exports and what the Harness records. The public-api.txt diff in each commit shows the exact surface.

A Pack can mark a tool that only a model may call, so a script's `tools.call` refuses it while the catalog keeps naming it.

- `ToolInfo::model_only_call` and its builder join `structured_output`, `survives_stop`, and `describes_tools`, serialized always and read as `false` when absent.
- `tool_route`'s script branch refuses a model-only tool with `Error::ModelOnlyToolCall`, which a `pcall` catches, after the one catalog lookup the bare id and a tool object share.
- `runvil_lua::ErrorKind::ModelOnlyTool` renders the refusal to Lua under the tag `model_only_tool`.
- `bind` copies the catalog's value onto a rendered tool, so a render cannot clear a declaration its Pack made.
- The Lua tool object gains `model_only_call`, so a prompt reads the flag before offering the tool.
- `crates/runvil/public-api.txt` gains the field and the builder.
- `vibe/2026-10-31-1-model-only-call-flag.md` records the change.
Prompts can ask for one of five fixed model tiers: default, strong, small, fast, and creative. This adds a closed type for those tiers so the Host can map each tier to a model. The documentation says a tier with no model of its own uses the default tier's model. The tiers read from their lowercase names, come in one fixed order, and work as ordered map keys. Any other name is rejected, and the error lists all five valid tiers.

- `#[non_exhaustive]` stops downstream crates from matching the five tiers exhaustively. A later tier then does not break them.
- `PartialOrd` and `Ord` are derived so the type can key a `BTreeMap`. A test pins that the key order matches the order of `ModelRole::ALL`.
- `ModelRole::ALL` is a fixed five-element array in declaration order. A test pins the order.
- `Deserialize` reads the lowercase tier names and rejects any other text. A test feeds `reviewer` and checks that the error names all five valid tiers.
- `as_str` returns the lowercase tier name. A test round-trips every tier through it and back through deserialization.
- `ModelRole` adds no `FromStr`, `Display`, `Serialize` or error type of its own, because the serde error already carries the five names. Nothing in the touched code uses it outside its own tests.

Design: new value-object @ crates/runvil-internal/types/src/models.rs::ModelRole boundary: wire
Design: new pure-function @ crates/runvil-internal/types/src/models.rs::ModelRole::as_str
Plan: vibe/2026-10-31-2-built-in-model-roles.md
Every prompt now gets five model roles, and the caller decides which model fills each one. The caller passes a map from role to model when it prepares a prompt. A role without its own entry uses the default entry, and an empty map means the caller has no model. A map that has entries but no default entry is refused before the run starts, and the notice says the caller's role map has no default. The context minimums and thinking requirements a prompt declares are checked against the model that fills each role.

- `fill_model_set` builds one binding per role in `ModelRole::ALL` order for every prompt, whether or not the prompt declares the role. A declared `thinking` or `no-thinking` keyword sets the thinking switch on that role's binding only.
- `ModelBindings` and `RunContext::model_bindings` are gone. `prepare` stores the model set it builds, and the run state takes that set as it is.
- `UnmetRequirement` carries the role as a `ModelRole` instead of a string, and `RequirementCheck` gains `Thinking` and `Unbound` in place of `HardKeyword`. The notice names the model filling the role.
- `RequirementCheck::Unbound` is reported once, for the default role, when the map is not empty and has no default entry. The set stays empty and the run is refused. An empty map reports nothing, a model call raises `ModelRequired`, and a prompt that never calls a model still runs.
- `prepare_from_snapshot` turns the Harness's optional model into an empty map for `None` and a map holding only the default role for `Some`.
- `set_default_alias` still runs at the end of `fill_model_set`, so a section that never calls `models.use` runs on the default role.
- `fill_model_set` never reads a declared label outside the five roles, and it never checks soft keywords or a declared description.
- `prompts/greet.md` and `prompts/hello.md` lose their `models:` block and their `models.default` call, and the README example changes with them.

Design: new surface-growth @ crates/runvil-internal/engine/src/execute/config.rs::RunContext::prepare boundary: pub
Design: new pure-function @ crates/runvil-internal/engine/src/execute/fill.rs::thinking_switch deps: ModelKeyword
Design: removes stringly-typed @ crates/runvil-internal/engine/src/execute/requirements.rs::UnmetRequirement boundary: pub
Plan: vibe/2026-10-31-2-built-in-model-roles.md
The run's model set is built once and shared as it is, so nothing locks it and nothing writes to it during a run. Each section starts on the default role's model, or on no model when the caller supplied none. Choosing another role switches the section's model, and inspecting a role leaves it alone. Only the five role names are accepted: a name outside them fails with an error that lists the five, and a role the caller gave no model fails saying so. The prompt-wide default call is gone, because the default role is now the baseline.

- `ModelSet` has no default alias and no writer. The mutex around it, `ModelView` and `ModelSetError` are gone, and the run shares it as `Arc<ModelSet>`.
- `inject_values_with_var` takes the section's selection as an argument, and the injected VM keeps it so `SectionVm::current_model` can read it.
- `SectionVmSetup` carries the selection that the two frame constructors create, starting on `RunState::default_binding`.
- `models.use` stores the binding it selects with its options applied, and `models.get` returns a role's handle without selecting it.
- `role_binding` fails with a message that says the caller supplied no model when the role has no binding, and a label outside the five roles fails with the five names listed.
- `models.get` still resolves a label outside the five roles as a raw gateway model id when the run has an application-state snapshot.
- `models.default` is deleted, so a prompt cannot park a prompt-wide default, and every section begins on the default role.
- `resolve_model_binding` and the section-level `ModelSelection` are deleted, and the engine reads the model through `current_model`.
- `tool_call_counts` no longer takes the run context, because the model read needs none.

Design: removes shared-mutable-state @ crates/runvil-internal/model-client/src/model/options.rs::ModelSet boundary: pub
Design: removes temporal-coupling @ crates/runvil-internal/model-client/src/model/options.rs::ModelSet::set_default_alias
Design: new pure-function @ crates/runvil-internal/lua/src/models.rs::find_role deps: str
Plan: vibe/2026-10-31-2-built-in-model-roles.md
A section can now read its current model and switch back to one it saved earlier. Reading the current model returns its handle, and choosing a model accepts a role name or a handle, so a script can save the current model, switch for a while, and restore it. Options given to the choosing call apply on top of the handle's own settings, and a raw gateway model id from the inspect call is accepted. A handle exposes its label, name, model id, description, context, thinking switch and provider, without a keyword list. A role's description is the description of the model that fills it.

- `models.use` takes a role name or a handle. A handle's binding is used as it is, and any other argument fails naming its Lua type, with an integer reported as number.
- `ModelBinding` drops its keyword list, `with_capabilities` and `capabilities`, which also takes both out of the facade's public API listing.
- `models.current` returns the handle for the section's current model, and fails saying the caller supplied no model when there is none.
- `fill_model_set` takes each role's description from its model. The declared keywords stay in the requirement checks, and a hard keyword still sets the thinking switch.
- `YieldParse` loses a lint expectation that the smaller binding makes unnecessary.
- `capabilities` is not exposed on the Lua handle.

Design: new surface-growth @ crates/runvil-internal/lua/src/models.rs::install_models
Design: new pure-function @ crates/runvil-internal/lua/src/models.rs::lua_type_name deps: Value
Plan: vibe/2026-10-31-2-built-in-model-roles.md
A prompt's models key now only attaches requirements to the five fixed roles. Each role may name a minimum context window and a thinking requirement, and a role name outside the five, a field outside those two, a repeated role, or a zero minimum is a parse error. Prepare reads the requirements through one accessor, checks them against the model that fills each role, and turns a thinking requirement into the thinking switch for that role's requests. The Workshop reports each declared role as its name, minimum context and thinking requirement, and the run panel shows them as text. The sample prompts and fixtures use the new keys.

- `RoleRequirements` holds the minimum context and the thinking requirement, and `Frontmatter::role_requirements` is the only accessor. It returns `None` for a role the prompt does not name.
- `deserialize_contract_map` is generic over the key type through `ContractKey`, so `args` keeps its alias-grammar string keys, `models` keys are `ModelRole` values, and duplicate rejection stays shared.
- `ModelDto` carries a label, a minimum context and a thinking requirement for each declared role in `ModelRole::ALL` order, with absent values sent as null.
- `fill_model_set` checks `thinking: true` against a model that never thinks and `thinking: false` against one that always does. A declared requirement sets the thinking switch on that role's binding only.
- `parseModel` rejects an entry whose thinking is neither a boolean nor null, and the run panel row reads min context and thinking text built from the parts that are set.
- `ModelKeyword`, `ModelRoles` and `ModelRolesIter` are deleted, along with the parser's old `ModelRole` struct, so a role has no soft keywords and no description.
- `keyword_name` and `keyword_wire` are deleted with the keyword vocabulary they spelled.

Design: new strategy @ crates/runvil-internal/parser/src/contract.rs::ContractKey
Design: removes stringly-typed @ crates/runvil-internal/parser/src/contract/models.rs::ModelRoles boundary: pub
Design: new schema-change @ crates/workshop/server/src/routes/prompts.rs::ModelDto boundary: wire
Plan: vibe/2026-10-31-2-built-in-model-roles.md
A switch of model now lasts across the sections of a chain instead of ending with the section that made it. A chain starts on the default role's model, or on none when the caller gave no model. The root walk that follows the opening pass starts on the model that pass ended on, including when a jump leaves the pass. A call child and a spawned task, which includes every fanout arm, start on a copy of the parent's model taken when the call or spawn is dispatched, so a task waiting in the queue is not changed by a later switch in its parent. A child's switch never reaches its parent or its siblings.

- `Chain` owns the selection that each of its section VMs shares, and `ChainStart` carries the model the selection starts on.
- `SectionContext::new` and `new_h1` take the chain's selection instead of creating a fresh one for each section.
- `scheduler/dispatch.rs` and `scheduler/tasks-spawn.rs` copy the parent's current model into the child's `ChainStart` before the child exists, which keeps a queued task's model independent of when the scheduler admits it.
- `scheduler/h1.rs` hands the model the opening pass ended on to the root walk it starts, and `scheduler/walk.rs` starts a walk with no pass before it on the default binding.
- `start_root_chain` takes the starting model as a parameter, so no root chain starts without one.
- `current_model` reads the chain's selection and fails with an internal error when its lock is poisoned.
- `requested_models` on `ScriptedChat` replaces four private copies of the same test helper.

Design: new shared-mutable-state @ crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs::Chain
Plan: vibe/2026-10-31-2-built-in-model-roles.md
A fanout can now take a model first, before the worker and the collection, and every arm runs on that model. The parent's own model is switched to the given one only while the arms are spawned, and it is put back before the first wait and also when a spawn fails part-way, so the parent ends on the model it had before. Because each arm copies its parent's model when it is dispatched, the switch needs no change to the scheduler or to the spawn request. A first argument that is not a model handle, or a call with any other number of arguments, fails with an error that says what was expected. An arm's own switch stays inside that arm.

- `__impl_fanout.lua` handles the three-argument form: it checks the handle first, saves the chain's model, sets it to the handle's, spawns every arm, and restores the saved model once, before the first join or before a spawn failure is re-raised.
- `selection_captures` builds `get_selection` and `set_selection` over the chain's selection. The first returns the handle or nil, and the second stores a handle, clears on nil, and refuses any other value by naming its Lua type.
- `install_shim_prelude` takes the selection and hands the fanout chunk its two captures beside `is_model_handle`. They are chunk arguments and not globals, so author code cannot reach them.
- `lua_type_name` and `selection_poisoned` are shared, so the refusal reports number for an integer as `models.use` does and a poisoned lock reads the same everywhere.
- `three_models` and the other chain-test helpers move into the shared scheduler test module, so the chain tests and the fanout tests use one copy.

Design: new surface-growth @ crates/runvil-internal/lua/src/__impl_fanout.lua::fanout
Design: new temporal-coupling @ crates/runvil-internal/lua/src/__impl_fanout.lua::fanout
Plan: vibe/2026-10-31-2-built-in-model-roles.md
Plan: vibe/2026-10-31-2-built-in-model-roles.md
`models.infer` and `models.loop` take an optional leading model handle, and `models.loop` keeps the compactor optional. A handle now carries only its fields, so the `models` namespace functions are the one way to run a model.

- The two `add_field_function_get` lines are gone from `models-userdata.rs`, and with them `handle_method`, `HANDLE_METHODS_REGISTRY`, and the `handle_methods` table the shim returned.
- `loop_begin` no longer takes an entry flag; it reads a leading handle off its `Value` arguments and refuses past two with `LOOP_ARITY`.
- `HANDLE_FIRST`, `NO_RECEIVER`, and `METHOD_ARITY` are deleted. `LOOP_ARITY` now reads `models.loop takes (compactor?) or (model, compactor?)`.
- The colon-diagnostic tests are retargeted at the merged argument rules, and no test asserts text that no longer exists.
A `Run` now always holds a boxed `Scheduler`, so a run that cannot start needs no state of its own. On the first `step`, `check_start` checks the `runvil:` version and then probes the declared store. A failed check sets `Phase::Ending` directly, and the run reports no events.

- `Start` with `Stillborn` and `Spent`, the `cancel` field on `Run`, and `prepare_state` are deleted. `cancel`, `cancel_handle` and `decided` stay public and read the scheduler, which gets `cancel_handle` and `phase_name`.
- The scheduler's `new` no longer reports `RUN_STARTED`. `step` reports it after `check_start` passes and before `start` runs the H1 pass or the walk.
- A second `step` on a run that could not start returns the internal failure "a finished run cannot be stepped again". The message "a run that failed to start cannot be stepped again" is deleted.
- `decided` is false before the first step and true after the step that refuses the run. Before, a run that could not start was decided when it was built.
- An answer that arrives before the first step still ends the run through `end`, which now reports the run's end event with no `RUN_STARTED` before it. No test checks the events on that path.
- `a_stillborn_run_reports_its_failure_on_the_first_step` is renamed `a_run_that_cannot_start_reports_its_failure_on_the_first_step`, and its assertions do not change. The facade API listing does not change.
The run's first step checks what the prompt requires, so the caller no longer refuses a run before it builds it. `RunContext::with_models` replaces `RunContext::prepare`, `RunState::new` fills the model set and keeps the refusal notice, and `check_start` returns `Error::RequirementsUnmet` with that notice after the version and store checks. `with_packs` takes one map of `RosterEntry` values, and the Harness gives the status of each Pack through `roster`.

- `Requirements`, `UnmetRequirement` and `RequirementCheck` are crate-private in `requirements.rs`. `MissingService`, `UsablePack`, `UnavailablePack`, `merge`, `refusal` and `is_satisfied` are deleted, and the facade exports `runvil::packs::RosterEntry`. The `public-api.txt` diff shows only these changes and the two new signatures.
- The `packs` field of `RunContext` is an `Option`. A run with no `with_packs` call checks no declared Pack, and a run with an empty roster finds each declared Pack missing.
- `HostRunContext::snapshot` takes the unresolved picks. A pick with a reason becomes `PackState::Unavailable`, and a pick with no reason stays off the roster, so the run finds it missing. `requirements`, `PrepareError::Refused` and `prepare_noting_cancel` are deleted, and `Harness::run_to_end` calls `prepare`.
- When a start check fails and the cancel flag is set, the first step ends the run `Cancelled`, with no run events. A run that passes its checks with the flag set starts as before.
- `end` on a run in `Phase::Fresh` sets `Phase::Ending` and reports nothing. So an answer before the first step no longer gives an end event without a start event.
- A prompt with an unsupported version and an unmet requirement reports the version failure first.
- A declared Pack that needs a service the run does not have gives one unavailable line with the reason "the run lacks the services it needs". Before, the notice had one "needs ..., and the environment provides none" line for each service.
- A refused run is prepared and driven. The effect loop records the failure, and the record holds the parse events and then the end.
- The first-step tests move to `run/tests-start.rs`, because `run/tests.rs` passed the 500-line limit. The test support deletes `unprepared`, and its callers of `catalog_scenario` use `loop_scenario`, because without a prepare step the two builders do the same thing.
The Engine and the Harness use one outcome type, so the Harness records the outcome that a run returns and does not convert it. `Step::Done` carries a closed `RunOutcome` with `Completed`, `Failed` (a `RunErrorKind` and a message) and `Cancelled`, and `to_outcome` builds the failed outcome from the internal `Error`. `RunResult`, `RunError` and `SourceLocation` leave the facade, and the Harness re-exports the Engine's `RunOutcome` and `RunErrorKind` from `harness_runner::recorder` and `harness::record`.

- `RunErrorKind::as_str` gives the variant name, the same text as `Debug`, and `RunErrorKind::from_tag` parses it back. `workshop_run_log` also re-exports `RunErrorKind`.
- The scheduler keeps the internal `Error` of the last failed outcome in a `#[cfg(test)]` field, and `take_failure_for_test` gives it to the test drivers. `Error::originating_error` is test-only.
- `effect_loop-outcome.rs` is deleted with `outcome_of` and `failed_outcome`, and the effect loop records the outcome of `Step::Done` as it is.
- The message walks the error's text and its `source()` chain as `display_chain` does. Then a parse failure adds `(prompt {name}, line {line}, column {column})` with the known parts, and an internal failure adds `(at {file}:{line})`. The message does not hold the byte span.
- A prompt with no `runvil:` key named `t` now fails with `not a runvil prompt: no runvil version (prompt t)`.
- Preparation failures use Engine kinds: `Parse` for a parse failure, `RequirementsUnmet` for `InputFileError::Undeclared` and `InputFileError::Missing`, and `Vfs` for `InputFileError::Vfs`. Before, the Harness wrote `Input` for each input failure.
- The run log writes `kind.as_str()`. It reads a stored tag that `from_tag` does not know, such as `Input`, as `RunErrorKind::Internal` with the stored message, and a new test pins this.
- `public-api.txt` changes only for these types, the `Step::Done` field and the two tag methods.
- A parse failure that the Harness finds before the run starts still has no location in its message, because the Harness keeps `display_chain` of the `ParseError`.
`Harness::run` begins the run's record before it resolves the model, so every run has a record and one outcome. A cancel before or during the model lookup ends the record as `RunOutcome::Cancelled`, and a failed model lookup or a stall ends it as `RunOutcome::Failed`. The only error is now a recorder that refuses a write: `HarnessError` keeps only `Recorder`, `DriveError` is removed, and `drive_run` returns `RecorderError`.

- `begin` in `prepare.rs` draws the seed and the start and begins the run. `prepare` takes the `Begun` value, the `PrepareError` variant `Recorder` holds a `RunId`, and `ended` returns the outcome or the refusal.
- The `RunReport` field `run_id` is a `RunId`, not an `Option`. Workshop's `take_report` gives it to `note_run` directly.
- `CurrentModelError` leaves the `harness` facade and stays public in `harness_runner::environment`. Its `kind` gives `RunErrorKind::Completion` for `CatalogFetchFailed` and `RunErrorKind::RequirementsUnmet` for `SelectionAbsent`.
- At a stall, the loop calls `self.flights.clear()` to drop the aborted futures it did not poll, then records the end. The message stays "the effect loop has nothing to await for a pending run".
- The message of a failed model lookup is `display_chain` of the `CurrentModelError`. Workshop no longer shows the prefix "the run's model could not be resolved".
- A run cancelled before it starts now has a record with no entries between `begin_run` and `end_run`. Workshop's run log keeps a row for a conversation closed before its run starts.
- The new stall test steps the run once and keeps its tool call from `drive_run`. No test makes a stall through `Harness::run`.
The step that decides a run now lists every outstanding effect in `withdrawn`, so the caller follows one rule to drop effects and needs no second signal. `orphan_all` puts the id of each outstanding effect in `withdrawn`, `end` no longer clears the list, and `take_step` keeps only the ids that still owe an answer. `Run::decided`, `Run::cancel` and `Run::cancel_handle` leave the facade, and the caller cancels a run through the flag that it gives to `RunContext::with_cancel`.

- The scheduler's `decided` is `#[cfg(test)]`, and `decided_for_test` gives it to the Engine tests that check when a run is decided. The doc text of `Run::cancel` moves to `RunContext::with_cancel`.
- The effect loop answers `Dropped` for each issued effect whose id is in `withdrawn` and does not perform it, so a `Vfs` effect withdrawn in its own step does not run. When the cancel flag is set at a pending step, the loop still drops each issued effect and every effect in flight.
- The loop no longer calls `self.run.cancel()`. The `drive_run` doc says that `cancel` must be the flag of the run's context, and the tests that cancel build the run with that flag through `run_with_child_and_cancel` or the cap suite's `Rig`.
- `drive_step`, the Engine `Scenario` driver and the facade suite driver drop each effect in `withdrawn`, issued or held. So a tool call that a task cancel withdraws in the step that issued it is answered `Dropped` and is not performed, and a held call is dropped at the step that withdraws it.
- The Engine `Scenario` gives the run its own `CancelHandle`. A context edit that replaces the flag disconnects `cancel_when` and `Driving::cancel`.
- The greeter example makes its own `CancelHandle`, gives it to `with_cancel`, and answers each withdrawn effect `Dropped`.
- New tests check that a cancel withdraws the chat round and the two tool calls still out, that a deciding step withdraws the timer, store write and tool call that it issued, that `take_step` omits a withdrawal already answered, and that the effect loop drops a withdrawn `Vfs` effect before it reaches the backend. `public-api.txt` loses only the three `Run` methods.
The `runvil` facade exposed record types, replay flags, event variants, getters, and error predicates that no production code reads. This change removes them or makes them crate-private. `Effect::record` and `EffectAnswer::record` now return the record as a JSON `Value`, which is the same value the Harness stored before.

- `EffectRecord`, `AnswerRecord`, `ChatAnswerRecord`, `ToolAnswerRecord`, and `FailureRecord` are `pub(crate)` and keep their serde derives. The new `to_record` gives the typed form, and Engine tests reach the types through `#[cfg(test)]` re-exports.
- `with_report_debug` takes a `bool` in place of `DebugMode`, and `Emitter` keeps the flag as a `bool`. The `Request` and `Response` events do not change.
- The diff removes `Flags`, `ReplayError`, the facade's `replay` module, `RunUid`, `with_flags`, and the `RunContext` getters `vfs`, `tools`, `seed`, `started_at`, and `flags`. The `RunContext` `Debug` output no longer shows `uid` or `flags`.
- The diff removes `TaskResumed`, `TaskNote`, and the `ModelCatalogValidation` and `LuaReplyBinding` triples from `Event`, with their `lifecycle` constants and the test mirrors in `observation.rs` and `forward.rs`.
- The diff removes `ToolErrorKind::Cancelled`, `is_cancelled`, and the `is_retryable` methods of `ToolError`, `CompletionError`, and `CompletionErrorKind`. Tests that called them now assert the kind.
- The effect loop appends `record()` directly and no longer maps a serialization failure to a `RecorderError`.
- The new test `every_kinds_public_record_is_json_that_round_trips_exactly` checks that each public record is the typed record's JSON and reads back the same. The facade tests read records as JSON, and the catalog test calls a structured-output tool in place of the `tools()` getter.
- The seed known-answer test now uses the `runvil.guard-nonce.v1` tag, because the run id tag has no user.
Every id parser and both id catalogs now return one error type, `IdError`, in place of seven error types and their kind enums. `IdError` stores the rendered message, has no kind enum, and is in `names.rs` beside the segment rules. `GlobalName`, `PackId` and `ToolId` parse through `GlobalName::validate`, which returns only the reason, and each type adds its own prefix to that reason.

- The facade exports `IdError` once, as `runvil::ids::IdError`. `runvil-plugin` re-exports `IdError` in place of `PackIdError`, `PackIdErrorKind`, `ToolIdError` and `ToolIdErrorKind`.
- The `public-api.txt` diff only removes the old types, adds `IdError`, and changes the error type in the signatures of `ModelId::new`, `ModelId::gateway`, `ModelCatalog::new`, `GlobalName::parse`, `PackId::parse`, `ToolId::parse`, `ToolCatalog::new` and the `FromStr` impls.
- Each message text stays the same. `PackIdVisitor` builds its message from the bare reason that `PackId::validate` returns.
- The diff removes `kind()` on the Pack id, tool id and tool catalog errors, `ToolCatalogError::duplicate_id`, and `ParseIdError::input`. The tests now assert the full message or `IdError` equality, and keep each rule they pinned.
- The doc of `flags` on `RunMeta` now says that the field is reserved and that the Harness records 0.
Three failure conditions now use a kind that already covers a related condition. `Error::UnsupportedVersion` maps to `Parse`, `Error::Determinism` maps to `Vfs`, and `Error::ModelRequired` maps to `RequirementsUnmet` in place of `Binding`. The diff removes the `Version` and `Determinism` variants, and `from_tag` still reads their tags so that a stored run keeps its meaning.

- `Binding` now covers only `BindSchema` and `ToolSets`. The docs of `Parse`, `Binding`, `Vfs` and `RequirementsUnmet` name the conditions that they now cover, and the `Vfs` doc keeps the rule that Lua code cannot catch a claim conflict.
- `as_str` never writes `Version` or `Determinism`. The new test `a_retired_tag_parses_to_the_kind_that_absorbed_it_and_no_kind_writes_it` checks both tags.
- The store conflict tests now match `Error::Determinism`, or the `store determinism violation: ` message prefix, together with the `Vfs` kind. So each test still makes a distinction between a claim conflict and a different store failure. The version order test matches `Error::UnsupportedVersion(7)` together with the `Parse` kind.
- The diff changes no message text and no Lua error value. `public-api.txt` loses only the two variants.
The run state now has two parts: the fields that are the same for all chains of a run, and the fields that a chain fork replaces. `SharedState` holds the run-wide fields, `refusal` included, and `ChainState` holds one `Arc<SharedState>` with `reporting`, `args` and `argv`. `ChainState::root` replaces `RunState::new`, and the accessors on `ChainState` forward to `shared`, so the other files change only the type name.

- `SharedState` is private to `context.rs` and does not derive `Clone`. `with_walk_state`, `with_args` and `with_task` clone the `ChainState` and replace only their own fields, so every fork shares one `SharedState`.
- `expose_raw_shims_for_test` and `with_models_for_test` write through `shared_mut_for_test`, which uses `Arc::get_mut` and panics after a clone. The hooks run before the run starts, so no clone exists yet.
- `ChainState` and `SharedState` each have a `Debug` impl, and `ChainState` prints `shared` beside its own three fields.
- The `context.rs` module doc says which type gets a new run-scoped field. The `SharedState` doc no longer says that the model set's default changes during the run.
- The fork test in `context-tests.rs` now checks that each fork shares the `shared` handle, and `new_builds_a_context_over_the_prompt` is now `root_builds_a_context_over_the_prompt`. The behavior does not change, and the facade API listing does not change.
The type holds the caller's inputs for one run, and its module is `config`, so its name now says config. `RunConfig` replaces `RunContext` in the Engine, the `runvil` facade, the Harness runner, `harness-gateway-client`, their tests and benches, the code spans in the `lua` and `types` crates, and the Engine `README.md`. Docs that call the type "the context" or "a context" now say "the config" or "a config".

- Names that exist only for the type follow it: `run_context` is `run_config`, `test_context` is `test_config`, `ContextEdit` is `ConfigEdit`, `context_of` is `config_of`, and both test `Scenario` builders `context` are `config`. Test names that say "context" for the type now say "config". The fixture module `tests::context` keeps its name.
- The `cancel` doc on `SharedState` now says that the caller supplies the flag with `RunConfig::with_cancel` and keeps its own clone. The old doc named a `cancel` on the run, which does not exist.
- The behavior does not change. `public-api.txt` changes on 15 lines, and each change is only the rename.
- Docs that say "context" for `ChainState`, or for a model's context window, do not change.
The `execute` module doc listed `cancel` as a method of the `Run` state machine. `Run` has only `step` and `resume`, so the doc now names those two.
@vinniefalco vinniefalco changed the title Add the model_only_call flag to ToolInfo Add built-in model roles and model_only_call, and slim the run API Nov 1, 2026
Replace the three-file minimum for source subdirectories with a rule that lets a module group use a directory of any size. New code follows the standard module layout and adds no path attribute for layout.

- `AGENTS.md`: existing `#[path = "foo-bar.rs"] mod bar;` siblings stay as they are, and no conversion happens because of group size.
- `AGENTS.md`: a module's unit tests go in `foo/tests.rs`.
The VFS access handle can now read a byte range of a file instead of only the whole file. A ranged read goes through the same policy check, read claim, and read event as a whole-file read, and then asks the backend for the range. The offset and length reach the backend unchanged, so a backend is free to read just that window. A range that runs past the end of the file returns the bytes that exist, and a range that starts past the end returns nothing.

- `crates/runvil/public-api.txt` gains one line for the new method and no existing line changes. The public surface of `Access` grows by exactly one method.
- `Access::read_range` uses the same gate, read claim, and read event calls as `Access::read`, so it is refused, ordered, and reported the same way. It then delegates to the backend's own range read.
- `a_policy_denied_read_range_never_reaches_the_backend` shows a denied range read returns the policy's reason and leaves the backend call log without a read. The event test beside it shows a denied call fires no event and an allowed call fires one read event with the canonical path.
- `a_read_range_takes_a_read_claim` shows that after one identity reads a range, another identity's write to that path conflicts and the message names the read claim. Two identities that both read ranges never conflict.
- `a_read_range_passes_its_offset_and_length_through_unclamped` shows a recording backend receives `(3, 16_777_217)` for that request, so the handle neither clamps the length nor turns the call into a whole-file read. On the memory backend, `hello world` gives `world` for `(6, 5)` and `(6, 100)`, and nothing for `(100, 5)`.
- `Access::read_range` has no caller outside the tests in this change and adds no length limit of its own. Its doc comment says a backend without its own range read reads the whole file and slices it, so the read is bounded only where a backend overrides the range method.

Design: new surface-growth @ crates/runvil-internal/vfs/src/handle/operations.rs::Access::read_range boundary: pub
Plan: vibe/2026-11-01-1-std-read-listdir-grep.md
Read now answers with a numbered window of a text file's lines instead of the not-implemented stub. It goes through the call's file access, so the run's mode and policy apply, and it refuses directories, special files, files over 16 MiB, and binary data. A read that stops early ends with a line that names the offset to continue from, and file text comes back marked untrusted. The error sentences, argument checks, and text decoding it needs live in shared modules so the other file tools can reuse them.

- `fill` replaces `{byte_cap}` and `{file_ceiling}` in the read description from `BYTE_CAP` and `MAX_FILE_BYTES`, so the text the model reads and the limits the code enforces cannot drift apart. `spec` now imports `read` while `read` imports `spec`, which makes the two modules a dependency cycle.
- `definition.segment` selects the tool in the Pack's call path: `"read"` runs the real Read and every other segment still answers the stub.
- `read_file` stats the path, then reads `MAX_FILE_BYTES + 1` bytes through `read_range`, so a file over the ceiling is refused and never truncated. A zero-byte file, or one that holds only a byte order mark, answers `File is empty.`.
- `window` reads an absent offset and `0` as line 1, counts a negative offset back from the end, and picks nothing for a positive offset past the last line.
- `render` stops before the line that would pass the byte cap and ends the read with a trailer that names the offset to continue from. A first line that alone passes the cap is cut at a character boundary through `clip_to_boundary` and printed alone, with a trailer that states how many bytes were kept.
- `decode` drops a UTF-8 mark, decodes a file with a UTF-16 mark and names its byte order, and counts each invalid UTF-8 chunk it replaces with U+FFFD. `sniff_binary` calls a file binary when its first 8,192 bytes hold a NUL or at least 5% control bytes, and it skips that test for a UTF-16 mark.
- `parse` refuses an undeclared key, then a blank path, then a bad `offset`, then a `limit` below 1, and a whole number written as `3.0` reads as that integer.
- `crates/plugin-std/schemas/read.json` changes `offset` and `limit` from `number` to `integer` and drops the `cat -n` and 2000-character lines from the description.
- `NOT_IMPLEMENTED` is still the answer for Write, StrReplace, Delete, Glob, and Grep, which keep the stub.

Design: new surface-growth @ crates/plugin-std/schemas/read.json boundary: wire
Design: new shared-parameter-cluster @ crates/plugin-std/src/check.rs::refuse_unknown deps: Value,str
Design: new pure-function @ crates/plugin-std/src/check.rs::refuse_blank deps: str
Design: new pure-function @ crates/plugin-std/src/check.rs::plural deps: str,usize
Design: new dispatch-on-tag @ crates/plugin-std/src/pack.rs::Std::call
Design: new temporal-coupling @ crates/plugin-std/src/read.rs::call deps: Access,Definition,Value
Design: new pure-function @ crates/plugin-std/src/read.rs::check_type deps: FileType,str
Design: new parameter-object @ crates/plugin-std/src/read/args.rs::Request
Design: new pure-function @ crates/plugin-std/src/read/args.rs::parse deps: Definition,Value
Design: new pure-function @ crates/plugin-std/src/read/render.rs::render deps: Window,[&str],[String],usize
Design: new pure-function @ crates/plugin-std/src/read/render.rs::notes deps: Option<&str>,usize
Design: new value-object @ crates/plugin-std/src/read/window.rs::Window
Design: new pure-function @ crates/plugin-std/src/read/window.rs::window deps: Option<i64>,Option<usize>,usize
Design: new pure-function @ crates/plugin-std/src/refusal.rs::refusal deps: VfsError,str
Design: new pure-function @ crates/plugin-std/src/refusal.rs::neither_file_nor_directory deps: str
Design: new cyclic-dependency @ crates/plugin-std/src/spec.rs::fill deps: str
Design: new pure-function @ crates/plugin-std/src/text.rs::decode deps: [u8]
Design: new value-object @ crates/plugin-std/src/text.rs::Decoded
Design: new pure-function @ crates/plugin-std/src/text.rs::sniff_binary deps: [u8]
Design: new pure-function @ crates/plugin-std/src/text.rs::split_lines deps: str
Design: new pure-function @ crates/plugin-std/src/text.rs::clip_to_boundary deps: str,usize
Design: new flag-parameter @ crates/plugin-std/src/read/tests.rs::numbered_file deps: bool,usize
Deferred: regex is declared in crates/plugin-std/Cargo.toml and no code uses it yet
Plan: vibe/2026-11-01-1-std-read-listdir-grep.md
ListDir stops being a stub. It lists a directory as a bullet tree, one level at a time, so the entries nearest the target come first, and it holds the tree to 15,000 bytes with a note when a listing is cut short. A glob matcher lets a call leave out entries by pattern, and a pattern with no leading slash applies at any depth. Names come back as untrusted output, and Write, StrReplace, Delete, Glob, and Grep still answer the stub.

- `Matcher` takes a pattern with no leading `/` at any depth, as if `**/` came first, and a pattern with a leading `/` as a full path from the root. `Matcher::new` validates once and keeps only compiled tokens, so `is_match` never re-checks, and its sweep is iterative so a long path cannot blow the stack.
- `walk` takes its directory lister as a closure and returns a tree with no text, and `render` turns the tree into text. The cap and the sort therefore run in tests against listers that make link entries the in-memory backend cannot.
- `crates/plugin-std/schemas/dir.json` names the tool ListDir with the parameters `target_directory` and `ignore_globs`, and `fill` replaces `{body_cap}` in its description from `BODY_CAP`, so the text and the limit cannot drift apart.
- `walk` counts each line's indent, bullet, name, slash, and newline against the cap and stops at the first line that would pass it. Each directory shown but not expanded ends in `...`, and `render` ends a cut tree with a `    ...` line, a blank line, and the too-large note, none of which count against the cap.
- `walk` returns the error for any listing failure below the root except `NotFound` and `NotADirectory`, which show the directory with no children. A conflict or a policy refusal therefore fails the call instead of showing a locked directory as empty.
- `visible` drops every entry named `.git`, file or directory, at any depth and lists every other dotfile. It sorts each listing by name with `/` added to a directory's name, because the lister promises no order.
- `is_ignored` matches each pattern against the entry's absolute path with a leading `/` added and no trailing `/`, and a matching directory is not entered.
- `check_directory` lets a directory or a link named as the target go on to the walk and answers every other kind of node with the not-a-directory sentence. Below the target, `walk` lists a link by name and never enters it.
- `parse` refuses an undeclared key first, then a blank target, then the first bad pattern, all before any path is looked at, and `invalid_pattern` echoes the pattern cut to 80 bytes on a character boundary.
- `crates/plugin-std/schemas/EDITS.md` records that the definition is this crate's own and not a Cursor snapshot, and that the extension-count summaries, the 5-second timeout, the terminal-log case, and the `Error listing directory:` prefix of Cursor's `LS` are dropped.
- `stub` still answers for Write, StrReplace, Delete, Glob, and Grep.

Design: new surface-growth @ crates/plugin-std/schemas/dir.json boundary: wire
Design: new pure-function @ crates/plugin-std/src/check.rs::invalid_pattern deps: PatternError,str
Design: new temporal-coupling @ crates/plugin-std/src/dir.rs::call deps: Access,Definition,Value
Design: new pure-function @ crates/plugin-std/src/dir.rs::check_directory deps: FileType,str
Design: new parameter-object @ crates/plugin-std/src/dir/args.rs::Request
Design: new pure-function @ crates/plugin-std/src/dir/args.rs::parse deps: Definition,Value
Design: new pure-function @ crates/plugin-std/src/dir/render.rs::render deps: Tree
Design: new pure-function @ crates/plugin-std/src/dir/render.rs::line deps: Node,usize
Design: new oversized-unit @ crates/plugin-std/src/dir/walk.rs::walk deps: F,[Matcher],str,usize
Design: new swallowed-exception @ crates/plugin-std/src/dir/walk.rs::walk deps: F,[Matcher],str,usize
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::is_gone deps: VfsError
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::visible deps: Vec<Entry>,[Matcher],str
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::is_ignored deps: [Matcher],str
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::line_cost deps: bool,str,usize
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::join deps: str
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::directory_path deps: str
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::virtual_path deps: str
Design: new pure-function @ crates/plugin-std/src/dir/walk.rs::root_line deps: str
Design: new encapsulated-invariant @ crates/plugin-std/src/matcher.rs::Matcher
Design: new oversized-unit @ crates/plugin-std/src/matcher.rs::Matcher::is_match
Design: new oversized-unit @ crates/plugin-std/src/matcher.rs::validate deps: str
Design: new pure-function @ crates/plugin-std/src/matcher.rs::validate deps: str
Design: new pure-function @ crates/plugin-std/src/refusal.rs::missing_root deps: str
Design: new pure-function @ crates/plugin-std/src/refusal.rs::not_a_directory deps: str
Design: extends dispatch-on-tag @ crates/plugin-std/src/pack.rs::Std::call
Design: extends dispatch-on-tag @ crates/plugin-std/src/spec.rs::fill deps: str
Plan: vibe/2026-11-01-1-std-read-listdir-grep.md
Grep stops being a stub: it searches the files under a path with a Rust regular expression and prints each matching line with its number and optional context lines. It goes through the call's file access, so the run's mode and policy apply, and file text comes back marked untrusted. Files come in the order Glob uses, output stops at 15,000 bytes, and a call with an offset pages through the same result. A binary file, a link, an over-size file, and an entry that cannot be read are skipped and counted, never a failure, and a long search hands control back to the executor now and then so a cancel can drop it. Write, StrReplace, Delete, and Glob still answer the stub.

- `Source` puts stat, list, read, and a bounded read behind a trait that `Access` implements. A test fake supplies a link, a control-character name, an over-size entry, a denied read, and a claim conflict that the in-memory backend cannot make.
- `find` returns a `Found` and writes no text, and `render` turns it into the answer and reads nothing.
- `sorted` moves into `crates/plugin-std/src/order.rs` with the skipped name `GIT`, and ListDir's `visible` now calls it, so ListDir and Grep visit entries in one order.
- `Pause` wakes its own task and returns `Pending` once, then `Ready`, and names no async runtime. `pace` awaits it after every 32 files considered or 2 MiB read, so a cancel or a stop can drop the search.
- `Limits` holds the file, byte, size, slice, and body caps and the line window, each with its constant as the default, so tests lower any of them.
- `crates/plugin-std/schemas/grep.json` declares Cursor's bundle parameters plus `literal`, drops `-n` and `context`, makes `content` the default `output_mode`, and gives `head_limit` no default, so leaving it out means no limit. `-n: true` is still accepted, and any other `-n` value or a `context` key is refused.
- `parse` checks the arguments in a fixed order, so a call that breaks two rules always hears about the same one, and it compiles the pattern before any path is looked at. `head_limit` and context values round up, and `offset` rounds down.
- `Admission` counts entries across all files: the first `offset` are only counted, the next `head_limit` are kept, and one more is counted to show the limit was reached. `keep` stores each kept line as the text the answer shows, so no decoded file is held after it is searched.
- `root_file` searches a file the model named with no glob or type filter, and `link` searches a named link as a directory and then as a file.
- `match_text` shows a line over 1000 bytes as a window that starts up to 200 bytes before the first match, with a marker on each cut side that counts the bytes left out. Both window cuts go through `clip_to_boundary`, so none splits a code point.
- `cut` stops the body before the line that would pass the cap, and a cut inside a file's lines ends at the last match or its own trailing context, so no path, gap, or leading context dangles.
- `ToolOutput::trusted` is returned only for a zero-match result with no replacement note and no skip line. Every other answer is `ToolOutput::untrusted`.
- `list_find_read_a_window_and_read_the_next_one_by_the_trailer_offset` runs ListDir, Grep, and Read over a directory mounted with `RealBackend::rooted`, and reads the second window from the offset the first window's trailer names.
- `paging_notice` keeps no cache behind it: each call runs from the start, so a write between two pages shows in the second. A search a limit ended gets `limit_notice` and no offset, because a re-run stops at the same place.
- `IGNORE_NOTE` tells a model that a zero-match result ran no ignore rules: only entries named `.git` are skipped, and `.gitignore` is not applied, so `target/` and `node_modules/` are searched under the path.

Design: new facade @ crates/plugin-std/src/grep.rs::call deps: Access,Definition,Value
Design: extends dispatch-on-tag @ crates/plugin-std/src/pack.rs::Std::call
Design: new parameter-object @ crates/plugin-std/src/grep/args.rs::Request
Design: new parameter-object @ crates/plugin-std/src/grep/found.rs::Limits
Design: new pure-function @ crates/plugin-std/src/grep/render.rs::render deps: Found,Limits,Request
Design: new pure-function @ crates/plugin-std/src/grep/window.rs::match_text deps: Window,str,usize
Design: new pure-function @ crates/plugin-std/src/grep/window.rs::context_text deps: Window,str
Design: new flag-parameter @ crates/plugin-std/src/grep/args.rs::count deps: bool,f64
Design: new surface-growth @ crates/plugin-std/schemas/grep.json boundary: wire
Design: new schema-change @ crates/plugin-std/schemas/grep.json boundary: wire
Plan: vibe/2026-11-01-1-std-read-listdir-grep.md
Plan: vibe/2026-11-01-1-std-read-listdir-grep.md
A directory that exists only because a mount sits beneath it now exists, stats as a directory, and lists the mount's next name. Workshop uses this to map a local directory read-only under `/_runvil` in each run, behind a switch in one file that is off. The chat agent now declares and offers the `std` file tools.

- `mount_children` finds the next path segment of every mount below a path. `stat`, `exists`, and `list` use it, so a parent of a mount lists the mount once, in name order, beside what its backend holds.
- `mount_at` overlays a directory read-only at an absolute virtual path and keeps the base filesystem when the directory cannot be mounted. `run_vfs` calls it for one fixed directory only when `ENABLED` is true.
- `chat.md` declares `std`, offers `user-input`, `std`, and `web`, and holds a commented-out `messages:system` line that names `/_runvil/language-guide`. Its intro now says the agent has one system message.
- `a_prompt_sees_the_directory_mapped_under_the_runvil_mount` runs a prompt over a mapped temporary directory and reads its page with `std/dir` and `std/read`. The other tests in that module also use a temporary directory, so no test needs the guide's files or a path on one machine.
- `role_content_pairs` skips leading system messages and `compacting_completions` does not count them. The chat gate tests then pass with the system line on or off.
- `gate_a_conversation_round_advertises_the_file_tools` is new. No test covers a mapping with `ENABLED` set to true, and none covers the system line.
@vinniefalco
vinniefalco merged commit 331a293 into cppalliance:master Nov 1, 2026
11 checks passed

This branch was previously deployed

1 inactive deployment
github-pages — 331a2933 Deployed Nov 1, 2026 by vinniefalco via deploy #155
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant