Skip to content

Retry a refused turn on another model - #2734

Open
t1mdurden wants to merge 3 commits into
get-bb:mainfrom
t1mdurden:feat/model-refusal-fallback
Open

Retry a refused turn on another model#2734
t1mdurden wants to merge 3 commits into
get-bb:mainfrom
t1mdurden:feat/model-refusal-fallback

Conversation

@t1mdurden

Copy link
Copy Markdown
Contributor

Human comments

What was wrong

Claude Code emits a model_refusal_no_fallback system message when the stream ends with stop_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.ts turned it into a generic provider/warning with the free-text summary "Model refused the request", which made the failure both untyped in the timeline and invisible to automation: policy is a real providerErrorCategory with its own title in thread-view, but no Claude Code path could produce it, so a plugin had nothing to match the way provider-retry matches rate-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_fallback now translates to a provider/error with errorInfo.category: "policy", the CLI's own explanation as detail, and the refusal category (cyber, bio, …) as providerCode when the CLI reports one. This reaches an existing category rather than adding one, so there is no domain or wire change and no HOST_DAEMON_PROTOCOL_VERSION bump. original_model and api_refusal_category are 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 way provider-retry does, but keyed on a policy provider error. It offers the models listed below the refused one in that provider's catalog, switches the thread with sdk.threads.update, and sends one agent-only Please 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.models and never names a model or a provider, so Codex's policy errors get the same treatment.

No new plugin API: it consumes bb.ui.requestInput, bb.storage.kv, bb.events.on, bb.cli.register, and app.slots.pendingInteraction, all already stable — so no experimental_ prefix and no docs/api_to_audit.md entry.

CLI and docs, per docs/cli-guide-and-skill.md. bb refusal-fallback status|forget|retry gives agents the same feature as the UI. Documented in docs/configuration.md, bb-guide-plugins.md, and bb-guide-providers.md; registered in builtin-registry.ts, plugin-icons.ts, turbo.json, and the builtin/official plugin test fixtures.

Deliberately not done: passing the SDK's own fallbackModel through buildClaudeSessionParams. 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 so modelOverride and the live session drift apart.

How you verified

Node 22.23.1, macOS.

pnpm exec turbo run test --filter=bb-plugin-provider-claude-code
  → 23 files, 337 passed (336 on main; the refusal test was replaced by two)

pnpm exec turbo run typecheck test --filter=bb-plugin-provider-refusal-fallback
  → 2 files, 12 passed; typecheck clean

pnpm exec turbo run test --filter=@bb/server -- --run test/services/plugins
  → 38 files, 444 passed  (builtin-plugins.test.ts reads the new manifest from disk)

pnpm exec turbo run test --filter=@bb/cli --filter=@bb/templates --continue
  → 503 + 43 passed

pnpm exec turbo run test --filter=@bb/plugin-api-map --filter=@bb/host-daemon-contract
  → 73 + 51 passed

pnpm exec turbo run typecheck --filter=@bb/server --filter=@bb/cli   → clean
pnpm exec oxlint plugins/provider-refusal-fallback plugins/provider-claude-code/src  → clean

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 on main, which emits a provider/warning.
  • plugins/provider-refusal-fallback/server.test.tsclassifyRefusalFallback returns 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. alternativeModels runs 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, carries remember: true only when the checkbox is ticked, and submits model: null when declined.

Not verified: I did not exercise this against a live refusal from the API. The translation is driven by the SDK's documented SDKModelRefusalNoFallbackMessage shape (@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-build tests ("names the unbuilt SDK dist…") fail identically on f4bbc2fe8 with no changes applied — the expected error string is compared against a macOS /private/tmp path.

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.

AGENT GENERATED

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.
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.
@t1mdurden

Copy link
Copy Markdown
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.

plugins/provider-refusal-fallback/server.test.ts now drives RefusalFallbackService through createFakePluginHost, so the prompt payload, the KV round-trip, and the sdk calls are all real:

  • accept → prompt offers Opus 4.8 / 4.7 / Sonnet 5 in catalog order, then threads.update + threads.send, and nothing is remembered
  • accept and remember → autoChoice persists, and a second refusal on a new turn switches with zero interactions raised
  • decline → no threads.update, no threads.send
  • refused model at the bottom of the catalog → no-alternative, no prompt
  • reconciling the same turn twice → already-handled, one send

pnpm exec turbo run typecheck test --filter=bb-plugin-provider-refusal-fallback17 passed, typecheck clean.

Still not verified, unchanged from the PR body: no live API refusal has been observed end to end, and the Codex policy path is reasoned-about rather than tested.

AGENT GENERATED

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.

A model refusal is untyped and unrecoverable: no way to retry the turn on another model

1 participant