Skip to content

fix: handle Anthropic 'refusal' stop reason instead of retry-looping#3640

Open
jeffbryner wants to merge 2 commits into
tailcallhq:mainfrom
jeffbryner:fix/anthropic-refusal-stop-reason
Open

fix: handle Anthropic 'refusal' stop reason instead of retry-looping#3640
jeffbryner wants to merge 2 commits into
tailcallhq:mainfrom
jeffbryner:fix/anthropic-refusal-stop-reason

Conversation

@jeffbryner

Copy link
Copy Markdown
Contributor

Fixes #3624

Problem

When Claude Fable's safety classifiers decline a request, the Anthropic API returns HTTP 200 with stop_reason: "refusal" in the message_delta SSE event. Forge's StopReason enum didn't know that variant, so:

  1. serde failed to parse the message_delta event (its stop_reason field is required),
  2. the event fell into the untagged EventData::Unknown catch-all and was silently converted to an empty assistant message — the finish reason was lost,
  3. the stream aggregator saw empty content + no tool calls + no finish reason and returned the retryable EmptyCompletion error,
  4. the retry loop resent the identical request to the same model. A refusal is deterministic, so every attempt produced the same result:
ERROR: Empty completion received - no content, tool calls, or valid finish reason

repeated until max retry attempts were exhausted.

Fix

  • forge_app (Anthropic DTO): add StopReason::Refusal, mapped to the existing FinishReason::ContentFilter (same mapping Bedrock uses for ContentFiltered and Google for SAFETY).
  • forge_domain (stream aggregator): when the finish reason is ContentFilter with no tool calls, return a new non-retryable Error::Refusal with an actionable message ("rephrase the request or switch to a different model") instead of entering the retry loop.

Intentional behavior change beyond the reported bug

Because the check lives in the provider-agnostic aggregator, content-filter finishes from Google (SAFETY/RECITATION), Bedrock (ContentFiltered), and OpenAI-compatible (content_filter) providers now also end the turn with the clear error. Previously they re-entered the orchestrator loop (the turn only completes on Stop), appending an empty assistant message and re-requesting. In the mid-stream case the partial output (already streamed to the UI) is no longer persisted to conversation context — per Anthropic's guidance, partial output before a refusal should be discarded. Happy to gate this to empty completions only if maintainers prefer the narrower change.

Out of scope / possible follow-ups

  • Automatic fallback-model retry on refusal (Anthropic's recommended recovery) — forge has no per-request model-fallback machinery today; this would be a feature PR.
  • pause_turn and model_context_window_exceeded stop reasons are also missing from the enum and currently swallowed the same way; pause_turn needs orchestrator resume semantics, so it was deliberately excluded here.
  • A #[serde(other)] catch-all on StopReason would prevent any future unknown stop reason from being silently dropped, at the cost of choosing a default mapping.

Testing

  • New: wire-level SSE parse test for stop_reason: "refusal" (asserts it parses as a known event, not EventData::Unknown), mapping test, non-retryable error downcast test, and a mid-stream refusal-after-partial-content test.
  • cargo test -p forge_app -p forge_domain -p forge_repo green (322 passed in forge_app), cargo clippy -D warnings clean, cargo fmt applied.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the type: fix Iterations on existing features or infrastructure. label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Iterations on existing features or infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Anthropic Fable new finish reason

1 participant