From 94fca22124b3f60afbad1960c45030edee7cf1b0 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Tue, 12 May 2026 14:36:05 +0300 Subject: [PATCH] redacted thinking --- crates/openfang-memory/src/session.rs | 3 + crates/openfang-runtime/src/agent_loop.rs | 60 +++++++ crates/openfang-runtime/src/compactor.rs | 1 + .../openfang-runtime/src/drivers/anthropic.rs | 164 ++++++++++++++++++ .../openfang-runtime/src/drivers/bedrock.rs | 7 +- crates/openfang-runtime/src/llm_driver.rs | 1 + crates/openfang-types/src/message.rs | 13 +- 7 files changed, 246 insertions(+), 3 deletions(-) diff --git a/crates/openfang-memory/src/session.rs b/crates/openfang-memory/src/session.rs index f0d4c373..71a43909 100644 --- a/crates/openfang-memory/src/session.rs +++ b/crates/openfang-memory/src/session.rs @@ -592,6 +592,9 @@ impl SessionStore { openfang_types::truncate_str(thinking, 200) )); } + ContentBlock::RedactedThinking { .. } => { + text_parts.push("[redacted_thinking]".to_string()); + } ContentBlock::Unknown => {} } } diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 364c18b9..8eebbffd 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -3313,6 +3313,66 @@ mod tests { assert_eq!(saved_text, Some(final_text)); } + /// Issue #1148 — when the LLM hits MaxTokens, the persisted assistant + /// turn must keep `Thinking` and `RedactedThinking` blocks so reasoning + /// state survives across the token-limit boundary. The helper used by + /// the MaxTokens branches is the same `build_assistant_message_preserving_thinking` + /// that EndTurn uses; this test pins that contract for both block types + /// so the four MaxTokens persistence sites stay correct. + #[test] + fn test_build_assistant_message_preserves_redacted_thinking_for_max_tokens() { + let response_blocks = vec![ + ContentBlock::Thinking { + thinking: "Mid-stream reasoning".to_string(), + signature: Some("sig_xyz".to_string()), + provider_metadata: Some(serde_json::json!({ + "format": "anthropic_extended_thinking" + })), + }, + ContentBlock::RedactedThinking { + data: "encrypted_blob_abc".to_string(), + }, + ContentBlock::Text { + text: "Partial answer before token limit".to_string(), + provider_metadata: None, + }, + ]; + let final_text = "Partial answer before token limit"; + let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text); + let blocks = match &msg.content { + MessageContent::Blocks(b) => b, + other => panic!("expected Blocks content for MaxTokens persistence, got {other:?}"), + }; + + // All reasoning blocks must survive the persistence step so the + // follow-up "Please continue." turn carries them back to the model. + let has_thinking = blocks + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { .. })); + let has_redacted = blocks + .iter() + .any(|b| matches!(b, ContentBlock::RedactedThinking { .. })); + assert!(has_thinking, "Thinking block must be preserved on MaxTokens"); + assert!( + has_redacted, + "RedactedThinking block must be preserved on MaxTokens" + ); + + // Verify the opaque blob is byte-identical (Anthropic rejects altered data). + for b in blocks { + if let ContentBlock::RedactedThinking { data } = b { + assert_eq!(data, "encrypted_blob_abc"); + } + } + + // Final text reflects what the user will see. + let saved_text = blocks.iter().find_map(|b| match b { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }); + assert_eq!(saved_text, Some(final_text)); + } + #[test] fn test_retry_constants() { assert_eq!(MAX_RETRIES, 3); diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index fef90c81..713829cf 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -404,6 +404,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S conversation_text.push_str(&format!("[Image: {media_type}]\n\n")); } ContentBlock::Thinking { .. } => {} + ContentBlock::RedactedThinking { .. } => {} ContentBlock::Unknown => {} } } diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index e6f10fe5..b82e5978 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -93,6 +93,13 @@ enum ApiContentBlock { /// one (e.g. legacy sessions saved before this field was tracked). #[serde(rename = "thinking")] Thinking { thinking: String, signature: String }, + /// Redacted (encrypted) thinking block echoed back to the API. + /// + /// Anthropic returns these when the model decides to hide reasoning; + /// the `data` blob is opaque and MUST be echoed verbatim on the next + /// turn or the API rejects the resubmitted history. + #[serde(rename = "redacted_thinking")] + RedactedThinking { data: String }, } #[derive(Debug, Serialize)] @@ -139,6 +146,11 @@ enum ResponseContentBlock { #[serde(default)] signature: Option, }, + /// Redacted (encrypted) thinking block. The `data` blob is opaque to + /// us and must be persisted as-is so we can echo it back on the next + /// request — Anthropic rejects history that strips these blocks. + #[serde(rename = "redacted_thinking")] + RedactedThinking { data: String }, } #[derive(Debug, Deserialize)] @@ -174,6 +186,10 @@ enum ContentBlockAccum { name: String, input_json: String, }, + /// Redacted (encrypted) thinking block streamed from Anthropic. + /// The opaque `data` blob arrives on `content_block_start` and must be + /// persisted so the next turn can echo it back verbatim. + RedactedThinking { data: String }, } #[async_trait] @@ -447,6 +463,19 @@ impl LlmDriver for AnthropicDriver { signature: initial_sig, }); } + "redacted_thinking" => { + // Anthropic delivers redacted_thinking + // as a single block_start with the opaque + // `data` blob (no delta events). Store it + // verbatim so we can echo it back on the + // next request — API rejects history + // that strips redacted_thinking blocks. + let data = block["data"] + .as_str() + .unwrap_or("") + .to_string(); + blocks.push(ContentBlockAccum::RedactedThinking { data }); + } _ => {} } } @@ -603,6 +632,11 @@ impl LlmDriver for AnthropicDriver { }); tool_calls.push(ToolCall { id, name, input }); } + ContentBlockAccum::RedactedThinking { data } => { + if !data.is_empty() { + content.push(ContentBlock::RedactedThinking { data }); + } + } } } @@ -710,6 +744,18 @@ fn convert_message(msg: &Message) -> ApiMessage { } }) } + ContentBlock::RedactedThinking { data } => { + // Echo the encrypted blob verbatim. Anthropic + // rejects history that drops redacted_thinking + // blocks, so always include them on resubmission. + if data.is_empty() { + None + } else { + Some(ApiContentBlock::RedactedThinking { + data: data.clone(), + }) + } + } ContentBlock::Unknown => None, }) .collect(); @@ -757,6 +803,9 @@ fn convert_response(api: ApiResponse) -> CompletionResponse { })), }); } + ResponseContentBlock::RedactedThinking { data } => { + content.push(ContentBlock::RedactedThinking { data }); + } } } @@ -1019,4 +1068,119 @@ mod tests { _ => panic!("expected Thinking response block"), } } + + /// Issue #1148 — Anthropic `redacted_thinking` blocks must survive the + /// full driver round-trip. The opaque `data` blob is required verbatim + /// on resubmission; dropping or mutating it causes the API to reject + /// the assistant turn on the next request. + #[test] + fn test_redacted_thinking_round_trip() { + // Step 1: API delivers a response with a redacted_thinking block. + let api_response = ApiResponse { + content: vec![ + ResponseContentBlock::RedactedThinking { + data: "EncRyPt3D_BLO8".to_string(), + }, + ResponseContentBlock::Text { + text: "The answer is 42.".to_string(), + }, + ], + stop_reason: "end_turn".to_string(), + usage: ApiUsage { + input_tokens: 100, + output_tokens: 50, + }, + }; + let response = convert_response(api_response); + assert_eq!(response.content.len(), 2); + + // Step 2: The opaque blob must reach the ContentBlock layer. + match &response.content[0] { + ContentBlock::RedactedThinking { data } => { + assert_eq!(data, "EncRyPt3D_BLO8"); + } + other => panic!("expected RedactedThinking content block, got {other:?}"), + } + + // Step 3: Resubmit the assistant turn as conversation history. + let assistant_msg = Message { + role: Role::Assistant, + content: MessageContent::Blocks(response.content.clone()), + }; + let api_msg = convert_message(&assistant_msg); + let blocks = match api_msg.content { + ApiContent::Blocks(b) => b, + _ => panic!("expected Blocks content"), + }; + + // The redacted_thinking block must appear in the outbound payload. + let mut found_redacted = false; + for block in &blocks { + if let ApiContentBlock::RedactedThinking { data } = block { + assert_eq!(data, "EncRyPt3D_BLO8"); + found_redacted = true; + } + } + assert!( + found_redacted, + "outbound API request must include the redacted_thinking block" + ); + + // Step 4: On-the-wire JSON shape (`type=redacted_thinking`, `data` present). + let outbound_json = serde_json::to_value(&blocks).unwrap(); + let arr = outbound_json.as_array().unwrap(); + let redacted_json = arr + .iter() + .find(|v| v["type"] == "redacted_thinking") + .expect("redacted_thinking block in JSON"); + assert_eq!(redacted_json["data"], "EncRyPt3D_BLO8"); + } + + /// API response wire format for `redacted_thinking` is parsed correctly. + #[test] + fn test_redacted_thinking_serde() { + let json = serde_json::json!({ + "type": "redacted_thinking", + "data": "opaque_blob_xyz" + }); + let block: ResponseContentBlock = serde_json::from_value(json).unwrap(); + match block { + ResponseContentBlock::RedactedThinking { data } => { + assert_eq!(data, "opaque_blob_xyz"); + } + _ => panic!("expected RedactedThinking response block"), + } + } + + /// Empty redacted_thinking blocks (e.g. interrupted stream) must be + /// dropped on outbound to avoid sending malformed history. + #[test] + fn test_redacted_thinking_empty_dropped_outbound() { + let assistant_msg = Message { + role: Role::Assistant, + content: MessageContent::Blocks(vec![ + ContentBlock::RedactedThinking { + data: String::new(), + }, + ContentBlock::Text { + text: "Hello.".to_string(), + provider_metadata: None, + }, + ]), + }; + let api_msg = convert_message(&assistant_msg); + let blocks = match api_msg.content { + ApiContent::Blocks(b) => b, + _ => panic!("expected Blocks content"), + }; + for block in &blocks { + assert!( + !matches!(block, ApiContentBlock::RedactedThinking { .. }), + "empty redacted_thinking block must be dropped" + ); + } + assert!(blocks + .iter() + .any(|b| matches!(b, ApiContentBlock::Text { .. }))); + } } diff --git a/crates/openfang-runtime/src/drivers/bedrock.rs b/crates/openfang-runtime/src/drivers/bedrock.rs index 4a32f243..e00395cb 100644 --- a/crates/openfang-runtime/src/drivers/bedrock.rs +++ b/crates/openfang-runtime/src/drivers/bedrock.rs @@ -299,8 +299,11 @@ fn convert_content_block(block: &ContentBlock) -> Option { }, }, }), - // Image, Thinking, and Unknown are not supported — silently drop - ContentBlock::Image { .. } | ContentBlock::Thinking { .. } | ContentBlock::Unknown => None, + // Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop + ContentBlock::Image { .. } + | ContentBlock::Thinking { .. } + | ContentBlock::RedactedThinking { .. } + | ContentBlock::Unknown => None, } } diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index c560bde9..500192c8 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -100,6 +100,7 @@ impl CompletionResponse { self.content.iter().any(|block| match block { ContentBlock::Text { text, .. } => !text.is_empty(), ContentBlock::Thinking { thinking, .. } => !thinking.is_empty(), + ContentBlock::RedactedThinking { data } => !data.is_empty(), ContentBlock::ToolUse { .. } | ContentBlock::Image { .. } => true, _ => false, }) diff --git a/crates/openfang-types/src/message.rs b/crates/openfang-types/src/message.rs index 67955a44..e0ee890d 100644 --- a/crates/openfang-types/src/message.rs +++ b/crates/openfang-types/src/message.rs @@ -106,6 +106,15 @@ pub enum ContentBlock { #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, + /// Anthropic redacted-thinking block — encrypted reasoning the model + /// chose to hide. The `data` blob is opaque to us but MUST be echoed + /// verbatim on subsequent turns, otherwise Anthropic rejects the + /// resubmitted history. Only emitted by the Anthropic driver. + #[serde(rename = "redacted_thinking")] + RedactedThinking { + /// Opaque encrypted reasoning payload from Anthropic. + data: String, + }, /// Catch-all for unrecognized content block types (forward compatibility). #[serde(other)] Unknown, @@ -161,7 +170,9 @@ impl MessageContent { ContentBlock::ToolUse { name, input, .. } => { name.len() + input.to_string().len() } - ContentBlock::Image { .. } | ContentBlock::Unknown => 0, + ContentBlock::Image { .. } + | ContentBlock::RedactedThinking { .. } + | ContentBlock::Unknown => 0, }) .sum(), }