From 0c26bf060c8a19da28006066028881d47badf095 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 7 Mar 2026 17:55:00 -0500 Subject: [PATCH] Strip OpenAI reasoning items from provider_parts after compaction After compaction replaces old turns with a summary, preserved Assistant turns may contain opaque openai_reasoning items that reference the now- removed context. These orphaned items violate the OpenAI Responses API constraint that reasoning items must be followed by their paired output, causing "Item 'rs_...' was provided without its required following item" errors. Strip them in a new strip_opaque_reasoning() method called at the end of compact(). Anthropic thinking blocks are left untouched. Co-Authored-By: Claude Opus 4.6 --- crates/arc-agent/src/history.rs | 128 ++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/arc-agent/src/history.rs b/crates/arc-agent/src/history.rs index be3d22259..cc95ee31f 100644 --- a/crates/arc-agent/src/history.rs +++ b/crates/arc-agent/src/history.rs @@ -27,6 +27,21 @@ impl History { timestamp: std::time::SystemTime::now(), }); self.turns.extend(preserved); + self.strip_opaque_reasoning(); + } + + /// Remove provider-specific reasoning items that are no longer valid after compaction. + /// OpenAI reasoning items are opaque round-trip data tied to specific API responses; + /// after compaction replaces their surrounding context with a summary, they serve no + /// purpose and can violate API constraints (reasoning must be followed by its output). + fn strip_opaque_reasoning(&mut self) { + for turn in &mut self.turns { + if let Turn::Assistant { provider_parts, .. } = turn { + provider_parts.retain(|p| { + !matches!(p, ContentPart::Other { kind, .. } if kind == "openai_reasoning") + }); + } + } } #[must_use] @@ -392,4 +407,117 @@ mod tests { assert_eq!(messages[1].role, Role::Assistant); assert_eq!(messages[2].role, Role::Tool); } + + #[test] + fn compact_strips_openai_reasoning_from_preserved_turns() { + let mut history = History::default(); + history.push(Turn::User { + content: "old msg".into(), + timestamp: SystemTime::now(), + }); + history.push(Turn::User { + content: "recent msg".into(), + timestamp: SystemTime::now(), + }); + let reasoning = ContentPart::Other { + kind: "openai_reasoning".into(), + data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), + }; + let tc = ToolCall::new("call_1", "search", serde_json::json!({})); + history.push(Turn::Assistant { + content: "response".into(), + tool_calls: vec![tc], + provider_parts: vec![reasoning], + usage: Usage::default(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + + history.compact(2, "Summary".into()); + + let assistant_turn = &history.turns()[2]; + if let Turn::Assistant { + provider_parts, + tool_calls, + content, + .. + } = assistant_turn + { + assert!(provider_parts.is_empty(), "reasoning items should be stripped"); + assert_eq!(tool_calls.len(), 1, "tool_calls should be preserved"); + assert_eq!(content, "response", "text content should be preserved"); + } else { + panic!("expected Assistant turn"); + } + } + + #[test] + fn compact_preserves_anthropic_thinking_blocks() { + let mut history = History::default(); + history.push(Turn::User { + content: "old msg".into(), + timestamp: SystemTime::now(), + }); + history.push(Turn::User { + content: "recent msg".into(), + timestamp: SystemTime::now(), + }); + let thinking = ContentPart::Thinking(arc_llm::types::ThinkingData { + text: "deep thought".into(), + signature: Some("sig_xyz".into()), + redacted: false, + }); + history.push(Turn::Assistant { + content: "answer".into(), + tool_calls: vec![], + provider_parts: vec![thinking], + usage: Usage::default(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + + history.compact(2, "Summary".into()); + + let assistant_turn = &history.turns()[2]; + if let Turn::Assistant { provider_parts, .. } = assistant_turn { + assert_eq!(provider_parts.len(), 1, "thinking block should be preserved"); + assert!(matches!(&provider_parts[0], ContentPart::Thinking(_))); + } else { + panic!("expected Assistant turn"); + } + } + + #[test] + fn compact_strips_reasoning_from_all_preserved_assistant_turns() { + let mut history = History::default(); + history.push(Turn::User { + content: "old msg".into(), + timestamp: SystemTime::now(), + }); + // Two assistant turns that will both be preserved + for i in 0..2 { + history.push(Turn::Assistant { + content: format!("response {i}"), + tool_calls: vec![], + provider_parts: vec![ContentPart::Other { + kind: "openai_reasoning".into(), + data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), + }], + usage: Usage::default(), + response_id: format!("resp_{i}"), + timestamp: SystemTime::now(), + }); + } + + history.compact(2, "Summary".into()); + + for turn in history.turns() { + if let Turn::Assistant { provider_parts, .. } = turn { + assert!( + provider_parts.is_empty(), + "all reasoning items should be stripped from all assistant turns" + ); + } + } + } }