From b7f806749f3f2474dabcdde13308d28ca4209f3f Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 31 Jul 2026 12:26:29 +0800 Subject: [PATCH 01/11] refactor custom tool call accumulation Signed-off-by: maral --- .../src/executor/accumulator.rs | 51 ++++--------------- .../src/types/io/output.rs | 35 +++++++++---- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index b0e0869b..b3f3c5ef 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -289,16 +289,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); } @@ -417,31 +407,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, @@ -453,14 +418,20 @@ impl ResponseAccumulator { else { return; }; - if *item_type == SSEItemType::McpCall { - if let Some(InFlight::McpCall { item }) = self.in_flight.get_mut(item_id).map(|entry| &mut entry.item) { + match (item_type, self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)) { + (SSEItemType::CustomToolCall, Some(InFlight::CustomToolCall { item, input })) => { + item.apply_done(payload, input); + return; + } + (SSEItemType::McpCall, Some(InFlight::McpCall { item })) => { item.apply_done(payload, &mut String::new()); return; } + _ => {} } - if let Some(output_item @ (OutputItem::WebSearchCall(_) | OutputItem::McpCall(_))) = - deserialize_from_value_opt::(raw_item.clone()) + if let Some( + output_item @ (OutputItem::CustomToolCall(_) | OutputItem::WebSearchCall(_) | OutputItem::McpCall(_)), + ) = deserialize_from_value_opt::(raw_item.clone()) { self.completed.push((*output_index, output_item)); } @@ -1425,7 +1396,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(), diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index c184870d..ff75b40e 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -500,15 +500,32 @@ impl ApplyDone for FunctionToolCall { 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; + } + _ => {} + } } } From 726c7367c707e17b70c13e070b453e969fe2d433 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 31 Jul 2026 19:15:24 +0800 Subject: [PATCH 02/11] fix custom tool normalization and SSE lifecycle Signed-off-by: maral --- .../src/executor/engine.rs | 50 +- .../src/executor/gateway.rs | 128 ++- .../src/executor/upstream.rs | 52 +- crates/agentic-server-core/src/lib.rs | 5 +- crates/agentic-server-core/src/tool/custom.rs | 183 ++++ crates/agentic-server-core/src/tool/mod.rs | 2 + .../agentic-server-core/src/tool/normalize.rs | 22 +- .../agentic-server-core/src/tool/registry.rs | 14 +- .../agentic-server-core/src/types/io/input.rs | 74 +- .../src/types/io/output.rs | 8 +- .../agentic-server-core/src/types/io/tools.rs | 28 +- crates/agentic-server-core/src/types/mod.rs | 2 +- .../src/types/request_response.rs | 119 +-- .../tests/accumulator_cassette_test.rs | 89 ++ .../tests/cassettes/README.md | 14 + ...Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml | 161 ++++ ...ay-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml | 872 ++++++++++++++++++ ...openai-reference-gpt-5.6-nonstreaming.yaml | 226 +++++ ...ol-openai-reference-gpt-5.6-streaming.yaml | 323 +++++++ .../cassettes/custom_tool/custom_tool.json | 12 + .../cassettes/custom_tool/tool_outputs.json | 3 + .../cassettes/record_custom_tool_cassettes.sh | 127 +++ .../tests/custom_tool_test.rs | 278 ++++++ .../tests/tool_normalization_test.rs | 18 +- .../tests/responses_websocket_test.rs | 46 +- 25 files changed, 2677 insertions(+), 179 deletions(-) create mode 100644 crates/agentic-server-core/src/tool/custom.rs create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-gateway-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-nonstreaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/custom-tool-openai-reference-gpt-5.6-streaming.yaml create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/custom_tool.json create mode 100644 crates/agentic-server-core/tests/cassettes/custom_tool/tool_outputs.json create mode 100755 crates/agentic-server-core/tests/cassettes/record_custom_tool_cassettes.sh create mode 100644 crates/agentic-server-core/tests/custom_tool_test.rs diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index 3e028ac9..735a7cbd 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -15,9 +15,9 @@ use tracing::{debug, warn}; use super::compaction::maybe_compact_context; 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, + append_tool_outputs, classify_round, emit_client_call_events, emit_gateway_completed_events, + emit_gateway_start_events, execute_and_emit_output_calls, execute_output_calls, gateway_event_plans, + has_client_owned_calls, is_client_custom_call, is_gateway_owned_call, public_output_items, }; use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk}; use crate::events::EventFrame; @@ -215,7 +215,10 @@ async fn execute_and_emit_round_output_calls( ctx: &RequestContext, stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult> { - match (deferred_events.is_empty(), stream) { + let has_client_custom_call = output_items + .iter() + .any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry))); + match (deferred_events.is_empty() && !has_client_custom_call, stream) { (true, stream) => execute_and_emit_output_calls(output_items, registry, output_offset, stream).await, (false, Some((stream_accumulator, stream_sender))) => { execute_and_emit_ordered_output_calls( @@ -262,13 +265,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)?; @@ -291,6 +302,23 @@ async fn execute_and_emit_ordered_output_calls( output_offset, )?; gateway_index += 1; + } else if let OutputItem::FunctionCall(call) = item + && is_client_custom_call(call, registry) + { + emit_client_call_events( + call, + output_offset.saturating_add(index), + stream_accumulator, + stream_sender, + )?; + emit_deferred_stream_events( + std::mem::take(&mut events_by_output[index]), + ctx, + registry, + stream_accumulator, + stream_sender, + output_offset, + )?; } else { emit_deferred_stream_events( std::mem::take(&mut events_by_output[index]), diff --git a/crates/agentic-server-core/src/executor/gateway.rs b/crates/agentic-server-core/src/executor/gateway.rs index 7e94e83a..34a328f0 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, @@ -276,6 +294,58 @@ fn output_item_value(item: &OutputItem) -> ExecutorResult { serde_json::to_value(item).map_err(ExecutorError::JsonError) } +pub(super) fn emit_client_call_events( + call: &FunctionToolCall, + output_index: usize, + stream_accumulator: &mut GatewayStreamAccumulator, + stream_sender: &tokio::sync::mpsc::UnboundedSender, +) -> ExecutorResult<()> { + let output_index = u32::try_from(output_index).unwrap_or(u32::MAX); + let started_output = crate::tool::CustomHandler::started_output_item(call); + let completed_output = crate::tool::CustomHandler::output_item(call); + let OutputItem::CustomToolCall(custom_call) = &completed_output else { + return Ok(()); + }; + + let mut added_event = synthetic_event( + SSEEventType::OutputItemAdded, + [ + ("output_index".to_owned(), serde_json::json!(output_index)), + ("item".to_owned(), output_item_value(&started_output)?), + ], + )?; + emit_gateway_event(&mut added_event, stream_accumulator, stream_sender)?; + + let mut input_delta_event = synthetic_event( + SSEEventType::CustomToolCallInputDelta, + [ + ("delta".to_owned(), serde_json::json!(custom_call.input)), + ("item_id".to_owned(), serde_json::json!(custom_call.id)), + ("output_index".to_owned(), serde_json::json!(output_index)), + ], + )?; + emit_gateway_event(&mut input_delta_event, stream_accumulator, stream_sender)?; + + let mut input_done_event = synthetic_event( + SSEEventType::CustomToolCallInputDone, + [ + ("input".to_owned(), serde_json::json!(custom_call.input)), + ("item_id".to_owned(), serde_json::json!(custom_call.id)), + ("output_index".to_owned(), serde_json::json!(output_index)), + ], + )?; + emit_gateway_event(&mut input_done_event, stream_accumulator, stream_sender)?; + + let mut done_event = synthetic_event( + SSEEventType::OutputItemDone, + [ + ("output_index".to_owned(), serde_json::json!(output_index)), + ("item".to_owned(), output_item_value(&completed_output)?), + ], + )?; + emit_gateway_event(&mut done_event, stream_accumulator, stream_sender) +} + pub(super) fn emit_gateway_start_events( plans: &[GatewayCallEventPlan], stream_accumulator: &mut GatewayStreamAccumulator, @@ -477,7 +547,8 @@ pub(super) fn append_gateway_calls_to_new_input( #[cfg(test)] mod tests { - use super::{GatewayCallResult, LoopDecision, classify_round}; + use super::{GatewayCallResult, LoopDecision, classify_round, emit_client_call_events}; + use crate::executor::gateway_accumulator::GatewayStreamAccumulator; use crate::types::io::output::FunctionToolCall; use crate::types::io::{InputItem, McpCallStatus}; use tokio::sync::mpsc; @@ -544,6 +615,59 @@ mod tests { assert!(matches!(decision, LoopDecision::Done)); } + #[test] + fn client_custom_events_follow_openai_lifecycle() { + let call = FunctionToolCall { + id: "fc_custom".to_owned(), + call_id: "call_custom".to_owned(), + name: "raw_echo".to_owned(), + arguments: r#"{"input":"CUSTOM_CASSETTE_OK"}"#.to_owned(), + status: crate::types::event::MessageStatus::Completed, + namespace: None, + }; + let (sender, mut receiver) = mpsc::unbounded_channel(); + let mut accumulator = GatewayStreamAccumulator::new(); + + emit_client_call_events(&call, 2, &mut accumulator, &sender).expect("custom events"); + drop(sender); + + let mut events = Vec::new(); + while let Ok(event) = receiver.try_recv() { + let data = event + .content + .strip_prefix("data: ") + .and_then(|data| data.strip_suffix("\n\n")) + .expect("SSE data frame"); + events.push(serde_json::from_str::(data).expect("event JSON")); + } + + assert_eq!( + events + .iter() + .map(|event| event["type"].as_str().unwrap()) + .collect::>(), + [ + "response.output_item.added", + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.done", + "response.output_item.done", + ] + ); + for (sequence_number, event) in events.iter().enumerate() { + assert_eq!(event["sequence_number"], sequence_number); + assert_eq!(event["output_index"], 2); + } + assert_eq!(events[0]["item"]["type"], "custom_tool_call"); + assert_eq!(events[0]["item"]["id"], "ctc_custom"); + assert_eq!(events[0]["item"]["status"], "in_progress"); + assert_eq!(events[1]["delta"], "CUSTOM_CASSETTE_OK"); + assert_eq!(events[1]["item_id"], "ctc_custom"); + assert_eq!(events[2]["input"], "CUSTOM_CASSETTE_OK"); + assert_eq!(events[3]["item"]["type"], "custom_tool_call"); + assert_eq!(events[3]["item"]["status"], "completed"); + assert_eq!(events[3]["item"]["input"], "CUSTOM_CASSETTE_OK"); + } + use std::pin::Pin; use std::sync::Arc; diff --git a/crates/agentic-server-core/src/executor/upstream.rs b/crates/agentic-server-core/src/executor/upstream.rs index 14eea041..8ccde7c4 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -10,7 +10,7 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; 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}; -use crate::tool::ToolRegistry; +use crate::tool::{ToolRegistry, ToolType}; use crate::types::request_response::ResponsePayload; use crate::utils::common::serialize_to_string; @@ -72,7 +72,7 @@ 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 hidden_public_call_item_ids = HashSet::new(); let mut pending_unnamed_function_events = HashMap::>::new(); let mut defer_from_output_index = None; let mut deferred_events = Vec::new(); @@ -91,7 +91,7 @@ pub(super) async fn fetch_stream_payload( emit_upstream_stream_event( frame, &mut emit_ctx, - &mut hidden_gateway_item_ids, + &mut hidden_public_call_item_ids, &mut pending_unnamed_function_events, &mut defer_from_output_index, &mut deferred_events, @@ -143,17 +143,17 @@ 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, + hidden_public_call_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); + defer_after_public_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, + hidden_public_call_item_ids, ) || is_terminal_response_event(frame.event_type) { drop_pending_function_events(&frame.payload, pending_unnamed_function_events); @@ -162,7 +162,7 @@ fn emit_upstream_stream_event( let Some(frame) = defer_or_flush_function_event( frame, emit_ctx, - hidden_gateway_item_ids, + hidden_public_call_item_ids, pending_unnamed_function_events, defer_from_output_index, deferred_events, @@ -195,7 +195,7 @@ pub(super) fn emit_deferred_stream_events( Ok(()) } -fn defer_after_gateway_call(frame: &EventFrame, registry: &ToolRegistry, defer_from_output_index: &mut Option) { +fn defer_after_public_call(frame: &EventFrame, registry: &ToolRegistry, defer_from_output_index: &mut Option) { let EventPayload::OutputItemAdded { item_type: SSEItemType::FunctionCall, name: Some(name), @@ -204,12 +204,12 @@ fn defer_after_gateway_call(frame: &EventFrame, registry: &ToolRegistry, defer_f else { return; }; - if registry.is_gateway_owned_name(name) { - record_first_hidden_gateway_output_index(frame, defer_from_output_index); + if uses_public_call_shape(registry, name) { + record_first_hidden_public_output_index(frame, defer_from_output_index); } } -fn record_first_hidden_gateway_output_index(frame: &EventFrame, defer_from_output_index: &mut Option) { +fn record_first_hidden_public_output_index(frame: &EventFrame, defer_from_output_index: &mut Option) { let Some(output_index) = frame.wire.output_index else { return; }; @@ -252,7 +252,7 @@ fn emit_or_defer_stream_frame( fn defer_or_flush_function_event( frame: EventFrame, emit_ctx: &mut StreamEmitContext<'_>, - hidden_gateway_item_ids: &mut HashSet, + hidden_public_call_item_ids: &mut HashSet, pending_unnamed_function_events: &mut HashMap>, defer_from_output_index: &mut Option, deferred_events: &mut Vec, @@ -276,9 +276,9 @@ fn defer_or_flush_function_event( 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); + if uses_public_call_shape(emit_ctx.registry, name) { + hidden_public_call_item_ids.insert(item_id.clone()); + record_first_hidden_public_output_index(&frame, defer_from_output_index); pending_unnamed_function_events.remove(item_id); return Ok(None); } @@ -300,10 +300,10 @@ fn defer_or_flush_function_event( if item .get("name") .and_then(Value::as_str) - .is_some_and(|name| emit_ctx.registry.is_gateway_owned_name(name)) + .is_some_and(|name| uses_public_call_shape(emit_ctx.registry, name)) { - hidden_gateway_item_ids.insert(item_id.clone()); - record_first_hidden_gateway_output_index(&frame, defer_from_output_index); + hidden_public_call_item_ids.insert(item_id.clone()); + record_first_hidden_public_output_index(&frame, defer_from_output_index); pending_unnamed_function_events.remove(item_id); return Ok(None); } @@ -363,7 +363,7 @@ fn should_hide_upstream_event( event_type: SSEEventType, payload: &EventPayload, registry: &ToolRegistry, - hidden_gateway_item_ids: &mut HashSet, + hidden_public_call_item_ids: &mut HashSet, ) -> bool { match (event_type, payload) { ( @@ -374,23 +374,29 @@ fn should_hide_upstream_event( name: Some(name), .. }, - ) if *item_type == SSEItemType::FunctionCall && registry.is_gateway_owned_name(name) => { - hidden_gateway_item_ids.insert(item_id.clone()); + ) if *item_type == SSEItemType::FunctionCall && uses_public_call_shape(registry, name) => { + hidden_public_call_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) => + if *item_type == SSEItemType::FunctionCall && hidden_public_call_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), + ) => hidden_public_call_item_ids.contains(item_id), _ => false, } } +fn uses_public_call_shape(registry: &ToolRegistry, name: &str) -> bool { + registry + .lookup(name) + .is_some_and(|entry| entry.tool_type == ToolType::Custom || entry.tool_type.is_gateway_owned()) +} + 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 8124ed7b..3b01c22b 100644 --- a/crates/agentic-server-core/src/lib.rs +++ b/crates/agentic-server-core/src/lib.rs @@ -26,8 +26,7 @@ pub use types::{ InputImageContent, InputItem, InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, McpCall, McpCallStatus, McpToolParam, NonEmptyToolName, OutputItem, OutputMessage, OutputTextContent, OutputTokenDetails, ReasoningOutput, ReasoningTextContent, RequestPayload, ResponsePayload, ResponseUsage, ResponsesInput, - ResponsesTool, ToolChoice, UpstreamRequest, UpstreamTool, WebSearchActionSearch, WebSearchCall, - WebSearchCallStatus, WebSearchContextSize, WebSearchFilters, WebSearchSource, WebSearchToolParam, - WebSearchUserLocation, + ResponsesTool, ToolChoice, UpstreamRequest, 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..7d058c28 --- /dev/null +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; + +use crate::types::event::MessageStatus; +use crate::types::io::{CustomToolCall, FunctionTool, FunctionToolCall, OutputItem}; +use crate::types::tools::CustomToolParam; +use crate::utils::common::serialize_to_value_or_custom_default; + +use super::{ToolEntry, ToolError, ToolHandler, ToolType}; + +/// 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 fn to_function_call(param: &CustomToolParam) -> FunctionTool { + FunctionTool { + type_: "function".to_owned(), + name: param.name.as_str().to_owned(), + description: param.description.clone(), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw input for the custom tool." + } + }, + "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), + }) + } + + #[must_use] + pub(crate) fn started_output_item(call: &FunctionToolCall) -> OutputItem { + OutputItem::CustomToolCall(CustomToolCall { + id: public_item_id(&call.id), + status: Some(MessageStatus::InProgress), + call_id: call.call_id.clone(), + name: call.name.clone(), + input: String::new(), + }) + } +} + +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(|| 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::*; + + #[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." + })) + .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"); + } +} 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..5c9c7849 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![] diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index f8241f43..4d2d66ce 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::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) } } @@ -229,7 +232,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"); @@ -422,12 +425,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 +456,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 +483,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..a350cd11 100644 --- a/crates/agentic-server-core/src/types/io/input.rs +++ b/crates/agentic-server-core/src/types/io/input.rs @@ -93,6 +93,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 { @@ -110,6 +123,15 @@ pub struct CustomToolCallOutputMessage { pub output: Value, } +impl From for FunctionToolResultMessage { + fn from(output: CustomToolCallOutputMessage) -> Self { + Self { + call_id: output.call_id, + output: custom_output_text(output.output), + } + } +} + #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub enum InputItem { @@ -121,8 +143,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 +252,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 +276,23 @@ 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()) +} + +fn custom_output_text(output: Value) -> String { + match output { + Value::String(output) => output, + output => output.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -321,4 +360,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/output.rs b/crates/agentic-server-core/src/types/io/output.rs index ff75b40e..33d00a94 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -577,7 +577,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, } } @@ -606,11 +606,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..524826de 100644 --- a/crates/agentic-server-core/src/types/io/tools.rs +++ b/crates/agentic-server-core/src/types/io/tools.rs @@ -23,9 +23,6 @@ pub enum ToolChoice { namespace: Option, name: NonEmptyToolName, }, - Custom { - name: NonEmptyToolName, - }, } impl Serialize for ToolChoice { @@ -46,12 +43,6 @@ impl Serialize for ToolChoice { map.serialize_entry("name", name.as_str())?; map.end() } - Self::Custom { name } => { - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry("type", "custom")?; - map.serialize_entry("name", name.as_str())?; - map.end() - } } } } @@ -89,7 +80,7 @@ impl<'de> Deserialize<'de> for ToolChoice { .and_then(Value::as_str) .ok_or_else(|| de::Error::missing_field("name"))?; let name = NonEmptyToolName::try_from(name).map_err(de::Error::custom)?; - return Ok(Self::Custom { name }); + return Ok(Self::Function { namespace: None, name }); } if let Some(function) = object.get("function").and_then(Value::as_object) { @@ -167,20 +158,27 @@ mod tests { } #[test] - fn custom_tool_choice_round_trips() { - let expected = serde_json::json!({ + fn custom_tool_choice_normalizes_to_function() { + 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).unwrap(); assert_eq!( choice, - ToolChoice::Custom { + ToolChoice::Function { + namespace: None, name: NonEmptyToolName::try_from("apply_patch").unwrap() } ); - assert_eq!(serde_json::to_value(choice).unwrap(), expected); + assert_eq!( + serde_json::to_value(choice).unwrap(), + serde_json::json!({ + "type": "function", + "name": "apply_patch" + }) + ); } #[test] diff --git a/crates/agentic-server-core/src/types/mod.rs b/crates/agentic-server-core/src/types/mod.rs index 18ceee2b..0ee23b56 100644 --- a/crates/agentic-server-core/src/types/mod.rs +++ b/crates/agentic-server-core/src/types/mod.rs @@ -14,7 +14,7 @@ pub use io::{ }; pub use request_response::{ CompactRequest, CompactedResponse, ContextManagement, IncompleteDetails, RequestPayload, ResponsePayload, - UpstreamRequest, UpstreamTool, + UpstreamRequest, }; pub use tools::{ CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, CustomToolParam, EmptyToolNameError, diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 9076a320..68ae6189 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,12 +49,11 @@ 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>, + pub tools: Option>, #[serde(skip_serializing_if = "is_absent_or_default_tool_choice")] pub tool_choice: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -74,44 +73,6 @@ pub struct UpstreamRequest<'a> { pub cache_salt: Option<&'a str>, } -/// A 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)] -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 // idiomatic `Option<&T>` clippy suggests does not apply here. #[allow(clippy::ref_option)] @@ -124,9 +85,8 @@ impl RequestPayload { /// /// 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 +112,8 @@ 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).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 +176,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 +246,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 +270,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 +540,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 +572,26 @@ 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(_))); + assert_eq!(tools[0].name, "read_file"); + assert_eq!(tools[1].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"); + 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] diff --git a/crates/agentic-server-core/tests/accumulator_cassette_test.rs b/crates/agentic-server-core/tests/accumulator_cassette_test.rs index a1e17b30..93989413 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"); // --- Legacy event cassette format --- @@ -88,6 +89,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) +} + /// Extracts `data: ...` lines from raw SSE entries (which may include /// `event:` lines and blank separators). fn extract_data_lines(sse_entries: &[String]) -> Vec { @@ -960,6 +965,90 @@ fn test_web_search_gateway_cassette_streaming() { assert_completed_potato_web_search(&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 59b864c8..4bbf2ce0 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 | ### Text-only (OpenAI) @@ -191,6 +192,19 @@ vllm serve Qwen/Qwen3-30B-A3B-FP8 --tool-call-parser hermes --enable-auto-tool-c VLLM_URL=http://0.0.0.0:5050 MODEL=Qwen/Qwen3-30B-A3B-FP8 bash tests/cassettes/record_tool_call_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..797ae802 --- /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,161 @@ +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: 1785495771 + error: null + id: resp_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + 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 "CUSTOM_CASSETTE_OK" as the raw text input. This is a specific + instruction that I need to follow. + + + Let me check the tool definition: + + - Name: agentic_raw_echo + + - Parameters: input (required, type: string) + + - Description: Emit the requested cassette token as raw text. + + + I need to call this function with the input parameter set to "CUSTOM_CASSETTE_OK". + + ' + type: reasoning_text + encrypted_content: null + id: rs_bc44cd476b689b6a + status: null + summary: [] + type: reasoning + - call_id: chatcmpl-tool-ae789b79ade9ed2c + id: ctc_8733a261cde63d0d + input: CUSTOM_CASSETTE_OK + name: agentic_raw_echo + status: completed + type: custom_tool_call + previous_response_id: null + status: completed + usage: + input_tokens: 345 + input_tokens_details: + cached_tokens: 0 + output_tokens: 140 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 485 + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: chatcmpl-tool-ae789b79ade9ed2c + 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_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + 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: 1785495772 + error: null + id: resp_019fb7d7-5b53-7700-9554-0583a5b5582b + incomplete_details: null + instructions: null + model: Qwen/Qwen3.5-35B-A3B-FP8 + object: response + output: + - content: + - text: 'The user wants me to use the custom tool output I received above. + They are telling me not to call any tool again, and to reply with exactly + "CUSTOM_CASSETTE_OUTPUT_OK". + + + Looking at the tool call result, the output was "CUSTOM_CASSETTE_OUTPUT_OK". + So I should simply reply with that exact text. + + ' + type: reasoning_text + encrypted_content: null + id: rs_9d9fd872f1a8ac64 + status: null + summary: [] + type: reasoning + - content: + - annotations: [] + text: ' + + + CUSTOM_CASSETTE_OUTPUT_OK' + type: output_text + id: msg_b75d898c95358c0d + role: assistant + status: completed + type: message + previous_response_id: resp_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + status: completed + usage: + input_tokens: 429 + input_tokens_details: + cached_tokens: 0 + output_tokens: 80 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 509 + 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..18319bf1 --- /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,872 @@ +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":1785495768,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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":[{"allowed_callers":null,"defer_loading":null,"description":"Emit + the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw + input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":1785495768,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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":[{"allowed_callers":null,"defer_loading":null,"description":"Emit + the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw + input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":"863af35ec60a2567","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"863af35ec60a2567","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + is","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + asking","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + me","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + to","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + call","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + the","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + ag","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":"entic","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"_raw","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_echo","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":" + custom","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" + tool","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" + once","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" + with","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" + the","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":" + raw","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + text","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" + input","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + \"","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":"_C","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":"AS","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":"SET","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":"TE","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":"_OK","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":"\".","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":" + I","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + need","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + to","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + make","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" + this","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" + function","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + call","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + with","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + the","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + specified","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" + input","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":".","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":"\n","item_id":"863af35ec60a2567"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":46,"output_index":0,"content_index":0,"item_id":"863af35ec60a2567","text":"The + user is asking me to call the agentic_raw_echo custom tool exactly once with + the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call + with the specified input.\n"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":47,"output_index":0,"content_index":0,"item_id":"863af35ec60a2567","part":{"text":"The + user is asking me to call the agentic_raw_echo custom tool exactly once with + the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call + with the specified input.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":48,"output_index":0,"item":{"content":[{"text":"The + user is asking me to call the agentic_raw_echo custom tool exactly once with + the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call + with the specified input.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"863af35ec60a2567","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":49,"item":{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"","name":"agentic_raw_echo","status":"in_progress","type":"custom_tool_call"},"output_index":1} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":50,"delta":"CUSTOM_CASSETTE_OK","item_id":"ctc_d4661a054fc2a62e","output_index":1} + + ' + - ' + + ' + - 'data: {"type":"response.custom_tool_call_input.done","sequence_number":51,"input":"CUSTOM_CASSETTE_OK","item_id":"ctc_d4661a054fc2a62e","output_index":1} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":52,"item":{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"},"output_index":1} + + ' + - ' + + ' + - 'data: {"type":"response.completed","sequence_number":53,"response":{"conversation_id":null,"created_at":1785495769,"error":null,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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 + the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call + with the specified input.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"863af35ec60a2567","status":null,"summary":[],"type":"reasoning"},{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":345,"input_tokens_details":{"cached_tokens":0},"output_tokens":77,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":422}}} + + ' + - ' + + ' + - 'data: [DONE] + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: + - call_id: call_9d32dcb8705b2c12 + 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_019fb7d7-4dcd-7582-8c3c-986fe188a548 + 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":1785495769,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","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_019fb7d7-4dcd-7582-8c3c-986fe188a548","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"allowed_callers":null,"defer_loading":null,"description":"Emit + the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw + input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":1785495769,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","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_019fb7d7-4dcd-7582-8c3c-986fe188a548","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"allowed_callers":null,"defer_loading":null,"description":"Emit + the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw + input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":"9a1eed49a5cbba75","status":"in_progress","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.added","sequence_number":3,"output_index":0,"content_index":0,"item_id":"9a1eed49a5cbba75","part":{"text":"","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":4,"output_index":0,"content_index":0,"delta":"The","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":5,"output_index":0,"content_index":0,"delta":" + user","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" + is","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" + asking","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" + me","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" + to","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" + reply","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" + with","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" + exactly","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" + \"","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":15,"output_index":0,"content_index":0,"delta":"_C","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":16,"output_index":0,"content_index":0,"delta":"AS","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":"SET","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":"TE","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":"_OUTPUT","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":"_OK","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":"\"","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":22,"output_index":0,"content_index":0,"delta":" + based","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" + on","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" + the","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" + output","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" + from","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":" + the","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" + custom","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" + tool","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" + I","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" + just","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" + called","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":33,"output_index":0,"content_index":0,"delta":".","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":34,"output_index":0,"content_index":0,"delta":" + This","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" + is","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" + straightforward","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" + -","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" + I","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" + should","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" + just","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" + output","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" + that","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" + exact","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":44,"output_index":0,"content_index":0,"delta":" + string","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":45,"output_index":0,"content_index":0,"delta":".","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.delta","sequence_number":46,"output_index":0,"content_index":0,"delta":"\n","item_id":"9a1eed49a5cbba75"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_text.done","sequence_number":47,"output_index":0,"content_index":0,"item_id":"9a1eed49a5cbba75","text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the output from the custom tool I just called. This is straightforward - + I should just output that exact string.\n"} + + ' + - ' + + ' + - 'data: {"type":"response.reasoning_part.done","sequence_number":48,"output_index":0,"content_index":0,"item_id":"9a1eed49a5cbba75","part":{"text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the output from the custom tool I just called. This is straightforward - + I should just output that exact string.\n","type":"reasoning_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":49,"output_index":0,"item":{"content":[{"text":"The + user is asking me to reply with exactly \"CUSTOM_CASSETTE_OUTPUT_OK\" based + on the output from the custom tool I just called. This is straightforward - + I should just output that exact string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9a1eed49a5cbba75","status":"completed","summary":[],"type":"reasoning"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.added","sequence_number":50,"output_index":1,"item":{"content":[],"id":"9d6454eaa4f2c93c","phase":null,"role":"assistant","status":"in_progress","type":"message"}} + + ' + - ' + + ' + - 'data: {"type":"response.content_part.added","sequence_number":51,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":52,"output_index":1,"content_index":0,"delta":"\n\nCUSTOM","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":53,"output_index":1,"content_index":0,"delta":"_C","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":54,"output_index":1,"content_index":0,"delta":"AS","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":55,"output_index":1,"content_index":0,"delta":"SET","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":56,"output_index":1,"content_index":0,"delta":"TE","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":57,"output_index":1,"content_index":0,"delta":"_OUTPUT","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.delta","sequence_number":58,"output_index":1,"content_index":0,"delta":"_OK","item_id":"9d6454eaa4f2c93c","logprobs":[]} + + ' + - ' + + ' + - 'data: {"type":"response.output_text.done","sequence_number":59,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","logprobs":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK"} + + ' + - ' + + ' + - 'data: {"type":"response.content_part.done","sequence_number":60,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","part":{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}} + + ' + - ' + + ' + - 'data: {"type":"response.output_item.done","sequence_number":61,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"9d6454eaa4f2c93c","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} + + ' + - ' + + ' + - 'data: {"type":"response.completed","sequence_number":62,"response":{"conversation_id":null,"created_at":1785495769,"error":null,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","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 output from the custom tool I just called. This is straightforward - + I should just output that exact string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9a1eed49a5cbba75","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"9d6454eaa4f2c93c","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","status":"completed","usage":{"input_tokens":429,"input_tokens_details":{"cached_tokens":0},"output_tokens":53,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":482}}} + + ' + - ' + + ' + - '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..21bb6bee --- /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: 1785495765 + created_at: 1785495764 + error: null + frequency_penalty: 0.0 + id: resp_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + 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_Ow0hzMz1qek4PhJPoGSisWOT + id: ctc_0cbb8731608c41ea006a6c80d514b0819abe522d32fe152a38 + 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_Ow0hzMz1qek4PhJPoGSisWOT + 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_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + 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: 1785495766 + created_at: 1785495765 + error: null + frequency_penalty: 0.0 + id: resp_0cbb8731608c41ea006a6c80d59dcc819abb682ff7f6d98861 + 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_0cbb8731608c41ea006a6c80d65540819a99820cfe2f18d7e4 + phase: final_answer + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + 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..531e6b17 --- /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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"in_progress","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"a7ZQNbR0hc","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"rFLmQCMkhqlLoF","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"miwxozqbUlqGXr","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"WKnGtMK5rcsVG","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"2gL1OF42jCHHx1","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"PAYmQslUAG1W6","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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"completed","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo"},"output_index":0,"sequence_number":10} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"status":"completed","background":false,"completed_at":1785495760,"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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"completed","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","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_CepKMdJfIWH7yRr8Ivcg5OhD + 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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce + 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_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"DKT3A55Jex","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"6ltub5CA7LyPwk","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"lOYBj8zLQeNLxj","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"Ndnnfrbe7HK2f","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"i2hvjcNtFWKKj3","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"veORN7hA6","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"O2nCAKRheES63","output_index":0,"sequence_number":10} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"status":"completed","background":false,"completed_at":1785495762,"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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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..429272ea --- /dev/null +++ b/crates/agentic-server-core/tests/custom_tool_test.rs @@ -0,0 +1,278 @@ +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 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" => { + input.push_str(event["delta"].as_str().unwrap_or_default()); + 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, + "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/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 0f75c67f..245249cd 100644 --- a/crates/agentic-server/tests/responses_websocket_test.rs +++ b/crates/agentic-server/tests/responses_websocket_test.rs @@ -487,39 +487,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!({ @@ -633,8 +634,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()); } @@ -1054,18 +1059,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] From 93e0fc3ab56c044ad23c2971b216e198d7b6ed49 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 31 Jul 2026 21:43:31 +0800 Subject: [PATCH 03/11] fix cassettes recording readme Signed-off-by: maral --- .../tests/cassettes/README.md | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/agentic-server-core/tests/cassettes/README.md b/crates/agentic-server-core/tests/cassettes/README.md index 4d142dfc..0ee58e3a 100644 --- a/crates/agentic-server-core/tests/cassettes/README.md +++ b/crates/agentic-server-core/tests/cassettes/README.md @@ -193,6 +193,16 @@ vllm serve Qwen/Qwen3-30B-A3B-FP8 --tool-call-parser hermes --enable-auto-tool-c VLLM_URL=http://0.0.0.0:5050 MODEL=Qwen/Qwen3-30B-A3B-FP8 bash tests/cassettes/record_tool_call_cassettes.sh ``` +### Web search (gateway and OpenAI) + +The default records both providers. Use `WEB_SEARCH_RECORD_SET=gateway` or +`WEB_SEARCH_RECORD_SET=openai` to record only one side. + +```bash +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 @@ -206,14 +216,23 @@ 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. -### Web search (gateway and OpenAI) +### Codex custom tools (gateway, vLLM, and OpenAI) -The default records both providers. Use `WEB_SEARCH_RECORD_SET=gateway` or -`WEB_SEARCH_RECORD_SET=openai` to record only one side. +The custom fixture uses a Lark grammar and records two turns: the model returns raw `custom_tool_call.input`, then the +recorder submits the matching `custom_tool_call_output` before the follow-up user message. ```bash +GATEWAY_URL=http://127.0.0.1:3018 \ +V_MODEL=Qwen/Qwen3.6-35B-A3B \ +bash tests/cassettes/record_codex_cli_tool_call_cassettes.sh gateway-custom + +VLLM_URL=http://127.0.0.1:8000 \ +V_MODEL=Qwen/Qwen3.6-35B-A3B \ +bash tests/cassettes/record_codex_cli_tool_call_cassettes.sh direct-vllm-custom + OPENAI_API_KEY=sk-... \ -bash crates/agentic-server-core/tests/cassettes/record_web_search_cassettes.sh +OPENAI_CUSTOM_MODEL=gpt-5.6 \ +bash tests/cassettes/record_codex_cli_tool_call_cassettes.sh openai-custom ``` ### Compaction replay (OpenAI) From 619d715f6e128b99d11213073b9f38af18f3a674 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 15:04:45 +0800 Subject: [PATCH 04/11] preserve custom tool constraints during normalization Signed-off-by: maral --- crates/agentic-server-core/src/tool/custom.rs | 85 ++++++++++++++++++- .../src/types/request_response.rs | 9 +- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/agentic-server-core/src/tool/custom.rs b/crates/agentic-server-core/src/tool/custom.rs index 7d058c28..36e0b590 100644 --- a/crates/agentic-server-core/src/tool/custom.rs +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -21,13 +21,13 @@ impl CustomHandler { FunctionTool { type_: "function".to_owned(), name: param.name.as_str().to_owned(), - description: param.description.clone(), + description: Some(model_visible_description(param)), parameters: Some(serde_json::json!({ "type": "object", "properties": { "input": { "type": "string", - "description": "Raw input for the custom tool." + "description": "Raw custom tool input. Follow the tool description and declared format exactly." } }, "required": ["input"], @@ -60,6 +60,53 @@ impl CustomHandler { } } +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 @@ -166,7 +213,13 @@ mod tests { 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." + "description": "Echo raw input.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: \"CUSTOM_OK\"" + }, + "x-provider-field": {"mode": "strict"} })) .expect("custom tool"); @@ -179,5 +232,31 @@ mod tests { "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}$")); } } diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index 68ae6189..c79b4d72 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -580,7 +580,14 @@ mod tests { assert_eq!(upstream["tools"][0]["name"], "read_file"); assert_eq!(upstream["tools"][1]["type"], "function"); assert_eq!(upstream["tools"][1]["name"], "apply_patch"); - assert_eq!(upstream["tools"][1]["description"], "Apply a patch."); + 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" From 9cc2fb948e6c9fb1bfda1d912c8cfed234024468 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 16:30:59 +0800 Subject: [PATCH 05/11] preserve typed custom tool outputs during normalization Signed-off-by: maral --- .../src/executor/compaction.rs | 8 +- crates/agentic-server-core/src/lib.rs | 15 +-- .../agentic-server-core/src/tool/normalize.rs | 2 +- .../agentic-server-core/src/types/io/input.rs | 114 ++++++++++++++++-- .../agentic-server-core/src/types/io/mod.rs | 5 +- crates/agentic-server-core/src/types/mod.rs | 12 +- .../tests/stateful_responses_integration.rs | 4 +- 7 files changed, 128 insertions(+), 32 deletions(-) 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/lib.rs b/crates/agentic-server-core/src/lib.rs index 74531d5f..7ca23efb 100644 --- a/crates/agentic-server-core/src/lib.rs +++ b/crates/agentic-server-core/src/lib.rs @@ -22,12 +22,13 @@ 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, WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, - WebSearchActionSearch, WebSearchCall, WebSearchCallStatus, WebSearchContextSize, WebSearchFilters, WebSearchSource, - WebSearchToolParam, WebSearchUserLocation, + 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, + 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/normalize.rs b/crates/agentic-server-core/src/tool/normalize.rs index 5c9c7849..1bd5b6df 100644 --- a/crates/agentic-server-core/src/tool/normalize.rs +++ b/crates/agentic-server-core/src/tool/normalize.rs @@ -95,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/types/io/input.rs b/crates/agentic-server-core/src/types/io/input.rs index a350cd11..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. @@ -120,14 +181,14 @@ 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: custom_output_text(output.output), + output: output.output, } } } @@ -286,13 +347,6 @@ fn function_call_item_id(item_id: &str) -> Option { Some(item_id.to_owned()) } -fn custom_output_text(output: Value) -> String { - match output { - Value::String(output) => output, - output => output.to_string(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -325,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!([{ diff --git a/crates/agentic-server-core/src/types/io/mod.rs b/crates/agentic-server-core/src/types/io/mod.rs index fdc923bb..8df0cad9 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, diff --git a/crates/agentic-server-core/src/types/mod.rs b/crates/agentic-server-core/src/types/mod.rs index 9c059bde..d7d3b1ed 100644 --- a/crates/agentic-server-core/src/types/mod.rs +++ b/crates/agentic-server-core/src/types/mod.rs @@ -6,12 +6,12 @@ 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, + 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/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(), }) } From c13a2435dec2dd874d29cc4ecfdaa30a5fe1668b Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 17:12:26 +0800 Subject: [PATCH 06/11] restore upstream tool types and OpenAI tool choices Signed-off-by: maral --- crates/agentic-server-core/src/lib.rs | 10 +- .../agentic-server-core/src/types/io/mod.rs | 2 +- .../agentic-server-core/src/types/io/tools.rs | 128 ++++++++++++++++-- crates/agentic-server-core/src/types/mod.rs | 17 +-- .../src/types/request_response.rs | 75 +++++++++- 5 files changed, 198 insertions(+), 34 deletions(-) diff --git a/crates/agentic-server-core/src/lib.rs b/crates/agentic-server-core/src/lib.rs index 7ca23efb..917eaa21 100644 --- a/crates/agentic-server-core/src/lib.rs +++ b/crates/agentic-server-core/src/lib.rs @@ -19,16 +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, + 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, - WebSearchAction, WebSearchActionFindInPage, WebSearchActionOpenPage, WebSearchActionSearch, WebSearchCall, - WebSearchCallStatus, WebSearchContextSize, WebSearchFilters, WebSearchSource, WebSearchToolParam, + 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/types/io/mod.rs b/crates/agentic-server-core/src/types/io/mod.rs index 8df0cad9..648b9036 100644 --- a/crates/agentic-server-core/src/types/io/mod.rs +++ b/crates/agentic-server-core/src/types/io/mod.rs @@ -14,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/tools.rs b/crates/agentic-server-core/src/types/io/tools.rs index 524826de..ee9af3a8 100644 --- a/crates/agentic-server-core/src/types/io/tools.rs +++ b/crates/agentic-server-core/src/types/io/tools.rs @@ -23,6 +23,27 @@ pub enum ToolChoice { namespace: Option, name: NonEmptyToolName, }, + 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 { @@ -43,6 +64,19 @@ impl Serialize for ToolChoice { map.serialize_entry("name", name.as_str())?; map.end() } + Self::Custom { name } => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", "custom")?; + 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() + } } } } @@ -80,7 +114,21 @@ impl<'de> Deserialize<'de> for ToolChoice { .and_then(Value::as_str) .ok_or_else(|| de::Error::missing_field("name"))?; let name = NonEmptyToolName::try_from(name).map_err(de::Error::custom)?; - return Ok(Self::Function { namespace: None, name }); + 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) { @@ -94,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] @@ -158,27 +232,20 @@ mod tests { } #[test] - fn custom_tool_choice_normalizes_to_function() { + fn custom_tool_choice_round_trips() { let custom = serde_json::json!({ "type": "custom", "name": "apply_patch" }); - let choice: ToolChoice = serde_json::from_value(custom).unwrap(); + let choice: ToolChoice = serde_json::from_value(custom.clone()).unwrap(); assert_eq!( choice, - ToolChoice::Function { - namespace: None, + ToolChoice::Custom { name: NonEmptyToolName::try_from("apply_patch").unwrap() } ); - assert_eq!( - serde_json::to_value(choice).unwrap(), - serde_json::json!({ - "type": "function", - "name": "apply_patch" - }) - ); + assert_eq!(serde_json::to_value(choice).unwrap(), custom); } #[test] @@ -191,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 d7d3b1ed..0c60e157 100644 --- a/crates/agentic-server-core/src/types/mod.rs +++ b/crates/agentic-server-core/src/types/mod.rs @@ -5,17 +5,18 @@ pub mod request_response; pub mod tools; pub use io::{ - 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, + 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, - UpstreamRequest, + UpstreamRequest, UpstreamTool, }; pub use tools::{ CodeInterpreterToolParam, CodexNamespaceMember, CodexNamespaceToolParam, CustomToolParam, EmptyToolNameError, diff --git a/crates/agentic-server-core/src/types/request_response.rs b/crates/agentic-server-core/src/types/request_response.rs index c79b4d72..3d0a03c5 100644 --- a/crates/agentic-server-core/src/types/request_response.rs +++ b/crates/agentic-server-core/src/types/request_response.rs @@ -53,8 +53,11 @@ pub struct UpstreamRequest<'a> { /// 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")] + pub tools: Option>, + #[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>, @@ -73,6 +76,16 @@ pub struct UpstreamRequest<'a> { pub cache_salt: Option<&'a str>, } +/// A normalized tool declaration supported by the upstream Responses endpoint. +/// +/// 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), +} + // serde's `skip_serializing_if` requires a `&Option` receiver, so the // idiomatic `Option<&T>` clippy suggests does not apply here. #[allow(clippy::ref_option)] @@ -80,6 +93,18 @@ 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. /// @@ -112,8 +137,13 @@ impl RequestPayload { .as_deref() .map(|tools| CodexNamespaceHandler.resolve_namespace_members(tools)) .transpose()?; - let tools: Option> = - renamed_tools.map(|tools| tools.iter().flat_map(ResponsesTool::to_function_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()); @@ -572,8 +602,10 @@ mod tests { let request = payload.to_upstream_request(false).unwrap(); let tools = request.tools.as_ref().expect("mixed upstream tools"); - assert_eq!(tools[0].name, "read_file"); - assert_eq!(tools[1].name, "apply_patch"); + 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"); @@ -601,6 +633,37 @@ mod tests { 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] fn responses_input_discards_unknown_items_when_converted_for_storage() { let input: ResponsesInput = serde_json::from_value(serde_json::json!([ From 311d3643f931a7dc0665c230e400ff31cedeea14 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 17:35:45 +0800 Subject: [PATCH 07/11] restore custom tool metadata in streaming responses Signed-off-by: maral --- crates/agentic-server-core/src/tool/custom.rs | 190 +++++++++++++++++- .../agentic-server-core/src/tool/registry.rs | 11 +- 2 files changed, 198 insertions(+), 3 deletions(-) diff --git a/crates/agentic-server-core/src/tool/custom.rs b/crates/agentic-server-core/src/tool/custom.rs index 36e0b590..163cfa25 100644 --- a/crates/agentic-server-core/src/tool/custom.rs +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -1,12 +1,39 @@ use std::collections::HashMap; +use serde_json::{Map, Value}; + +use crate::events::WireEvent; use crate::types::event::MessageStatus; use crate::types::io::{CustomToolCall, FunctionTool, FunctionToolCall, OutputItem}; -use crate::types::tools::CustomToolParam; +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, @@ -16,6 +43,11 @@ use super::{ToolEntry, ToolError, ToolHandler, ToolType}; 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 { @@ -58,6 +90,95 @@ impl CustomHandler { input: String::new(), }) } + + /// 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 { @@ -259,4 +380,71 @@ mod tests { 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/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 4d2d66ce..4d284590 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::codex::insert_namespace_entries; -use super::custom::insert_custom_entry; +use super::custom::{CustomHandler, CustomToolMap, insert_custom_entry}; use super::executors::GatewayExecutors; use super::function::insert_function_entry; use super::mcp::handler::{McpToolMap, McpToolRef}; @@ -166,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, @@ -241,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, }) } @@ -278,7 +284,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. From 0b0323922b1b12f524931493dd050a11dc04c11d Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 18:19:03 +0800 Subject: [PATCH 08/11] apply authoritative done metadata to function calls Signed-off-by: maral --- .../src/executor/accumulator.rs | 150 +++++++++++++----- .../src/types/io/output.rs | 68 +++++--- 2 files changed, 157 insertions(+), 61 deletions(-) diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 55163721..c8cd15ca 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -422,57 +422,58 @@ impl ResponseAccumulator { else { return; }; - match (item_type, self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)) { - (SSEItemType::CustomToolCall, Some(InFlight::CustomToolCall { item, input })) => { - item.apply_done(payload, input); - return; - } - (SSEItemType::McpCall, Some(InFlight::McpCall { item })) => { - item.apply_done(payload, &mut String::new()); - return; + 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 *item_type == SSEItemType::WebSearchCall { - let Some(OutputItem::WebSearchCall(mut call)) = deserialize_from_value_opt::(raw_item.clone()) - else { + + if let Some( + mut output_item @ (OutputItem::FunctionCall(_) + | OutputItem::CustomToolCall(_) + | OutputItem::WebSearchCall(_) + | OutputItem::McpCall(_)), + ) = done_item + { + let OutputItem::WebSearchCall(call) = &mut output_item else { + self.completed.push((*output_index, output_item)); 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))); + call.id = uuid7_str("ws_"); } - return; - } - if let Some(output_item @ (OutputItem::CustomToolCall(_) | OutputItem::McpCall(_))) = - deserialize_from_value_opt::(raw_item.clone()) - { 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; @@ -509,6 +510,16 @@ 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) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1401,6 +1412,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); diff --git a/crates/agentic-server-core/src/types/io/output.rs b/crates/agentic-server-core/src/types/io/output.rs index b83fc2ab..65e6db62 100644 --- a/crates/agentic-server-core/src/types/io/output.rs +++ b/crates/agentic-server-core/src/types/io/output.rs @@ -521,26 +521,54 @@ 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; + } + _ => {} } } } From b564a9b5a67a2464bee68207a4bb7cc996250855 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 18:28:14 +0800 Subject: [PATCH 09/11] log custom tool input envelope fallback Signed-off-by: maral --- crates/agentic-server-core/src/tool/custom.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/agentic-server-core/src/tool/custom.rs b/crates/agentic-server-core/src/tool/custom.rs index 163cfa25..1564357d 100644 --- a/crates/agentic-server-core/src/tool/custom.rs +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -286,7 +286,13 @@ fn stable_name_hash(value: &str) -> u64 { } pub(crate) fn input_from_arguments(arguments: &str) -> String { - try_input_from_arguments(arguments).unwrap_or_else(|| arguments.to_owned()) + 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 { From 78a91248562343474cf4f4c27a7d0666037da048 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 18:38:15 +0800 Subject: [PATCH 10/11] cover done-only custom tool call ordering Signed-off-by: maral --- .../src/executor/accumulator.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index c8cd15ca..751fce59 100644 --- a/crates/agentic-server-core/src/executor/accumulator.rs +++ b/crates/agentic-server-core/src/executor/accumulator.rs @@ -1620,4 +1620,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"); + } } From 294fe8dac989bee041c7725356c18957fbcc40b3 Mon Sep 17 00:00:00 2001 From: maral Date: Mon, 3 Aug 2026 19:59:48 +0800 Subject: [PATCH 11/11] translate custom tool SSE deltas incrementally Signed-off-by: maral --- .../src/executor/accumulator.rs | 89 +++ .../src/executor/engine.rs | 28 +- .../src/executor/function_sse.rs | 606 ++++++++++++++++ .../src/executor/gateway.rs | 108 +-- .../agentic-server-core/src/executor/mod.rs | 1 + .../src/executor/upstream.rs | 244 +------ crates/agentic-server-core/src/tool/custom.rs | 13 +- .../agentic-server-core/src/tool/registry.rs | 7 + ...Qwen-Qwen3.5-35B-A3B-FP8-nonstreaming.yaml | 58 +- ...ay-Qwen-Qwen3.5-35B-A3B-FP8-streaming.yaml | 651 +++++++++++++----- ...openai-reference-gpt-5.6-nonstreaming.yaml | 24 +- ...ol-openai-reference-gpt-5.6-streaming.yaml | 58 +- .../tests/custom_tool_test.rs | 6 +- 13 files changed, 1306 insertions(+), 587 deletions(-) create mode 100644 crates/agentic-server-core/src/executor/function_sse.rs diff --git a/crates/agentic-server-core/src/executor/accumulator.rs b/crates/agentic-server-core/src/executor/accumulator.rs index 751fce59..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; @@ -520,6 +585,30 @@ fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool ) } +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::*; diff --git a/crates/agentic-server-core/src/executor/engine.rs b/crates/agentic-server-core/src/executor/engine.rs index 735a7cbd..fcfd9209 100644 --- a/crates/agentic-server-core/src/executor/engine.rs +++ b/crates/agentic-server-core/src/executor/engine.rs @@ -15,9 +15,9 @@ use tracing::{debug, warn}; use super::compaction::maybe_compact_context; use super::gateway::{ GatewayCallResult, LoopDecision, append_gateway_calls_to_new_input, append_output_items_to_input, - append_tool_outputs, classify_round, emit_client_call_events, emit_gateway_completed_events, - emit_gateway_start_events, execute_and_emit_output_calls, execute_output_calls, gateway_event_plans, - has_client_owned_calls, is_client_custom_call, is_gateway_owned_call, public_output_items, + 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_client_custom_call, is_gateway_owned_call, public_output_items, }; use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk}; use crate::events::EventFrame; @@ -215,10 +215,7 @@ async fn execute_and_emit_round_output_calls( ctx: &RequestContext, stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender)>, ) -> ExecutorResult> { - let has_client_custom_call = output_items - .iter() - .any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry))); - match (deferred_events.is_empty() && !has_client_custom_call, stream) { + match (deferred_events.is_empty(), stream) { (true, stream) => execute_and_emit_output_calls(output_items, registry, output_offset, stream).await, (false, Some((stream_accumulator, stream_sender))) => { execute_and_emit_ordered_output_calls( @@ -302,23 +299,6 @@ async fn execute_and_emit_ordered_output_calls( output_offset, )?; gateway_index += 1; - } else if let OutputItem::FunctionCall(call) = item - && is_client_custom_call(call, registry) - { - emit_client_call_events( - call, - output_offset.saturating_add(index), - stream_accumulator, - stream_sender, - )?; - emit_deferred_stream_events( - std::mem::take(&mut events_by_output[index]), - ctx, - registry, - stream_accumulator, - stream_sender, - output_offset, - )?; } else { emit_deferred_stream_events( std::mem::take(&mut events_by_output[index]), 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 34a328f0..8b895963 100644 --- a/crates/agentic-server-core/src/executor/gateway.rs +++ b/crates/agentic-server-core/src/executor/gateway.rs @@ -294,58 +294,6 @@ fn output_item_value(item: &OutputItem) -> ExecutorResult { serde_json::to_value(item).map_err(ExecutorError::JsonError) } -pub(super) fn emit_client_call_events( - call: &FunctionToolCall, - output_index: usize, - stream_accumulator: &mut GatewayStreamAccumulator, - stream_sender: &tokio::sync::mpsc::UnboundedSender, -) -> ExecutorResult<()> { - let output_index = u32::try_from(output_index).unwrap_or(u32::MAX); - let started_output = crate::tool::CustomHandler::started_output_item(call); - let completed_output = crate::tool::CustomHandler::output_item(call); - let OutputItem::CustomToolCall(custom_call) = &completed_output else { - return Ok(()); - }; - - let mut added_event = synthetic_event( - SSEEventType::OutputItemAdded, - [ - ("output_index".to_owned(), serde_json::json!(output_index)), - ("item".to_owned(), output_item_value(&started_output)?), - ], - )?; - emit_gateway_event(&mut added_event, stream_accumulator, stream_sender)?; - - let mut input_delta_event = synthetic_event( - SSEEventType::CustomToolCallInputDelta, - [ - ("delta".to_owned(), serde_json::json!(custom_call.input)), - ("item_id".to_owned(), serde_json::json!(custom_call.id)), - ("output_index".to_owned(), serde_json::json!(output_index)), - ], - )?; - emit_gateway_event(&mut input_delta_event, stream_accumulator, stream_sender)?; - - let mut input_done_event = synthetic_event( - SSEEventType::CustomToolCallInputDone, - [ - ("input".to_owned(), serde_json::json!(custom_call.input)), - ("item_id".to_owned(), serde_json::json!(custom_call.id)), - ("output_index".to_owned(), serde_json::json!(output_index)), - ], - )?; - emit_gateway_event(&mut input_done_event, stream_accumulator, stream_sender)?; - - let mut done_event = synthetic_event( - SSEEventType::OutputItemDone, - [ - ("output_index".to_owned(), serde_json::json!(output_index)), - ("item".to_owned(), output_item_value(&completed_output)?), - ], - )?; - emit_gateway_event(&mut done_event, stream_accumulator, stream_sender) -} - pub(super) fn emit_gateway_start_events( plans: &[GatewayCallEventPlan], stream_accumulator: &mut GatewayStreamAccumulator, @@ -547,8 +495,7 @@ pub(super) fn append_gateway_calls_to_new_input( #[cfg(test)] mod tests { - use super::{GatewayCallResult, LoopDecision, classify_round, emit_client_call_events}; - use crate::executor::gateway_accumulator::GatewayStreamAccumulator; + use super::{GatewayCallResult, LoopDecision, classify_round}; use crate::types::io::output::FunctionToolCall; use crate::types::io::{InputItem, McpCallStatus}; use tokio::sync::mpsc; @@ -615,59 +562,6 @@ mod tests { assert!(matches!(decision, LoopDecision::Done)); } - #[test] - fn client_custom_events_follow_openai_lifecycle() { - let call = FunctionToolCall { - id: "fc_custom".to_owned(), - call_id: "call_custom".to_owned(), - name: "raw_echo".to_owned(), - arguments: r#"{"input":"CUSTOM_CASSETTE_OK"}"#.to_owned(), - status: crate::types::event::MessageStatus::Completed, - namespace: None, - }; - let (sender, mut receiver) = mpsc::unbounded_channel(); - let mut accumulator = GatewayStreamAccumulator::new(); - - emit_client_call_events(&call, 2, &mut accumulator, &sender).expect("custom events"); - drop(sender); - - let mut events = Vec::new(); - while let Ok(event) = receiver.try_recv() { - let data = event - .content - .strip_prefix("data: ") - .and_then(|data| data.strip_suffix("\n\n")) - .expect("SSE data frame"); - events.push(serde_json::from_str::(data).expect("event JSON")); - } - - assert_eq!( - events - .iter() - .map(|event| event["type"].as_str().unwrap()) - .collect::>(), - [ - "response.output_item.added", - "response.custom_tool_call_input.delta", - "response.custom_tool_call_input.done", - "response.output_item.done", - ] - ); - for (sequence_number, event) in events.iter().enumerate() { - assert_eq!(event["sequence_number"], sequence_number); - assert_eq!(event["output_index"], 2); - } - assert_eq!(events[0]["item"]["type"], "custom_tool_call"); - assert_eq!(events[0]["item"]["id"], "ctc_custom"); - assert_eq!(events[0]["item"]["status"], "in_progress"); - assert_eq!(events[1]["delta"], "CUSTOM_CASSETTE_OK"); - assert_eq!(events[1]["item_id"], "ctc_custom"); - assert_eq!(events[2]["input"], "CUSTOM_CASSETTE_OK"); - assert_eq!(events[3]["item"]["type"], "custom_tool_call"); - assert_eq!(events[3]["item"]["status"], "completed"); - assert_eq!(events[3]["item"]["input"], "CUSTOM_CASSETTE_OK"); - } - use std::pin::Pin; use std::sync::Arc; 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 8ccde7c4..25de00c8 100644 --- a/crates/agentic-server-core/src/executor/upstream.rs +++ b/crates/agentic-server-core/src/executor/upstream.rs @@ -1,16 +1,16 @@ -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}; -use crate::tool::{ToolRegistry, ToolType}; +use crate::tool::ToolRegistry; use crate::types::request_response::ResponsePayload; use crate::utils::common::serialize_to_string; @@ -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_public_call_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_public_call_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_public_call_item_ids: &mut HashSet, - pending_unnamed_function_events: &mut HashMap>, - defer_from_output_index: &mut Option, - deferred_events: &mut Vec, -) -> ExecutorResult<()> { - defer_after_public_call(&frame, emit_ctx.registry, defer_from_output_index); - if should_hide_upstream_event( - frame.event_type, - &frame.payload, - emit_ctx.registry, - hidden_public_call_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_public_call_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_public_call(frame: &EventFrame, registry: &ToolRegistry, defer_from_output_index: &mut Option) { - let EventPayload::OutputItemAdded { - item_type: SSEItemType::FunctionCall, - name: Some(name), - .. - } = &frame.payload - 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 uses_public_call_shape(registry, name) { - record_first_hidden_public_output_index(frame, defer_from_output_index); - } -} - -fn record_first_hidden_public_output_index(frame: &EventFrame, defer_from_output_index: &mut Option) { - let Some(output_index) = frame.wire.output_index 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,154 +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_public_call_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 uses_public_call_shape(emit_ctx.registry, name) { - hidden_public_call_item_ids.insert(item_id.clone()); - record_first_hidden_public_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| uses_public_call_shape(emit_ctx.registry, name)) - { - hidden_public_call_item_ids.insert(item_id.clone()); - record_first_hidden_public_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_public_call_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 && uses_public_call_shape(registry, name) => { - hidden_public_call_item_ids.insert(item_id.clone()); - true - } - (SSEEventType::OutputItemDone, EventPayload::OutputItemDone { item_id, item_type, .. }) - if *item_type == SSEItemType::FunctionCall && hidden_public_call_item_ids.contains(item_id) => - { - true - } - ( - SSEEventType::FunctionCallArgumentsDelta | SSEEventType::FunctionCallArgumentsDone, - EventPayload::FunctionCallArgsDelta { item_id, .. } | EventPayload::FunctionCallArgsDone { item_id, .. }, - ) => hidden_public_call_item_ids.contains(item_id), - _ => false, - } -} - -fn uses_public_call_shape(registry: &ToolRegistry, name: &str) -> bool { - registry - .lookup(name) - .is_some_and(|entry| entry.tool_type == ToolType::Custom || entry.tool_type.is_gateway_owned()) -} - fn is_terminal_response_event(event_type: SSEEventType) -> bool { matches!( event_type, diff --git a/crates/agentic-server-core/src/tool/custom.rs b/crates/agentic-server-core/src/tool/custom.rs index 1564357d..85116d94 100644 --- a/crates/agentic-server-core/src/tool/custom.rs +++ b/crates/agentic-server-core/src/tool/custom.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use serde_json::{Map, Value}; use crate::events::WireEvent; -use crate::types::event::MessageStatus; use crate::types::io::{CustomToolCall, FunctionTool, FunctionToolCall, OutputItem}; use crate::types::tools::{CustomToolParam, ResponsesTool}; use crate::utils::common::serialize_to_value_or_custom_default; @@ -80,17 +79,6 @@ impl CustomHandler { }) } - #[must_use] - pub(crate) fn started_output_item(call: &FunctionToolCall) -> OutputItem { - OutputItem::CustomToolCall(CustomToolCall { - id: public_item_id(&call.id), - status: Some(MessageStatus::InProgress), - call_id: call.call_id.clone(), - name: call.name.clone(), - input: String::new(), - }) - } - /// 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 { @@ -309,6 +297,7 @@ pub(crate) fn try_input_from_arguments(arguments: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::types::event::MessageStatus; #[test] fn function_fallback_uses_public_custom_tool_shape() { diff --git a/crates/agentic-server-core/src/tool/registry.rs b/crates/agentic-server-core/src/tool/registry.rs index 4d284590..23c7929a 100644 --- a/crates/agentic-server-core/src/tool/registry.rs +++ b/crates/agentic-server-core/src/tool/registry.rs @@ -260,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() 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 index 797ae802..e760fc43 100644 --- 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 @@ -26,9 +26,9 @@ turns: response: body: conversation_id: null - created_at: 1785495771 + created_at: 1785758003 error: null - id: resp_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + id: resp_019fc778-af92-74c0-9cec-96bc69a19084 incomplete_details: null instructions: null model: Qwen/Qwen3.5-35B-A3B-FP8 @@ -36,30 +36,29 @@ turns: output: - content: - text: 'The user is asking me to call the agentic_raw_echo custom tool exactly - once with "CUSTOM_CASSETTE_OK" as the raw text input. This is a specific - instruction that I need to follow. + once with exactly "CUSTOM_CASSETTE_OK" as its raw text input. - Let me check the tool definition: + Looking at the tool definition: - Name: agentic_raw_echo - - Parameters: input (required, type: string) + - Required parameter: input (string) - - Description: Emit the requested cassette token as raw text. + - Description says the string must match the lark grammar: start: "CUSTOM_CASSETTE_OK" - I need to call this function with the input parameter set to "CUSTOM_CASSETTE_OK". + So I need to call this tool with input="CUSTOM_CASSETTE_OK". ' type: reasoning_text encrypted_content: null - id: rs_bc44cd476b689b6a + id: rs_a2b8fea477983752 status: null summary: [] type: reasoning - - call_id: chatcmpl-tool-ae789b79ade9ed2c - id: ctc_8733a261cde63d0d + - call_id: chatcmpl-tool-95a45c75da2681e6 + id: ctc_bee6b80c4899ad49 input: CUSTOM_CASSETTE_OK name: agentic_raw_echo status: completed @@ -67,13 +66,13 @@ turns: previous_response_id: null status: completed usage: - input_tokens: 345 + input_tokens: 390 input_tokens_details: cached_tokens: 0 - output_tokens: 140 + output_tokens: 133 output_tokens_details: reasoning_tokens: 0 - total_tokens: 485 + total_tokens: 523 headers: content-type: application/json status_code: 200 @@ -81,7 +80,7 @@ turns: request: body: input: - - call_id: chatcmpl-tool-ae789b79ade9ed2c + - 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. @@ -90,7 +89,7 @@ turns: type: message max_output_tokens: 2048 model: Qwen/Qwen3.5-35B-A3B-FP8 - previous_response_id: resp_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + previous_response_id: resp_019fc778-af92-74c0-9cec-96bc69a19084 store: true stream: false tools: @@ -111,27 +110,24 @@ turns: response: body: conversation_id: null - created_at: 1785495772 + created_at: 1785758004 error: null - id: resp_019fb7d7-5b53-7700-9554-0583a5b5582b + 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 wants me to use the custom tool output I received above. - They are telling me not to call any tool again, and to reply with exactly - "CUSTOM_CASSETTE_OUTPUT_OK". - - - Looking at the tool call result, the output was "CUSTOM_CASSETTE_OUTPUT_OK". - So I should simply reply with that exact text. + - 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_9d9fd872f1a8ac64 + id: rs_88750adaa679be92 status: null summary: [] type: reasoning @@ -142,20 +138,20 @@ turns: CUSTOM_CASSETTE_OUTPUT_OK' type: output_text - id: msg_b75d898c95358c0d + id: msg_9a4add76dc0f27e6 role: assistant status: completed type: message - previous_response_id: resp_019fb7d7-578f-7a61-be05-595ddfc4b6d0 + previous_response_id: resp_019fc778-af92-74c0-9cec-96bc69a19084 status: completed usage: - input_tokens: 429 + input_tokens: 474 input_tokens_details: cached_tokens: 0 - output_tokens: 80 + output_tokens: 62 output_tokens_details: reasoning_tokens: 0 - total_tokens: 509 + 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 index 18319bf1..8e84ae7c 100644 --- 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 @@ -27,370 +27,585 @@ turns: headers: content-type: text/event-stream; charset=utf-8 sse: - - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1785495768,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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":[{"allowed_callers":null,"defer_loading":null,"description":"Emit - the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw - input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + - '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":1785495768,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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":[{"allowed_callers":null,"defer_loading":null,"description":"Emit - the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw - input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":"863af35ec60a2567","status":"in_progress","summary":[],"type":"reasoning"}} + - '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":"863af35ec60a2567","part":{"text":"","type":"reasoning_text"}} + - '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":"863af35ec60a2567"} + - '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":"863af35ec60a2567"} + user","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" - is","item_id":"863af35ec60a2567"} + is","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" - asking","item_id":"863af35ec60a2567"} + asking","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" - me","item_id":"863af35ec60a2567"} + me","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" - to","item_id":"863af35ec60a2567"} + to","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" - call","item_id":"863af35ec60a2567"} + call","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" - the","item_id":"863af35ec60a2567"} + the","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" - ag","item_id":"863af35ec60a2567"} + ag","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":"entic","item_id":"863af35ec60a2567"} + - '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":"863af35ec60a2567"} + - '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":"863af35ec60a2567"} + - '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":"863af35ec60a2567"} + custom","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":17,"output_index":0,"content_index":0,"delta":" - tool","item_id":"863af35ec60a2567"} + tool","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":18,"output_index":0,"content_index":0,"delta":" - exactly","item_id":"863af35ec60a2567"} + with","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":19,"output_index":0,"content_index":0,"delta":" - once","item_id":"863af35ec60a2567"} + exactly","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":20,"output_index":0,"content_index":0,"delta":" - with","item_id":"863af35ec60a2567"} + \"","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":21,"output_index":0,"content_index":0,"delta":" - the","item_id":"863af35ec60a2567"} + - '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":" - raw","item_id":"863af35ec60a2567"} + - '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":" - text","item_id":"863af35ec60a2567"} + - '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":" - input","item_id":"863af35ec60a2567"} + - '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":" - \"","item_id":"863af35ec60a2567"} + - '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":"CUSTOM","item_id":"863af35ec60a2567"} + - '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":"_C","item_id":"863af35ec60a2567"} + - '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":"863af35ec60a2567"} + - '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":"SET","item_id":"863af35ec60a2567"} + - '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":"TE","item_id":"863af35ec60a2567"} + - '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":"_OK","item_id":"863af35ec60a2567"} + - '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":"\".","item_id":"863af35ec60a2567"} + - '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":" - I","item_id":"863af35ec60a2567"} + - '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":" - need","item_id":"863af35ec60a2567"} + They","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" - to","item_id":"863af35ec60a2567"} + specifically","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" - make","item_id":"863af35ec60a2567"} + said","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" - this","item_id":"863af35ec60a2567"} + I","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" - function","item_id":"863af35ec60a2567"} + must","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" - call","item_id":"863af35ec60a2567"} + call","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" - with","item_id":"863af35ec60a2567"} + it","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" - the","item_id":"863af35ec60a2567"} + exactly","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" - specified","item_id":"863af35ec60a2567"} + once","item_id":"82adad6aadd55280"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" - input","item_id":"863af35ec60a2567"} + 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":44,"output_index":0,"content_index":0,"delta":".","item_id":"863af35ec60a2567"} + - '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":45,"output_index":0,"content_index":0,"delta":"\n","item_id":"863af35ec60a2567"} + - '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.done","sequence_number":46,"output_index":0,"content_index":0,"item_id":"863af35ec60a2567","text":"The - user is asking me to call the agentic_raw_echo custom tool exactly once with - the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call - with the specified input.\n"} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":60,"output_index":0,"content_index":0,"delta":" + the","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.reasoning_part.done","sequence_number":47,"output_index":0,"content_index":0,"item_id":"863af35ec60a2567","part":{"text":"The - user is asking me to call the agentic_raw_echo custom tool exactly once with - the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call - with the specified input.\n","type":"reasoning_text"}} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":61,"output_index":0,"content_index":0,"delta":" + input","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.output_item.done","sequence_number":48,"output_index":0,"item":{"content":[{"text":"The - user is asking me to call the agentic_raw_echo custom tool exactly once with - the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call - with the specified input.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"863af35ec60a2567","status":"completed","summary":[],"type":"reasoning"}} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":62,"output_index":0,"content_index":0,"delta":" + parameter","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.output_item.added","sequence_number":49,"item":{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"","name":"agentic_raw_echo","status":"in_progress","type":"custom_tool_call"},"output_index":1} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":63,"output_index":0,"content_index":0,"delta":" + set","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.custom_tool_call_input.delta","sequence_number":50,"delta":"CUSTOM_CASSETTE_OK","item_id":"ctc_d4661a054fc2a62e","output_index":1} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":64,"output_index":0,"content_index":0,"delta":" + to","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.custom_tool_call_input.done","sequence_number":51,"input":"CUSTOM_CASSETTE_OK","item_id":"ctc_d4661a054fc2a62e","output_index":1} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":65,"output_index":0,"content_index":0,"delta":" + \"","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.output_item.done","sequence_number":52,"item":{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"},"output_index":1} + - 'data: {"type":"response.reasoning_text.delta","sequence_number":66,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"82adad6aadd55280"} ' - ' ' - - 'data: {"type":"response.completed","sequence_number":53,"response":{"conversation_id":null,"created_at":1785495769,"error":null,"id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","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 - the raw text input \"CUSTOM_CASSETTE_OK\". I need to make this function call - with the specified input.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"863af35ec60a2567","status":null,"summary":[],"type":"reasoning"},{"call_id":"call_9d32dcb8705b2c12","id":"ctc_d4661a054fc2a62e","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo","status":"completed","type":"custom_tool_call"}],"previous_response_id":null,"status":"completed","usage":{"input_tokens":345,"input_tokens_details":{"cached_tokens":0},"output_tokens":77,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":422}}} + - '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}}} ' - ' @@ -407,7 +622,7 @@ turns: request: body: input: - - call_id: call_9d32dcb8705b2c12 + - 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. @@ -416,7 +631,7 @@ turns: type: message max_output_tokens: 2048 model: Qwen/Qwen3.5-35B-A3B-FP8 - previous_response_id: resp_019fb7d7-4dcd-7582-8c3c-986fe188a548 + previous_response_id: resp_019fc778-a4c4-7b23-9854-f47e99aa89de store: true stream: true tools: @@ -438,426 +653,550 @@ turns: headers: content-type: text/event-stream; charset=utf-8 sse: - - 'data: {"type":"response.created","sequence_number":0,"response":{"background":false,"created_at":1785495769,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","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_019fb7d7-4dcd-7582-8c3c-986fe188a548","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"allowed_callers":null,"defer_loading":null,"description":"Emit - the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw - input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"top_logprobs":null,"top_p":0.95,"truncation":"disabled","usage":null,"user":null}} + - '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":1785495769,"ec_transfer_params":null,"frequency_penalty":0.0,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","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_019fb7d7-4dcd-7582-8c3c-986fe188a548","prompt":null,"reasoning":null,"service_tier":"auto","status":"in_progress","temperature":1.0,"text":null,"tool_choice":"auto","tools":[{"allowed_callers":null,"defer_loading":null,"description":"Emit - the requested cassette token as raw text.","name":"agentic_raw_echo","output_schema":null,"parameters":{"additionalProperties":false,"properties":{"input":{"description":"Raw - input for the custom tool.","type":"string"}},"required":["input"],"type":"object"},"strict":true,"type":"function"}],"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":"9a1eed49a5cbba75","status":"in_progress","summary":[],"type":"reasoning"}} + - '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":"9a1eed49a5cbba75","part":{"text":"","type":"reasoning_text"}} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + user","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":6,"output_index":0,"content_index":0,"delta":" - is","item_id":"9a1eed49a5cbba75"} + is","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":7,"output_index":0,"content_index":0,"delta":" - asking","item_id":"9a1eed49a5cbba75"} + asking","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":8,"output_index":0,"content_index":0,"delta":" - me","item_id":"9a1eed49a5cbba75"} + me","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":9,"output_index":0,"content_index":0,"delta":" - to","item_id":"9a1eed49a5cbba75"} + to","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":10,"output_index":0,"content_index":0,"delta":" - reply","item_id":"9a1eed49a5cbba75"} + reply","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":11,"output_index":0,"content_index":0,"delta":" - with","item_id":"9a1eed49a5cbba75"} + with","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":12,"output_index":0,"content_index":0,"delta":" - exactly","item_id":"9a1eed49a5cbba75"} + exactly","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":13,"output_index":0,"content_index":0,"delta":" - \"","item_id":"9a1eed49a5cbba75"} + \"","item_id":"8360c29b181cca85"} ' - ' ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":14,"output_index":0,"content_index":0,"delta":"CUSTOM","item_id":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + - '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":"9a1eed49a5cbba75"} + based","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":23,"output_index":0,"content_index":0,"delta":" - on","item_id":"9a1eed49a5cbba75"} + on","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":24,"output_index":0,"content_index":0,"delta":" - the","item_id":"9a1eed49a5cbba75"} + the","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":25,"output_index":0,"content_index":0,"delta":" - output","item_id":"9a1eed49a5cbba75"} + custom","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":26,"output_index":0,"content_index":0,"delta":" - from","item_id":"9a1eed49a5cbba75"} + tool","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":27,"output_index":0,"content_index":0,"delta":" - the","item_id":"9a1eed49a5cbba75"} + output","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":28,"output_index":0,"content_index":0,"delta":" - custom","item_id":"9a1eed49a5cbba75"} + that","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":29,"output_index":0,"content_index":0,"delta":" - tool","item_id":"9a1eed49a5cbba75"} + was","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":30,"output_index":0,"content_index":0,"delta":" - I","item_id":"9a1eed49a5cbba75"} + provided","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":31,"output_index":0,"content_index":0,"delta":" - just","item_id":"9a1eed49a5cbba75"} + above","item_id":"8360c29b181cca85"} ' - ' ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":32,"output_index":0,"content_index":0,"delta":" - called","item_id":"9a1eed49a5cbba75"} + - '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":".","item_id":"9a1eed49a5cbba75"} + - '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":" - This","item_id":"9a1eed49a5cbba75"} + also","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":35,"output_index":0,"content_index":0,"delta":" - is","item_id":"9a1eed49a5cbba75"} + explicitly","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":36,"output_index":0,"content_index":0,"delta":" - straightforward","item_id":"9a1eed49a5cbba75"} + stated","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":37,"output_index":0,"content_index":0,"delta":" - -","item_id":"9a1eed49a5cbba75"} + not","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":38,"output_index":0,"content_index":0,"delta":" - I","item_id":"9a1eed49a5cbba75"} + to","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":39,"output_index":0,"content_index":0,"delta":" - should","item_id":"9a1eed49a5cbba75"} + call","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":40,"output_index":0,"content_index":0,"delta":" - just","item_id":"9a1eed49a5cbba75"} + any","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":41,"output_index":0,"content_index":0,"delta":" - output","item_id":"9a1eed49a5cbba75"} + tool","item_id":"8360c29b181cca85"} ' - ' ' - 'data: {"type":"response.reasoning_text.delta","sequence_number":42,"output_index":0,"content_index":0,"delta":" - that","item_id":"9a1eed49a5cbba75"} + again","item_id":"8360c29b181cca85"} ' - ' ' - - 'data: {"type":"response.reasoning_text.delta","sequence_number":43,"output_index":0,"content_index":0,"delta":" - exact","item_id":"9a1eed49a5cbba75"} + - '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":" - string","item_id":"9a1eed49a5cbba75"} + - '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":45,"output_index":0,"content_index":0,"delta":".","item_id":"9a1eed49a5cbba75"} + - '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":46,"output_index":0,"content_index":0,"delta":"\n","item_id":"9a1eed49a5cbba75"} + - '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":47,"output_index":0,"content_index":0,"item_id":"9a1eed49a5cbba75","text":"The + - '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 output from the custom tool I just called. This is straightforward - - I should just output that exact string.\n"} + 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":48,"output_index":0,"content_index":0,"item_id":"9a1eed49a5cbba75","part":{"text":"The + - '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 output from the custom tool I just called. This is straightforward - - I should just output that exact string.\n","type":"reasoning_text"}} + 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":49,"output_index":0,"item":{"content":[{"text":"The + - '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 output from the custom tool I just called. This is straightforward - - I should just output that exact string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9a1eed49a5cbba75","status":"completed","summary":[],"type":"reasoning"}} + 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":50,"output_index":1,"item":{"content":[],"id":"9d6454eaa4f2c93c","phase":null,"role":"assistant","status":"in_progress","type":"message"}} + - '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":51,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","part":{"annotations":[],"logprobs":[],"text":"","type":"output_text"}} + - '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":52,"output_index":1,"content_index":0,"delta":"\n\nCUSTOM","item_id":"9d6454eaa4f2c93c","logprobs":[]} + - '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":53,"output_index":1,"content_index":0,"delta":"_C","item_id":"9d6454eaa4f2c93c","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":54,"output_index":1,"content_index":0,"delta":"AS","item_id":"9d6454eaa4f2c93c","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":55,"output_index":1,"content_index":0,"delta":"SET","item_id":"9d6454eaa4f2c93c","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":56,"output_index":1,"content_index":0,"delta":"TE","item_id":"9d6454eaa4f2c93c","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":57,"output_index":1,"content_index":0,"delta":"_OUTPUT","item_id":"9d6454eaa4f2c93c","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":58,"output_index":1,"content_index":0,"delta":"_OK","item_id":"9d6454eaa4f2c93c","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":59,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","logprobs":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK"} + - '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":60,"output_index":1,"content_index":0,"item_id":"9d6454eaa4f2c93c","part":{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}} + - '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":61,"output_index":1,"item":{"content":[{"annotations":[],"logprobs":null,"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"9d6454eaa4f2c93c","phase":null,"role":"assistant","status":"completed","summary":[],"type":"message"}} + - '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":62,"response":{"conversation_id":null,"created_at":1785495769,"error":null,"id":"resp_019fb7d7-501f-7e01-a2c2-8b6eb038730b","incomplete_details":null,"instructions":null,"model":"Qwen/Qwen3.5-35B-A3B-FP8","object":"response","output":[{"content":[{"text":"The + - '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 output from the custom tool I just called. This is straightforward - - I should just output that exact string.\n","type":"reasoning_text"}],"encrypted_content":null,"id":"9a1eed49a5cbba75","status":null,"summary":[],"type":"reasoning"},{"content":[{"annotations":[],"text":"\n\nCUSTOM_CASSETTE_OUTPUT_OK","type":"output_text"}],"id":"9d6454eaa4f2c93c","role":"assistant","status":"completed","type":"message"}],"previous_response_id":"resp_019fb7d7-4dcd-7582-8c3c-986fe188a548","status":"completed","usage":{"input_tokens":429,"input_tokens_details":{"cached_tokens":0},"output_tokens":53,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":482}}} + 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}}} ' - ' 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 index 21bb6bee..296194fc 100644 --- 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 @@ -29,11 +29,11 @@ turns: background: false billing: payer: developer - completed_at: 1785495765 - created_at: 1785495764 + completed_at: 1785757996 + created_at: 1785757995 error: null frequency_penalty: 0.0 - id: resp_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 incomplete_details: null instructions: null max_output_tokens: 2048 @@ -43,8 +43,8 @@ turns: moderation: null object: response output: - - call_id: call_Ow0hzMz1qek4PhJPoGSisWOT - id: ctc_0cbb8731608c41ea006a6c80d514b0819abe522d32fe152a38 + - call_id: call_t7gsPOaICdXkvdPvRE4WXCA7 + id: ctc_06bab1ed35efb02b006a70812ca2c8819bbb90c4d33d3d7e65 input: CUSTOM_CASSETTE_OK name: agentic_raw_echo status: completed @@ -110,7 +110,7 @@ turns: request: body: input: - - call_id: call_Ow0hzMz1qek4PhJPoGSisWOT + - 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. @@ -119,7 +119,7 @@ turns: type: message max_output_tokens: 2048 model: gpt-5.6 - previous_response_id: resp_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + previous_response_id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 store: true stream: false tools: @@ -143,11 +143,11 @@ turns: background: false billing: payer: developer - completed_at: 1785495766 - created_at: 1785495765 + completed_at: 1785757998 + created_at: 1785757997 error: null frequency_penalty: 0.0 - id: resp_0cbb8731608c41ea006a6c80d59dcc819abb682ff7f6d98861 + id: resp_06bab1ed35efb02b006a70812d3df8819ba1b13ebc8fda0f48 incomplete_details: null instructions: null max_output_tokens: 2048 @@ -162,14 +162,14 @@ turns: logprobs: [] text: CUSTOM_CASSETTE_OUTPUT_OK type: output_text - id: msg_0cbb8731608c41ea006a6c80d65540819a99820cfe2f18d7e4 + id: msg_06bab1ed35efb02b006a70812e310c819b92f139271e842828 phase: final_answer role: assistant status: completed type: message parallel_tool_calls: true presence_penalty: 0.0 - previous_response_id: resp_0cbb8731608c41ea006a6c80d44b48819ab05ed7d969304f13 + previous_response_id: resp_06bab1ed35efb02b006a70812beab0819b8cde61712bc97926 prompt_cache_key: null prompt_cache_retention: 24h reasoning: 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 index 531e6b17..0e8dc151 100644 --- 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 @@ -31,7 +31,7 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"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 + - '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} @@ -42,7 +42,7 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"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 + - '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} @@ -53,7 +53,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","item":{"id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"in_progress","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","input":"","name":"agentic_raw_echo"},"output_index":0,"sequence_number":2} + - '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} ' - ' @@ -62,7 +62,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"CUSTOM","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"a7ZQNbR0hc","output_index":0,"sequence_number":3} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"CUSTOM","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"iKj60AjELI","output_index":0,"sequence_number":3} ' - ' @@ -71,7 +71,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_C","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"rFLmQCMkhqlLoF","output_index":0,"sequence_number":4} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_C","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"lpLfLxmONimOdW","output_index":0,"sequence_number":4} ' - ' @@ -80,7 +80,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"AS","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"miwxozqbUlqGXr","output_index":0,"sequence_number":5} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"AS","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"Keke7bvg4ujy01","output_index":0,"sequence_number":5} ' - ' @@ -89,7 +89,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"SET","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"WKnGtMK5rcsVG","output_index":0,"sequence_number":6} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"SET","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"IoXJdu7Had2QM","output_index":0,"sequence_number":6} ' - ' @@ -98,7 +98,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"TE","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"2gL1OF42jCHHx1","output_index":0,"sequence_number":7} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"TE","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"Ei8acgZkyX6jB0","output_index":0,"sequence_number":7} ' - ' @@ -107,7 +107,7 @@ turns: - 'event: response.custom_tool_call_input.delta ' - - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_OK","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","obfuscation":"PAYmQslUAG1W6","output_index":0,"sequence_number":8} + - 'data: {"type":"response.custom_tool_call_input.delta","delta":"_OK","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","obfuscation":"eHVq7wVYgPe6Q","output_index":0,"sequence_number":8} ' - ' @@ -116,7 +116,7 @@ turns: - 'event: response.custom_tool_call_input.done ' - - 'data: {"type":"response.custom_tool_call_input.done","input":"CUSTOM_CASSETTE_OK","item_id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","output_index":0,"sequence_number":9} + - 'data: {"type":"response.custom_tool_call_input.done","input":"CUSTOM_CASSETTE_OK","item_id":"ctc_0e5f0bf89f3666ec006a7081288740819abccdcbc0827012df","output_index":0,"sequence_number":9} ' - ' @@ -125,7 +125,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","item":{"id":"ctc_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"completed","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","input":"CUSTOM_CASSETTE_OK","name":"agentic_raw_echo"},"output_index":0,"sequence_number":10} + - '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} ' - ' @@ -134,7 +134,7 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","object":"response","created_at":1785495760,"status":"completed","background":false,"completed_at":1785495760,"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_0b750dfdc6e7e34f006a6c80d0b5f08198b00da5e2503965e3","type":"custom_tool_call","status":"completed","call_id":"call_CepKMdJfIWH7yRr8Ivcg5OhD","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 + - '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} @@ -147,7 +147,7 @@ turns: request: body: input: - - call_id: call_CepKMdJfIWH7yRr8Ivcg5OhD + - 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. @@ -156,7 +156,7 @@ turns: type: message max_output_tokens: 2048 model: gpt-5.6 - previous_response_id: resp_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce + previous_response_id: resp_0e5f0bf89f3666ec006a708127ae80819aa3b6e074a5e96090 store: true stream: true tools: @@ -182,7 +182,7 @@ turns: - 'event: response.created ' - - 'data: {"type":"response.created","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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 + - '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} @@ -193,7 +193,7 @@ turns: - 'event: response.in_progress ' - - 'data: {"type":"response.in_progress","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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 + - '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} @@ -204,7 +204,7 @@ turns: - 'event: response.output_item.added ' - - 'data: {"type":"response.output_item.added","item":{"id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + - '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} ' - ' @@ -213,7 +213,7 @@ turns: - 'event: response.content_part.added ' - - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + - '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} ' - ' @@ -222,7 +222,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"CUSTOM","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"DKT3A55Jex","output_index":0,"sequence_number":4} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"CUSTOM","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"eg5PAdTr2c","output_index":0,"sequence_number":4} ' - ' @@ -231,7 +231,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_C","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"6ltub5CA7LyPwk","output_index":0,"sequence_number":5} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_C","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"71vxzOutU5cxqu","output_index":0,"sequence_number":5} ' - ' @@ -240,7 +240,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"AS","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"lOYBj8zLQeNLxj","output_index":0,"sequence_number":6} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"AS","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"scPGQYc6HlN5o8","output_index":0,"sequence_number":6} ' - ' @@ -249,7 +249,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"SET","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"Ndnnfrbe7HK2f","output_index":0,"sequence_number":7} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"SET","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"M22kypdz9Oamg","output_index":0,"sequence_number":7} ' - ' @@ -258,7 +258,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"TE","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"i2hvjcNtFWKKj3","output_index":0,"sequence_number":8} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"TE","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"3WSJWoMJGEBiPQ","output_index":0,"sequence_number":8} ' - ' @@ -267,7 +267,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OUTPUT","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"veORN7hA6","output_index":0,"sequence_number":9} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OUTPUT","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"eSDIfwy0i","output_index":0,"sequence_number":9} ' - ' @@ -276,7 +276,7 @@ turns: - 'event: response.output_text.delta ' - - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"obfuscation":"O2nCAKRheES63","output_index":0,"sequence_number":10} + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"_OK","item_id":"msg_0e5f0bf89f3666ec006a708129d710819a841381d0b93084da","logprobs":[],"obfuscation":"WfKxw8RyqRoqG","output_index":0,"sequence_number":10} ' - ' @@ -285,7 +285,7 @@ turns: - 'event: response.output_text.done ' - - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","logprobs":[],"output_index":0,"sequence_number":11,"text":"CUSTOM_CASSETTE_OUTPUT_OK"} + - '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"} ' - ' @@ -294,7 +294,7 @@ turns: - 'event: response.content_part.done ' - - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"CUSTOM_CASSETTE_OUTPUT_OK"},"sequence_number":12} + - '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} ' - ' @@ -303,7 +303,7 @@ turns: - 'event: response.output_item.done ' - - 'data: {"type":"response.output_item.done","item":{"id":"msg_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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} + - '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} ' - ' @@ -312,7 +312,7 @@ turns: - 'event: response.completed ' - - 'data: {"type":"response.completed","response":{"id":"resp_0b750dfdc6e7e34f006a6c80d14b94819883f79f1633797aa5","object":"response","created_at":1785495761,"status":"completed","background":false,"completed_at":1785495762,"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_0b750dfdc6e7e34f006a6c80d24e6c8198a612e063389d06c9","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_0b750dfdc6e7e34f006a6c80d01f188198b18c2701e4fd87ce","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 + - '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} diff --git a/crates/agentic-server-core/tests/custom_tool_test.rs b/crates/agentic-server-core/tests/custom_tool_test.rs index 429272ea..a1b8ca83 100644 --- a/crates/agentic-server-core/tests/custom_tool_test.rs +++ b/crates/agentic-server-core/tests/custom_tool_test.rs @@ -158,6 +158,7 @@ fn normalized_custom_lifecycle(events: &[Value]) -> Value { .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; @@ -174,7 +175,9 @@ fn normalized_custom_lifecycle(events: &[Value]) -> Value { lifecycle.push(event_type); } "response.custom_tool_call_input.delta" => { - input.push_str(event["delta"].as_str().unwrap_or_default()); + 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); } @@ -192,6 +195,7 @@ fn normalized_custom_lifecycle(events: &[Value]) -> Value { 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"],