Skip to content

Rearm the wire tools array after a mid-turn toggle_toolbox - #468

Merged
sysread merged 3 commits into
mainfrom
claude/llm-followup-tool-confusion-z0kao5
Aug 1, 2026
Merged

Rearm the wire tools array after a mid-turn toggle_toolbox#468
sysread merged 3 commits into
mainfrom
claude/llm-followup-tool-confusion-z0kao5

Conversation

@sysread

@sysread sysread commented Aug 1, 2026

Copy link
Copy Markdown
Owner

SYNOPSIS

Make a toolbox the model enables mid-turn callable in the same turn, by shipping the full tool catalog in the /stream envelope and rebuilding the wire tools array server-side after a successful toggle_toolbox.

PURPOSE

Currently the browser builds the request's tools array once, at envelope-POST time, from the thread's toolboxes_enabled, and the orchestrator's round chain reuses that frozen array for every round. A toolbox the model enables mid-turn via toggle_toolbox therefore ships no tool schemas until the next user turn. Bad because the whole toggle-then-write flow the system prompt teaches ("enable that toolbox FIRST... Then call the write tool") silently depends on the serving backend accepting calls to tools the request never declared. A backend that holds the model to the declared list turns the flow into a visible failure: in production (thread "Baking Mishap with Joy's Loaf", model deepseek-v4-flash-0731), the model's intended followup_create came out as followup_list eight rounds in a row - right after two successful toggles - while each call's activity text said "Creating a follow-up...".

DESCRIPTION

How the existing code behaves: buildToolList (browser) filters the catalog to enabled boxes and the result rides bodyTemplate.tools; getStreamingResponse copies the body round-to-round mutating only messages; performToolCall dispatches any registered name without re-checking the gate - enablement is request-shape only.

What this PR changes, at each of those points:

  • envelope - chat/loop.ts now also ships toolCatalog: buildToolCatalog(mcpToolboxes): always-on defs plus every gated box's wire defs keyed by name (static boxes in TOOLBOXES order, then MCP boxes). Browser stays the single source of truth for what tools exist.
  • round chain - after a round whose toggle_toolbox succeeded, the orchestrator rebuilds body.tools from the catalog + the toggle's accepted set (buildToolsFromCatalog in the new dependency-free venice/tool_catalog.ts); next round declares the newly enabled writes. Guarded on Array.isArray(body.tools) so a stripped-tools body stays stripped.
  • dispatch - unchanged; the gate stays request-shape. New tools.md gotcha documents that posture explicitly.

How that fixes PURPOSE: the rebuilt array is exactly what buildToolList would have produced had the toggle preceded the turn - tests/tool-catalog-parity.test.ts imports both runtimes' halves and pins the equivalence per enabled-set shape; Deno tests cover the envelope boundary coercion. Envelopes without the field (older cached PWA builds) degrade to the frozen-array behavior.

Also in the diff:

  • deletes the dead onToolboxesEnabledChange handler surface + unread ChatLoopResult.toolboxesEnabled (orphaned by the server-side round-chain migration; nothing invoked or read them - the composer flash keys off the threads realtime UPDATE echo)
  • rewrites the stale chat.md gotcha describing the retired browser-side loop (incl. a toolboxes_enabled_change broadcast that no longer exists) + the stale loop.ts comment
  • backfills QA use-case toolbox-midturn-enable.md for the single-turn toggle-then-write seam; notes in prompt-augmentation.md + mcp-integrations.md

Notes:

  • intentional: the full catalog (disabled MCP boxes included) rides every /stream POST - browser-to-function weight only, never Venice-wire or model-context cost (only the filtered subset reaches Venice)
  • intentional: server module is import-free so vitest can import it directly for parity; no new hand-maintained mirror is introduced
  • not verified live: no browser/model in the cloud env; the QA use-case is the manual proof script (checks the rearmed tools log line)

Generated by Claude Code

claude added 3 commits August 1, 2026 17:01
The browser builds the request's tools array once, at envelope-POST
time, from the thread's toolboxes_enabled - and the orchestrator's
round chain reused that frozen array for every round. A toolbox the
model enabled mid-turn via toggle_toolbox therefore shipped no tool
schemas until the next user turn. Most serving backends papered over
the gap by accepting calls to tools the request never declared (and
performToolCall runs any registered name), so toggle-then-write
appeared to work; a backend that holds the model to the declared
list turned the same flow into a visible failure - the model's
intended followup_create came out as followup_list, eight rounds in
a row, right after two successful toggles (thread "Baking Mishap
with Joy's Loaf", model deepseek-v4-flash-0731).

The envelope now also carries the full tool catalog (always-on defs
plus every gated toolbox's defs keyed by name, MCP boxes included),
and the round loop rebuilds body.tools from it whenever a round's
toggle_toolbox succeeds. The browser stays the single source of
truth for what tools exist; the server only filters and dedupes what
it was shipped, in catalog key order, so a rebuilt array is exactly
what buildToolList would have produced had the toggle preceded the
turn - tests/tool-catalog-parity.test.ts imports both sides and pins
that equivalence, and the new Deno tests cover the envelope boundary
coercion. Envelopes without the field (older cached browser builds)
degrade to the old frozen-array behavior.

Also rewrites the stale chat.md gotcha that described the retired
browser-side round loop's in-flight toggle handling (including a
toolboxes_enabled_change broadcast that no longer exists), fixes the
matching stale comment in chat/loop.ts, and backfills a QA use-case
for the single-turn toggle-then-write flow.
The handler (and ChatLoopResult.toolboxesEnabled alongside it) was
the browser-driven round loop's way of reacting to a model toggle
in-process. Since the round chain moved into the venice function,
nothing ever invoked the handler and nothing read the returned
snapshot - the composer toolbox flash already keys off the threads
realtime UPDATE echo in the drawer-refresh path, which Chat.svelte's
own comments called the only remaining signal. Remove the handler
declaration, the dead Chat.svelte callback, the unread result field,
and the comment/doc references to them.
The prompt-augmentation contributor table called the wire tools
array a per-turn snapshot of toolboxes_enabled, and the MCP doc's
wire-inflation bullet did not mention that the /stream envelope now
also ships the full catalog (disabled MCP boxes included) for the
rearm. Both statements were left behind by the rearm change; the
catalog note also draws the line that envelope weight is
browser-to-function POST cost, never Venice-wire or model-context
cost.
@sysread
sysread merged commit dfb3070 into main Aug 1, 2026
1 check passed
@sysread
sysread deleted the claude/llm-followup-tool-confusion-z0kao5 branch August 1, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants