Retry a refused turn on another model - #2734
Open
t1mdurden wants to merge 3 commits into
Open
Conversation
Claude Code emits a `model_refusal_no_fallback` system message when the model ends the stream with stop_reason "refusal" and no fallback model is configured. bb never configures one, so this is the message bb sees for every safeguards refusal, and it was translated into a generic `provider/warning` with the free-text summary "Model refused the request". Nothing downstream could tell that failure apart from any other warning: `policy` is a real `providerErrorCategory` with its own title in thread-view, but no Claude Code code path could ever produce it. Translate the refusal into a `provider/error` carrying `errorInfo.category: "policy"`, the CLI's own explanation as `detail`, and the refusal category as `providerCode` when the CLI reports one. The timeline now titles it "Provider policy blocked request" instead of a generic warning, and a plugin can match it the way provider-retry matches `rate-limit`. `original_model` and `api_refusal_category` are optional because the SDK documents both as absent from older CLIs.
When a provider's safety policy refuses a message, the turn ends with nothing the user can act on: the model will not answer this message, and retrying it unchanged on the same model cannot help. bb had no recovery for that, so the thread simply stopped. Add a builtin plugin that reconciles a finished turn the same way provider-retry does, but keyed on a `policy` provider error instead of a rate limit. It offers the models listed below the refused one in that provider's own catalog, switches the thread to the chosen model, and sends one agent-only "Please continue." turn on the existing conversation. Offering only lower catalog entries is what makes a repeatedly refused thread terminate instead of switching forever. Ticking "Switch automatically next time, do not ask again" stores the chosen model per provider in the plugin's KV storage and skips the prompt from then on. `bb refusal-fallback status|forget|retry` reads and clears that choice so an agent can drive the same feature. The plugin is provider-agnostic: it reads the catalog through `sdk.providers.models`, so Codex's `policy` errors get the same treatment.
2 tasks
The pure classifier and the renderer were covered, but the orchestration between them was not: nothing checked that the prompt offers the right models, that accepting switches the thread and continues the turn, or that "do not ask again" actually persists and skips the next prompt — the behavior the plugin exists for. Drive the service through createFakePluginHost so the prompt, the KV round-trip, and the sdk calls are all real. Covers accept, accept and remember (the second refusal switches with no prompt raised), decline, a refused model with no alternatives below it, and the guard against acting twice on one turn.
Contributor
Author
|
Pushed 73563df — service-level coverage for the gap I flagged in How you verified: the classifier and the renderer were tested, but nothing exercised the orchestration between them.
Still not verified, unchanged from the PR body: no live API refusal has been observed end to end, and the Codex
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human comments
What was wrong
Claude Code emits a
model_refusal_no_fallbacksystem message when the stream ends withstop_reason: "refusal"and no fallback model is configured — and bb never configures one, so that is the message bb sees for every safeguards refusal.delta-translation.tsturned it into a genericprovider/warningwith the free-text summary "Model refused the request", which made the failure both untyped in the timeline and invisible to automation:policyis a realproviderErrorCategorywith its own title in thread-view, but no Claude Code path could produce it, so a plugin had nothing to match the wayprovider-retrymatchesrate-limit. With nothing typed to react to, the only recovery was to open the model picker, switch by hand, and resend — once per refusal, even though the refusal is model-specific and "retry on another model" is the single useful action.Fixes #2733
What changed
plugins/provider-claude-code— type the refusal.model_refusal_no_fallbacknow translates to aprovider/errorwitherrorInfo.category: "policy", the CLI's own explanation asdetail, and the refusal category (cyber,bio, …) asproviderCodewhen the CLI reports one. This reaches an existing category rather than adding one, so there is no domain or wire change and noHOST_DAEMON_PROTOCOL_VERSIONbump.original_modelandapi_refusal_categoryare optional in the schema because the SDK documents both as absent from older CLIs. The timeline now titles it "Provider policy blocked request".plugins/provider-refusal-fallback— a new builtin plugin (enabled by default). It reconciles a finished turn the wayprovider-retrydoes, but keyed on apolicyprovider error. It offers the models listed below the refused one in that provider's catalog, switches the thread withsdk.threads.update, and sends one agent-onlyPlease continue.turn. Offering only lower catalog entries is what makes a repeatedly refused thread terminate rather than switch forever. "Switch automatically next time, do not ask again" stores the chosen model per provider in the plugin's KV storage and skips the prompt from then on.It reads the catalog through
sdk.providers.modelsand never names a model or a provider, so Codex'spolicyerrors get the same treatment.No new plugin API: it consumes
bb.ui.requestInput,bb.storage.kv,bb.events.on,bb.cli.register, andapp.slots.pendingInteraction, all already stable — so noexperimental_prefix and nodocs/api_to_audit.mdentry.CLI and docs, per
docs/cli-guide-and-skill.md.bb refusal-fallback status|forget|retrygives agents the same feature as the UI. Documented indocs/configuration.md,bb-guide-plugins.md, andbb-guide-providers.md; registered inbuiltin-registry.ts,plugin-icons.ts,turbo.json, and the builtin/official plugin test fixtures.Deliberately not done: passing the SDK's own
fallbackModelthroughbuildClaudeSessionParams. It is less code, but it decides for the user, its documented purpose is overload/unavailability rather than refusal, and it swaps the session's model behind bb's back somodelOverrideand the live session drift apart.How you verified
Node 22.23.1, macOS.
New tests that fail before and pass after:
delta-translation.test.ts— "surfaces refusal without fallback as a policy provider error" and "names the refusing model when the refusal carries no content". Both fail onmain, which emits aprovider/warning.plugins/provider-refusal-fallback/server.test.ts—classifyRefusalFallbackreturns the refused model only for a finished turn carrying a policy error; a rate-limit error, an unfinished turn, a manual stop, and a null environment each return no candidate.alternativeModelsruns out at the bottom of the catalog, which is the termination guarantee.plugins/provider-refusal-fallback/app.test.tsx— the prompt submits the first offered model by default, carriesremember: trueonly when the checkbox is ticked, and submitsmodel: nullwhen declined.Not verified: I did not exercise this against a live refusal from the API. The translation is driven by the SDK's documented
SDKModelRefusalNoFallbackMessageshape (@anthropic-ai/claude-agent-sdk@0.3.251) and tested through the existing delta harness, but a real end-to-end refusal has not been observed.Pre-existing and unrelated: two
@bb/plugin-buildtests ("names the unbuilt SDK dist…") fail identically onf4bbc2fe8with no changes applied — the expected error string is compared against a macOS/private/tmppath.Opened per CONTRIBUTING as the prototype for #2733 rather than as a merge request — happy to land only the first commit, or to reshape the second, on sign-off.