Skip to content

Guard: inspect and restore every choice of an n > 1 answer - #343

Merged
fylorn merged 1 commit into
mainfrom
fix/guard-multiple-choices
Oct 16, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/guard-multiple-choices

Conversation

@fylorn

@fylorn fylorn commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Problem

An answer can carry several choices: Chat Completions choices (for example with n > 1) and Gemini candidates (for example with candidateCount > 1), and one stream frame can carry more than one of them. Two parts of tw-guard only looked at the first element:

  • Tool call firewall (tools/wall.rs): in a stream, only choices[0] / candidates[0] of each frame was inspected, and Chat argument fragments were keyed by the tool call index alone, so fragments of different choices were assembled together. In a whole Gemini body only the first candidate was inspected. A tool call in another choice could therefore reach a client without being checked against the dangerous-command rules.
  • Streamed placeholder restorer (redact/sse.rs): all choices shared one text lane and Chat tool arguments were keyed by call index alone, any choice's finish reason closed every lane, and synthesized frames always said index: 0. Half a placeholder held back for one choice could be emitted into another, leaving placeholders unrestored or tool arguments broken.

Fix

Tool call firewall:

  • every element of choices / candidates in a stream frame is inspected, and every candidate of a whole Gemini body (Chat whole bodies already were);
  • Chat argument fragments are assembled per choice (the choice's index; a missing one counts as 0, like a duplicate it is merged into that choice) and per tool call index;
  • they are also assembled per position in the frame, which is how a client that reads only the first element of each frame puts them together. A call is checked both ways, so neither kind of client can be handed a call the firewall did not see. A single-choice stream assembles the same text both ways and is matched once;
  • verdict paths name the choice or candidate the call is in. Cutting and error frames behave as before.

Placeholder restorer:

  • Chat and Gemini lanes are per choice (the choice index in the lane key), a finish reason closes only that choice's lanes, and synthesized frames carry the right choice index (and the tool call index for Chat arguments).

Public signatures are unchanged.

Tests

New tests (all but one fail on main):

  • tools::wall: a_call_in_any_element_of_a_chat_frame_is_checked, interleaved_choices_are_assembled_per_choice, repeated_or_missing_choice_indexes_are_merged_like_the_client_does, a_function_call_in_any_gemini_candidate_is_checked, and what_a_first_element_reader_assembles_is_checked_too (passes on main; it keeps the first-element reading covered after the change).
  • redact::sse: chat_choices_hold_back_and_restore_apart, a_chat_choice_that_finishes_closes_only_itself, gemini_candidates_hold_back_and_restore_apart.

cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass locally.

🤖 Generated with Claude Code

Chat Completions answers with several choices and Gemini answers with
several candidates were only partly covered by the tool call firewall
and by the streamed placeholder restorer: both looked at the first
element of `choices` / `candidates` only.

Tool call firewall (tools/wall.rs):
- every element of `choices` / `candidates` in a stream frame is
  inspected, and every Gemini candidate of a whole body;
- Chat argument fragments are assembled per choice (by the choice
  `index`, a missing one counting as 0) and per tool call index, and
  also per position in the frame, the way a client that reads only the
  first element of each frame puts them together; a call is checked the
  way either kind of client would assemble it;
- verdict paths name the choice or candidate the call is in.

Placeholder restorer (redact/sse.rs):
- Chat and Gemini text and argument lanes are kept per choice; a finish
  reason closes only the lanes of that choice, and synthesized frames
  carry its `index`.

Public signatures are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 4184876 into main Oct 16, 2026
6 checks passed
@fylorn
fylorn deleted the fix/guard-multiple-choices branch October 16, 2026 10:15
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant