Repository navigation
Add built-in model roles and model_only_call, and slim the run API - #188
Merged
Merged
Conversation
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.
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
temporarily deployed
to
github-pages
November 1, 2026 12:29 — with
GitHub Actions
Inactive
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This started as one commit, the
model_only_callflag. It now holds 29, in seven groups and one housekeeping commit, in branch order:model_only_callflag (1 commit): a Pack can keep a tool in the catalog while a script'stools.callrefuses it.models.usenow lasts for the rest of the chain.modelsfunctions are the one way to run a model.cf572c063moves the October plan files undervibe/2026-10/, adds a line tocrates/plugin-std/AGENTS.mdthat each tool's source goes in its own directory named after the tool's Lua name, and shortens the doc link incrates/runvil-internal/engine/src/execute/error.rsthat failed thedocsjob.4cec10f24replaces the three-file minimum for source subdirectories inAGENTS.md.stdPack (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.331a2933bmakes 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 thestdtools.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 intovibe/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.txtloses 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_callflag toToolInfoA 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:
tools.getreturns a tool object.tools.offeraccepts it, alone or with its Pack.falsemeanstools.callrefuses it and the model may still call it.How it works:
ToolInfo::model_only_calland its builder sit besidestructured_output,survives_stop, anddescribes_toolsincrates/runvil-internal/types/src/tools/info.rs. It has#[serde(default)], so a run log written before this change still loads.ToolInfo::newleaves it false, so every existing tool behaves as before. The key is always serialized, so a recorded run shows the flag.tool_routeincrates/runvil-internal/engine/src/execute/scheduler/tool_call.rsrefuses 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.Error::ModelOnlyToolCall { name, id }, kindRunErrorKind::Tool. It reaches Lua as an error table taggedmodel_only_tool, so a prompt canpcallit and tell "model-only" apart from "no such tool".bindincrates/runvil-internal/engine/src/execute/scope.rscopies the catalog's value onto every rendered tool with a catalog id, so a render cannot clear a declaration its own Pack made.model_only_callas 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 calledmodels.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.ModelRoleis a closed type withDefault,Strong,Small,Fast, andCreative, in the fixed order ofModelRole::ALL. It is#[non_exhaustive], reads only its lowercase names, and the error for any other name lists all five.RunConfig::with_modelsone map from role to model. A role with no entry uses thedefaultentry. An empty map means the caller has no model, so a model call raisesModelRequiredand a prompt that never calls a model still runs. A map with entries but nodefaultentry is refused before the run starts, with a notice that the caller's role map has no default (RequirementCheck::Unbound).fill_model_setbuilds 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 statemin_contextandthinking. 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 withModelKeyword,ModelRoles, andModelRolesIter.Frontmatter::role_requirementsis the one accessor.thinking: truefails against a model that never thinks,thinking: falseagainst one that always does, and a declared value sets the thinking switch on that role's requests only.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.defaultis deleted. Every section starts on thedefaultrole's model, or on no model when the caller gave none.models.usetakes a role name or a handle and applies its options on top of the handle's own settings.models.getreturns a role's handle without selecting it. The newmodels.currentreturns the handle for the section's model.ModelBindingdrops 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.ModelDtoreports each declared role as its name, minimum context, and thinking requirement, and the run panel shows them as text.parseModelrejects 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.
defaultrole'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.callchild 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.Chainowns the selection its section VMs share, andChainStartholds the model it starts on.tasks.fanouthas 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.inferandmodels.looptake an optional leading handle, and the compactor ofmodels.loopstays optional.handle_method,HANDLE_METHODS_REGISTRY, and thehandle_methodstable are deleted, andLOOP_ARITYnow readsmodels.loop takes (compactor?) or (model, compactor?).Check a run's start conditions in its first step
A
Runalways holds a boxedScheduler, so a run that cannot start needs no state of its own. On the firststep,check_startchecks therunvil:version, then probes the declared store, then checks the prompt's requirements. A failed check ends the run and reports no run events. This replacesStart,Stillborn,Spent,prepare_state, and the refusal step the caller used to run before it built the run.RunConfig::with_modelsreplacesprepare.with_packstakes one map ofRosterEntryvalues, and the Harness reports each Pack's status through the roster.Requirements,UnmetRequirement,RequirementCheck,MissingService,UsablePack, andUnavailablePackleave the facade. The facade exportsrunvil::packs::RosterEntry.with_packscall checks no declared Pack. A run with an empty roster finds each declared Pack missing.Cancelled. An unsupported version and an unmet requirement together report the version failure first.Use one outcome for the Engine and the Harness
Step::Doneholds a closedRunOutcome:Completed,Failedwith aRunErrorKindand a message, orCancelled. The Harness records the outcome the Engine returns and no longer converts it, soeffect_loop-outcome.rsis deleted.RunResult,RunError, andSourceLocationleave the facade.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::runbegins 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 asCancelled, and a failed lookup or a stall ends asFailed.HarnessErrorkeeps onlyRecorder,DriveErroris gone, andRunReport::run_idis aRunId, no longer anOption.withdrawn. The effect loop answersDroppedfor each one without performing it.Run::decided,Run::cancel, andRun::cancel_handleleave the facade, and the caller cancels through the flag it givesRunConfig::with_cancel.Parsefor a parse failure,RequirementsUnmetfor an undeclared or missing input file, andVfsfor a store failure on an input. The Harness wroteInputfor all three.Delete facade surface that nothing uses
EffectRecord,AnswerRecord,ChatAnswerRecord,ToolAnswerRecord, andFailureRecordarepub(crate).Effect::recordandEffectAnswer::recordreturn the record as a JSONValue, which is the value the Harness stored before.Flags,ReplayError, thereplaymodule,RunUid,with_flags, theRunConfiggettersvfs,tools,seed,started_at, andflags, the eventsTaskResumedandTaskNote,ToolErrorKind::Cancelled, and theis_retryablemethods.IdErroris 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.RunErrorKindgoes from 12 kinds to 10.UnsupportedVersionmaps toParse,DeterminismtoVfs, andModelRequiredtoRequirementsUnmet.from_tagstill reads the retiredVersionandDeterminismtags, so a stored run keeps its meaning, andas_strnever writes them.Split the run state and rename
RunContextRunStatebecomesSharedState, which holds the run-wide fields behind oneArc, andChainState, which holds thatArcplus the fields a chain fork replaces (reporting,args, andargv). Every fork shares oneSharedState.RunContextis renamedRunConfigin 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 isconfig.HostRunContextin the Harness runner is a different type and keeps its name. The last commit fixes theexecutemodule doc, which listedcancelas a method ofRun.Loosen the source layout rule
AGENTS.mdsaid a source subdirectory must hold at least three files, and that a smaller group sits beside its parent asfoo-bar.rsfiles wired with#[path = "foo-bar.rs"] mod bar;. The repository had about 250 of those attributes insrc/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.rsbesidefoo/bar.rs), a directory may hold any number of files, a module's unit tests go infoo/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
stdfile tools realThe
stdPack shipped as six stubs that all answered one fixed text. This branch makes Read and Grep real and adds a seventh tool, ListDir (Lua idstd/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 asread(gate, read claim, event, backend) and passes the range to the backend's ownread_range. It is the one new facade method, sopublic-api.txtgains one line.RealBackendreads only the bytes asked for, and a backend that does not override it reads the whole file and slices it.bea66707c) numbers each line as1|text, caps its output at 100,000 bytes on a line boundary, and ends a cut read with a trailer that names the nextoffset. It stats the file first, reads at most 16 MiB throughread_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.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 isListDir, not Cursor's deprecatedLS.f7472905c) uses the Rustregexcrate and aliteralflag, walks in path order, and pages withhead_limitandoffset. 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.crates/plugin-std/src/:refusal.rswords everyVfsErrorthe same way for all tools,text.rsholds decode, binary sniff, line split, andclip_to_boundary, andmatcher.rsis a glob matcher where only*and**are special and a relative pattern matches at any depth.clip_to_boundaryis the only function that shortens text inside a line, so no cut splits a UTF-8 code point.plugin-stdruns 386 tests, and its yield test countsPendingpolls over 1,000 seeded files. A real-directory test incrates/workshop/server/tests/it/std_real_directory.rsmounts a directory withRealBackend::rootedand runs the list, find, read, and read-the-next-window flow through the Pack. It lives inworkshop-serverbecause a Plugin crate may not depend on the vfs crate.94e6a172bcloses 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/_runvilgot "Path does not exist", though/_runvil/language-guideworked. 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 ismount_childreninrouter-access.rs, with three tests inrouter-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/_runvilin each run, so a prompt can read the language guide with the file tools. The directory is a path on one machine, soENABLEDisfalse, 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.mddeclaresstd, offersuser-inputandstd, and holds a commented-outmessages:systemline 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 onlydefault,strong,small,fast, andcreative, each withmin_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 callsmodels.useruns on thedefaultrole.models.inferormodels.loopas the first argument.chatagent moved to the new keys, andgreet.mdandhello.mdlost theirmodels:block.For code that embeds the Engine or the Harness:
RunContextisRunConfig,prepareiswith_models, andStep::Doneholds aRunOutcome.Access::read_range.crates/runvil/public-api.txtloses 327 lines and gains 73.For recorded runs and Workshop:
ToolInfonow always serializesmodel_only_call, so arenderanswer record shows"model_only_call":false. A line without the key still reads as false.VersionandDeterminismtags asParseandVfs. It reads a stored tag it does not know, such asInput, asInternalwith the stored message.begin_runandend_run.stdand offersuser-input,std, andweb, so its model can call Read, ListDir, and Grep, and can ask the operator a question.What this does not do
default(crates/harness-internal/runner/src/prepare.rs), sostrong,small,fast, andcreativeall resolve to that model. Only an embedder that builds its own map can fill them differently.cppalliance/runvil-docs, not in this PR. Its model chapters match this branch inrunvil-docscommit1b99039: 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.crates/plugin-stdsetsmodel_only_call.ModelRolehas noFromStrorDisplay, because the serde error already names the five roles.stdare still stubs, and the read record that Write will need is not built..gitand apply no.gitignore, so a real repository'starget/andnode_modules/are searched. A shared ignore module comes later.ENABLEDset totrue.How this was verified
CI at
331a2933b, the head of this branch: every job passes. They areapi-surface,check-workshop,check-workshop-linux,ci-green,clippy,docs,fmt,msrv,supply-chain,test, andui.CI at
cf572c063, before the file tools: every job passed too. They areapi-surface,check-workshop,check-workshop-linux,ci-green,clippy,docs,fmt,msrv,supply-chain,test, andui. Thedocsjob had failed at3527ad195onrustdoc::redundant_explicit_linksin the doc ofRunErrorKind, where[`RunOutcome::Failed`](super::RunOutcome::Failed)repeated its own target, andcf572c063fixes 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, andcargo 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-docsbuilds withcargo xtask sitein its books-only mode, withRUNVIL_DOCSpointing at the clone (mdBook 0.4.44). A link check over the guide finds only the twoindex.mdfiles 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 ofModelRole::ALL, the round trip throughas_str, and areviewerrole that the error rejects with all five names.a_run_that_cannot_start_reports_its_failure_on_the_first_step.a_retired_tag_parses_to_the_kind_that_absorbed_it_and_no_kind_writes_it.every_kinds_public_record_is_json_that_round_trips_exactly.tools.callby id and by tool object both fail withModelOnlyToolCall,pcallcatches it,tools.offerstill accepts the tool, and a model's call of it reaches aToolCalleffect.Reviewer notes
RunContexttoRunConfigrename touches many of them.4f547a6b9(the five roles) to7075dfc4c(the model-first fanout), so review them in order.vibe/2026-10-31-2-built-in-model-roles.mdholds that plan, andd4a6e3f3ccloses it.07c1cbe7a,bea66707c,316a76f0b, thenf7472905c.vibe/2026-11-01-1-std-read-listdir-grep.mdholds that plan, and94e6a172bcloses it.public-api.txtdiff in each commit shows the exact surface.