Skip to content

Rust-backed messages and models.loop, tools by canonical id, and a plugins table - #139

Merged
vinniefalco merged 29 commits into
cppalliance:masterfrom
vinniefalco:master
Oct 10, 2026
Merged

vinniefalco merged 29 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

This branch lands five plans on the prompt-author surface, in 29 commits.

  • Message lists. messages.new() lists move from a pure-Lua table to a Rust-backed list that the Engine owns, so each model round's Chat record logs only what changed since that list's previous round. Before this, every round recorded the whole conversation again, so a conversation of k rounds logged on the order of k squared message bodies. Each Chat record now names the round it extends (after), says how many leading messages it repeats from that round's request (keep), and stores only the messages after those.
  • The model loop in Rust. The models.loop rules move out of the Lua coroutine shim into a typed Rust state machine behind a small Lua trampoline. Model handles gain loop and infer methods, so the namespace functions no longer guess which argument is a handle. Pending task notices now ride the chat round itself instead of a separate drain request.
  • The author API. Lua names every catalog tool by its canonical id, such as web/fetch, and the frontmatter declares Plugins only. Role labels and tool aliases stop being Lua globals. A new plugins table reports the run's Plugins, tools and Plugins are shared read-only objects, and model handles report their provider from the gateway catalog.
  • Two debt removals follow debt reviews of the loop work and the author API work.

Plans:

  • vibe/2026-10-09-2-messages-userdata-refactor.md
  • vibe/2026-10-09-3-models-loop-in-rust.md
  • vibe/2026-10-09-4-models-loop-debt-removal.md
  • vibe/2026-10-09-5-author-api-reshape.md
  • vibe/2026-10-10-1-author-api-debt-removal.md

Changes

Message list:

  • Honor userdata __pairs in sandbox pairs
  • Add the Rust-backed MessageList core
  • Add read-only record views and iteration to MessageList
  • Back messages.new() with the Rust MessageList
  • Record after and keep on Chat effects
  • Fix models.loop compactor error and bound list pairs (follow-up from a debt review, no plan)

Model loop:

  • Give model handles loop and infer methods
  • Record the guide update for handle methods (the guide change is promptforge-docs commit 44ff253)
  • Pin models.loop behavior with contract tests
  • Run models.loop rules in a Rust state machine
  • Fold the task-notice drain into the chat dispatch
  • Resume the chat answer as an opaque ChatResult

Model loop debt removal:

  • Keep shim raises working when author code rebinds error
  • Drop model and metrics from ChatResult and fix stale docs

Author API:

  • Stop installing role labels as Lua globals
  • Drop tool slots from the Workshop contract and run panel
  • Name tools by canonical id and remove tool slots
  • Add the plugins table over shared plugin objects
  • Report an optional model provider in the gateway catalog
  • Expose the model provider on model handles

Author API debt removal:

  • Run the docs-claims wording scan from local gates
  • Resolve model tool names by wire name only
  • Rename Lua tool internals to match the offer API
  • Describe ToolPerformer alias as wire name or canonical id

The five Close plan commits only retire each plan's active marker.

Behavior changes

For prompt authors, message lists:

  • models.loop and h:loop accept only a messages.new() list. A plain Lua array fails with models.loop needs a messages.new() list; build one with messages.new() and :user, :append, or :replace. No in-tree prompt passes one.
  • Each builder validates its record when it adds it, so a malformed record fails at the builder call instead of at the loop. A system record after any non-system record is refused by the edit that adds it.
  • append drops fields other than role, content, tool_calls, and tool_call_id, so those fields cannot be read back from the list.
  • The new replace(first, last, records...) deletes, inserts, and replaces ranges. Removing the last record is msgs:replace(#msgs, #msgs). Index assignment such as msgs[#msgs] = nil is refused.
  • msgs[i] returns a read-only view whose type() is userdata. Assigning a field raises an error that points to replace. A view converts to JSON like the record table, so it still works as a tool argument and with append on another list.
  • pairs(msgs) stops at the record count it started with. ipairs and numeric loops read the live list. The sandbox pairs now honors a userdata's __pairs, as stock Lua does.
  • A list or a view cannot be stored directly in var or substituted with {{ }}, and a whole list no longer converts to JSON.

For prompt authors, the model loop:

  • A role's handle runs its own calls with h:loop(msgs) and h:infer(prompt). models.loop and models.infer always run on the section's current model, and refuse a handle as their first argument with an error that names the method form. A dot call such as h.loop(msgs) raises an error that says to use a colon.
  • Every loop entry checks its receiver, its argument count, its compactor, and its list before any request leaves, so each mistake raises at the call site.
  • Pending task notices are appended to the author's list as user messages at the start of each round, so pending notices can fill an empty list. An empty list with nothing pending still raises messages must not be empty.
  • The shim captures the base error at install, so author code that rebinds error can no longer turn a refused loop call into a spin.

For prompt authors, tools, Plugins, and models:

  • The frontmatter tools: key is gone, and a leftover one fails the parse as an unknown key. plugins: is the only declaration. No frontmatter key creates a Lua global.
  • Role labels are no longer globals: local analyst = models.get('analyst') replaces a bare analyst. Tool aliases are gone with the slots, so after setup the global table holds only Lua, Engine, and Plugin prelude names.
  • Lua names catalog tools by canonical id. tools.offer and tools.always_offer replace tools.add and tools.always, and take ids, tool objects, or arrays of them, so tools.offer(plugins.get('web').tools) offers a whole Plugin. tools.required() and tools.extras() replace tools.offered(), tools.get(id) returns one tool object or nil, and tools.offer_local replaces tools.add_local with the same arguments. tools.call takes an id, a tool object, or a local tool's alias, and tools.calls is keyed by id. The removed functions are nil.
  • Each tool and each Plugin is one read-only object per VM, so the same tool is == in every list. A tool object has id, description, and plugin.
  • The new plugins table has plugins.required(), plugins.extras(), and plugins.get(name), and plugins is a reserved global name.
  • handle.provider is the model's provider id from the gateway catalog, such as xai, or nil when the catalog names none.
  • The model still sees generated wire names (web/fetch becomes web_fetch), so the shipped prompts' tools now reach the model as web_fetch and web_search instead of fetch and search. A model call that names a tool by its canonical id gets the plain out-of-scope error.
  • New error texts: tools.offer: "{id}" is not a catalog tool in this run (and the same for tools.always_offer), kind unbound_tool with tool {name:?} is not a tool in this run; catalog tools: [...], and the out-of-scope suffix (a catalog tool that was not offered in this section).

For hosts:

  • Effect::Chat and EffectRecord::Chat gain the public fields after: Option<RoundId> and keep: u64. Effect::Chat.messages still holds the full request. EffectRecord::Chat.messages now holds only the messages after keep, so rebuilding a round's request means following after.
  • A host that logs records must record every Chat effect a step returns, including ones it drops, so that each after names a recorded round. The in-tree Harness already does this.
  • A models.infer record is unchanged apart from after: null and keep: 0.
  • The run-log table layout and LAYOUT_VERSION are unchanged. Only the JSON in Chat records changes, and run logs written before this PR hold whole requests with no after or keep.
  • Because the notice drain no longer sends the owning chain to the back of the ready queue, the order effects are issued in can change when several chains are ready.
  • The facade drops ToolSlot, ToolSlots, ToolBindings, RunContext::tool_bindings, Frontmatter::tools, and Requirements::missing_tools, and gains with_provider and provider on ModelDescriptor and ModelBinding. A declared Plugin that lacks one expected tool is no longer refused at prepare; the prompt fails at tools.offer instead. A missing declared Plugin still lands in missing_required.
  • Effect::ToolCall.alias now holds the tool's wire name for every call to a tool the run can offer, and the canonical id only for a script call to a catalog tool the run could not offer.
  • The gateway's [[model]] and [[local_model]] accept an optional provider of lowercase letters, digits, ., _, and -, refused at load otherwise. GET /v1/models includes it only when set.
  • The Workshop prompt contract response drops its tools field, and the run panel no longer shows tool rows.

For contributors:

  • The root AGENTS.md verification list and the pre-push hook now run node --test crates/workshop/ui/test/docs-claims.mjs, so an Engine doc that names a Host application fails locally before push instead of only in CI's ui job.

Known limits

  • In promptforge-docs, the conversations chapter (11) and the quick reference (17) still describe plain-table lists, and the guide still documents tool slots, tools.add, and role and alias globals. Those change in separate work there. The guide already documents the handle methods.
  • The per-model rewrite of model-facing tool names (such as WebSearch for Grok) is deferred. This branch only adds the provider it will key on.
  • after is a run-wide round number, so, like round, it can differ between two live runs of a prompt whose tasks run concurrently. Within one run log, every after names a recorded round.
  • Messages after keep are logged in full. A compaction that rewrites early messages logs the kept tail again, once.
  • A catalog tool whose generated wire name repeats an earlier one is left out of the offering with a log line, a declared Plugin's tool included. No shipped configuration produces such a collision.

Testing

  • The full workspace gates pass on 8cac6f030, the last code commit (the tip, abe218a36, only deletes vibe/ACTIVE). They cover cargo fmt --all --check, clippy with warnings denied on both partitions, the headless gateway check, both doc builds, the facade API check, and the Workshop and gateway config UI npm suites with the docs-claims scan. The Rust suites passed with 4,959 and 310 tests.
  • The facade listing's diff is exactly the slot removals, the four provider accessors, and the four after and keep field lines.
  • A rebuild test walks each case's Chat records in log order, rebuilds every request from after and keep, and checks that it equals the request the round sent. The cases are a tool round, a compaction, an add then a remove, a resend with no changes, an identical re-insert, and removing the last reply. A separate test checks that a precheck refusal writes no record and that the next send extends the last issued round.
  • Contract tests written before the Rust rewrite pin the loop's observable behavior: compactors, list and arity refusals, notice draining, the clean exit, the batch rule under a raising handler, dropped and cancelled tool calls, and a full event trace. The state machine's own tests run without a Lua VM.
  • Tests run prompts/research-person.md and crates/workshop/agents/agents/chat.md end to end against a canned model, on the new tool surface.
  • Identity tests check that each tool and each Plugin is the same object across tools.required(), tools.extras(), tools.get, plugins.get, and a Plugin's tools, while each list call returns a new array.
  • Regression tests fail before their fixes: the compactor type error without a handle, a pairs loop that appends (which raises runaway pairs after ten passes instead of hanging), a refused loop call after author code rebinds error, and a model call by canonical id that used to get the false "not offered" suffix.

The sandbox replaces Lua's iteration function with a deterministic walk, but the replacement honored a custom iteration metamethod only on tables and refused every userdata. It now also calls that metamethod when a userdata's metatable provides one, as stock Lua does, so values backed by Rust can define their own iteration. A userdata without the metamethod is still refused with the same error, and tables behave as before.

- `pairs` now resolves the `__pairs` metamethod before it checks the argument's type, with a raw read from a table's metatable or a lookup in a userdata's metatable, and requires a table only for the deterministic walk.
- `userdata.metatable()` errors count as no metamethod, so a userdata whose metatable mlua cannot expose gets the usual table-expected refusal rather than the lookup error.
- `__pairs` set to a non-function on a userdata now raises the attempt-to-call error that tables already raised.
- `Counted` and `Opaque` are test-only userdata types: one test iterates a `Counted` through its `__pairs`, and another asserts that an `Opaque` is refused with a message naming the expected and actual types.
- `pairs` looks for `__pairs` only on tables and userdata; every other value is refused without a metatable lookup.

Repairs: stock Lua __pairs precedence @ crates/promptforge-internal/lua/src/iteration.rs::pairs - pairs refused a userdata carrying a __pairs metamethod instead of calling it
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
Add a Rust-backed message list that a Lua script edits through chained builder calls and that a model round's request can hold as the same shared value. Every edit validates its records with the chat request's own record parse, keeps system messages at the front of the list, and leaves the list unchanged when it refuses. The list also remembers its last send, so a later round can name the round it extends and count the leading messages the two requests share. This change does not install the new list, so scripts still get the existing Lua-built one.

- `MessageList` wraps `Arc<Mutex<ListState>>`, so every clone shares one list, and the crate root re-exports it. The lock ignores poisoning, and each Lua method converts its Lua values to JSON outside the lock.
- `build` passes the role as a string and assembles each builder's record as a JSON object, so the builders, `append`, and `replace` all validate through `parse_record` at the position the record takes.
- `parse_record` gives the crate the per-record chat parse. `parse_message` and its helpers now return `String` errors, and `parse_messages` wraps each one with `chat_error`, so the chat request refuses with the same text as before.
- `push` refuses a system record after any non-system record, and `replace` checks the whole edited list before it swaps in the edited records. The refusal names the position and points to `replace(first, last, records...)`, and a refused edit leaves the list unchanged.
- `replace` edits a 1-based inclusive range under `1 <= first <= last + 1 <= len + 1`: with no records it deletes, and with first one past last it inserts. A bound must be an integer or an integral float.
- `commit` stores the round and a copy of the wire request, and returns the previous round with the count of leading messages that both requests share. The first send returns `(None, 0)`.
- `append` stores only the fields that the record parse reads, so extra fields on a record table are dropped.
- `MetaMethod::NewIndex` refuses every assignment with a message that points to `append` and `replace`, and `MetaMethod::Len` returns the record count.
- `install_messages` still loads the Lua builders chunk, so `messages.new()` returns the old Lua list. This change adds no caller of `MessageList`, `records`, or `commit` outside tests, and the list defines no index read or iteration.

Design: new shared-mutable-state @ crates/promptforge-internal/lua/src/messages.rs::MessageList boundary: pub
Design: new stringly-typed @ crates/promptforge-internal/lua/src/messages.rs::build deps: &Lua,&str,AnyUserData,[(&str, Value); N]
Design: new shared-parameter-cluster @ crates/promptforge-internal/lua/src/messages.rs::out_of_bounds deps: impl Display,impl Display,usize
Design: new pure-function @ crates/promptforge-internal/lua/src/protocol/parse/chat.rs::parse_record deps: &serde_json::Value,usize
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::in_bounds deps: usize,usize,usize
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::out_of_bounds deps: impl Display,impl Display,usize
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::check_leading_system deps: &[Arc<MessageRecord>]
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::late_system deps: usize
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::refusal deps: String
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::bound deps: &Value,&str
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests-list.rs::text deps: &str,MessageRole
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests-list.rs::system deps: &str
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests-list.rs::user deps: &str
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests-list.rs::assistant deps: &str
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests-list.rs::expect deps: &[(MessageRole, &str)]
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
Message lists can now be read by position and walked with the standard Lua iteration functions. Each read returns a read-only view that shares the stored record instead of copying it, and nested fields come back as fresh tables, so nothing written through a view can change the list. A view converts to the same JSON form a record table has, so another list accepts it as a record. Walking a list reads it live, so a loop that edits the list sees its edits.

- `RecordView` holds its record by `Arc`, shared with the list's storage, so `msgs[i]` copies nothing until a field is read. It lives in the private child module `messages-view.rs`.
- `MessageRecord::to_json` renders a record in the JSON form the chat parse accepts, and a view serializes through it. Appending a view to another list, or converting it to JSON, yields the same record as its table form.
- `integral` holds the integer rule, split out of `bound`, so `replace` bounds and list indexing accept the same numbers.
- `MetaMethod::Index` on the list returns a new view for an integer or integral-float key, so `msgs[2.0]` reads `msgs[2]`. Zero, negative, fractional, and out-of-range indexes read nil, as do other keys that name no list method.
- `next_record` reads the live list at each step of `pairs`, so a loop that appends or replaces records visits the edited list, as `ipairs` does.
- `next_field` makes `pairs` over a view visit only the fields present, in the order `role`, `content`, `tool_calls`, `tool_call_id`.
- `MetaMethod::NewIndex` on a view refuses every assignment, including unknown fields, with an error pointing to `replace(first, last, records...)`. `field` builds nested tables fresh on each read, so writes into them never reach the record.
- `messages.new()` still returns a plain table from the pure-Lua builders; this change does not switch it to the Rust list.

Design: new newtype @ crates/promptforge-internal/lua/src/messages-view.rs::RecordView
Design: extends shared-mutable-state @ crates/promptforge-internal/lua/src/messages.rs::MessageList
Design: new oversized-unit @ crates/promptforge-internal/lua/src/messages.rs::MessageList::add_methods
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::integral deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::key_index deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-view.rs::field_position deps: Value
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
Scripts now build model conversations on the Rust message list, and the old pure-Lua builders are gone. The list validates each record as an edit adds it, so a model round no longer checks the records again and only confirms that it got a non-empty list. A plain Lua array or any other value in the list's place now fails at the model loop call, with an error that says how to build a list. The round's request shares the author's list rather than holding its own validated copy.

- `Request::Chat` holds `list: MessageList`, a clone of the author's list handle, instead of a vector of validated records. `prepare_chat` reads the records with `list.records()` at dispatch, so the round sees the list as it stands then, not a copy taken at the parse.
- `parse_chat` drops its `lua` parameter and checks only that `messages` is a non-empty `MessageList`. With `parse_messages` gone, the per-record rules run only in `parse_record`, as the list adds each record.
- `__impl_messages.lua` leaves the tree with `MESSAGES_PROGRAM` and its chunk constants, and `install_messages` now registers a Rust function that returns an empty `MessageList`.
- `AGENTS.md` and the crate docs now name the Rust-backed list, with its colon builders and `replace`, as the one exception to the methodless-handle rule, and say a record view has metamethods only.
- `append_record` calls `messages:append(record)`, so the list validates every reply, tool call, tool result, and task notice the loop adds, just like an author's append.
- `drain_task_notices` checks `is_message_list`, a new shim capture backed by `is_list`, and drains nothing for a value that is not a list. The round's parse then refuses that value, and pending notices wait for a later round over a list.
- `models_loop` reads a leading handle only when the first argument is userdata and the second is neither nil nor a function. A list followed by a non-function value therefore reads as a handle and its list, so the compactor type test now passes an explicit handle.
- `NOT_A_LIST` is the refusal for nil, a number, a plain array, an empty table, a record view, or another userdata type, and it fires at the `models.loop` call before any request leaves. An empty list still gets `messages must not be empty`.
- `messages-tests-records.rs` asserts the per-record refusals on `append`, with the same texts the chat parse tests expected.
- `msgs:replace(#msgs, #msgs)` takes the place of `msgs[#msgs] = nil` in two engine tests, because the list refuses index assignment.
- `a_list_or_a_record_view_is_refused_by_the_var_and_prose_guards` shows that storing a list or a record view in `var`, or naming one in prose, fails with the existing userdata errors.
- `models_loop-arguments.rs` checks that all four call forms send the whole list on the expected model. `models_loop-author-shapes.rs` runs the research and chat agent list shapes against a canned model.
- `prepare_chat` calls only `list.records()` on the list, so an issued round leaves no trace on it.

Design: new shared-mutable-state @ crates/promptforge-internal/lua/src/protocol/request.rs::Request::Chat boundary: pub
Design: removes parallel-abstraction @ crates/promptforge-internal/lua/src/__impl_messages.lua
Design: removes global-state @ crates/promptforge-internal/lua/src/messages.rs::MESSAGES_PROGRAM
Design: removes pure-function @ crates/promptforge-internal/lua/src/protocol/parse/chat.rs::parse_messages deps: &serde_json::Value
Design: new pure-function @ crates/promptforge-internal/lua/src/messages.rs::is_list deps: &Value
Design: extends oversized-unit @ crates/promptforge-internal/lua/src/__impl_coro.lua::models_loop deps: ...
Design: extends oversized-unit @ crates/promptforge-internal/lua/src/coro.rs::install_shim_prelude deps: &Arc<AtomicU32>,&InstructionBudget,&Lua,usize
Design: extends oversized-unit @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests.rs::text deps: &str,MessageRole
Design: new pure-function @ crates/promptforge-internal/lua/src/messages-tests.rs::chat_error deps: YieldParse
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
A model round's log record now names the earlier round whose request it extends and how many leading messages it repeats, and stores only the messages after those. A growing conversation no longer writes its whole history into the log on every send, and any request can still be rebuilt by walking the records in order. The live round still carries the full request, so the host performs it unchanged. A send that the context precheck refuses is not recorded, so the next send extends the last round that was actually issued.

- `EffectRecord::Chat` gains `after` and `keep`, and its `messages` now hold only the request's messages after the first `keep`. The persisted record changes shape with no migration or version marker, since no run logs exist yet.
- `Effect::Chat` carries the same two fields but keeps the whole projected request in `messages`, so `Effect::record` derives the logged suffix from the effect alone. A nested `models.infer` round sets `after` to `None` and `keep` to 0.
- `prepare_chat` calls `list.commit` only after the context precheck passes and the round is numbered, so a refused round leaves the list's last send in place. Nothing in the types enforces that order.
- `chat_record_rebuild.rs` rebuilds every request from the records alone and checks it against the live effect's wire messages, across a tool round, a compaction, an add then remove, an unchanged resend, a re-inserted identical record, and a removed terminal reply.
- `precheck_anchor-resend.rs` checks that a send the precheck refuses writes no Chat record and that the resend's `keep` counts against the last issued round's request.
- `crates/harness-internal/runner/src/effect_loop.rs` only adds `..` to its Chat pattern, so the host ignores `after` and `keep`.

Design: extends surface-growth @ crates/promptforge-internal/engine/src/execute/run/effect.rs::Effect::Chat
  boundary: pub
Design: extends surface-growth @ crates/promptforge-internal/engine/src/execute/run/effect.rs::EffectRecord::Chat
  boundary: pub
Design: new schema-change @ crates/promptforge-internal/engine/src/execute/run/effect.rs::EffectRecord::Chat
  boundary: persisted
Design: new temporal-coupling @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Design: extends oversized-unit @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
Plan: vibe/2026-10-09-2-messages-userdata-refactor.md
A list passed first to `models.loop` with a non-function second argument now gets the compactor type error, not the handle error. A `pairs` loop over a list now stops at the record count it started with, so a loop that appends to the list ends. Four doc comments now state where records get validated, the recording that `after` needs, and every caller of `shared_source_new`.

- `models_loop` adds `not is_message_list((...))` to the handle-form test, so a list in the first position always selects the list form.
- The `MetaMethod::Pairs` function reads `list.len()` once and gives it to `next_record` as `extent`. The walk reads each record live and stops at `extent` or at the end of the live list.
- `ipairs` still reads the live list, so `pairs` and `ipairs` visit different records when the loop edits the list. `pairs_keeps_its_starting_length_while_ipairs_reads_the_live_list` pins this.
- `a_non_function_compactor_is_the_calls_error_in_the_engines_type_names` checks each bad compactor with and without a leading handle. `a_pairs_loop_that_appends_ends_after_the_records_it_started_with` stops a runaway loop at 10 passes with `runaway pairs`, so it fails without hanging.
- `a_pairs_loop_stops_early_when_the_list_shrinks_below_its_next_index` also passes without the `extent` bound. It guards the live stop, not the bound.
A prompt that runs a model call on a specific handle now calls the loop or infer method on that handle, and the namespace-level loop and infer functions always run on the section's current model. The old form passed the handle as an optional first argument and guessed which argument was the handle from the Lua types of the first two arguments. Splitting each call into one entry per form removes that guess. Every entry now checks its arguments before any request leaves, so each mistake raises at the call site with an error that names the right form.

- `handle_methods` holds the handle's `infer` and `loop` entries in one table, which `install_shim_prelude` stashes in the Lua registry, and the handle's field getters read it through `handle_method`. The methods are Lua functions rather than Rust methods because each may suspend, and a Rust method cannot.
- `handle_method` returns nil on a VM where the shim prelude never ran, so a handle's methods depend on the prelude running first. `a_handle_reads_nil_methods_where_the_shim_prelude_never_ran` pins the nil case.
- `run_loop` takes the loop body unchanged apart from the new list check, and `models_loop` and `handle_loop` become thin entries over it, as `infer` and `handle_infer` are over `run_infer`.
- `is_handle` tests a value with an exact `is::<LuaModelHandle>()` match. `models.rs` re-exports it beside `LuaModelHandle`, and the prelude hands it to the shim as the `is_model_handle` capture.
- `models.infer` and `models.loop` now refuse a model handle as the first argument with an error that names the `handle:infer` or `handle:loop` method, instead of running on the handle.
- `h:infer` and `h:loop` raise an error that tells the author to use a colon when a call passes no handle as the receiver, as a dot call or a bare call of the method does.
- `handle_loop` and `models_loop` check the receiver, then the argument count with trailing nils counted, then the compactor, then the list, all before any yield. The new argument tests assert that no request reaches the gateway.
- `NOT_A_LIST` copies the chat parse's refusal text, so the two texts must change together. `drain_task_notices` no longer skips a non-list, since only a list reaches it now.
- `crates/promptforge-internal/lua/AGENTS.md` and the crate's invariants list drop the rule that handles are methodless and that new operations take a leading handle argument.
- `tools.call(alias_or_tool, arguments)` stays the only way to invoke a tool handle; tool handles gain no methods.
- `LuaModelHandle` and `is_handle` stay `pub(crate)`, so the public Rust API does not change.

Design: new surface-growth @ crates/promptforge-internal/lua/src/models-userdata.rs::LuaModelHandle
Design: new temporal-coupling @ crates/promptforge-internal/lua/src/coro.rs::handle_method deps: Lua,str
Design: new pure-function @ crates/promptforge-internal/lua/src/models-userdata.rs::is_handle deps: Value
Design: replaces oversized-unit @ crates/promptforge-internal/lua/src/__impl_coro.lua::run_loop deps: compactor,handle,messages
  was: crates/promptforge-internal/lua/src/__impl_coro.lua::models_loop
Design: extends oversized-unit @ crates/promptforge-internal/lua/src/coro.rs::install_shim_prelude deps: Arc<AtomicU32>,InstructionBudget,Lua,usize
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
The guide now documents the loop and infer methods on model handles. That change lives in the promptforge-docs repository as commit 44ff253; this commit only marks the step complete in the plan.

Plan: vibe/2026-10-09-3-models-loop-in-rust.md
These contract tests pin the model loop's observable behavior so that a rewrite of the loop can be checked against them unchanged. Each case runs the loop from an author's block over a built message list and asserts only what an author or caller sees: the list, the returned or raised value, the effects, and the events. The cases cover the default and argument compactors, the list and arity refusals, notice draining, the clean exit's call count, the batch rule under a raising handler, dropped and cancelled tool calls, a local tool's return value, and the full event trace of a run. One case, an empty answer that names no detail, drives the loop's yields by hand because no real round produces it.

- `models_loop_contract-rounds.rs` holds the round, batch, tool, and trace cases as a sibling wired with `#[path]` from `models_loop_contract.rs`. The split keeps each file under 400 lines without adding a directory.
- `TraceRecorder` records every observation and every content report as one line, in order. The trace test compares that whole sequence rather than a filtered list of events.
- `resume_with` feeds canned `Answer` values to the Lua-layer case, which drives the drain and chat yields by hand to reach the empty-answer fallback.
- `compactors.fail` is pinned as the default at each call: an author's replacement becomes the default, and a non-function there lets an ordinary round run and fails only at an overflow round with Lua's own call error as a string.
- `messages must not be empty` is pinned as a per-round refusal after the drain. With nothing pending the call raises before any request; with one pending notice an empty list runs one round over it.
- `TOOL_CALL_FAILED` fires for a handler that raises its own table mid-batch, which leaves the list untouched, reports no tool result, and reaches the caller as the same table. A handler cancelled mid-run reports no failed call.
- `Duration::from_millis(100)` times the cancellation in the cancelled-handler case on a two-worker runtime, so that case assumes the first round completes within that window.
- `messages.new()` builds every case's list; the cases read no internal state, and no engine or shim code changes.

Design: new newtype @ crates/promptforge-internal/engine/src/execute/tests/models_loop_contract-rounds.rs::TraceRecorder
Design: new clone-block @ crates/promptforge-internal/engine/src/execute/tests/models_loop_contract.rs::two_notices_drained_into_one_round_land_in_the_order_their_tasks_ended
Design: new pure-function @ crates/promptforge-internal/engine/src/lua/tests/models_loop_contract.rs::empty_round deps: Option<&str>
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
The model loop's rules now run in Rust as a typed state machine that needs no Lua VM, behind a small Lua trampoline that only performs the action each step hands back. A Rust function called from Lua cannot yield or call Lua code that may yield, so each step returns what to do next: yield a request, call a tool handler or the compactor, raise an error, or return. Requests, records, raised errors, and argument checks keep the shape and order the Lua loop gave them, and the instruction-cost test now bounds only the trampoline's handful of instructions per yield.

- `Machine` holds every loop rule and the whole call's state over typed inputs, with each Lua value it only passes along as an opaque type parameter, so its tests run without a VM. It pushes records onto its own clone of the list handle, which shares one list with the userdata each `chat` request names.
- `loop_begin` takes the place of the round cap, the `compactors` table, and the list check among the shim chunk's arguments. Each call of it returns a step closure that owns that call's machine, so Lua never holds a state handle.
- `drive` performs whatever action the step returns, dispatching on bare string tags that `act` writes, so only convention keeps the tag set in sync across Rust and Lua.
- `begin` reads a leading boolean that each Lua entry passes as a literal and picks that entry's receiver and arity checks from it, so one Rust function serves both entries and the chunk gains a single capture.
- `chat_result` reads the rendered chat table back into a `ChatResult` by string key, so the adapter depends on the renderer's key names. It leaves `model` and `metrics` empty because the loop never reads them.
- `NOT_A_LIST` and `normalized` now serve both their original callers and the loop, so its up-front checks and compactor rule raise the existing values by construction.
- `round` and `call` appear line for line in both the machine tests and the adapter tests.
- `begin` runs the receiver, arity, compactor, and list checks in that order before the first yield, with the previous shim's texts and trailing nils counted. A nil compactor selects `compactors.fail`, which it looks up in the table at each call.
- `judge` reads an empty reply string as no reply, so such a round takes the clean exit or raises `empty_model_reply`.
- `Input::Called` carries the cancel flag read beside a handler's or compactor's outcome, so the step raises a failure under cancellation raw, while a compactor that returned still raises the deferred-replacement error.
- `UNEXPECTED_INPUT` names the `internal` error the step raises for an input its phase does not await, and a refused record push raises `internal` too. A malformed value at the adapter fails as an mlua callback error instead.
- `envelope_failure` names a non-table failure through whatever the `tostring` global holds at call time. Only a hand-built envelope reaches that branch.
- `ROUND_INSTRUCTION_CEILING` drops from 300 to 90 and `ROUND_INSTRUCTION_FLOOR` from 20 to 3, since the counting hook now sees only the trampoline, measured at 30 instructions per round.
- `run_loop` and its append, drain, and compact helpers leave the shim, along with the Lua copies of the empty-reply and not-a-list texts, so no loop rule remains in Lua.
- `tools_call_as_model` now serves only the test-only `tools.call_as_model` hook, because the loop builds its `tool_call` requests in Rust.

Design: new shared-mutable-state @ crates/promptforge-internal/lua/src/models_loop/machine.rs::Machine
Design: new dispatch-on-tag @ crates/promptforge-internal/lua/src/__impl_coro.lua::drive deps: a,action,b,step
Design: new stringly-typed @ crates/promptforge-internal/lua/src/models_loop.rs::act deps: Lua,Then<Value>
Design: new flag-parameter @ crates/promptforge-internal/lua/src/models_loop.rs::begin deps: InstructionBudget,Lua,MultiValue,Table,usize
Design: replaces service-locator @ crates/promptforge-internal/lua/src/models_loop.rs::begin deps: InstructionBudget,Lua,MultiValue,Table,usize was: crates/promptforge-internal/lua/src/__impl_coro.lua::run_loop
Design: new hidden-dependency @ crates/promptforge-internal/lua/src/models_loop.rs::envelope_failure deps: Lua,Value
Design: new feature-envy @ crates/promptforge-internal/lua/src/models_loop.rs::chat_result deps: Lua,Value
Design: removes oversized-unit @ crates/promptforge-internal/lua/src/__impl_coro.lua::run_loop deps: compactor,handle,messages
Design: new clone-block @ crates/promptforge-internal/lua/src/models_loop/tests.rs::round
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::lua_type_name deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::truthy deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::text deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::optional_text deps: Value
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::malformed deps: str
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop/machine.rs::text_record deps: MessageRole,String
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop/machine.rs::raised deps: ErrorKind,str
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
Model-task notices now reach the model through the chat round itself. Before each round goes out, the chat dispatch takes the chain's pending notices, appends each to the author's message list as a user message, and only then refuses a list that is still empty, so pending notices can fill an empty list. The separate drain request the loop yielded ahead of every round goes away, with its answer, its parsing and rendering, and the loop machine's drain phase, so a round costs one fewer yield. Because the owning chain no longer waits in the ready queue between its batch and its next round, the order effects are issued in can change across chains.

- `prepare_chat` drains the chain's notices first and pushes each through `notice_record` before it reads `list.records()`, so the notices are part of the round's projection and of the send the list commits.
- `MessageList` makes `push` public so the Engine can append to the author's list, and drops `is_empty` along with the parse check that called it.
- `Phase::Draining` leaves the loop machine with `Input::Drained` and `Then::Drain`, so `next_round` starts each round with its `chat` and the machine no longer pushes notice records itself.
- `parse_chat` now accepts an empty list. The dispatch refuses it after the notice push with the same `messages must not be empty` text, so the refusal still reaches the call site as the call's error.
- `prepare_chat` keeps pushed notices on the list whatever the round's outcome, so a later failure in the same dispatch does not lose them. A refused push, which a user record never triggers, is an internal error.
- `answer_inline` no longer sits between a batch and the next round: the owning chain's `chat` is issued in the same scheduler step that finished its batch, instead of after the drain answer sent the chain to the back of the ready queue. The order effects are issued in can change when other chains are ready.
- `ROUND_INSTRUCTION_CEILING` drops to 60 and the floor to 2, because a loop round now spends two yields instead of three and measured 20 instructions per round.
- `Request::DrainTaskNotices` and `Answer::DrainTaskNotices` are deleted with their parse, render, error-mapping, and dispatch arms and `dispatch_drain_task_notices`. A `drain_task_notices` yield now parses as malformed.
- `an_empty_list_is_the_calls_error` and the matching messages test are deleted, and this change adds no test of the dispatch's empty-list refusal.
- `model_task_notices.rs` and the other notice-timing tests change only their comments; no scripted reply changes order.

Design: new pure-function @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::notice_record deps: String
Design: extends oversized-unit @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::prepare_chat
Design: new surface-growth @ crates/promptforge-internal/lua/src/messages.rs::MessageList::push
  boundary: pub
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
A model round's result now passes through Lua as the scheduler's own typed value, wrapped as an opaque object that no Lua code can read. The loop's adapter takes that value back out whole instead of rebuilding it by key name from a rendered table, so no renderer and reader have to agree on a table shape. Fields the reader used to leave empty, such as the serving model, the round's metrics, and each requested call's tool, now arrive as the scheduler set them. The table renderer, its reader, and the tests that pinned the table's shape are deleted.

- `ChatResult` implements `mlua::UserData` with no methods, so the type itself is the userdata and Lua code can hold it but not read its fields. Its nine public fields stay as they are on the Rust side.
- `into_envelope` moves a successful chat answer's boxed result into a new userdata as the resumed value, in place of the table it used to build.
- `read_input` unpacks that value as an `AnyUserData` and calls `take::<ChatResult>()`, so the loop gets the scheduler's value itself rather than a copy rebuilt by key.
- `chat_result_table` and `chat_result` are deleted together, along with `tool_call` and `optional_text`, which only the reader called. The overflow reason no longer crosses this answer as a tag string that is parsed back.
- `take::<ChatResult>()` moves the value out, so the userdata is empty afterward and any later borrow of it fails, which the new adapter test asserts.
- `read_input` now fails with mlua's own unpack or type-mismatch error, not a `malformed` message, when a chat answer is not a `ChatResult` userdata.
- `model`, `metrics`, and each call's `tool` now reach the loop as the scheduler set them. The reader set them to empty values.
- `reply` set to an empty string now reaches the loop unchanged, because the rule that dropped it lived in `chat_result_table`.
- `answer_chat.rs` keeps only the error round-trip test, and its eight table-shape tests are deleted. The one new userdata test covers a tool-calls round, and no test here covers an overflow round or an empty reply string on the userdata path.

Design: extends bag-of-state @ crates/promptforge-internal/lua/src/protocol/answer.rs::ChatResult
  boundary: pub
  instead-of: newtype: a wrapper type around the result
Design: removes parallel-abstraction @ crates/promptforge-internal/lua/src/protocol/render.rs::chat_result_table deps: ChatResult,Lua
Design: removes feature-envy @ crates/promptforge-internal/lua/src/models_loop.rs::chat_result deps: Lua,Value
Design: removes pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::tool_call deps: Lua,Table
Design: removes pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::optional_text deps: Value
Design: removes clone-block @ crates/promptforge-internal/lua/src/protocol/tests/answer_chat.rs
Deferred: Keeping a model-issued bound tool's text out of Lua awaits retiring or rebuilding the tools.call_as_model hook.
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
Plan: vibe/2026-10-09-3-models-loop-in-rust.md
The coroutine shim raised every failure through whatever the global error-raising function was at the moment of the raise. Author code that replaced that function with one that returns turned a refused loop call into a spin instead of a raised error. The shim now captures the base function once at install, so its raises behave the same whatever author code rebinds later. It also removes one entry from the table the shim returns and the guards that skipped installing into missing namespace tables.

- `local error = error`: The chunk captures the base `error` at install, beside the raw `pcall` and `xpcall`. Every raise in the chunk, including the trampoline's, now uses it, so a later rebind of `error` cannot change how the shim raises.
- `models.infer = infer`: The chunk sets `models.infer` and `tools.call` unconditionally and drops the guards that skipped them for nil tables.
- `infer = infer,`: The chunk's return table no longer carries `infer`. The handle's own `infer` under `handle_methods` stays.
- `the_list_error_still_raises_when_the_author_has_rebound_error`: The test rebinds `error` to a no-op, runs `pcall(models.loop, 42)`, and arms a cancel for one second later. It asserts that the call returns the list error text uninterrupted and sends no request.

Repairs: shim raises ignore author rebinds of error @ crates/promptforge-internal/lua/src/__impl_coro.lua::error - a refused models.loop call spun instead of raising its error
Plan: vibe/2026-10-09-4-models-loop-debt-removal.md
The chat round result stops carrying the serving model and the round's metrics, since neither had a reader. The scheduler still takes both from the served completion where it needs them. Docs that still credited the loop shim now name the loop's state machine, the reply doc says how an empty reply can arrive, and the message list doc says which records skip validation. The loop tests keep a single copy of their two shared test fixtures.

- `ChatResult` drops `model` and `metrics` with their docs, and the four scheduler builders and the test literals drop the matching lines. Seven public fields remain.
- `call` and `round` now exist once, as `pub(super)` fixtures in `models_loop/tests.rs`, and `machine-tests.rs` imports them through `super::super::tests` instead of keeping its own copies.
- `served.model` still feeds the emitter's assistant tool-call event and `served.metrics` still settles the chain anchor with the round's usage. The scheduler keeps both values at the sites that use them.
- `MessageList::push` is now documented as the path for records the Engine adds itself, from the loop's state machine and the chat dispatch's notices. Those records skip author-edit validation and get only the leading-system check.
- `reply` is now documented as possibly an empty string when the caller builds the completion, with the loop reading `None` and empty text alike as no reply.
- `PartialEq` stays the only equality derive on `ChatResult`. The comment explaining the missing `Eq` left with `metrics`.
- `turn` still credits the loop shim with passing it back on each requested tool call. Only the compactor, exit-rule, and earlier-rounds wording moved to the state machine.

Design: removes clone-block @ crates/promptforge-internal/lua/src/models_loop/machine-tests.rs
Design: removes pure-function @ crates/promptforge-internal/lua/src/models_loop/machine-tests.rs::call deps: &str
Design: removes pure-function @ crates/promptforge-internal/lua/src/models_loop/machine-tests.rs::round
Plan: vibe/2026-10-09-4-models-loop-debt-removal.md
Plan: vibe/2026-10-09-4-models-loop-debt-removal.md
Model role labels no longer become bare globals in a section's Lua VM, so a script reaches a role's handle only by looking it up by label. Since a label no longer claims a global name, the parser accepts labels that match a reserved name or a tool alias, and a plugin prelude may define a global named like a role label. Tool aliases still install as globals and keep every existing name check. Scripts and tests that read a role label as a bare global now use the lookup.

- `install_captured_bindings` installs only the tool alias globals and no longer locks `bound_models`. Its model half, which raw-set each role's handle under its label, is gone.
- `installs_global` is now false for the `models` map, which turns off the reserved-name refusal for role labels. The label grammar still applies, so `_G` and `_VERSION` stay refused.
- `check_distinct_aliases` is deleted along with its call during parsing, so one name may be both a tool alias and a role label.
- `frontmatter_aliases` returns tool aliases only, so a prelude global named like a role label installs beside the role's handle instead of failing the run.
- `models.get(label)` returns the role's handle in both the section VM and the H1 VM, and a role labeled `tools` leaves the Engine's `tools` table in place.
- `check_collision` and the reserved-name refusal now word their errors around tool aliases only. Tests that match the old wording are updated.
- `writer:infer` and other calls through a bare role label now fail, because the label names no global unless the script or a prelude defines one.

Design: removes pure-function @ crates/promptforge-internal/parser/src/contract.rs::check_distinct_aliases deps: ModelRoles,ToolSlots
Design: new surface-growth @ crates/promptforge-internal/parser/src/contract/models.rs::ModelRoles boundary: pub
Design: new pure-function @ crates/promptforge-internal/engine/src/lua/tests/globals.rs::roles
Plan: vibe/2026-10-09-5-author-api-reshape.md
The Workshop prompt contract no longer has a tools section, and the Run window no longer shows a read-only row for each tool slot. The server stops reading tool slots from parsed frontmatter, so those slots can be retired without further Workshop changes, while declared plugins still appear in the contract. The client stops requiring the section, and the server and UI tests now check that it is absent. An engine test's module doc now names the shipped chat agent instead of a Host application.

- `ContractResponse` drops its `tools` field, and `ToolDto` goes with its slot-to-DTO constructor, so the `/prompts/contract` response has no `tools` key. The response is unversioned, so a client that still requires a `tools` array would reject it as an unexpected shape.
- `parseContract` no longer requires a `tools` array, and `RunContract` loses its `tools` field along with `RunContractTool` and `parseTool`.
- `renderContractRows` no longer appends `ws-run-panel__row--tool` rows.
- `the_contract_has_no_tools_section` checks that the full fixture, which no longer declares `tools:`, answers without a `tools` key. The kind-tag test becomes `the_dto_serializes_absent_declarations_as_nulls` and keeps only its null checks.
- `crates/workshop/ui/test/run-panel.mjs` checks that the ready panel renders no tool row, and `crates/workshop/ui/test/run-api.mjs` checks that the narrowed contract has no `tools` property.
- `crates/promptforge-internal/engine/src/execute/tests/models_loop-author-shapes.rs` names the shipped chat agent in its module doc instead of the Workshop.
- `unexpected_shape` checks for an unknown tool kind and the retired fuzzy kind leave the run API test, and the run panel test drops its fuzzy-contract case, since the contract has no tool entries left to reject.

Design: new schema-change @ crates/workshop/server/src/routes/prompts.rs::ContractResponse
  boundary: wire
Design: removes pure-function @ crates/workshop/server/src/routes/prompts.rs::ToolDto::new deps: ToolSlot,str
Design: removes pure-function @ crates/workshop/ui/src/services/run-api.ts::parseTool deps: unknown
Design: removes oversized-unit @ crates/workshop/ui/src/parts/run/run-rows.ts::renderContractRows deps: HTMLElement,RunContract,RunRowsOptions
Plan: vibe/2026-10-09-5-author-api-reshape.md
Prompts no longer declare tool slots in their frontmatter, and no tool becomes a Lua global. Every catalog tool the run can offer is bound under a wire name derived from its id, and scripts name tools by canonical id or by a shared read-only tool object. The model still sees only wire names, while call counts, error texts, and the author functions for offering, listing, and calling tools now speak in ids. Removing the slots also deletes the slot fill, its missing-tool report, and the alias collision checks, and drops the slot types from the public API.

- `ToolSet` now records the declared Plugins in place of slot bindings, and its offering holds every catalog tool, declared Plugin or not, under its wire name.
- `offered_binding` resolves a name containing `/` as a canonical id and any other name as a wire name, so one lookup serves scoping, model calls, and script calls.
- `TOOL_OBJECTS_REGISTRY` keeps one frozen tool object per offered tool in each VM, so `tools.required()`, `tools.extras()`, and `tools.get` return the same userdata and a tool compares equal across them.
- `LuaToolHandle` exposes only `id` and the catalog `description`, and stands for its id wherever a tool argument is accepted.
- `tools.offer` and `tools.always_offer` take canonical ids, tool objects, or arrays of them, check every entry before recording any, and refuse a wire name, a malformed id, or a local alias.
- `tools.call` from a script resolves a local alias, then a canonical id against the offering and then the full catalog; a model call resolves only by wire name.
- `EffectRecord::ToolCall` now carries the wire name as `alias` for every offerable tool, and the id only for a catalog tool the run could not offer.
- `tools.calls` keys a catalog tool by its id and a local tool by its alias, so a model call and a script call of one tool count under one key.
- `current_tool_bindings` drops a section offer that is already offered prompt-wide, so each tool appears once in a round's scope.
- `tools.offer_local` accepts an alias equal to an offered wire name, and the local tool wins in scope.
- `Frontmatter` drops its `tools` field, so a leftover `tools:` key now fails to parse as an unknown field.
- `tools.add`, `tools.always`, `tools.offered`, and `tools.add_local` are no longer installed, and no tool becomes a Lua global.
- `ToolBindings`, `ToolSlot`, `ToolSlots`, `Frontmatter::tools`, `RunContext::tool_bindings`, and `Requirements::missing_tools` leave the public API with the slot fill that fed them.
- `install_preludes` no longer takes the alias list, so a prelude global may share a name with an offered tool's wire name.

Design: removes parallel-abstraction @ crates/promptforge-internal/engine/src/execute/bindings.rs::ToolBindings
  boundary: pub
Design: removes pure-function @ crates/promptforge-internal/engine/src/execute/bindings.rs::tests::fixture deps: ToolId
Design: removes surface-growth @ crates/promptforge-internal/engine/src/execute/config.rs::RunContext::tool_bindings
  boundary: pub
Design: extends surface-growth @ crates/promptforge-internal/engine/src/execute/context-bound.rs::offered_bindings deps: RunContext
  boundary: wire
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/context-bound.rs::offer_refusal deps: BTreeSet<String>,ToolDescriptor,str
Design: removes pure-function @ crates/promptforge-internal/engine/src/execute/context-bound.rs::frontmatter_aliases deps: Prompt
Design: removes stringly-typed @ crates/promptforge-internal/engine/src/execute/context-bound.rs::frontmatter_aliases deps: Prompt
Design: removes pure-function @ crates/promptforge-internal/engine/src/execute/requirements-tests.rs::tool deps: str
Design: removes surface-growth @ crates/promptforge-internal/engine/src/execute/requirements.rs::Requirements::missing_tools
  boundary: pub
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs::unbound_tool_call deps: ToolSet,str
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/tests/full_id_calls.rs::full_id_prompt deps: str
Design: new pure-function @ crates/promptforge-internal/engine/src/lua/tests/globals.rs::offering
Design: new stringly-typed @ crates/promptforge-internal/lua/src/handles.rs::ToolSet::offered_binding
Design: extends speculative-abstraction @ crates/promptforge-internal/lua/src/handles.rs::ToolView
Design: extends shotgun-surgery @ crates/promptforge-internal/lua/src/handles.rs::ToolSet::for_test
Design: extends pure-function @ crates/promptforge-internal/lua/src/prelude.rs::check_collision deps: BTreeMap<String,&PluginId>,PluginId,Table,str
Design: extends pure-function @ crates/promptforge-internal/lua/src/tests.rs::fixture_set deps: [&str],[(&str,&str)]
Design: removes oversized-unit @ crates/promptforge-internal/lua/src/tools.rs::install_tools deps: Arc<Mutex<ToolRuntime>>,Arc<Mutex<ToolSet>>,LocalTools,Lua,Table
Design: new surface-growth @ crates/promptforge-internal/lua/src/tools.rs::install_offer deps: Arc<Mutex<ToolRuntime>>,Arc<Mutex<ToolSet>>,Lua,Table
  boundary: pub
Design: new shared-parameter-cluster @ crates/promptforge-internal/lua/src/tools.rs::install_offer deps: Arc<Mutex<ToolRuntime>>,Arc<Mutex<ToolSet>>,Lua,Table
Design: new surface-growth @ crates/promptforge-internal/lua/src/tools.rs::install_always_offer deps: Arc<Mutex<ToolSet>>,Lua,Table
  boundary: pub
Design: new shared-parameter-cluster @ crates/promptforge-internal/lua/src/tools.rs::install_always_offer deps: Arc<Mutex<ToolSet>>,Lua,Table
Design: replaces surface-growth @ crates/promptforge-internal/lua/src/tools.rs::install_offer_local deps: LocalTools,Lua,Table
  boundary: pub
  was: crates/promptforge-internal/lua/src/tools.rs::install_add_local
Design: replaces shared-parameter-cluster @ crates/promptforge-internal/lua/src/tools.rs::install_offer_local deps: LocalTools,Lua,Table
  was: crates/promptforge-internal/lua/src/tools.rs::install_add_local
Design: new pure-function @ crates/promptforge-internal/lua/src/tools.rs::resolve_entries deps: ToolSet,Vec<ToolsAddEntry>,str
Design: replaces pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::entry_name deps: Value,str
  was: crates/promptforge-internal/lua/src/tools/decode.rs::add_alias
Design: extends pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::collect_tools_add_entries deps: Variadic<Value>,str
Design: removes pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::record_name deps: Table
Design: replaces surface-growth @ crates/promptforge-internal/lua/src/tools/objects.rs::install_tool_lists deps: Arc<Mutex<ToolSet>>,Lua,Table
  boundary: pub
  was: crates/promptforge-internal/lua/src/tools.rs::install_offered
Design: replaces shared-parameter-cluster @ crates/promptforge-internal/lua/src/tools/objects.rs::install_tool_lists deps: Arc<Mutex<ToolSet>>,Lua,Table
  was: crates/promptforge-internal/lua/src/tools.rs::install_offered
Design: new temporal-coupling @ crates/promptforge-internal/lua/src/tools/objects.rs::tool_object deps: Lua,str
Design: new pure-function @ crates/promptforge-internal/lua/src/tools/tests-offering.rs::plain deps: str
Design: removes temporal-coupling @ crates/promptforge-internal/lua/src/vm/install.rs::SectionVm::install_captured_bindings
Design: extends pure-function @ crates/promptforge-internal/lua/src/vm/state.rs::binding_for_scope deps: ToolRuntime,ToolSet,str
Design: new schema-change @ crates/promptforge-internal/parser/src/build-frontmatter.rs::Frontmatter
  boundary: persisted
Design: removes surface-growth @ crates/promptforge-internal/parser/src/build-frontmatter.rs::Frontmatter::tools
  boundary: pub
Design: new newtype @ crates/promptforge-internal/parser/src/contract.rs::ContractKeys
Design: removes surface-growth @ crates/promptforge-internal/parser/src/contract.rs::ToolSlot
  boundary: pub
Design: removes surface-growth @ crates/promptforge-internal/parser/src/contract.rs::ToolSlots
  boundary: pub
Design: removes pure-function @ crates/promptforge/tests/suite/prepare.rs::web_descriptor deps: str,str
Design: new pure-function @ crates/promptforge/tests/suite/shipped.rs::web_tool deps: str
Plan: vibe/2026-10-09-5-author-api-reshape.md
Prompt scripts can now see which Plugins a run has. A new global table lists the Plugins the frontmatter declares and every other Plugin that offers a tool, and it looks up one Plugin by name. Each Plugin is a single shared read-only object per VM whose tool list holds the run's shared tool objects, so a script can offer all of a Plugin's tools in one call. Every tool object now also points to its Plugin's object, and the new global's name is reserved.

- `PLUGIN_OBJECTS_REGISTRY` holds one `LuaPluginHandle` per Plugin in each VM, keyed by name and built once at install. `plugins.required()`, `plugins.extras()`, `plugins.get`, and a tool object's `plugin` getter all hand out that one userdata, while each list call builds a fresh array.
- `LuaPluginHandle` keeps its tools as id strings in id order and looks each one up through `tool_object` on every read of `tools`, so the array holds the shared tool objects and passes straight to `tools.offer`.
- `plugin_object` is called by the tool object's `plugin` getter in the tools module, while the plugins module calls `tool_object` from the tools module, so the two modules now depend on each other.
- `install_plugins` runs right after `install_tools` in `SectionVm` setup and takes the same VM, globals, and shared `ToolSet` arguments.
- `plugins.required()` lists every declared Plugin in name order, including one that offers no tool. `plugins.extras()` lists every other Plugin with an offered tool, also in name order.
- `plugins.get` returns nil for an unknown name or a tool id, and refuses a non-string argument with an error naming the type it got.
- `MetaMethod::NewIndex` on a Plugin object refuses every assignment, to an existing field or a new one, with `Plugin objects are frozen: cannot assign field {key:?}`.
- `plugin_object` returns nil when the VM has no plugin-object table, so a tool object's `plugin` reads nil instead of raising in a VM where the table was never installed.
- `RESERVED_NAMES` lists `plugins` as an Engine global and grows to 61 entries.
- `a_script_offers_a_declared_plugins_tools_and_the_model_calls_one_by_its_wire_name` and the extra-Plugin test after it check that offering a Plugin's `tools` advertises only that Plugin's wire names, and that the model's call is recorded under the canonical id with the wire name as its alias. A third test reads the table in the H1 VM.
- `LuaPluginHandle` has no methods and no `__eq`, so two Plugin objects compare equal only when they are the same shared userdata.
- `plugin_tools` gives an undeclared Plugin an entry only when it offers a tool, so `plugins.get` returns nil for one that offers none.

Design: new pure-function @ crates/promptforge-internal/engine/src/execute/tests/offering.rs::rounds_and_calls deps: [EffectRecord]
Design: new pure-function @ crates/promptforge-internal/engine/src/execute/tests/offering.rs::model_call deps: str,str,str
Design: new clone-block @ crates/promptforge-internal/engine/src/execute/tests/offering.rs::a_script_offers_an_extra_plugins_tools_after_checking_plugins_get
Design: new pure-function @ crates/promptforge-internal/lua/src/plugins-tests.rs::tool_at deps: str
Design: new pure-function @ crates/promptforge-internal/lua/src/plugins-tests.rs::plugin_set
Design: new pure-function @ crates/promptforge-internal/lua/src/plugins-tests.rs::lua_over deps: ToolSet
Design: new stringly-typed @ crates/promptforge-internal/lua/src/plugins.rs::LuaPluginHandle
Design: new temporal-coupling @ crates/promptforge-internal/lua/src/plugins.rs::plugin_object deps: Lua,str
Design: new stringly-typed @ crates/promptforge-internal/lua/src/plugins.rs::plugin_object deps: Lua,str
Design: new surface-growth @ crates/promptforge-internal/lua/src/plugins.rs::install_plugins deps: Arc<Mutex<ToolSet>>,Lua,Table
  boundary: pub
Design: new shared-parameter-cluster @ crates/promptforge-internal/lua/src/plugins.rs::install_plugins deps: Arc<Mutex<ToolSet>>,Lua,Table
Design: new pure-function @ crates/promptforge-internal/lua/src/plugins.rs::plugin_tools deps: ToolSet
Design: new cyclic-dependency @ crates/promptforge-internal/lua/src/tools/userdata.rs
Design: extends surface-growth @ crates/promptforge-internal/lua/src/tools/userdata.rs::LuaToolHandle
  boundary: pub
Plan: vibe/2026-10-09-5-author-api-reshape.md
Model catalog entries can now name the provider a model comes from, and the gateway's model list reports it only for models that set one. The provider describes the model itself, not the endpoint the gateway reaches it through, which gives clients the input they need to adapt per-model behavior such as tool naming. The configuration loader refuses a provider that uses anything other than lowercase letters, digits, dots, underscores, or hyphens, for both remote and local models.

- `Capabilities` gains a public `provider: Option<String>` field, so it parses on both model tables and appears on each catalog entry through the existing flattening.
- `is_valid_provider` holds the `[a-z0-9._-]+` grammar as a byte-level check, and `validate_capabilities` calls it for both model kinds with the same `{label} {name}` error prefix as its other checks.
- `/v1/models` reports `provider` for a model that sets it and omits the key, never emitting null, for one that does not.
- `provider` values such as `x.ai`, `open_router`, and `gpt4` load, while an empty string, uppercase letters, spaces, `/`, and `:` fail validation.
- `Capabilities` gets no `provider()` accessor, unlike its other eight fields, so the validator and tests read the public field directly.

Design: extends surface-growth @ crates/gateway-api-types/src/metadata.rs::Capabilities boundary: wire
Design: extends bag-of-state @ crates/gateway-api-types/src/metadata.rs::Capabilities boundary: pub
Design: new oversized-unit @ crates/gateway/config/src/config/validate.rs::validate_capabilities deps: Capabilities,ThinkingMode,str,u32
Design: extends pure-function @ crates/gateway/config/src/config/validate.rs::validate_capabilities deps: Capabilities,ThinkingMode,str,u32
Design: new pure-function @ crates/gateway/config/src/config/validate.rs::is_valid_provider deps: str
Design: new clone-block @ crates/gateway/app/tests/it/chat/catalog.rs::models_catalog_reports_a_provider_only_when_configured
Plan: vibe/2026-10-09-5-author-api-reshape.md
Model handles now report which provider serves the model, so prompt scripts can tell providers apart. The client reads an optional provider from each gateway catalog entry and carries it through the catalogued model and the run's role binding to the handle. An entry that names no provider decodes as before, and its handle reports nil.

- `ModelDescriptor` and `ModelBinding` each store the provider in a private field set through a consuming `with_provider`, so both `new` constructors keep their signatures. The public API listing gains exactly the two setters and the two getters.
- `provider: Option<String>` is the representation from the wire entry through the descriptor and the binding. No dedicated id type wraps it.
- `fetch_model_catalog` attaches a provider only when the entry carries one, so entries without the field decode as before.
- `bound_model_set` copies the descriptor's provider onto a role's binding only when the catalog names one.
- `LuaModelHandle` exposes `provider` as a field getter that returns the catalog's id, or nil when the catalog names none.
- `ModelsListEntry` accepts any string as `provider`. The client neither checks its shape nor rejects an empty value.

Design: new surface-growth @ crates/promptforge-internal/types/src/models.rs::ModelDescriptor::with_provider boundary: pub
Design: new stringly-typed @ crates/promptforge-internal/types/src/models.rs::ModelDescriptor::with_provider boundary: pub
Design: new surface-growth @ crates/promptforge-internal/types/src/models.rs::ModelDescriptor::provider boundary: pub
Design: new surface-growth @ crates/promptforge-internal/model-client/src/model/options.rs::ModelBinding::with_provider boundary: pub
Design: new stringly-typed @ crates/promptforge-internal/model-client/src/model/options.rs::ModelBinding::with_provider boundary: pub
Design: new surface-growth @ crates/promptforge-internal/model-client/src/model/options.rs::ModelBinding::provider boundary: pub
Design: extends surface-growth @ crates/promptforge-internal/lua/src/models-userdata.rs::LuaModelHandle boundary: pub
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/context-bound.rs::bound_model_set deps: Prompt,RunContext
Plan: vibe/2026-10-09-5-author-api-reshape.md
Plan: vibe/2026-10-09-5-author-api-reshape.md
The wording scan that guards Engine vocabulary, the rulebook definitions, and Plugin lifecycle terms ran in neither the pre-push hook nor the repository's verification list, so an Engine change could pass every local check while breaking those rules. The hook now runs the scan before anything else, and the verification list names it, so a violation fails on the developer's machine before it reaches master.

- `.githooks/pre-push` runs `node --test crates/workshop/ui/test/docs-claims.mjs` as its first step, ahead of the headless gateway check and Clippy, so a wording violation stops the push before any Cargo work starts.
- `AGENTS.md` lists the scan under `## Verification` and records that it covers the Engine wording rule, the rulebook Definitions, and Plugin lifecycle wording, and that it needs no `npm ci`.
- `node --test crates/workshop/ui/test/docs-claims.mjs` runs with no `command -v` guard, unlike the `cargo-deny` step. Under the hook's `set -e`, a machine without Node fails the push instead of silently skipping the scan.

Plan: vibe/2026-10-10-1-author-api-debt-removal.md
When a model calls a tool its section did not advertise, the out-of-scope error adds a hint if the name belongs to a tool the run offers. That check also matched canonical ids, so naming an advertised tool by its id drew a false claim that the section had not offered it. The hint now requires the wire name of an offered tool. Model tool-call dispatch and the hint now share one wire-name lookup, so the two rules cannot drift apart, and dispatch behaves as before.

- `wire_binding` is the single lookup for model-supplied tool names. It returns the offered binding whose wire name equals the name and never matches a canonical id.
- `tool_loop-scope-gate.rs` holds the scope-gate tests as a child module of the tool loop tests, and they share one `scoped_and_global_tools` fixture for the scoped and unscoped tools.
- `global_exists` now asks `wire_binding` instead of `offered_binding`. A canonical id such as `tools/scoped` reports false, so the error ends at the in-scope list with no not-offered hint.
- `tool_set.wire_binding(alias)` replaces the id-or-wire lookup plus alias filter in model-call dispatch. Wire names never contain a slash and ids always do, so the filter already rejected every id match and dispatch behaves as before.
- `model_calling_an_advertised_tool_by_canonical_id_gets_no_not_offered_suffix` pins the fix: it asserts `global_exists` is false and the hint is absent for an advertised tool's id.
- `offered_binding` is unchanged and still serves script calls, which may name a catalog tool by canonical id.
- `OutOfScopeToolCall` keeps its error text; only the `global_exists` field doc narrows to wire names.

Design: new pure-function @ crates/promptforge-internal/engine/src/execute/tests/tool_loop-scope-gate.rs::scoped_and_global_tools
Design: replaces clone-block @ crates/promptforge-internal/engine/src/execute/tests/tool_loop-scope-gate.rs::model_calling_an_offered_but_unscoped_tool_is_a_hard_error
  was: crates/promptforge-internal/engine/src/execute/tests/tool_loop.rs::model_calling_an_offered_but_unscoped_tool_is_a_hard_error
Design: replaces clone-block @ crates/promptforge-internal/engine/src/execute/tests/tool_loop-scope-gate.rs::model_calling_pure_unknown_tool_is_a_hard_error
  was: crates/promptforge-internal/engine/src/execute/tests/tool_loop.rs::model_calling_pure_unknown_tool_is_a_hard_error
Design: new clone-block @ crates/promptforge-internal/engine/src/execute/tests/tool_loop-scope-gate.rs::model_calling_an_advertised_tool_by_canonical_id_gets_no_not_offered_suffix
Repairs: global_exists is true only for an offered tool's wire name @ crates/promptforge-internal/engine/src/execute/scheduler/chat.rs::global_exists - a model naming an advertised tool by canonical id got the not-offered hint
Plan: vibe/2026-10-10-1-author-api-debt-removal.md
Several Lua crate internals were named for adding tools and for aliases, though they serve the offer calls and hold the name a tool argument stands for. This change renames the shared argument decode, the offer entry type, the local parameter schema builder, and the call counter's method and parameters, along with their tests. Behavior is unchanged except for one internal invariant message, which now says name instead of alias.

- `tool_name` replaces `tool_alias` as the one decode from a string or tool object to the name it stands for. `collect_offer_entries` and `local_params_schema` replace `collect_tools_add_entries` and `add_local_params_schema`, and every caller and import follows, including the `tools.call` parse.
- `OfferEntry` replaces `ToolsAddEntry`, and its `alias` field becomes `name`. `resolve_entries` parses that field as a tool id.
- `ToolCallCounts` renames its public `aliases` method to `names` and the `alias` parameters of `new`, `ensure`, `increment`, and `get` to `name`. The `tools.calls` index error path follows.
- `increment` now reports an unseeded key as `tool call counts: name {name:?} was not pre-seeded`. It is the only string literal this change alters.
- `binding.alias()`, the `tools.offer_local alias` registration error, and the `tools.call` argument error keep the word alias, since there it names a binding field or a local tool.
- `tool_name_accepts_a_bare_string` and the five other renamed tests assert the same values as before, and no test is added.

Design: replaces pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::tool_name deps: Value
  was: crates/promptforge-internal/lua/src/tools/decode.rs::tool_alias
Design: replaces pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::collect_offer_entries deps: Variadic<Value>,str
  was: crates/promptforge-internal/lua/src/tools/decode.rs::collect_tools_add_entries
Design: replaces pure-function @ crates/promptforge-internal/lua/src/tools/decode.rs::local_params_schema deps: mlua::Table
  was: crates/promptforge-internal/lua/src/tools/decode.rs::add_local_params_schema
Plan: vibe/2026-10-10-1-author-api-debt-removal.md
Correct the documentation of the tool performer's call method, which still described the alias argument as the prompt-local name the call used. It now says the alias is the tool's wire name, or the tool's canonical id when the run could not offer the tool, and that it is there for the performer's own diagnostics. Only the comment changes; the method signature and argument names stay the same.

- `ToolPerformer` - The `call` doc now names `alias` as the tool's wire name, or its canonical id when the run could not offer the tool, and no longer calls it the prompt-local name. The `alias` parameter and the method signature are unchanged.

Plan: vibe/2026-10-10-1-author-api-debt-removal.md
Plan: vibe/2026-10-10-1-author-api-debt-removal.md
@vinniefalco vinniefalco changed the title Back messages.new() with Rust and log only each round's new messages Rust-backed messages and models.loop, tools by canonical id, and a plugins table Oct 10, 2026
@vinniefalco
vinniefalco merged commit 8a39ee8 into cppalliance:master Oct 10, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant