Repository navigation
Collapse tool offering into tools.offer and define Lua tools with tools.adhoc - #170
Merged
Merged
Conversation
Plugin tests can now build a direct tool call that appears to come from a model round instead of the section's script. A call still comes from the script by default, so existing tests keep their behavior. This lets a Pack test output that depends on who made the call. - `TestCall::with_caller` rebuilds the origin with `ToolCallOrigin::new`, keeping execution `plugin-test` and section `Test`. It is public within the `test-support` module and returns the call by value like `with_services`. - `a_test_call_with_a_caller_lends_that_caller` asserts that a call built with `ToolCaller::Model` lends that caller, section `Test`, and the given tool. Design: new surface-growth @ crates/runvil-plugin/src/testing.rs::TestCall::with_caller boundary: pub Plan: vibe/2026-10-21-1-mcp-script-raw-text.md
Both MCP tools now decide output trust from who made the call. A call from the section's own script gets the server's text marked trusted, so the script receives it without the untrusted envelope, while a model round or any other caller still gets untrusted text. The rendering path below the dispatch now returns plain text, so trust is decided in one place. - `marked` maps `ToolCaller::Script` to `ToolOutput::trusted` and every other caller, through a `_` arm, to `ToolOutput::untrusted`; `Mcp::call` reads `cx.origin().caller` once and applies it to both tools. - `render`, `output`, `send`, `call`, `call_tool`, and the `AbortOnDrop` join handle now carry `Result<String, ToolError>` instead of `ToolOutput`, so the result module no longer sets trust. - `invoke` now calls as `ToolCaller::Model` and still asserts untrusted output, so the existing tests keep pinning the model path; `invoke_as_script` returns the whole output for the new script-path tests. - `a_script_calls_is_error_result_is_the_same_backend_error_as_a_models` pins that a script call's `isError` result stays the same `Backend` error with the message `it failed`. - `lib.rs` replaces the invariant that every output is untrusted with the per-caller rule and states the same rule beside the two tool descriptions. Design: new pure-function @ crates/plugin-mcp/src/dynamic.rs::marked deps: String,ToolCaller Design: new surface-growth @ crates/plugin-mcp/src/dynamic.rs::Mcp::call Design: new pure-function @ crates/plugin-mcp/tests/it/support.rs::tool_call deps: str Plan: vibe/2026-10-21-1-mcp-script-raw-text.md
close cppalliance#167 Plan: vibe/2026-10-21-1-mcp-script-raw-text.md
A tool that a prompt author writes as a Lua function is now an adhoc tool, not a local tool, everywhere in the engine and the Lua crate and in the doc comments that describe tool events. The rename reaches types, functions, fields, test names and files, the yield op and handler registry key that the Lua shim and the Rust driver share, and every comment that described the concept. Those shared names cross crate lines, so the whole rename lands as one change. Behavior stays the same apart from the wording of the error a prompt sees when a tool handler tries to jump to another section. - `AdhocToolOutcome`, `ToolCallOutcome::Adhoc`, and `Request::AdhocToolDone` replace their local-named forms in the Lua crate's public exports, and `SectionVm` now answers `adhoc_tool_schemas` and `has_adhoc_tool`. Their engine callers move to the new names in this commit. - `adhoc_tool_done` is the op the Lua shim and the loop step yield once a handler has run, and the Rust request parser matches that op under the same name. The handler table lives under the registry key `runvil.tools.adhoc_handlers`, which `ADHOC_HANDLERS_REGISTRY` names for both its writer and its reader. - `tests/adhoc_tools.rs` and `tests/tool_call_arm-adhoc-handlers.rs` replace `local_tools.rs` and `tool_call_arm-local-handlers.rs`, wired through their `mod` lines and `#[path]` attribute. Inside them only names, comments, and assertion strings change. - `install_jump_global` now refuses a jump inside a handler with `jump is unavailable inside an adhoc tool handler`, followed by the same advice to return a value and jump after the tool call. The shim test's `find` string and `JUMP_REFUSAL` assert the new wording. - `AdhocTools` reports a poisoned lock as `adhoc tools registry was poisoned`, and `adhoc_handler` reports a missing handler as `an adhoc tool answer names a registered handler`. Both are internal-invariant errors, and their tests assert the new text. - `tools.offer_local` keeps its name, as do `install_offer_local`, `local_params_schema`, and the errors that begin `tools.offer_local`, so prompt scripts register tools as before. - `ToolCallEvent` and the tool-result event change only in doc comments: their `tool` field is still `None` for an adhoc tool, under the same serde attributes. Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::dispatch_adhoc_tool_done was: crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::dispatch_local_tool_done Design: replaces temporal-coupling @ crates/runvil-internal/lua/src/coro.rs::adhoc_handler_captures deps: Arc<AtomicU32>,Lua was: crates/runvil-internal/lua/src/coro.rs::local_handler_captures Design: replaces shared-mutable-state @ crates/runvil-internal/lua/src/coro.rs::adhoc_handler_captures deps: Arc<AtomicU32>,Lua was: crates/runvil-internal/lua/src/coro.rs::local_handler_captures Design: replaces pure-function @ crates/runvil-internal/lua/src/protocol/parse/tools.rs::parse_adhoc_tool_done deps: mlua::Table was: crates/runvil-internal/lua/src/protocol/parse/tools.rs::parse_local_tool_done Design: replaces hidden-dependency @ crates/runvil-internal/lua/src/tools.rs::adhoc_handler deps: Lua,str was: crates/runvil-internal/lua/src/tools.rs::local_handler Design: replaces shared-mutable-state @ crates/runvil-internal/lua/src/vm.rs::AdhocTools was: crates/runvil-internal/lua/src/vm.rs::LocalTools Design: extends surface-growth @ crates/runvil-internal/lua/src/vm/install.rs::SectionVm::install_jump_global boundary: pub Plan: vibe/2026-10-23-1-one-offer-api.md
Scripts can read a catalog tool's parameter schema from its tool object. Tool-call and tool-result events for a catalog tool now record the vendor of the Pack that answered, as the caller reported it, so readers can tell runvil's tools from another vendor's tools with the same id, and the Workshop shows its ask, search, and fetch views only for runvil's tools. A tool that lists other tools hands its listing to a calling script as plain text the script can parse, while a model's call still receives the untrusted envelope. A section's own tool whose function fetched such a listing during its call returns its result wrapped and untrusted, so wrapping a listing tool in a section's own tool gives the model the same envelope as calling it directly. The Pack id that holds a section's own tools is reserved, and the plugin registry, the prompt parser, and run preparation each refuse it. - `UsablePack` pairs a usable Pack id with its vendor as a plain string, and `RunContext::packs` takes it in place of a bare Pack id, so every caller of the facade changes. - `vendors` keeps each usable Pack's vendor in a second map beside the roster, filled by the same call. `catalog_binding` stamps each catalog binding from it, and `from_info` leaves the vendor unset. - `Advertised` holds the tool id and vendor for each advertised name in the rendered dispatch map, still `None` for an adhoc tool, and the round's tool-call events take both from it. - `AskTool` holds the Workshop's ask as vendor `runvil` plus id `user-input/ask`, the same pair `Advertised` holds, in a separate server-side type. - `mark_listing` records a listing on the innermost open adhoc call of the chain's current frame and marks nothing when no call is open. `accept_tool_call` now takes `&mut self` to set it when a script's dispatch reports a listing. - `InstallError::AdhocPackId` is its own variant, checked after the duplicate check and before the built-in reservation, because `ReservedName`'s message names a built-in Pack that does not exist for this id. - `reserved_adhoc` carries the run-level refusal through the preflight report, set when a catalog tool or any roster Pack, usable or not, has the id. It merges with or, blocks satisfaction, and writes one notice line, so the run fails with the usual unmet-requirements error. - `prepare_dispatch` passes untrusted output verbatim, still marked untrusted, only for a script's call to a binding that describes tools. Trusted output from such a tool passes as before and reports no listing, and a model's call still gets the envelope. - `AdhocToolOutcome::Returned` wraps a marked adhoc call's text with the run's nonce and reports it untrusted. A failed listing call or a trusted listing marks nothing, and a listing fetched before the adhoc call opened and then returned by it reaches the model unwrapped with `trusted` true. - `mcp/get_dynamic_tools` is the only tool in the mcp Pack that sets `describes_tools`. - `toolKind` takes a vendor-qualified name and matches only `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch`, so another vendor's same id, a call without a vendor, and an adhoc `ask` are ordinary tools. Labels still read the bare tool id. - `from_event` frames a script's result as the operator's message only when both its vendor and its tool id match the ask. - `roster` in the runner takes each usable Pack's vendor from the first segment of the FQN the run picked, such as acme for a declared `acme/kit`. - `PackListError::Adhoc` refuses `adhoc` from any vendor under `uses:` as a frontmatter parse error, checked after the duplicate check. - `parameters` converts a tool object's schema into a fresh Lua table on every read, with empty lists kept as arrays and nulls read as `json.null`. Assigning it raises, and its text never takes the untrusted envelope. - `vendor` is optional and omitted when absent on both event kinds, so a log written without it reads back as naming no vendor, and a run given no roster records none. - `LuaToolHandle` exposes exactly `id`, `description`, `parameters`, and `pack`, so scripts never see a tool's vendor or listing flag. Design: extends surface-growth @ crates/runvil-internal/types/src/metrics.rs::ToolCallEvent boundary: persisted Design: extends surface-growth @ crates/runvil-internal/types/src/event.rs::Event::ToolResult boundary: persisted Design: new surface-growth @ crates/runvil-internal/types/src/tools/info.rs::ToolInfo boundary: pub Design: new surface-growth @ crates/runvil-internal/engine/src/execute/requirements.rs::UsablePack boundary: pub Design: new stringly-typed @ crates/runvil-internal/engine/src/execute/requirements.rs::UsablePack Design: new surface-growth @ crates/runvil-internal/engine/src/execute/requirements.rs::Requirements boundary: pub Design: new stringly-typed @ crates/runvil-internal/engine/src/execute/config.rs::RunContext::prepare Design: new surface-growth @ crates/runvil-plugin/src/registry.rs::InstallError boundary: pub Design: new surface-growth @ crates/runvil-internal/lua/src/tools/userdata.rs::LuaToolHandle boundary: pub Design: new parallel-abstraction @ crates/workshop/server/src/agents/wire.rs::AskTool Design: new pure-function @ crates/runvil-internal/engine/src/execute/context-bound.rs::catalog_binding deps: RunContext,ToolInfo,str Design: new pure-function @ crates/runvil-internal/parser/src/contract.rs::check_no_adhoc_pack deps: PackRef Design: new pure-function @ crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts::qualifiedTool deps: ToolCallRow Plan: vibe/2026-10-23-1-one-offer-api.md
A section now offers tools and Packs through one offer verb that takes ids or objects, checks each offer when it is made, and renders the remembered offers in call order at the first model loop round. Lua-defined tools get their own definition call, which gives each one an id under an always-present adhoc Pack and registers it without offering it. A read-only Pack list on the tools table replaces the separate Pack global and the required and extras lists. Model calls route through the section's rendered list while scripts name tools by id, so adhoc calls count and report under their ids. A dynamic twin of the offer verb records a second list beside the rendered one that nothing consumes yet. - `ToolRuntime` holds one ordered list of `Offer` values, each a tool id or a Pack under a static or dynamic mode, and `add_tool` and `add_pack` skip an offer equal to one already held. No offer holds a wire name. - `expanded` is the one pass behind `current_tool_bindings` and `current_dynamic_tools`, so both lists agree on what an offer names, and a Pack offer expands to its tools at render. The static pass, not `prepare_scoped_tools`, drops an offered catalog tool whose wire name an offered adhoc tool shares. - `AdhocTools` moves to its own module and becomes public, holding one `ToolBinding` per tool, and finds a tool by id when the name holds a `/` and by wire name otherwise. - `Offering` bundles the tool set, the offers, the adhoc tools, and the conversation, so one `install_offer` body installs both offer verbs. - `tools.packs` is an empty proxy whose protected metatable serves integer keys, `#`, `pairs` in name order, `get`, and `extras`, and refuses every write. The always-present adhoc Pack reads the VM's adhoc tools on each `tools` read. - `tool_route` takes the call's `CallSite`: a model call resolves its wire name through the current section's rendered list, never the chain's advertised map, and a script call routes by id. The adhoc arm moves to `tool_call-adhoc.rs`. - `tools.adhoc` gives a tool the id `adhoc/` plus its lowercased name, keeps the name as its wire name, checks attrs, name, and schema on the defining line, and returns the tool object without offering it, before or after the render. A `parameters` table whose `type` is `object` converts through `to_json`, which `json.encode` also uses. - `tools.offer` and `tools.offer_dynamic` take one tool id, Pack id, tool object, Pack object, or array of them read through `__len` and `__index`, with the fixed-list refusal first and nothing recorded when any entry fails. An unknown Pack name suggests the id of a tool with that wire name. - `tool_name` refuses a string without a `/`, so a script call by bare name raises before resolution, while a model-issued call decodes its name with `model_tool_name`. - `dispatch_adhoc_tool_done` reports an adhoc result under the tool's id with no vendor, adhoc calls count under that id, and an unseeded adhoc id in `tools.calls` gets its own hint. - `RESERVED_NAMES` drops `packs`, going from 63 to 62 entries: the `packs` global, `tools.required`, `tools.extras`, and `tools.offer_local` are gone. - `crates/workshop/agents/agents/chat.md` offers the `web` Pack and then the extras through `tools.offer`. - `RenderedTools` gains `dynamic`, filled at the first render, while the round still sends only the static schemas, so dynamic offers change nothing the model sees. - `rendered_target` finds nothing before the section's first render, so a model-issued call made then routes as a catalog call and never reaches an adhoc tool. Design: new value-object @ crates/runvil-internal/lua/src/scope.rs::Offer boundary: pub Design: new speculative-abstraction @ crates/runvil-internal/lua/src/scope.rs::OfferMode boundary: pub Design: new shared-parameter-cluster @ crates/runvil-internal/lua/src/vm/state.rs::current_dynamic_tools deps: AdhocTools,Mutex<ToolRuntime>,ToolBinding,ToolSet boundary: pub Design: new shotgun-surgery @ crates/runvil-internal/lua/src/vm/state.rs::current_tool_bindings deps: AdhocTools,Mutex<ToolRuntime>,ToolSet boundary: pub Design: replaces shared-mutable-state @ crates/runvil-internal/lua/src/vm/adhoc.rs::AdhocTools boundary: pub was: crates/runvil-internal/lua/src/vm.rs::AdhocTools Design: new stringly-typed @ crates/runvil-internal/lua/src/vm/adhoc.rs::AdhocTools::binding boundary: pub Design: new parameter-object @ crates/runvil-internal/lua/src/tools/offer.rs::Offering Design: replaces surface-growth @ crates/runvil-internal/lua/src/tools/offer.rs::install_offer deps: Lua,OfferMode,Offering,Table,str boundary: pub was: crates/runvil-internal/lua/src/tools.rs::install_offer Design: replaces surface-growth @ crates/runvil-internal/lua/src/tools.rs::install_adhoc deps: AdhocTools,Lua,Table boundary: pub was: crates/runvil-internal/lua/src/tools.rs::install_offer_local Design: replaces surface-growth @ crates/runvil-internal/lua/src/packs.rs::install_pack_list deps: Lua,PackNames,Table boundary: pub was: crates/runvil-internal/lua/src/packs.rs::install_packs Design: replaces surface-growth @ crates/runvil-internal/lua/src/tools/objects.rs::install_tool_get deps: Lua,Table boundary: pub was: crates/runvil-internal/lua/src/tools/objects.rs::install_tool_lists Design: new hidden-dependency @ crates/runvil-internal/lua/src/tools/objects.rs::insert_tool_object deps: Lua,ToolBinding Design: new temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::tool_route instead-of: shared-mutable-state: routing through the chain's shared advertised map Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs::Scheduler::dispatch_adhoc_tool_done was: crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::dispatch_adhoc_tool_done Design: new pure-function @ crates/runvil-internal/lua/src/packs.rs::is_adhoc deps: PackId Design: new pure-function @ crates/runvil-internal/lua/src/packs.rs::position deps: Value Design: new pure-function @ crates/runvil-internal/lua/src/tools/decode.rs::model_tool_name deps: Value Design: new pure-function @ crates/runvil-internal/lua/src/tools/decode.rs::object_id deps: Value Design: replaces pure-function @ crates/runvil-internal/lua/src/tools/decode.rs::adhoc_params_schema deps: mlua::Table was: crates/runvil-internal/lua/src/tools/decode.rs::local_params_schema Design: new pure-function @ crates/runvil-internal/lua/src/tools/offer.rs::wrong_type deps: Option<usize>,Value,str Design: new pure-function @ crates/runvil-internal/lua/src/tools.rs::attr_text deps: Table,str Deferred: Nothing reads the dynamic list yet. Plan: vibe/2026-10-23-1-one-offer-api.md
Plan: vibe/2026-10-23-1-one-offer-api.md
The config UI tests import a prebuilt bundle, so a run against a missing or stale bundle fails or checks old code. Running the package's tests now builds the bundle first, so every run sees the current sources. The test comments now name the test command as what builds the bundle, in place of the old instruction to build it by hand first. - `pretest` runs `npm run build` through npm's lifecycle ahead of `test`. The `test` entry itself is unchanged. - `npm test` builds `dist/` before every run, so a deleted or stale bundle no longer fails or misleads it. A bare `node --test` still needs a prior build. - `loadApp` and the header comments in `main.test.mjs` and `tokens.test.mjs` change only in wording. No test logic or assertion changes. Design: new event-hook @ crates/gateway/config-ui/ui/package.json::pretest Plan: vibe/2026-10-24-1-one-offer-api-debt.md
The offer verbs now refuse an array longer than 100,000 entries before they allocate for or read any entry. A script could report a huge length through its length metamethod, and the decoder reserved room for that many entries up front, which panicked the run on an impossible size or aborted the whole process when the allocation failed. A plain array of more than a million entries also panicked the run, because each entry held a Lua reference until the offer was recorded and the reference stack ran out. The refusal names the verb, the limit, and the reported length, records nothing, and leaves the verbs usable.
- `HELD_KEY_LIMIT` sets the bound, imported from the walk budget instead of restated, so the offer verbs share the 100,000 cap that keeps held Lua references well below the mlua reference stack limit.
- `array` reads `#t` once and returns the refusal before `Vec::with_capacity` and before any `get`, so no `__index` runs and nothing is recorded. The message is `{verb} takes an array of at most {HELD_KEY_LIMIT} entries, got {len}`, with `len` exactly as the script reported it.
- `usize::try_from` still turns a negative `#t` into an empty array, and a positive length too large for `usize` becomes `usize::MAX` and is refused instead of read as empty. An array of exactly 100,000 entries is still read whole.
- `tests-offer-limits.rs` covers a `__len` of `math.maxinteger` and of `1 << 40`, a 1,200,000-entry array, an array at the limit, and `tools.offer_dynamic` one past it. The `math.maxinteger` case also checks that the same VM offers `web` afterward.
- `a_len_too_large_to_allocate_is_refused` asserts only the error text and does not check that nothing was recorded.
Design: new surface-growth @ crates/runvil-internal/lua/src/tools/offer.rs::array deps: Table,str boundary: pub
Repairs: an offer array longer than HELD_KEY_LIMIT is refused before allocation @ crates/runvil-internal/lua/src/tools/offer.rs::array - a __len of math.maxinteger panicked with capacity overflow
Repairs: an offer array longer than HELD_KEY_LIMIT is refused before any entry is held @ crates/runvil-internal/lua/src/tools/offer.rs::array - a 1,200,000-entry array panicked on an exhausted mlua reference stack
Repairs: an offer array longer than HELD_KEY_LIMIT is refused before allocation @ crates/runvil-internal/lua/src/tools/offer.rs::array - a __len of 1 << 40 aborted the process on a failed allocation
Plan: vibe/2026-10-24-1-one-offer-api-debt.md
Offering a tool object under the adhoc Pack now requires a defined adhoc tool to hold its id, the same check an id string already gets. Before, a catalog tool filed under that Pack was accepted as an offer, and resolving the section's tools then failed with an internal error because no binding existed for it. That offer now raises the ordinary unknown-tool error at the offer line and records nothing. - `self.named(lua, verb, &id.to_string())` A tool object under the `adhoc` Pack resolves through the string path by its id text, so the object and string forms share one adhoc lookup and one refusal message. - `is_adhoc(&id.pack())` Only objects under the `adhoc` Pack take that path. A defined adhoc tool's object is still recorded, and every other tool object is recorded from its handle as before. - `a_catalog_tool_object_under_the_adhoc_pack_is_refused_as_an_unknown_tool` Offers the object of a catalog `adhoc/x` and asserts the unknown-tool message, no recorded offer, and a scope that resolves without that tool. Design: new stringly-typed @ crates/runvil-internal/lua/src/tools/offer.rs::Offering::target Repairs: a tool object under the adhoc Pack is offered only when an adhoc tool holds its id @ crates/runvil-internal/lua/src/tools/offer.rs::Offering::target - offering a catalog adhoc/x tool object made scope resolution raise an internal error Plan: vibe/2026-10-24-1-one-offer-api-debt.md
Defining an adhoc tool is now all or nothing. The tool used to be registered before its handler and tool object were written into the Lua state, so when either write failed, for example on a memory error the script caught, the tool stayed registered without an object and its name could not be defined again. Registration now comes last, and a failed registration clears the handler and the object, so a failed definition leaves no tool behind. - `register(&self, binding: ToolBinding)` takes the binding its caller built and returns nothing, so the registry entry and the tool object come from one binding. - `remove_tool_object` clears the tool-object entry for an id given as a string, reading the table from the VM registry the same way `insert_tool_object` does. - `TOOL_OBJECTS_REGISTRY` widens to `pub(super)` so a test can swap out the tool-object table. - `install_adhoc` builds the binding, writes the handler, stores the tool object, and registers last, so a failure in either Lua write leaves the registry untouched. When registering fails, it sets the handler entry back to nil and removes the tool object before returning the error. - `a_definition_whose_tool_object_cannot_be_stored_leaves_no_tool_behind` replaces the tool-object table with `false` so the object store fails, then checks that no binding exists and that a second definition returns the object `tools.get` reads. - `handlers.raw_set(name.as_str(), handler)` runs before the object store, so a failed store leaves that handler entry in place. The name collision checks read only the registry, so a later definition proceeds and overwrites it. Design: new hidden-dependency @ crates/runvil-internal/lua/src/tools/objects.rs::remove_tool_object deps: Lua,str Design: new stringly-typed @ crates/runvil-internal/lua/src/tools/objects.rs::remove_tool_object deps: Lua,str Repairs: tools.adhoc is all or nothing @ crates/runvil-internal/lua/src/tools.rs::install_adhoc - a failed tool-object store left the tool registered Plan: vibe/2026-10-24-1-one-offer-api-debt.md
Once a section has rendered its tool list, script tool calls seed the section's call counts from the tools that list advertises instead of from the live scope. An offered adhoc pack expands to whatever adhoc tools exist when it is read, so an adhoc tool defined after the render, which the model can never call, showed up in the counts with a count of zero after a later script tool call. Reading that tool's count now raises the usual error for an unseeded name. Before the first render, seeding still follows the live scope. - `section_call_counts` seeds from `vm.rendered_ids()` once the section has a rendered list and maps `current_tool_bindings` to ids only before that. It reads both sources by reaching through the chain's frame into the VM. - `install_tool_call_counts` takes `ids: &[ToolId]` instead of `bindings: &[ToolBinding]`, on `SectionVm` and in the Lua crate's free function alike, and `script_call_counts` and `install_section_scope` pass ids through. The counts were always keyed by the id string, so nothing else from a binding was needed. - `rendered_ids` is a new `pub` method on `SectionVm` that collects the ids from the rendered list's `dispatch` values and returns `None` before any render. It never includes the dynamic tools. - `an_adhoc_tool_defined_after_the_render_has_no_seeded_count` renders one round with `grab` offered through the adhoc Pack, defines `late` afterwards, and asserts that reading `tools.calls['adhoc/late']` raises `has no seeded count` with the adhoc hint while `adhoc/grab` counts once. - `current_tool_bindings` no longer documents its bindings as fixed once the list is rendered. Its doc now says an offered adhoc Pack expands to the adhoc tools defined when it runs, and that the Engine reads the rendered list after the render. Design: extends feature-envy @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::section_call_counts instead-of: parallel-abstraction: a render-time snapshot of the adhoc Pack expansion Repairs: after the first render tools.calls seeds only the rendered list's ids @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::section_call_counts - an adhoc tool defined after the render read a count of 0 instead of raising has no seeded count Plan: vibe/2026-10-24-1-one-offer-api-debt.md
Plan: vibe/2026-10-24-1-one-offer-api-debt.md
This branch was successfully 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 PR gives section Lua one way to offer tools and turns prompt-written Lua tools into real tools with ids. It follows the plans in
vibe/2026-10-23-1-one-offer-api.mdandvibe/2026-10-24-1-one-offer-api-debt.md.tools.offernow takes tool ids, Pack ids, tool objects, Pack objects, or an array of them. Each offer is checked when it is made and rendered in call order at the section's first loop round.tools.adhoc(attrs, f)defines a Lua tool with an id under an always-presentadhocPack, without offering it.tools.packsreplaces thepacksglobal and thetools.required()andtools.extras()lists. Tool objects gainparameters, tool events record the vendor of the Pack that answered, and the Workshop's ask, search, and fetch views match only runvil's own tools. A debt pass at the end bounds offer arrays, fixes call-count seeding after the render, makestools.adhocall or nothing, and makes the config UI tests build their bundle first.One offer verb
What changes
tools.offerandtools.offer_dynamictake one argument. A string with a slash is a tool id and a string without one is a Pack id. A tool object, a Pack object, or an array mixing all four forms is also accepted. Arrays are read through__lenand__index.ToolRuntimeholds one ordered list ofOffer { target, mode }, where the target is aToolIdor aPackId. No offer holds a wire name.tools.offer('web')thentools.offer('web/search')rendersweb_fetch,web_search, and the reverse order rendersweb_search,web_fetch.prepare_scoped_toolsinto the Lua static pass, so the Engine receives one finished list.tools.offer_dynamicis a scaffold. The render fillsRenderedTools.dynamicwith dynamically offered tools that are not in the static list, and nothing reads it yet. The rendered schemas are byte for byte the same with or without dynamic offers.tools.packsis a read-only proxy with a protected metatable. Integer keys,#,ipairs, andpairswalk the Pack objects in name order,adhocincluded.get(name)returns a Pack object or nil, andextras()returns a fresh array of the usable Packs not declared underuses:. Pack objects have exactlyname,tools, andrequired.tool_routetakes the call site. A model call resolves its wire name through the current section's rendered list, never the chain's advertised map, and a script call routes by id. The adhoc arm moves toscheduler/tool_call-adhoc.rs.Changes callers will notice
packsglobal (includingpacks.offer),tools.offer_local,tools.required(), andtools.extras().RESERVED_NAMESgoes from 63 to 62.tools.offer(tools.packs.extras()).tools.callby bare name raisestools.call: "grab" is not a tool id. Call by id (tools.call('adhoc/grab')) or by object.tools.offer(nil)and malformed arrays raise. A gap, a stray key, or a nested table raises, so a mistypedtools.getid fails at the offer line.weband then the extras throughtools.offer.Adhoc tools
What changes
adhoc_tool_doneop and therunvil.tools.adhoc_handlersregistry key) and two test files. The rename commit changes no behavior except the wording of the jump refusal inside a handler.tools.adhoc(attrs, f)gives a tool the idadhoc/<name lowercased>and keepsnameas its wire name, soShoutgets the idadhoc/shoutand the wire nameShout. It returns the tool object without offering it. Defining works after the render; only offers are refused there.attrstakes exactlyname,description, and an optionalparameters.parametersis either the existing short form or a JSON Schema object, told apart bytype = 'object', soparameters = tools.get('web/search').parameterswraps a catalog tool exactly. The name and the schema are checked on the defining line.adhocPack exists in every section VM from the start, the H1 pass included, withrequiredfalse.tools.calls['adhoc/grab']counts, and tool-call and tool-result events for adhoc tools carrytool: Some("adhoc/<name>").Changes callers will notice
tools.offer_local(...)becomestools.offer(tools.adhoc({ name = ..., description = ..., parameters = ... }, f)).tool: Noneas a Lua tool now sees an adhoc id. The Workshop UI labels an adhoc call as<name> in adhoc.jump is unavailable inside an adhoc tool handler: return a value from the handler and call jump from the block after the tool call returns.Tool metadata, vendors, listings, and the adhoc reservation
What changes
tool.parametersreturns a fresh Lua table holding the tool's schema on every read, converted the wayjson.decodeconverts, sojson.encodegives back[]andjson.nullintact. Tool and Pack metadata (id,description,parameters,pack, and a Pack'sname,required, andtools) never arrives in the untrusted envelope.runvilfor a bareuses:entry or an undeclared Pack,acmeforacme/kit.ToolCallEventandEvent::ToolResultgain an optionalvendor, omitted when absent. Adhoc tools record none, and Lua never sees vendors.runvil/user-input/ask,runvil/web/search, andrunvil/web/fetch. Another vendor's same id, a call with no vendor, or an adhoc tool namedaskshows as an ordinary tool. The server frames a script's result as the operator's message only when both the vendor and the id match.ToolInfogainsdescribes_tools, set only onmcp/get_dynamic_tools. A script's call to it receives the listing as plain text, still marked untrusted, while a model's call still receives the envelope. An adhoc call whose own function fetched a listing during that call returns its result wrapped and untrusted, so an adhoc wrapper ofget_dynamic_toolsgives the model the same envelope, byte for byte, as a direct model call.adhocPack id is reserved. The plugin registry refuses it with the newInstallError::AdhocPackId, and the parser refusesuses: [adhoc]oruses: [acme/adhoc]with the newPackListError::Adhoc.RunContext::preparesets the newRequirements.reserved_adhocwhen the catalog or the roster names the Pack idadhoc, so the run fails withRequirementsUnmetand a one-line notice.Changes callers will notice
RunContext::packstakesVec<UsablePack>in place ofVec<PackId>.UsablePack::new(pack, vendor)is new in the facade, so every caller ofRunContext::packschanges.crates/runvil/public-api.txt:UsablePack,Requirements::reserved_adhoc,ToolInfo::describes_tools(field and builder),ToolCallEvent::vendorwithwith_vendor, andEvent::ToolResult::vendor.vendorand reads back as naming no vendor. Nothing treats such a log as naming arunviltool.Debt removal
#tis greater than 100,000 (HELD_KEY_LIMIT, the limitjson.encodealready applies) raises<verb> takes an array of at most 100000 entries, got <n>before anything is allocated or read. Before this fix, a script's__lencould make the offer decoder panic the run on a capacity overflow or abort the process on a failed allocation, and a plain array of more than about a million entries panicked on an exhausted mlua reference stack.0intools.callsafter a later dispatch. It now raises the usualhas no seeded counterror with the adhoc hint. Seeding before the first render is unchanged.tools.adhocis all or nothing. The handler and the tool object are written first and registration runs last, and a failed registration clears both. When one of the Lua writes failed, for example on a memory error the script caught, the definition used to leave a registered tool with no object, whose name could not be defined again.adhocPack is refused at the offer line with the ordinarytools.offer: "adhoc/x" is not a tool in this run, where it used to reach an internal error at render. This is reachable only when a caller ignores thepreparerefusal, becauseRun::newaccepts an unprepared context.crates/gateway/config-ui/ui/package.jsongains apretestscript, sonpm testnever runs against a missing or staledist/. Three test comments that said a debugcargo buildproducesdist/are corrected.Commits
858aaa233Rename local tools to adhoc. A pure rename across the Engine, the Lua crate, and the event doc comments.97d8de2efAdd tool parameters, vendors, listing trust; reserve adhoc. The groundwork that works against the old offer surface.6a521c27bCollapse tool offering into tools.offer with adhoc tools. The offer verbs,tools.packs,tools.adhoc, the ordered render, and routing by call site.838458243Close plan: one-offer-api1f0c75b90Build the config UI bundle before its tests.a259b241cBound offer arrays at the held key limit.086887609Refuse offered adhoc objects that no adhoc tool holds.4c5a0dbe0Make tools.adhoc all or nothing.f7c54f412Seed tool call counts from the rendered list.5ddfac479Close plan: one-offer-api-debtThis branch is rebased on
dff58bbe1. An earlier commit here that bumped tokio and ctutils for RUSTSEC-2026-0385 and RUSTSEC-2026-0386 was dropped, becausedff58bbe1makes the same fix with newer versions. The other new commit onmaster,c18df6b7b, touches only gateway code and shares no file with this PR.Testing
After the rebase onto
dff58bbe1, these passed locally on Windows:cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features, with 5422 tests passing and 76 skippedcargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api, with 308 tests passing and 4 skippedcargo check --workspace --all-targets --all-features --locked,cargo fmt --all --check, and the docs-claims wording scancargo deny check, with advisories, bans, licenses, and sources all okBefore the rebase, the plans record each step as completed only after the gates it lists passed, before the next step started. Those gates include both clippy runs with warnings denied, the rustdoc builds with
-D warnings,cargo +nightly-2026-09-05 xtask api --checkagainst the updatedpublic-api.txt, the Workshop and config UI Node tests, and therunvil-docssite build. Each debt-pass regression test was confirmed to fail before its fix, including the offer-limit cases that panicked at the pre-fix code. The Workshop clippy run, the rustdoc builds, and the API check were not rerun after the rebase, which moves these commits onto upstream's newer tokio and ctutils and the gateway change inc18df6b7b.Known gaps
tools.offer_dynamicrecords a list that nothing reads. Connecting it to discovery tools is deferred until MCP servers' tools become catalog tools.subagents), and structured output from adhoc tools are deferred.toolfield change. Nothing was added for old records.preparerefusal,tools.call('adhoc/x')runs the catalog tooladhoc/xrather than raising. It reaches no internal error.UsablePack's vendor is a plain string and is not checked to be one segment.Related
runvil-docsrepository and are not part of this PR.24e8143documents the one offer API and also removestools.allow_tasks, the task built-ins, model tasks, and task origin, which the code no longer has.dd8a245documents the offer array limit.