diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index fca3f9cc..93c4459c 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -16,6 +16,7 @@ use futures::{Stream, StreamExt}; use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType, normalize_sse_line}; use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::function_sse::{FunctionSseTranslation, FunctionSseTranslator}; use crate::types::event::{MessageStatus, ResponseStatus}; use crate::types::io::{ ApplyDone, CustomToolCall, FunctionToolCall, OutputItem, OutputMessage, OutputTextContent, ReasoningOutput, @@ -92,6 +93,23 @@ struct InFlightEntry { item: InFlight, } +#[derive(Clone, Copy)] +pub(super) struct AccumulatedFunctionCall<'a> { + pub(super) item: &'a FunctionToolCall, + pub(super) output_index: u32, + arguments: &'a str, +} + +impl AccumulatedFunctionCall<'_> { + pub(super) fn arguments(&self) -> &str { + if self.item.arguments.is_empty() { + self.arguments + } else { + &self.item.arguments + } + } +} + /// Accumulates LLM response chunks from streaming or non-streaming sources. #[derive(Debug)] pub struct ResponseAccumulator { @@ -251,6 +269,53 @@ impl ResponseAccumulator { Some(frame) } + pub(super) fn process_sse_line_with_translator( + &mut self, + line: &str, + translator: &mut FunctionSseTranslator, + ) -> ExecutorResult> { + let Some(frame) = self.process_sse_line(line) else { + return Ok(None); + }; + let call_key = function_event_key(&frame.payload); + let call = call_key.and_then(|(item_id, output_index)| self.accumulated_function_call(item_id, output_index)); + translator.translate(frame, call).map(Some) + } + + fn accumulated_function_call(&self, item_id: &str, output_index: u32) -> Option> { + let entry = self + .in_flight + .get(item_id) + .filter(|entry| matches!(entry.item, InFlight::FunctionCall { .. })) + .or_else(|| { + self.in_flight.values().find(|entry| { + entry.output_index == output_index && matches!(entry.item, InFlight::FunctionCall { .. }) + }) + }); + if let Some(InFlightEntry { + output_index, + item: InFlight::FunctionCall { item, arguments }, + }) = entry + { + return Some(AccumulatedFunctionCall { + item, + output_index: *output_index, + arguments, + }); + } + + self.completed.iter().rev().find_map(|(completed_index, item)| { + let OutputItem::FunctionCall(item) = item else { + return None; + }; + (*completed_index == output_index).then_some(AccumulatedFunctionCall { + item, + output_index: *completed_index, + arguments: &item.arguments, + }) + }) + } + fn capture_terminal_details(&mut self, frame: &EventFrame) { let Some(response) = frame.wire.rest.get("response") else { return; @@ -292,16 +357,6 @@ impl ResponseAccumulator { (SSEEventType::OutputItemAdded, payload @ EventPayload::OutputItemAdded { .. }) => { self.start_output_item(payload); } - ( - SSEEventType::OutputItemDone, - EventPayload::OutputItemDone { - item_id, - item_type: SSEItemType::CustomToolCall, - output_index, - item, - .. - }, - ) => self.complete_custom_tool_call(item_id, *output_index, item), (SSEEventType::OutputItemDone, payload @ EventPayload::OutputItemDone { .. }) => { self.complete_call_item(payload); } @@ -421,31 +476,6 @@ impl ResponseAccumulator { self.usage = usage; } - fn complete_custom_tool_call(&mut self, item_id: &str, output_index: u32, raw_item: &serde_json::Value) { - let Some(OutputItem::CustomToolCall(mut call)) = deserialize_from_value_opt::(raw_item.clone()) - else { - return; - }; - - if let Some(InFlight::CustomToolCall { item, input }) = - self.in_flight.get_mut(item_id).map(|entry| &mut entry.item) - { - if call.input.is_empty() { - call.input = if item.input.is_empty() { - std::mem::take(input) - } else { - std::mem::take(&mut item.input) - }; - } else { - input.clear(); - } - *item = call; - } else { - // Some Responses-compatible providers omit `output_item.added`. - self.completed.push((output_index, OutputItem::CustomToolCall(call))); - } - } - fn complete_call_item(&mut self, payload: &EventPayload) { let EventPayload::OutputItemDone { item_id, @@ -457,50 +487,58 @@ impl ResponseAccumulator { else { return; }; - if *item_type == SSEItemType::WebSearchCall { - let Some(OutputItem::WebSearchCall(mut call)) = deserialize_from_value_opt::(raw_item.clone()) - else { - return; - }; - let in_flight_key = self - .in_flight - .get(item_id) - .filter(|entry| matches!(entry.item, InFlight::WebSearchCall { .. })) - .map(|_| item_id.to_owned()) - .or_else(|| { - self.in_flight.iter().find_map(|(key, entry)| { - (entry.output_index == *output_index && matches!(entry.item, InFlight::WebSearchCall { .. })) - .then(|| key.clone()) - }) - }); - if call.id.is_empty() { - call.id = in_flight_key - .as_deref() - .filter(|id| !id.is_empty()) - .map_or_else(|| uuid7_str("ws_"), str::to_owned); - } - if let Some(InFlight::WebSearchCall { item }) = in_flight_key - .as_deref() - .and_then(|key| self.in_flight.get_mut(key)) - .map(|entry| &mut entry.item) - { - *item = Some(call); - } else { - self.completed.push((*output_index, OutputItem::WebSearchCall(call))); + let in_flight_key = self.in_flight_call_key(item_id, *item_type, *output_index); + let done_item = deserialize_from_value_opt::(raw_item.clone()); + if let Some(entry) = in_flight_key.as_deref().and_then(|key| self.in_flight.get_mut(key)) { + match (&mut entry.item, done_item) { + (InFlight::FunctionCall { item, arguments }, _) => item.apply_done(payload, arguments), + (InFlight::CustomToolCall { item, input }, _) => item.apply_done(payload, input), + (InFlight::McpCall { item }, _) => item.apply_done(payload, &mut String::new()), + (InFlight::WebSearchCall { item }, Some(OutputItem::WebSearchCall(mut call))) => { + if call.id.is_empty() { + call.id = in_flight_key + .as_deref() + .filter(|id| !id.is_empty()) + .map_or_else(|| uuid7_str("ws_"), str::to_owned); + } + *item = Some(call); + } + _ => {} } return; } - if let (SSEItemType::McpCall, Some(InFlight::McpCall { item })) = - (item_type, self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)) + + if let Some( + mut output_item @ (OutputItem::FunctionCall(_) + | OutputItem::CustomToolCall(_) + | OutputItem::WebSearchCall(_) + | OutputItem::McpCall(_)), + ) = done_item { - item.apply_done(payload, &mut String::new()); - return; - } - if let Some(output_item @ OutputItem::McpCall(_)) = deserialize_from_value_opt::(raw_item.clone()) { + let OutputItem::WebSearchCall(call) = &mut output_item else { + self.completed.push((*output_index, output_item)); + return; + }; + if call.id.is_empty() { + call.id = uuid7_str("ws_"); + } self.completed.push((*output_index, output_item)); } } + fn in_flight_call_key(&self, item_id: &str, item_type: SSEItemType, output_index: u32) -> Option { + self.in_flight + .get(item_id) + .filter(|entry| in_flight_matches_call_type(&entry.item, item_type)) + .map(|_| item_id.to_owned()) + .or_else(|| { + self.in_flight.iter().find_map(|(key, entry)| { + (entry.output_index == output_index && in_flight_matches_call_type(&entry.item, item_type)) + .then(|| key.clone()) + }) + }) + } + /// Marks the response as incomplete due to an error or interruption. pub fn mark_incomplete(&mut self, reason: impl Into) { self.status = ResponseStatus::Incomplete; @@ -537,6 +575,40 @@ impl ResponseAccumulator { } } +fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool { + matches!( + (item, item_type), + (InFlight::FunctionCall { .. }, SSEItemType::FunctionCall) + | (InFlight::CustomToolCall { .. }, SSEItemType::CustomToolCall) + | (InFlight::WebSearchCall { .. }, SSEItemType::WebSearchCall) + | (InFlight::McpCall { .. }, SSEItemType::McpCall) + ) +} + +fn function_event_key(payload: &EventPayload) -> Option<(&str, u32)> { + match payload { + EventPayload::OutputItemAdded { + item_id, + item_type: SSEItemType::FunctionCall, + output_index, + .. + } + | EventPayload::OutputItemDone { + item_id, + item_type: SSEItemType::FunctionCall, + output_index, + .. + } + | EventPayload::FunctionCallArgsDelta { + item_id, output_index, .. + } + | EventPayload::FunctionCallArgsDone { + item_id, output_index, .. + } => Some((item_id, *output_index)), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1429,6 +1501,63 @@ mod tests { } } + #[test] + fn test_output_item_done_restores_initially_unnamed_function_call() { + let lines = vec![ + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"","name":"","arguments":"","status":"in_progress"}}"#.to_string(), + r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"input\":\"hello\"}"}"#.to_string(), + r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"raw_echo","arguments":"","status":"completed"}}"#.to_string(), + r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert_eq!(acc.output.len(), 1); + let OutputItem::FunctionCall(call) = &acc.output[0] else { + panic!("expected function_call"); + }; + assert_eq!(call.id, "fc_1"); + assert_eq!(call.call_id, "call_1"); + assert_eq!(call.name, "raw_echo"); + assert_eq!(call.arguments, r#"{"input":"hello"}"#); + assert_eq!(call.status, MessageStatus::Completed); + } + + #[test] + fn test_function_call_done_matches_empty_added_id_by_output_index() { + let lines = vec![ + r#"data: {"type":"response.output_item.added","output_index":3,"item":{"type":"function_call","id":"","call_id":"","name":"","arguments":"","status":"in_progress"}}"#.to_string(), + r#"data: {"type":"response.output_item.done","output_index":3,"item":{"type":"function_call","id":"fc_done","call_id":"call_done","name":"raw_echo","arguments":"{}","status":"completed"}}"#.to_string(), + r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert_eq!(acc.output.len(), 1); + let OutputItem::FunctionCall(call) = &acc.output[0] else { + panic!("expected function_call"); + }; + assert_eq!(call.id, "fc_done"); + assert_eq!(call.call_id, "call_done"); + assert_eq!(call.name, "raw_echo"); + } + + #[test] + fn test_done_only_function_call_is_completed() { + let lines = vec![ + r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}","status":"completed"}}"#.to_string(), + r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert_eq!(acc.output.len(), 1); + let OutputItem::FunctionCall(call) = &acc.output[0] else { + panic!("expected function_call"); + }; + assert_eq!(call.id, "fc_1"); + assert_eq!(call.call_id, "call_1"); + assert_eq!(call.name, "get_weather"); + assert_eq!(call.arguments, r#"{"city":"Paris"}"#); + } + #[test] fn test_function_call_empty_item_id_generates_uuid() { let mut acc = ResponseAccumulator::new("resp_1".into(), None); @@ -1562,7 +1691,7 @@ mod tests { fn test_custom_tool_call_accumulates_freeform_input() { let lines = vec![ r#"data: {"type":"response.created","response":{"id":"resp_custom"}}"#.to_string(), - r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_1","name":"apply_patch","input":"","status":"in_progress"}}"#.to_string(), + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"","name":"","input":"","status":"in_progress"}}"#.to_string(), r#"data: {"type":"response.custom_tool_call_input.delta","item_id":"ctc_1","output_index":0,"delta":"*** Begin"}"#.to_string(), r#"data: {"type":"response.custom_tool_call_input.delta","item_id":"ctc_1","output_index":0,"delta":" Patch"}"#.to_string(), r#"data: {"type":"response.custom_tool_call_input.done","item_id":"ctc_1","output_index":0,"input":""}"#.to_string(), @@ -1580,4 +1709,25 @@ mod tests { assert_eq!(call.input, "*** Begin Patch"); assert_eq!(call.status, Some(MessageStatus::Completed)); } + + #[test] + fn test_reasoning_before_done_only_custom_tool_call_preserves_order() { + let lines = vec![ + r#"data: {"type":"response.created","response":{"id":"resp_custom"}}"#.to_string(), + r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(), + r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(), + r#"data: {"type":"response.output_item.done","output_index":1,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_1","name":"raw_echo","input":"hello","status":"completed"}}"#.to_string(), + r#"data: {"type":"response.completed","response":{"id":"resp_custom","status":"completed","usage":null}}"#.to_string(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert_eq!(acc.output.len(), 2); + assert!(matches!(acc.output[0], OutputItem::Reasoning(_))); + let OutputItem::CustomToolCall(call) = &acc.output[1] else { + panic!("expected CustomToolCall"); + }; + assert_eq!(call.call_id, "call_1"); + assert_eq!(call.name, "raw_echo"); + assert_eq!(call.input, "hello"); + } } diff --git a/crates/agentic-server-core/src/executor/compaction.rs b/crates/agentic-server-core/src/executor/compaction.rs index e1a221ca..ff127b59 100644 --- a/crates/agentic-server-core/src/executor/compaction.rs +++ b/crates/agentic-server-core/src/executor/compaction.rs @@ -86,9 +86,9 @@ fn item_has_meaningful_context(item: &InputItem) -> bool { }), }, InputItem::FunctionCall(call) => !call.name.trim().is_empty() || !call.arguments.trim().is_empty(), - InputItem::FunctionCallOutput(output) => !output.output.trim().is_empty(), + InputItem::FunctionCallOutput(output) => output.output.has_content(), InputItem::CustomToolCall(call) => !call.name.trim().is_empty() || !call.input.trim().is_empty(), - InputItem::CustomToolCallOutput(output) => value_has_content(&output.output), + InputItem::CustomToolCallOutput(output) => output.output.has_content(), InputItem::Reasoning(reasoning) => { reasoning.content.iter().any(|content| !content.text.trim().is_empty()) || reasoning.summary.iter().any(value_has_content) @@ -390,7 +390,7 @@ mod tests { user_message("first"), InputItem::FunctionCallOutput(FunctionToolResultMessage { call_id: "call_1".to_owned(), - output: "tool output".to_owned(), + output: "tool output".into(), }), user_message("second"), ]; @@ -427,7 +427,7 @@ mod tests { user_message("hello context"), InputItem::FunctionCallOutput(FunctionToolResultMessage { call_id: "call_1".to_owned(), - output: "substantial tool output".to_owned(), + output: "substantial tool output".into(), }), ]); diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index cf0d5744..050b1163 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -17,7 +17,7 @@ use super::gateway::{ GatewayCallResult, LoopDecision, append_gateway_calls_to_new_input, append_output_items_to_input, append_tool_outputs, classify_round, emit_gateway_completed_events, emit_gateway_start_events, execute_and_emit_output_calls, execute_output_calls, gateway_event_plans, has_client_owned_calls, - is_gateway_owned_call, public_output_items, + is_client_custom_call, is_gateway_owned_call, public_output_items, }; use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk}; use crate::events::EventFrame; @@ -262,13 +262,21 @@ async fn execute_and_emit_ordered_output_calls( let first_gateway_index = output_items .iter() .position(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry))); - let first_gateway_run_end = first_gateway_index.map_or(0, |start| { - output_items[start..] - .iter() - .take_while(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry))) - .count() - .saturating_add(start) - }); + let first_gateway_run_end = first_gateway_index + .filter(|start| { + !output_items[..*start] + .iter() + .any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry))) + }) + .map_or(0, |start| { + output_items[start..] + .iter() + .take_while( + |item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)), + ) + .count() + .saturating_add(start) + }); let first_gateway_run_len = first_gateway_run_end.saturating_sub(first_gateway_index.unwrap_or(0)); emit_gateway_start_events(&event_plans[..first_gateway_run_len], stream_accumulator, stream_sender)?; diff --git a/crates/agentic-server-core/src/executor/function_sse.rs b/crates/agentic-server-core/src/executor/function_sse.rs new file mode 100644 index 00000000..757a4cea --- /dev/null +++ b/crates/agentic-server-core/src/executor/function_sse.rs @@ -0,0 +1,606 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType}; +use crate::executor::accumulator::AccumulatedFunctionCall; +use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::gateway_accumulator::synthetic_event; +use crate::tool::ToolType; +use crate::utils::common::serialize_to_string; + +const MAX_PENDING_FUNCTION_BYTES: usize = 256 * 1024; + +#[derive(Debug)] +enum FunctionCallShape { + PublicFunction { output_index: u32 }, + GatewayOwned { output_index: u32 }, + Custom(CustomCallState), +} + +impl FunctionCallShape { + const fn output_index(&self) -> u32 { + match self { + Self::PublicFunction { output_index } + | Self::GatewayOwned { output_index } + | Self::Custom(CustomCallState { output_index, .. }) => *output_index, + } + } +} + +#[derive(Debug)] +struct CustomCallState { + public_item_id: String, + output_index: u32, + emitted_input: String, + input_done: bool, +} + +#[derive(Debug, Default)] +struct PendingFunctionCall { + frames: Vec, + bytes: usize, +} + +#[derive(Debug, Default)] +pub(super) struct FunctionSseTranslation { + pub(super) frames: Vec, + pub(super) gateway_output_index: Option, +} + +/// Restores normalized upstream function-call SSE to the public call shape. +/// Tool routing remains outside this type; it receives only the request's +/// model-visible name-to-type mapping. +#[derive(Debug, Default)] +pub(super) struct FunctionSseTranslator { + tool_types: HashMap, + active: HashMap, + pending_unnamed: HashMap, + pending_bytes: usize, +} + +impl FunctionSseTranslator { + pub(super) fn new(tool_types: HashMap) -> Self { + Self { + tool_types, + ..Self::default() + } + } + + pub(super) fn translate( + &mut self, + frame: EventFrame, + call: Option>, + ) -> ExecutorResult { + match &frame.payload { + EventPayload::OutputItemAdded { + item_id, + item_type: SSEItemType::FunctionCall, + output_index, + name: Some(name), + .. + } => self.start_call(item_id, name, *output_index, Some(frame.clone()), call), + EventPayload::OutputItemAdded { + item_id, + item_type: SSEItemType::FunctionCall, + name: None, + .. + } => self.buffer_unnamed(item_id.clone(), frame), + EventPayload::FunctionCallArgsDelta { + item_id, output_index, .. + } => self.translate_delta(item_id, *output_index, frame.clone(), call), + EventPayload::FunctionCallArgsDone { + item_id, + name, + output_index, + .. + } => self.finish_arguments(item_id, name, *output_index, frame.clone(), call), + EventPayload::OutputItemDone { + item_id, + item_type: SSEItemType::FunctionCall, + output_index, + item, + } => { + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + self.finish_call(item_id, name, *output_index, frame.clone(), call) + } + _ => Ok(FunctionSseTranslation { + frames: vec![frame], + gateway_output_index: None, + }), + } + } + + fn start_call( + &mut self, + item_id: &str, + name: &str, + output_index: u32, + original: Option, + call: Option>, + ) -> ExecutorResult { + match self.tool_type(name) { + ToolType::Custom => { + self.active.insert( + item_id.to_owned(), + FunctionCallShape::Custom(CustomCallState { + public_item_id: crate::tool::custom::public_item_id(item_id), + output_index, + emitted_input: String::new(), + input_done: false, + }), + ); + Ok(FunctionSseTranslation { + frames: call + .map(|call| custom_added_frame(&call)) + .transpose()? + .into_iter() + .collect(), + gateway_output_index: None, + }) + } + ToolType::Mcp | ToolType::WebSearch | ToolType::FileSearch | ToolType::CodeInterpreter => { + self.active + .insert(item_id.to_owned(), FunctionCallShape::GatewayOwned { output_index }); + Ok(FunctionSseTranslation { + frames: Vec::new(), + gateway_output_index: Some(output_index), + }) + } + ToolType::Function | ToolType::CodexNamespace => { + self.active + .insert(item_id.to_owned(), FunctionCallShape::PublicFunction { output_index }); + Ok(FunctionSseTranslation { + frames: original.into_iter().collect(), + gateway_output_index: None, + }) + } + } + } + + fn translate_delta( + &mut self, + item_id: &str, + output_index: u32, + original: EventFrame, + call: Option>, + ) -> ExecutorResult { + let key = self.active_key(item_id, output_index); + match key.as_deref().and_then(|key| self.active.get_mut(key)) { + Some(FunctionCallShape::PublicFunction { .. }) => Ok(FunctionSseTranslation { + frames: vec![original], + gateway_output_index: None, + }), + Some(FunctionCallShape::GatewayOwned { .. }) => Ok(FunctionSseTranslation::default()), + Some(FunctionCallShape::Custom(state)) => { + let frame = match call { + Some(call) => incremental_custom_delta(state, call.arguments())?, + None => None, + }; + Ok(FunctionSseTranslation { + frames: frame.into_iter().collect(), + gateway_output_index: None, + }) + } + None => self.buffer_unnamed(item_id.to_owned(), original), + } + } + + fn finish_arguments( + &mut self, + item_id: &str, + name: &str, + output_index: u32, + original: EventFrame, + call: Option>, + ) -> ExecutorResult { + let mut translated = self.resolve_pending(item_id, name, output_index, call)?; + let key = self.active_key(item_id, output_index); + match key.as_deref().and_then(|key| self.active.get_mut(key)) { + Some(FunctionCallShape::PublicFunction { .. }) | None => translated.frames.push(original), + Some(FunctionCallShape::GatewayOwned { .. }) => {} + Some(FunctionCallShape::Custom(state)) => { + if let Some(call) = call { + translated.frames.extend(finish_custom_input(state, call.arguments())?); + } + } + } + Ok(translated) + } + + fn finish_call( + &mut self, + item_id: &str, + name: &str, + output_index: u32, + original: EventFrame, + call: Option>, + ) -> ExecutorResult { + let mut translated = self.resolve_pending(item_id, name, output_index, call)?; + let key = self.active_key(item_id, output_index); + match key.as_deref().and_then(|key| self.active.remove(key)) { + Some(FunctionCallShape::PublicFunction { .. }) | None => translated.frames.push(original), + Some(FunctionCallShape::GatewayOwned { .. }) => {} + Some(FunctionCallShape::Custom(mut state)) => { + if let Some(call) = call { + translated + .frames + .extend(finish_custom_input(&mut state, call.arguments())?); + translated.frames.push(custom_done_frame(&state, &call)?); + } + } + } + Ok(translated) + } + + fn resolve_pending( + &mut self, + item_id: &str, + name: &str, + output_index: u32, + call: Option>, + ) -> ExecutorResult { + if self.active_key(item_id, output_index).is_some() { + return Ok(FunctionSseTranslation::default()); + } + + let pending = self.take_pending(item_id); + let original_added = pending.iter().find(|frame| { + matches!( + frame.payload, + EventPayload::OutputItemAdded { + item_type: SSEItemType::FunctionCall, + .. + } + ) + }); + let mut translated = self.start_call(item_id, name, output_index, original_added.cloned(), call)?; + + for frame in pending { + if let EventPayload::FunctionCallArgsDelta { output_index, .. } = &frame.payload { + let delta = self.translate_delta(item_id, *output_index, frame.clone(), call)?; + translated.frames.extend(delta.frames); + } + } + Ok(translated) + } + + fn tool_type(&self, name: &str) -> ToolType { + self.tool_types.get(name).copied().unwrap_or(ToolType::Function) + } + + fn active_key(&self, item_id: &str, output_index: u32) -> Option { + self.active + .contains_key(item_id) + .then(|| item_id.to_owned()) + .or_else(|| { + self.active + .iter() + .find_map(|(key, shape)| (shape.output_index() == output_index).then(|| key.clone())) + }) + } + + fn buffer_unnamed(&mut self, item_id: String, frame: EventFrame) -> ExecutorResult { + let bytes = serialize_to_string(&frame.wire) + .map_err(ExecutorError::JsonError)? + .len(); + if self.pending_bytes.saturating_add(bytes) > MAX_PENDING_FUNCTION_BYTES { + return Err(ExecutorError::StreamError(format!( + "unnamed function-call SSE exceeded {MAX_PENDING_FUNCTION_BYTES} buffered bytes" + ))); + } + let pending = self.pending_unnamed.entry(item_id).or_default(); + pending.frames.push(frame); + pending.bytes = pending.bytes.saturating_add(bytes); + self.pending_bytes = self.pending_bytes.saturating_add(bytes); + Ok(FunctionSseTranslation::default()) + } + + fn take_pending(&mut self, item_id: &str) -> Vec { + let Some(pending) = self.pending_unnamed.remove(item_id) else { + return Vec::new(); + }; + self.pending_bytes = self.pending_bytes.saturating_sub(pending.bytes); + pending.frames + } +} + +fn custom_added_frame(call: &AccumulatedFunctionCall<'_>) -> ExecutorResult { + custom_frame( + SSEEventType::OutputItemAdded, + call.output_index, + [( + "item".to_owned(), + serde_json::json!({ + "id": crate::tool::custom::public_item_id(&call.item.id), + "type": "custom_tool_call", + "status": "in_progress", + "call_id": call.item.call_id, + "input": "", + "name": call.item.name, + }), + )], + ) +} + +fn incremental_custom_delta(state: &mut CustomCallState, arguments: &str) -> ExecutorResult> { + let Some(input) = partial_custom_input(arguments) else { + return Ok(None); + }; + let Some(delta) = input + .strip_prefix(&state.emitted_input) + .filter(|delta| !delta.is_empty()) + .map(str::to_owned) + else { + return Ok(None); + }; + state.emitted_input = input; + custom_frame( + SSEEventType::CustomToolCallInputDelta, + state.output_index, + [ + ("delta".to_owned(), Value::String(delta)), + ("item_id".to_owned(), Value::String(state.public_item_id.clone())), + ], + ) + .map(Some) +} + +fn finish_custom_input(state: &mut CustomCallState, arguments: &str) -> ExecutorResult> { + if state.input_done { + return Ok(Vec::new()); + } + let input = crate::tool::custom::input_from_arguments(arguments); + let remaining = input + .strip_prefix(&state.emitted_input) + .filter(|delta| !delta.is_empty()) + .map(str::to_owned); + state.emitted_input.clone_from(&input); + state.input_done = true; + + let mut frames = Vec::with_capacity(2); + if let Some(delta) = remaining { + frames.push(custom_frame( + SSEEventType::CustomToolCallInputDelta, + state.output_index, + [ + ("delta".to_owned(), Value::String(delta)), + ("item_id".to_owned(), Value::String(state.public_item_id.clone())), + ], + )?); + } + frames.push(custom_frame( + SSEEventType::CustomToolCallInputDone, + state.output_index, + [ + ("input".to_owned(), Value::String(input)), + ("item_id".to_owned(), Value::String(state.public_item_id.clone())), + ], + )?); + Ok(frames) +} + +fn custom_done_frame(state: &CustomCallState, call: &AccumulatedFunctionCall<'_>) -> ExecutorResult { + custom_frame( + SSEEventType::OutputItemDone, + state.output_index, + [( + "item".to_owned(), + serde_json::json!({ + "id": state.public_item_id, + "type": "custom_tool_call", + "status": "completed", + "call_id": call.item.call_id, + "input": state.emitted_input, + "name": call.item.name, + }), + )], + ) +} + +fn custom_frame( + event_type: SSEEventType, + output_index: u32, + fields: impl IntoIterator, +) -> ExecutorResult { + let mut frame = synthetic_event(event_type, fields)?; + frame.wire.output_index = Some(u64::from(output_index)); + Ok(frame) +} + +fn partial_custom_input(arguments: &str) -> Option { + let arguments = arguments.trim_start(); + let arguments = arguments.strip_prefix("{}").unwrap_or(arguments).trim_start(); + let encoded = arguments + .strip_prefix('{')? + .trim_start() + .strip_prefix("\"input\"")? + .trim_start() + .strip_prefix(':')? + .trim_start() + .strip_prefix('"')?; + let end = unescaped_quote(encoded).unwrap_or(encoded.len()); + let mut end = end; + loop { + let candidate = format!("\"{}\"", &encoded[..end]); + if let Ok(input) = serde_json::from_str::(&candidate) { + return Some(input); + } + end = encoded[..end].rfind('\\')?; + } +} + +fn unescaped_quote(value: &str) -> Option { + let mut escaped = false; + value.char_indices().find_map(|(index, character)| { + if escaped { + escaped = false; + return None; + } + match character { + '\\' => escaped = true, + '"' => return Some(index), + _ => {} + } + None + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::executor::accumulator::ResponseAccumulator; + + fn sse(value: &Value) -> String { + format!("data: {value}") + } + + fn translate( + accumulator: &mut ResponseAccumulator, + translator: &mut FunctionSseTranslator, + value: &Value, + ) -> FunctionSseTranslation { + accumulator + .process_sse_line_with_translator(&sse(value), translator) + .expect("translation succeeds") + .expect("SSE event") + } + + #[test] + fn custom_function_arguments_are_emitted_incrementally() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = FunctionSseTranslator::new(HashMap::from([("raw_echo".to_owned(), ToolType::Custom)])); + let mut frames = Vec::new(); + + for event in [ + serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_custom", + "type": "function_call", + "status": "in_progress", + "call_id": "call_custom", + "name": "raw_echo", + "arguments": "" + } + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_custom", + "call_id": "call_custom", + "delta": "{\"in" + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_custom", + "call_id": "call_custom", + "delta": "put\":\"hello " + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_custom", + "call_id": "call_custom", + "delta": "world\"}" + }), + serde_json::json!({ + "type": "response.function_call_arguments.done", + "output_index": 0, + "item_id": "fc_custom", + "call_id": "call_custom", + "name": "raw_echo", + "arguments": "{\"input\":\"hello world\"}" + }), + serde_json::json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "id": "fc_custom", + "type": "function_call", + "status": "completed", + "call_id": "call_custom", + "name": "raw_echo", + "arguments": "{\"input\":\"hello world\"}" + } + }), + ] { + frames.extend(translate(&mut accumulator, &mut translator, &event).frames); + } + + assert_eq!( + frames.iter().map(|frame| frame.event_type).collect::>(), + [ + SSEEventType::OutputItemAdded, + SSEEventType::CustomToolCallInputDelta, + SSEEventType::CustomToolCallInputDelta, + SSEEventType::CustomToolCallInputDone, + SSEEventType::OutputItemDone, + ] + ); + assert_eq!(frames[0].wire.rest["item"]["type"], "custom_tool_call"); + assert_eq!(frames[1].wire.rest["delta"], "hello "); + assert_eq!(frames[2].wire.rest["delta"], "world"); + assert_eq!(frames[3].wire.rest["input"], "hello world"); + assert_eq!(frames[4].wire.rest["item"]["input"], "hello world"); + } + + #[test] + fn ordinary_functions_pass_through_unchanged() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = FunctionSseTranslator::new(HashMap::from([("echo".to_owned(), ToolType::Function)])); + let event = serde_json::json!({ + "type": "response.output_item.added", + "output_index": 3, + "item": { + "id": "fc_echo", + "type": "function_call", + "call_id": "call_echo", + "name": "echo", + "arguments": "" + } + }); + + let translated = translate(&mut accumulator, &mut translator, &event); + + assert_eq!(translated.frames.len(), 1); + assert_eq!(translated.frames[0].event_type, SSEEventType::OutputItemAdded); + assert_eq!(translated.frames[0].wire.rest["item"]["type"], "function_call"); + assert_eq!(translated.gateway_output_index, None); + } + + #[test] + fn gateway_owned_functions_are_suppressed_and_mark_the_defer_boundary() { + let mut accumulator = ResponseAccumulator::new("resp_1".to_owned(), None); + let mut translator = + FunctionSseTranslator::new(HashMap::from([("web_search".to_owned(), ToolType::WebSearch)])); + let added = serde_json::json!({ + "type": "response.output_item.added", + "output_index": 2, + "item": { + "id": "fc_search", + "type": "function_call", + "call_id": "call_search", + "name": "web_search", + "arguments": "" + } + }); + let delta = serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 2, + "item_id": "fc_search", + "call_id": "call_search", + "delta": "{}" + }); + + let added = translate(&mut accumulator, &mut translator, &added); + let delta = translate(&mut accumulator, &mut translator, &delta); + + assert!(added.frames.is_empty()); + assert_eq!(added.gateway_output_index, Some(2)); + assert!(delta.frames.is_empty()); + assert_eq!(delta.gateway_output_index, None); + } +} diff --git a/crates/agentic-server-core/src/executor/gateway.rs b/crates/agentic-server-core/src/executor/gateway.rs index 7e94e83a..8b895963 100644 --- a/crates/agentic-server-core/src/executor/gateway.rs +++ b/crates/agentic-server-core/src/executor/gateway.rs @@ -108,6 +108,12 @@ pub(super) fn is_gateway_owned_call(call: &FunctionToolCall, registry: &ToolRegi .is_some_and(|entry| entry.tool_type.is_gateway_owned()) } +pub(super) fn is_client_custom_call(call: &FunctionToolCall, registry: &ToolRegistry) -> bool { + registry + .lookup(&call.name) + .is_some_and(|entry| entry.tool_type == ToolType::Custom) +} + pub(super) fn has_client_owned_calls(output_items: &[OutputItem], registry: &ToolRegistry) -> bool { output_items.iter().any(|item| item.requires_client_action(registry)) } @@ -193,7 +199,11 @@ fn gateway_public_output( ToolType::Mcp => registry .mcp_tool_ref(&call.name) .map(|tool_ref| crate::tool::mcp::handler::output_item(call, output, status, tool_ref)), - ToolType::Function | ToolType::CodexNamespace | ToolType::FileSearch | ToolType::CodeInterpreter => None, + ToolType::Function + | ToolType::Custom + | ToolType::CodexNamespace + | ToolType::FileSearch + | ToolType::CodeInterpreter => None, } } @@ -229,6 +239,13 @@ pub(super) fn public_output_items( output_items .iter() .map(|item| match item { + OutputItem::FunctionCall(call) + if registry + .lookup(&call.name) + .is_some_and(|entry| entry.tool_type == ToolType::Custom) => + { + crate::tool::CustomHandler::output_item(call) + } OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry) => gateway_results .iter() .find(|result| result.call.call_id == call.call_id) @@ -261,6 +278,7 @@ pub(super) fn gateway_event_plans( .mcp_tool_ref(&call.name) .map(|tool_ref| crate::tool::mcp::handler::started_output_item(call, tool_ref)), ToolType::Function + | ToolType::Custom | ToolType::CodexNamespace | ToolType::FileSearch | ToolType::CodeInterpreter => None, diff --git a/crates/agentic-server-core/src/executor/mod.rs b/crates/agentic-server-core/src/executor/mod.rs index e45f8633..cf55b193 100644 --- a/crates/agentic-server-core/src/executor/mod.rs +++ b/crates/agentic-server-core/src/executor/mod.rs @@ -12,6 +12,7 @@ pub mod persist; pub mod rehydrate; pub mod request; +mod function_sse; mod gateway; pub mod gateway_accumulator; mod upstream; diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index 14eea041..25de00c8 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -1,12 +1,12 @@ -use std::collections::{HashMap, HashSet}; use std::sync::Arc; use futures::StreamExt; use serde_json::Value; -use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType, WireEvent}; +use crate::events::{EventFrame, SSEEventType, WireEvent}; use crate::executor::accumulator::ResponseAccumulator; use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::function_sse::FunctionSseTranslator; use crate::executor::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, emit_sse_frame}; use crate::executor::inference::{call_inference, fetch_response_json}; use crate::executor::request::{ExecutionContext, RequestContext}; @@ -72,14 +72,22 @@ pub(super) async fn fetch_stream_payload( exec_ctx.streaming_timeout, )); let mut acc = ResponseAccumulator::new(ctx.response_id.clone(), ctx.conversation_id.clone()); - let mut hidden_gateway_item_ids = HashSet::new(); - let mut pending_unnamed_function_events = HashMap::>::new(); + let mut function_sse = FunctionSseTranslator::new(registry.tool_type_map()); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); while let Some(line_result) = line_stream.next().await { let line = line_result?; - if let Some(frame) = acc.process_sse_line(&line) { - log_upstream_failure(&frame, &ctx.response_id); + if stream.is_none() { + if let Some(frame) = acc.process_sse_line(&line) { + log_upstream_failure(&frame, &ctx.response_id); + } + continue; + } + if let Some(translation) = acc.process_sse_line_with_translator(&line, &mut function_sse)? { + record_first_gateway_output_index(translation.gateway_output_index, &mut defer_from_output_index); + for frame in &translation.frames { + log_upstream_failure(frame, &ctx.response_id); + } if let Some((accumulator, sender)) = stream.as_mut() { let mut emit_ctx = StreamEmitContext { request: ctx, @@ -88,14 +96,16 @@ pub(super) async fn fetch_stream_payload( accumulator, output_offset, }; - emit_upstream_stream_event( - frame, - &mut emit_ctx, - &mut hidden_gateway_item_ids, - &mut pending_unnamed_function_events, - &mut defer_from_output_index, - &mut deferred_events, - )?; + for frame in translation.frames { + if !is_terminal_response_event(frame.event_type) { + emit_or_defer_stream_frame( + frame, + &mut emit_ctx, + defer_from_output_index, + &mut deferred_events, + )?; + } + } } } } @@ -140,40 +150,6 @@ fn log_upstream_failure(frame: &EventFrame, gateway_response_id: &str) { ); } -fn emit_upstream_stream_event( - frame: EventFrame, - emit_ctx: &mut StreamEmitContext<'_>, - hidden_gateway_item_ids: &mut HashSet, - pending_unnamed_function_events: &mut HashMap>, - defer_from_output_index: &mut Option, - deferred_events: &mut Vec, -) -> ExecutorResult<()> { - defer_after_gateway_call(&frame, emit_ctx.registry, defer_from_output_index); - if should_hide_upstream_event( - frame.event_type, - &frame.payload, - emit_ctx.registry, - hidden_gateway_item_ids, - ) || is_terminal_response_event(frame.event_type) - { - drop_pending_function_events(&frame.payload, pending_unnamed_function_events); - return Ok(()); - } - let Some(frame) = defer_or_flush_function_event( - frame, - emit_ctx, - hidden_gateway_item_ids, - pending_unnamed_function_events, - defer_from_output_index, - deferred_events, - )? - else { - return Ok(()); - }; - - emit_or_defer_stream_frame(frame, emit_ctx, *defer_from_output_index, deferred_events) -} - pub(super) fn emit_deferred_stream_events( deferred_events: Vec, request: &RequestContext, @@ -195,26 +171,12 @@ pub(super) fn emit_deferred_stream_events( Ok(()) } -fn defer_after_gateway_call(frame: &EventFrame, registry: &ToolRegistry, defer_from_output_index: &mut Option) { - let EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - name: Some(name), - .. - } = &frame.payload - else { - return; - }; - if registry.is_gateway_owned_name(name) { - record_first_hidden_gateway_output_index(frame, defer_from_output_index); - } -} - -fn record_first_hidden_gateway_output_index(frame: &EventFrame, defer_from_output_index: &mut Option) { - let Some(output_index) = frame.wire.output_index else { +fn record_first_gateway_output_index(output_index: Option, first_gateway_output_index: &mut Option) { + let Some(output_index) = output_index.map(u64::from) else { return; }; - if defer_from_output_index.is_none_or(|first_hidden_index| output_index < first_hidden_index) { - *defer_from_output_index = Some(output_index); + if first_gateway_output_index.is_none_or(|first| output_index < first) { + *first_gateway_output_index = Some(output_index); } } @@ -249,148 +211,6 @@ fn emit_or_defer_stream_frame( emit_stream_frame(&mut frame, emit_ctx) } -fn defer_or_flush_function_event( - frame: EventFrame, - emit_ctx: &mut StreamEmitContext<'_>, - hidden_gateway_item_ids: &mut HashSet, - pending_unnamed_function_events: &mut HashMap>, - defer_from_output_index: &mut Option, - deferred_events: &mut Vec, -) -> ExecutorResult> { - match &frame.payload { - EventPayload::OutputItemAdded { - item_id, - item_type, - name: None, - .. - } if *item_type == SSEItemType::FunctionCall => { - let item_id = item_id.clone(); - pending_unnamed_function_events.entry(item_id).or_default().push(frame); - Ok(None) - } - EventPayload::FunctionCallArgsDelta { item_id, .. } - if pending_unnamed_function_events.contains_key(item_id) => - { - let item_id = item_id.clone(); - pending_unnamed_function_events.entry(item_id).or_default().push(frame); - Ok(None) - } - EventPayload::FunctionCallArgsDone { item_id, name, .. } => { - if emit_ctx.registry.is_gateway_owned_name(name) { - hidden_gateway_item_ids.insert(item_id.clone()); - record_first_hidden_gateway_output_index(&frame, defer_from_output_index); - pending_unnamed_function_events.remove(item_id); - return Ok(None); - } - flush_pending_function_events( - item_id, - emit_ctx, - pending_unnamed_function_events, - *defer_from_output_index, - deferred_events, - )?; - Ok(Some(frame)) - } - EventPayload::OutputItemDone { - item_id, - item_type, - item, - .. - } if *item_type == SSEItemType::FunctionCall => { - if item - .get("name") - .and_then(Value::as_str) - .is_some_and(|name| emit_ctx.registry.is_gateway_owned_name(name)) - { - hidden_gateway_item_ids.insert(item_id.clone()); - record_first_hidden_gateway_output_index(&frame, defer_from_output_index); - pending_unnamed_function_events.remove(item_id); - return Ok(None); - } - flush_pending_function_events( - item_id, - emit_ctx, - pending_unnamed_function_events, - *defer_from_output_index, - deferred_events, - )?; - Ok(Some(frame)) - } - _ => Ok(Some(frame)), - } -} - -fn flush_pending_function_events( - item_id: &str, - emit_ctx: &mut StreamEmitContext<'_>, - pending_unnamed_function_events: &mut HashMap>, - defer_from_output_index: Option, - deferred_events: &mut Vec, -) -> ExecutorResult<()> { - let Some(frames) = pending_unnamed_function_events.remove(item_id) else { - return Ok(()); - }; - for frame in frames { - emit_or_defer_stream_frame(frame, emit_ctx, defer_from_output_index, deferred_events)?; - } - Ok(()) -} - -fn drop_pending_function_events( - payload: &EventPayload, - pending_unnamed_function_events: &mut HashMap>, -) { - match payload { - EventPayload::OutputItemDone { item_id, .. } - | EventPayload::FunctionCallArgsDelta { item_id, .. } - | EventPayload::FunctionCallArgsDone { item_id, .. } => { - pending_unnamed_function_events.remove(item_id); - } - EventPayload::OutputItemAdded { .. } - | EventPayload::TextDelta { .. } - | EventPayload::TextDone { .. } - | EventPayload::CustomToolCallInputDelta { .. } - | EventPayload::CustomToolCallInputDone { .. } - | EventPayload::ReasoningDelta { .. } - | EventPayload::ReasoningDone { .. } - | EventPayload::Response { .. } - | EventPayload::Raw(_) - | EventPayload::None => {} - } -} - -fn should_hide_upstream_event( - event_type: SSEEventType, - payload: &EventPayload, - registry: &ToolRegistry, - hidden_gateway_item_ids: &mut HashSet, -) -> bool { - match (event_type, payload) { - ( - SSEEventType::OutputItemAdded, - EventPayload::OutputItemAdded { - item_id, - item_type, - name: Some(name), - .. - }, - ) if *item_type == SSEItemType::FunctionCall && registry.is_gateway_owned_name(name) => { - hidden_gateway_item_ids.insert(item_id.clone()); - true - } - (SSEEventType::OutputItemDone, EventPayload::OutputItemDone { item_id, item_type, .. }) - if *item_type == SSEItemType::FunctionCall && hidden_gateway_item_ids.contains(item_id) => - { - true - } - ( - SSEEventType::FunctionCallArgumentsDelta | SSEEventType::FunctionCallArgumentsDone, - EventPayload::FunctionCallArgsDelta { item_id, .. } | EventPayload::FunctionCallArgsDone { item_id, .. }, - ) => hidden_gateway_item_ids.contains(item_id), - _ => false, - } -} - fn is_terminal_response_event(event_type: SSEEventType) -> bool { matches!( event_type, diff --git a/crates/agentic-server-core/src/lib.rs b/crates/agentic-server-core/src/lib.rs index 8e7254da..1981fb85 100644 --- a/crates/agentic-server-core/src/lib.rs +++ b/crates/agentic-server-core/src/lib.rs @@ -19,15 +19,16 @@ pub use tool::{ ToolError, ToolHandler, ToolOutput, ToolRegistry, ToolType, WebSearchHandler, }; pub use types::{ - CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, CompactRequest, CompactedResponse, - CompactionItem, ContextManagement, CustomToolCall, CustomToolCallOutputMessage, CustomToolParam, - EmptyToolNameError, FileSearchToolParam, FunctionTool, FunctionToolCall, FunctionToolParam, - FunctionToolResultMessage, GatewayCallStatus, IncompleteDetails, InputContent, InputFunctionToolCall, - InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, McpCall, - McpCallStatus, McpToolParam, NonEmptyToolName, OutputItem, OutputMessage, OutputTextContent, OutputTokenDetails, - ReasoningOutput, ReasoningTextContent, RequestPayload, ResponsePayload, ResponseUsage, ResponsesInput, - ResponsesTool, ToolChoice, UpstreamRequest, UpstreamTool, WebSearchAction, WebSearchActionFindInPage, - WebSearchActionOpenPage, WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, WebSearchContextSize, - WebSearchFilters, WebSearchSource, WebSearchToolParam, WebSearchUserLocation, + AllowedTool, AllowedToolsMode, CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, + CompactRequest, CompactedResponse, CompactionItem, ContextManagement, CustomToolCall, CustomToolCallOutputMessage, + CustomToolParam, EmptyToolNameError, FileSearchToolParam, FunctionTool, FunctionToolCall, FunctionToolParam, + FunctionToolResultMessage, GatewayCallStatus, IncompleteDetails, InputContent, InputFileContent, + InputFunctionToolCall, InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, + InputTokenDetails, McpCall, McpCallStatus, McpToolParam, NonEmptyToolName, OutputItem, OutputMessage, + OutputTextContent, OutputTokenDetails, ReasoningOutput, ReasoningTextContent, RequestPayload, ResponsePayload, + ResponseUsage, ResponsesInput, ResponsesTool, ToolCallOutput, ToolChoice, ToolOutputContent, UpstreamRequest, + UpstreamTool, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, + WebSearchCall, WebSearchCallStatus, WebSearchContextSize, WebSearchFilters, WebSearchSource, WebSearchToolParam, + WebSearchUserLocation, }; pub use utils::{utcnow_str, uuid7_str}; diff --git a/crates/agentic-server-core/src/tool/custom.rs b/crates/agentic-server-core/src/tool/custom.rs new file mode 100644 index 00000000..85116d94 --- /dev/null +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -0,0 +1,445 @@ +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::events::WireEvent; +use crate::types::io::{CustomToolCall, FunctionTool, FunctionToolCall, OutputItem}; +use crate::types::tools::{CustomToolParam, ResponsesTool}; +use crate::utils::common::serialize_to_value_or_custom_default; + +use super::{ToolEntry, ToolError, ToolHandler, ToolType}; + +/// Request-scoped mapping from normalized function names to their original +/// public custom-tool declarations. +#[derive(Debug, Default)] +pub(crate) struct CustomToolMap { + declarations: HashMap, +} + +impl CustomToolMap { + fn from_tools(tools: &[ResponsesTool]) -> Option { + let declarations = tools + .iter() + .filter_map(|tool| match tool { + ResponsesTool::Custom(param) => Some((param.name.as_str().to_owned(), param.clone())), + _ => None, + }) + .collect::>(); + (!declarations.is_empty()).then_some(Self { declarations }) + } + + fn declaration(&self, name: &str) -> Option<&CustomToolParam> { + self.declarations.get(name) + } +} + +/// Handler for client-owned `type: "custom"` tools. +/// +/// Custom tools are normalized for the model but are executed by the client, +/// so this intentionally implements [`ToolHandler`] without +/// [`super::GatewayExecutor`]. +#[derive(Debug)] +pub struct CustomHandler; + +impl CustomHandler { + #[must_use] + pub(crate) fn build_tool_map(tools: &[ResponsesTool]) -> Option { + CustomToolMap::from_tools(tools) + } + + #[must_use] + pub fn to_function_call(param: &CustomToolParam) -> FunctionTool { + FunctionTool { + type_: "function".to_owned(), + name: param.name.as_str().to_owned(), + description: Some(model_visible_description(param)), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw custom tool input. Follow the tool description and declared format exactly." + } + }, + "required": ["input"], + "additionalProperties": false + })), + strict: Some(true), + } + } + + #[must_use] + pub(crate) fn output_item(call: &FunctionToolCall) -> OutputItem { + OutputItem::CustomToolCall(CustomToolCall { + id: public_item_id(&call.id), + status: Some(call.status), + call_id: call.call_id.clone(), + name: call.name.clone(), + input: input_from_arguments(&call.arguments), + }) + } + + /// Restores normalized custom-tool declarations in response lifecycle + /// metadata before the event is emitted to the client. + pub(crate) fn restore_response_wire(wire: &mut WireEvent, map: Option<&CustomToolMap>) -> bool { + let Some(map) = map else { + return false; + }; + restore_response_map(&mut wire.rest, map) + } +} + +fn restore_response_map(object: &mut Map, map: &CustomToolMap) -> bool { + let mut changed = restore_response_metadata(object, map); + for key in ["response", "payload"] { + if let Some(nested) = object.get_mut(key).and_then(Value::as_object_mut) { + changed |= restore_response_map(nested, map); + } + } + changed +} + +fn restore_response_metadata(object: &mut Map, map: &CustomToolMap) -> bool { + let mut changed = false; + if let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) { + for tool in tools { + changed |= restore_custom_declaration(tool, map); + } + } + if let Some(tool_choice) = object.get_mut("tool_choice") { + changed |= restore_custom_tool_choice(tool_choice, map); + } + changed +} + +fn restore_custom_declaration(tool: &mut Value, map: &CustomToolMap) -> bool { + let Some(name) = normalized_custom_name(tool, map) else { + return false; + }; + let Some(param) = map.declaration(&name) else { + return false; + }; + let Some(mut declaration) = + serialize_to_value_or_custom_default(param, "custom tool metadata serialization failed", Some, None) + else { + return false; + }; + let Some(object) = declaration.as_object_mut() else { + return false; + }; + object.insert("type".to_owned(), Value::String("custom".to_owned())); + *tool = declaration; + true +} + +fn restore_custom_tool_choice(choice: &mut Value, map: &CustomToolMap) -> bool { + let Some(object) = choice.as_object_mut() else { + return false; + }; + if object.get("type").and_then(Value::as_str) == Some("allowed_tools") { + let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) else { + return false; + }; + return tools + .iter_mut() + .map(|tool| restore_custom_choice_type(tool, map)) + .fold(false, |changed, restored| changed | restored); + } + restore_custom_choice_type(choice, map) +} + +fn restore_custom_choice_type(choice: &mut Value, map: &CustomToolMap) -> bool { + if normalized_custom_name(choice, map).is_none() { + return false; + } + let Some(object) = choice.as_object_mut() else { + return false; + }; + object.insert("type".to_owned(), Value::String("custom".to_owned())); + object.remove("namespace"); + true +} + +fn normalized_custom_name(value: &Value, map: &CustomToolMap) -> Option { + let object = value.as_object()?; + if object.get("type").and_then(Value::as_str) != Some("function") { + return None; + } + let name = object.get("name")?.as_str()?; + map.declaration(name).map(|_| name.to_owned()) +} + +fn model_visible_description(param: &CustomToolParam) -> String { + let mut fragments = Vec::new(); + if let Some(description) = param + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + fragments.push(description.to_owned()); + } + + fragments.push("Provide the raw tool input in the `input` string field.".to_owned()); + + if let Some(format) = ¶m.format { + let format_type = format.get("type").and_then(serde_json::Value::as_str); + let syntax = format.get("syntax").and_then(serde_json::Value::as_str); + let definition = format + .get("definition") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + if format_type == Some("grammar") + && matches!(syntax, Some("lark" | "regex")) + && let (Some(syntax), Some(definition)) = (syntax, definition) + { + fragments.push(format!( + "The string must match this {syntax} grammar exactly:\n{definition}" + )); + } else { + fragments.push(format!( + "The string must conform to this custom tool format declaration exactly:\n{format}" + )); + } + } + + if !param.extra.is_empty() + && let Ok(extra) = serde_json::to_string(¶m.extra) + { + fragments.push(format!( + "Additional custom tool declaration fields that must be respected:\n{extra}" + )); + } + + fragments.join("\n\n") +} + +impl ToolHandler for CustomHandler { + fn tool_type(&self) -> ToolType { + ToolType::Custom + } + + fn validate(&self, param: &serde_json::Value) -> Result<(), ToolError> { + serde_json::from_value::(param.clone()) + .map(|_| ()) + .map_err(|error| ToolError::Config(format!("invalid custom tool config: {error}"))) + } + + fn normalize(&self, param: &serde_json::Value) -> Vec { + match serde_json::from_value::(param.clone()) { + Ok(param) => vec![Self::to_function_call(¶m)], + Err(error) => { + tracing::warn!(%error, "invalid custom tool param"); + Vec::new() + } + } + } +} + +pub(crate) fn insert_custom_entry(entries: &mut HashMap, param: &CustomToolParam) { + serialize_to_value_or_custom_default( + param, + "custom tool config serialization failed", + |config| { + entries.insert( + param.name.as_str().to_owned(), + ToolEntry { + tool_type: ToolType::Custom, + config, + server_label: None, + handler: None, + }, + ); + }, + (), + ); +} + +pub(crate) fn public_item_id(item_id: &str) -> String { + if item_id.starts_with("ctc_") { + return item_id.to_owned(); + } + if let Some(suffix) = item_id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) { + return format!("ctc_{suffix}"); + } + format!("ctc_{:016x}", stable_name_hash(item_id)) +} + +fn stable_name_hash(value: &str) -> u64 { + value.as_bytes().iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +pub(crate) fn input_from_arguments(arguments: &str) -> String { + try_input_from_arguments(arguments).unwrap_or_else(|| { + tracing::debug!( + argument_bytes = arguments.len(), + "custom tool arguments did not match the normalized input envelope; forwarding raw arguments" + ); + arguments.to_owned() + }) +} + +pub(crate) fn try_input_from_arguments(arguments: &str) -> Option { + match serde_json::from_str::(arguments).ok()? { + serde_json::Value::String(input) => Some(input), + serde_json::Value::Object(fields) if fields.len() == 1 => fields + .get("input") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::event::MessageStatus; + + #[test] + fn function_fallback_uses_public_custom_tool_shape() { + let call = FunctionToolCall { + id: "fc_1".to_owned(), + call_id: "call_1".to_owned(), + name: "raw_echo".to_owned(), + namespace: None, + arguments: r#"{"input":"hello"}"#.to_owned(), + status: MessageStatus::Completed, + }; + + let OutputItem::CustomToolCall(completed) = CustomHandler::output_item(&call) else { + panic!("expected custom output item"); + }; + assert_eq!(completed.id, "ctc_1"); + assert_eq!(completed.input, "hello"); + assert_eq!(completed.status, Some(MessageStatus::Completed)); + } + + #[test] + fn custom_call_id_is_stable_for_every_source_item_id() { + assert_eq!(public_item_id("fc_item"), "ctc_item"); + assert_eq!(public_item_id("ctc_item"), "ctc_item"); + assert_eq!(public_item_id("provider_item"), public_item_id("provider_item")); + } + + #[test] + fn custom_declaration_normalizes_to_function_with_raw_input() { + let param = serde_json::from_value::(serde_json::json!({ + "name": "raw_echo", + "description": "Echo raw input.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: \"CUSTOM_OK\"" + }, + "x-provider-field": {"mode": "strict"} + })) + .expect("custom tool"); + + let tool = CustomHandler::to_function_call(¶m); + + assert_eq!(tool.type_, "function"); + assert_eq!(tool.name, "raw_echo"); + assert_eq!( + tool.parameters.as_ref().unwrap()["properties"]["input"]["type"], + "string" + ); + assert_eq!(tool.parameters.as_ref().unwrap()["required"][0], "input"); + let description = tool.description.as_deref().expect("model-visible description"); + assert!(description.contains("Echo raw input.")); + assert!(description.contains("raw tool input in the `input` string field")); + assert!(description.contains("lark grammar exactly")); + assert!(description.contains("start: \"CUSTOM_OK\"")); + assert!(description.contains("x-provider-field")); + assert!(description.contains("strict")); + } + + #[test] + fn regex_grammar_is_preserved_in_model_visible_description() { + let param = serde_json::from_value::(serde_json::json!({ + "name": "timestamp", + "description": "Save a timestamp.", + "format": { + "type": "grammar", + "syntax": "regex", + "definition": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + })) + .expect("custom tool"); + + let tool = CustomHandler::to_function_call(¶m); + let description = tool.description.as_deref().expect("model-visible description"); + assert!(description.contains("regex grammar exactly")); + assert!(description.contains("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")); + } + + #[test] + fn response_lifecycle_metadata_restores_public_custom_tool_shape() { + let param = serde_json::from_value::(serde_json::json!({ + "name": "raw_echo", + "description": "Echo raw input.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: echo" + } + })) + .expect("custom tool"); + let tools = vec![ResponsesTool::Custom(param)]; + let map = CustomHandler::build_tool_map(&tools); + let mut wire = WireEvent::new("response.created"); + wire.rest.insert( + "response".to_owned(), + serde_json::json!({ + "tools": [{ + "type": "function", + "name": "raw_echo", + "description": "normalized description", + "parameters": {"type": "object"} + }], + "tool_choice": {"type": "function", "name": "raw_echo"} + }), + ); + + assert!(CustomHandler::restore_response_wire(&mut wire, map.as_ref())); + let response = &wire.rest["response"]; + assert_eq!(response["tools"][0]["type"], "custom"); + assert_eq!(response["tools"][0]["description"], "Echo raw input."); + assert_eq!(response["tools"][0]["format"]["syntax"], "lark"); + assert!(response["tools"][0].get("parameters").is_none()); + assert_eq!(response["tool_choice"]["type"], "custom"); + assert_eq!(response["tool_choice"]["name"], "raw_echo"); + } + + #[test] + fn allowed_tools_metadata_restores_custom_selector_type() { + let param = serde_json::from_value::(serde_json::json!({ + "name": "raw_echo" + })) + .expect("custom tool"); + let tools = vec![ResponsesTool::Custom(param)]; + let map = CustomHandler::build_tool_map(&tools); + let mut wire = WireEvent::new("response.in_progress"); + wire.rest.insert( + "response".to_owned(), + serde_json::json!({ + "tool_choice": { + "type": "allowed_tools", + "mode": "required", + "tools": [ + {"type": "function", "name": "ordinary"}, + {"type": "function", "name": "raw_echo"} + ] + } + }), + ); + + assert!(CustomHandler::restore_response_wire(&mut wire, map.as_ref())); + let tools = &wire.rest["response"]["tool_choice"]["tools"]; + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[1]["type"], "custom"); + } +} diff --git a/crates/agentic-server-core/src/tool/mod.rs b/crates/agentic-server-core/src/tool/mod.rs index 868bf8b0..1802d32b 100644 --- a/crates/agentic-server-core/src/tool/mod.rs +++ b/crates/agentic-server-core/src/tool/mod.rs @@ -4,6 +4,7 @@ //! This module owns the behavioral layer: routing, handler interface, and normalization. pub mod codex; +pub mod custom; pub mod executors; pub mod function; pub mod handler; @@ -13,6 +14,7 @@ pub mod registry; pub mod web_search; pub use codex::{CodexNamespaceHandler, NamespaceMap, model_visible_namespace_member_name}; +pub use custom::CustomHandler; pub use executors::{GatewayExecutorRegistration, GatewayExecutors}; pub use function::FunctionHandler; pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput}; diff --git a/crates/agentic-server-core/src/tool/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index b07e63eb..1bd5b6df 100644 --- a/crates/agentic-server-core/src/tool/normalize.rs +++ b/crates/agentic-server-core/src/tool/normalize.rs @@ -4,6 +4,7 @@ use crate::types::tools::ResponsesTool; use crate::utils::common::serialize_to_value_or_custom_default; use super::codex::CodexNamespaceHandler; +use super::custom::CustomHandler; use super::function::FunctionHandler; use super::handler::{ToolHandler, ToolOutput}; use super::mcp::McpHandler; @@ -21,7 +22,8 @@ impl ResponsesTool { Self::FileSearch(_) => Some(ToolType::FileSearch), Self::CodeInterpreter(_) => Some(ToolType::CodeInterpreter), Self::Namespace(_) => Some(ToolType::CodexNamespace), - Self::Custom(_) | Self::Unknown => None, + Self::Custom(_) => Some(ToolType::Custom), + Self::Unknown => None, } } @@ -36,15 +38,13 @@ impl ResponsesTool { /// Returns an empty list and logs at `debug` level if the name is empty. /// - `Mcp` variants convert gateway MCP built-ins to the function specs /// vLLM can call. - /// - `Custom` variants return no function tools because - /// `RequestPayload::to_upstream_request()` forwards their native - /// Responses declarations separately. + /// - `Custom` variants become function tools with one string `input` + /// parameter; the gateway restores their public custom-call shape. /// - Unimplemented variants (`FileSearch`, `CodeInterpreter`) return /// an empty list and emit a `tracing::debug!`. /// /// `RequestPayload::to_upstream_request()` uses this conversion for - /// function-like tools while preserving native custom declarations in its - /// heterogeneous upstream tool list. + /// all model-visible tools. #[must_use] pub fn to_function_tools(&self) -> Vec { match self { @@ -77,10 +77,12 @@ impl ResponsesTool { |param| CodexNamespaceHandler.normalize(¶m), vec![], ), - Self::Custom(p) => { - tracing::debug!(name = %p.name, "custom tool retained for native upstream forwarding"); - vec![] - } + Self::Custom(p) => serialize_to_value_or_custom_default( + p, + "custom tool config serialization failed", + |param| CustomHandler.normalize(¶m), + vec![], + ), Self::Unknown => { tracing::debug!("unknown tool skipped in normalize"); vec![] @@ -93,7 +95,7 @@ impl From for FunctionToolResultMessage { fn from(o: ToolOutput) -> Self { Self { call_id: o.call_id, - output: o.output, + output: o.output.into(), } } } diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index f8241f43..23c7929a 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::codex::insert_namespace_entries; +use super::custom::{CustomHandler, CustomToolMap, insert_custom_entry}; use super::executors::GatewayExecutors; use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; @@ -23,6 +24,7 @@ use crate::utils::common::serialize_to_value_or_custom_default; #[serde(rename_all = "snake_case")] pub enum ToolType { Function, + Custom, CodexNamespace, Mcp, /// Internal routing discriminant. Serializes as `"web_search"`. @@ -38,6 +40,7 @@ impl ToolType { pub(crate) const fn description(self) -> &'static str { match self { Self::Function => "function tool", + Self::Custom => "custom tool", Self::CodexNamespace => "Codex namespace tool", Self::Mcp => "MCP tool", Self::WebSearch => "web search tool", @@ -48,7 +51,7 @@ impl ToolType { #[must_use] pub const fn is_gateway_owned(self) -> bool { - !matches!(self, Self::Function | Self::CodexNamespace) + !matches!(self, Self::Function | Self::Custom | Self::CodexNamespace) } } @@ -163,6 +166,10 @@ pub struct ToolRegistry { /// restoration don't rebuild it on every call. namespace_map: Option, + /// Maps normalized custom function names back to their public declarations + /// for response lifecycle metadata restoration. + custom_tool_map: Option, + /// Maps model-visible MCP function names back to their public server and /// tool identities without reparsing executor configuration. mcp_tool_map: McpToolMap, @@ -229,7 +236,7 @@ impl ToolRegistry { insert_unique_tool_entries(&mut entries, |resolved| insert_namespace_entries(resolved, p))?; } ResponsesTool::Custom(p) => { - tracing::debug!(name = %p.name, "client-owned custom tool skipped in function registry"); + insert_unique_tool_entries(&mut entries, |resolved| insert_custom_entry(resolved, p))?; } ResponsesTool::Unknown => { tracing::debug!("unknown tool declared but skipped in registry"); @@ -238,10 +245,12 @@ impl ToolRegistry { } let namespace_map = CodexNamespaceHandler.build_namespace_map((!tools.is_empty()).then_some(tools))?; + let custom_tool_map = CustomHandler::build_tool_map(tools); Ok(Self { entries, namespace_map, + custom_tool_map, mcp_tool_map, }) } @@ -251,6 +260,13 @@ impl ToolRegistry { self.entries.get(tool_name) } + pub(crate) fn tool_type_map(&self) -> HashMap { + self.entries + .iter() + .map(|(name, entry)| (name.clone(), entry.tool_type)) + .collect() + } + #[must_use] pub fn is_empty(&self) -> bool { self.entries.is_empty() @@ -275,7 +291,8 @@ impl ToolRegistry { } pub fn restore_stream_event_wire(&self, wire: &mut WireEvent) -> bool { - CodexNamespaceHandler.restore_response_wire(wire, self.namespace_map.as_ref()) + let custom_restored = CustomHandler::restore_response_wire(wire, self.custom_tool_map.as_ref()); + CodexNamespaceHandler.restore_response_wire(wire, self.namespace_map.as_ref()) | custom_restored } /// Returns the subset of `calls` whose names map to gateway-owned tools. @@ -422,12 +439,13 @@ mod tests { .await .expect("mixed registry"); - assert_eq!(registry.len(), 7); + assert_eq!(registry.len(), 8); assert!(registry.contains_mcp_server_label("counter")); assert!(!registry.contains_mcp_server_label("missing")); let expected_entries = [ ("echo", ToolType::Function, None, false), + ("freeform", ToolType::Custom, None, false), ("mcp__counter__increment", ToolType::Mcp, Some("counter"), true), ("mcp__counter__get_value", ToolType::Mcp, Some("counter"), true), ("web_search", ToolType::WebSearch, None, true), @@ -452,7 +470,7 @@ mod tests { ); assert_eq!(entry.handler.is_some(), has_handler, "unexpected handler for '{name}'"); } - assert!(registry.lookup("freeform").is_none()); + assert_eq!(registry.lookup("freeform").unwrap().config["name"], "freeform"); assert_eq!(registry.lookup("echo").unwrap().config["name"], "echo"); assert_eq!( registry.lookup("mcp__counter__increment").unwrap().config["tool_name"], @@ -479,7 +497,7 @@ mod tests { ] { assert!(registry.is_gateway_owned_name(name), "'{name}' should be gateway-owned"); } - for name in ["echo", "agentic_ns__mcp__shell__run"] { + for name in ["echo", "freeform", "agentic_ns__mcp__shell__run"] { assert!(!registry.is_gateway_owned_name(name), "'{name}' should be client-owned"); } diff --git a/crates/agentic-server-core/src/types/io/input.rs b/crates/agentic-server-core/src/types/io/input.rs index 4d0e3c9f..5a76bbdf 100644 --- a/crates/agentic-server-core/src/types/io/input.rs +++ b/crates/agentic-server-core/src/types/io/input.rs @@ -15,7 +15,25 @@ pub struct InputTextContent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputImageContent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub image_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputFileContent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, } @@ -59,7 +77,50 @@ pub enum InputMessageContent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunctionToolResultMessage { pub call_id: String, - pub output: String, + pub output: ToolCallOutput, +} + +/// Text or structured content returned by a client-owned tool call. +/// +/// The Responses API accepts either a string or an array containing text, +/// image, and file input content. Keeping the array structured preserves its +/// media semantics when a custom-tool output is normalized to a function-tool +/// output for the upstream model. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolCallOutput { + Text(String), + Content(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolOutputContent { + InputText(InputTextContent), + InputImage(InputImageContent), + InputFile(InputFileContent), +} + +impl ToolCallOutput { + #[must_use] + pub fn has_content(&self) -> bool { + match self { + Self::Text(text) => !text.trim().is_empty(), + Self::Content(content) => !content.is_empty(), + } + } +} + +impl From for ToolCallOutput { + fn from(output: String) -> Self { + Self::Text(output) + } +} + +impl From<&str> for ToolCallOutput { + fn from(output: &str) -> Self { + Self::Text(output.to_owned()) + } } /// A model-generated function call replayed as Responses input. @@ -93,6 +154,19 @@ impl From for InputFunctionToolCall { } } +impl From for InputFunctionToolCall { + fn from(call: CustomToolCall) -> Self { + Self { + id: function_call_item_id(&call.id), + call_id: call.call_id, + name: call.name, + namespace: None, + arguments: serde_json::json!({ "input": call.input }).to_string(), + status: call.status, + } + } +} + /// An opaque compacted context checkpoint accepted as Responses input. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompactionItem { @@ -107,7 +181,16 @@ pub struct CustomToolCallOutputMessage { pub call_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - pub output: Value, + pub output: ToolCallOutput, +} + +impl From for FunctionToolResultMessage { + fn from(output: CustomToolCallOutputMessage) -> Self { + Self { + call_id: output.call_id, + output: output.output, + } + } } #[derive(Debug, Clone, Serialize)] @@ -121,8 +204,7 @@ pub enum InputItem { FunctionCall(InputFunctionToolCall), #[serde(rename = "function_call_output")] FunctionCallOutput(FunctionToolResultMessage), - /// The model's freeform invocation, retained when rehydrating the matching - /// client-provided `custom_tool_call_output` on the next turn. + /// The public freeform invocation accepted from a client request. #[serde(rename = "custom_tool_call")] CustomToolCall(CustomToolCall), #[serde(rename = "custom_tool_call_output")] @@ -231,6 +313,7 @@ impl ResponsesInput { let Self::Items(items) = self else { return Cow::Borrowed(self); }; + let Some(window) = latest_compaction_window(items) else { return Cow::Borrowed(self); }; @@ -254,6 +337,16 @@ impl ResponsesInput { } } +fn function_call_item_id(item_id: &str) -> Option { + if item_id.is_empty() { + return None; + } + if let Some(suffix) = item_id.strip_prefix("ctc_").filter(|suffix| !suffix.is_empty()) { + return Some(format!("fc_{suffix}")); + } + Some(item_id.to_owned()) +} + #[cfg(test)] mod tests { use super::*; @@ -286,6 +379,46 @@ mod tests { assert!(result.is_err()); } + #[test] + fn structured_custom_tool_output_is_preserved_when_normalized() { + let content = serde_json::json!([ + {"type": "input_text", "text": "diagram"}, + {"type": "input_image", "image_url": "data:image/png;base64,abc", "detail": "low"}, + {"type": "input_file", "file_id": "file_123", "filename": "report.pdf"} + ]); + let item: InputItem = serde_json::from_value(serde_json::json!({ + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": content + })) + .expect("valid structured custom-tool output"); + + let InputItem::CustomToolCallOutput(output) = item else { + panic!("expected custom-tool output"); + }; + let normalized = FunctionToolResultMessage::from(output); + let value = serde_json::to_value(normalized).expect("normalized output serializes"); + + assert_eq!(value["output"], content); + } + + #[test] + fn custom_tool_output_rejects_unsupported_shapes() { + for output in [ + serde_json::json!({"result": "not a supported top-level object"}), + serde_json::json!([{"type": "output_text", "text": "wrong content type"}]), + serde_json::json!(["content items must be objects"]), + ] { + let result = serde_json::from_value::(serde_json::json!({ + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": output + })); + + assert!(result.is_err(), "unsupported custom-tool output should fail"); + } + } + #[test] fn compaction_item_becomes_assistant_model_context() { let input: ResponsesInput = serde_json::from_value(serde_json::json!([{ @@ -321,4 +454,35 @@ mod tests { assert_eq!(serialized[1]["content"][0]["text"], "latest summary"); assert_eq!(serialized[2]["content"], "keep me"); } + + #[test] + fn custom_items_convert_to_function_history() { + let input: ResponsesInput = serde_json::from_value(serde_json::json!([ + { + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_1", + "name": "raw_echo", + "input": "hello", + "status": "completed" + }, + { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": "done" + } + ])) + .expect("custom history"); + + let canonical_value = serde_json::to_value(Vec::::from(&input)).expect("canonical items"); + assert_eq!(canonical_value[0]["type"], "function_call"); + assert_eq!(canonical_value[0]["id"], "fc_1"); + assert_eq!(canonical_value[0]["arguments"], r#"{"input":"hello"}"#); + assert_eq!(canonical_value[1]["type"], "function_call_output"); + assert_eq!(canonical_value[1]["output"], "done"); + + let public_value = serde_json::to_value(input).expect("public input"); + assert_eq!(public_value[0]["type"], "custom_tool_call"); + assert_eq!(public_value[1]["type"], "custom_tool_call_output"); + } } diff --git a/crates/agentic-server-core/src/types/io/mod.rs b/crates/agentic-server-core/src/types/io/mod.rs index fdc923bb..648b9036 100644 --- a/crates/agentic-server-core/src/types/io/mod.rs +++ b/crates/agentic-server-core/src/types/io/mod.rs @@ -4,8 +4,9 @@ pub mod tools; pub mod usage; pub use input::{ - CompactionItem, CustomToolCallOutputMessage, FunctionToolResultMessage, InputContent, InputFunctionToolCall, - InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, ResponsesInput, + CompactionItem, CustomToolCallOutputMessage, FunctionToolResultMessage, InputContent, InputFileContent, + InputFunctionToolCall, InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, + ResponsesInput, ToolCallOutput, ToolOutputContent, }; pub use output::{ ApplyDone, CustomToolCall, FunctionToolCall, GatewayCallStatus, McpCall, McpCallError, McpCallStatus, @@ -13,6 +14,6 @@ pub use output::{ ReasoningTextContent, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, WebSearchSource, }; -pub use tools::{FunctionTool, ToolChoice}; +pub use tools::{AllowedTool, AllowedToolsMode, FunctionTool, ToolChoice}; pub(crate) use tools::{resolve_tool_choice, resolve_tools}; pub use usage::{InputTokenDetails, OutputTokenDetails, ResponseUsage}; diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index e25aedbe..65e6db62 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -521,41 +521,86 @@ impl ApplyDone for ReasoningOutput { impl ApplyDone for FunctionToolCall { fn apply_done(&mut self, payload: &EventPayload, buffer: &mut String) { - let EventPayload::FunctionCallArgsDone { - arguments, - call_id, - name, - .. - } = payload - else { - return; - }; - self.arguments = if arguments.is_empty() { - std::mem::take(buffer) - } else { - buffer.clear(); - arguments.clone() - }; - if let Some(cid) = call_id.as_deref().filter(|s| !s.is_empty()) { - cid.clone_into(&mut self.call_id); - } - if !name.is_empty() { - name.clone_into(&mut self.name); + match payload { + EventPayload::FunctionCallArgsDone { + arguments, + call_id, + name, + .. + } => { + self.arguments = if arguments.is_empty() { + std::mem::take(buffer) + } else { + buffer.clear(); + arguments.clone() + }; + if let Some(cid) = call_id.as_deref().filter(|s| !s.is_empty()) { + cid.clone_into(&mut self.call_id); + } + if !name.is_empty() { + name.clone_into(&mut self.name); + } + } + EventPayload::OutputItemDone { item, .. } => { + let Some(mut call) = deserialize_from_value_opt::(item.clone()) else { + return; + }; + if item.get("id").and_then(Value::as_str).is_none_or(str::is_empty) { + call.id.clone_from(&self.id); + } + if call.call_id.is_empty() { + call.call_id.clone_from(&self.call_id); + } + if call.name.is_empty() { + call.name.clone_from(&self.name); + } + if call.namespace.is_none() { + call.namespace.clone_from(&self.namespace); + } + if call.arguments.is_empty() { + call.arguments = if self.arguments.is_empty() { + std::mem::take(buffer) + } else { + std::mem::take(&mut self.arguments) + }; + } else { + buffer.clear(); + } + *self = call; + } + _ => {} } } } impl ApplyDone for CustomToolCall { fn apply_done(&mut self, payload: &EventPayload, buffer: &mut String) { - let EventPayload::CustomToolCallInputDone { input, .. } = payload else { - return; - }; - self.input = if input.is_empty() { - std::mem::take(buffer) - } else { - buffer.clear(); - input.clone() - }; + match payload { + EventPayload::CustomToolCallInputDone { input, .. } => { + self.input = if input.is_empty() { + std::mem::take(buffer) + } else { + buffer.clear(); + input.clone() + }; + } + EventPayload::OutputItemDone { item, .. } => { + let Some(mut call) = deserialize_from_value_opt::(item.clone()) else { + return; + }; + if call.input.is_empty() { + call.input = if self.input.is_empty() { + std::mem::take(buffer) + } else { + std::mem::take(&mut self.input) + }; + } else { + buffer.clear(); + } + *self = call; + } + _ => {} + } } } @@ -607,7 +652,7 @@ impl OutputItem { Self::Message(message) => Some(InputItem::Message(message.clone().into())), Self::Reasoning(reasoning) => Some(InputItem::Reasoning(reasoning.clone())), Self::FunctionCall(call) => Some(InputItem::FunctionCall(InputFunctionToolCall::from(call.clone()))), - Self::CustomToolCall(call) => Some(InputItem::CustomToolCall(call.clone())), + Self::CustomToolCall(call) => Some(InputItem::FunctionCall(call.clone().into())), Self::WebSearchCall(_) | Self::McpCall(_) | Self::Unknown => None, } } @@ -636,11 +681,11 @@ mod tests { }; assert_eq!(call.status, Some(MessageStatus::Completed)); - let Some(InputItem::CustomToolCall(call)) = item.to_input_item() else { - panic!("custom call should rehydrate as input"); + let Some(InputItem::FunctionCall(call)) = item.to_input_item() else { + panic!("custom call should rehydrate as a function call"); }; assert_eq!(call.name, "apply_patch"); - assert_eq!(call.input, "*** Begin Patch\n*** End Patch"); + assert_eq!(call.arguments, r#"{"input":"*** Begin Patch\n*** End Patch"}"#); } #[test] diff --git a/crates/agentic-server-core/src/types/io/tools.rs b/crates/agentic-server-core/src/types/io/tools.rs index d2067041..ee9af3a8 100644 --- a/crates/agentic-server-core/src/types/io/tools.rs +++ b/crates/agentic-server-core/src/types/io/tools.rs @@ -26,6 +26,24 @@ pub enum ToolChoice { Custom { name: NonEmptyToolName, }, + AllowedTools { + mode: AllowedToolsMode, + tools: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AllowedTool { + #[serde(rename = "type")] + pub type_: NonEmptyToolName, + pub name: NonEmptyToolName, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AllowedToolsMode { + Auto, + Required, } impl Serialize for ToolChoice { @@ -52,6 +70,13 @@ impl Serialize for ToolChoice { map.serialize_entry("name", name.as_str())?; map.end() } + Self::AllowedTools { mode, tools } => { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("type", "allowed_tools")?; + map.serialize_entry("mode", mode)?; + map.serialize_entry("tools", tools)?; + map.end() + } } } } @@ -92,6 +117,20 @@ impl<'de> Deserialize<'de> for ToolChoice { return Ok(Self::Custom { name }); } + if object.get("type").and_then(Value::as_str) == Some("allowed_tools") { + let mode = object + .get("mode") + .cloned() + .ok_or_else(|| de::Error::missing_field("mode"))?; + let mode = serde_json::from_value(mode).map_err(de::Error::custom)?; + let tools = object + .get("tools") + .cloned() + .ok_or_else(|| de::Error::missing_field("tools"))?; + let tools = serde_json::from_value(tools).map_err(de::Error::custom)?; + return Ok(Self::AllowedTools { mode, tools }); + } + if let Some(function) = object.get("function").and_then(Value::as_object) { let namespace = function.get("namespace").and_then(Value::as_str).map(str::to_string); let name = function @@ -103,16 +142,42 @@ impl<'de> Deserialize<'de> for ToolChoice { } Err(de::Error::custom( - "expected tool_choice string, function object, or custom object", + "expected tool_choice string, named tool object, or allowed_tools object", )) } _ => Err(de::Error::custom( - "expected tool_choice string, function object, or custom object", + "expected tool_choice string, named tool object, or allowed_tools object", )), } } } +impl ToolChoice { + /// Converts client-facing custom-tool selectors to the function-tool shape + /// used by the normalized upstream tool declarations. + #[must_use] + pub(crate) fn normalized_for_upstream(&self) -> Self { + match self { + Self::Custom { name } => Self::Function { + namespace: None, + name: name.clone(), + }, + Self::AllowedTools { mode, tools } => Self::AllowedTools { + mode: *mode, + tools: tools.iter().cloned().map(normalize_allowed_tool).collect(), + }, + choice => choice.clone(), + } + } +} + +fn normalize_allowed_tool(mut tool: AllowedTool) -> AllowedTool { + if tool.type_.as_str() == "custom" { + tool.type_ = NonEmptyToolName::try_from("function").expect("function is a non-empty tool type"); + } + tool +} + /// Returns the effective tool list, preferring `request_tools` when explicitly /// set by the caller, otherwise falling back to the stored configuration. #[inline] @@ -168,19 +233,19 @@ mod tests { #[test] fn custom_tool_choice_round_trips() { - let expected = serde_json::json!({ + let custom = serde_json::json!({ "type": "custom", "name": "apply_patch" }); - let choice: ToolChoice = serde_json::from_value(expected.clone()).unwrap(); + let choice: ToolChoice = serde_json::from_value(custom.clone()).unwrap(); assert_eq!( choice, ToolChoice::Custom { name: NonEmptyToolName::try_from("apply_patch").unwrap() } ); - assert_eq!(serde_json::to_value(choice).unwrap(), expected); + assert_eq!(serde_json::to_value(choice).unwrap(), custom); } #[test] @@ -193,4 +258,37 @@ mod tests { .is_err() ); } + + #[test] + fn allowed_tools_round_trip() { + let expected = serde_json::json!({ + "type": "allowed_tools", + "mode": "required", + "tools": [ + {"type": "function", "name": "get_weather"}, + {"type": "custom", "name": "code_exec"} + ] + }); + + let choice: ToolChoice = serde_json::from_value(expected.clone()).unwrap(); + assert_eq!(serde_json::to_value(choice).unwrap(), expected); + } + + #[test] + fn allowed_tools_require_non_empty_type_and_name() { + for invalid_tool in [ + serde_json::json!({"type": "function"}), + serde_json::json!({"type": "", "name": "get_weather"}), + serde_json::json!({"type": "function", "name": ""}), + ] { + assert!( + serde_json::from_value::(serde_json::json!({ + "type": "allowed_tools", + "mode": "auto", + "tools": [invalid_tool] + })) + .is_err() + ); + } + } } diff --git a/crates/agentic-server-core/src/types/mod.rs b/crates/agentic-server-core/src/types/mod.rs index 892f52cf..0c60e157 100644 --- a/crates/agentic-server-core/src/types/mod.rs +++ b/crates/agentic-server-core/src/types/mod.rs @@ -5,13 +5,14 @@ pub mod request_response; pub mod tools; pub use io::{ - CompactionItem, CustomToolCall, CustomToolCallOutputMessage, FunctionTool, FunctionToolCall, - FunctionToolResultMessage, GatewayCallStatus, InputContent, InputFunctionToolCall, InputImageContent, InputItem, - InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, McpCall, McpCallError, McpCallStatus, - McpToolExecutionError, McpToolExecutionErrorContent, OutputItem, OutputMessage, OutputTextContent, - OutputTokenDetails, ReasoningOutput, ReasoningTextContent, ResponseUsage, ResponsesInput, ToolChoice, - WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, WebSearchCall, - WebSearchCallStatus, WebSearchSource, + AllowedTool, AllowedToolsMode, CompactionItem, CustomToolCall, CustomToolCallOutputMessage, FunctionTool, + FunctionToolCall, FunctionToolResultMessage, GatewayCallStatus, InputContent, InputFileContent, + InputFunctionToolCall, InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, + InputTokenDetails, McpCall, McpCallError, McpCallStatus, McpToolExecutionError, McpToolExecutionErrorContent, + OutputItem, OutputMessage, OutputTextContent, OutputTokenDetails, ReasoningOutput, ReasoningTextContent, + ResponseUsage, ResponsesInput, ToolCallOutput, ToolChoice, ToolOutputContent, WebSearchAction, + WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, + WebSearchSource, }; pub use request_response::{ CompactRequest, CompactedResponse, ContextManagement, IncompleteDetails, RequestPayload, ResponsePayload, diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 9076a320..3d0a03c5 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::io::{ FunctionTool, InputItem, InputMessage, InputMessageContent, OutputItem, ResponseUsage, ResponsesInput, ToolChoice, }; -use super::tools::{CustomToolParam, ResponsesTool}; +use super::tools::ResponsesTool; use crate::tool::{CodexNamespaceHandler, ToolError}; use crate::utils::common::serialize_to_string; @@ -49,13 +49,15 @@ pub struct UpstreamRequest<'a> { pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option<&'a str>, - /// Tools forwarded to vLLM. Namespace members are flattened to ordinary - /// function declarations; native custom declarations retain their freeform - /// wire shape. + /// Tools forwarded to vLLM. Function-like declarations are normalized to + /// ordinary function tools. /// Skipped when empty so vLLM does not receive an empty array. #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, - #[serde(skip_serializing_if = "is_absent_or_default_tool_choice")] + #[serde( + skip_serializing_if = "is_absent_or_default_tool_choice", + serialize_with = "serialize_upstream_tool_choice" + )] pub tool_choice: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include: Option<&'a Vec>, @@ -74,42 +76,14 @@ pub struct UpstreamRequest<'a> { pub cache_salt: Option<&'a str>, } -/// A tool declaration supported by the upstream Responses endpoint. +/// A normalized tool declaration supported by the upstream Responses endpoint. /// -/// Function-like gateway declarations are normalized to [`FunctionTool`], -/// while freeform custom declarations retain their native Responses shape. -/// Keeping these as distinct variants prevents unrelated request tool types -/// from entering the upstream tool list. -#[derive(Debug, Clone)] +/// Gateway and client tool declarations are converted to function tools before +/// entering this upstream-only payload. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] pub enum UpstreamTool { Function(FunctionTool), - Custom(CustomToolParam), -} - -impl Serialize for UpstreamTool { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - Self::Function(tool) => tool.serialize(serializer), - Self::Custom(declaration) => { - #[derive(Serialize)] - struct NativeCustomTool<'a> { - #[serde(rename = "type")] - type_: &'static str, - #[serde(flatten)] - declaration: &'a CustomToolParam, - } - - NativeCustomTool { - type_: "custom", - declaration, - } - .serialize(serializer) - } - } - } } // serde's `skip_serializing_if` requires a `&Option` receiver, so the @@ -119,14 +93,25 @@ fn is_absent_or_default_tool_choice(choice: &Option) -> bool { choice.as_ref().is_none_or(|choice| matches!(choice, ToolChoice::Auto)) } +// serde's `serialize_with` passes a reference to the field's concrete type. +#[allow(clippy::ref_option)] +fn serialize_upstream_tool_choice(choice: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + choice + .as_ref() + .map(ToolChoice::normalized_for_upstream) + .serialize(serializer) +} + impl RequestPayload { /// Construct an `UpstreamRequest` suitable for forwarding to vLLM. /// /// Codex `namespace` tools' members are first renamed to their flat, /// model-visible names via [`CodexNamespaceHandler::resolve_namespace_members`]. - /// Namespace and gateway tools are then normalized to function declarations. - /// Native custom tools are forwarded unchanged because their calls are not - /// function calls. `tool_choice` is resolved the same way via + /// Namespace, gateway, and custom tools are then normalized to function + /// declarations. `tool_choice` is resolved the same way via /// [`CodexNamespaceHandler::resolve_tool_choice`]. /// /// # Errors @@ -152,8 +137,13 @@ impl RequestPayload { .as_deref() .map(|tools| CodexNamespaceHandler.resolve_namespace_members(tools)) .transpose()?; - let tools: Option> = - renamed_tools.map(|tools| tools.into_iter().flat_map(upstream_tools).collect()); + let tools: Option> = renamed_tools.map(|tools| { + tools + .iter() + .flat_map(ResponsesTool::to_function_tools) + .map(UpstreamTool::Function) + .collect() + }); let tools = tools.filter(|tools| !tools.is_empty()); let namespace_map = CodexNamespaceHandler.build_namespace_map(self.tools.as_deref())?; let tool_choice = CodexNamespaceHandler.resolve_tool_choice(namespace_map.as_ref(), self.tool_choice.as_ref()); @@ -216,24 +206,6 @@ pub struct CompactedResponse { pub usage: ResponseUsage, } -fn upstream_tools(tool: ResponsesTool) -> Vec { - match tool { - ResponsesTool::Custom(declaration) => { - tracing::debug!( - name = %declaration.name, - has_format = declaration.format.is_some(), - "forwarding native custom tool declaration upstream" - ); - vec![UpstreamTool::Custom(declaration)] - } - function_like => function_like - .to_function_tools() - .into_iter() - .map(UpstreamTool::Function) - .collect(), - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IncompleteDetails { pub reason: Option, @@ -304,7 +276,17 @@ impl From<&ResponsesInput> for Vec { status: None, content: InputMessageContent::Text(text.clone()), })], - ResponsesInput::Items(items) => items.iter().filter(|item| !item.is_unknown()).cloned().collect(), + ResponsesInput::Items(items) => items + .iter() + .filter_map(|item| match item { + InputItem::Unknown => None, + InputItem::CustomToolCall(call) => Some(InputItem::FunctionCall(call.clone().into())), + InputItem::CustomToolCallOutput(output) => { + Some(InputItem::FunctionCallOutput(output.clone().into())) + } + item => Some(item.clone()), + }) + .collect(), } } } @@ -318,7 +300,15 @@ impl From for Vec { status: None, content: InputMessageContent::Text(text), })], - ResponsesInput::Items(items) => items.into_iter().filter(|item| !item.is_unknown()).collect(), + ResponsesInput::Items(items) => items + .into_iter() + .filter_map(|item| match item { + InputItem::Unknown => None, + InputItem::CustomToolCall(call) => Some(InputItem::FunctionCall(call.into())), + InputItem::CustomToolCallOutput(output) => Some(InputItem::FunctionCallOutput(output.into())), + item => Some(item), + }) + .collect(), } } } @@ -580,7 +570,7 @@ mod tests { } #[test] - fn to_upstream_request_serializes_mixed_function_and_native_custom_tools() { + fn to_upstream_request_normalizes_custom_tools_to_functions() { let payload: RequestPayload = serde_json::from_value(serde_json::json!({ "model": "test", "input": "hi", @@ -612,21 +602,66 @@ mod tests { let request = payload.to_upstream_request(false).unwrap(); let tools = request.tools.as_ref().expect("mixed upstream tools"); - assert!(matches!(tools[0], UpstreamTool::Function(_))); - assert!(matches!(tools[1], UpstreamTool::Custom(_))); + let UpstreamTool::Function(first) = &tools[0]; + let UpstreamTool::Function(second) = &tools[1]; + assert_eq!(first.name, "read_file"); + assert_eq!(second.name, "apply_patch"); let upstream = serde_json::to_value(request).unwrap(); assert_eq!(upstream["tools"][0]["type"], "function"); assert_eq!(upstream["tools"][0]["name"], "read_file"); - assert_eq!(upstream["tools"][1]["type"], "custom"); + assert_eq!(upstream["tools"][1]["type"], "function"); assert_eq!(upstream["tools"][1]["name"], "apply_patch"); - assert_eq!(upstream["tools"][1]["description"], "Apply a patch."); - assert_eq!(upstream["tools"][1]["format"]["type"], "grammar"); - assert_eq!(upstream["tools"][1]["format"]["syntax"], "lark"); - assert_eq!(upstream["tools"][1]["format"]["definition"], "start: patch"); - assert_eq!(upstream["tools"][1]["x-provider-field"]["mode"], "strict"); - assert_eq!(upstream["tool_choice"]["type"], "custom"); + let custom_description = upstream["tools"][1]["description"] + .as_str() + .expect("custom tool description"); + assert!(custom_description.contains("Apply a patch.")); + assert!(custom_description.contains("raw tool input in the `input` string field")); + assert!(custom_description.contains("lark grammar exactly")); + assert!(custom_description.contains("start: patch")); + assert!(custom_description.contains("x-provider-field")); + assert_eq!( + upstream["tools"][1]["parameters"]["properties"]["input"]["type"], + "string" + ); + assert_eq!(upstream["tools"][1]["parameters"]["required"][0], "input"); + assert_eq!(upstream["tool_choice"]["type"], "function"); assert_eq!(upstream["tool_choice"]["name"], "apply_patch"); + + let deserialized: FunctionTool = + serde_json::from_value(upstream["tools"][1].clone()).expect("upstream function tool should deserialize"); + assert_eq!(deserialized.name, "apply_patch"); + } + + #[test] + fn to_upstream_request_normalizes_custom_allowed_tool_choices() { + let payload: RequestPayload = serde_json::from_value(serde_json::json!({ + "model": "test", + "input": "hi", + "tool_choice": { + "type": "allowed_tools", + "mode": "required", + "tools": [ + {"type": "function", "name": "read_file"}, + {"type": "custom", "name": "apply_patch"} + ] + }, + "tools": [ + {"type": "function", "name": "read_file"}, + {"type": "custom", "name": "apply_patch"} + ] + })) + .unwrap(); + + let public_choice = serde_json::to_value(payload.tool_choice.as_ref().unwrap()).unwrap(); + assert_eq!(public_choice["tools"][1]["type"], "custom"); + + let upstream = serde_json::to_value(payload.to_upstream_request(false).unwrap()).unwrap(); + assert_eq!(upstream["tool_choice"]["type"], "allowed_tools"); + assert_eq!(upstream["tool_choice"]["mode"], "required"); + assert_eq!(upstream["tool_choice"]["tools"][0]["type"], "function"); + assert_eq!(upstream["tool_choice"]["tools"][1]["type"], "function"); + assert_eq!(upstream["tool_choice"]["tools"][1]["name"], "apply_patch"); } #[test] diff --git a/crates/agentic-server-core/tests/accumulator_cassette_test.rs b/crates/agentic-server-core/tests/accumulator_cassette_test.rs index 54f21e5f..6bcc7a07 100644 --- a/crates/agentic-server-core/tests/accumulator_cassette_test.rs +++ b/crates/agentic-server-core/tests/accumulator_cassette_test.rs @@ -16,6 +16,7 @@ const TOOL_CALLS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassett const REASONING_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/reasoning/responses"); const CODEX_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/codex"); const WEB_SEARCH_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/web_search"); +const CUSTOM_TOOL_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/custom_tool"); const WEB_SEARCH_GATEWAY_MODEL: &str = "Qwen/Qwen3.5-35B-A3B-FP8"; const WEB_SEARCH_GATEWAY_MODEL_SLUG: &str = "Qwen-Qwen3.5-35B-A3B-FP8"; const WEB_SEARCH_OPENAI_MODEL: &str = "gpt-5.6"; @@ -92,6 +93,10 @@ fn load_web_search_cassette(filename: &str) -> TurnCassette { load_turn_cassette_from(WEB_SEARCH_DIR, filename) } +fn load_custom_tool_cassette(filename: &str) -> TurnCassette { + load_turn_cassette_from(CUSTOM_TOOL_DIR, filename) +} + fn load_web_search_cassette_pair(streaming: bool) -> (TurnCassette, TurnCassette) { let mode = if streaming { "streaming" } else { "nonstreaming" }; let openai = load_web_search_cassette(&format!( @@ -987,6 +992,90 @@ fn test_web_search_accumulator_streaming_matches_openai() { assert_matching_web_search_output(&openai_output, &gateway_output); } +#[test] +fn test_custom_tool_cassettes_accumulate_public_streaming_shape() { + let cases = [ + ( + "gateway", + "custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml", + "Qwen/Qwen3.5-35B-A3B-FP8", + ), + ( + "OpenAI", + "custom-tool-openai-reference-gpt-5.6-streaming.yaml", + "gpt-5.6", + ), + ]; + + for (provider, filename, model) in cases { + let cassette = load_custom_tool_cassette(filename); + assert_eq!(cassette.turns.len(), 2, "{provider} cassette should contain two turns"); + + let output = process_codex_streaming_turn(&cassette, 0, model); + assert!( + !output.iter().any(|item| matches!(item, OutputItem::FunctionCall(_))), + "{provider} must not expose the normalized function call" + ); + let call = first_custom_tool_call(&output); + assert!(call.id.starts_with("ctc_"), "{provider} custom item ID"); + assert!(!call.call_id.is_empty(), "{provider} custom call ID"); + assert_eq!(call.name, "agentic_raw_echo", "{provider} custom tool name"); + assert_eq!(call.input, "CUSTOM_CASSETTE_OK", "{provider} custom input"); + assert_eq!(call.status, Some(MessageStatus::Completed), "{provider} custom status"); + + let continuation = process_codex_streaming_turn(&cassette, 1, model); + assert!(continuation.iter().any(|item| { + matches!( + item, + OutputItem::Message(message) + if message.content.iter().any(|content| content.text.contains("CUSTOM_CASSETTE_OUTPUT_OK")) + ) + })); + } +} + +#[test] +fn test_custom_tool_cassettes_accumulate_public_nonstreaming_shape() { + let cases = [ + ( + "gateway", + "custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml", + "Qwen/Qwen3.5-35B-A3B-FP8", + ), + ( + "OpenAI", + "custom-tool-openai-reference-gpt-5.6-nonstreaming.yaml", + "gpt-5.6", + ), + ]; + + for (provider, filename, model) in cases { + let cassette = load_custom_tool_cassette(filename); + assert_eq!(cassette.turns.len(), 2, "{provider} cassette should contain two turns"); + + let output = process_nonstreaming_turn(&cassette, 0, model); + assert!( + !output.iter().any(|item| matches!(item, OutputItem::FunctionCall(_))), + "{provider} must not expose the normalized function call" + ); + let call = first_custom_tool_call(&output); + assert!(call.id.starts_with("ctc_"), "{provider} custom item ID"); + assert!(!call.call_id.is_empty(), "{provider} custom call ID"); + assert_eq!(call.name, "agentic_raw_echo", "{provider} custom tool name"); + assert_eq!(call.input, "CUSTOM_CASSETTE_OK", "{provider} custom input"); + assert_eq!(call.status, Some(MessageStatus::Completed), "{provider} custom status"); + + let continuation = process_nonstreaming_turn(&cassette, 1, model); + assert!(continuation.iter().any(|item| { + matches!( + item, + OutputItem::Message(message) + if message.content.iter().any(|content| content.text.contains("CUSTOM_CASSETTE_OUTPUT_OK")) + ) + })); + } +} + // ═══════════════════════════════════════════════════════════════════ // Stateful 3-turn: get_job_status → get_error_logs → search_runbook // Non-streaming, store=true, previous_response_id chain diff --git a/crates/agentic-server-core/tests/cassettes/README.md b/crates/agentic-server-core/tests/cassettes/README.md index b5d31756..0ee58e3a 100644 --- a/crates/agentic-server-core/tests/cassettes/README.md +++ b/crates/agentic-server-core/tests/cassettes/README.md @@ -166,6 +166,7 @@ turns: | `record_reasoning_cassettes.sh` | 2 reasoning cassettes (single turn, streaming + non-streaming) | vLLM | | `record_tool_call_cassettes.sh` | 8 tool-call cassettes (4 tool_choice modes x streaming + non-streaming) | vLLM | | `record_codex_cli_tool_call_cassettes.sh` | Codex function/namespace/custom-tool matrix | gateway, vLLM, and OpenAI | +| `record_custom_tool_cassettes.sh` | Matching two-turn custom-tool flows (streaming + non-streaming) | gateway and OpenAI reference | | `record_mcp_cassettes.sh` | Native MCP counter tool discovery and calls (streaming + non-streaming) | gateway and OpenAI reference | | `record_web_search_cassettes.sh` | Matching web-search calls (streaming + non-streaming) | gateway and OpenAI reference | @@ -202,6 +203,19 @@ OPENAI_API_KEY=sk-... \ bash crates/agentic-server-core/tests/cassettes/record_web_search_cassettes.sh ``` +### Custom tool (gateway and OpenAI) + +This records a generic freeform `custom` tool with a Lark grammar, including +the `custom_tool_call_output` continuation. + +```bash +OPENAI_API_KEY=sk-... \ +bash crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh +``` + +Use `CUSTOM_TOOL_RECORD_SET=gateway` or `CUSTOM_TOOL_RECORD_SET=openai` to +record only one provider. + ### Codex custom tools (gateway, vLLM, and OpenAI) The custom fixture uses a Lark grammar and records two turns: the model returns raw `custom_tool_call.input`, then the diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml new file mode 100644 index 00000000..e760fc43 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml @@ -0,0 +1,157 @@ +turns: +- filename: t1 + request: + body: + input: You must call the agentic_raw_echo custom tool exactly once with exactly + CUSTOM_CASSETTE_OK as its raw text input. + max_output_tokens: 2048 + model: Qwen/Qwen3.5-35B-A3B-FP8 + store: true + stream: false + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + conversation_id: null + created_at: 1785758003 + error: null + id: resp_019fc778-af92-74c0-9cec-96bc69a19084 + incomplete_details: null + instructions: null + model: Qwen/Qwen3.5-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user is asking me to call the agentic_raw_echo custom tool exactly + once with exactly "CUSTOM_CASSETTE_OK" as its raw text input. + + + Looking at the tool definition: + + - Name: agentic_raw_echo + + - Required parameter: input (string) + + - Description says the string must match the lark grammar: start: "CUSTOM_CASSETTE_OK" + + + So I need to call this tool with input="CUSTOM_CASSETTE_OK". + + ' + type: reasoning_text + encrypted_content: null + id: rs_a2b8fea477983752 + status: null + summary: [] + type: reasoning + - call_id: chatcmpl-tool-95a45c75da2681e6 + id: ctc_bee6b80c4899ad49 + input: CUSTOM_CASSETTE_OK + name: agentic_raw_echo + status: completed + type: custom_tool_call + previous_response_id: null + status: completed + usage: + input_tokens: 390 + input_tokens_details: + cached_tokens: 0 + output_tokens: 133 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 523 + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: chatcmpl-tool-95a45c75da2681e6 + output: CUSTOM_CASSETTE_OUTPUT_OK + type: custom_tool_call_output + - content: Use the custom tool output provided above. Do not call any tool again. + Reply with exactly CUSTOM_CASSETTE_OUTPUT_OK. + role: user + type: message + max_output_tokens: 2048 + model: Qwen/Qwen3.5-35B-A3B-FP8 + previous_response_id: resp_019fc778-af92-74c0-9cec-96bc69a19084 + store: true + stream: false + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + conversation_id: null + created_at: 1785758004 + error: null + id: resp_019fc778-b32a-76c1-b581-6f61a08e0d23 + incomplete_details: null + instructions: null + model: Qwen/Qwen3.5-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user is asking me to use the custom tool output I just received + and reply with exactly "CUSTOM_CASSETTE_OUTPUT_OK". The tool returned + "CUSTOM_CASSETTE_OUTPUT_OK" as the output, so I should echo that back + exactly. + + ' + type: reasoning_text + encrypted_content: null + id: rs_88750adaa679be92 + status: null + summary: [] + type: reasoning + - content: + - annotations: [] + text: ' + + + CUSTOM_CASSETTE_OUTPUT_OK' + type: output_text + id: msg_9a4add76dc0f27e6 + role: assistant + status: completed + type: message + previous_response_id: resp_019fc778-af92-74c0-9cec-96bc69a19084 + status: completed + usage: + input_tokens: 474 + input_tokens_details: + cached_tokens: 0 + output_tokens: 62 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 536 + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml new file mode 100644 index 00000000..8e84ae7c --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml @@ -0,0 +1,1211 @@ +turns: +- filename: t1 + request: + body: + input: You must call the agentic_raw_echo custom tool exactly once with exactly + CUSTOM_CASSETTE_OK as its raw text input. + max_output_tokens: 2048 + model: Qwen/Qwen3.5-35B-A3B-FP8 + store: true + stream: true + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1785758000,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":2048,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Emit + the requested cassette token as raw text.","format":{"definition":"start: \"CUSTOM_CASSETTE_OK\"","syntax":"lark","type":"grammar"},"name":"agentic_raw_echo","type":"custom"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1785758000,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":2048,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Emit + the requested cassette token as raw text.","format":{"definition":"start: \"CUSTOM_CASSETTE_OK\"","syntax":"lark","type":"grammar"},"name":"agentic_raw_echo","type":"custom"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"82adad6aadd55280","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"82adad6aadd55280","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + is","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + asking","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + me","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + to","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + call","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + the","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + ag","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":"entic","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"_raw","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_echo","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + custom","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" + tool","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" + with","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" + \"","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":"_C","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":"AS","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":"SET","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":"TE","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"_OK","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":"\"","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" + as","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" + its","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" + raw","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" + text","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" + input","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":".","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + They","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + specifically","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + said","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" + I","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" + must","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + call","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + it","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + once","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" + with","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":" + this","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":" + input","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":".","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":"\n\n","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":"I","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" + need","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":" + to","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" + use","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":" + the","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":" + ag","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":"entic","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":"_raw","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":"_echo","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":" + function","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":" + with","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":" + the","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":" + input","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + parameter","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + set","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":" + to","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":" + \"","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":67,"output_index":0,"content_index":0,"delta":"_C","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":68,"output_index":0,"content_index":0,"delta":"AS","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":69,"output_index":0,"content_index":0,"delta":"SET","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":70,"output_index":0,"content_index":0,"delta":"TE","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":71,"output_index":0,"content_index":0,"delta":"_OK","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":72,"output_index":0,"content_index":0,"delta":"\".","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":73,"output_index":0,"content_index":0,"delta":"\n","item_id":"82adad6aadd55280"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":74,"output_index":0,"content_index":0,"item_id":"82adad6aadd55280","text":"The + user is asking me to call the agentic_raw_echo custom tool with exactly \"CUSTOM_CASSETTE_OK\" + as its raw text input. They specifically said I must call it exactly once with + exactly this input.\n\nI need to use the agentic_raw_echo function with the + input parameter set to \"CUSTOM_CASSETTE_OK\".\n"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":75,"output_index":0,"content_index":0,"item_id":"82adad6aadd55280","part":{"text":"The + user is asking me to call the agentic_raw_echo custom tool with exactly \"CUSTOM_CASSETTE_OK\" + as its raw text input. They specifically said I must call it exactly once with + exactly this input.\n\nI need to use the agentic_raw_echo function with the + input parameter set to \"CUSTOM_CASSETTE_OK\".\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":76,"output_index":0,"item":{"content":[{"text":"The + user is asking me to call the agentic_raw_echo custom tool with exactly \"CUSTOM_CASSETTE_OK\" + as its raw text input. They specifically said I must call it exactly once with + exactly this input.\n\nI need to use the agentic_raw_echo function with the + input parameter set to \"CUSTOM_CASSETTE_OK\".\n","type":"reasoning_text"}],"encrypted_content":null,"id":"82adad6aadd55280","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":77,"output_index":1,"item":{"call_id":"call_8e0a538bf00e25a8","id":"ctc_ac27a0ae302a449c","input":"","name":"agentic_raw_echo","status":"in_progress","type":"custom_tool_call"}} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":78,"output_index":1,"delta":"CUSTOM","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":79,"output_index":1,"delta":"_C","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":80,"output_index":1,"delta":"AS","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":81,"output_index":1,"delta":"SET","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":82,"output_index":1,"delta":"TE","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":83,"output_index":1,"delta":"_OK","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.done","sequence_number":84,"output_index":1,"input":"CUSTOM_CASSETTE_OK","item_id":"ctc_ac27a0ae302a449c"} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":85,"output_index":1,"item":{"call_id":"call_8e0a538bf00e25a8","id":"ctc_ac27a0ae302a449c","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"}} + + ' + - ' + + ' + - 'data: {"type":"response.completed","sequence_number":86,"response":{"conversation_id":null,"created_at":1785758001,"error":null,"id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user is asking me to call the agentic_raw_echo custom tool with exactly \"CUSTOM_CASSETTE_OK\" + as its raw text input. They specifically said I must call it exactly once with + exactly this input.\n\nI need to use the agentic_raw_echo function with the + input parameter set to \"CUSTOM_CASSETTE_OK\".\n","type":"reasoning_text"}],"encrypted_content":null,"id":"82adad6aadd55280","status":null,"summary":[],"type":"reasoning"},{"call_id":"call_8e0a538bf00e25a8","id":"ctc_ac27a0ae302a449c","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":390,"input_tokens_details":{"cached_tokens":0},"output_tokens":105,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":495}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_8e0a538bf00e25a8 + output: CUSTOM_CASSETTE_OUTPUT_OK + type: custom_tool_call_output + - content: Use the custom tool output provided above. Do not call any tool again. + Reply with exactly CUSTOM_CASSETTE_OUTPUT_OK. + role: user + type: message + max_output_tokens: 2048 + model: Qwen/Qwen3.5-35B-A3B-FP8 + previous_response_id: resp_019fc778-a4c4-7b23-9854-f47e99aa89de + store: true + stream: true + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1785758001,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fc778-a7ca-71f2-9e13-f247b4babe93","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":2048,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Emit + the requested cassette token as raw text.","format":{"definition":"start: \"CUSTOM_CASSETTE_OK\"","syntax":"lark","type":"grammar"},"name":"agentic_raw_echo","type":"custom"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'data: {"type":"response.in_progress","sequence_number":1,"response":{"background":false,"created_at":1785758001,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fc778-a7ca-71f2-9e13-f247b4babe93","incomplete_details":null,"input_messages":null,"instructions":null,"kv_transfer_params":null,"max_output_tokens":2048,"max_tool_calls":null,"metadata":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[],"output_messages":null,"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"description":"Emit + the requested cassette token as raw text.","format":{"definition":"start: \"CUSTOM_CASSETTE_OK\"","syntax":"lark","type":"grammar"},"name":"agentic_raw_echo","type":"custom"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"content":null,"encrypted_content":null,"id":"8360c29b181cca85","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"8360c29b181cca85","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + is","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + asking","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + me","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + to","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + reply","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + with","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" + \"","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_C","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"AS","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":"SET","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":"TE","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"_OUTPUT","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":"_OK","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"\"","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":" + based","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + on","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" + the","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + custom","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" + tool","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":" + output","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" + that","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" + was","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" + provided","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" + above","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":".","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":" + They","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + also","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + explicitly","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + stated","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" + not","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" + to","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + call","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + any","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + tool","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + again","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":".","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":"\n\n","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"I","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":" + should","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":47,"output_index":0,"content_index":0,"delta":" + simply","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":48,"output_index":0,"content_index":0,"delta":" + reply","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":49,"output_index":0,"content_index":0,"delta":" + with","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":50,"output_index":0,"content_index":0,"delta":" + the","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":51,"output_index":0,"content_index":0,"delta":" + exact","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":52,"output_index":0,"content_index":0,"delta":" + text","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":53,"output_index":0,"content_index":0,"delta":" + \"","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":54,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":55,"output_index":0,"content_index":0,"delta":"_C","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":56,"output_index":0,"content_index":0,"delta":"AS","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":57,"output_index":0,"content_index":0,"delta":"SET","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":58,"output_index":0,"content_index":0,"delta":"TE","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":59,"output_index":0,"content_index":0,"delta":"_OUTPUT","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":"_OK","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":"\"","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + as","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + requested","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":".","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":"\n","item_id":"8360c29b181cca85"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":66,"output_index":0,"content_index":0,"item_id":"8360c29b181cca85","text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the custom tool output that was provided above. They also explicitly stated + not to call any tool again.\n\nI should simply reply with the exact text \"CUSTOM_CASSETTE_OUTPUT_OK\" + as requested.\n"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":67,"output_index":0,"content_index":0,"item_id":"8360c29b181cca85","part":{"text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the custom tool output that was provided above. They also explicitly stated + not to call any tool again.\n\nI should simply reply with the exact text \"CUSTOM_CASSETTE_OUTPUT_OK\" + as requested.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":68,"output_index":0,"item":{"content":[{"text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the custom tool output that was provided above. They also explicitly stated + not to call any tool again.\n\nI should simply reply with the exact text \"CUSTOM_CASSETTE_OUTPUT_OK\" + as requested.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8360c29b181cca85","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":69,"output_index":1,"item":{"content":[],"id":"ab907eea77b6e005","phase":null,"role":"assistant","status":"in_progress","type":"message"}} + + ' + - ' + + ' + - 'data: {"type":"response.content_part.added","sequence_number":70,"output_index":1,"content_index":0,"item_id":"ab907eea77b6e005","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":71,"output_index":1,"content_index":0,"delta":"\n\nCUSTOM","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":72,"output_index":1,"content_index":0,"delta":"_C","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":73,"output_index":1,"content_index":0,"delta":"AS","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":74,"output_index":1,"content_index":0,"delta":"SET","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":75,"output_index":1,"content_index":0,"delta":"TE","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":76,"output_index":1,"content_index":0,"delta":"_OUTPUT","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":77,"output_index":1,"content_index":0,"delta":"_OK","item_id":"ab907eea77b6e005","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.done","sequence_number":78,"output_index":1,"content_index":0,"item_id":"ab907eea77b6e005","logprobs":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK"} + + ' + - ' + + ' + - 'data: {"type":"response.content_part.done","sequence_number":79,"output_index":1,"content_index":0,"item_id":"ab907eea77b6e005","part":{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":80,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"ab907eea77b6e005","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} + + ' + - ' + + ' + - 'data: {"type":"response.completed","sequence_number":81,"response":{"conversation_id":null,"created_at":1785758001,"error":null,"id":"resp_019fc778-a7ca-71f2-9e13-f247b4babe93","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the custom tool output that was provided above. They also explicitly stated + not to call any tool again.\n\nI should simply reply with the exact text \"CUSTOM_CASSETTE_OUTPUT_OK\" + as requested.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"8360c29b181cca85","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"ab907eea77b6e005","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_019fc778-a4c4-7b23-9854-f47e99aa89de","status":"completed","usage":{"input_tokens":474,"input_tokens_details":{"cached_tokens":0},"output_tokens":72,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":546}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-nonstreaming.yaml b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-nonstreaming.yaml new file mode 100644 index 00000000..296194fc --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-nonstreaming.yaml @@ -0,0 +1,226 @@ +turns: +- filename: t1 + request: + body: + input: You must call the agentic_raw_echo custom tool exactly once with exactly + CUSTOM_CASSETTE_OK as its raw text input. + max_output_tokens: 2048 + model: gpt-5.6 + store: true + stream: false + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1785757996 + created_at: 1785757995 + error: null + frequency_penalty: 0.0 + id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 + incomplete_details: null + instructions: null + max_output_tokens: 2048 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - call_id: call_t7gsPOaICdXkvdPvRE4WXCA7 + id: ctc_06bab1ed35efb02b006a70812ca2c8819bbb90c4d33d3d7e65 + input: CUSTOM_CASSETTE_OK + name: agentic_raw_echo + status: completed + type: custom_tool_call + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: 24h + reasoning: + context: all_turns + effort: medium + mode: standard + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tool_usage: + image_gen: + input_tokens: 0 + input_tokens_details: + image_tokens: 0 + text_tokens: 0 + output_tokens: 0 + output_tokens_details: + image_tokens: 0 + text_tokens: 0 + total_tokens: 0 + web_search: + num_requests: 0 + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 123 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 19 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 142 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_t7gsPOaICdXkvdPvRE4WXCA7 + output: CUSTOM_CASSETTE_OUTPUT_OK + type: custom_tool_call_output + - content: Use the custom tool output provided above. Do not call any tool again. + Reply with exactly CUSTOM_CASSETTE_OUTPUT_OK. + role: user + type: message + max_output_tokens: 2048 + model: gpt-5.6 + previous_response_id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 + store: true + stream: false + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1785757998 + created_at: 1785757997 + error: null + frequency_penalty: 0.0 + id: resp_06bab1ed35efb02b006a70812d3df8819ba1b13ebc8fda0f48 + incomplete_details: null + instructions: null + max_output_tokens: 2048 + max_tool_calls: null + metadata: {} + model: gpt-5.6-sol + moderation: null + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: CUSTOM_CASSETTE_OUTPUT_OK + type: output_text + id: msg_06bab1ed35efb02b006a70812e310c819b92f139271e842828 + phase: final_answer + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 + prompt_cache_key: null + prompt_cache_retention: 24h + reasoning: + context: all_turns + effort: medium + mode: standard + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tool_usage: + image_gen: + input_tokens: 0 + input_tokens_details: + image_tokens: 0 + text_tokens: 0 + output_tokens: 0 + output_tokens_details: + image_tokens: 0 + text_tokens: 0 + total_tokens: 0 + web_search: + num_requests: 0 + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + top_logprobs: 0 + top_p: 0.98 + truncation: disabled + usage: + input_tokens: 192 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 11 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 203 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-streaming.yaml b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-streaming.yaml new file mode 100644 index 00000000..0e8dc151 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-streaming.yaml @@ -0,0 +1,323 @@ +turns: +- filename: t1 + request: + body: + input: You must call the agentic_raw_echo custom tool exactly once with exactly + CUSTOM_CASSETTE_OK as its raw text input. + max_output_tokens: 2048 + model: gpt-5.6 + store: true + stream: true + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","object":"response","created_at":1785757991,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","object":"response","created_at":1785757991,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","type":"custom_tool_call","status":"in_progress","call_id":"call_ViMQXZ8jjdVl9ocRHVLCkWED","input":"","name":"agentic_raw_echo"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"CUSTOM","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"iKj60AjELI","output_index":0,"sequence_number":3} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_C","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"lpLfLxmONimOdW","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"AS","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"Keke7bvg4ujy01","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"SET","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"IoXJdu7Had2QM","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"TE","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"Ei8acgZkyX6jB0","output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.delta + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_OK","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"eHVq7wVYgPe6Q","output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.custom_tool_call_input.done + + ' + - 'data: {"type":"response.custom_tool_call_input.done","input":"CUSTOM_CASSETTE_OK","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","type":"custom_tool_call","status":"completed","call_id":"call_ViMQXZ8jjdVl9ocRHVLCkWED","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo"},"output_index":0,"sequence_number":10} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","object":"response","created_at":1785757991,"status":"completed","background":false,"completed_at":1785757992,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","type":"custom_tool_call","status":"completed","call_id":"call_ViMQXZ8jjdVl9ocRHVLCkWED","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":123,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":19,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":142},"user":null,"metadata":{}},"sequence_number":11} + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_ViMQXZ8jjdVl9ocRHVLCkWED + output: CUSTOM_CASSETTE_OUTPUT_OK + type: custom_tool_call_output + - content: Use the custom tool output provided above. Do not call any tool again. + Reply with exactly CUSTOM_CASSETTE_OUTPUT_OK. + role: user + type: message + max_output_tokens: 2048 + model: gpt-5.6 + previous_response_id: resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090 + store: true + stream: true + tools: + - description: Emit the requested cassette token as raw text. + format: + definition: 'start: "CUSTOM_CASSETTE_OK"' + syntax: lark + type: grammar + name: agentic_raw_echo + type: custom + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0e5f0bf89f3666ec006a70812918c4819aad7cc5d509d9a3b7","object":"response","created_at":1785757993,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0e5f0bf89f3666ec006a70812918c4819aad7cc5d509d9a3b7","object":"response","created_at":1785757993,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"CUSTOM","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"eg5PAdTr2c","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_C","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"71vxzOutU5cxqu","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"AS","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"scPGQYc6HlN5o8","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"SET","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"M22kypdz9Oamg","output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"TE","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"3WSJWoMJGEBiPQ","output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OUTPUT","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"eSDIfwy0i","output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"WfKxw8RyqRoqG","output_index":0,"sequence_number":10} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"output_index":0,"sequence_number":11,"text":"CUSTOM_CASSETTE_OUTPUT_OK"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"CUSTOM_CASSETTE_OUTPUT_OK"},"sequence_number":12} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"CUSTOM_CASSETTE_OUTPUT_OK"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":13} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0e5f0bf89f3666ec006a70812918c4819aad7cc5d509d9a3b7","object":"response","created_at":1785757993,"status":"completed","background":false,"completed_at":1785757993,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":2048,"max_tool_calls":null,"model":"gpt-5.6-sol","moderation":null,"output":[{"id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"CUSTOM_CASSETTE_OUTPUT_OK"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090","prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"all_turns","effort":"medium","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"custom","description":"Emit + the requested cassette token as raw text.","format":{"type":"grammar","definition":"start: + \"CUSTOM_CASSETTE_OK\"","syntax":"lark"},"name":"agentic_raw_echo"}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":192,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":11,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":203},"user":null,"metadata":{}},"sequence_number":14} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/custom_tool.json b/crates/agentic-server-core/tests/cassettes/custom_tool/custom_tool.json new file mode 100644 index 00000000..c78aec09 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/custom_tool.json @@ -0,0 +1,12 @@ +[ + { + "type": "custom", + "name": "agentic_raw_echo", + "description": "Emit the requested cassette token as raw text.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: \"CUSTOM_CASSETTE_OK\"" + } + } +] diff --git a/crates/agentic-server-core/tests/cassettes/custom_tool/tool_outputs.json b/crates/agentic-server-core/tests/cassettes/custom_tool/tool_outputs.json new file mode 100644 index 00000000..7f21473a --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/custom_tool/tool_outputs.json @@ -0,0 +1,3 @@ +{ + "agentic_raw_echo": "CUSTOM_CASSETTE_OUTPUT_OK" +} diff --git a/crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh b/crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh new file mode 100755 index 00000000..0b0feca9 --- /dev/null +++ b/crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Records the same client-executed custom-tool flow against vLLM, the gateway, +# and OpenAI. +# +# Each provider records a two-turn streaming and non-streaming cassette: +# 1. the model emits raw text in a custom_tool_call +# 2. the recorder submits custom_tool_call_output and captures the final reply +# +# Usage from the repository root: +# OPENAI_API_KEY=sk-... \ +# bash crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh +# CUSTOM_TOOL_RECORD_SET=gateway \ +# bash crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh +# CUSTOM_TOOL_RECORD_SET=vllm VLLM_URL=http://localhost:5050 \ +# bash crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh +# CUSTOM_TOOL_RECORD_SET=openai OPENAI_API_KEY=sk-... \ +# bash crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh + +set -euo pipefail + +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE_DIR="$SCRIPTS_DIR/custom_tool" +TOOLS_FILE="$BASE_DIR/custom_tool.json" +TOOL_OUTPUTS_FILE="$BASE_DIR/tool_outputs.json" +GATEWAY_URL="${GATEWAY_URL:-http://localhost:9000}" +MODEL="${MODEL:-Qwen/Qwen3.5-35B-A3B-FP8}" +MODEL_SLUG="$(echo "$MODEL" | tr '/: ' '---')" +OPENAI_MODEL="${OPENAI_MODEL:-gpt-5.6}" +OPENAI_MODEL_SLUG="$(echo "$OPENAI_MODEL" | tr '/: ' '---')" +CUSTOM_TOOL_RECORD_SET="${CUSTOM_TOOL_RECORD_SET:-all}" +FIRST_PROMPT='You must call the agentic_raw_echo custom tool exactly once with exactly CUSTOM_CASSETTE_OK as its raw text input.' +SECOND_PROMPT='Use the custom tool output provided above. Do not call any tool again. Reply with exactly CUSTOM_CASSETTE_OUTPUT_OK.' + +green() { printf '\033[32m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } + +record_scenario() { + local endpoint_flag="$1" + local endpoint="$2" + local model="$3" + local output="$4" + local stream_flag="$5" + local temporary_output + + temporary_output="$(mktemp "$BASE_DIR/.custom-tool-cassette.XXXXXX")" + + if ! printf '%s\n%s\n' "$FIRST_PROMPT" "$SECOND_PROMPT" \ + | python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 2 \ + "$stream_flag" \ + --model "$model" \ + "$endpoint_flag" "$endpoint" \ + --tools "$TOOLS_FILE" \ + --tool-outputs "$TOOL_OUTPUTS_FILE" \ + --max-output-tokens 2048 \ + --output "$temporary_output" + then + rm -f -- "$temporary_output" + return 1 + fi + + mv -- "$temporary_output" "$output" + green "✓ custom-tool cassette recorded -> $output" +} + +record_provider_suite() { + local provider="$1" + local endpoint_flag="$2" + local endpoint="$3" + local model="$4" + local output_prefix="$5" + + bold "$provider custom-tool cassettes" + bold "Endpoint: $endpoint" + bold "Model: $model" + + bold "$provider streaming custom-tool flow" + record_scenario \ + "$endpoint_flag" "$endpoint" "$model" \ + "$BASE_DIR/${output_prefix}-streaming.yaml" \ + --stream + + bold "$provider non-streaming custom-tool flow" + record_scenario \ + "$endpoint_flag" "$endpoint" "$model" \ + "$BASE_DIR/${output_prefix}-nonstreaming.yaml" \ + --no-stream +} + +case "$CUSTOM_TOOL_RECORD_SET" in + gateway|vllm|openai|all) ;; + *) + echo "ERROR: CUSTOM_TOOL_RECORD_SET must be gateway, vllm, openai, or all" >&2 + exit 1 + ;; +esac + +for required_file in "$TOOLS_FILE" "$TOOL_OUTPUTS_FILE"; do + if [[ ! -f "$required_file" ]]; then + echo "ERROR: required custom-tool fixture does not exist: $required_file" >&2 + exit 1 + fi +done + +if [[ "$CUSTOM_TOOL_RECORD_SET" == "openai" || "$CUSTOM_TOOL_RECORD_SET" == "all" ]]; then + if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "ERROR: OPENAI_API_KEY must be set for CUSTOM_TOOL_RECORD_SET=$CUSTOM_TOOL_RECORD_SET" >&2 + exit 1 + fi +fi + +if [[ "$CUSTOM_TOOL_RECORD_SET" == "openai" || "$CUSTOM_TOOL_RECORD_SET" == "all" ]]; then + record_provider_suite \ + OpenAI \ + --openai https://api.openai.com \ + "$OPENAI_MODEL" \ + "custom-tool-openai-reference-${OPENAI_MODEL_SLUG}" +fi + +if [[ "$CUSTOM_TOOL_RECORD_SET" == "gateway" || "$CUSTOM_TOOL_RECORD_SET" == "all" ]]; then + record_provider_suite \ + Gateway \ + --gateway "$GATEWAY_URL" \ + "$MODEL" \ + "custom-tool-gateway-${MODEL_SLUG}" +fi diff --git a/crates/agentic-server-core/tests/custom_tool_test.rs b/crates/agentic-server-core/tests/custom_tool_test.rs new file mode 100644 index 00000000..a1b8ca83 --- /dev/null +++ b/crates/agentic-server-core/tests/custom_tool_test.rs @@ -0,0 +1,282 @@ +use std::collections::HashSet; + +use agentic_core::executor::accumulator::ResponseAccumulator; +use agentic_core::tool::{GatewayExecutors, ToolRegistry, ToolType}; +use agentic_core::types::event::MessageStatus; +use agentic_core::types::io::{CustomToolCall, OutputItem}; +use agentic_core::types::tools::ResponsesTool; +use serde_json::{Value, json}; + +mod support; + +const MODEL: &str = "Qwen/Qwen3.5-35B-A3B-FP8"; +const CUSTOM_TOOL_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/custom_tool"); +const GATEWAY_MODEL_SLUG: &str = "Qwen-Qwen3.5-35B-A3B-FP8"; +const OPENAI_MODEL_SLUG: &str = "gpt-5.6"; + +fn load_custom_tool_cassette(filename: &str) -> support::Cassette { + support::load_cassette(&format!("{CUSTOM_TOOL_DIR}/{filename}")) +} + +fn load_pair(streaming: bool) -> (support::Cassette, support::Cassette) { + let mode = if streaming { "streaming" } else { "nonstreaming" }; + let openai = load_custom_tool_cassette(&format!("custom-tool-openai-reference-{OPENAI_MODEL_SLUG}-{mode}.yaml")); + let gateway = load_custom_tool_cassette(&format!("custom-tool-gateway-{GATEWAY_MODEL_SLUG}-{mode}.yaml")); + (openai, gateway) +} + +fn streaming_events(turn: &support::Turn) -> Vec { + turn.response + .sse + .as_ref() + .expect("streaming SSE response") + .iter() + .flat_map(|entry| entry.lines()) + .filter_map(|line| line.strip_prefix("data: ")) + .filter(|data| *data != "[DONE]") + .filter_map(|data| serde_json::from_str(data).ok()) + .collect() +} + +fn response_output(turn: &support::Turn) -> Vec { + let response = if let Some(body) = &turn.response.body { + body.clone() + } else { + streaming_events(turn) + .into_iter() + .rev() + .filter_map(|event| event.get("response").cloned()) + .find(|response| response["status"] == "completed" && response["output"].is_array()) + .expect("completed streaming response payload") + }; + let accumulator = ResponseAccumulator::from_json(&response.to_string(), None).expect("valid completed response"); + let payload = accumulator.finalize(MODEL, None, None); + assert_eq!(payload.status, "completed"); + payload.output +} + +fn custom_call(output: &[OutputItem]) -> &CustomToolCall { + assert!( + !output.iter().any(|item| matches!(item, OutputItem::FunctionCall(_))), + "normalized function calls must not leak through the public response" + ); + let calls = output + .iter() + .filter_map(|item| match item { + OutputItem::CustomToolCall(call) => Some(call), + _ => None, + }) + .collect::>(); + assert_eq!(calls.len(), 1, "expected exactly one custom tool call"); + calls[0] +} + +fn output_text(output: &[OutputItem]) -> String { + output + .iter() + .filter_map(|item| match item { + OutputItem::Message(message) => Some( + message + .content + .iter() + .map(|content| content.text.as_str()) + .collect::(), + ), + _ => None, + }) + .collect::() + .trim() + .to_owned() +} + +fn assert_request_contract(cassette: &support::Cassette, streaming: bool) { + assert_eq!(cassette.turns.len(), 2); + + for turn in &cassette.turns { + assert_eq!(turn.request.path, "/v1/responses"); + assert_eq!(turn.request.body.stream, streaming); + assert_eq!(turn.request.body.tools.len(), 1); + let tool = &turn.request.body.tools[0]; + assert_eq!(tool["type"], "custom"); + assert_eq!(tool["name"], "agentic_raw_echo"); + assert_eq!(tool["format"]["type"], "grammar"); + assert_eq!(tool["format"]["syntax"], "lark"); + assert_eq!(tool["format"]["definition"], r#"start: "CUSTOM_CASSETTE_OK""#); + } + + let continuation = cassette.turns[1] + .request + .body + .input + .as_array() + .expect("continuation input array"); + assert_eq!(continuation[0]["type"], "custom_tool_call_output"); + assert_eq!(continuation[0]["output"], "CUSTOM_CASSETTE_OUTPUT_OK"); + assert_eq!(continuation[1]["type"], "message"); + assert!( + continuation[1]["content"] + .as_str() + .is_some_and(|content| content.contains("CUSTOM_CASSETTE_OUTPUT_OK")) + ); +} + +fn assert_public_calls_match(openai: &support::Cassette, gateway: &support::Cassette) { + let expected_output = response_output(&openai.turns[0]); + let actual_output = response_output(&gateway.turns[0]); + let expected = custom_call(&expected_output); + let actual = custom_call(&actual_output); + + for call in [expected, actual] { + assert!(call.id.starts_with("ctc_")); + assert!(!call.call_id.is_empty()); + assert_eq!(call.name, "agentic_raw_echo"); + assert_eq!(call.input, "CUSTOM_CASSETTE_OK"); + assert_eq!(call.status, Some(MessageStatus::Completed)); + } + assert_eq!(actual.name, expected.name); + assert_eq!(actual.input, expected.input); + assert_eq!(actual.status, expected.status); + + assert_eq!( + gateway.turns[1].request.body.input[0]["call_id"].as_str(), + Some(actual.call_id.as_str()) + ); + assert_eq!( + openai.turns[1].request.body.input[0]["call_id"].as_str(), + Some(expected.call_id.as_str()) + ); + assert_eq!( + output_text(&response_output(&gateway.turns[1])), + output_text(&response_output(&openai.turns[1])) + ); +} + +fn normalized_custom_lifecycle(events: &[Value]) -> Value { + let added = events + .iter() + .find(|event| event["type"] == "response.output_item.added" && event["item"]["type"] == "custom_tool_call") + .expect("custom output item added"); + let item_id = added["item"]["id"].as_str().expect("custom item ID"); + let mut lifecycle = Vec::new(); + let mut deltas = Vec::new(); + let mut input = String::new(); + let mut lifecycle_item_ids = HashSet::new(); + let mut done_item = None; + + for event in events { + let event_type = event["type"].as_str().unwrap_or_default(); + let event_item_id = event["item_id"].as_str().or_else(|| event["item"]["id"].as_str()); + if event_item_id != Some(item_id) { + continue; + } + lifecycle_item_ids.insert(event_item_id.unwrap().to_owned()); + match event_type { + "response.output_item.added" if event["item"]["type"] == "custom_tool_call" => { + lifecycle.push(event_type); + } + "response.custom_tool_call_input.delta" => { + let delta = event["delta"].as_str().unwrap_or_default(); + deltas.push(delta); + input.push_str(delta); + if lifecycle.last().copied() != Some(event_type) { + lifecycle.push(event_type); + } + } + "response.custom_tool_call_input.done" => lifecycle.push(event_type), + "response.output_item.done" if event["item"]["type"] == "custom_tool_call" => { + lifecycle.push(event_type); + done_item = Some(&event["item"]); + } + _ => {} + } + } + + let done_item = done_item.expect("custom output item done"); + assert_eq!(lifecycle_item_ids.len(), 1, "one public ID must span the lifecycle"); + json!({ + "lifecycle": lifecycle, + "deltas": deltas, + "delta_input": input, + "added": { + "type": added["item"]["type"], + "name": added["item"]["name"], + "status": added["item"]["status"], + "input": added["item"]["input"], + }, + "done": { + "type": done_item["type"], + "name": done_item["name"], + "status": done_item["status"], + "input": done_item["input"], + } + }) +} + +fn assert_contiguous_sequence_numbers(events: &[Value]) { + let sequence_numbers = events + .iter() + .filter_map(|event| event["sequence_number"].as_u64()) + .collect::>(); + assert!( + sequence_numbers.windows(2).all(|pair| pair[1] == pair[0] + 1), + "stream sequence numbers must be contiguous: {sequence_numbers:?}" + ); +} + +#[tokio::test] +async fn custom_tool_type_normalizes_for_the_model_but_remains_client_owned() { + let mut tools = vec![ + serde_json::from_value::(serde_json::json!({ + "type": "custom", + "name": "agentic_raw_echo", + "description": "Emit raw text.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: \"CUSTOM_CASSETTE_OK\"" + } + })) + .expect("custom declaration"), + ]; + let registry = ToolRegistry::build_with_handlers(&mut tools, &mut GatewayExecutors::default()) + .await + .expect("custom registry"); + let entry = registry.lookup("agentic_raw_echo").expect("custom entry"); + assert_eq!(entry.tool_type, ToolType::Custom); + assert!(!entry.tool_type.is_gateway_owned()); + assert!(entry.handler.is_none()); + + let normalized = tools[0].to_function_tools(); + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].type_, "function"); + assert_eq!(normalized[0].name, "agentic_raw_echo"); + assert_eq!( + normalized[0].parameters.as_ref().unwrap()["properties"]["input"]["type"], + "string" + ); +} + +#[test] +fn streaming_custom_tool_contract_matches_openai() { + let (openai, gateway) = load_pair(true); + assert_request_contract(&openai, true); + assert_request_contract(&gateway, true); + assert_public_calls_match(&openai, &gateway); + + let expected_events = streaming_events(&openai.turns[0]); + let actual_events = streaming_events(&gateway.turns[0]); + assert_contiguous_sequence_numbers(&expected_events); + assert_contiguous_sequence_numbers(&actual_events); + assert_eq!( + normalized_custom_lifecycle(&actual_events), + normalized_custom_lifecycle(&expected_events) + ); +} + +#[test] +fn nonstreaming_custom_tool_contract_matches_openai() { + let (openai, gateway) = load_pair(false); + assert_request_contract(&openai, false); + assert_request_contract(&gateway, false); + assert_public_calls_match(&openai, &gateway); +} diff --git a/crates/agentic-server-core/tests/stateful_responses_integration.rs b/crates/agentic-server-core/tests/stateful_responses_integration.rs index 560716c7..ee05538e 100644 --- a/crates/agentic-server-core/tests/stateful_responses_integration.rs +++ b/crates/agentic-server-core/tests/stateful_responses_integration.rs @@ -433,7 +433,7 @@ async fn test_previous_response_id_rehydrates_function_call_before_tool_output() let mut second = make_request("ignored", true, false, Some(p1.id), None); second.input = ResponsesInput::Items(vec![InputItem::FunctionCallOutput(FunctionToolResultMessage { call_id: "call_1".to_string(), - output: "{\"stdout\":\"/workspace\"}".to_string(), + output: "{\"stdout\":\"/workspace\"}".into(), })]); let _p2 = unwrap_blocking( execute(second, Arc::clone(&fixture.exec_ctx)) @@ -786,7 +786,7 @@ fn upstream_mcp_fixture_call(id: &str, call_id: &str, name: &str, arguments: &st fn tool_output(call_id: &str, output: &str) -> InputItem { InputItem::FunctionCallOutput(FunctionToolResultMessage { call_id: call_id.to_string(), - output: output.to_string(), + output: output.into(), }) } diff --git a/crates/agentic-server-core/tests/tool_normalization_test.rs b/crates/agentic-server-core/tests/tool_normalization_test.rs index d108692f..55c5f990 100644 --- a/crates/agentic-server-core/tests/tool_normalization_test.rs +++ b/crates/agentic-server-core/tests/tool_normalization_test.rs @@ -314,7 +314,7 @@ fn codex_request_payloads_parse_all_recorded_shapes() { } #[tokio::test] -async fn codex_custom_cassettes_preserve_native_upstream_shape_and_client_ownership() { +async fn codex_custom_cassettes_normalize_upstream_and_preserve_client_ownership() { for filename in CODEX_CUSTOM_CASSETTES { let cassette = load_codex_cassette(filename); assert_eq!(cassette.turns.len(), 2, "{filename} should have two turns"); @@ -340,7 +340,9 @@ async fn codex_custom_cassettes_preserve_native_upstream_shape_and_client_owners .await .unwrap_or_else(|err| panic!("{filename} turn {i}: registry failed: {err}")); assert!( - registry.lookup("agentic_raw_echo").is_none(), + registry + .lookup("agentic_raw_echo") + .is_some_and(|entry| entry.tool_type == ToolType::Custom && !entry.tool_type.is_gateway_owned()), "{filename} turn {i}: custom tool must remain client-owned" ); @@ -351,15 +353,17 @@ async fn codex_custom_cassettes_preserve_native_upstream_shape_and_client_owners .unwrap_or_else(|| panic!("{filename} turn {i}: upstream request should contain tools")); assert!( upstream_tools.iter().any(|tool| { - tool.get("type").and_then(Value::as_str) == Some("custom") + tool.get("type").and_then(Value::as_str) == Some("function") && tool.get("name").and_then(Value::as_str) == Some("agentic_raw_echo") && tool - .get("format") - .and_then(|format| format.get("definition")) + .get("parameters") + .and_then(|parameters| parameters.get("properties")) + .and_then(|properties| properties.get("input")) + .and_then(|input| input.get("type")) .and_then(Value::as_str) - == Some("start: \"CUSTOM_CASSETTE_OK\"") + == Some("string") }), - "{filename} turn {i}: custom declaration must be forwarded natively" + "{filename} turn {i}: custom declaration must normalize to a function" ); } } diff --git a/crates/agentic-server/tests/responses_websocket_test.rs b/crates/agentic-server/tests/responses_websocket_test.rs index 02c8e57e..2e3349d8 100644 --- a/crates/agentic-server/tests/responses_websocket_test.rs +++ b/crates/agentic-server/tests/responses_websocket_test.rs @@ -507,39 +507,40 @@ fn sse_custom_tool_call_response() -> String { "sequence_number": 1, "output_index": 0, "item": { - "id": "ctc_upstream_1", - "type": "custom_tool_call", + "id": "fc_upstream_1", + "type": "function_call", "status": "in_progress", "name": "apply_patch", "call_id": "call_custom_1", - "input": "" + "arguments": "" } }); let delta = json!({ - "type": "response.custom_tool_call_input.delta", + "type": "response.function_call_arguments.delta", "sequence_number": 2, "output_index": 0, - "item_id": "ctc_upstream_1", - "delta": "*** Begin Patch\n*** End Patch" + "item_id": "fc_upstream_1", + "delta": "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" }); let input_done = json!({ - "type": "response.custom_tool_call_input.done", + "type": "response.function_call_arguments.done", "sequence_number": 3, "output_index": 0, - "item_id": "ctc_upstream_1", - "input": "*** Begin Patch\n*** End Patch" + "item_id": "fc_upstream_1", + "name": "apply_patch", + "arguments": "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" }); let item_done = json!({ "type": "response.output_item.done", "sequence_number": 4, "output_index": 0, "item": { - "id": "ctc_upstream_1", - "type": "custom_tool_call", + "id": "fc_upstream_1", + "type": "function_call", "status": "completed", "name": "apply_patch", "call_id": "call_custom_1", - "input": "*** Begin Patch\n*** End Patch" + "arguments": "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" } }); let completed = json!({ @@ -653,8 +654,12 @@ async fn test_websocket_generate_false_prewarm_persists_context_without_inferenc assert_eq!(requests[0]["instructions"], "Follow the warmup rules."); assert_eq!(requests[0]["input"][0]["content"], "warmup prefix"); assert_eq!(requests[0]["input"][1]["content"], "first turn"); - assert_eq!(requests[0]["tools"][0]["type"], "custom"); + assert_eq!(requests[0]["tools"][0]["type"], "function"); assert_eq!(requests[0]["tools"][0]["name"], "apply_patch"); + assert_eq!( + requests[0]["tools"][0]["parameters"]["properties"]["input"]["type"], + "string" + ); assert!(requests[0].get("generate").is_none()); } @@ -1102,18 +1107,21 @@ async fn test_websocket_custom_tool_round_trip_and_continuation() { let requests = mock.request_bodies().await; assert_eq!(requests.len(), 2); - assert_eq!(requests[0]["tools"][0]["type"], "custom"); - assert_eq!(requests[0]["tools"][0]["format"]["syntax"], "lark"); + assert_eq!(requests[0]["tools"][0]["type"], "function"); + assert_eq!( + requests[0]["tools"][0]["parameters"]["properties"]["input"]["type"], + "string" + ); let continuation = requests[1]["input"].as_array().unwrap(); assert!(continuation.iter().any(|item| { - item["type"] == "custom_tool_call" + item["type"] == "function_call" && item["call_id"] == "call_custom_1" - && item["input"] == "*** Begin Patch\n*** End Patch" + && item["arguments"] == "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" })); assert!(continuation.iter().any(|item| { - item["type"] == "custom_tool_call_output" && item["call_id"] == "call_custom_1" && item["output"] == "Done!" + item["type"] == "function_call_output" && item["call_id"] == "call_custom_1" && item["output"] == "Done!" })); - assert_eq!(requests[1]["tools"][0]["type"], "custom"); + assert_eq!(requests[1]["tools"][0]["type"], "function"); } #[tokio::test]