mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
b7883d6bef
commit
4915574f96
4 changed files with 46 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue