From 4915574f96a578c04df801de655dc48e16ff594d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 10:54:35 -0500 Subject: [PATCH] Preserve provider-specific content parts across agent turns The agent's Turn::Assistant decomposed responses into text, tool_calls, and reasoning fields, discarding ContentPart::Other items. This lost OpenAI reasoning items needed for Responses API round-tripping. Add provider_parts field to Turn::Assistant to carry opaque content parts through the history, and emit them first in convert_to_messages so they precede function_call items as the API requires. Co-Authored-By: Claude Opus 4.6 --- crates/agent/src/history.rs | 33 ++++++++++++++++++++++++++++++ crates/agent/src/loop_detection.rs | 1 + crates/agent/src/session.rs | 8 ++++++++ crates/agent/src/types.rs | 5 ++++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/history.rs b/crates/agent/src/history.rs index 215b6c6d7..789cb7113 100644 --- a/crates/agent/src/history.rs +++ b/crates/agent/src/history.rs @@ -24,9 +24,13 @@ impl History { content, tool_calls, reasoning, + provider_parts, .. } => { let mut parts: Vec = Vec::new(); + // Provider-specific opaque parts (e.g. OpenAI reasoning items) + // must precede function calls for correct round-tripping. + parts.extend(provider_parts.iter().cloned()); if let Some(reasoning_text) = reasoning { parts.push(ContentPart::Thinking( llm::types::ThinkingData { @@ -108,6 +112,7 @@ mod tests { content: "Hi there".into(), tool_calls: vec![], reasoning: None, + provider_parts: vec![], usage: Usage::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), @@ -126,6 +131,7 @@ mod tests { content: "Let me read that".into(), tool_calls: vec![tc], reasoning: None, + provider_parts: vec![], usage: Usage::default(), response_id: "resp_2".into(), timestamp: SystemTime::now(), @@ -147,6 +153,7 @@ mod tests { content: "The answer is 42".into(), tool_calls: vec![], reasoning: Some("Let me think about this...".into()), + provider_parts: vec![], usage: Usage::default(), response_id: "resp_3".into(), timestamp: SystemTime::now(), @@ -160,6 +167,30 @@ mod tests { assert_eq!(thinking_parts.len(), 1); } + #[test] + fn assistant_turn_preserves_provider_parts() { + let mut history = History::default(); + let reasoning_item = ContentPart::Other { + kind: "openai_reasoning".to_string(), + data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), + }; + let tc = ToolCall::new("call_1", "search", serde_json::json!({})); + history.push(Turn::Assistant { + content: String::new(), + tool_calls: vec![tc], + reasoning: None, + provider_parts: vec![reasoning_item], + usage: Usage::default(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + let messages = history.convert_to_messages(); + assert_eq!(messages.len(), 1); + // Provider parts come first, then tool calls + assert!(matches!(&messages[0].content[0], ContentPart::Other { kind, .. } if kind == "openai_reasoning")); + assert!(matches!(&messages[0].content[1], ContentPart::ToolCall(_))); + } + #[test] fn tool_results_turn_maps_to_tool_message() { let mut history = History::default(); @@ -219,6 +250,7 @@ mod tests { content: "Second".into(), tool_calls: vec![], reasoning: None, + provider_parts: vec![], usage: Usage::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), @@ -241,6 +273,7 @@ mod tests { serde_json::json!({"cmd": "ls"}), )], reasoning: Some("thinking...".into()), + provider_parts: vec![], usage: Usage { input_tokens: 10, output_tokens: 5, diff --git a/crates/agent/src/loop_detection.rs b/crates/agent/src/loop_detection.rs index c48e14bc9..51dd47e81 100644 --- a/crates/agent/src/loop_detection.rs +++ b/crates/agent/src/loop_detection.rs @@ -101,6 +101,7 @@ mod tests { content: String::new(), tool_calls: vec![ToolCall::new("call_1", name, args)], reasoning: None, + provider_parts: vec![], usage: Usage::default(), response_id: "resp".into(), timestamp: SystemTime::now(), diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index 684d2efeb..6472fed80 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -289,12 +289,20 @@ impl Session { let text = response.text(); let tool_calls = response.tool_calls(); let reasoning = response.reasoning(); + let provider_parts: Vec<_> = response + .message + .content + .iter() + .filter(|p| matches!(p, llm::types::ContentPart::Other { .. })) + .cloned() + .collect(); let usage = response.usage.clone(); self.history.push(Turn::Assistant { content: text.clone(), tool_calls: tool_calls.clone(), reasoning, + provider_parts, usage, response_id: response.id.clone(), timestamp: SystemTime::now(), diff --git a/crates/agent/src/types.rs b/crates/agent/src/types.rs index 7b329edbf..cdf7fdd87 100644 --- a/crates/agent/src/types.rs +++ b/crates/agent/src/types.rs @@ -1,5 +1,5 @@ use std::time::SystemTime; -use llm::types::{ToolCall, ToolResult, Usage}; +use llm::types::{ContentPart, ToolCall, ToolResult, Usage}; #[derive(Debug, Clone)] pub enum Turn { @@ -11,6 +11,9 @@ pub enum Turn { content: String, tool_calls: Vec, reasoning: Option, + /// Opaque provider-specific content parts (e.g. OpenAI reasoning items) + /// that must be preserved for round-tripping but don't map to standard fields. + provider_parts: Vec, usage: Usage, response_id: String, timestamp: SystemTime,