Skip to content

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

Merged
amitksingh1490 merged 3 commits into
tailcallhq:mainfrom
jeffbryner:fix/anthropic-refusal-stop-reason
Jul 12, 2026
Merged

fix: handle Anthropic 'refusal' stop reason instead of retry-looping#3640
amitksingh1490 merged 3 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
@github-actions

Copy link
Copy Markdown

Action required: PR inactive for 5 days.
Status update or closure in 10 days.

@github-actions github-actions Bot added the state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. label Jul 12, 2026
@amitksingh1490
amitksingh1490 enabled auto-merge (squash) July 12, 2026 03:27
@amitksingh1490 amitksingh1490 removed the state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. label Jul 12, 2026
@amitksingh1490
amitksingh1490 merged commit b8ac475 into tailcallhq:main Jul 12, 2026
9 checks passed
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

2 participants