Skip to content

fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) - #10005

Merged
DOsinga merged 3 commits into
aaif-goose:mainfrom
kyledef:kdefreitas/retry-skip-permanent-400
Jun 30, 2026
Merged

fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability)#10005
DOsinga merged 3 commits into
aaif-goose:mainfrom
kyledef:kdefreitas/retry-skip-permanent-400

Conversation

@kyledef

@kyledef kyledef commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Problem

When a thinking model's config changes mid-conversation, the request history still carries the original signed thinking / redacted_thinking blocks. Anthropic (including via Databricks/Bedrock) rejects them with:

400 BAD_REQUEST — messages.N.content.M: `thinking` or `redacted_thinking` blocks in the
latest assistant message cannot be modified. These blocks must remain as they were in the
original response.

should_retry returned !config.transient_only for all RequestFailed errors, and RetryConfig::default() has transient_only = false. So this identical, never-recoverable payload was retried 3× (with 1s/1.6s/3.4s backoff) before the agent turn failed anyway — wasting ~6s and obscuring the real cause. Observed in the wild ending an agent session unexpectedly.

Fix

Detect this class of deterministically-permanent 4xx by message marker and never retry it, regardless of transient_only. The broad RequestFailed retry behavior is intentionally unchanged (the existing default_config_retries_request_failed contract still holds), so this is a narrow, non-regressing fix.

ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false,
ProviderError::RequestFailed(_) => !config.transient_only,

Tests

  • never_retries_permanent_thinking_block_400 — the exact 400 payload is not retried even under default (retrying) config.
  • permanent_request_failure_marker_detection — marker matching, and that ordinary 400s (e.g. "model not found") are unaffected.
  • All existing retry tests still pass.

Notes / follow-up

This is the safe half of a two-part issue. The deeper root cause — stale signed thinking blocks being sent at all after a mid-conversation config change — lives in crates/goose/src/providers/formats/anthropic.rs (message_to_anthropic) and is tracked separately. This PR stops the wasteful retries and lets the failure surface immediately.

Internal tracking: BOT-1019.

When a thinking model's config changes mid-conversation, the request history
still carries the original signed `thinking`/`redacted_thinking` blocks, and
Anthropic (incl. via Databricks/Bedrock) rejects them with HTTP 400 "blocks in
the latest assistant message cannot be modified. These blocks must remain as
they were in the original response."

`should_retry` returned `!config.transient_only` for all `RequestFailed`
errors, and the default config has `transient_only = false`, so this identical,
never-recoverable payload was retried 3x (with backoff) before the agent turn
finally failed — wasting ~6s and obscuring the cause.

Detect this class of permanent 4xx by message marker and never retry it,
regardless of `transient_only`. Broad `RequestFailed` behavior is unchanged
(the existing default-retries-RequestFailed contract still holds), so this is a
narrow, non-regressing fix.

Tests: new never_retries_permanent_thinking_block_400 +
permanent_request_failure_marker_detection; existing retry tests still pass.

Refs BOT-1019.
@DOsinga

DOsinga commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Pushed a small commit trimming a few redundant comments (the per-marker doc and the inline comment in should_retry were restating the constant's doc; the test had a comment restating its own name). Kept the one comment that earns its place — the thinking/redacted_thinking immutability explanation on the constant.

On the substring matching: that's the right call here. By the time should_retry sees a RequestFailed, it's just a String — the status code and structured payload are already gone. And this matches the existing convention in http_status.rs (is_context_length_exceeded_message), which classifies 400s by message content the same way. Also merged main in. Approving.

@DOsinga DOsinga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tidy, well-tested narrow fix. Comments trimmed, merged main, tests + clippy green. LGTM.

@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: 8c194e76cd

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ProviderError::RateLimitExceeded { .. }
| ProviderError::ServerError(_)
| ProviderError::NetworkError(_) => true,
ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle Bedrock validation errors too

Restricting the permanent-marker bypass to RequestFailed misses the Bedrock streaming path I checked: ConverseStreamError::ValidationException values that are not context-length errors fall through to ProviderError::ServerError in crates/goose/src/providers/bedrock.rs:481-496, and should_retry returns true for all ServerErrors before reaching this branch. When Bedrock returns the same thinking-block immutability validation message during request setup, it will still be retried with the identical payload, leaving that documented scenario unfixed.

Useful? React with 👍 / 👎.

kyledef added a commit to kyledef/goose that referenced this pull request Jun 30, 2026
Root cause of the 400 "thinking/redacted_thinking blocks in the latest
assistant message cannot be modified" that ends extended-thinking sessions.

A thinking block's signature is issued by — and only valid for — the model
that produced it. When a conversation switches models or thinking-effort
mid-stream (e.g. via set_config_option), the stored history still carries the
previous model's signed `thinking` / `redacted_thinking` (Databricks
`reasoning`) blocks. Replaying those signatures against a different model makes
Anthropic (direct and via Databricks/Bedrock) reject the whole request with a
400, which is unrecoverable and kills the agent turn.

Fix: when serializing history, drop signed thinking/redacted blocks whose
originating model (from `message.metadata.inference`) differs from the model
this request targets. The turn's text and tool content are still sent. When
provenance is unknown (older stored messages, or no inference metadata) the
prior behavior is kept, so single-model conversations are unaffected.

- anthropic formatter: `AnthropicFormatOptions.current_model` + shared
  `thinking_block_is_stale()`; skip stale signed `thinking` and all
  `redacted_thinking`.
- databricks formatter (the path in the observed incident): same staleness
  check applied to its `reasoning` summary blocks, threading the target model
  into `format_messages`.

Tests: stale-from-other-model is dropped (text preserved); same-model is kept;
unknown-provenance is kept — for both formatters.

Pairs with the retry-side fix (PR aaif-goose#10005). Refs BOT-1019.
@DOsinga
DOsinga added this pull request to the merge queue Jun 30, 2026
Merged via the queue into aaif-goose:main with commit c45f757 Jun 30, 2026
24 checks passed
lifeizhou-ap added a commit that referenced this pull request Jul 1, 2026
* main: (26 commits)
  Fix MCP app sandbox bridge lifecycle (#10064)
  fix(bedrock): send inference config (max_tokens, temperature) on Converse (#9889)
  feat(providers): support OpenRouter request parameters (#9276)
  Migrate local inference model management to ACP (#10124)
  (attempt to) fix disk space errors in linux release builds (#10024)
  feat: add --edit session flag to edit conversation before forking (#9799)
  feat: add iFlytek Spark and Astron MaaS providers (#9837)
  fix(desktop): dedupe Nostr session deep link imports (#9918)
  [codex] Add SessionStart hook parity outside CLI (#9970)
  feat(providers): add Fireworks AI declarative provider (#9990)
  fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) (#10005)
  fix(deps): downgrade pkcs8 to v0.10 to match sec1/pkcs1 v0.7 (#10119)
  chore(deps): bump actions/cache from 5.0.2 to 6.0.0 (#10051)
  Make OpenAI Responses API store param configurable (#10040)
  remove unsupported model (#10121)
  chore(release): bump version to 1.40.0 (minor) (#10099)
  move ollama provider into goose-providers (#9986)
  UI acp migratoin: Decouple desktop UI types from generated OpenAPI types (#10109)
  fix(otel): use async reqwest client so OTLP export works in `goose serve` mode (#10100)
  feat (acp): exposed available tools in acp schema (#10097)
  ...
lifeizhou-ap added a commit that referenced this pull request Jul 1, 2026
* main: (42 commits)
  Fix MCP app sandbox bridge lifecycle (#10064)
  fix(bedrock): send inference config (max_tokens, temperature) on Converse (#9889)
  feat(providers): support OpenRouter request parameters (#9276)
  Migrate local inference model management to ACP (#10124)
  (attempt to) fix disk space errors in linux release builds (#10024)
  feat: add --edit session flag to edit conversation before forking (#9799)
  feat: add iFlytek Spark and Astron MaaS providers (#9837)
  fix(desktop): dedupe Nostr session deep link imports (#9918)
  [codex] Add SessionStart hook parity outside CLI (#9970)
  feat(providers): add Fireworks AI declarative provider (#9990)
  fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) (#10005)
  fix(deps): downgrade pkcs8 to v0.10 to match sec1/pkcs1 v0.7 (#10119)
  chore(deps): bump actions/cache from 5.0.2 to 6.0.0 (#10051)
  Make OpenAI Responses API store param configurable (#10040)
  remove unsupported model (#10121)
  chore(release): bump version to 1.40.0 (minor) (#10099)
  move ollama provider into goose-providers (#9986)
  UI acp migratoin: Decouple desktop UI types from generated OpenAPI types (#10109)
  fix(otel): use async reqwest client so OTLP export works in `goose serve` mode (#10100)
  feat (acp): exposed available tools in acp schema (#10097)
  ...
lifeizhou-ap added a commit that referenced this pull request Jul 1, 2026
* main: (31 commits)
  test: generic validator for declarative providers (#10010)
  UI acp migratoin: Decouple desktop UI types from generated OpenAPI types (Part 2) (#10149)
  Remove MCP sampling support (#10087)
  Support TLS for ACP serve (#10088)
  feat (ui): Migrate dictation local model manager to ACP (#10131)
  Fix MCP app sandbox bridge lifecycle (#10064)
  fix(bedrock): send inference config (max_tokens, temperature) on Converse (#9889)
  feat(providers): support OpenRouter request parameters (#9276)
  Migrate local inference model management to ACP (#10124)
  (attempt to) fix disk space errors in linux release builds (#10024)
  feat: add --edit session flag to edit conversation before forking (#9799)
  feat: add iFlytek Spark and Astron MaaS providers (#9837)
  fix(desktop): dedupe Nostr session deep link imports (#9918)
  [codex] Add SessionStart hook parity outside CLI (#9970)
  feat(providers): add Fireworks AI declarative provider (#9990)
  fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) (#10005)
  fix(deps): downgrade pkcs8 to v0.10 to match sec1/pkcs1 v0.7 (#10119)
  chore(deps): bump actions/cache from 5.0.2 to 6.0.0 (#10051)
  Make OpenAI Responses API store param configurable (#10040)
  remove unsupported model (#10121)
  ...
kyledef added a commit to kyledef/goose that referenced this pull request Jul 5, 2026
Root cause of the 400 "thinking/redacted_thinking blocks in the latest
assistant message cannot be modified" that ends extended-thinking sessions.

A thinking block's signature is issued by — and only valid for — the model
that produced it. When a conversation switches models or thinking-effort
mid-stream (e.g. via set_config_option), the stored history still carries the
previous model's signed `thinking` / `redacted_thinking` (Databricks
`reasoning`) blocks. Replaying those signatures against a different model makes
Anthropic (direct and via Databricks/Bedrock) reject the whole request with a
400, which is unrecoverable and kills the agent turn.

Fix: when serializing history, drop signed thinking/redacted blocks whose
originating model (from `message.metadata.inference`) differs from the model
this request targets. The turn's text and tool content are still sent. When
provenance is unknown (older stored messages, or no inference metadata) the
prior behavior is kept, so single-model conversations are unaffected.

- anthropic formatter: `AnthropicFormatOptions.current_model` + shared
  `thinking_block_is_stale()`; skip stale signed `thinking` and all
  `redacted_thinking`.
- databricks formatter (the path in the observed incident): same staleness
  check applied to its `reasoning` summary blocks, threading the target model
  into `format_messages`.

Tests: stale-from-other-model is dropped (text preserved); same-model is kept;
unknown-provenance is kept — for both formatters.

Pairs with the retry-side fix (PR aaif-goose#10005). Refs BOT-1019.
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