Skip to content

Collapse tool offering into tools.offer and define Lua tools with tools.adhoc - #170

Merged
vinniefalco merged 13 commits into
cppalliance:masterfrom
vinniefalco:master
Oct 24, 2026
Merged

vinniefalco merged 13 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

Copy link
Copy Markdown
Member

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.md and vibe/2026-10-24-1-one-offer-api-debt.md.

tools.offer now 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-present adhoc Pack, without offering it. tools.packs replaces the packs global and the tools.required() and tools.extras() lists. Tool objects gain parameters, 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, makes tools.adhoc all or nothing, and makes the config UI tests build their bundle first.

One offer verb

What changes

  • tools.offer and tools.offer_dynamic take 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 __len and __index.
  • Every entry is checked before any is recorded, and an entry identical to one already recorded is skipped. The fixed-list refusal after the first loop round still comes first.
  • ToolRuntime holds one ordered list of Offer { target, mode }, where the target is a ToolId or a PackId. No offer holds a wire name.
  • The first loop round renders the static offers in call order, appending and skipping tools already listed. A Pack offer expands to its tools in id order at render time. For example, tools.offer('web') then tools.offer('web/search') renders web_fetch, web_search, and the reverse order renders web_search, web_fetch.
  • When an offered adhoc tool and an offered catalog tool share a wire name, the adhoc tool is rendered and the catalog tool is dropped. That rule moved out of the Engine's prepare_scoped_tools into the Lua static pass, so the Engine receives one finished list.
  • tools.offer_dynamic is a scaffold. The render fills RenderedTools.dynamic with 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.packs is a read-only proxy with a protected metatable. Integer keys, #, ipairs, and pairs walk the Pack objects in name order, adhoc included. get(name) returns a Pack object or nil, and extras() returns a fresh array of the usable Packs not declared under uses:. Pack objects have exactly name, tools, and required.
  • tool_route takes 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 to scheduler/tool_call-adhoc.rs.

Changes callers will notice

  • Removed, with no shim: the packs global (including packs.offer), tools.offer_local, tools.required(), and tools.extras(). RESERVED_NAMES goes from 63 to 62.
  • Extras are opt-in per section through tools.offer(tools.packs.extras()).
  • tools.call by bare name raises tools.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 mistyped tools.get id fails at the offer line.
  • Render order follows the offer lines instead of grouping single tools, Packs, and Lua tools.
  • Prompts. The Workshop chat agent offers web and then the extras through tools.offer.

Adhoc tools

What changes

  • The local-tool concept is renamed adhoc everywhere in the code, including the internal protocol names (the adhoc_tool_done op and the runvil.tools.adhoc_handlers registry 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 id adhoc/<name lowercased> and keeps name as its wire name, so Shout gets the id adhoc/shout and the wire name Shout. It returns the tool object without offering it. Defining works after the render; only offers are refused there.
  • attrs takes exactly name, description, and an optional parameters. parameters is either the existing short form or a JSON Schema object, told apart by type = 'object', so parameters = tools.get('web/search').parameters wraps a catalog tool exactly. The name and the schema are checked on the defining line.
  • The adhoc Pack exists in every section VM from the start, the H1 pass included, with required false.
  • Adhoc calls, counts, and events use the id. tools.calls['adhoc/grab'] counts, and tool-call and tool-result events for adhoc tools carry tool: Some("adhoc/<name>").

Changes callers will notice

  • tools.offer_local(...) becomes tools.offer(tools.adhoc({ name = ..., description = ..., parameters = ... }, f)).
  • Events. A reader that treated tool: None as a Lua tool now sees an adhoc id. The Workshop UI labels an adhoc call as <name> in adhoc.
  • The jump refusal reads 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.parameters returns a fresh Lua table holding the tool's schema on every read, converted the way json.decode converts, so json.encode gives back [] and json.null intact. Tool and Pack metadata (id, description, parameters, pack, and a Pack's name, required, and tools) never arrives in the untrusted envelope.
  • Vendors in events. The Harness reports each usable Pack's vendor from the FQN it picked: runvil for a bare uses: entry or an undeclared Pack, acme for acme/kit. ToolCallEvent and Event::ToolResult gain an optional vendor, omitted when absent. Adhoc tools record none, and Lua never sees vendors.
  • The Workshop names its special tools by vendor. The ask, search, and fetch views appear only for runvil/user-input/ask, runvil/web/search, and runvil/web/fetch. Another vendor's same id, a call with no vendor, or an adhoc tool named ask shows 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.
  • Tool listings. ToolInfo gains describes_tools, set only on mcp/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 of get_dynamic_tools gives the model the same envelope, byte for byte, as a direct model call.
  • The adhoc Pack id is reserved. The plugin registry refuses it with the new InstallError::AdhocPackId, and the parser refuses uses: [adhoc] or uses: [acme/adhoc] with the new PackListError::Adhoc. RunContext::prepare sets the new Requirements.reserved_adhoc when the catalog or the roster names the Pack id adhoc, so the run fails with RequirementsUnmet and a one-line notice.

Changes callers will notice

  • RunContext::packs takes Vec<UsablePack> in place of Vec<PackId>. UsablePack::new(pack, vendor) is new in the facade, so every caller of RunContext::packs changes.
  • New public items in crates/runvil/public-api.txt: UsablePack, Requirements::reserved_adhoc, ToolInfo::describes_tools (field and builder), ToolCallEvent::vendor with with_vendor, and Event::ToolResult::vendor.
  • Logs. A log written before this change has no vendor and reads back as naming no vendor. Nothing treats such a log as naming a runvil tool.

Debt removal

  • Offer arrays are bounded. An array whose #t is greater than 100,000 (HELD_KEY_LIMIT, the limit json.encode already 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 __len could 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.
  • Counts seed from the rendered list after the render. An adhoc tool defined after the first loop round, which the model cannot call, read 0 in tools.calls after a later dispatch. It now raises the usual has no seeded count error with the adhoc hint. Seeding before the first render is unchanged.
  • tools.adhoc is 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.
  • A catalog tool object under the adhoc Pack is refused at the offer line with the ordinary tools.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 the prepare refusal, because Run::new accepts an unprepared context.
  • The config UI tests build their bundle first. crates/gateway/config-ui/ui/package.json gains a pretest script, so npm test never runs against a missing or stale dist/. Three test comments that said a debug cargo build produces dist/ are corrected.

Commits

  • 858aaa233 Rename local tools to adhoc. A pure rename across the Engine, the Lua crate, and the event doc comments.
  • 97d8de2ef Add tool parameters, vendors, listing trust; reserve adhoc. The groundwork that works against the old offer surface.
  • 6a521c27b Collapse tool offering into tools.offer with adhoc tools. The offer verbs, tools.packs, tools.adhoc, the ordered render, and routing by call site.
  • 838458243 Close plan: one-offer-api
  • 1f0c75b90 Build the config UI bundle before its tests.
  • a259b241c Bound offer arrays at the held key limit.
  • 086887609 Refuse offered adhoc objects that no adhoc tool holds.
  • 4c5a0dbe0 Make tools.adhoc all or nothing.
  • f7c54f412 Seed tool call counts from the rendered list.
  • 5ddfac479 Close plan: one-offer-api-debt

This branch is rebased on dff58bbe1. An earlier commit here that bumped tokio and ctutils for RUSTSEC-2026-0385 and RUSTSEC-2026-0386 was dropped, because dff58bbe1 makes the same fix with newer versions. The other new commit on master, 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 skipped
  • cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api, with 308 tests passing and 4 skipped
  • cargo check --workspace --all-targets --all-features --locked, cargo fmt --all --check, and the docs-claims wording scan
  • the pre-push hook: the headless gateway check, the workspace clippy run with warnings denied, and cargo deny check, with advisories, bans, licenses, and sources all ok

Before 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 --check against the updated public-api.txt, the Workshop and config UI Node tests, and the runvil-docs site 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 in c18df6b7b.

Known gaps

  • tools.offer_dynamic records a list that nothing reads. Connecting it to discovery tools is deferred until MCP servers' tools become catalog tools.
  • Offer filters, Pack-chosen wire names for catalog tools, shaped Packs (the core file tools and subagents), and structured output from adhoc tools are deferred.
  • Replays of records written before this change no longer match their Chat effects, because the advertised order and the adhoc events' tool field change. Nothing was added for old records.
  • Before a section's first render, call counts still seed from the live scope at each dispatch.
  • When a caller bypasses the prepare refusal, tools.call('adhoc/x') runs the catalog tool adhoc/x rather than raising. It reaches no internal error.
  • UsablePack's vendor is a plain string and is not checked to be one segment.

Related

  • The language guide changes are two commits in the runvil-docs repository and are not part of this PR. 24e8143 documents the one offer API and also removes tools.allow_tasks, the task built-ins, model tasks, and task origin, which the code no longer has. dd8a245 documents the offer array limit.

jasonbrazeal and others added 13 commits October 24, 2026 06:24
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
@vinniefalco
vinniefalco merged commit c27899e into cppalliance:master Oct 24, 2026
19 checks passed

This branch was successfully deployed

1 active deployment
github-pages — c27899e7 Deployed Oct 24, 2026 by vinniefalco via deploy #138
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants