Skip to content

fix(anthropic): scope refusal handling to Anthropic - #166

Merged
KooshaPari merged 1 commit into
mainfrom
reconcile/anthropic-refusal-v21321-20260816
Aug 19, 2026
Merged

fix(anthropic): scope refusal handling to Anthropic#166
KooshaPari merged 1 commit into
mainfrom
reconcile/anthropic-refusal-v21321-20260816

Conversation

@KooshaPari

@KooshaPari KooshaPari commented Aug 16, 2026

Copy link
Copy Markdown
Owner

User description

Supersedes

Semantically replays the still-valid provider-scoped portion of #162 onto current v2.13.21-sync main (cf3a3ef) without rewriting or deleting the original conflicting branch.

Resolution

  • Current main already contains the generic no-retry fix, so its duplicate commit was intentionally not replayed.
  • Adds a distinct FinishReason::Refusal for Anthropic stop_reason: refusal.
  • Keeps generic content_filter as a normal completion, including partial-content behavior.

Validation

  • cargo test -q -p forge_app dto::anthropic::response::tests --lib
  • cargo test -q -p forge_domain result_stream_ext::tests --lib
  • cargo test -q -p forge_domain message::tests --lib
  • cargo fmt --check -- crates/forge_app/src/dto/anthropic/response.rs crates/forge_domain/src/message.rs crates/forge_domain/src/result_stream_ext.rs
  • git diff fork/main..HEAD --check

CodeAnt-AI Description

Distinguish Anthropic refusals from content-filtered completions

What Changed

  • Anthropic refusal responses now produce a dedicated refusal outcome instead of being labeled as content-filter events
  • Refusals stop processing without retrying, including when partial content was already streamed
  • Content-filtered responses remain normal completions

Impact

✅ Fewer pointless retries for Anthropic refusals
✅ Clearer refusal errors
✅ Content-filtered completions remain available

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Copilot AI lite review requested due to automatic review settings August 16, 2026 10:42
@codeant-ai

codeant-ai Bot commented Aug 16, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed 5d349e2 Aug 19, 2026 · 11:31 11:32
✅ Reviewed your PR adaab6a Aug 16, 2026 · 10:42 10:45

@codeant-ai

codeant-ai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codeant-ai codeant-ai Bot added the size:S label Aug 16, 2026
@mergify mergify Bot added the rust label Aug 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: adaab6a0ab

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// An Anthropic refusal finish is deterministic - the provider will
// return the same result for the same request - so it must not enter
// the retry loop. Content filters remain provider-neutral completions.
if finish_reason == Some(FinishReason::Refusal) && tool_calls.is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop the orchestration loop on filtered completions

When OpenAI, Google, or Bedrock returns ContentFilter without tool calls, this branch now returns a successful completion, but Orchestrator::run only sets should_yield for FinishReason::Stop or a yielding tool call (crates/forge_app/src/orch.rs:373-381). It therefore appends the empty or partial filtered response and sends another paid model request; with no max_requests_per_turn configured this can continue indefinitely, and otherwise it repeats until the limit interrupt. If content filters are meant to remain successful completions, they also need to be treated as terminal by the orchestrator.

Useful? React with 👍 / 👎.

@kilo-code-bot

kilo-code-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 2 Issues Found | Recommendation: Approve with notes — confirm the open content-filter termination thread before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2

Incremental review at 230c199: no new changes to the 3 files since ecc662e. Previous findings remain unresolved.

1. Summary

Maps Anthropic stop_reason: "refusal" to a new FinishReason::Refusal instead of ContentFilter, and narrows the non-retryable short-circuit in into_full_streaming to that variant, leaving content_filter as a normal completion.

Verified positives:

  • Error::Refusal is genuinely non-retryable — should_retry only retries Error::Retryable (crates/forge_app/src/retry.rs:34-38), so Anthropic refusals no longer spin the retry loop.
  • Tests cover empty-content refusal, mid-stream refusal with partial content, content_filter staying a completion, and FinishReason::from_str("refusal").
  • No new dependencies, no new inter-crate coupling, no secrets, no impl Trait in public APIs; FinishReason keeps Debug/Clone.
  • No exhaustive match on FinishReason exists in the workspace, and no struct containing finish_reason derives serde, so the new variant does not break in-repo compilation or persisted data.

2. Must Fix

None in the changed lines. Assumption stated for transparency: cargo clippy --workspace -- -D warnings, cargo fmt --check, and cargo test --workspace were not executed (read-only review). The changed code is equality-based and the suggested doc edit respects comment_width = 80.

3. Should Fix — open thread, not duplicated here

  • crates/forge_domain/src/result_stream_ext.rs:265 — an existing unresolved review thread flags that narrowing the guard to Refusal makes FinishReason::ContentFilter a successful (often empty) completion. I independently re-verified this against current HEAD: Orchestrator::run only completes on FinishReason::Stop (crates/forge_app/src/orch.rs:374), so a filtered turn is appended to the context and another paid request is issued — repeated until max_requests_per_turn, or unbounded when that limit is unset. Google SAFETY/RECITATION (crates/forge_app/src/dto/google/response.rs:382) and Bedrock ContentFiltered (crates/forge_repo/src/provider/bedrock.rs:404-405) both feed this path and previously terminated the turn. Please confirm the intent or make content filters terminal in the orchestrator.

4. Consider

  • crates/forge_domain/src/result_stream_ext.rs is now 1315 lines and crates/forge_app/src/dto/anthropic/response.rs is 881 lines — both already exceed the repo's ≤500-line guideline and this PR grows them further. Pre-existing, but decomposition (e.g. splitting the mod tests block into a sibling file) would help.
  • test_into_full_content_filter_remains_a_completion (result_stream_ext.rs:1268-1279) is the only new test without a rationale comment, and it asserts only finish_reason. Asserting the empty content/tool_calls and documenting the expected orchestrator behaviour would make the intended contract explicit.
  • The test comment at result_stream_ext.rs:1233 still reads "A refusal/content-filter finish" although the fixture now uses Refusal only — stale wording.
Issue Details (click to expand)

SUGGESTION

File Line Issue
crates/forge_domain/src/message.rs 165 Doc says "The Anthropic provider refused…", but Refusal is provider-neutral: every OpenAI-compatible provider goes through FinishReason::from_str (dto/openai/response.rs:345,356,421), so any finish_reason: "refusal" now hits the non-retryable path — the change is not actually scoped to Anthropic
crates/forge_domain/src/message.rs 167 New variant on this public enum is a source-breaking change for downstream consumers; consider #[non_exhaustive] as used in node.rs:288
Files Reviewed (3 files)
  • crates/forge_app/src/dto/anthropic/response.rs - 0 issues
  • crates/forge_domain/src/message.rs - 2 issues
  • crates/forge_domain/src/result_stream_ext.rs - 0 new inline issues (1 open thread from another reviewer)

5. Final Recommendation

Approve with notes. Nothing in the diff blocks compilation, tests, or security. Both inline items are non-blocking, but the open content-filter termination question should be answered before merge because it can cost real money in a loop.

Reply @kilocode-bot fix it on either inline comment to have Kilo Code apply the fix, or use the link below to address everything at once.

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit ecc662e)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit ecc662e)

Status: 2 Issues Found | Recommendation: Approve with notes — confirm the open content-filter termination thread before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2

Incremental context was discarded: history was rewritten since the previous review (5d349e2 is no longer an ancestor of ecc662e), so this run is a full re-review of the PR diff at ecc662e.

1. Summary

Maps Anthropic stop_reason: "refusal" to a new FinishReason::Refusal instead of ContentFilter, and narrows the non-retryable short-circuit in into_full_streaming to that variant, leaving content_filter as a normal completion.

Verified positives:

  • Error::Refusal is genuinely non-retryable — should_retry only retries Error::Retryable (crates/forge_app/src/retry.rs:34-38), so Anthropic refusals no longer spin the retry loop.
  • Tests cover empty-content refusal, mid-stream refusal with partial content, content_filter staying a completion, and FinishReason::from_str("refusal").
  • No new dependencies, no new inter-crate coupling, no secrets, no impl Trait in public APIs; FinishReason keeps Debug/Clone.
  • No exhaustive match on FinishReason exists in the workspace, and no struct containing finish_reason derives serde, so the new variant does not break in-repo compilation or persisted data.

2. Must Fix

None in the changed lines. Assumption stated for transparency: cargo clippy --workspace -- -D warnings, cargo fmt --check, and cargo test --workspace were not executed (read-only review). The changed code is equality-based and the suggested doc edit respects comment_width = 80.

3. Should Fix — open thread, not duplicated here

  • crates/forge_domain/src/result_stream_ext.rs:265 — an existing unresolved review thread flags that narrowing the guard to Refusal makes FinishReason::ContentFilter a successful (often empty) completion. I independently re-verified this against current HEAD: Orchestrator::run only completes on FinishReason::Stop (crates/forge_app/src/orch.rs:374), so a filtered turn is appended to the context and another paid request is issued — repeated until max_requests_per_turn, or unbounded when that limit is unset. Google SAFETY/RECITATION (crates/forge_app/src/dto/google/response.rs:382) and Bedrock ContentFiltered (crates/forge_repo/src/provider/bedrock.rs:404-405) both feed this path and previously terminated the turn. Please confirm the intent or make content filters terminal in the orchestrator.

4. Consider

  • crates/forge_domain/src/result_stream_ext.rs is now 1315 lines and crates/forge_app/src/dto/anthropic/response.rs is 881 lines — both already exceed the repo's ≤500-line guideline and this PR grows them further. Pre-existing, but decomposition (e.g. splitting the mod tests block into a sibling file) would help.
  • test_into_full_content_filter_remains_a_completion (result_stream_ext.rs:1268-1279) is the only new test without a rationale comment, and it asserts only finish_reason. Asserting the empty content/tool_calls and documenting the expected orchestrator behaviour would make the intended contract explicit.
  • The test comment at result_stream_ext.rs:1233 still reads "A refusal/content-filter finish" although the fixture now uses Refusal only — stale wording.
Issue Details (click to expand)

SUGGESTION

File Line Issue
crates/forge_domain/src/message.rs 165 Doc says "The Anthropic provider refused…", but Refusal is provider-neutral: every OpenAI-compatible provider goes through FinishReason::from_str (dto/openai/response.rs:345,356,421), so any finish_reason: "refusal" now hits the non-retryable path — the change is not actually scoped to Anthropic
crates/forge_domain/src/message.rs 167 New variant on a pub, non-#[non_exhaustive] enum is a source-breaking change for independent consumers of forge_domain (crate still at 0.1.1); consider #[non_exhaustive] as used in node.rs:288
Files Reviewed (3 files)
  • crates/forge_app/src/dto/anthropic/response.rs - 0 issues
  • crates/forge_domain/src/message.rs - 2 issues
  • crates/forge_domain/src/result_stream_ext.rs - 0 new inline issues (1 open thread from another reviewer)

5. Final Recommendation

Approve with notes. Nothing in the diff blocks compilation, tests, or security. Both inline items are non-blocking, but the open content-filter termination question should be answered before merge because it can cost real money in a loop.

Reply @kilocode-bot fix it on either inline comment to have Kilo Code apply the fix, or use the link below to address everything at once.

Fix these issues in Kilo Cloud

Previous review (commit 5d349e2)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • crates/forge_app/src/dto/anthropic/response.rs
  • crates/forge_domain/src/message.rs
  • crates/forge_domain/src/result_stream_ext.rs

Previous review (commit adaab6a)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • crates/forge_app/src/dto/anthropic/response.rs
  • crates/forge_domain/src/message.rs
  • crates/forge_domain/src/result_stream_ext.rs

Reviewed by free · Input: 37.4K · Output: 6.2K · Cached: 298.9K

@mergify mergify Bot added the rust label Aug 16, 2026
@github-actions github-actions Bot removed the rust label Aug 16, 2026
@mergify mergify Bot added the rust label Aug 16, 2026
@github-actions github-actions Bot removed the rust label Aug 17, 2026
@mergify mergify Bot added the rust label Aug 17, 2026
@github-actions github-actions Bot removed the rust label Aug 17, 2026
@mergify mergify Bot added the rust label Aug 17, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@KooshaPari
KooshaPari force-pushed the reconcile/anthropic-refusal-v21321-20260816 branch from 9e88217 to 5d349e2 Compare August 19, 2026 11:31
@codeant-ai

codeant-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added the size:S label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions

Copy link
Copy Markdown

🏎️ Performance Benchmark Results

test drift/observe_exact_match ... bench:        1064 ns/iter (+/- 7)
test drift/observe_similar_prompt ... bench:        1345 ns/iter (+/- 15)
test drift/observe_disjoint_prompt ... bench:        1222 ns/iter (+/- 10)

test eventsource/parse_1000_events_single_chunk ... bench:      548895 ns/iter (+/- 2377)
test eventsource/parse_100_events_fragmented ... bench:       21807 ns/iter (+/- 88)

test forge_fs/read_64kib ... bench:       35064 ns/iter (+/- 3959)
test forge_fs/write_then_read_64kib ... bench:      380238 ns/iter (+/- 81702)

test json_repair/small_truncated ... bench:        1155 ns/iter (+/- 6)
test json_repair/nested_broken ... bench:        4818 ns/iter (+/- 1839)
test json_repair/markdown_wrapped ... bench:       44434 ns/iter (+/- 1000)

test similarity/hash_only_compare ... bench:         158 ns/iter (+/- 3)

test stream/mpsc_stream/1000_items ... bench:      498445 ns/iter (+/- 6617)

test walker/walk_200_files ... bench:     1204228 ns/iter (+/- 12611)

Performance regression gate: check above for regressions against baseline.

@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
@KooshaPari
KooshaPari force-pushed the reconcile/anthropic-refusal-v21321-20260816 branch from 5d349e2 to ecc662e Compare August 19, 2026 18:30
@github-actions

Copy link
Copy Markdown

🏎️ Performance Benchmark Results

test drift/observe_exact_match ... bench:        1244 ns/iter (+/- 9)
test drift/observe_similar_prompt ... bench:        1589 ns/iter (+/- 7)
test drift/observe_disjoint_prompt ... bench:        1439 ns/iter (+/- 21)

test eventsource/parse_1000_events_single_chunk ... bench:      640856 ns/iter (+/- 5133)
test eventsource/parse_100_events_fragmented ... bench:       25389 ns/iter (+/- 380)

test forge_fs/read_64kib ... bench:       32656 ns/iter (+/- 2405)
test forge_fs/write_then_read_64kib ... bench:      354482 ns/iter (+/- 109271)

test json_repair/small_truncated ... bench:        1338 ns/iter (+/- 13)
test json_repair/nested_broken ... bench:        5762 ns/iter (+/- 1918)
test json_repair/markdown_wrapped ... bench:       51450 ns/iter (+/- 809)

test similarity/hash_only_compare ... bench:         182 ns/iter (+/- 2)

test stream/mpsc_stream/1000_items ... bench:      582309 ns/iter (+/- 2386)

test walker/walk_200_files ... bench:     1227513 ns/iter (+/- 16296)

Performance regression gate: check above for regressions against baseline.

/// violated filters.
#[strum(serialize = "content_filter")]
ContentFilter,
/// The Anthropic provider refused to generate a response.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Doc comment claims Anthropic scope, but this variant is provider-neutral

FinishReason lives in the provider-agnostic forge_domain crate, and Refusal is reachable well beyond Anthropic: every OpenAI-compatible provider parses the raw string through FinishReason::from_str(&s) (crates/forge_app/src/dto/openai/response.rs:345, :356, :421). With the new #[strum(serialize = "refusal")] on the next line, any provider that reports finish_reason: "refusal" (OpenRouter native reasons, Azure, gateways) now short-circuits into the non-retryable crate::Error::Refusal at crates/forge_domain/src/result_stream_ext.rs:265-267. So the PR title's "scope refusal handling to Anthropic" is not what the code does, and a reader relying on this doc will draw the wrong conclusion about which providers can produce it. Keep the doc provider-neutral (rustfmt comment_width = 80 applies).

Suggested change
/// The Anthropic provider refused to generate a response.
/// The model refused to generate a response (e.g. Anthropic refusal).

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

ContentFilter,
/// The Anthropic provider refused to generate a response.
#[strum(serialize = "refusal")]
Refusal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Adding a variant to this public enum is a source-breaking change for downstream consumers

FinishReason is pub and not #[non_exhaustive], so inserting Refusal breaks any exhaustive match in code that consumes forge_domain as an independent crate. I verified nothing in this workspace matches FinishReason exhaustively today (all call sites use == or from_str), so CI will not fail — but given the polyrepo goal of independently consumable crates, this is a semver-visible API change on a crate still published as 0.1.1 (crates/forge_domain/Cargo.toml:3).

Consider marking the enum #[non_exhaustive] (the idiom is already used at crates/forge_domain/src/node.rs:288) so future finish reasons are additive rather than breaking, and reflect this change in the crate version.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@github-actions github-actions Bot removed the rust label Aug 19, 2026
@mergify mergify Bot added the rust label Aug 19, 2026
Co-Authored-By: ForgeCode <noreply@forgecode.dev>
@KooshaPari
KooshaPari force-pushed the reconcile/anthropic-refusal-v21321-20260816 branch from ecc662e to 230c199 Compare August 19, 2026 20:14
@KooshaPari
KooshaPari merged commit cc15029 into main Aug 19, 2026
17 of 22 checks passed
@KooshaPari
KooshaPari deleted the reconcile/anthropic-refusal-v21321-20260816 branch August 19, 2026 20:14
@github-actions

Copy link
Copy Markdown

🏎️ Performance Benchmark Results

test drift/observe_exact_match ... bench:        1234 ns/iter (+/- 7)
test drift/observe_similar_prompt ... bench:        1553 ns/iter (+/- 9)
test drift/observe_disjoint_prompt ... bench:        1430 ns/iter (+/- 18)

test eventsource/parse_1000_events_single_chunk ... bench:      643632 ns/iter (+/- 5413)
test eventsource/parse_100_events_fragmented ... bench:       25434 ns/iter (+/- 92)

test forge_fs/read_64kib ... bench:       34019 ns/iter (+/- 2497)
test forge_fs/write_then_read_64kib ... bench:      478174 ns/iter (+/- 184417)

test json_repair/small_truncated ... bench:        1347 ns/iter (+/- 37)
test json_repair/nested_broken ... bench:        6031 ns/iter (+/- 1706)
test json_repair/markdown_wrapped ... bench:       51558 ns/iter (+/- 1077)

test similarity/hash_only_compare ... bench:         182 ns/iter (+/- 2)

test stream/mpsc_stream/1000_items ... bench:      591481 ns/iter (+/- 17141)

test walker/walk_200_files ... bench:     1240779 ns/iter (+/- 34557)

Performance regression gate: check above for regressions against baseline.

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.

2 participants