Skip to content

Give each section one conversation and compact it in place - #166

Closed
vinniefalco wants to merge 1 commit into
cppalliance:masterfrom
vinniefalco:master
Closed

vinniefalco wants to merge 1 commit into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

Copy link
Copy Markdown
Member

This PR gives each section exactly one conversation and makes compaction the one place its history is rewritten. It follows the plans in vibe/2026-10-21-1-section-messages.md and vibe/2026-10-22-1-section-messages-debt-removal.md.

The Engine now creates messages with every section VM, and models.loop(compactor?) always runs over it. The section's tool list is rendered once, at the first loop round, and every later round sends the same schemas, which keeps the provider's prompt cache. Records can carry meta that the model never sees. Compaction rewrites messages in place, on overflow or through models.compact. A debt pass at the end removed leftovers, made an uncaught direct compactors.fail end the run as context exhaustion, and gave the Workshop chat agent a summarizing compactor.

One conversation per section

What changes

  • SectionVm owns one MessageList, created with the VM and served as messages through the _G guard's slot table, the way argv and prose are served. Every section entry gets a fresh, empty list: walked sections, jump targets, call chains, task chains, fanout arms, and the H1 pass. A required library sees the requiring section's list.
  • models.loop(compactor?) and handle:loop(compactor?) take only the compactor and read the list through the guard. An omitted compactor means compactors.fail. The loop checks the receiver, then the arity, then the compactor's type, then whether a loop is running.
  • A loop started while a loop or a compactor runs in the same section raises a loop or a compactor is already running in this section; ask a side question with models.infer(prompt). models.infer and tools.call stay allowed inside handlers and compactors.

Changes callers will notice

  • No constructor. messages.new reads nil like any other non-integer key, with no guiding error. Assigning the global, assigning by index, and writing to a record view each raise their own refusal.
  • No list argument. models.loop(msgs, c) becomes models.loop(c), and the four argument refusals name (compactor?).
  • Chat parse. NOT_A_LIST is gone. A chat request whose messages field is not a list is a malformed yield, because only the Engine builds that field.
  • Prompts. The Workshop chat agent and prompts/research-person.md write to messages directly.

The rendered tool list

What changes

  • The first models.loop or handle:loop round that builds its tools attaches a RenderedTools (the schemas, plus each name's Option<ToolId>) to messages, before the projection and the precheck. Every later round reuses it, including rounds on another model and the retry after a compaction.
  • tools.offer, packs.offer, and tools.offer_local check MessageList::rendered() before anything else, so every offer after the render raises, even one identical to an earlier offer. A round refused before the build, such as one over an empty list or with no model, renders nothing.
  • DispatchTarget is removed. chain.advertised holds Option<ToolId>, the value ToolCallEvent::with_tool takes.

Changes callers will notice

  • Offers after the first loop raise with tools.offer: the section's tool list is fixed at its first models.loop round; make every offer before the first loop, or offer in another section. Work that offered between two loops now uses two sections.
  • Events. The TOOL_SCOPE_VALIDATION_* pair fires once per section instead of once per round.

Record metadata

  • The builders take an optional last meta argument, as in assistant(content, nil, meta), and append and replace accept a meta field. messages[i].meta reads it, and a view's JSON form includes it.
  • meta is any JSON value of at most 8192 bytes as compact JSON (MAX_RECORD_META_BYTES), and json.null means none. The projection builds wire messages from the four contract fields only, so meta never reaches the model. Tools see it when a view is passed as arguments.

Compaction

What changes

  • compactors.fail(reason) takes any reason string. The reason is a String in both the Lua and the Engine error types, and the message reads context exhausted: {detail}, where the detail keeps today's text for precheck and provider and is the reason itself otherwise.
  • When a loop round overflows at the precheck or the provider, or when the section calls models.compact(compactor, reason), the Engine snapshots the records and calls the compactor with the compacting flag set. The compactor edits messages in place.
    • If it raises, the snapshot is restored and its failure is raised.
    • If it returns, the projection's structural rules always apply, and after an overflow the estimate must shrink and the retried round must pass the precheck. Any failure restores the snapshot first.
    • After an overflow, the round retries once without counting against max_tool_iterations. A retry that overflows again restores and raises context_exhausted.
  • compactors.clear empties the list and leaves the rendered tool list attached. replace and compactors.clear work only while a compactor runs.

Changes callers will notice

  • A compactor that returns now retries the round instead of raising. COMPACTOR_RETURNED is gone.
  • replace outside a compactor raises replace works only inside a compactor; rewrite history with models.compact(compactor, reason), and the assignment, index, view, and late-system refusals now point to models.compact.
  • Run errors. An uncaught context_exhausted with a caller's reason ends the run as ContextExhausted with that reason in its message.

Debt removal

  • OverflowReason::ALL and the unused std::error::Error impl on OverflowReason are removed, along with two stale doc texts.
  • An uncaught direct compactors.fail(reason) in a block now ends the run as ContextExhausted, where it used to end as Lua (or RequirementsUnmet in H1). map_runtime_error_among classifies the typed raise next to the existing quota case, and only that variant.
  • The Workshop chat agent passes a summarizing compactor to models.loop. When a turn overflows, it summarizes every record before the last two user turns with one models.infer call and retries. The summary input is capped at 1,500 characters per record and 48,000 in total, sized for the agent's min_context: 32768. With fewer than two user turns there is nothing to summarize, and that turn fails as before. Before this change, every turn after the first overflow failed silently.

Commits

  • df3f01c78 Refuse a loop started while another runs in a section. Adds loop_running and the LOOP_RUNNING refusal.
  • b1950987b Give each section VM its own messages list. Injects the list, removes the constructor and the list argument, and migrates every prompt and test.
  • f40914cd5 Freeze a section's tool list at its first loop round. Adds RenderedTools and the offer gate, and removes DispatchTarget.
  • 4a516fdc9 Let records hold metadata that never reaches the model.
  • c952b5c78 Let compactors.fail raise with any reason.
  • 511386d85 Compact a section's messages in place on overflow or demand. Adds the snapshot, the validation, models.compact, and compactors.clear.
  • 4ca36d6f6 Allow replace and compactors.clear only inside a compactor.
  • 182167935 Align prose docs with the section's one conversation.
  • 1e808b41f Close plan: section-messages
  • 79305a90f Remove OverflowReason's ALL list, Error impl, and stale docs.
  • 5a426a4ef Map uncaught direct compactors.fail to context exhaustion.
  • 992c6fdff Summarize older chat turns when a turn overflows.
  • 7b6ababb5 Close plan: section-messages-debt-removal

Testing

Each commit passed a review and its gates locally on Windows before the next one started. At the final code state these all passed:

  • the gateway build, the format check, and both clippy runs with warnings denied
  • the rustdoc builds with -D warnings: the workspace, runvil, harness, and runvil-engine and workshop-server with private items
  • the full nextest suites, with 5360 and 308 tests passing
  • the docs-claims wording scan and cargo +nightly-2026-09-05 xtask api --check, with public-api.txt unchanged
  • cargo test -p build-xtask (257 passing) and the 1.95 floor build of the Engine crates

The plan's completeness searches for messages.new, NOT_A_LIST, DispatchTarget, and COMPACTOR_RETURNED print nothing. The new regression tests failed before their fixes, including a Workshop chat gate that runs the shipped chat.md through two overflowed turns. One gateway-logging timing test failed once during the local runs and passed when rerun. This PR changes no gateway code.

The two commits now on master (#161 and #165) touch only gateway code and share no file with this PR.

Known gaps

  • The precheck still counts neither tool schemas nor image parts, so a list that passes can overflow at the provider, which then compacts.
  • A Lua memory error raised in the loop trampoline outside any pcall could leave the busy flags set for the rest of the section. This has not been demonstrated.
  • After a chat turn overflows and the retry succeeds, the operator still sees the overflowed round's Model turn failed frame before the reply.

Related

  • The language guide changes are two commits in the runvil-docs repository and are not part of this PR: c0f04c1 rewrites the guide for the section's conversation, and e4937ed documents the direct compactors.fail run error and the chat agent's compactor.

Shrink the public API of the Engine by removing public items and narrowing others to `pub(crate)`. The change spans the parser, types, vfs, lua, and engine crates and the `runvil` facade. `crates/runvil/public-api.txt` loses 52 lines to match.

- Remove `Prompt::strip_h1_prose`, the `description_text` field, `RunContext::cancel_handle`, `RunContext::current_model`, `RunContext::depth`, `RunContext::name`, `ModelCatalog::is_empty`, `ToolIdError::field`, `ModeHandle::mode`, `SectionVm::global_json`, and `runvil_version`.
- Remove the `Depth` and `GlobKind` enums and the forms that take them: `mkdir_with` and `remove_with` on `Access` and `VfsAccess`, and `glob_with` on `VfsAccess`. Remove `Access::read_range`, `Access::read_range_numbered`, and the helper `with_line_range`.
- Stop exporting `RunUid`, `GlobalNameErrorKind`, and `OpSink` from the `runvil` facade. `RunContext::uid` and `GlobalNameError::kind` become `pub(crate)`, because they return types that the facade no longer exports.
- Narrow 48 items in total to `pub(crate)`, for example `RunLimits::tool_iterations`, `SourceLocation::new`, `FailureRecord::new`, `RealBackend::with_read_only`, `Compactor`, and `SharedSource`. Items that only tests call also get `#[cfg(test)]`.
- `split_h1` now returns `Vec<Block>` and no longer builds the description string. The `Debug` output of `RunContext` no longer shows `depth`.
- The tests for `read_range`, `read_range_numbered`, `remove_with`, `mkdir_with`, and `runvil_version` are deleted with the code, and nothing replaces them. The `glob_with` test now calls `glob_kind`, and the parser tests that read `description_text` now check `h1_blocks`.

This branch was successfully deployed

1 active deployment
github-pages — 890b41fa Deployed Oct 23, 2026 by vinniefalco via deploy #135
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