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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 10:54:35 -05:00
parent b7883d6bef
commit 4915574f96
4 changed files with 46 additions and 1 deletions

View file

@ -24,9 +24,13 @@ impl History {
content,
tool_calls,
reasoning,
provider_parts,
..
} => {
let mut parts: Vec<ContentPart> = 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,

View file

@ -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(),

View file

@ -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(),

View file

@ -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<ToolCall>,
reasoning: Option<String>,
/// 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<ContentPart>,
usage: Usage,
response_id: String,
timestamp: SystemTime,