use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use crate::driver_registry::{LlmCompletionMetadata, LlmStreamEvent, disjoint_prompt_tokens};
use crate::error::{AgentLoopError, Result};
use crate::llm_retry::RetryMetadata;
use crate::openresponses_types::{self as types, StreamingEvent};
use crate::tool_types::ToolCall;
#[derive(Clone, Default)]
pub(crate) struct ToolCallAccumulator {
pub(crate) id: String,
pub(crate) call_id: String,
pub(crate) name: String,
pub(crate) arguments: String,
pub(crate) completed: bool,
}
impl ToolCallAccumulator {
pub(crate) fn signature(&self) -> (String, String, String) {
(
self.call_id.clone(),
self.name.clone(),
self.arguments.clone(),
)
}
}
#[derive(Default)]
pub(crate) struct ToolCallStream {
pub(crate) calls: Vec<ToolCallAccumulator>,
pub(crate) emitted: Vec<(String, String, String)>,
}
impl ToolCallStream {
pub(crate) fn observe_arguments_delta(&mut self, item_id: &str, delta: &str) {
match self.calls.iter_mut().find(|tc| tc.id == item_id) {
Some(entry) if !entry.completed => entry.arguments.push_str(delta),
Some(_) => {}
None => self.calls.push(ToolCallAccumulator {
id: item_id.to_string(),
arguments: delta.to_string(),
..Default::default()
}),
}
}
pub(crate) fn observe_item(&mut self, id: &str, call_id: &str, name: &str, arguments: &str) {
let existing = self.calls.iter().position(|tc| {
(!id.is_empty() && tc.id == id) || (!call_id.is_empty() && tc.call_id == call_id)
});
let entry = match existing {
Some(index) => &mut self.calls[index],
None => {
self.calls.push(ToolCallAccumulator::default());
self.calls.last_mut().expect("entry just pushed")
}
};
if !id.is_empty() {
entry.id = id.to_string();
}
if !call_id.is_empty() {
entry.call_id = call_id.to_string();
}
if !name.is_empty() {
entry.name = name.to_string();
}
if !arguments.is_empty() {
entry.arguments = arguments.to_string();
}
}
pub(crate) fn observe_response(&mut self, output: &[types::OutputItem]) {
for item in output {
if let types::OutputItem::FunctionCall {
id,
call_id,
name,
arguments,
..
} = item
{
self.observe_item(id, call_id, name, arguments);
self.mark_complete(id, call_id);
}
}
}
pub(crate) fn observe_response_json(&mut self, response: &Value) {
let Some(output) = response.get("output").and_then(|o| o.as_array()) else {
return;
};
for item in output {
if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
continue;
}
let field = |key: &str| item.get(key).and_then(|v| v.as_str()).unwrap_or("");
self.observe_item(
field("id"),
field("call_id"),
field("name"),
field("arguments"),
);
self.mark_complete(field("id"), field("call_id"));
}
}
pub(crate) fn mark_complete(&mut self, id: &str, call_id: &str) {
if let Some(entry) = self.calls.iter_mut().find(|tc| {
(!id.is_empty() && tc.id == id) || (!call_id.is_empty() && tc.call_id == call_id)
}) {
entry.completed = true;
}
}
pub(crate) fn take_unemitted(&mut self) -> Option<Vec<ToolCall>> {
let signature: Vec<(String, String, String)> = self
.calls
.iter()
.filter(|tc| tc.completed && !tc.name.is_empty())
.map(ToolCallAccumulator::signature)
.collect();
if signature.is_empty() || signature == self.emitted {
return None;
}
self.emitted = signature;
Some(self.snapshot())
}
pub(crate) fn snapshot(&self) -> Vec<ToolCall> {
self.calls
.iter()
.filter(|tc| tc.completed && !tc.name.is_empty())
.map(|tc| {
let arguments: Value =
serde_json::from_str(&tc.arguments).unwrap_or_else(|error| {
if !tc.arguments.trim().is_empty() {
tracing::warn!(
tool = %tc.name,
call_id = %tc.call_id,
%error,
"OpenResponses: unparseable tool-call arguments, \
falling back to empty arguments"
);
}
json!({})
});
ToolCall {
id: tc.call_id.clone(),
name: tc.name.clone(),
arguments,
}
})
.collect()
}
}
pub(crate) fn completed_tool_call_event(
item: &Value,
accumulated: &Mutex<ToolCallStream>,
finish_reason: &Mutex<Option<String>>,
) -> Result<LlmStreamEvent> {
let mut complete = item.clone();
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let acc = accumulated.lock().unwrap();
if let Some(tc) = acc
.calls
.iter()
.find(|tc| item.get("id").and_then(Value::as_str) == Some(tc.id.as_str()))
{
for (field, value) in [
("arguments", &tc.arguments),
("name", &tc.name),
("call_id", &tc.call_id),
] {
if complete.get(field).is_none() {
complete[field] = Value::String(value.clone());
}
}
}
}
let call: crate::native_async::NativeToolCall = serde_json::from_value(complete)
.map_err(|_| AgentLoopError::llm("invalid completed tool call"))?;
call.validate()?;
*finish_reason.lock().unwrap() = Some("tool_calls".to_string());
if call.is_async() || matches!(call, crate::native_async::NativeToolCall::Custom { .. }) {
return Ok(LlmStreamEvent::NativeToolCall(call));
}
let crate::native_async::NativeToolCall::Function {
call_id,
name,
arguments,
..
} = call
else {
unreachable!()
};
let id = item
.get("id")
.and_then(Value::as_str)
.unwrap_or(&call_id)
.to_string();
let mut acc = accumulated.lock().unwrap();
acc.observe_item(&id, &call_id, &name, &arguments);
acc.mark_complete(&id, &call_id);
Ok(acc
.take_unemitted()
.map(LlmStreamEvent::ToolCalls)
.unwrap_or_else(|| LlmStreamEvent::TextDelta(String::new())))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_streaming_event(
event: StreamingEvent,
input_tokens: &Mutex<u32>,
output_tokens: &Mutex<u32>,
cache_read_tokens: &Mutex<Option<u32>>,
accumulated_tool_calls: &Mutex<ToolCallStream>,
finish_reason: &Mutex<Option<String>>,
deferred_events: &Mutex<Vec<LlmStreamEvent>>,
model: String,
retry_metadata: Option<Arc<RetryMetadata>>,
) -> LlmStreamEvent {
match event {
StreamingEvent::OutputTextDelta { delta, .. } => LlmStreamEvent::TextDelta(delta),
StreamingEvent::ReasoningDelta { delta, .. } => LlmStreamEvent::ReasoningDelta {
delta,
summary: false,
},
StreamingEvent::ReasoningTextDelta { delta, .. } => LlmStreamEvent::ReasoningDelta {
delta,
summary: false,
},
StreamingEvent::ReasoningSummaryDelta { delta, .. } => {
LlmStreamEvent::ReasoningDelta {
delta,
summary: true,
}
}
StreamingEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
accumulated_tool_calls
.lock()
.unwrap()
.observe_arguments_delta(&item_id, &delta);
LlmStreamEvent::TextDelta(String::new())
}
StreamingEvent::OutputItemAdded { item, .. } => {
match item {
Some(types::OutputItem::FunctionCall {
id,
call_id,
name,
arguments,
..
}) => {
accumulated_tool_calls
.lock()
.unwrap()
.observe_item(&id, &call_id, &name, &arguments);
LlmStreamEvent::TextDelta(String::new())
}
Some(types::OutputItem::Message {
phase: Some(phase_str),
..
}) => match crate::execution_phase::ExecutionPhase::from_provider_str(&phase_str) {
Some(phase) => LlmStreamEvent::MessagePhase(phase),
None => LlmStreamEvent::TextDelta(String::new()),
},
_ => LlmStreamEvent::TextDelta(String::new()),
}
}
StreamingEvent::OutputItemDone { item, .. } => {
match item {
Some(types::OutputItem::FunctionCall {
id,
call_id,
name,
arguments,
..
}) => {
let mut acc = accumulated_tool_calls.lock().unwrap();
acc.observe_item(&id, &call_id, &name, &arguments);
if let Some(tool_calls) = acc.take_unemitted() {
*finish_reason.lock().unwrap() = Some("tool_calls".to_string());
return LlmStreamEvent::ToolCalls(tool_calls);
}
LlmStreamEvent::TextDelta(String::new())
}
Some(types::OutputItem::Reasoning {
id,
summary,
content: _, encrypted_content,
}) => {
let safe_summary: Vec<String> = summary
.into_iter()
.filter_map(|part| match part {
types::ContentPart::SummaryText { text } => Some(text),
_ => None,
})
.collect();
tracing::debug!(
item_id = %id,
encrypted_len = encrypted_content.as_ref().map(|s| s.len()).unwrap_or(0),
summary_segments = safe_summary.len(),
"OpenResponses: received reasoning item"
);
let mut item =
crate::reasoning::ReasoningContentPart::opaque("openai").with_item_id(id);
if let Some(encrypted) = encrypted_content {
item = item.with_encrypted(encrypted);
}
if !safe_summary.is_empty() {
item = item.with_text(crate::reasoning::ReasoningText::Summary {
parts: safe_summary,
});
}
LlmStreamEvent::ReasoningItem(item)
}
_ => LlmStreamEvent::TextDelta(String::new()),
}
}
StreamingEvent::ResponseCompleted { response, .. }
| StreamingEvent::ResponseIncomplete { response, .. } => {
{
let mut acc = accumulated_tool_calls.lock().unwrap();
acc.observe_response(&response.output);
if let Some(tool_calls) = acc.take_unemitted() {
*finish_reason.lock().unwrap() = Some("tool_calls".to_string());
deferred_events
.lock()
.unwrap()
.push(LlmStreamEvent::ToolCalls(tool_calls));
}
}
if let Some(usage) = &response.usage {
*input_tokens.lock().unwrap() = usage.input_tokens;
*output_tokens.lock().unwrap() = usage.output_tokens;
if let Some(details) = &usage.input_tokens_details {
*cache_read_tokens.lock().unwrap() = Some(details.cached_tokens);
}
}
let reason = match response.status {
types::ResponseStatus::Completed => {
let existing = finish_reason.lock().unwrap().clone();
existing.unwrap_or_else(|| "stop".to_string())
}
types::ResponseStatus::Failed => {
tracing::warn!(
response_id = %response.id,
error = ?response.error,
"OpenResponsesDriver: response completed with 'failed' status"
);
"error".to_string()
}
types::ResponseStatus::Cancelled => "cancelled".to_string(),
types::ResponseStatus::Incomplete => response
.incomplete_details
.as_ref()
.map(|details| match details.reason.as_str() {
"max_output_tokens" | "max_tokens" => "length",
other => other,
})
.unwrap_or("stop")
.to_string(),
_ => "stop".to_string(),
};
let phase = response.output.iter().rev().find_map(|item| {
if let types::OutputItem::Message { phase, .. } = item {
phase.clone()
} else {
None
}
});
let input = *input_tokens.lock().unwrap();
let output = *output_tokens.lock().unwrap();
let cached = *cache_read_tokens.lock().unwrap();
let written = response
.usage
.as_ref()
.and_then(|u| u.input_tokens_details.as_ref())
.and_then(|d| d.cache_write_tokens);
let provider_cost_usd = response.usage.as_ref().and_then(|u| u.cost);
LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
total_tokens: Some(input + output),
prompt_tokens: Some(
disjoint_prompt_tokens(input, cached).saturating_sub(written.unwrap_or(0)),
),
completion_tokens: Some(output),
cache_read_tokens: cached,
cache_creation_tokens: written,
provider_cost_usd,
model: Some(model),
finish_reason: Some(reason),
retry_metadata: retry_metadata.map(|arc| (*arc).clone()),
response_id: Some(response.id),
phase,
cache_diagnostics: None,
}))
}
StreamingEvent::Error { error, .. } => {
tracing::warn!(
error_code = error.code.as_deref().unwrap_or("none"),
error_message = %error.message,
"OpenResponsesDriver: received streaming error event from provider"
);
LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
error.code,
None,
error.message,
))
}
StreamingEvent::ResponseFailed { response, .. } => {
let error = response.error.unwrap_or(types::Error {
code: "processing_error".to_string(),
message: "The provider failed while processing the response".to_string(),
});
tracing::warn!(
response_id = %response.id,
error_code = %error.code,
error_message = %error.message,
"OpenResponsesDriver: response failed in stream"
);
LlmStreamEvent::Error(crate::driver_registry::LlmStreamError::provider(
Some(error.code),
None,
error.message,
))
}
StreamingEvent::RefusalDelta { delta, .. } => {
LlmStreamEvent::Error(format!("Model refused: {}", delta).into())
}
_ => LlmStreamEvent::TextDelta(String::new()),
}
}