Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions docs/dev/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,11 @@ A chat turn goes:
gateway records only the bare status code.
- `ChatLoopHandlers` - the event surface the UI uses: text
updates, tool start/done/error, persistence events,
`onToolboxesEnabledChange` (for the composer toolbox flash
when `toggle_toolbox` fires), `onGuardRetry` (a
function-side output guard discarded a junk attempt and is
re-rolling). Every handler is optional; the loop runs cleanly
with none of them. (Generated images are NOT delivered through
`onGuardRetry` (a function-side output guard discarded a junk
attempt and is re-rolling). Every handler is optional; the loop
runs cleanly with none of them. (A model-driven `toggle_toolbox`
flip has NO handler: the composer toolbox flash keys off the
`threads` realtime UPDATE echo in the drawer-refresh path.) (Generated images are NOT delivered through
a handler: the function attaches them server-side per round and
`GeneratedImageCard` resolves them by filename - see
[./attachments.md](./attachments.md).)
Expand Down Expand Up @@ -387,12 +387,15 @@ A chat turn goes:
`runExchange` resolved from `ctx.threadId`. See
`./exchange.md` for the full lifecycle and the
`respondingElsewhere` / observer-side wiring.
- **Tools** - every main-chat round's `/stream` request body
carries `tools: buildToolList(thread.toolboxes_enabled)` assembled
on the browser side. The function-side `performToolCall`
dispatches each call against its own (ported) tool registry,
persists results as `role='tool'` rows, and echoes them back into
the next round's request. See `./tools.md`.
- **Tools** - the `/stream` envelope carries two tool payloads
assembled on the browser side: `tools: buildToolList(
thread.toolboxes_enabled)` (the first round's pre-filtered wire
array) and `toolCatalog: buildToolCatalog(...)` (the full
always-on + gated catalog the orchestrator rebuilds `tools` from
after a mid-turn `toggle_toolbox`). The function-side
`performToolCall` dispatches each call against its own (ported)
tool registry, persists results as `role='tool'` rows, and echoes
them back into the next round's request. See `./tools.md`.
- **Memory (recall)** — `memory_recall` is a tool; the main model
calls it whenever it judges prior memory context would help. The
chat loop dispatches it like any other tool. See `./memory.md`.
Expand Down Expand Up @@ -496,12 +499,19 @@ A chat turn goes:
[./auto-title.md](./auto-title.md).
- **`toggle_toolbox` is the only tool that mutates the round
loop's gated-toolbox set in-flight.** The function-side round
loop inspects each tool's name and, when it sees
`toggle_toolbox`, applies the new toolbox-enabled set in
memory (no DB re-read) and emits a `toolboxes_enabled_change`
broadcast event so the browser can flash the composer
toolbox button. If you add another tool that also flips
thread state, it needs similar special-casing or a refetch.
loop inspects each round's outcomes and, when a `toggle_toolbox`
call succeeded, rebuilds the wire `tools` array in memory from the
envelope's `toolCatalog` (no DB re-read; see
`venice/tool_catalog.ts`), so a toolbox the model enables is
callable in the SAME turn. Without the rebuild the array stays
frozen at its envelope-POST shape, and a model backend that holds
the model to the declared tool list coerces the intended write
call onto the nearest declared name. The browser has no in-process
signal for the flip - it notices via the `threads` realtime UPDATE
echo and flashes the composer toolbox button off the row delta
(see the drawer-refresh handler in `Chat.svelte`). If you add
another tool that also flips thread state, it needs similar
special-casing or a refetch.
- **The round boundary needs an explicit signal; the browser
can't derive it.** The round loop runs inside the edge function
now, so the browser sees only a flat stream of deltas. The live
Expand Down
6 changes: 5 additions & 1 deletion docs/dev/mcp-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,11 @@ through them.
on `buildToolList`. MCP server catalogs can be large
(Fastmail's 10 tools). Per-toolbox enablement gates which
schemas are armed; gated MCP toolboxes follow the same
shape as built-in toolboxes.
shape as built-in toolboxes. (The /stream envelope separately
carries the FULL catalog - disabled MCP boxes included - via
`buildToolCatalog` for mid-turn toolbox rearming; that is
browser-to-function POST weight only, never Venice-wire or
model-context cost. See `tools.md`.)
- **Edge dispatch -** `performToolCall` in
`supabase/functions/venice/performToolCall.ts` has a
module-load registry populated by
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/prompt-augmentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Two distinct injection surfaces:
| Samskara compound | `<think>` (row 5) | `getCompoundSummary` (`venice/priming/samskara.ts`) | cached prose row | always-on; no fuse |
| Samskara fire | `<think>` (row 6) | `fireSamskaras` (`venice/priming/samskara.ts`) | computed per turn | raced against `SAMSKARA_PRIMING_TIMEOUT_MS` |
| Intuition | `<think>` (row 7) | `runIntuitionPipeline` (`venice/priming/intuition.ts`) | `threads.intuition_payload` | `isPayloadFreshForInjection` (STALE_FUSE_MS) |
| Tool catalog | system (row 1) | `buildSystemPrompt` / `buildToolList` (`src/lib/tools`) | n/a (derived from enabled toolboxes) | per-turn snapshot of `toolboxes_enabled` |
| Tool catalog | system (row 1) | `buildSystemPrompt` / `buildToolList` (`src/lib/tools`) | n/a (derived from enabled toolboxes) | per-turn snapshot of `toolboxes_enabled`; the wire `tools` array additionally rearms mid-turn after a `toggle_toolbox` (see `tools.md`) |
| Metadata block | system (row 8) | `buildMetadataSystemMessage` (`src/lib/chat/prompt-assembly`) | n/a | rebuilt every turn |

## Ordering
Expand Down
51 changes: 42 additions & 9 deletions docs/dev/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,14 @@ Edge dispatch (`supabase/functions/venice/`):

- **Chat loop** - `chat/loop.ts` calls
`buildToolList(thread.toolboxes_enabled)` to ship the wire
`tools` array, then observes the streamed `tool_call_request` /
`tool_call_response` events. The edge function is
writer-of-record for the whole turn: it dispatches each call via
`performToolCall` and persists the assistant-with-tool-calls row
and the per-call `role='tool'` rows. See `./chat.md`.
`tools` array plus `buildToolCatalog(...)` to ship the full
catalog in the /stream envelope, then observes the streamed
`tool_call_request` / `tool_call_response` events. The edge
function is writer-of-record for the whole turn: it dispatches
each call via `performToolCall`, persists the
assistant-with-tool-calls row and the per-call `role='tool'`
rows, and rebuilds its in-memory `tools` array from the catalog
when a round's `toggle_toolbox` succeeds. See `./chat.md`.
- **Background agents** - server-side only. Each agent composes its
own prompt and toolbox and calls `runHeadlessAgent`, which drives
model -> tool -> model rounds entirely in memory (no DB writes,
Expand Down Expand Up @@ -389,6 +392,19 @@ Edge dispatch (`supabase/functions/venice/`):
ignored; duplicates across toolboxes are deduped by tool name
(first-seen wins). Callers should never construct this array
by hand.
- `buildToolCatalog(mcpToolboxes?): ToolCatalog` - the full catalog
for the /stream envelope: always-on defs plus every gated
toolbox's wire defs keyed by toolbox name (static boxes in
`TOOLBOXES` order, then MCP boxes). The venice orchestrator
rebuilds the request's `tools` array from it after a successful
mid-turn `toggle_toolbox` (`buildToolsFromCatalog` in
`supabase/functions/venice/tool_catalog.ts`), so a toolbox the
model enables is callable in the same turn. Key order is
load-bearing (the server iterates insertion order);
`tests/tool-catalog-parity.test.ts` pins that a rebuild equals
`buildToolList` for the same enabled set. The server module is
deliberately dependency-free so the vitest parity suite can
import it directly.
- `buildSystemPrompt(opts?)` / `buildToolboxStateBlock(enabled)` -
live in `src/lib/chat/system-prompt.ts`, importing the registry from
here. The baseline is state-free; the state block renders the
Expand Down Expand Up @@ -455,10 +471,11 @@ Edge dispatch (`supabase/functions/venice/`):
## Interactions with other features

- **Chat** - `buildToolList(thread.toolboxes_enabled)` shapes the
wire catalog; the edge function dispatches and persists;
`onToolboxesEnabledChange` fires whenever the model flips the
thread's enabled set so the UI can patch its local thread row
without a refetch. See `./chat.md`.
first round's wire array and `buildToolCatalog` rides the same
envelope for the mid-turn rebuild; the edge function dispatches
and persists. The browser notices a model-driven toolbox flip via
the `threads` realtime UPDATE echo (the composer button flash
keys off the row delta). See `./chat.md`.
- **Attachments** - `generate_image` (gated `images` toolbox) is
the one tool whose output bypasses the tool-result content
entirely: the edge orchestrator harvests its generated bytes and
Expand Down Expand Up @@ -550,6 +567,22 @@ Edge dispatch (`supabase/functions/venice/`):
always-on catalog block by `toggleToolbox.name`; the toggle rule
is explained in its own prompt paragraph with the exact call
shape (`toggle_toolbox({enabled: [...]})`).
- **Enablement is request-shape only; dispatch does not re-check the
gate.** `performToolCall` runs whatever registered name the model
emits - the catalog filter (`buildToolList` + the mid-turn rebuild
from `toolCatalog`) is the whole gate. Two consequences. First,
the mid-turn rebuild is load-bearing: before it existed, a
toolbox enabled by `toggle_toolbox` shipped no schemas for the
rest of the turn, and whether the flow still worked depended on
the serving backend - most accept calls to undeclared tool names,
but a backend that holds the model to the declared list coerces
the intended write onto the nearest declared name (observed as
`followup_create` coming out as `followup_list` eight rounds in a
row while the activity text said "Creating a follow-up...").
Second, a model that hallucinates an undeclared-but-registered
write call will still execute it; the gate assumes models call
only declared tools, and history shows most do not - treat that
leniency as load-bearing legacy, not a guarantee.
- **Unknown toolbox names are dropped silently.** Both writers
(`toggle_toolbox` and the composer popover) filter against
`GATED_TOOLBOX_NAMES`, so a renamed or deleted toolbox doesn't
Expand Down
91 changes: 91 additions & 0 deletions docs/qa/use-cases/toolbox-midturn-enable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Toolbox gating: mid-turn enable arms the write tools in the same turn

> Backfilled alongside the mid-turn rearm fix. The pre-fix baseline
> is the production incident this case reproduces (thread "Baking
> Mishap with Joy's Loaf", 2026-08-01): eight `followup_list` calls
> whose activity text said "Creating a follow-up...", after two
> successful toggles. Not yet executed against the fixed code - run
> it and start the results log.

## Covers

The toggle-then-write flow inside a single assistant turn
([dev: tools](../../dev/tools.md), "Enablement is request-shape
only" gotcha; [dev: chat](../../dev/chat.md), the `toggle_toolbox`
gotcha):

- **Mid-turn rearm** - when the model calls `toggle_toolbox` and
then a write tool in the SAME turn, the orchestrator rebuilds the
wire `tools` array from the envelope's `toolCatalog`
(`supabase/functions/venice/tool_catalog.ts`), so the write is
declared to the model on the very next round.
- **Cross-model robustness** - the flow must not depend on the
serving backend accepting calls to undeclared tools. A strict
backend is the case that exposed the gap.

## Preconditions

- Local stack up (`mise run dev-start`), signed in as the dev user
(`dev@nak.local` / `devpass123`).
- A fresh thread with EVERY gated toolbox off (composer toolbox
popover shows none enabled) - the point is to force the model
through the toggle.
- Logs drawer open at Info, source filter on `stream` - the rearm
logs a line per rebuild.
- If reproducing the original incident shape, pick a model known to
hold to the declared tool list (the incident model was
`deepseek-v4-flash-0731`); any model exercises the rearm path
itself.

## Steps

1. Send a message that makes a follow-up save the obvious next
move, e.g.: "I'm baking a loaf for a friend tomorrow - remind me
later to tell you how it went."
2. Watch the tool-call cards on the assistant turn as they land.
3. In the Logs drawer (source `stream`), find the round lines for
this turn.
4. Check the row landed:

```sql
select question, status, relevant_after
from public.followups
where user_id = (select id from auth.users
where email = 'dev@nak.local')
order by created_at desc limit 3;
```

5. Repeat the shape with a different gated box in the same style,
e.g. "Save this recipe: ..." with `cooking` off, and confirm the
same single-turn toggle-then-write pattern.

## Expected

- Step 2: the turn shows a `toggle_toolbox` call (activity names
the enable) followed IN THE SAME TURN by a `followup_create`
call - not `followup_list` repurposed with a "creating" activity
line, and not a stall. A preliminary `followup_list` dedup check
before the create is fine.
- Step 3: after the round with the successful toggle, a
`rearmed tools for [followups]` line with a def count larger
than the turn's opening `toolsLen`.
- Step 4: exactly one new open follow-up row for the loaf.
- Step 5: same shape for the other box (`toggle_toolbox` ->
`recipe_save`, one new recipe row).

## Cleanup

```sql
delete from public.followups
where user_id = (select id from auth.users
where email = 'dev@nak.local');
```

Delete any test recipe from the Cookbook screen, and the test
threads from the drawer.

## Results log

| Date | Env | Commit | Result | Notes |
|---|---|---|---|---|
| - | - | - | - | Fix landed with this case; no runs yet. |
19 changes: 12 additions & 7 deletions src/lib/chat/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
*/

import type { VeniceMessage } from '../venice';
import { buildToolList } from '../tools';
import { buildToolList, buildToolCatalog } from '../tools';
import { buildSystemPrompt } from './system-prompt';
import { recordSubstrateStub } from '../samskara';
import { createLogger } from '../logger.svelte';
Expand Down Expand Up @@ -107,11 +107,13 @@ export async function runChatLoop(opts: ChatLoopOptions): Promise<ChatLoopResult
} = opts;
// Copy so we can extend locally each round without mutating the caller.
const history: VeniceMessage[] = [...opts.history];
// Snapshot the thread's current toolbox set. Mutated in the loop
// whenever the model calls `toggle_toolbox` so later rounds see the
// new wire catalog. Returned to the caller at the end for local
// state rehydration.
let toolboxesEnabled: readonly string[] = thread.toolboxes_enabled;
// Turn-entry snapshot of the thread's toolbox set. Shapes the first
// round's wire `tools` array and the metadata state block. A mid-turn
// toggle_toolbox lands server-side: the orchestrator rearms its own
// tools array from the envelope's toolCatalog, and the browser's
// thread row catches up via the realtime echo - nothing mutates this
// snapshot.
const toolboxesEnabled: readonly string[] = thread.toolboxes_enabled;
// Snapshot the user's connected MCP integrations as dynamic
// toolboxes for this turn. Built once at turn entry from the
// caller-supplied list (Chat.svelte computes it from app state via
Expand Down Expand Up @@ -261,6 +263,10 @@ export async function runChatLoop(opts: ChatLoopOptions): Promise<ChatLoopResult
messages: requestMessages,
signal,
tools: buildToolList(toolboxesEnabled, mcpToolboxes),
// Full catalog for the server-side round chain: lets it rearm
// `tools` mid-turn when the model enables a toolbox, instead of
// the new box's schemas staying undeclared until the next turn.
toolCatalog: buildToolCatalog(mcpToolboxes),
reasoningEffort,
disableThinking,
verbosity,
Expand Down Expand Up @@ -315,7 +321,6 @@ export async function runChatLoop(opts: ChatLoopOptions): Promise<ChatLoopResult
stoppedByLimit,
interrupted,
conflictDetected,
toolboxesEnabled,
awaitingUserAnswer,
};
}
14 changes: 0 additions & 14 deletions src/lib/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,6 @@ export interface ChatLoopHandlers {
onAssistantPersisted?(message: Message): void;
/** A tool-result row has been written (fires once per tool). */
onToolResultPersisted?(message: Message): void;
/**
* The thread's gated-toolbox set changed during the round (triggered
* by a `toggle_toolbox` call from the model). UI surfaces this as
* a flash on the composer toolbox button. The handler receives the
* new enabled array verbatim; callers wanting a delta should diff
* against what they stored.
*/
onToolboxesEnabledChange?(enabled: readonly string[]): void;
/**
* The thread title changed mid-turn (triggered by an `update_title`
* call from the model). Fires with the sanitised title the handler
Expand Down Expand Up @@ -367,12 +359,6 @@ export interface ChatLoopResult {
* Only set when userMessageId was provided; always false otherwise.
*/
conflictDetected: boolean;
/**
* The thread's enabled gated-toolbox set at the end of the loop.
* Callers persist this back to local state so subsequent user sends
* see the same surface the model last saw.
*/
toolboxesEnabled: readonly string[];
/**
* Non-null when the loop exited because the model called the
* `ask_user` tool and is now waiting on a human answer. The tool-
Expand Down
35 changes: 33 additions & 2 deletions src/lib/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
* decay confidence. The user-facing surface keeps hard-delete because
* "forget X" is user-directed and unambiguous. Don't collapse the two.
*/
import type { ToolDef, OpenAIToolDef, Toolbox } from './types';
import type { ToolDef, OpenAIToolDef, Toolbox, ToolCatalog } from './types';

// --- Always-on tool schemas ------------------------------------------
// Schema-only registrations: every browser `execute()` is dead - tool
Expand Down Expand Up @@ -642,6 +642,37 @@ export function buildToolList(
return out;
}

/**
* The full catalog for the /stream envelope: always-on defs plus every
* gated toolbox's defs keyed by name (static boxes in TOOLBOXES order,
* then MCP boxes). The venice orchestrator rebuilds the wire `tools`
* array from this after a mid-turn toggle_toolbox call - without it, a
* toolbox the model enables mid-turn ships no schemas until the next
* envelope POST, and a backend that holds the model to the declared
* tool list turns the enable-then-write flow into a wrong-tool call.
*
* Key order is load-bearing: the server iterates `gated` in insertion
* order, so emitting boxes here in the same order buildToolList walks
* them keeps a rebuilt array identical to what buildToolList would
* produce for the same enabled set
* (tests/tool-catalog-parity.test.ts pins this).
*/
export function buildToolCatalog(
mcpToolboxes: readonly Toolbox[] = []
): ToolCatalog {
const gated: Record<string, OpenAIToolDef[]> = {};
for (const tb of GATED_TOOLBOXES) {
gated[tb.name] = tb.tools.map(toOpenAIToolDef);
}
for (const tb of mcpToolboxes) {
gated[tb.name] = tb.tools.map(toOpenAIToolDef);
}
return {
alwaysOn: alwaysOnToolbox.tools.map(toOpenAIToolDef),
gated,
};
}

/**
* Look up the optional pretty-formatter overrides a tool may
* declare on its schema. Used by the tool-call detail panel
Expand All @@ -668,5 +699,5 @@ export { toOpenAIToolDef };
// `toggleToolbox` is read by chat/system-prompt.ts for its `.name`; re-exported
// for that one consumer.
export { toggleToolbox };
export type { ToolDef, OpenAIToolDef, ToolContext, ToolResult, Toolbox } from './types';
export type { ToolDef, OpenAIToolDef, ToolContext, ToolResult, Toolbox, ToolCatalog } from './types';
export type { OpenAIToolCall } from './types';
Loading