Skip to content

fix: a call refused for what it carried is sent again without its res… - #744

Merged
tgockel merged 1 commit into
version/0.3.xfrom
fix/722-content-refusal-withholds-results
Oct 28, 2026
Merged

tgockel merged 1 commit into
version/0.3.xfrom
fix/722-content-refusal-withholds-results

Conversation

@tgockel

@tgockel tgockel commented Oct 28, 2026

Copy link
Copy Markdown
Owner

…ults

A provider's content filter refused the first call carrying one tool result (an empty reply ending in content_filter), and every later message in run-new failed the same way: a failed round commits nothing, the first two rounds stay in every view, and the agent's code never runs to demote the turn. Nothing ever changed what was sent.

Now a candidate whose empty reply ends in content_filter or refusal, past its usual retries, is sent the call once more with a notice in place of each result of the turn the call answers -- the one thing no earlier call carried. The notice gives the finish reason and says the results are whole in runtime.history.turns[N], so the model neither works blind nor reruns the code. The tool calls stay beside their results, so roles and left_out indices are unchanged. If that call is answered, the store withholds the turn for every later call. If not, nothing stays withheld, and the call moves on or fails as any other.

  • The re-send goes to the same candidate before the call moves on. Moving on first would leave the head refusing, three attempts at a time, on every later call, since each call starts at the head.
  • The re-send is one request. The chain marks it on the CallSlot, and RetryingModel does not retry an unusable reply to it; the HTTP layer still retries transient failures.
  • A round's first call answers its opening, which holds no results, so it is never re-sent this way.
  • The view counts a refused turn whole, as it does for what fit leaves out, so the estimate only gets more conservative.
  • Each attempt is built from the request less rig's prompt, which every view ends with anyway. That replaces taking the request for the last candidate, and lets a pass clone it cheaply.

model.withheld records the re-send. The refused field of model.call names each turn sent with a notice, and the notice itself, so a call still rebuilds from the log alone. run-new puts the re-send in the conversation as an [outrig] line, and render-session.py shows both.

Fixes #722

Summary

What does this change, and why?

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace --all-targets -- -D warnings is clean
  • cargo test --workspace passes
  • Docs in doc/ updated if behavior changed (mdbook build still clean)
  • CHANGELOG updated for user-visible changes

Notes for reviewers

Anything worth calling out -- tricky bits, follow-ups, or intentionally out-of-scope items.

…ults

A provider's content filter refused the first call carrying one tool
result (an empty reply ending in `content_filter`), and every later
message in `run-new` failed the same way: a failed round commits
nothing, the first two rounds stay in every view, and the agent's code
never runs to demote the turn. Nothing ever changed what was sent.

Now a candidate whose empty reply ends in `content_filter` or `refusal`,
past its usual retries, is sent the call once more with a notice in
place of each result of the turn the call answers -- the one thing no
earlier call carried. The notice gives the finish reason and says the
results are whole in `runtime.history.turns[N]`, so the model neither
works blind nor reruns the code. The tool calls stay beside their
results, so roles and `left_out` indices are unchanged. If that call is
answered, the store withholds the turn for every later call. If not,
nothing stays withheld, and the call moves on or fails as any other.

- The re-send goes to the same candidate before the call moves on.
  Moving on first would leave the head refusing, three attempts at a
  time, on every later call, since each call starts at the head.
- The re-send is one request. The chain marks it on the CallSlot, and
  RetryingModel does not retry an unusable reply to it; the HTTP layer
  still retries transient failures.
- A round's first call answers its opening, which holds no results, so
  it is never re-sent this way.
- A withheld turn is counted as it is sent, with its notices, in
  choosing what fits, in the prompt's room and in the estimate. A notice
  is longer than a short or empty result, so counting the turn as it
  was could assemble a re-send over the window. The re-send's view is
  costed by withholding the turn in the store only while it assembles.
- Each attempt is built from the request less rig's prompt, which every
  view ends with anyway. That replaces taking the request for the last
  candidate, and lets a pass clone it cheaply.

`model.withheld` records the re-send. The `refused` field of
`model.call` names each turn sent with a notice, and the notice itself,
so a call still rebuilds from the log alone. run-new puts the re-send
in the conversation as an `[outrig]` line, and render-session.py shows
both.

Fixes #722
@tgockel
tgockel force-pushed the fix/722-content-refusal-withholds-results branch from fd76cae to 1584695 Compare October 28, 2026 02:59
@tgockel
tgockel merged commit 1584695 into version/0.3.x Oct 28, 2026
10 of 11 checks passed
@tgockel
tgockel deleted the fix/722-content-refusal-withholds-results branch October 28, 2026 03:11
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