fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) - #10005
Conversation
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.
|
Pushed a small commit trimming a few redundant comments (the per-marker doc and the inline comment in On the substring matching: that's the right call here. By the time |
DOsinga
left a comment
There was a problem hiding this comment.
Tidy, well-tested narrow fix. Comments trimmed, merged main, tests + clippy green. LGTM.
There was a problem hiding this comment.
💡 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, |
There was a problem hiding this comment.
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 👍 / 👎.
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.
* 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) ...
* 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) ...
* 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) ...
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.
Problem
When a thinking model's config changes mid-conversation, the request history still carries the original signed
thinking/redacted_thinkingblocks. Anthropic (including via Databricks/Bedrock) rejects them with:should_retryreturned!config.transient_onlyfor allRequestFailederrors, andRetryConfig::default()hastransient_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 broadRequestFailedretry behavior is intentionally unchanged (the existingdefault_config_retries_request_failedcontract still holds), so this is a narrow, non-regressing fix.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.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.