Skip to content

Prompt section reads, a Plugin package registry, capped parallel tool calls, and tasks.fanout - #142

Merged
vinniefalco merged 34 commits into
cppalliance:masterfrom
vinniefalco:master
Oct 12, 2026
Merged

vinniefalco merged 34 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

This branch lands nine plans, in 34 commits, on how prompts read their own sections, how Hosts install Plugins, and how a model's tool calls run.

  • Prompt reads. Section code gets a prompt table with two reads. prompt.section(heading) returns a visible section's own raw Markdown, and prompt.list(heading) returns a list section's items. Both return nil when no visible section has that heading, so an optional read needs no pcall and headings can serve as an index. They replace list_from_section, which could read only list sections and raised on every miss. One lookup, find_visible, now answers every heading lookup, and call and jump keep their not-found error on top of it.
  • Prompt reads debt removal. A debt review of the reads led to a cleanup with no behavior change. The parser's section builder splits into named checks, H1's visible set gets one helper, resolve_sibling becomes resolve_visible, two stale docs about reserved tool aliases are corrected, and closed plan records in vibe/ stop listing their todos as pending.
  • Package registry. Hosts used to install a Plugin by passing its compile-time Package constant, so nothing could list the available Plugins or install one by name. Now a Host adds packages to a PackageRegistry, lists it, and installs by package name. Every registry holds the Engine-native promptforge/subagents package, which reserves the subagents name so no other Plugin can take it, and offers nothing yet.
  • Native docs and in-place reads. A second debt review corrects the native package's text, which claimed it let the model start, wait for, and cancel subagents, and fixes the UnknownPackage advice, which named a step a Host can no longer take. It also stops the Engine from copying sections: every section entry and every jump, call, or fanout target lookup used to clone the sections it could address, subtrees and all. The parser now keeps each prompt body as one shared string that sections point into by byte range, and every lookup reads that shared tree in place.
  • Parallel tool calls and tasks.fanout. When a model asked for several tools in one models.loop round, the loop used to run them one at a time. Now the Engine issues the round's catalog calls together, so they run at the same time, and the loop still appends their results once, in the order the model asked for them. Local Lua tools and the task built-ins still run alone. The same plan moves the fanout global onto the tasks table as tasks.fanout(worker, collection), with no compatibility stub, so prompts that call fanout must be updated.
  • Workshop waits. Parallel calls made two Workshop agent panel bugs reachable. A sibling call's result switched the composer to Stop while the model's question to the operator was still open, and a second question in the same round stranded the first. The agent session service now keeps every open question in order, offers the oldest, and keeps the composer on Send while any question is open.
  • Easy fixes. A follow-up cleanup with no behavior change for prompts, Plugins, or Hosts. The scheduler decides a tool call's route in one place instead of two, a private CallSite enum replaces three loose options, the ToolContext docs state the rule for sibling calls, and the tool_call parse tests move into a file of their own.
  • Tool-call cap. Parallel calls had no ceiling: one round could ask for 50 fetches, or eight fanout arms could each ask for ten. Now a run has at most ten tool calls running at once, and the rest wait. When a slot frees, the call issued earliest starts, so the oldest batch finishes first and nothing starves. Model rounds and timers never wait. Each Engine step also lists the tool calls it withdrew when a task ended early, and the Harness drops them, running or waiting, instead of letting them run until they land.
  • Tool-call cap debt removal. A debt review of the cap found one debt: the public Plugin contract still said only a cancel could drop a call whose tool survives stops, but a withdrawal now drops one while the run goes on. Plugin::call now holds the one complete list of drop occasions, and the other public copies point to it. Two doc edits ride along: a rule that a call must not wait for another call of the same run, and "in flight or waiting" in RunControl::stop_round. A new test pins that cancelling a task drops its question to the operator.

Plans:

  • vibe/2026-10-10-2-prompt-namespace-reads.md
  • vibe/2026-10-10-3-prompt-reads-debt-removal.md
  • vibe/2026-10-11-1-package-registry.md
  • vibe/2026-10-11-2-native-docs-debt-removal.md
  • vibe/2026-10-11-3-parallel-tool-calls.md
  • vibe/2026-10-11-4-workshop-waits.md
  • vibe/2026-10-11-5-easy-fixes.md
  • vibe/2026-10-11-6-tool-call-cap.md
  • vibe/2026-10-11-7-tool-call-cap-debt.md

Changes

Prompt reads:

  • Keep each section's raw Markdown in the parser
  • Split find_visible out of resolve_sibling
  • Replace list_from_section with prompt.section/list

Prompt reads debt removal:

  • Split build_sections into named check helpers
  • Give H1's visible set one helper, rename resolve_sibling
  • Mark every finished record's open tasks completed
  • Rename fanout-arm test and fix reserved-alias docs
  • Tidy up vibe/ plans (no plan; it only moves 14 October plan records into vibe/2026-10/)

Package registry:

  • Add a one-line description to each Plugin package
  • Install Plugins by name from a package registry

Native docs and in-place reads:

  • Correct the native subagents and Plugin package docs
  • Make the UnknownPackage advice followable
  • Resolve jump, call, and fanout targets in place
  • Read prompt sections in place at section entry
  • Borrow parser intermediates from the prompt body
  • Store each prompt's body once and read its text in place

Parallel tool calls and tasks.fanout:

  • Run one round's catalog tool calls in parallel
  • Move fanout into the tasks table as tasks.fanout

Workshop waits:

  • Keep Workshop waits answerable when calls run in parallel

Easy fixes:

  • Decide a tool call's route once and type its call site
  • Document sibling calls and split the tool_call parse tests

Tool-call cap:

  • Report the tool calls each step withdraws
  • Run at most the cap of tool calls, oldest first

Tool-call cap debt removal:

  • Document every occasion that drops a Plugin call
  • Pin that cancelling a task drops its operator question

The nine Close plan commits retire each plan's active marker, and all but the first also mark that plan record's todos completed.

Behavior changes

For prompt authors:

  • Breaking: the fanout global is gone, with no stub and no alias, and tasks.fanout(worker, collection) replaces it. A prompt that still calls fanout(...) now fails at run time with a call to a nil global, so every call site must change to tasks.fanout(...). It takes and returns exactly what fanout did: how arms run, how their results are ordered, and when the depth cap fires do not change. It needs no declaration, because tasks is part of the language. fanout also leaves the reserved Engine globals, which drop from 61 to 60.
  • The four errors an author can see from that call now name it by its new path: tasks.fanout's second parameter is a collection; ..., tasks.fanout over an empty collection: no work is likely a bug, tasks.fanout results are read-only, and tasks.fanout recursion exceeded cap of 8, parallel to the call wording. The collection key and member errors still use "fanout" as the name of the operation.
  • Breaking: list_from_section is gone, with no stub, and prompt.list(heading) replaces it. prompt takes its place as a reserved Engine global, so a Plugin prelude can no longer define prompt. The only in-tree callers were four fanout test fixtures, which now call prompt.list.
  • prompt.section(heading) returns the section's own Markdown, from the line after its heading to the next heading of any level, so child sections are left out. Leading blank lines are dropped as whole lines, so an indented first line keeps its indentation, and trailing whitespace is trimmed. Lua fences, thematic breaks, and {{ }} placeholders come back as text, neither run nor rendered. An empty section returns "", and any kind of section can be read, list sections included.
  • Both reads search only the caller's visible set, the same set call and jump use: its siblings minus itself plus its direct children, or the whole top-level slice from H1. A heading outside that set (a niece, a grandchild, or the caller itself) and a heading no section has both read as nil, where list_from_section raised not-found.
  • Both raise, catchably, only when the call itself is wrong: a non-string target, a malformed heading, or an ambiguous one. prompt.list also raises for a visible section with no list items, and that message now names the trimmed heading as written, as in section `## Rules` has no pre-parsed items, instead of the bare section name.
  • Both work in H1 and in a lua shared library. A read at library load time resolves over each replaying VM's own visible set, and a shared function resolves over the visible set of whichever section calls it.
  • Since a miss reads nil, tasks.fanout(worker, prompt.list('## Missing')) fails with the collection error, which reads tasks.fanout's second parameter is a collection; for a list section use prompt.list(heading), which returns nil when no visible section has that heading.
  • call and jump are unchanged, error messages included. A call to a missing heading still raises and lists only the visible sections.
  • Tool calls in one round now run concurrently. In models.loop, a round's unanswered calls go to the Engine as one batch, even a lone call, and the Engine issues the leading catalog calls in one step. The loop resumes once every issued call has landed and appends one assistant record and then the tool records in call order, so the message list never shows a half-answered round. TOOL_CALL_* and ToolResult events fire in landing order, and every ToolResult keeps the round's turn. What models.loop takes and returns does not change, and a round with one catalog call gives the same results as before.
  • A local Lua tool or a task built-in (task, task_cancel, task_status, await_tasks) still runs alone. The batch stops in front of it, it runs by itself, and the calls after it go out as the next batch, so [web_a, web_b, grab, web_c] with grab local runs web_a and web_b together, then grab, then web_c. Script tools.call stays a single call.
  • Failure keeps its serial meaning. A failed catalog call still becomes untrusted failure text in its slot, and a dropped call fills its slot with the cancelled error. A call the round cannot bind stops the batch: the calls before it still run, the loop raises unbound_tool once they land, nothing is appended, and the calls after it are never issued.
  • Same-path writes between sibling calls now conflict. The calls of one round are concurrent store identities, like fanout arms, so one call's write to a path conflicts with a sibling's read or write of it. Two calls in one round that write the same store path now fail the run with a determinism conflict, where before they ran one after another. No shipped Plugin writes the run's store.
  • At most ten tool calls run at once in a run. Calls beyond that wait, across every chain and task of the run, and start in the order they were issued. So the oldest batch finishes first, and a later batch never jumps ahead of an earlier batch's leftovers. Model rounds and timers never wait and never count. Results, their order, and what models.loop returns do not change.
  • Cancelling a task abandons every call it has out, and so does its owner ending or an ancestor's abort. The Harness now drops those calls at once, running or waiting, instead of letting them run until they land, and the Engine still discards their answers. stop_round drops every call of an in-flight batch and every waiting call, unstarted. A loop under pcall catches the drop as cancelled.

For Plugin authors:

  • A Plugin tool can now have several calls from the same model round in flight at once, including several calls to the same tool, up to the run's cap of ten tool calls across every tool. Each call still forks its own store access and ends it when answered. The ToolContext docs now say that calls from one round fork from the calling chain together, so one call's write to a path conflicts with a sibling's read or write of it.

  • Plugin::call's invariants now list every occasion the Harness drops a call's future:

    • a stop;
    • the run's end, a cancel included;
    • the early end of the task that made the call: its cancel, its owner's end, or an ancestor's abort.

    Only a stop spares a call whose tool sets survives_stop. That last occasion is new for such tools. The old text said only a cancel could drop them, but a withdrawn ask is now dropped while the run goes on. The promptforge-plugin crate doc and the plugin-user-input docs now point to that list, and ToolDescriptor::survives_stop says the same in its own words.

  • A new invariant on Plugin::call: a call must not wait for another call of the same run to finish. The Harness holds calls beyond its cap, so such a wait can stall the run until a stop or a cancel. No in-tree tool does this.

For hosts:

  • HostContext::new takes a PackageRegistry by value beside the Host-wide services, and HostContext::install takes a package name as &str instead of a Package. A Host adds each linked Plugin's PACKAGE with PackageRegistry::add, builds the context, and then installs by name, as in host.install(plugin_web::PACKAGE.name, None, Value::Null).
  • harness::plugin gains PackageRegistry (new, Default, add, and list, which yields (&Package, PluginKind) pairs in package name order) and PluginKind, a #[non_exhaustive] enum with Native and Linked.
  • Every registry holds promptforge/subagents from the start, with the default local name subagents. Installing it meets a prompt that declares subagents but adds no catalog tool and no prelude. A Host that does not install it reports subagents in missing_required for such a prompt.
  • InstallError gains three variants. add returns DuplicatePackage, Plugin package `{package}` is already in the registry, for a name the registry holds, the native's included. install returns UnknownPackage, Plugin package `{package}` is not in the registry; add it with PackageRegistry::add before building the HostContext, for a name it does not hold, promptforge/tasks included, since tasks stays a language feature. It returns ReservedName, Plugin package `{package}` cannot be installed as {name}: a native Plugin keeps its own name, and no other package may take it, for the native under any name but subagents and for any other package under subagents, chosen or default. InvalidPackage now comes only from add, and installing the native twice is NameTaken.
  • Package gains a public description field and a const fn description builder, empty by default. Package is #[non_exhaustive], so Plugin crates that do not set it still compile. Web, user-input, and MCP describe themselves as "Search the web and fetch pages", "Ask the person running the prompt", and "Tools from one MCP server". Debug for Package now shows the description, and Debug for HostContext shows the registry.
  • Workshop adds web, user-input, and MCP to a registry and installs the native after user-input. A refused add or install logs a warning and the rest still run, so an mcp.json server named subagents is now skipped with a warning.
  • Parallel calls needed no Harness change, because its effect loop already performed in-flight effects concurrently. The tool-call cap is the Harness's own. Harness::new's doc names the default of ten, and a Host has no setting for it yet.
  • RunControl::stop_round's doc now says a stop also drops tool calls waiting for a slot, unstarted, except those that survive stops, which start once a slot frees. It also says a stop reaches the effects in flight or waiting when the loop sees it. RunControl::cancel's doc says every waiting call is answered Dropped too, unstarted.
  • Workshop's agent panel keeps every open operator question in the order the server announced it and always offers the oldest, so answering or cancelling one moves on to the next instead of stranding the rest. A sibling call's result no longer switches the panel into its working state while a question is open, so the composer keeps offering Send rather than Stop. After a reconnect the list rebuilds from the server's resend, oldest first. The Workshop server and its wire format do not change.
  • The promptforge facade's API listing changes in two lines, both from the tool-call cap. EffectId now derives PartialOrd and Ord, and its doc promises that an effect issued later in a run has a greater id. Step::Pending gains withdrawn: Vec<EffectId>, the tool calls that step orphaned, in effect-id order. The list is empty once the run is decided, and each listed call still owes one answer. Code that drives a Run and matches Step::Pending exhaustively must add the field or ... Nothing in the per-run Plugin snapshot changes.

For contributors:

  • The AGENTS.md Plugin definition now says a Host adds packages to its registry and installs by package name, and names three kinds: linked, library (not built yet), and Engine-native (promptforge/subagents, reserved for subagent tools the Engine will implement, not built yet).
  • In the Engine crates, find_visible is the one heading lookup and resolve_visible its not-found adapter, both over a borrowed Visible view of the visible set. SlicePath moves to walk_target.rs. The parser keeps the body in one Arc<str>, and a section's Markdown, items, and prose blocks are BodyText ranges into it, so Section::items() returns &[BodyText] while markdown() still returns &str. Test support's synthetic_section drops its items parameter.
  • The in-place work changes no observable behavior. Read results, nil on a miss, jump, call, and fanout targets, error texts, the order in which a not-found error lists the visible sections, parse errors, and Debug output for sections and blocks all stay the same, because BodyText compares and prints as its text.
  • 133 closed plan records under vibe/ had their frontmatter todo statuses set to completed, replacing 970 pending and 5 in_progress lines and changing nothing outside the frontmatter.
  • In the Lua crate's coroutine protocol, Request::ToolCalls holds a round's unanswered calls and its turn, and Answer::ToolCallsResult answers with one text per issued call in call order, or the first failure in call order. Fewer texts than calls marks where the scheduler stopped, and the loop sends that call alone through the old tool_call request, which now serves only that call and script tools.call. A lone local call or task built-in therefore costs one extra exchange between the loop and the Engine, with no I/O. None of this is in the facade.
  • In the Engine scheduler, tool_route decides each call's ToolRoute (Builtin, Local, or Catalog) for lone and batched calls alike. dispatch_tool_calls issues catalog calls through prepare_catalog_call in model order and stops at the first other route, and fill_batch_slot fills each slot as its answer lands and resumes the chain once. A private CallSite (Script, Model, Batch) replaces the call_id, turn, and slot options, so a batch slot can only exist beside a model call id. runs_alone and its internal error are gone. When several route lookups fail at once, a frame or local-tool lookup error now surfaces first.
  • Chains gain a batch field that abort and teardown clear, and an aborted chain now orphans every pending effect, sorted by effect id, instead of only the first.
  • In the Engine scheduler, abort_effect lists every tool call it orphans in a withdrawn buffer that each step drains and sorts into Step::Pending. end clears the buffer after teardown, so a decided run lists none rather than a partial set.
  • In the Harness, Flights keeps waiting tool calls as unpolled performer futures in a BTreeMap keyed by effect id, and starts the lowest id while fewer than the cap are in flight. A withdrawal is a third Reach beside a cancel and a stop, so all three share abort's merge in effect order, refill, and teardown. ToolPerformer::max_in_flight is a default method returning ten, and ToolPerformer stays internal to harness-runner. The Driver drops a step's withdrawn calls only after it has queued that step's effects, because one step can issue a call and withdraw it.
  • No replay flag gates the batch path. Flags still defines nothing beyond Flags::EMPTY, because no code reads a bit and no run-log replayer feeds recorded answers back. A batch keeps the old issue order, but its effect ids match the old ones only in a run with one live chain. The parallel-tool-calls plan record was corrected in place to say so. The order in which the Harness starts capped calls changes only the order answers land, which the Engine already records as input.
  • The eleven tool_call request tests move unchanged from protocol/tests/parse.rs, which sat one line under the 500-line ceiling, into a new parse_tool_call module, and the protocol module overview now lists tool_calls among the rounds models.loop yields.
  • The language spec's tasks.fanout entry lives in the separate promptforge-design repository and was updated there, outside this PR.

Known limits

  • The promptforge-docs user guide still teaches the removed list_from_section global, in nine chapters under src/language/, and does not mention prompt.section or prompt.list. It also still calls the bare fanout global, 32 times across chapters 01, 02, 07, 08, 09, 14, and 17 (counted at promptforge-docs 44ff253), and never tasks.fanout. The site workflow publishes that checkout beside this repository, so once this lands every published fanout example fails at run time. That repository needs its own change. It was not checked for copies of the Plugin drop contract.
  • The task built-ins are not yet gated on installing the native promptforge/subagents package. Leaving the native out only refuses prompts that declare subagents, and a section that calls tools.allow_tasks() still offers the model the task built-ins without the Host's say. That waits for the tasks and subagents overhaul, which will also rewrite the native's description, the AGENTS.md clause, and the PluginKind::Native doc.
  • The native offers nothing yet, so a prompt that declares subagents prepares on a Host that installed it but gets no tools from it.
  • No Host reads a package's description yet: Workshop builds its registry inside with_plugins and keeps no handle to it. The package selection screen, library Plugins, and per-package config schemas are deferred, and PluginKind has no library kind, though AGENTS.md names one.
  • The native's package name is not exported as a constant, so Workshop installs it by the literal "promptforge/subagents", which its test pins.
  • Because a miss reads nil, a misspelled heading surfaces later, for example as a nil concatenation or the tasks.fanout collection error. assert(prompt.section(h), msg) is the strict form. A read still cannot name a grandchild.
  • Each read still copies the found section's Markdown and items into Lua strings, and each compiled Lua fence keeps its own copy of its source.
  • SectionContext::new grows to 80 lines with the slice lookup, which makes it a candidate for a later split.
  • The speedup from parallel calls needs a model that asks for several calls in one round. Hosted models commonly do. Local models served by llama.cpp likely ask for one call per round, since llama.cpp's own parallel-tool-call option is off by default, and passing that option through is cppalliance/promptforge#141.
  • Local Lua tools, the task built-ins, and script tools.call still run one at a time.
  • The cap is fixed at ten per run. A Host or a prompt can't set it, and it can't change mid-run.
  • A big batch holds later batches behind it: with the cap at ten, a 40-call batch makes a later one-call batch wait four rounds.
  • Calls that survive stops count toward the cap, so an operator question can wait behind slow calls, and the operator sees it only once it starts. A stop can then start a waiting question whose answer is discarded with the stopped round.
  • Nothing reports a call as started, so a waiting call shows as running until its result arrives.
  • Only tool calls are withdrawn. A cancelled task's model round runs to the end, and its tokens are spent.
  • A deliberate Stop while an operator question is open still drops the round's running sibling calls and discards the round, the operator's answer included.
  • Workshop wait frames name no call id, so the panel fix assumes one client per conversation. A second client attached to the same conversation could keep generating off after its question is answered elsewhere, until the next reply. Shipped Workshop gives each agent panel its own socket and session.
  • crates/harness-internal/runner/src/effect_loop.rs sits at 499 of its 500 allowed lines, so its next change has to move something out first.

Testing

  • For the first four plans, the full workspace gates pass on the native-docs plan's last step commit from before its rebase onto master. That commit's code is identical to e62bd6d77, the last of those four plans' commits; the two differ only in the plan record and vibe/ACTIVE. The gates cover the formatting check, clippy with warnings denied on both partitions, the headless gateway check, cargo deny, cargo audit, cargo hakari verify, five doc builds with warnings denied, the facade API check, the site build, the Workshop and gateway config UI typechecks and npm suites, the docs-claims scan, and the build-xtask tests. The four nextest runs (the workspace without Workshop, the Workshop crates, workshop-workspace, and headless workshop-server) passed with 4,966, 311, 155, and 197 tests.
  • For the five later plans, no test counts are recorded. Each plan record marks its verification todo completed.
    • For parallel tool calls, easy fixes, the tool-call cap, and its debt removal, that covers both nextest runs (with and without the Workshop crates), both clippy runs with warnings denied, the format check, the docs and facade docs builds, the facade API check, the build-xtask tests, and the docs-claims scan, at each plan's last step. Every step also ran its own focused tests.
    • For workshop waits, it covers the Workshop npm tests and typecheck, the docs-claims scan, and a check that each new test fails at 83d40b1b9, before the fix.
  • Two exit searches print nothing: one for the old native and install wording in crates and AGENTS.md, and one for fn visible_sections, fn home_without, or Vec<Section> in non-test Engine code. A third, git grep -n "only a cancel" -- . ':(exclude)vibe', prints nothing after the drop-contract fix.
  • The facade API check passes. crates/promptforge/public-api.txt changes only in the two lines the tool-call cap adds, EffectId's derives and Step::Pending::withdrawn. The other Host-facing changes sit in harness::plugin and promptforge-plugin, which that listing does not cover, and the new coroutine protocol variants are internal to the Lua crate.
  • prompt_reads_resolve_over_the_visible_set runs one shared document and checks that prompt.section returns a section's own Markdown byte for byte (an indented first line, a lua fence, a thematic break, and {{ args }} included), that prompt.list returns child and numbered items in order, that an empty section reads "", and that a niece, a grandchild, the caller, and a missing heading read nil from both.
  • prompt_reads_raise_only_for_mistakes_in_the_call pins each raise (a non-string, a bare name, no whitespace after the markers, and prompt.list on a prose or empty section), that misses do not raise, and that a call to a missing heading still lists only the visible sections. prompt_reads_work_in_h1_and_the_shared_library pins that H1 sees every top-level section and no child, and that a shared-library read resolves per VM at load time and per calling section from a shared function.
  • section_markdown_is_the_sections_own_content pins the parser's Markdown span byte for byte, and find_visible_returns_none_for_an_address_not_in_the_set pins None for a missing name and for a level mismatch. The old list_from_section.rs test file and three other list_from_section tests are deleted. No test now drives an ambiguous heading through a read, though resolve_visible_rejects_more_than_one_match covers the shared lookup.
  • The new registry integration tests pin the listing order and kinds, InvalidPackage and DuplicatePackage from add (the native's name included), UnknownPackage for promptforge/tasks, each ReservedName case and its message text, and NameTaken on a second native install. natives_offer_no_tools_prelude_or_needs fails if anyone gives a native tools, a prelude, or needs. Host tests check that an installed native meets a declared subagents and adds no catalog tool or prelude, and a Workshop test checks that a prompt declaring subagents runs on the server's installed Plugins.
  • Three BodyText unit tests pin that a range reads back its text, that a string outside the shared text is refused, and that equal text from different ranges compares equal. Otherwise the existing read, addressing, jump, fanout, and parser suites serve as the in-place work's regression suite, edited only at call sites and in the expressions that build expected values.
  • parallel_tool_calls.rs is new and steps a run by hand. a_rounds_catalog_calls_are_issued_in_one_step_and_append_in_call_order checks that two catalog calls issue in one step, c1 first, that answering c2 first issues nothing, and that the records still hold c1's result before c2's, both on the round's turn. a_local_call_or_a_task_builtin_runs_alone_between_its_neighbors covers both stop checks and a lone catalog call on the batch path, a_call_the_round_cannot_bind_stops_the_batch_and_raises_once_the_issued_calls_land pins the serial failure meaning, and cancelling_a_task_parked_on_a_batch_orphans_every_call pins that both in-flight calls are discarded after a cancel.
  • two_calls_in_one_round_are_in_flight_at_once_and_a_stop_drops_both, in the Harness stop tests, waits until both calls are running, then stops the round and expects false:cancelled with both calls dropped. Under the old serial loop that wait never succeeds, so it is the end-to-end proof that the calls overlap. It still passes under the default cap of ten.
  • The loop machine gains a_short_batch_answer_sends_the_next_call_alone_then_the_rest_together. The tests that answered the old single-call request after a model reply are rewritten for the batch request, and the existing one-call catalog rounds across the execute suite now run through the batch path as its regression suite.
  • The tasks.fanout rename edits call sites in 25 test files and 5 fixture prompts. The error-text asserts now match the full new texts, because the old substrings also matched the new messages, and the two-way RESERVED_NAMES test checks that no fanout global is left.
  • In Workshop, new service checks pin that two open waits pin the oldest and advance on each answer, that cancelling a newer wait keeps the oldest pinned, that a reattach resend pins the oldest unresolved wait, and that a sibling call's result leaves generating off and the wait pinned. A view check pins that the composer stays on Send. Four existing model-ask checks used to leave a wait open when a tool result landed, a state the shipped UI cannot reach, so they now cancel each wait first and keep their assertions.
  • The easy fixes add no tests. The route refactor touches no test file, so the existing scheduler and Harness suites are its regression suite, and the parse-test split keeps every moved test's name and body.
  • The Engine's parallel_tool_calls-withdrawn.rs steps a run by hand:
    • cancelling_a_task_withdraws_its_unanswered_tool_calls checks that the step that cancels lists both calls in effect-id order, and that earlier steps list none.
    • a_call_issued_and_withdrawn_in_one_step_is_listed_twice pins that one step can list a call in both effects and withdrawn.
    • one_step_lists_every_tasks_withdrawn_calls_in_effect_id_order cancels two tasks in reverse spawn order and expects the list sorted.
    • a_decided_run_lists_no_withdrawn_calls fails without the clear in end.
  • The Harness's effect_loop-cap.rs drives runs with small caps. Each of its tests failed before the change it covers:
    • the_cap_holds_extra_calls_until_one_lands;
    • waiting_calls_start_oldest_first, where a later batch's call starts after an earlier batch's leftovers;
    • a_call_withdrawn_in_the_step_that_issued_it_never_starts, which fails if the Driver withdraws before queuing;
    • cancelling_a_task_drops_its_waiting_and_running_calls;
    • a_cancel_drops_waiting_calls_without_starting_them.
  • harness-stop-cap.rs runs through Harness::run under the default cap of ten:
    • a_stop_drops_waiting_calls_without_starting_them runs 11 held calls, stops once 10 have started, and expects 10 starts, 10 teardowns, and 11 Dropped answers.
    • a_stop_starts_a_waiting_call_that_survives_stops checks that an operator question waiting behind 10 held calls is asked only after the stop frees their slots.
    • cancelling_a_task_drops_its_question_to_the_operator checks that cancelling a task tears down its open question while the run goes on to complete. It passes on the existing behavior by design, because it pins what the new docs promise. When a withdrawal was made to spare calls that survive stops, it failed on its 10-second timeout.

Each parsed section now keeps its own raw Markdown: everything after its heading line up to the next heading of any level, so a child section's text is not included. Blank lines before the first line of content are dropped as whole lines rather than trimmed, so an indented first line keeps its indentation, and trailing whitespace is removed. Code that walks the parsed tree can now read a section's text as the author wrote it, without its heading or its children.

- `Section` gains a crate-visible `markdown` field with a public `markdown()` accessor, the same shape as its other fields.
- `trim_leading_blank_lines` becomes `pub(crate)` so section building reuses its whole-line trim; a plain trim would strip the first line's indentation.
- `build_sections` fills `markdown` from the current heading's content, so a section's text stops at its first child heading and an empty section reads as an empty string.
- `section_markdown_is_the_sections_own_content` pins one section's text byte for byte through an indented first line, a `lua` fence, a `---` break with a blank line above it, and `{{ x }}`, then checks its child's text and an empty section.
- `synthetic_section` sets `markdown` to an empty string, so synthetic test sections carry no Markdown.
- `markdown()` has no caller in this change other than the new test.

Design: extends oversized-unit @ crates/promptforge-internal/parser/src/build.rs::build_sections deps: &Emitter,&[Heading],&mut usize,u32,u8
Design: extends pure-function @ crates/promptforge-internal/parser/src/fence.rs::trim_leading_blank_lines deps: &str
Design: extends pure-function @ crates/promptforge-internal/parser/src/test_support.rs::synthetic_section deps: &str,Vec<Block>,Vec<String>,u8
Plan: vibe/2026-10-10-2-prompt-namespace-reads.md
Split heading lookup into a search that reports a miss as an empty answer and a resolver that treats a miss as an error. The search looks only at the caller's visible sections, so a heading outside that set and a heading that names no section at all get the same empty answer, while a malformed or ambiguous heading still fails. The resolver now delegates to the search and keeps its not-found error and message text unchanged, so every heading lookup shares one matching rule.

- `find_visible` is the shared lookup: it parses the heading, filters the given slice on exact level and name, and returns `None` on a miss while still erroring on a malformed or ambiguous heading. It takes the heading as a raw `&str` and parses it itself, as `resolve_sibling` and `parse_heading_address` do.
- `resolve_sibling` is now a thin adapter that maps `None` from `find_visible` to the not-found error listing only the visible sections. Its signature and both error texts are unchanged, so no caller is edited.
- `find_visible_returns_none_for_an_address_not_in_the_set` pins that a missing name and a level mismatch both yield `None` without error, and that the exact address yields the section.

Design: new stringly-typed @ crates/promptforge-internal/engine/src/heading_address.rs::find_visible deps: Section,str
Design: new pure-function @ crates/promptforge-internal/engine/src/heading_address.rs::find_visible deps: Section,str
Design: extends pure-function @ crates/promptforge-internal/engine/src/heading_address.rs::resolve_sibling deps: Section,str
Plan: vibe/2026-10-10-2-prompt-namespace-reads.md
Section code now reads other sections through two Lua read functions. One returns a section's own raw Markdown, and the other returns a list section's items. Both search only the caller's visible set and return nil when no visible section has the heading, so a missing heading and an out-of-scope heading give one answer. They raise only when the call itself is wrong: a non-string target, a malformed or ambiguous heading, or a list read of a section with no list items. The old list-reading global is gone, and one lookup callback serves both reads.

- `install_prompt_table` builds `prompt.section` and `prompt.list` over one `read` callback shared through an `Arc`. Every `read` bound therefore requires `Send + Sync + 'static`, and the "no pre-parsed items" check moves out of the Engine into `prompt.list`.
- `read_visible` replaces `list_items_from_visible` as a pure adapter over `heading_address::find_visible`. It maps a visible match to a `SectionText` and a miss to `None`.
- `SectionText` is a new exported type in the Lua crate that holds a section's raw `markdown` and its `items`. The Engine's `lua` module re-exports it.
- `prompt.list` returns `nil` for a heading outside the visible set or absent from the document, where the old global raised not-found. It still raises for a section with no pre-parsed items, and that message now names the trimmed heading as written instead of the section's bare name.
- `prompt.section` returns a section's Markdown verbatim, without its child sections, and the empty string for an empty section. A `lua` fence or template inside it comes back as text.
- `RESERVED_NAMES` reserves `prompt` as an Engine global in place of `list_from_section`, so a Plugin prelude can no longer claim `prompt`. The array length stays 61.
- `collection_members` now points a fanout caller that passes a string at `prompt.list(heading)` and states that it returns nil on a miss.
- `prompt_reads_work_in_h1_and_the_shared_library` pins that a shared-library read resolves per VM: at load time over each replaying VM's own visible set, and from a shared function over the calling section's.
- `list_from_section_ambiguous_error_is_loud` is deleted, and no test in this change drives an ambiguous heading through either read.

Design: new value-object @ crates/promptforge-internal/lua/src/handles.rs::SectionText
Design: replaces pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::read_visible deps: Section,str was: crates/promptforge-internal/engine/src/execute/walk_target.rs::list_items_from_visible
Design: new surface-growth @ crates/promptforge-internal/lua/src/vm/install.rs::SectionVm::install_prompt_table boundary: pub
Design: new shotgun-surgery @ crates/promptforge-internal/lua/src/vm/install.rs::SectionVm::install_scheduler_control_globals
Plan: vibe/2026-10-10-2-prompt-namespace-reads.md
Plan: vibe/2026-10-10-2-prompt-namespace-reads.md
Break the parser's section tree builder into small named checks so its loop reads as a sequence of steps: reject a heading that skips a level, reject an empty heading, detect a list section, and reject a duplicate sibling name. Each check keeps the error kind, location, and message it produced inline, and the checks run in the same order, so parsing behavior does not change. The test helper that builds synthetic sections also drops its list items argument, which every caller changed here passed empty.

- `build_sections` keeps its signature and shrinks from 93 to 38 lines. Its loop now calls `check_child_level`, `section_name`, `list_items`, and `check_unique_sibling` in the order the inline checks ran.
- `synthetic_section` loses its `items` parameter and always sets `items: Vec::new()`. The `sibling` helper and the three calls in `duplicate_top_level_section_names_error_loudly` drop the trailing `Vec::new()`.
- `Error::parse_at` calls move into the helpers with the same `ParseErrorKind::Structure` kind, heading span, and message text, so parse errors are unchanged.
- `exec_flow.rs` and `heading_address-tests.rs` change only call shape. No test or assertion changes.

Design: removes oversized-unit @ crates/promptforge-internal/parser/src/build.rs::build_sections deps: Emitter,[Heading],u32,u8,usize
Design: new pure-function @ crates/promptforge-internal/parser/src/build.rs::check_child_level deps: Heading,u8
Design: new pure-function @ crates/promptforge-internal/parser/src/build.rs::section_name deps: Heading,u8
Design: new stringly-typed @ crates/promptforge-internal/parser/src/build.rs::section_name deps: Heading,u8
Design: new pure-function @ crates/promptforge-internal/parser/src/build.rs::list_items deps: [Block],str
Design: new stringly-typed @ crates/promptforge-internal/parser/src/build.rs::list_items deps: [Block],str
Design: new pure-function @ crates/promptforge-internal/parser/src/build.rs::check_unique_sibling deps: Heading,[(String,u32)],str,u32
Design: new stringly-typed @ crates/promptforge-internal/parser/src/build.rs::check_unique_sibling deps: Heading,[(String,u32)],str,u32
Design: extends pure-function @ crates/promptforge-internal/parser/src/test_support.rs::synthetic_section deps: Vec<Block>,str,u8
Plan: vibe/2026-10-10-3-prompt-reads-debt-removal.md
The H1 pass sees every top-level section, and three places in the engine each built that set inline with the same comment explaining why. They now call one helper that names the set and states the reason once. The heading resolver behind jumps and calls is renamed to say it resolves a heading within a visible set, which is what its callers hand it, rather than among siblings. Its signature, body, and error messages stay the same, so behavior does not change.

- `h1_visible_sections` sits beside `visible_sections` and borrows the whole top-level slice from the parsed prompt. It is `pub(super)`, and the H1 doc comments now link to it instead of restating why section 0 excludes nothing.
- `resolve_visible` replaces `resolve_sibling` with the same signature, body, and messages, and its callers and doc links follow the new name.
- `read_visible` in the H1 section context still reads an owned copy of the set made with `.to_vec()`, because the `move` closure around it keeps the set. The two scheduler sites borrow the slice.
- `promptforge_parser::detail::sections` stays a direct call in the H1 pass's emptiness check, which asks whether any section exists, not which ones are visible.
- `resolve_visible_finds_exact_match` and the six other renamed tests keep their bodies and assertions. Only their names and the module doc change.

Design: new pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::h1_visible_sections deps: Prompt
Design: replaces pure-function @ crates/promptforge-internal/engine/src/heading_address.rs::resolve_visible deps: Section,str was: crates/promptforge-internal/engine/src/heading_address.rs::resolve_sibling
Design: replaces stringly-typed @ crates/promptforge-internal/engine/src/heading_address.rs::resolve_visible deps: Section,str was: crates/promptforge-internal/engine/src/heading_address.rs::resolve_sibling
Plan: vibe/2026-10-10-3-prompt-reads-debt-removal.md
Archived work records still listed most of their tasks as pending, and a few as in progress, so anything reading their frontmatter saw finished work as open. Every such status in those records now reads completed. The edit touches only frontmatter status lines and changes no other byte, so each file keeps its line count and line endings. The one record still underway keeps its open statuses.

- `status: completed` is the single target state. It replaces 970 `status: pending` lines and 5 `status: in_progress` lines across 133 files.
- `vibe/` changes outside the active record stay inside the YAML frontmatter. Every hunk ends before the closing `---`, and each file's size moves by exactly the length difference of the swapped words.
- `vibe/2026-10-10-3-prompt-reads-debt-removal.md` keeps every `status: pending` in its frontmatter. Its only change is a `[completed]` marker on one heading.

Plan: vibe/2026-10-10-3-prompt-reads-debt-removal.md
A fanout-arm test's name said both lookups reject a section outside the worker's visible set, but only the call raises and the listing reads nil. The new name states each outcome. Two stale doc comments said the parser refuses a frontmatter tool alias with a reserved name. One now says only that a Plugin prelude global may not take a reserved name, and the other drops the case from the frontmatter error list. No runtime behavior changes.

- `call_raises_and_list_reads_nil_for_non_visible_sections_in_a_fanout_arm` replaces `call_and_list_inside_a_fanout_arm_reject_non_visible_sections`. The test body and its doc comment are unchanged.
- `RESERVED_NAMES` module doc keeps the definition of the reserved set and now limits the rule to Plugin prelude globals. The sentence about frontmatter tool aliases installing as globals is gone.
- `Frontmatter` error causes in the `Prompt` parse docs no longer include a reserved tool alias. The missing delimiters, decode failure, non-plain Plugin entry, and duplicate Plugin causes stay.

Plan: vibe/2026-10-10-3-prompt-reads-debt-removal.md
Plan: vibe/2026-10-10-3-prompt-reads-debt-removal.md
Every Plugin package can now carry a short line saying what it offers, so a Host listing the packages it can install has something to show beside each name. The line is optional and empty by default. The web search, user input, and MCP server packages each set one.

- `Package::description` is both a public field and a `const fn` builder beside `prelude` and `needs`, so a Plugin crate sets it in the same chain that builds its `PACKAGE` constant. `Package::new` sets it to an empty string, so setting it is optional.
- `promptforge/web`, `promptforge/user-input`, and `promptforge/mcp` now describe themselves as "Search the web and fetch pages", "Ask the person running the prompt", and "Tools from one MCP server".
- `fmt::Debug` for `Package` now shows the description between the name and the prelude flag.
- `description` is read only by `fmt::Debug` in the touched files, and the change adds no tests.

Design: extends surface-growth @ crates/promptforge-plugin/src/plugin.rs::Package boundary: pub
Design: new surface-growth @ crates/promptforge-plugin/src/plugin.rs::Package::description boundary: pub
Design: extends surface-growth @ crates/plugin-web/src/web.rs::PACKAGE boundary: pub
Design: extends surface-growth @ crates/plugin-user-input/src/lib.rs::PACKAGE boundary: pub
Design: extends surface-growth @ crates/plugin-mcp/src/lib.rs::PACKAGE boundary: pub
Deferred: Nothing reads Package::description beyond its Debug output yet
Plan: vibe/2026-10-11-1-package-registry.md
A Host now adds the Plugin packages it links to a package registry, builds its context over that registry, and installs each Plugin by package name. Every registry starts out holding the Engine-native subagents package, which offers no tools of its own and keeps its own local name, so no other package can install under that name. An unknown package, a duplicate package, or a misused native name is refused at startup, and a Host can list every package it may install along with its kind. Workshop now installs the native subagents Plugin alongside web and user-input.

- `PackageRegistry` maps each package name to its `Package`, its `PluginKind`, and its default local name, which `add` parses once. `HostContext::install` looks the name up there and no longer re-checks the `vendor/name` form.
- `HostContext::new` takes the registry by value beside the Host-wide services, and `install` takes a package name as `&str` instead of a `Package`. Every Host and test now adds its packages before building the context, then installs by `package.name`.
- `NATIVE` defines `promptforge/subagents` as an ordinary `Package` with a `Native` object, so it goes through the same install path as a linked package.
- `InstallError` adds `DuplicatePackage` and `UnknownPackage`, and `InvalidPackage` now comes only from `add`. `add` refuses a native's name as a duplicate, and the unknown-package message tells the Host to call `PackageRegistry::add` first.
- `ReservedName` refuses the native under any name but `subagents` and refuses every other package under `subagents`, whether the Host picks that name or it is the default. Installing the native twice is `NameTaken`.
- `with_plugins` adds the web, user-input, and MCP packages to a new registry and installs the native after user-input. A refused add or native install only logs a warning, and the remaining installs still run.
- `PackageRegistry::default` skips a native whose name fails to parse instead of panicking. The registry listing test pins that `promptforge/subagents` parses.
- `Native` offers no tools, prelude, or needs, and its `call` returns an error saying the Engine answers native calls. Installing it satisfies a prompt that declares `subagents` but adds nothing to the run's catalog or preludes.
- `PluginKind` has only `Native` and `Linked`; the library kind that the Plugin definition in `AGENTS.md` names is not built yet.

Design: new registry @ crates/harness-internal/runner/src/packages.rs::PackageRegistry boundary: pub
Design: new pure-function @ crates/harness-internal/runner/src/packages.rs::native deps: HostServices,PluginId,Value
Design: replaces pure-function @ crates/harness-internal/runner/src/packages.rs::package_name deps: str was: crates/harness-internal/runner/src/host.rs::package_name
Design: extends constructor-injection @ crates/harness-internal/runner/src/host.rs::HostContext boundary: pub
Design: new stringly-typed @ crates/harness-internal/runner/src/host.rs::HostContext::install boundary: pub
Design: new shotgun-surgery @ crates/harness-internal/runner/src/host.rs::HostContext::new
Design: extends surface-growth @ crates/harness-internal/runner/src/host.rs::InstallError boundary: pub
Design: extends facade @ crates/harness/src/lib.rs::plugin boundary: pub
Design: extends swallowed-exception @ crates/workshop/server/src/agents.rs::with_plugins deps: HostServices,Vec
Plan: vibe/2026-10-11-1-package-registry.md
Plan: vibe/2026-10-11-1-package-registry.md
The subagents package that every registry holds was described as letting the model start, wait for, and cancel subagents, but it offers no tools yet. Its description, the documentation of its kind, and the repository's Plugin definition now say it only reserves the subagents name for tools the Engine will implement later. The label docs of the three Plugin crates now say a Host adds the label to its package registry, which matches how a Host installs Plugins by package name.

- `NATIVE` now describes `promptforge/subagents` as reserving the subagents name for the Engine's subagent tools, which are not built yet. `PackageRegistry::list` returns this package with every other, so the corrected line is what a Host listing a registry gets.
- `PluginKind` documents `Native` as a kind whose behavior will live in the Engine, and says today's one native, `promptforge/subagents`, has none yet.
- `AGENTS.md` changes only the Engine-native clause of the Plugin definition, which now says `promptforge/subagents` is reserved for subagent tools the Engine will implement under the fixed name `subagents` and is not built yet.
- `crates/plugin-web/src/web.rs`, `crates/plugin-user-input/src/lib.rs`, and `crates/plugin-mcp/src/lib.rs` each document the Plugin's label as something a Host adds to its package registry, instead of something it passes to its install.
- `NATIVE` keeps its doc comment word for word, including the rules every native must follow and the line that a native's behavior and tools live in the Engine. The change adds no tests.

Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
When an install asks for a package the registry does not hold, the error now tells the Host to add the package before building the host context, instead of before installing it. The host context takes the registry by value when it is built and offers no way back to it, so by the time an install is refused there is no registry left to add to. The old advice named a step a Host could no longer take.

- `UnknownPackage` now says to add the package with `PackageRegistry::add` before building the `HostContext`. `HostContext::new` takes the `PackageRegistry` by value and keeps it in a private field, so building the context is the last point where `add` can still be called.
- `UnknownPackage` keeps its `package` field and its doc comment unchanged. Only the message text changes.

Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
Jump, call, and fanout targets now resolve against a borrowed view of the caller's visible set instead of an owned copy of it. The view reads the home slice in place, skips the caller, and then reads the caller's direct children, so lookups see the same sections in the same order without cloning them. Heading matching, ambiguity detection, and error text are unchanged. Section read callbacks still build their copy and read it through the same view.

- `Visible` holds a home slice, an optional caller index, and the caller's children, all private and borrowed. `Visible::of` derives the children from the caller and debug-asserts that the caller index lies inside the home slice, while `Visible::all` covers a whole slice with no caller and no children.
- `resolve_jump_target` takes the jumper's index in its sibling slice instead of a section reference. It excludes the jumper by that index rather than by a level-and-name match.
- `find_visible` and `resolve_visible` take the view in place of a slice and keep their matching and error text. The not-found message walks the view a second time, in the same order the copied list had.
- `resolve_jump_target` returns an internal error when the jumper index falls outside its sibling slice.
- `visible_sections` still clones the visible set for the section read callbacks, which wrap that copy in `Visible::all`. The H1 read callback likewise wraps a cloned top-level slice.
- `heading_address-tests.rs` exercises the view only through `Visible::all`, so caller exclusion and child ordering in `Visible::of` have no unit test there.

Design: new encapsulated-invariant @ crates/promptforge-internal/engine/src/heading_address.rs::Visible
Design: extends pure-function @ crates/promptforge-internal/engine/src/heading_address.rs::find_visible deps: Visible,str
Design: extends pure-function @ crates/promptforge-internal/engine/src/heading_address.rs::resolve_visible deps: Visible,str
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::read_visible deps: Visible,str
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::resolve_jump_target deps: Section,str,usize
Deferred: Section read callbacks still read an owned copy of their visible set
Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
Entering a section no longer copies the sections it can address by heading. Section entry now receives the path to the section's sibling slice and its index there, and each prompt read resolves that slice from the shared prompt tree and reads it in place. The H1 pass reads the top-level sections in place the same way. The type that names a walk position moves beside the other heading lookup helpers so section setup can use it.

- `SlicePath` moves out of the scheduler module into `walk_target.rs`, and the struct and its four methods become `pub(super)` so section setup can reach them. The scheduler files import it from there, and the scheduler module doc links to the new path.
- `SectionContext::new` takes `home: &SlicePath` and `index: usize` in place of the section and its sibling slice, so its argument count stays at eight. The added lookup pushes the constructor to 80 lines, past the 75-line unit ceiling.
- `Visible::of` now backs the walked-section read callback, which captures the shared prompt `Arc`, a clone of the slice path, and the index, and resolves the home slice on every read.
- `h1_visible_sections` is read in place by the H1 read callback, which captures the shared prompt instead of a `.to_vec()` copy.
- `Error::Internal` is returned when section entry finds no section at `index` in the resolved slice, with the message "an entered section is absent from its slice". The walk already stops when the index is past the slice, so this marks a broken invariant, and no test in this change reaches it.
- `visible_sections` and `home_without` are deleted, and with them the cloned `Vec<Section>` each section entry built.

Design: replaces newtype @ crates/promptforge-internal/engine/src/execute/walk_target.rs::SlicePath was: crates/promptforge-internal/engine/src/execute/scheduler.rs::SlicePath
Design: new oversized-unit @ crates/promptforge-internal/engine/src/execute/section_context-construct.rs::SectionContext::new
Design: removes pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::visible_sections deps: Section
Design: removes pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::home_without deps: Section
Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
Parsing no longer copies each heading's content, each prose segment, and each list item into a new string while it builds the section tree. Those intermediate values now borrow from the text they were cut from, and the one copy left for each is made where the finished section or prose block is built. Parsed sections, their text, and parse errors stay the same, and no test changes.

- `Heading` gains a lifetime, and its `content` field is now a `&'a str` slice of the body in place of a copied `String`. `collect_headings` returns headings that borrow from the body it walks, and every function that takes a heading now spells it `Heading<'_>`.
- `RawBlock::Prose` carries a `&'a str` in place of a `String`, so `split_section_blocks` returns prose that borrows from the content it splits. Lua fence sources stay owned `String`s.
- `parse_bullet_items` returns items borrowed from its prose input instead of copying each one.
- `Block::Prose` and a section's items and markdown still own their text, so each is copied once with `to_owned()` where it is built. `list_items` copies the items, and `build_heading_blocks` and `split_h1` copy the prose.
- `split_h1` still splits its own copy of the H1 content, with any `lua shared` fence blanked out, so H1 prose borrows from that copy rather than from the body.

Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::collect_headings deps: str
Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::check_child_level deps: Heading,u8
Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::section_name deps: Heading,u8
Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::list_items deps: [Block],str
Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::check_unique_sibling deps: Heading,[(String,u32)],str,u32
Design: extends pure-function @ crates/promptforge-internal/parser/src/fence.rs::split_section_blocks deps: str,str
Design: extends pure-function @ crates/promptforge-internal/parser/src/list.rs::parse_bullet_items deps: str,str
Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
The parser now keeps one shared, read-only copy of each prompt's body, and each section's Markdown, list items, and prose blocks point into it by offset instead of holding their own copies. Prose under the top-level heading points into its own shared copy instead, since that copy can have the shared library fence blanked out. Prose waiting for the next Lua fence now shares that text instead of copying it. Equality and debug output still work on the text itself, so blocks compare and print as they did before.

- `BodyText` holds a shared handle to the text plus a private byte range. `within` builds one only when the given slice lies wholly inside the shared text, located by address arithmetic with no `unsafe`, and `as_str` slices without checking again.
- `from_owned` compiles only under test or the `test-support` feature, so other builds can make a `BodyText` only through `within`.
- `body_text` returns an internal parser error, not a panic, when section text is not a slice of the shared text.
- `build_sections` and `build_heading_blocks` take the shared body alongside the frontmatter line count and emitter, the same three values `parse_body` carries.
- `split_h1` moves its masked copy of the H1 content into its own shared text and builds H1 prose against that copy.
- `Block::Prose` now holds a `BodyText`, and a section's items come back as `&[BodyText]`; `markdown()` still returns `&str`.
- `PartialEq` for `BodyText` compares the text read, not its position, and `Debug` prints the bare string.
- `pending_prose` now holds a `BodyText`, so stashing a prose block for the next Lua fence clones a shared handle rather than the prose.
- `read_visible` still copies the found section's Markdown and items into owned strings for Lua.
- `AsRef<str>` is implemented for `BodyText`, but nothing in this change calls it.

Design: new encapsulated-invariant @ crates/promptforge-internal/parser/src/body_text.rs::BodyText boundary: pub
Design: new pure-function @ crates/promptforge-internal/parser/src/build.rs::body_text deps: Arc<str>,str
Design: new shared-parameter-cluster @ crates/promptforge-internal/parser/src/build.rs::build_heading_blocks deps: Arc<str>,Emitter,Heading,str,u32
Design: new shared-parameter-cluster @ crates/promptforge-internal/parser/src/build.rs::build_sections deps: Arc<str>,Emitter,[Heading],u32,u8,usize
Design: new shared-parameter-cluster @ crates/promptforge-internal/parser/src/parse.rs::Prompt::parse_body
Design: extends pure-function @ crates/promptforge-internal/parser/src/build.rs::list_items deps: Arc<str>,[Block],str
Design: extends oversized-unit @ crates/promptforge-internal/parser/src/fence.rs::split_h1 deps: Emitter,str,str,u32
Design: extends pure-function @ crates/promptforge-internal/parser/src/test_support.rs::synthetic_section deps: Vec<Block>,str,u8 boundary: pub
Design: extends pure-function @ crates/promptforge-internal/parser/src/test_support.rs::prose_block deps: String boundary: pub
Design: extends facade @ crates/promptforge-internal/parser/src/lib.rs boundary: pub
Design: extends facade @ crates/promptforge-internal/engine/src/parser.rs
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/walk_target.rs::read_visible deps: Visible,str
Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
Plan: vibe/2026-10-11-2-native-docs-debt-removal.md
When a model asks for several tools in one round, the loop now hands all of the unanswered calls to the scheduler together instead of one at a time. The scheduler starts every catalog call in the same step so they run concurrently, and answers the loop only once all of them have landed, with the results kept in the order the model asked for them. Local Lua tools and the task built-ins still run alone: the scheduler stops the batch in front of one, the loop sends that call by itself, and the calls after it go out as the next batch. Cancelling a task that is waiting on a batch abandons every call in it, and a stop drops them all at once.

- `Request::ToolCalls` carries a round's unanswered calls and its turn in one yield, and `Answer::ToolCallsResult` answers with one text per issued call in call order, or the first failure in call order. Fewer texts than calls marks the call where the scheduler stopped.
- `next_call` batches every unanswered call, even a lone one, and `take_batch` sends a call alone only when the answer stops short of it. A catalog call's route never depends on how many calls share its round.
- `dispatch_tool_calls` decides which calls run together: it issues catalog calls in model order and stops at the first reserved built-in name or local tool, the two checks `prepare_tool_call` makes before any catalog lookup.
- `fill_batch_slot` fills the slot named by the continuation's `slot` as each answer lands and resumes the chain once no slot is open. A slot only means something while the chain is parked on that batch, so abort and teardown clear the chain's `batch`.
- `abort_effect` now runs on every pending effect of an aborted chain, sorted by effect id, so cancelling a task parked on a batch orphans all of its calls and their late answers are discarded.
- `unbound_tool` raised by a call the round cannot bind stops the batch. The calls before it still run and the loop raises once they land, with nothing appended and the calls after it never issued.
- `blocked_on` reports a chain parked on a batch as `tool_call`, the same status a single call reports.
- `stop_round` drops every call of an in-flight batch, and a loop under `pcall` catches the drop as `cancelled`.
- `EffectId` gets no new ordering; the abort sorts by the id's numeric value instead.
- `ToolCallDispatch` gains no outcome: a batched call resolves only as `Issued`, and any other outcome fills its slot with an internal error.

Design: extends dispatch-on-tag @ crates/promptforge-internal/lua/src/protocol/parse.rs::Request::from_yield
  boundary: pub
Design: new pure-function @ crates/promptforge-internal/lua/src/protocol/parse.rs::parse_tool_calls deps: Lua,mlua::Table
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::call_fields deps: Lua,ToolCallEvent
Design: new pure-function @ crates/promptforge-internal/lua/src/models_loop.rs::texts deps: Value
Design: extends dispatch-on-tag @ crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs::Scheduler::dispatch
Design: extends pure-function @ crates/promptforge-internal/engine/src/execute/scheduler/dispatch.rs::blocked_on deps: Request
Design: extends shared-parameter-cluster @ crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::prepare_tool_call
Design: new temporal-coupling @ crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::fill_batch_slot
Design: extends bag-of-state @ crates/promptforge-internal/engine/src/execute/scheduler/chain_record.rs::Chain
Plan: vibe/2026-10-11-3-parallel-tool-calls.md
The call that runs a worker section once per collection member is no longer a top-level global in section Lua. It now sits in the namespace that already holds spawning, joining, and the concurrency limit, so prompts reach it there and the old global name is gone. The four error messages an author can see from this call now name it by its new path, so each message matches what the author wrote. How arms run, how their results are ordered, and when the depth cap fires do not change.

- `install_shim_prelude` sets `fanout` on the `tasks` table it already installs, in place of a global of its own. The `tasks` global is now set from a borrowed handle so the same table can take the new field.
- `RESERVED_NAMES` drops `fanout` and shrinks from 61 to 60 entries, so the reserved-global list still matches the globals section setup installs.
- `tripped` names an arm's depth-cap refusal `tasks.fanout`, so the error reads `tasks.fanout recursion exceeded cap of 8`, parallel to the `call` wording.
- `collection_members` now opens its non-table error with `tasks.fanout's second parameter is a collection`, and the shim's empty-collection and read-only result errors name `tasks.fanout` the same way.
- `fanout_results_are_sealed_against_writes_and_metatable_replacement` now matches the full `tasks.fanout results are read-only` text, and the recursion-cap and second-parameter substring asserts now include the `tasks.` prefix. The old substrings also matched the new messages, so they could not tell the two names apart.
- `fanout` is no longer a global and nothing aliases the old name, so prompts that call it must be updated.
- `fanout: bool` on the spawn request keeps its name, as do the `fanout = true` spawn mark and the collection key and member errors, which use fanout as the noun for the operation.

Design: new shotgun-surgery @ crates/promptforge-internal/lua/src/coro.rs::install_shim_prelude deps: Arc<AtomicU32>,InstructionBudget,Lua,usize
  boundary: pub
Plan: vibe/2026-10-11-3-parallel-tool-calls.md
Plan: vibe/2026-10-11-3-parallel-tool-calls.md
Now that one model round can run several tool calls at once, the Workshop agent panel can face more than one open operator question, and other calls can finish while a question waits. The session service now keeps every open question in the order the server announced it and always offers the oldest, so answering or cancelling one moves on to the next instead of stranding the rest. A sibling call's result no longer switches the panel into its working state while a question is open, so the composer keeps offering Send rather than Stop. After a reconnect the list rebuilds from the server's resend, oldest first.

- `openWaits` holds the open wait tokens in announcement order and is now the source of the pin. `pendingInputToken` returns its oldest entry, `pinOldestWait` republishes that entry through `setPinnedToken` after every add, answer, and cancel, and `pinnedToken` only remembers the last published value.
- `this.openWaits.includes(token)` guards each new announcement, so a token the server repeats is never queued twice.
- `respond` answers the oldest open wait and spends only that token, then turns `generating` on only when no wait remains open. A failed send still keeps every wait open.
- `wire.onInputCancelled` removes the cancelled token wherever it sits, so cancelling a newer wait leaves the older one pinned.
- `tool_call_update` turns `generating` on only when `answersModelCall` matches and no wait is open. This relies on each wait closing before its own call's result arrives.
- `acknowledge` empties the list on every acknowledgment and relies on the server resending unresolved waits right after, in creation order, so the oldest comes back first.
- `crates/workshop/ui/test/agent-session-service.mjs` now cancels each open wait in the existing model-ask checks before the next result or turn arrives, and keeps their assertions. A cancel leaves `generating` off, so only the result can turn it on and the checks still test the flip.
- `crates/workshop/ui/test/agent-session-view.mjs` checks that the composer action stays `send` when a sibling call's result lands while the ask waits.

Design: extends god-object @ crates/workshop/ui/src/services/agent-session.ts::AgentSessionService
  boundary: pub
Design: extends temporal-coupling @ crates/workshop/ui/src/services/agent-session.ts::AgentSessionService::acknowledge
Design: new temporal-coupling @ crates/workshop/ui/src/services/agent-session.ts::AgentSessionService::foldEvent
Design: new clone-block @ crates/workshop/ui/test/agent-session-view.mjs
Deferred: The published guide's move from fanout to tasks.fanout waits for a separate change.
Deferred: The published guide's removal of list_from_section calls waits for a separate change.
Repairs: generating stays off while a wait is open @ crates/workshop/ui/src/services/agent-session.ts::AgentSessionService::foldEvent - a sibling call's result no longer turns generating on
Repairs: every open wait stays reachable oldest first @ crates/workshop/ui/src/services/agent-session.ts::AgentSessionService::pendingInputToken - a newer wait no longer replaces an older open one
Plan: vibe/2026-10-11-4-workshop-waits.md
Plan: vibe/2026-10-11-4-workshop-waits.md
The scheduler now decides where a tool call is answered in one place, so a call sent alone and a call sent in a batch are no longer routed by two copies of the same checks. Catalog preparation moves into its own function that the batch calls directly, which retires the internal error the batch raised when a catalog call came back answered instead of issued. The three loose options that said who asked for a call and where its answer goes become one private enum, so a batch slot can exist only beside a model call id. Script calls, lone model calls, and batched model calls keep the same call ids, turns, caller origins, and answer paths.

- `tool_route` decides each call's `ToolRoute` as `Builtin`, `Local`, or `Catalog`, checking reserved names before local tools. Both `prepare_tool_call` and `dispatch_tool_calls` match on it.
- `prepare_catalog_call` holds the catalog work split out of `prepare_tool_call`: the binding lookup, the attempt count, the access fork, the origin, and the issue. It returns `Result<()>`, and the batch calls it with a `CallSite::Batch`.
- `CallSite` replaces the `call_id`, `turn`, and `slot` options with `Script`, `Model`, and `Batch` variants, and `ToolCallContinuation` stores it in place of its `call_id` and `slot` fields. Only `Batch` carries a slot, and `Script` carries no call id.
- `section_call_counts` returns the tool set snapshot and the section's counts for the local and catalog arms, and `call_turn` resolves a site's turn for every arm.
- `CallSite::Batch` is the only site whose answer fills a batch slot, and `Model` and `Batch` both take the model-issued answer branch under their call id. A `Script` site still issues with `ToolCaller::Script`, and `Model` and `Batch` sites with `ToolCaller::Model`.
- `tool_route` now runs before the tool set snapshot and the counts install. When several of these lookups fail at once, a frame or local-tool lookup error surfaces first.
- `runs_alone` is deleted, and with it the batch's internal error "a batched catalog call is issued as an effect".
- `ToolRoute` stays private to its module and `CallSite` to the scheduler, and no test file is touched.

Design: new parameter-object @ crates/promptforge-internal/engine/src/execute/scheduler/pending.rs::CallSite
Design: removes oversized-unit @ crates/promptforge-internal/engine/src/execute/scheduler/tool_call.rs::prepare_tool_call
Plan: vibe/2026-10-11-5-easy-fixes.md
Tool calls a model asks for in one round now carry their concurrency rule in the documentation Plugin authors read: they fork from the calling chain together, so one call's write to a path conflicts with a sibling's read or write of it. The protocol overview now lists the batched round among the rounds the model loop yields. The tool-call request parsing tests move unchanged into a file of their own, which gives the general parse tests room to grow. Nothing a script, Plugin, or Host can observe changes.

- `parse_tool_call` is a new test module, declared beside `parse`, `parse_chat`, and `parse_tasks`. It takes the eleven `tool_call` request tests from `parse.rs` with the same names and bodies, and `parse.rs` keeps the `infer`, `call`, `tool_calls`, and `local_tool_done` cases and the shared malformed-yield rejections.
- `crates/promptforge-plugin/src/context.rs` states the sibling rule in the `ToolContext` access doc, between the chain ordering and the root path. Only the doc comment changes.
- `tool_calls` now appears in the protocol module overview with `chat` and `tool_call` as a round the `models.loop` shim yields on the author's behalf.
- `Request::from_yield` and every other non-test item are unchanged; outside the test modules, the Rust edits are doc comments only.

Plan: vibe/2026-10-11-5-easy-fixes.md
Plan: vibe/2026-10-11-5-easy-fixes.md
@vinniefalco vinniefalco changed the title prompt.section and prompt.list, a Plugin package registry, and in-place section reads Prompt section reads, a Plugin package registry, parallel tool calls, and tasks.fanout Oct 11, 2026
Each step of a run now lists the tool calls it orphaned while the run goes on, so the caller can drop work whose answer will be discarded. The list is sorted by effect id, and effect ids now carry a public order that follows issue order within a run. A decided run lists nothing, because its teardown orphans the remaining effects without listing them and the caller drops a decided run's work whole. The caller still owes one answer for every listed call. The harness driver and the test drivers ignore the list, so run behavior does not change.

- `Step::Pending` gains a public `withdrawn` field rather than a query on the run. The list belongs to the step that produced it, so a caller cannot read it at the wrong time.
- `EffectId` derives `PartialOrd` and `Ord`, and its doc promises that an effect issued later in a run has a greater id. The order is public API, and the chain abort now sorts ids by it directly.
- `Scheduler` collects orphaned tool-call ids in its own `withdrawn` buffer, which each step drains and sorts into `Step::Pending`.
- `parallel_tool_calls-withdrawn.rs` holds four new tests in a child module loaded through `#[path]`, reaching the parent's private helpers through `super::*`.
- `abort_effect` lists an id only when it removes a pending tool-call entry. Each id appears at most once, and no other kind of effect is listed.
- `withdrawn` stays in effect-id order when one step cancels tasks in reverse spawn order, and it may name a call that the same step's `effects` also lists, because one step can issue a call and then orphan it. Tests pin both cases.
- `self.withdrawn.clear()` runs at the run's end after teardown. Settling the abandoned tasks fills the buffer but teardown orphans the rest unlisted, so a decided run lists none rather than a partial set.
- `crates/harness-internal/runner/src/effect_loop.rs` matches `Step::Pending` with `..` and ignores the list, as do the test drivers and the example, so no tool call is dropped early.

Design: new surface-growth @ crates/promptforge-internal/engine/src/execute/run.rs::Step::Pending
  boundary: pub
  instead-of: temporal-coupling: a Run query valid only until the next step
Design: new surface-growth @ crates/promptforge-internal/engine/src/execute/run/effect.rs::EffectId
  boundary: pub
Plan: vibe/2026-10-11-6-tool-call-cap.md
A run now has at most ten tool calls running at once, unless its tool performer sets another limit, and the rest wait their turn. When a slot frees, the waiting call issued earliest starts, so the oldest batch finishes first and a newer call never jumps ahead. Model rounds and timers never wait and never count toward the limit. A waiting call has started nothing, so a cancel, a stop, or the early end of the task that issued it drops it unstarted and answers it as dropped, while a waiting call that survives stops starts once a slot frees. The driver now also drops the tool calls a run reports as withdrawn, rather than letting them run until they land.

- `max_in_flight` is a default method on `ToolPerformer` that returns `DEFAULT_MAX_IN_FLIGHT`, ten. A default body means no implementor has to change, and the driver reads it once when the drive begins.
- `Flights` keeps waiting tool calls as unpolled performer futures in a `BTreeMap` keyed by effect id, beside the effects in flight, and its `len` and `is_empty` count them because each still owes one answer. One owner then answers for waiting and running calls alike when a run stops, cancels, or withdraws calls.
- `Reach::Withdrawn` makes a withdrawal a third reach beside a cancel and a stop, so `abort` and `drop_in_flight` give it the same merge in effect order, refill, and teardown. It looks ids up with `binary_search`, so it relies on the withdrawn list arriving in effect order.
- `launch` now holds the span, panic catching, and abort wrapping, and takes flags for whether the effect is a tool call and whether it survives stops. `start` serves only chat and timer effects and loses its `survives_stop` parameter, while `queue` takes tool calls.
- `fill` starts waiting calls, lowest effect id first, while fewer than the cap of tool calls are in flight, counting them by a scan of the effects in flight.
- `poll_landed` refills as soon as a tool call lands, a panicking one included. An aborted call's late landing frees nothing, because `abort` already refilled its slot.
- `abort` takes waiting and running calls by the same reach and refills only after taking everything, so it never starts a call it is about to drop. After a stop, that refill is what starts a waiting call that survives stops.
- `withdrawn` is dropped only after the step's effects are queued, because one step can issue a call and withdraw it; withdrawing first would find nothing to drop and let the call start. The drop marks the step answered, so the loop steps again rather than waiting.
- `survives_stop` calls count toward the cap like any other tool call, so an operator question can wait behind slow calls.
- `effect_loop-cap.rs` pins the cap through `drive_run` with small caps: extra calls hold until one lands, waiting calls start oldest first across tasks, a call withdrawn in the step that issued it never starts, and a task cancel and a run cancel drop waiting calls unstarted. `harness-stop-cap.rs` pins a stop under the default cap of 10, for a plain waiting call and for one that survives stops.
- `Harness` and `RunControl` change only in their docs, which now name the default cap of 10, oldest-first start, and how a stop and a cancel treat waiting calls.

Design: new surface-growth @ crates/harness-internal/runner/src/performers.rs::ToolPerformer::max_in_flight
  boundary: pub
Design: extends dispatch-on-tag @ crates/harness-internal/runner/src/effect_loop-flight.rs::Flights::abort
Design: new flag-parameter @ crates/harness-internal/runner/src/effect_loop-flight.rs::Flights::launch
Design: new temporal-coupling @ crates/harness-internal/runner/src/effect_loop.rs::Driver::drive
Plan: vibe/2026-10-11-6-tool-call-cap.md
Plan: vibe/2026-10-11-6-tool-call-cap.md
The public docs said only a cancel could drop a call whose tool survives stops, which left out the run ending for other reasons and the early end of the task that made the call. The Plugin contract now holds the one full list of drop occasions, and the other public copies state only their own fact and point to it, so the next occasion needs one edit. The same contract now tells Plugin authors that a call must not wait for another call of the same run, because the Harness runs a bounded number at once and holds the rest. The doc for stopping a round now says a stop reaches waiting effects as well as those in flight. Behavior is unchanged.

- `Plugin::call` now lists every occasion the Harness drops its future: a stop, the run's end with a cancel included, and the early end of the task that made the call, when it is cancelled, its owner ends, or an ancestor is aborted. Only a stop spares a call whose tool sets `survives_stop`, and the run goes on after a stop or a task's early end.
- `crates/promptforge-plugin/src/lib.rs` and `crates/plugin-user-input/src/lib.rs` stop restating the occasions and point to the list on `Plugin::call`. The user-input docs keep only their own fact, that a stop leaves the operator's question open.
- `ToolDescriptor` keeps its own accurate sentence on `survives_stop` instead of a link, because the types crate does not depend on the Plugin crate. It now says the run's end and the early end of the task that made the call still end a stop-surviving call.
- `Plugin::call` gains a rule that a call must not wait for another call of the same run to finish. The Harness runs a bounded number of a run's calls at once and holds the rest, so such a wait can stall the run until a stop or a cancel, and the rule names no number.
- `stop_round` now says a stop reaches the effects in flight or waiting when the loop sees it. The rest of that doc is only rewrapped.

Plan: vibe/2026-10-11-7-tool-call-cap-debt.md
Add a test that cancels a task while it waits on a question to the operator. It checks that the question is torn down and answered as dropped while the run goes on to complete. The ask tool survives stops, so this pins that a task's early end still drops such a call. The scripted model holds back the round after the cancel until the question is gone, so a regression that left the question open stalls the run and fails the test on timeout. The module docs now name this case beside the stop cases.

- `holds_back` tells Main's rounds apart by the last message's text, answering the `wait` round only once `asked` is nonzero and every other round only once `abandoned` is nonzero. It spins on `yield_now` without bound, so a question left open fails the test through `run_beside`'s patience.
- `cancelling_a_task_drops_its_question_to_the_operator` runs `CANCELS_AN_ASKER`, where Main spawns Child to ask the operator, waits on a `wait` round, cancels Child, and then waits on an `after` round. It asserts the run completes with `cancelled`, `operator.abandoned` is 1, and the ask's one `ToolCall` answer is recorded as `Dropped`.
- `_answers` holds the answer sender for the whole test, but nothing is ever sent on it, so the question is never answered.

Design: new shared-mutable-state @ crates/harness-internal/runner/tests/it/harness-stop-cap.rs::holds_back deps: Operator
Plan: vibe/2026-10-11-7-tool-call-cap-debt.md
Plan: vibe/2026-10-11-7-tool-call-cap-debt.md
@vinniefalco vinniefalco changed the title Prompt section reads, a Plugin package registry, parallel tool calls, and tasks.fanout Prompt section reads, a Plugin package registry, capped parallel tool calls, and tasks.fanout Oct 12, 2026
@vinniefalco
vinniefalco merged commit adfdc66 into cppalliance:master Oct 12, 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